@getxflow/cli 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/args.js CHANGED
@@ -8,6 +8,7 @@ exports.flagBool = flagBool;
8
8
  exports.flagNumber = flagNumber;
9
9
  /** Flags that never take a value, so `--force ./dir` keeps the path as a word. */
10
10
  exports.BOOLEAN_FLAGS = new Set([
11
+ 'function',
11
12
  'force',
12
13
  'yes',
13
14
  'json',
@@ -143,7 +143,6 @@ function serverRow(server) {
143
143
  server.status === 'active' ? '' : server.status,
144
144
  room,
145
145
  `${server.databases} ${server.databases === 1 ? 'database' : 'databases'}`,
146
- server.new_databases_here ? 'new databases go here' : '',
147
146
  ];
148
147
  }
149
148
  function attachedProjects(database) {
@@ -207,7 +206,7 @@ function printServers(servers) {
207
206
  (0, ui_1.out)('');
208
207
  (0, ui_1.out)((0, ui_1.bold)('Servers'));
209
208
  (0, ui_1.table)(servers.map(serverRow));
210
- (0, ui_1.note)((0, ui_1.dim)(' A new project with its database on one of them: xflow init --server <name>. An existing one: xflow db move --to <name>'));
209
+ (0, ui_1.note)((0, ui_1.dim)(' Without --server a new database goes to the platform server. To put it on another: xflow init --server <name>, or xflow db move --to <name> for one that already exists'));
211
210
  }
212
211
  /** Long values would tear the columns apart; the cut is marked, never silent. */
213
212
  const CELL_LIMIT = 60;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.versionComment = versionComment;
4
+ exports.statedRevision = statedRevision;
4
5
  exports.deploy = deploy;
5
6
  exports.publish = publish;
6
7
  exports.rollback = rollback;
@@ -56,7 +57,7 @@ async function waitForBuild(client, projectId, started) {
56
57
  */
57
58
  async function refreshPlatformEnv(root, client, projectId) {
58
59
  const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
59
- (0, template_1.writePlatformEnv)(root, card.project_token, client.apiUrl, card.functions);
60
+ (0, template_1.writePlatformEnv)(root, card.project_token, client.apiUrl, card.functions, card.function_map);
60
61
  return card.functions.filter((fn) => fn.invoke_url).map((fn) => fn.name);
61
62
  }
62
63
  /**
@@ -141,25 +142,14 @@ function requireComment(args) {
141
142
  }
142
143
  return comment;
143
144
  }
144
- async function deploy(args) {
145
- const comment = requireComment(args);
146
- const { root, config } = (0, config_1.requireProject)();
147
- const client = await (0, session_1.connectProject)(config.projectId, root, config);
148
- let revision;
149
- if ((0, args_1.flagBool)(args, 'no-push')) {
150
- const server = await (0, sources_1.latestRevision)(client, config.projectId);
151
- if (!server) {
152
- throw new errors_1.CliError('The server holds no sources', 'Repeat without --no-push, so that the sources go up first');
153
- }
154
- revision = server.revision;
155
- (0, ui_1.warn)(`Sources not sent (--no-push), the build runs from revision ${revision}`);
156
- }
157
- else {
158
- await assertBuildAllowed(client, config.projectId);
159
- revision = (await (0, sources_1.pushSources)(root, config, client, { force: (0, args_1.flagBool)(args, 'force') })).revision;
160
- }
161
- (0, ui_1.step)(`Building on the platform from revision ${revision}`);
162
- const started = await startBuild(client, config.projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'), comment);
145
+ /**
146
+ * Everything after the build has started: the same for a build of the sources
147
+ * just sent and for a rebuild of a revision the platform already holds.
148
+ *
149
+ * `root` is the folder to point at this project afterwards, and null when there
150
+ * is none: a build asked for by id has no .env to refresh.
151
+ */
152
+ async function finishBuild(client, projectId, started, root) {
163
153
  if (started.removed_functions?.length) {
164
154
  (0, ui_1.warn)(`Removed from the cloud, gone from the sources: ${started.removed_functions.join(', ')}`);
165
155
  }
@@ -167,7 +157,11 @@ async function deploy(args) {
167
157
  (0, ui_1.warn)(`Access revoked by the provider: ${started.revoked_connections.join(', ')}`);
168
158
  (0, ui_1.note)((0, ui_1.dim)(' Reconnect the account in the platform settings: another build will not fix this'));
169
159
  }
170
- const build = await waitForBuild(client, config.projectId, started);
160
+ if (started.shadowed_functions?.length) {
161
+ (0, ui_1.warn)(`Named like a standalone function of the organization: ${started.shadowed_functions.join(', ')}`);
162
+ (0, ui_1.note)((0, ui_1.dim)(' The app calls its own function under that name. Rename one of them to call the shared one'));
163
+ }
164
+ const build = await waitForBuild(client, projectId, started);
171
165
  if (build.phase === 'failed') {
172
166
  if (build.log_tail) {
173
167
  (0, ui_1.out)();
@@ -180,20 +174,94 @@ async function deploy(args) {
180
174
  // The functions were shipped by the build itself, so their addresses are known
181
175
  // only now. A failure here does not undo a finished build: warn and stop there.
182
176
  let functions = null;
183
- try {
184
- functions = await refreshPlatformEnv(root, client, config.projectId);
185
- }
186
- catch {
187
- (0, ui_1.warn)('Could not refresh the function addresses in .env, the build itself is fine');
177
+ if (root !== null) {
178
+ try {
179
+ functions = await refreshPlatformEnv(root, client, projectId);
180
+ }
181
+ catch {
182
+ (0, ui_1.warn)('Could not refresh the function addresses in .env, the build itself is fine');
183
+ }
188
184
  }
185
+ // A project without a site has no pages to publish, and its functions went live
186
+ // with this very build. Naming publish there sends the reader to a refusal.
187
+ const hasSite = started.has_site !== false;
189
188
  if (functions === null) {
190
- (0, ui_1.note)((0, ui_1.dim)(' The cloud functions of this build are already live: publishing does not hold them back'));
189
+ (0, ui_1.note)((0, ui_1.dim)(' The cloud functions of this build are already live'));
190
+ if (hasSite)
191
+ (0, ui_1.note)((0, ui_1.dim)(' Publishing does not hold them back'));
191
192
  }
192
193
  else if (functions.length > 0) {
193
194
  (0, ui_1.note)((0, ui_1.dim)(` Cloud functions live now: ${functions.join(', ')}`));
194
- (0, ui_1.note)((0, ui_1.dim)(' There is one copy per project, publishing does not hold them back'));
195
+ if (hasSite)
196
+ (0, ui_1.note)((0, ui_1.dim)(' There is one copy per project, publishing does not hold them back'));
195
197
  }
196
- (0, ui_1.note)((0, ui_1.dim)(' Show the new pages to visitors: xflow publish'));
198
+ if (hasSite)
199
+ (0, ui_1.note)((0, ui_1.dim)(' Show the new pages to visitors: xflow publish'));
200
+ }
201
+ /**
202
+ * Build a revision the platform already holds: this is how a version comes back.
203
+ *
204
+ * The sources of revision N are on the server, so nothing goes up and no copy of
205
+ * them is needed next to the caller. That also means `--force` has nothing to
206
+ * force: the revision on the server stays the newest one, and this build simply
207
+ * runs an older one. The project can therefore be named by id, which is the only
208
+ * way to bring back the version of a standalone function: its folder may live on
209
+ * another machine entirely, and there is nothing else to roll back with, because
210
+ * a project without a site has no pages to return.
211
+ */
212
+ async function deployRevision(args, revision, comment) {
213
+ const { projectId, client, root } = await (0, session_1.projectTarget)(args);
214
+ await assertBuildAllowed(client, projectId);
215
+ (0, ui_1.step)(`Building on the platform from revision ${revision}`);
216
+ const started = await startBuild(client, projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'), comment);
217
+ await finishBuild(client, projectId, started, root);
218
+ }
219
+ /**
220
+ * The revision named by --revision, with the two rules that guard such a build.
221
+ *
222
+ * Kept pure and read before anything else: a wrong number or a flag that cannot
223
+ * apply is worth saying before the version comment is asked for, and a rule like
224
+ * this deserves a test that needs no platform to talk to.
225
+ */
226
+ function statedRevision(revision, force) {
227
+ if (revision === undefined)
228
+ return undefined;
229
+ // flagNumber already refuses anything but a whole number, so zero is what is
230
+ // left to catch here: revisions are counted from one.
231
+ if (revision < 1) {
232
+ throw new errors_1.CliError(`--revision takes a revision number, got "${revision}"`, 'The revision of each version: xflow deployments');
233
+ }
234
+ if (force) {
235
+ throw new errors_1.CliError('--force does not apply to --revision', 'Nothing is sent: the build runs the sources the platform already holds');
236
+ }
237
+ return revision;
238
+ }
239
+ async function deploy(args) {
240
+ const stated = statedRevision((0, args_1.flagNumber)(args, 'revision'), (0, args_1.flagBool)(args, 'force'));
241
+ const comment = requireComment(args);
242
+ if (stated !== undefined)
243
+ return deployRevision(args, stated, comment);
244
+ if ((0, args_1.flagString)(args, 'project') !== undefined) {
245
+ throw new errors_1.CliError('xflow deploy sends the sources next to it, so it runs in the project folder', 'Name a project by id only to build a revision it already has: xflow deploy --project <id> --revision <n> -m "what came back"');
246
+ }
247
+ const { root, config } = (0, config_1.requireProject)();
248
+ const client = await (0, session_1.connectProject)(config.projectId, root, config);
249
+ let revision;
250
+ if ((0, args_1.flagBool)(args, 'no-push')) {
251
+ const server = await (0, sources_1.latestRevision)(client, config.projectId);
252
+ if (!server) {
253
+ throw new errors_1.CliError('The server holds no sources', 'Repeat without --no-push, so that the sources go up first');
254
+ }
255
+ revision = server.revision;
256
+ (0, ui_1.warn)(`Sources not sent (--no-push), the build runs from revision ${revision}`);
257
+ }
258
+ else {
259
+ await assertBuildAllowed(client, config.projectId);
260
+ revision = (await (0, sources_1.pushSources)(root, config, client, { force: (0, args_1.flagBool)(args, 'force') })).revision;
261
+ }
262
+ (0, ui_1.step)(`Building on the platform from revision ${revision}`);
263
+ const started = await startBuild(client, config.projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'), comment);
264
+ await finishBuild(client, config.projectId, started, root);
197
265
  }
198
266
  async function publish(args) {
199
267
  const { projectId, client } = await (0, session_1.projectTarget)(args);
@@ -30,7 +30,7 @@ async function dev() {
30
30
  // project token, would reach this run only after one of them.
31
31
  try {
32
32
  const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`);
33
- Object.assign(env, (0, template_1.platformEnv)(card.project_token, client.apiUrl, card.functions));
33
+ Object.assign(env, (0, template_1.platformEnv)(card.project_token, client.apiUrl, card.functions, card.function_map));
34
34
  }
35
35
  catch (e) {
36
36
  (0, ui_1.warn)(`The function addresses were not fetched (${e instanceof Error ? e.message : String(e)}): the ones in .env are used`);
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.templates = templates;
4
+ exports.functionNameFrom = functionNameFrom;
4
5
  exports.init = init;
5
6
  exports.link = link;
6
7
  exports.list = list;
@@ -48,6 +49,66 @@ async function templates() {
48
49
  }
49
50
  (0, ui_1.table)(list.map((t) => [t.id, t.name, t.is_default ? 'default' : '', t.description ?? '']));
50
51
  }
52
+ /**
53
+ * A function name out of the project name: the platform takes lowercase latin
54
+ * letters, digits and dashes, and the folder it lives in gives it its name.
55
+ */
56
+ function functionNameFrom(name) {
57
+ const slug = name
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9-]+/g, '-')
60
+ .replace(/^-+|-+$/g, '')
61
+ .slice(0, 41);
62
+ if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(slug)) {
63
+ throw new errors_1.CliError(`Cannot make a function name out of "${name}"`, 'Name it in latin letters, digits and dashes: xflow init my-fn --function');
64
+ }
65
+ return slug;
66
+ }
67
+ /**
68
+ * Create a standalone function: a shared backend of the organization, a project
69
+ * without a site. No template is fetched — there is no frontend to fetch.
70
+ */
71
+ async function initFunction(args, client, target, name) {
72
+ const functionName = functionNameFrom(name);
73
+ (0, ui_1.step)('Creating the function on the platform');
74
+ const project = await (0, api_1.apiJson)(client, '/api/v1/projects', {
75
+ method: 'POST',
76
+ body: {
77
+ name,
78
+ kind: 'function',
79
+ database_id: (0, args_1.flagString)(args, 'database') ?? null,
80
+ server: (0, args_1.flagString)(args, 'server') ?? null,
81
+ },
82
+ timeoutMs: 60_000,
83
+ });
84
+ try {
85
+ (0, node_fs_1.mkdirSync)(target, { recursive: true });
86
+ for (const file of (0, template_1.functionScaffoldFiles)(name, functionName))
87
+ write(target, file.path, file.content);
88
+ // No build block: the platform builds the functions of such a project and
89
+ // never its site, so a build command would be a promise nobody keeps.
90
+ (0, config_1.writeConfig)(target, { projectId: project.id });
91
+ (0, config_1.ignoreStateInGit)(target);
92
+ if (client.organizationId)
93
+ (0, config_1.writeState)(target, { organizationId: client.organizationId });
94
+ }
95
+ catch (e) {
96
+ (0, ui_1.note)((0, ui_1.dim)(` The function "${project.name}" is already created (${project.id}).`));
97
+ (0, ui_1.note)((0, ui_1.dim)(' Sort the folder out and pick it up: xflow link ' + project.id));
98
+ throw e;
99
+ }
100
+ (0, skills_1.installSkillQuietly)(target);
101
+ (0, ui_1.ok)(`Function "${project.name}" created: functions/${functionName}`);
102
+ if (project.database) {
103
+ const { server } = project.database;
104
+ (0, ui_1.note)((0, ui_1.dim)(` Its database "${project.database.name}" is on ${server ? `own "${server.name}"` : 'the platform server'}`));
105
+ }
106
+ (0, ui_1.out)('');
107
+ (0, ui_1.out)(` ${(0, ui_1.bold)('Next:')}`);
108
+ (0, ui_1.out)(` cd ${(0, node_path_1.basename)(target)}`);
109
+ (0, ui_1.out)(' npm install');
110
+ (0, ui_1.out)(' xflow deploy # send the code, build and release the function');
111
+ }
51
112
  /** Create a project from the platform template. */
52
113
  async function init(args) {
53
114
  const target = (0, node_path_1.resolve)(args.words[0] ?? '.');
@@ -56,6 +117,8 @@ async function init(args) {
56
117
  if (!isEmptyEnough(target)) {
57
118
  throw new errors_1.CliError(`The directory ${target} is not empty`, 'Create the project in an empty folder: xflow init my-app. To link an existing folder to a project: xflow link <id>');
58
119
  }
120
+ if ((0, args_1.flagBool)(args, 'function'))
121
+ return initFunction(args, client, target, name);
59
122
  let templateId = (0, args_1.flagString)(args, 'template');
60
123
  if (!templateId) {
61
124
  const { templates: list } = await (0, api_1.apiJson)(client, '/api/v1/templates');
@@ -164,7 +227,7 @@ async function link(args) {
164
227
  (0, config_1.ignoreStateInGit)(dir);
165
228
  if (client.organizationId)
166
229
  (0, config_1.writeState)(dir, { organizationId: client.organizationId });
167
- (0, template_1.writePlatformEnv)(dir, card.project_token, client.apiUrl, card.functions);
230
+ (0, template_1.writePlatformEnv)(dir, card.project_token, client.apiUrl, card.functions, card.function_map);
168
231
  (0, ui_1.note)((0, ui_1.dim)(' .env now points at this project, run it locally with xflow dev'));
169
232
  const skillReady = (0, skills_1.installSkillQuietly)(dir);
170
233
  (0, ui_1.ok)(`The folder is linked to the project "${card.name}"`);
@@ -202,7 +265,15 @@ async function list() {
202
265
  (0, ui_1.table)(projects.map((p) => [
203
266
  p.id,
204
267
  p.name,
205
- p.live_deploy_id ? 'published' : p.dev_deploy_id ? 'dev only' : 'no build',
268
+ // A standalone function has no pages at all, so the states of a site
269
+ // ("published", "dev only") would say nothing about it.
270
+ p.kind === 'function'
271
+ ? 'function'
272
+ : p.live_deploy_id
273
+ ? 'published'
274
+ : p.dev_deploy_id
275
+ ? 'dev only'
276
+ : 'no build',
206
277
  (0, ui_1.formatAge)(p.updated_at),
207
278
  ]));
208
279
  }
package/dist/flags.js CHANGED
@@ -21,6 +21,10 @@ const help_1 = require("./help");
21
21
  * only when that command can answer without the working copy. The ones that cannot
22
22
  * have a folder to stand in, and naming a project they cannot read would be a lie;
23
23
  * they are listed in WORKING_COPY below, each with what it reads from that folder.
24
+ * `deploy` sits halfway and keeps the flag: sending the sources needs the folder,
25
+ * but building a revision the platform already holds (`--revision`) does not, and
26
+ * it is the only way to bring back the version of a project without pages. Named
27
+ * by id without `--revision` it refuses, in those words.
24
28
  * `db status` sits halfway: named by id it answers with the applied history alone,
25
29
  * without the comparison against migrations/. `connections unlink` sits there too:
26
30
  * by id it removes the link without the check of what reads the variables, because
@@ -40,7 +44,7 @@ exports.KNOWN = {
40
44
  'org members': [],
41
45
  'org switch': [],
42
46
  templates: [],
43
- init: ['name', 'template', 'database', 'server'],
47
+ init: ['name', 'template', 'database', 'server', 'function'],
44
48
  link: [],
45
49
  projects: [],
46
50
  'projects list': [],
@@ -91,7 +95,7 @@ exports.KNOWN = {
91
95
  status: [],
92
96
  dev: [],
93
97
  pull: ['into', 'force', 'revision'],
94
- deploy: ['no-push', 'force', 'allow-removals', 'message'],
98
+ deploy: ['no-push', 'force', 'allow-removals', 'message', 'revision', 'project'],
95
99
  publish: ['project'],
96
100
  rollback: ['project'],
97
101
  deployments: ['project'],
@@ -110,7 +114,6 @@ exports.KNOWN = {
110
114
  * line here, or a refusal nobody prints any more stays behind as a lie.
111
115
  */
112
116
  exports.WORKING_COPY = {
113
- deploy: 'it sends the sources next to it',
114
117
  pull: 'it writes the sources next to it',
115
118
  status: 'it compares the folder with the revision on the platform',
116
119
  dev: 'it runs the dev server of the code next to it',
package/dist/help.js CHANGED
@@ -168,14 +168,17 @@ Which commands: ${(0, ui_1.bold)('db status')}, ${(0, ui_1.bold)('db query')}, $
168
168
  ${(0, ui_1.bold)('functions invoke')}, ${(0, ui_1.bold)('functions logs')}, ${(0, ui_1.bold)('logs')}, ${(0, ui_1.bold)('env')}, ${(0, ui_1.bold)('env rm')},
169
169
  ${(0, ui_1.bold)('connections')}, ${(0, ui_1.bold)('connections link')}, ${(0, ui_1.bold)('connections unlink')}, ${(0, ui_1.bold)('schedules')},
170
170
  ${(0, ui_1.bold)('schedules set')}, ${(0, ui_1.bold)('schedules rm')}, ${(0, ui_1.bold)('storage ls')}, ${(0, ui_1.bold)('storage push')},
171
- ${(0, ui_1.bold)('storage rm')}, ${(0, ui_1.bold)('deployments')}, ${(0, ui_1.bold)('publish')}, ${(0, ui_1.bold)('rollback')}. Two of them
172
- answer with less under the flag: ${(0, ui_1.bold)('db status')} gives the applied history alone,
171
+ ${(0, ui_1.bold)('storage rm')}, ${(0, ui_1.bold)('deployments')}, ${(0, ui_1.bold)('publish')}, ${(0, ui_1.bold)('rollback')}, ${(0, ui_1.bold)('deploy')}. Three of
172
+ them answer with less under the flag: ${(0, ui_1.bold)('db status')} gives the applied history alone,
173
173
  because comparing it against the local ${(0, ui_1.bold)('migrations/')} directory needs the folder,
174
- and ${(0, ui_1.bold)('connections unlink')} removes the link without naming the functions that read
175
- its variables, for the same reason.
174
+ ${(0, ui_1.bold)('connections unlink')} removes the link without naming the functions that read
175
+ its variables, for the same reason, and ${(0, ui_1.bold)('deploy')} takes the flag only together with
176
+ ${(0, ui_1.bold)('--revision')}: that build runs a revision the platform already holds, so there is
177
+ nothing to send and no folder to send it from. Without ${(0, ui_1.bold)('--revision')} it refuses and
178
+ says so.
176
179
 
177
180
  The rest cannot answer without the working copy and have nothing to do with an id
178
- instead: ${(0, ui_1.bold)('deploy')}, ${(0, ui_1.bold)('pull')}, ${(0, ui_1.bold)('status')}, ${(0, ui_1.bold)('dev')}, ${(0, ui_1.bold)('db migrate')},
181
+ instead: ${(0, ui_1.bold)('pull')}, ${(0, ui_1.bold)('status')}, ${(0, ui_1.bold)('dev')}, ${(0, ui_1.bold)('db migrate')},
179
182
  ${(0, ui_1.bold)('env check')}, ${(0, ui_1.bold)('env set')}. They answer about the files next to them, so
180
183
  they keep asking for a folder. ${(0, ui_1.bold)('projects get')} and ${(0, ui_1.bold)('link')} take no flag either:
181
184
  they name the project by its id as an argument.
@@ -306,8 +309,9 @@ attached to each one. That last column is the one to read before a migration. Th
306
309
  column says where a database lives: ${(0, ui_1.bold)('platform')} is the common one, ${(0, ui_1.bold)('own "<name>"')} the
307
310
  organization's own server. While an own server is not running, its databases do not
308
311
  answer, and that is not fixable from the code: its state is in the web interface. An
309
- organization with own servers also gets them listed, with the room used on each and the
310
- one new databases go to: that is the choice for ${(0, ui_1.bold)('xflow init --server')} and ${(0, ui_1.bold)('db move')}.
312
+ organization with own servers also gets them listed, with the room used on each: that is
313
+ the choice for ${(0, ui_1.bold)('xflow init --server')} and ${(0, ui_1.bold)('db move')}. Without that flag a new
314
+ database goes to ${(0, ui_1.bold)('platform')}.
311
315
 
312
316
  ${(0, ui_1.bold)('move')} takes the database of this project to another server of the organization:
313
317
  ${(0, ui_1.bold)('platform')} or an own server by name (or id, when two share a name). The platform copies
@@ -661,6 +665,9 @@ by default).
661
665
  user speaks, saying what changed. It names the version in the
662
666
  history (--message is the long form)
663
667
  --no-push do not send sources, build from the latest server revision
668
+ --revision <N> build a revision the platform already holds: this is how a
669
+ version comes back (the list: xflow deployments)
670
+ --project <id> with --revision only: the project to rebuild, no folder needed
664
671
  --force allow overwriting the server revision while sending
665
672
  --allow-removals agree in advance to remove the functions gone from the sources
666
673
 
@@ -703,7 +710,15 @@ they stay their own command (${(0, ui_1.bold)('xflow db migrate')}).
703
710
  The built version is visible on the project page, and the CLI prints the link. Visitors
704
711
  see it after ${(0, ui_1.bold)('xflow publish')}. The address of the build itself is not printed:
705
712
  it carries the version number, and after the next publish such a link quietly serves an
706
- old copy.`,
713
+ old copy.
714
+
715
+ ${(0, ui_1.bold)('--revision <N>')} brings a version back. The sources of that revision are already on
716
+ the server, so nothing goes up, no copy of them is needed next to you and the newest
717
+ revision stays the newest: this build simply runs an older one. Since there is no folder
718
+ to stand in, the project can be named by id (${(0, ui_1.bold)('--project')}), and for a standalone
719
+ function that is the only way back: it has no pages, so ${(0, ui_1.bold)('xflow rollback')} has nothing
720
+ to return. Functions that exist now but not in that revision would be removed, and the
721
+ same question is asked as in any other deploy.`,
707
722
  init: `${(0, ui_1.bold)('xflow init')} [dir]: a new project
708
723
 
709
724
  Lays down the platform template: React on Vite, Tailwind, a set of components
@@ -716,14 +731,21 @@ Building the interface on top of these components and the tokens in ${(0, ui_1.b
716
731
  is not a formality: your own palette on top of them looks foreign inside the platform.
717
732
 
718
733
  --name <name> project name (the folder name by default)
734
+ --function a standalone function instead of an application
719
735
  --template <id> a different template (the list: xflow templates)
720
736
  --database <id> attach a logical database of the organization (the list: xflow db list)
721
737
  --server <name> a new database for the project, right away on that server:
722
738
  platform, or an own server of the organization (xflow db list)
723
739
 
740
+ ${(0, ui_1.bold)('--function')} makes a shared backend of the organization: a project without a site,
741
+ with one handler in ${(0, ui_1.bold)('functions/<name>/index.ts')} and no template to fetch. Any
742
+ application of the organization calls it; who exactly is allowed in is decided by
743
+ ${(0, ui_1.bold)('minRole')} inside the function. It builds and releases with the same deploy, but
744
+ there are no pages to publish, so publish and rollback do not apply to it.
745
+
724
746
  Without either flag the project gets its database with the first migration, on the
725
- server new databases of the organization go to. ${(0, ui_1.bold)('--server')} decides it up front: the
726
- server has to be running, and the database counts against the plan at once.
747
+ platform server. ${(0, ui_1.bold)('--server')} decides it up front: the server has to be running, and
748
+ the database counts against the plan at once.
727
749
 
728
750
  The order: the project is created on the platform first, because without it there is
729
751
  nowhere to get the token for .env. If writing the files fails, the project stays empty
package/dist/template.js CHANGED
@@ -4,6 +4,7 @@ exports.envApiUrl = envApiUrl;
4
4
  exports.platformEnv = platformEnv;
5
5
  exports.writePlatformEnv = writePlatformEnv;
6
6
  exports.scaffoldFiles = scaffoldFiles;
7
+ exports.functionScaffoldFiles = functionScaffoldFiles;
7
8
  const node_fs_1 = require("node:fs");
8
9
  const node_path_1 = require("node:path");
9
10
  const GITIGNORE = `node_modules/
@@ -45,19 +46,22 @@ function envApiUrl(apiUrl) {
45
46
  return apiUrl.replace(/^https?:\/\//, '');
46
47
  }
47
48
  /** The variables that point a local run at the project: written to .env, handed to xflow dev. */
48
- function platformEnv(projectToken, apiUrl, functions = []) {
49
+ function platformEnv(projectToken, apiUrl, functions = [],
50
+ // The map the platform assembled: it also holds the standalone functions of the
51
+ // organization. Own functions alone are the fallback for a platform without it.
52
+ map) {
49
53
  return {
50
54
  VITE_XFLOW_PROJECT_TOKEN: projectToken ?? '',
51
55
  VITE_XFLOW_API_URL: envApiUrl(apiUrl),
52
- [FUNCTIONS_ENV_KEY]: functionsEnvValue(functions),
56
+ [FUNCTIONS_ENV_KEY]: map ? JSON.stringify(map) : functionsEnvValue(functions),
53
57
  };
54
58
  }
55
59
  /**
56
60
  * Point .env at the current project: written on every link and deploy, so that `npm run dev`
57
61
  * never keeps talking to the previous one. Variables of your own are left alone.
58
62
  */
59
- function writePlatformEnv(root, projectToken, apiUrl, functions = []) {
60
- for (const [key, value] of Object.entries(platformEnv(projectToken, apiUrl, functions))) {
63
+ function writePlatformEnv(root, projectToken, apiUrl, functions = [], map) {
64
+ for (const [key, value] of Object.entries(platformEnv(projectToken, apiUrl, functions, map))) {
61
65
  writeEnvValue(root, key, value);
62
66
  }
63
67
  }
@@ -81,3 +85,57 @@ function scaffoldFiles(projectName) {
81
85
  { path: 'README.md', content: README.replace('%NAME%', projectName.replace(/"/g, '\\"')) },
82
86
  ];
83
87
  }
88
+ /**
89
+ * A standalone function has no site, so it gets no Vite tree: one handler, and
90
+ * esbuild, which the sandbox takes from the project to bundle the function.
91
+ */
92
+ const FUNCTION_HANDLER = `/**
93
+ * Cloud function of the %NAME% project.
94
+ *
95
+ * XFlow calls it over HTTP and, if a schedule is set, on a timer. The answer is
96
+ * { statusCode, body }, where body is a string.
97
+ */
98
+ export async function handler(event: {
99
+ httpMethod: string;
100
+ body: string | null;
101
+ queryStringParameters: Record<string, string> | null;
102
+ }) {
103
+ const input = event.body ? JSON.parse(event.body) : {};
104
+
105
+ return {
106
+ statusCode: 200,
107
+ body: JSON.stringify({ ok: true, method: event.httpMethod, input }),
108
+ };
109
+ }
110
+ `;
111
+ const FUNCTION_README = `# %NAME%
112
+
113
+ A standalone function on XFlow: a shared backend for the applications of the
114
+ organization, without a site of its own.
115
+
116
+ \`\`\`bash
117
+ npm install # dependencies
118
+ xflow deploy # send the code, build and release the function
119
+ xflow functions # its address and status
120
+ \`\`\`
121
+
122
+ The code lives in \`functions/%FUNCTION%/index.ts\`. A schedule, keys and secrets
123
+ are set in the platform, in the "Functions" section.
124
+ `;
125
+ function functionScaffoldFiles(projectName, functionName) {
126
+ const fill = (text) => text.replace(/%NAME%/g, projectName.replace(/"/g, '\\"')).replace(/%FUNCTION%/g, functionName);
127
+ return [
128
+ { path: '.gitignore', content: GITIGNORE },
129
+ { path: 'README.md', content: fill(FUNCTION_README) },
130
+ {
131
+ path: 'package.json',
132
+ content: `${JSON.stringify({
133
+ name: functionName,
134
+ private: true,
135
+ type: 'module',
136
+ devDependencies: { esbuild: '0.28.2' },
137
+ }, null, 2)}\n`,
138
+ },
139
+ { path: `functions/${functionName}/index.ts`, content: fill(FUNCTION_HANDLER) },
140
+ ];
141
+ }
package/dist/version.js CHANGED
@@ -2,6 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_API_URL = exports.CLI_VERSION = void 0;
4
4
  /** Keep in sync with cli/package.json. */
5
- exports.CLI_VERSION = '0.29.0';
5
+ exports.CLI_VERSION = '0.30.0';
6
6
  /** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
7
7
  exports.DEFAULT_API_URL = 'https://app.getxflow.com';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -22,7 +22,7 @@ contains the fix.
22
22
 
23
23
  ## Keeping these instructions current
24
24
 
25
- These instructions ship with xflow CLI 0.29.0, so the copy you are reading can be older than
25
+ These instructions ship with xflow CLI 0.30.0, so the copy you are reading can be older than
26
26
  the CLI answering your commands.
27
27
 
28
28
  When the CLI says this file does not match it, run `xflow skills --refresh` and then **read
@@ -123,6 +123,12 @@ the pages of an earlier build. Only the pages come back and they talk to today's
123
123
  code of that build is a separate fetch (`xflow pull --revision N --into ../old-version`, kept
124
124
  outside the project folder); building it over the current one needs a human.
125
125
 
126
+ Bringing the code back instead of the pages: `xflow deploy --revision N -m "what came back"`
127
+ builds a revision the platform already holds. Nothing goes up, so no copy of that code is needed
128
+ next to you and the newest revision stays the newest. It is the only way back for a standalone
129
+ function, which has no pages to return, and it needs no folder at all: `--project <id>` names the
130
+ project. Functions that exist now but not in that revision are removed, with the usual question.
131
+
126
132
  Pages are kept for the **three latest successful builds** plus whatever dev and live show;
127
133
  older versions stay in the history with `pages_kept` false, readable but not openable. The
128
134
  history holds the **last 30 builds**, each keeping the revision it was built from; any other
@@ -216,6 +222,28 @@ There is no separate deploy command: `xflow deploy` ships the functions and then
216
222
  application, in that order. `xflow functions list` shows what is live. The handler returns
217
223
  `{ statusCode, body }` where `body` is a JSON string.
218
224
 
225
+ A function that belongs to no single application is created on its own: `xflow init <dir>
226
+ --function` makes a project without a site, with one handler and no template. It is a shared
227
+ backend of the organization, and any application of that organization calls it; who is let in
228
+ is decided by `minRole` inside the function. Deploy builds and releases it the same way, but it
229
+ has no pages, so `xflow publish` and `xflow rollback` do not apply; an earlier version comes back
230
+ with `xflow deploy --revision N`. It counts against the plan
231
+ limit of functions, not of applications. Put a function here when it serves several
232
+ applications, a bot or a webhook; a function serving one application belongs in that project.
233
+
234
+ An application calls it like its own: `xflow.functions.invoke('<name>')`, the platform puts the
235
+ address into the map the bundle carries. Two things follow from that map. An application sees a
236
+ newly created standalone function only after its own `xflow deploy`, and a function of the
237
+ project always wins a name it shares with a standalone one: the deploy names such a name in its
238
+ output, and the shared function stays unreachable from that project until one of them is
239
+ renamed. Such a call carries a person: a server key of another project opens the functions of
240
+ its own project only.
241
+
242
+ Inside a standalone function, remember who can reach it: any member of the organization,
243
+ including someone whose access to projects is narrowed to one folder. The role in `minRole` is
244
+ the role in the organization, so a function holding data that is not for everyone checks the
245
+ caller itself (`event.xflow.user`).
246
+
219
247
  Keep one shape inside that string across the whole project: `{ success: true, data }`
220
248
  when it worked, `{ success: false, error: { message, code } }` when it did not. Nothing
221
249
  enforces this, but a project where every function answers its own way costs an adapter
@@ -290,7 +318,8 @@ answers 401.
290
318
 
291
319
  An outside service (a payment webhook, a bot, a CRM) has no person behind it and needs a key of
292
320
  that one function. Keys are not issued by default and the CLI cannot create one: a human issues
293
- it in the project settings, «Облачные функции» → the function → «Настройки». Ask the user to do
321
+ it in the project settings, «Облачные функции» → the function → «Настройки»; a standalone
322
+ function has a screen of its own, «Функции» → the function → «Настройки». Ask the user to do
294
323
  that and to paste the address back — never invent another way in. Send it in the
295
324
  `X-Function-Key` header. A function holds at most two keys, the second one to replace the first
296
325
  without downtime, not to serve a second consumer.
@@ -437,16 +466,17 @@ strips them from arguments. Look before you write a migration against a shared d
437
466
 
438
467
  An organization can have own database servers, several of them for different jobs. `xflow db
439
468
  list` names the server of each database (`platform` is the common one) and lists the own servers
440
- with the room used and the one new databases go to; `xflow db status` names the one under this
441
- project. The server belongs to the database: projects sharing a database share its server.
469
+ with the room used; `xflow db status` names the one under this project. The server belongs to the
470
+ database: projects sharing a database share its server.
442
471
  - A new project with its database on a chosen server: `xflow init --server <name>` (`platform`
443
- for the common one). Without it the first migration creates the database where new ones go.
472
+ for the common one). Without it the first migration creates the database on `platform`.
444
473
  - An existing database: `xflow db move --to <name> --confirm <database name>`. Writes to every
445
474
  project on it close while it copies, usually a couple of minutes; the platform switches the
446
475
  functions itself, no deploy. It needs the right to move databases, off on the key by default:
447
476
  refused for the right, say what should move where and why, and the person turns it on or
448
477
  moves it in the "Data" section.
449
- - Buying, paying for and deleting servers are the person's, in the web interface.
478
+ - Buying, paying for and deleting servers are the person's, in the web interface. There is no
479
+ limit on how many. If a database has outgrown its server, say so and suggest buying one.
450
480
 
451
481
  Code stays the same: `DATABASE_URL` arrives as before, pass it as is, without an `ssl` option (an
452
482
  own server is reached over a closed network, and `ssl` makes the connection fail). While an own
@@ -454,6 +484,23 @@ server is not running, migrations and queries say so and functions that use the
454
484
  out. That is not your code and not yours to fix: tell the person, who sees the server state in
455
485
  the "Data" section of the web interface, instead of retrying.
456
486
 
487
+ An own server comes tuned and guarded, so do not tune it from migrations:
488
+ - Installed from the start: `pgcrypto`, `pg_trgm`, `unaccent`, `citext`, `btree_gin`,
489
+ `btree_gist`, `pg_stat_statements`. Use them, do not `CREATE EXTENSION` them again. UUIDs
490
+ need no extension anywhere: `gen_random_uuid()` is built in, and on an own server so is
491
+ `uuidv7()`. Never `uuid-ossp`. Anything else (`postgis`, `pgvector`) is a request to the
492
+ person, not a migration.
493
+ - Limits on every connection: a statement is cut after 30 seconds, waiting for a lock after 3,
494
+ a transaction left open after 30. A query that needs longer is a wrong query (an index is
495
+ missing, or it is a report that belongs in a scheduled function writing a summary table). A
496
+ migration that needs longer is split into smaller ones. The one exception is a scheduled job
497
+ that genuinely needs more: it runs `SET statement_timeout = '5min'` on its own connection,
498
+ and nowhere else. A database that used `citext` or another extension type cannot move back to
499
+ `platform`: the move refuses, the common server has no such extensions.
500
+ - A restart happens on a plan change and takes about a minute. Create the `Client` inside the
501
+ handler, or keep a `Pool` at module level: a `Client` created at module level dies with the
502
+ restart and stays dead until the function cold-starts.
503
+
457
504
  ## File storage
458
505
 
459
506
  Two kinds of files live here, and they arrive through different doors.