@getxflow/cli 0.4.0 → 0.5.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
@@ -21,6 +21,7 @@ else's code belongs next to it.
21
21
  | `status` / `push` / `pull` | state, sending and fetching sources |
22
22
  | `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
23
23
  | `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
24
+ | `db schema` / `db query` | tables and columns, reading data in a read-only transaction |
24
25
  | `functions list` | cloud functions of the project from `functions/<name>/index.ts`, shipped by `deploy` |
25
26
  | `functions invoke` / `functions logs` | call a function, look at its crashes with the stack |
26
27
  | `schedules list` / `set` / `rm` | running functions on a timer (timer triggers) |
package/dist/api.js CHANGED
@@ -14,7 +14,9 @@ class ApiError extends Error {
14
14
  issues;
15
15
  /** Set when a plan limit was hit. */
16
16
  limit;
17
- constructor(message, code, status, hint, issues, limit) {
17
+ /** Cloud functions a build would delete, with code `confirm_required`. */
18
+ removing;
19
+ constructor(message, code, status, hint, issues, limit, removing) {
18
20
  super(message);
19
21
  this.name = 'ApiError';
20
22
  this.code = code;
@@ -22,6 +24,7 @@ class ApiError extends Error {
22
24
  this.hint = hint;
23
25
  this.issues = issues;
24
26
  this.limit = limit;
27
+ this.removing = removing;
25
28
  }
26
29
  }
27
30
  exports.ApiError = ApiError;
@@ -90,7 +93,7 @@ async function readError(response) {
90
93
  const text = await response.text().catch(() => '');
91
94
  try {
92
95
  const parsed = JSON.parse(text);
93
- return new ApiError(parsed.error || `Request rejected (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed.issues, parsed.limit);
96
+ return new ApiError(parsed.error || `Request rejected (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed.issues, parsed.limit, parsed.removing);
94
97
  }
95
98
  catch {
96
99
  return new ApiError(`Request rejected (${response.status})`, 'unknown', response.status, text.slice(0, 200) || undefined);
package/dist/args.js CHANGED
@@ -19,6 +19,7 @@ const BOOLEAN_FLAGS = new Set([
19
19
  'all',
20
20
  'dry-run',
21
21
  'allow-destructive',
22
+ 'allow-removals',
22
23
  'show-token',
23
24
  ]);
24
25
  function parseArgs(argv) {
package/dist/bin.js CHANGED
@@ -122,11 +122,19 @@ async function run(args) {
122
122
  await (0, db_1.dbMigrate)(rest);
123
123
  return;
124
124
  }
125
+ if (second === 'schema') {
126
+ await (0, db_1.dbSchema)({ ...args, words: args.words.slice(2) });
127
+ return;
128
+ }
129
+ if (second === 'query') {
130
+ await (0, db_1.dbQuery)({ ...args, words: args.words.slice(2) });
131
+ return;
132
+ }
125
133
  if (second === undefined || second === 'status') {
126
134
  await (0, db_1.dbStatus)();
127
135
  return;
128
136
  }
129
- throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status and migrate');
137
+ throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, schema, query and migrate');
130
138
  case 'status':
131
139
  await (0, sources_1.status)();
132
140
  return;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.dbStatus = dbStatus;
4
+ exports.dbSchema = dbSchema;
5
+ exports.dbQuery = dbQuery;
4
6
  exports.dbMigrate = dbMigrate;
5
7
  const node_fs_1 = require("node:fs");
6
8
  const node_crypto_1 = require("node:crypto");
@@ -74,6 +76,84 @@ async function dbStatus() {
74
76
  (0, ui_1.warn)('A changed file will not be applied again: write a new migration');
75
77
  }
76
78
  }
79
+ /** Long values would tear the columns apart; the cut is marked, never silent. */
80
+ const CELL_LIMIT = 60;
81
+ function cell(value) {
82
+ if (value === null || value === undefined)
83
+ return '-';
84
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
85
+ return text.length > CELL_LIMIT ? `${text.slice(0, CELL_LIMIT - 1)}…` : text;
86
+ }
87
+ /**
88
+ * What the database looks like right now.
89
+ *
90
+ * Not the same question as the migrations/ directory: one logical database is
91
+ * shared by several projects, so the files answer "what did I do", not "what is
92
+ * in there".
93
+ */
94
+ async function dbSchema(args) {
95
+ const { config } = (0, config_1.requireProject)();
96
+ const client = (0, session_1.connect)(config);
97
+ const wanted = args.words[0];
98
+ const path = `/api/v1/projects/${config.projectId}/db/schema${wanted ? `?table=${encodeURIComponent(wanted)}` : ''}`;
99
+ if (wanted) {
100
+ const data = await (0, api_1.apiJson)(client, path);
101
+ (0, ui_1.out)(`${(0, ui_1.bold)(`${data.schema}.${data.table}`)}`);
102
+ (0, ui_1.out)('');
103
+ (0, ui_1.table)(data.columns.map((column) => [
104
+ column.column_name,
105
+ column.data_type,
106
+ column.is_nullable ? 'null' : 'not null',
107
+ [
108
+ column.is_primary_key ? 'primary key' : '',
109
+ column.is_foreign_key ? `→ ${column.foreign_key_ref ?? 'foreign key'}` : '',
110
+ ]
111
+ .filter(Boolean)
112
+ .join(' '),
113
+ ]));
114
+ return;
115
+ }
116
+ const data = await (0, api_1.apiJson)(client, path);
117
+ (0, ui_1.out)(`Schema: ${(0, ui_1.bold)(data.schema)}`);
118
+ (0, ui_1.out)('');
119
+ if (data.tables.length === 0) {
120
+ (0, ui_1.note)('No tables yet. The first migration: migrations/0001_init.sql');
121
+ return;
122
+ }
123
+ (0, ui_1.table)(data.tables.map((row) => [
124
+ row.table_name,
125
+ row.row_count === null ? '' : `~${row.row_count} rows`,
126
+ row.size ?? '',
127
+ ]));
128
+ (0, ui_1.note)((0, ui_1.dim)(' Columns of one table: xflow db schema <table>'));
129
+ }
130
+ /**
131
+ * Reading data.
132
+ *
133
+ * The query runs inside a READ ONLY transaction, so writes are rejected by the
134
+ * database itself rather than by us guessing at the text of the SQL.
135
+ */
136
+ async function dbQuery(args) {
137
+ const { config } = (0, config_1.requireProject)();
138
+ const sql = args.words.join(' ').trim();
139
+ if (!sql) {
140
+ throw new errors_1.CliError('A query is required', 'For example: xflow db query "select * from orders order by created_at desc limit 20"');
141
+ }
142
+ const client = (0, session_1.connect)(config);
143
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/query`, {
144
+ method: 'POST',
145
+ body: { sql, limit: (0, args_1.flagNumber)(args, 'limit') },
146
+ timeoutMs: 60_000,
147
+ });
148
+ if (data.rows.length === 0) {
149
+ (0, ui_1.note)('No rows');
150
+ return;
151
+ }
152
+ (0, ui_1.table)([data.columns, ...data.rows.map((row) => data.columns.map((column) => cell(row[column])))]);
153
+ if (data.truncated) {
154
+ (0, ui_1.note)((0, ui_1.dim)(` Shown ${data.rows.length} of ${data.total}: narrow the query or raise --limit`));
155
+ }
156
+ }
77
157
  async function dbMigrate(args) {
78
158
  const { root, config } = (0, config_1.requireProject)();
79
159
  const client = (0, session_1.connect)(config);
@@ -10,6 +10,7 @@ const api_1 = require("../api");
10
10
  const args_1 = require("../args");
11
11
  const config_1 = require("../config");
12
12
  const errors_1 = require("../errors");
13
+ const prompt_1 = require("../prompt");
13
14
  const session_1 = require("../session");
14
15
  const template_1 = require("../template");
15
16
  const ui_1 = require("../ui");
@@ -61,6 +62,44 @@ async function refreshFunctionsEnv(root, client, projectId) {
61
62
  }
62
63
  return card.functions.filter((fn) => fn.invoke_url).map((fn) => fn.name);
63
64
  }
65
+ /**
66
+ * Start the build, asking before anything is destroyed.
67
+ *
68
+ * Removing a cloud function cannot be undone: one created again later gets a
69
+ * different address, and its schedules go with it. The platform therefore
70
+ * refuses such a build until the caller says yes, and the question is asked
71
+ * here, before the build starts, rather than reported once it already has.
72
+ */
73
+ async function startBuild(client, projectId, revision, allowRemovals) {
74
+ const path = `/api/v1/projects/${projectId}/builds`;
75
+ try {
76
+ return await (0, api_1.apiJson)(client, path, {
77
+ method: 'POST',
78
+ body: { revision, allow_removals: allowRemovals },
79
+ });
80
+ }
81
+ catch (e) {
82
+ if (e instanceof api_1.ApiError && e.issues?.length)
83
+ reportIssues(e);
84
+ const removing = e instanceof api_1.ApiError && e.code === 'confirm_required' ? (e.removing ?? []) : [];
85
+ // No terminal (CI, an agent): the flag is the only way to say yes, and the
86
+ // platform message already names it.
87
+ if (removing.length === 0 || process.stdin.isTTY !== true || process.stderr.isTTY !== true)
88
+ throw e;
89
+ (0, ui_1.warn)(`Gone from the sources, the build would remove them: ${removing.join(', ')}`);
90
+ (0, ui_1.note)((0, ui_1.dim)(' Removal is final: a function created again later gets a different address, schedules included'));
91
+ const answer = await (0, prompt_1.select)('Remove them from the cloud?', [
92
+ { label: 'No, stop here', hint: 'bring the functions/<name>/index.ts directories back' },
93
+ { label: 'Yes, remove and build', hint: removing.join(', ') },
94
+ ]);
95
+ if (answer !== 1)
96
+ throw new errors_1.CliError('The build did not start', 'Nothing was removed');
97
+ return (0, api_1.apiJson)(client, path, {
98
+ method: 'POST',
99
+ body: { revision, allow_removals: true },
100
+ });
101
+ }
102
+ }
64
103
  async function deploy(args) {
65
104
  const { root, config } = (0, config_1.requireProject)();
66
105
  const client = (0, session_1.connect)(config);
@@ -77,20 +116,7 @@ async function deploy(args) {
77
116
  revision = (await (0, sources_1.pushSources)(root, config, client, { force: (0, args_1.flagBool)(args, 'force') })).revision;
78
117
  }
79
118
  (0, ui_1.step)(`Building on the platform from revision ${revision}`);
80
- let started;
81
- try {
82
- started = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/builds`, {
83
- method: 'POST',
84
- body: { revision },
85
- });
86
- }
87
- catch (e) {
88
- if (e instanceof api_1.ApiError && e.issues?.length)
89
- reportIssues(e);
90
- throw e;
91
- }
92
- // Removing a function cannot be undone: a new one gets a new address. Say it
93
- // out loud rather than leaving it to be discovered in functions list.
119
+ const started = await startBuild(client, config.projectId, revision, (0, args_1.flagBool)(args, 'allow-removals'));
94
120
  if (started.removed_functions?.length) {
95
121
  (0, ui_1.warn)(`Removed from the cloud, gone from the sources: ${started.removed_functions.join(', ')}`);
96
122
  }
package/dist/help.js CHANGED
@@ -46,6 +46,8 @@ ${(0, ui_1.bold)('Functions')}
46
46
 
47
47
  ${(0, ui_1.bold)('Database')}
48
48
  xflow db status which migrations are applied and which are waiting
49
+ xflow db schema [table] tables of the project, or the columns of one
50
+ xflow db query "select ..." read data, in a read-only transaction
49
51
  xflow db migrate [--dry-run] [--allow-destructive]
50
52
  apply the migrations from migrations/*.sql
51
53
 
@@ -63,22 +65,35 @@ ${(0, ui_1.bold)('Environment')}
63
65
  More about one command: xflow help <command>`);
64
66
  }
65
67
  const TOPICS = {
66
- db: `${(0, ui_1.bold)('xflow db migrate')}: apply migrations
68
+ db: `${(0, ui_1.bold)('xflow db')}: schema, data, migrations
67
69
 
68
- The files live in the repository: ${(0, ui_1.bold)('migrations/0001_init.sql')}, ${(0, ui_1.bold)('migrations/0002_orders.sql')}
69
- and so on. The order comes from the file name, which is why the leading number is
70
- required. Every migration runs in its own transaction, the history is kept in the
71
- database itself, and anything already applied is not run again.
70
+ xflow db status what is applied and what is waiting
71
+ xflow db schema [table] tables of the project, or the columns of one
72
+ xflow db query "select ..." read data (--limit N)
73
+ xflow db migrate apply migrations/*.sql
74
+
75
+ ${(0, ui_1.bold)('schema')} answers a different question than the ${(0, ui_1.bold)('migrations/')} directory: one logical
76
+ database is shared by several projects, so the files say what you did, and the schema
77
+ says what is actually in there. ${(0, ui_1.bold)('query')} runs inside a READ ONLY transaction, so
78
+ writes are rejected by the database itself, not by us reading your SQL.
79
+
80
+ ${(0, ui_1.bold)('migrate')}: the files live in the repository (${(0, ui_1.bold)('migrations/0001_init.sql')},
81
+ ${(0, ui_1.bold)('migrations/0002_orders.sql')} and so on). The order comes from the file name, which
82
+ is why the leading number is required. Every migration runs in its own transaction, the
83
+ history is kept in the database itself, and anything already applied is not run again.
72
84
 
73
85
  --dry-run report what would be applied without touching the database
74
86
  --allow-destructive allow operations that destroy data
75
87
 
76
88
  About destructive ones. The platform keeps no database history and makes no backups,
77
89
  so ${(0, ui_1.bold)('DROP TABLE')}, ${(0, ui_1.bold)('DROP COLUMN')}, ${(0, ui_1.bold)('TRUNCATE')} and ${(0, ui_1.bold)('DELETE FROM')} without a
78
- condition are rejected unless the flag is given. With the flag, the contents of the
79
- affected tables are dumped and kept for 7 days: that is a "caught it right away"
80
- safety net, not a backup. ${(0, ui_1.bold)('DROP DATABASE')} is never allowed, because the database
81
- is shared across the organization.
90
+ condition need two things at once: this flag, and the right to destroy data on the
91
+ access key. That right is off by default, and only its owner turns it on, in the
92
+ platform settings under Developers: a flag is something an agent adds by itself, a
93
+ right is not. With both in place, the contents of the affected tables are dumped and
94
+ kept for 7 days: that is a "caught it right away" safety net, not a backup.
95
+ ${(0, ui_1.bold)('DROP DATABASE')} is never allowed, because the database is shared across the
96
+ organization.
82
97
 
83
98
  Editing an already applied file achieves nothing: the comparison is by name, not by
84
99
  content. ${(0, ui_1.bold)('xflow db status')} lists such a file separately, so write a new migration
@@ -233,8 +248,9 @@ Three steps: sending the sources, shipping the cloud functions, building the app
233
248
  The build command and the output directory come from xflow.json (npm run build and dist
234
249
  by default).
235
250
 
236
- --no-push do not send sources, build from the latest server revision
237
- --force allow overwriting the server revision while sending
251
+ --no-push do not send sources, build from the latest server revision
252
+ --force allow overwriting the server revision while sending
253
+ --allow-removals agree in advance to remove the functions gone from the sources
238
254
 
239
255
  The platform builds, in a clean sandbox on one Node version for everybody, so "it
240
256
  worked on my machine" no longer depends on your machine. Before the build the project
@@ -247,10 +263,12 @@ works: the addresses of the functions are baked into the bundle, so they have to
247
263
  first. A function whose code and variables did not change is left alone, and a function
248
264
  that fails to ship fails the whole build.
249
265
 
250
- A function gone from the sources is deleted from the cloud along with its schedules, and
251
- the CLI names it before the build starts. That one is final: a function created again
252
- later gets a different address. If the sources hold no functions at all while the cloud
253
- holds several, nothing is deleted: that looks like a directory which never made it (a
266
+ A function gone from the sources would be deleted from the cloud along with its
267
+ schedules, and that is final: one created again later gets a different address. So the
268
+ build does not start at all until you say yes. In a terminal the CLI names the functions
269
+ and asks; without one (CI, an agent) it stops and ${(0, ui_1.bold)('--allow-removals')} is the only way
270
+ to agree. If the sources hold no functions at all while the cloud holds several, nothing
271
+ is deleted and nothing is asked: that looks like a directory which never made it (a
254
272
  ${(0, ui_1.bold)('functions/')} line in .xflowignore) rather than a decision.
255
273
 
256
274
  The database is not part of this: migrations change data in ways nothing can undo, so
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.4.0';
5
+ exports.CLI_VERSION = '0.5.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -68,7 +68,9 @@ restored within an hour of the data going back under the limit.
68
68
  6. `xflow publish` makes that same version visible to visitors.
69
69
 
70
70
  The split is deliberate: shipping a build and showing it are two separate decisions.
71
- Until `publish` runs, visitors keep seeing the previous version.
71
+ Until `publish` runs, visitors keep seeing the previous version. The one exception is
72
+ the very first version of a project: it publishes automatically, since there is no
73
+ live version to protect yet.
72
74
 
73
75
  Rolling back: `xflow deployments` lists the version history, `xflow rollback <id>`
74
76
  points the project back at an earlier build. Sources stay on their own revision.
@@ -162,10 +164,17 @@ on every call. Branch the frontend on `error.code`, never on `error.message`: wo
162
164
  gets rewritten on any edit, a code does not.
163
165
 
164
166
  The sources are the whole truth about which functions exist. Delete the directory and the
165
- next deploy deletes the function from the cloud, schedules included, and that cannot be
167
+ next deploy would delete the function from the cloud, schedules included, and that cannot be
166
168
  undone: a function created again later gets a different address. So never remove a function
167
169
  directory to "clean up" unless the user asked for the function to go.
168
170
 
171
+ Such a deploy does not start on its own: the platform names the functions it would remove and
172
+ refuses until somebody agrees. Under an agent there is no terminal to ask in, so the refusal
173
+ reaches you, and `--allow-removals` is the only way past it. Adding that flag to get the
174
+ build running is exactly the wrong move: it means you deleted something the user did not ask
175
+ you to delete. Put the directories back instead, and if the removal really is intended, say
176
+ which functions are about to go and let the user answer.
177
+
169
178
  Debugging a deployed function is two commands: `xflow functions invoke <name>` calls it
170
179
  the way the app does and prints status, timing and body (`--data '{"a":1}'` sends a body),
171
180
  and `xflow functions logs <name>` shows the failures, each with its stack and the console
@@ -276,13 +285,23 @@ under one of them would shadow the real one.
276
285
 
277
286
  The platform keeps no database history and no backups. Anything that destroys data
278
287
  (`DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, `DELETE FROM` without a condition) is refused
279
- unless you pass `--allow-destructive`, and with that flag the affected tables are dumped
280
- first and kept for 7 days. Check with `--dry-run` before applying.
288
+ unless two things hold at once: you pass `--allow-destructive`, and the access key carries
289
+ the right to destroy data. That right is off by default and only its owner can turn it on,
290
+ in the platform settings, under Developers. So when a destructive migration is refused for
291
+ the right rather than the flag, adding the flag changes nothing: say what needs deleting and
292
+ why, and let the person decide. With both in place the affected tables are dumped first and
293
+ kept for 7 days. Check with `--dry-run` before applying.
281
294
 
282
295
  One logical database can be shared by several projects, so your migration can break an app
283
296
  you do not see. `xflow db status` lists applied migrations that have no file in your
284
297
  repository: that is what someone else's project did.
285
298
 
299
+ For the same reason the `migrations/` directory is not the schema. It says what you did;
300
+ `xflow db schema` says what is in the database right now, and `xflow db schema <table>` gives
301
+ the columns of one table. `xflow db query "select ..."` reads data, inside a READ ONLY
302
+ transaction, so a write there fails by design rather than by accident. Look before you write
303
+ a migration against a shared database.
304
+
286
305
  ## File storage
287
306
 
288
307
  The project has file storage, and the browser cannot reach it. Those endpoints take only the
@@ -356,12 +375,11 @@ read-only queries, migrations, function logs and invocations, schedules, environ
356
375
  variables, versions, publish and rollback. They answer with aggregates and say explicitly
357
376
  when a result is truncated, which parsing terminal output does not.
358
377
 
359
- Code never travels through those tools. Sending sources stays in the CLI (`xflow push`):
360
- pulling a repository through tool calls burns the user's tokens for nothing. Building is
361
- available through the tools, because it runs from the revision already stored on the
362
- server: `deployments action=build` starts it and answers immediately, `action=status`
363
- reports the phase. It ships the functions too, from that same revision. A build takes
364
- minutes, so never expect the starting call to return a finished version.
378
+ Anything that depends on the working copy stays in the CLI: sending sources (`xflow push`),
379
+ building and shipping the functions (`xflow deploy`), creating a project (`xflow init`). The
380
+ tools cannot see the folder you are working in, so a build started from there would release
381
+ whatever revision the server happens to hold, not what you have on disk. Pulling a repository
382
+ through tool calls also burns the user's tokens for nothing.
365
383
 
366
384
  ## Do not
367
385