@neta-art/cohub-cli 3.5.1 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/commands/sandbox.d.ts +1 -0
- package/dist/commands/sandbox.js +3 -2
- package/dist/commands/spaces.d.ts +14 -0
- package/dist/commands/spaces.js +56 -32
- package/dist/commands/tasks.d.ts +7 -1
- package/dist/commands/tasks.js +7 -5
- package/dist/commands/works.js +24 -4
- package/dist/work-download.d.ts +12 -0
- package/dist/work-download.js +254 -0
- package/dist/work-ref.d.ts +13 -0
- package/dist/work-ref.js +61 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ cohub spaces get <spaceId> --json
|
|
|
63
63
|
cohub -s <spaceId> spaces get
|
|
64
64
|
COHUB_SPACE_ID=<spaceId> cohub spaces get
|
|
65
65
|
cohub spaces create --name "<name>" --description "<description>" --json
|
|
66
|
+
cohub spaces create --name "<name>" --checkpoint <checkpointId> --json
|
|
66
67
|
cohub spaces update <spaceId> --slug <space-slug>
|
|
67
68
|
cohub spaces rename <spaceId> "<new name>"
|
|
68
69
|
cohub -s <spaceId> spaces invites create --role builder --days 7
|
|
@@ -274,7 +275,8 @@ Publish and manage Work entries from a Space workspace. Public Work URLs require
|
|
|
274
275
|
cohub profile update --username <username>
|
|
275
276
|
cohub spaces update <spaceId> --slug <space-slug>
|
|
276
277
|
cohub -s <spaceId> works ls --json
|
|
277
|
-
cohub works get <workId> --json
|
|
278
|
+
cohub works get <workId|url|username/space/work> --json
|
|
279
|
+
cohub works download <workId|url|username/space/work> --output <path>
|
|
278
280
|
cohub -s <spaceId> works publish demo --file dist/index.html
|
|
279
281
|
cohub -s <spaceId> works publish site --dir dist
|
|
280
282
|
cohub -s <spaceId> works publish app --port 3000
|
|
@@ -289,7 +291,10 @@ Resolve a published Work by public identity:
|
|
|
289
291
|
cohub works resolve <workSlug> --owner <username> --space-slug <spaceSlug>
|
|
290
292
|
```
|
|
291
293
|
|
|
292
|
-
Use `--json` for machine-readable output.
|
|
294
|
+
Use `--json` for machine-readable output. `works get` and `works download` also accept `cohub://works/<username>/<space>/<work>` mention URIs. Download restores newly published file and directory artifacts directly from the CDN with checksum verification. HTML files with companion assets are restored as directory bundles; Board and port Works are not downloadable. The resolve command remains available for explicit slug-based lookup.
|
|
295
|
+
|
|
296
|
+
Realtime rooms use a published Work's runtime identity, so they are available
|
|
297
|
+
through `client.work.realtime` in the SDK rather than as CLI commands.
|
|
293
298
|
|
|
294
299
|
## Saves
|
|
295
300
|
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
|
-
import { resolve } from "node:path";
|
|
4
|
+
import { basename, resolve } from "node:path";
|
|
5
5
|
import { resolveCohubEnvironment, resolveWebsocketUrl } from "@neta-art/cohub";
|
|
6
6
|
import { requireAccessToken } from "../auth.js";
|
|
7
7
|
import { createClient } from "../client.js";
|
|
@@ -28,6 +28,7 @@ const confirm = async (question) => {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
const webBaseUrl = () => resolveCohubEnvironment() === "prod" ? "https://cohub.run" : "https://dev.cohub.run";
|
|
31
|
+
export const resolveLocalSpaceName = (rootDir, requestedName) => requestedName?.trim() || basename(rootDir) || "local-space";
|
|
31
32
|
// Consent copy is deliberately explicit: a local sandbox runs agent-issued
|
|
32
33
|
// shell commands as the current OS user. File RPCs are fenced to the folder,
|
|
33
34
|
// but shell commands are NOT — they can read/write anything the user can
|
|
@@ -97,7 +98,7 @@ export function registerSandbox(program) {
|
|
|
97
98
|
return error("Aborted", "No space was created");
|
|
98
99
|
}
|
|
99
100
|
const created = await client.spaces.create({
|
|
100
|
-
name: opts.name,
|
|
101
|
+
name: resolveLocalSpaceName(rootDir, opts.name),
|
|
101
102
|
config: { sandbox: { provider: "local" } },
|
|
102
103
|
});
|
|
103
104
|
spaceId = created.space.id;
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
import type { CohubHttpClient, CreateSpaceInput } from "@neta-art/cohub";
|
|
1
2
|
import type { Command } from "commander";
|
|
3
|
+
export type SpaceCreateOptions = {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
checkpoint?: string;
|
|
7
|
+
autoDestroy?: string;
|
|
8
|
+
idleTtl?: string;
|
|
9
|
+
spec?: string;
|
|
10
|
+
json?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export declare function buildSpaceCreateInput(opts: SpaceCreateOptions): CreateSpaceInput;
|
|
2
13
|
export declare function registerPrompt(program: Command): void;
|
|
14
|
+
export declare function registerSpaceCreate(spacesCmd: Command, dependencies?: {
|
|
15
|
+
createClient?: () => CohubHttpClient;
|
|
16
|
+
}): Command;
|
|
3
17
|
export declare function registerSpaces(program: Command): void;
|
package/dist/commands/spaces.js
CHANGED
|
@@ -67,6 +67,31 @@ const parseAutoDestroy = (opts) => {
|
|
|
67
67
|
return { mode: "idle", ttlSeconds };
|
|
68
68
|
};
|
|
69
69
|
const parseSandboxSpec = (value) => value ? parseChoice(value, "sandbox spec", SANDBOX_SPEC_IDS) : undefined;
|
|
70
|
+
export function buildSpaceCreateInput(opts) {
|
|
71
|
+
const autoDestroy = parseAutoDestroy(opts);
|
|
72
|
+
const spec = parseSandboxSpec(opts.spec);
|
|
73
|
+
const checkpointId = opts.checkpoint?.trim();
|
|
74
|
+
if (opts.checkpoint !== undefined && !checkpointId) {
|
|
75
|
+
return error("Invalid checkpoint", "Checkpoint ID is required");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
name: opts.name,
|
|
79
|
+
description: opts.description,
|
|
80
|
+
...(checkpointId
|
|
81
|
+
? { bootstrapSource: { type: "checkpoint", checkpointId } }
|
|
82
|
+
: {}),
|
|
83
|
+
...((autoDestroy || spec)
|
|
84
|
+
? {
|
|
85
|
+
config: {
|
|
86
|
+
sandbox: {
|
|
87
|
+
...(autoDestroy ? { autoDestroy } : {}),
|
|
88
|
+
...(spec ? { spec } : {}),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
70
95
|
const formatAutoDestroy = (policy) => {
|
|
71
96
|
if (!policy)
|
|
72
97
|
return `${cliEnv === "prod" ? "12h" : "10m"} (default)`;
|
|
@@ -376,6 +401,36 @@ export function registerPrompt(program) {
|
|
|
376
401
|
.option("--json", "Output as JSON")
|
|
377
402
|
.action((words, opts) => runCompletionCommand(program, words, opts));
|
|
378
403
|
}
|
|
404
|
+
export function registerSpaceCreate(spacesCmd, dependencies = {}) {
|
|
405
|
+
const getClient = dependencies.createClient ?? createClient;
|
|
406
|
+
return spacesCmd
|
|
407
|
+
.command("create")
|
|
408
|
+
.description("Create a new space")
|
|
409
|
+
.requiredOption("-n, --name <name>", "Space name")
|
|
410
|
+
.option("-d, --description <desc>", "Space description")
|
|
411
|
+
.option("--checkpoint <id>", "Create from a checkpoint")
|
|
412
|
+
.option("--auto-destroy <mode>", "Sandbox auto destroy mode: idle or never")
|
|
413
|
+
.option("--idle-ttl <seconds>", "Idle auto destroy TTL in seconds, max 2592000 (30d)")
|
|
414
|
+
.option("--spec <spec>", "Sandbox spec: standard, boost, or ultra")
|
|
415
|
+
.option("--json", "Output as JSON")
|
|
416
|
+
.action(async (opts) => {
|
|
417
|
+
const client = getClient();
|
|
418
|
+
try {
|
|
419
|
+
const result = await client.spaces.create(buildSpaceCreateInput(opts));
|
|
420
|
+
if (jsonRequested(opts))
|
|
421
|
+
return outJson(result);
|
|
422
|
+
ok(`Space created: ${result.space.id}`);
|
|
423
|
+
table([{ ...result.space, taskRunId: result.taskRunId }], [
|
|
424
|
+
{ key: "id", label: "ID" },
|
|
425
|
+
{ key: "name", label: "Name" },
|
|
426
|
+
{ key: "taskRunId", label: "Task" },
|
|
427
|
+
]);
|
|
428
|
+
}
|
|
429
|
+
catch (e) {
|
|
430
|
+
handleHttp(e);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
379
434
|
export function registerSpaces(program) {
|
|
380
435
|
const spacesCmd = program.command("spaces").description("Space management");
|
|
381
436
|
registerSpaceInvitations(spacesCmd);
|
|
@@ -441,38 +496,7 @@ export function registerSpaces(program) {
|
|
|
441
496
|
}
|
|
442
497
|
});
|
|
443
498
|
// ── spaces create ──
|
|
444
|
-
spacesCmd
|
|
445
|
-
.command("create")
|
|
446
|
-
.description("Create a new space")
|
|
447
|
-
.option("-n, --name <name>", "Space name")
|
|
448
|
-
.option("-d, --description <desc>", "Space description")
|
|
449
|
-
.option("--auto-destroy <mode>", "Sandbox auto destroy mode: idle or never")
|
|
450
|
-
.option("--idle-ttl <seconds>", "Idle auto destroy TTL in seconds, max 2592000 (30d)")
|
|
451
|
-
.option("--spec <spec>", "Sandbox spec: standard, boost, or ultra")
|
|
452
|
-
.option("--json", "Output as JSON")
|
|
453
|
-
.action(async (opts) => {
|
|
454
|
-
const client = createClient();
|
|
455
|
-
try {
|
|
456
|
-
const autoDestroy = parseAutoDestroy(opts);
|
|
457
|
-
const spec = parseSandboxSpec(opts.spec);
|
|
458
|
-
const result = await client.spaces.create({
|
|
459
|
-
name: opts.name,
|
|
460
|
-
description: opts.description,
|
|
461
|
-
...((autoDestroy || spec) ? { config: { sandbox: { ...(autoDestroy ? { autoDestroy } : {}), ...(spec ? { spec } : {}) } } } : {}),
|
|
462
|
-
});
|
|
463
|
-
if (jsonRequested(opts))
|
|
464
|
-
return outJson(result);
|
|
465
|
-
ok(`Space created: ${result.space.id}`);
|
|
466
|
-
table([result.space], [
|
|
467
|
-
{ key: "id", label: "ID" },
|
|
468
|
-
{ key: "name", label: "Name" },
|
|
469
|
-
{ key: "taskRunId", label: "Task" },
|
|
470
|
-
]);
|
|
471
|
-
}
|
|
472
|
-
catch (e) {
|
|
473
|
-
handleHttp(e);
|
|
474
|
-
}
|
|
475
|
-
});
|
|
499
|
+
registerSpaceCreate(spacesCmd);
|
|
476
500
|
// ── spaces update ──
|
|
477
501
|
spacesCmd
|
|
478
502
|
.command("update <id>")
|
package/dist/commands/tasks.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
-
|
|
2
|
+
import { createClient } from "../client.js";
|
|
3
|
+
type TasksClient = ReturnType<typeof createClient>;
|
|
4
|
+
type RegisterTasksDependencies = {
|
|
5
|
+
createClient?: () => TasksClient;
|
|
6
|
+
};
|
|
7
|
+
export declare function registerTasks(program: Command, dependencies?: RegisterTasksDependencies): void;
|
|
8
|
+
export {};
|
package/dist/commands/tasks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createClient } from "../client.js";
|
|
2
2
|
import { table, json as outJson, jsonRequested, handleHttp } from "../output.js";
|
|
3
|
-
export function registerTasks(program) {
|
|
3
|
+
export function registerTasks(program, dependencies = {}) {
|
|
4
|
+
const getClient = dependencies.createClient ?? createClient;
|
|
4
5
|
const cmd = program.command("tasks", { hidden: true }).description("Task runs");
|
|
5
6
|
cmd
|
|
6
7
|
.command("ls")
|
|
@@ -15,16 +16,17 @@ export function registerTasks(program) {
|
|
|
15
16
|
.option("--cursor <cursor>", "Page cursor")
|
|
16
17
|
.option("--json", "Output as JSON")
|
|
17
18
|
.action(async (opts) => {
|
|
18
|
-
const client =
|
|
19
|
+
const client = getClient();
|
|
19
20
|
try {
|
|
20
21
|
const limit = opts.limit ? Number(opts.limit) : 50;
|
|
21
22
|
if (!Number.isFinite(limit) || limit < 1)
|
|
22
23
|
throw new Error("limit must be a positive number");
|
|
23
24
|
const filters = { limit: Math.floor(limit) };
|
|
25
|
+
const spaceId = opts.space ?? program.opts().space;
|
|
24
26
|
if (opts.cronJob)
|
|
25
27
|
filters.cronJobId = opts.cronJob;
|
|
26
|
-
if (
|
|
27
|
-
filters.spaceId =
|
|
28
|
+
if (spaceId)
|
|
29
|
+
filters.spaceId = spaceId;
|
|
28
30
|
if (opts.session)
|
|
29
31
|
filters.sessionId = opts.session;
|
|
30
32
|
if (opts.type)
|
|
@@ -58,7 +60,7 @@ export function registerTasks(program) {
|
|
|
58
60
|
.description("Task run details")
|
|
59
61
|
.option("--json", "Output as JSON")
|
|
60
62
|
.action(async (id, opts) => {
|
|
61
|
-
const client =
|
|
63
|
+
const client = getClient();
|
|
62
64
|
try {
|
|
63
65
|
const result = await client.tasks.get(id);
|
|
64
66
|
if (jsonRequested(opts))
|
package/dist/commands/works.js
CHANGED
|
@@ -2,6 +2,8 @@ import { HttpError } from "@neta-art/cohub";
|
|
|
2
2
|
import { createClient } from "../client.js";
|
|
3
3
|
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
4
4
|
import { resolveSpace } from "../space.js";
|
|
5
|
+
import { downloadWork } from "../work-download.js";
|
|
6
|
+
import { getWorkByRef } from "../work-ref.js";
|
|
5
7
|
import { registerWorkCommerce } from "./work-commerce.js";
|
|
6
8
|
const WORK_STATUSES = ["published", "disabled"];
|
|
7
9
|
const WORK_VISIBILITIES = ["public", "space"];
|
|
@@ -143,13 +145,13 @@ export function registerWorks(program) {
|
|
|
143
145
|
}
|
|
144
146
|
});
|
|
145
147
|
worksCmd
|
|
146
|
-
.command("get <
|
|
147
|
-
.description("Show work details")
|
|
148
|
+
.command("get <work>")
|
|
149
|
+
.description("Show work details by id, URL, mention URI, or username/space/work")
|
|
148
150
|
.option("--json", "Output as JSON")
|
|
149
|
-
.action(async (
|
|
151
|
+
.action(async (work, opts) => {
|
|
150
152
|
const client = createClient();
|
|
151
153
|
try {
|
|
152
|
-
const result = await client
|
|
154
|
+
const result = await getWorkByRef(client, work);
|
|
153
155
|
if (jsonRequested(opts))
|
|
154
156
|
return outJson(result);
|
|
155
157
|
printWork(result.work);
|
|
@@ -159,6 +161,24 @@ export function registerWorks(program) {
|
|
|
159
161
|
handleHttp(e);
|
|
160
162
|
}
|
|
161
163
|
});
|
|
164
|
+
worksCmd
|
|
165
|
+
.command("download <work>")
|
|
166
|
+
.description("Download a published file or directory Work")
|
|
167
|
+
.option("-o, --output <path>", "Output file or directory")
|
|
168
|
+
.option("--json", "Output as JSON")
|
|
169
|
+
.action(async (work, opts) => {
|
|
170
|
+
const client = createClient();
|
|
171
|
+
try {
|
|
172
|
+
const detail = await getWorkByRef(client, work);
|
|
173
|
+
const result = await downloadWork(detail, opts.output);
|
|
174
|
+
if (jsonRequested(opts))
|
|
175
|
+
return outJson(result);
|
|
176
|
+
ok(`Downloaded ${result.files} file${result.files === 1 ? "" : "s"} to ${result.output}`);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
handleHttp(e);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
162
182
|
worksCmd
|
|
163
183
|
.command("resolve <workSlug>")
|
|
164
184
|
.description("Resolve a published work by owner and space slug")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorkGetResponse } from "@neta-art/cohub";
|
|
2
|
+
type DownloadResult = {
|
|
3
|
+
workId: string;
|
|
4
|
+
version: number;
|
|
5
|
+
kind: "file" | "directory";
|
|
6
|
+
output: string;
|
|
7
|
+
files: number;
|
|
8
|
+
bytes: number;
|
|
9
|
+
verified: true;
|
|
10
|
+
};
|
|
11
|
+
export declare function downloadWork(detail: WorkGetResponse, outputOption?: string, fetcher?: typeof fetch): Promise<DownloadResult>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, mkdtemp, rename, rm, rmdir } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, posix, resolve } from "node:path";
|
|
5
|
+
import { Readable, Transform } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const DOWNLOAD_CONCURRENCY = 4;
|
|
9
|
+
function safeRelativePath(value, label) {
|
|
10
|
+
if (!value || value.includes("\\") || value.includes("\0") || posix.isAbsolute(value)) {
|
|
11
|
+
throw new Error(`Invalid ${label} in Work manifest`);
|
|
12
|
+
}
|
|
13
|
+
const segments = value.split("/");
|
|
14
|
+
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
15
|
+
throw new Error(`Invalid ${label} in Work manifest`);
|
|
16
|
+
}
|
|
17
|
+
return posix.normalize(value);
|
|
18
|
+
}
|
|
19
|
+
function isManifestFile(value) {
|
|
20
|
+
if (!value || typeof value !== "object")
|
|
21
|
+
return false;
|
|
22
|
+
const file = value;
|
|
23
|
+
return typeof file.artifactPath === "string"
|
|
24
|
+
&& typeof file.outputPath === "string"
|
|
25
|
+
&& (typeof file.mimeType === "string" || file.mimeType === null)
|
|
26
|
+
&& Number.isSafeInteger(file.sizeBytes)
|
|
27
|
+
&& Number(file.sizeBytes) >= 0
|
|
28
|
+
&& typeof file.sha256 === "string"
|
|
29
|
+
&& /^[0-9a-f]{64}$/i.test(file.sha256);
|
|
30
|
+
}
|
|
31
|
+
function parseManifest(value) {
|
|
32
|
+
if (!value || typeof value !== "object")
|
|
33
|
+
throw new Error("Work download manifest is invalid");
|
|
34
|
+
const manifest = value;
|
|
35
|
+
if (manifest.kind !== "cohub.work.artifact-manifest"
|
|
36
|
+
|| manifest.version !== 1
|
|
37
|
+
|| (manifest.targetType !== "file" && manifest.targetType !== "directory")
|
|
38
|
+
|| typeof manifest.targetRef !== "string"
|
|
39
|
+
|| typeof manifest.entrypoint !== "string"
|
|
40
|
+
|| !Number.isSafeInteger(manifest.fileCount)
|
|
41
|
+
|| Number(manifest.fileCount) < 1
|
|
42
|
+
|| !Number.isSafeInteger(manifest.sizeBytes)
|
|
43
|
+
|| Number(manifest.sizeBytes) < 0
|
|
44
|
+
|| !Array.isArray(manifest.files)
|
|
45
|
+
|| !manifest.files.every(isManifestFile)
|
|
46
|
+
|| manifest.files.length !== manifest.fileCount) {
|
|
47
|
+
throw new Error("Work download manifest is invalid");
|
|
48
|
+
}
|
|
49
|
+
const seenArtifactPaths = new Set();
|
|
50
|
+
const seenOutputPaths = new Set();
|
|
51
|
+
let sizeBytes = 0;
|
|
52
|
+
for (const file of manifest.files) {
|
|
53
|
+
file.artifactPath = safeRelativePath(file.artifactPath, "artifact path");
|
|
54
|
+
file.outputPath = safeRelativePath(file.outputPath, "output path");
|
|
55
|
+
if (seenArtifactPaths.has(file.artifactPath) || seenOutputPaths.has(file.outputPath)) {
|
|
56
|
+
throw new Error("Work download manifest contains duplicate paths");
|
|
57
|
+
}
|
|
58
|
+
seenArtifactPaths.add(file.artifactPath);
|
|
59
|
+
seenOutputPaths.add(file.outputPath);
|
|
60
|
+
sizeBytes += file.sizeBytes;
|
|
61
|
+
}
|
|
62
|
+
if (sizeBytes !== manifest.sizeBytes)
|
|
63
|
+
throw new Error("Work download manifest size is invalid");
|
|
64
|
+
safeRelativePath(manifest.entrypoint, "entrypoint");
|
|
65
|
+
return manifest;
|
|
66
|
+
}
|
|
67
|
+
async function readManifest(url, expectedSha256, fetcher) {
|
|
68
|
+
const response = await fetcher(url);
|
|
69
|
+
if (!response.ok)
|
|
70
|
+
throw new Error(`Failed to download Work manifest (${response.status})`);
|
|
71
|
+
if (!response.body)
|
|
72
|
+
throw new Error("Work download manifest is invalid");
|
|
73
|
+
const contentLength = Number(response.headers.get("content-length") ?? 0);
|
|
74
|
+
if (Number.isFinite(contentLength) && contentLength > MANIFEST_MAX_BYTES) {
|
|
75
|
+
throw new Error("Work download manifest is too large");
|
|
76
|
+
}
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let sizeBytes = 0;
|
|
79
|
+
for await (const chunk of Readable.fromWeb(response.body)) {
|
|
80
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
81
|
+
sizeBytes += bytes.byteLength;
|
|
82
|
+
if (sizeBytes > MANIFEST_MAX_BYTES)
|
|
83
|
+
throw new Error("Work download manifest is too large");
|
|
84
|
+
chunks.push(bytes);
|
|
85
|
+
}
|
|
86
|
+
const bytes = Buffer.concat(chunks, sizeBytes);
|
|
87
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
88
|
+
if (sha256 !== expectedSha256)
|
|
89
|
+
throw new Error("Work download manifest checksum mismatch");
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error("Work download manifest is invalid");
|
|
96
|
+
}
|
|
97
|
+
return parseManifest(parsed);
|
|
98
|
+
}
|
|
99
|
+
function artifactUrl(contentUrl, artifactPath) {
|
|
100
|
+
const base = new URL("./", contentUrl);
|
|
101
|
+
const encodedPath = safeRelativePath(artifactPath, "artifact path")
|
|
102
|
+
.split("/")
|
|
103
|
+
.map(encodeURIComponent)
|
|
104
|
+
.join("/");
|
|
105
|
+
return new URL(encodedPath, base).toString();
|
|
106
|
+
}
|
|
107
|
+
async function downloadFile(url, output, expected, fetcher) {
|
|
108
|
+
const response = await fetcher(url);
|
|
109
|
+
if (!response.ok || !response.body)
|
|
110
|
+
throw new Error(`Failed to download ${expected.outputPath} (${response.status})`);
|
|
111
|
+
await mkdir(dirname(output), { recursive: true });
|
|
112
|
+
let sizeBytes = 0;
|
|
113
|
+
const hash = createHash("sha256");
|
|
114
|
+
const verifier = new Transform({
|
|
115
|
+
transform(chunk, _encoding, callback) {
|
|
116
|
+
sizeBytes += chunk.byteLength;
|
|
117
|
+
if (sizeBytes > expected.sizeBytes) {
|
|
118
|
+
callback(new Error(`Downloaded file verification failed: ${expected.outputPath}`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
hash.update(chunk);
|
|
122
|
+
callback(null, chunk);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
await pipeline(Readable.fromWeb(response.body), verifier, createWriteStream(output, { flags: "wx" }));
|
|
126
|
+
if (sizeBytes !== expected.sizeBytes || hash.digest("hex") !== expected.sha256) {
|
|
127
|
+
throw new Error(`Downloaded file verification failed: ${expected.outputPath}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function outputExistsError(output) {
|
|
131
|
+
return new Error(`Output already exists: ${output}`);
|
|
132
|
+
}
|
|
133
|
+
async function outputExists(output) {
|
|
134
|
+
return Boolean(await lstat(output).catch((cause) => {
|
|
135
|
+
if (cause.code === "ENOENT")
|
|
136
|
+
return null;
|
|
137
|
+
throw cause;
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
async function assertOutputMissing(output) {
|
|
141
|
+
if (await outputExists(output))
|
|
142
|
+
throw outputExistsError(output);
|
|
143
|
+
}
|
|
144
|
+
async function installFileNoReplace(stagedFile, output) {
|
|
145
|
+
try {
|
|
146
|
+
await link(stagedFile, output);
|
|
147
|
+
}
|
|
148
|
+
catch (cause) {
|
|
149
|
+
if (cause.code === "EEXIST")
|
|
150
|
+
throw outputExistsError(output);
|
|
151
|
+
throw cause;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function installDirectoryNoReplace(stage, output) {
|
|
155
|
+
if (process.platform === "win32") {
|
|
156
|
+
try {
|
|
157
|
+
// Windows directory renames already fail when the destination exists.
|
|
158
|
+
await rename(stage, output);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
catch (cause) {
|
|
162
|
+
if (await outputExists(output))
|
|
163
|
+
throw outputExistsError(output);
|
|
164
|
+
throw cause;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
await mkdir(output);
|
|
169
|
+
}
|
|
170
|
+
catch (cause) {
|
|
171
|
+
if (cause.code === "EEXIST")
|
|
172
|
+
throw outputExistsError(output);
|
|
173
|
+
throw cause;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await rename(stage, output);
|
|
177
|
+
}
|
|
178
|
+
catch (cause) {
|
|
179
|
+
await rmdir(output).catch(() => undefined);
|
|
180
|
+
throw cause;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function downloadFiles(input) {
|
|
184
|
+
let next = 0;
|
|
185
|
+
let failed = false;
|
|
186
|
+
let failure;
|
|
187
|
+
const workers = Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, input.files.length) }, async () => {
|
|
188
|
+
while (!failed && next < input.files.length) {
|
|
189
|
+
const index = next++;
|
|
190
|
+
const file = input.files[index];
|
|
191
|
+
if (!file)
|
|
192
|
+
continue;
|
|
193
|
+
try {
|
|
194
|
+
await downloadFile(artifactUrl(input.contentUrl, file.artifactPath), join(input.stage, ...file.outputPath.split("/")), file, input.fetcher);
|
|
195
|
+
}
|
|
196
|
+
catch (cause) {
|
|
197
|
+
if (!failed)
|
|
198
|
+
failure = cause;
|
|
199
|
+
failed = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
await Promise.all(workers);
|
|
204
|
+
if (failed)
|
|
205
|
+
throw failure;
|
|
206
|
+
}
|
|
207
|
+
export async function downloadWork(detail, outputOption, fetcher = fetch) {
|
|
208
|
+
const { work, content } = detail;
|
|
209
|
+
if (!content)
|
|
210
|
+
throw new Error("This Work has no published downloadable artifact");
|
|
211
|
+
if (content.kind === "port")
|
|
212
|
+
throw new Error("Port Works do not have a downloadable artifact");
|
|
213
|
+
if (content.kind === "board")
|
|
214
|
+
throw new Error("Board Works do not have a restorable file or directory artifact");
|
|
215
|
+
if (!content.download)
|
|
216
|
+
throw new Error("This Work version does not support download");
|
|
217
|
+
const manifest = await readManifest(content.download.manifestUrl, content.download.manifestSha256, fetcher);
|
|
218
|
+
if (manifest.targetType !== content.targetType || manifest.targetRef !== content.path) {
|
|
219
|
+
throw new Error("Work download manifest does not match the published artifact");
|
|
220
|
+
}
|
|
221
|
+
const entry = manifest.files.find((file) => file.artifactPath === manifest.entrypoint);
|
|
222
|
+
if (!entry)
|
|
223
|
+
throw new Error("Work download manifest entrypoint is missing");
|
|
224
|
+
const hasDirectoryOutput = manifest.targetType === "directory" || manifest.files.length > 1;
|
|
225
|
+
const output = resolve(outputOption ?? (hasDirectoryOutput ? work.slug : basename(manifest.targetRef)));
|
|
226
|
+
await assertOutputMissing(output);
|
|
227
|
+
await mkdir(dirname(output), { recursive: true });
|
|
228
|
+
const stage = await mkdtemp(join(dirname(output), `.${basename(output)}.cohub-download-`));
|
|
229
|
+
try {
|
|
230
|
+
const files = hasDirectoryOutput ? manifest.files : [entry];
|
|
231
|
+
await downloadFiles({ files, contentUrl: content.url, stage, fetcher });
|
|
232
|
+
if (hasDirectoryOutput) {
|
|
233
|
+
await installDirectoryNoReplace(stage, output);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const stagedFile = join(stage, ...entry.outputPath.split("/"));
|
|
237
|
+
await installFileNoReplace(stagedFile, output);
|
|
238
|
+
await rm(stage, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
workId: work.id,
|
|
242
|
+
version: work.latestVersion,
|
|
243
|
+
kind: hasDirectoryOutput ? "directory" : "file",
|
|
244
|
+
output,
|
|
245
|
+
files: files.length,
|
|
246
|
+
bytes: files.reduce((sum, file) => sum + file.sizeBytes, 0),
|
|
247
|
+
verified: true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
catch (cause) {
|
|
251
|
+
await rm(stage, { recursive: true, force: true }).catch(() => undefined);
|
|
252
|
+
throw cause;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CohubHttpClient, WorkGetResponse } from "@neta-art/cohub";
|
|
2
|
+
type WorkPublicRef = {
|
|
3
|
+
username: string;
|
|
4
|
+
spaceSlug: string;
|
|
5
|
+
workSlug: string;
|
|
6
|
+
};
|
|
7
|
+
export type ParsedWorkRef = {
|
|
8
|
+
id: string;
|
|
9
|
+
} | WorkPublicRef;
|
|
10
|
+
export declare function parseWorkRef(input: string): ParsedWorkRef;
|
|
11
|
+
export declare function formatWorkRef(ref: ParsedWorkRef): string;
|
|
12
|
+
export declare function getWorkByRef(client: CohubHttpClient, input: string): Promise<WorkGetResponse>;
|
|
13
|
+
export {};
|
package/dist/work-ref.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
2
|
+
const USERNAME_PATTERN = /^(?!-)(?!.*--)[a-z0-9-]{1,39}(?<!-)$/;
|
|
3
|
+
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,78}[a-z0-9])?$/;
|
|
4
|
+
function decodePart(value) {
|
|
5
|
+
try {
|
|
6
|
+
return decodeURIComponent(value).trim();
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return "";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function publicRef(parts) {
|
|
13
|
+
if (parts.length !== 3)
|
|
14
|
+
return null;
|
|
15
|
+
const [username = "", spaceSlug = "", workSlug = ""] = parts.map(decodePart);
|
|
16
|
+
return USERNAME_PATTERN.test(username) && SLUG_PATTERN.test(spaceSlug) && SLUG_PATTERN.test(workSlug)
|
|
17
|
+
? { username, spaceSlug, workSlug }
|
|
18
|
+
: null;
|
|
19
|
+
}
|
|
20
|
+
function parseUrlRef(value) {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(value);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
29
|
+
if (url.protocol === "cohub:" && url.hostname === "works")
|
|
30
|
+
return publicRef(parts) ?? null;
|
|
31
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
32
|
+
return null;
|
|
33
|
+
if (parts.length === 4 && parts[0] === "spaces" && UUID_PATTERN.test(parts[1] ?? "") && parts[2] === "works" && UUID_PATTERN.test(parts[3] ?? "")) {
|
|
34
|
+
return { id: parts[3] };
|
|
35
|
+
}
|
|
36
|
+
if (parts.length === 4 && parts[2] === "w")
|
|
37
|
+
return publicRef([parts[0], parts[1], parts[3]]);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function parseWorkRef(input) {
|
|
41
|
+
const value = input.trim();
|
|
42
|
+
if (UUID_PATTERN.test(value))
|
|
43
|
+
return { id: value };
|
|
44
|
+
const parsedUrl = parseUrlRef(value.includes("://") ? value : value.startsWith("/") ? `https://cohub.invalid${value}` : value);
|
|
45
|
+
if (parsedUrl)
|
|
46
|
+
return parsedUrl;
|
|
47
|
+
const parts = value.split("/").filter(Boolean);
|
|
48
|
+
const parsedPublic = parts.length === 3 ? publicRef(parts) : null;
|
|
49
|
+
if (parsedPublic)
|
|
50
|
+
return parsedPublic;
|
|
51
|
+
throw new Error("Work must be an id, public URL, cohub://works URI, or username/space/work reference");
|
|
52
|
+
}
|
|
53
|
+
export function formatWorkRef(ref) {
|
|
54
|
+
return "id" in ref ? ref.id : `${ref.username}/${ref.spaceSlug}/${ref.workSlug}`;
|
|
55
|
+
}
|
|
56
|
+
export function getWorkByRef(client, input) {
|
|
57
|
+
const ref = parseWorkRef(input);
|
|
58
|
+
return "id" in ref
|
|
59
|
+
? client.works.get(ref.id)
|
|
60
|
+
: client.works.getBySlug(ref.username, ref.spaceSlug, ref.workSlug);
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"commander": "^15.0.0",
|
|
20
20
|
"pixi.js": "^8.19.0",
|
|
21
21
|
"sharp": "^0.35.3",
|
|
22
|
-
"@neta-art/cohub": "
|
|
22
|
+
"@neta-art/cohub": "5.1.0"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|