@getxflow/cli 0.34.2 → 1.0.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/api.js +21 -6
- package/dist/args.js +1 -1
- package/dist/bin.js +10 -3
- package/dist/commands/connections.js +4 -4
- package/dist/commands/db.js +26 -63
- package/dist/commands/deploy.js +170 -216
- package/dist/commands/dev.js +15 -29
- package/dist/commands/env.js +6 -3
- package/dist/commands/functions.js +27 -119
- package/dist/commands/logs.js +19 -15
- package/dist/commands/pages.js +1 -0
- package/dist/commands/projects.js +134 -190
- package/dist/commands/sources.js +218 -120
- package/dist/commands/storage.js +2 -2
- package/dist/config.js +82 -26
- package/dist/flags.js +19 -12
- package/dist/help.js +210 -209
- package/dist/limits.js +6 -6
- package/dist/session.js +167 -52
- package/dist/template.js +29 -125
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/skills/xflow/SKILL.md +682 -740
package/dist/commands/sources.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.streamPath = streamPath;
|
|
4
|
+
exports.prepareTree = prepareTree;
|
|
3
5
|
exports.latestRevision = latestRevision;
|
|
4
6
|
exports.pushSources = pushSources;
|
|
5
|
-
exports.
|
|
7
|
+
exports.appFolderName = appFolderName;
|
|
8
|
+
exports.pullAppInto = pullAppInto;
|
|
9
|
+
exports.appFoldersOf = appFoldersOf;
|
|
10
|
+
exports.cloneProject = cloneProject;
|
|
6
11
|
exports.pull = pull;
|
|
7
12
|
exports.status = status;
|
|
8
13
|
const node_fs_1 = require("node:fs");
|
|
@@ -17,14 +22,36 @@ const zip_1 = require("../zip");
|
|
|
17
22
|
const ui_1 = require("../ui");
|
|
18
23
|
/** Server cap, checked locally to fail before uploading. Keep in sync with the server. */
|
|
19
24
|
const MAX_ARCHIVE_BYTES = 10 * 1024 * 1024;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
/** Where a stream lives on the platform: the same routes under two prefixes. */
|
|
26
|
+
function streamPath(target) {
|
|
27
|
+
return target.kind === 'app' ? `/api/v1/apps/${target.appId}` : `/api/v1/projects/${target.projectId}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The files of a stream. An app sends its own folder. The project root sends
|
|
31
|
+
* everything but apps/, plus apps/<folder>/xflow.json: the callers of a function
|
|
32
|
+
* name app folders, and the platform finds their appId in those files.
|
|
33
|
+
*/
|
|
34
|
+
function prepareTree(stream) {
|
|
35
|
+
let extra;
|
|
36
|
+
let added = [];
|
|
37
|
+
if (stream.kind === 'app') {
|
|
38
|
+
const config = (0, config_1.readAppConfig)(stream.root);
|
|
39
|
+
const buildDir = config.build?.dir?.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
40
|
+
extra = [...(config.ignore ?? []), ...(buildDir ? [`${buildDir}/`] : [])];
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
extra = [...((0, config_1.readConfig)(stream.root).ignore ?? []), `/${config_1.APPS_DIR}/`];
|
|
44
|
+
added = (0, config_1.localApps)(stream.root).map((app) => ({
|
|
45
|
+
path: `${config_1.APPS_DIR}/${app.folder}/${config_1.CONFIG_FILE}`,
|
|
46
|
+
content: Buffer.from((0, node_fs_1.readFileSync)((0, node_path_1.join)(app.root, config_1.CONFIG_FILE), 'utf-8').replace(/\r\n/g, '\n'), 'utf-8'),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
const collected = (0, tree_1.collectFiles)(stream.root, (0, tree_1.loadIgnoreRules)(stream.root, extra));
|
|
50
|
+
const files = [...collected.files, ...added].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
24
51
|
if (files.length === 0) {
|
|
25
52
|
throw new errors_1.CliError('The folder has no files to send', 'Check .xflowignore: everything may be excluded');
|
|
26
53
|
}
|
|
27
|
-
for (const link of skippedLinks) {
|
|
54
|
+
for (const link of collected.skippedLinks) {
|
|
28
55
|
(0, ui_1.warn)(`Skipped the link ${link}: symbolic links are not synchronized`);
|
|
29
56
|
}
|
|
30
57
|
const archive = (0, zip_1.zipCreate)(files);
|
|
@@ -37,17 +64,17 @@ function prepareTree(root, config) {
|
|
|
37
64
|
}
|
|
38
65
|
return { files, hash: (0, tree_1.treeHash)(files), archive };
|
|
39
66
|
}
|
|
40
|
-
async function latestRevision(client,
|
|
41
|
-
const { revisions } = await (0, api_1.apiJson)(client,
|
|
67
|
+
async function latestRevision(client, base) {
|
|
68
|
+
const { revisions } = await (0, api_1.apiJson)(client, `${base}/revisions`);
|
|
42
69
|
return revisions[0] ?? null;
|
|
43
70
|
}
|
|
44
|
-
/** Show what --force would overwrite and ask for confirmation. */
|
|
45
|
-
async function confirmForce(client,
|
|
46
|
-
const
|
|
71
|
+
/** Show what --force would overwrite and ask for confirmation by the name of the project or app. */
|
|
72
|
+
async function confirmForce(client, base, server, local) {
|
|
73
|
+
const { name } = await (0, api_1.apiJson)(client, base);
|
|
47
74
|
(0, ui_1.note)('');
|
|
48
75
|
(0, ui_1.warn)(`The server holds revision ${server.revision} from ${(0, ui_1.formatAge)(server.created_at)}, ${server.file_count} files`);
|
|
49
76
|
try {
|
|
50
|
-
const info = await (0, api_1.apiJson)(client,
|
|
77
|
+
const info = await (0, api_1.apiJson)(client, `${base}/pull`);
|
|
51
78
|
const serverPaths = new Set((0, zip_1.dropDirectoryEntries)((0, zip_1.zipRead)(await (0, api_1.downloadUrl)(info.download_url))).map((e) => e.path));
|
|
52
79
|
const localPaths = new Set(local.map((f) => f.path));
|
|
53
80
|
const disappearing = [...serverPaths].filter((p) => !localPaths.has(p));
|
|
@@ -66,22 +93,20 @@ async function confirmForce(client, projectId, server, local) {
|
|
|
66
93
|
(0, ui_1.warn)('Could not read the server copy: the list of disappearing files is unavailable');
|
|
67
94
|
}
|
|
68
95
|
(0, ui_1.note)('');
|
|
69
|
-
const confirmed = await (0, ui_1.confirmWord)(
|
|
96
|
+
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.', name);
|
|
70
97
|
if (!confirmed)
|
|
71
98
|
throw new errors_1.CliError('Cancelled');
|
|
72
99
|
}
|
|
73
|
-
/** The first step of deploy: sources go up as a revision before the build. */
|
|
74
|
-
async function pushSources(
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
100
|
+
/** The first step of deploy: sources of one stream go up as a revision before the build. */
|
|
101
|
+
async function pushSources(stream, client, options) {
|
|
102
|
+
const base = streamPath(stream);
|
|
103
|
+
const tree = prepareTree(stream);
|
|
104
|
+
const state = (0, config_1.readState)(stream.root);
|
|
105
|
+
const server = await latestRevision(client, base);
|
|
78
106
|
if (server) {
|
|
79
107
|
const relation = (0, tree_1.syncState)(tree.hash, server.tree_hash, state.treeHash);
|
|
80
108
|
if (relation === 'matches') {
|
|
81
|
-
(0, config_1.writeState)(root, {
|
|
82
|
-
treeHash: server.tree_hash,
|
|
83
|
-
organizationId: client.organizationId ?? undefined,
|
|
84
|
-
});
|
|
109
|
+
(0, config_1.writeState)(stream.root, { treeHash: server.tree_hash });
|
|
85
110
|
(0, ui_1.ok)(`No changes, revision ${server.revision}`);
|
|
86
111
|
return { revision: server.revision, status: 'unchanged' };
|
|
87
112
|
}
|
|
@@ -91,7 +116,7 @@ async function pushSources(root, config, client, options) {
|
|
|
91
116
|
'Fetch the server copy alongside and compare: xflow pull --into ./server-copy');
|
|
92
117
|
}
|
|
93
118
|
if (options.force) {
|
|
94
|
-
await confirmForce(client,
|
|
119
|
+
await confirmForce(client, base, server, tree.files);
|
|
95
120
|
}
|
|
96
121
|
else if (relation === 'unrecorded') {
|
|
97
122
|
(0, ui_1.warn)(`This folder has no record of the server revision: the code goes up on top of revision ${server.revision}`);
|
|
@@ -99,47 +124,36 @@ async function pushSources(root, config, client, options) {
|
|
|
99
124
|
}
|
|
100
125
|
(0, ui_1.step)(`Sending ${tree.files.length} files (${(0, ui_1.formatBytes)(tree.archive.length)})`);
|
|
101
126
|
const headers = {};
|
|
102
|
-
// Just read from this project: the header only guards the gap until the upload lands.
|
|
103
127
|
if (server)
|
|
104
128
|
headers['X-Base-Revision'] = String(server.revision);
|
|
105
129
|
if (options.force)
|
|
106
130
|
headers['X-Force'] = 'true';
|
|
107
|
-
const result = await (0, api_1.apiUpload)(client,
|
|
108
|
-
(0, config_1.writeState)(root, {
|
|
109
|
-
|
|
110
|
-
organizationId: client.organizationId ?? undefined,
|
|
111
|
-
});
|
|
112
|
-
if (result.status === 'unchanged') {
|
|
131
|
+
const result = await (0, api_1.apiUpload)(client, `${base}/push`, tree.archive, headers);
|
|
132
|
+
(0, config_1.writeState)(stream.root, { treeHash: result.tree_hash });
|
|
133
|
+
if (result.status === 'unchanged')
|
|
113
134
|
(0, ui_1.ok)(`No changes, revision ${result.revision}`);
|
|
114
|
-
|
|
115
|
-
else {
|
|
135
|
+
else
|
|
116
136
|
(0, ui_1.ok)(`Revision ${result.revision}: ${result.file_count} files, ${(0, ui_1.formatBytes)(result.size_bytes)}`);
|
|
117
|
-
}
|
|
118
137
|
return { revision: result.revision, status: result.status };
|
|
119
138
|
}
|
|
120
139
|
/**
|
|
121
|
-
* Does the folder hold work of its own?
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* instructions, dot-files the platform or the editor keeps) is bookkeeping, and
|
|
125
|
-
* counting it turned the documented way to clone a project into a dead end:
|
|
126
|
-
* link wrote its files, and the pull that follows refused over them.
|
|
140
|
+
* Does the folder hold work of its own? Only sources count: what link lays down
|
|
141
|
+
* (xflow.json, agent instructions, dot-files) is bookkeeping. `skipApps`: the
|
|
142
|
+
* project root, whose apps/ is not part of its stream.
|
|
127
143
|
*/
|
|
128
|
-
function hasContent(dir) {
|
|
144
|
+
function hasContent(dir, skipApps) {
|
|
129
145
|
if (!(0, node_fs_1.existsSync)(dir))
|
|
130
146
|
return false;
|
|
131
|
-
return (0, node_fs_1.readdirSync)(dir).some((name) => !name.startsWith('.') && name !== config_1.CONFIG_FILE && name !== 'AGENTS.md');
|
|
147
|
+
return (0, node_fs_1.readdirSync)(dir).some((name) => !name.startsWith('.') && name !== config_1.CONFIG_FILE && name !== 'AGENTS.md' && !(skipApps && name === config_1.APPS_DIR));
|
|
132
148
|
}
|
|
133
149
|
/**
|
|
134
|
-
* Write a server revision into `target`.
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* folder is not empty for a pull is a dead end the platform can avoid by
|
|
138
|
-
* fetching the sources itself.
|
|
150
|
+
* Write a server revision of a stream into `target`. `skipApps` leaves apps/ of the
|
|
151
|
+
* project root alone: every app has its own stream. The entries come back too, so
|
|
152
|
+
* a clone can read the app folders from the project revision.
|
|
139
153
|
*/
|
|
140
|
-
async function downloadRevision(client,
|
|
141
|
-
const query = revision !== undefined ? `?revision=${revision}` : '';
|
|
142
|
-
const info = await (0, api_1.apiJson)(client,
|
|
154
|
+
async function downloadRevision(client, base, target, options = {}) {
|
|
155
|
+
const query = options.revision !== undefined ? `?revision=${options.revision}` : '';
|
|
156
|
+
const info = await (0, api_1.apiJson)(client, `${base}/pull${query}`);
|
|
143
157
|
(0, ui_1.step)(`Downloading revision ${info.revision} (${(0, ui_1.formatBytes)(info.size_bytes)})`);
|
|
144
158
|
const entries = (0, zip_1.zipRead)(await (0, api_1.downloadUrl)(info.download_url));
|
|
145
159
|
const hash = (0, tree_1.treeHash)(entries.map((e) => ({ path: e.path, content: e.content })));
|
|
@@ -147,102 +161,186 @@ async function downloadRevision(client, projectId, target, revision) {
|
|
|
147
161
|
(0, ui_1.warn)('The hash of the downloaded tree did not match the server one: the contents may have changed in transit');
|
|
148
162
|
}
|
|
149
163
|
(0, node_fs_1.mkdirSync)(target, { recursive: true });
|
|
150
|
-
// The hash
|
|
151
|
-
//
|
|
152
|
-
const files = (0, zip_1.dropDirectoryEntries)(entries).filter((entry) => !(0, tree_1.isSkillCopy)(entry.path));
|
|
164
|
+
// The hash covers the archive as the server holds it; what lands on disk is the code,
|
|
165
|
+
// without the skill copies older revisions carry and without apps/ of the root.
|
|
166
|
+
const files = (0, zip_1.dropDirectoryEntries)(entries).filter((entry) => !(0, tree_1.isSkillCopy)(entry.path) && !(options.skipApps && entry.path.startsWith(`${config_1.APPS_DIR}/`)));
|
|
153
167
|
for (const entry of files) {
|
|
154
168
|
const path = (0, node_path_1.join)(target, entry.path);
|
|
155
169
|
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
|
|
156
170
|
(0, node_fs_1.writeFileSync)(path, entry.content);
|
|
157
171
|
}
|
|
158
|
-
return { info, files: files.length };
|
|
172
|
+
return { info, files: files.length, entries };
|
|
173
|
+
}
|
|
174
|
+
/** A folder name out of an app name: latin letters, digits and dashes, unique in apps/. */
|
|
175
|
+
function appFolderName(name, taken, fallback) {
|
|
176
|
+
const base = name
|
|
177
|
+
.toLowerCase()
|
|
178
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
179
|
+
.replace(/^-+|-+$/g, '')
|
|
180
|
+
.slice(0, 40) || fallback;
|
|
181
|
+
let folder = base;
|
|
182
|
+
for (let n = 2; taken.has(folder); n++)
|
|
183
|
+
folder = `${base}-${n}`;
|
|
184
|
+
taken.add(folder);
|
|
185
|
+
return folder;
|
|
186
|
+
}
|
|
187
|
+
/** Pull the latest revision of one app into its folder and make sure its xflow.json names it. */
|
|
188
|
+
async function pullAppInto(client, appId, root) {
|
|
189
|
+
try {
|
|
190
|
+
const { info, files } = await downloadRevision(client, `/api/v1/apps/${appId}`, root);
|
|
191
|
+
(0, config_1.writeState)(root, { treeHash: info.tree_hash });
|
|
192
|
+
(0, ui_1.ok)(`${root}: revision ${info.revision}, ${files} files`);
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
if (!(e instanceof api_1.ApiError && e.status === 404))
|
|
196
|
+
throw e;
|
|
197
|
+
(0, node_fs_1.mkdirSync)(root, { recursive: true });
|
|
198
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${root}: the app has no sources yet`));
|
|
199
|
+
}
|
|
200
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, config_1.CONFIG_FILE)) || (0, config_1.configKind)(safeJson((0, node_path_1.join)(root, config_1.CONFIG_FILE))) !== 'app') {
|
|
201
|
+
(0, config_1.writeConfig)(root, { appId, build: { command: 'npm run build', dir: 'dist' } });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function parseJson(text) {
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(text);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function safeJson(path) {
|
|
213
|
+
return (0, node_fs_1.existsSync)(path) ? parseJson((0, node_fs_1.readFileSync)(path, 'utf-8')) : undefined;
|
|
214
|
+
}
|
|
215
|
+
/** appId → folder, read from the apps/<folder>/xflow.json files a project revision carries. */
|
|
216
|
+
function appFoldersOf(entries) {
|
|
217
|
+
const folders = new Map();
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const match = /^apps\/([^/]+)\/xflow\.json$/.exec(entry.path);
|
|
220
|
+
if (!match)
|
|
221
|
+
continue;
|
|
222
|
+
const parsed = parseJson(entry.content.toString('utf-8'));
|
|
223
|
+
if ((0, config_1.configKind)(parsed) === 'app')
|
|
224
|
+
folders.set(parsed.appId, match[1]);
|
|
225
|
+
}
|
|
226
|
+
return folders;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The whole project into `dir`: the root stream, then the newest revision of every
|
|
230
|
+
* app into apps/<folder>. The folder names come from the project revision itself
|
|
231
|
+
* (apps/<folder>/xflow.json names the appId), so a clone lays out as the original.
|
|
232
|
+
*/
|
|
233
|
+
async function cloneProject(client, card, dir) {
|
|
234
|
+
const folders = new Map();
|
|
235
|
+
try {
|
|
236
|
+
const { info, files, entries } = await downloadRevision(client, `/api/v1/projects/${card.id}`, dir, { skipApps: true });
|
|
237
|
+
(0, config_1.writeState)(dir, { treeHash: info.tree_hash });
|
|
238
|
+
(0, ui_1.ok)(`Project root: revision ${info.revision}, ${files} files`);
|
|
239
|
+
for (const [appId, folder] of appFoldersOf(entries))
|
|
240
|
+
folders.set(appId, folder);
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
if (!(e instanceof api_1.ApiError && e.status === 404))
|
|
244
|
+
throw e;
|
|
245
|
+
(0, ui_1.note)((0, ui_1.dim)(' The project root has no sources yet'));
|
|
246
|
+
}
|
|
247
|
+
const taken = new Set(folders.values());
|
|
248
|
+
for (const app of card.apps) {
|
|
249
|
+
const folder = folders.get(app.id) ?? appFolderName(app.name, taken, `app-${app.id.slice(0, 6)}`);
|
|
250
|
+
await pullAppInto(client, app.id, (0, node_path_1.join)(dir, config_1.APPS_DIR, folder));
|
|
251
|
+
}
|
|
159
252
|
}
|
|
160
253
|
async function pull(args) {
|
|
161
|
-
const { root, config } = (0, config_1.requireProject)();
|
|
162
254
|
const into = (0, args_1.flagString)(args, 'into');
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
255
|
+
const word = args.words[0];
|
|
256
|
+
const { root, config, appRoot } = (0, config_1.requireProject)();
|
|
257
|
+
// In the project root, with nowhere named: the whole project goes next to it.
|
|
258
|
+
if (into && word === undefined && appRoot === null) {
|
|
259
|
+
const target = (0, node_path_1.resolve)(into);
|
|
260
|
+
if (hasContent(target, false) && !(0, args_1.flagBool)(args, 'force')) {
|
|
261
|
+
throw new errors_1.CliError(`The directory ${target} is not empty`, 'Pick an empty one, or overwrite it: --force');
|
|
262
|
+
}
|
|
263
|
+
const client = await (0, session_1.connectProject)(config.projectId, root, config);
|
|
264
|
+
await cloneProject(client, await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`), target);
|
|
265
|
+
(0, ui_1.ok)(`The project is in ${target}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const stream = await (0, session_1.folderTarget)(word, false);
|
|
269
|
+
const base = streamPath(stream);
|
|
270
|
+
if (!stream.root && !into)
|
|
271
|
+
throw new errors_1.CliError('This app has no folder here', 'Name one: xflow pull --into <dir>');
|
|
272
|
+
const target = into ? (0, node_path_1.resolve)(into) : stream.root;
|
|
273
|
+
if (hasContent(target, stream.kind === 'project' && !into) && !(0, args_1.flagBool)(args, 'force')) {
|
|
166
274
|
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: ' +
|
|
167
275
|
'xflow pull --into ./server-copy, or overwrite the folder completely: xflow pull --force');
|
|
168
276
|
}
|
|
169
|
-
const { info, files } = await downloadRevision(client,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
organizationId: client.organizationId ?? undefined,
|
|
277
|
+
const { info, files } = await downloadRevision(stream.client, base, target, {
|
|
278
|
+
revision: (0, args_1.flagNumber)(args, 'revision'),
|
|
279
|
+
skipApps: stream.kind === 'project',
|
|
173
280
|
});
|
|
281
|
+
(0, config_1.writeState)(target, { treeHash: info.tree_hash });
|
|
174
282
|
(0, ui_1.ok)(`Revision ${info.revision}: ${files} files in ${target}`);
|
|
175
283
|
}
|
|
284
|
+
/** One line on how a local tree stands against its stream on the server. */
|
|
285
|
+
async function syncLine(client, stream, base) {
|
|
286
|
+
const tree = prepareTree(stream);
|
|
287
|
+
const server = await latestRevision(client, base);
|
|
288
|
+
if (!server)
|
|
289
|
+
return `${tree.files.length} files, nothing on the server yet`;
|
|
290
|
+
const relation = (0, tree_1.syncState)(tree.hash, server.tree_hash, (0, config_1.readState)(stream.root).treeHash);
|
|
291
|
+
const head = `revision ${server.revision} on the server (${(0, ui_1.formatAge)(server.created_at)})`;
|
|
292
|
+
if (relation === 'matches')
|
|
293
|
+
return `${head}, the folder matches`;
|
|
294
|
+
if (relation === 'diverged')
|
|
295
|
+
return `${head}, ${(0, ui_1.bold)('diverged')}: the server holds a version this folder has not seen`;
|
|
296
|
+
if (relation === 'unrecorded')
|
|
297
|
+
return `${head}, the folder differs and has no record of it`;
|
|
298
|
+
return `${head}, ${(0, ui_1.bold)('local changes')}`;
|
|
299
|
+
}
|
|
300
|
+
function versionsLine(card) {
|
|
301
|
+
const live = card.live ? `live ${card.live.deploy_id}` : 'never published';
|
|
302
|
+
const newer = card.newest && !card.published ? `, newer build ${card.newest.deploy_id} not published` : '';
|
|
303
|
+
const building = card.building ? `, building ${card.building.deploy_id}` : '';
|
|
304
|
+
return `${live}${newer}${building}`;
|
|
305
|
+
}
|
|
306
|
+
async function appStatus(client, appId, root) {
|
|
307
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/apps/${appId}`);
|
|
308
|
+
(0, ui_1.out)(`${(0, ui_1.bold)(card.name)} ${(0, ui_1.dim)(card.id)}`);
|
|
309
|
+
(0, ui_1.out)(`Sources: ${await syncLine(client, { kind: 'app', root, appId }, `/api/v1/apps/${appId}`)}`);
|
|
310
|
+
(0, ui_1.out)(`Versions: ${versionsLine(card)}`);
|
|
311
|
+
if (card.newest && !card.published)
|
|
312
|
+
(0, ui_1.note)((0, ui_1.dim)(' Show the newest build to visitors: xflow publish'));
|
|
313
|
+
(0, ui_1.out)('');
|
|
314
|
+
(0, ui_1.out)(card.url);
|
|
315
|
+
}
|
|
176
316
|
async function status() {
|
|
177
|
-
const { root, config } = (0, config_1.requireProject)();
|
|
317
|
+
const { root, config, appRoot } = (0, config_1.requireProject)();
|
|
178
318
|
const client = await (0, session_1.connectProject)(config.projectId, root, config);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
latestRevision(client, config.projectId),
|
|
184
|
-
// History only decorates the output, so its failure must not take the command
|
|
185
|
-
// down with it: without it the line below simply says less.
|
|
186
|
-
(0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/deployments`).catch(() => ({ deployments: [] })),
|
|
187
|
-
]);
|
|
319
|
+
if (appRoot)
|
|
320
|
+
return appStatus(client, (0, config_1.readAppConfig)(appRoot).appId, appRoot);
|
|
321
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`);
|
|
322
|
+
const local = (0, config_1.localApps)(root);
|
|
188
323
|
(0, ui_1.out)(`${(0, ui_1.bold)(card.name)} ${(0, ui_1.dim)(card.id)}`);
|
|
189
324
|
(0, ui_1.out)('');
|
|
190
|
-
(0, ui_1.out)(`
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
(0,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
(0, ui_1.out)(`
|
|
197
|
-
|
|
198
|
-
if (relation === 'matches') {
|
|
199
|
-
(0, ui_1.out)('State: matches the server');
|
|
200
|
-
}
|
|
201
|
-
else if (relation === 'diverged') {
|
|
202
|
-
(0, ui_1.out)(`State: ${(0, ui_1.bold)('diverged')}, the server holds a version this folder has not seen`);
|
|
203
|
-
(0, ui_1.note)((0, ui_1.dim)(' Fetch the server copy alongside: xflow pull --into ./server-copy'));
|
|
204
|
-
}
|
|
205
|
-
else if (relation === 'unrecorded') {
|
|
206
|
-
(0, ui_1.out)(`State: ${(0, ui_1.bold)('differs')}, this folder has no record of the server revision`);
|
|
207
|
-
(0, ui_1.note)((0, ui_1.dim)(` xflow deploy sends this code on top of revision ${server.revision}; to compare first: xflow pull --into ./server-copy`));
|
|
208
|
-
}
|
|
209
|
-
else {
|
|
210
|
-
(0, ui_1.out)(`State: ${(0, ui_1.bold)('local changes')}`);
|
|
211
|
-
(0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
|
|
212
|
-
}
|
|
325
|
+
(0, ui_1.out)(`Project root (functions, migrations): ${await syncLine(client, { kind: 'project', root, projectId: card.id }, `/api/v1/projects/${card.id}`)}`);
|
|
326
|
+
for (const app of card.apps) {
|
|
327
|
+
const folder = local.find((item) => item.appId === app.id);
|
|
328
|
+
const appCard = await (0, api_1.apiJson)(client, `/api/v1/apps/${app.id}`);
|
|
329
|
+
(0, ui_1.out)('');
|
|
330
|
+
(0, ui_1.out)(`${(0, ui_1.bold)(folder ? `${config_1.APPS_DIR}/${folder.folder}` : app.name)} ${app.url}`);
|
|
331
|
+
(0, ui_1.out)(` Sources: ${folder ? await syncLine(client, { kind: 'app', root: folder.root, appId: app.id }, `/api/v1/apps/${app.id}`) : 'no folder here (xflow pull --into, or xflow link in an empty folder)'}`);
|
|
332
|
+
(0, ui_1.out)(` Versions: ${versionsLine(appCard)}`);
|
|
213
333
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
const running = history.deployments.some((d) => ['pending', 'building', 'uploading'].includes(d.status));
|
|
218
|
-
const devRow = running ? null : (history.deployments.find((d) => d.is_dev) ?? null);
|
|
219
|
-
const builtFrom = devRow?.revision ?? null;
|
|
220
|
-
// The dev address holds a build that is not the newest one: the project was put back
|
|
221
|
-
// on earlier pages on purpose. Saying "the code is newer" here would name the wrong
|
|
222
|
-
// reason and send the developer to build what is already built.
|
|
223
|
-
const newest = history.deployments.find((d) => d.status === 'deployed');
|
|
224
|
-
const rolledBack = devRow?.status === 'deployed' && !!newest && newest.deploy_id !== devRow.deploy_id;
|
|
225
|
-
(0, ui_1.out)('');
|
|
226
|
-
(0, ui_1.out)(`Built version: ${card.dev_deploy_id ?? '(no build)'}` +
|
|
227
|
-
(builtFrom !== null ? ` from revision ${builtFrom}` : ''));
|
|
228
|
-
(0, ui_1.out)(`Visitors see: ${card.live_deploy_id ?? '(never published)'}`);
|
|
229
|
-
if (rolledBack) {
|
|
230
|
-
(0, ui_1.note)((0, ui_1.dim)(' The project serves the pages of an earlier build: a newer one is in the history'));
|
|
231
|
-
(0, ui_1.note)((0, ui_1.dim)(' Cloud functions and the database are not versioned and stay as they are'));
|
|
232
|
-
}
|
|
233
|
-
else if (builtFrom !== null && server && builtFrom < server.revision) {
|
|
234
|
-
(0, ui_1.note)((0, ui_1.dim)(` The built version is behind the sources: revision ${builtFrom} against ${server.revision} on the server`));
|
|
235
|
-
}
|
|
236
|
-
// Two different problems, two different fixes: an expiring token is renewed by
|
|
237
|
-
// any build, a revoked one is not renewed by anything until a human reconnects
|
|
238
|
-
// the account. Telling them apart saves a pointless rebuild.
|
|
334
|
+
if (card.apps.length === 0)
|
|
335
|
+
(0, ui_1.note)((0, ui_1.dim)(' No apps: the functions are workflows. Add one: xflow apps add <folder>'));
|
|
336
|
+
// An expiring token is renewed by any deploy of the functions, a revoked one only by a human.
|
|
239
337
|
const ailing = (card.connections ?? []).filter((c) => c.health !== 'ok');
|
|
240
338
|
if (ailing.length > 0) {
|
|
241
339
|
(0, ui_1.out)('');
|
|
242
340
|
(0, ui_1.out)((0, ui_1.bold)('Connected accounts needing attention:'));
|
|
243
341
|
for (const connection of ailing) {
|
|
244
342
|
const problem = connection.health === 'expiring'
|
|
245
|
-
? `expires in ${connection.days_left} days,
|
|
343
|
+
? `expires in ${connection.days_left} days, xflow deploy functions renews it`
|
|
246
344
|
: connection.health === 'expired'
|
|
247
345
|
? 'access has expired, reconnect the account in the platform settings'
|
|
248
346
|
: 'access was revoked by the provider, reconnect the account in the platform settings';
|
package/dist/commands/storage.js
CHANGED
|
@@ -336,7 +336,7 @@ function report(outcome, asJson) {
|
|
|
336
336
|
}
|
|
337
337
|
(0, ui_1.ok)(parts.join(', '));
|
|
338
338
|
if (outcome.uploaded.length > 0) {
|
|
339
|
-
(0, ui_1.note)((0, ui_1.dim)('
|
|
339
|
+
(0, ui_1.note)((0, ui_1.dim)(' Use the address as is inside an app: it is relative to the app host and permanent'));
|
|
340
340
|
(0, ui_1.note)((0, ui_1.dim)(' The whole list in one piece: xflow storage ls --json'));
|
|
341
341
|
}
|
|
342
342
|
}
|
|
@@ -344,7 +344,7 @@ async function storageRemove(args) {
|
|
|
344
344
|
const folder = (0, args_1.flagString)(args, 'folder');
|
|
345
345
|
const url = args.words[1];
|
|
346
346
|
if ((!url && !folder) || (url && folder)) {
|
|
347
|
-
throw new errors_1.CliError('Either the address of a file or --folder is required', 'For example: xflow storage rm
|
|
347
|
+
throw new errors_1.CliError('Either the address of a file or --folder is required', 'For example: xflow storage rm /_files/<id>, or xflow storage rm --folder photos --yes');
|
|
348
348
|
}
|
|
349
349
|
const query = new URLSearchParams(url ? { url } : { folder: folder });
|
|
350
350
|
if ((0, args_1.flagBool)(args, 'yes'))
|
package/dist/config.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ConfigError = exports.CONFIG_FILE = void 0;
|
|
3
|
+
exports.ConfigError = exports.APPS_DIR = exports.CONFIG_FILE = void 0;
|
|
4
|
+
exports.configKind = configKind;
|
|
5
|
+
exports.findRoots = findRoots;
|
|
4
6
|
exports.findProjectRoot = findProjectRoot;
|
|
5
7
|
exports.readConfig = readConfig;
|
|
8
|
+
exports.readAppConfig = readAppConfig;
|
|
6
9
|
exports.writeConfig = writeConfig;
|
|
7
10
|
exports.localConfig = localConfig;
|
|
8
11
|
exports.requireProject = requireProject;
|
|
12
|
+
exports.localApps = localApps;
|
|
9
13
|
exports.apiUrlFor = apiUrlFor;
|
|
10
14
|
exports.readState = readState;
|
|
11
15
|
exports.writeState = writeState;
|
|
@@ -14,60 +18,112 @@ const node_fs_1 = require("node:fs");
|
|
|
14
18
|
const node_path_1 = require("node:path");
|
|
15
19
|
const version_1 = require("./version");
|
|
16
20
|
exports.CONFIG_FILE = 'xflow.json';
|
|
21
|
+
exports.APPS_DIR = 'apps';
|
|
17
22
|
const STATE_DIR = '.xflow';
|
|
18
23
|
class ConfigError extends Error {
|
|
19
24
|
}
|
|
20
25
|
exports.ConfigError = ConfigError;
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
function readJson(path) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Which of the two files this is; null when it is neither. */
|
|
35
|
+
function configKind(value) {
|
|
36
|
+
if (!value || typeof value !== 'object')
|
|
37
|
+
return null;
|
|
38
|
+
const { appId, projectId } = value;
|
|
39
|
+
if (typeof appId === 'string' && appId)
|
|
40
|
+
return 'app';
|
|
41
|
+
if (typeof projectId === 'string' && projectId)
|
|
42
|
+
return 'project';
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Walk up: the first xflow.json with appId is the app folder, the one with
|
|
47
|
+
* projectId above it is the project. A file that is neither stops the walk as a
|
|
48
|
+
* project root, so readConfig can say what is wrong with it.
|
|
49
|
+
*/
|
|
50
|
+
function findRoots(from = process.cwd()) {
|
|
23
51
|
let dir = (0, node_path_1.resolve)(from);
|
|
52
|
+
let appRoot = null;
|
|
24
53
|
for (;;) {
|
|
25
|
-
|
|
26
|
-
|
|
54
|
+
const path = (0, node_path_1.join)(dir, exports.CONFIG_FILE);
|
|
55
|
+
if ((0, node_fs_1.existsSync)(path)) {
|
|
56
|
+
const kind = configKind(readJson(path));
|
|
57
|
+
if (kind === 'app')
|
|
58
|
+
appRoot ??= dir;
|
|
59
|
+
else
|
|
60
|
+
return { projectRoot: dir, appRoot };
|
|
61
|
+
}
|
|
27
62
|
const parent = (0, node_path_1.dirname)(dir);
|
|
28
63
|
if (parent === dir)
|
|
29
|
-
|
|
64
|
+
break;
|
|
30
65
|
dir = parent;
|
|
31
66
|
}
|
|
67
|
+
if (appRoot) {
|
|
68
|
+
throw new ConfigError(`This app folder is not inside its project: no ${exports.CONFIG_FILE} with projectId above ${appRoot}.\n` +
|
|
69
|
+
' Clone the whole project: xflow link <project id> in an empty folder');
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function findProjectRoot(from) {
|
|
74
|
+
return findRoots(from)?.projectRoot ?? null;
|
|
32
75
|
}
|
|
33
76
|
function readConfig(root) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
catch (e) {
|
|
40
|
-
throw new ConfigError(`Could not read ${exports.CONFIG_FILE}: ${e instanceof Error ? e.message : e}`);
|
|
41
|
-
}
|
|
42
|
-
const config = parsed;
|
|
43
|
-
if (!config || typeof config.projectId !== 'string' || !config.projectId) {
|
|
77
|
+
const parsed = readJson((0, node_path_1.join)(root, exports.CONFIG_FILE));
|
|
78
|
+
if (parsed === undefined)
|
|
79
|
+
throw new ConfigError(`Could not read ${(0, node_path_1.join)(root, exports.CONFIG_FILE)}: it is not JSON`);
|
|
80
|
+
if (configKind(parsed) !== 'project') {
|
|
44
81
|
throw new ConfigError(`${exports.CONFIG_FILE} has no projectId. Link the folder to a project: xflow link <id>`);
|
|
45
82
|
}
|
|
46
|
-
return
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
function readAppConfig(appRoot) {
|
|
86
|
+
const parsed = readJson((0, node_path_1.join)(appRoot, exports.CONFIG_FILE));
|
|
87
|
+
if (configKind(parsed) !== 'app') {
|
|
88
|
+
throw new ConfigError(`${(0, node_path_1.join)(appRoot, exports.CONFIG_FILE)} has no appId. Apps are created with xflow apps add <folder>`);
|
|
89
|
+
}
|
|
90
|
+
return parsed;
|
|
47
91
|
}
|
|
48
92
|
function writeConfig(root, config) {
|
|
49
93
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, exports.CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
|
50
94
|
}
|
|
51
|
-
/** The config of the
|
|
95
|
+
/** The config of the project we are in, when there is one and it parses. */
|
|
52
96
|
function localConfig() {
|
|
53
|
-
const root = findProjectRoot();
|
|
54
|
-
if (!root)
|
|
55
|
-
return undefined;
|
|
56
97
|
try {
|
|
57
|
-
|
|
98
|
+
const root = findProjectRoot();
|
|
99
|
+
return root ? readConfig(root) : undefined;
|
|
58
100
|
}
|
|
59
101
|
catch {
|
|
60
102
|
return undefined;
|
|
61
103
|
}
|
|
62
104
|
}
|
|
63
105
|
function requireProject() {
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
66
|
-
throw new ConfigError(`The folder is not linked to a project: there is no ${exports.CONFIG_FILE}
|
|
106
|
+
const roots = findRoots();
|
|
107
|
+
if (!roots) {
|
|
108
|
+
throw new ConfigError(`The folder is not linked to a project: there is no ${exports.CONFIG_FILE} here or above.\n` +
|
|
67
109
|
' Link an existing one: xflow link <project id>\n' +
|
|
68
110
|
' Create a new one: xflow init');
|
|
69
111
|
}
|
|
70
|
-
return { root, config: readConfig(
|
|
112
|
+
return { root: roots.projectRoot, config: readConfig(roots.projectRoot), appRoot: roots.appRoot };
|
|
113
|
+
}
|
|
114
|
+
/** The app folders of the project: apps/<folder>/xflow.json with an appId. */
|
|
115
|
+
function localApps(projectRoot) {
|
|
116
|
+
const base = (0, node_path_1.join)(projectRoot, exports.APPS_DIR);
|
|
117
|
+
if (!(0, node_fs_1.existsSync)(base))
|
|
118
|
+
return [];
|
|
119
|
+
return (0, node_fs_1.readdirSync)(base, { withFileTypes: true })
|
|
120
|
+
.filter((entry) => entry.isDirectory())
|
|
121
|
+
.flatMap((entry) => {
|
|
122
|
+
const root = (0, node_path_1.join)(base, entry.name);
|
|
123
|
+
const config = readJson((0, node_path_1.join)(root, exports.CONFIG_FILE));
|
|
124
|
+
return configKind(config) === 'app' ? [{ folder: entry.name, root, appId: config.appId }] : [];
|
|
125
|
+
})
|
|
126
|
+
.sort((a, b) => (a.folder < b.folder ? -1 : 1));
|
|
71
127
|
}
|
|
72
128
|
function apiUrlFor(config) {
|
|
73
129
|
const raw = (process.env.XFLOW_API_URL?.trim() || config?.api?.trim() || version_1.DEFAULT_API_URL).replace(/\/+$/, '');
|