@getxflow/cli 0.6.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin.js +16 -4
- package/dist/commands/auth.js +58 -30
- package/dist/commands/deploy.js +8 -8
- 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 +20 -14
- package/dist/config.js +20 -1
- package/dist/credentials.js +203 -17
- package/dist/help.js +45 -17
- package/dist/session.js +68 -5
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +40 -13
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ else's code belongs next to it.
|
|
|
18
18
|
|---|---|
|
|
19
19
|
| `login` / `logout` / `whoami` | sign in through the browser, sign out, whose key this is and what it can do |
|
|
20
20
|
| `init` / `link` | new project, link a folder to an existing one |
|
|
21
|
-
| `status` / `
|
|
21
|
+
| `status` / `pull` | state of the sources on the server and fetching them back |
|
|
22
22
|
| `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
|
|
23
23
|
| `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
|
|
24
24
|
| `db schema` / `db query` | tables and columns, reading data in a read-only transaction |
|
package/dist/bin.js
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
const api_1 = require("./api");
|
|
5
5
|
const args_1 = require("./args");
|
|
6
6
|
const config_1 = require("./config");
|
|
7
|
+
const credentials_1 = require("./credentials");
|
|
7
8
|
const errors_1 = require("./errors");
|
|
8
9
|
const help_1 = require("./help");
|
|
9
10
|
const limits_1 = require("./limits");
|
|
@@ -19,6 +20,7 @@ const env_1 = require("./commands/env");
|
|
|
19
20
|
const functions_1 = require("./commands/functions");
|
|
20
21
|
const logs_1 = require("./commands/logs");
|
|
21
22
|
const mcp_1 = require("./commands/mcp");
|
|
23
|
+
const org_1 = require("./commands/org");
|
|
22
24
|
const schedules_1 = require("./commands/schedules");
|
|
23
25
|
const skills_1 = require("./commands/skills");
|
|
24
26
|
const sources_1 = require("./commands/sources");
|
|
@@ -38,11 +40,21 @@ async function run(args) {
|
|
|
38
40
|
await (0, auth_1.login)();
|
|
39
41
|
return;
|
|
40
42
|
case 'logout':
|
|
41
|
-
(0, auth_1.logout)();
|
|
43
|
+
(0, auth_1.logout)(rest);
|
|
42
44
|
return;
|
|
43
45
|
case 'whoami':
|
|
44
46
|
await (0, auth_1.whoami)();
|
|
45
47
|
return;
|
|
48
|
+
case 'org':
|
|
49
|
+
if (second === 'switch') {
|
|
50
|
+
(0, org_1.orgSwitch)({ ...args, words: args.words.slice(2) });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (second === undefined || second === 'list') {
|
|
54
|
+
(0, org_1.orgList)();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
throw new errors_1.CliError(`Unknown command: org ${second}`, 'Available: list and switch');
|
|
46
58
|
case 'templates':
|
|
47
59
|
await (0, projects_1.templates)();
|
|
48
60
|
return;
|
|
@@ -155,9 +167,6 @@ async function run(args) {
|
|
|
155
167
|
case 'status':
|
|
156
168
|
await (0, sources_1.status)();
|
|
157
169
|
return;
|
|
158
|
-
case 'push':
|
|
159
|
-
await (0, sources_1.push)(rest);
|
|
160
|
-
return;
|
|
161
170
|
case 'pull':
|
|
162
171
|
await (0, sources_1.pull)(rest);
|
|
163
172
|
return;
|
|
@@ -216,6 +225,9 @@ async function main() {
|
|
|
216
225
|
(0, ui_1.note)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
|
|
217
226
|
if (e.hint)
|
|
218
227
|
(0, ui_1.note)((0, ui_1.dim)(` ${e.hint}`));
|
|
228
|
+
if (e.code === 'not_found' && (0, credentials_1.anyMultipleOrgs)()) {
|
|
229
|
+
(0, ui_1.note)((0, ui_1.dim)(' Several organizations are signed in: the answer may live in another one, see xflow org'));
|
|
230
|
+
}
|
|
219
231
|
quiet = quiet || e.code === 'outdated_cli';
|
|
220
232
|
return 1;
|
|
221
233
|
}
|
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,12 +102,12 @@ async function startBuild(client, projectId, revision, allowRemovals) {
|
|
|
102
102
|
}
|
|
103
103
|
async function deploy(args) {
|
|
104
104
|
const { root, config } = (0, config_1.requireProject)();
|
|
105
|
-
const client = (0, session_1.
|
|
105
|
+
const client = await (0, session_1.connectProject)(root, config);
|
|
106
106
|
let revision;
|
|
107
107
|
if ((0, args_1.flagBool)(args, 'no-push')) {
|
|
108
108
|
const server = await (0, sources_1.latestRevision)(client, config.projectId);
|
|
109
109
|
if (!server) {
|
|
110
|
-
throw new errors_1.CliError('The server holds no sources', '
|
|
110
|
+
throw new errors_1.CliError('The server holds no sources', 'Repeat without --no-push, so that the sources go up first');
|
|
111
111
|
}
|
|
112
112
|
revision = server.revision;
|
|
113
113
|
(0, ui_1.warn)(`Sources not sent (--no-push), the build runs from revision ${revision}`);
|
|
@@ -148,8 +148,8 @@ async function deploy(args) {
|
|
|
148
148
|
(0, ui_1.note)((0, ui_1.dim)(' Show it to visitors: xflow publish'));
|
|
149
149
|
}
|
|
150
150
|
async function publish() {
|
|
151
|
-
const { config } = (0, config_1.requireProject)();
|
|
152
|
-
const client = (0, session_1.
|
|
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
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.latestRevision = latestRevision;
|
|
4
4
|
exports.pushSources = pushSources;
|
|
5
|
-
exports.push = push;
|
|
6
5
|
exports.pull = pull;
|
|
7
6
|
exports.status = status;
|
|
8
7
|
const node_fs_1 = require("node:fs");
|
|
@@ -69,7 +68,7 @@ async function confirmForce(client, projectId, server, local) {
|
|
|
69
68
|
if (!confirmed)
|
|
70
69
|
throw new errors_1.CliError('Cancelled');
|
|
71
70
|
}
|
|
72
|
-
/**
|
|
71
|
+
/** The first step of deploy: sources go up as a revision before the build. */
|
|
73
72
|
async function pushSources(root, config, client, options) {
|
|
74
73
|
const tree = prepareTree(root, config);
|
|
75
74
|
const state = (0, config_1.readState)(root);
|
|
@@ -79,7 +78,11 @@ async function pushSources(root, config, client, options) {
|
|
|
79
78
|
// a mismatch must not overwrite silently.
|
|
80
79
|
if (server && state.revision === undefined && !options.force) {
|
|
81
80
|
if (server.tree_hash === tree.hash) {
|
|
82
|
-
(0, config_1.writeState)(root, {
|
|
81
|
+
(0, config_1.writeState)(root, {
|
|
82
|
+
revision: server.revision,
|
|
83
|
+
treeHash: server.tree_hash,
|
|
84
|
+
organizationId: client.organizationId ?? undefined,
|
|
85
|
+
});
|
|
83
86
|
(0, ui_1.ok)(`Already in sync, revision ${server.revision}`);
|
|
84
87
|
return { revision: server.revision, status: 'unchanged' };
|
|
85
88
|
}
|
|
@@ -94,7 +97,11 @@ async function pushSources(root, config, client, options) {
|
|
|
94
97
|
if (options.force)
|
|
95
98
|
headers['X-Force'] = 'true';
|
|
96
99
|
const result = await (0, api_1.apiUpload)(client, `/api/v1/projects/${config.projectId}/push`, tree.archive, headers);
|
|
97
|
-
(0, config_1.writeState)(root, {
|
|
100
|
+
(0, config_1.writeState)(root, {
|
|
101
|
+
revision: result.revision,
|
|
102
|
+
treeHash: result.tree_hash,
|
|
103
|
+
organizationId: client.organizationId ?? undefined,
|
|
104
|
+
});
|
|
98
105
|
if (result.status === 'unchanged') {
|
|
99
106
|
(0, ui_1.ok)(`No changes, revision ${result.revision}`);
|
|
100
107
|
}
|
|
@@ -103,11 +110,6 @@ async function pushSources(root, config, client, options) {
|
|
|
103
110
|
}
|
|
104
111
|
return { revision: result.revision, status: result.status };
|
|
105
112
|
}
|
|
106
|
-
async function push(args) {
|
|
107
|
-
const { root, config } = (0, config_1.requireProject)();
|
|
108
|
-
const client = (0, session_1.connect)(config);
|
|
109
|
-
await pushSources(root, config, client, { force: (0, args_1.flagBool)(args, 'force') });
|
|
110
|
-
}
|
|
111
113
|
function hasContent(dir) {
|
|
112
114
|
if (!(0, node_fs_1.existsSync)(dir))
|
|
113
115
|
return false;
|
|
@@ -117,7 +119,7 @@ async function pull(args) {
|
|
|
117
119
|
const { root, config } = (0, config_1.requireProject)();
|
|
118
120
|
const into = (0, args_1.flagString)(args, 'into');
|
|
119
121
|
const target = into ? (0, node_path_1.resolve)(into) : root;
|
|
120
|
-
const client = (0, session_1.
|
|
122
|
+
const client = await (0, session_1.connectProject)(root, config);
|
|
121
123
|
const revision = (0, args_1.flagNumber)(args, 'revision');
|
|
122
124
|
const query = revision !== undefined ? `?revision=${revision}` : '';
|
|
123
125
|
const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/pull${query}`);
|
|
@@ -139,13 +141,17 @@ async function pull(args) {
|
|
|
139
141
|
}
|
|
140
142
|
// Only the working folder tracks revisions; a side copy (--into) must not reset them.
|
|
141
143
|
if (!into) {
|
|
142
|
-
(0, config_1.writeState)(root, {
|
|
144
|
+
(0, config_1.writeState)(root, {
|
|
145
|
+
revision: info.revision,
|
|
146
|
+
treeHash: info.tree_hash,
|
|
147
|
+
organizationId: client.organizationId ?? undefined,
|
|
148
|
+
});
|
|
143
149
|
}
|
|
144
150
|
(0, ui_1.ok)(`Revision ${info.revision}: ${entries.length} files in ${target}`);
|
|
145
151
|
}
|
|
146
152
|
async function status() {
|
|
147
153
|
const { root, config } = (0, config_1.requireProject)();
|
|
148
|
-
const client = (0, session_1.
|
|
154
|
+
const client = await (0, session_1.connectProject)(root, config);
|
|
149
155
|
const tree = prepareTree(root, config);
|
|
150
156
|
const state = (0, config_1.readState)(root);
|
|
151
157
|
const [card, server] = await Promise.all([
|
|
@@ -157,7 +163,7 @@ async function status() {
|
|
|
157
163
|
(0, ui_1.out)(`Local: ${tree.files.length} files, ${(0, ui_1.formatBytes)(tree.archive.length)} archived`);
|
|
158
164
|
if (!server) {
|
|
159
165
|
(0, ui_1.out)('On the server: no sources yet');
|
|
160
|
-
(0, ui_1.note)((0, ui_1.dim)(' Send them: xflow
|
|
166
|
+
(0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
|
|
161
167
|
}
|
|
162
168
|
else {
|
|
163
169
|
(0, ui_1.out)(`On the server: revision ${server.revision}, ${server.file_count} files, ${(0, ui_1.formatAge)(server.created_at)}`);
|
|
@@ -170,7 +176,7 @@ async function status() {
|
|
|
170
176
|
}
|
|
171
177
|
else {
|
|
172
178
|
(0, ui_1.out)(`State: ${(0, ui_1.bold)('local changes')}`);
|
|
173
|
-
(0, ui_1.note)((0, ui_1.dim)(' Send them: xflow
|
|
179
|
+
(0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
|
|
174
180
|
}
|
|
175
181
|
}
|
|
176
182
|
(0, ui_1.out)('');
|
package/dist/config.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.ConfigError = exports.CONFIG_FILE = void 0;
|
|
|
4
4
|
exports.findProjectRoot = findProjectRoot;
|
|
5
5
|
exports.readConfig = readConfig;
|
|
6
6
|
exports.writeConfig = writeConfig;
|
|
7
|
+
exports.localConfig = localConfig;
|
|
7
8
|
exports.requireProject = requireProject;
|
|
8
9
|
exports.apiUrlFor = apiUrlFor;
|
|
9
10
|
exports.readState = readState;
|
|
@@ -47,6 +48,18 @@ function readConfig(root) {
|
|
|
47
48
|
function writeConfig(root, config) {
|
|
48
49
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, exports.CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
|
49
50
|
}
|
|
51
|
+
/** The config of the folder we are in, when there is one and it parses. */
|
|
52
|
+
function localConfig() {
|
|
53
|
+
const root = findProjectRoot();
|
|
54
|
+
if (!root)
|
|
55
|
+
return undefined;
|
|
56
|
+
try {
|
|
57
|
+
return readConfig(root);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
50
63
|
function requireProject() {
|
|
51
64
|
const root = findProjectRoot();
|
|
52
65
|
if (!root) {
|
|
@@ -75,9 +88,15 @@ function readState(root) {
|
|
|
75
88
|
return {};
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
|
-
|
|
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
|
@@ -19,9 +19,8 @@ ${(0, ui_1.bold)('Getting started')}
|
|
|
19
19
|
xflow skills pick the agents that get the platform instructions
|
|
20
20
|
xflow mcp install give the agent platform access without a terminal
|
|
21
21
|
|
|
22
|
-
${(0, ui_1.bold)('
|
|
22
|
+
${(0, ui_1.bold)('Sources')}
|
|
23
23
|
xflow status what is on the server and how the local copy differs
|
|
24
|
-
xflow push [--force] send the sources as a new revision
|
|
25
24
|
xflow pull [--into dir] [--revision N]
|
|
26
25
|
fetch the sources (the latest revision by default)
|
|
27
26
|
|
|
@@ -60,8 +59,10 @@ ${(0, ui_1.bold)('Reference')}
|
|
|
60
59
|
xflow logs [--limit N] browser errors from the released application
|
|
61
60
|
xflow projects list projects of the organization
|
|
62
61
|
xflow projects get [id] project card
|
|
62
|
+
xflow org organizations with a stored key, the active one marked
|
|
63
|
+
xflow org switch <name|id> make another organization the active one
|
|
63
64
|
xflow whoami whose key this is and what it can do
|
|
64
|
-
xflow logout
|
|
65
|
+
xflow logout [--all] forget the key of the active organization (--all: every one)
|
|
65
66
|
xflow update update the CLI itself, and the skill that ships with it
|
|
66
67
|
|
|
67
68
|
${(0, ui_1.bold)('Environment')}
|
|
@@ -71,6 +72,35 @@ ${(0, ui_1.bold)('Environment')}
|
|
|
71
72
|
More about one command: xflow help <command>`);
|
|
72
73
|
}
|
|
73
74
|
const TOPICS = {
|
|
75
|
+
org: `${(0, ui_1.bold)('xflow org')}: several organizations, one terminal
|
|
76
|
+
|
|
77
|
+
One key per organization: every ${(0, ui_1.bold)('xflow login')} stores the key of the organization
|
|
78
|
+
chosen in the browser next to the ones already stored, it does not replace them.
|
|
79
|
+
Which key a command then uses, in order:
|
|
80
|
+
|
|
81
|
+
1. XFLOW_TOKEN, when set (and only for the default platform address)
|
|
82
|
+
2. the organization this folder is bound to (${(0, ui_1.bold)('.xflow/state.json')}, written by
|
|
83
|
+
init, link and the first successful deploy or pull)
|
|
84
|
+
3. the active organization
|
|
85
|
+
|
|
86
|
+
xflow org the stored organizations, the active one marked
|
|
87
|
+
xflow org switch <name|id> make another one active, no browser involved
|
|
88
|
+
xflow logout forget the key of the active organization
|
|
89
|
+
xflow logout --all forget every key of this platform address
|
|
90
|
+
|
|
91
|
+
The switch is local: it changes which stored key is used and nothing happens on
|
|
92
|
+
the platform. A folder bound to an organization is not affected, its commands
|
|
93
|
+
stay in its own organization: two projects of two organizations in two terminals
|
|
94
|
+
work without switching anything.
|
|
95
|
+
|
|
96
|
+
Outside a project folder the switch also rewrites the key in the agent config
|
|
97
|
+
(see ${(0, ui_1.bold)('xflow mcp')}) when an xflow entry already exists there; a running agent
|
|
98
|
+
session picks the new key up only after a restart.
|
|
99
|
+
|
|
100
|
+
A project id is unique across the whole platform, so "project not found" under
|
|
101
|
+
the wrong organization can never touch somebody else's project. When that error
|
|
102
|
+
names a project you know exists, the key is simply from another organization:
|
|
103
|
+
check ${(0, ui_1.bold)('xflow org')}.`,
|
|
74
104
|
update: `${(0, ui_1.bold)('xflow update')}: bring the CLI up to date
|
|
75
105
|
|
|
76
106
|
Installs the published version and, if anything changed, rewrites the platform
|
|
@@ -291,20 +321,6 @@ Plus a few pointer lines in ${(0, ui_1.bold)('AGENTS.md')}. A skill is picked up
|
|
|
291
321
|
description matches the task, and "add a customers table" will not trigger it. AGENTS.md
|
|
292
322
|
is always read by the agent, which is why the pointer is appended there, at the end of
|
|
293
323
|
the file and once. ${(0, ui_1.bold)('CLAUDE.md')} is left alone: Claude Code reads .claude/skills anyway.`,
|
|
294
|
-
push: `${(0, ui_1.bold)('xflow push')}: send the sources
|
|
295
|
-
|
|
296
|
-
The whole working copy goes up at once, as one revision. What is not sent:
|
|
297
|
-
node_modules, .git, dist, build, .next, any .env, plus everything listed in
|
|
298
|
-
.xflowignore and in the ignore field of xflow.json.
|
|
299
|
-
|
|
300
|
-
If the server holds a revision newer than the one you worked from, the push is
|
|
301
|
-
rejected. That means somebody pushed before you: fetch their changes alongside
|
|
302
|
-
(${(0, ui_1.bold)('xflow pull --into ./server-copy')}), merge them in git on your side, and retry.
|
|
303
|
-
|
|
304
|
-
--force overwrites the server revision. Before that the CLI shows whose work you are
|
|
305
|
-
about to destroy and asks for confirmation by typing the project name. In a
|
|
306
|
-
non-interactive run (CI, an agent) there is no way to confirm: a version conflict has
|
|
307
|
-
to fail the build rather than silently destroy somebody else's work.`,
|
|
308
324
|
pull: `${(0, ui_1.bold)('xflow pull')}: fetch the sources
|
|
309
325
|
|
|
310
326
|
By default it fetches the latest revision into the project folder and refuses to write
|
|
@@ -323,6 +339,18 @@ by default).
|
|
|
323
339
|
--force allow overwriting the server revision while sending
|
|
324
340
|
--allow-removals agree in advance to remove the functions gone from the sources
|
|
325
341
|
|
|
342
|
+
The whole working copy goes up at once, as one revision. What is not sent: node_modules,
|
|
343
|
+
.git, dist, build, .next, any .env, plus everything listed in .xflowignore and in the
|
|
344
|
+
ignore field of xflow.json.
|
|
345
|
+
|
|
346
|
+
If the server holds a revision newer than the one you worked from, the sources are
|
|
347
|
+
rejected and the build does not start. That means somebody deployed before you: fetch
|
|
348
|
+
their changes alongside (${(0, ui_1.bold)('xflow pull --into ./server-copy')}), merge them in git on
|
|
349
|
+
your side, and retry. ${(0, ui_1.bold)('--force')} overwrites the server revision, and before that the
|
|
350
|
+
CLI shows whose work you are about to destroy and asks for confirmation by typing the
|
|
351
|
+
project name. In a non-interactive run (CI, an agent) there is no way to confirm: a
|
|
352
|
+
version conflict has to fail the build rather than silently destroy somebody else's work.
|
|
353
|
+
|
|
326
354
|
The platform builds, in a clean sandbox on one Node version for everybody, so "it
|
|
327
355
|
worked on my machine" no longer depends on your machine. Before the build the project
|
|
328
356
|
is checked against the template: mismatches are printed as a list and the build does
|
package/dist/session.js
CHANGED
|
@@ -2,21 +2,84 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.connect = connect;
|
|
4
4
|
exports.anonymous = anonymous;
|
|
5
|
+
exports.findProjectOrg = findProjectOrg;
|
|
6
|
+
exports.connectProject = connectProject;
|
|
7
|
+
const api_1 = require("./api");
|
|
5
8
|
const config_1 = require("./config");
|
|
6
9
|
const credentials_1 = require("./credentials");
|
|
7
10
|
const errors_1 = require("./errors");
|
|
11
|
+
const ui_1 = require("./ui");
|
|
8
12
|
/** Platform connection: address plus key. */
|
|
9
13
|
function connect(config) {
|
|
10
14
|
const apiUrl = (0, config_1.apiUrlFor)(config);
|
|
11
|
-
|
|
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.8.0';
|
|
6
6
|
/** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
|
|
7
7
|
exports.DEFAULT_API_URL = 'https://app.getxflow.com';
|
package/package.json
CHANGED
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
|
|
@@ -357,12 +361,17 @@ const link = await fetch(`${process.env.XFLOW_API_URL}/api/storage/project/uploa
|
|
|
357
361
|
```
|
|
358
362
|
|
|
359
363
|
`confirm` takes the same fields plus the returned `s3Key` and answers with the file and its
|
|
360
|
-
|
|
364
|
+
address; `delete` takes that same `url`. Never pipe the bytes through the function itself.
|
|
361
365
|
|
|
362
366
|
There is no endpoint that lists the files back, so the address that `confirm` returns is the
|
|
363
367
|
only copy you get: write it into a table of your own in the same call, and the application
|
|
364
368
|
reads its files from there.
|
|
365
369
|
|
|
370
|
+
That address never expires and is safe to store, but it is not a public link. It opens only
|
|
371
|
+
for a visitor who is signed in and has access to this project, the same rule that guards the
|
|
372
|
+
application itself, so it works on your pages and does nothing in an email or on a page
|
|
373
|
+
anyone can open.
|
|
374
|
+
|
|
366
375
|
Four things bite an upload that otherwise looks right, and none of them is obvious from the
|
|
367
376
|
answers you get:
|
|
368
377
|
|
|
@@ -393,12 +402,30 @@ key that reached a visitor lets them delete every file of the project.
|
|
|
393
402
|
## Syncing code
|
|
394
403
|
|
|
395
404
|
`xflow status` shows how the local copy differs from the server revision.
|
|
396
|
-
`xflow
|
|
397
|
-
|
|
398
|
-
If a
|
|
399
|
-
Fetch their work next to yours (`xflow pull --into ./server-copy`),
|
|
400
|
-
then
|
|
401
|
-
error.
|
|
405
|
+
`xflow deploy` sends sources, `xflow pull` fetches them.
|
|
406
|
+
|
|
407
|
+
If a deploy is rejected before the build starts, the server revision is newer, meaning
|
|
408
|
+
someone deployed first. Fetch their work next to yours (`xflow pull --into ./server-copy`),
|
|
409
|
+
merge it locally, then deploy again. `--force` destroys their work: a last resort, not a
|
|
410
|
+
way around the error.
|
|
411
|
+
|
|
412
|
+
## Organizations and keys
|
|
413
|
+
|
|
414
|
+
A key belongs to one organization, and `xflow login` stores it next to the ones already
|
|
415
|
+
stored instead of replacing them. Which key a command uses, in order: `XFLOW_TOKEN` when
|
|
416
|
+
set (default platform address only), then the organization the project folder is bound
|
|
417
|
+
to (`.xflow/state.json`, written by init, link and the first successful deploy or pull),
|
|
418
|
+
then the active organization. `xflow org` lists the stored organizations with the active
|
|
419
|
+
one marked, `xflow org switch <name|id>` makes another one active without a browser, and
|
|
420
|
+
`xflow whoami` names the organization behind the current key.
|
|
421
|
+
|
|
422
|
+
Inside a project folder there is nothing to switch: commands follow the folder's own
|
|
423
|
+
organization whatever the active one is, which is what lets two projects of two
|
|
424
|
+
organizations work side by side. Project ids are unique across the platform, so a key of
|
|
425
|
+
the wrong organization can never touch another organization's project: the command fails
|
|
426
|
+
with "not found" instead. When that error names a project you know exists, check
|
|
427
|
+
`xflow org`; signing in to a missing organization is `xflow login`, and that needs a
|
|
428
|
+
person with a browser.
|
|
402
429
|
|
|
403
430
|
## Direct access without the terminal
|
|
404
431
|
|
|
@@ -408,8 +435,8 @@ read-only queries, migrations, function logs and invocations, schedules, environ
|
|
|
408
435
|
variables, versions, publish and rollback. They answer with aggregates and say explicitly
|
|
409
436
|
when a result is truncated, which parsing terminal output does not.
|
|
410
437
|
|
|
411
|
-
Anything that depends on the working copy stays in the CLI: sending sources
|
|
412
|
-
|
|
438
|
+
Anything that depends on the working copy stays in the CLI: sending sources, building and
|
|
439
|
+
shipping the functions (`xflow deploy`), creating a project (`xflow init`). The
|
|
413
440
|
tools cannot see the folder you are working in, so a build started from there would release
|
|
414
441
|
whatever revision the server happens to hold, not what you have on disk. Pulling a repository
|
|
415
442
|
through tool calls also burns the user's tokens for nothing.
|
|
@@ -418,7 +445,7 @@ through tool calls also burns the user's tokens for nothing.
|
|
|
418
445
|
|
|
419
446
|
- Edit `xflow.json` by hand: the CLI writes it.
|
|
420
447
|
- Commit `.env`: it holds the project token.
|
|
421
|
-
-
|
|
448
|
+
- Deploy with `--force` without checking `xflow status` first.
|
|
422
449
|
- Invent platform commands: what is not in `xflow help` does not exist.
|
|
423
450
|
|
|
424
451
|
## App design
|