@getxflow/cli 0.25.0 → 0.27.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/deploy.js +13 -7
- package/dist/commands/pages.js +232 -0
- package/dist/commands/storage.js +2 -1
- package/dist/flags.js +7 -0
- package/dist/help.js +61 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +33 -5
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/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) {
|
|
@@ -0,0 +1,232 @@
|
|
|
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" ./dist'));
|
|
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 source = args.words.length > 2 ? args.words[args.words.length - 1] : undefined;
|
|
87
|
+
const title = args.words.slice(1, -1).join(' ').trim();
|
|
88
|
+
if (!source || !title) {
|
|
89
|
+
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');
|
|
90
|
+
}
|
|
91
|
+
const upload = readUpload(source);
|
|
92
|
+
const slug = (0, args_1.flagString)(args, 'slug');
|
|
93
|
+
const query = new URLSearchParams(slug ? { title, slug } : { title });
|
|
94
|
+
(0, ui_1.step)(`Uploading ${upload.label}`);
|
|
95
|
+
const result = await (0, api_1.apiUpload)(pagesClient(), `/api/v1/pages?${query}`, upload.body, {
|
|
96
|
+
'Content-Type': contentTypeOf(upload),
|
|
97
|
+
});
|
|
98
|
+
(0, ui_1.ok)(`Page "${result.page.title}" is live: ${result.file_count} file(s), ${(0, ui_1.formatBytes)(result.size_bytes)}`);
|
|
99
|
+
(0, ui_1.out)(result.page.url);
|
|
100
|
+
(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>`));
|
|
101
|
+
}
|
|
102
|
+
function contentTypeOf(upload) {
|
|
103
|
+
return upload.kind === 'zip' ? 'application/zip' : 'text/html';
|
|
104
|
+
}
|
|
105
|
+
function assertUploadSize(bytes) {
|
|
106
|
+
if (bytes <= MAX_UPLOAD_BYTES)
|
|
107
|
+
return;
|
|
108
|
+
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');
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The folder of a built site, zipped here. The sources of a site are refused before the
|
|
112
|
+
* walk: the platform would reject them anyway, and node_modules is a long way down.
|
|
113
|
+
*/
|
|
114
|
+
function zipSite(absolute, source) {
|
|
115
|
+
const built = BUILD_DIRS.find((dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, dir, 'index.html')));
|
|
116
|
+
const pointAtBuild = built
|
|
117
|
+
? `The built site is in ${(0, node_path_1.join)(source, built)}: upload that folder`
|
|
118
|
+
: 'Build it and upload the folder the build writes';
|
|
119
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, 'package.json'))) {
|
|
120
|
+
throw new errors_1.CliError(`${source} holds the sources of a site, not a built one`, pointAtBuild);
|
|
121
|
+
}
|
|
122
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(absolute, 'index.html'))) {
|
|
123
|
+
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');
|
|
124
|
+
}
|
|
125
|
+
const files = (0, storage_1.collectMedia)(absolute);
|
|
126
|
+
if (files.length > MAX_FILES) {
|
|
127
|
+
throw new errors_1.CliError(`${source} holds ${files.length} files, a page takes ${MAX_FILES} at most`, 'Drop what the site never loads');
|
|
128
|
+
}
|
|
129
|
+
const bytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
130
|
+
if (bytes > MAX_UNPACKED_BYTES) {
|
|
131
|
+
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');
|
|
132
|
+
}
|
|
133
|
+
const body = (0, zip_1.zipCreate)(files.map((file) => ({ path: file.remote, content: (0, node_fs_1.readFileSync)(file.absolute) })));
|
|
134
|
+
assertUploadSize(body.length);
|
|
135
|
+
return { kind: 'zip', body, label: `${files.length} file(s), ${(0, ui_1.formatBytes)(bytes)}` };
|
|
136
|
+
}
|
|
137
|
+
function readUpload(source) {
|
|
138
|
+
const absolute = (0, node_path_1.resolve)(source);
|
|
139
|
+
let stat;
|
|
140
|
+
try {
|
|
141
|
+
stat = (0, node_fs_1.statSync)(absolute);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new errors_1.CliError(`No such file or folder: ${source}`);
|
|
145
|
+
}
|
|
146
|
+
if (stat.isDirectory())
|
|
147
|
+
return zipSite(absolute, source);
|
|
148
|
+
const extension = (0, node_path_1.extname)(absolute).toLowerCase();
|
|
149
|
+
if (extension !== '.html' && extension !== '.htm' && extension !== '.zip') {
|
|
150
|
+
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');
|
|
151
|
+
}
|
|
152
|
+
assertUploadSize(stat.size);
|
|
153
|
+
return {
|
|
154
|
+
kind: extension === '.zip' ? 'zip' : 'html',
|
|
155
|
+
body: (0, node_fs_1.readFileSync)(absolute),
|
|
156
|
+
label: `${(0, node_path_1.basename)(absolute)}, ${(0, ui_1.formatBytes)(stat.size)}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async function pagesDeploy(args) {
|
|
160
|
+
const [, ref, source] = args.words;
|
|
161
|
+
if (!ref || !source) {
|
|
162
|
+
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');
|
|
163
|
+
}
|
|
164
|
+
const comment = (0, deploy_1.versionComment)((0, args_1.flagString)(args, 'message'));
|
|
165
|
+
const upload = readUpload(source);
|
|
166
|
+
const client = pagesClient();
|
|
167
|
+
(0, ui_1.step)(`Uploading ${upload.label}`);
|
|
168
|
+
const query = comment ? `?comment=${encodeURIComponent(comment)}` : '';
|
|
169
|
+
const result = await (0, api_1.apiUpload)(client, `${pagePath(ref)}/deploy${query}`, upload.body, {
|
|
170
|
+
'Content-Type': contentTypeOf(upload),
|
|
171
|
+
});
|
|
172
|
+
if (result.status === 'unchanged') {
|
|
173
|
+
(0, ui_1.ok)(`Nothing changed: version ${result.revision} is already on the address`);
|
|
174
|
+
}
|
|
175
|
+
else if (result.status === 'stored') {
|
|
176
|
+
(0, ui_1.warn)(`The page is switched off: version ${result.revision} is kept, not shown`);
|
|
177
|
+
(0, ui_1.note)((0, ui_1.dim)(' It goes live when a person switches the page on'));
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
(0, ui_1.ok)(`Version ${result.revision} is live: ${result.file_count} file(s), ${(0, ui_1.formatBytes)(result.size_bytes)}`);
|
|
181
|
+
}
|
|
182
|
+
(0, ui_1.out)(result.page.url);
|
|
183
|
+
if (result.status === 'deployed') {
|
|
184
|
+
(0, ui_1.note)((0, ui_1.dim)(` Anyone with the address sees it within seconds. The versions: xflow pages status ${result.page.slug}`));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function pagesStatus(args) {
|
|
188
|
+
const ref = args.words[1];
|
|
189
|
+
if (!ref)
|
|
190
|
+
throw new errors_1.CliError('A page is required', 'The name from its address, or its id. The pages: xflow pages');
|
|
191
|
+
const data = await (0, api_1.apiJson)(pagesClient(), `${pagePath(ref)}/revisions`);
|
|
192
|
+
if ((0, args_1.flagBool)(args, 'json')) {
|
|
193
|
+
(0, ui_1.out)(JSON.stringify(data, null, 2));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const { page, revisions } = data;
|
|
197
|
+
(0, ui_1.out)(`${(0, ui_1.bold)(page.title)} ${page.url}`);
|
|
198
|
+
(0, ui_1.out)(describeState(page));
|
|
199
|
+
if (revisions.length === 0) {
|
|
200
|
+
(0, ui_1.note)((0, ui_1.dim)(` Upload: xflow pages deploy ${page.slug} <folder, .zip or .html>`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
(0, ui_1.out)();
|
|
204
|
+
(0, ui_1.table)(revisions.map((revision) => [
|
|
205
|
+
String(revision.revision),
|
|
206
|
+
(0, ui_1.formatAge)(revision.created_at),
|
|
207
|
+
`${revision.file_count} file(s)`,
|
|
208
|
+
(0, ui_1.formatBytes)(revision.size_bytes),
|
|
209
|
+
clip(revision.comment ?? '', 60),
|
|
210
|
+
revision.current ? 'current' : revision.restorable ? '' : 'files removed',
|
|
211
|
+
]));
|
|
212
|
+
(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`));
|
|
213
|
+
}
|
|
214
|
+
async function pagesRollback(args) {
|
|
215
|
+
const [, ref, raw] = args.words;
|
|
216
|
+
if (!ref || !raw) {
|
|
217
|
+
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>');
|
|
218
|
+
}
|
|
219
|
+
const revision = Number(raw);
|
|
220
|
+
if (!Number.isInteger(revision) || revision < 1) {
|
|
221
|
+
throw new errors_1.CliError(`A version number is a positive integer, got "${raw}"`, `The versions: xflow pages status ${ref}`);
|
|
222
|
+
}
|
|
223
|
+
const { page } = await (0, api_1.apiJson)(pagesClient(), `${pagePath(ref)}/rollback`, {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
body: { revision },
|
|
226
|
+
});
|
|
227
|
+
if (page.status === 'active')
|
|
228
|
+
(0, ui_1.ok)(`Version ${revision} is back on the address`);
|
|
229
|
+
else
|
|
230
|
+
(0, ui_1.warn)(`The page is switched off: version ${revision} is current and goes live when it is switched on`);
|
|
231
|
+
(0, ui_1.out)(page.url);
|
|
232
|
+
}
|
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/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> <folder|.zip|.html> [--slug name]
|
|
95
|
+
a new page with the site on it, live at once
|
|
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,58 @@ 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> <path> a new page with its first version: there are no
|
|
236
|
+
empty pages
|
|
237
|
+
--slug <name> the name in the address; without it the name is made
|
|
238
|
+
from the title, with a random tail
|
|
239
|
+
xflow pages deploy <page> <path> upload a new version
|
|
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)('<path>')} is a folder with index.html at the top, its .zip, or one .html file.
|
|
245
|
+
${(0, ui_1.bold)('<page>')} is the name from the address or the id. ${(0, ui_1.bold)('--json')} on list and status
|
|
246
|
+
prints the answer as JSON. A page belongs to the organization, not to a project, so there is
|
|
247
|
+
no ${(0, ui_1.bold)('--project')}: the key is picked as for every other command (${(0, ui_1.bold)('xflow help org')}).
|
|
248
|
+
|
|
249
|
+
${(0, ui_1.bold)('An upload is live at once.')} There is no draft and no publish step: anyone with the
|
|
250
|
+
address sees the new version within seconds. Uploading the same files again changes nothing.
|
|
251
|
+
The files of the last 5 versions are kept, so a rollback takes seconds and needs no upload.
|
|
252
|
+
|
|
253
|
+
What goes up is the built site: HTML, CSS, JS, images, fonts, PDF, audio and video. No code
|
|
254
|
+
runs on the platform side, and a folder with package.json is refused as the sources of a site:
|
|
255
|
+
build it and upload what the build writes. Dot entries are skipped. At most 5 MB zipped, 25 MB
|
|
256
|
+
unpacked and 1000 files.
|
|
257
|
+
|
|
258
|
+
The page is public, so a file holding a secret key is refused whole: every visitor would read
|
|
259
|
+
it. A function key is the exception: it opens that one function to every visitor, which is
|
|
260
|
+
what a form on a landing page needs. Search engines are asked not to index pages, so do not
|
|
261
|
+
promise SEO.
|
|
262
|
+
|
|
263
|
+
A page takes the key of the whole organization, the one ${(0, ui_1.bold)('xflow login')} issues. The
|
|
264
|
+
section is in beta: XFlow support switches it on for an organization. Switching a page off
|
|
265
|
+
and deleting it are for a person, in the platform interface.`,
|
|
205
266
|
update: `${(0, ui_1.bold)('xflow update')}: bring the CLI up to date
|
|
206
267
|
|
|
207
268
|
Installs the published version and, if anything changed, rewrites the platform
|
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.27.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.27.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,34 @@ 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" ./dist` makes a page with the built site on it and prints its
|
|
531
|
+
address: there are no empty pages. `--slug price-list` picks the name, otherwise it comes from
|
|
532
|
+
the title with a random tail.
|
|
533
|
+
- `xflow pages deploy <page> <folder|.zip|.html> -m "what changed"` uploads a new version.
|
|
534
|
+
- **Both are public the moment the command finishes**: no draft, no publish step. Upload when the
|
|
535
|
+
user wants the page out, and give them the address the CLI printed.
|
|
536
|
+
- `xflow pages status <page>` lists the versions, `xflow pages rollback <page> <N>` puts one back
|
|
537
|
+
in seconds; the files of the last 5 are kept.
|
|
538
|
+
- Static files only: HTML, CSS, JS, images, fonts, PDF, audio, video. No server code, database
|
|
539
|
+
or functions on a page. Upload what the build writes (`dist`), never a folder with
|
|
540
|
+
`package.json`. At most 5 MB zipped, 25 MB unpacked, 1000 files.
|
|
541
|
+
- Everything in a page is readable by every visitor. Never put a key, token or password into
|
|
542
|
+
it: a known key format gets the upload refused, anything else simply leaks. A function key is
|
|
543
|
+
the exception: a person issues it in the settings of the function, and a form on a page may
|
|
544
|
+
call that function with it. So can every visitor.
|
|
545
|
+
- Search engines are asked not to index pages: do not promise SEO.
|
|
546
|
+
- The MCP `pages` tool uploads one self-contained HTML document up to 150 KB.
|
|
547
|
+
- Pages take the key of the whole organization, the one `xflow login` issues. The section is in
|
|
548
|
+
beta, switched on per organization by XFlow support: that refusal is not yours to fix. Switching
|
|
549
|
+
a page off and deleting it are for a person, in the platform interface.
|
|
550
|
+
|
|
523
551
|
## Organizations and keys
|
|
524
552
|
|
|
525
553
|
One key per organization, stored side by side rather than replacing each other: `xflow org`
|