@getxflow/cli 0.8.1 → 0.9.1
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 +5 -3
- package/dist/commands/deploy.js +27 -10
- package/dist/commands/env.js +48 -10
- package/dist/commands/projects.js +22 -0
- package/dist/commands/sources.js +63 -20
- package/dist/help.js +17 -9
- package/dist/ui.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/xflow/SKILL.md +34 -8
- package/dist/commands/update.test.js +0 -34
- package/dist/limits.test.js +0 -25
package/README.md
CHANGED
|
@@ -17,17 +17,19 @@ else's code belongs next to it.
|
|
|
17
17
|
| Command | What it does |
|
|
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
|
+
| `org` / `org switch` | organizations with a stored key, and which one this folder works with |
|
|
21
|
+
| `init` / `link` / `templates` | new project, link a folder to an existing one, which templates there are |
|
|
21
22
|
| `status` / `pull` | state of the sources on the server and fetching them back |
|
|
22
|
-
| `deploy` / `publish` / `rollback` / `deployments` | build, publish,
|
|
23
|
+
| `deploy` / `publish` / `rollback` / `deployments` | build, publish, serve earlier pages, version history |
|
|
23
24
|
| `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
|
|
24
25
|
| `db schema` / `db query` | tables and columns, reading data in a read-only transaction |
|
|
25
26
|
| `functions list` | cloud functions of the project from `functions/<name>/index.ts`, shipped by `deploy` |
|
|
26
27
|
| `functions invoke` / `functions logs` | call a function, look at its crashes with the stack |
|
|
27
28
|
| `schedules list` / `set` / `rm` | running functions on a timer (timer triggers) |
|
|
28
29
|
| `env` / `env check` / `env set` | environment variables of the functions, values are never handed back |
|
|
30
|
+
| `connections` / `connections link` / `connections unlink` | connected accounts the functions can use, and their variables |
|
|
29
31
|
| `logs` | browser errors from the released application |
|
|
30
|
-
| `projects list` / `projects get`
|
|
32
|
+
| `projects list` / `projects get` | projects of the organization and the application addresses |
|
|
31
33
|
| `skills` | platform instructions for an AI agent ([Agent Skills](https://agentskills.io) format) |
|
|
32
34
|
| `mcp install` | connect the agent to the platform directly, without a terminal |
|
|
33
35
|
| `update` | update the CLI itself, and the skill that ships inside it |
|
package/dist/commands/deploy.js
CHANGED
|
@@ -18,6 +18,8 @@ const sources_1 = require("./sources");
|
|
|
18
18
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
19
|
/** Slightly longer than the platform's own build timeout. */
|
|
20
20
|
const WAIT_LIMIT_MS = 16 * 60_000;
|
|
21
|
+
/** Said the same way with a terminal and without one. */
|
|
22
|
+
const REMOVAL_IS_FINAL = 'Removal is final: a function created again later gets a different address, schedules included';
|
|
21
23
|
/** Print every check violation at once. */
|
|
22
24
|
function reportIssues(e) {
|
|
23
25
|
const issues = e.issues ?? [];
|
|
@@ -81,13 +83,19 @@ async function startBuild(client, projectId, revision, allowRemovals) {
|
|
|
81
83
|
catch (e) {
|
|
82
84
|
if (e instanceof api_1.ApiError && e.issues?.length)
|
|
83
85
|
reportIssues(e);
|
|
84
|
-
|
|
85
|
-
// No terminal (CI, an agent): the flag is the only way to say yes, and the
|
|
86
|
-
// platform message already names it.
|
|
87
|
-
if (removing.length === 0 || process.stdin.isTTY !== true || process.stderr.isTTY !== true)
|
|
86
|
+
if (!(e instanceof api_1.ApiError) || e.code !== 'confirm_required')
|
|
88
87
|
throw e;
|
|
88
|
+
const removing = e.removing ?? [];
|
|
89
|
+
if (removing.length === 0)
|
|
90
|
+
throw e;
|
|
91
|
+
// No terminal (CI, an agent): the flag is the only way to say yes. The platform
|
|
92
|
+
// hint names the API field, so translate it into the flag this CLI accepts.
|
|
93
|
+
if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
|
|
94
|
+
throw new errors_1.CliError(e.message, `${REMOVAL_IS_FINAL}. If that is intended, retry with --allow-removals. ` +
|
|
95
|
+
'If it is not, bring the functions/<name>/index.ts directories back');
|
|
96
|
+
}
|
|
89
97
|
(0, ui_1.warn)(`Gone from the sources, the build would remove them: ${removing.join(', ')}`);
|
|
90
|
-
(0, ui_1.note)((0, ui_1.dim)(
|
|
98
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${REMOVAL_IS_FINAL}`));
|
|
91
99
|
const answer = await (0, prompt_1.select)('Remove them from the cloud?', [
|
|
92
100
|
{ label: 'No, stop here', hint: 'bring the functions/<name>/index.ts directories back' },
|
|
93
101
|
{ label: 'Yes, remove and build', hint: removing.join(', ') },
|
|
@@ -158,7 +166,9 @@ async function deploy(args) {
|
|
|
158
166
|
catch {
|
|
159
167
|
(0, ui_1.warn)('Could not refresh the function addresses in .env, the build itself is fine');
|
|
160
168
|
}
|
|
161
|
-
(0, ui_1.note)((0, ui_1.dim)('
|
|
169
|
+
(0, ui_1.note)((0, ui_1.dim)(' The cloud functions of this build are already live: they are one per project'));
|
|
170
|
+
(0, ui_1.note)((0, ui_1.dim)(' and are not held back by publishing'));
|
|
171
|
+
(0, ui_1.note)((0, ui_1.dim)(' Show the new pages to visitors: xflow publish'));
|
|
162
172
|
}
|
|
163
173
|
async function publish() {
|
|
164
174
|
const { root, config } = (0, config_1.requireProject)();
|
|
@@ -169,9 +179,11 @@ async function publish() {
|
|
|
169
179
|
(0, ui_1.ok)('This version is already published');
|
|
170
180
|
}
|
|
171
181
|
else {
|
|
172
|
-
(0, ui_1.ok)(`Version ${result.deploy_id} published: visitors see
|
|
182
|
+
(0, ui_1.ok)(`Version ${result.deploy_id} published: visitors see its pages now`);
|
|
173
183
|
}
|
|
174
184
|
(0, ui_1.out)(result.project_url);
|
|
185
|
+
(0, ui_1.note)((0, ui_1.dim)(' Publishing moves the pages only. Cloud functions are one per project: visitors'));
|
|
186
|
+
(0, ui_1.note)((0, ui_1.dim)(' have been running the code of the last xflow deploy since it finished'));
|
|
175
187
|
}
|
|
176
188
|
async function rollback(args) {
|
|
177
189
|
const { root, config } = (0, config_1.requireProject)();
|
|
@@ -181,12 +193,15 @@ async function rollback(args) {
|
|
|
181
193
|
}
|
|
182
194
|
const client = await (0, session_1.connectProject)(root, config);
|
|
183
195
|
const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/rollback`, { method: 'POST', body: { deploy_id: deployId } });
|
|
184
|
-
(0, ui_1.ok)(`The
|
|
196
|
+
(0, ui_1.ok)(`The project now serves the pages of build ${result.deploy_id}`);
|
|
185
197
|
(0, ui_1.out)(result.project_url);
|
|
198
|
+
(0, ui_1.note)((0, ui_1.dim)(' Only the pages came back. Cloud functions and the database are one per project:'));
|
|
199
|
+
(0, ui_1.note)((0, ui_1.dim)(' they are not versioned and stay as they are now'));
|
|
186
200
|
if (result.revision !== null) {
|
|
187
|
-
(0, ui_1.note)((0, ui_1.dim)(` The code of this
|
|
201
|
+
(0, ui_1.note)((0, ui_1.dim)(` The code of this build: xflow pull --revision ${result.revision} --into ../v${result.deploy_id}`));
|
|
202
|
+
(0, ui_1.note)((0, ui_1.dim)(' Building that code over the current one is a separate step: it overwrites the server revision'));
|
|
188
203
|
}
|
|
189
|
-
(0, ui_1.note)((0, ui_1.dim)(' Visitors see
|
|
204
|
+
(0, ui_1.note)((0, ui_1.dim)(' Visitors see these pages only after xflow publish'));
|
|
190
205
|
}
|
|
191
206
|
async function deployments() {
|
|
192
207
|
const { root, config } = (0, config_1.requireProject)();
|
|
@@ -203,4 +218,6 @@ async function deployments() {
|
|
|
203
218
|
(0, ui_1.formatAge)(d.deployed_at ?? d.created_at),
|
|
204
219
|
[d.is_dev ? (0, ui_1.bold)('dev') : '', d.is_live ? (0, ui_1.bold)('live') : ''].filter(Boolean).join(' '),
|
|
205
220
|
]));
|
|
221
|
+
(0, ui_1.note)((0, ui_1.dim)(' Serve the pages of an earlier build: xflow rollback <number>'));
|
|
222
|
+
(0, ui_1.note)((0, ui_1.dim)(' Fetch the code of a build: xflow pull --revision <its revision> --into ../copy'));
|
|
206
223
|
}
|
package/dist/commands/env.js
CHANGED
|
@@ -21,6 +21,14 @@ const SCOPE_LABEL = {
|
|
|
21
21
|
const FUNCTIONS_DIR = 'functions';
|
|
22
22
|
/** Matches `process.env.NAME` and `process.env['NAME']`. */
|
|
23
23
|
const ENV_REFERENCE = /process\.env(?:\.([A-Z0-9_]+)|\[['"]([A-Z0-9_]+)['"]\])/g;
|
|
24
|
+
/**
|
|
25
|
+
* Reading the environment in a shape no name can be read from: a name built from
|
|
26
|
+
* an expression, reached through `?.`, destructured or spread. The platform
|
|
27
|
+
* passes a variable only to a function that spells its name out, so these reads
|
|
28
|
+
* arrive empty and the build gate rejects them. The same rule lives on the
|
|
29
|
+
* platform side in lib/env-references.ts and the two must agree.
|
|
30
|
+
*/
|
|
31
|
+
const OPAQUE_REFERENCE = /process\.env\b(?!\.[A-Z0-9_]+|\[['"][A-Z0-9_]+['"]\])/;
|
|
24
32
|
/** Set by the platform itself. */
|
|
25
33
|
const PROVIDED = new Set([
|
|
26
34
|
'XFLOW_PROJECT_ID',
|
|
@@ -28,6 +36,7 @@ const PROVIDED = new Set([
|
|
|
28
36
|
'XFLOW_PROJECT_SCHEMA',
|
|
29
37
|
'XFLOW_FUNCTION_NAME',
|
|
30
38
|
'XFLOW_API_URL',
|
|
39
|
+
'XFLOW_SERVER_KEY',
|
|
31
40
|
'DATABASE_URL',
|
|
32
41
|
'NODE_ENV',
|
|
33
42
|
]);
|
|
@@ -47,8 +56,11 @@ function sourceFiles(dir, found = []) {
|
|
|
47
56
|
function referencedByFunctions(root) {
|
|
48
57
|
const needed = new Map();
|
|
49
58
|
const provided = new Map();
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const opaque = new Map();
|
|
60
|
+
const base = (0, node_path_1.join)(root, FUNCTIONS_DIR);
|
|
61
|
+
for (const file of sourceFiles(base)) {
|
|
62
|
+
const relative = file.slice(base.length + 1);
|
|
63
|
+
const functionName = relative.split(/[\\/]/)[0];
|
|
52
64
|
const code = (0, node_fs_1.readFileSync)(file, 'utf-8');
|
|
53
65
|
for (const match of code.matchAll(ENV_REFERENCE)) {
|
|
54
66
|
const name = match[1] || match[2];
|
|
@@ -60,8 +72,15 @@ function referencedByFunctions(root) {
|
|
|
60
72
|
users.push(functionName);
|
|
61
73
|
target.set(name, users);
|
|
62
74
|
}
|
|
75
|
+
code.split('\n').forEach((line, index) => {
|
|
76
|
+
if (!OPAQUE_REFERENCE.test(line))
|
|
77
|
+
return;
|
|
78
|
+
const places = opaque.get(functionName) ?? [];
|
|
79
|
+
places.push(`${FUNCTIONS_DIR}/${relative.replace(/\\/g, '/')}:${index + 1}`);
|
|
80
|
+
opaque.set(functionName, places);
|
|
81
|
+
});
|
|
63
82
|
}
|
|
64
|
-
return { needed, provided };
|
|
83
|
+
return { needed, provided, opaque };
|
|
65
84
|
}
|
|
66
85
|
async function fetchVariables() {
|
|
67
86
|
const { root, config } = (0, config_1.requireProject)();
|
|
@@ -82,8 +101,8 @@ async function envList() {
|
|
|
82
101
|
/** Check that every referenced variable is stored. */
|
|
83
102
|
async function envCheck() {
|
|
84
103
|
const { names, root } = await fetchVariables();
|
|
85
|
-
const { needed, provided } = referencedByFunctions(root);
|
|
86
|
-
if (needed.size === 0 && provided.size === 0) {
|
|
104
|
+
const { needed, provided, opaque } = referencedByFunctions(root);
|
|
105
|
+
if (needed.size === 0 && provided.size === 0 && opaque.size === 0) {
|
|
87
106
|
(0, ui_1.note)('The functions of this project read no environment variables');
|
|
88
107
|
return;
|
|
89
108
|
}
|
|
@@ -106,14 +125,33 @@ async function envCheck() {
|
|
|
106
125
|
(0, ui_1.out)((0, ui_1.bold)('Stored on the platform:'));
|
|
107
126
|
(0, ui_1.table)(present);
|
|
108
127
|
}
|
|
109
|
-
if (missing.length
|
|
128
|
+
if (missing.length > 0) {
|
|
129
|
+
(0, ui_1.out)('');
|
|
130
|
+
(0, ui_1.fail)('Missing on the platform:');
|
|
131
|
+
(0, ui_1.table)(missing);
|
|
132
|
+
}
|
|
133
|
+
if (opaque.size > 0) {
|
|
134
|
+
(0, ui_1.out)('');
|
|
135
|
+
(0, ui_1.fail)('Reading the environment without naming the variable:');
|
|
136
|
+
(0, ui_1.table)([...opaque.entries()].sort().map(([name, places]) => [name, places.join(', ')]));
|
|
137
|
+
(0, ui_1.note)((0, ui_1.dim)(' A name built from an expression, reached through ?., destructured or spread is invisible to the platform: ' +
|
|
138
|
+
'the value never arrives and the build is rejected'));
|
|
139
|
+
}
|
|
140
|
+
if (missing.length === 0 && opaque.size === 0) {
|
|
110
141
|
(0, ui_1.ok)('Every function has the variables it needs');
|
|
111
142
|
return;
|
|
112
143
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
144
|
+
const problems = [];
|
|
145
|
+
const hints = [];
|
|
146
|
+
if (missing.length > 0) {
|
|
147
|
+
problems.push(`missing variables: ${missing.length}`);
|
|
148
|
+
hints.push('To store one: xflow env set NAME=value. Until then the function receives undefined');
|
|
149
|
+
}
|
|
150
|
+
if (opaque.size > 0) {
|
|
151
|
+
problems.push(`functions reading the environment without naming it: ${opaque.size}`);
|
|
152
|
+
hints.push('Spell every variable name out: process.env.NAME');
|
|
153
|
+
}
|
|
154
|
+
throw new errors_1.CliError(problems.join(', '), hints.join('. '));
|
|
117
155
|
}
|
|
118
156
|
async function envSet(args) {
|
|
119
157
|
const { root, config } = (0, config_1.requireProject)();
|
|
@@ -13,6 +13,7 @@ const config_1 = require("../config");
|
|
|
13
13
|
const errors_1 = require("../errors");
|
|
14
14
|
const session_1 = require("../session");
|
|
15
15
|
const skills_1 = require("./skills");
|
|
16
|
+
const sources_1 = require("./sources");
|
|
16
17
|
const template_1 = require("../template");
|
|
17
18
|
const zip_1 = require("../zip");
|
|
18
19
|
const ui_1 = require("../ui");
|
|
@@ -102,6 +103,9 @@ async function init(args) {
|
|
|
102
103
|
async function link(args) {
|
|
103
104
|
const root = (0, config_1.findProjectRoot)() ?? process.cwd();
|
|
104
105
|
const existing = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, config_1.CONFIG_FILE)) ? (0, config_1.readConfig)(root) : undefined;
|
|
106
|
+
// Asked before anything is written: an empty folder means a clone, and the
|
|
107
|
+
// sources have to come with it.
|
|
108
|
+
const fresh = !existing && isEmptyEnough(process.cwd());
|
|
105
109
|
let client = (0, session_1.connect)(existing);
|
|
106
110
|
const projectId = args.words[0];
|
|
107
111
|
if (!projectId) {
|
|
@@ -147,6 +151,24 @@ async function link(args) {
|
|
|
147
151
|
(0, skills_1.installSkillQuietly)(dir);
|
|
148
152
|
(0, ui_1.ok)(`The folder is linked to the project "${card.name}"`);
|
|
149
153
|
(0, ui_1.note)((0, ui_1.dim)(` ${(0, node_path_1.join)(dir, config_1.CONFIG_FILE)}`));
|
|
154
|
+
if (!fresh)
|
|
155
|
+
return;
|
|
156
|
+
// A clone without the code is half a clone, and the folder has to remember
|
|
157
|
+
// which revision it works from: without that the first push has nothing to
|
|
158
|
+
// compare against and stops.
|
|
159
|
+
try {
|
|
160
|
+
const { info, files } = await (0, sources_1.downloadRevision)(client, card.id, dir);
|
|
161
|
+
(0, config_1.writeState)(dir, { revision: info.revision, treeHash: info.tree_hash });
|
|
162
|
+
(0, ui_1.ok)(`Revision ${info.revision}: ${files} files`);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
if (e instanceof api_1.ApiError && e.status === 404) {
|
|
166
|
+
(0, ui_1.note)((0, ui_1.dim)(' The project has no sources yet: write the code and run xflow deploy'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
(0, ui_1.warn)(`The sources did not arrive: ${e instanceof Error ? e.message : e}`);
|
|
170
|
+
(0, ui_1.note)((0, ui_1.dim)(' The folder is linked, fetch them separately: xflow pull'));
|
|
171
|
+
}
|
|
150
172
|
}
|
|
151
173
|
async function list() {
|
|
152
174
|
const root = (0, config_1.findProjectRoot)();
|
package/dist/commands/sources.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.latestRevision = latestRevision;
|
|
4
4
|
exports.pushSources = pushSources;
|
|
5
|
+
exports.downloadRevision = downloadRevision;
|
|
5
6
|
exports.pull = pull;
|
|
6
7
|
exports.status = status;
|
|
7
8
|
const node_fs_1 = require("node:fs");
|
|
@@ -14,8 +15,8 @@ const session_1 = require("../session");
|
|
|
14
15
|
const tree_1 = require("../tree");
|
|
15
16
|
const zip_1 = require("../zip");
|
|
16
17
|
const ui_1 = require("../ui");
|
|
17
|
-
/** Server cap, checked locally to fail before uploading. */
|
|
18
|
-
const MAX_ARCHIVE_BYTES =
|
|
18
|
+
/** Server cap, checked locally to fail before uploading. Keep in sync with the server. */
|
|
19
|
+
const MAX_ARCHIVE_BYTES = 10 * 1024 * 1024;
|
|
19
20
|
function prepareTree(root, config) {
|
|
20
21
|
const buildDir = config.build?.dir?.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
21
22
|
const extra = [...(config.ignore ?? []), ...(buildDir ? [`${buildDir}/`] : [])];
|
|
@@ -31,7 +32,8 @@ function prepareTree(root, config) {
|
|
|
31
32
|
const top = (0, tree_1.heaviest)(files)
|
|
32
33
|
.map((f) => ` ${f.path} ${(0, ui_1.formatBytes)(f.content.length)}`)
|
|
33
34
|
.join('\n');
|
|
34
|
-
throw new errors_1.CliError(`The archive is ${(0, ui_1.formatBytes)(archive.length)}, the cap is ${(0, ui_1.formatBytes)(MAX_ARCHIVE_BYTES)}`, `The heaviest files:\n${top}\n Exclude what is not needed in .xflowignore`
|
|
35
|
+
throw new errors_1.CliError(`The archive is ${(0, ui_1.formatBytes)(archive.length)}, the cap is ${(0, ui_1.formatBytes)(MAX_ARCHIVE_BYTES)}`, `The heaviest files:\n${top}\n Exclude what is not needed in .xflowignore.\n` +
|
|
36
|
+
' Heavy media the app loads at runtime belong in the project file storage, not in the build');
|
|
35
37
|
}
|
|
36
38
|
return { files, hash: (0, tree_1.treeHash)(files), archive };
|
|
37
39
|
}
|
|
@@ -64,7 +66,7 @@ async function confirmForce(client, projectId, server, local) {
|
|
|
64
66
|
(0, ui_1.warn)('Could not read the server copy: the list of disappearing files is unavailable');
|
|
65
67
|
}
|
|
66
68
|
(0, ui_1.note)('');
|
|
67
|
-
const confirmed = await (0, ui_1.confirmWord)(`
|
|
69
|
+
const confirmed = await (0, ui_1.confirmWord)(`The server revision will be replaced by the contents of this folder. Anything newer on the platform goes away, and it cannot be recovered from here.`, card.name);
|
|
68
70
|
if (!confirmed)
|
|
69
71
|
throw new errors_1.CliError('Cancelled');
|
|
70
72
|
}
|
|
@@ -110,23 +112,29 @@ async function pushSources(root, config, client, options) {
|
|
|
110
112
|
}
|
|
111
113
|
return { revision: result.revision, status: result.status };
|
|
112
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Does the folder hold work of its own?
|
|
117
|
+
*
|
|
118
|
+
* Only sources count. What `xflow link` itself lays down (xflow.json, the agent
|
|
119
|
+
* instructions, dot-files the platform or the editor keeps) is bookkeeping, and
|
|
120
|
+
* counting it turned the documented way to clone a project into a dead end:
|
|
121
|
+
* link wrote its files, and the pull that follows refused over them.
|
|
122
|
+
*/
|
|
113
123
|
function hasContent(dir) {
|
|
114
124
|
if (!(0, node_fs_1.existsSync)(dir))
|
|
115
125
|
return false;
|
|
116
|
-
return (0, node_fs_1.readdirSync)(dir).some((name) => name !==
|
|
126
|
+
return (0, node_fs_1.readdirSync)(dir).some((name) => !name.startsWith('.') && name !== config_1.CONFIG_FILE && name !== 'AGENTS.md');
|
|
117
127
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Write a server revision into `target`.
|
|
130
|
+
*
|
|
131
|
+
* Shared with `xflow link`: linking an empty folder and then being told the
|
|
132
|
+
* folder is not empty for a pull is a dead end the platform can avoid by
|
|
133
|
+
* fetching the sources itself.
|
|
134
|
+
*/
|
|
135
|
+
async function downloadRevision(client, projectId, target, revision) {
|
|
124
136
|
const query = revision !== undefined ? `?revision=${revision}` : '';
|
|
125
|
-
const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${
|
|
126
|
-
if (hasContent(target) && !(0, args_1.flagBool)(args, 'force')) {
|
|
127
|
-
throw new errors_1.CliError(`The directory ${target} is not empty`, 'The platform cannot merge changes, that is git work. Fetch the server copy alongside: ' +
|
|
128
|
-
'xflow pull --into ./server-copy, or overwrite the folder completely: xflow pull --force');
|
|
129
|
-
}
|
|
137
|
+
const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/pull${query}`);
|
|
130
138
|
(0, ui_1.step)(`Downloading revision ${info.revision} (${(0, ui_1.formatBytes)(info.size_bytes)})`);
|
|
131
139
|
const entries = (0, zip_1.zipRead)(await (0, api_1.downloadUrl)(info.download_url));
|
|
132
140
|
const hash = (0, tree_1.treeHash)(entries.map((e) => ({ path: e.path, content: e.content })));
|
|
@@ -134,11 +142,24 @@ async function pull(args) {
|
|
|
134
142
|
(0, ui_1.warn)('The hash of the downloaded tree did not match the server one: the contents may have changed in transit');
|
|
135
143
|
}
|
|
136
144
|
(0, node_fs_1.mkdirSync)(target, { recursive: true });
|
|
137
|
-
|
|
145
|
+
const files = (0, zip_1.dropDirectoryEntries)(entries);
|
|
146
|
+
for (const entry of files) {
|
|
138
147
|
const path = (0, node_path_1.join)(target, entry.path);
|
|
139
148
|
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
|
|
140
149
|
(0, node_fs_1.writeFileSync)(path, entry.content);
|
|
141
150
|
}
|
|
151
|
+
return { info, files: files.length };
|
|
152
|
+
}
|
|
153
|
+
async function pull(args) {
|
|
154
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
155
|
+
const into = (0, args_1.flagString)(args, 'into');
|
|
156
|
+
const target = into ? (0, node_path_1.resolve)(into) : root;
|
|
157
|
+
const client = await (0, session_1.connectProject)(root, config);
|
|
158
|
+
if (hasContent(target) && !(0, args_1.flagBool)(args, 'force')) {
|
|
159
|
+
throw new errors_1.CliError(`The directory ${target} is not empty`, 'The platform cannot merge changes, that is git work. Fetch the server copy alongside: ' +
|
|
160
|
+
'xflow pull --into ./server-copy, or overwrite the folder completely: xflow pull --force');
|
|
161
|
+
}
|
|
162
|
+
const { info, files } = await downloadRevision(client, config.projectId, target, (0, args_1.flagNumber)(args, 'revision'));
|
|
142
163
|
// Only the working folder tracks revisions; a side copy (--into) must not reset them.
|
|
143
164
|
if (!into) {
|
|
144
165
|
(0, config_1.writeState)(root, {
|
|
@@ -147,16 +168,19 @@ async function pull(args) {
|
|
|
147
168
|
organizationId: client.organizationId ?? undefined,
|
|
148
169
|
});
|
|
149
170
|
}
|
|
150
|
-
(0, ui_1.ok)(`Revision ${info.revision}: ${
|
|
171
|
+
(0, ui_1.ok)(`Revision ${info.revision}: ${files} files in ${target}`);
|
|
151
172
|
}
|
|
152
173
|
async function status() {
|
|
153
174
|
const { root, config } = (0, config_1.requireProject)();
|
|
154
175
|
const client = await (0, session_1.connectProject)(root, config);
|
|
155
176
|
const tree = prepareTree(root, config);
|
|
156
177
|
const state = (0, config_1.readState)(root);
|
|
157
|
-
const [card, server] = await Promise.all([
|
|
178
|
+
const [card, server, history] = await Promise.all([
|
|
158
179
|
(0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`),
|
|
159
180
|
latestRevision(client, config.projectId),
|
|
181
|
+
// History only decorates the output, so its failure must not take the command
|
|
182
|
+
// down with it: without it the line below simply says less.
|
|
183
|
+
(0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/deployments`).catch(() => ({ deployments: [] })),
|
|
160
184
|
]);
|
|
161
185
|
(0, ui_1.out)(`${(0, ui_1.bold)(card.name)} ${(0, ui_1.dim)(card.id)}`);
|
|
162
186
|
(0, ui_1.out)('');
|
|
@@ -179,9 +203,28 @@ async function status() {
|
|
|
179
203
|
(0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
|
|
180
204
|
}
|
|
181
205
|
}
|
|
206
|
+
// Which revision the built version came from. Silent when there is nothing to say:
|
|
207
|
+
// the row fell out of the truncated history, it predates revisions, or a build is
|
|
208
|
+
// running right now and the answer would change in a minute.
|
|
209
|
+
const running = history.deployments.some((d) => ['pending', 'building', 'uploading'].includes(d.status));
|
|
210
|
+
const devRow = running ? null : (history.deployments.find((d) => d.is_dev) ?? null);
|
|
211
|
+
const builtFrom = devRow?.revision ?? null;
|
|
212
|
+
// The dev address holds a build that is not the newest one: the project was put back
|
|
213
|
+
// on earlier pages on purpose. Saying "the code is newer" here would name the wrong
|
|
214
|
+
// reason and send the developer to build what is already built.
|
|
215
|
+
const newest = history.deployments.find((d) => d.status === 'deployed');
|
|
216
|
+
const rolledBack = devRow?.status === 'deployed' && !!newest && newest.deploy_id !== devRow.deploy_id;
|
|
182
217
|
(0, ui_1.out)('');
|
|
183
|
-
(0, ui_1.out)(`Built version: ${card.dev_deploy_id ?? '(no build)'}`
|
|
218
|
+
(0, ui_1.out)(`Built version: ${card.dev_deploy_id ?? '(no build)'}` +
|
|
219
|
+
(builtFrom !== null ? ` from revision ${builtFrom}` : ''));
|
|
184
220
|
(0, ui_1.out)(`Visitors see: ${card.live_deploy_id ?? '(never published)'}`);
|
|
221
|
+
if (rolledBack) {
|
|
222
|
+
(0, ui_1.note)((0, ui_1.dim)(' The project serves the pages of an earlier build: a newer one is in the history'));
|
|
223
|
+
(0, ui_1.note)((0, ui_1.dim)(' Cloud functions and the database are not versioned and stay as they are'));
|
|
224
|
+
}
|
|
225
|
+
else if (builtFrom !== null && server && builtFrom < server.revision) {
|
|
226
|
+
(0, ui_1.note)((0, ui_1.dim)(` The built version is behind the sources: revision ${builtFrom} against ${server.revision} on the server`));
|
|
227
|
+
}
|
|
185
228
|
// Two different problems, two different fixes: an expiring token is renewed by
|
|
186
229
|
// any build, a revoked one is not renewed by anything until a human reconnects
|
|
187
230
|
// the account. Telling them apart saves a pointless rebuild.
|
package/dist/help.js
CHANGED
|
@@ -15,7 +15,7 @@ ${(0, ui_1.bold)('Getting started')}
|
|
|
15
15
|
xflow login sign in through the browser
|
|
16
16
|
xflow init [dir] new project from the platform template
|
|
17
17
|
xflow templates which templates are available
|
|
18
|
-
xflow link <id> link this folder to a project
|
|
18
|
+
xflow link <id> link this folder to a project, an empty one also gets the sources
|
|
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
|
|
|
@@ -27,7 +27,7 @@ ${(0, ui_1.bold)('Sources')}
|
|
|
27
27
|
${(0, ui_1.bold)('Releasing')}
|
|
28
28
|
xflow deploy [--no-push] send the code, ship the functions, build on the platform
|
|
29
29
|
xflow publish show the dev version to visitors
|
|
30
|
-
xflow rollback <version number>
|
|
30
|
+
xflow rollback <version number> serve the pages of an earlier build
|
|
31
31
|
xflow deployments version history
|
|
32
32
|
|
|
33
33
|
${(0, ui_1.bold)('Functions')}
|
|
@@ -194,8 +194,10 @@ the function. So keep your own copy wherever you got it from.
|
|
|
194
194
|
|
|
195
195
|
${(0, ui_1.bold)('check')} reads the sources in ${(0, ui_1.bold)('functions/')} and looks for ${(0, ui_1.bold)('process.env.NAME')}
|
|
196
196
|
references. The same rule applies on deploy: a function receives only the variables it
|
|
197
|
-
mentions by name. A name assembled from an expression (${(0, ui_1.bold)("process.env['KEY_' + n]")})
|
|
198
|
-
never reaches the environment, so read
|
|
197
|
+
mentions by name. A name assembled from an expression (${(0, ui_1.bold)("process.env['KEY_' + n]")}),
|
|
198
|
+
reached through ${(0, ui_1.bold)('?.')}, destructured or spread never reaches the environment, so read
|
|
199
|
+
variables literally. ${(0, ui_1.bold)('check')} names those reads and the build rejects them: a secret
|
|
200
|
+
that quietly fails to arrive is found by the function falling over weeks later.
|
|
199
201
|
|
|
200
202
|
The value reaches the function on deploy, not at the moment it is stored: after
|
|
201
203
|
${(0, ui_1.bold)('env set')} run ${(0, ui_1.bold)('xflow deploy')}. The build ships a function whose code did not
|
|
@@ -394,11 +396,17 @@ is not a formality: your own palette on top of them looks foreign inside the pla
|
|
|
394
396
|
The order: the project is created on the platform first, because without it there is
|
|
395
397
|
nowhere to get the token for .env. If writing the files fails, the project stays empty
|
|
396
398
|
and the CLI explains how to pick it up with the link command.`,
|
|
397
|
-
rollback: `${(0, ui_1.bold)('xflow rollback')} <version number>:
|
|
399
|
+
rollback: `${(0, ui_1.bold)('xflow rollback')} <version number>: serve the pages of an earlier build
|
|
398
400
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
401
|
+
Points the project at the pages of that build. ${(0, ui_1.bold)('Only the pages come back.')} Cloud
|
|
402
|
+
functions and the database are one per project: they are not versioned, they are shared
|
|
403
|
+
with the published application, and they stay exactly as they are now. A page from August
|
|
404
|
+
will be talking to today's functions.
|
|
402
405
|
|
|
403
|
-
|
|
406
|
+
The sources stay at their own revision too. Fetching the code of that build is a separate
|
|
407
|
+
command (${(0, ui_1.bold)('xflow pull --revision N --into ../old-version')}, keep the copy outside the
|
|
408
|
+
project folder). Building that code over the current one is a further, deliberate step: it
|
|
409
|
+
replaces the server revision, and whatever is newer on the platform goes away.
|
|
410
|
+
|
|
411
|
+
Visitors keep seeing the published pages until ${(0, ui_1.bold)('xflow publish')} is run.`,
|
|
404
412
|
};
|
package/dist/ui.js
CHANGED
|
@@ -119,7 +119,7 @@ function offerBrowser(url) {
|
|
|
119
119
|
async function confirmWord(question, expected) {
|
|
120
120
|
if (!process.stdin.isTTY) {
|
|
121
121
|
fail('Confirmation is only possible in an interactive terminal');
|
|
122
|
-
note((0, exports.dim)(' In CI this is the right behaviour: a version conflict has to fail the build rather than overwrite
|
|
122
|
+
note((0, exports.dim)(' In CI this is the right behaviour: a version conflict has to fail the build rather than overwrite what the platform holds'));
|
|
123
123
|
return false;
|
|
124
124
|
}
|
|
125
125
|
const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stderr });
|
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.9.1';
|
|
6
6
|
/** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
|
|
7
7
|
exports.DEFAULT_API_URL = 'https://app.getxflow.com';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getxflow/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"engines": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"skills"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
-
"
|
|
17
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
18
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
18
19
|
"prepublishOnly": "npm run build"
|
|
19
20
|
},
|
|
20
21
|
"publishConfig": {
|
package/skills/xflow/SKILL.md
CHANGED
|
@@ -27,8 +27,9 @@ These mistakes cost the most because nothing fails at the moment they are made,
|
|
|
27
27
|
the error points away from the cause. The sections below carry the details.
|
|
28
28
|
|
|
29
29
|
1. Read environment variables literally: `process.env.API_KEY`. Destructuring
|
|
30
|
-
(`const { API_KEY } = process.env`)
|
|
31
|
-
|
|
30
|
+
(`const { API_KEY } = process.env`), a name built from an expression, `?.` and
|
|
31
|
+
spreading the environment all read as no mention of the variable at all. The
|
|
32
|
+
build rejects them, and `xflow env check` names them before that.
|
|
32
33
|
2. A value written with `xflow env set` reaches the functions on the next
|
|
33
34
|
`xflow deploy`, not at the moment it is written.
|
|
34
35
|
3. Never delete a `functions/<name>/` directory unless the user asked for that
|
|
@@ -72,12 +73,29 @@ restored within an hour of the data going back under the limit.
|
|
|
72
73
|
6. `xflow publish` makes that same version visible to visitors.
|
|
73
74
|
|
|
74
75
|
The split is deliberate: shipping a build and showing it are two separate decisions.
|
|
75
|
-
Until `publish` runs, visitors keep seeing the previous
|
|
76
|
+
Until `publish` runs, visitors keep seeing the previous pages. The one exception is
|
|
76
77
|
the very first version of a project: it publishes automatically, since there is no
|
|
77
78
|
live version to protect yet.
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
**Only pages are versioned.** Cloud functions and the database are one per project: they
|
|
81
|
+
are not versioned, and dev and live share them. So `xflow deploy` changes the running
|
|
82
|
+
application the moment it finishes, before any `publish`, and `publish` moves the pages
|
|
83
|
+
only. Tell the user this when a deploy touches `functions/`: there is no staging step for
|
|
84
|
+
server code.
|
|
85
|
+
|
|
86
|
+
Rolling back: `xflow deployments` lists the version history, `xflow rollback <id>` points
|
|
87
|
+
the project at the pages of an earlier build. Only the pages come back — those pages then
|
|
88
|
+
talk to today's functions. The sources stay on their own revision; fetching the code of
|
|
89
|
+
that build is a separate command (`xflow pull --revision N --into ../old-version`, keep the
|
|
90
|
+
copy outside the project folder). Building that code over the current one is a further,
|
|
91
|
+
deliberate step: it replaces the server revision, needs `--force`, and asks a human to type
|
|
92
|
+
the project name, so you cannot do it on your own.
|
|
93
|
+
|
|
94
|
+
The platform keeps the **last 30 successful builds**. Older ones are deleted, files and
|
|
95
|
+
history row alike, and rolling back to them stops working. That is rollback depth, not a
|
|
96
|
+
backup of the code: the code lives in revisions (the last 100, and a revision a live
|
|
97
|
+
version was built from is never deleted), and a build is made from a revision again.
|
|
98
|
+
Keep anything you must not lose in your own git repository, not in the version history.
|
|
81
99
|
|
|
82
100
|
**The only link you give a person is the project page**, `https://app.getxflow.com/projects/<id>`,
|
|
83
101
|
which the CLI prints for you. Refer to builds by their number ("version 481203 is built,
|
|
@@ -247,9 +265,11 @@ functions read but the platform does not have. Values never come back out — th
|
|
|
247
265
|
they exist is inside the running function.
|
|
248
266
|
|
|
249
267
|
A function receives only the variables it mentions by name via `process.env.NAME`, so never
|
|
250
|
-
assemble a variable name from an expression
|
|
251
|
-
(`const { API_KEY } = process.env` reads as no mention at all,
|
|
252
|
-
empty).
|
|
268
|
+
assemble a variable name from an expression, never reach the environment through `?.`, and
|
|
269
|
+
never destructure or spread it (`const { API_KEY } = process.env` reads as no mention at all,
|
|
270
|
+
and the variable arrives empty). The build stops on such a read rather than shipping a
|
|
271
|
+
function whose secret silently never arrives. New values arrive on the next `xflow deploy`,
|
|
272
|
+
not at the moment they are written.
|
|
253
273
|
|
|
254
274
|
Some variables come from a connected account instead of from you. When someone connects an
|
|
255
275
|
advertising cabinet or another external service in the platform settings and links it to the
|
|
@@ -341,6 +361,12 @@ a migration against a shared database.
|
|
|
341
361
|
|
|
342
362
|
## File storage
|
|
343
363
|
|
|
364
|
+
Sources go up as one archive, capped at 10 MB. That cap is about heavy media, not about
|
|
365
|
+
code: an app with hundreds of files is nowhere near it. Icons, fonts and small artwork the
|
|
366
|
+
build needs belong in the repository as usual. Photos, video, PDFs and anything a user
|
|
367
|
+
uploads belong in file storage, which is metered against the organization plan and is not
|
|
368
|
+
rebuilt and re-uploaded on every deploy.
|
|
369
|
+
|
|
344
370
|
The project has file storage, and the browser cannot reach it. Those endpoints take only the
|
|
345
371
|
server key of the project, and the platform puts it into the environment of your cloud
|
|
346
372
|
functions as `XFLOW_SERVER_KEY`. Nothing else holds it: not the bundle, not `.env`, not
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
-
const node_test_1 = require("node:test");
|
|
8
|
-
const update_1 = require("./update");
|
|
9
|
-
/**
|
|
10
|
-
* The price of a mistake here is npm running over a pnpm or bun installation, which
|
|
11
|
-
* leaves two copies and a user who cannot tell which one answers. Path shapes only,
|
|
12
|
-
* the rest of the command is a run, not a rule.
|
|
13
|
-
*/
|
|
14
|
-
(0, node_test_1.test)('a global npm prefix is an ordinary install', () => {
|
|
15
|
-
strict_1.default.equal((0, update_1.classifyPath)('C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@getxflow\\cli\\dist'), 'node_modules');
|
|
16
|
-
strict_1.default.equal((0, update_1.classifyPath)('/usr/local/lib/node_modules/@getxflow/cli/dist'), 'node_modules');
|
|
17
|
-
strict_1.default.equal((0, update_1.classifyPath)('/home/me/.npm-global/lib/node_modules/@getxflow/cli/dist'), 'node_modules');
|
|
18
|
-
});
|
|
19
|
-
(0, node_test_1.test)('npm as a path segment is not pnpm', () => {
|
|
20
|
-
strict_1.default.notEqual((0, update_1.classifyPath)('C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@getxflow\\cli\\dist'), 'pnpm');
|
|
21
|
-
});
|
|
22
|
-
(0, node_test_1.test)('other package managers are recognized by their own folders', () => {
|
|
23
|
-
strict_1.default.equal((0, update_1.classifyPath)('/home/me/.local/share/pnpm/global/5/node_modules/@getxflow/cli/dist'), 'pnpm');
|
|
24
|
-
strict_1.default.equal((0, update_1.classifyPath)('/home/me/project/node_modules/.pnpm/@getxflow+cli@0.6.0/node_modules/@getxflow/cli/dist'), 'pnpm');
|
|
25
|
-
strict_1.default.equal((0, update_1.classifyPath)('/home/me/.bun/install/global/node_modules/@getxflow/cli/dist'), 'bun');
|
|
26
|
-
strict_1.default.equal((0, update_1.classifyPath)('/home/me/project/.yarn/cache/@getxflow-cli/dist'), 'yarn');
|
|
27
|
-
});
|
|
28
|
-
(0, node_test_1.test)('an npx run is a cached copy, not an installation', () => {
|
|
29
|
-
strict_1.default.equal((0, update_1.classifyPath)('/home/me/.npm/_npx/8f1a2b/node_modules/@getxflow/cli/dist'), 'npx');
|
|
30
|
-
});
|
|
31
|
-
(0, node_test_1.test)('without node_modules the code runs from a checkout', () => {
|
|
32
|
-
strict_1.default.equal((0, update_1.classifyPath)('C:\\cursor\\xflow 2.0\\cli\\dist'), 'checkout');
|
|
33
|
-
strict_1.default.equal((0, update_1.classifyPath)('/home/me/xflow/cli/src'), 'checkout');
|
|
34
|
-
});
|
package/dist/limits.test.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
const node_test_1 = require("node:test");
|
|
7
|
-
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
8
|
-
const limits_1 = require("./limits");
|
|
9
|
-
(0, node_test_1.test)('a counted limit reads as a tally', () => {
|
|
10
|
-
const line = (0, limits_1.limitLine)({ code: 'limit_functions', used: 29, limit: 29 });
|
|
11
|
-
strict_1.default.equal(line, 'Plan limit: cloud functions, 29 of 29 used. Repeating the same command will not help.');
|
|
12
|
-
});
|
|
13
|
-
(0, node_test_1.test)('an interval reads as a frequency, not as a tally', () => {
|
|
14
|
-
const line = (0, limits_1.limitLine)({ code: 'limit_schedule_interval', used: 5, limit: 60 });
|
|
15
|
-
strict_1.default.match(line, /every 5 min against a 60 min minimum/);
|
|
16
|
-
strict_1.default.doesNotMatch(line, /used/);
|
|
17
|
-
});
|
|
18
|
-
(0, node_test_1.test)('a refusal without numbers stays a plain sentence', () => {
|
|
19
|
-
const line = (0, limits_1.limitLine)({ code: 'plan_no_schedules' });
|
|
20
|
-
strict_1.default.equal(line, 'Plan limit: schedules. Repeating the same command will not help.');
|
|
21
|
-
});
|
|
22
|
-
(0, node_test_1.test)('an unknown code falls back to the code itself', () => {
|
|
23
|
-
const line = (0, limits_1.limitLine)({ code: 'limit_unheard_of' });
|
|
24
|
-
strict_1.default.match(line, /Plan limit: limit_unheard_of\./);
|
|
25
|
-
});
|