@getxflow/cli 0.6.6 → 0.8.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
@@ -18,7 +18,7 @@ else's code belongs next to it.
18
18
  |---|---|
19
19
  | `login` / `logout` / `whoami` | sign in through the browser, sign out, whose key this is and what it can do |
20
20
  | `init` / `link` | new project, link a folder to an existing one |
21
- | `status` / `push` / `pull` | state, sending and fetching sources |
21
+ | `status` / `pull` | state of the sources on the server and fetching them back |
22
22
  | `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
23
23
  | `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
24
24
  | `db schema` / `db query` | tables and columns, reading data in a read-only transaction |
package/dist/bin.js CHANGED
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const api_1 = require("./api");
5
5
  const args_1 = require("./args");
6
6
  const config_1 = require("./config");
7
+ const credentials_1 = require("./credentials");
7
8
  const errors_1 = require("./errors");
8
9
  const help_1 = require("./help");
9
10
  const limits_1 = require("./limits");
@@ -19,6 +20,7 @@ const env_1 = require("./commands/env");
19
20
  const functions_1 = require("./commands/functions");
20
21
  const logs_1 = require("./commands/logs");
21
22
  const mcp_1 = require("./commands/mcp");
23
+ const org_1 = require("./commands/org");
22
24
  const schedules_1 = require("./commands/schedules");
23
25
  const skills_1 = require("./commands/skills");
24
26
  const sources_1 = require("./commands/sources");
