@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 +1 -0
- package/dist/api.js +5 -2
- package/dist/args.js +1 -0
- package/dist/bin.js +10 -2
- package/dist/commands/db.js +80 -0
- package/dist/commands/deploy.js +40 -14
- package/dist/commands/skills.js +132 -31
- package/dist/help.js +48 -26
- package/dist/prompt.js +111 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +59 -10
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
|
-
|
|
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
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;
|
package/dist/commands/db.js
CHANGED
|
@@ -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);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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/commands/skills.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
['.
|
|
18
|
-
['.
|
|
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
|
-
/**
|
|
21
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (path)
|
|
71
|
-
|
|
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
|
-
|
|
98
|
+
changes.push(pointer);
|
|
81
99
|
}
|
|
82
|
-
return
|
|
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
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
|
68
|
+
db: `${(0, ui_1.bold)('xflow db')}: schema, data, migrations
|
|
67
69
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
is
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
233
|
-
--force
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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) => `[2K${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.
|
|
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
package/skills/xflow/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
249
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
server
|
|
332
|
-
|
|
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
|
|