@getxflow/cli 0.24.0 → 0.26.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 +28 -1
- package/dist/commands/auth.js +2 -5
- package/dist/commands/deploy.js +13 -7
- package/dist/commands/org.js +1 -10
- package/dist/commands/pages.js +226 -0
- package/dist/commands/storage.js +2 -1
- package/dist/credentials.js +43 -64
- package/dist/flags.js +7 -0
- package/dist/help.js +62 -2
- package/dist/session.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +32 -6
package/dist/bin.js
CHANGED
|
@@ -26,6 +26,7 @@ const schedules_1 = require("./commands/schedules");
|
|
|
26
26
|
const skills_1 = require("./commands/skills");
|
|
27
27
|
const sources_1 = require("./commands/sources");
|
|
28
28
|
const storage_1 = require("./commands/storage");
|
|
29
|
+
const pages_1 = require("./commands/pages");
|
|
29
30
|
const deploy_1 = require("./commands/deploy");
|
|
30
31
|
const dev_1 = require("./commands/dev");
|
|
31
32
|
const update_1 = require("./commands/update");
|
|
@@ -194,6 +195,28 @@ async function run(args) {
|
|
|
194
195
|
return;
|
|
195
196
|
}
|
|
196
197
|
throw new errors_1.CliError(`Unknown command: storage ${second}`, 'Available: ls, push and rm');
|
|
198
|
+
case 'pages':
|
|
199
|
+
if (second === 'create') {
|
|
200
|
+
await (0, pages_1.pagesCreate)(rest);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (second === 'deploy') {
|
|
204
|
+
await (0, pages_1.pagesDeploy)(rest);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (second === 'status') {
|
|
208
|
+
await (0, pages_1.pagesStatus)(rest);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (second === 'rollback') {
|
|
212
|
+
await (0, pages_1.pagesRollback)(rest);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (second === undefined || second === 'list') {
|
|
216
|
+
await (0, pages_1.pagesList)(rest);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
throw new errors_1.CliError(`Unknown command: pages ${second}`, 'Available: list, create, deploy, status and rollback');
|
|
197
220
|
case 'status':
|
|
198
221
|
await (0, sources_1.status)();
|
|
199
222
|
return;
|
|
@@ -294,11 +317,15 @@ async function main() {
|
|
|
294
317
|
(0, ui_1.fatalNote)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
|
|
295
318
|
if (e.hint)
|
|
296
319
|
(0, ui_1.fatalNote)((0, ui_1.dim)(` ${e.hint}`));
|
|
320
|
+
if (e.code === 'unauthorized' && process.env.XFLOW_TOKEN?.trim()) {
|
|
321
|
+
// Signing in again changes nothing here: the stored key is not the one being sent.
|
|
322
|
+
(0, ui_1.fatalNote)((0, ui_1.dim)(' XFLOW_TOKEN is set, so that is the key refused: put the current one there'));
|
|
323
|
+
}
|
|
297
324
|
if (e.code === 'not_found') {
|
|
298
325
|
// XFLOW_TOKEN is taken as given and no organization is looked for, so a
|
|
299
326
|
// project of another one answers "not found" with nothing else to say.
|
|
300
327
|
if (process.env.XFLOW_TOKEN?.trim()) {
|
|
301
|
-
(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'));
|
|
328
|
+
(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'));
|
|
302
329
|
}
|
|
303
330
|
else if ((0, credentials_1.anyMultipleOrgs)()) {
|
|
304
331
|
(0, ui_1.fatalNote)((0, ui_1.dim)(' Several organizations are signed in: the answer may live in another one, see xflow org'));
|
package/dist/commands/auth.js
CHANGED
|
@@ -92,13 +92,10 @@ function logout(args) {
|
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
const orgs = (0, credentials_1.listOrgs)(apiUrl);
|
|
95
|
+
// The pointer always lands on a stored key, so no active one means no keys.
|
|
95
96
|
const active = orgs.find((org) => org.active);
|
|
96
97
|
if (!active) {
|
|
97
|
-
|
|
98
|
-
if ((0, credentials_1.forgetAllOrgs)(apiUrl))
|
|
99
|
-
(0, ui_1.ok)(`The key for ${apiUrl} is deleted`);
|
|
100
|
-
else
|
|
101
|
-
(0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
|
|
98
|
+
(0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
|
|
102
99
|
return;
|
|
103
100
|
}
|
|
104
101
|
const { nextActive } = (0, credentials_1.forgetOrg)(apiUrl, active.organizationId);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.versionComment = versionComment;
|
|
3
4
|
exports.deploy = deploy;
|
|
4
5
|
exports.publish = publish;
|
|
5
6
|
exports.rollback = rollback;
|
|
@@ -118,21 +119,26 @@ async function assertBuildAllowed(client, projectId) {
|
|
|
118
119
|
const MAX_COMMENT_LENGTH = 200;
|
|
119
120
|
/** Control and bidi-override characters: they spoof terminals, not comments. */
|
|
120
121
|
const COMMENT_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
122
|
+
/** The version comment as typed after -m, cleaned; empty when there is none. Pages take the same. */
|
|
123
|
+
function versionComment(raw) {
|
|
124
|
+
const comment = (raw ?? '')
|
|
125
|
+
.replace(/\s+/g, ' ')
|
|
126
|
+
.replace(COMMENT_CONTROL_CHARS, '')
|
|
127
|
+
.trim();
|
|
128
|
+
if (comment.length > MAX_COMMENT_LENGTH) {
|
|
129
|
+
throw new errors_1.CliError(`The version comment is too long: ${MAX_COMMENT_LENGTH} characters at most`, 'One short line about what changed in this version is enough');
|
|
130
|
+
}
|
|
131
|
+
return comment;
|
|
132
|
+
}
|
|
121
133
|
/**
|
|
122
134
|
* The version comment, checked before anything goes up: a refusal after the push
|
|
123
135
|
* would leave the project with a revision newer than its build for no good reason.
|
|
124
136
|
*/
|
|
125
137
|
function requireComment(args) {
|
|
126
|
-
const comment = ((0, args_1.flagString)(args, 'message')
|
|
127
|
-
.replace(/\s+/g, ' ')
|
|
128
|
-
.replace(COMMENT_CONTROL_CHARS, '')
|
|
129
|
-
.trim();
|
|
138
|
+
const comment = versionComment((0, args_1.flagString)(args, 'message'));
|
|
130
139
|
if (!comment) {
|
|
131
140
|
throw new errors_1.CliError('Every version needs a comment saying what changed in it', 'Add -m: xflow deploy -m "Cart and card payments". One short line, written in the language the user speaks: it names this version in the history');
|
|
132
141
|
}
|
|
133
|
-
if (comment.length > MAX_COMMENT_LENGTH) {
|
|
134
|
-
throw new errors_1.CliError(`The version comment is too long: ${MAX_COMMENT_LENGTH} characters at most`, 'One short line about what changed in this version is enough');
|
|
135
|
-
}
|
|
136
142
|
return comment;
|
|
137
143
|
}
|
|
138
144
|
async function deploy(args) {
|
package/dist/commands/org.js
CHANGED
|
@@ -18,13 +18,7 @@ function orgList() {
|
|
|
18
18
|
const apiUrl = (0, config_1.apiUrlFor)((0, config_1.localConfig)());
|
|
19
19
|
const orgs = (0, credentials_1.listOrgs)(apiUrl);
|
|
20
20
|
if (orgs.length === 0) {
|
|
21
|
-
|
|
22
|
-
(0, ui_1.note)(`One key is stored for ${apiUrl}, its organization is unknown`);
|
|
23
|
-
(0, ui_1.note)((0, ui_1.dim)(' Sign in again to refresh it: xflow login'));
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
(0, ui_1.note)(`No keys for ${apiUrl}. Sign in: xflow login`);
|
|
27
|
-
}
|
|
21
|
+
(0, ui_1.note)(`No keys for ${apiUrl}. Sign in: xflow login`);
|
|
28
22
|
return;
|
|
29
23
|
}
|
|
30
24
|
(0, ui_1.table)(orgs.map((org) => [org.active ? '*' : ' ', org.name ?? '(unnamed)', org.organizationId]));
|
|
@@ -62,9 +56,6 @@ function orgSwitch(args) {
|
|
|
62
56
|
const apiUrl = (0, config_1.apiUrlFor)((0, config_1.localConfig)());
|
|
63
57
|
const orgs = (0, credentials_1.listOrgs)(apiUrl);
|
|
64
58
|
if (orgs.length === 0) {
|
|
65
|
-
if ((0, credentials_1.storedKeyInputs)(apiUrl).legacyToken) {
|
|
66
|
-
throw new errors_1.CliError(`The stored key for ${apiUrl} names no organization`, 'Sign in again to refresh it: xflow login');
|
|
67
|
-
}
|
|
68
59
|
throw new errors_1.CliError(`No keys for ${apiUrl}`, 'Sign in: xflow login');
|
|
69
60
|
}
|
|
70
61
|
const byId = orgs.find((org) => org.organizationId === wanted);
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const api_1 = require("../api");
|
|
11
|
+
const args_1 = require("../args");
|
|
12
|
+
const config_1 = require("../config");
|
|
13
|
+
const credentials_1 = require("../credentials");
|
|
14
|
+
const errors_1 = require("../errors");
|
|
15
|
+
const session_1 = require("../session");
|
|
16
|
+
const ui_1 = require("../ui");
|
|
17
|
+
const zip_1 = require("../zip");
|
|
18
|
+
const deploy_1 = require("./deploy");
|
|
19
|
+
const storage_1 = require("./storage");
|
|
20
|
+
/**
|
|
21
|
+
* Pages: public sites of the organization at <name>.xflowpage.ru. They open without
|
|
22
|
+
* signing in and outside the platform, and an upload is live at once.
|
|
23
|
+
*/
|
|
24
|
+
/** Keep in sync with the platform: a refusal here saves the upload. */
|
|
25
|
+
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
26
|
+
const MAX_UNPACKED_BYTES = 25 * 1024 * 1024;
|
|
27
|
+
const MAX_FILES = 1000;
|
|
28
|
+
/** Where build tools write the site: named when the folder turns out to be the sources. */
|
|
29
|
+
const BUILD_DIRS = ['dist', 'build', 'out'];
|
|
30
|
+
/** A page belongs to the organization, not to a project: the key is picked as for xflow org members. */
|
|
31
|
+
function pagesClient() {
|
|
32
|
+
return (0, session_1.connect)((0, config_1.localConfig)());
|
|
33
|
+
}
|
|
34
|
+
function pagePath(ref) {
|
|
35
|
+
return `/api/v1/pages/${encodeURIComponent(ref)}`;
|
|
36
|
+
}
|
|
37
|
+
function clip(text, max) {
|
|
38
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
39
|
+
}
|
|
40
|
+
function stateOf(page) {
|
|
41
|
+
if (page.status === 'blocked')
|
|
42
|
+
return 'taken down';
|
|
43
|
+
if (page.status === 'disabled')
|
|
44
|
+
return 'switched off';
|
|
45
|
+
return page.current_revision === null ? 'empty' : 'live';
|
|
46
|
+
}
|
|
47
|
+
function describeState(page) {
|
|
48
|
+
if (page.status === 'blocked') {
|
|
49
|
+
const reason = page.blocked_reason ? ': ' + page.blocked_reason : '';
|
|
50
|
+
return `Taken down by the platform${reason}. Write to team@getxflow.com if it is a mistake`;
|
|
51
|
+
}
|
|
52
|
+
if (page.status === 'disabled')
|
|
53
|
+
return 'Switched off: the address shows a placeholder until a person switches it on';
|
|
54
|
+
if (page.current_revision === null)
|
|
55
|
+
return 'Empty: nothing is uploaded yet';
|
|
56
|
+
return `Live: version ${page.current_revision}, put there ${(0, ui_1.formatAge)(page.deployed_at)}`;
|
|
57
|
+
}
|
|
58
|
+
async function pagesList(args) {
|
|
59
|
+
const client = pagesClient();
|
|
60
|
+
const { pages } = await (0, api_1.apiJson)(client, '/api/v1/pages');
|
|
61
|
+
if ((0, args_1.flagBool)(args, 'json')) {
|
|
62
|
+
(0, ui_1.out)(JSON.stringify({ pages }, null, 2));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (pages.length === 0) {
|
|
66
|
+
(0, ui_1.note)('The organization has no pages yet');
|
|
67
|
+
(0, ui_1.note)((0, ui_1.dim)(' A new one: xflow pages create "Price list"'));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
(0, ui_1.table)(pages.map((page) => [
|
|
71
|
+
page.slug,
|
|
72
|
+
clip(page.title, 40),
|
|
73
|
+
stateOf(page),
|
|
74
|
+
(0, ui_1.formatAge)(page.deployed_at ?? page.created_at),
|
|
75
|
+
page.url,
|
|
76
|
+
]));
|
|
77
|
+
}
|
|
78
|
+
const orgs = (0, credentials_1.listOrgs)(client.apiUrl);
|
|
79
|
+
if (client.source === 'active' && orgs.length > 1) {
|
|
80
|
+
const name = orgs.find((org) => org.active)?.name;
|
|
81
|
+
const whose = name ? `"${name}"` : 'the active organization';
|
|
82
|
+
(0, ui_1.note)((0, ui_1.dim)(` Pages of ${whose} only. For another organization: xflow org switch <name or id>`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function pagesCreate(args) {
|
|
86
|
+
const title = args.words.slice(1).join(' ').trim();
|
|
87
|
+
if (!title) {
|
|
88
|
+
throw new errors_1.CliError('A title is required', 'For example: xflow pages create "Price list" --slug price-list');
|
|
89
|
+
}
|
|
90
|
+
const slug = (0, args_1.flagString)(args, 'slug');
|
|
91
|
+
const { page } = await (0, api_1.apiJson)(pagesClient(), '/api/v1/pages', {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
body: slug ? { title, slug } : { title },
|
|
94
|
+
});
|
|
95
|
+
(0, ui_1.ok)(`Page "${page.title}" created`);
|
|
96
|
+
(0, ui_1.out)(page.url);
|
|
97
|
+
(0, ui_1.note)((0, ui_1.dim)(` Nothing is on it yet: xflow pages deploy ${page.slug} <folder, .zip or .html>`));
|
|
98
|
+
}
|
|
99
|
+
function assertUploadSize(bytes) {
|
|
100
|
+
if (bytes <= MAX_UPLOAD_BYTES)
|
|
101
|
+
return;
|
|
102
|
+
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');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The folder of a built site, zipped here. The sources of a site are refused before the
|
|
106
|
+
* walk: the platform would reject them anyway, and node_modules is a long way down.
|
|
107
|
+
*/
|
|
108
|
+
function zipSite(absolute, source) {
|
|
109
|
+
const built = BUILD_DIRS.find((dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, dir, 'index.html')));
|
|
110
|
+
const pointAtBuild = built
|
|
111
|
+
? `The built site is in ${(0, node_path_1.join)(source, built)}: upload that folder`
|
|
112
|
+
: 'Build it and upload the folder the build writes';
|
|
113
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, 'package.json'))) {
|
|
114
|
+
throw new errors_1.CliError(`${source} holds the sources of a site, not a built one`, pointAtBuild);
|
|
115
|
+
}
|
|
116
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, 'index.html'))) {
|
|
117
|
+
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');
|
|
118
|
+
}
|
|
119
|
+
const files = (0, storage_1.collectMedia)(absolute);
|
|
120
|
+
if (files.length > MAX_FILES) {
|
|
121
|
+
throw new errors_1.CliError(`${source} holds ${files.length} files, a page takes ${MAX_FILES} at most`, 'Drop what the site never loads');
|
|
122
|
+
}
|
|
123
|
+
const bytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
124
|
+
if (bytes > MAX_UNPACKED_BYTES) {
|
|
125
|
+
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');
|
|
126
|
+
}
|
|
127
|
+
const body = (0, zip_1.zipCreate)(files.map((file) => ({ path: file.remote, content: (0, node_fs_1.readFileSync)(file.absolute) })));
|
|
128
|
+
assertUploadSize(body.length);
|
|
129
|
+
return { kind: 'zip', body, label: `${files.length} file(s), ${(0, ui_1.formatBytes)(bytes)}` };
|
|
130
|
+
}
|
|
131
|
+
function readUpload(source) {
|
|
132
|
+
const absolute = (0, node_path_1.resolve)(source);
|
|
133
|
+
let stat;
|
|
134
|
+
try {
|
|
135
|
+
stat = (0, node_fs_1.statSync)(absolute);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new errors_1.CliError(`No such file or folder: ${source}`);
|
|
139
|
+
}
|
|
140
|
+
if (stat.isDirectory())
|
|
141
|
+
return zipSite(absolute, source);
|
|
142
|
+
const extension = (0, node_path_1.extname)(absolute).toLowerCase();
|
|
143
|
+
if (extension !== '.html' && extension !== '.htm' && extension !== '.zip') {
|
|
144
|
+
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');
|
|
145
|
+
}
|
|
146
|
+
assertUploadSize(stat.size);
|
|
147
|
+
return {
|
|
148
|
+
kind: extension === '.zip' ? 'zip' : 'html',
|
|
149
|
+
body: (0, node_fs_1.readFileSync)(absolute),
|
|
150
|
+
label: `${(0, node_path_1.basename)(absolute)}, ${(0, ui_1.formatBytes)(stat.size)}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function pagesDeploy(args) {
|
|
154
|
+
const [, ref, source] = args.words;
|
|
155
|
+
if (!ref || !source) {
|
|
156
|
+
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');
|
|
157
|
+
}
|
|
158
|
+
const comment = (0, deploy_1.versionComment)((0, args_1.flagString)(args, 'message'));
|
|
159
|
+
const upload = readUpload(source);
|
|
160
|
+
const client = pagesClient();
|
|
161
|
+
(0, ui_1.step)(`Uploading ${upload.label}`);
|
|
162
|
+
const query = comment ? `?comment=${encodeURIComponent(comment)}` : '';
|
|
163
|
+
const result = await (0, api_1.apiUpload)(client, `${pagePath(ref)}/deploy${query}`, upload.body, {
|
|
164
|
+
'Content-Type': upload.kind === 'zip' ? 'application/zip' : 'text/html',
|
|
165
|
+
});
|
|
166
|
+
if (result.status === 'unchanged') {
|
|
167
|
+
(0, ui_1.ok)(`Nothing changed: version ${result.revision} is already on the address`);
|
|
168
|
+
}
|
|
169
|
+
else if (result.status === 'stored') {
|
|
170
|
+
(0, ui_1.warn)(`The page is switched off: version ${result.revision} is kept, not shown`);
|
|
171
|
+
(0, ui_1.note)((0, ui_1.dim)(' It goes live when a person switches the page on'));
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
(0, ui_1.ok)(`Version ${result.revision} is live: ${result.file_count} file(s), ${(0, ui_1.formatBytes)(result.size_bytes)}`);
|
|
175
|
+
}
|
|
176
|
+
(0, ui_1.out)(result.page.url);
|
|
177
|
+
if (result.status === 'deployed') {
|
|
178
|
+
(0, ui_1.note)((0, ui_1.dim)(` Anyone with the address sees it within seconds. The versions: xflow pages status ${result.page.slug}`));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function pagesStatus(args) {
|
|
182
|
+
const ref = args.words[1];
|
|
183
|
+
if (!ref)
|
|
184
|
+
throw new errors_1.CliError('A page is required', 'The name from its address, or its id. The pages: xflow pages');
|
|
185
|
+
const data = await (0, api_1.apiJson)(pagesClient(), `${pagePath(ref)}/revisions`);
|
|
186
|
+
if ((0, args_1.flagBool)(args, 'json')) {
|
|
187
|
+
(0, ui_1.out)(JSON.stringify(data, null, 2));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const { page, revisions } = data;
|
|
191
|
+
(0, ui_1.out)(`${(0, ui_1.bold)(page.title)} ${page.url}`);
|
|
192
|
+
(0, ui_1.out)(describeState(page));
|
|
193
|
+
if (revisions.length === 0) {
|
|
194
|
+
(0, ui_1.note)((0, ui_1.dim)(` Upload: xflow pages deploy ${page.slug} <folder, .zip or .html>`));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
(0, ui_1.out)();
|
|
198
|
+
(0, ui_1.table)(revisions.map((revision) => [
|
|
199
|
+
String(revision.revision),
|
|
200
|
+
(0, ui_1.formatAge)(revision.created_at),
|
|
201
|
+
`${revision.file_count} file(s)`,
|
|
202
|
+
(0, ui_1.formatBytes)(revision.size_bytes),
|
|
203
|
+
clip(revision.comment ?? '', 60),
|
|
204
|
+
revision.current ? 'current' : revision.restorable ? '' : 'files removed',
|
|
205
|
+
]));
|
|
206
|
+
(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`));
|
|
207
|
+
}
|
|
208
|
+
async function pagesRollback(args) {
|
|
209
|
+
const [, ref, raw] = args.words;
|
|
210
|
+
if (!ref || !raw) {
|
|
211
|
+
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>');
|
|
212
|
+
}
|
|
213
|
+
const revision = Number(raw);
|
|
214
|
+
if (!Number.isInteger(revision) || revision < 1) {
|
|
215
|
+
throw new errors_1.CliError(`A version number is a positive integer, got "${raw}"`, `The versions: xflow pages status ${ref}`);
|
|
216
|
+
}
|
|
217
|
+
const { page } = await (0, api_1.apiJson)(pagesClient(), `${pagePath(ref)}/rollback`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
body: { revision },
|
|
220
|
+
});
|
|
221
|
+
if (page.status === 'active')
|
|
222
|
+
(0, ui_1.ok)(`Version ${revision} is back on the address`);
|
|
223
|
+
else
|
|
224
|
+
(0, ui_1.warn)(`The page is switched off: version ${revision} is current and goes live when it is switched on`);
|
|
225
|
+
(0, ui_1.out)(page.url);
|
|
226
|
+
}
|
package/dist/commands/storage.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.contentTypeFor = contentTypeFor;
|
|
4
|
+
exports.collectMedia = collectMedia;
|
|
4
5
|
exports.planBatches = planBatches;
|
|
5
6
|
exports.caseCollisions = caseCollisions;
|
|
6
7
|
exports.storageList = storageList;
|
|
@@ -74,7 +75,7 @@ function contentTypeFor(name) {
|
|
|
74
75
|
* what one is asked to put there so it stays out of the 10 MB archive.
|
|
75
76
|
*
|
|
76
77
|
* Dot entries and symlinks are skipped: pointing push at a working copy should
|
|
77
|
-
* not upload .git, and a link is not ours to follow.
|
|
78
|
+
* not upload .git, and a link is not ours to follow. Pages walk a site the same way.
|
|
78
79
|
*/
|
|
79
80
|
function collectMedia(root, prefix = '') {
|
|
80
81
|
const found = [];
|
package/dist/credentials.js
CHANGED
|
@@ -47,56 +47,38 @@ function writeStore(store) {
|
|
|
47
47
|
function isLegacy(value) {
|
|
48
48
|
return !!value && typeof value.token === 'string';
|
|
49
49
|
}
|
|
50
|
-
/** The mirror for older CLIs: the key of the active organization, or nothing. */
|
|
51
|
-
function syncLegacy(store, apiUrl) {
|
|
52
|
-
const address = store.orgs?.[apiUrl];
|
|
53
|
-
if (!address)
|
|
54
|
-
return;
|
|
55
|
-
const active = address.active ? address.keys[address.active] : undefined;
|
|
56
|
-
if (active && address.active) {
|
|
57
|
-
store[apiUrl] = { token: active.token, organizationId: address.active, savedAt: active.savedAt };
|
|
58
|
-
address.mirror = address.active;
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
delete store[apiUrl];
|
|
62
|
-
address.mirror = null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
50
|
/**
|
|
66
|
-
* Fold
|
|
67
|
-
*
|
|
68
|
-
*
|
|
51
|
+
* Fold a record predating the map into it and erase the record. A record naming
|
|
52
|
+
* no organization cannot be placed in the map and is dropped: the answer is one
|
|
53
|
+
* xflow login, while leaving it would leave the key where the old proxy looks.
|
|
69
54
|
* Returns true when the store changed and has to be written back.
|
|
70
55
|
*/
|
|
71
56
|
function reconcile(store, apiUrl) {
|
|
72
57
|
const legacy = store[apiUrl];
|
|
73
|
-
let address = store.orgs?.[apiUrl];
|
|
74
58
|
let changed = false;
|
|
75
|
-
if (isLegacy(legacy)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
orgs.keys[legacy.organizationId]
|
|
80
|
-
|
|
59
|
+
if (isLegacy(legacy)) {
|
|
60
|
+
if (legacy.organizationId) {
|
|
61
|
+
const orgs = store.orgs?.[apiUrl] ?? { active: null, keys: {} };
|
|
62
|
+
// The map wins: it is what this CLI wrote last.
|
|
63
|
+
if (!orgs.keys[legacy.organizationId]) {
|
|
64
|
+
orgs.keys[legacy.organizationId] = { token: legacy.token, savedAt: legacy.savedAt };
|
|
65
|
+
}
|
|
81
66
|
store.orgs = store.orgs ?? {};
|
|
82
67
|
store.orgs[apiUrl] = orgs;
|
|
83
|
-
address = orgs;
|
|
84
|
-
changed = true;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (!isLegacy(legacy) && address?.active && address.keys[address.active]) {
|
|
88
|
-
if (address.mirror === address.active) {
|
|
89
|
-
// The mirror we wrote for this key is gone: an older CLI logged out.
|
|
90
|
-
delete address.keys[address.active];
|
|
91
|
-
address.active = null;
|
|
92
68
|
}
|
|
93
|
-
|
|
94
|
-
// restored file): deleting it would lose a key nobody asked to forget.
|
|
95
|
-
// Either way the store changed: the mirror has to be rebuilt.
|
|
69
|
+
delete store[apiUrl];
|
|
96
70
|
changed = true;
|
|
97
71
|
}
|
|
98
|
-
|
|
72
|
+
const address = store.orgs?.[apiUrl];
|
|
99
73
|
if (address) {
|
|
74
|
+
// The field the erased record was tracked by. Left in place, a 0.24.0 still on
|
|
75
|
+
// the machine reads "the mirror I wrote is gone" as a logout and drops the key.
|
|
76
|
+
const stale = address;
|
|
77
|
+
if ('mirror' in stale) {
|
|
78
|
+
delete stale.mirror;
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
// The pointer must land on a stored key, or on nothing.
|
|
100
82
|
const ids = Object.keys(address.keys);
|
|
101
83
|
const active = address.active && address.keys[address.active] ? address.active : (ids[0] ?? null);
|
|
102
84
|
if (address.active !== active) {
|
|
@@ -104,8 +86,6 @@ function reconcile(store, apiUrl) {
|
|
|
104
86
|
changed = true;
|
|
105
87
|
}
|
|
106
88
|
}
|
|
107
|
-
if (changed)
|
|
108
|
-
syncLegacy(store, apiUrl);
|
|
109
89
|
return changed;
|
|
110
90
|
}
|
|
111
91
|
/** Read the store with the drift of older CLIs healed, persisting the healing. */
|
|
@@ -116,7 +96,6 @@ function load(apiUrl) {
|
|
|
116
96
|
return store;
|
|
117
97
|
}
|
|
118
98
|
function commit(store, apiUrl) {
|
|
119
|
-
syncLegacy(store, apiUrl);
|
|
120
99
|
const address = store.orgs?.[apiUrl];
|
|
121
100
|
if (address && Object.keys(address.keys).length === 0) {
|
|
122
101
|
delete store.orgs?.[apiUrl];
|
|
@@ -158,32 +137,38 @@ function saveOrgCredential(apiUrl, organizationId, token, name) {
|
|
|
158
137
|
/** Cache the display name; a stale one is refreshed by any login or whoami. */
|
|
159
138
|
function rememberOrgName(apiUrl, organizationId, name) {
|
|
160
139
|
const store = readStore();
|
|
161
|
-
reconcile(store, apiUrl);
|
|
140
|
+
let changed = reconcile(store, apiUrl);
|
|
162
141
|
const entry = store.orgs?.[apiUrl]?.keys[organizationId];
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
142
|
+
if (entry && entry.name !== name) {
|
|
143
|
+
entry.name = name;
|
|
144
|
+
changed = true;
|
|
145
|
+
}
|
|
146
|
+
if (changed)
|
|
147
|
+
commit(store, apiUrl);
|
|
167
148
|
}
|
|
168
149
|
function setActiveOrg(apiUrl, organizationId) {
|
|
169
150
|
const store = readStore();
|
|
170
|
-
reconcile(store, apiUrl);
|
|
151
|
+
let changed = reconcile(store, apiUrl);
|
|
171
152
|
const orgs = store.orgs?.[apiUrl];
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (orgs.active !== organizationId) {
|
|
153
|
+
const known = !!orgs?.keys[organizationId];
|
|
154
|
+
if (orgs && known && orgs.active !== organizationId) {
|
|
175
155
|
orgs.active = organizationId;
|
|
176
|
-
|
|
156
|
+
changed = true;
|
|
177
157
|
}
|
|
178
|
-
|
|
158
|
+
if (changed)
|
|
159
|
+
commit(store, apiUrl);
|
|
160
|
+
return known;
|
|
179
161
|
}
|
|
180
162
|
/** Forget one organization; the pointer moves to any remaining one. */
|
|
181
163
|
function forgetOrg(apiUrl, organizationId) {
|
|
182
164
|
const store = readStore();
|
|
183
|
-
reconcile(store, apiUrl);
|
|
165
|
+
const healed = reconcile(store, apiUrl);
|
|
184
166
|
const orgs = store.orgs?.[apiUrl];
|
|
185
|
-
if (!orgs?.keys[organizationId])
|
|
167
|
+
if (!orgs?.keys[organizationId]) {
|
|
168
|
+
if (healed)
|
|
169
|
+
commit(store, apiUrl);
|
|
186
170
|
return { removed: false, nextActive: null };
|
|
171
|
+
}
|
|
187
172
|
delete orgs.keys[organizationId];
|
|
188
173
|
if (orgs.active === organizationId)
|
|
189
174
|
orgs.active = Object.keys(orgs.keys)[0] ?? null;
|
|
@@ -199,8 +184,9 @@ function forgetOrg(apiUrl, organizationId) {
|
|
|
199
184
|
/** Forget every key of the address; other addresses are untouched. */
|
|
200
185
|
function forgetAllOrgs(apiUrl) {
|
|
201
186
|
const store = readStore();
|
|
202
|
-
reconcile
|
|
203
|
-
|
|
187
|
+
// A record erased by reconcile was a key as well: counted here, or the address
|
|
188
|
+
// looks empty and the erasure never reaches the file.
|
|
189
|
+
const had = reconcile(store, apiUrl) || !!store.orgs?.[apiUrl];
|
|
204
190
|
if (!had)
|
|
205
191
|
return false;
|
|
206
192
|
if (store.orgs) {
|
|
@@ -236,16 +222,9 @@ function pickKey(inputs) {
|
|
|
236
222
|
const active = activeId ? inputs.orgs?.keys[activeId] : undefined;
|
|
237
223
|
if (active && activeId)
|
|
238
224
|
return { token: active.token, source: 'active', organizationId: activeId };
|
|
239
|
-
if (inputs.legacyToken)
|
|
240
|
-
return { token: inputs.legacyToken, source: 'active', organizationId: null };
|
|
241
225
|
return null;
|
|
242
226
|
}
|
|
243
227
|
/** The store-backed half of pickKey's inputs; the caller adds env and the folder binding. */
|
|
244
228
|
function storedKeyInputs(apiUrl) {
|
|
245
|
-
|
|
246
|
-
const legacy = store[apiUrl];
|
|
247
|
-
return {
|
|
248
|
-
orgs: store.orgs?.[apiUrl] ?? null,
|
|
249
|
-
legacyToken: isLegacy(legacy) ? legacy.token : null,
|
|
250
|
-
};
|
|
229
|
+
return { orgs: load(apiUrl).orgs?.[apiUrl] ?? null };
|
|
251
230
|
}
|
package/dist/flags.js
CHANGED
|
@@ -81,6 +81,12 @@ exports.KNOWN = {
|
|
|
81
81
|
'storage push': ['to', 'replace', 'json', 'project'],
|
|
82
82
|
'storage rm': ['folder', 'yes', 'project'],
|
|
83
83
|
'storage remove': ['folder', 'yes', 'project'],
|
|
84
|
+
pages: ['json'],
|
|
85
|
+
'pages list': ['json'],
|
|
86
|
+
'pages create': ['slug'],
|
|
87
|
+
'pages deploy': ['message'],
|
|
88
|
+
'pages status': ['json'],
|
|
89
|
+
'pages rollback': [],
|
|
84
90
|
status: [],
|
|
85
91
|
dev: [],
|
|
86
92
|
pull: ['into', 'force', 'revision'],
|
|
@@ -127,6 +133,7 @@ const NAMESPACES = new Set([
|
|
|
127
133
|
'schedules',
|
|
128
134
|
'db',
|
|
129
135
|
'storage',
|
|
136
|
+
'pages',
|
|
130
137
|
'mcp',
|
|
131
138
|
]);
|
|
132
139
|
/** The command these words name, as far as the table knows it. */
|
package/dist/help.js
CHANGED
|
@@ -89,6 +89,15 @@ ${(0, ui_1.bold)('Database')}
|
|
|
89
89
|
xflow db migrate [--dry-run] [--allow-destructive]
|
|
90
90
|
apply the migrations from migrations/*.sql
|
|
91
91
|
|
|
92
|
+
${(0, ui_1.bold)('Pages')}
|
|
93
|
+
xflow pages public sites of the organization at <name>.xflowpage.ru
|
|
94
|
+
xflow pages create <title> [--slug name]
|
|
95
|
+
a new page, empty until the first upload
|
|
96
|
+
xflow pages deploy <page> <folder|.zip|.html> [-m "what changed"]
|
|
97
|
+
upload the site: live at once, there is no publish step
|
|
98
|
+
xflow pages status <page> the page and its versions
|
|
99
|
+
xflow pages rollback <page> <N> put an earlier version back on the address
|
|
100
|
+
|
|
92
101
|
${(0, ui_1.bold)('Reference')}
|
|
93
102
|
xflow logs [--limit N] browser errors from the released application
|
|
94
103
|
xflow projects list projects of the organization
|
|
@@ -202,6 +211,55 @@ active one outside. With several signed in, a line under the table says whose
|
|
|
202
211
|
projects these are.
|
|
203
212
|
|
|
204
213
|
${(0, ui_1.bold)('xflow link <id>')} binds the current folder to a project; ${(0, ui_1.bold)('xflow init')} makes a new one.`,
|
|
214
|
+
link: `${(0, ui_1.bold)('xflow link')} <id>: bind this folder to a project
|
|
215
|
+
|
|
216
|
+
Writes xflow.json and .xflow/state.json, points .env at the project and lays down the
|
|
217
|
+
instructions for the agents. The id comes from ${(0, ui_1.bold)('xflow projects list')}; a project of
|
|
218
|
+
another signed-in organization is found too, and the folder is bound to that organization.
|
|
219
|
+
|
|
220
|
+
An empty folder (.git alone counts as empty) also gets the latest sources, so there is no
|
|
221
|
+
${(0, ui_1.bold)('pull')} after it: pull would refuse the folder anyway, it is not empty any more. A
|
|
222
|
+
folder with files keeps them as they are: ${(0, ui_1.bold)('xflow status')} says how they differ from
|
|
223
|
+
the server revision, ${(0, ui_1.bold)('xflow pull --into ./server-copy')} fetches it next to them.
|
|
224
|
+
|
|
225
|
+
In a folder that is already linked, the command points xflow.json at the other project and
|
|
226
|
+
keeps the rest of the file. A new project instead of an existing one: ${(0, ui_1.bold)('xflow init')}.`,
|
|
227
|
+
pages: `${(0, ui_1.bold)('xflow pages')}: public sites of the organization
|
|
228
|
+
|
|
229
|
+
A page is a static site at https://<name>.xflowpage.ru: a landing page, a presentation, an
|
|
230
|
+
invoice for people outside the organization. It opens by its address for anyone, without
|
|
231
|
+
signing in and outside the platform. A project is the opposite: it opens only inside the
|
|
232
|
+
platform, for the members of the organization.
|
|
233
|
+
|
|
234
|
+
xflow pages the pages, their state and address
|
|
235
|
+
xflow pages create <title> a new page, empty until the first upload
|
|
236
|
+
--slug <name> the name in the address; without it the name is made
|
|
237
|
+
from the title, with a random tail
|
|
238
|
+
xflow pages deploy <page> <path> upload a folder with index.html at the top, its .zip,
|
|
239
|
+
or one .html file
|
|
240
|
+
-m <text> what changed, one short line: it names the version
|
|
241
|
+
xflow pages status <page> the page and its versions
|
|
242
|
+
xflow pages rollback <page> <N> put version N back on the address
|
|
243
|
+
|
|
244
|
+
${(0, ui_1.bold)('<page>')} is the name from the address or the id. ${(0, ui_1.bold)('--json')} on list and status
|
|
245
|
+
prints the answer as JSON. A page belongs to the organization, not to a project, so there is
|
|
246
|
+
no ${(0, ui_1.bold)('--project')}: the key is picked as for every other command (${(0, ui_1.bold)('xflow help org')}).
|
|
247
|
+
|
|
248
|
+
${(0, ui_1.bold)('An upload is live at once.')} There is no draft and no publish step: anyone with the
|
|
249
|
+
address sees the new version within seconds. Uploading the same files again changes nothing.
|
|
250
|
+
The files of the last 5 versions are kept, so a rollback takes seconds and needs no upload.
|
|
251
|
+
|
|
252
|
+
What goes up is the built site: HTML, CSS, JS, images, fonts, PDF, audio and video. No code
|
|
253
|
+
runs on the platform side, and a folder with package.json is refused as the sources of a site:
|
|
254
|
+
build it and upload what the build writes. Dot entries are skipped. At most 5 MB zipped, 25 MB
|
|
255
|
+
unpacked and 1000 files.
|
|
256
|
+
|
|
257
|
+
The page is public, so a file holding a secret key is refused whole: every visitor would read
|
|
258
|
+
it. Search engines are asked not to index pages, so do not promise SEO.
|
|
259
|
+
|
|
260
|
+
A page takes the key of the whole organization, the one ${(0, ui_1.bold)('xflow login')} issues. The
|
|
261
|
+
section is in beta: XFlow support switches it on for an organization. Switching a page off
|
|
262
|
+
and deleting it are for a person, in the platform interface.`,
|
|
205
263
|
update: `${(0, ui_1.bold)('xflow update')}: bring the CLI up to date
|
|
206
264
|
|
|
207
265
|
Installs the published version and, if anything changed, rewrites the platform
|
|
@@ -547,8 +605,10 @@ so the cloud functions answer here the way they do on the platform. The key is p
|
|
|
547
605
|
as for every other command (${(0, ui_1.bold)('xflow help org')}): with several organizations signed
|
|
548
606
|
in, the right one is found and the folder is bound to it.
|
|
549
607
|
|
|
550
|
-
Use it instead of a bare ${(0, ui_1.bold)('npm run dev')}
|
|
551
|
-
|
|
608
|
+
Use it instead of a bare ${(0, ui_1.bold)('npm run dev')}: that one is handed no key, so its calls to
|
|
609
|
+
cloud functions fail. In a project made before this command the failure reads "No access
|
|
610
|
+
key for the platform: run xflow login", and signing in will not help, because the key is
|
|
611
|
+
no longer left in a file for that dev server to pick up.
|
|
552
612
|
|
|
553
613
|
The platform address comes from the CLI too, not from .env, so the key goes to the
|
|
554
614
|
platform that issued it and nowhere else. It never reaches the browser: the page gets
|
package/dist/session.js
CHANGED
|
@@ -84,7 +84,7 @@ async function findProjectOrg(apiUrl, projectId) {
|
|
|
84
84
|
*/
|
|
85
85
|
async function connectProject(projectId, root, config) {
|
|
86
86
|
const session = connect(config, { ignoreFolder: root === null });
|
|
87
|
-
if (session.source !== 'active'
|
|
87
|
+
if (session.source !== 'active')
|
|
88
88
|
return session;
|
|
89
89
|
const orgs = (0, credentials_1.listOrgs)(session.apiUrl);
|
|
90
90
|
if (orgs.length <= 1)
|
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.26.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
|
@@ -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 the project root has xflow.json or VITE_XFLOW_* variables, and for any task that touches deployment, publishing, rollback, source sync, 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,
|
|
3
|
+
description: Build, deploy and publish apps on the XFlow platform with the xflow CLI. Use whenever the project root has xflow.json or VITE_XFLOW_* variables, and for any task that touches deployment, publishing, rollback, source sync, 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 platform design system, or a public page on xflowpage.ru (a landing page, presentation or invoice for people outside the organization).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# XFlow
|
|
@@ -22,7 +22,7 @@ contains the fix.
|
|
|
22
22
|
|
|
23
23
|
## Keeping these instructions current
|
|
24
24
|
|
|
25
|
-
These instructions ship with xflow CLI 0.
|
|
25
|
+
These instructions ship with xflow CLI 0.26.0, so the copy you are reading can be older than
|
|
26
26
|
the CLI answering your commands.
|
|
27
27
|
|
|
28
28
|
When the CLI says this file does not match it, run `xflow skills --refresh` and then **read
|
|
@@ -125,9 +125,9 @@ outside the project folder); building it over the current one needs a human.
|
|
|
125
125
|
|
|
126
126
|
Pages are kept for the **three latest successful builds** plus whatever dev and live show;
|
|
127
127
|
older versions stay in the history with `pages_kept` false, readable but not openable. The
|
|
128
|
-
history holds the **last 30 builds**,
|
|
129
|
-
|
|
130
|
-
active project: keep anything you must not lose in your own git repository.
|
|
128
|
+
history holds the **last 30 builds**, each keeping the revision it was built from; any other
|
|
129
|
+
revision is kept only while it is among the **10 latest**. Three builds is the whole rollback
|
|
130
|
+
depth, hours on an active project: keep anything you must not lose in your own git repository.
|
|
131
131
|
|
|
132
132
|
**The only link you give a person is the project page**, `https://app.getxflow.com/projects/<id>`,
|
|
133
133
|
which the CLI prints for you. Refer to builds by their number ("version 481203 is built, 092399
|
|
@@ -520,6 +520,31 @@ What the app may do with files is decided inside that function, because the page
|
|
|
520
520
|
by whoever opened it. Never send the key to the frontend: the build gate stops on a key found in
|
|
521
521
|
the sources, and a key that reached a visitor deletes every file of the project.
|
|
522
522
|
|
|
523
|
+
## Pages
|
|
524
|
+
|
|
525
|
+
A page is a public static site at `https://<name>.xflowpage.ru`: a landing page, a
|
|
526
|
+
presentation, an invoice for people outside the organization. Anyone with the address opens it,
|
|
527
|
+
without signing in and outside the platform. A project is the opposite, it opens only inside the
|
|
528
|
+
platform: pick a page when the audience is outside the organization.
|
|
529
|
+
|
|
530
|
+
- `xflow pages create "Price list"` makes an empty page and prints its address. `--slug
|
|
531
|
+
price-list` picks the name, otherwise it comes from the title with a random tail.
|
|
532
|
+
- `xflow pages deploy <page> <folder|.zip|.html> -m "what changed"` uploads the built site.
|
|
533
|
+
**It is public the moment the command finishes**: no draft, no publish step. Upload when the
|
|
534
|
+
user wants the page out, and give them the address the CLI printed.
|
|
535
|
+
- `xflow pages status <page>` lists the versions, `xflow pages rollback <page> <N>` puts one back
|
|
536
|
+
in seconds; the files of the last 5 are kept.
|
|
537
|
+
- Static files only: HTML, CSS, JS, images, fonts, PDF, audio, video. No server code, database
|
|
538
|
+
or functions on a page. Upload what the build writes (`dist`), never a folder with
|
|
539
|
+
`package.json`. At most 5 MB zipped, 25 MB unpacked, 1000 files.
|
|
540
|
+
- Everything in a page is readable by every visitor. Never put a key, token or password into
|
|
541
|
+
it: a known key format gets the upload refused, anything else simply leaks.
|
|
542
|
+
- Search engines are asked not to index pages: do not promise SEO.
|
|
543
|
+
- The MCP `pages` tool uploads one self-contained HTML document up to 150 KB.
|
|
544
|
+
- Pages take the key of the whole organization, the one `xflow login` issues. The section is in
|
|
545
|
+
beta, switched on per organization by XFlow support: that refusal is not yours to fix. Switching
|
|
546
|
+
a page off and deleting it are for a person, in the platform interface.
|
|
547
|
+
|
|
523
548
|
## Organizations and keys
|
|
524
549
|
|
|
525
550
|
One key per organization, stored side by side rather than replacing each other: `xflow org`
|
|
@@ -602,7 +627,8 @@ own devtools.
|
|
|
602
627
|
|
|
603
628
|
Run the app locally with `xflow dev`, not a bare `npm run dev`: it hands the dev server
|
|
604
629
|
the key of the project's organization, which cloud functions need to answer, and their
|
|
605
|
-
current addresses, including functions someone else deployed.
|
|
630
|
+
current addresses, including functions someone else deployed. A bare `npm run dev` gets
|
|
631
|
+
no key at all, so its function calls fail whatever the error says. The server answers
|
|
606
632
|
`127.0.0.1` alone, because it holds that key: a phone on the same network cannot open it,
|
|
607
633
|
and neither can anybody else on that network.
|
|
608
634
|
|