@getxflow/cli 1.5.0 → 1.6.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 CHANGED
@@ -25,7 +25,6 @@ const schedules_1 = require("./commands/schedules");
25
25
  const skills_1 = require("./commands/skills");
26
26
  const sources_1 = require("./commands/sources");
27
27
  const storage_1 = require("./commands/storage");
28
- const pages_1 = require("./commands/pages");
29
28
  const deploy_1 = require("./commands/deploy");
30
29
  const dev_1 = require("./commands/dev");
31
30
  const update_1 = require("./commands/update");
@@ -199,32 +198,6 @@ async function run(args) {
199
198
  return;
200
199
  }
201
200
  throw new errors_1.CliError(`Unknown command: storage ${second}`, 'Available: ls, push and rm');
202
- case 'pages':
203
- if (second === 'create') {
204
- await (0, pages_1.pagesCreate)(rest);
205
- return;
206
- }
207
- if (second === 'deploy') {
208
- await (0, pages_1.pagesDeploy)(rest);
209
- return;
210
- }
211
- if (second === 'status') {
212
- await (0, pages_1.pagesStatus)(rest);
213
- return;
214
- }
215
- if (second === 'rollback') {
216
- await (0, pages_1.pagesRollback)(rest);
217
- return;
218
- }
219
- if (second === 'rm' || second === 'remove') {
220
- await (0, pages_1.pagesRemove)(rest);
221
- return;
222
- }
223
- if (second === undefined || second === 'list') {
224
- await (0, pages_1.pagesList)(rest);
225
- return;
226
- }
227
- throw new errors_1.CliError(`Unknown command: pages ${second}`, 'Available: list, create, deploy, status, rollback and rm');
228
201
  case 'status':
229
202
  await (0, sources_1.status)();
230
203
  return;
