@getxflow/cli 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +41 -1
- package/dist/commands/skills.js +119 -21
- package/dist/commands/storage.js +370 -0
- package/dist/commands/update.js +6 -4
- package/dist/help.js +71 -2
- package/dist/state.js +36 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +170 -134
package/dist/bin.js
CHANGED
|
@@ -24,6 +24,7 @@ const org_1 = require("./commands/org");
|
|
|
24
24
|
const schedules_1 = require("./commands/schedules");
|
|
25
25
|
const skills_1 = require("./commands/skills");
|
|
26
26
|
const sources_1 = require("./commands/sources");
|
|
27
|
+
const storage_1 = require("./commands/storage");
|
|
27
28
|
const deploy_1 = require("./commands/deploy");
|
|
28
29
|
const update_1 = require("./commands/update");
|
|
29
30
|
async function run(args) {
|
|
@@ -113,7 +114,7 @@ async function run(args) {
|
|
|
113
114
|
await (0, env_1.envCheck)();
|
|
114
115
|
return;
|
|
115
116
|
}
|
|
116
|
-
if (second === undefined || second === 'list'
|
|
117
|
+
if (second === undefined || second === 'list') {
|
|
117
118
|
await (0, env_1.envList)();
|
|
118
119
|
return;
|
|
119
120
|
}
|
|
@@ -164,6 +165,20 @@ async function run(args) {
|
|
|
164
165
|
return;
|
|
165
166
|
}
|
|
166
167
|
throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, schema, query and migrate');
|
|
168
|
+
case 'storage':
|
|
169
|
+
if (second === 'push') {
|
|
170
|
+
await (0, storage_1.storagePush)(rest);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (second === 'rm' || second === 'remove') {
|
|
174
|
+
await (0, storage_1.storageRemove)(rest);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (second === undefined || second === 'ls' || second === 'list') {
|
|
178
|
+
await (0, storage_1.storageList)(rest);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
throw new errors_1.CliError(`Unknown command: storage ${second}`, 'Available: ls, push and rm');
|
|
167
182
|
case 'status':
|
|
168
183
|
await (0, sources_1.status)();
|
|
169
184
|
return;
|
|
@@ -201,6 +216,27 @@ function nudge() {
|
|
|
201
216
|
(0, ui_1.note)((0, ui_1.dim)(` xflow ${latest} is out, ${version_1.CLI_VERSION} is installed: run xflow update`));
|
|
202
217
|
(0, state_1.markNudged)(latest);
|
|
203
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The other staleness, and the one that costs more: the instructions an agent reads are
|
|
221
|
+
* older than the CLI answering it, and nothing about that is visible in the text. Louder
|
|
222
|
+
* than the notice above because it is wrong now rather than out of date soon, and it names
|
|
223
|
+
* the file, because a machine can hold a dozen copies and the wrong one gets edited.
|
|
224
|
+
*
|
|
225
|
+
* The command is skills --refresh, not update: what is out of step here is the copies, not
|
|
226
|
+
* the package, and refreshing needs no network, no npm and no rights on a global folder.
|
|
227
|
+
* The re-read is half the repair, so it is in the same breath as the command.
|
|
228
|
+
*/
|
|
229
|
+
function nudgeSkill() {
|
|
230
|
+
const stale = (0, skills_1.staleSkillCopy)(process.cwd());
|
|
231
|
+
if (stale === null)
|
|
232
|
+
return;
|
|
233
|
+
const key = `${stale} ${version_1.CLI_VERSION}`;
|
|
234
|
+
if (!(0, state_1.shouldNudgeSkill)(key))
|
|
235
|
+
return;
|
|
236
|
+
(0, ui_1.warn)(`${stale} is older than this CLI`);
|
|
237
|
+
(0, ui_1.note)((0, ui_1.dim)(' Run xflow skills --refresh, then read that file again: the refresh only writes it to disk'));
|
|
238
|
+
(0, state_1.markSkillNudged)(key);
|
|
239
|
+
}
|
|
204
240
|
async function main() {
|
|
205
241
|
const args = (0, args_1.parseArgs)(process.argv.slice(2));
|
|
206
242
|
if ((0, args_1.flagBool)(args, 'version')) {
|
|
@@ -246,6 +282,10 @@ async function main() {
|
|
|
246
282
|
finally {
|
|
247
283
|
if (!quiet)
|
|
248
284
|
nudge();
|
|
285
|
+
// Not behind the same flag: a copy out of step with the running package stays wrong
|
|
286
|
+
// whatever the command was, and when the package itself is the problem the copies
|
|
287
|
+
// match it and this says nothing anyway.
|
|
288
|
+
nudgeSkill();
|
|
249
289
|
}
|
|
250
290
|
}
|
|
251
291
|
main().then((code) => {
|
package/dist/commands/skills.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.replacePointer = replacePointer;
|
|
4
|
+
exports.copyIsStale = copyIsStale;
|
|
5
|
+
exports.staleSkillCopy = staleSkillCopy;
|
|
3
6
|
exports.installSkillQuietly = installSkillQuietly;
|
|
4
7
|
exports.skills = skills;
|
|
5
8
|
const node_fs_1 = require("node:fs");
|
|
@@ -8,6 +11,7 @@ const node_path_1 = require("node:path");
|
|
|
8
11
|
const args_1 = require("../args");
|
|
9
12
|
const errors_1 = require("../errors");
|
|
10
13
|
const prompt_1 = require("../prompt");
|
|
14
|
+
const update_1 = require("./update");
|
|
11
15
|
const ui_1 = require("../ui");
|
|
12
16
|
const AGENTS = [
|
|
13
17
|
{ id: 'claude', label: 'Claude Code', project: ['.claude', 'skills'], global: ['.claude', 'skills'] },
|
|
@@ -32,7 +36,8 @@ const POINTER = `${POINTER_MARKER}
|
|
|
32
36
|
This project is hosted on the XFlow platform and ships through the \`xflow\` CLI, not
|
|
33
37
|
through a git push. Read \`.agents/skills/xflow/SKILL.md\` before deploying, publishing,
|
|
34
38
|
rolling back, touching the database or migrations, cloud functions, schedules,
|
|
35
|
-
environment variables or production logs.
|
|
39
|
+
environment variables, file storage and uploads, connected accounts or production logs.
|
|
40
|
+
Command list: \`xflow help\`.
|
|
36
41
|
`;
|
|
37
42
|
/** The skill ships inside the package, next to dist. */
|
|
38
43
|
function skillSource() {
|
|
@@ -63,13 +68,43 @@ function writeIfChanged(path, content) {
|
|
|
63
68
|
(0, node_fs_1.writeFileSync)(path, content, 'utf-8');
|
|
64
69
|
return before === null ? 'new' : 'updated';
|
|
65
70
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Our own block put back in place, or null when the file has none.
|
|
73
|
+
*
|
|
74
|
+
* The block runs from the marker to the next heading of the same level, so whatever the
|
|
75
|
+
* user wrote around it survives. Appending once was enough while the text never changed;
|
|
76
|
+
* it does change, and a pointer that names half the sections is worse than an old CLI:
|
|
77
|
+
* the agent reads it every time, unlike the skill, which at least announces its own age.
|
|
78
|
+
*/
|
|
79
|
+
function replacePointer(text) {
|
|
80
|
+
const start = text.indexOf(POINTER_MARKER);
|
|
81
|
+
if (start < 0)
|
|
82
|
+
return null;
|
|
83
|
+
// Past our own heading: the newline before it sits at start + length, so the search
|
|
84
|
+
// begins after it and the first hit is somebody else's section.
|
|
85
|
+
const after = text.indexOf('\n## ', start + POINTER_MARKER.length + 1);
|
|
86
|
+
const tail = after < 0 ? '' : `\n${text.slice(after + 1)}`;
|
|
87
|
+
return `${text.slice(0, start)}${POINTER}${tail}`;
|
|
88
|
+
}
|
|
89
|
+
/** The file belongs to the user: created only on install, rewritten only where ours is. */
|
|
90
|
+
function writePointer(base, create) {
|
|
68
91
|
const path = (0, node_path_1.join)(base, 'AGENTS.md');
|
|
69
92
|
const before = (0, node_fs_1.existsSync)(path) ? (0, node_fs_1.readFileSync)(path, 'utf-8') : null;
|
|
70
|
-
|
|
93
|
+
let next;
|
|
94
|
+
if (before === null) {
|
|
95
|
+
if (!create)
|
|
96
|
+
return null;
|
|
97
|
+
next = POINTER;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const replaced = replacePointer(before);
|
|
101
|
+
if (replaced === null && !create)
|
|
102
|
+
return null;
|
|
103
|
+
next = replaced ?? `${before.replace(/\s*$/, '')}\n\n${POINTER}`;
|
|
104
|
+
}
|
|
105
|
+
if (before === next)
|
|
71
106
|
return null;
|
|
72
|
-
(0, node_fs_1.writeFileSync)(path,
|
|
107
|
+
(0, node_fs_1.writeFileSync)(path, next, 'utf-8');
|
|
73
108
|
return { path, state: before === null ? 'new' : 'updated' };
|
|
74
109
|
}
|
|
75
110
|
function install(base, ids, global) {
|
|
@@ -93,12 +128,79 @@ function install(base, ids, global) {
|
|
|
93
128
|
}
|
|
94
129
|
// Project-level only: these have no global equivalents.
|
|
95
130
|
if (!global) {
|
|
96
|
-
const pointer =
|
|
131
|
+
const pointer = writePointer(base, true);
|
|
97
132
|
if (pointer)
|
|
98
133
|
changes.push(pointer);
|
|
99
134
|
}
|
|
100
135
|
return changes;
|
|
101
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Every place a copy can live, deduplicated, project copies first. Two consumers read
|
|
139
|
+
* this list: the refresh below and the staleness check the nudge runs, and they have to
|
|
140
|
+
* agree on what a copy is, or one of them would report the other's work as undone.
|
|
141
|
+
*
|
|
142
|
+
* The Cursor rule is not one of the agent paths and not a plain copy either: it carries
|
|
143
|
+
* the same body under its own header, hence the flag rather than a second list.
|
|
144
|
+
*/
|
|
145
|
+
function copyPaths(base) {
|
|
146
|
+
const paths = [];
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const add = (path, rule) => {
|
|
149
|
+
if (seen.has(path))
|
|
150
|
+
return;
|
|
151
|
+
seen.add(path);
|
|
152
|
+
paths.push({ path, rule });
|
|
153
|
+
};
|
|
154
|
+
for (const agent of AGENTS)
|
|
155
|
+
add(skillPath(base, agent, false), false);
|
|
156
|
+
add((0, node_path_1.join)(base, ...CURSOR_RULE), true);
|
|
157
|
+
// A source checkout holds the skill as it is being written, not as it was published,
|
|
158
|
+
// while the home copies are read from every project on this machine. Refreshing them
|
|
159
|
+
// from here would hand a draft to every agent on the machine, and reporting them stale
|
|
160
|
+
// would nag for ever with a command we do not want run. So from a checkout the CLI
|
|
161
|
+
// minds this folder and nothing else, and both consumers of the list agree because it
|
|
162
|
+
// is decided here rather than twice.
|
|
163
|
+
if ((0, update_1.classifyPath)(__dirname) !== 'checkout') {
|
|
164
|
+
for (const agent of AGENTS)
|
|
165
|
+
add(skillPath(base, agent, true), false);
|
|
166
|
+
}
|
|
167
|
+
return paths;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Line endings are not a difference. An editor that saved a copy as CRLF would otherwise
|
|
171
|
+
* leave the machine announcing a stale skill for ever, and the refresh that "fixes" it
|
|
172
|
+
* would change nothing visible. Same normalisation the template gate uses.
|
|
173
|
+
*/
|
|
174
|
+
function copyIsStale(expected, actual) {
|
|
175
|
+
return expected.replace(/\r\n/g, '\n') !== actual.replace(/\r\n/g, '\n');
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The first installed copy whose text is not what this CLI ships, or null when every
|
|
179
|
+
* copy matches. Compared by content rather than by a version stamp: a release that left
|
|
180
|
+
* the skill alone must not declare identical copies stale, and copies old enough to
|
|
181
|
+
* carry no stamp still have to be recognised.
|
|
182
|
+
*
|
|
183
|
+
* Never throws: this runs after a command that already succeeded, and an unreadable file
|
|
184
|
+
* somewhere in a home folder is not a reason to fail it.
|
|
185
|
+
*/
|
|
186
|
+
function staleSkillCopy(base) {
|
|
187
|
+
try {
|
|
188
|
+
const present = copyPaths(base).filter((copy) => (0, node_fs_1.existsSync)(copy.path));
|
|
189
|
+
if (present.length === 0)
|
|
190
|
+
return null;
|
|
191
|
+
const skill = skillSource();
|
|
192
|
+
const rule = cursorRule(skill);
|
|
193
|
+
for (const copy of present) {
|
|
194
|
+
const expected = copy.rule ? rule : skill;
|
|
195
|
+
if (copyIsStale(expected, (0, node_fs_1.readFileSync)(copy.path, 'utf-8')))
|
|
196
|
+
return copy.path;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
102
204
|
/**
|
|
103
205
|
* Rewrite every copy that already exists and create none: this is what xflow update
|
|
104
206
|
* calls, and it runs wherever the user happened to stand. Installing defaults here
|
|
@@ -106,24 +208,20 @@ function install(base, ids, global) {
|
|
|
106
208
|
*/
|
|
107
209
|
function refreshInstalled(base) {
|
|
108
210
|
const skill = skillSource();
|
|
211
|
+
const rule = cursorRule(skill);
|
|
109
212
|
const changes = [];
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
seen.add(path);
|
|
115
|
-
const state = writeIfChanged(path, content);
|
|
213
|
+
for (const copy of copyPaths(base)) {
|
|
214
|
+
if (!(0, node_fs_1.existsSync)(copy.path))
|
|
215
|
+
continue;
|
|
216
|
+
const state = writeIfChanged(copy.path, copy.rule ? rule : skill);
|
|
116
217
|
if (state)
|
|
117
|
-
changes.push({ path, state });
|
|
118
|
-
};
|
|
119
|
-
for (const agent of AGENTS) {
|
|
120
|
-
put(skillPath(base, agent, false), skill);
|
|
121
|
-
put(skillPath(base, agent, true), skill);
|
|
122
|
-
// The Cursor rule is not one of the agent paths: without this line it keeps the
|
|
123
|
-
// old text next to a fresh SKILL.md, and the two drift apart unnoticed.
|
|
124
|
-
if (agent.rule === true)
|
|
125
|
-
put((0, node_path_1.join)(base, ...CURSOR_RULE), cursorRule(skill));
|
|
218
|
+
changes.push({ path: copy.path, state });
|
|
126
219
|
}
|
|
220
|
+
// AGENTS.md is read on every task, unlike the lazily loaded skill, so a stale pointer
|
|
221
|
+
// there misleads more often than a stale skill. Rewritten, never created.
|
|
222
|
+
const pointer = writePointer(base, false);
|
|
223
|
+
if (pointer)
|
|
224
|
+
changes.push(pointer);
|
|
127
225
|
return changes;
|
|
128
226
|
}
|
|
129
227
|
/** Defaults plus whatever is already installed, so an update run refreshes everything. */
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.contentTypeFor = contentTypeFor;
|
|
4
|
+
exports.planBatches = planBatches;
|
|
5
|
+
exports.caseCollisions = caseCollisions;
|
|
6
|
+
exports.storageList = storageList;
|
|
7
|
+
exports.storagePush = storagePush;
|
|
8
|
+
exports.storageRemove = storageRemove;
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const node_stream_1 = require("node:stream");
|
|
12
|
+
const api_1 = require("../api");
|
|
13
|
+
const args_1 = require("../args");
|
|
14
|
+
const config_1 = require("../config");
|
|
15
|
+
const errors_1 = require("../errors");
|
|
16
|
+
const session_1 = require("../session");
|
|
17
|
+
const ui_1 = require("../ui");
|
|
18
|
+
/**
|
|
19
|
+
* File storage of the project: the place for heavy static assets of the
|
|
20
|
+
* application.
|
|
21
|
+
*
|
|
22
|
+
* Sources are capped at 10 MB and are rebuilt on every deploy, so photos, video
|
|
23
|
+
* and PDFs do not belong there. Files here live beside the versions instead:
|
|
24
|
+
* they survive a build, they are metered against the plan of the organization,
|
|
25
|
+
* and their addresses do not change when the application is rebuilt.
|
|
26
|
+
*/
|
|
27
|
+
/** Files per batch, and bytes per batch: whichever comes first closes it. */
|
|
28
|
+
const BATCH_FILES = 10;
|
|
29
|
+
const BATCH_BYTES = 20 * 1024 * 1024;
|
|
30
|
+
/** Uploads running at the same time. */
|
|
31
|
+
const PARALLEL_UPLOADS = 4;
|
|
32
|
+
/** Below this a file is sent as one buffer; above it, streamed off disk. */
|
|
33
|
+
const STREAM_OVER_BYTES = 16 * 1024 * 1024;
|
|
34
|
+
const UPLOAD_TIMEOUT_MS = 10 * 60_000;
|
|
35
|
+
const CONTENT_TYPES = {
|
|
36
|
+
'.png': 'image/png',
|
|
37
|
+
'.jpg': 'image/jpeg',
|
|
38
|
+
'.jpeg': 'image/jpeg',
|
|
39
|
+
'.gif': 'image/gif',
|
|
40
|
+
'.webp': 'image/webp',
|
|
41
|
+
'.avif': 'image/avif',
|
|
42
|
+
'.svg': 'image/svg+xml',
|
|
43
|
+
'.ico': 'image/x-icon',
|
|
44
|
+
'.mp4': 'video/mp4',
|
|
45
|
+
'.webm': 'video/webm',
|
|
46
|
+
'.mov': 'video/quicktime',
|
|
47
|
+
'.mp3': 'audio/mpeg',
|
|
48
|
+
'.wav': 'audio/wav',
|
|
49
|
+
'.ogg': 'audio/ogg',
|
|
50
|
+
'.pdf': 'application/pdf',
|
|
51
|
+
'.json': 'application/json',
|
|
52
|
+
'.csv': 'text/csv',
|
|
53
|
+
'.txt': 'text/plain',
|
|
54
|
+
'.md': 'text/markdown',
|
|
55
|
+
'.html': 'text/html',
|
|
56
|
+
'.css': 'text/css',
|
|
57
|
+
'.js': 'text/javascript',
|
|
58
|
+
'.woff': 'font/woff',
|
|
59
|
+
'.woff2': 'font/woff2',
|
|
60
|
+
'.ttf': 'font/ttf',
|
|
61
|
+
'.otf': 'font/otf',
|
|
62
|
+
'.zip': 'application/zip',
|
|
63
|
+
};
|
|
64
|
+
/** Unknown extensions go up as bytes: the browser then trusts the file name. */
|
|
65
|
+
function contentTypeFor(name) {
|
|
66
|
+
return CONTENT_TYPES[(0, node_path_1.extname)(name).toLowerCase()] ?? 'application/octet-stream';
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Files of a directory, recursively.
|
|
70
|
+
*
|
|
71
|
+
* A walker of its own instead of the one the sources use: that one reads every
|
|
72
|
+
* file into memory to hash it and rewrites CRLF into LF for anything without a
|
|
73
|
+
* NUL byte early on, which would quietly damage SVG, CSV and subtitles on the
|
|
74
|
+
* way to the bucket. It also obeys .xflowignore, and a media folder is exactly
|
|
75
|
+
* what one is asked to put there so it stays out of the 10 MB archive.
|
|
76
|
+
*
|
|
77
|
+
* 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.
|
|
79
|
+
*/
|
|
80
|
+
function collectMedia(root, prefix = '') {
|
|
81
|
+
const found = [];
|
|
82
|
+
for (const entry of (0, node_fs_1.readdirSync)(root, { withFileTypes: true })) {
|
|
83
|
+
if (entry.name.startsWith('.') || entry.isSymbolicLink())
|
|
84
|
+
continue;
|
|
85
|
+
if (entry.name === 'Thumbs.db')
|
|
86
|
+
continue;
|
|
87
|
+
const absolute = (0, node_path_1.join)(root, entry.name);
|
|
88
|
+
const remote = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
found.push(...collectMedia(absolute, remote));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!entry.isFile())
|
|
94
|
+
continue;
|
|
95
|
+
found.push({ absolute, remote, size: (0, node_fs_1.statSync)(absolute).size });
|
|
96
|
+
}
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Split the upload into batches.
|
|
101
|
+
*
|
|
102
|
+
* The window where an object is in the bucket but not yet recorded lasts from
|
|
103
|
+
* the PUT to the confirm, and everything in that window is invisible to the
|
|
104
|
+
* project while still taking up the quota. Ten files or twenty megabytes keep
|
|
105
|
+
* the window that size no matter whether these are icons or video: a file
|
|
106
|
+
* heavier than the limit goes up alone.
|
|
107
|
+
*/
|
|
108
|
+
function planBatches(files, maxFiles = BATCH_FILES, maxBytes = BATCH_BYTES) {
|
|
109
|
+
const batches = [];
|
|
110
|
+
let batch = [];
|
|
111
|
+
let bytes = 0;
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
if (batch.length > 0 && (batch.length >= maxFiles || bytes + file.size > maxBytes)) {
|
|
114
|
+
batches.push(batch);
|
|
115
|
+
batch = [];
|
|
116
|
+
bytes = 0;
|
|
117
|
+
}
|
|
118
|
+
batch.push(file);
|
|
119
|
+
bytes += file.size;
|
|
120
|
+
}
|
|
121
|
+
if (batch.length > 0)
|
|
122
|
+
batches.push(batch);
|
|
123
|
+
return batches;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Paths that differ only in case.
|
|
127
|
+
*
|
|
128
|
+
* The bucket tells them apart and Windows does not, so a folder that looks like
|
|
129
|
+
* one file locally becomes two files on the platform, and the application asks
|
|
130
|
+
* for whichever the code spells.
|
|
131
|
+
*/
|
|
132
|
+
function caseCollisions(files) {
|
|
133
|
+
const seen = new Map();
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
const key = file.remote.toLowerCase();
|
|
136
|
+
seen.set(key, [...(seen.get(key) ?? []), file.remote]);
|
|
137
|
+
}
|
|
138
|
+
return [...seen.values()].filter((group) => group.length > 1);
|
|
139
|
+
}
|
|
140
|
+
async function fetchPage(client, projectId, params) {
|
|
141
|
+
const query = new URLSearchParams(params).toString();
|
|
142
|
+
return (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/storage?${query}`);
|
|
143
|
+
}
|
|
144
|
+
/** Everything under a folder, following the cursor to the end. */
|
|
145
|
+
async function fetchAll(client, projectId, folder) {
|
|
146
|
+
const files = [];
|
|
147
|
+
let cursor = null;
|
|
148
|
+
do {
|
|
149
|
+
const params = { limit: '500' };
|
|
150
|
+
if (folder)
|
|
151
|
+
params.folder = folder;
|
|
152
|
+
if (cursor)
|
|
153
|
+
params.cursor = cursor;
|
|
154
|
+
const page = await fetchPage(client, projectId, params);
|
|
155
|
+
files.push(...page.files);
|
|
156
|
+
cursor = page.next_cursor;
|
|
157
|
+
} while (cursor);
|
|
158
|
+
return files;
|
|
159
|
+
}
|
|
160
|
+
async function storageList(args) {
|
|
161
|
+
const { config } = (0, config_1.requireProject)();
|
|
162
|
+
const client = (0, session_1.connect)(config);
|
|
163
|
+
const folder = args.words[1] ?? '';
|
|
164
|
+
const files = await fetchAll(client, config.projectId, folder);
|
|
165
|
+
if ((0, args_1.flagBool)(args, 'json')) {
|
|
166
|
+
(0, ui_1.out)(JSON.stringify({ files }, null, 2));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (files.length === 0) {
|
|
170
|
+
(0, ui_1.note)(folder ? `No files under ${folder}` : 'The project has no files in storage');
|
|
171
|
+
(0, ui_1.note)((0, ui_1.dim)(' To upload a folder: xflow storage push ./media --to media'));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
(0, ui_1.table)(files.map((file) => [file.path, (0, ui_1.formatBytes)(file.size), file.url]));
|
|
175
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${files.length} file(s), ${(0, ui_1.formatBytes)(files.reduce((sum, f) => sum + f.size, 0))}`));
|
|
176
|
+
}
|
|
177
|
+
/** PUT the bytes straight into the bucket: they never pass through the platform. */
|
|
178
|
+
async function putObject(file, ticket) {
|
|
179
|
+
const contentType = contentTypeFor(file.remote);
|
|
180
|
+
// Small files go as one buffer, big ones off the disk: 200 MB in memory is a
|
|
181
|
+
// price nothing here pays for.
|
|
182
|
+
const body = file.size > STREAM_OVER_BYTES
|
|
183
|
+
? node_stream_1.Readable.toWeb((0, node_fs_1.createReadStream)(file.absolute))
|
|
184
|
+
: (0, node_fs_1.readFileSync)(file.absolute);
|
|
185
|
+
// `duplex` is required by Node to send a stream as a request body and is not
|
|
186
|
+
// in the type of fetch, hence the cast.
|
|
187
|
+
const init = {
|
|
188
|
+
method: 'PUT',
|
|
189
|
+
body,
|
|
190
|
+
headers: { 'Content-Type': contentType, 'Content-Length': String(file.size) },
|
|
191
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
192
|
+
duplex: 'half',
|
|
193
|
+
};
|
|
194
|
+
const response = await fetch(ticket.upload_url, init);
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
throw new errors_1.CliError(`${file.remote}: the bucket rejected the upload (${response.status})`, 'Run the command again: what was uploaded before is kept');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Run tasks a few at a time, keeping the order of the results. */
|
|
200
|
+
async function inParallel(items, limit, task) {
|
|
201
|
+
const results = new Array(items.length);
|
|
202
|
+
let next = 0;
|
|
203
|
+
async function worker() {
|
|
204
|
+
while (next < items.length) {
|
|
205
|
+
const index = next++;
|
|
206
|
+
results[index] = await task(items[index]);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
210
|
+
return results;
|
|
211
|
+
}
|
|
212
|
+
async function storagePush(args) {
|
|
213
|
+
const { config } = (0, config_1.requireProject)();
|
|
214
|
+
const client = (0, session_1.connect)(config);
|
|
215
|
+
const source = args.words[1];
|
|
216
|
+
if (!source) {
|
|
217
|
+
throw new errors_1.CliError('A folder or a file is required', 'For example: xflow storage push ./media --to media');
|
|
218
|
+
}
|
|
219
|
+
const absolute = (0, node_path_1.resolve)(source);
|
|
220
|
+
let stat;
|
|
221
|
+
try {
|
|
222
|
+
stat = (0, node_fs_1.statSync)(absolute);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
throw new errors_1.CliError(`No such file or folder: ${source}`);
|
|
226
|
+
}
|
|
227
|
+
const target = ((0, args_1.flagString)(args, 'to') ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
228
|
+
const local = stat.isDirectory()
|
|
229
|
+
? collectMedia(absolute)
|
|
230
|
+
: [{ absolute, remote: (0, node_path_1.basename)(absolute), size: stat.size }];
|
|
231
|
+
if (local.length === 0)
|
|
232
|
+
throw new errors_1.CliError(`Nothing to upload in ${source}`);
|
|
233
|
+
const files = local
|
|
234
|
+
.map((file) => ({ ...file, remote: target ? `${target}/${file.remote}` : file.remote }))
|
|
235
|
+
.sort((a, b) => a.remote.localeCompare(b.remote));
|
|
236
|
+
for (const group of caseCollisions(files)) {
|
|
237
|
+
(0, ui_1.warn)(`Names that differ only in case: ${group.join(', ')}`);
|
|
238
|
+
(0, ui_1.note)((0, ui_1.dim)(' The platform keeps them as separate files'));
|
|
239
|
+
}
|
|
240
|
+
const replace = (0, args_1.flagBool)(args, 'replace');
|
|
241
|
+
const asJson = (0, args_1.flagBool)(args, 'json');
|
|
242
|
+
// What is already there decides what to send: the same run repeated has to be
|
|
243
|
+
// cheap and quiet, not a pile of conflicts.
|
|
244
|
+
const known = new Map((await fetchAll(client, config.projectId, target)).map((f) => [f.path, f]));
|
|
245
|
+
const outcome = { uploaded: [], skipped: [], failed: [] };
|
|
246
|
+
const pending = [];
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
const remote = known.get(file.remote);
|
|
249
|
+
if (!remote) {
|
|
250
|
+
pending.push(file);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (remote.size === file.size) {
|
|
254
|
+
outcome.skipped.push({ path: file.remote, reason: 'already there' });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (!replace) {
|
|
258
|
+
outcome.skipped.push({ path: file.remote, reason: 'differs, kept as is' });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
pending.push(file);
|
|
262
|
+
}
|
|
263
|
+
if (pending.length === 0) {
|
|
264
|
+
report(outcome, asJson);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const batches = planBatches(pending);
|
|
268
|
+
if (!asJson) {
|
|
269
|
+
const bytes = pending.reduce((sum, file) => sum + file.size, 0);
|
|
270
|
+
(0, ui_1.step)(`Uploading ${pending.length} file(s), ${(0, ui_1.formatBytes)(bytes)}`);
|
|
271
|
+
}
|
|
272
|
+
for (const batch of batches) {
|
|
273
|
+
const { files: tickets } = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage/upload-url`, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
body: {
|
|
276
|
+
files: batch.map((file) => ({
|
|
277
|
+
path: file.remote,
|
|
278
|
+
content_type: contentTypeFor(file.remote),
|
|
279
|
+
size: file.size,
|
|
280
|
+
})),
|
|
281
|
+
overwrite: replace,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const byPath = new Map(tickets.map((ticket) => [ticket.path, ticket]));
|
|
285
|
+
const done = [];
|
|
286
|
+
const results = await inParallel(batch, PARALLEL_UPLOADS, async (file) => {
|
|
287
|
+
const ticket = byPath.get(file.remote);
|
|
288
|
+
if (!ticket)
|
|
289
|
+
return { file, error: 'the platform did not return an upload address' };
|
|
290
|
+
try {
|
|
291
|
+
await putObject(file, ticket);
|
|
292
|
+
return { file, ticket };
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
return { file, error: e instanceof Error ? e.message : String(e) };
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
for (const result of results) {
|
|
299
|
+
if ('ticket' in result)
|
|
300
|
+
done.push({ file: result.file, ticket: result.ticket });
|
|
301
|
+
else
|
|
302
|
+
outcome.failed.push({ path: result.file.remote, reason: result.error });
|
|
303
|
+
}
|
|
304
|
+
if (done.length === 0)
|
|
305
|
+
continue;
|
|
306
|
+
// Confirm right after the batch, not at the end: an object that is in the
|
|
307
|
+
// bucket without a record is invisible to the project and still takes up
|
|
308
|
+
// the quota.
|
|
309
|
+
const confirmed = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage/confirm`, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
body: { files: done.map(({ ticket }) => ({ s3_key: ticket.s3_key })) },
|
|
312
|
+
});
|
|
313
|
+
outcome.uploaded.push(...confirmed.files.map((file) => ({ path: file.path, url: file.url, size: file.size })));
|
|
314
|
+
for (const failure of confirmed.failed) {
|
|
315
|
+
const owner = done.find(({ ticket }) => ticket.s3_key === failure.s3_key);
|
|
316
|
+
outcome.failed.push({ path: owner?.file.remote ?? failure.s3_key, reason: failure.error });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
report(outcome, asJson);
|
|
320
|
+
}
|
|
321
|
+
function report(outcome, asJson) {
|
|
322
|
+
if (asJson) {
|
|
323
|
+
(0, ui_1.out)(JSON.stringify(outcome, null, 2));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (outcome.uploaded.length > 0) {
|
|
327
|
+
(0, ui_1.table)(outcome.uploaded.map((file) => [file.path, (0, ui_1.formatBytes)(file.size), file.url]));
|
|
328
|
+
}
|
|
329
|
+
const parts = [`uploaded ${outcome.uploaded.length}`];
|
|
330
|
+
if (outcome.skipped.length > 0)
|
|
331
|
+
parts.push(`skipped ${outcome.skipped.length}`);
|
|
332
|
+
if (outcome.failed.length > 0)
|
|
333
|
+
parts.push(`failed ${outcome.failed.length}`);
|
|
334
|
+
if (outcome.failed.length > 0) {
|
|
335
|
+
for (const file of outcome.failed)
|
|
336
|
+
(0, ui_1.warn)(`${file.path}: ${file.reason}`);
|
|
337
|
+
throw new errors_1.CliError(parts.join(', '), 'Run the command again: what went up is kept and skipped');
|
|
338
|
+
}
|
|
339
|
+
(0, ui_1.ok)(parts.join(', '));
|
|
340
|
+
if (outcome.uploaded.length > 0) {
|
|
341
|
+
(0, ui_1.note)((0, ui_1.dim)(' Addresses are permanent: replacing a file keeps its address'));
|
|
342
|
+
(0, ui_1.note)((0, ui_1.dim)(' The whole list in one piece: xflow storage ls --json'));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async function storageRemove(args) {
|
|
346
|
+
const { config } = (0, config_1.requireProject)();
|
|
347
|
+
const client = (0, session_1.connect)(config);
|
|
348
|
+
const folder = (0, args_1.flagString)(args, 'folder');
|
|
349
|
+
const url = args.words[1];
|
|
350
|
+
if ((!url && !folder) || (url && folder)) {
|
|
351
|
+
throw new errors_1.CliError('Either the address of a file or --folder is required', 'For example: xflow storage rm https://app.getxflow.com/api/storage/files/…/view, or xflow storage rm --folder photos --yes');
|
|
352
|
+
}
|
|
353
|
+
const query = new URLSearchParams(url ? { url } : { folder: folder });
|
|
354
|
+
if ((0, args_1.flagBool)(args, 'yes'))
|
|
355
|
+
query.set('confirm', 'true');
|
|
356
|
+
let result;
|
|
357
|
+
try {
|
|
358
|
+
result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage?${query.toString()}`, { method: 'DELETE' });
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
// The platform counts what a folder holds and refuses until the answer is
|
|
362
|
+
// seen. Said in the words of this command, not of the API.
|
|
363
|
+
if (e instanceof api_1.ApiError && e.code === 'confirm_required') {
|
|
364
|
+
throw new errors_1.CliError(e.message, `To delete them anyway: xflow storage rm --folder ${folder} --yes`);
|
|
365
|
+
}
|
|
366
|
+
throw e;
|
|
367
|
+
}
|
|
368
|
+
(0, ui_1.ok)(`${(0, ui_1.bold)(String(result.deleted))} file(s) deleted`);
|
|
369
|
+
(0, ui_1.note)((0, ui_1.dim)(' There is no undo and no copy on the platform side'));
|
|
370
|
+
}
|
package/dist/commands/update.js
CHANGED
|
@@ -150,10 +150,12 @@ function update() {
|
|
|
150
150
|
(0, ui_1.note)((0, ui_1.dim)(' Check what is on disk now: xflow --version'));
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
|
-
if (after === version_1.CLI_VERSION)
|
|
153
|
+
if (after === version_1.CLI_VERSION)
|
|
154
154
|
(0, ui_1.ok)(`xflow ${after} is the latest version`);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
else
|
|
156
|
+
(0, ui_1.ok)(`xflow ${version_1.CLI_VERSION} → ${after}`);
|
|
157
|
+
// Also when the version did not move: copies drift on their own, from a global install
|
|
158
|
+
// made by npm directly or a folder that never saw this command. The refresh writes
|
|
159
|
+
// nothing where the text already matches, so the quiet case stays quiet.
|
|
158
160
|
refreshSkill(root);
|
|
159
161
|
}
|