@getxflow/cli 0.6.6 → 0.7.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/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;
@@ -216,6 +228,9 @@ async function main() {
216
228
  (0, ui_1.note)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
217
229
  if (e.hint)
218
230
  (0, ui_1.note)((0, ui_1.dim)(` ${e.hint}`));
231
+ if (e.code === 'not_found' && (0, credentials_1.anyMultipleOrgs)()) {
232
+ (0, ui_1.note)((0, ui_1.dim)(' Several organizations are signed in: the answer may live in another one, see xflow org'));
233
+ }
219
234
  quiet = quiet || e.code === 'outdated_cli';
220
235
  return 1;
221
236
  }
@@ -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,7 +102,7 @@ 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);
@@ -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));
@@ -79,7 +79,11 @@ async function pushSources(root, config, client, options) {
79
79
  // a mismatch must not overwrite silently.
80
80
  if (server && state.revision === undefined && !options.force) {
81
81
  if (server.tree_hash === tree.hash) {
82
- (0, config_1.writeState)(root, { revision: server.revision, treeHash: server.tree_hash });
82
+ (0, config_1.writeState)(root, {
83
+ revision: server.revision,
84
+ treeHash: server.tree_hash,
85
+ organizationId: client.organizationId ?? undefined,
86
+ });
83
87
  (0, ui_1.ok)(`Already in sync, revision ${server.revision}`);
84
88
  return { revision: server.revision, status: 'unchanged' };
85
89
  }
@@ -94,7 +98,11 @@ async function pushSources(root, config, client, options) {
94
98
  if (options.force)
95
99
  headers['X-Force'] = 'true';
96
100
  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 });
101
+ (0, config_1.writeState)(root, {
102
+ revision: result.revision,
103
+ treeHash: result.tree_hash,
104
+ organizationId: client.organizationId ?? undefined,
105
+ });
98
106
  if (result.status === 'unchanged') {
99
107
  (0, ui_1.ok)(`No changes, revision ${result.revision}`);
100
108
  }
@@ -105,7 +113,7 @@ async function pushSources(root, config, client, options) {
105
113
  }
106
114
  async function push(args) {
107
115
  const { root, config } = (0, config_1.requireProject)();
108
- const client = (0, session_1.connect)(config);
116
+ const client = await (0, session_1.connectProject)(root, config);
109
117
  await pushSources(root, config, client, { force: (0, args_1.flagBool)(args, 'force') });
110
118
  }
111
119
  function hasContent(dir) {
@@ -117,7 +125,7 @@ async function pull(args) {
117
125
  const { root, config } = (0, config_1.requireProject)();
118
126
  const into = (0, args_1.flagString)(args, 'into');
119
127
  const target = into ? (0, node_path_1.resolve)(into) : root;
120
- const client = (0, session_1.connect)(config);
128
+ const client = await (0, session_1.connectProject)(root, config);
121
129
  const revision = (0, args_1.flagNumber)(args, 'revision');
122
130
  const query = revision !== undefined ? `?revision=${revision}` : '';
123
131
  const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/pull${query}`);
@@ -139,13 +147,17 @@ async function pull(args) {
139
147
  }
140
148
  // Only the working folder tracks revisions; a side copy (--into) must not reset them.
141
149
  if (!into) {
142
- (0, config_1.writeState)(root, { revision: info.revision, treeHash: info.tree_hash });
150
+ (0, config_1.writeState)(root, {
151
+ revision: info.revision,
152
+ treeHash: info.tree_hash,
153
+ organizationId: client.organizationId ?? undefined,
154
+ });
143
155
  }
144
156
  (0, ui_1.ok)(`Revision ${info.revision}: ${entries.length} files in ${target}`);
145
157
  }
146
158
  async function status() {
147
159
  const { root, config } = (0, config_1.requireProject)();
148
- const client = (0, session_1.connect)(config);
160
+ const client = await (0, session_1.connectProject)(root, config);
149
161
  const tree = prepareTree(root, config);
150
162
  const state = (0, config_1.readState)(root);
151
163
  const [card, server] = await Promise.all([
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
@@ -60,8 +60,10 @@ ${(0, ui_1.bold)('Reference')}
60
60
  xflow logs [--limit N] browser errors from the released application
61
61
  xflow projects list projects of the organization
62
62
  xflow projects get [id] project card
63
+ xflow org organizations with a stored key, the active one marked
64
+ xflow org switch <name|id> make another organization the active one
63
65
  xflow whoami whose key this is and what it can do
64
- xflow logout forget the key
66
+ xflow logout [--all] forget the key of the active organization (--all: every one)
65
67
  xflow update update the CLI itself, and the skill that ships with it
66
68
 
67
69
  ${(0, ui_1.bold)('Environment')}
@@ -71,6 +73,35 @@ ${(0, ui_1.bold)('Environment')}
71
73
  More about one command: xflow help <command>`);
72
74
  }
73
75
  const TOPICS = {
76
+ org: `${(0, ui_1.bold)('xflow org')}: several organizations, one terminal
77
+
78
+ One key per organization: every ${(0, ui_1.bold)('xflow login')} stores the key of the organization
79
+ chosen in the browser next to the ones already stored, it does not replace them.
80
+ Which key a command then uses, in order:
81
+
82
+ 1. XFLOW_TOKEN, when set (and only for the default platform address)
83
+ 2. the organization this folder is bound to (${(0, ui_1.bold)('.xflow/state.json')}, written by
84
+ init, link and the first successful push or pull)
85
+ 3. the active organization
86
+
87
+ xflow org the stored organizations, the active one marked
88
+ xflow org switch <name|id> make another one active, no browser involved
89
+ xflow logout forget the key of the active organization
90
+ xflow logout --all forget every key of this platform address
91
+
92
+ The switch is local: it changes which stored key is used and nothing happens on
93
+ the platform. A folder bound to an organization is not affected, its commands
94
+ stay in its own organization: two projects of two organizations in two terminals
95
+ work without switching anything.
96
+
97
+ Outside a project folder the switch also rewrites the key in the agent config
98
+ (see ${(0, ui_1.bold)('xflow mcp')}) when an xflow entry already exists there; a running agent
99
+ session picks the new key up only after a restart.
100
+
101
+ A project id is unique across the whole platform, so "project not found" under
102
+ the wrong organization can never touch somebody else's project. When that error
103
+ names a project you know exists, the key is simply from another organization:
104
+ check ${(0, ui_1.bold)('xflow org')}.`,
74
105
  update: `${(0, ui_1.bold)('xflow update')}: bring the CLI up to date
75
106
 
76
107
  Installs the published version and, if anything changed, rewrites the platform
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.7.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.7.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
@@ -400,6 +404,24 @@ Fetch their work next to yours (`xflow pull --into ./server-copy`), merge it loc
400
404
  then push again. `--force` destroys their work: a last resort, not a way around the
401
405
  error.
402
406
 
407
+ ## Organizations and keys
408
+
409
+ A key belongs to one organization, and `xflow login` stores it next to the ones already
410
+ stored instead of replacing them. Which key a command uses, in order: `XFLOW_TOKEN` when
411
+ set (default platform address only), then the organization the project folder is bound
412
+ to (`.xflow/state.json`, written by init, link and the first successful push or pull),
413
+ then the active organization. `xflow org` lists the stored organizations with the active
414
+ one marked, `xflow org switch <name|id>` makes another one active without a browser, and
415
+ `xflow whoami` names the organization behind the current key.
416
+
417
+ Inside a project folder there is nothing to switch: commands follow the folder's own
418
+ organization whatever the active one is, which is what lets two projects of two
419
+ organizations work side by side. Project ids are unique across the platform, so a key of
420
+ the wrong organization can never touch another organization's project: the command fails
421
+ with "not found" instead. When that error names a project you know exists, check
422
+ `xflow org`; signing in to a missing organization is `xflow login`, and that needs a
423
+ person with a browser.
424
+
403
425
  ## Direct access without the terminal
404
426
 
405
427
  The platform also exposes an MCP server, connected with `xflow mcp install`. When its tools