@getxflow/cli 0.8.1 → 0.9.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/README.md CHANGED
@@ -17,7 +17,8 @@ else's code belongs next to it.
17
17
  | Command | What it does |
18
18
  |---|---|
19
19
  | `login` / `logout` / `whoami` | sign in through the browser, sign out, whose key this is and what it can do |
20
- | `init` / `link` | new project, link a folder to an existing one |
20
+ | `org` / `org switch` | organizations with a stored key, and which one this folder works with |
21
+ | `init` / `link` / `templates` | new project, link a folder to an existing one, which templates there are |
21
22
  | `status` / `pull` | state of the sources on the server and fetching them back |
22
23
  | `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
23
24
  | `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
@@ -26,8 +27,9 @@ else's code belongs next to it.
26
27
  | `functions invoke` / `functions logs` | call a function, look at its crashes with the stack |
27
28
  | `schedules list` / `set` / `rm` | running functions on a timer (timer triggers) |
28
29
  | `env` / `env check` / `env set` | environment variables of the functions, values are never handed back |
30
+ | `connections` / `connections link` / `connections unlink` | connected accounts the functions can use, and their variables |
29
31
  | `logs` | browser errors from the released application |
30
- | `projects list` / `projects get` / `open` | projects of the organization and the application addresses |
32
+ | `projects list` / `projects get` | projects of the organization and the application addresses |
31
33
  | `skills` | platform instructions for an AI agent ([Agent Skills](https://agentskills.io) format) |
32
34
  | `mcp install` | connect the agent to the platform directly, without a terminal |
33
35
  | `update` | update the CLI itself, and the skill that ships inside it |
@@ -21,6 +21,14 @@ const SCOPE_LABEL = {
21
21
  const FUNCTIONS_DIR = 'functions';
22
22
  /** Matches `process.env.NAME` and `process.env['NAME']`. */
23
23
  const ENV_REFERENCE = /process\.env(?:\.([A-Z0-9_]+)|\[['"]([A-Z0-9_]+)['"]\])/g;
24
+ /**
25
+ * Reading the environment in a shape no name can be read from: a name built from
26
+ * an expression, reached through `?.`, destructured or spread. The platform
27
+ * passes a variable only to a function that spells its name out, so these reads
28
+ * arrive empty and the build gate rejects them. The same rule lives on the
29
+ * platform side in lib/env-references.ts and the two must agree.
30
+ */
31
+ const OPAQUE_REFERENCE = /process\.env\b(?!\.[A-Z0-9_]+|\[['"][A-Z0-9_]+['"]\])/;
24
32
  /** Set by the platform itself. */
25
33
  const PROVIDED = new Set([
26
34
  'XFLOW_PROJECT_ID',
@@ -28,6 +36,7 @@ const PROVIDED = new Set([
28
36
  'XFLOW_PROJECT_SCHEMA',
29
37
  'XFLOW_FUNCTION_NAME',
30
38
  'XFLOW_API_URL',
39
+ 'XFLOW_SERVER_KEY',
31
40
  'DATABASE_URL',
32
41
  'NODE_ENV',
33
42
  ]);
@@ -47,8 +56,11 @@ function sourceFiles(dir, found = []) {
47
56
  function referencedByFunctions(root) {
48
57
  const needed = new Map();
49
58
  const provided = new Map();
50
- for (const file of sourceFiles((0, node_path_1.join)(root, FUNCTIONS_DIR))) {
51
- const functionName = file.slice((0, node_path_1.join)(root, FUNCTIONS_DIR).length + 1).split(/[\\/]/)[0];
59
+ const opaque = new Map();
60
+ const base = (0, node_path_1.join)(root, FUNCTIONS_DIR);
61
+ for (const file of sourceFiles(base)) {
62
+ const relative = file.slice(base.length + 1);
63
+ const functionName = relative.split(/[\\/]/)[0];
52
64
  const code = (0, node_fs_1.readFileSync)(file, 'utf-8');
53
65
  for (const match of code.matchAll(ENV_REFERENCE)) {
54
66
  const name = match[1] || match[2];
@@ -60,8 +72,15 @@ function referencedByFunctions(root) {
60
72
  users.push(functionName);
61
73
  target.set(name, users);
62
74
  }
75
+ code.split('\n').forEach((line, index) => {
76
+ if (!OPAQUE_REFERENCE.test(line))
77
+ return;
78
+ const places = opaque.get(functionName) ?? [];
79
+ places.push(`${FUNCTIONS_DIR}/${relative.replace(/\\/g, '/')}:${index + 1}`);
80
+ opaque.set(functionName, places);
81
+ });
63
82
  }
64
- return { needed, provided };
83
+ return { needed, provided, opaque };
65
84
  }
66
85
  async function fetchVariables() {
67
86
  const { root, config } = (0, config_1.requireProject)();
@@ -82,8 +101,8 @@ async function envList() {
82
101
  /** Check that every referenced variable is stored. */
83
102
  async function envCheck() {
84
103
  const { names, root } = await fetchVariables();
85
- const { needed, provided } = referencedByFunctions(root);
86
- if (needed.size === 0 && provided.size === 0) {
104
+ const { needed, provided, opaque } = referencedByFunctions(root);
105
+ if (needed.size === 0 && provided.size === 0 && opaque.size === 0) {
87
106
  (0, ui_1.note)('The functions of this project read no environment variables');
88
107
  return;
89
108
  }
@@ -106,14 +125,33 @@ async function envCheck() {
106
125
  (0, ui_1.out)((0, ui_1.bold)('Stored on the platform:'));
107
126
  (0, ui_1.table)(present);
108
127
  }
109
- if (missing.length === 0) {
128
+ if (missing.length > 0) {
129
+ (0, ui_1.out)('');
130
+ (0, ui_1.fail)('Missing on the platform:');
131
+ (0, ui_1.table)(missing);
132
+ }
133
+ if (opaque.size > 0) {
134
+ (0, ui_1.out)('');
135
+ (0, ui_1.fail)('Reading the environment without naming the variable:');
136
+ (0, ui_1.table)([...opaque.entries()].sort().map(([name, places]) => [name, places.join(', ')]));
137
+ (0, ui_1.note)((0, ui_1.dim)(' A name built from an expression, reached through ?., destructured or spread is invisible to the platform: ' +
138
+ 'the value never arrives and the build is rejected'));
139
+ }
140
+ if (missing.length === 0 && opaque.size === 0) {
110
141
  (0, ui_1.ok)('Every function has the variables it needs');
111
142
  return;
112
143
  }
113
- (0, ui_1.out)('');
114
- (0, ui_1.fail)('Missing on the platform:');
115
- (0, ui_1.table)(missing);
116
- throw new errors_1.CliError(`Missing variables: ${missing.length}`, 'To store them: xflow env set NAME=value. Until then the function receives undefined');
144
+ const problems = [];
145
+ const hints = [];
146
+ if (missing.length > 0) {
147
+ problems.push(`missing variables: ${missing.length}`);
148
+ hints.push('To store one: xflow env set NAME=value. Until then the function receives undefined');
149
+ }
150
+ if (opaque.size > 0) {
151
+ problems.push(`functions reading the environment without naming it: ${opaque.size}`);
152
+ hints.push('Spell every variable name out: process.env.NAME');
153
+ }
154
+ throw new errors_1.CliError(problems.join(', '), hints.join('. '));
117
155
  }
118
156
  async function envSet(args) {
119
157
  const { root, config } = (0, config_1.requireProject)();
@@ -13,6 +13,7 @@ const config_1 = require("../config");
13
13
  const errors_1 = require("../errors");
14
14
  const session_1 = require("../session");
15
15
  const skills_1 = require("./skills");
16
+ const sources_1 = require("./sources");
16
17
  const template_1 = require("../template");
17
18
  const zip_1 = require("../zip");
18
19
  const ui_1 = require("../ui");
@@ -102,6 +103,9 @@ async function init(args) {
102
103
  async function link(args) {
103
104
  const root = (0, config_1.findProjectRoot)() ?? process.cwd();
104
105
  const existing = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, config_1.CONFIG_FILE)) ? (0, config_1.readConfig)(root) : undefined;
106
+ // Asked before anything is written: an empty folder means a clone, and the
107
+ // sources have to come with it.
108
+ const fresh = !existing && isEmptyEnough(process.cwd());
105
109
  let client = (0, session_1.connect)(existing);
106
110
  const projectId = args.words[0];
107
111
  if (!projectId) {
@@ -147,6 +151,24 @@ async function link(args) {
147
151
  (0, skills_1.installSkillQuietly)(dir);
148
152
  (0, ui_1.ok)(`The folder is linked to the project "${card.name}"`);
149
153
  (0, ui_1.note)((0, ui_1.dim)(` ${(0, node_path_1.join)(dir, config_1.CONFIG_FILE)}`));
154
+ if (!fresh)
155
+ return;
156
+ // A clone without the code is half a clone, and the folder has to remember
157
+ // which revision it works from: without that the first push has nothing to
158
+ // compare against and stops.
159
+ try {
160
+ const { info, files } = await (0, sources_1.downloadRevision)(client, card.id, dir);
161
+ (0, config_1.writeState)(dir, { revision: info.revision, treeHash: info.tree_hash });
162
+ (0, ui_1.ok)(`Revision ${info.revision}: ${files} files`);
163
+ }
164
+ catch (e) {
165
+ if (e instanceof api_1.ApiError && e.status === 404) {
166
+ (0, ui_1.note)((0, ui_1.dim)(' The project has no sources yet: write the code and run xflow deploy'));
167
+ return;
168
+ }
169
+ (0, ui_1.warn)(`The sources did not arrive: ${e instanceof Error ? e.message : e}`);
170
+ (0, ui_1.note)((0, ui_1.dim)(' The folder is linked, fetch them separately: xflow pull'));
171
+ }
150
172
  }
151
173
  async function list() {
152
174
  const root = (0, config_1.findProjectRoot)();
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.latestRevision = latestRevision;
4
4
  exports.pushSources = pushSources;
5
+ exports.downloadRevision = downloadRevision;
5
6
  exports.pull = pull;
6
7
  exports.status = status;
7
8
  const node_fs_1 = require("node:fs");
@@ -14,8 +15,8 @@ const session_1 = require("../session");
14
15
  const tree_1 = require("../tree");
15
16
  const zip_1 = require("../zip");
16
17
  const ui_1 = require("../ui");
17
- /** Server cap, checked locally to fail before uploading. */
18
- const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
18
+ /** Server cap, checked locally to fail before uploading. Keep in sync with the server. */
19
+ const MAX_ARCHIVE_BYTES = 10 * 1024 * 1024;
19
20
  function prepareTree(root, config) {
20
21
  const buildDir = config.build?.dir?.replace(/^\.\//, '').replace(/\/+$/, '');
21
22
  const extra = [...(config.ignore ?? []), ...(buildDir ? [`${buildDir}/`] : [])];
@@ -31,7 +32,8 @@ function prepareTree(root, config) {
31
32
  const top = (0, tree_1.heaviest)(files)
32
33
  .map((f) => ` ${f.path} ${(0, ui_1.formatBytes)(f.content.length)}`)
33
34
  .join('\n');
34
- throw new errors_1.CliError(`The archive is ${(0, ui_1.formatBytes)(archive.length)}, the cap is ${(0, ui_1.formatBytes)(MAX_ARCHIVE_BYTES)}`, `The heaviest files:\n${top}\n Exclude what is not needed in .xflowignore`);
35
+ throw new errors_1.CliError(`The archive is ${(0, ui_1.formatBytes)(archive.length)}, the cap is ${(0, ui_1.formatBytes)(MAX_ARCHIVE_BYTES)}`, `The heaviest files:\n${top}\n Exclude what is not needed in .xflowignore.\n` +
36
+ ' Heavy media the app loads at runtime belong in the project file storage, not in the build');
35
37
  }
36
38
  return { files, hash: (0, tree_1.treeHash)(files), archive };
37
39
  }
@@ -110,23 +112,29 @@ async function pushSources(root, config, client, options) {
110
112
  }
111
113
  return { revision: result.revision, status: result.status };
112
114
  }
115
+ /**
116
+ * Does the folder hold work of its own?
117
+ *
118
+ * Only sources count. What `xflow link` itself lays down (xflow.json, the agent
119
+ * instructions, dot-files the platform or the editor keeps) is bookkeeping, and
120
+ * counting it turned the documented way to clone a project into a dead end:
121
+ * link wrote its files, and the pull that follows refused over them.
122
+ */
113
123
  function hasContent(dir) {
114
124
  if (!(0, node_fs_1.existsSync)(dir))
115
125
  return false;
116
- return (0, node_fs_1.readdirSync)(dir).some((name) => name !== '.git' && name !== '.xflow');
126
+ return (0, node_fs_1.readdirSync)(dir).some((name) => !name.startsWith('.') && name !== config_1.CONFIG_FILE && name !== 'AGENTS.md');
117
127
  }
118
- async function pull(args) {
119
- const { root, config } = (0, config_1.requireProject)();
120
- const into = (0, args_1.flagString)(args, 'into');
121
- const target = into ? (0, node_path_1.resolve)(into) : root;
122
- const client = await (0, session_1.connectProject)(root, config);
123
- const revision = (0, args_1.flagNumber)(args, 'revision');
128
+ /**
129
+ * Write a server revision into `target`.
130
+ *
131
+ * Shared with `xflow link`: linking an empty folder and then being told the
132
+ * folder is not empty for a pull is a dead end the platform can avoid by
133
+ * fetching the sources itself.
134
+ */
135
+ async function downloadRevision(client, projectId, target, revision) {
124
136
  const query = revision !== undefined ? `?revision=${revision}` : '';
125
- const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/pull${query}`);
126
- if (hasContent(target) && !(0, args_1.flagBool)(args, 'force')) {
127
- throw new errors_1.CliError(`The directory ${target} is not empty`, 'The platform cannot merge changes, that is git work. Fetch the server copy alongside: ' +
128
- 'xflow pull --into ./server-copy, or overwrite the folder completely: xflow pull --force');
129
- }
137
+ const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/pull${query}`);
130
138
  (0, ui_1.step)(`Downloading revision ${info.revision} (${(0, ui_1.formatBytes)(info.size_bytes)})`);
131
139
  const entries = (0, zip_1.zipRead)(await (0, api_1.downloadUrl)(info.download_url));
132
140
  const hash = (0, tree_1.treeHash)(entries.map((e) => ({ path: e.path, content: e.content })));
@@ -134,11 +142,24 @@ async function pull(args) {
134
142
  (0, ui_1.warn)('The hash of the downloaded tree did not match the server one: the contents may have changed in transit');
135
143
  }
136
144
  (0, node_fs_1.mkdirSync)(target, { recursive: true });
137
- for (const entry of (0, zip_1.dropDirectoryEntries)(entries)) {
145
+ const files = (0, zip_1.dropDirectoryEntries)(entries);
146
+ for (const entry of files) {
138
147
  const path = (0, node_path_1.join)(target, entry.path);
139
148
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
140
149
  (0, node_fs_1.writeFileSync)(path, entry.content);
141
150
  }
151
+ return { info, files: files.length };
152
+ }
153
+ async function pull(args) {
154
+ const { root, config } = (0, config_1.requireProject)();
155
+ const into = (0, args_1.flagString)(args, 'into');
156
+ const target = into ? (0, node_path_1.resolve)(into) : root;
157
+ const client = await (0, session_1.connectProject)(root, config);
158
+ if (hasContent(target) && !(0, args_1.flagBool)(args, 'force')) {
159
+ throw new errors_1.CliError(`The directory ${target} is not empty`, 'The platform cannot merge changes, that is git work. Fetch the server copy alongside: ' +
160
+ 'xflow pull --into ./server-copy, or overwrite the folder completely: xflow pull --force');
161
+ }
162
+ const { info, files } = await downloadRevision(client, config.projectId, target, (0, args_1.flagNumber)(args, 'revision'));
142
163
  // Only the working folder tracks revisions; a side copy (--into) must not reset them.
143
164
  if (!into) {
144
165
  (0, config_1.writeState)(root, {
@@ -147,7 +168,7 @@ async function pull(args) {
147
168
  organizationId: client.organizationId ?? undefined,
148
169
  });
149
170
  }
150
- (0, ui_1.ok)(`Revision ${info.revision}: ${entries.length} files in ${target}`);
171
+ (0, ui_1.ok)(`Revision ${info.revision}: ${files} files in ${target}`);
151
172
  }
152
173
  async function status() {
153
174
  const { root, config } = (0, config_1.requireProject)();
package/dist/help.js CHANGED
@@ -15,7 +15,7 @@ ${(0, ui_1.bold)('Getting started')}
15
15
  xflow login sign in through the browser
16
16
  xflow init [dir] new project from the platform template
17
17
  xflow templates which templates are available
18
- xflow link <id> link this folder to a project (id: xflow projects list)
18
+ xflow link <id> link this folder to a project, an empty one also gets the sources
19
19
  xflow skills pick the agents that get the platform instructions
20
20
  xflow mcp install give the agent platform access without a terminal
21
21
 
@@ -194,8 +194,10 @@ the function. So keep your own copy wherever you got it from.
194
194
 
195
195
  ${(0, ui_1.bold)('check')} reads the sources in ${(0, ui_1.bold)('functions/')} and looks for ${(0, ui_1.bold)('process.env.NAME')}
196
196
  references. The same rule applies on deploy: a function receives only the variables it
197
- mentions by name. A name assembled from an expression (${(0, ui_1.bold)("process.env['KEY_' + n]")})
198
- never reaches the environment, so read variables literally.
197
+ mentions by name. A name assembled from an expression (${(0, ui_1.bold)("process.env['KEY_' + n]")}),
198
+ reached through ${(0, ui_1.bold)('?.')}, destructured or spread never reaches the environment, so read
199
+ variables literally. ${(0, ui_1.bold)('check')} names those reads and the build rejects them: a secret
200
+ that quietly fails to arrive is found by the function falling over weeks later.
199
201
 
200
202
  The value reaches the function on deploy, not at the moment it is stored: after
201
203
  ${(0, ui_1.bold)('env set')} run ${(0, ui_1.bold)('xflow deploy')}. The build ships a function whose code did not
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.8.1';
5
+ exports.CLI_VERSION = '0.9.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.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -27,8 +27,9 @@ These mistakes cost the most because nothing fails at the moment they are made,
27
27
  the error points away from the cause. The sections below carry the details.
28
28
 
29
29
  1. Read environment variables literally: `process.env.API_KEY`. Destructuring
30
- (`const { API_KEY } = process.env`) reads as no mention of the variable at all,
31
- and it arrives empty.
30
+ (`const { API_KEY } = process.env`), a name built from an expression, `?.` and
31
+ spreading the environment all read as no mention of the variable at all. The
32
+ build rejects them, and `xflow env check` names them before that.
32
33
  2. A value written with `xflow env set` reaches the functions on the next
33
34
  `xflow deploy`, not at the moment it is written.
34
35
  3. Never delete a `functions/<name>/` directory unless the user asked for that
@@ -79,6 +80,12 @@ live version to protect yet.
79
80
  Rolling back: `xflow deployments` lists the version history, `xflow rollback <id>`
80
81
  points the project back at an earlier build. Sources stay on their own revision.
81
82
 
83
+ The platform keeps the **last 30 successful builds**. Older ones are deleted, files and
84
+ history row alike, and rolling back to them stops working. That is rollback depth, not a
85
+ backup of the code: the code lives in revisions (the last 100, and a revision a live
86
+ version was built from is never deleted), and a build is made from a revision again.
87
+ Keep anything you must not lose in your own git repository, not in the version history.
88
+
82
89
  **The only link you give a person is the project page**, `https://app.getxflow.com/projects/<id>`,
83
90
  which the CLI prints for you. Refer to builds by their number ("version 481203 is built,
84
91
  092399 is what visitors see"), never by address. The platform does not hand out build
@@ -247,9 +254,11 @@ functions read but the platform does not have. Values never come back out — th
247
254
  they exist is inside the running function.
248
255
 
249
256
  A function receives only the variables it mentions by name via `process.env.NAME`, so never
250
- assemble a variable name from an expression and never destructure the environment
251
- (`const { API_KEY } = process.env` reads as no mention at all, and the variable arrives
252
- empty). New values arrive on the next `xflow deploy`, not at the moment they are written.
257
+ assemble a variable name from an expression, never reach the environment through `?.`, and
258
+ never destructure or spread it (`const { API_KEY } = process.env` reads as no mention at all,
259
+ and the variable arrives empty). The build stops on such a read rather than shipping a
260
+ function whose secret silently never arrives. New values arrive on the next `xflow deploy`,
261
+ not at the moment they are written.
253
262
 
254
263
  Some variables come from a connected account instead of from you. When someone connects an
255
264
  advertising cabinet or another external service in the platform settings and links it to the
@@ -341,6 +350,12 @@ a migration against a shared database.
341
350
 
342
351
  ## File storage
343
352
 
353
+ Sources go up as one archive, capped at 10 MB. That cap is about heavy media, not about
354
+ code: an app with hundreds of files is nowhere near it. Icons, fonts and small artwork the
355
+ build needs belong in the repository as usual. Photos, video, PDFs and anything a user
356
+ uploads belong in file storage, which is metered against the organization plan and is not
357
+ rebuilt and re-uploaded on every deploy.
358
+
344
359
  The project has file storage, and the browser cannot reach it. Those endpoints take only the
345
360
  server key of the project, and the platform puts it into the environment of your cloud
346
361
  functions as `XFLOW_SERVER_KEY`. Nothing else holds it: not the bundle, not `.env`, not