@getxflow/cli 0.2.0 → 0.3.1
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 +0 -4
- package/dist/commands/functions.js +12 -0
- package/dist/commands/projects.js +1 -1
- package/dist/commands/sources.js +2 -2
- package/dist/help.js +3 -2
- package/dist/template.js +1 -1
- package/dist/tree.js +3 -0
- package/dist/version.js +1 -1
- package/dist/zip.js +20 -0
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +111 -10
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
|
-
// The command is gone, but silence would leave the habit unexplained.
|
|
73
|
-
if (second === 'deploy') {
|
|
74
|
-
throw new errors_1.CliError('Functions are deployed by the build now', 'Run xflow deploy: it ships the functions and then builds the application with their addresses');
|
|
75
|
-
}
|
|
76
72
|
if (second === 'invoke') {
|
|
77
73
|
await (0, functions_1.functionsInvoke)(rest);
|
|
78
74
|
return;
|
|
@@ -49,6 +49,17 @@ async function functionsInvoke(args) {
|
|
|
49
49
|
const data = (0, args_1.flagString)(args, 'data');
|
|
50
50
|
const method = ((0, args_1.flagString)(args, 'method') ?? (data ? 'POST' : 'GET')).toUpperCase();
|
|
51
51
|
const sendsBody = method !== 'GET' && method !== 'HEAD';
|
|
52
|
+
// A pass for the person who owns the key: the project token alone is not an
|
|
53
|
+
// identity, and a project built with the current template refuses without one.
|
|
54
|
+
// A read-only key cannot get a pass, so the call still goes out without it.
|
|
55
|
+
let pass = '';
|
|
56
|
+
try {
|
|
57
|
+
const issued = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/pass`, { method: 'POST' });
|
|
58
|
+
pass = issued.pass;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
(0, ui_1.note)((0, ui_1.dim)(' Could not get a visitor pass: calling with the project token only'));
|
|
62
|
+
}
|
|
52
63
|
const started = Date.now();
|
|
53
64
|
let response;
|
|
54
65
|
try {
|
|
@@ -57,6 +68,7 @@ async function functionsInvoke(args) {
|
|
|
57
68
|
headers: {
|
|
58
69
|
'Content-Type': 'application/json',
|
|
59
70
|
'X-Project-Token': card.project_token ?? '',
|
|
71
|
+
...(pass ? { 'X-Project-Pass': pass } : {}),
|
|
60
72
|
},
|
|
61
73
|
body: sendsBody ? (data ?? '{}') : undefined,
|
|
62
74
|
// Wait past the function's own 90 s cap to see its timeout, not ours.
|
|
@@ -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',
|
package/dist/commands/sources.js
CHANGED
|
@@ -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
|
@@ -150,8 +150,9 @@ A failed run shows up in ${(0, ui_1.bold)('xflow functions logs')}: nobody is wa
|
|
|
150
150
|
function, so the wrapper reports a crash the same way it does on an ordinary call.`,
|
|
151
151
|
invoke: `${(0, ui_1.bold)('xflow functions invoke')} <name>: call a function
|
|
152
152
|
|
|
153
|
-
Calls it exactly the way the application does
|
|
154
|
-
|
|
153
|
+
Calls it exactly the way the application does: the project token plus a visitor pass
|
|
154
|
+
for the person who owns the key, so the function sees a real caller and its role.
|
|
155
|
+
Both are taken from the platform, no local .env is needed.
|
|
155
156
|
|
|
156
157
|
--data '{"a":1}' request body (the method becomes POST by default)
|
|
157
158
|
--method GET a different method
|
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\` (
|
|
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.
|
|
5
|
+
exports.CLI_VERSION = '0.3.1';
|
|
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
package/skills/xflow/SKILL.md
CHANGED
|
@@ -103,6 +103,30 @@ 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
132
|
Server-side code lives in `functions/<name>/index.ts` and exports `handler`. There is no
|
|
@@ -122,13 +146,53 @@ output of that call. Only failed calls are logged, so an empty output means the
|
|
|
122
146
|
never crashed, not that logging is broken.
|
|
123
147
|
|
|
124
148
|
From the app, call a function through `src/lib/xflow.ts`:
|
|
125
|
-
`await xflow.functions.invoke('send-mail', { body: { to } })`. It carries the
|
|
126
|
-
|
|
149
|
+
`await xflow.functions.invoke('send-mail', { body: { to } })`. It carries the credentials
|
|
150
|
+
for you. Addresses are baked into the build, which is why the functions go out first:
|
|
127
151
|
by the time the bundle is built they already exist, and a new function is never missing
|
|
128
152
|
from the application that calls it.
|
|
129
153
|
|
|
130
|
-
|
|
131
|
-
|
|
154
|
+
### Who is calling
|
|
155
|
+
|
|
156
|
+
A function answers only to a member of the organization who has access to that project.
|
|
157
|
+
The platform issues a short-lived pass when it opens the application, the wrapper checks it
|
|
158
|
+
with the platform on every call, and the handler receives the answer in `event.xflow`:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
exports.handler = async (event) => {
|
|
162
|
+
const { caller, user } = event.xflow
|
|
163
|
+
// caller: 'visitor' (a person), 'service' (another function of this project),
|
|
164
|
+
// 'external' (an outside service with a key), 'schedule' (a timer run)
|
|
165
|
+
// user: { id, role } for a visitor, null for everything else
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Never trust an identity that arrives in the body or in a header of the request: those are
|
|
170
|
+
written by the page, which lives on someone else's computer. `event.xflow` is the only
|
|
171
|
+
identity the platform stands behind, and `usePlatformAuth()` in the frontend is a hint for
|
|
172
|
+
the interface, not a check.
|
|
173
|
+
|
|
174
|
+
A function that changes data should say so instead of checking the role by hand:
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
exports.minRole = 'admin' // 'member' | 'developer' | 'admin' | 'owner'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The wrapper refuses anything below that role before your code runs. Without the line every
|
|
181
|
+
member of the project can call the function, including the ones who may only look at apps.
|
|
182
|
+
|
|
183
|
+
Losing access closes the function within a minute, so a removed member cannot keep calling it.
|
|
184
|
+
Opening the deployed address directly does not work either: there is no pass outside the
|
|
185
|
+
platform.
|
|
186
|
+
|
|
187
|
+
Calling a function from another function is a server call and uses the server key of the
|
|
188
|
+
project: send `process.env.XFLOW_SERVER_KEY` in the `X-Server-Key` header.
|
|
189
|
+
|
|
190
|
+
An outside service (a webhook from a payment provider, a bot, a CRM) has no person behind it
|
|
191
|
+
and needs a key of that one function. Keys are not issued by default and the CLI cannot
|
|
192
|
+
create one: a human opens the function in the web interface, section "Внешний доступ", and
|
|
193
|
+
issues it there. Ask the user to do that and to paste the address back to you — never invent
|
|
194
|
+
another way in. A function holds at most two keys, and the second one exists to replace the
|
|
195
|
+
first without downtime, not to serve a second consumer.
|
|
132
196
|
|
|
133
197
|
Keys and passwords live on the platform, not in the repository: `xflow env set SMTP_PASSWORD=…`
|
|
134
198
|
writes one, `xflow env` lists the names, `xflow env check` tells you which variables your
|
|
@@ -136,8 +200,9 @@ functions read but the platform does not have. Values never come back out — th
|
|
|
136
200
|
they exist is inside the running function.
|
|
137
201
|
|
|
138
202
|
A function receives only the variables it mentions by name via `process.env.NAME`, so never
|
|
139
|
-
assemble a variable name from an expression
|
|
140
|
-
|
|
203
|
+
assemble a variable name from an expression and never destructure the environment
|
|
204
|
+
(`const { API_KEY } = process.env` reads as no mention at all, and the variable arrives
|
|
205
|
+
empty). New values arrive on the next `xflow deploy`, not at the moment they are written.
|
|
141
206
|
|
|
142
207
|
To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily at 03:00).
|
|
143
208
|
Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
|
|
@@ -160,7 +225,9 @@ const db = new Client({ connectionString: process.env.DATABASE_URL })
|
|
|
160
225
|
|
|
161
226
|
The platform passes `DATABASE_URL` only to functions that mention it, and sets the project
|
|
162
227
|
schema on every connection, so plain table names (`select * from tasks`) hit your project.
|
|
163
|
-
You never write that variable yourself: `xflow env set DATABASE_URL=...` is refused.
|
|
228
|
+
You never write that variable yourself: `xflow env set DATABASE_URL=...` is refused. The same
|
|
229
|
+
goes for every name starting with `XFLOW`: the platform fills those in itself, and your value
|
|
230
|
+
under one of them would shadow the real one.
|
|
164
231
|
|
|
165
232
|
The platform keeps no database history and no backups. Anything that destroys data
|
|
166
233
|
(`DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, `DELETE FROM` without a condition) is refused
|
|
@@ -171,6 +238,35 @@ One logical database can be shared by several projects, so your migration can br
|
|
|
171
238
|
you do not see. `xflow db status` lists applied migrations that have no file in your
|
|
172
239
|
repository: that is what someone else's project did.
|
|
173
240
|
|
|
241
|
+
## File storage
|
|
242
|
+
|
|
243
|
+
The project has file storage, and the browser cannot reach it. Those endpoints take only the
|
|
244
|
+
server key of the project, and the platform puts it into the environment of your cloud
|
|
245
|
+
functions as `XFLOW_SERVER_KEY`. Nothing else holds it: not the bundle, not `.env`, not
|
|
246
|
+
`xflow env`.
|
|
247
|
+
|
|
248
|
+
So uploading is a function of your own. It asks the platform for a one-time link, the browser
|
|
249
|
+
then sends the bytes straight to storage, and a second call records the file:
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
const link = await fetch(`${process.env.XFLOW_API_URL}/api/storage/project/upload-url`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: {
|
|
255
|
+
'Content-Type': 'application/json',
|
|
256
|
+
'X-Server-Key': process.env.XFLOW_SERVER_KEY,
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify({ fileName, fileSize, contentType, folderPath: 'invoices' }),
|
|
259
|
+
}).then((r) => r.json())
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`confirm` takes the same fields plus the returned `s3Key` and makes the file visible to the
|
|
263
|
+
app; `delete` takes the file `url`. Never pipe the bytes through the function itself.
|
|
264
|
+
|
|
265
|
+
What the app may do with files is decided inside that function, because the page in the
|
|
266
|
+
browser can be edited by whoever opened it. Never write the key into the sources and never
|
|
267
|
+
send it to the frontend: the build gate stops on a key found in the application code, and a
|
|
268
|
+
key that reached a visitor lets them delete every file of the project.
|
|
269
|
+
|
|
174
270
|
## Syncing code
|
|
175
271
|
|
|
176
272
|
`xflow status` shows how the local copy differs from the server revision.
|
|
@@ -209,8 +305,7 @@ The platform design system already ships inside the project, and the app is supp
|
|
|
209
305
|
to look like a part of the platform:
|
|
210
306
|
|
|
211
307
|
- `src/components/ui` for primitives: buttons, inputs, dialogs, tables, menus
|
|
212
|
-
- `src/components/blocks` for composed blocks: `data-table
|
|
213
|
-
`filter-bar`, `detail-panel`, `kanban`, `charts`, `user-picker`, `relation-picker`
|
|
308
|
+
- `src/components/blocks` for composed blocks: `data-table` and `charts`
|
|
214
309
|
- `src/index.css` for color tokens
|
|
215
310
|
|
|
216
311
|
Before writing your own component, check whether a block already covers it: props are
|
|
@@ -221,7 +316,9 @@ the design system sits in the project.
|
|
|
221
316
|
|
|
222
317
|
The app runs inside the platform in an iframe and receives the theme and the current
|
|
223
318
|
user from it. The `usePlatformAuth()` hook gives the name, role, permissions and the
|
|
224
|
-
list of organization members
|
|
319
|
+
list of organization members. Use it to draw the interface, never to guard data: the
|
|
320
|
+
value lives on the page and is edited from the console. Guard data in the function, by
|
|
321
|
+
`event.xflow`.
|
|
225
322
|
|
|
226
323
|
## Errors from a deployed app
|
|
227
324
|
|
|
@@ -233,6 +330,10 @@ records per project are kept.
|
|
|
233
330
|
Local `npm run dev` does not report anything: these logs exist for what you cannot open
|
|
234
331
|
in your own devtools.
|
|
235
332
|
|
|
333
|
+
Cloud functions do work under `npm run dev`, and nothing has to be configured for that:
|
|
334
|
+
the dev server swaps the access key of the logged-in developer for the same narrow pass and
|
|
335
|
+
forwards the call. If it answers that the key is missing, the fix is `xflow login`.
|
|
336
|
+
|
|
236
337
|
## Environment
|
|
237
338
|
|
|
238
339
|
`VITE_XFLOW_PROJECT_TOKEN` and `VITE_XFLOW_API_URL` are written by the CLI when the
|