@getxflow/cli 0.1.10 → 0.3.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,7 +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
- | `functions list` / `functions deploy` | cloud functions of the project from `functions/<name>/index.ts` |
24
+ | `functions list` | cloud functions of the project from `functions/<name>/index.ts`, shipped by `deploy` |
25
25
  | `functions invoke` / `functions logs` | call a function, look at its crashes with the stack |
26
26
  | `schedules list` / `set` / `rm` | running functions on a timer (timer triggers) |
27
27
  | `env` / `env check` / `env set` | environment variables of the functions, values are never handed back |
package/dist/bin.js CHANGED
@@ -69,10 +69,6 @@ async function run(args) {
69
69
  }
70
70
  throw new errors_1.CliError(`Unknown command: mcp ${second}`, 'Available: install');
71
71
  case 'functions':
72
- if (second === 'deploy') {
73
- await (0, functions_1.functionsDeploy)(rest);
74
- return;
75
- }
76
72
  if (second === 'invoke') {
77
73
  await (0, functions_1.functionsInvoke)(rest);
78
74
  return;
@@ -85,7 +81,7 @@ async function run(args) {
85
81
  await (0, functions_1.functionsList)();
86
82
  return;
87
83
  }
88
- throw new errors_1.CliError(`Unknown command: functions ${second}`, 'Available: list, deploy, invoke and logs');
84
+ throw new errors_1.CliError(`Unknown command: functions ${second}`, 'Available: list, invoke and logs');
89
85
  case 'logs':
90
86
  await (0, logs_1.logs)(rest);
91
87
  return;
@@ -4,11 +4,14 @@ exports.deploy = deploy;
4
4
  exports.publish = publish;
5
5
  exports.rollback = rollback;
6
6
  exports.deployments = deployments;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = require("node:path");
7
9
  const api_1 = require("../api");
8
10
  const args_1 = require("../args");
9
11
  const config_1 = require("../config");
10
12
  const errors_1 = require("../errors");
11
13
  const session_1 = require("../session");
14
+ const template_1 = require("../template");
12
15
  const ui_1 = require("../ui");
13
16
  const sources_1 = require("./sources");
14
17
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -40,6 +43,24 @@ async function waitForBuild(client, projectId, started) {
40
43
  }
41
44
  throw new errors_1.CliError('The build is taking longer than expected', `To check the state: xflow deployments. Version ${started.deploy_id}`);
42
45
  }
