@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 +16 -1
- package/dist/commands/auth.js +58 -30
- package/dist/commands/deploy.js +7 -7
- package/dist/commands/mcp.js +21 -0
- package/dist/commands/org.js +80 -0
- package/dist/commands/projects.js +28 -2
- package/dist/commands/sources.js +18 -6
- package/dist/config.js +20 -1
- package/dist/credentials.js +203 -17
- package/dist/help.js +32 -1
- package/dist/session.js +68 -5
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +25 -3
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
|
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
-
(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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,
|
|
89
|
-
(0,
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
if (
|
|
110
|
-
(0, ui_1.note)((0, ui_1.dim)(' The key comes from XFLOW_TOKEN, the
|
|
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
|
|
113
|
-
(0,
|
|
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}` : '';
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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));
|
package/dist/commands/sources.js
CHANGED
|
@@ -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, {
|
|
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, {
|
|
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.
|
|
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.
|
|
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, {
|
|
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.
|
|
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
|
-
|
|
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. */
|
package/dist/credentials.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.
|
|
5
|
-
exports.
|
|
6
|
-
exports.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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.
|
|
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
package/skills/xflow/SKILL.md
CHANGED
|
@@ -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
|
|
316
|
-
goes for every name starting with `XFLOW`: the platform
|
|
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
|