@getxflow/cli 0.22.0 → 0.24.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.
@@ -26,7 +26,9 @@ function reportIssues(e) {
26
26
  (0, ui_1.out)(` ${(0, ui_1.bold)(issue.code)} ${where}`);
27
27
  (0, ui_1.note)((0, ui_1.dim)(` ${issue.message}`));
28
28
  }
29
- throw new errors_1.CliError(e.message, e.hint);
29
+ // Not the platform hint: it names its own JSON field, and a terminal has no field,
30
+ // it has the list printed right above.
31
+ throw new errors_1.CliError(e.message, 'Fix what is listed above and retry: the build did not start');
30
32
  }
31
33
  /** Poll the build until it finishes, printing phases. */
32
34
  async function waitForBuild(client, projectId, started) {
@@ -38,7 +38,14 @@ async function dev() {
38
38
  // Ctrl+C reaches the server on its own; staying alive until it is gone is what
39
39
  // hands its exit code back instead of leaving it to print over the prompt.
40
40
  process.on('SIGINT', () => { });
41
- const result = (0, spawn_1.run)('npm', ['run', 'dev'], { stdio: 'inherit', cwd: root, env });
41
+ // Appended last, so it beats the --host of the dev script: the server holds the key
42
+ // of the project and answers this machine alone. By number, not by name: proxies of
43
+ // older templates cut the Host at ':' and so never match [::1].
44
+ const result = (0, spawn_1.run)('npm', ['run', 'dev', '--', '--host', '127.0.0.1'], {
45
+ stdio: 'inherit',
46
+ cwd: root,
47
+ env,
48
+ });
42
49
  if (result.error) {
43
50
  throw new errors_1.CliError(`Could not start npm run dev: ${result.error.message}`, 'npm ships with Node.js: check that npm works in this terminal');
44
51
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.functionsList = functionsList;
4
+ exports.failureHint = failureHint;
4
5
  exports.functionsInvoke = functionsInvoke;
5
6
  const api_1 = require("../api");
6
7
  const args_1 = require("../args");
@@ -38,6 +39,37 @@ function prettyBody(text) {
38
39
  return text;
39
40
  }
40
41
  }
42
+ /**
43
+ * Refusals the wrapper writes itself, before the handler runs. Nothing about them
44
+ * reaches the logs, and the answer already names the cause.
45
+ *
46
+ * Matched by value and not by the presence of the field, because a handler is free to
47
+ * put a reason of its own into the body it returns, and that one did run.
48
+ *
49
+ * Keep in sync with lib/yandex-functions/call-outcome.ts, which the MCP tool calls:
50
+ * this package ships without dependencies and cannot reach into the platform. One test
51
+ * holds the two halves together.
52
+ */
53
+ const WRAPPER_REASONS = ['no_pass', 'invalid', 'expired', 'no_access', 'unavailable', 'role'];
54
+ /** What to look at next, when the function did not answer with a success. */
55
+ function failureHint(name, status, body) {
56
+ let reason;
57
+ try {
58
+ reason = JSON.parse(body).reason;
59
+ }
60
+ catch {
61
+ reason = undefined;
62
+ }
63
+ const logs = `The stack and console output: xflow functions logs ${name}`;
64
+ if (typeof reason !== 'string' || !WRAPPER_REASONS.includes(reason))
65
+ return logs;
66
+ if (reason !== 'role' || status < 500)
67
+ return undefined;
68
+ // Two different answers wear this one: a threshold the platform cannot read, refused
69
+ // before the handler and so absent from the logs, and a handler that answered 5xx with
70
+ // a reason of that name itself, which is logged. Emptiness there tells them apart.
71
+ return `${logs}. Nothing there means the declared minRole is the problem: fix functions/${name}/index.ts`;
72
+ }
41
73
  /** Call a function the same way the application does. */
42
74
  async function functionsInvoke(args) {
43
75
  const { projectId, client } = await (0, session_1.projectTarget)(args);
@@ -95,6 +127,6 @@ async function functionsInvoke(args) {
95
127
  if (text)
96
128
  (0, ui_1.out)((0, args_1.flagBool)(args, 'json') ? text : prettyBody(text));
97
129
  if (!response.ok) {
98
- throw new errors_1.CliError(`The function answered ${response.status}`, `The stack and console output: xflow functions logs ${name}`);
130
+ throw new errors_1.CliError(`The function answered ${response.status}`, failureHint(name, response.status, text));
99
131
  }
100
132
  }
@@ -8,6 +8,13 @@ const errors_1 = require("../errors");
8
8
  const json_arg_1 = require("../json-arg");
9
9
  const session_1 = require("../session");
10
10
  const ui_1 = require("../ui");
11
+ /**
12
+ * The payload is kept as it was written, and `--payload-file` takes a file a person
13
+ * formatted: its newlines would break a row of a table into several.
14
+ */
15
+ function oneLine(payload) {
16
+ return (payload ?? '').replace(/\s+/g, ' ').trim();
17
+ }
11
18
  async function schedulesList(args) {
12
19
  const { projectId, client } = await (0, session_1.projectTarget)(args);
13
20
  const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/schedules`);
@@ -16,12 +23,18 @@ async function schedulesList(args) {
16
23
  (0, ui_1.note)((0, ui_1.dim)(' To run a function on a timer: xflow schedules set <function> "0 3 ? * * *"'));
17
24
  return;
18
25
  }
19
- (0, ui_1.table)(data.schedules.map((row) => [
20
- row.function,
21
- row.cron,
22
- row.description === row.cron ? '' : row.description,
23
- row.status === 'active' ? '' : (row.error ?? row.status),
24
- ]));
26
+ (0, ui_1.table)(data.schedules.map((row) => {
27
+ // The body the timer sends. Nothing else printed it, so a schedule said when a
28
+ // function runs and never what it gets.
29
+ const payload = oneLine(row.payload);
30
+ return [
31
+ row.function,
32
+ row.cron,
33
+ row.description === row.cron ? '' : row.description,
34
+ payload.length > 40 ? `${payload.slice(0, 39)}…` : payload,
35
+ row.status === 'active' ? '' : (row.error ?? row.status),
36
+ ];
37
+ }));
25
38
  }
26
39
  async function schedulesSet(args) {
27
40
  const functionName = args.words[1];
@@ -36,6 +49,8 @@ async function schedulesSet(args) {
36
49
  body: { function: functionName, cron, payload },
37
50
  });
38
51
  (0, ui_1.ok)(`${(0, ui_1.bold)(row.function)} runs ${row.description}`);
52
+ if (row.payload)
53
+ (0, ui_1.note)((0, ui_1.dim)(` The timer sends: ${oneLine(row.payload)}`));
39
54
  (0, ui_1.note)((0, ui_1.dim)(` The time is UTC. To check by hand: xflow functions invoke ${row.function}`));
40
55
  }
41
56
  async function schedulesRemove(args) {
@@ -75,21 +75,27 @@ async function pushSources(root, config, client, options) {
75
75
  const tree = prepareTree(root, config);
76
76
  const state = (0, config_1.readState)(root);
77
77
  const server = await latestRevision(client, config.projectId);
78
- if (server && server.tree_hash === tree.hash) {
79
- (0, config_1.writeState)(root, {
80
- treeHash: server.tree_hash,
81
- organizationId: client.organizationId ?? undefined,
82
- });
83
- (0, ui_1.ok)(`No changes, revision ${server.revision}`);
84
- return { revision: server.revision, status: 'unchanged' };
85
- }
86
- // Asked before the upload, so a doomed archive never leaves the machine.
87
- if (server && server.tree_hash !== state.treeHash && !options.force) {
88
- throw new errors_1.CliError(`The server holds revision ${server.revision}, and this folder does not stand on it`, 'Someone deployed before you, or this folder worked on a different project. ' +
89
- 'Fetch the server copy alongside and compare: xflow pull --into ./server-copy');
90
- }
91
- if (options.force && server && server.tree_hash !== tree.hash) {
92
- await confirmForce(client, config.projectId, server, tree.files);
78
+ if (server) {
79
+ const relation = (0, tree_1.syncState)(tree.hash, server.tree_hash, state.treeHash);
80
+ if (relation === 'matches') {
81
+ (0, config_1.writeState)(root, {
82
+ treeHash: server.tree_hash,
83
+ organizationId: client.organizationId ?? undefined,
84
+ });
85
+ (0, ui_1.ok)(`No changes, revision ${server.revision}`);
86
+ return { revision: server.revision, status: 'unchanged' };
87
+ }
88
+ // Asked before the upload, so a doomed archive never leaves the machine.
89
+ if (relation === 'diverged' && !options.force) {
90
+ throw new errors_1.CliError(`The server holds revision ${server.revision}, and this folder does not stand on it`, 'Someone deployed before you, or this folder worked on a different project. ' +
91
+ 'Fetch the server copy alongside and compare: xflow pull --into ./server-copy');
92
+ }
93
+ if (options.force) {
94
+ await confirmForce(client, config.projectId, server, tree.files);
95
+ }
96
+ else if (relation === 'unrecorded') {
97
+ (0, ui_1.warn)(`This folder has no record of the server revision: the code goes up on top of revision ${server.revision}`);
98
+ }
93
99
  }
94
100
  (0, ui_1.step)(`Sending ${tree.files.length} files (${(0, ui_1.formatBytes)(tree.archive.length)})`);
95
101
  const headers = {};
@@ -161,13 +167,10 @@ async function pull(args) {
161
167
  'xflow pull --into ./server-copy, or overwrite the folder completely: xflow pull --force');
162
168
  }
163
169
  const { info, files } = await downloadRevision(client, config.projectId, target, (0, args_1.flagNumber)(args, 'revision'));
164
- // Only the working folder tracks revisions; a side copy (--into) must not reset them.
165
- if (!into) {
166
- (0, config_1.writeState)(root, {
167
- treeHash: info.tree_hash,
168
- organizationId: client.organizationId ?? undefined,
169
- });
170
- }
170
+ (0, config_1.writeState)(target, {
171
+ treeHash: info.tree_hash,
172
+ organizationId: client.organizationId ?? undefined,
173
+ });
171
174
  (0, ui_1.ok)(`Revision ${info.revision}: ${files} files in ${target}`);
172
175
  }
173
176
  async function status() {
@@ -191,13 +194,18 @@ async function status() {
191
194
  }
192
195
  else {
193
196
  (0, ui_1.out)(`On the server: revision ${server.revision}, ${server.file_count} files, ${(0, ui_1.formatAge)(server.created_at)}`);
194
- if (server.tree_hash === tree.hash) {
197
+ const relation = (0, tree_1.syncState)(tree.hash, server.tree_hash, state.treeHash);
198
+ if (relation === 'matches') {
195
199
  (0, ui_1.out)('State: matches the server');
196
200
  }
197
- else if (server.tree_hash !== state.treeHash) {
201
+ else if (relation === 'diverged') {
198
202
  (0, ui_1.out)(`State: ${(0, ui_1.bold)('diverged')}, the server holds a version this folder has not seen`);
199
203
  (0, ui_1.note)((0, ui_1.dim)(' Fetch the server copy alongside: xflow pull --into ./server-copy'));
200
204
  }
205
+ else if (relation === 'unrecorded') {
206
+ (0, ui_1.out)(`State: ${(0, ui_1.bold)('differs')}, this folder has no record of the server revision`);
207
+ (0, ui_1.note)((0, ui_1.dim)(` xflow deploy sends this code on top of revision ${server.revision}; to compare first: xflow pull --into ./server-copy`));
208
+ }
201
209
  else {
202
210
  (0, ui_1.out)(`State: ${(0, ui_1.bold)('local changes')}`);
203
211
  (0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
package/dist/help.js CHANGED
@@ -40,7 +40,7 @@ ${(0, ui_1.bold)('Getting started')}
40
40
  xflow init [dir] new project from the platform template
41
41
  xflow templates which templates are available
42
42
  xflow link <id> link this folder to a project, an empty one also gets the sources
43
- xflow dev run the app on localhost:3000, cloud functions included
43
+ xflow dev run the app on 127.0.0.1:3000, cloud functions included
44
44
  xflow skills pick the agents that get the platform instructions
45
45
  xflow mcp install give the agent platform access without a terminal
46
46
 
@@ -465,6 +465,9 @@ how Yandex works. ${(0, ui_1.bold)('The time is always UTC')}, local time is not
465
465
  --payload '{"mode":"full"}' the body the function will receive
466
466
  --payload-file body.json the same body from a file
467
467
 
468
+ ${(0, ui_1.bold)('list')} prints that body next to the schedule, so what a timer sends can be read
469
+ back without setting it again.
470
+
468
471
  A scheduled run arrives at the handler as a POST with no headers, and the project
469
472
  token is not checked on it: such an event cannot be forged from outside. The function
470
473
  has to be deployed already, since the schedule points at it and not the other way round.
@@ -538,7 +541,7 @@ is always read by the agent, which is why the pointer is appended there, at the
538
541
  the file and once. ${(0, ui_1.bold)('CLAUDE.md')} is left alone: Claude Code reads .claude/skills anyway.`,
539
542
  dev: `${(0, ui_1.bold)('xflow dev')}: run the application on this machine
540
543
 
541
- Runs ${(0, ui_1.bold)('npm run dev')} in the project folder (localhost:3000) with the access key of the
544
+ Runs ${(0, ui_1.bold)('npm run dev')} in the project folder (127.0.0.1:3000) with the access key of the
542
545
  organization that holds the project. The dev server trades that key for a visitor pass,
543
546
  so the cloud functions answer here the way they do on the platform. The key is picked
544
547
  as for every other command (${(0, ui_1.bold)('xflow help org')}): with several organizations signed
@@ -551,7 +554,11 @@ The platform address comes from the CLI too, not from .env, so the key goes to t
551
554
  platform that issued it and nowhere else. It never reaches the browser: the page gets
552
555
  only the ${(0, ui_1.bold)('VITE_')} variables. The project token and the function addresses are
553
556
  fetched on every start as well: a function someone else shipped answers here without a
554
- deploy of yours.`,
557
+ deploy of yours.
558
+
559
+ The server listens on 127.0.0.1 and nowhere else, because it holds that key: on a
560
+ shared network, anything else would let a neighbour call the functions of the project
561
+ as you. A phone on the same Wi-Fi therefore cannot open it either.`,
555
562
  pull: `${(0, ui_1.bold)('xflow pull')}: fetch the sources
556
563
 
557
564
  By default it fetches the latest revision into the project folder and refuses to write
@@ -580,7 +587,9 @@ ignore field of xflow.json.
580
587
  If the server holds a revision newer than the one you worked from, the sources are
581
588
  rejected and the build does not start. That means somebody deployed before you: fetch
582
589
  their changes alongside (${(0, ui_1.bold)('xflow pull --into ./server-copy')}), merge them in git on
583
- your side, and retry. ${(0, ui_1.bold)('--force')} overwrites the server revision, and before that the
590
+ your side, and retry. A folder with no record of the server revision (a fresh clone, a CI
591
+ checkout) is not rejected: its first deploy goes on top of the current revision and says
592
+ so, the check applies from the next one. ${(0, ui_1.bold)('--force')} overwrites the server revision, and before that the
584
593
  CLI shows whose work you are about to destroy and asks for confirmation by typing the
585
594
  project name. In a non-interactive run (CI, an agent) there is no way to confirm: a
586
595
  version conflict has to fail the build rather than silently destroy somebody else's work.
package/dist/template.js CHANGED
@@ -18,7 +18,7 @@ An application on XFlow.
18
18
 
19
19
  \`\`\`bash
20
20
  npm install # dependencies
21
- xflow dev # develop on localhost:3000
21
+ xflow dev # develop on 127.0.0.1:3000
22
22
  xflow deploy # send the code and build it on the platform
23
23
  xflow publish # show the dev version to visitors
24
24
  \`\`\`
package/dist/tree.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isSkillCopy = isSkillCopy;
4
4
  exports.loadIgnoreRules = loadIgnoreRules;
5
5
  exports.collectFiles = collectFiles;
6
+ exports.syncState = syncState;
6
7
  exports.treeHash = treeHash;
7
8
  exports.heaviest = heaviest;
8
9
  const node_crypto_1 = require("node:crypto");
@@ -130,6 +131,17 @@ function collectFiles(root, rules) {
130
131
  files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
131
132
  return { files, skippedLinks };
132
133
  }
134
+ /**
135
+ * How the folder stands against the server tree. A folder with no record (a fresh
136
+ * clone, a CI checkout) has not diverged: nothing is known about what it started from.
137
+ */
138
+ function syncState(local, server, remembered) {
139
+ if (local === server)
140
+ return 'matches';
141
+ if (remembered === undefined)
142
+ return 'unrecorded';
143
+ return remembered === server ? 'local-changes' : 'diverged';
144
+ }
133
145
  /** Computed identically on the server. */
134
146
  function treeHash(files) {
135
147
  const parts = files.map((file) => `${file.path}\0${(0, node_crypto_1.createHash)('sha256').update(file.content).digest('hex')}`);
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.22.0';
5
+ exports.CLI_VERSION = '0.24.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.22.0",
3
+ "version": "0.24.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.22.0, so the copy you are reading can be older than
25
+ These instructions ship with xflow CLI 0.24.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
@@ -108,7 +108,9 @@ one exception is the very first version of a project, which publishes automatica
108
108
  `xflow status` says how the local copy differs from the server revision. A deploy rejected
109
109
  before the build starts means the server holds a version this folder has never seen: somebody
110
110
  deployed first, or the folder was linked elsewhere. Fetch it next to yours (`xflow pull --into
111
- ./server-copy`), merge in git, deploy again. `--force` destroys that work.
111
+ ./server-copy`), merge in git, deploy again. `--force` destroys that work. A folder with no
112
+ record of the server revision (a fresh clone, a CI checkout) is not rejected: its first deploy
113
+ goes on top of the current revision and says so, the check applies from the next one.
112
114
 
113
115
  **Only pages are versioned.** Cloud functions and the database are one per project: they
114
116
  are not versioned, and dev and live share them. So `xflow deploy` changes the running
@@ -600,7 +602,9 @@ own devtools.
600
602
 
601
603
  Run the app locally with `xflow dev`, not a bare `npm run dev`: it hands the dev server
602
604
  the key of the project's organization, which cloud functions need to answer, and their
603
- current addresses, including functions someone else deployed.
605
+ current addresses, including functions someone else deployed. The server answers
606
+ `127.0.0.1` alone, because it holds that key: a phone on the same network cannot open it,
607
+ and neither can anybody else on that network.
604
608
 
605
609
  ## Environment
606
610