46
+ /**
47
+ * Write the function addresses into the local .env.
48
+ *
49
+ * The build bakes its own copy of the map into the bundle, this one is for
50
+ * `npm run dev`: without it the local run calls a function that it has no
51
+ * address for.
52
+ */
53
+ async function refreshFunctionsEnv(root, client, projectId) {
54
+ const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
55
+ // No .env at all (fresh clone): recreate it fully, like link does.
56
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, '.env')) && card.project_token) {
57
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, '.env'), (0, template_1.envFile)(card.project_token, client.apiUrl, card.functions), 'utf-8');
58
+ }
59
+ else {
60
+ (0, template_1.writeEnvValue)(root, template_1.FUNCTIONS_ENV_KEY, (0, template_1.functionsEnvValue)(card.functions));
61
+ }
62
+ return card.functions.filter((fn) => fn.invoke_url).map((fn) => fn.name);
63
+ }
43
64
  async function deploy(args) {
44
65
  const { root, config } = (0, config_1.requireProject)();
45
66
  const client = (0, session_1.connect)(config);
@@ -68,6 +89,11 @@ async function deploy(args) {
68
89
  reportIssues(e);
69
90
  throw e;
70
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.
94
+ if (started.removed_functions?.length) {
95
+ (0, ui_1.warn)(`Removed from the cloud, gone from the sources: ${started.removed_functions.join(', ')}`);
96
+ }
71
97
  const build = await waitForBuild(client, config.projectId, started);
72
98
  if (build.phase === 'failed') {
73
99
  if (build.log_tail) {
@@ -78,6 +104,17 @@ async function deploy(args) {
78
104
  }
79
105
  (0, ui_1.ok)(`Version ${build.deploy_id} built from revision ${build.revision} in ${build.elapsed_s} s`);
80
106
  (0, ui_1.out)(build.project_url);
107
+ // The functions were shipped by the build itself, so their addresses are known
108
+ // only now. A failure here does not undo a finished build: warn and stop there.
109
+ try {
110
+ const functions = await refreshFunctionsEnv(root, client, config.projectId);
111
+ if (functions.length > 0) {
112
+ (0, ui_1.note)((0, ui_1.dim)(` Functions in .env: ${functions.join(', ')}`));
113
+ }
114
+ }
115
+ catch {
116
+ (0, ui_1.warn)('Could not refresh the function addresses in .env, the build itself is fine');
117
+ }
81
118
  (0, ui_1.note)((0, ui_1.dim)(' Show it to visitors: xflow publish'));
82
119
  }
83
120
  async function publish() {
@@ -126,7 +126,7 @@ async function envSet(args) {
126
126
  // Values reach a function on its next deploy.
127
127
  const users = referencedByFunctions(root).needed.get(name);
128
128
  if (users && users.length > 0) {
129
- (0, ui_1.note)((0, ui_1.dim)(` For the value to arrive, redeploy: xflow functions deploy ${users.join(' && xflow functions deploy ')}`));
129
+ (0, ui_1.note)((0, ui_1.dim)(` For the value to arrive, run xflow deploy: it ships ${users.join(', ')} again`));
130
130
  }
131
131
  }
132
132
  async function envRemove(args) {
@@ -2,68 +2,19 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.functionsList = functionsList;
4
4
  exports.functionsInvoke = functionsInvoke;
5
- exports.functionsDeploy = functionsDeploy;
6
- const node_fs_1 = require("node:fs");
7
- const node_path_1 = require("node:path");
8
5
  const api_1 = require("../api");
9
6
  const args_1 = require("../args");
10
7
  const config_1 = require("../config");
11
8
  const errors_1 = require("../errors");
12
9
  const session_1 = require("../session");
13
- const template_1 = require("../template");
14
10
  const ui_1 = require("../ui");
15
- const ENTRY_NAMES = ['index.ts', 'index.js', 'index.mjs'];
16
11
  const FUNCTIONS_DIR = 'functions';
17
- function entryFor(root, name) {
18
- for (const entry of ENTRY_NAMES) {
19
- const candidate = (0, node_path_1.join)(root, FUNCTIONS_DIR, name, entry);
20
- if ((0, node_fs_1.existsSync)(candidate))
21
- return candidate;
22
- }
23
- return null;
24
- }
25
- function discover(root) {
26
- const dir = (0, node_path_1.join)(root, FUNCTIONS_DIR);
27
- if (!(0, node_fs_1.existsSync)(dir))
28
- return [];
29
- return (0, node_fs_1.readdirSync)(dir)
30
- .filter((name) => (0, node_fs_1.statSync)((0, node_path_1.join)(dir, name)).isDirectory())
31
- .filter((name) => entryFor(root, name) !== null)
32
- .sort();
33
- }
34
- /**
35
- * Bundle a function into one file. esbuild comes from the project's node_modules;
36
- * `pg` stays external so the server wrapper can inject the project schema.
37
- */
38
- function bundle(root, entry) {
39
- let esbuild;
40
- try {
41
- esbuild = require(require.resolve('esbuild', { paths: [root] }));
42
- }
43
- catch {
44
- throw new errors_1.CliError('Could not find esbuild in the project', 'Install it: npm i -D esbuild. It usually comes with Vite already');
45
- }
46
- const result = esbuild.buildSync({
47
- entryPoints: [entry],
48
- bundle: true,
49
- platform: 'node',
50
- target: 'node20',
51
- format: 'cjs',
52
- external: ['pg'],
53
- write: false,
54
- logLevel: 'silent',
55
- });
56
- const text = result.outputFiles[0]?.text;
57
- if (!text)
58
- throw new errors_1.CliError(`Building ${entry} produced nothing`);
59
- return text;
60
- }
61
12
  async function functionsList() {
62
13
  const { config } = (0, config_1.requireProject)();
63
14
  const client = (0, session_1.connect)(config);
64
15
  const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/functions`);
65
16
  if (data.functions.length === 0) {
66
- (0, ui_1.note)(`No functions. Put the code in ${FUNCTIONS_DIR}/<name>/index.ts and run xflow functions deploy`);
17
+ (0, ui_1.note)(`No functions. Put the code in ${FUNCTIONS_DIR}/<name>/index.ts and run xflow deploy`);
67
18
  return;
68
19
  }
69
20
  (0, ui_1.table)(data.functions.map((fn) => [
@@ -73,18 +24,6 @@ async function functionsList() {
73
24
  fn.error_message ?? '',
74
25
  ]));
75
26
  }
76
- /** Write function URLs into .env, where the build picks them up. */
77
- async function refreshFunctionsEnv(root, client, projectId) {
78
- const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
79
- // No .env at all (fresh clone): recreate it fully, like link does.
80
- if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, '.env')) && card.project_token) {
81
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, '.env'), (0, template_1.envFile)(card.project_token, client.apiUrl, card.functions), 'utf-8');
82
- }
83
- else {
84
- (0, template_1.writeEnvValue)(root, template_1.FUNCTIONS_ENV_KEY, (0, template_1.functionsEnvValue)(card.functions));
85
- }
86
- return card.functions.filter((fn) => fn.invoke_url).map((fn) => fn.name);
87
- }
88
27
  function prettyBody(text) {
89
28
  try {
90
29
  return JSON.stringify(JSON.parse(text), null, 2);
@@ -105,7 +44,7 @@ async function functionsInvoke(args) {
105
44
  if (!fn || !fn.invoke_url) {
106
45
  throw new errors_1.CliError(`Function ${name} is not deployed`, card.functions.length > 0
107
46
  ? `Deployed: ${card.functions.map((item) => item.name).join(', ')}`
108
- : `To deploy it: xflow functions deploy ${name}`);
47
+ : 'Functions ship with the build: xflow deploy');
109
48
  }
110
49
  const data = (0, args_1.flagString)(args, 'data');
111
50
  const method = ((0, args_1.flagString)(args, 'method') ?? (data ? 'POST' : 'GET')).toUpperCase();
@@ -136,29 +75,3 @@ async function functionsInvoke(args) {
136
75
  throw new errors_1.CliError(`The function answered ${response.status}`, `The stack and console output: xflow functions logs ${name}`);
137
76
  }
138
77
  }
139
- async function functionsDeploy(args) {
140
- const { root, config } = (0, config_1.requireProject)();
141
- const client = (0, session_1.connect)(config);
142
- const wanted = args.words[1];
143
- const names = wanted ? [wanted] : discover(root);
144
- if (names.length === 0) {
145
- throw new errors_1.CliError(`The project has no functions`, `Create ${FUNCTIONS_DIR}/<name>/index.ts exporting handler and try again`);
146
- }
147
- for (const name of names) {
148
- const entry = entryFor(root, name);
149
- if (!entry) {
150
- throw new errors_1.CliError(`Could not find ${FUNCTIONS_DIR}/${name}/index.ts`, `Available functions: ${discover(root).join(', ') || 'none at all'}`);
151
- }
152
- (0, ui_1.step)(`Building ${name}`);
153
- const code = bundle(root, entry);
154
- (0, ui_1.step)(`Deploying ${name}`);
155
- const result = await (0, api_1.apiUpload)(client, `/api/v1/projects/${config.projectId}/functions?name=${encodeURIComponent(name)}`, Buffer.from(code, 'utf-8'), { 'Content-Type': 'application/javascript' });
156
- (0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} deployed`);
157
- (0, ui_1.out)(result.url);
158
- if (result.secrets.length > 0) {
159
- (0, ui_1.note)((0, ui_1.dim)(` Organization secrets in the environment: ${result.secrets.join(', ')}`));
160
- }
161
- }
162
- const available = await refreshFunctionsEnv(root, client, config.projectId);
163
- (0, ui_1.note)((0, ui_1.dim)(` Addresses in .env updated (${available.join(', ')}). In the frontend: xflow.functions.invoke('${names[0]}')`));
164
- }
@@ -63,7 +63,7 @@ async function init(args) {
63
63
  }
64
64
  (0, ui_1.step)('Fetching the template');
65
65
  const archive = await (0, api_1.apiBinary)(client, `/api/v1/templates/${templateId}/archive`);
66
- const files = (0, zip_1.zipRead)(archive);
66
+ const files = (0, zip_1.dropDirectoryEntries)((0, zip_1.zipRead)(archive));
67
67
  (0, ui_1.step)('Creating the project on the platform');
68
68
  const project = await (0, api_1.apiJson)(client, '/api/v1/projects', {
69
69
  method: 'POST',
@@ -47,7 +47,7 @@ async function confirmForce(client, projectId, server, local) {
47
47
  (0, ui_1.warn)(`The server holds revision ${server.revision} from ${(0, ui_1.formatAge)(server.created_at)}, ${server.file_count} files`);
48
48
  try {
49
49
  const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/pull`);
50
- const serverPaths = new Set((0, zip_1.zipRead)(await (0, api_1.downloadUrl)(info.download_url)).map((e) => e.path));
50
+ const serverPaths = new Set((0, zip_1.dropDirectoryEntries)((0, zip_1.zipRead)(await (0, api_1.downloadUrl)(info.download_url))).map((e) => e.path));
51
51
  const localPaths = new Set(local.map((f) => f.path));
52
52
  const disappearing = [...serverPaths].filter((p) => !localPaths.has(p));
53
53
  if (disappearing.length > 0) {
@@ -132,7 +132,7 @@ async function pull(args) {
132
132
  (0, ui_1.warn)('The hash of the downloaded tree did not match the server one: the contents may have changed in transit');
133
133
  }
134
134
  (0, node_fs_1.mkdirSync)(target, { recursive: true });
135
- for (const entry of entries) {
135
+ for (const entry of (0, zip_1.dropDirectoryEntries)(entries)) {
136
136
  const path = (0, node_path_1.join)(target, entry.path);
137
137
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
138
138
  (0, node_fs_1.writeFileSync)(path, entry.content);
package/dist/help.js CHANGED
@@ -26,14 +26,13 @@ ${(0, ui_1.bold)('Code')}
26
26
  fetch the sources (the latest revision by default)
27
27
 
28
28
  ${(0, ui_1.bold)('Releasing')}
29
- xflow deploy [--no-push] send the code, build on the platform, release to dev
29
+ xflow deploy [--no-push] send the code, ship the functions, build on the platform
30
30
  xflow publish show the dev version to visitors
31
31
  xflow rollback <version number> return the project to an earlier version
32
32
  xflow deployments version history
33
33
 
34
34
  ${(0, ui_1.bold)('Functions')}
35
- xflow functions list what is deployed
36
- xflow functions deploy [name] build and ship a function
35
+ xflow functions list what is deployed (they ship with xflow deploy)
37
36
  xflow functions invoke <name> [--data '{"a":1}']
38
37
  call a function and print the answer
39
38
  xflow functions logs [name] function crashes: stack and console output
@@ -125,8 +124,9 @@ mentions by name. A name assembled from an expression (${(0, ui_1.bold)("process
125
124
  never reaches the environment, so read variables literally.
126
125
 
127
126
  The value reaches the function on deploy, not at the moment it is stored: after
128
- ${(0, ui_1.bold)('env set')} you need ${(0, ui_1.bold)('xflow functions deploy')} for the functions involved. The same
129
- after a delete: a function already deployed keeps the old value until its next deploy.`,
127
+ ${(0, ui_1.bold)('env set')} run ${(0, ui_1.bold)('xflow deploy')}. The build ships a function whose code did not
128
+ change but whose variables did, so nothing is left holding an old value. The same after a
129
+ delete: a function already deployed keeps the old value until its next deploy.`,
130
130
  schedules: `${(0, ui_1.bold)('xflow schedules')}: running functions on a timer
131
131
 
132
132
  A schedule is a Yandex timer trigger: it calls the function itself, with no
@@ -224,8 +224,9 @@ into a non-empty one: the platform cannot merge changes, that is git's job.
224
224
  --force overwrite the folder completely`,
225
225
  deploy: `${(0, ui_1.bold)('xflow deploy')}: build and release
226
226
 
227
- Two steps: sending the sources and building on the platform. The build command and the
228
- output directory come from xflow.json (npm run build and dist by default).
227
+ Three steps: sending the sources, shipping the cloud functions, building the application.
228
+ The build command and the output directory come from xflow.json (npm run build and dist
229
+ by default).
229
230
 
230
231
  --no-push do not send sources, build from the latest server revision
231
232
  --force allow overwriting the server revision while sending
@@ -235,6 +236,21 @@ worked on my machine" no longer depends on your machine. Before the build the pr
235
236
  is checked against the template: mismatches are printed as a list and the build does
236
237
  not start at all.
237
238
 
239
+ Everything in ${(0, ui_1.bold)('functions/<name>/index.ts')} is bundled and shipped by the same run,
240
+ before the application is built. That order is not a convention but the only one that
241
+ works: the addresses of the functions are baked into the bundle, so they have to exist
242
+ first. A function whose code and variables did not change is left alone, and a function
243
+ that fails to ship fails the whole build.
244
+
245
+ A function gone from the sources is deleted from the cloud along with its schedules, and
246
+ the CLI names it before the build starts. That one is final: a function created again
247
+ later gets a different address. If the sources hold no functions at all while the cloud
248
+ holds several, nothing is deleted: that looks like a directory which never made it (a
249
+ ${(0, ui_1.bold)('functions/')} line in .xflowignore) rather than a decision.
250
+
251
+ The database is not part of this: migrations change data in ways nothing can undo, so
252
+ they stay their own command (${(0, ui_1.bold)('xflow db migrate')}).
253
+
238
254
  The built version is visible on the project page, and the CLI prints the link. Visitors
239
255
  see it after ${(0, ui_1.bold)('xflow publish')}. The address of the build itself is not printed:
240
256
  it carries the version number, and after the next publish such a link quietly serves an
package/dist/template.js CHANGED
@@ -25,7 +25,7 @@ xflow publish # show the dev version to visitors
25
25
  \`\`\`
26
26
 
27
27
  The interface is built on the template components: \`src/components/ui\` (buttons, fields,
28
- dialogs) and \`src/components/blocks\` (table, form, filters, kanban, charts). Layout built
28
+ dialogs) and \`src/components/blocks\` (a data table and charts). Layout built
29
29
  on top of them looks like the rest of the platform, while custom colors in place of the
30
30
  tokens from \`src/index.css\` break that consistency.
31
31
 
package/dist/tree.js CHANGED
@@ -19,6 +19,9 @@ const DEFAULT_IGNORE = [
19
19
  '.env.*',
20
20
  '.DS_Store',
21
21
  'Thumbs.db',
22
+ // Compiler cache: rewritten by every local build, so it would make a new
23
+ // revision out of nothing.
24
+ 'tsconfig.tsbuildinfo',
22
25
  ];
23
26
  const IGNORE_FILE = '.xflowignore';
24
27
  function globToRegExpSource(pattern) {
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.1.10';
5
+ exports.CLI_VERSION = '0.3.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/dist/zip.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ZipError = void 0;
4
4
  exports.zipCreate = zipCreate;
5
+ exports.dropDirectoryEntries = dropDirectoryEntries;
5
6
  exports.zipRead = zipRead;
6
7
  const node_zlib_1 = require("node:zlib");
7
8
  class ZipError extends Error {
@@ -122,6 +123,25 @@ function isSafeEntryPath(path) {
122
123
  }
123
124
  return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
124
125
  }
126
+ /**
127
+ * Drop entries whose name is a directory elsewhere in the same archive: old
128
+ * revisions carry folders as empty files, and unpacking them collides with the
129
+ * directory of the same name. Call it before writing to disk, not inside
130
+ * zipRead: the tree hash is checked against the archive as the server made it.
131
+ */
132
+ function dropDirectoryEntries(entries) {
133
+ const directories = new Set();
134
+ for (const entry of entries) {
135
+ const segments = entry.path.split('/');
136
+ segments.pop();
137
+ let current = '';
138
+ for (const segment of segments) {
139
+ current = current ? `${current}/${segment}` : segment;
140
+ directories.add(current);
141
+ }
142
+ }
143
+ return entries.filter((entry) => !directories.has(entry.path));
144
+ }
125
145
  function zipRead(buffer) {
126
146
  if (buffer.length < 22)
127
147
  throw new ZipError('The file is too small to be a zip');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "0.1.10",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -40,8 +40,8 @@ restored within an hour of the data going back under the limit.
40
40
  2. `npm run typecheck` for a two-second type check (older projects may not have the
41
41
  script, then `npx tsc --noEmit`).
42
42
  3. `npm run build` if the change is substantial, before deploying.
43
- 4. `xflow deploy` sends the sources and builds them on the platform, printing each
44
- phase and the six-digit number of the version it built.
43
+ 4. `xflow deploy` sends the sources, ships the cloud functions and builds the application
44
+ on the platform, printing each phase and the six-digit number of the version it built.
45
45
  5. Give the user the project link the CLI printed and let them look. Do not open a
46
46
  browser for them.
47
47
  6. `xflow publish` makes that same version visible to visitors.
@@ -103,13 +103,42 @@ Interface rules, checked outside `src/components/ui` and `src/components/blocks`
103
103
  `react-hot-toast`, `react-dnd`) must have it mounted in `App.tsx`. Missing providers
104
104
  build fine and give visitors a white screen.
105
105
 
106
+ Template integrity. The app grows out of the platform template, and part of that template
107
+ is not yours to change. The reference is a snapshot of the project itself, taken when the
108
+ platform first looked at it, so these rules never argue with work that was already there:
109
+
110
+ - Platform files must stay byte for byte as they arrived: `src/lib/theme-sync.ts`,
111
+ `src/lib/platform-auth.ts`, `src/contexts/platform-auth-context.tsx`,
112
+ `src/hooks/use-platform-auth.ts`, `src/utils/error-logger.ts`, `src/lib/xflow.ts`.
113
+ They wire the app to the platform, and every way they break is a silent one. Build what
114
+ you need around them, never inside them.
115
+ - The entry point keeps calling `initThemeSync()`, `initPlatformAuth()` and
116
+ `initErrorLogger()`, keeps importing `index.css` and keeps mounting `ThemeProvider`.
117
+ How the file is written is up to you.
118
+ - `index.html` keeps the element with `id="root"` and the script that loads `src/main`.
119
+ - Theme token names in `src/index.css` stay declared, in `:root` and in `.dark` alike, and
120
+ the Tailwind config keeps mapping them. Change the values as much as the design needs:
121
+ it is the names that components paint with.
122
+ - The Tailwind `content` globs keep covering `src/**`. Narrow them and Tailwind strips
123
+ every class the app uses.
124
+ - Files under `src/components/ui` and `src/components/blocks` may be edited freely but
125
+ not deleted.
126
+
127
+ A rejection names the file and the revision to take the original from:
128
+ `xflow pull --revision N --into ./original`, then copy the file back.
129
+
106
130
  ## Cloud functions
107
131
 
108
- Server-side code lives in `functions/<name>/index.ts` and exports `handler`. Deploy it
109
- with `xflow functions deploy` (one name to deploy a single function, no name for all),
110
- list what is live with `xflow functions list`. The handler returns
132
+ Server-side code lives in `functions/<name>/index.ts` and exports `handler`. There is no
133
+ separate deploy command: `xflow deploy` ships the functions and then builds the application,
134
+ in that order. List what is live with `xflow functions list`. The handler returns
111
135
  `{ statusCode, body }` where `body` is a JSON string.
112
136
 
137
+ The sources are the whole truth about which functions exist. Delete the directory and the
138
+ next deploy deletes the function from the cloud, schedules included, and that cannot be
139
+ undone: a function created again later gets a different address. So never remove a function
140
+ directory to "clean up" unless the user asked for the function to go.
141
+
113
142
  Debugging a deployed function is two commands: `xflow functions invoke <name>` calls it
114
143
  the way the app does and prints status, timing and body (`--data '{"a":1}'` sends a body),
115
144
  and `xflow functions logs <name>` shows the failures, each with its stack and the console
@@ -118,9 +147,9 @@ never crashed, not that logging is broken.
118
147
 
119
148
  From the app, call a function through `src/lib/xflow.ts`:
120
149
  `await xflow.functions.invoke('send-mail', { body: { to } })`. It carries the project
121
- token for you. Addresses are baked into the build: the CLI writes them to `.env` when
122
- you deploy a function, so a frontend built before the function existed cannot see it,
123
- and needs `xflow deploy` again.
150
+ token for you. Addresses are baked into the build, which is why the functions go out first:
151
+ by the time the bundle is built they already exist, and a new function is never missing
152
+ from the application that calls it.
124
153
 
125
154
  Treat a function as a public API: the token ships inside the frontend bundle, so anyone
126
155
  who opens the app can call it.
@@ -131,8 +160,8 @@ functions read but the platform does not have. Values never come back out — th
131
160
  they exist is inside the running function.
132
161
 
133
162
  A function receives only the variables it mentions by name via `process.env.NAME`, so never
134
- assemble a variable name from an expression. New values arrive on the next
135
- `xflow functions deploy`, not at the moment they are written.
163
+ assemble a variable name from an expression. New values arrive on the next `xflow deploy`,
164
+ not at the moment they are written.
136
165
 
137
166
  To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily at 03:00).
138
167
  Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
@@ -184,12 +213,12 @@ read-only queries, migrations, function logs and invocations, schedules, environ
184
213
  variables, versions, publish and rollback. They answer with aggregates and say explicitly
185
214
  when a result is truncated, which parsing terminal output does not.
186
215
 
187
- Code never travels through those tools. Sending sources and deploying functions stay in
188
- the CLI (`xflow push`, `xflow functions deploy`): pulling a repository through tool calls
189
- burns the user's tokens for nothing. Building is available through the tools, because it
190
- runs from the revision already stored on the server: `deployments action=build` starts it
191
- and answers immediately, `action=status` reports the phase. A build takes minutes, so
192
- never expect the starting call to return a finished version.
216
+ Code never travels through those tools. Sending sources stays in the CLI (`xflow push`):
217
+ pulling a repository through tool calls burns the user's tokens for nothing. Building is
218
+ available through the tools, because it runs from the revision already stored on the
219
+ server: `deployments action=build` starts it and answers immediately, `action=status`
220
+ reports the phase. It ships the functions too, from that same revision. A build takes
221
+ minutes, so never expect the starting call to return a finished version.
193
222
 
194
223
  ## Do not
195
224
 
@@ -204,8 +233,7 @@ The platform design system already ships inside the project, and the app is supp
204
233
  to look like a part of the platform:
205
234
 
206
235
  - `src/components/ui` for primitives: buttons, inputs, dialogs, tables, menus
207
- - `src/components/blocks` for composed blocks: `data-table`, `entity-form`,
208
- `filter-bar`, `detail-panel`, `kanban`, `charts`, `user-picker`, `relation-picker`
236
+ - `src/components/blocks` for composed blocks: `data-table` and `charts`
209
237
  - `src/index.css` for color tokens
210
238
 
211
239
  Before writing your own component, check whether a block already covers it: props are
@@ -216,7 +244,7 @@ the design system sits in the project.
216
244
 
217
245
  The app runs inside the platform in an iframe and receives the theme and the current
218
246
  user from it. The `usePlatformAuth()` hook gives the name, role, permissions and the
219
- list of organization members: that is what `user-picker` is built on.
247
+ list of organization members.
220
248
 
221
249
  ## Errors from a deployed app
222
250