@getxflow/cli 0.23.0 → 0.25.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.
@@ -92,13 +92,10 @@ function logout(args) {
92
92
  return;
93
93
  }
94
94
  const orgs = (0, credentials_1.listOrgs)(apiUrl);
95
+ // The pointer always lands on a stored key, so no active one means no keys.
95
96
  const active = orgs.find((org) => org.active);
96
97
  if (!active) {
97
- // A record from an older CLI names no organization: forget the whole address.
98
- if ((0, credentials_1.forgetAllOrgs)(apiUrl))
99
- (0, ui_1.ok)(`The key for ${apiUrl} is deleted`);
100
- else
101
- (0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
98
+ (0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
102
99
  return;
103
100
  }
104
101
  const { nextActive } = (0, credentials_1.forgetOrg)(apiUrl, active.organizationId);
@@ -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
  }
@@ -18,13 +18,7 @@ function orgList() {
18
18
  const apiUrl = (0, config_1.apiUrlFor)((0, config_1.localConfig)());
19
19
  const orgs = (0, credentials_1.listOrgs)(apiUrl);
20
20
  if (orgs.length === 0) {
21
- if ((0, credentials_1.storedKeyInputs)(apiUrl).legacyToken) {
22
- (0, ui_1.note)(`One key is stored for ${apiUrl}, its organization is unknown`);
23
- (0, ui_1.note)((0, ui_1.dim)(' Sign in again to refresh it: xflow login'));
24
- }
25
- else {
26
- (0, ui_1.note)(`No keys for ${apiUrl}. Sign in: xflow login`);
27
- }
21
+ (0, ui_1.note)(`No keys for ${apiUrl}. Sign in: xflow login`);
28
22
  return;
29
23
  }
30
24
  (0, ui_1.table)(orgs.map((org) => [org.active ? '*' : ' ', org.name ?? '(unnamed)', org.organizationId]));
@@ -62,9 +56,6 @@ function orgSwitch(args) {
62
56
  const apiUrl = (0, config_1.apiUrlFor)((0, config_1.localConfig)());
63
57
  const orgs = (0, credentials_1.listOrgs)(apiUrl);
64
58
  if (orgs.length === 0) {
65
- if ((0, credentials_1.storedKeyInputs)(apiUrl).legacyToken) {
66
- throw new errors_1.CliError(`The stored key for ${apiUrl} names no organization`, 'Sign in again to refresh it: xflow login');
67
- }
68
59
  throw new errors_1.CliError(`No keys for ${apiUrl}`, 'Sign in: xflow login');
69
60
  }
70
61
  const byId = orgs.find((org) => org.organizationId === wanted);
@@ -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) {
@@ -47,56 +47,38 @@ function writeStore(store) {
47
47
  function isLegacy(value) {
48
48
  return !!value && typeof value.token === 'string';
49
49
  }
50
- /** The mirror for older CLIs: the key of the active organization, or nothing. */
51
- function syncLegacy(store, apiUrl) {
52
- const address = store.orgs?.[apiUrl];
53
- if (!address)
54
- return;
55
- const active = address.active ? address.keys[address.active] : undefined;
56
- if (active && address.active) {
57
- store[apiUrl] = { token: active.token, organizationId: address.active, savedAt: active.savedAt };
58
- address.mirror = address.active;
59
- }
60
- else {
61
- delete store[apiUrl];
62
- address.mirror = null;
63
- }
64
- }
65
50
  /**
66
- * Fold writes made by an older CLI back into the map. Its login rewrites only
67
- * the mirror record, its logout deletes only it; both are treated as what the
68
- * person meant: "sign in to this organization" and "forget the key I am using".
51
+ * Fold a record predating the map into it and erase the record. A record naming
52
+ * no organization cannot be placed in the map and is dropped: the answer is one
53
+ * xflow login, while leaving it would leave the key where the old proxy looks.
69
54
  * Returns true when the store changed and has to be written back.
70
55
  */
71
56
  function reconcile(store, apiUrl) {
72
57
  const legacy = store[apiUrl];
73
- let address = store.orgs?.[apiUrl];
74
58
  let changed = false;
75
- if (isLegacy(legacy) && legacy.organizationId) {
76
- const orgs = address ?? { active: null, keys: {} };
77
- const known = orgs.keys[legacy.organizationId];
78
- if (!known || known.token !== legacy.token || orgs.active !== legacy.organizationId) {
79
- orgs.keys[legacy.organizationId] = { ...known, token: legacy.token, savedAt: legacy.savedAt };
80
- orgs.active = legacy.organizationId;
59
+ if (isLegacy(legacy)) {
60
+ if (legacy.organizationId) {
61
+ const orgs = store.orgs?.[apiUrl] ?? { active: null, keys: {} };
62
+ // The map wins: it is what this CLI wrote last.
63
+ if (!orgs.keys[legacy.organizationId]) {
64
+ orgs.keys[legacy.organizationId] = { token: legacy.token, savedAt: legacy.savedAt };
65
+ }
81
66
  store.orgs = store.orgs ?? {};
82
67
  store.orgs[apiUrl] = orgs;
83
- address = orgs;
84
- changed = true;
85
- }
86
- }
87
- if (!isLegacy(legacy) && address?.active && address.keys[address.active]) {
88
- if (address.mirror === address.active) {
89
- // The mirror we wrote for this key is gone: an older CLI logged out.
90
- delete address.keys[address.active];
91
- address.active = null;
92
68
  }
93
- // Otherwise no mirror was ever written for this key (a hand-made or
94
- // restored file): deleting it would lose a key nobody asked to forget.
95
- // Either way the store changed: the mirror has to be rebuilt.
69
+ delete store[apiUrl];
96
70
  changed = true;
97
71
  }
98
- // The pointer must land on a stored key, or on nothing.
72
+ const address = store.orgs?.[apiUrl];
99
73
  if (address) {
74
+ // The field the erased record was tracked by. Left in place, a 0.24.0 still on
75
+ // the machine reads "the mirror I wrote is gone" as a logout and drops the key.
76
+ const stale = address;
77
+ if ('mirror' in stale) {
78
+ delete stale.mirror;
79
+ changed = true;
80
+ }
81
+ // The pointer must land on a stored key, or on nothing.
100
82
  const ids = Object.keys(address.keys);
101
83
  const active = address.active && address.keys[address.active] ? address.active : (ids[0] ?? null);
102
84
  if (address.active !== active) {
@@ -104,8 +86,6 @@ function reconcile(store, apiUrl) {
104
86
  changed = true;
105
87
  }
106
88
  }
107
- if (changed)
108
- syncLegacy(store, apiUrl);
109
89
  return changed;
110
90
  }
111
91
  /** Read the store with the drift of older CLIs healed, persisting the healing. */
@@ -116,7 +96,6 @@ function load(apiUrl) {
116
96
  return store;
117
97
  }
118
98
  function commit(store, apiUrl) {
119
- syncLegacy(store, apiUrl);
120
99
  const address = store.orgs?.[apiUrl];
121
100
  if (address && Object.keys(address.keys).length === 0) {
122
101
  delete store.orgs?.[apiUrl];
@@ -158,32 +137,38 @@ function saveOrgCredential(apiUrl, organizationId, token, name) {
158
137
  /** Cache the display name; a stale one is refreshed by any login or whoami. */
159
138
  function rememberOrgName(apiUrl, organizationId, name) {
160
139
  const store = readStore();
161
- reconcile(store, apiUrl);
140
+ let changed = reconcile(store, apiUrl);
162
141
  const entry = store.orgs?.[apiUrl]?.keys[organizationId];
163
- if (!entry || entry.name === name)
164
- return;
165
- entry.name = name;
166
- commit(store, apiUrl);
142
+ if (entry && entry.name !== name) {
143
+ entry.name = name;
144
+ changed = true;
145
+ }
146
+ if (changed)
147
+ commit(store, apiUrl);
167
148
  }
168
149
  function setActiveOrg(apiUrl, organizationId) {
169
150
  const store = readStore();
170
- reconcile(store, apiUrl);
151
+ let changed = reconcile(store, apiUrl);
171
152
  const orgs = store.orgs?.[apiUrl];
172
- if (!orgs?.keys[organizationId])
173
- return false;
174
- if (orgs.active !== organizationId) {
153
+ const known = !!orgs?.keys[organizationId];
154
+ if (orgs && known && orgs.active !== organizationId) {
175
155
  orgs.active = organizationId;
176
- commit(store, apiUrl);
156
+ changed = true;
177
157
  }
178
- return true;
158
+ if (changed)
159
+ commit(store, apiUrl);
160
+ return known;
179
161
  }
180
162
  /** Forget one organization; the pointer moves to any remaining one. */
181
163
  function forgetOrg(apiUrl, organizationId) {
182
164
  const store = readStore();
183
- reconcile(store, apiUrl);
165
+ const healed = reconcile(store, apiUrl);
184
166
  const orgs = store.orgs?.[apiUrl];
185
- if (!orgs?.keys[organizationId])
167
+ if (!orgs?.keys[organizationId]) {
168
+ if (healed)
169
+ commit(store, apiUrl);
186
170
  return { removed: false, nextActive: null };
171
+ }
187
172
  delete orgs.keys[organizationId];
188
173
  if (orgs.active === organizationId)
189
174
  orgs.active = Object.keys(orgs.keys)[0] ?? null;
@@ -199,8 +184,9 @@ function forgetOrg(apiUrl, organizationId) {
199
184
  /** Forget every key of the address; other addresses are untouched. */
200
185
  function forgetAllOrgs(apiUrl) {
201
186
  const store = readStore();
202
- reconcile(store, apiUrl);
203
- const had = !!store.orgs?.[apiUrl] || isLegacy(store[apiUrl]);
187
+ // A record erased by reconcile was a key as well: counted here, or the address
188
+ // looks empty and the erasure never reaches the file.
189
+ const had = reconcile(store, apiUrl) || !!store.orgs?.[apiUrl];
204
190
  if (!had)
205
191
  return false;
206
192
  if (store.orgs) {
@@ -236,16 +222,9 @@ function pickKey(inputs) {
236
222
  const active = activeId ? inputs.orgs?.keys[activeId] : undefined;
237
223
  if (active && activeId)
238
224
  return { token: active.token, source: 'active', organizationId: activeId };
239
- if (inputs.legacyToken)
240
- return { token: inputs.legacyToken, source: 'active', organizationId: null };
241
225
  return null;
242
226
  }
243
227
  /** The store-backed half of pickKey's inputs; the caller adds env and the folder binding. */
244
228
  function storedKeyInputs(apiUrl) {
245
- const store = load(apiUrl);
246
- const legacy = store[apiUrl];
247
- return {
248
- orgs: store.orgs?.[apiUrl] ?? null,
249
- legacyToken: isLegacy(legacy) ? legacy.token : null,
250
- };
229
+ return { orgs: load(apiUrl).orgs?.[apiUrl] ?? null };
251
230
  }
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,20 +541,26 @@ 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
545
548
  in, the right one is found and the folder is bound to it.
546
549
 
547
- Use it instead of a bare ${(0, ui_1.bold)('npm run dev')}, which gets no key from here: its function
548
- calls fail, or go out under the key of whichever organization is active.
550
+ Use it instead of a bare ${(0, ui_1.bold)('npm run dev')}: that one is handed no key, so its calls to
551
+ cloud functions fail. In a project made before this command the failure reads "No access
552
+ key for the platform: run xflow login", and signing in will not help, because the key is
553
+ no longer left in a file for that dev server to pick up.
549
554
 
550
555
  The platform address comes from the CLI too, not from .env, so the key goes to the
551
556
  platform that issued it and nowhere else. It never reaches the browser: the page gets
552
557
  only the ${(0, ui_1.bold)('VITE_')} variables. The project token and the function addresses are
553
558
  fetched on every start as well: a function someone else shipped answers here without a
554
- deploy of yours.`,
559
+ deploy of yours.
560
+
561
+ The server listens on 127.0.0.1 and nowhere else, because it holds that key: on a
562
+ shared network, anything else would let a neighbour call the functions of the project
563
+ as you. A phone on the same Wi-Fi therefore cannot open it either.`,
555
564
  pull: `${(0, ui_1.bold)('xflow pull')}: fetch the sources
556
565
 
557
566
  By default it fetches the latest revision into the project folder and refuses to write
package/dist/session.js CHANGED
@@ -84,7 +84,7 @@ async function findProjectOrg(apiUrl, projectId) {
84
84
  */
85
85
  async function connectProject(projectId, root, config) {
86
86
  const session = connect(config, { ignoreFolder: root === null });
87
- if (session.source !== 'active' || session.organizationId === null)
87
+ if (session.source !== 'active')
88
88
  return session;
89
89
  const orgs = (0, credentials_1.listOrgs)(session.apiUrl);
90
90
  if (orgs.length <= 1)
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/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.23.0';
5
+ exports.CLI_VERSION = '0.25.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.23.0",
3
+ "version": "0.25.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.23.0, so the copy you are reading can be older than
25
+ These instructions ship with xflow CLI 0.25.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
@@ -602,7 +602,10 @@ own devtools.
602
602
 
603
603
  Run the app locally with `xflow dev`, not a bare `npm run dev`: it hands the dev server
604
604
  the key of the project's organization, which cloud functions need to answer, and their
605
- current addresses, including functions someone else deployed.
605
+ current addresses, including functions someone else deployed. A bare `npm run dev` gets
606
+ no key at all, so its function calls fail whatever the error says. The server answers
607
+ `127.0.0.1` alone, because it holds that key: a phone on the same network cannot open it,
608
+ and neither can anybody else on that network.
606
609
 
607
610
  ## Environment
608
611