@getxflow/cli 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1 -1
- package/dist/commands/skills.js +132 -31
- package/dist/help.js +18 -14
- package/dist/prompt.js +111 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +44 -2
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') {
|
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')}
|
|
@@ -183,19 +183,23 @@ project from the ${(0, ui_1.bold)('project_id')} argument, not from your folder,
|
|
|
183
183
|
An agent has no way to know about XFlow: the platform is not in its training. The
|
|
184
184
|
instructions explain how to release and publish, where the design system components
|
|
185
185
|
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.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
186
|
+
down on their own for the common agents, so this command exists to pick tools
|
|
187
|
+
precisely and to refresh after a CLI update: the skill and the CLI ship as one version.
|
|
188
|
+
|
|
189
|
+
Run in a terminal, it asks two questions: where (this project, or the home folder,
|
|
190
|
+
which makes the skill visible in every project) and for which agents. Copies that are
|
|
191
|
+
already installed come pre-selected and are updated in place. Without a terminal, in
|
|
192
|
+
CI or when an agent runs the command, there are no questions: the defaults plus every
|
|
193
|
+
already installed copy are refreshed.
|
|
194
|
+
|
|
195
|
+
xflow skills list the agents, their folders and what is installed
|
|
196
|
+
--agent claude,cursor exact agents, no questions asked
|
|
197
|
+
--global the home folder instead of the project
|
|
198
|
+
--yes no questions: defaults plus what is installed
|
|
199
|
+
|
|
200
|
+
The format is shared (agentskills.io) and inside a project most tools read the shared
|
|
201
|
+
.agents/skills folder, so the paths differ mostly in the home directory. Cursor also
|
|
202
|
+
gets .cursor/rules/xflow.mdc: its older versions do not read the shared format.
|
|
199
203
|
|
|
200
204
|
Plus a few pointer lines in ${(0, ui_1.bold)('AGENTS.md')}. A skill is picked up lazily, only when its
|
|
201
205
|
description matches the task, and "add a customers table" will not trigger it. AGENTS.md
|
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.4.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
|
@@ -6,8 +6,9 @@ description: Build, deploy and publish apps on the XFlow platform with the xflow
|
|
|
6
6
|
# XFlow
|
|
7
7
|
|
|
8
8
|
Hosting for web apps. The code lives in an ordinary repository on the developer
|
|
9
|
-
machine
|
|
10
|
-
|
|
9
|
+
machine, `xflow deploy` sends the sources, and the platform builds them in a clean
|
|
10
|
+
sandbox and serves the result as static files. A platform project is recognized by
|
|
11
|
+
the `xflow.json` file in its root.
|
|
11
12
|
|
|
12
13
|
## First rule
|
|
13
14
|
|
|
@@ -16,6 +17,26 @@ memory. If a command is not in the help output, it does not exist: guessing flag
|
|
|
16
17
|
pointless. The CLI prints a hint with almost every error, read it in full, it usually
|
|
17
18
|
contains the fix.
|
|
18
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
|
+
|
|
19
40
|
## Plan limits
|
|
20
41
|
|
|
21
42
|
The organization runs on a plan with finite limits: projects, cloud functions, developer
|
|
@@ -134,6 +155,12 @@ separate deploy command: `xflow deploy` ships the functions and then builds the
|
|
|
134
155
|
in that order. List what is live with `xflow functions list`. The handler returns
|
|
135
156
|
`{ statusCode, body }` where `body` is a JSON string.
|
|
136
157
|
|
|
158
|
+
Keep one shape inside that string across the whole project: `{ success: true, data }`
|
|
159
|
+
when it worked, `{ success: false, error: { message, code } }` when it did not. Nothing
|
|
160
|
+
enforces this, but a project where every function answers its own way costs an adapter
|
|
161
|
+
on every call. Branch the frontend on `error.code`, never on `error.message`: wording
|
|
162
|
+
gets rewritten on any edit, a code does not.
|
|
163
|
+
|
|
137
164
|
The sources are the whole truth about which functions exist. Delete the directory and the
|
|
138
165
|
next deploy deletes the function from the cloud, schedules included, and that cannot be
|
|
139
166
|
undone: a function created again later gets a different address. So never remove a function
|
|
@@ -215,6 +242,17 @@ To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily
|
|
|
215
242
|
Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
|
|
216
243
|
how Yandex wants it. A scheduled run reaches the handler as a POST with no headers.
|
|
217
244
|
|
|
245
|
+
The pieces line up in one pass. From a new function to a verified schedule:
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
xflow env set SMTP_PASSWORD=... # secrets first: values ride the next deploy
|
|
249
|
+
# write functions/report/index.ts, reading process.env.SMTP_PASSWORD literally
|
|
250
|
+
xflow deploy # ships the function, then builds the app
|
|
251
|
+
xflow schedules set report "0 3 ? * * *" # after the deploy: a schedule needs a deployed function
|
|
252
|
+
xflow functions invoke report # run it once, the way the app would
|
|
253
|
+
xflow functions logs report # empty output means it never crashed
|
|
254
|
+
```
|
|
255
|
+
|
|
218
256
|
## Database
|
|
219
257
|
|
|
220
258
|
Schema changes are files: `migrations/0001_init.sql`, `migrations/0002_orders.sql`, applied
|
|
@@ -291,6 +329,10 @@ answers you get:
|
|
|
291
329
|
and takes its real size from there, not from what you declared, so an early `confirm`
|
|
292
330
|
answers that the file is not there.
|
|
293
331
|
|
|
332
|
+
A refusal comes back as `{ error, code }`. Branch on `code` (`invalid_name`, `file_too_large`,
|
|
333
|
+
`quota_exceeded`, `not_uploaded`, `duplicate_name`, `not_found`, …) and never on the text:
|
|
334
|
+
the wording is free to change, the code is not.
|
|
335
|
+
|
|
294
336
|
What the app may do with files is decided inside that function, because the page in the
|
|
295
337
|
browser can be edited by whoever opened it. Never write the key into the sources and never
|
|
296
338
|
send it to the frontend: the build gate stops on a key found in the application code, and a
|