Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: CLI Local development #862

Merged
merged 13 commits into from
Jun 17, 2024
15 changes: 15 additions & 0 deletions src/SDK/Language/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,21 @@ public function getFiles(): array
'destination' => 'lib/commands/push.js',
'template' => 'cli/lib/commands/push.js.twig',
],
[
'scope' => 'default',
'destination' => 'lib/commands/run.js',
'template' => 'cli/lib/commands/run.js.twig',
],
Meldiron marked this conversation as resolved.
Show resolved Hide resolved
[
'scope' => 'default',
'destination' => 'lib/emulation/docker.js',
'template' => 'cli/lib/emulation/docker.js.twig',
],
[
'scope' => 'default',
'destination' => 'lib/emulation/utils.js',
'template' => 'cli/lib/emulation/utils.js.twig',
],
[
'scope' => 'service',
'destination' => '/lib/commands/{{service.name | caseDash}}.js',
Expand Down
2 changes: 2 additions & 0 deletions templates/cli/base/params.twig
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

const func = localConfig.getFunction(functionId);

ignorer.add('.appwrite');

if (func.ignore) {
ignorer.add(func.ignore);
} else if (fs.existsSync(pathLib.join({{ parameter.name | caseCamel | escapeKeyword }}, '.gitignore'))) {
Expand Down
2 changes: 2 additions & 0 deletions templates/cli/index.js.twig
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const inquirer = require("inquirer");
const { login, logout, whoami } = require("./lib/commands/generic");
const { init } = require("./lib/commands/init");
const { pull } = require("./lib/commands/pull");
const { run } = require("./lib/commands/run");
const { push } = require("./lib/commands/push");
{% else %}
const { migrate } = require("./lib/commands/generic");
Expand Down Expand Up @@ -65,6 +66,7 @@ program
.addCommand(init)
.addCommand(pull)
.addCommand(push)
.addCommand(run)
.addCommand(logout)
{% endif %}
{% for service in spec.services %}
Expand Down
283 changes: 283 additions & 0 deletions templates/cli/lib/commands/run.js.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
const Tail = require('tail').Tail;
const EventEmitter = require('node:events');
const ignore = require("ignore");
const tar = require("tar");
const fs = require("fs");
const ID = require("../id");
const childProcess = require('child_process');
const chokidar = require('chokidar');
const inquirer = require("inquirer");
const path = require("path");
const { Command } = require("commander");
const { localConfig, globalConfig } = require("../config");
const { paginate } = require('../paginate');
const { functionsListVariables } = require('./functions');
const { usersGet, usersCreateJWT } = require('./users');
const { projectsCreateJWT } = require('./projects');
const { questionsRunFunctions } = require("../questions");
const { actionRunner, success, log, error, commandDescriptions, drawTable } = require("../parser");
const { systemHasCommand, isPortTaken, getAllFiles } = require('../utils');
const { openRuntimesVersion, runtimeNames, systemTools, JwtManager, Queue } = require('../emulation/utils');
const { dockerStop, dockerCleanup, dockerStart, dockerBuild, dockerPull, dockerStopActive } = require('../emulation/docker');

const runFunction = async ({ port, functionId, noVariables, noReload, userId } = {}) => {
// Selection
if(!functionId) {
const answers = await inquirer.prompt(questionsRunFunctions[0]);
functionId = answers.function;
}

const functions = localConfig.getFunctions();
const func = functions.find((f) => f.$id === functionId);
if (!func) {
throw new Error("Function '" + functionId + "' not found.")
}

const runtimeName = func.runtime.split("-").slice(0, -1).join("-");
const tool = systemTools[runtimeName];

// Configuration: Port
if(port) {
port = +port;
}

if(isNaN(port)) {
port = null;
}

if(port) {
const taken = await isPortTaken(port);

if(taken) {
error(`Port ${port} is already in use by another process.`);
return;
}
}

if(!port) {
let portFound = fale;
port = 3000;
while(port < 3100) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while very rare, why limit the search to 100 ports? Why not set it to something higher like 6000 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea how long that might take. And as you said, very rare. I wouldn' worry too much until we get some complains.

const taken = await isPortTaken(port);
if(!taken) {
portFound = true;
break;
}

port++;
}

if(!portFound) {
error('Could not find an available port. Please select a port with `appwrite run --port YOUR_PORT` command.');
return;
}
}

// Configuration: Engine
if(!systemHasCommand('docker')) {
return error("Docker Engine is required for local development. Please install Docker using: https://docs.docker.com/engine/install/");
}

// Settings
const settings = {
runtime: func.runtime,
entrypoint: func.entrypoint,
path: func.path,
commands: func.commands,
};

log("Local function configuration:");
drawTable([settings]);
log('If you wish to change your local settings, update the appwrite.json file and rerun the `appwrite run` command.');

await dockerCleanup();

process.on('SIGINT', async () => {
log('Cleaning up ...');
await dockerCleanup();
success();
process.exit();
});

const logsPath = path.join(process.cwd(), func.path, '.appwrite/logs.txt');
const errorsPath = path.join(process.cwd(), func.path, '.appwrite/errors.txt');

if(!fs.existsSync(path.dirname(logsPath))) {
fs.mkdirSync(path.dirname(logsPath), { recursive: true });
}

if (!fs.existsSync(logsPath)) {
fs.writeFileSync(logsPath, '');
}

if (!fs.existsSync(errorsPath)) {
fs.writeFileSync(errorsPath, '');
}

const variables = {};
if(!noVariables) {
if (globalConfig.getEndpoint() === '' || globalConfig.getCookie() === '') {
error("No user is signed in. To sign in, run: appwrite login. Function will run locally, but will not have your function's environment variables set.");
} else {
try {
const { variables: remoteVariables } = await paginate(functionsListVariables, {
functionId: func['$id'],
parseOutput: false
}, 100, 'variables');

remoteVariables.forEach((v) => {
variables[v.key] = v.value;
});
} catch(err) {
error("Could not fetch remote variables: " + err.message);
error("Function will run locally, but will not have your function's environment variables set.");
}
}
}

variables['APPWRITE_FUNCTION_API_ENDPOINT'] = globalConfig.getFrom('endpoint');
variables['APPWRITE_FUNCTION_ID'] = func.$id;
variables['APPWRITE_FUNCTION_NAME'] = func.name;
variables['APPWRITE_FUNCTION_DEPLOYMENT'] = ''; // TODO: Implement when relevant
variables['APPWRITE_FUNCTION_PROJECT_ID'] = localConfig.getProject().projectId;
variables['APPWRITE_FUNCTION_RUNTIME_NAME'] = runtimeNames[runtimeName] ?? '';
variables['APPWRITE_FUNCTION_RUNTIME_VERSION'] = func.runtime;

await JwtManager.setup(userId);

const headers = {};
headers['x-appwrite-key'] = JwtManager.functionJwt ?? '';
headers['x-appwrite-trigger'] = 'http';
headers['x-appwrite-event'] = '';
headers['x-appwrite-user-id'] = userId ?? '';
headers['x-appwrite-user-jwt'] = JwtManager.userJwt ?? '';
variables['OPEN_RUNTIMES_HEADERS'] = JSON.stringify(headers);

await dockerPull(func);
await dockerBuild(func, variables);
await dockerStart(func, variables, port);

new Tail(logsPath).on("line", function(data) {
console.log(data);
});
new Tail(errorsPath).on("line", function(data) {
console.log(data);
});

if(!noReload) {
chokidar.watch('.', {
cwd: path.join(process.cwd(), func.path),
ignoreInitial: true,
ignored: [ ...(func.ignore ?? []), 'code.tar.gz', '.appwrite', '.appwrite/', '.appwrite/*', '.appwrite/**', '.appwrite/*.*', '.appwrite/**/*.*' ]
}).on('all', async (_event, filePath) => {
Queue.push(filePath);
});
}

Queue.events.on('reload', async ({ files }) => {
Queue.lock();

log('Live-reloading due to file changes: ');
for(const file of files) {
log(`- ${file}`);
}

try {
log('Stopping the function ...');

await dockerStopActive();

const dependencyFile = files.find((filePath) => tool.dependencyFiles.includes(filePath));
if(tool.isCompiled || dependencyFile) {
log(`Rebuilding the function due to cange in ${dependencyFile} ...`);
await dockerBuild(func, variables);
await dockerStart(func, variables, port);
} else {
log('Hot-swapping function files ...');

const functionPath = path.join(process.cwd(), func.path);
const hotSwapPath = path.join(functionPath, '.appwrite/hot-swap');
const buildPath = path.join(functionPath, '.appwrite/build.tar.gz');

// Prepare temp folder
if (!fs.existsSync(hotSwapPath)) {
fs.mkdirSync(hotSwapPath, { recursive: true });
} else {
fs.rmSync(hotSwapPath, { recursive: true, force: true });
fs.mkdirSync(hotSwapPath, { recursive: true });
}

await tar
.extract({
gzip: true,
sync: true,
cwd: hotSwapPath,
file: buildPath
});

const ignorer = ignore();
ignorer.add('.appwrite');
if (func.ignore) {
ignorer.add(func.ignore);
}

const filesToCopy = getAllFiles(functionPath).map((file) => path.relative(functionPath, file)).filter((file) => !ignorer.ignores(file));
for(const f of filesToCopy) {
const filePath = path.join(hotSwapPath, f);
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true });
}

const fileDir = path.dirname(filePath);
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir, { recursive: true });
}

const sourcePath = path.join(functionPath, f);
fs.copyFileSync(sourcePath, filePath);
}

await tar
.create({
gzip: true,
sync: true,
cwd: hotSwapPath,
file: buildPath
}, ['.']);

fs.rmSync(hotSwapPath, { recursive: true, force: true });

await dockerStart(func, variables, port);
}
} catch(err) {
console.error(err);
} finally {
Queue.unlock();
}
});
}

const run = new Command("run")
.alias("dev")
.description(commandDescriptions['run'])
.configureHelp({
helpWidth: process.stdout.columns || 80
})
.action(actionRunner(async (_options, command) => {
command.help();
}));

run
.command("function")
.alias("functions")
.description("Run functions in the current directory.")
.option(`--functionId <functionId>`, `Function ID`)
.option(`--port <port>`, `Local port`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets fix a default port so user doesnt need to enter it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's optional param. If not provided ,question is asked (with default 3000 as placeholder)

.option(`--userId <userId>`, `ID of user to impersonate`)
.option(`--noVariables`, `Prevent pulling variables from function settings`)
.option(`--noReload`, `Prevent live reloading of server when changes are made to function files`)
.action(actionRunner(runFunction));

module.exports = {
run
}
1 change: 0 additions & 1 deletion templates/cli/lib/config.js.twig
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,6 @@ class Global extends Config {
this.setTo(Global.PREFERENCE_KEY, key);
}


hasFrom(key) {
const current = this.getCurrentSession();

Expand Down
Loading
Loading