@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 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 20+. The package has no dependencies: it holds an access key, and nobody
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);
@@ -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 typed = args.words.join(' ').trim();
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;
@@ -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 byId = orgs.find((org) => org.organizationId === wanted);
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
- const client = (0, session_1.connect)();
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/',