@skrr-ai/cli 0.1.87 → 0.1.88
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/commands/agents/chat.js +27 -1
- package/dist/commands/computer/index.d.ts +19 -0
- package/dist/commands/computer/index.js +37 -0
- package/dist/commands/labels/create.d.ts +1 -0
- package/dist/commands/labels/create.js +2 -0
- package/dist/commands/labels/restore.d.ts +1 -0
- package/dist/commands/labels/restore.js +6 -3
- package/dist/commands/labels/update.d.ts +1 -0
- package/dist/commands/labels/update.js +2 -0
- package/dist/commands/machines/computer-adoption.d.ts +15 -0
- package/dist/commands/machines/computer-adoption.js +33 -0
- package/dist/commands/machines/dedicated/archive.d.ts +26 -0
- package/dist/commands/machines/dedicated/archive.js +113 -0
- package/dist/commands/machines/dedicated/audit.d.ts +25 -0
- package/dist/commands/machines/dedicated/audit.js +113 -0
- package/dist/commands/machines/dedicated/computer.d.ts +23 -0
- package/dist/commands/machines/dedicated/computer.js +64 -0
- package/dist/commands/machines/dedicated/cp.d.ts +2 -0
- package/dist/commands/machines/dedicated/cp.js +74 -2
- package/dist/commands/machines/dedicated/git-status.d.ts +15 -0
- package/dist/commands/machines/dedicated/git-status.js +56 -0
- package/dist/commands/machines/dedicated/index.js +15 -1
- package/dist/commands/machines/dedicated/ls.d.ts +27 -0
- package/dist/commands/machines/dedicated/ls.js +100 -0
- package/dist/commands/machines/dedicated/mkdir.d.ts +18 -0
- package/dist/commands/machines/dedicated/mkdir.js +57 -0
- package/dist/commands/machines/dedicated/mv.d.ts +24 -0
- package/dist/commands/machines/dedicated/mv.js +83 -0
- package/dist/commands/machines/dedicated/rm.d.ts +26 -0
- package/dist/commands/machines/dedicated/rm.js +100 -0
- package/dist/commands/machines/dedicated/search.d.ts +24 -0
- package/dist/commands/machines/dedicated/search.js +78 -0
- package/dist/commands/machines/dedicated/stat.d.ts +22 -0
- package/dist/commands/machines/dedicated/stat.js +92 -0
- package/dist/commands/machines/hosted/index.js +1 -1
- package/dist/commands/machines/hosted/list.js +1 -1
- package/dist/commands/machines/hosted/start.js +1 -1
- package/dist/commands/machines/services/declare.d.ts +24 -0
- package/dist/commands/machines/services/declare.js +73 -0
- package/dist/commands/machines/services/index.d.ts +15 -0
- package/dist/commands/machines/services/index.js +31 -0
- package/dist/commands/machines/services/ls.d.ts +17 -0
- package/dist/commands/machines/services/ls.js +60 -0
- package/dist/commands/machines/services/withdraw.d.ts +19 -0
- package/dist/commands/machines/services/withdraw.js +47 -0
- package/dist/commands/machines/share.d.ts +29 -0
- package/dist/commands/machines/share.js +100 -0
- package/dist/commands/machines/shared.d.ts +19 -0
- package/dist/commands/machines/shared.js +62 -0
- package/dist/commands/machines/shares.d.ts +16 -0
- package/dist/commands/machines/shares.js +69 -0
- package/dist/commands/machines/unshare.d.ts +19 -0
- package/dist/commands/machines/unshare.js +61 -0
- package/dist/commands/tasks/labels/create.d.ts +1 -0
- package/dist/commands/tasks/labels/create.js +3 -0
- package/dist/commands/views/create.d.ts +1 -0
- package/dist/commands/views/create.js +5 -0
- package/dist/lib/agent-home-workspace.d.ts +7 -0
- package/dist/lib/agent-home-workspace.js +147 -0
- package/dist/lib/computer-adoption.d.ts +55 -0
- package/dist/lib/computer-adoption.js +67 -0
- package/dist/lib/computer-consent.d.ts +13 -0
- package/dist/lib/computer-consent.js +33 -0
- package/dist/lib/computer-files.d.ts +239 -0
- package/dist/lib/computer-files.js +707 -0
- package/dist/lib/dedicated-copy.d.ts +45 -9
- package/dist/lib/dedicated-copy.js +141 -44
- package/dist/lib/dedicated-machines.d.ts +49 -0
- package/dist/lib/dedicated-machines.js +97 -6
- package/dist/lib/label-scope.d.ts +12 -0
- package/dist/lib/label-scope.js +15 -1
- package/dist/lib/machine-audit.d.ts +34 -0
- package/dist/lib/machine-audit.js +50 -0
- package/dist/lib/machine-grants.d.ts +70 -0
- package/dist/lib/machine-grants.js +47 -0
- package/dist/lib/machine-services.d.ts +64 -0
- package/dist/lib/machine-services.js +60 -0
- package/dist/lib/task-view-render.d.ts +2 -0
- package/dist/lib/task-view-render.js +1 -1
- package/dist/lib/views/vocabulary.d.ts +1 -1
- package/dist/lib/views/vocabulary.js +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.d.ts +955 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.js +1070 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.js +7 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.d.ts +955 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.js +1051 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.js +7 -0
- package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +22143 -20728
- package/dist/node_modules/@skrr-ai/data-provider/package.json +1 -1
- package/oclif.manifest.json +11550 -9963
- package/package.json +2 -2
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DedicatedFileGenerationUnavailableError = void 0;
|
|
4
|
+
exports.resolveDedicatedFileGeneration = resolveDedicatedFileGeneration;
|
|
5
|
+
exports.listComputerFiles = listComputerFiles;
|
|
6
|
+
exports.statComputerFile = statComputerFile;
|
|
7
|
+
exports.searchComputerFiles = searchComputerFiles;
|
|
8
|
+
exports.gitStatusComputerFiles = gitStatusComputerFiles;
|
|
9
|
+
exports.mkdirComputerFile = mkdirComputerFile;
|
|
10
|
+
exports.moveComputerFile = moveComputerFile;
|
|
11
|
+
exports.deleteComputerFile = deleteComputerFile;
|
|
12
|
+
exports.prepareComputerFileArchive = prepareComputerFileArchive;
|
|
13
|
+
exports.readComputerFileArchiveChunk = readComputerFileArchiveChunk;
|
|
14
|
+
exports.releaseComputerFileArchive = releaseComputerFileArchive;
|
|
15
|
+
exports.computerFileArchiveDestination = computerFileArchiveDestination;
|
|
16
|
+
exports.downloadComputerFileArchive = downloadComputerFileArchive;
|
|
17
|
+
exports.computerFileTagString = computerFileTagString;
|
|
18
|
+
exports.parseComputerFileTag = parseComputerFileTag;
|
|
19
|
+
exports.computerFileModeString = computerFileModeString;
|
|
20
|
+
exports.formatComputerFileSize = formatComputerFileSize;
|
|
21
|
+
exports.computerFileEntryName = computerFileEntryName;
|
|
22
|
+
exports.computerFileSearchHitLine = computerFileSearchHitLine;
|
|
23
|
+
exports.computerFileSearchLines = computerFileSearchLines;
|
|
24
|
+
exports.computerFileGitStatusLines = computerFileGitStatusLines;
|
|
25
|
+
exports.computerFileRefusalBody = computerFileRefusalBody;
|
|
26
|
+
exports.computerFileRefusalInfo = computerFileRefusalInfo;
|
|
27
|
+
exports.describeComputerFileRefusal = describeComputerFileRefusal;
|
|
28
|
+
exports.describeComputerFileFailure = describeComputerFileFailure;
|
|
29
|
+
/**
|
|
30
|
+
* computer-files.ts — the computer's file surface from the CLI:
|
|
31
|
+
* `skrr machines dedicated ls|stat|search|git-status|mkdir|mv|rm|archive`, each a POST to
|
|
32
|
+
* `/api/machines/:machineId/files/<op>` (design §6.3; routes OSK-13455; CLI
|
|
33
|
+
* parity OSK-13463). `read` and the chunked `write` stay in
|
|
34
|
+
* dedicated-machines.ts behind `cp`.
|
|
35
|
+
*
|
|
36
|
+
* Every call is an `apiFetch` against a LITERAL path: the parity reader
|
|
37
|
+
* (cli/src/__tests__/helpers/api-surface.ts) resolves which routes a lib
|
|
38
|
+
* reaches by reading the literal at the call site, and a path composed inside
|
|
39
|
+
* a helper reads as an unreachable route — the reading that trains people to
|
|
40
|
+
* allowlist things that are fine.
|
|
41
|
+
*
|
|
42
|
+
* Refusals come back in the COMPUTER vocabulary, not DEDICATED_RUNTIME_*: the
|
|
43
|
+
* body is `{error, code, message, ...}` with `code` from
|
|
44
|
+
* `COMPUTER_REFUSAL_CODES` — `file_changed` (409, carrying `currentTag`),
|
|
45
|
+
* `too_large` (413, `details.maxBytes`), `path_withheld` (403, `details.rule`
|
|
46
|
+
* when a rule withheld it), `capability_missing`, `stale_generation`,
|
|
47
|
+
* `machine_unavailable`, `daemon_unreachable`, `timeout`, `consent_missing`,
|
|
48
|
+
* `unauthorized`, `rate_limited`, and `invalid_request` whose `details.reason`
|
|
49
|
+
* names the daemon-side cause (`exists`, `not_empty`, `is_directory`,
|
|
50
|
+
* `not_directory`, `into_itself`, `bad_cursor`, `is_root`, `too_many`,
|
|
51
|
+
* `bad_name`, `contents_tag_required`).
|
|
52
|
+
*
|
|
53
|
+
* Every call carries `generation` — the lease generation the COMMAND's view of
|
|
54
|
+
* the machine was made on (review C-01). A command resolves it once, with
|
|
55
|
+
* `resolveDedicatedFileGeneration`, before its first file call and presents
|
|
56
|
+
* that same number on every call it makes, so a machine replaced while the
|
|
57
|
+
* command runs refuses the rest (`stale_generation`) instead of the remaining
|
|
58
|
+
* calls landing on the replacement. A call without one is refused too
|
|
59
|
+
* (`details.reason: 'generation_required'`).
|
|
60
|
+
*/
|
|
61
|
+
const node_crypto_1 = require("node:crypto");
|
|
62
|
+
const node_fs_1 = require("node:fs");
|
|
63
|
+
const promises_1 = require("node:fs/promises");
|
|
64
|
+
const node_path_1 = require("node:path");
|
|
65
|
+
const data_provider_1 = require("@skrr-ai/data-provider");
|
|
66
|
+
const base_command_1 = require("../base-command");
|
|
67
|
+
const api_fetch_1 = require("./api-fetch");
|
|
68
|
+
const dedicated_machines_1 = require("./dedicated-machines");
|
|
69
|
+
const dedicated_file_retry_1 = require("./dedicated-file-retry");
|
|
70
|
+
/**
|
|
71
|
+
* The lease could not say which generation is running — a server that does not
|
|
72
|
+
* publish it yet, or a lease with none. File calls cannot be fenced without
|
|
73
|
+
* one, and the server refuses them unfenced, so the command stops here.
|
|
74
|
+
*/
|
|
75
|
+
class DedicatedFileGenerationUnavailableError extends Error {
|
|
76
|
+
code = 'DEDICATED_RUNTIME_GENERATION_UNAVAILABLE';
|
|
77
|
+
constructor(leaseId) {
|
|
78
|
+
super(`The server did not say which generation of ${leaseId} is running, so its files cannot be reached safely. ` +
|
|
79
|
+
'Try again; if it persists, the API is older than this CLI.');
|
|
80
|
+
this.name = 'DedicatedFileGenerationUnavailableError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
exports.DedicatedFileGenerationUnavailableError = DedicatedFileGenerationUnavailableError;
|
|
84
|
+
/**
|
|
85
|
+
* Read the lease once and return the generation every file call of this
|
|
86
|
+
* command presents. Resolved at the START of a command and never again: a
|
|
87
|
+
* second read mid-command would follow a replacement, which is exactly what
|
|
88
|
+
* the fence exists to refuse.
|
|
89
|
+
*/
|
|
90
|
+
async function resolveDedicatedFileGeneration(leaseId, scope = {}) {
|
|
91
|
+
const response = await (0, dedicated_machines_1.getDedicatedRuntime)(leaseId, scope);
|
|
92
|
+
const generation = response?.lease?.generation;
|
|
93
|
+
if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation < 1) {
|
|
94
|
+
throw new DedicatedFileGenerationUnavailableError(leaseId);
|
|
95
|
+
}
|
|
96
|
+
return generation;
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Operations
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* List one directory on the machine, one page. `cursor` continues an earlier
|
|
103
|
+
* page's `nextCursor`; a withheld entry comes back with `withheld.reason`
|
|
104
|
+
* rather than silently absent (§18).
|
|
105
|
+
*/
|
|
106
|
+
async function listComputerFiles(leaseId, input, scope = {}) {
|
|
107
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/list`, scope), {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
body: {
|
|
110
|
+
path: input.path,
|
|
111
|
+
generation: input.generation,
|
|
112
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
113
|
+
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Stat one path — the entry, and its optimistic-concurrency `tag`. With
|
|
119
|
+
* `contents` a folder's tag also carries `contents` (a digest of everything
|
|
120
|
+
* inside it, at any depth) and the answer its total `entries`: the tag a
|
|
121
|
+
* fenced recursive delete presents. Needs `computer_files_fenced_v1`.
|
|
122
|
+
*/
|
|
123
|
+
async function statComputerFile(leaseId, input, scope = {}) {
|
|
124
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/stat`, scope), {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
body: {
|
|
127
|
+
path: input.path,
|
|
128
|
+
generation: input.generation,
|
|
129
|
+
...(input.contents === true ? { contents: true } : {}),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Search under a folder by file name or (`content`) by text. The answer carries
|
|
135
|
+
* its own `state` and the bounds that applied — see `computerFileSearchLines`
|
|
136
|
+
* for saying them. Defaults (ignore rules honoured, the machine's limit and
|
|
137
|
+
* time bound) are the machine's, so only what the caller set is sent.
|
|
138
|
+
*/
|
|
139
|
+
async function searchComputerFiles(leaseId, input, scope = {}) {
|
|
140
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/search`, scope), {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
body: {
|
|
143
|
+
path: input.path,
|
|
144
|
+
query: input.query,
|
|
145
|
+
generation: input.generation,
|
|
146
|
+
mode: input.content === true ? 'content' : 'name',
|
|
147
|
+
...(input.respectIgnore !== undefined ? { respectIgnore: input.respectIgnore } : {}),
|
|
148
|
+
...(input.caseSensitive === true ? { caseSensitive: true } : {}),
|
|
149
|
+
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
|
150
|
+
...(input.budgetMs !== undefined ? { budgetMs: input.budgetMs } : {}),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** The git marks of one folder's entries (`modified`, `untracked`, …). */
|
|
155
|
+
async function gitStatusComputerFiles(leaseId, input, scope = {}) {
|
|
156
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/gitstatus`, scope), { method: 'POST', body: { path: input.path, generation: input.generation } });
|
|
157
|
+
}
|
|
158
|
+
/** Create a directory, including missing parents. */
|
|
159
|
+
async function mkdirComputerFile(leaseId, input, scope = {}) {
|
|
160
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/mkdir`, scope), { method: 'POST', body: { path: input.path, generation: input.generation } });
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Rename or move one path. `tag` fences it: the move is refused `file_changed`
|
|
164
|
+
* when the source no longer matches the tag it was read with.
|
|
165
|
+
*/
|
|
166
|
+
async function moveComputerFile(leaseId, input, scope = {}) {
|
|
167
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/move`, scope), {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: {
|
|
170
|
+
from: input.from,
|
|
171
|
+
to: input.to,
|
|
172
|
+
generation: input.generation,
|
|
173
|
+
...(input.tag ? { tag: input.tag } : {}),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Delete one path. A folder is refused `not_empty` unless `recursive` is asked
|
|
179
|
+
* for; `tag` fences the delete against a file that changed since it was read.
|
|
180
|
+
* A RECURSIVE delete with a tag must present the folder's contents tag
|
|
181
|
+
* (`statComputerFile({contents: true})`): anything changed inside it since is
|
|
182
|
+
* refused `file_changed`, and a tag without `contents` is refused
|
|
183
|
+
* `contents_tag_required`. A recursive delete without a tag is unfenced.
|
|
184
|
+
*/
|
|
185
|
+
async function deleteComputerFile(leaseId, input, scope = {}) {
|
|
186
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/delete`, scope), {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
body: {
|
|
189
|
+
path: input.path,
|
|
190
|
+
generation: input.generation,
|
|
191
|
+
...(input.tag ? { tag: input.tag } : {}),
|
|
192
|
+
...(input.recursive === true ? { recursive: true } : {}),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Stage a folder's archive on the machine (`archive` prepare). The machine
|
|
198
|
+
* holds the staged bytes until `releaseComputerFileArchive` — or until its own
|
|
199
|
+
* stale-staging sweep — so callers always release.
|
|
200
|
+
*/
|
|
201
|
+
async function prepareComputerFileArchive(leaseId, input, scope = {}) {
|
|
202
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/archive`, scope), { method: 'POST', body: { path: input.path, generation: input.generation } });
|
|
203
|
+
}
|
|
204
|
+
/** One chunk of a staged archive; `eof` ends the read. */
|
|
205
|
+
async function readComputerFileArchiveChunk(leaseId, input, scope = {}) {
|
|
206
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/archive`, scope), {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
body: {
|
|
209
|
+
archiveId: input.archiveId,
|
|
210
|
+
generation: input.generation,
|
|
211
|
+
...(input.offset !== undefined ? { offset: input.offset } : {}),
|
|
212
|
+
...(input.length !== undefined ? { length: input.length } : {}),
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/** Release a staged archive. Best-effort callers still call it. */
|
|
217
|
+
async function releaseComputerFileArchive(leaseId, input, scope = {}) {
|
|
218
|
+
return (0, api_fetch_1.apiFetch)((0, dedicated_machines_1.withDedicatedScope)(`/api/machines/${encodeURIComponent((0, dedicated_machines_1.computerMachineId)(leaseId))}/files/archive`, scope), {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
body: { archiveId: input.archiveId, generation: input.generation, release: true },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const defaultArchiveIo = {
|
|
224
|
+
prepare: prepareComputerFileArchive,
|
|
225
|
+
readChunk: readComputerFileArchiveChunk,
|
|
226
|
+
release: releaseComputerFileArchive,
|
|
227
|
+
onProcessExit: (fn) => {
|
|
228
|
+
process.on('exit', fn);
|
|
229
|
+
return () => process.removeListener('exit', fn);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
function throwIfCancelled(signal) {
|
|
233
|
+
if (signal?.aborted) {
|
|
234
|
+
throw signal.reason instanceof Error ? signal.reason : new Error('Download cancelled');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Where a folder download lands. A destination that names a directory — spelled
|
|
239
|
+
* with a trailing separator, or an existing one — receives `<name>.<format>`
|
|
240
|
+
* inside it, as `cp` already treats directories.
|
|
241
|
+
*/
|
|
242
|
+
function computerFileArchiveDestination(localPath, name, format,
|
|
243
|
+
/** Whether `localPath` names an existing directory (caller's stat). */
|
|
244
|
+
isDirectory) {
|
|
245
|
+
const fileName = `${name}.${format || 'tar'}`;
|
|
246
|
+
if (!localPath)
|
|
247
|
+
return fileName;
|
|
248
|
+
if (localPath.endsWith('/') || localPath.endsWith(node_path_1.sep))
|
|
249
|
+
return (0, node_path_1.join)(localPath, fileName);
|
|
250
|
+
return isDirectory ? (0, node_path_1.join)(localPath, fileName) : localPath;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Download one folder as its staged archive, prepare → chunks → release. Every
|
|
254
|
+
* call — prepare, each chunk, the release — presents the one `generation` the
|
|
255
|
+
* command resolved, so a machine replaced mid-download refuses the rest.
|
|
256
|
+
*
|
|
257
|
+
* The local write is never half-visible: chunks land in a temporary file beside
|
|
258
|
+
* the destination and are renamed into place at the end; a failed or cancelled
|
|
259
|
+
* download removes the temporary and still releases the staged archive. The
|
|
260
|
+
* archive itself is one immutable staged file, so chunks always belong to the
|
|
261
|
+
* same tree snapshot — there is no mid-copy change to fence here.
|
|
262
|
+
*/
|
|
263
|
+
async function downloadComputerFileArchive(leaseId, remotePath, localPath, generation, scope = {}, io = {}) {
|
|
264
|
+
const { prepare, readChunk, release, onProcessExit, signal, onProgress } = {
|
|
265
|
+
...defaultArchiveIo,
|
|
266
|
+
...io,
|
|
267
|
+
};
|
|
268
|
+
throwIfCancelled(signal);
|
|
269
|
+
const prepared = await (0, dedicated_file_retry_1.withFileAdmissionRetry)(() => prepare(leaseId, { path: remotePath, generation }, scope), signal);
|
|
270
|
+
let releaseAttempted = false;
|
|
271
|
+
try {
|
|
272
|
+
const existingDir = localPath
|
|
273
|
+
? await (0, promises_1.stat)(localPath)
|
|
274
|
+
.then((info) => info.isDirectory())
|
|
275
|
+
.catch(() => false)
|
|
276
|
+
: false;
|
|
277
|
+
const target = computerFileArchiveDestination(localPath, prepared.name, prepared.format, existingDir);
|
|
278
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(target), { recursive: true });
|
|
279
|
+
const temporary = (0, node_path_1.join)((0, node_path_1.dirname)(target), `.${(0, node_path_1.basename)(target)}.skrr-archive-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`);
|
|
280
|
+
const handle = await (0, promises_1.open)(temporary, 'wx');
|
|
281
|
+
// For an exit nothing waits for (a second Ctrl-C, a killed process): the
|
|
282
|
+
// temporary must not sit beside the destination. Synchronous because an
|
|
283
|
+
// exit handler cannot await; harmless after the rename, when it is gone.
|
|
284
|
+
const forgetExitCleanup = onProcessExit(() => (0, node_fs_1.rmSync)(temporary, { force: true }));
|
|
285
|
+
let closed = false;
|
|
286
|
+
try {
|
|
287
|
+
let offset = 0;
|
|
288
|
+
for (;;) {
|
|
289
|
+
throwIfCancelled(signal);
|
|
290
|
+
const chunk = await (0, dedicated_file_retry_1.withFileAdmissionRetry)(() => readChunk(leaseId, { archiveId: prepared.archiveId, offset, length: prepared.chunkBytes, generation }, scope), signal);
|
|
291
|
+
const bytes = Buffer.from(chunk.data, 'base64');
|
|
292
|
+
if (chunk.offset !== offset) {
|
|
293
|
+
throw new Error('An archive chunk arrived out of order; nothing was written to the destination.');
|
|
294
|
+
}
|
|
295
|
+
await handle.write(bytes, 0, bytes.byteLength, offset);
|
|
296
|
+
offset += bytes.byteLength;
|
|
297
|
+
onProgress?.(offset, prepared.size);
|
|
298
|
+
if (chunk.eof)
|
|
299
|
+
break;
|
|
300
|
+
if (bytes.byteLength === 0) {
|
|
301
|
+
throw new Error('The archive stream ended without completing.');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await handle.close();
|
|
305
|
+
closed = true;
|
|
306
|
+
await (0, promises_1.rename)(temporary, target);
|
|
307
|
+
releaseAttempted = true;
|
|
308
|
+
const released = await release(leaseId, { archiveId: prepared.archiveId, generation }, scope)
|
|
309
|
+
.then(() => true)
|
|
310
|
+
.catch(() => false);
|
|
311
|
+
return {
|
|
312
|
+
path: prepared.path,
|
|
313
|
+
name: prepared.name,
|
|
314
|
+
format: prepared.format,
|
|
315
|
+
entries: prepared.entries,
|
|
316
|
+
size: prepared.size,
|
|
317
|
+
bytes: offset,
|
|
318
|
+
destination: target,
|
|
319
|
+
archiveReleased: released,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
if (!closed)
|
|
324
|
+
await handle.close().catch(() => undefined);
|
|
325
|
+
await (0, promises_1.unlink)(temporary).catch(() => undefined);
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
forgetExitCleanup();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
if (!releaseAttempted) {
|
|
334
|
+
await release(leaseId, { archiveId: prepared.archiveId, generation }, scope).catch(() => undefined);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Entity tags
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
/** A folder contents tag: SHA-256, lowercase hex (`COMPUTER_FILE_CONTENTS_TAG_PATTERN`). */
|
|
342
|
+
const CONTENTS_TAG = /^[0-9a-f]{64}$/;
|
|
343
|
+
/**
|
|
344
|
+
* The tag exactly as `--tag` takes it back: `inode:size:mtimeNs`, the triple
|
|
345
|
+
* `stat` prints and a `file_changed` refusal carries — and, for a folder read
|
|
346
|
+
* with `stat --contents`, a fourth part, `inode:size:mtimeNs:contents`, the
|
|
347
|
+
* digest a fenced recursive delete presents.
|
|
348
|
+
*/
|
|
349
|
+
function computerFileTagString(tag) {
|
|
350
|
+
const base = `${tag.inode}:${tag.size}:${tag.mtimeNs}`;
|
|
351
|
+
return typeof tag.contents === 'string' ? `${base}:${tag.contents}` : base;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Parse a `--tag` value: the compact `inode:size:mtimeNs[:contents]` form, or
|
|
355
|
+
* the JSON object `--json` output prints. Throws a usage-shaped Error, never a
|
|
356
|
+
* half-tag.
|
|
357
|
+
*/
|
|
358
|
+
function parseComputerFileTag(text) {
|
|
359
|
+
const trimmed = text.trim();
|
|
360
|
+
if (trimmed.startsWith('{')) {
|
|
361
|
+
let parsed;
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(trimmed);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
throw new Error('--tag is not readable JSON.');
|
|
367
|
+
}
|
|
368
|
+
if (isEntityTag(parsed))
|
|
369
|
+
return entityTagOf(parsed);
|
|
370
|
+
throw new Error('--tag JSON must carry inode, size and mtimeNs (and a valid contents, if any).');
|
|
371
|
+
}
|
|
372
|
+
const parts = trimmed.split(':');
|
|
373
|
+
if ((parts.length === 3 || parts.length === 4) &&
|
|
374
|
+
/^\d+$/.test(parts[0]) &&
|
|
375
|
+
/^\d+$/.test(parts[1]) &&
|
|
376
|
+
Number.isSafeInteger(Number(parts[1])) &&
|
|
377
|
+
/^\d+$/.test(parts[2]) &&
|
|
378
|
+
(parts.length === 3 || CONTENTS_TAG.test(parts[3]))) {
|
|
379
|
+
return {
|
|
380
|
+
inode: parts[0],
|
|
381
|
+
size: Number(parts[1]),
|
|
382
|
+
mtimeNs: parts[2],
|
|
383
|
+
...(parts.length === 4 ? { contents: parts[3] } : {}),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
throw new Error('--tag wants the <inode>:<size>:<mtimeNs> string `stat` prints (with :<contents> for a folder read with `stat --contents`).');
|
|
387
|
+
}
|
|
388
|
+
/** Only the tag's own fields, so a JSON tag never smuggles anything else onto the wire. */
|
|
389
|
+
function entityTagOf(tag) {
|
|
390
|
+
return {
|
|
391
|
+
inode: tag.inode,
|
|
392
|
+
size: tag.size,
|
|
393
|
+
mtimeNs: tag.mtimeNs,
|
|
394
|
+
...(typeof tag.contents === 'string' ? { contents: tag.contents } : {}),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function isEntityTag(value) {
|
|
398
|
+
const tag = value;
|
|
399
|
+
return (typeof tag?.inode === 'string' &&
|
|
400
|
+
/^\d+$/.test(tag.inode) &&
|
|
401
|
+
typeof tag?.size === 'number' &&
|
|
402
|
+
Number.isSafeInteger(tag.size) &&
|
|
403
|
+
tag.size >= 0 &&
|
|
404
|
+
typeof tag?.mtimeNs === 'string' &&
|
|
405
|
+
/^\d+$/.test(tag.mtimeNs) &&
|
|
406
|
+
(tag.contents === undefined ||
|
|
407
|
+
(typeof tag.contents === 'string' && CONTENTS_TAG.test(tag.contents))));
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Rendering
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
const MODE_KINDS = {
|
|
413
|
+
file: '-',
|
|
414
|
+
directory: 'd',
|
|
415
|
+
symlink: 'l',
|
|
416
|
+
other: '?',
|
|
417
|
+
};
|
|
418
|
+
/** `drwxr-x---` — the entry's type char plus its permission bits. */
|
|
419
|
+
function computerFileModeString(type, mode) {
|
|
420
|
+
const kind = MODE_KINDS[type] ?? '?';
|
|
421
|
+
let out = '';
|
|
422
|
+
for (const shift of [6, 3, 0]) {
|
|
423
|
+
const bits = (mode >> shift) & 0o7;
|
|
424
|
+
out += bits & 0o4 ? 'r' : '-';
|
|
425
|
+
out += bits & 0o2 ? 'w' : '-';
|
|
426
|
+
out += bits & 0o1 ? 'x' : '-';
|
|
427
|
+
}
|
|
428
|
+
return `${kind}${out}`;
|
|
429
|
+
}
|
|
430
|
+
/** `1.2 MiB`, `3182 B` — sizes a person scans. */
|
|
431
|
+
function formatComputerFileSize(bytes) {
|
|
432
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
433
|
+
return '-';
|
|
434
|
+
if (bytes >= 1024 * 1024 * 1024)
|
|
435
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
|
|
436
|
+
if (bytes >= 1024 * 1024)
|
|
437
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
438
|
+
if (bytes >= 1024)
|
|
439
|
+
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
440
|
+
return `${bytes} B`;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* How one `ls` row names its entry: `name` (dirs marked `/`), a symlink's
|
|
444
|
+
* stored target (`a -> b`, never followed), and a withheld entry said as
|
|
445
|
+
* withheld with the reason — never rendered as an ordinary readable file.
|
|
446
|
+
*/
|
|
447
|
+
function computerFileEntryName(entry) {
|
|
448
|
+
let name = entry.type === 'directory' ? `${entry.name}/` : entry.name;
|
|
449
|
+
if (entry.type === 'symlink' && entry.linkTarget !== undefined) {
|
|
450
|
+
name += ` -> ${entry.linkTarget}`;
|
|
451
|
+
}
|
|
452
|
+
if (entry.withheld) {
|
|
453
|
+
name += ` [withheld: ${entry.withheld.reason}${entry.withheld.rule ? `, ${entry.withheld.rule}` : ''}]`;
|
|
454
|
+
}
|
|
455
|
+
return name;
|
|
456
|
+
}
|
|
457
|
+
/** One search hit as a line: `path`, or `path:line: text` for a content match. */
|
|
458
|
+
function computerFileSearchHitLine(hit) {
|
|
459
|
+
const where = hit.line !== undefined
|
|
460
|
+
? `${hit.relativePath}:${hit.line}: ${hit.text ?? ''}`
|
|
461
|
+
: hit.relativePath;
|
|
462
|
+
return hit.pathLossy ? `${where} [name is not valid UTF-8; shown lossy]` : where;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* The search read-out. The closing line says how it ended: a list cut at the
|
|
466
|
+
* limit or the time bound is never printed as if it were the whole answer, and
|
|
467
|
+
* "nothing found" is said only for a search that finished.
|
|
468
|
+
*/
|
|
469
|
+
function computerFileSearchLines(result) {
|
|
470
|
+
const content = result.mode === 'content';
|
|
471
|
+
const count = result.hits.length;
|
|
472
|
+
const lines = result.hits.map(computerFileSearchHitLine);
|
|
473
|
+
const ignore = result.respectIgnore ? '' : ', ignore rules off';
|
|
474
|
+
const found = (n) => content ? `${n} match${n === 1 ? '' : 'es'}` : `${n} file${n === 1 ? '' : 's'}`;
|
|
475
|
+
switch (result.state) {
|
|
476
|
+
case 'truncated':
|
|
477
|
+
lines.push(`Truncated at ${result.limit} results — there are more. Narrow the query or raise --limit${ignore}.`);
|
|
478
|
+
break;
|
|
479
|
+
case 'timed_out':
|
|
480
|
+
lines.push(`Still searching: stopped at the ${result.budgetMs} ms time bound; ${count === 0 ? 'nothing found so far' : `${found(count)} found so far — the results are at least these`}. Narrow the folder or raise --budget-ms.`);
|
|
481
|
+
break;
|
|
482
|
+
default:
|
|
483
|
+
lines.push(count === 0
|
|
484
|
+
? `No ${content ? 'matches' : 'files'} found${ignore}.`
|
|
485
|
+
: `${found(count)}${ignore}.`);
|
|
486
|
+
}
|
|
487
|
+
return lines;
|
|
488
|
+
}
|
|
489
|
+
/** The git-status read-out; `timed_out` offers no marks and says so. */
|
|
490
|
+
function computerFileGitStatusLines(result) {
|
|
491
|
+
if (!result.repository)
|
|
492
|
+
return [`${result.path} is not inside a git repository.`];
|
|
493
|
+
if (result.state === 'timed_out') {
|
|
494
|
+
return [
|
|
495
|
+
`${result.path}: git status did not finish in time; no marks are available. Try again.`,
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
const names = Object.keys(result.marks).sort();
|
|
499
|
+
if (names.length === 0)
|
|
500
|
+
return [`${result.path}: clean — no marked entries.`];
|
|
501
|
+
return [
|
|
502
|
+
`${result.path} — ${names.length} marked entr${names.length === 1 ? 'y' : 'ies'}`,
|
|
503
|
+
...names.map((name) => ` ${result.marks[name].padEnd(11)} ${name}`),
|
|
504
|
+
];
|
|
505
|
+
}
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// Refusals — the computer vocabulary, read once for every command
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
/** The parsed refusal body of a thrown call, when the wire sent one. */
|
|
510
|
+
function computerFileRefusalBody(err) {
|
|
511
|
+
if (!(err instanceof api_fetch_1.ApiFetchError) || !err.body)
|
|
512
|
+
return null;
|
|
513
|
+
try {
|
|
514
|
+
const parsed = JSON.parse(err.body);
|
|
515
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
516
|
+
? parsed
|
|
517
|
+
: null;
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The refusal, or null when the failure is not a computer refusal at all (an
|
|
525
|
+
* edge page, a lease that does not resolve — `formatDedicatedRuntimeApiError`'s
|
|
526
|
+
* cases, which the caller then handles).
|
|
527
|
+
*
|
|
528
|
+
* Codes the route returns that are not in COMPUTER_REFUSAL_CODES — `internal`,
|
|
529
|
+
* `not_supported`, a middleware's own — are still the refusal: they carry the
|
|
530
|
+
* same `{error, code, message}` envelope and there is no second reader to hand
|
|
531
|
+
* them to.
|
|
532
|
+
*/
|
|
533
|
+
function computerFileRefusalInfo(err) {
|
|
534
|
+
const body = computerFileRefusalBody(err);
|
|
535
|
+
if (!body)
|
|
536
|
+
return null;
|
|
537
|
+
const rawCode = typeof body.code === 'string' ? body.code : '';
|
|
538
|
+
if (!rawCode)
|
|
539
|
+
return null;
|
|
540
|
+
const normalized = (0, data_provider_1.normalizeComputerRefusalCode)(rawCode);
|
|
541
|
+
if (normalized === null && rawCode !== 'internal' && rawCode !== 'not_supported')
|
|
542
|
+
return null;
|
|
543
|
+
const details = body.details && typeof body.details === 'object' && !Array.isArray(body.details)
|
|
544
|
+
? body.details
|
|
545
|
+
: undefined;
|
|
546
|
+
return {
|
|
547
|
+
code: normalized ?? rawCode,
|
|
548
|
+
message: (typeof body.message === 'string' && body.message) ||
|
|
549
|
+
(typeof body.error === 'string' && body.error) ||
|
|
550
|
+
'The machine refused this request.',
|
|
551
|
+
status: err instanceof api_fetch_1.ApiFetchError ? err.status : undefined,
|
|
552
|
+
...(isEntityTag(body.currentTag) ? { currentTag: body.currentTag } : {}),
|
|
553
|
+
...(details ? { details } : {}),
|
|
554
|
+
...(typeof body.missingCapability === 'string'
|
|
555
|
+
? { missingCapability: body.missingCapability }
|
|
556
|
+
: {}),
|
|
557
|
+
...(typeof body.operation === 'string' ? { operation: body.operation } : {}),
|
|
558
|
+
body,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const RETRYABLE_REFUSAL_CODES = new Set(['daemon_unreachable', 'timeout', 'rate_limited']);
|
|
562
|
+
/**
|
|
563
|
+
* The daemon-side reason an `invalid_request` carries, in the words that tell
|
|
564
|
+
* the caller what to change. `exists`/`not_empty`/… arrive under 409;
|
|
565
|
+
* `bad_cursor`/`bad_name`/`is_root`/`too_many` under 400 — the distinction is
|
|
566
|
+
* the server's, not something the wording should lean on.
|
|
567
|
+
*/
|
|
568
|
+
const INVALID_REQUEST_REASONS = {
|
|
569
|
+
exists: 'a file or folder by that name already exists',
|
|
570
|
+
not_empty: 'the folder is not empty; pass --recursive to remove it anyway',
|
|
571
|
+
is_directory: 'the path is a directory; pass --recursive to remove it',
|
|
572
|
+
not_directory: 'the path is not a directory',
|
|
573
|
+
into_itself: 'a folder cannot be moved into itself',
|
|
574
|
+
bad_cursor: 'the --cursor is stale or not from this listing; start the listing again',
|
|
575
|
+
bad_name: 'the name is not usable',
|
|
576
|
+
is_root: 'the workspace roots (repos/ and user-data/) cannot be renamed or removed',
|
|
577
|
+
too_many: 'the folder holds more entries than a listing can return',
|
|
578
|
+
contents_tag_required: "a recursive delete fenced by --tag needs the folder's contents tag; read it with `stat --contents` and pass that tag",
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* One refusal → the sentence the command prints: the server's own message, then
|
|
582
|
+
* what to do about it. A refusal that names its fix in the message already
|
|
583
|
+
* (`too_large` ends "Push it through Git or object storage instead.") is not
|
|
584
|
+
* given a second one.
|
|
585
|
+
*/
|
|
586
|
+
function describeComputerFileRefusal(refusal, ctx) {
|
|
587
|
+
const { bin } = ctx;
|
|
588
|
+
const lease = ctx.leaseId || '<lease-id>';
|
|
589
|
+
const reason = typeof refusal.details?.reason === 'string'
|
|
590
|
+
? (INVALID_REQUEST_REASONS[refusal.details.reason] ?? refusal.details.reason)
|
|
591
|
+
: '';
|
|
592
|
+
switch (refusal.code) {
|
|
593
|
+
case 'file_changed': {
|
|
594
|
+
if (refusal.currentTag === undefined &&
|
|
595
|
+
'currentTag' in refusal.body &&
|
|
596
|
+
refusal.body.currentTag === null) {
|
|
597
|
+
return `${refusal.message} It is no longer there.`;
|
|
598
|
+
}
|
|
599
|
+
const tag = refusal.currentTag
|
|
600
|
+
? ` Its current tag is ${computerFileTagString(refusal.currentTag)}.`
|
|
601
|
+
: '';
|
|
602
|
+
let fix = ctx.takesTag
|
|
603
|
+
? ' If you mean to act on this version, stat it again and pass --tag.'
|
|
604
|
+
: ' Re-read it and try again.';
|
|
605
|
+
if (ctx.takesTag && typeof refusal.currentTag?.contents === 'string') {
|
|
606
|
+
// A folder's contents changed: only a fresh contents walk shows what is there now.
|
|
607
|
+
fix =
|
|
608
|
+
' If you mean to delete what is there now, look again with `stat --contents` and pass that tag.';
|
|
609
|
+
}
|
|
610
|
+
return `${refusal.message}${tag}${fix}`;
|
|
611
|
+
}
|
|
612
|
+
case 'path_withheld': {
|
|
613
|
+
const detail = (key) => typeof refusal.details?.[key] === 'string' ? refusal.details[key] : '';
|
|
614
|
+
const rule = detail('rule') || detail('reason');
|
|
615
|
+
return `${refusal.message}${rule ? ` Withheld by: ${rule}.` : ''} On a Dedicated Runtime only repos/ and user-data/ are reachable; the rest of the workspace belongs to the runtime.`;
|
|
616
|
+
}
|
|
617
|
+
case 'not_found': {
|
|
618
|
+
// `details.path` marks a path refusal; without it the machine itself was
|
|
619
|
+
// not found — "No computer exists by that machine id."
|
|
620
|
+
if (refusal.details?.path || /path|file|folder|directory/i.test(refusal.message)) {
|
|
621
|
+
return `${refusal.message} Look with \`${bin} machines dedicated ls ${lease} <dir>\`.`;
|
|
622
|
+
}
|
|
623
|
+
return `${refusal.message} List your machines with \`${bin} machines dedicated list\`.`;
|
|
624
|
+
}
|
|
625
|
+
case 'capability_missing':
|
|
626
|
+
if (refusal.missingCapability === 'computer_files_fenced_v1') {
|
|
627
|
+
return `${refusal.message} This machine's image predates fenced file changes (copy only onto a version you chose, \`stat --contents\`, a fenced \`rm -r --tag\`), so the machine's image must be updated: \`${bin} machines dedicated update-image ${lease}\` moves it onto the current image. Nothing was changed.`;
|
|
628
|
+
}
|
|
629
|
+
if (refusal.missingCapability === 'computer_files_search_v1') {
|
|
630
|
+
return `${refusal.message} Search and git marks are guest-only today: a Dedicated Runtime whose image carries them, not a personal machine. \`${bin} machines dedicated update-image ${lease}\` moves a dedicated machine onto the current image.`;
|
|
631
|
+
}
|
|
632
|
+
return `${refusal.message} \`${bin} machines dedicated update-image ${lease}\` moves the machine onto the current image, which carries it.`;
|
|
633
|
+
case 'consent_missing':
|
|
634
|
+
return `${refusal.message} On a personal machine the consent is set on that machine: \`${bin} computer allow\`.`;
|
|
635
|
+
case 'unauthorized':
|
|
636
|
+
return `${refusal.message} File access needs the operate tier — see \`${bin} machines shares ${lease}\`.`;
|
|
637
|
+
case 'stale_generation':
|
|
638
|
+
// `generation_required`: the request named no generation at all (an older
|
|
639
|
+
// CLI path); otherwise the machine was replaced after this command read
|
|
640
|
+
// it. Either way nothing reached the replacement.
|
|
641
|
+
return `${refusal.message} The machine was replaced while this command ran (or the request did not say which machine it meant); nothing was changed on the replacement. Run it again.`;
|
|
642
|
+
case 'too_large': {
|
|
643
|
+
// A contents walk (`stat --contents`, a fenced `rm -r --tag`) names only
|
|
644
|
+
// `maxEntries`; an archive names both and its message already says why.
|
|
645
|
+
const maxEntries = refusal.details?.maxEntries;
|
|
646
|
+
if (typeof maxEntries === 'number' && refusal.details?.maxBytes === undefined) {
|
|
647
|
+
return `${refusal.message} A contents tag covers at most ${maxEntries.toLocaleString('en-US')} entries, so a folder this large cannot be fenced by its contents: work on smaller folders inside it, or delete it without --tag (unfenced).`;
|
|
648
|
+
}
|
|
649
|
+
return refusal.message;
|
|
650
|
+
}
|
|
651
|
+
case 'machine_unavailable':
|
|
652
|
+
return `${refusal.message} See \`${bin} machines dedicated show ${lease}\`; a stopped machine serves no files until \`${bin} machines dedicated start ${lease} --wait\`.`;
|
|
653
|
+
case 'daemon_unreachable':
|
|
654
|
+
return `${refusal.message} \`${bin} machines dedicated health-check ${lease}\` asks the control plane to re-observe it; retry once the daemon is back.`;
|
|
655
|
+
case 'timeout':
|
|
656
|
+
return `${refusal.message} Retry; the machine did not answer in time.`;
|
|
657
|
+
case 'rate_limited':
|
|
658
|
+
return `${refusal.message} The limit is per minute — retry shortly.`;
|
|
659
|
+
case 'invalid_request':
|
|
660
|
+
return `${refusal.message}${reason ? ` (${reason})` : ''}`;
|
|
661
|
+
default:
|
|
662
|
+
return refusal.message;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Everything a command needs to print a failure — the computer refusal
|
|
667
|
+
* sentence when the wire sent one, or the dedicated-runtime reading otherwise
|
|
668
|
+
* (edge page, lease not found). Null means neither had anything to say, and
|
|
669
|
+
* the caller falls back to `handleApiError`.
|
|
670
|
+
*/
|
|
671
|
+
function describeComputerFileFailure(err, ctx) {
|
|
672
|
+
if (err instanceof DedicatedFileGenerationUnavailableError) {
|
|
673
|
+
return { message: err.message, code: err.code, retryable: true };
|
|
674
|
+
}
|
|
675
|
+
const refusal = computerFileRefusalInfo(err);
|
|
676
|
+
if (refusal) {
|
|
677
|
+
// Schema-issue strings (`details.issues`, already "path: message" text) go
|
|
678
|
+
// into the message; nothing else here renders them.
|
|
679
|
+
const issues = Array.isArray(refusal.details?.issues)
|
|
680
|
+
? refusal.details.issues.filter((i) => typeof i === 'string').slice(0, 10)
|
|
681
|
+
: [];
|
|
682
|
+
return {
|
|
683
|
+
message: describeComputerFileRefusal(refusal, ctx) +
|
|
684
|
+
(issues.length ? `\n${issues.map((i) => ` ${i}`).join('\n')}` : ''),
|
|
685
|
+
code: refusal.code,
|
|
686
|
+
status: refusal.status,
|
|
687
|
+
retryable: RETRYABLE_REFUSAL_CODES.has(refusal.code) ||
|
|
688
|
+
(0, base_command_1.isRetryableFailure)(refusal.status, refusal.body),
|
|
689
|
+
details: refusal.body,
|
|
690
|
+
requestId: err instanceof api_fetch_1.ApiFetchError ? err.requestId : undefined,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const message = (0, dedicated_machines_1.formatDedicatedRuntimeApiError)(err, { bin: ctx.bin, leaseId: ctx.leaseId });
|
|
694
|
+
if (message) {
|
|
695
|
+
return {
|
|
696
|
+
message,
|
|
697
|
+
code: (0, dedicated_machines_1.dedicatedRuntimeApiErrorCode)(err) ?? 'CLI_ERROR',
|
|
698
|
+
status: err instanceof api_fetch_1.ApiFetchError ? err.status : undefined,
|
|
699
|
+
retryable: err instanceof api_fetch_1.ApiFetchError
|
|
700
|
+
? (0, base_command_1.isRetryableFailure)(err.status, computerFileRefusalBody(err))
|
|
701
|
+
: false,
|
|
702
|
+
details: computerFileRefusalBody(err) ?? undefined,
|
|
703
|
+
requestId: err instanceof api_fetch_1.ApiFetchError ? err.requestId : undefined,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
return null;
|
|
707
|
+
}
|