@getxflow/cli 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +5 -2
- package/dist/args.js +1 -0
- package/dist/bin.js +6 -2
- package/dist/commands/db.js +125 -9
- package/dist/commands/deploy.js +97 -29
- package/dist/commands/dev.js +1 -1
- package/dist/commands/projects.js +79 -3
- package/dist/credentials.js +0 -6
- package/dist/flags.js +7 -3
- package/dist/help.js +54 -8
- package/dist/template.js +62 -4
- package/dist/ui.js +3 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +67 -9
package/dist/api.js
CHANGED
|
@@ -16,7 +16,9 @@ class ApiError extends Error {
|
|
|
16
16
|
limit;
|
|
17
17
|
removing;
|
|
18
18
|
request;
|
|
19
|
-
|
|
19
|
+
/** The platform that answered: its signed-in organizations are the ones worth naming. */
|
|
20
|
+
apiUrl;
|
|
21
|
+
constructor(message, code, status, hint, details = {}, apiUrl) {
|
|
20
22
|
super(message);
|
|
21
23
|
this.name = 'ApiError';
|
|
22
24
|
this.code = code;
|
|
@@ -26,6 +28,7 @@ class ApiError extends Error {
|
|
|
26
28
|
this.limit = details.limit;
|
|
27
29
|
this.removing = details.removing;
|
|
28
30
|
this.request = details.request;
|
|
31
|
+
this.apiUrl = apiUrl;
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
exports.ApiError = ApiError;
|
|
@@ -126,7 +129,7 @@ async function readError(response, client) {
|
|
|
126
129
|
const text = await response.text().catch(() => '');
|
|
127
130
|
try {
|
|
128
131
|
const parsed = JSON.parse(text);
|
|
129
|
-
return new ApiError(parsed.error || `Request rejected (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed);
|
|
132
|
+
return new ApiError(parsed.error || `Request rejected (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed, client.apiUrl);
|
|
130
133
|
}
|
|
131
134
|
catch {
|
|
132
135
|
return notAnAnswer(response.status, client.apiUrl, text);
|
package/dist/args.js
CHANGED
package/dist/bin.js
CHANGED
|
@@ -176,11 +176,15 @@ async function run(args) {
|
|
|
176
176
|
await (0, db_1.dbQuery)({ ...args, words: args.words.slice(2) });
|
|
177
177
|
return;
|
|
178
178
|
}
|
|
179
|
+
if (second === 'move') {
|
|
180
|
+
await (0, db_1.dbMove)(rest);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
179
183
|
if (second === undefined || second === 'status') {
|
|
180
184
|
await (0, db_1.dbStatus)({ ...args, words: args.words.slice(2) });
|
|
181
185
|
return;
|
|
182
186
|
}
|
|
183
|
-
throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query and
|
|
187
|
+
throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query, migrate and move');
|
|
184
188
|
case 'storage':
|
|
185
189
|
if (second === 'push') {
|
|
186
190
|
await (0, storage_1.storagePush)(rest);
|
|
@@ -327,7 +331,7 @@ async function main() {
|
|
|
327
331
|
if (process.env.XFLOW_TOKEN?.trim()) {
|
|
328
332
|
(0, ui_1.fatalNote)((0, ui_1.dim)(' XFLOW_TOKEN is set: it is the key of one organization and is used as it comes, so a project or a page of another one is not found'));
|
|
329
333
|
}
|
|
330
|
-
else if ((0, credentials_1.
|
|
334
|
+
else if (e.apiUrl && (0, credentials_1.listOrgs)(e.apiUrl).length > 1) {
|
|
331
335
|
(0, ui_1.fatalNote)((0, ui_1.dim)(' Several organizations are signed in: the answer may live in another one, see xflow org'));
|
|
332
336
|
}
|
|
333
337
|
}
|
package/dist/commands/db.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.dbStatus = dbStatus;
|
|
|
4
4
|
exports.dbList = dbList;
|
|
5
5
|
exports.dbSchema = dbSchema;
|
|
6
6
|
exports.dbQuery = dbQuery;
|
|
7
|
+
exports.dbMove = dbMove;
|
|
7
8
|
exports.dbMigrate = dbMigrate;
|
|
8
9
|
const node_fs_1 = require("node:fs");
|
|
9
10
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -24,12 +25,34 @@ function serverLabel(server) {
|
|
|
24
25
|
return 'platform';
|
|
25
26
|
return server.status === 'active' ? `own "${server.name}"` : `own "${server.name}", ${server.status}`;
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
const moving = (move) => move?.status === 'copying' || move?.status === 'switching';
|
|
29
|
+
/** Asked apart from the history: during a move the database does not answer, this does. */
|
|
30
|
+
async function projectDatabase(client, projectId) {
|
|
31
|
+
try {
|
|
32
|
+
return await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/move`);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (e instanceof api_1.ApiError && e.code === 'not_deployed')
|
|
36
|
+
return null;
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function printPlace(history, place) {
|
|
28
41
|
(0, ui_1.out)(`Schema: ${(0, ui_1.bold)(history.schema)}`);
|
|
29
42
|
if (history.server !== undefined)
|
|
30
43
|
(0, ui_1.out)(`Server: ${serverLabel(history.server)}`);
|
|
44
|
+
if (place?.move)
|
|
45
|
+
(0, ui_1.out)(`Move: ${moveLine(place.move)}`);
|
|
31
46
|
(0, ui_1.out)('');
|
|
32
47
|
}
|
|
48
|
+
/** Mid-move the history cannot be read, so the answer is the move itself. */
|
|
49
|
+
function printMoving(place) {
|
|
50
|
+
if (place.database)
|
|
51
|
+
(0, ui_1.out)(`Database: ${(0, ui_1.bold)(place.database.name)}`);
|
|
52
|
+
(0, ui_1.out)(`Server: ${serverLabel(place.server)}`);
|
|
53
|
+
(0, ui_1.out)(`Move: ${place.move?.note ?? ''}`);
|
|
54
|
+
(0, ui_1.note)((0, ui_1.dim)(' Migrations and queries wait until it is over; the platform finishes it on its own'));
|
|
55
|
+
}
|
|
33
56
|
function checksum(sql) {
|
|
34
57
|
return (0, node_crypto_1.createHash)('sha256').update(sql).digest('hex').slice(0, 16);
|
|
35
58
|
}
|
|
@@ -58,8 +81,11 @@ async function dbStatus(args) {
|
|
|
58
81
|
// is the applied history alone, which does not depend on any working copy.
|
|
59
82
|
if ((0, args_1.flagString)(args, 'project') !== undefined) {
|
|
60
83
|
const { projectId, client } = await (0, session_1.projectTarget)(args);
|
|
84
|
+
const place = await projectDatabase(client, projectId);
|
|
85
|
+
if (place && moving(place.move))
|
|
86
|
+
return printMoving(place);
|
|
61
87
|
const history = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/migrate`);
|
|
62
|
-
printPlace(history);
|
|
88
|
+
printPlace(history, place);
|
|
63
89
|
if (history.applied.length === 0) {
|
|
64
90
|
(0, ui_1.note)('No migrations have been applied yet');
|
|
65
91
|
return;
|
|
@@ -71,9 +97,12 @@ async function dbStatus(args) {
|
|
|
71
97
|
const { root, config } = (0, config_1.requireProject)();
|
|
72
98
|
const client = await (0, session_1.connectProject)(config.projectId, root, config);
|
|
73
99
|
const local = readMigrations(root);
|
|
100
|
+
const place = await projectDatabase(client, config.projectId);
|
|
101
|
+
if (place && moving(place.move))
|
|
102
|
+
return printMoving(place);
|
|
74
103
|
const history = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/migrate`);
|
|
75
104
|
const applied = new Map(history.applied.map((row) => [row.name, row]));
|
|
76
|
-
printPlace(history);
|
|
105
|
+
printPlace(history, place);
|
|
77
106
|
if (local.length === 0 && applied.size === 0) {
|
|
78
107
|
(0, ui_1.note)(`No migrations. The first one: ${MIGRATIONS_DIR}/0001_init.sql`);
|
|
79
108
|
return;
|
|
@@ -104,6 +133,18 @@ async function dbStatus(args) {
|
|
|
104
133
|
(0, ui_1.warn)('A changed file will not be applied again: write a new migration');
|
|
105
134
|
}
|
|
106
135
|
}
|
|
136
|
+
function serverRow(server) {
|
|
137
|
+
const room = server.used_bytes !== null && server.space_bytes !== null
|
|
138
|
+
? `${(0, ui_1.formatBytes)(server.used_bytes)} of ${(0, ui_1.formatBytes)(server.space_bytes)} used`
|
|
139
|
+
: '';
|
|
140
|
+
return [
|
|
141
|
+
server.id === null ? 'platform' : `own "${server.name}"`,
|
|
142
|
+
server.id === null ? 'plan space' : server.tier ?? '',
|
|
143
|
+
server.status === 'active' ? '' : server.status,
|
|
144
|
+
room,
|
|
145
|
+
`${server.databases} ${server.databases === 1 ? 'database' : 'databases'}`,
|
|
146
|
+
];
|
|
147
|
+
}
|
|
107
148
|
function attachedProjects(database) {
|
|
108
149
|
const names = database.projects.map((project) => project.name);
|
|
109
150
|
if (database.hidden_projects > 0)
|
|
@@ -123,24 +164,34 @@ async function dbList() {
|
|
|
123
164
|
if (data.databases.length === 0) {
|
|
124
165
|
(0, ui_1.note)('The organization has no databases');
|
|
125
166
|
(0, ui_1.note)((0, ui_1.dim)(' One is created with the first project: xflow init'));
|
|
126
|
-
return;
|
|
127
167
|
}
|
|
128
|
-
|
|
168
|
+
else {
|
|
169
|
+
printDatabases(data.databases, data.total, data.truncated);
|
|
170
|
+
}
|
|
171
|
+
printServers(data.servers);
|
|
172
|
+
}
|
|
173
|
+
function moveLine(move) {
|
|
174
|
+
return move.finished_at ? `${move.note} (${(0, ui_1.formatAge)(move.finished_at)})` : move.note;
|
|
175
|
+
}
|
|
176
|
+
function printDatabases(databases, total, truncated) {
|
|
177
|
+
(0, ui_1.table)(databases.map((database) => [
|
|
129
178
|
database.name,
|
|
130
179
|
database.id,
|
|
131
180
|
database.schema ?? '-',
|
|
132
181
|
serverLabel(database.server),
|
|
133
182
|
attachedProjects(database),
|
|
134
183
|
]));
|
|
135
|
-
if (
|
|
136
|
-
(0, ui_1.note)((0, ui_1.dim)(` Shown ${
|
|
137
|
-
for (const database of
|
|
184
|
+
if (truncated)
|
|
185
|
+
(0, ui_1.note)((0, ui_1.dim)(` Shown ${databases.length} of ${total}`));
|
|
186
|
+
for (const database of databases) {
|
|
138
187
|
const attached = database.projects.length + database.hidden_projects;
|
|
139
188
|
if (attached > 1)
|
|
140
189
|
(0, ui_1.note)((0, ui_1.dim)(` ${database.name} is shared by ${attached} projects: a migration affects all of them`));
|
|
190
|
+
if (database.move)
|
|
191
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${database.name}: ${moveLine(database.move)}`));
|
|
141
192
|
}
|
|
142
193
|
const down = new Map();
|
|
143
|
-
for (const { server } of
|
|
194
|
+
for (const { server } of databases)
|
|
144
195
|
if (server && server.status !== 'active')
|
|
145
196
|
down.set(server.id, server);
|
|
146
197
|
for (const server of down.values()) {
|
|
@@ -148,6 +199,15 @@ async function dbList() {
|
|
|
148
199
|
'Not fixable from the code: tell the person, the server state is in the "Data" section of the web interface'));
|
|
149
200
|
}
|
|
150
201
|
}
|
|
202
|
+
/** Where a new or a moved database can go, with the room on each. Only the platform: no choice to show. */
|
|
203
|
+
function printServers(servers) {
|
|
204
|
+
if (!servers || servers.length < 2)
|
|
205
|
+
return;
|
|
206
|
+
(0, ui_1.out)('');
|
|
207
|
+
(0, ui_1.out)((0, ui_1.bold)('Servers'));
|
|
208
|
+
(0, ui_1.table)(servers.map(serverRow));
|
|
209
|
+
(0, ui_1.note)((0, ui_1.dim)(' Without --server a new database goes to the platform server. To put it on another: xflow init --server <name>, or xflow db move --to <name> for one that already exists'));
|
|
210
|
+
}
|
|
151
211
|
/** Long values would tear the columns apart; the cut is marked, never silent. */
|
|
152
212
|
const CELL_LIMIT = 60;
|
|
153
213
|
function plain(value) {
|
|
@@ -255,6 +315,62 @@ async function dbQuery(args) {
|
|
|
255
315
|
: ` Shown the first ${data.rows.length} rows, more exist: narrow the query, with a where clause or an offset`));
|
|
256
316
|
}
|
|
257
317
|
}
|
|
318
|
+
const MOVE_POLL_MS = 3_000;
|
|
319
|
+
/** Past this the command lets go: the move carries on, db status follows it. */
|
|
320
|
+
const MOVE_FOLLOW_MS = 10 * 60_000;
|
|
321
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
322
|
+
/**
|
|
323
|
+
* Moving the project database to another server of the organization. The platform
|
|
324
|
+
* runs the move by itself; this command starts it and follows it to the end.
|
|
325
|
+
*/
|
|
326
|
+
async function dbMove(args) {
|
|
327
|
+
const to = (0, args_1.flagString)(args, 'to');
|
|
328
|
+
if (!to) {
|
|
329
|
+
throw new errors_1.CliError('Name the server: --to <name>', 'The servers and their names: xflow db list ("platform" is the common one)');
|
|
330
|
+
}
|
|
331
|
+
const { projectId, client } = await (0, session_1.projectTarget)(args);
|
|
332
|
+
const path = `/api/v1/projects/${projectId}/db/move`;
|
|
333
|
+
let place;
|
|
334
|
+
try {
|
|
335
|
+
place = await (0, api_1.apiJson)(client, path, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
body: { to, confirm: (0, args_1.flagString)(args, 'confirm') },
|
|
338
|
+
timeoutMs: 60_000,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
// Said in the words of this command: the flag, and the name that goes in it.
|
|
343
|
+
if (e instanceof api_1.ApiError && e.code === 'confirm_required') {
|
|
344
|
+
const found = await projectDatabase(client, projectId).catch(() => null);
|
|
345
|
+
const name = found?.database?.name ?? '<database>';
|
|
346
|
+
throw new errors_1.CliError(e.message, `To move it: xflow db move --to "${to}" --confirm "${name}"`);
|
|
347
|
+
}
|
|
348
|
+
throw e;
|
|
349
|
+
}
|
|
350
|
+
(0, ui_1.note)((0, ui_1.dim)(' The platform carries the move through by itself: stopping this command does not stop it'));
|
|
351
|
+
const deadline = Date.now() + MOVE_FOLLOW_MS;
|
|
352
|
+
let shown = '';
|
|
353
|
+
while (moving(place.move)) {
|
|
354
|
+
const line = place.move?.note ?? '';
|
|
355
|
+
if (line !== shown)
|
|
356
|
+
(0, ui_1.step)(line);
|
|
357
|
+
shown = line;
|
|
358
|
+
if (Date.now() > deadline) {
|
|
359
|
+
(0, ui_1.note)('Still going: xflow db status follows it');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
await sleep(MOVE_POLL_MS);
|
|
363
|
+
place = await (0, api_1.apiJson)(client, path);
|
|
364
|
+
}
|
|
365
|
+
const { move } = place;
|
|
366
|
+
if (move?.status === 'failed')
|
|
367
|
+
throw new errors_1.CliError(move.note);
|
|
368
|
+
if (move && (move.functions_done ?? 0) < (move.functions_total ?? 0)) {
|
|
369
|
+
(0, ui_1.warn)(move.note);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
(0, ui_1.ok)(move?.note ?? `The database is on ${serverLabel(place.server)}`);
|
|
373
|
+
}
|
|
258
374
|
async function dbMigrate(args) {
|
|
259
375
|
const { root, config } = (0, config_1.requireProject)();
|
|
260
376
|
const client = await (0, session_1.connectProject)(config.projectId, root, config);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.versionComment = versionComment;
|
|
4
|
+
exports.statedRevision = statedRevision;
|
|
4
5
|
exports.deploy = deploy;
|
|
5
6
|
exports.publish = publish;
|
|
6
7
|
exports.rollback = rollback;
|
|
@@ -56,7 +57,7 @@ async function waitForBuild(client, projectId, started) {
|
|
|
56
57
|
*/
|
|
57
58
|
async function refreshPlatformEnv(root, client, projectId) {
|
|
58
59
|
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
|
|
59
|
-
(0, template_1.writePlatformEnv)(root, card.project_token, client.apiUrl, card.functions);
|
|
60
|
+
(0, template_1.writePlatformEnv)(root, card.project_token, client.apiUrl, card.functions, card.function_map);
|
|
60
61
|
return card.functions.filter((fn) => fn.invoke_url).map((fn) => fn.name);
|
|
61
62
|
}
|
|
62
63
|
/**
|
|
@@ -141,25 +142,14 @@ function requireComment(args) {
|
|
|
141
142
|
}
|
|
142
143
|
return comment;
|
|
143
144
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
throw new errors_1.CliError('The server holds no sources', 'Repeat without --no-push, so that the sources go up first');
|
|
153
|
-
}
|
|
154
|
-
revision = server.revision;
|
|
155
|
-
(0, ui_1.warn)(`Sources not sent (--no-push), the build runs from revision ${revision}`);
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
await assertBuildAllowed(client, config.projectId);
|
|
159
|
-
revision = (await (0, sources_1.pushSources)(root, config, client, { force: (0, args_1.flagBool)(args, 'force') })).revision;
|
|
160
|
-
}
|
|
161
|
-
(0, ui_1.step)(`Building on the platform from revision ${revision}`);
|
|
162
|
-
const started = await startBuild(client, config.projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'), comment);
|
|
145
|
+
/**
|
|
146
|
+
* Everything after the build has started: the same for a build of the sources
|
|
147
|
+
* just sent and for a rebuild of a revision the platform already holds.
|
|
148
|
+
*
|
|
149
|
+
* `root` is the folder to point at this project afterwards, and null when there
|
|
150
|
+
* is none: a build asked for by id has no .env to refresh.
|
|
151
|
+
*/
|
|
152
|
+
async function finishBuild(client, projectId, started, root) {
|
|
163
153
|
if (started.removed_functions?.length) {
|
|
164
154
|
(0, ui_1.warn)(`Removed from the cloud, gone from the sources: ${started.removed_functions.join(', ')}`);
|
|
165
155
|
}
|
|
@@ -167,7 +157,11 @@ async function deploy(args) {
|
|
|
167
157
|
(0, ui_1.warn)(`Access revoked by the provider: ${started.revoked_connections.join(', ')}`);
|
|
168
158
|
(0, ui_1.note)((0, ui_1.dim)(' Reconnect the account in the platform settings: another build will not fix this'));
|
|
169
159
|
}
|
|
170
|
-
|
|
160
|
+
if (started.shadowed_functions?.length) {
|
|
161
|
+
(0, ui_1.warn)(`Named like a standalone function of the organization: ${started.shadowed_functions.join(', ')}`);
|
|
162
|
+
(0, ui_1.note)((0, ui_1.dim)(' The app calls its own function under that name. Rename one of them to call the shared one'));
|
|
163
|
+
}
|
|
164
|
+
const build = await waitForBuild(client, projectId, started);
|
|
171
165
|
if (build.phase === 'failed') {
|
|
172
166
|
if (build.log_tail) {
|
|
173
167
|
(0, ui_1.out)();
|
|
@@ -180,20 +174,94 @@ async function deploy(args) {
|
|
|
180
174
|
// The functions were shipped by the build itself, so their addresses are known
|
|
181
175
|
// only now. A failure here does not undo a finished build: warn and stop there.
|
|
182
176
|
let functions = null;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
177
|
+
if (root !== null) {
|
|
178
|
+
try {
|
|
179
|
+
functions = await refreshPlatformEnv(root, client, projectId);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
(0, ui_1.warn)('Could not refresh the function addresses in .env, the build itself is fine');
|
|
183
|
+
}
|
|
188
184
|
}
|
|
185
|
+
// A project without a site has no pages to publish, and its functions went live
|
|
186
|
+
// with this very build. Naming publish there sends the reader to a refusal.
|
|
187
|
+
const hasSite = started.has_site !== false;
|
|
189
188
|
if (functions === null) {
|
|
190
|
-
(0, ui_1.note)((0, ui_1.dim)(' The cloud functions of this build are already live
|
|
189
|
+
(0, ui_1.note)((0, ui_1.dim)(' The cloud functions of this build are already live'));
|
|
190
|
+
if (hasSite)
|
|
191
|
+
(0, ui_1.note)((0, ui_1.dim)(' Publishing does not hold them back'));
|
|
191
192
|
}
|
|
192
193
|
else if (functions.length > 0) {
|
|
193
194
|
(0, ui_1.note)((0, ui_1.dim)(` Cloud functions live now: ${functions.join(', ')}`));
|
|
194
|
-
|
|
195
|
+
if (hasSite)
|
|
196
|
+
(0, ui_1.note)((0, ui_1.dim)(' There is one copy per project, publishing does not hold them back'));
|
|
195
197
|
}
|
|
196
|
-
|
|
198
|
+
if (hasSite)
|
|
199
|
+
(0, ui_1.note)((0, ui_1.dim)(' Show the new pages to visitors: xflow publish'));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Build a revision the platform already holds: this is how a version comes back.
|
|
203
|
+
*
|
|
204
|
+
* The sources of revision N are on the server, so nothing goes up and no copy of
|
|
205
|
+
* them is needed next to the caller. That also means `--force` has nothing to
|
|
206
|
+
* force: the revision on the server stays the newest one, and this build simply
|
|
207
|
+
* runs an older one. The project can therefore be named by id, which is the only
|
|
208
|
+
* way to bring back the version of a standalone function: its folder may live on
|
|
209
|
+
* another machine entirely, and there is nothing else to roll back with, because
|
|
210
|
+
* a project without a site has no pages to return.
|
|
211
|
+
*/
|
|
212
|
+
async function deployRevision(args, revision, comment) {
|
|
213
|
+
const { projectId, client, root } = await (0, session_1.projectTarget)(args);
|
|
214
|
+
await assertBuildAllowed(client, projectId);
|
|
215
|
+
(0, ui_1.step)(`Building on the platform from revision ${revision}`);
|
|
216
|
+
const started = await startBuild(client, projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'), comment);
|
|
217
|
+
await finishBuild(client, projectId, started, root);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The revision named by --revision, with the two rules that guard such a build.
|
|
221
|
+
*
|
|
222
|
+
* Kept pure and read before anything else: a wrong number or a flag that cannot
|
|
223
|
+
* apply is worth saying before the version comment is asked for, and a rule like
|
|
224
|
+
* this deserves a test that needs no platform to talk to.
|
|
225
|
+
*/
|
|
226
|
+
function statedRevision(revision, force) {
|
|
227
|
+
if (revision === undefined)
|
|
228
|
+
return undefined;
|
|
229
|
+
// flagNumber already refuses anything but a whole number, so zero is what is
|
|
230
|
+
// left to catch here: revisions are counted from one.
|
|
231
|
+
if (revision < 1) {
|
|
232
|
+
throw new errors_1.CliError(`--revision takes a revision number, got "${revision}"`, 'The revision of each version: xflow deployments');
|
|
233
|
+
}
|
|
234
|
+
if (force) {
|
|
235
|
+
throw new errors_1.CliError('--force does not apply to --revision', 'Nothing is sent: the build runs the sources the platform already holds');
|
|
236
|
+
}
|
|
237
|
+
return revision;
|
|
238
|
+
}
|
|
239
|
+
async function deploy(args) {
|
|
240
|
+
const stated = statedRevision((0, args_1.flagNumber)(args, 'revision'), (0, args_1.flagBool)(args, 'force'));
|
|
241
|
+
const comment = requireComment(args);
|
|
242
|
+
if (stated !== undefined)
|
|
243
|
+
return deployRevision(args, stated, comment);
|
|
244
|
+
if ((0, args_1.flagString)(args, 'project') !== undefined) {
|
|
245
|
+
throw new errors_1.CliError('xflow deploy sends the sources next to it, so it runs in the project folder', 'Name a project by id only to build a revision it already has: xflow deploy --project <id> --revision <n> -m "what came back"');
|
|
246
|
+
}
|
|
247
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
248
|
+
const client = await (0, session_1.connectProject)(config.projectId, root, config);
|
|
249
|
+
let revision;
|
|
250
|
+
if ((0, args_1.flagBool)(args, 'no-push')) {
|
|
251
|
+
const server = await (0, sources_1.latestRevision)(client, config.projectId);
|
|
252
|
+
if (!server) {
|
|
253
|
+
throw new errors_1.CliError('The server holds no sources', 'Repeat without --no-push, so that the sources go up first');
|
|
254
|
+
}
|
|
255
|
+
revision = server.revision;
|
|
256
|
+
(0, ui_1.warn)(`Sources not sent (--no-push), the build runs from revision ${revision}`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
await assertBuildAllowed(client, config.projectId);
|
|
260
|
+
revision = (await (0, sources_1.pushSources)(root, config, client, { force: (0, args_1.flagBool)(args, 'force') })).revision;
|
|
261
|
+
}
|
|
262
|
+
(0, ui_1.step)(`Building on the platform from revision ${revision}`);
|
|
263
|
+
const started = await startBuild(client, config.projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'), comment);
|
|
264
|
+
await finishBuild(client, config.projectId, started, root);
|
|
197
265
|
}
|
|
198
266
|
async function publish(args) {
|
|
199
267
|
const { projectId, client } = await (0, session_1.projectTarget)(args);
|
package/dist/commands/dev.js
CHANGED
|
@@ -30,7 +30,7 @@ async function dev() {
|
|
|
30
30
|
// project token, would reach this run only after one of them.
|
|
31
31
|
try {
|
|
32
32
|
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`);
|
|
33
|
-
Object.assign(env, (0, template_1.platformEnv)(card.project_token, client.apiUrl, card.functions));
|
|
33
|
+
Object.assign(env, (0, template_1.platformEnv)(card.project_token, client.apiUrl, card.functions, card.function_map));
|
|
34
34
|
}
|
|
35
35
|
catch (e) {
|
|
36
36
|
(0, ui_1.warn)(`The function addresses were not fetched (${e instanceof Error ? e.message : String(e)}): the ones in .env are used`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.templates = templates;
|
|
4
|
+
exports.functionNameFrom = functionNameFrom;
|
|
4
5
|
exports.init = init;
|
|
5
6
|
exports.link = link;
|
|
6
7
|
exports.list = list;
|
|
@@ -48,6 +49,66 @@ async function templates() {
|
|
|
48
49
|
}
|
|
49
50
|
(0, ui_1.table)(list.map((t) => [t.id, t.name, t.is_default ? 'default' : '', t.description ?? '']));
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* A function name out of the project name: the platform takes lowercase latin
|
|
54
|
+
* letters, digits and dashes, and the folder it lives in gives it its name.
|
|
55
|
+
*/
|
|
56
|
+
function functionNameFrom(name) {
|
|
57
|
+
const slug = name
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
60
|
+
.replace(/^-+|-+$/g, '')
|
|
61
|
+
.slice(0, 41);
|
|
62
|
+
if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(slug)) {
|
|
63
|
+
throw new errors_1.CliError(`Cannot make a function name out of "${name}"`, 'Name it in latin letters, digits and dashes: xflow init my-fn --function');
|
|
64
|
+
}
|
|
65
|
+
return slug;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Create a standalone function: a shared backend of the organization, a project
|
|
69
|
+
* without a site. No template is fetched — there is no frontend to fetch.
|
|
70
|
+
*/
|
|
71
|
+
async function initFunction(args, client, target, name) {
|
|
72
|
+
const functionName = functionNameFrom(name);
|
|
73
|
+
(0, ui_1.step)('Creating the function on the platform');
|
|
74
|
+
const project = await (0, api_1.apiJson)(client, '/api/v1/projects', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
body: {
|
|
77
|
+
name,
|
|
78
|
+
kind: 'function',
|
|
79
|
+
database_id: (0, args_1.flagString)(args, 'database') ?? null,
|
|
80
|
+
server: (0, args_1.flagString)(args, 'server') ?? null,
|
|
81
|
+
},
|
|
82
|
+
timeoutMs: 60_000,
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
(0, node_fs_1.mkdirSync)(target, { recursive: true });
|
|
86
|
+
for (const file of (0, template_1.functionScaffoldFiles)(name, functionName))
|
|
87
|
+
write(target, file.path, file.content);
|
|
88
|
+
// No build block: the platform builds the functions of such a project and
|
|
89
|
+
// never its site, so a build command would be a promise nobody keeps.
|
|
90
|
+
(0, config_1.writeConfig)(target, { projectId: project.id });
|
|
91
|
+
(0, config_1.ignoreStateInGit)(target);
|
|
92
|
+
if (client.organizationId)
|
|
93
|
+
(0, config_1.writeState)(target, { organizationId: client.organizationId });
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
(0, ui_1.note)((0, ui_1.dim)(` The function "${project.name}" is already created (${project.id}).`));
|
|
97
|
+
(0, ui_1.note)((0, ui_1.dim)(' Sort the folder out and pick it up: xflow link ' + project.id));
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
(0, skills_1.installSkillQuietly)(target);
|
|
101
|
+
(0, ui_1.ok)(`Function "${project.name}" created: functions/${functionName}`);
|
|
102
|
+
if (project.database) {
|
|
103
|
+
const { server } = project.database;
|
|
104
|
+
(0, ui_1.note)((0, ui_1.dim)(` Its database "${project.database.name}" is on ${server ? `own "${server.name}"` : 'the platform server'}`));
|
|
105
|
+
}
|
|
106
|
+
(0, ui_1.out)('');
|
|
107
|
+
(0, ui_1.out)(` ${(0, ui_1.bold)('Next:')}`);
|
|
108
|
+
(0, ui_1.out)(` cd ${(0, node_path_1.basename)(target)}`);
|
|
109
|
+
(0, ui_1.out)(' npm install');
|
|
110
|
+
(0, ui_1.out)(' xflow deploy # send the code, build and release the function');
|
|
111
|
+
}
|
|
51
112
|
/** Create a project from the platform template. */
|
|
52
113
|
async function init(args) {
|
|
53
114
|
const target = (0, node_path_1.resolve)(args.words[0] ?? '.');
|
|
@@ -56,6 +117,8 @@ async function init(args) {
|
|
|
56
117
|
if (!isEmptyEnough(target)) {
|
|
57
118
|
throw new errors_1.CliError(`The directory ${target} is not empty`, 'Create the project in an empty folder: xflow init my-app. To link an existing folder to a project: xflow link <id>');
|
|
58
119
|
}
|
|
120
|
+
if ((0, args_1.flagBool)(args, 'function'))
|
|
121
|
+
return initFunction(args, client, target, name);
|
|
59
122
|
let templateId = (0, args_1.flagString)(args, 'template');
|
|
60
123
|
if (!templateId) {
|
|
61
124
|
const { templates: list } = await (0, api_1.apiJson)(client, '/api/v1/templates');
|
|
@@ -69,7 +132,8 @@ async function init(args) {
|
|
|
69
132
|
(0, ui_1.step)('Creating the project on the platform');
|
|
70
133
|
const project = await (0, api_1.apiJson)(client, '/api/v1/projects', {
|
|
71
134
|
method: 'POST',
|
|
72
|
-
body: { name, database_id: (0, args_1.flagString)(args, 'database') ?? null },
|
|
135
|
+
body: { name, database_id: (0, args_1.flagString)(args, 'database') ?? null, server: (0, args_1.flagString)(args, 'server') ?? null },
|
|
136
|
+
timeoutMs: 60_000,
|
|
73
137
|
});
|
|
74
138
|
try {
|
|
75
139
|
(0, node_fs_1.mkdirSync)(target, { recursive: true });
|
|
@@ -94,6 +158,10 @@ async function init(args) {
|
|
|
94
158
|
}
|
|
95
159
|
(0, skills_1.installSkillQuietly)(target);
|
|
96
160
|
(0, ui_1.ok)(`Project "${project.name}" created: ${files.length} template files`);
|
|
161
|
+
if (project.database) {
|
|
162
|
+
const { server } = project.database;
|
|
163
|
+
(0, ui_1.note)((0, ui_1.dim)(` Its database "${project.database.name}" is on ${server ? `own "${server.name}"` : 'the platform server'}`));
|
|
164
|
+
}
|
|
97
165
|
(0, ui_1.out)('');
|
|
98
166
|
(0, ui_1.out)(` ${(0, ui_1.bold)('Next:')}`);
|
|
99
167
|
(0, ui_1.out)(` cd ${(0, node_path_1.basename)(target)}`);
|
|
@@ -159,7 +227,7 @@ async function link(args) {
|
|
|
159
227
|
(0, config_1.ignoreStateInGit)(dir);
|
|
160
228
|
if (client.organizationId)
|
|
161
229
|
(0, config_1.writeState)(dir, { organizationId: client.organizationId });
|
|
162
|
-
(0, template_1.writePlatformEnv)(dir, card.project_token, client.apiUrl, card.functions);
|
|
230
|
+
(0, template_1.writePlatformEnv)(dir, card.project_token, client.apiUrl, card.functions, card.function_map);
|
|
163
231
|
(0, ui_1.note)((0, ui_1.dim)(' .env now points at this project, run it locally with xflow dev'));
|
|
164
232
|
const skillReady = (0, skills_1.installSkillQuietly)(dir);
|
|
165
233
|
(0, ui_1.ok)(`The folder is linked to the project "${card.name}"`);
|
|
@@ -197,7 +265,15 @@ async function list() {
|
|
|
197
265
|
(0, ui_1.table)(projects.map((p) => [
|
|
198
266
|
p.id,
|
|
199
267
|
p.name,
|
|
200
|
-
|
|
268
|
+
// A standalone function has no pages at all, so the states of a site
|
|
269
|
+
// ("published", "dev only") would say nothing about it.
|
|
270
|
+
p.kind === 'function'
|
|
271
|
+
? 'function'
|
|
272
|
+
: p.live_deploy_id
|
|
273
|
+
? 'published'
|
|
274
|
+
: p.dev_deploy_id
|
|
275
|
+
? 'dev only'
|
|
276
|
+
: 'no build',
|
|
201
277
|
(0, ui_1.formatAge)(p.updated_at),
|
|
202
278
|
]));
|
|
203
279
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -7,7 +7,6 @@ exports.rememberOrgName = rememberOrgName;
|
|
|
7
7
|
exports.setActiveOrg = setActiveOrg;
|
|
8
8
|
exports.forgetOrg = forgetOrg;
|
|
9
9
|
exports.forgetAllOrgs = forgetAllOrgs;
|
|
10
|
-
exports.anyMultipleOrgs = anyMultipleOrgs;
|
|
11
10
|
exports.pickKey = pickKey;
|
|
12
11
|
exports.storedKeyInputs = storedKeyInputs;
|
|
13
12
|
const node_fs_1 = require("node:fs");
|
|
@@ -198,11 +197,6 @@ function forgetAllOrgs(apiUrl) {
|
|
|
198
197
|
writeStore(store);
|
|
199
198
|
return true;
|
|
200
199
|
}
|
|
201
|
-
/** True when any address holds keys of more than one organization. */
|
|
202
|
-
function anyMultipleOrgs() {
|
|
203
|
-
const store = readStore();
|
|
204
|
-
return Object.values(store.orgs ?? {}).some((address) => Object.keys(address.keys).length > 1);
|
|
205
|
-
}
|
|
206
200
|
/**
|
|
207
201
|
* One rule for the whole CLI: XFLOW_TOKEN (default address only) beats the
|
|
208
202
|
* folder's organization, which beats the active one. A folder bound to an
|
package/dist/flags.js
CHANGED
|
@@ -21,6 +21,10 @@ const help_1 = require("./help");
|
|
|
21
21
|
* only when that command can answer without the working copy. The ones that cannot
|
|
22
22
|
* have a folder to stand in, and naming a project they cannot read would be a lie;
|
|
23
23
|
* they are listed in WORKING_COPY below, each with what it reads from that folder.
|
|
24
|
+
* `deploy` sits halfway and keeps the flag: sending the sources needs the folder,
|
|
25
|
+
* but building a revision the platform already holds (`--revision`) does not, and
|
|
26
|
+
* it is the only way to bring back the version of a project without pages. Named
|
|
27
|
+
* by id without `--revision` it refuses, in those words.
|
|
24
28
|
* `db status` sits halfway: named by id it answers with the applied history alone,
|
|
25
29
|
* without the comparison against migrations/. `connections unlink` sits there too:
|
|
26
30
|
* by id it removes the link without the check of what reads the variables, because
|
|
@@ -40,7 +44,7 @@ exports.KNOWN = {
|
|
|
40
44
|
'org members': [],
|
|
41
45
|
'org switch': [],
|
|
42
46
|
templates: [],
|
|
43
|
-
init: ['name', 'template', 'database'],
|
|
47
|
+
init: ['name', 'template', 'database', 'server', 'function'],
|
|
44
48
|
link: [],
|
|
45
49
|
projects: [],
|
|
46
50
|
'projects list': [],
|
|
@@ -75,6 +79,7 @@ exports.KNOWN = {
|
|
|
75
79
|
'db schema': ['project'],
|
|
76
80
|
'db query': ['limit', 'json', 'project'],
|
|
77
81
|
'db migrate': ['dry-run', 'allow-destructive'],
|
|
82
|
+
'db move': ['to', 'confirm', 'project'],
|
|
78
83
|
storage: ['json', 'project'],
|
|
79
84
|
'storage ls': ['json', 'project'],
|
|
80
85
|
'storage list': ['json', 'project'],
|
|
@@ -90,7 +95,7 @@ exports.KNOWN = {
|
|
|
90
95
|
status: [],
|
|
91
96
|
dev: [],
|
|
92
97
|
pull: ['into', 'force', 'revision'],
|
|
93
|
-
deploy: ['no-push', 'force', 'allow-removals', 'message'],
|
|
98
|
+
deploy: ['no-push', 'force', 'allow-removals', 'message', 'revision', 'project'],
|
|
94
99
|
publish: ['project'],
|
|
95
100
|
rollback: ['project'],
|
|
96
101
|
deployments: ['project'],
|
|
@@ -109,7 +114,6 @@ exports.KNOWN = {
|
|
|
109
114
|
* line here, or a refusal nobody prints any more stays behind as a lie.
|
|
110
115
|
*/
|
|
111
116
|
exports.WORKING_COPY = {
|
|
112
|
-
deploy: 'it sends the sources next to it',
|
|
113
117
|
pull: 'it writes the sources next to it',
|
|
114
118
|
status: 'it compares the folder with the revision on the platform',
|
|
115
119
|
dev: 'it runs the dev server of the code next to it',
|
package/dist/help.js
CHANGED
|
@@ -88,6 +88,8 @@ ${(0, ui_1.bold)('Database')}
|
|
|
88
88
|
xflow db query "select ..." read data, in a read-only transaction
|
|
89
89
|
xflow db migrate [--dry-run] [--allow-destructive]
|
|
90
90
|
apply the migrations from migrations/*.sql
|
|
91
|
+
xflow db move --to <server> --confirm <database>
|
|
92
|
+
move the project database to another server
|
|
91
93
|
|
|
92
94
|
${(0, ui_1.bold)('Pages')}
|
|
93
95
|
xflow pages public sites of the organization at <name>.xflowpage.ru
|
|
@@ -162,18 +164,21 @@ no working copy of its own:
|
|
|
162
164
|
xflow db query --project 3f2a… "select id, title from tasks where done = false"
|
|
163
165
|
xflow functions invoke kanban --project 3f2a… --data-file card.json
|
|
164
166
|
|
|
165
|
-
Which commands: ${(0, ui_1.bold)('db status')}, ${(0, ui_1.bold)('db query')}, ${(0, ui_1.bold)('db schema')}, ${(0, ui_1.bold)('functions list')},
|
|
167
|
+
Which commands: ${(0, ui_1.bold)('db status')}, ${(0, ui_1.bold)('db query')}, ${(0, ui_1.bold)('db schema')}, ${(0, ui_1.bold)('db move')}, ${(0, ui_1.bold)('functions list')},
|
|
166
168
|
${(0, ui_1.bold)('functions invoke')}, ${(0, ui_1.bold)('functions logs')}, ${(0, ui_1.bold)('logs')}, ${(0, ui_1.bold)('env')}, ${(0, ui_1.bold)('env rm')},
|
|
167
169
|
${(0, ui_1.bold)('connections')}, ${(0, ui_1.bold)('connections link')}, ${(0, ui_1.bold)('connections unlink')}, ${(0, ui_1.bold)('schedules')},
|
|
168
170
|
${(0, ui_1.bold)('schedules set')}, ${(0, ui_1.bold)('schedules rm')}, ${(0, ui_1.bold)('storage ls')}, ${(0, ui_1.bold)('storage push')},
|
|
169
|
-
${(0, ui_1.bold)('storage rm')}, ${(0, ui_1.bold)('deployments')}, ${(0, ui_1.bold)('publish')}, ${(0, ui_1.bold)('rollback')}.
|
|
170
|
-
answer with less under the flag: ${(0, ui_1.bold)('db status')} gives the applied history alone,
|
|
171
|
+
${(0, ui_1.bold)('storage rm')}, ${(0, ui_1.bold)('deployments')}, ${(0, ui_1.bold)('publish')}, ${(0, ui_1.bold)('rollback')}, ${(0, ui_1.bold)('deploy')}. Three of
|
|
172
|
+
them answer with less under the flag: ${(0, ui_1.bold)('db status')} gives the applied history alone,
|
|
171
173
|
because comparing it against the local ${(0, ui_1.bold)('migrations/')} directory needs the folder,
|
|
172
|
-
|
|
173
|
-
its variables, for the same reason.
|
|
174
|
+
${(0, ui_1.bold)('connections unlink')} removes the link without naming the functions that read
|
|
175
|
+
its variables, for the same reason, and ${(0, ui_1.bold)('deploy')} takes the flag only together with
|
|
176
|
+
${(0, ui_1.bold)('--revision')}: that build runs a revision the platform already holds, so there is
|
|
177
|
+
nothing to send and no folder to send it from. Without ${(0, ui_1.bold)('--revision')} it refuses and
|
|
178
|
+
says so.
|
|
174
179
|
|
|
175
180
|
The rest cannot answer without the working copy and have nothing to do with an id
|
|
176
|
-
instead: ${(0, ui_1.bold)('
|
|
181
|
+
instead: ${(0, ui_1.bold)('pull')}, ${(0, ui_1.bold)('status')}, ${(0, ui_1.bold)('dev')}, ${(0, ui_1.bold)('db migrate')},
|
|
177
182
|
${(0, ui_1.bold)('env check')}, ${(0, ui_1.bold)('env set')}. They answer about the files next to them, so
|
|
178
183
|
they keep asking for a folder. ${(0, ui_1.bold)('projects get')} and ${(0, ui_1.bold)('link')} take no flag either:
|
|
179
184
|
they name the project by its id as an argument.
|
|
@@ -295,13 +300,30 @@ will work.`,
|
|
|
295
300
|
--json prints the values whole)
|
|
296
301
|
xflow db query <file>.sql the same, the query read from a file
|
|
297
302
|
xflow db migrate apply migrations/*.sql
|
|
303
|
+
xflow db move --to <server> --confirm <database>
|
|
304
|
+
move the project database to another server
|
|
298
305
|
|
|
299
306
|
${(0, ui_1.bold)('list')} is about the organization, not about this project: it names the logical
|
|
300
307
|
databases, the identifier ${(0, ui_1.bold)('xflow init --database <id>')} asks for, and the projects
|
|
301
308
|
attached to each one. That last column is the one to read before a migration. The server
|
|
302
309
|
column says where a database lives: ${(0, ui_1.bold)('platform')} is the common one, ${(0, ui_1.bold)('own "<name>"')} the
|
|
303
310
|
organization's own server. While an own server is not running, its databases do not
|
|
304
|
-
answer, and that is not fixable from the code: its state is in the web interface.
|
|
311
|
+
answer, and that is not fixable from the code: its state is in the web interface. An
|
|
312
|
+
organization with own servers also gets them listed, with the room used on each: that is
|
|
313
|
+
the choice for ${(0, ui_1.bold)('xflow init --server')} and ${(0, ui_1.bold)('db move')}. Without that flag a new
|
|
314
|
+
database goes to ${(0, ui_1.bold)('platform')}.
|
|
315
|
+
|
|
316
|
+
${(0, ui_1.bold)('move')} takes the database of this project to another server of the organization:
|
|
317
|
+
${(0, ui_1.bold)('platform')} or an own server by name (or id, when two share a name). The platform copies
|
|
318
|
+
the tables, switches the functions of every project on the database and removes the old
|
|
319
|
+
copy; nothing needs a deploy. While it copies, the applications read data but cannot save
|
|
320
|
+
anything new, usually for a couple of minutes, and that goes for every project on the
|
|
321
|
+
database, which is why ${(0, ui_1.bold)('--confirm')} takes the database name. It also needs the right
|
|
322
|
+
to move databases on the key: off by default, only its owner turns it on, in the platform
|
|
323
|
+
settings where the key permissions live. The command follows the move to the end;
|
|
324
|
+
stopping it does not stop the move, and ${(0, ui_1.bold)('xflow db status')} shows where it stands. A move
|
|
325
|
+
that fails is rolled back: the database stays where it was and works as before. Buying,
|
|
326
|
+
paying for and deleting servers stay with the person, in the web interface.
|
|
305
327
|
|
|
306
328
|
${(0, ui_1.bold)('schema')} answers a different question than the ${(0, ui_1.bold)('migrations/')} directory: one logical
|
|
307
329
|
database is shared by several projects, so the files say what you did, and the schema
|
|
@@ -643,6 +665,9 @@ by default).
|
|
|
643
665
|
user speaks, saying what changed. It names the version in the
|
|
644
666
|
history (--message is the long form)
|
|
645
667
|
--no-push do not send sources, build from the latest server revision
|
|
668
|
+
--revision <N> build a revision the platform already holds: this is how a
|
|
669
|
+
version comes back (the list: xflow deployments)
|
|
670
|
+
--project <id> with --revision only: the project to rebuild, no folder needed
|
|
646
671
|
--force allow overwriting the server revision while sending
|
|
647
672
|
--allow-removals agree in advance to remove the functions gone from the sources
|
|
648
673
|
|
|
@@ -685,7 +710,15 @@ they stay their own command (${(0, ui_1.bold)('xflow db migrate')}).
|
|
|
685
710
|
The built version is visible on the project page, and the CLI prints the link. Visitors
|
|
686
711
|
see it after ${(0, ui_1.bold)('xflow publish')}. The address of the build itself is not printed:
|
|
687
712
|
it carries the version number, and after the next publish such a link quietly serves an
|
|
688
|
-
old copy
|
|
713
|
+
old copy.
|
|
714
|
+
|
|
715
|
+
${(0, ui_1.bold)('--revision <N>')} brings a version back. The sources of that revision are already on
|
|
716
|
+
the server, so nothing goes up, no copy of them is needed next to you and the newest
|
|
717
|
+
revision stays the newest: this build simply runs an older one. Since there is no folder
|
|
718
|
+
to stand in, the project can be named by id (${(0, ui_1.bold)('--project')}), and for a standalone
|
|
719
|
+
function that is the only way back: it has no pages, so ${(0, ui_1.bold)('xflow rollback')} has nothing
|
|
720
|
+
to return. Functions that exist now but not in that revision would be removed, and the
|
|
721
|
+
same question is asked as in any other deploy.`,
|
|
689
722
|
init: `${(0, ui_1.bold)('xflow init')} [dir]: a new project
|
|
690
723
|
|
|
691
724
|
Lays down the platform template: React on Vite, Tailwind, a set of components
|
|
@@ -698,8 +731,21 @@ Building the interface on top of these components and the tokens in ${(0, ui_1.b
|
|
|
698
731
|
is not a formality: your own palette on top of them looks foreign inside the platform.
|
|
699
732
|
|
|
700
733
|
--name <name> project name (the folder name by default)
|
|
734
|
+
--function a standalone function instead of an application
|
|
701
735
|
--template <id> a different template (the list: xflow templates)
|
|
702
736
|
--database <id> attach a logical database of the organization (the list: xflow db list)
|
|
737
|
+
--server <name> a new database for the project, right away on that server:
|
|
738
|
+
platform, or an own server of the organization (xflow db list)
|
|
739
|
+
|
|
740
|
+
${(0, ui_1.bold)('--function')} makes a shared backend of the organization: a project without a site,
|
|
741
|
+
with one handler in ${(0, ui_1.bold)('functions/<name>/index.ts')} and no template to fetch. Any
|
|
742
|
+
application of the organization calls it; who exactly is allowed in is decided by
|
|
743
|
+
${(0, ui_1.bold)('minRole')} inside the function. It builds and releases with the same deploy, but
|
|
744
|
+
there are no pages to publish, so publish and rollback do not apply to it.
|
|
745
|
+
|
|
746
|
+
Without either flag the project gets its database with the first migration, on the
|
|
747
|
+
platform server. ${(0, ui_1.bold)('--server')} decides it up front: the server has to be running, and
|
|
748
|
+
the database counts against the plan at once.
|
|
703
749
|
|
|
704
750
|
The order: the project is created on the platform first, because without it there is
|
|
705
751
|
nowhere to get the token for .env. If writing the files fails, the project stays empty
|
package/dist/template.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.envApiUrl = envApiUrl;
|
|
|
4
4
|
exports.platformEnv = platformEnv;
|
|
5
5
|
exports.writePlatformEnv = writePlatformEnv;
|
|
6
6
|
exports.scaffoldFiles = scaffoldFiles;
|
|
7
|
+
exports.functionScaffoldFiles = functionScaffoldFiles;
|
|
7
8
|
const node_fs_1 = require("node:fs");
|
|
8
9
|
const node_path_1 = require("node:path");
|
|
9
10
|
const GITIGNORE = `node_modules/
|
|
@@ -45,19 +46,22 @@ function envApiUrl(apiUrl) {
|
|
|
45
46
|
return apiUrl.replace(/^https?:\/\//, '');
|
|
46
47
|
}
|
|
47
48
|
/** The variables that point a local run at the project: written to .env, handed to xflow dev. */
|
|
48
|
-
function platformEnv(projectToken, apiUrl, functions = []
|
|
49
|
+
function platformEnv(projectToken, apiUrl, functions = [],
|
|
50
|
+
// The map the platform assembled: it also holds the standalone functions of the
|
|
51
|
+
// organization. Own functions alone are the fallback for a platform without it.
|
|
52
|
+
map) {
|
|
49
53
|
return {
|
|
50
54
|
VITE_XFLOW_PROJECT_TOKEN: projectToken ?? '',
|
|
51
55
|
VITE_XFLOW_API_URL: envApiUrl(apiUrl),
|
|
52
|
-
[FUNCTIONS_ENV_KEY]: functionsEnvValue(functions),
|
|
56
|
+
[FUNCTIONS_ENV_KEY]: map ? JSON.stringify(map) : functionsEnvValue(functions),
|
|
53
57
|
};
|
|
54
58
|
}
|
|
55
59
|
/**
|
|
56
60
|
* Point .env at the current project: written on every link and deploy, so that `npm run dev`
|
|
57
61
|
* never keeps talking to the previous one. Variables of your own are left alone.
|
|
58
62
|
*/
|
|
59
|
-
function writePlatformEnv(root, projectToken, apiUrl, functions = []) {
|
|
60
|
-
for (const [key, value] of Object.entries(platformEnv(projectToken, apiUrl, functions))) {
|
|
63
|
+
function writePlatformEnv(root, projectToken, apiUrl, functions = [], map) {
|
|
64
|
+
for (const [key, value] of Object.entries(platformEnv(projectToken, apiUrl, functions, map))) {
|
|
61
65
|
writeEnvValue(root, key, value);
|
|
62
66
|
}
|
|
63
67
|
}
|
|
@@ -81,3 +85,57 @@ function scaffoldFiles(projectName) {
|
|
|
81
85
|
{ path: 'README.md', content: README.replace('%NAME%', projectName.replace(/"/g, '\\"')) },
|
|
82
86
|
];
|
|
83
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* A standalone function has no site, so it gets no Vite tree: one handler, and
|
|
90
|
+
* esbuild, which the sandbox takes from the project to bundle the function.
|
|
91
|
+
*/
|
|
92
|
+
const FUNCTION_HANDLER = `/**
|
|
93
|
+
* Cloud function of the %NAME% project.
|
|
94
|
+
*
|
|
95
|
+
* XFlow calls it over HTTP and, if a schedule is set, on a timer. The answer is
|
|
96
|
+
* { statusCode, body }, where body is a string.
|
|
97
|
+
*/
|
|
98
|
+
export async function handler(event: {
|
|
99
|
+
httpMethod: string;
|
|
100
|
+
body: string | null;
|
|
101
|
+
queryStringParameters: Record<string, string> | null;
|
|
102
|
+
}) {
|
|
103
|
+
const input = event.body ? JSON.parse(event.body) : {};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
statusCode: 200,
|
|
107
|
+
body: JSON.stringify({ ok: true, method: event.httpMethod, input }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
const FUNCTION_README = `# %NAME%
|
|
112
|
+
|
|
113
|
+
A standalone function on XFlow: a shared backend for the applications of the
|
|
114
|
+
organization, without a site of its own.
|
|
115
|
+
|
|
116
|
+
\`\`\`bash
|
|
117
|
+
npm install # dependencies
|
|
118
|
+
xflow deploy # send the code, build and release the function
|
|
119
|
+
xflow functions # its address and status
|
|
120
|
+
\`\`\`
|
|
121
|
+
|
|
122
|
+
The code lives in \`functions/%FUNCTION%/index.ts\`. A schedule, keys and secrets
|
|
123
|
+
are set in the platform, in the "Functions" section.
|
|
124
|
+
`;
|
|
125
|
+
function functionScaffoldFiles(projectName, functionName) {
|
|
126
|
+
const fill = (text) => text.replace(/%NAME%/g, projectName.replace(/"/g, '\\"')).replace(/%FUNCTION%/g, functionName);
|
|
127
|
+
return [
|
|
128
|
+
{ path: '.gitignore', content: GITIGNORE },
|
|
129
|
+
{ path: 'README.md', content: fill(FUNCTION_README) },
|
|
130
|
+
{
|
|
131
|
+
path: 'package.json',
|
|
132
|
+
content: `${JSON.stringify({
|
|
133
|
+
name: functionName,
|
|
134
|
+
private: true,
|
|
135
|
+
type: 'module',
|
|
136
|
+
devDependencies: { esbuild: '0.28.2' },
|
|
137
|
+
}, null, 2)}\n`,
|
|
138
|
+
},
|
|
139
|
+
{ path: `functions/${functionName}/index.ts`, content: fill(FUNCTION_HANDLER) },
|
|
140
|
+
];
|
|
141
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -96,7 +96,9 @@ function formatBytes(bytes) {
|
|
|
96
96
|
return `${bytes} B`;
|
|
97
97
|
if (bytes < 1024 * 1024)
|
|
98
98
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
99
|
-
|
|
99
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
100
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
101
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
100
102
|
}
|
|
101
103
|
function formatAge(iso) {
|
|
102
104
|
if (!iso)
|
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.30.0';
|
|
6
6
|
/** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
|
|
7
7
|
exports.DEFAULT_API_URL = 'https://app.getxflow.com';
|
package/package.json
CHANGED
package/skills/xflow/SKILL.md
CHANGED
|
@@ -22,7 +22,7 @@ contains the fix.
|
|
|
22
22
|
|
|
23
23
|
## Keeping these instructions current
|
|
24
24
|
|
|
25
|
-
These instructions ship with xflow CLI 0.
|
|
25
|
+
These instructions ship with xflow CLI 0.30.0, so the copy you are reading can be older than
|
|
26
26
|
the CLI answering your commands.
|
|
27
27
|
|
|
28
28
|
When the CLI says this file does not match it, run `xflow skills --refresh` and then **read
|
|
@@ -123,6 +123,12 @@ the pages of an earlier build. Only the pages come back and they talk to today's
|
|
|
123
123
|
code of that build is a separate fetch (`xflow pull --revision N --into ../old-version`, kept
|
|
124
124
|
outside the project folder); building it over the current one needs a human.
|
|
125
125
|
|
|
126
|
+
Bringing the code back instead of the pages: `xflow deploy --revision N -m "what came back"`
|
|
127
|
+
builds a revision the platform already holds. Nothing goes up, so no copy of that code is needed
|
|
128
|
+
next to you and the newest revision stays the newest. It is the only way back for a standalone
|
|
129
|
+
function, which has no pages to return, and it needs no folder at all: `--project <id>` names the
|
|
130
|
+
project. Functions that exist now but not in that revision are removed, with the usual question.
|
|
131
|
+
|
|
126
132
|
Pages are kept for the **three latest successful builds** plus whatever dev and live show;
|
|
127
133
|
older versions stay in the history with `pages_kept` false, readable but not openable. The
|
|
128
134
|
history holds the **last 30 builds**, each keeping the revision it was built from; any other
|
|
@@ -216,6 +222,28 @@ There is no separate deploy command: `xflow deploy` ships the functions and then
|
|
|
216
222
|
application, in that order. `xflow functions list` shows what is live. The handler returns
|
|
217
223
|
`{ statusCode, body }` where `body` is a JSON string.
|
|
218
224
|
|
|
225
|
+
A function that belongs to no single application is created on its own: `xflow init <dir>
|
|
226
|
+
--function` makes a project without a site, with one handler and no template. It is a shared
|
|
227
|
+
backend of the organization, and any application of that organization calls it; who is let in
|
|
228
|
+
is decided by `minRole` inside the function. Deploy builds and releases it the same way, but it
|
|
229
|
+
has no pages, so `xflow publish` and `xflow rollback` do not apply; an earlier version comes back
|
|
230
|
+
with `xflow deploy --revision N`. It counts against the plan
|
|
231
|
+
limit of functions, not of applications. Put a function here when it serves several
|
|
232
|
+
applications, a bot or a webhook; a function serving one application belongs in that project.
|
|
233
|
+
|
|
234
|
+
An application calls it like its own: `xflow.functions.invoke('<name>')`, the platform puts the
|
|
235
|
+
address into the map the bundle carries. Two things follow from that map. An application sees a
|
|
236
|
+
newly created standalone function only after its own `xflow deploy`, and a function of the
|
|
237
|
+
project always wins a name it shares with a standalone one: the deploy names such a name in its
|
|
238
|
+
output, and the shared function stays unreachable from that project until one of them is
|
|
239
|
+
renamed. Such a call carries a person: a server key of another project opens the functions of
|
|
240
|
+
its own project only.
|
|
241
|
+
|
|
242
|
+
Inside a standalone function, remember who can reach it: any member of the organization,
|
|
243
|
+
including someone whose access to projects is narrowed to one folder. The role in `minRole` is
|
|
244
|
+
the role in the organization, so a function holding data that is not for everyone checks the
|
|
245
|
+
caller itself (`event.xflow.user`).
|
|
246
|
+
|
|
219
247
|
Keep one shape inside that string across the whole project: `{ success: true, data }`
|
|
220
248
|
when it worked, `{ success: false, error: { message, code } }` when it did not. Nothing
|
|
221
249
|
enforces this, but a project where every function answers its own way costs an adapter
|
|
@@ -290,7 +318,8 @@ answers 401.
|
|
|
290
318
|
|
|
291
319
|
An outside service (a payment webhook, a bot, a CRM) has no person behind it and needs a key of
|
|
292
320
|
that one function. Keys are not issued by default and the CLI cannot create one: a human issues
|
|
293
|
-
it in the project settings, «Облачные функции» → the function →
|
|
321
|
+
it in the project settings, «Облачные функции» → the function → «Настройки»; a standalone
|
|
322
|
+
function has a screen of its own, «Функции» → the function → «Настройки». Ask the user to do
|
|
294
323
|
that and to paste the address back — never invent another way in. Send it in the
|
|
295
324
|
`X-Function-Key` header. A function holds at most two keys, the second one to replace the first
|
|
296
325
|
without downtime, not to serve a second consumer.
|
|
@@ -435,13 +464,42 @@ transaction (50 rows by default, `--limit` up to 300; `--json` prints long value
|
|
|
435
464
|
with double quotes goes in a `.sql` file outside the project, `xflow db query ../q.sql`: PowerShell
|
|
436
465
|
strips them from arguments. Look before you write a migration against a shared database.
|
|
437
466
|
|
|
438
|
-
An organization can have
|
|
439
|
-
|
|
440
|
-
status` the one under this project.
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
467
|
+
An organization can have own database servers, several of them for different jobs. `xflow db
|
|
468
|
+
list` names the server of each database (`platform` is the common one) and lists the own servers
|
|
469
|
+
with the room used; `xflow db status` names the one under this project. The server belongs to the
|
|
470
|
+
database: projects sharing a database share its server.
|
|
471
|
+
- A new project with its database on a chosen server: `xflow init --server <name>` (`platform`
|
|
472
|
+
for the common one). Without it the first migration creates the database on `platform`.
|
|
473
|
+
- An existing database: `xflow db move --to <name> --confirm <database name>`. Writes to every
|
|
474
|
+
project on it close while it copies, usually a couple of minutes; the platform switches the
|
|
475
|
+
functions itself, no deploy. It needs the right to move databases, off on the key by default:
|
|
476
|
+
refused for the right, say what should move where and why, and the person turns it on or
|
|
477
|
+
moves it in the "Data" section.
|
|
478
|
+
- Buying, paying for and deleting servers are the person's, in the web interface. There is no
|
|
479
|
+
limit on how many. If a database has outgrown its server, say so and suggest buying one.
|
|
480
|
+
|
|
481
|
+
Code stays the same: `DATABASE_URL` arrives as before, pass it as is, without an `ssl` option (an
|
|
482
|
+
own server is reached over a closed network, and `ssl` makes the connection fail). While an own
|
|
483
|
+
server is not running, migrations and queries say so and functions that use the database time
|
|
484
|
+
out. That is not your code and not yours to fix: tell the person, who sees the server state in
|
|
485
|
+
the "Data" section of the web interface, instead of retrying.
|
|
486
|
+
|
|
487
|
+
An own server comes tuned and guarded, so do not tune it from migrations:
|
|
488
|
+
- Installed from the start: `pgcrypto`, `pg_trgm`, `unaccent`, `citext`, `btree_gin`,
|
|
489
|
+
`btree_gist`, `pg_stat_statements`. Use them, do not `CREATE EXTENSION` them again. UUIDs
|
|
490
|
+
need no extension anywhere: `gen_random_uuid()` is built in, and on an own server so is
|
|
491
|
+
`uuidv7()`. Never `uuid-ossp`. Anything else (`postgis`, `pgvector`) is a request to the
|
|
492
|
+
person, not a migration.
|
|
493
|
+
- Limits on every connection: a statement is cut after 30 seconds, waiting for a lock after 3,
|
|
494
|
+
a transaction left open after 30. A query that needs longer is a wrong query (an index is
|
|
495
|
+
missing, or it is a report that belongs in a scheduled function writing a summary table). A
|
|
496
|
+
migration that needs longer is split into smaller ones. The one exception is a scheduled job
|
|
497
|
+
that genuinely needs more: it runs `SET statement_timeout = '5min'` on its own connection,
|
|
498
|
+
and nowhere else. A database that used `citext` or another extension type cannot move back to
|
|
499
|
+
`platform`: the move refuses, the common server has no such extensions.
|
|
500
|
+
- A restart happens on a plan change and takes about a minute. Create the `Client` inside the
|
|
501
|
+
handler, or keep a `Pool` at module level: a `Client` created at module level dies with the
|
|
502
|
+
restart and stays dead until the function cold-starts.
|
|
445
503
|
|
|
446
504
|
## File storage
|
|
447
505
|
|