@neta-art/cohub-cli 3.5.2 → 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 CHANGED
@@ -275,7 +275,8 @@ Publish and manage Work entries from a Space workspace. Public Work URLs require
275
275
  cohub profile update --username <username>
276
276
  cohub spaces update <spaceId> --slug <space-slug>
277
277
  cohub -s <spaceId> works ls --json
278
- 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>
279
280
  cohub -s <spaceId> works publish demo --file dist/index.html
280
281
  cohub -s <spaceId> works publish site --dir dist
281
282
  cohub -s <spaceId> works publish app --port 3000
@@ -290,7 +291,7 @@ Resolve a published Work by public identity:
290
291
  cohub works resolve <workSlug> --owner <username> --space-slug <spaceSlug>
291
292
  ```
292
293
 
293
- Use `--json` for machine-readable output. The resolve command requires both `--owner` and `--space-slug` so missing public profile data fails with a clear message.
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.
294
295
 
295
296
  Realtime rooms use a published Work's runtime identity, so they are available
296
297
  through `client.work.realtime` in the SDK rather than as CLI commands.
@@ -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 <id>")
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 (id, opts) => {
151
+ .action(async (work, opts) => {
150
152
  const client = createClient();
151
153
  try {
152
- const result = await client.works.get(id);
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 {};
@@ -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.5.2",
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": "5.0.0"
22
+ "@neta-art/cohub": "5.1.0"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"