@skrr-ai/cli 0.1.87 → 0.1.89

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.
Files changed (97) hide show
  1. package/dist/commands/agents/chat.js +27 -1
  2. package/dist/commands/computer/index.d.ts +19 -0
  3. package/dist/commands/computer/index.js +37 -0
  4. package/dist/commands/labels/create.d.ts +1 -0
  5. package/dist/commands/labels/create.js +2 -0
  6. package/dist/commands/labels/restore.d.ts +1 -0
  7. package/dist/commands/labels/restore.js +6 -3
  8. package/dist/commands/labels/update.d.ts +1 -0
  9. package/dist/commands/labels/update.js +2 -0
  10. package/dist/commands/machines/computer-adoption.d.ts +16 -0
  11. package/dist/commands/machines/computer-adoption.js +34 -0
  12. package/dist/commands/machines/dedicated/agent-browser.d.ts +25 -0
  13. package/dist/commands/machines/dedicated/agent-browser.js +65 -0
  14. package/dist/commands/machines/dedicated/archive.d.ts +26 -0
  15. package/dist/commands/machines/dedicated/archive.js +113 -0
  16. package/dist/commands/machines/dedicated/audit.d.ts +25 -0
  17. package/dist/commands/machines/dedicated/audit.js +113 -0
  18. package/dist/commands/machines/dedicated/computer.d.ts +23 -0
  19. package/dist/commands/machines/dedicated/computer.js +64 -0
  20. package/dist/commands/machines/dedicated/cp.d.ts +2 -0
  21. package/dist/commands/machines/dedicated/cp.js +74 -2
  22. package/dist/commands/machines/dedicated/git-status.d.ts +15 -0
  23. package/dist/commands/machines/dedicated/git-status.js +56 -0
  24. package/dist/commands/machines/dedicated/index.js +15 -1
  25. package/dist/commands/machines/dedicated/ls.d.ts +27 -0
  26. package/dist/commands/machines/dedicated/ls.js +100 -0
  27. package/dist/commands/machines/dedicated/mkdir.d.ts +18 -0
  28. package/dist/commands/machines/dedicated/mkdir.js +57 -0
  29. package/dist/commands/machines/dedicated/mv.d.ts +24 -0
  30. package/dist/commands/machines/dedicated/mv.js +83 -0
  31. package/dist/commands/machines/dedicated/rm.d.ts +26 -0
  32. package/dist/commands/machines/dedicated/rm.js +100 -0
  33. package/dist/commands/machines/dedicated/search.d.ts +24 -0
  34. package/dist/commands/machines/dedicated/search.js +78 -0
  35. package/dist/commands/machines/dedicated/show.js +17 -2
  36. package/dist/commands/machines/dedicated/stat.d.ts +22 -0
  37. package/dist/commands/machines/dedicated/stat.js +92 -0
  38. package/dist/commands/machines/hosted/index.js +1 -1
  39. package/dist/commands/machines/hosted/list.js +1 -1
  40. package/dist/commands/machines/hosted/start.js +1 -1
  41. package/dist/commands/machines/services/declare.d.ts +24 -0
  42. package/dist/commands/machines/services/declare.js +73 -0
  43. package/dist/commands/machines/services/index.d.ts +15 -0
  44. package/dist/commands/machines/services/index.js +31 -0
  45. package/dist/commands/machines/services/ls.d.ts +17 -0
  46. package/dist/commands/machines/services/ls.js +60 -0
  47. package/dist/commands/machines/services/withdraw.d.ts +19 -0
  48. package/dist/commands/machines/services/withdraw.js +47 -0
  49. package/dist/commands/machines/share.d.ts +29 -0
  50. package/dist/commands/machines/share.js +100 -0
  51. package/dist/commands/machines/shared.d.ts +19 -0
  52. package/dist/commands/machines/shared.js +62 -0
  53. package/dist/commands/machines/shares.d.ts +16 -0
  54. package/dist/commands/machines/shares.js +69 -0
  55. package/dist/commands/machines/unshare.d.ts +19 -0
  56. package/dist/commands/machines/unshare.js +61 -0
  57. package/dist/commands/tasks/labels/create.d.ts +1 -0
  58. package/dist/commands/tasks/labels/create.js +3 -0
  59. package/dist/commands/views/create.d.ts +1 -0
  60. package/dist/commands/views/create.js +5 -0
  61. package/dist/lib/agent-home-workspace.d.ts +7 -0
  62. package/dist/lib/agent-home-workspace.js +147 -0
  63. package/dist/lib/computer-adoption.d.ts +60 -0
  64. package/dist/lib/computer-adoption.js +72 -0
  65. package/dist/lib/computer-consent.d.ts +55 -0
  66. package/dist/lib/computer-consent.js +154 -0
  67. package/dist/lib/computer-files.d.ts +239 -0
  68. package/dist/lib/computer-files.js +707 -0
  69. package/dist/lib/dedicated-copy.d.ts +45 -9
  70. package/dist/lib/dedicated-copy.js +141 -44
  71. package/dist/lib/dedicated-machines.d.ts +80 -0
  72. package/dist/lib/dedicated-machines.js +137 -6
  73. package/dist/lib/exec-runtime-binary.d.ts +3 -1
  74. package/dist/lib/exec-runtime-binary.js +16 -2
  75. package/dist/lib/label-scope.d.ts +12 -0
  76. package/dist/lib/label-scope.js +15 -1
  77. package/dist/lib/machine-audit.d.ts +34 -0
  78. package/dist/lib/machine-audit.js +50 -0
  79. package/dist/lib/machine-grants.d.ts +70 -0
  80. package/dist/lib/machine-grants.js +47 -0
  81. package/dist/lib/machine-services.d.ts +64 -0
  82. package/dist/lib/machine-services.js +60 -0
  83. package/dist/lib/task-view-render.d.ts +2 -0
  84. package/dist/lib/task-view-render.js +1 -1
  85. package/dist/lib/views/vocabulary.d.ts +1 -1
  86. package/dist/lib/views/vocabulary.js +2 -1
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.d.ts +1050 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.js +1196 -0
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.js +7 -0
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.d.ts +1050 -0
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.js +1175 -0
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.js +7 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  94. package/dist/node_modules/@skrr-ai/data-provider/index.js +22391 -20727
  95. package/dist/node_modules/@skrr-ai/data-provider/package.json +1 -1
  96. package/oclif.manifest.json +14767 -13095
  97. 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
+ }