@@ -334,7 +307,7 @@ async function main() {
334
307
  // XFLOW_TOKEN is taken as given and no organization is looked for, so a
335
308
  // project of another one answers "not found" with nothing else to say.
336
309
  if (process.env.XFLOW_TOKEN?.trim()) {
337
- (0, ui_1.fatalNote)((0, ui_1.dim)(' XFLOW_TOKEN is set: it is the key of one organization and is used as it comes, so a project or a page of another one is not found'));
310
+ (0, ui_1.fatalNote)((0, ui_1.dim)(' XFLOW_TOKEN is set: it is the key of one organization and is used as it comes, so a project of another one is not found'));
338
311
  }
339
312
  else if (e.apiUrl && (0, credentials_1.listOrgs)(e.apiUrl).length > 1) {
340
313
  (0, ui_1.fatalNote)((0, ui_1.dim)(' Several organizations are signed in: the answer may live in another one, see xflow org'));
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.versionComment = versionComment;
4
3
  exports.statedRevision = statedRevision;
5
4
  exports.deploy = deploy;
6
5
  exports.publish = publish;
@@ -70,7 +69,7 @@ async function startBuild(client, base, revision, allowRemovals, comment) {
70
69
  const MAX_COMMENT_LENGTH = 200;
71
70
  /** Control and bidi-override characters: they spoof terminals, not comments. */
72
71
  const COMMENT_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
73
- /** The version comment as typed after -m, cleaned; empty when there is none. Pages take the same. */
72
+ /** The version comment as typed after -m, cleaned; empty when there is none. */
74
73
  function versionComment(raw) {
75
74
  const comment = (raw ?? '').replace(/\s+/g, ' ').replace(COMMENT_CONTROL_CHARS, '').trim();
76
75
  if (comment.length > MAX_COMMENT_LENGTH) {
@@ -8,14 +8,12 @@ exports.envRemove = envRemove;
8
8
  const node_fs_1 = require("node:fs");
9
9
  const node_path_1 = require("node:path");
10
10
  const api_1 = require("../api");
11
- const args_1 = require("../args");
12
11
  const config_1 = require("../config");
13
12
  const errors_1 = require("../errors");
14
13
  const session_1 = require("../session");
15
14
  const ui_1 = require("../ui");
16
15
  const SCOPE_LABEL = {
17
- organization: 'the whole organization',
18
- project: 'this project only',
16
+ project: 'this project',
19
17
  connection: 'from a connected service, read-only',
20
18
  };
21
19
  const FUNCTIONS_DIR = 'functions';
@@ -101,7 +99,7 @@ async function envList(args) {
101
99
  (0, ui_1.note)((0, ui_1.dim)(' To store one: xflow env set SMTP_PASSWORD=secret'));
102
100
  return;
103
101
  }
104
- (0, ui_1.table)(rows.map((row) => [row.name, SCOPE_LABEL[row.scope] ?? SCOPE_LABEL.organization]));
102
+ (0, ui_1.table)(rows.map((row) => [row.name, SCOPE_LABEL[row.scope]]));
105
103
  (0, ui_1.note)((0, ui_1.dim)(' The platform never returns values: they are visible only inside the function'));
106
104
  }
107
105
  /** Check that every referenced variable is stored. */
@@ -172,12 +170,9 @@ async function envSet(args) {
172
170
  const value = pair.slice(pair.indexOf('=') + 1);
173
171
  const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/env`, {
174
172
  method: 'POST',
175
- // The word goes to the platform as typed: it knows the two scopes and
176
- // refuses a third. Guessing here would have made --scope proj mean the
177
- // whole organization, which is the opposite of what such a typo means.
178
- body: { name, value, scope: (0, args_1.flagString)(args, 'scope') },
173
+ body: { name, value },
179
174
  });
180
- (0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} stored (${result.scope === 'project' ? 'this project only' : 'the whole organization'})`);
175
+ (0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} stored`);
181
176
  // Values reach a function on its next deploy.
182
177
  const users = referencedByFunctions(root).needed.get(name);
183
178
  if (users && users.length > 0) {
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.contentTypeFor = contentTypeFor;
4
- exports.collectMedia = collectMedia;
5
4
  exports.planBatches = planBatches;
6
5
  exports.caseCollisions = caseCollisions;
7
6
  exports.storageList = storageList;
@@ -75,7 +74,7 @@ function contentTypeFor(name) {
75
74
  * what one is asked to put there so it stays out of the 10 MB archive.
76
75
  *
77
76
  * Dot entries and symlinks are skipped: pointing push at a working copy should
78
- * not upload .git, and a link is not ours to follow. Pages walk a site the same way.
77
+ * not upload .git, and a link is not ours to follow.
79
78
  */
80
79
  function collectMedia(root, prefix = '') {
81
80
  const found = [];
package/dist/flags.js CHANGED
@@ -60,7 +60,7 @@ exports.KNOWN = {
60
60
  logs: ['limit', 'project', 'app'],
61
61
  env: ['project'],
62
62
  'env list': ['project'],
63
- 'env set': ['scope'],
63
+ 'env set': [],
64
64
  'env rm': ['project'],
65
65
  'env remove': ['project'],
66
66
  'env check': [],
@@ -87,14 +87,6 @@ exports.KNOWN = {
87
87
  'storage push': ['to', 'replace', 'json', 'project'],
88
88
  'storage rm': ['folder', 'yes', 'project'],
89
89
  'storage remove': ['folder', 'yes', 'project'],
90
- pages: ['json'],
91
- 'pages list': ['json'],
92
- 'pages create': ['slug'],
93
- 'pages deploy': ['message'],
94
- 'pages status': ['json'],
95
- 'pages rollback': [],
96
- 'pages rm': ['yes'],
97
- 'pages remove': ['yes'],
98
90
  status: [],
99
91
  dev: [],
100
92
  pull: ['into', 'force', 'revision'],
@@ -144,7 +136,6 @@ const NAMESPACES = new Set([
144
136
  'schedules',
145
137
  'db',
146
138
  'storage',
147
- 'pages',
148
139
  ]);
149
140
  /** The command these words name, as far as the table knows it. */
150
141
  function commandOf(words) {
package/dist/help.js CHANGED
@@ -98,16 +98,6 @@ ${(0, ui_1.bold)('Database')}
98
98
  xflow db migrate [--dry-run] [--allow-destructive]
99
99
  apply the migrations from migrations/*.sql
100
100
 
101
- ${(0, ui_1.bold)('Pages')}
102
- xflow pages public sites of the organization at <name>.xflowpage.ru
103
- xflow pages create <title> <folder|.zip|.html> [--slug name]
104
- a new page with the site on it, live at once
105
- xflow pages deploy <page> <folder|.zip|.html> [-m "what changed"]
106
- upload the site: live at once, there is no publish step
107
- xflow pages status <page> the page and its versions
108
- xflow pages rollback <page> <N> put an earlier version back on the address
109
- xflow pages rm <page> --yes delete the page for good: the address empties
110
-
111
101
  ${(0, ui_1.bold)('Reference')}
112
102
  xflow logs [--limit N] browser errors of the published apps
113
103
  xflow projects list projects of the organization
@@ -262,53 +252,6 @@ how they differ, ${(0, ui_1.bold)('xflow pull --into ./server-copy')} fetches th
262
252
 
263
253
  In a folder that is already linked, the command points xflow.json at the other project and
264
254
  keeps the rest of the file. A new project instead of an existing one: ${(0, ui_1.bold)('xflow init')}.`,
265
- pages: `${(0, ui_1.bold)('xflow pages')}: public sites of the organization
266
-
267
- A page is a static site at https://<name>.xflowpage.ru: a landing page, a presentation, an
268
- invoice for people outside the organization. It opens by its address for anyone, without
269
- signing in, and has no build, no publish step and no database. An app is the opposite: code
270
- the platform builds, with versions, access levels and a database through functions.
271
-
272
- xflow pages the pages, their state and address
273
- xflow pages create <title> <path> a new page with its first version: there are no
274
- empty pages
275
- --slug <name> the name in the address; without it the name is made
276
- from the title, with a random tail
277
- xflow pages deploy <page> <path> upload a new version
278
- -m <text> what changed, one short line: it names the version
279
- xflow pages status <page> the page and its versions
280
- xflow pages rollback <page> <N> put version N back on the address
281
- xflow pages rm <page> delete the page
282
- --yes without it the command names what goes and stops
283
-
284
- ${(0, ui_1.bold)('<path>')} is a folder with index.html at the top, its .zip, or one .html file.
285
- ${(0, ui_1.bold)('<page>')} is the name from the address or the id. ${(0, ui_1.bold)('--json')} on list and status
286
- prints the answer as JSON. A page belongs to the organization, not to a project, so there is
287
- no ${(0, ui_1.bold)('--project')}: the key is picked as for every other command (${(0, ui_1.bold)('xflow help org')}).
288
-
289
- ${(0, ui_1.bold)('An upload is live at once.')} There is no draft and no publish step: anyone with the
290
- address sees the new version within seconds. Uploading the same files again changes nothing.
291
- The files of the last 5 versions are kept, so a rollback takes seconds and needs no upload.
292
-
293
- What goes up is the built site: HTML, CSS, JS, images, fonts, PDF, audio and video. No code
294
- runs on the platform side, and a folder with package.json is refused as the sources of a site:
295
- build it and upload what the build writes. Dot entries are skipped. At most 5 MB zipped, 25 MB
296
- unpacked and 1000 files.
297
-
298
- The page is public, so a file holding a secret key is refused whole: every visitor would read
299
- it. A page has no backend: a form or a lookup belongs in an app with access "anyone". The one
300
- key a page may hold is a function key, and it opens that function to every visitor: an HTML
301
- form can post to its webhook address with ${(0, ui_1.bold)('?key=')}, while fetch cannot read the answer
302
- (no CORS). Search engines are asked not to index pages, so do not promise SEO.
303
-
304
- ${(0, ui_1.bold)('Deleting is final.')} The address goes empty and every version of the page is gone: there
305
- is no undo and no copy on the platform side. The name stays reserved for the organization, so
306
- the same ${(0, ui_1.bold)('--slug')} is free for the next page of yours and taken for everyone else. A
307
- throwaway page put up to try something is worth deleting the same way it was made.
308
-
309
- A page takes the key of the whole organization, the one ${(0, ui_1.bold)('xflow login')} issues: a key
310
- scoped to projects is refused. A page is deleted by whoever made it, or by the owner of the
311
- organization. Switching a page off for a while is for a person, in the platform interface.`,
312
255
  update: `${(0, ui_1.bold)('xflow update')}: bring the CLI up to date
313
256
 
314
257
  Installs the published version and, if anything changed, rewrites the platform
@@ -463,11 +406,7 @@ the function. So keep your own copy wherever you got it from.
463
406
  xflow env set SMTP_PASSWORD=… store
464
407
  xflow env rm SMTP_PASSWORD delete
465
408
 
466
- --scope project visible to this project only
467
- --scope organization visible to every project of the organization (the default)
468
-
469
- Those two words are the whole list: a third is refused rather than read as the default,
470
- because a value meant for one project would have gone out to all of them.
409
+ A variable belongs to this project: functions of other projects never see it.
471
410
 
472
411
  ${(0, ui_1.bold)('check')} reads the sources in ${(0, ui_1.bold)('functions/')} and looks for ${(0, ui_1.bold)('process.env.NAME')}
473
412
  references. The same rule applies on deploy: a function receives only the variables it
@@ -609,8 +548,7 @@ Who may call a function is its callers, in the root xflow.json:
609
548
 
610
549
  { "projectId": "…", "functions": { "book-slot": { "callers": ["zapis"] } } }
611
550
 
612
- A caller is an app folder of this project (apps/zapis is "zapis"); pages are static and call
613
- nothing. A function nobody lists has no callers: a workflow, woken by a schedule, by a service
551
+ A caller is an app folder of this project (apps/zapis is "zapis"). A function nobody lists has no callers: a workflow, woken by a schedule, by a service
614
552
  holding a key a person issued, or by ${(0, ui_1.bold)('invoke')}. ${(0, ui_1.bold)('open')} in the list means a caller is an app
615
553
  anyone with the link opens, so anyone on the internet can call it. The address in the note
616
554
  under the table is for webhooks and other functions, with a key; apps call ${(0, ui_1.bold)('/_fn/<name>')} on
package/dist/limits.js CHANGED
@@ -4,12 +4,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.limitLine = limitLine;
5
5
  exports.quotaRows = quotaRows;
6
6
  const DENIAL_LABELS = {
7
- limit_projects: 'projects, the platform cap',
7
+ limit_projects: 'new projects today, the platform cap (resets at 00:00 UTC)',
8
8
  limit_apps: 'apps',
9
9
  plan_app_access: 'access levels of apps',
10
10
  functions_stopped: 'function minutes, the functions of the organization are stopped',
11
11
  limit_connections: 'active connector connections',
12
- limit_pages: 'public pages',
13
12
  limit_functions: 'cloud functions',
14
13
  limit_builds: 'builds this month',
15
14
  limit_builds_concurrent: 'builds running at once',
@@ -32,12 +31,12 @@ function limitLine(detail) {
32
31
  : detail.code === 'limit_schedule_interval'
33
32
  ? `, every ${detail.used} min against a ${detail.limit} min minimum`
34
33
  : `, ${detail.used} of ${detail.limit} used`;
35
- return `Plan limit: ${label}${numbers}. Repeating the same command will not help.`;
34
+ const tail = detail.code === 'limit_projects' ? 'Repeating it before then will not help.' : 'Repeating the same command will not help.';
35
+ return `Plan limit: ${label}${numbers}. ${tail}`;
36
36
  }
37
37
  const QUOTA_ORDER = [
38
38
  'apps',
39
39
  'connections',
40
- 'pages',
41
40
  'functions',
42
41
  'schedules',
43
42
  'people',
@@ -49,7 +48,6 @@ const QUOTA_ORDER = [
49
48
  const QUOTA_LABELS = {
50
49
  apps: 'Apps',
51
50
  connections: 'Active connections',
52
- pages: 'Public pages',
53
51
  functions: 'Cloud functions',
54
52
  schedules: 'Schedules',
55
53
  people: 'People',
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 = '1.5.0';
5
+ exports.CLI_VERSION = '1.6.0';
6
6
  /** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
7
7
  exports.DEFAULT_API_URL = 'https://app.getxflow.com';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for the XFlow platform: projects with apps, functions and a database; source sync, deployment and publishing",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: xflow
3
- description: Build, deploy and publish apps on the XFlow platform with the xflow CLI. Use whenever a folder has xflow.json (the project root or an app folder in apps/), and for any task that touches a project and its apps, deployment, publishing, rollback, source sync, who may open an app or call a function, sign-in inside a public app, the project database or SQL migrations, cloud functions, schedules, environment variables and secrets, file storage and uploads of photos, video or PDFs, connected accounts of external services, production errors and logs, UI built on the template components, or a public page on xflowpage.ru (a landing page, presentation or invoice for people outside the organization).
3
+ description: Build, deploy and publish apps on the XFlow platform with the xflow CLI. Use whenever a folder has xflow.json (the project root or an app folder in apps/), and for any task that touches a project and its apps, deployment, publishing, rollback, source sync, who may open an app or call a function, sign-in inside a public app, the project database or SQL migrations, cloud functions, schedules, environment variables and secrets, file storage and uploads of photos, video or PDFs, connected accounts of external services, production errors and logs, UI built on the template components, or a public site for people outside the organization (a landing page, presentation or invoice).
4
4
  ---
5
5
 
6
6
  # XFlow
@@ -28,7 +28,7 @@ instead of guessing; give the person https://docs.getxflow.com when they ask how
28
28
 
29
29
  ## Keeping these instructions current
30
30
 
31
- These instructions ship with xflow CLI 1.5.0, so the copy you are reading can be older than
31
+ These instructions ship with xflow CLI 1.6.0, so the copy you are reading can be older than
32
32
  the CLI answering your commands.
33
33
 
34
34
  When the CLI says this file does not match it, run `xflow skills --refresh` and then **read
@@ -113,9 +113,10 @@ One folder per project, apps in subfolders:
113
113
 
114
114
  ## Plan limits
115
115
 
116
- The organization runs on a plan with limits: apps, functions, pages, active connections,
117
- people in the organization, database and file storage, function minutes, builds per month and at once, schedules
118
- (one count for all projects of the organization) and how often they may run, and which access levels an app may have. Most are finite; a plan
116
+ The organization runs on a plan with limits: apps, active connections, people in the
117
+ organization, database and file storage, function minutes, builds per month and at once, how often
118
+ schedules may run, and which access levels an app may have. The number of functions, schedules
119
+ and projects is not capped; at most 15 new projects a day (UTC) per organization, deleted ones count. Most are finite; a plan
119
120
  may leave one uncapped, and then `whoami` prints `no limit` instead of a ceiling.
120
121
  `xflow whoami` prints every one of them next to what is used, and the rights of your own key
121
122
  with them: destroying data (a migration or `db exec`), deleting files from storage, moving a database and
@@ -124,18 +125,19 @@ halfway through the work into a sentence said before it starts.
124
125
 
125
126
  - Connections are counted while active: switching an unused one off frees the seat without
126
127
  deleting it.
127
- - Function minutes are shared by the whole organization: every app, page and schedule spends
128
+ - Function minutes are shared by the whole organization: every app and schedule spends
128
129
  them. When they run out, every function of the organization stops (`functions_stopped`).
129
130
  - An unpaid plan stops the functions at once, and five days later freezes the organization:
130
131
  every command except `xflow whoami` answers `subscription_blocked`,
131
- apps and pages show a "suspended" page. Nothing is deleted and no code fixes it: tell the
132
+ apps show a "suspended" page. Nothing is deleted and no code fixes it: tell the
132
133
  person that the owner pays the invoice in the organization settings, section «Тариф» (Plan).
133
134
  - The cap on how long one function call may run is written into the function when it is
134
135
  deployed, so a plan change reaches the cloud only with the next `xflow deploy functions` of
135
136
  each project, in both directions.
136
- - One function runs at most a plan-set number of calls at once: Free 5, Start 15, Business 35
137
- per cloud zone (the cloud has three, the spread is not promised). A call over it gets 429 at
138
- once: fan out in batches of that size, never all items of a list in parallel.
137
+ - The functions of one project take a plan-set rate: Free 5 calls a second (a burst of 60),
138
+ Start 20 (300), Business 50 (750). A call over the rate gets 429 at once. Calls at the same time:
139
+ Free 4, Start 10, Business 25, the rest wait in a queue. Fan out in batches of that size, never
140
+ all items of a list in parallel.
139
141
 
140
142
  A limit refusal is not a bad request. The CLI prints a line starting with `Plan limit:`, the
141
143
  API answers `code: "forbidden"` with a `limit` object (`code`, `used`, `limit`). Retrying, renaming or rewriting the code changes nothing: tell the user
@@ -274,9 +276,8 @@ The root `xflow.json` names the callers of each function:
274
276
  }
275
277
  }
276
278
 
277
- - A caller is an app folder of this project (`apps/zapis` is `"zapis"`); a page is never
278
- a caller. A function without callers is left out of `functions` or given `"callers": []`,
279
- the same thing.
279
+ - A caller is an app folder of this project (`apps/zapis` is `"zapis"`). A function without
280
+ callers is left out of `functions` or given `"callers": []`, the same thing.
280
281
  - A function nobody lists has no callers: no app reaches it. That is a workflow,
281
282
  run by a schedule, a key or `xflow functions invoke`.
282
283
  - Callers ship with `xflow deploy functions` and come back with its `--revision N`.
@@ -345,19 +346,11 @@ At the closed levels do none of this: the platform already knows the person.
345
346
  - `xflow dev` does not carry `X-Session` and calls functions as you (a member).
346
347
  Try the sign-in on the app address.
347
348
 
348
- ## Page or app
349
+ ## A site for people outside the organization
349
350
 
350
- - A page is finished static files at `https://<name>.xflowpage.ru`: public, live the moment it
351
- is uploaded, no build, no publish step, no database. A landing page, a presentation, an invoice.
352
- - An app is source the platform builds: versions and publish, access levels, a database through
353
- functions. Anything people sign in to, enter data into or come back to.
354
- - Keep the sources of a page outside the project folder, or list them in `.xflowignore`: the
355
- root travels with `deploy functions`.
356
- - A page has no backend. A form or a lookup for strangers is an app at "Anyone with the link"
357
- (a paid plan): the platform checks every call to its functions.
358
- - A function key on a page is allowed, and it opens that function to every visitor: an HTML
359
- form posts to `https://fn.xflowpage.ru/<projectId>/<name>?key=...`, fetch cannot read the
360
- answer (no CORS). Nothing guards it but the function's own checks: say so to the user.
351
+ - A landing page, a presentation, an invoice is an app at "Anyone with the link" (a paid plan):
352
+ built, published and rolled back like any app, opened by its address without signing in.
353
+ - Search engines are asked not to index apps: do not promise SEO.
361
354
 
362
355
  ## Cloud functions
363
356
 
@@ -457,11 +450,8 @@ writes one, `xflow env` lists the names, `xflow env check` names the variables y
457
450
  read but the platform does not have. Values never come back out: the only place they exist is
458
451
  inside the running function. Apps get no variables at all.
459
452
 
460
- A variable goes to the whole organization unless you say otherwise: `--scope project` keeps it
461
- to this project alone, `--scope organization` is the default, and a third word is refused.
462
- Pick the narrow one for anything that belongs to one project. These commands see only what
463
- this project can see, so `env rm` reports a variable of another project missing even though
464
- names are unique within the organization.
453
+ A variable belongs to one project: functions of other projects never see it, and each project
454
+ keeps its own value under the same name.
465
455
 
466
456
  `DATABASE_URL` and every name starting with `XFLOW` are filled in by the platform: writing them
467
457
  is refused.
@@ -571,8 +561,9 @@ Pass `DATABASE_URL` as is, without an `ssl` option. The database is PostgreSQL 1
571
561
  tune it from migrations:
572
562
  - Installed: `pg_trgm`. `gen_random_uuid()` and `uuidv7()` are built in. Never `uuid-ossp`.
573
563
  Anything else (`postgis`, `pgvector`) is a request to the person.
574
- - A statement is cut after 30 seconds, waiting for a lock after 10, a transaction left open after
575
- 60. A query that needs longer is a wrong query (a missing index, or a report that belongs in a
564
+ - A statement is cut after 10 seconds (in a migration through `xflow db migrate`, after 2 minutes),
565
+ waiting for a lock after 10, a transaction left open after 60. Connections to the database of
566
+ one project: Free 10, Start 25, Business 50. A query that needs longer is a wrong query (a missing index, or a report that belongs in a
576
567
  scheduled function writing a summary table).
577
568
 
578
569
  Calls of one project run at the same time in one process. A `Client`, even one made at module
@@ -633,35 +624,6 @@ key to the frontend.
633
624
  A refusal comes back as `{ error, code }` (`invalid_name`, `file_too_large`, `quota_exceeded`,
634
625
  `not_uploaded`, `duplicate_name`, `forbidden_type`, `not_found`, …): branch on `code`.
635
626
 
636
- ## Pages
637
-
638
- A page is a public static site at `https://<name>.xflowpage.ru`, outside projects. Anyone with
639
- the address opens it, without signing in.
640
-
641
- - `xflow pages create "Price list" ./dist` makes a page with the built site on it and prints its
642
- address: there are no empty pages. `--slug price-list` picks the name, otherwise it comes from
643
- the title with a random tail.
644
- - `xflow pages deploy <page> <folder|.zip|.html> -m "what changed"` uploads a new version.
645
- - **Both are public the moment the command finishes**: no draft, no publish step.
646
- - `xflow pages status <page>` lists the versions, `xflow pages rollback <page> <N>` puts one back
647
- in seconds; the files of the last 5 are kept.
648
- - `xflow pages rm <page> --yes` deletes the page: the address goes empty and every version is
649
- gone for good, without `--yes` the command names what goes and stops. The name stays reserved
650
- for the organization. A page put up to try something out is yours to take down when you are
651
- done: do not leave it standing.
652
- - Names are 3-40 characters. At most 100 pages per organization, 20 new and 200 uploads a day,
653
- 10 seconds between uploads of one page: batch changes into one upload.
654
- - Static files only, what the build writes (`dist`), never a folder with `package.json`. At most
655
- 5 MB zipped, 25 MB unpacked, 1000 files. Everything a page shows travels inside it: project
656
- file storage does not serve pages.
657
- - A backend for a page: see **Page or app**.
658
- - Everything in a page is readable by every visitor. A known key format gets the upload refused,
659
- anything else simply leaks.
660
- - Search engines are asked not to index pages or apps: do not promise SEO.
661
- - The tab icon is the platform's on every page: do not spend a request on a favicon of your own.
662
- - A page is deleted by whoever made it, or by the owner of the organization. Switching a page
663
- off for a while is for a person, in the platform interface.
664
-
665
627
  ## Organizations and keys
666
628
 
667
629
  One key per organization, stored side by side: `xflow org` lists them, `xflow org switch
@@ -1,257 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pagesList = pagesList;
4
- exports.pagesCreate = pagesCreate;
5
- exports.pagesDeploy = pagesDeploy;
6
- exports.pagesStatus = pagesStatus;
7
- exports.pagesRollback = pagesRollback;
8
- exports.pagesRemove = pagesRemove;
9
- const node_fs_1 = require("node:fs");
10
- const node_path_1 = require("node:path");
11
- const api_1 = require("../api");
12
- const args_1 = require("../args");
13
- const config_1 = require("../config");
14
- const credentials_1 = require("../credentials");
15
- const errors_1 = require("../errors");
16
- const session_1 = require("../session");
17
- const ui_1 = require("../ui");
18
- const zip_1 = require("../zip");
19
- const deploy_1 = require("./deploy");
20
- const storage_1 = require("./storage");
21
- /**
22
- * Pages: public sites of the organization at <name>.xflowpage.ru. They open without
23
- * signing in and outside the platform, and an upload is live at once.
24
- */
25
- /** Keep in sync with the platform: a refusal here saves the upload. */
26
- const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
27
- const MAX_UNPACKED_BYTES = 25 * 1024 * 1024;
28
- const MAX_FILES = 1000;
29
- /** Where build tools write the site: named when the folder turns out to be the sources. */
30
- const BUILD_DIRS = ['dist', 'build', 'out'];
31
- /** A page belongs to the organization, not to a project: the key is picked as for xflow org members. */
32
- function pagesClient() {
33
- return (0, session_1.connect)((0, config_1.localConfig)());
34
- }
35
- function pagePath(ref) {
36
- return `/api/v1/pages/${encodeURIComponent(ref)}`;
37
- }
38
- function clip(text, max) {
39
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
40
- }
41
- function stateOf(page) {
42
- if (page.status === 'blocked')
43
- return 'taken down';
44
- if (page.status === 'disabled')
45
- return 'switched off';
46
- return page.current_revision === null ? 'empty' : 'live';
47
- }
48
- function describeState(page) {
49
- if (page.status === 'blocked') {
50
- const reason = page.blocked_reason ? ': ' + page.blocked_reason : '';
51
- return `Taken down by the platform${reason}. Write to team@getxflow.com if it is a mistake`;
52
- }
53
- if (page.status === 'disabled')
54
- return 'Switched off: the address shows a placeholder until a person switches it on';
55
- if (page.current_revision === null)
56
- return 'Empty: nothing is uploaded yet';
57
- return `Live: version ${page.current_revision}, put there ${(0, ui_1.formatAge)(page.deployed_at)}`;
58
- }
59
- async function pagesList(args) {
60
- const client = pagesClient();
61
- const { pages } = await (0, api_1.apiJson)(client, '/api/v1/pages');
62
- if ((0, args_1.flagBool)(args, 'json')) {
63
- (0, ui_1.out)(JSON.stringify({ pages }, null, 2));
64
- return;
65
- }
66
- if (pages.length === 0) {
67
- (0, ui_1.note)('The organization has no pages yet');
68
- (0, ui_1.note)((0, ui_1.dim)(' A new one: xflow pages create "Price list" ./dist'));
69
- }
70
- else {
71
- (0, ui_1.table)(pages.map((page) => [
72
- page.slug,
73
- clip(page.title, 40),
74
- stateOf(page),
75
- (0, ui_1.formatAge)(page.deployed_at ?? page.created_at),
76
- page.url,
77
- ]));
78
- }
79
- const orgs = (0, credentials_1.listOrgs)(client.apiUrl);
80
- if (client.source === 'active' && orgs.length > 1) {
81
- const name = orgs.find((org) => org.active)?.name;
82
- const whose = name ? `"${name}"` : 'the active organization';
83
- (0, ui_1.note)((0, ui_1.dim)(` Pages of ${whose} only. For another organization: xflow org switch <name or id>`));
84
- }
85
- }
86
- async function pagesCreate(args) {
87
- const source = args.words.length > 2 ? args.words[args.words.length - 1] : undefined;
88
- const title = args.words.slice(1, -1).join(' ').trim();
89
- if (!source || !title) {
90
- throw new errors_1.CliError('A title and what to put on the page are required', 'A page is born with its first version: xflow pages create "Price list" ./dist --slug price-list');
91
- }
92
- const upload = readUpload(source);
93
- const slug = (0, args_1.flagString)(args, 'slug');
94
- const query = new URLSearchParams(slug ? { title, slug } : { title });
95
- (0, ui_1.step)(`Uploading ${upload.label}`);
96
- const result = await (0, api_1.apiUpload)(pagesClient(), `/api/v1/pages?${query}`, upload.body, {
97
- 'Content-Type': contentTypeOf(upload),
98
- });
99
- (0, ui_1.ok)(`Page "${result.page.title}" is live: ${result.file_count} file(s), ${(0, ui_1.formatBytes)(result.size_bytes)}`);
100
- (0, ui_1.out)(result.page.url);
101
- (0, ui_1.note)((0, ui_1.dim)(` Anyone with the address sees it within seconds. The next version: xflow pages deploy ${result.page.slug} <path>`));
102
- }
103
- function contentTypeOf(upload) {
104
- return upload.kind === 'zip' ? 'application/zip' : 'text/html';
105
- }
106
- function assertUploadSize(bytes) {
107
- if (bytes <= MAX_UPLOAD_BYTES)
108
- return;
109
- throw new errors_1.CliError(`The upload is ${(0, ui_1.formatBytes)(bytes)}, a page takes ${MAX_UPLOAD_BYTES / 1024 / 1024} MB at most`, 'Pages are for sites, presentations and invoices: compress the images and drop the sourcemaps');
110
- }
111
- /**
112
- * The folder of a built site, zipped here. The sources of a site are refused before the
113
- * walk: the platform would reject them anyway, and node_modules is a long way down.
114
- */
115
- function zipSite(absolute, source) {
116
- const built = BUILD_DIRS.find((dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, dir, 'index.html')));
117
- const pointAtBuild = built
118
- ? `The built site is in ${(0, node_path_1.join)(source, built)}: upload that folder`
119
- : 'Build it and upload the folder the build writes';
120
- if ((0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, 'package.json'))) {
121
- throw new errors_1.CliError(`${source} holds the sources of a site, not a built one`, pointAtBuild);
122
- }
123
- if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, 'index.html'))) {
124
- throw new errors_1.CliError(`There is no index.html at the top of ${source}`, built ? pointAtBuild : 'The start page of a site is index.html at the top of its folder');
125
- }
126
- const files = (0, storage_1.collectMedia)(absolute);
127
- if (files.length > MAX_FILES) {
128
- throw new errors_1.CliError(`${source} holds ${files.length} files, a page takes ${MAX_FILES} at most`, 'Drop what the site never loads');
129
- }
130
- const bytes = files.reduce((sum, file) => sum + file.size, 0);
131
- if (bytes > MAX_UNPACKED_BYTES) {
132
- throw new errors_1.CliError(`${source} is ${(0, ui_1.formatBytes)(bytes)}, a page takes ${MAX_UNPACKED_BYTES / 1024 / 1024} MB at most`, 'Compress the images and drop the sourcemaps');
133
- }
134
- const body = (0, zip_1.zipCreate)(files.map((file) => ({ path: file.remote, content: (0, node_fs_1.readFileSync)(file.absolute) })));
135
- assertUploadSize(body.length);
136
- return { kind: 'zip', body, label: `${files.length} file(s), ${(0, ui_1.formatBytes)(bytes)}` };
137
- }
138
- function readUpload(source) {
139
- const absolute = (0, node_path_1.resolve)(source);
140
- let stat;
141
- try {
142
- stat = (0, node_fs_1.statSync)(absolute);
143
- }
144
- catch {
145
- throw new errors_1.CliError(`No such file or folder: ${source}`);
146
- }
147
- if (stat.isDirectory())
148
- return zipSite(absolute, source);
149
- const extension = (0, node_path_1.extname)(absolute).toLowerCase();
150
- if (extension !== '.html' && extension !== '.htm' && extension !== '.zip') {
151
- throw new errors_1.CliError(`${(0, node_path_1.basename)(absolute)} cannot be a page`, 'A page goes up as a folder with index.html at the top, its .zip, or one .html file');
152
- }
153
- assertUploadSize(stat.size);
154
- return {
155
- kind: extension === '.zip' ? 'zip' : 'html',
156
- body: (0, node_fs_1.readFileSync)(absolute),
157
- label: `${(0, node_path_1.basename)(absolute)}, ${(0, ui_1.formatBytes)(stat.size)}`,
158
- };
159
- }
160
- async function pagesDeploy(args) {
161
- const [, ref, source] = args.words;
162
- if (!ref || !source) {
163
- throw new errors_1.CliError('A page and what to upload are required', 'For example: xflow pages deploy price-list ./dist. The pages: xflow pages');
164
- }
165
- const comment = (0, deploy_1.versionComment)((0, args_1.flagString)(args, 'message'));
166
- const upload = readUpload(source);
167
- const client = pagesClient();
168
- (0, ui_1.step)(`Uploading ${upload.label}`);
169
- const query = comment ? `?comment=${encodeURIComponent(comment)}` : '';
170
- const result = await (0, api_1.apiUpload)(client, `${pagePath(ref)}/deploy${query}`, upload.body, {
171
- 'Content-Type': contentTypeOf(upload),
172
- });
173
- if (result.status === 'unchanged') {
174
- (0, ui_1.ok)(`Nothing changed: version ${result.revision} is already on the address`);
175
- }
176
- else if (result.status === 'stored') {
177
- (0, ui_1.warn)(`The page is switched off: version ${result.revision} is kept, not shown`);
178
- (0, ui_1.note)((0, ui_1.dim)(' It goes live when a person switches the page on'));
179
- }
180
- else {
181
- (0, ui_1.ok)(`Version ${result.revision} is live: ${result.file_count} file(s), ${(0, ui_1.formatBytes)(result.size_bytes)}`);
182
- }
183
- (0, ui_1.out)(result.page.url);
184
- if (result.status === 'deployed') {
185
- (0, ui_1.note)((0, ui_1.dim)(` Anyone with the address sees it within seconds. The versions: xflow pages status ${result.page.slug}`));
186
- }
187
- }
188
- async function pagesStatus(args) {
189
- const ref = args.words[1];
190
- if (!ref)
191
- throw new errors_1.CliError('A page is required', 'The name from its address, or its id. The pages: xflow pages');
192
- const data = await (0, api_1.apiJson)(pagesClient(), `${pagePath(ref)}/revisions`);
193
- if ((0, args_1.flagBool)(args, 'json')) {
194
- (0, ui_1.out)(JSON.stringify(data, null, 2));
195
- return;
196
- }
197
- const { page, revisions } = data;
198
- (0, ui_1.out)(`${(0, ui_1.bold)(page.title)} ${page.url}`);
199
- (0, ui_1.out)(describeState(page));
200
- if (revisions.length === 0) {
201
- (0, ui_1.note)((0, ui_1.dim)(` Upload: xflow pages deploy ${page.slug} <folder, .zip or .html>`));
202
- return;
203
- }
204
- (0, ui_1.out)();
205
- (0, ui_1.table)(revisions.map((revision) => [
206
- String(revision.revision),
207
- (0, ui_1.formatAge)(revision.created_at),
208
- `${revision.file_count} file(s)`,
209
- (0, ui_1.formatBytes)(revision.size_bytes),
210
- clip(revision.comment ?? '', 60),
211
- revision.current ? 'current' : revision.restorable ? '' : 'files removed',
212
- ]));
213
- (0, ui_1.note)((0, ui_1.dim)(` Put a version back: xflow pages rollback ${page.slug} <number>. The files of the last 5 are kept`));
214
- }
215
- async function pagesRollback(args) {
216
- const [, ref, raw] = args.words;
217
- if (!ref || !raw) {
218
- throw new errors_1.CliError('A page and a version number are required', 'For example: xflow pages rollback price-list 2. The versions: xflow pages status <page>');
219
- }
220
- const revision = Number(raw);
221
- if (!Number.isInteger(revision) || revision < 1) {
222
- throw new errors_1.CliError(`A version number is a positive integer, got "${raw}"`, `The versions: xflow pages status ${ref}`);
223
- }
224
- const { page } = await (0, api_1.apiJson)(pagesClient(), `${pagePath(ref)}/rollback`, {
225
- method: 'POST',
226
- body: { revision },
227
- });
228
- if (page.status === 'active')
229
- (0, ui_1.ok)(`Version ${revision} is back on the address`);
230
- else
231
- (0, ui_1.warn)(`The page is switched off: version ${revision} is current and goes live when it is switched on`);
232
- (0, ui_1.out)(page.url);
233
- }
234
- async function pagesRemove(args) {
235
- const ref = args.words[1];
236
- if (!ref) {
237
- throw new errors_1.CliError('A page is required', 'The name from its address, or its id. The pages: xflow pages');
238
- }
239
- // The page is read first: deleting is confirmed by the name in the address, and the
240
- // page can be named here by its id.
241
- const client = pagesClient();
242
- const { page } = await (0, api_1.apiJson)(client, pagePath(ref));
243
- const query = (0, args_1.flagBool)(args, 'yes') ? `?confirm=${encodeURIComponent(page.slug)}` : '';
244
- try {
245
- await (0, api_1.apiJson)(client, `${pagePath(page.id)}${query}`, { method: 'DELETE' });
246
- }
247
- catch (e) {
248
- // The platform names what goes and refuses until the answer is seen. Said in the
249
- // words of this command, not of the API.
250
- if (e instanceof api_1.ApiError && e.code === 'confirm_required') {
251
- throw new errors_1.CliError(e.message, `To delete it anyway: xflow pages rm ${page.slug} --yes`);
252
- }
253
- throw e;
254
- }
255
- (0, ui_1.ok)(`The page is deleted: ${page.url} shows nothing now`);
256
- (0, ui_1.note)((0, ui_1.dim)(` Its versions are gone for good, the name ${page.slug} stays reserved for the organization`));
257
- }