@getxflow/cli 0.3.3 → 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
@@ -60,7 +60,7 @@ async function run(args) {
60
60
  }
61
61
  throw new errors_1.CliError(`Unknown command: projects ${second}`, 'Available: list and get');
62
62
  case 'skills':
63
- (0, skills_1.skills)(rest);
63
+ await (0, skills_1.skills)(rest);
64
64
  return;
65
65
  case 'mcp':
66
66
  if (second === undefined || second === 'install') {
@@ -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
  }
@@ -7,18 +7,22 @@ const node_os_1 = require("node:os");
7
7
  const node_path_1 = require("node:path");
8
8
  const args_1 = require("../args");
9
9
  const errors_1 = require("../errors");
10
+ const prompt_1 = require("../prompt");
10
11
  const ui_1 = require("../ui");
11
- /**
12
- * The SKILL.md format is shared across tools (agentskills.io); each agent reads
13
- * its own folder. Cursor reads .cursor/rules/*.mdc instead, so it gets the same
14
- * text under its own header.
15
- */
16
- const SKILL_DIRS = [
17
- ['.claude', 'skills'],
18
- ['.agents', 'skills'],
12
+ const AGENTS = [
13
+ { id: 'claude', label: 'Claude Code', project: ['.claude', 'skills'], global: ['.claude', 'skills'] },
14
+ { id: 'cursor', label: 'Cursor', project: ['.agents', 'skills'], global: ['.cursor', 'skills'], rule: true },
15
+ { id: 'codex', label: 'Codex', project: ['.agents', 'skills'], global: ['.codex', 'skills'] },
16
+ { id: 'copilot', label: 'GitHub Copilot', project: ['.agents', 'skills'], global: ['.copilot', 'skills'] },
17
+ { id: 'gemini', label: 'Gemini CLI', project: ['.agents', 'skills'], global: ['.gemini', 'skills'] },
18
+ { id: 'opencode', label: 'OpenCode', project: ['.agents', 'skills'], global: ['.config', 'opencode', 'skills'] },
19
+ { id: 'windsurf', label: 'Windsurf', project: ['.windsurf', 'skills'], global: ['.codeium', 'windsurf', 'skills'] },
20
+ { id: 'openclaw', label: 'OpenClaw', project: ['skills'], global: ['.openclaw', 'skills'] },
21
+ { id: 'universal', label: 'Other agents (Cline, Warp, Zed, Amp)', project: ['.agents', 'skills'], global: ['.agents', 'skills'] },
19
22
  ];
20
- /** Codex reads its global skills from .codex/skills, not from .agents. */
21
- const GLOBAL_SKILL_DIRS = [...SKILL_DIRS, ['.codex', 'skills']];
23
+ /** What init and link lay down, and what a run without questions refreshes. */
24
+ const DEFAULT_PROJECT = ['claude', 'cursor', 'codex'];
25
+ const DEFAULT_GLOBAL = ['claude', 'codex', 'universal'];
22
26
  const CURSOR_RULE = ['.cursor', 'rules', 'xflow.mdc'];
23
27
  /** AGENTS.md is always read, unlike the lazily loaded skill: a short pointer lives there. */
24
28
  const POINTER_MARKER = '<!-- xflow-skill -->';
@@ -44,14 +48,20 @@ function cursorRule(skill) {
44
48
  const body = skill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
45
49
  return `---\ndescription: ${description}\nalwaysApply: false\n---\n\n${body}`;
46
50
  }
47
- /** Writes only when the content differs. Returns the touched path. */
51
+ function skillPath(base, agent, global) {
52
+ return (0, node_path_1.join)(global ? (0, node_os_1.homedir)() : base, ...(global ? agent.global : agent.project), 'xflow', 'SKILL.md');
53
+ }
54
+ function installed(base, agent, global) {
55
+ return (0, node_fs_1.existsSync)(skillPath(base, agent, global));
56
+ }
57
+ /** Writes only when the content differs. */
48
58
  function writeIfChanged(path, content) {
49
59
  const before = (0, node_fs_1.existsSync)(path) ? (0, node_fs_1.readFileSync)(path, 'utf-8') : null;
50
60
  if (before === content)
51
61
  return null;
52
62
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
53
63
  (0, node_fs_1.writeFileSync)(path, content, 'utf-8');
54
- return path;
64
+ return before === null ? 'new' : 'updated';
55
65
  }
56
66
  /** Append once, by marker: the file belongs to the user. */
57
67
  function appendPointer(base) {
@@ -60,31 +70,48 @@ function appendPointer(base) {
60
70
  if (before?.includes(POINTER_MARKER))
61
71
  return null;
62
72
  (0, node_fs_1.writeFileSync)(path, before ? `${before.replace(/\s*$/, '')}\n\n${POINTER}` : POINTER, 'utf-8');
63
- return path;
73
+ return { path, state: before === null ? 'new' : 'updated' };
64
74
  }
65
- function install(base, global) {
75
+ function install(base, ids, global) {
66
76
  const skill = skillSource();
67
- const touched = [];
68
- for (const dir of global ? GLOBAL_SKILL_DIRS : SKILL_DIRS) {
69
- const path = writeIfChanged((0, node_path_1.join)(base, ...dir, 'xflow', 'SKILL.md'), skill);
70
- if (path)
71
- touched.push(path);
77
+ const changes = [];
78
+ const seen = new Set();
79
+ const put = (path, content) => {
80
+ if (seen.has(path))
81
+ return;
82
+ seen.add(path);
83
+ const state = writeIfChanged(path, content);
84
+ if (state)
85
+ changes.push({ path, state });
86
+ };
87
+ for (const agent of AGENTS) {
88
+ if (!ids.includes(agent.id))
89
+ continue;
90
+ put(skillPath(base, agent, global), skill);
91
+ if (!global && agent.rule === true)
92
+ put((0, node_path_1.join)(base, ...CURSOR_RULE), cursorRule(skill));
72
93
  }
73
94
  // Project-level only: these have no global equivalents.
74
95
  if (!global) {
75
- const rule = writeIfChanged((0, node_path_1.join)(base, ...CURSOR_RULE), cursorRule(skill));
76
- if (rule)
77
- touched.push(rule);
78
96
  const pointer = appendPointer(base);
79
97
  if (pointer)
80
- touched.push(pointer);
98
+ changes.push(pointer);
81
99
  }
82
- return touched;
100
+ return changes;
101
+ }
102
+ /** Defaults plus whatever is already installed, so an update run refreshes everything. */
103
+ function defaultIds(base, global) {
104
+ const ids = new Set(global ? DEFAULT_GLOBAL : DEFAULT_PROJECT);
105
+ for (const agent of AGENTS) {
106
+ if (installed(base, agent, global))
107
+ ids.add(agent.id);
108
+ }
109
+ return [...ids];
83
110
  }
84
111
  /** Best-effort install from init and link: a failure must not break the linking. */
85
112
  function installSkillQuietly(base) {
86
113
  try {
87
- if (install(base, false).length > 0) {
114
+ if (install(base, defaultIds(base, false), false).length > 0) {
88
115
  (0, ui_1.note)((0, ui_1.dim)(' The platform instructions are laid out for the AI agent'));
89
116
  }
90
117
  }
@@ -92,12 +119,86 @@ function installSkillQuietly(base) {
92
119
  (0, ui_1.note)((0, ui_1.dim)(' Could not lay out the AI agent instructions: xflow skills'));
93
120
  }
94
121
  }
95
- function skills(args) {
96
- const global = (0, args_1.flagBool)(args, 'global');
97
- const touched = install(global ? (0, node_os_1.homedir)() : process.cwd(), global);
98
- for (const path of touched)
99
- (0, ui_1.out)(path);
100
- if (touched.length === 0)
122
+ function pathLabel(agent, global) {
123
+ const dir = global ? agent.global : agent.project;
124
+ return (global ? ['~', ...dir] : dir).join('/');
125
+ }
126
+ function listAgents(base) {
127
+ (0, ui_1.table)(AGENTS.map((agent) => [
128
+ agent.id,
129
+ agent.label,
130
+ pathLabel(agent, false) + (installed(base, agent, false) ? ' *' : ''),
131
+ pathLabel(agent, true) + (installed(base, agent, true) ? ' *' : ''),
132
+ ]));
133
+ (0, ui_1.note)((0, ui_1.dim)(' * installed. Columns: id, agent, in the project, in the home folder'));
134
+ (0, ui_1.note)((0, ui_1.dim)(' Install without questions: xflow skills --agent <id,id> [--global]'));
135
+ }
136
+ async function pick(globalFlag) {
137
+ let global = globalFlag;
138
+ if (!globalFlag) {
139
+ const scope = await (0, prompt_1.select)('Where to install the xflow skill', [
140
+ { label: 'This project', hint: process.cwd() },
141
+ { label: 'Globally', hint: 'home folder, visible to every project' },
142
+ ]);
143
+ if (scope === null)
144
+ return null;
145
+ global = scope === 1;
146
+ }
147
+ const base = process.cwd();
148
+ const present = AGENTS.map((agent) => installed(base, agent, global));
149
+ const anyPresent = present.includes(true);
150
+ const defaults = global ? DEFAULT_GLOBAL : DEFAULT_PROJECT;
151
+ const picked = await (0, prompt_1.multiSelect)('Which agents get the skill', AGENTS.map((agent, i) => ({
152
+ label: agent.label,
153
+ hint: pathLabel(agent, global) + (present[i] ? ', installed' : ''),
154
+ checked: anyPresent ? present[i] : defaults.includes(agent.id),
155
+ })));
156
+ if (picked === null || picked.length === 0)
157
+ return null;
158
+ return { ids: picked.map((i) => AGENTS[i].id), global };
159
+ }
160
+ async function skills(args) {
161
+ const sub = args.words[0];
162
+ if (sub === 'list') {
163
+ listAgents(process.cwd());
164
+ return;
165
+ }
166
+ if (sub !== undefined) {
167
+ throw new errors_1.CliError(`Unknown command: skills ${sub}`, 'Available: xflow skills [list]');
168
+ }
169
+ let global = (0, args_1.flagBool)(args, 'global');
170
+ const agentFlag = (0, args_1.flagString)(args, 'agent');
171
+ if (args.flags.agent !== undefined && agentFlag === undefined) {
172
+ throw new errors_1.CliError('The --agent flag needs a value', 'For example: --agent claude,cursor. The ids: xflow skills list');
173
+ }
174
+ let ids;
175
+ if (agentFlag !== undefined) {
176
+ ids = agentFlag
177
+ .split(',')
178
+ .map((id) => id.trim())
179
+ .filter((id) => id.length > 0);
180
+ const known = new Set(AGENTS.map((agent) => agent.id));
181
+ const unknown = ids.filter((id) => !known.has(id));
182
+ if (unknown.length > 0 || ids.length === 0) {
183
+ throw new errors_1.CliError(unknown.length > 0 ? `Unknown agent: ${unknown.join(', ')}` : 'No agents named', 'The ids: xflow skills list');
184
+ }
185
+ }
186
+ else if (process.stdin.isTTY === true && process.stderr.isTTY === true && !(0, args_1.flagBool)(args, 'yes')) {
187
+ const picked = await pick(global);
188
+ if (picked === null) {
189
+ (0, ui_1.note)((0, ui_1.dim)(' nothing chosen, nothing changed'));
190
+ return;
191
+ }
192
+ ids = picked.ids;
193
+ global = picked.global;
194
+ }
195
+ else {
196
+ ids = defaultIds(process.cwd(), global);
197
+ }
198
+ const changes = install(process.cwd(), ids, global);
199
+ for (const change of changes)
200
+ (0, ui_1.out)(`${change.state === 'new' ? '+' : '~'} ${change.path}`);
201
+ if (changes.length === 0)
101
202
  (0, ui_1.note)((0, ui_1.dim)(' everything is already up to date'));
102
203
  (0, ui_1.ok)(global ? 'The xflow skill is available in every project' : 'The xflow skill is in place');
103
204
  (0, ui_1.note)((0, ui_1.dim)(' After a CLI update run the command again: the skill ships with it'));
package/dist/help.js CHANGED
@@ -16,7 +16,7 @@ ${(0, ui_1.bold)('Getting started')}
16
16
  xflow init [dir] new project from the platform template
17
17
  xflow templates which templates are available
18
18
  xflow link <id> link this folder to a project (id: xflow projects list)
19
- xflow skills [--global] refresh the platform instructions for an AI agent
19
+ xflow skills pick the agents that get the platform instructions
20
20
  xflow mcp install give the agent platform access without a terminal
21
21
 
22
22
  ${(0, ui_1.bold)('Code')}
@@ -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
@@ -183,19 +198,23 @@ project from the ${(0, ui_1.bold)('project_id')} argument, not from your folder,
183
198
  An agent has no way to know about XFlow: the platform is not in its training. The
184
199
  instructions explain how to release and publish, where the design system components
185
200
  come from, and where to look for production errors. ${(0, ui_1.bold)('init')} and ${(0, ui_1.bold)('link')} lay them
186
- down on their own, so this command is for refreshing them after a CLI update: the two
187
- ship as one version.
201
+ down on their own for the common agents, so this command exists to pick tools
202
+ precisely and to refresh after a CLI update: the skill and the CLI ship as one version.
188
203
 
189
- The format is shared (agentskills.io) but the folders differ per tool, so we write all:
204
+ Run in a terminal, it asks two questions: where (this project, or the home folder,
205
+ which makes the skill visible in every project) and for which agents. Copies that are
206
+ already installed come pre-selected and are updated in place. Without a terminal, in
207
+ CI or when an agent runs the command, there are no questions: the defaults plus every
208
+ already installed copy are refreshed.
190
209
 
191
- .claude/skills/xflow/SKILL.md Claude Code
192
- .agents/skills/xflow/SKILL.md Codex, OpenClaw
193
- .cursor/rules/xflow.mdc Cursor: it does not read the shared format
210
+ xflow skills list the agents, their folders and what is installed
211
+ --agent claude,cursor exact agents, no questions asked
212
+ --global the home folder instead of the project
213
+ --yes no questions: defaults plus what is installed
194
214
 
195
- --global the same in the home folder, which makes the skill visible in every
196
- project. There ~/.codex/skills is added as well, because Codex looks in
197
- its shared catalog only inside a repository. The Cursor rule and
198
- AGENTS.md stay per project, they have no global counterpart
215
+ The format is shared (agentskills.io) and inside a project most tools read the shared
216
+ .agents/skills folder, so the paths differ mostly in the home directory. Cursor also
217
+ gets .cursor/rules/xflow.mdc: its older versions do not read the shared format.
199
218
 
200
219
  Plus a few pointer lines in ${(0, ui_1.bold)('AGENTS.md')}. A skill is picked up lazily, only when its
201
220
  description matches the task, and "add a customers table" will not trigger it. AGENTS.md
@@ -229,8 +248,9 @@ Three steps: sending the sources, shipping the cloud functions, building the app
229
248
  The build command and the output directory come from xflow.json (npm run build and dist
230
249
  by default).
231
250
 
232
- --no-push do not send sources, build from the latest server revision
233
- --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
234
254
 
235
255
  The platform builds, in a clean sandbox on one Node version for everybody, so "it
236
256
  worked on my machine" no longer depends on your machine. Before the build the project
@@ -243,10 +263,12 @@ works: the addresses of the functions are baked into the bundle, so they have to
243
263
  first. A function whose code and variables did not change is left alone, and a function
244
264
  that fails to ship fails the whole build.
245
265
 
246
- A function gone from the sources is deleted from the cloud along with its schedules, and
247
- the CLI names it before the build starts. That one is final: a function created again
248
- later gets a different address. If the sources hold no functions at all while the cloud
249
- 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
250
272
  ${(0, ui_1.bold)('functions/')} line in .xflowignore) rather than a decision.
251
273
 
252
274
  The database is not part of this: migrations change data in ways nothing can undo, so
package/dist/prompt.js ADDED
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.multiSelect = multiSelect;
4
+ exports.select = select;
5
+ const node_readline_1 = require("node:readline");
6
+ const errors_1 = require("./errors");
7
+ const ui_1 = require("./ui");
8
+ function render(lines, first) {
9
+ const up = first ? '' : `[${lines.length}A`;
10
+ process.stderr.write(up + lines.map((line) => `${line}\n`).join(''));
11
+ }
12
+ function listen(onKey) {
13
+ if (process.stdin.isTTY !== true) {
14
+ throw new errors_1.CliError('An interactive prompt needs a terminal', 'Pass the choice through flags instead');
15
+ }
16
+ (0, node_readline_1.emitKeypressEvents)(process.stdin);
17
+ const wasRaw = process.stdin.isRaw === true;
18
+ process.stdin.setRawMode(true);
19
+ process.stdin.resume();
20
+ const handler = (_, key) => {
21
+ if (key)
22
+ onKey(key);
23
+ };
24
+ process.stdin.on('keypress', handler);
25
+ return () => {
26
+ process.stdin.off('keypress', handler);
27
+ process.stdin.setRawMode(wasRaw);
28
+ process.stdin.pause();
29
+ };
30
+ }
31
+ const isConfirm = (key) => key.name === 'return' || key.name === 'enter';
32
+ const isCancel = (key) => key.name === 'escape' || (key.ctrl === true && key.name === 'c');
33
+ /** Checkbox list. Resolves with the picked indexes, or null when cancelled. */
34
+ function multiSelect(title, choices) {
35
+ return new Promise((resolve) => {
36
+ let cursor = 0;
37
+ let first = true;
38
+ const checked = choices.map((choice) => choice.checked === true);
39
+ const draw = () => {
40
+ render([
41
+ (0, ui_1.bold)(title),
42
+ ...choices.map((choice, i) => {
43
+ const hint = choice.hint ? ` ${(0, ui_1.dim)(choice.hint)}` : '';
44
+ return `${i === cursor ? '>' : ' '} ${checked[i] ? '[x]' : '[ ]'} ${choice.label}${hint}`;
45
+ }),
46
+ (0, ui_1.dim)(' up/down move, space toggle, enter confirm, esc cancel'),
47
+ ], first);
48
+ first = false;
49
+ };
50
+ const stop = listen((key) => {
51
+ if (key.name === 'up')
52
+ cursor = (cursor + choices.length - 1) % choices.length;
53
+ else if (key.name === 'down')
54
+ cursor = (cursor + 1) % choices.length;
55
+ else if (key.name === 'space')
56
+ checked[cursor] = !checked[cursor];
57
+ else if (isConfirm(key)) {
58
+ stop();
59
+ resolve(checked.flatMap((on, i) => (on ? [i] : [])));
60
+ return;
61
+ }
62
+ else if (isCancel(key)) {
63
+ stop();
64
+ resolve(null);
65
+ return;
66
+ }
67
+ else
68
+ return;
69
+ draw();
70
+ });
71
+ draw();
72
+ });
73
+ }
74
+ /** Radio list. Resolves with the picked index, or null when cancelled. */
75
+ function select(title, choices) {
76
+ return new Promise((resolve) => {
77
+ let cursor = 0;
78
+ let first = true;
79
+ const draw = () => {
80
+ render([
81
+ (0, ui_1.bold)(title),
82
+ ...choices.map((choice, i) => {
83
+ const hint = choice.hint ? ` ${(0, ui_1.dim)(choice.hint)}` : '';
84
+ return `${i === cursor ? '>' : ' '} ${choice.label}${hint}`;
85
+ }),
86
+ (0, ui_1.dim)(' up/down move, enter confirm, esc cancel'),
87
+ ], first);
88
+ first = false;
89
+ };
90
+ const stop = listen((key) => {
91
+ if (key.name === 'up')
92
+ cursor = (cursor + choices.length - 1) % choices.length;
93
+ else if (key.name === 'down')
94
+ cursor = (cursor + 1) % choices.length;
95
+ else if (isConfirm(key)) {
96
+ stop();
97
+ resolve(cursor);
98
+ return;
99
+ }
100
+ else if (isCancel(key)) {
101
+ stop();
102
+ resolve(null);
103
+ return;
104
+ }
105
+ else
106
+ return;
107
+ draw();
108
+ });
109
+ draw();
110
+ });
111
+ }
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.3.3';
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.3.3",
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": {
@@ -17,6 +17,26 @@ memory. If a command is not in the help output, it does not exist: guessing flag
17
17
  pointless. The CLI prints a hint with almost every error, read it in full, it usually
18
18
  contains the fix.
19
19
 
20
+ ## Hard rules
21
+
22
+ These mistakes cost the most because nothing fails at the moment they are made, or
23
+ the error points away from the cause. The sections below carry the details.
24
+
25
+ 1. Read environment variables literally: `process.env.API_KEY`. Destructuring
26
+ (`const { API_KEY } = process.env`) reads as no mention of the variable at all,
27
+ and it arrives empty.
28
+ 2. A value written with `xflow env set` reaches the functions on the next
29
+ `xflow deploy`, not at the moment it is written.
30
+ 3. Never delete a `functions/<name>/` directory unless the user asked for that
31
+ function to go. The next deploy removes it from the cloud together with its
32
+ schedules, and a function created again later gets a different address.
33
+ 4. An already applied migration is never re-run, so editing its file changes
34
+ nothing. A schema change is always a new file.
35
+ 5. When inserts start failing with `db_write_locked`, the database is over its plan
36
+ size. The fix is a migration that deletes data, never a rewrite of the failing SQL.
37
+ 6. In file storage, call `confirm` only after the PUT has finished, and send the same
38
+ `Content-Type` in both calls. Both mistakes answer 200 and break later.
39
+
20
40
  ## Plan limits
21
41
 
22
42
  The organization runs on a plan with finite limits: projects, cloud functions, developer
@@ -48,7 +68,9 @@ restored within an hour of the data going back under the limit.
48
68
  6. `xflow publish` makes that same version visible to visitors.
49
69
 
50
70
  The split is deliberate: shipping a build and showing it are two separate decisions.
51
- 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.
52
74
 
53
75
  Rolling back: `xflow deployments` lists the version history, `xflow rollback <id>`
54
76
  points the project back at an earlier build. Sources stay on their own revision.
@@ -142,10 +164,17 @@ on every call. Branch the frontend on `error.code`, never on `error.message`: wo
142
164
  gets rewritten on any edit, a code does not.
143
165
 
144
166
  The sources are the whole truth about which functions exist. Delete the directory and the
145
- 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
146
168
  undone: a function created again later gets a different address. So never remove a function
147
169
  directory to "clean up" unless the user asked for the function to go.
148
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
+
149
178
  Debugging a deployed function is two commands: `xflow functions invoke <name>` calls it
150
179
  the way the app does and prints status, timing and body (`--data '{"a":1}'` sends a body),
151
180
  and `xflow functions logs <name>` shows the failures, each with its stack and the console
@@ -222,6 +251,17 @@ To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily
222
251
  Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
223
252
  how Yandex wants it. A scheduled run reaches the handler as a POST with no headers.
224
253
 
254
+ The pieces line up in one pass. From a new function to a verified schedule:
255
+
256
+ ```
257
+ xflow env set SMTP_PASSWORD=... # secrets first: values ride the next deploy
258
+ # write functions/report/index.ts, reading process.env.SMTP_PASSWORD literally
259
+ xflow deploy # ships the function, then builds the app
260
+ xflow schedules set report "0 3 ? * * *" # after the deploy: a schedule needs a deployed function
261
+ xflow functions invoke report # run it once, the way the app would
262
+ xflow functions logs report # empty output means it never crashed
263
+ ```
264
+
225
265
  ## Database
226
266
 
227
267
  Schema changes are files: `migrations/0001_init.sql`, `migrations/0002_orders.sql`, applied
@@ -245,13 +285,23 @@ under one of them would shadow the real one.
245
285
 
246
286
  The platform keeps no database history and no backups. Anything that destroys data
247
287
  (`DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, `DELETE FROM` without a condition) is refused
248
- unless you pass `--allow-destructive`, and with that flag the affected tables are dumped
249
- 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.
250
294
 
251
295
  One logical database can be shared by several projects, so your migration can break an app
252
296
  you do not see. `xflow db status` lists applied migrations that have no file in your
253
297
  repository: that is what someone else's project did.
254
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
+
255
305
  ## File storage
256
306
 
257
307
  The project has file storage, and the browser cannot reach it. Those endpoints take only the
@@ -325,12 +375,11 @@ read-only queries, migrations, function logs and invocations, schedules, environ
325
375
  variables, versions, publish and rollback. They answer with aggregates and say explicitly
326
376
  when a result is truncated, which parsing terminal output does not.
327
377
 
328
- Code never travels through those tools. Sending sources stays in the CLI (`xflow push`):
329
- pulling a repository through tool calls burns the user's tokens for nothing. Building is
330
- available through the tools, because it runs from the revision already stored on the
331
- server: `deployments action=build` starts it and answers immediately, `action=status`
332
- reports the phase. It ships the functions too, from that same revision. A build takes
333
- 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.
334
383
 
335
384
  ## Do not
336
385