@getxflow/cli 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin.js +5 -1
- package/dist/commands/db.js +56 -8
- package/dist/commands/org.js +15 -9
- package/dist/commands/projects.js +16 -2
- package/dist/flags.js +3 -2
- package/dist/help.js +727 -713
- package/dist/version.js +1 -1
- package/package.json +24 -24
- package/skills/xflow/SKILL.md +20 -7
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ cd my-app/apps/my-app && npm install && xflow dev
|
|
|
10
10
|
xflow deploy -m "First version"
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Requires Node
|
|
13
|
+
Requires Node 22+. The package has no dependencies: it holds an access key, and nobody
|
|
14
14
|
else's code belongs next to it.
|
|
15
15
|
|
|
16
16
|
| Command | What it does |
|
package/dist/bin.js
CHANGED
|
@@ -176,6 +176,10 @@ 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 === 'exec') {
|
|
180
|
+
await (0, db_1.dbExec)({ ...args, words: args.words.slice(2) });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
179
183
|
if (second === 'move') {
|
|
180
184
|
await (0, db_1.dbMove)(rest);
|
|
181
185
|
return;
|
|
@@ -184,7 +188,7 @@ async function run(args) {
|
|
|
184
188
|
await (0, db_1.dbStatus)({ ...args, words: args.words.slice(2) });
|
|
185
189
|
return;
|
|
186
190
|
}
|
|
187
|
-
throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query, migrate and move');
|
|
191
|
+
throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query, exec, migrate and move');
|
|
188
192
|
case 'storage':
|
|
189
193
|
if (second === 'push') {
|
|
190
194
|
await (0, storage_1.storagePush)(rest);
|
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.dbExec = dbExec;
|
|
7
8
|
exports.dbMove = dbMove;
|
|
8
9
|
exports.dbMigrate = dbMigrate;
|
|
9
10
|
exports.migrateBeforeDeploy = migrateBeforeDeploy;
|
|
@@ -227,6 +228,20 @@ async function dbSchema(args) {
|
|
|
227
228
|
]));
|
|
228
229
|
(0, ui_1.note)((0, ui_1.dim)(' Columns of one table: xflow db schema <table>'));
|
|
229
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* The statement typed after the command, or read from the .sql file named there.
|
|
233
|
+
* The hint is split in lines: an agent terminal cuts a long one at its width.
|
|
234
|
+
*/
|
|
235
|
+
function readSql(words, command, example, file) {
|
|
236
|
+
const typed = words.join(' ').trim();
|
|
237
|
+
const sql = /\.sql$/i.test(typed)
|
|
238
|
+
? (0, text_file_1.readTextFile)(typed, 'An argument ending in .sql is read as a file: check the path, or end the statement with ;')
|
|
239
|
+
: typed;
|
|
240
|
+
if (!sql) {
|
|
241
|
+
throw new errors_1.CliError(typed ? `${typed} is empty` : 'An SQL statement is required', `For example: xflow db ${command} '${example}'\n A long one from a file outside the project: xflow db ${command} ../${file}`);
|
|
242
|
+
}
|
|
243
|
+
return sql;
|
|
244
|
+
}
|
|
230
245
|
/**
|
|
231
246
|
* Reading data.
|
|
232
247
|
*
|
|
@@ -234,14 +249,7 @@ async function dbSchema(args) {
|
|
|
234
249
|
* database itself rather than by us guessing at the text of the SQL.
|
|
235
250
|
*/
|
|
236
251
|
async function dbQuery(args) {
|
|
237
|
-
const
|
|
238
|
-
const sql = /\.sql$/i.test(typed)
|
|
239
|
-
? (0, text_file_1.readTextFile)(typed, 'An argument ending in .sql is read as a file: check the path, or end the query with ;')
|
|
240
|
-
: typed;
|
|
241
|
-
if (!sql) {
|
|
242
|
-
throw new errors_1.CliError(typed ? `${typed} is empty` : 'A query is required', "For example: xflow db query 'select * from orders order by created_at desc limit 20', " +
|
|
243
|
-
'or a long one from a file outside the project: xflow db query ../report.sql');
|
|
244
|
-
}
|
|
252
|
+
const sql = readSql(args.words, 'query', 'select * from orders order by created_at desc limit 20', 'report.sql');
|
|
245
253
|
const { projectId, client } = await (0, session_1.projectTarget)(args);
|
|
246
254
|
const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/query`, {
|
|
247
255
|
method: 'POST',
|
|
@@ -280,6 +288,46 @@ async function dbQuery(args) {
|
|
|
280
288
|
: ` Shown the first ${data.rows.length} rows, more exist: narrow the query, with a where clause or an offset`));
|
|
281
289
|
}
|
|
282
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* A one-off data change: one INSERT, UPDATE, DELETE or MERGE, saved at once and
|
|
293
|
+
* kept out of the migration history. The platform judges it by what the database
|
|
294
|
+
* reports, not by the text: schema changes are refused, a mass change asks for
|
|
295
|
+
* --allow-destructive.
|
|
296
|
+
*/
|
|
297
|
+
async function dbExec(args) {
|
|
298
|
+
const sql = readSql(args.words, 'exec', "update orders set status = 'paid' where id = 42", 'fix.sql');
|
|
299
|
+
const { projectId, client } = await (0, session_1.projectTarget)(args);
|
|
300
|
+
const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/exec`, {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
body: { sql, allow_destructive: (0, args_1.flagBool)(args, 'allow-destructive') },
|
|
303
|
+
timeoutMs: 120_000,
|
|
304
|
+
});
|
|
305
|
+
if ((0, args_1.flagBool)(args, 'json')) {
|
|
306
|
+
(0, ui_1.out)(JSON.stringify(data, null, 2));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// A write inside WITH comes tagged SELECT: its count is the rows read, the tables below say what changed.
|
|
310
|
+
(0, ui_1.ok)(data.command === 'SELECT'
|
|
311
|
+
? 'The writes inside WITH are saved'
|
|
312
|
+
: `${data.command}: ${data.row_count} ${data.row_count === 1 ? 'row' : 'rows'}, saved`);
|
|
313
|
+
for (const t of data.tables) {
|
|
314
|
+
const parts = [
|
|
315
|
+
t.inserted > 0 ? `${t.inserted} inserted` : '',
|
|
316
|
+
t.updated > 0 ? `${t.updated} updated` : '',
|
|
317
|
+
t.deleted > 0 ? `${t.deleted} deleted` : '',
|
|
318
|
+
].filter(Boolean);
|
|
319
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${t.table}: ${parts.join(', ')}`));
|
|
320
|
+
}
|
|
321
|
+
for (const dump of data.dumps) {
|
|
322
|
+
(0, ui_1.note)((0, ui_1.dim)(` Copy of ${dump.table} before the change: ${dump.rows} rows, kept for ${data.dump_retention_days} days`));
|
|
323
|
+
}
|
|
324
|
+
if (data.rows.length > 0) {
|
|
325
|
+
(0, ui_1.out)('');
|
|
326
|
+
(0, ui_1.table)([data.columns, ...data.rows.map((row) => data.columns.map((column) => cell(row[column])))]);
|
|
327
|
+
if (data.truncated)
|
|
328
|
+
(0, ui_1.note)((0, ui_1.dim)(` The first ${data.rows.length} returned rows: the rest are saved but not sent back`));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
283
331
|
const MOVE_POLL_MS = 3_000;
|
|
284
332
|
/** Past this the command lets go: the move carries on, db status follows it. */
|
|
285
333
|
const MOVE_FOLLOW_MS = 10 * 60_000;
|
package/dist/commands/org.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.orgList = orgList;
|
|
4
4
|
exports.orgMembers = orgMembers;
|
|
5
|
+
exports.findOrg = findOrg;
|
|
5
6
|
exports.orgSwitch = orgSwitch;
|
|
6
7
|
const api_1 = require("../api");
|
|
7
8
|
const config_1 = require("../config");
|
|
@@ -47,6 +48,19 @@ async function orgMembers() {
|
|
|
47
48
|
const count = data.truncated ? `${data.members.length} of ${data.total}` : String(data.total);
|
|
48
49
|
(0, ui_1.note)((0, ui_1.dim)(` ${count} in "${org ? label(org) : 'the organization'}". Inviting and removing people happens in the platform interface`));
|
|
49
50
|
}
|
|
51
|
+
/** An organization by id, or by name ignoring case; a name shared by several asks for the id. */
|
|
52
|
+
function findOrg(orgs, wanted) {
|
|
53
|
+
const byId = orgs.find((org) => org.organizationId === wanted);
|
|
54
|
+
if (byId)
|
|
55
|
+
return byId;
|
|
56
|
+
const byName = orgs.filter((org) => (org.name ?? '').toLowerCase() === wanted.toLowerCase());
|
|
57
|
+
if (byName.length > 1) {
|
|
58
|
+
throw new errors_1.CliError(`Several organizations are named "${wanted}"`, `Use the id instead: ${byName.map((org) => org.organizationId).join(', ')}`);
|
|
59
|
+
}
|
|
60
|
+
if (!byName[0])
|
|
61
|
+
throw new errors_1.CliError(`No stored key for "${wanted}"`, 'The list: xflow org. To add one: xflow login');
|
|
62
|
+
return byName[0];
|
|
63
|
+
}
|
|
50
64
|
/** Local switch of the active organization: a pointer move, no browser. */
|
|
51
65
|
function orgSwitch(args) {
|
|
52
66
|
const wanted = args.words[0];
|
|
@@ -57,15 +71,7 @@ function orgSwitch(args) {
|
|
|
57
71
|
if (orgs.length === 0) {
|
|
58
72
|
throw new errors_1.CliError(`No keys for ${apiUrl}`, 'Sign in: xflow login');
|
|
59
73
|
}
|
|
60
|
-
const
|
|
61
|
-
const byName = orgs.filter((org) => (org.name ?? '').toLowerCase() === wanted.toLowerCase());
|
|
62
|
-
if (!byId && byName.length > 1) {
|
|
63
|
-
throw new errors_1.CliError(`Several organizations are named "${wanted}"`, `Use the id instead: ${byName.map((org) => org.organizationId).join(', ')}`);
|
|
64
|
-
}
|
|
65
|
-
const target = byId ?? byName[0];
|
|
66
|
-
if (!target) {
|
|
67
|
-
throw new errors_1.CliError(`No stored key for "${wanted}"`, 'The list: xflow org. To add one: xflow login');
|
|
68
|
-
}
|
|
74
|
+
const target = findOrg(orgs, wanted);
|
|
69
75
|
if (target.active) {
|
|
70
76
|
(0, ui_1.ok)(`"${label(target)}" is already the active organization`);
|
|
71
77
|
return;
|
|
@@ -13,6 +13,7 @@ const args_1 = require("../args");
|
|
|
13
13
|
const config_1 = require("../config");
|
|
14
14
|
const credentials_1 = require("../credentials");
|
|
15
15
|
const errors_1 = require("../errors");
|
|
16
|
+
const org_1 = require("./org");
|
|
16
17
|
const session_1 = require("../session");
|
|
17
18
|
const skills_1 = require("./skills");
|
|
18
19
|
const sources_1 = require("./sources");
|
|
@@ -61,12 +62,25 @@ async function init(args) {
|
|
|
61
62
|
const target = (0, node_path_1.resolve)(args.words[0] ?? '.');
|
|
62
63
|
const name = (0, args_1.flagString)(args, 'name') ?? (0, node_path_1.basename)(target);
|
|
63
64
|
const noApp = (0, args_1.flagBool)(args, 'no-app');
|
|
64
|
-
|
|
65
|
+
let client = (0, session_1.connect)();
|
|
66
|
+
// A person in several organizations names the one that gets the project: the active one may be a client's.
|
|
67
|
+
const orgs = client.source === 'env' ? [] : (0, credentials_1.listOrgs)(client.apiUrl);
|
|
68
|
+
const wanted = (0, args_1.flagString)(args, 'org');
|
|
69
|
+
if (wanted) {
|
|
70
|
+
if (client.source === 'env')
|
|
71
|
+
throw new errors_1.CliError('--org does not apply to a key from XFLOW_TOKEN', 'That key belongs to one organization: drop --org');
|
|
72
|
+
const org = (0, org_1.findOrg)(orgs, wanted);
|
|
73
|
+
client = { apiUrl: client.apiUrl, token: org.token, source: 'folder', organizationId: org.organizationId };
|
|
74
|
+
}
|
|
75
|
+
else if (orgs.length > 1) {
|
|
76
|
+
throw new errors_1.CliError(`You are signed in to ${orgs.length} organizations: name the one that gets the project`, `xflow init ${args.words[0] ?? '.'} --org <name>. The list: xflow org`);
|
|
77
|
+
}
|
|
78
|
+
const orgName = orgs.find((org) => org.organizationId === client.organizationId)?.name;
|
|
65
79
|
if (!isEmptyEnough(target)) {
|
|
66
80
|
throw new errors_1.CliError(`The directory ${target} is not empty`, 'Create the project in an empty folder: xflow init my-project. To link an existing folder to a project: xflow link <id>');
|
|
67
81
|
}
|
|
68
82
|
const folder = noApp ? null : checkFolder((0, args_1.flagString)(args, 'app') ?? (0, sources_1.appFolderName)(name, new Set(), 'app'));
|
|
69
|
-
(0, ui_1.step)('Creating the project on the platform');
|
|
83
|
+
(0, ui_1.step)(orgName ? `Creating the project in "${orgName}"` : 'Creating the project on the platform');
|
|
70
84
|
const project = await (0, api_1.apiJson)(client, '/api/v1/projects', {
|
|
71
85
|
method: 'POST',
|
|
72
86
|
body: { name, app: folder ? { name } : null, server: (0, args_1.flagString)(args, 'server') ?? null },
|
package/dist/flags.js
CHANGED
|
@@ -44,7 +44,7 @@ exports.KNOWN = {
|
|
|
44
44
|
'org list': [],
|
|
45
45
|
'org members': [],
|
|
46
46
|
'org switch': [],
|
|
47
|
-
init: ['name', 'app', 'no-app', 'server'],
|
|
47
|
+
init: ['name', 'app', 'no-app', 'server', 'org'],
|
|
48
48
|
link: [],
|
|
49
49
|
projects: [],
|
|
50
50
|
'projects list': [],
|
|
@@ -79,6 +79,7 @@ exports.KNOWN = {
|
|
|
79
79
|
'db list': [],
|
|
80
80
|
'db schema': ['project'],
|
|
81
81
|
'db query': ['json', 'project'],
|
|
82
|
+
'db exec': ['allow-destructive', 'json', 'project'],
|
|
82
83
|
'db migrate': ['dry-run', 'allow-destructive'],
|
|
83
84
|
'db move': ['to', 'yes', 'project'],
|
|
84
85
|
storage: ['json', 'project'],
|
|
@@ -117,7 +118,7 @@ exports.KNOWN = {
|
|
|
117
118
|
* line here, or a refusal nobody prints any more stays behind as a lie.
|
|
118
119
|
*/
|
|
119
120
|
exports.WORKING_COPY = {
|
|
120
|
-
pull: 'it writes the sources next to it',
|
|
121
|
+
pull: 'it writes the sources next to it. A project without a folder comes down whole with xflow link <id> in an empty one',
|
|
121
122
|
status: 'it compares the folder with the revision on the platform',
|
|
122
123
|
dev: 'it runs the dev server of the app folder it stands in',
|
|
123
124
|
'apps add': 'it lays the template into apps/',
|