@getxflow/cli 0.6.5 → 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/api.js CHANGED
@@ -98,26 +98,50 @@ async function send(client, path, options = {}) {
98
98
  assertProtocol(response);
99
99
  return response;
100
100
  }
101
- async function readError(response) {
101
+ /**
102
+ * The body is not an answer of the API: every one of those is JSON with content.
103
+ * A page in its place, or nothing at all, means the request never reached a
104
+ * route, so we say that instead of quoting what came back.
105
+ *
106
+ * Markup never goes into the hint. The reader is an agent, and 200 characters of
107
+ * HTML read like a bug in its own code rather than like a platform that has not
108
+ * deployed this endpoint yet. An empty body is worse still: it used to leave no
109
+ * hint at all.
110
+ *
111
+ * Plain text is kept: some proxies say something useful in one line.
112
+ */
113
+ function notAnAnswer(status, apiUrl, body) {
114
+ const text = body.trim();
115
+ if (text.length > 0 && !text.startsWith('<')) {
116
+ return new ApiError(`Request rejected (${status})`, 'unknown', status, text.slice(0, 200));
117
+ }
118
+ // 404 is a missing route, 405 a route without this method. Both mean the same
119
+ // thing to the caller: this platform does not serve the endpoint yet.
120
+ if (status === 404 || status === 405) {
121
+ return new ApiError(`The platform does not serve that endpoint (${status})`, 'not_deployed', status, `This CLI is ${version_1.CLI_VERSION} and ${apiUrl} is older than it. Wait for the platform to deploy, or check XFLOW_API_URL`);
122
+ }
123
+ return new ApiError(`The platform is not answering (${status})`, 'unavailable', status, `Something on the way to ${apiUrl} answered with a page instead of an answer. Retry in a minute`);
124
+ }
125
+ async function readError(response, client) {
102
126
  const text = await response.text().catch(() => '');
103
127
  try {
104
128
  const parsed = JSON.parse(text);
105
129
  return new ApiError(parsed.error || `Request rejected (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed.issues, parsed.limit, parsed.removing);
106
130
  }
107
131
  catch {
108
- return new ApiError(`Request rejected (${response.status})`, 'unknown', response.status, text.slice(0, 200) || undefined);
132
+ return notAnAnswer(response.status, client.apiUrl, text);
109
133
  }
110
134
  }
111
135
  async function apiJson(client, path, options = {}) {
112
136
  const response = await send(client, path, options);
113
137
  if (!response.ok)
114
- throw await readError(response);
138
+ throw await readError(response, client);
115
139
  return (await response.json());
116
140
  }
117
141
  async function apiBinary(client, path) {
118
142
  const response = await send(client, path, { timeoutMs: TRANSFER_TIMEOUT_MS });
119
143
  if (!response.ok)
120
- throw await readError(response);
144
+ throw await readError(response, client);
121
145
  return Buffer.from(await response.arrayBuffer());
122
146
  }
123
147
  async function apiUpload(client, path, body, headers = {}) {
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;
@@ -107,11 +119,19 @@ async function run(args) {
107
119
  }
108
120
  throw new errors_1.CliError(`Unknown command: env ${second}`, 'Available: list, check, set and rm');
109
121
  case 'connections':
122
+ if (second === 'link') {
123
+ await (0, connections_1.connectionsLink)(rest);
124
+ return;
125
+ }
126
+ if (second === 'unlink') {
127
+ await (0, connections_1.connectionsUnlink)(rest);
128
+ return;
129
+ }
110
130
  if (second === undefined || second === 'list') {
111
131
  await (0, connections_1.connectionsList)();
112
132
  return;
113
133
  }
114
- throw new errors_1.CliError(`Unknown command: connections ${second}`, 'Available: list');
134
+ throw new errors_1.CliError(`Unknown command: connections ${second}`, 'Available: list, link and unlink');
115
135
  case 'schedules':
116
136
  if (second === 'set') {
117
137
  await (0, schedules_1.schedulesSet)(rest);
@@ -208,6 +228,9 @@ async function main() {
208
228
  (0, ui_1.note)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
209
229
  if (e.hint)
210
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
+ }
211
234
  quiet = quiet || e.code === 'outdated_cli';
212
235
  return 1;
213
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}` : '';
@@ -1,10 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.connectionsList = connectionsList;
4
+ exports.connectionsLink = connectionsLink;
5
+ exports.connectionsUnlink = connectionsUnlink;
4
6
  const api_1 = require("../api");
7
+ const args_1 = require("../args");
5
8
  const config_1 = require("../config");
9
+ const errors_1 = require("../errors");
6
10
  const session_1 = require("../session");
7
11
  const ui_1 = require("../ui");
12
+ const env_1 = require("./env");
8
13
  // An expiring token is renewed by any build, a revoked one is not renewed by
9
14
  // anything until a human reconnects the account. Telling them apart saves a
10
15
  // pointless rebuild, so every case gets its own line.
@@ -26,10 +31,35 @@ function state(row) {
26
31
  return 'linked';
27
32
  }
28
33
  }
34
+ function endpoint(projectId) {
35
+ return `/api/v1/projects/${projectId}/connections`;
36
+ }
37
+ /**
38
+ * Which connection the argument means. Identifiers are what the platform knows,
39
+ * names are what the list shows and what a human said out loud; both are
40
+ * accepted, so nobody has to copy a UUID from somewhere the command never
41
+ * printed it. Names repeat across accounts of the same service, so an ambiguous
42
+ * one is refused with the identifiers rather than resolved by luck.
43
+ */
44
+ function resolve(connections, target) {
45
+ const byId = connections.find((row) => row.id === target);
46
+ if (byId)
47
+ return byId;
48
+ const wanted = target.toLowerCase();
49
+ const named = connections.filter((row) => row.label.toLowerCase() === wanted);
50
+ if (named.length === 1)
51
+ return named[0];
52
+ if (named.length > 1) {
53
+ (0, ui_1.fail)(`The organization has ${named.length} connections named ${target}:`);
54
+ (0, ui_1.table)(named.map((row) => [row.id, row.connector_name ?? row.connector_key ?? '-']));
55
+ throw new errors_1.CliError('The name is ambiguous', 'Repeat with one of the identifiers above');
56
+ }
57
+ throw new errors_1.CliError(`No connection ${target}`, 'What this project can use: xflow connections');
58
+ }
29
59
  async function connectionsList() {
30
60
  const { config } = (0, config_1.requireProject)();
31
61
  const client = (0, session_1.connect)(config);
32
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/connections`);
62
+ const data = await (0, api_1.apiJson)(client, endpoint(config.projectId));
33
63
  if (data.connections.length === 0) {
34
64
  (0, ui_1.note)('No connections you can use in this organization');
35
65
  (0, ui_1.note)((0, ui_1.dim)(' Somebody connects an account first: platform settings, Connectors'));
@@ -47,6 +77,63 @@ async function connectionsList() {
47
77
  (0, ui_1.note)((0, ui_1.dim)(' A change reaches a function on its next deploy: xflow deploy'));
48
78
  }
49
79
  if (data.connections.some((row) => !row.alias)) {
50
- (0, ui_1.note)((0, ui_1.dim)(' To link one: project settings, Connectors. Needs the developer role on the project'));
80
+ (0, ui_1.note)((0, ui_1.dim)(' To link one: xflow connections link <name> --as ALIAS'));
81
+ }
82
+ }
83
+ async function connectionsLink(args) {
84
+ const target = args.words[1];
85
+ if (!target) {
86
+ throw new errors_1.CliError('A connection is required', 'Its name is the first column of xflow connections');
87
+ }
88
+ // The alias is asked for, never guessed: it becomes the prefix of the
89
+ // variables, it ends up in the code of the functions, and renaming it later
90
+ // means editing that code.
91
+ const alias = (0, args_1.flagString)(args, 'as');
92
+ if (!alias) {
93
+ throw new errors_1.CliError('An alias is required', 'For example: xflow connections link "Яндекс Метрика" --as YANDEX_METRIKA');
94
+ }
95
+ const { config } = (0, config_1.requireProject)();
96
+ const client = (0, session_1.connect)(config);
97
+ const data = await (0, api_1.apiJson)(client, endpoint(config.projectId));
98
+ const connectionId = resolve(data.connections, target).id;
99
+ const result = await (0, api_1.apiJson)(client, endpoint(config.projectId), { method: 'POST', body: { connection_id: connectionId, alias } });
100
+ (0, ui_1.ok)(`${(0, ui_1.bold)(result.label)} linked as ${result.alias}`);
101
+ if (result.env.length > 0)
102
+ (0, ui_1.note)((0, ui_1.dim)(` The functions will get: ${result.env.join(', ')}`));
103
+ (0, ui_1.note)((0, ui_1.dim)(' The values arrive on the next deploy: xflow deploy'));
104
+ }
105
+ async function connectionsUnlink(args) {
106
+ const target = args.words[1];
107
+ if (!target) {
108
+ throw new errors_1.CliError('A connection is required', 'For example: xflow connections unlink YANDEX_METRIKA');
109
+ }
110
+ const { root, config } = (0, config_1.requireProject)();
111
+ const client = (0, session_1.connect)(config);
112
+ // The alias is what the code of the functions knows, and it wins: it is the
113
+ // most precise of the three, since it names the link and not just the account.
114
+ const data = await (0, api_1.apiJson)(client, endpoint(config.projectId));
115
+ const byAlias = data.connections.find((item) => item.alias !== null && item.alias === target.toUpperCase());
116
+ const row = byAlias ?? resolve(data.connections, target);
117
+ if (!row.alias) {
118
+ (0, ui_1.note)(`${row.label} is not linked to this project`);
119
+ return;
120
+ }
121
+ // The platform does not see the sources, so this check lives here. Without it
122
+ // the unlink is silent and the breakage shows up one deploy later.
123
+ const needed = (0, env_1.referencedByFunctions)(root).needed;
124
+ const inUse = row.env
125
+ .map((name) => ({ name, users: needed.get(name) ?? [] }))
126
+ .filter((entry) => entry.users.length > 0);
127
+ if (inUse.length > 0 && !(0, args_1.flagBool)(args, 'force')) {
128
+ (0, ui_1.fail)(`The functions of this project read the variables of ${row.alias}:`);
129
+ (0, ui_1.table)(inUse.map((entry) => [entry.name, entry.users.join(', ')]));
130
+ throw new errors_1.CliError('They would lose these variables on the next deploy', 'Repeat with --force if that is intended');
131
+ }
132
+ const result = await (0, api_1.apiJson)(client, `${endpoint(config.projectId)}?connection_id=${encodeURIComponent(row.id)}`, { method: 'DELETE' });
133
+ if (!result.removed) {
134
+ (0, ui_1.note)(`${row.label} is not linked to this project`);
135
+ return;
51
136
  }
137
+ (0, ui_1.ok)(`${(0, ui_1.bold)(row.label)} unlinked, ${result.alias} is gone`);
138
+ (0, ui_1.note)((0, ui_1.dim)(' Functions already deployed keep the values until their next deploy'));
52
139
  }
@@ -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.referencedByFunctions = referencedByFunctions;
3
4
  exports.envList = envList;
4
5
  exports.envCheck = envCheck;
5
6
  exports.envSet = envSet;
@@ -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));