Skip to content

Commit 303cc6d

Browse files
Merge pull request #862 from appwrite/feat-cli-local-development
Feat: CLI Local development
2 parents 8abc65a + cebd23e commit 303cc6d

File tree

11 files changed

+708
-6
lines changed

11 files changed

+708
-6
lines changed

src/SDK/Language/CLI.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,21 @@ public function getFiles(): array
245245
'destination' => 'lib/commands/push.js',
246246
'template' => 'cli/lib/commands/push.js.twig',
247247
],
248+
[
249+
'scope' => 'default',
250+
'destination' => 'lib/commands/run.js',
251+
'template' => 'cli/lib/commands/run.js.twig',
252+
],
253+
[
254+
'scope' => 'default',
255+
'destination' => 'lib/emulation/docker.js',
256+
'template' => 'cli/lib/emulation/docker.js.twig',
257+
],
258+
[
259+
'scope' => 'default',
260+
'destination' => 'lib/emulation/utils.js',
261+
'template' => 'cli/lib/emulation/utils.js.twig',
262+
],
248263
[
249264
'scope' => 'service',
250265
'destination' => '/lib/commands/{{service.name | caseDash}}.js',

templates/cli/base/params.twig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
const func = localConfig.getFunction(functionId);
1818

19+
ignorer.add('.appwrite');
20+
1921
if (func.ignore) {
2022
ignorer.add(func.ignore);
2123
} else if (fs.existsSync(pathLib.join({{ parameter.name | caseCamel | escapeKeyword }}, '.gitignore'))) {

templates/cli/index.js.twig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const inquirer = require("inquirer");
1515
const { login, logout, whoami } = require("./lib/commands/generic");
1616
const { init } = require("./lib/commands/init");
1717
const { pull } = require("./lib/commands/pull");
18+
const { run } = require("./lib/commands/run");
1819
const { push } = require("./lib/commands/push");
1920
{% else %}
2021
const { migrate } = require("./lib/commands/generic");
@@ -65,6 +66,7 @@ program
6566
.addCommand(init)
6667
.addCommand(pull)
6768
.addCommand(push)
69+
.addCommand(run)
6870
.addCommand(logout)
6971
{% endif %}
7072
{% for service in spec.services %}
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
const Tail = require('tail').Tail;
2+
const EventEmitter = require('node:events');
3+
const ignore = require("ignore");
4+
const tar = require("tar");
5+
const fs = require("fs");
6+
const ID = require("../id");
7+
const childProcess = require('child_process');
8+
const chokidar = require('chokidar');
9+
const inquirer = require("inquirer");
10+
const path = require("path");
11+
const { Command } = require("commander");
12+
const { localConfig, globalConfig } = require("../config");
13+
const { paginate } = require('../paginate');
14+
const { functionsListVariables } = require('./functions');
15+
const { usersGet, usersCreateJWT } = require('./users');
16+
const { projectsCreateJWT } = require('./projects');
17+
const { questionsRunFunctions } = require("../questions");
18+
const { actionRunner, success, log, error, commandDescriptions, drawTable } = require("../parser");
19+
const { systemHasCommand, isPortTaken, getAllFiles } = require('../utils');
20+
const { openRuntimesVersion, runtimeNames, systemTools, JwtManager, Queue } = require('../emulation/utils');
21+
const { dockerStop, dockerCleanup, dockerStart, dockerBuild, dockerPull, dockerStopActive } = require('../emulation/docker');
22+
23+
const runFunction = async ({ port, functionId, noVariables, noReload, userId } = {}) => {
24+
// Selection
25+
if(!functionId) {
26+
const answers = await inquirer.prompt(questionsRunFunctions[0]);
27+
functionId = answers.function;
28+
}
29+
30+
const functions = localConfig.getFunctions();
31+
const func = functions.find((f) => f.$id === functionId);
32+
if (!func) {
33+
throw new Error("Function '" + functionId + "' not found.")
34+
}
35+
36+
const runtimeName = func.runtime.split("-").slice(0, -1).join("-");
37+
const tool = systemTools[runtimeName];
38+
39+
// Configuration: Port
40+
if(port) {
41+
port = +port;
42+
}
43+
44+
if(isNaN(port)) {
45+
port = null;
46+
}
47+
48+
if(port) {
49+
const taken = await isPortTaken(port);
50+
51+
if(taken) {
52+
error(`Port ${port} is already in use by another process.`);
53+
return;
54+
}
55+
}
56+
57+
if(!port) {
58+
let portFound = fale;
59+
port = 3000;
60+
while(port < 3100) {
61+
const taken = await isPortTaken(port);
62+
if(!taken) {
63+
portFound = true;
64+
break;
65+
}
66+
67+
port++;
68+
}
69+
70+
if(!portFound) {
71+
error('Could not find an available port. Please select a port with `appwrite run --port YOUR_PORT` command.');
72+
return;
73+
}
74+
}
75+
76+
// Configuration: Engine
77+
if(!systemHasCommand('docker')) {
78+
return error("Docker Engine is required for local development. Please install Docker using: https://docs.docker.com/engine/install/");
79+
}
80+
81+
// Settings
82+
const settings = {
83+
runtime: func.runtime,
84+
entrypoint: func.entrypoint,
85+
path: func.path,
86+
commands: func.commands,
87+
};
88+
89+
log("Local function configuration:");
90+
drawTable([settings]);
91+
log('If you wish to change your local settings, update the appwrite.json file and rerun the `appwrite run` command.');
92+
93+
await dockerCleanup();
94+
95+
process.on('SIGINT', async () => {
96+
log('Cleaning up ...');
97+
await dockerCleanup();
98+
success();
99+
process.exit();
100+
});
101+
102+
const logsPath = path.join(process.cwd(), func.path, '.appwrite/logs.txt');
103+
const errorsPath = path.join(process.cwd(), func.path, '.appwrite/errors.txt');
104+
105+
if(!fs.existsSync(path.dirname(logsPath))) {
106+
fs.mkdirSync(path.dirname(logsPath), { recursive: true });
107+
}
108+
109+
if (!fs.existsSync(logsPath)) {
110+
fs.writeFileSync(logsPath, '');
111+
}
112+
113+
if (!fs.existsSync(errorsPath)) {
114+
fs.writeFileSync(errorsPath, '');
115+
}
116+
117+
const variables = {};
118+
if(!noVariables) {
119+
if (globalConfig.getEndpoint() === '' || globalConfig.getCookie() === '') {
120+
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.");
121+
} else {
122+
try {
123+
const { variables: remoteVariables } = await paginate(functionsListVariables, {
124+
functionId: func['$id'],
125+
parseOutput: false
126+
}, 100, 'variables');
127+
128+
remoteVariables.forEach((v) => {
129+
variables[v.key] = v.value;
130+
});
131+
} catch(err) {
132+
error("Could not fetch remote variables: " + err.message);
133+
error("Function will run locally, but will not have your function's environment variables set.");
134+
}
135+
}
136+
}
137+
138+
variables['APPWRITE_FUNCTION_API_ENDPOINT'] = globalConfig.getFrom('endpoint');
139+
variables['APPWRITE_FUNCTION_ID'] = func.$id;
140+
variables['APPWRITE_FUNCTION_NAME'] = func.name;
141+
variables['APPWRITE_FUNCTION_DEPLOYMENT'] = ''; // TODO: Implement when relevant
142+
variables['APPWRITE_FUNCTION_PROJECT_ID'] = localConfig.getProject().projectId;
143+
variables['APPWRITE_FUNCTION_RUNTIME_NAME'] = runtimeNames[runtimeName] ?? '';
144+
variables['APPWRITE_FUNCTION_RUNTIME_VERSION'] = func.runtime;
145+
146+
await JwtManager.setup(userId);
147+
148+
const headers = {};
149+
headers['x-appwrite-key'] = JwtManager.functionJwt ?? '';
150+
headers['x-appwrite-trigger'] = 'http';
151+
headers['x-appwrite-event'] = '';
152+
headers['x-appwrite-user-id'] = userId ?? '';
153+
headers['x-appwrite-user-jwt'] = JwtManager.userJwt ?? '';
154+
variables['OPEN_RUNTIMES_HEADERS'] = JSON.stringify(headers);
155+
156+
await dockerPull(func);
157+
await dockerBuild(func, variables);
158+
await dockerStart(func, variables, port);
159+
160+
new Tail(logsPath).on("line", function(data) {
161+
console.log(data);
162+
});
163+
new Tail(errorsPath).on("line", function(data) {
164+
console.log(data);
165+
});
166+
167+
if(!noReload) {
168+
chokidar.watch('.', {
169+
cwd: path.join(process.cwd(), func.path),
170+
ignoreInitial: true,
171+
ignored: [ ...(func.ignore ?? []), 'code.tar.gz', '.appwrite', '.appwrite/', '.appwrite/*', '.appwrite/**', '.appwrite/*.*', '.appwrite/**/*.*' ]
172+
}).on('all', async (_event, filePath) => {
173+
Queue.push(filePath);
174+
});
175+
}
176+
177+
Queue.events.on('reload', async ({ files }) => {
178+
Queue.lock();
179+
180+
log('Live-reloading due to file changes: ');
181+
for(const file of files) {
182+
log(`- ${file}`);
183+
}
184+
185+
try {
186+
log('Stopping the function ...');
187+
188+
await dockerStopActive();
189+
190+
const dependencyFile = files.find((filePath) => tool.dependencyFiles.includes(filePath));
191+
if(tool.isCompiled || dependencyFile) {
192+
log(`Rebuilding the function due to cange in ${dependencyFile} ...`);
193+
await dockerBuild(func, variables);
194+
await dockerStart(func, variables, port);
195+
} else {
196+
log('Hot-swapping function files ...');
197+
198+
const functionPath = path.join(process.cwd(), func.path);
199+
const hotSwapPath = path.join(functionPath, '.appwrite/hot-swap');
200+
const buildPath = path.join(functionPath, '.appwrite/build.tar.gz');
201+
202+
// Prepare temp folder
203+
if (!fs.existsSync(hotSwapPath)) {
204+
fs.mkdirSync(hotSwapPath, { recursive: true });
205+
} else {
206+
fs.rmSync(hotSwapPath, { recursive: true, force: true });
207+
fs.mkdirSync(hotSwapPath, { recursive: true });
208+
}
209+
210+
await tar
211+
.extract({
212+
gzip: true,
213+
sync: true,
214+
cwd: hotSwapPath,
215+
file: buildPath
216+
});
217+
218+
const ignorer = ignore();
219+
ignorer.add('.appwrite');
220+
if (func.ignore) {
221+
ignorer.add(func.ignore);
222+
}
223+
224+
const filesToCopy = getAllFiles(functionPath).map((file) => path.relative(functionPath, file)).filter((file) => !ignorer.ignores(file));
225+
for(const f of filesToCopy) {
226+
const filePath = path.join(hotSwapPath, f);
227+
if (fs.existsSync(filePath)) {
228+
fs.rmSync(filePath, { force: true });
229+
}
230+
231+
const fileDir = path.dirname(filePath);
232+
if (!fs.existsSync(fileDir)) {
233+
fs.mkdirSync(fileDir, { recursive: true });
234+
}
235+
236+
const sourcePath = path.join(functionPath, f);
237+
fs.copyFileSync(sourcePath, filePath);
238+
}
239+
240+
await tar
241+
.create({
242+
gzip: true,
243+
sync: true,
244+
cwd: hotSwapPath,
245+
file: buildPath
246+
}, ['.']);
247+
248+
fs.rmSync(hotSwapPath, { recursive: true, force: true });
249+
250+
await dockerStart(func, variables, port);
251+
}
252+
} catch(err) {
253+
console.error(err);
254+
} finally {
255+
Queue.unlock();
256+
}
257+
});
258+
}
259+
260+
const run = new Command("run")
261+
.alias("dev")
262+
.description(commandDescriptions['run'])
263+
.configureHelp({
264+
helpWidth: process.stdout.columns || 80
265+
})
266+
.action(actionRunner(async (_options, command) => {
267+
command.help();
268+
}));
269+
270+
run
271+
.command("function")
272+
.alias("functions")
273+
.description("Run functions in the current directory.")
274+
.option(`--functionId <functionId>`, `Function ID`)
275+
.option(`--port <port>`, `Local port`)
276+
.option(`--userId <userId>`, `ID of user to impersonate`)
277+
.option(`--noVariables`, `Prevent pulling variables from function settings`)
278+
.option(`--noReload`, `Prevent live reloading of server when changes are made to function files`)
279+
.action(actionRunner(runFunction));
280+
281+
module.exports = {
282+
run
283+
}

templates/cli/lib/config.js.twig

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,6 @@ class Global extends Config {
511511
this.setTo(Global.PREFERENCE_KEY, key);
512512
}
513513

514-
515514
hasFrom(key) {
516515
const current = this.getCurrentSession();
517516

0 commit comments

Comments
 (0)