@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.
@@ -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.downloadRevision = downloadRevision;
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
- function prepareTree(root, config) {
21
- const buildDir = config.build?.dir?.replace(/^\.\//, '').replace(/\/+$/, '');
22
- const extra = [...(config.ignore ?? []), ...(buildDir ? [`${buildDir}/`] : [])];
23
- const { files, skippedLinks } = (0, tree_1.collectFiles)(root, (0, tree_1.loadIgnoreRules)(root, extra));
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, projectId) {
41
- const { revisions } = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/revisions`);
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, projectId, server, local) {
46
- const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
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, `/api/v1/projects/${projectId}/pull`);
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)(`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);
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(root, config, client, options) {
75
- const tree = prepareTree(root, config);
76
- const state = (0, config_1.readState)(root);
77
- const server = await latestRevision(client, config.projectId);
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, config.projectId, server, tree.files);
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, `/api/v1/projects/${config.projectId}/push`, tree.archive, headers);
108
- (0, config_1.writeState)(root, {
109
- treeHash: result.tree_hash,
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
- * Only sources count. What `xflow link` itself lays down (xflow.json, the agent
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
- * Shared with `xflow link`: linking an empty folder and then being told the
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, projectId, target, revision) {
141
- const query = revision !== undefined ? `?revision=${revision}` : '';
142
- const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/pull${query}`);
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 above covers the archive as the server holds it; what lands on disk is the
151
- // code, without the skill copies older revisions still carry.
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 target = into ? (0, node_path_1.resolve)(into) : root;
164
- const client = await (0, session_1.connectProject)(config.projectId, root, config);
165
- if (hasContent(target) && !(0, args_1.flagBool)(args, 'force')) {
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, config.projectId, target, (0, args_1.flagNumber)(args, 'revision'));
170
- (0, config_1.writeState)(target, {
171
- treeHash: info.tree_hash,
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
- const tree = prepareTree(root, config);
180
- const state = (0, config_1.readState)(root);
181
- const [card, server, history] = await Promise.all([
182
- (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`),
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)(`Local: ${tree.files.length} files, ${(0, ui_1.formatBytes)(tree.archive.length)} archived`);
191
- if (!server) {
192
- (0, ui_1.out)('On the server: no sources yet');
193
- (0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
194
- }
195
- else {
196
- (0, ui_1.out)(`On the server: revision ${server.revision}, ${server.file_count} files, ${(0, ui_1.formatAge)(server.created_at)}`);
197
- const relation = (0, tree_1.syncState)(tree.hash, server.tree_hash, state.treeHash);
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
- // Which revision the built version came from. Silent when there is nothing to say:
215
- // the row fell out of the truncated history, it predates revisions, or a build is
216
- // running right now and the answer would change in a minute.
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, any build renews it`
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';
@@ -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)(' Addresses are permanent: replacing a file keeps its address'));
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 https://app.getxflow.com/api/storage/files/…/view, or xflow storage rm --folder photos --yes');
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
- /** Walk up from cwd. */
22
- function findProjectRoot(from = process.cwd()) {
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
- if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, exports.CONFIG_FILE)))
26
- return dir;
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
- return null;
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 path = (0, node_path_1.join)(root, exports.CONFIG_FILE);
35
- let parsed;
36
- try {
37
- parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
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 config;
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 folder we are in, when there is one and it parses. */
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
- return readConfig(root);
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 root = findProjectRoot();
65
- if (!root) {
66
- throw new ConfigError(`The folder is not linked to a project: there is no ${exports.CONFIG_FILE} next to it.\n` +
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(root) };
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(/\/+$/, '');