@getxflow/cli 0.9.0 → 0.9.1

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
@@ -20,7 +20,7 @@ else's code belongs next to it.
20
20
  | `org` / `org switch` | organizations with a stored key, and which one this folder works with |
21
21
  | `init` / `link` / `templates` | new project, link a folder to an existing one, which templates there are |
22
22
  | `status` / `pull` | state of the sources on the server and fetching them back |
23
- | `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
23
+ | `deploy` / `publish` / `rollback` / `deployments` | build, publish, serve earlier pages, version history |
24
24
  | `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
25
25
  | `db schema` / `db query` | tables and columns, reading data in a read-only transaction |
26
26
  | `functions list` | cloud functions of the project from `functions/<name>/index.ts`, shipped by `deploy` |
@@ -18,6 +18,8 @@ const sources_1 = require("./sources");
18
18
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
19
  /** Slightly longer than the platform's own build timeout. */
20
20
  const WAIT_LIMIT_MS = 16 * 60_000;
21
+ /** Said the same way with a terminal and without one. */
22
+ const REMOVAL_IS_FINAL = 'Removal is final: a function created again later gets a different address, schedules included';
21
23
  /** Print every check violation at once. */
22
24
  function reportIssues(e) {
23
25
  const issues = e.issues ?? [];
@@ -81,13 +83,19 @@ async function startBuild(client, projectId, revision, allowRemovals) {
81
83
  catch (e) {
82
84
  if (e instanceof api_1.ApiError && e.issues?.length)
83
85
  reportIssues(e);
84
- const removing = e instanceof api_1.ApiError && e.code === 'confirm_required' ? (e.removing ?? []) : [];
85
- // No terminal (CI, an agent): the flag is the only way to say yes, and the
86
- // platform message already names it.
87
- if (removing.length === 0 || process.stdin.isTTY !== true || process.stderr.isTTY !== true)
86
+ if (!(e instanceof api_1.ApiError) || e.code !== 'confirm_required')
88
87
  throw e;
88
+ const removing = e.removing ?? [];
89
+ if (removing.length === 0)
90
+ throw e;
91
+ // No terminal (CI, an agent): the flag is the only way to say yes. The platform
92
+ // hint names the API field, so translate it into the flag this CLI accepts.
93
+ if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
94
+ throw new errors_1.CliError(e.message, `${REMOVAL_IS_FINAL}. If that is intended, retry with --allow-removals. ` +
95
+ 'If it is not, bring the functions/<name>/index.ts directories back');
96
+ }
89
97
  (0, ui_1.warn)(`Gone from the sources, the build would remove them: ${removing.join(', ')}`);
90
- (0, ui_1.note)((0, ui_1.dim)(' Removal is final: a function created again later gets a different address, schedules included'));
98
+ (0, ui_1.note)((0, ui_1.dim)(` ${REMOVAL_IS_FINAL}`));
91
99
  const answer = await (0, prompt_1.select)('Remove them from the cloud?', [
92
100
  { label: 'No, stop here', hint: 'bring the functions/<name>/index.ts directories back' },
93
101
  { label: 'Yes, remove and build', hint: removing.join(', ') },
@@ -158,7 +166,9 @@ async function deploy(args) {
158
166
  catch {
159
167
  (0, ui_1.warn)('Could not refresh the function addresses in .env, the build itself is fine');
160
168
  }
161
- (0, ui_1.note)((0, ui_1.dim)(' Show it to visitors: xflow publish'));
169
+ (0, ui_1.note)((0, ui_1.dim)(' The cloud functions of this build are already live: they are one per project'));
170
+ (0, ui_1.note)((0, ui_1.dim)(' and are not held back by publishing'));
171
+ (0, ui_1.note)((0, ui_1.dim)(' Show the new pages to visitors: xflow publish'));
162
172
  }
163
173
  async function publish() {
164
174
  const { root, config } = (0, config_1.requireProject)();
@@ -169,9 +179,11 @@ async function publish() {
169
179
  (0, ui_1.ok)('This version is already published');
170
180
  }
171
181
  else {
172
- (0, ui_1.ok)(`Version ${result.deploy_id} published: visitors see it now`);
182
+ (0, ui_1.ok)(`Version ${result.deploy_id} published: visitors see its pages now`);
173
183
  }
174
184
  (0, ui_1.out)(result.project_url);
185
+ (0, ui_1.note)((0, ui_1.dim)(' Publishing moves the pages only. Cloud functions are one per project: visitors'));
186
+ (0, ui_1.note)((0, ui_1.dim)(' have been running the code of the last xflow deploy since it finished'));
175
187
  }
176
188
  async function rollback(args) {
177
189
  const { root, config } = (0, config_1.requireProject)();
@@ -181,12 +193,15 @@ async function rollback(args) {
181
193
  }
182
194
  const client = await (0, session_1.connectProject)(root, config);
183
195
  const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/rollback`, { method: 'POST', body: { deploy_id: deployId } });
184
- (0, ui_1.ok)(`The dev version of the project is switched to ${result.deploy_id}`);
196
+ (0, ui_1.ok)(`The project now serves the pages of build ${result.deploy_id}`);
185
197
  (0, ui_1.out)(result.project_url);
198
+ (0, ui_1.note)((0, ui_1.dim)(' Only the pages came back. Cloud functions and the database are one per project:'));
199
+ (0, ui_1.note)((0, ui_1.dim)(' they are not versioned and stay as they are now'));
186
200
  if (result.revision !== null) {
187
- (0, ui_1.note)((0, ui_1.dim)(` The code of this version: xflow pull --revision ${result.revision} --into ./v${result.deploy_id}`));
201
+ (0, ui_1.note)((0, ui_1.dim)(` The code of this build: xflow pull --revision ${result.revision} --into ../v${result.deploy_id}`));
202
+ (0, ui_1.note)((0, ui_1.dim)(' Building that code over the current one is a separate step: it overwrites the server revision'));
188
203
  }
189
- (0, ui_1.note)((0, ui_1.dim)(' Visitors see it only after xflow publish'));
204
+ (0, ui_1.note)((0, ui_1.dim)(' Visitors see these pages only after xflow publish'));
190
205
  }
191
206
  async function deployments() {
192
207
  const { root, config } = (0, config_1.requireProject)();
@@ -203,4 +218,6 @@ async function deployments() {
203
218
  (0, ui_1.formatAge)(d.deployed_at ?? d.created_at),
204
219
  [d.is_dev ? (0, ui_1.bold)('dev') : '', d.is_live ? (0, ui_1.bold)('live') : ''].filter(Boolean).join(' '),
205
220
  ]));
221
+ (0, ui_1.note)((0, ui_1.dim)(' Serve the pages of an earlier build: xflow rollback <number>'));
222
+ (0, ui_1.note)((0, ui_1.dim)(' Fetch the code of a build: xflow pull --revision <its revision> --into ../copy'));
206
223
  }
@@ -66,7 +66,7 @@ async function confirmForce(client, projectId, server, local) {
66
66
  (0, ui_1.warn)('Could not read the server copy: the list of disappearing files is unavailable');
67
67
  }
68
68
  (0, ui_1.note)('');
69
- const confirmed = await (0, ui_1.confirmWord)(`This overwrites work that is not yours, with no way to recover it from the platform.`, card.name);
69
+ const confirmed = await (0, ui_1.confirmWord)(`The server revision will be replaced by the contents of this folder. Anything newer on the platform goes away, and it cannot be recovered from here.`, card.name);
70
70
  if (!confirmed)
71
71
  throw new errors_1.CliError('Cancelled');
72
72
  }
@@ -175,9 +175,12 @@ async function status() {
175
175
  const client = await (0, session_1.connectProject)(root, config);
176
176
  const tree = prepareTree(root, config);
177
177
  const state = (0, config_1.readState)(root);
178
- const [card, server] = await Promise.all([
178
+ const [card, server, history] = await Promise.all([
179
179
  (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`),
180
180
  latestRevision(client, config.projectId),
181
+ // History only decorates the output, so its failure must not take the command
182
+ // down with it: without it the line below simply says less.
183
+ (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/deployments`).catch(() => ({ deployments: [] })),
181
184
  ]);
182
185
  (0, ui_1.out)(`${(0, ui_1.bold)(card.name)} ${(0, ui_1.dim)(card.id)}`);
183
186
  (0, ui_1.out)('');
@@ -200,9 +203,28 @@ async function status() {
200
203
  (0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
201
204
  }
202
205
  }
206
+ // Which revision the built version came from. Silent when there is nothing to say:
207
+ // the row fell out of the truncated history, it predates revisions, or a build is
208
+ // running right now and the answer would change in a minute.
209
+ const running = history.deployments.some((d) => ['pending', 'building', 'uploading'].includes(d.status));
210
+ const devRow = running ? null : (history.deployments.find((d) => d.is_dev) ?? null);
211
+ const builtFrom = devRow?.revision ?? null;
212
+ // The dev address holds a build that is not the newest one: the project was put back
213
+ // on earlier pages on purpose. Saying "the code is newer" here would name the wrong
214
+ // reason and send the developer to build what is already built.
215
+ const newest = history.deployments.find((d) => d.status === 'deployed');
216
+ const rolledBack = devRow?.status === 'deployed' && !!newest && newest.deploy_id !== devRow.deploy_id;
203
217
  (0, ui_1.out)('');
204
- (0, ui_1.out)(`Built version: ${card.dev_deploy_id ?? '(no build)'}`);
218
+ (0, ui_1.out)(`Built version: ${card.dev_deploy_id ?? '(no build)'}` +
219
+ (builtFrom !== null ? ` from revision ${builtFrom}` : ''));
205
220
  (0, ui_1.out)(`Visitors see: ${card.live_deploy_id ?? '(never published)'}`);
221
+ if (rolledBack) {
222
+ (0, ui_1.note)((0, ui_1.dim)(' The project serves the pages of an earlier build: a newer one is in the history'));
223
+ (0, ui_1.note)((0, ui_1.dim)(' Cloud functions and the database are not versioned and stay as they are'));
224
+ }
225
+ else if (builtFrom !== null && server && builtFrom < server.revision) {
226
+ (0, ui_1.note)((0, ui_1.dim)(` The built version is behind the sources: revision ${builtFrom} against ${server.revision} on the server`));
227
+ }
206
228
  // Two different problems, two different fixes: an expiring token is renewed by
207
229
  // any build, a revoked one is not renewed by anything until a human reconnects
208
230
  // the account. Telling them apart saves a pointless rebuild.
package/dist/help.js CHANGED
@@ -27,7 +27,7 @@ ${(0, ui_1.bold)('Sources')}
27
27
  ${(0, ui_1.bold)('Releasing')}
28
28
  xflow deploy [--no-push] send the code, ship the functions, build on the platform
29
29
  xflow publish show the dev version to visitors
30
- xflow rollback <version number> return the project to an earlier version
30
+ xflow rollback <version number> serve the pages of an earlier build
31
31
  xflow deployments version history
32
32
 
33
33
  ${(0, ui_1.bold)('Functions')}
@@ -396,11 +396,17 @@ is not a formality: your own palette on top of them looks foreign inside the pla
396
396
  The order: the project is created on the platform first, because without it there is
397
397
  nowhere to get the token for .env. If writing the files fails, the project stays empty
398
398
  and the CLI explains how to pick it up with the link command.`,
399
- rollback: `${(0, ui_1.bold)('xflow rollback')} <version number>: return an earlier build
399
+ rollback: `${(0, ui_1.bold)('xflow rollback')} <version number>: serve the pages of an earlier build
400
400
 
401
- Switches the project to the chosen build. Only the build comes back: the sources stay
402
- at their own revision, and returning them is a separate decision
403
- (${(0, ui_1.bold)('xflow pull --revision N --into ./old-version')}).
401
+ Points the project at the pages of that build. ${(0, ui_1.bold)('Only the pages come back.')} Cloud
402
+ functions and the database are one per project: they are not versioned, they are shared
403
+ with the published application, and they stay exactly as they are now. A page from August
404
+ will be talking to today's functions.
404
405
 
405
- Visitors keep seeing the published version until ${(0, ui_1.bold)('xflow publish')} is run.`,
406
+ The sources stay at their own revision too. Fetching the code of that build is a separate
407
+ command (${(0, ui_1.bold)('xflow pull --revision N --into ../old-version')}, keep the copy outside the
408
+ project folder). Building that code over the current one is a further, deliberate step: it
409
+ replaces the server revision, and whatever is newer on the platform goes away.
410
+
411
+ Visitors keep seeing the published pages until ${(0, ui_1.bold)('xflow publish')} is run.`,
406
412
  };
package/dist/ui.js CHANGED
@@ -119,7 +119,7 @@ function offerBrowser(url) {
119
119
  async function confirmWord(question, expected) {
120
120
  if (!process.stdin.isTTY) {
121
121
  fail('Confirmation is only possible in an interactive terminal');
122
- note((0, exports.dim)(' In CI this is the right behaviour: a version conflict has to fail the build rather than overwrite work that is not yours'));
122
+ note((0, exports.dim)(' In CI this is the right behaviour: a version conflict has to fail the build rather than overwrite what the platform holds'));
123
123
  return false;
124
124
  }
125
125
  const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stderr });
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.9.0';
5
+ exports.CLI_VERSION = '0.9.1';
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.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -14,7 +14,8 @@
14
14
  "skills"
15
15
  ],
16
16
  "scripts": {
17
- "build": "tsc -p tsconfig.json",
17
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
18
+ "build": "npm run clean && tsc -p tsconfig.json",
18
19
  "prepublishOnly": "npm run build"
19
20
  },
20
21
  "publishConfig": {
@@ -73,12 +73,23 @@ restored within an hour of the data going back under the limit.
73
73
  6. `xflow publish` makes that same version visible to visitors.
74
74
 
75
75
  The split is deliberate: shipping a build and showing it are two separate decisions.
76
- Until `publish` runs, visitors keep seeing the previous version. The one exception is
76
+ Until `publish` runs, visitors keep seeing the previous pages. The one exception is
77
77
  the very first version of a project: it publishes automatically, since there is no
78
78
  live version to protect yet.
79
79
 
80
- Rolling back: `xflow deployments` lists the version history, `xflow rollback <id>`
81
- points the project back at an earlier build. Sources stay on their own revision.
80
+ **Only pages are versioned.** Cloud functions and the database are one per project: they
81
+ are not versioned, and dev and live share them. So `xflow deploy` changes the running
82
+ application the moment it finishes, before any `publish`, and `publish` moves the pages
83
+ only. Tell the user this when a deploy touches `functions/`: there is no staging step for
84
+ server code.
85
+
86
+ Rolling back: `xflow deployments` lists the version history, `xflow rollback <id>` points
87
+ the project at the pages of an earlier build. Only the pages come back — those pages then
88
+ talk to today's functions. The sources stay on their own revision; fetching the code of
89
+ that build is a separate command (`xflow pull --revision N --into ../old-version`, keep the
90
+ copy outside the project folder). Building that code over the current one is a further,
91
+ deliberate step: it replaces the server revision, needs `--force`, and asks a human to type
92
+ the project name, so you cannot do it on your own.
82
93
 
83
94
  The platform keeps the **last 30 successful builds**. Older ones are deleted, files and
84
95
  history row alike, and rolling back to them stops working. That is rollback depth, not a
@@ -1,34 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const strict_1 = __importDefault(require("node:assert/strict"));
7
- const node_test_1 = require("node:test");
8
- const update_1 = require("./update");
9
- /**
10
- * The price of a mistake here is npm running over a pnpm or bun installation, which
11
- * leaves two copies and a user who cannot tell which one answers. Path shapes only,
12
- * the rest of the command is a run, not a rule.
13
- */
14
- (0, node_test_1.test)('a global npm prefix is an ordinary install', () => {
15
- strict_1.default.equal((0, update_1.classifyPath)('C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@getxflow\\cli\\dist'), 'node_modules');
16
- strict_1.default.equal((0, update_1.classifyPath)('/usr/local/lib/node_modules/@getxflow/cli/dist'), 'node_modules');
17
- strict_1.default.equal((0, update_1.classifyPath)('/home/me/.npm-global/lib/node_modules/@getxflow/cli/dist'), 'node_modules');
18
- });
19
- (0, node_test_1.test)('npm as a path segment is not pnpm', () => {
20
- strict_1.default.notEqual((0, update_1.classifyPath)('C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@getxflow\\cli\\dist'), 'pnpm');
21
- });
22
- (0, node_test_1.test)('other package managers are recognized by their own folders', () => {
23
- strict_1.default.equal((0, update_1.classifyPath)('/home/me/.local/share/pnpm/global/5/node_modules/@getxflow/cli/dist'), 'pnpm');
24
- strict_1.default.equal((0, update_1.classifyPath)('/home/me/project/node_modules/.pnpm/@getxflow+cli@0.6.0/node_modules/@getxflow/cli/dist'), 'pnpm');
25
- strict_1.default.equal((0, update_1.classifyPath)('/home/me/.bun/install/global/node_modules/@getxflow/cli/dist'), 'bun');
26
- strict_1.default.equal((0, update_1.classifyPath)('/home/me/project/.yarn/cache/@getxflow-cli/dist'), 'yarn');
27
- });
28
- (0, node_test_1.test)('an npx run is a cached copy, not an installation', () => {
29
- strict_1.default.equal((0, update_1.classifyPath)('/home/me/.npm/_npx/8f1a2b/node_modules/@getxflow/cli/dist'), 'npx');
30
- });
31
- (0, node_test_1.test)('without node_modules the code runs from a checkout', () => {
32
- strict_1.default.equal((0, update_1.classifyPath)('C:\\cursor\\xflow 2.0\\cli\\dist'), 'checkout');
33
- strict_1.default.equal((0, update_1.classifyPath)('/home/me/xflow/cli/src'), 'checkout');
34
- });
@@ -1,25 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const node_test_1 = require("node:test");
7
- const strict_1 = __importDefault(require("node:assert/strict"));
8
- const limits_1 = require("./limits");
9
- (0, node_test_1.test)('a counted limit reads as a tally', () => {
10
- const line = (0, limits_1.limitLine)({ code: 'limit_functions', used: 29, limit: 29 });
11
- strict_1.default.equal(line, 'Plan limit: cloud functions, 29 of 29 used. Repeating the same command will not help.');
12
- });
13
- (0, node_test_1.test)('an interval reads as a frequency, not as a tally', () => {
14
- const line = (0, limits_1.limitLine)({ code: 'limit_schedule_interval', used: 5, limit: 60 });
15
- strict_1.default.match(line, /every 5 min against a 60 min minimum/);
16
- strict_1.default.doesNotMatch(line, /used/);
17
- });
18
- (0, node_test_1.test)('a refusal without numbers stays a plain sentence', () => {
19
- const line = (0, limits_1.limitLine)({ code: 'plan_no_schedules' });
20
- strict_1.default.equal(line, 'Plan limit: schedules. Repeating the same command will not help.');
21
- });
22
- (0, node_test_1.test)('an unknown code falls back to the code itself', () => {
23
- const line = (0, limits_1.limitLine)({ code: 'limit_unheard_of' });
24
- strict_1.default.match(line, /Plan limit: limit_unheard_of\./);
25
- });