@@ -38,11 +40,21 @@ async function run(args) {
38
40
  await (0, auth_1.login)();
39
41
  return;
40
42
  case 'logout':
41
- (0, auth_1.logout)();
43
+ (0, auth_1.logout)(rest);
42
44
  return;
43
45
  case 'whoami':
44
46
  await (0, auth_1.whoami)();
45
47
  return;
48
+ case 'org':
49
+ if (second === 'switch') {
50
+ (0, org_1.orgSwitch)({ ...args, words: args.words.slice(2) });
51
+ return;
52
+ }
53
+ if (second === undefined || second === 'list') {
54
+ (0, org_1.orgList)();
55
+ return;
56
+ }
57
+ throw new errors_1.CliError(`Unknown command: org ${second}`, 'Available: list and switch');
46
58
  case 'templates':
47
59
  await (0, projects_1.templates)();
48
60
  return;
@@ -155,9 +167,6 @@ async function run(args) {
155
167
  case 'status':
156
168
  await (0, sources_1.status)();
157
169
  return;
158
- case 'push':
159
- await (0, sources_1.push)(rest);
160
- return;
161
170
  case 'pull':
162
171
  await (0, sources_1.pull)(rest);
163
172
  return;
@@ -216,6 +225,9 @@ async function main() {
216
225
  (0, ui_1.note)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
217
226
  if (e.hint)
218
227
  (0, ui_1.note)((0, ui_1.dim)(` ${e.hint}`));
228
+ if (e.code === 'not_found' && (0, credentials_1.anyMultipleOrgs)()) {
229
+ (0, ui_1.note)((0, ui_1.dim)(' Several organizations are signed in: the answer may live in another one, see xflow org'));
230
+ }
219
231
  quiet = quiet || e.code === 'outdated_cli';
220
232
  return 1;
221
233
  }
@@ -5,27 +5,17 @@ exports.logout = logout;
5
5
  exports.whoami = whoami;
6
6
  const node_os_1 = require("node:os");
7
7
  const api_1 = require("../api");
8
+ const args_1 = require("../args");
8
9
  const config_1 = require("../config");
9
10
  const credentials_1 = require("../credentials");
10
11
  const errors_1 = require("../errors");
11
12
  const limits_1 = require("../limits");
12
13
  const session_1 = require("../session");
13
14
  const ui_1 = require("../ui");
14
- function localConfig() {
15
- const root = (0, config_1.findProjectRoot)();
16
- if (!root)
17
- return undefined;
18
- try {
19
- return (0, config_1.readConfig)(root);
20
- }
21
- catch {
22
- return undefined;
23
- }
24
- }
25
15
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
26
16
  /** Device flow: the key is issued after confirmation in the browser, never typed. */
27
17
  async function login() {
28
- const config = localConfig();
18
+ const config = (0, config_1.localConfig)();
29
19
  const client = (0, session_1.anonymous)(config);
30
20
  // The machine name becomes the key label on the platform.
31
21
  const start = await (0, api_1.apiJson)(client, '/api/v1/auth/device', {
@@ -74,22 +64,55 @@ async function awaitConfirmation(client, start) {
74
64
  }
75
65
  if (poll.status === 'pending')
76
66
  continue;
77
- (0, credentials_1.saveCredential)(client.apiUrl, {
78
- token: poll.token,
79
- organizationId: poll.organization_id,
80
- projectId: poll.project_id,
81
- });
82
- (0, ui_1.ok)(`Signed in, the key is stored for ${client.apiUrl}`);
67
+ if (!poll.organization_id) {
68
+ throw new errors_1.CliError('The platform did not name the organization of the key', 'Try again: xflow login');
69
+ }
70
+ (0, credentials_1.saveOrgCredential)(client.apiUrl, poll.organization_id, poll.token);
71
+ // The key of every other stored organization stays; this one becomes active.
72
+ const authed = { apiUrl: client.apiUrl, token: poll.token };
73
+ const name = await (0, api_1.apiJson)(authed, '/api/v1/me')
74
+ .then((me) => me.organization.name)
75
+ .catch(() => null);
76
+ if (name)
77
+ (0, credentials_1.rememberOrgName)(client.apiUrl, poll.organization_id, name);
78
+ (0, ui_1.ok)(`Signed in to "${name ?? poll.organization_id}", the key is stored for ${client.apiUrl}`);
79
+ const others = (0, credentials_1.listOrgs)(client.apiUrl).length - 1;
80
+ if (others > 0)
81
+ (0, ui_1.note)((0, ui_1.dim)(` ${others} more stored: xflow org`));
83
82
  return;
84
83
  }
85
84
  }
86
- function logout() {
87
- const apiUrl = (0, config_1.apiUrlFor)(localConfig());
88
- if ((0, credentials_1.forgetCredential)(apiUrl)) {
89
- (0, ui_1.ok)(`The key for ${apiUrl} is deleted`);
85
+ function logout(args) {
86
+ const apiUrl = (0, config_1.apiUrlFor)((0, config_1.localConfig)());
87
+ if ((0, args_1.flagBool)(args, 'all')) {
88
+ if ((0, credentials_1.forgetAllOrgs)(apiUrl))
89
+ (0, ui_1.ok)(`Every key for ${apiUrl} is deleted`);
90
+ else
91
+ (0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
92
+ return;
90
93
  }
91
- else {
92
- (0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
94
+ const orgs = (0, credentials_1.listOrgs)(apiUrl);
95
+ const active = orgs.find((org) => org.active);
96
+ 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}`);
102
+ return;
103
+ }
104
+ const { nextActive } = (0, credentials_1.forgetOrg)(apiUrl, active.organizationId);
105
+ (0, ui_1.ok)(`Signed out of "${active.name ?? active.organizationId}"`);
106
+ // Inside a folder bound to another organization the command may read as
107
+ // "revoke access to this project", which is not what just happened.
108
+ const root = (0, config_1.findProjectRoot)();
109
+ const boundId = root ? (0, config_1.readState)(root).organizationId : undefined;
110
+ if (boundId && boundId !== active.organizationId) {
111
+ const bound = orgs.find((org) => org.organizationId === boundId);
112
+ (0, ui_1.note)((0, ui_1.dim)(` This folder is bound to "${bound?.name ?? boundId}": its key is untouched`));
113
+ }
114
+ if (nextActive) {
115
+ (0, ui_1.note)((0, ui_1.dim)(` Active organization now: "${nextActive.name ?? nextActive.organizationId}". Every key: xflow logout --all`));
93
116
  }
94
117
  }
95
118
  /** Suffix for a non-active subscription. */
@@ -99,18 +122,23 @@ const SUBSCRIPTION_STATE = {
99
122
  terminated: ', the subscription is terminated',
100
123
  };
101
124
  async function whoami() {
102
- const config = localConfig();
125
+ const config = (0, config_1.localConfig)();
103
126
  const client = (0, session_1.connect)(config);
104
127
  const me = await (0, api_1.apiJson)(client, '/api/v1/me');
105
128
  (0, ui_1.out)(`Organization: ${me.organization.name ?? me.organization.id}`);
106
129
  (0, ui_1.out)(`Key: xfk_${me.key.prefix}… (${me.key.scopes.join(', ')})`);
107
130
  (0, ui_1.out)(`Platform: ${client.apiUrl}`);
108
- // Same condition as in connect: for an address out of xflow.json the env key is ignored.
109
- if (process.env.XFLOW_TOKEN?.trim() && client.apiUrl === (0, config_1.apiUrlFor)()) {
110
- (0, ui_1.note)((0, ui_1.dim)(' The key comes from XFLOW_TOKEN, the one stored in ~/.xflow is not used'));
131
+ (0, credentials_1.rememberOrgName)(client.apiUrl, me.organization.id, me.organization.name);
132
+ if (client.source === 'env') {
133
+ (0, ui_1.note)((0, ui_1.dim)(' The key comes from XFLOW_TOKEN, the ones stored in ~/.xflow are not used'));
134
+ }
135
+ else if (client.source === 'folder') {
136
+ (0, ui_1.note)((0, ui_1.dim)(' The key follows the organization this folder is bound to (.xflow/state.json)'));
111
137
  }
112
- else if (!(0, credentials_1.readCredential)(client.apiUrl)) {
113
- (0, ui_1.note)((0, ui_1.dim)(' The key is not stored'));
138
+ else {
139
+ const others = (0, credentials_1.listOrgs)(client.apiUrl).length - 1;
140
+ if (others > 0)
141
+ (0, ui_1.note)((0, ui_1.dim)(` The key of the active organization, ${others} more stored: xflow org`));
114
142
  }
115
143
  if (me.billing) {
116
144
  const pkg = me.billing.package_title ? `, ${me.billing.package_title}` : '';
@@ -102,12 +102,12 @@ async function startBuild(client, projectId, revision, allowRemovals) {
102
102
  }
103
103
  async function deploy(args) {
104
104
  const { root, config } = (0, config_1.requireProject)();
105
- const client = (0, session_1.connect)(config);
105
+ const client = await (0, session_1.connectProject)(root, config);
106
106
  let revision;
107
107
  if ((0, args_1.flagBool)(args, 'no-push')) {
108
108
  const server = await (0, sources_1.latestRevision)(client, config.projectId);
109
109
  if (!server) {
110
- throw new errors_1.CliError('The server holds no sources', 'Send them: xflow push');
110
+ throw new errors_1.CliError('The server holds no sources', 'Repeat without --no-push, so that the sources go up first');
111
111
  }
112
112
  revision = server.revision;
113
113
  (0, ui_1.warn)(`Sources not sent (--no-push), the build runs from revision ${revision}`);
@@ -148,8 +148,8 @@ async function deploy(args) {
148
148
  (0, ui_1.note)((0, ui_1.dim)(' Show it to visitors: xflow publish'));
149
149
  }
150
150
  async function publish() {
151
- const { config } = (0, config_1.requireProject)();
152
- const client = (0, session_1.connect)(config);
151
+ const { root, config } = (0, config_1.requireProject)();
152
+ const client = await (0, session_1.connectProject)(root, config);
153
153
  (0, ui_1.step)('Publishing the current dev version');
154
154
  const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/publish`, { method: 'POST', timeoutMs: 120_000 });
155
155
  if (result.already_published) {
@@ -161,12 +161,12 @@ async function publish() {
161
161
  (0, ui_1.out)(result.project_url);
162
162
  }
163
163
  async function rollback(args) {
164
- const { config } = (0, config_1.requireProject)();
164
+ const { root, config } = (0, config_1.requireProject)();
165
165
  const deployId = args.words[0];
166
166
  if (!deployId) {
167
167
  throw new errors_1.CliError('A version number is required', 'To see the versions: xflow deployments. For example: xflow rollback 481203');
168
168
  }
169
- const client = (0, session_1.connect)(config);
169
+ const client = await (0, session_1.connectProject)(root, config);
170
170
  const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/rollback`, { method: 'POST', body: { deploy_id: deployId } });
171
171
  (0, ui_1.ok)(`The dev version of the project is switched to ${result.deploy_id}`);
172
172
  (0, ui_1.out)(result.project_url);
@@ -176,8 +176,8 @@ async function rollback(args) {
176
176
  (0, ui_1.note)((0, ui_1.dim)(' Visitors see it only after xflow publish'));
177
177
  }
178
178
  async function deployments() {
179
- const { config } = (0, config_1.requireProject)();
180
- const client = (0, session_1.connect)(config);
179
+ const { root, config } = (0, config_1.requireProject)();
180
+ const client = await (0, session_1.connectProject)(root, config);
181
181
  const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/deployments`);
182
182
  if (data.deployments.length === 0) {
183
183
  (0, ui_1.note)('No versions yet. To build and release: xflow deploy');
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.refreshAgentKey = refreshAgentKey;
3
4
  exports.mcpInstall = mcpInstall;
4
5
  const node_os_1 = require("node:os");
5
6
  const node_path_1 = require("node:path");
@@ -26,6 +27,8 @@ const CLIENTS = [
26
27
  ],
27
28
  // Re-running must update the entry, not fail on it.
28
29
  reset: ['mcp', 'remove', 'xflow', '-s', 'local'],
30
+ // Succeeds only when an xflow entry is visible from this folder.
31
+ probe: ['mcp', 'get', 'xflow'],
29
32
  },
30
33
  ];
31
34
  // Output is captured, not inherited: clients may echo the added header together
@@ -33,6 +36,24 @@ const CLIENTS = [
33
36
  function hasBinary(binary) {
34
37
  return (0, spawn_1.run)(binary, ['--version']).status === 0;
35
38
  }
39
+ /**
40
+ * Rewrite the key in clients that already hold an xflow entry visible from this
41
+ * folder. Entries are never created here: that stays with xflow mcp install.
42
+ */
43
+ function refreshAgentKey(client) {
44
+ const url = `${client.apiUrl.replace(/\/+$/, '')}/api/mcp`;
45
+ let updated = false;
46
+ for (const target of CLIENTS) {
47
+ if (!hasBinary(target.binary))
48
+ continue;
49
+ if ((0, spawn_1.run)(target.binary, [...target.probe]).status !== 0)
50
+ continue;
51
+ (0, spawn_1.run)(target.binary, [...target.reset]);
52
+ if ((0, spawn_1.run)(target.binary, target.args(url, client.token)).status === 0)
53
+ updated = true;
54
+ }
55
+ return updated;
56
+ }
36
57
  async function mcpInstall(args) {
37
58
  // The server covers the organization: the command works outside a project folder too.
38
59
  const root = (0, config_1.findProjectRoot)();
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.orgList = orgList;
4
+ exports.orgSwitch = orgSwitch;
5
+ const config_1 = require("../config");
6
+ const credentials_1 = require("../credentials");
7
+ const errors_1 = require("../errors");
8
+ const ui_1 = require("../ui");
9
+ const mcp_1 = require("./mcp");
10
+ function label(org) {
11
+ return org.name ?? org.organizationId;
12
+ }
13
+ /** Organizations of this platform address that hold a stored key. */
14
+ function orgList() {
15
+ const apiUrl = (0, config_1.apiUrlFor)((0, config_1.localConfig)());
16
+ const orgs = (0, credentials_1.listOrgs)(apiUrl);
17
+ if (orgs.length === 0) {
18
+ if ((0, credentials_1.storedKeyInputs)(apiUrl).legacyToken) {
19
+ (0, ui_1.note)(`One key is stored for ${apiUrl}, its organization is unknown`);
20
+ (0, ui_1.note)((0, ui_1.dim)(' Sign in again to refresh it: xflow login'));
21
+ }
22
+ else {
23
+ (0, ui_1.note)(`No keys for ${apiUrl}. Sign in: xflow login`);
24
+ }
25
+ return;
26
+ }
27
+ (0, ui_1.table)(orgs.map((org) => [org.active ? '*' : ' ', org.name ?? '(unnamed)', org.organizationId]));
28
+ const root = (0, config_1.findProjectRoot)();
29
+ const boundId = root ? (0, config_1.readState)(root).organizationId : undefined;
30
+ if (boundId) {
31
+ const bound = orgs.find((org) => org.organizationId === boundId);
32
+ (0, ui_1.note)((0, ui_1.dim)(` This folder is bound to "${bound ? label(bound) : boundId}": commands here stay in it`));
33
+ }
34
+ if (orgs.length > 1)
35
+ (0, ui_1.note)((0, ui_1.dim)(' Switch: xflow org switch <name or id>'));
36
+ (0, ui_1.note)((0, ui_1.dim)(' To add an organization, sign in to it: xflow login'));
37
+ }
38
+ /** Local switch of the active organization: a pointer move, no browser. */
39
+ function orgSwitch(args) {
40
+ const wanted = args.words[0];
41
+ if (!wanted)
42
+ throw new errors_1.CliError('An organization is required', 'The list: xflow org');
43
+ const apiUrl = (0, config_1.apiUrlFor)((0, config_1.localConfig)());
44
+ const orgs = (0, credentials_1.listOrgs)(apiUrl);
45
+ if (orgs.length === 0) {
46
+ if ((0, credentials_1.storedKeyInputs)(apiUrl).legacyToken) {
47
+ throw new errors_1.CliError(`The stored key for ${apiUrl} names no organization`, 'Sign in again to refresh it: xflow login');
48
+ }
49
+ throw new errors_1.CliError(`No keys for ${apiUrl}`, 'Sign in: xflow login');
50
+ }
51
+ const byId = orgs.find((org) => org.organizationId === wanted);
52
+ const byName = orgs.filter((org) => (org.name ?? '').toLowerCase() === wanted.toLowerCase());
53
+ if (!byId && byName.length > 1) {
54
+ throw new errors_1.CliError(`Several organizations are named "${wanted}"`, `Use the id instead: ${byName.map((org) => org.organizationId).join(', ')}`);
55
+ }
56
+ const target = byId ?? byName[0];
57
+ if (!target) {
58
+ throw new errors_1.CliError(`No stored key for "${wanted}"`, 'The list: xflow org. To add one: xflow login');
59
+ }
60
+ if (target.active) {
61
+ (0, ui_1.ok)(`"${label(target)}" is already the active organization`);
62
+ return;
63
+ }
64
+ (0, credentials_1.setActiveOrg)(apiUrl, target.organizationId);
65
+ (0, ui_1.ok)(`Active organization: "${label(target)}"`);
66
+ const root = (0, config_1.findProjectRoot)();
67
+ if (root) {
68
+ // The agent entry of a project folder belongs to the folder's organization,
69
+ // so the switch leaves it alone.
70
+ const boundId = (0, config_1.readState)(root).organizationId;
71
+ if (boundId && boundId !== target.organizationId) {
72
+ const bound = orgs.find((org) => org.organizationId === boundId);
73
+ (0, ui_1.note)((0, ui_1.dim)(` This folder is bound to "${bound ? label(bound) : boundId}": commands here stay in it`));
74
+ }
75
+ return;
76
+ }
77
+ if ((0, mcp_1.refreshAgentKey)({ apiUrl, token: target.token })) {
78
+ (0, ui_1.note)((0, ui_1.dim)(' The agent key here is rewritten: restart the agent session to pick it up'));
79
+ }
80
+ }
@@ -82,6 +82,8 @@ async function init(args) {
82
82
  };
83
83
  (0, config_1.writeConfig)(target, config);
84
84
  (0, config_1.ignoreStateInGit)(target);
85
+ if (client.organizationId)
86
+ (0, config_1.writeState)(target, { organizationId: client.organizationId });
85
87
  }
86
88
  catch (e) {
87
89
  (0, ui_1.note)((0, ui_1.dim)(` The project "${project.name}" is already created (${project.id}).`));
@@ -100,12 +102,34 @@ async function init(args) {
100
102
  async function link(args) {
101
103
  const root = (0, config_1.findProjectRoot)() ?? process.cwd();
102
104
  const existing = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, config_1.CONFIG_FILE)) ? (0, config_1.readConfig)(root) : undefined;
103
- const client = (0, session_1.connect)(existing);
105
+ let client = (0, session_1.connect)(existing);
104
106
  const projectId = args.words[0];
105
107
  if (!projectId) {
106
108
  throw new errors_1.CliError('A project identifier is required', 'The list: xflow projects list');
107
109
  }
108
- const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
110
+ let card;
111
+ try {
112
+ card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
113
+ }
114
+ catch (e) {
115
+ // Project ids are unique across the platform, so a 404 under this key does
116
+ // not mean the project does not exist: another signed-in organization may
117
+ // hold it. A dead key (401/403) says even less. Ask every stored
118
+ // organization before giving up.
119
+ if (!(e instanceof api_1.ApiError && (e.status === 404 || e.status === 401 || e.status === 403)) ||
120
+ client.source === 'env') {
121
+ throw e;
122
+ }
123
+ const { hit, dead } = await (0, session_1.findProjectOrg)(client.apiUrl, projectId);
124
+ if (!hit) {
125
+ throw new errors_1.CliError(`No signed-in organization holds the project ${projectId}`, dead.length > 0
126
+ ? `The key of ${dead.map((d) => d.name ?? d.organizationId).join(', ')} is not working (sign in again: xflow login); the project may live there`
127
+ : 'Check the id (xflow projects list), or sign in to the organization that owns it: xflow login');
128
+ }
129
+ client = { apiUrl: client.apiUrl, token: hit.token, source: 'folder', organizationId: hit.organizationId };
130
+ card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
131
+ (0, ui_1.note)((0, ui_1.dim)(` The project belongs to "${hit.name ?? hit.organizationId}", not to the active organization`));
132
+ }
109
133
  const dir = existing ? root : process.cwd();
110
134
  (0, config_1.writeConfig)(dir, {
111
135
  ...(existing ?? {}),
@@ -113,6 +137,8 @@ async function link(args) {
113
137
  build: existing?.build ?? { command: 'npm run build', dir: 'dist' },
114
138
  });
115
139
  (0, config_1.ignoreStateInGit)(dir);
140
+ if (client.organizationId)
141
+ (0, config_1.writeState)(dir, { organizationId: client.organizationId });
116
142
  // A fresh clone has no .env: recreate it, never overwrite an existing one.
117
143
  if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(dir, '.env')) && card.project_token) {
118
144
  write(dir, '.env', (0, template_1.envFile)(card.project_token, client.apiUrl, card.functions));
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.latestRevision = latestRevision;
4
4
  exports.pushSources = pushSources;
5
- exports.push = push;
6
5
  exports.pull = pull;
7
6
  exports.status = status;
8
7
  const node_fs_1 = require("node:fs");
@@ -69,7 +68,7 @@ async function confirmForce(client, projectId, server, local) {
69
68
  if (!confirmed)
70
69
  throw new errors_1.CliError('Cancelled');
71
70
  }
72
- /** Used by push and by the first step of deploy. */
71
+ /** The first step of deploy: sources go up as a revision before the build. */
73
72
  async function pushSources(root, config, client, options) {
74
73
  const tree = prepareTree(root, config);
75
74
  const state = (0, config_1.readState)(root);
@@ -79,7 +78,11 @@ async function pushSources(root, config, client, options) {
79
78
  // a mismatch must not overwrite silently.
80
79
  if (server && state.revision === undefined && !options.force) {
81
80
  if (server.tree_hash === tree.hash) {
82
- (0, config_1.writeState)(root, { revision: server.revision, treeHash: server.tree_hash });
81
+ (0, config_1.writeState)(root, {
82
+ revision: server.revision,
83
+ treeHash: server.tree_hash,
84
+ organizationId: client.organizationId ?? undefined,
85
+ });
83
86
  (0, ui_1.ok)(`Already in sync, revision ${server.revision}`);
84
87
  return { revision: server.revision, status: 'unchanged' };
85
88
  }
@@ -94,7 +97,11 @@ async function pushSources(root, config, client, options) {
94
97
  if (options.force)
95
98
  headers['X-Force'] = 'true';
96
99
  const result = await (0, api_1.apiUpload)(client, `/api/v1/projects/${config.projectId}/push`, tree.archive, headers);
97
- (0, config_1.writeState)(root, { revision: result.revision, treeHash: result.tree_hash });
100
+ (0, config_1.writeState)(root, {
101
+ revision: result.revision,
102
+ treeHash: result.tree_hash,
103
+ organizationId: client.organizationId ?? undefined,
104
+ });
98
105
  if (result.status === 'unchanged') {
99
106
  (0, ui_1.ok)(`No changes, revision ${result.revision}`);
100
107
  }
@@ -103,11 +110,6 @@ async function pushSources(root, config, client, options) {
103
110
  }
104
111
  return { revision: result.revision, status: result.status };
105
112
  }
106
- async function push(args) {
107
- const { root, config } = (0, config_1.requireProject)();
108
- const client = (0, session_1.connect)(config);
109
- await pushSources(root, config, client, { force: (0, args_1.flagBool)(args, 'force') });
110
- }
111
113
  function hasContent(dir) {
112
114
  if (!(0, node_fs_1.existsSync)(dir))
113
115
  return false;
@@ -117,7 +119,7 @@ async function pull(args) {
117
119
  const { root, config } = (0, config_1.requireProject)();
118
120
  const into = (0, args_1.flagString)(args, 'into');
119
121
  const target = into ? (0, node_path_1.resolve)(into) : root;
120
- const client = (0, session_1.connect)(config);
122
+ const client = await (0, session_1.connectProject)(root, config);
121
123
  const revision = (0, args_1.flagNumber)(args, 'revision');
122
124
  const query = revision !== undefined ? `?revision=${revision}` : '';
123
125
  const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/pull${query}`);
@@ -139,13 +141,17 @@ async function pull(args) {
139
141
  }
140
142
  // Only the working folder tracks revisions; a side copy (--into) must not reset them.
141
143
  if (!into) {
142
- (0, config_1.writeState)(root, { revision: info.revision, treeHash: info.tree_hash });
144
+ (0, config_1.writeState)(root, {
145
+ revision: info.revision,
146
+ treeHash: info.tree_hash,
147
+ organizationId: client.organizationId ?? undefined,
148
+ });
143
149
  }
144
150
  (0, ui_1.ok)(`Revision ${info.revision}: ${entries.length} files in ${target}`);
145
151
  }
146
152
  async function status() {
147
153
  const { root, config } = (0, config_1.requireProject)();
148
- const client = (0, session_1.connect)(config);
154
+ const client = await (0, session_1.connectProject)(root, config);
149
155
  const tree = prepareTree(root, config);
150
156
  const state = (0, config_1.readState)(root);
151
157
  const [card, server] = await Promise.all([
@@ -157,7 +163,7 @@ async function status() {
157
163
  (0, ui_1.out)(`Local: ${tree.files.length} files, ${(0, ui_1.formatBytes)(tree.archive.length)} archived`);
158
164
  if (!server) {
159
165
  (0, ui_1.out)('On the server: no sources yet');
160
- (0, ui_1.note)((0, ui_1.dim)(' Send them: xflow push'));
166
+ (0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
161
167
  }
162
168
  else {
163
169
  (0, ui_1.out)(`On the server: revision ${server.revision}, ${server.file_count} files, ${(0, ui_1.formatAge)(server.created_at)}`);
@@ -170,7 +176,7 @@ async function status() {
170
176
  }
171
177
  else {
172
178
  (0, ui_1.out)(`State: ${(0, ui_1.bold)('local changes')}`);
173
- (0, ui_1.note)((0, ui_1.dim)(' Send them: xflow push'));
179
+ (0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
174
180
  }
175
181
  }
176
182
  (0, ui_1.out)('');
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ exports.ConfigError = exports.CONFIG_FILE = void 0;
4
4
  exports.findProjectRoot = findProjectRoot;
5
5
  exports.readConfig = readConfig;
6
6
  exports.writeConfig = writeConfig;
7
+ exports.localConfig = localConfig;
7
8
  exports.requireProject = requireProject;
8
9
  exports.apiUrlFor = apiUrlFor;
9
10
  exports.readState = readState;
@@ -47,6 +48,18 @@ function readConfig(root) {
47
48
  function writeConfig(root, config) {
48
49
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, exports.CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
49
50
  }
51
+ /** The config of the folder we are in, when there is one and it parses. */
52
+ function localConfig() {
53
+ const root = findProjectRoot();
54
+ if (!root)
55
+ return undefined;
56
+ try {
57
+ return readConfig(root);
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
50
63
  function requireProject() {
51
64
  const root = findProjectRoot();
52
65
  if (!root) {
@@ -75,9 +88,15 @@ function readState(root) {
75
88
  return {};
76
89
  }
77
90
  }
78
- function writeState(root, state) {
91
+ /** Merge, never replace: callers write their own fields, the rest survives. */
92
+ function writeState(root, patch) {
79
93
  const dir = (0, node_path_1.join)(root, STATE_DIR);
80
94
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
95
+ const state = { ...readState(root) };
96
+ for (const [key, value] of Object.entries(patch)) {
97
+ if (value !== undefined)
98
+ state[key] = value;
99
+ }
81
100
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
82
101
  }
83
102
  /** Keep .xflow/ out of git. */
@@ -1,9 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.saveCredential = saveCredential;
4
- exports.forgetCredential = forgetCredential;
5
- exports.resolveToken = resolveToken;
6
- exports.readCredential = readCredential;
3
+ exports.reconcile = reconcile;
4
+ exports.listOrgs = listOrgs;
5
+ exports.saveOrgCredential = saveOrgCredential;
6
+ exports.rememberOrgName = rememberOrgName;
7
+ exports.setActiveOrg = setActiveOrg;
8
+ exports.forgetOrg = forgetOrg;
9
+ exports.forgetAllOrgs = forgetAllOrgs;
10
+ exports.anyMultipleOrgs = anyMultipleOrgs;
11
+ exports.pickKey = pickKey;
12
+ exports.storedKeyInputs = storedKeyInputs;
7
13
  const node_fs_1 = require("node:fs");
8
14
  const node_os_1 = require("node:os");
9
15
  const node_path_1 = require("node:path");
@@ -38,28 +44,208 @@ function writeStore(store) {
38
44
  // Windows has no POSIX modes.
39
45
  }
40
46
  }
41
- function saveCredential(apiUrl, credential) {
47
+ function isLegacy(value) {
48
+ return !!value && typeof value.token === 'string';
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
+ /**
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".
69
+ * Returns true when the store changed and has to be written back.
70
+ */
71
+ function reconcile(store, apiUrl) {
72
+ const legacy = store[apiUrl];
73
+ let address = store.orgs?.[apiUrl];
74
+ 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;
81
+ store.orgs = store.orgs ?? {};
82
+ 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
+ }
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.
96
+ changed = true;
97
+ }
98
+ // The pointer must land on a stored key, or on nothing.
99
+ if (address) {
100
+ const ids = Object.keys(address.keys);
101
+ const active = address.active && address.keys[address.active] ? address.active : (ids[0] ?? null);
102
+ if (address.active !== active) {
103
+ address.active = active;
104
+ changed = true;
105
+ }
106
+ }
107
+ if (changed)
108
+ syncLegacy(store, apiUrl);
109
+ return changed;
110
+ }
111
+ /** Read the store with the drift of older CLIs healed, persisting the healing. */
112
+ function load(apiUrl) {
42
113
  const store = readStore();
43
- store[apiUrl] = { ...credential, savedAt: new Date().toISOString() };
114
+ if (reconcile(store, apiUrl))
115
+ commit(store, apiUrl);
116
+ return store;
117
+ }
118
+ function commit(store, apiUrl) {
119
+ syncLegacy(store, apiUrl);
120
+ const address = store.orgs?.[apiUrl];
121
+ if (address && Object.keys(address.keys).length === 0) {
122
+ delete store.orgs?.[apiUrl];
123
+ if (store.orgs && Object.keys(store.orgs).length === 0)
124
+ delete store.orgs;
125
+ }
44
126
  writeStore(store);
45
127
  }
46
- function forgetCredential(apiUrl) {
128
+ /** Organizations with a stored key for the address, the active one first. */
129
+ function listOrgs(apiUrl) {
130
+ const address = load(apiUrl).orgs?.[apiUrl];
131
+ if (!address)
132
+ return [];
133
+ return Object.entries(address.keys)
134
+ .map(([organizationId, key]) => ({
135
+ organizationId,
136
+ name: key.name ?? null,
137
+ token: key.token,
138
+ active: organizationId === address.active,
139
+ }))
140
+ .sort((a, b) => Number(b.active) - Number(a.active));
141
+ }
142
+ /** Store the key and make its organization the active one. */
143
+ function saveOrgCredential(apiUrl, organizationId, token, name) {
144
+ const store = readStore();
145
+ reconcile(store, apiUrl);
146
+ const orgs = store.orgs?.[apiUrl] ?? { active: null, keys: {} };
147
+ const known = orgs.keys[organizationId];
148
+ orgs.keys[organizationId] = {
149
+ token,
150
+ name: name ?? known?.name ?? null,
151
+ savedAt: new Date().toISOString(),
152
+ };
153
+ orgs.active = organizationId;
154
+ store.orgs = store.orgs ?? {};
155
+ store.orgs[apiUrl] = orgs;
156
+ commit(store, apiUrl);
157
+ }
158
+ /** Cache the display name; a stale one is refreshed by any login or whoami. */
159
+ function rememberOrgName(apiUrl, organizationId, name) {
47
160
  const store = readStore();
48
- if (!store[apiUrl])
161
+ reconcile(store, apiUrl);
162
+ const entry = store.orgs?.[apiUrl]?.keys[organizationId];
163
+ if (!entry || entry.name === name)
164
+ return;
165
+ entry.name = name;
166
+ commit(store, apiUrl);
167
+ }
168
+ function setActiveOrg(apiUrl, organizationId) {
169
+ const store = readStore();
170
+ reconcile(store, apiUrl);
171
+ const orgs = store.orgs?.[apiUrl];
172
+ if (!orgs?.keys[organizationId])
173
+ return false;
174
+ if (orgs.active !== organizationId) {
175
+ orgs.active = organizationId;
176
+ commit(store, apiUrl);
177
+ }
178
+ return true;
179
+ }
180
+ /** Forget one organization; the pointer moves to any remaining one. */
181
+ function forgetOrg(apiUrl, organizationId) {
182
+ const store = readStore();
183
+ reconcile(store, apiUrl);
184
+ const orgs = store.orgs?.[apiUrl];
185
+ if (!orgs?.keys[organizationId])
186
+ return { removed: false, nextActive: null };
187
+ delete orgs.keys[organizationId];
188
+ if (orgs.active === organizationId)
189
+ orgs.active = Object.keys(orgs.keys)[0] ?? null;
190
+ commit(store, apiUrl);
191
+ const next = orgs.active ? orgs.keys[orgs.active] : undefined;
192
+ return {
193
+ removed: true,
194
+ nextActive: next && orgs.active
195
+ ? { organizationId: orgs.active, name: next.name ?? null, token: next.token, active: true }
196
+ : null,
197
+ };
198
+ }
199
+ /** Forget every key of the address; other addresses are untouched. */
200
+ function forgetAllOrgs(apiUrl) {
201
+ const store = readStore();
202
+ reconcile(store, apiUrl);
203
+ const had = !!store.orgs?.[apiUrl] || isLegacy(store[apiUrl]);
204
+ if (!had)
49
205
  return false;
206
+ if (store.orgs) {
207
+ delete store.orgs[apiUrl];
208
+ if (Object.keys(store.orgs).length === 0)
209
+ delete store.orgs;
210
+ }
50
211
  delete store[apiUrl];
51
212
  writeStore(store);
52
213
  return true;
53
214
  }
54
- /** XFLOW_TOKEN wins over the store; the caller decides whether env is allowed for this address. */
55
- function resolveToken(apiUrl, allowEnv = true) {
56
- if (allowEnv) {
57
- const fromEnv = process.env.XFLOW_TOKEN?.trim();
58
- if (fromEnv)
59
- return fromEnv;
215
+ /** True when any address holds keys of more than one organization. */
216
+ function anyMultipleOrgs() {
217
+ const store = readStore();
218
+ return Object.values(store.orgs ?? {}).some((address) => Object.keys(address.keys).length > 1);
219
+ }
220
+ /**
221
+ * One rule for the whole CLI: XFLOW_TOKEN (default address only) beats the
222
+ * folder's organization, which beats the active one. A folder bound to an
223
+ * organization without a stored key is an error, not a fallback: the command
224
+ * would silently run against a different organization.
225
+ */
226
+ function pickKey(inputs) {
227
+ if (inputs.envAllowed && inputs.env)
228
+ return { token: inputs.env, source: 'env', organizationId: null };
229
+ if (inputs.folderOrg) {
230
+ const key = inputs.orgs?.keys[inputs.folderOrg];
231
+ if (key)
232
+ return { token: key.token, source: 'folder', organizationId: inputs.folderOrg };
233
+ return { missing: 'folder-key', organizationId: inputs.folderOrg };
60
234
  }
61
- return readStore()[apiUrl]?.token ?? null;
235
+ const activeId = inputs.orgs?.active;
236
+ const active = activeId ? inputs.orgs?.keys[activeId] : undefined;
237
+ if (active && activeId)
238
+ return { token: active.token, source: 'active', organizationId: activeId };
239
+ if (inputs.legacyToken)
240
+ return { token: inputs.legacyToken, source: 'active', organizationId: null };
241
+ return null;
62
242
  }
63
- function readCredential(apiUrl) {
64
- return readStore()[apiUrl] ?? null;
243
+ /** The store-backed half of pickKey's inputs; the caller adds env and the folder binding. */
244
+ 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
+ };
65
251
  }
package/dist/help.js CHANGED
@@ -19,9 +19,8 @@ ${(0, ui_1.bold)('Getting started')}
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
 
22
- ${(0, ui_1.bold)('Code')}
22
+ ${(0, ui_1.bold)('Sources')}
23
23
  xflow status what is on the server and how the local copy differs
24
- xflow push [--force] send the sources as a new revision
25
24
  xflow pull [--into dir] [--revision N]
26
25
  fetch the sources (the latest revision by default)
27
26
 
@@ -60,8 +59,10 @@ ${(0, ui_1.bold)('Reference')}
60
59
  xflow logs [--limit N] browser errors from the released application
61
60
  xflow projects list projects of the organization
62
61
  xflow projects get [id] project card
62
+ xflow org organizations with a stored key, the active one marked
63
+ xflow org switch <name|id> make another organization the active one
63
64
  xflow whoami whose key this is and what it can do
64
- xflow logout forget the key
65
+ xflow logout [--all] forget the key of the active organization (--all: every one)
65
66
  xflow update update the CLI itself, and the skill that ships with it
66
67
 
67
68
  ${(0, ui_1.bold)('Environment')}
@@ -71,6 +72,35 @@ ${(0, ui_1.bold)('Environment')}
71
72
  More about one command: xflow help <command>`);
72
73
  }
73
74
  const TOPICS = {
75
+ org: `${(0, ui_1.bold)('xflow org')}: several organizations, one terminal
76
+
77
+ One key per organization: every ${(0, ui_1.bold)('xflow login')} stores the key of the organization
78
+ chosen in the browser next to the ones already stored, it does not replace them.
79
+ Which key a command then uses, in order:
80
+
81
+ 1. XFLOW_TOKEN, when set (and only for the default platform address)
82
+ 2. the organization this folder is bound to (${(0, ui_1.bold)('.xflow/state.json')}, written by
83
+ init, link and the first successful deploy or pull)
84
+ 3. the active organization
85
+
86
+ xflow org the stored organizations, the active one marked
87
+ xflow org switch <name|id> make another one active, no browser involved
88
+ xflow logout forget the key of the active organization
89
+ xflow logout --all forget every key of this platform address
90
+
91
+ The switch is local: it changes which stored key is used and nothing happens on
92
+ the platform. A folder bound to an organization is not affected, its commands
93
+ stay in its own organization: two projects of two organizations in two terminals
94
+ work without switching anything.
95
+
96
+ Outside a project folder the switch also rewrites the key in the agent config
97
+ (see ${(0, ui_1.bold)('xflow mcp')}) when an xflow entry already exists there; a running agent
98
+ session picks the new key up only after a restart.
99
+
100
+ A project id is unique across the whole platform, so "project not found" under
101
+ the wrong organization can never touch somebody else's project. When that error
102
+ names a project you know exists, the key is simply from another organization:
103
+ check ${(0, ui_1.bold)('xflow org')}.`,
74
104
  update: `${(0, ui_1.bold)('xflow update')}: bring the CLI up to date
75
105
 
76
106
  Installs the published version and, if anything changed, rewrites the platform
@@ -291,20 +321,6 @@ Plus a few pointer lines in ${(0, ui_1.bold)('AGENTS.md')}. A skill is picked up
291
321
  description matches the task, and "add a customers table" will not trigger it. AGENTS.md
292
322
  is always read by the agent, which is why the pointer is appended there, at the end of
293
323
  the file and once. ${(0, ui_1.bold)('CLAUDE.md')} is left alone: Claude Code reads .claude/skills anyway.`,
294
- push: `${(0, ui_1.bold)('xflow push')}: send the sources
295
-
296
- The whole working copy goes up at once, as one revision. What is not sent:
297
- node_modules, .git, dist, build, .next, any .env, plus everything listed in
298
- .xflowignore and in the ignore field of xflow.json.
299
-
300
- If the server holds a revision newer than the one you worked from, the push is
301
- rejected. That means somebody pushed before you: fetch their changes alongside
302
- (${(0, ui_1.bold)('xflow pull --into ./server-copy')}), merge them in git on your side, and retry.
303
-
304
- --force overwrites the server revision. Before that the CLI shows whose work you are
305
- about to destroy and asks for confirmation by typing the project name. In a
306
- non-interactive run (CI, an agent) there is no way to confirm: a version conflict has
307
- to fail the build rather than silently destroy somebody else's work.`,
308
324
  pull: `${(0, ui_1.bold)('xflow pull')}: fetch the sources
309
325
 
310
326
  By default it fetches the latest revision into the project folder and refuses to write
@@ -323,6 +339,18 @@ by default).
323
339
  --force allow overwriting the server revision while sending
324
340
  --allow-removals agree in advance to remove the functions gone from the sources
325
341
 
342
+ The whole working copy goes up at once, as one revision. What is not sent: node_modules,
343
+ .git, dist, build, .next, any .env, plus everything listed in .xflowignore and in the
344
+ ignore field of xflow.json.
345
+
346
+ If the server holds a revision newer than the one you worked from, the sources are
347
+ rejected and the build does not start. That means somebody deployed before you: fetch
348
+ their changes alongside (${(0, ui_1.bold)('xflow pull --into ./server-copy')}), merge them in git on
349
+ your side, and retry. ${(0, ui_1.bold)('--force')} overwrites the server revision, and before that the
350
+ CLI shows whose work you are about to destroy and asks for confirmation by typing the
351
+ project name. In a non-interactive run (CI, an agent) there is no way to confirm: a
352
+ version conflict has to fail the build rather than silently destroy somebody else's work.
353
+
326
354
  The platform builds, in a clean sandbox on one Node version for everybody, so "it
327
355
  worked on my machine" no longer depends on your machine. Before the build the project
328
356
  is checked against the template: mismatches are printed as a list and the build does
package/dist/session.js CHANGED
@@ -2,21 +2,84 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.connect = connect;
4
4
  exports.anonymous = anonymous;
5
+ exports.findProjectOrg = findProjectOrg;
6
+ exports.connectProject = connectProject;
7
+ const api_1 = require("./api");
5
8
  const config_1 = require("./config");
6
9
  const credentials_1 = require("./credentials");
7
10
  const errors_1 = require("./errors");
11
+ const ui_1 = require("./ui");
8
12
  /** Platform connection: address plus key. */
9
13
  function connect(config) {
10
14
  const apiUrl = (0, config_1.apiUrlFor)(config);
11
- // XFLOW_TOKEN never follows an address taken from the repo's xflow.json:
12
- // a cloned config must not be able to redirect the key elsewhere.
13
- const token = (0, credentials_1.resolveToken)(apiUrl, apiUrl === (0, config_1.apiUrlFor)());
14
- if (!token) {
15
+ const root = (0, config_1.findProjectRoot)();
16
+ const picked = (0, credentials_1.pickKey)({
17
+ env: process.env.XFLOW_TOKEN?.trim() || null,
18
+ // XFLOW_TOKEN never follows an address taken from the repo's xflow.json:
19
+ // a cloned config must not be able to redirect the key elsewhere.
20
+ envAllowed: apiUrl === (0, config_1.apiUrlFor)(),
21
+ folderOrg: root ? ((0, config_1.readState)(root).organizationId ?? null) : null,
22
+ ...(0, credentials_1.storedKeyInputs)(apiUrl),
23
+ });
24
+ if (!picked) {
15
25
  throw new errors_1.CliError(`No access key for ${apiUrl}`, 'Sign in: xflow login. In CI pass the key in the XFLOW_TOKEN variable');
16
26
  }
17
- return { apiUrl, token };
27
+ if ('missing' in picked) {
28
+ throw new errors_1.CliError(`This folder is bound to the organization ${picked.organizationId}, and there is no stored key for it`, 'Sign in to that organization: xflow login. The stored ones: xflow org');
29
+ }
30
+ return { apiUrl, token: picked.token, source: picked.source, organizationId: picked.organizationId };
18
31
  }
19
32
  /** Sign-in only. */
20
33
  function anonymous(config) {
21
34
  return { apiUrl: (0, config_1.apiUrlFor)(config), token: '' };
22
35
  }
36
+ /**
37
+ * Which signed-in organization serves the project. Project ids are unique
38
+ * across the platform, so a 404 means "not this one" and exactly one
39
+ * organization can answer. A dead key must not end the search: the answer may
40
+ * sit behind the next one.
41
+ */
42
+ async function findProjectOrg(apiUrl, projectId) {
43
+ const dead = [];
44
+ for (const candidate of (0, credentials_1.listOrgs)(apiUrl)) {
45
+ try {
46
+ await (0, api_1.apiJson)({ apiUrl, token: candidate.token }, `/api/v1/projects/${projectId}`);
47
+ return { hit: candidate, dead };
48
+ }
49
+ catch (e) {
50
+ if (e instanceof api_1.ApiError && (e.status === 401 || e.status === 403)) {
51
+ dead.push(candidate);
52
+ continue;
53
+ }
54
+ if (e instanceof api_1.ApiError && e.status === 404)
55
+ continue;
56
+ throw e;
57
+ }
58
+ }
59
+ return { hit: null, dead };
60
+ }
61
+ /**
62
+ * Connection for project commands. A folder not yet bound to an organization
63
+ * gets probed once: the organization that serves the project is found and
64
+ * written into .xflow/state.json, so every later command resolves the key
65
+ * synchronously. With one stored key there is nothing to probe.
66
+ */
67
+ async function connectProject(root, config) {
68
+ const session = connect(config);
69
+ if (session.source !== 'active' || session.organizationId === null)
70
+ return session;
71
+ const orgs = (0, credentials_1.listOrgs)(session.apiUrl);
72
+ if (orgs.length <= 1)
73
+ return session;
74
+ const { hit, dead } = await findProjectOrg(session.apiUrl, config.projectId);
75
+ if (!hit) {
76
+ throw new errors_1.CliError(`No signed-in organization holds the project ${config.projectId}`, dead.length > 0
77
+ ? `The key of ${dead.map((d) => d.name ?? d.organizationId).join(', ')} is not working (sign in again: xflow login); the project may live there`
78
+ : 'Check projectId in xflow.json, or sign in to the organization that owns it: xflow login');
79
+ }
80
+ (0, config_1.writeState)(root, { organizationId: hit.organizationId });
81
+ if (hit.organizationId !== session.organizationId) {
82
+ (0, ui_1.note)((0, ui_1.dim)(` The project belongs to "${hit.name ?? hit.organizationId}", not to the active organization: the folder is bound to it`));
83
+ }
84
+ return { apiUrl: session.apiUrl, token: hit.token, source: 'folder', organizationId: hit.organizationId };
85
+ }
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.6.6';
5
+ exports.CLI_VERSION = '0.8.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.6.6",
3
+ "version": "0.8.0",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -312,9 +312,13 @@ const db = new Client({ connectionString: process.env.DATABASE_URL })
312
312
 
313
313
  The platform passes `DATABASE_URL` only to functions that mention it, and sets the project
314
314
  schema on every connection, so plain table names (`select * from tasks`) hit your project.
315
- You never write that variable yourself: `xflow env set DATABASE_URL=...` is refused. The same
316
- goes for every name starting with `XFLOW`: the platform fills those in itself, and your value
317
- under one of them would shadow the real one.
315
+ You never write that variable yourself: `xflow env set DATABASE_URL=...` is refused, and so is
316
+ `xflow env rm DATABASE_URL`. The same goes for every name starting with `XFLOW`: the platform
317
+ fills those in itself, and your value under one of them would shadow the real one.
318
+
319
+ `env` commands reach only what this project can see: variables shared across the organization
320
+ and the ones bound to this project. A variable bound to a different project is invisible here,
321
+ so `env rm` reports it as missing even though names are unique within the organization.
318
322
 
319
323
  The platform keeps no database history and no backups. Anything that destroys data
320
324
  (`DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, `DELETE FROM` without a condition) is refused
@@ -357,12 +361,17 @@ const link = await fetch(`${process.env.XFLOW_API_URL}/api/storage/project/uploa
357
361
  ```
358
362
 
359
363
  `confirm` takes the same fields plus the returned `s3Key` and answers with the file and its
360
- public address; `delete` takes the file `url`. Never pipe the bytes through the function itself.
364
+ address; `delete` takes that same `url`. Never pipe the bytes through the function itself.
361
365
 
362
366
  There is no endpoint that lists the files back, so the address that `confirm` returns is the
363
367
  only copy you get: write it into a table of your own in the same call, and the application
364
368
  reads its files from there.
365
369
 
370
+ That address never expires and is safe to store, but it is not a public link. It opens only
371
+ for a visitor who is signed in and has access to this project, the same rule that guards the
372
+ application itself, so it works on your pages and does nothing in an email or on a page
373
+ anyone can open.
374
+
366
375
  Four things bite an upload that otherwise looks right, and none of them is obvious from the
367
376
  answers you get:
368
377
 
@@ -393,12 +402,30 @@ key that reached a visitor lets them delete every file of the project.
393
402
  ## Syncing code
394
403
 
395
404
  `xflow status` shows how the local copy differs from the server revision.
396
- `xflow push` sends sources, `xflow pull` fetches them.
397
-
398
- If a push is rejected, the server revision is newer, meaning someone pushed first.
399
- Fetch their work next to yours (`xflow pull --into ./server-copy`), merge it locally,
400
- then push again. `--force` destroys their work: a last resort, not a way around the
401
- error.
405
+ `xflow deploy` sends sources, `xflow pull` fetches them.
406
+
407
+ If a deploy is rejected before the build starts, the server revision is newer, meaning
408
+ someone deployed first. Fetch their work next to yours (`xflow pull --into ./server-copy`),
409
+ merge it locally, then deploy again. `--force` destroys their work: a last resort, not a
410
+ way around the error.
411
+
412
+ ## Organizations and keys
413
+
414
+ A key belongs to one organization, and `xflow login` stores it next to the ones already
415
+ stored instead of replacing them. Which key a command uses, in order: `XFLOW_TOKEN` when
416
+ set (default platform address only), then the organization the project folder is bound
417
+ to (`.xflow/state.json`, written by init, link and the first successful deploy or pull),
418
+ then the active organization. `xflow org` lists the stored organizations with the active
419
+ one marked, `xflow org switch <name|id>` makes another one active without a browser, and
420
+ `xflow whoami` names the organization behind the current key.
421
+
422
+ Inside a project folder there is nothing to switch: commands follow the folder's own
423
+ organization whatever the active one is, which is what lets two projects of two
424
+ organizations work side by side. Project ids are unique across the platform, so a key of
425
+ the wrong organization can never touch another organization's project: the command fails
426
+ with "not found" instead. When that error names a project you know exists, check
427
+ `xflow org`; signing in to a missing organization is `xflow login`, and that needs a
428
+ person with a browser.
402
429
 
403
430
  ## Direct access without the terminal
404
431
 
@@ -408,8 +435,8 @@ read-only queries, migrations, function logs and invocations, schedules, environ
408
435
  variables, versions, publish and rollback. They answer with aggregates and say explicitly
409
436
  when a result is truncated, which parsing terminal output does not.
410
437
 
411
- Anything that depends on the working copy stays in the CLI: sending sources (`xflow push`),
412
- building and shipping the functions (`xflow deploy`), creating a project (`xflow init`). The
438
+ Anything that depends on the working copy stays in the CLI: sending sources, building and
439
+ shipping the functions (`xflow deploy`), creating a project (`xflow init`). The
413
440
  tools cannot see the folder you are working in, so a build started from there would release
414
441
  whatever revision the server happens to hold, not what you have on disk. Pulling a repository
415
442
  through tool calls also burns the user's tokens for nothing.
@@ -418,7 +445,7 @@ through tool calls also burns the user's tokens for nothing.
418
445
 
419
446
  - Edit `xflow.json` by hand: the CLI writes it.
420
447
  - Commit `.env`: it holds the project token.
421
- - Push with `--force` without checking `xflow status` first.
448
+ - Deploy with `--force` without checking `xflow status` first.
422
449
  - Invent platform commands: what is not in `xflow help` does not exist.
423
450
 
424
451
  ## App design