@neta-art/cohub-cli 8.4.1 → 9.0.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.
Files changed (68) hide show
  1. package/README.md +25 -2
  2. package/dist/board-export.d.ts +21 -36
  3. package/dist/board-export.js +163 -88
  4. package/dist/board-kernel.d.ts +2 -3
  5. package/dist/board-kernel.js +3428 -3770
  6. package/dist/board-time.d.ts +3 -0
  7. package/dist/board-time.js +28 -0
  8. package/dist/commands/apps.js +2 -2
  9. package/dist/commands/auth.js +2 -2
  10. package/dist/commands/boards/examples.d.ts +8 -2
  11. package/dist/commands/boards/examples.js +79 -411
  12. package/dist/commands/boards.d.ts +4 -9
  13. package/dist/commands/boards.js +346 -286
  14. package/dist/commands/desktop.d.ts +1 -5
  15. package/dist/commands/desktop.js +5 -16
  16. package/dist/commands/generations.js +1 -1
  17. package/dist/commands/me.js +1 -1
  18. package/dist/commands/public.js +3 -3
  19. package/dist/commands/references.js +3 -0
  20. package/dist/commands/referrals.js +2 -2
  21. package/dist/commands/run.js +3 -3
  22. package/dist/commands/runtime.d.ts +1 -1
  23. package/dist/commands/runtime.js +12 -8
  24. package/dist/commands/search.js +15 -7
  25. package/dist/commands/space-activity.js +3 -5
  26. package/dist/commands/space-displays.d.ts +2 -0
  27. package/dist/commands/space-displays.js +347 -0
  28. package/dist/commands/space-files.d.ts +5 -0
  29. package/dist/commands/space-files.js +215 -0
  30. package/dist/commands/space-invitations.js +3 -3
  31. package/dist/commands/spaces.d.ts +3 -11
  32. package/dist/commands/spaces.js +146 -75
  33. package/dist/index.js +7 -4
  34. package/dist/runtime/launch.d.ts +2 -0
  35. package/dist/runtime/launch.js +35 -8
  36. package/dist/runtime/presentation.d.ts +1 -0
  37. package/dist/runtime/presentation.js +3 -2
  38. package/dist/runtime/supervisor.d.ts +1 -0
  39. package/dist/runtime/supervisor.js +2 -1
  40. package/dist/space-files/limit.d.ts +2 -0
  41. package/dist/space-files/limit.js +16 -0
  42. package/dist/space-files/operands.d.ts +16 -0
  43. package/dist/space-files/operands.js +33 -0
  44. package/dist/space-files/transfer.d.ts +53 -0
  45. package/dist/space-files/transfer.js +296 -0
  46. package/dist/space-files/upload.d.ts +14 -0
  47. package/dist/space-files/upload.js +53 -0
  48. package/dist/space.d.ts +18 -16
  49. package/dist/space.js +86 -39
  50. package/dist/web.d.ts +1 -0
  51. package/dist/web.js +6 -0
  52. package/package.json +2 -2
  53. package/dist/commands/board-domain.d.ts +0 -2
  54. package/dist/commands/board-domain.js +0 -16
  55. package/dist/commands/boards/animation.d.ts +0 -2
  56. package/dist/commands/boards/animation.js +0 -171
  57. package/dist/commands/boards/appearance.d.ts +0 -2
  58. package/dist/commands/boards/appearance.js +0 -127
  59. package/dist/commands/boards/batch.d.ts +0 -4
  60. package/dist/commands/boards/batch.js +0 -62
  61. package/dist/commands/boards/connections.d.ts +0 -2
  62. package/dist/commands/boards/connections.js +0 -95
  63. package/dist/commands/boards/context.d.ts +0 -31
  64. package/dist/commands/boards/context.js +0 -71
  65. package/dist/commands/boards/items.d.ts +0 -2
  66. package/dist/commands/boards/items.js +0 -184
  67. package/dist/commands/boards/transactions.d.ts +0 -12
  68. package/dist/commands/boards/transactions.js +0 -69
@@ -0,0 +1,347 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { compileDisplayActions, DISPLAY_ELEMENT_REF_PATTERN, DISPLAY_SYSTEM_ACTIONS, DisplayActionError, } from "@neta-art/cohub";
5
+ import { createClient } from "../client.js";
6
+ import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
7
+ import { resolveSpace } from "../space.js";
8
+ import { webUrl } from "../web.js";
9
+ const DIRECTIONS = ["up", "down", "left", "right"];
10
+ const BUTTONS = ["primary", "secondary", "middle"];
11
+ const DEFAULT_MAX_SIZE = 1280;
12
+ const SETTLE_MS = 600;
13
+ const FRAMES_PATH = join(homedir(), ".cache", "cohub-cli", "display-frames.json");
14
+ const NO_DISPLAY_HINT = "Share one first: the Android app's Runtime menu, `cohub runtime up --display` on a computer, or `cohub spaces displays start` for a virtual screen.";
15
+ function parseSize(value) {
16
+ const match = /^(\d{1,5})x(\d{1,5})$/i.exec(value.trim());
17
+ const width = Number(match?.[1]);
18
+ const height = Number(match?.[2]);
19
+ if (!match || width < 1 || height < 1)
20
+ return error("Invalid size", "Use WIDTHxHEIGHT, e.g. 576x1280.");
21
+ return { width, height };
22
+ }
23
+ function parseNumber(name, value) {
24
+ const number = Number(value);
25
+ if (!Number.isFinite(number))
26
+ return error(`Invalid ${name}`, `${name} must be a number.`);
27
+ return number;
28
+ }
29
+ function isRef(value) {
30
+ return DISPLAY_ELEMENT_REF_PATTERN.test(value);
31
+ }
32
+ async function readFrames() {
33
+ try {
34
+ return JSON.parse(await readFile(FRAMES_PATH, "utf8"));
35
+ }
36
+ catch {
37
+ return {};
38
+ }
39
+ }
40
+ async function rememberFrame(spaceId, display, size) {
41
+ const frames = await readFrames();
42
+ frames[`${spaceId}/${display.id}`] = { width: size.width, height: size.height, display: { width: display.width, height: display.height } };
43
+ await mkdir(dirname(FRAMES_PATH), { recursive: true });
44
+ await writeFile(FRAMES_PATH, JSON.stringify(frames));
45
+ }
46
+ async function frameOf(spaceId, display, size) {
47
+ if (size)
48
+ return parseSize(size);
49
+ const frame = (await readFrames())[`${spaceId}/${display.id}`];
50
+ if (frame && frame.display.width === display.width && frame.display.height === display.height)
51
+ return frame;
52
+ return { width: display.width, height: display.height };
53
+ }
54
+ function pickDisplay(displays, requested) {
55
+ if (requested) {
56
+ const found = displays.find((display) => display.id === requested);
57
+ if (!found)
58
+ return error(`Display "${requested}" is not shared`, `Shared displays: ${displays.map((display) => display.id).join(", ") || "none"}`);
59
+ return found;
60
+ }
61
+ if (displays.length === 1 && displays[0])
62
+ return displays[0];
63
+ if (displays.length === 0)
64
+ return error("No display is shared with this Space", NO_DISPLAY_HINT);
65
+ return error("Several displays are shared", `Pass --display with one of: ${displays.map((display) => display.id).join(", ")}`);
66
+ }
67
+ async function target(spacesCmd, opts) {
68
+ const spaceId = await resolveSpace(spacesCmd);
69
+ const api = createClient().space(spaceId).displays;
70
+ const { displays } = await api.list();
71
+ return { spaceId, api, display: pickDisplay(displays, opts.display) };
72
+ }
73
+ function displayUrl(spaceId, displayId) {
74
+ return webUrl(`/spaces/${spaceId}?window=${encodeURIComponent(`display:${displayId}`)}`);
75
+ }
76
+ function needsHint(display) {
77
+ const needs = display.needs ?? [];
78
+ if (needs.length === 0)
79
+ return null;
80
+ if (!display.desktop)
81
+ return `${display.name || display.id} needs control: on the phone, choose Allow control in the Space's Runtime menu and enable Cohub in Accessibility settings.`;
82
+ const names = needs.map((need) => (need === "screenRecording" ? "Screen Recording" : "Accessibility")).join(" and ");
83
+ return `${display.name || display.id} needs ${names}: allow it for the Runtime's terminal in System Settings → Privacy & Security, then restart the Runtime.`;
84
+ }
85
+ async function screenshot(api, spaceId, display, maxSize, output) {
86
+ const capture = await api.capture(display.id, { maxSize });
87
+ const path = resolve(output ?? `${display.id}-${new Date().toISOString().replace(/[:.]/g, "-")}.jpg`);
88
+ await writeFile(path, Buffer.from(capture.data, "base64"));
89
+ await rememberFrame(spaceId, display, capture);
90
+ return { path, width: capture.width, height: capture.height };
91
+ }
92
+ async function act(spacesCmd, opts, actions) {
93
+ try {
94
+ const { spaceId, api, display } = await target(spacesCmd, opts);
95
+ if (!display.input)
96
+ return error("This display is view only", needsHint(display) ?? "Allow control on the device first.");
97
+ const frame = await frameOf(spaceId, display, opts.size);
98
+ const events = compileDisplayActions(actions, frame.width, frame.height);
99
+ const result = await api.input(display.id, events);
100
+ let shot;
101
+ if (opts.screenshot) {
102
+ await new Promise((done) => setTimeout(done, SETTLE_MS));
103
+ shot = await screenshot(api, spaceId, display, Math.max(frame.width, frame.height), typeof opts.screenshot === "string" ? opts.screenshot : undefined);
104
+ }
105
+ if (jsonRequested(opts))
106
+ return outJson({ display: display.id, ...result, frame: { width: frame.width, height: frame.height }, ...(shot ? { screenshot: shot } : {}) });
107
+ ok(`Performed ${actions.length} action${actions.length === 1 ? "" : "s"} on ${display.name || display.id}, in ${frame.width}x${frame.height} pixels`);
108
+ if (shot)
109
+ console.log(` Screenshot ${shot.width}x${shot.height}: ${shot.path}`);
110
+ }
111
+ catch (e) {
112
+ if (e instanceof DisplayActionError)
113
+ return error("Invalid action", e.message);
114
+ handleHttp(e);
115
+ }
116
+ }
117
+ function withTarget(command) {
118
+ return command
119
+ .option("-d, --display <id>", "Display id; defaults to the only shared display")
120
+ .option("--size <WxH>", "Coordinates are pixels of an image this size (default: the latest screenshot)")
121
+ .option("--screenshot [path]", "Save a screenshot once the screen settles")
122
+ .option("--json", "Output as JSON");
123
+ }
124
+ function pointOrRef(x, y, command) {
125
+ if (y === undefined)
126
+ return isRef(x) ? { ref: x } : error("Invalid target", `Pass x and y, or an element ref from \`cohub spaces displays tree\`, e.g. ${command} e3.12.`);
127
+ return { x: parseNumber("x", x), y: parseNumber("y", y) };
128
+ }
129
+ function formatTree(tree, frame) {
130
+ const scaleX = (frame.width - 1) || 1;
131
+ const scaleY = (frame.height - 1) || 1;
132
+ return tree.elements
133
+ .map((element) => {
134
+ const [x, y, width, height] = element.bounds;
135
+ const box = `(${Math.round(x * scaleX)},${Math.round(y * scaleY)} ${Math.round(width * scaleX)}x${Math.round(height * scaleY)})`;
136
+ const parts = [`${" ".repeat(element.depth)}${element.ref}`, element.role];
137
+ if (element.name)
138
+ parts.push(JSON.stringify(element.name));
139
+ if (element.value)
140
+ parts.push(`value=${JSON.stringify(element.value)}`);
141
+ if (element.states?.length)
142
+ parts.push(`[${element.states.join(", ")}]`);
143
+ parts.push(box);
144
+ return parts.join(" ");
145
+ })
146
+ .join("\n");
147
+ }
148
+ export function registerSpaceDisplays(spacesCmd) {
149
+ const displays = spacesCmd
150
+ .command("displays")
151
+ .description("Screens shared with a Space: view, read and control")
152
+ .hook("preAction", async () => { await resolveSpace(spacesCmd); });
153
+ displays
154
+ .command("ls")
155
+ .alias("list")
156
+ .description("List shared displays and who watches them")
157
+ .option("--json", "Output as JSON")
158
+ .action(async (opts) => {
159
+ try {
160
+ const spaceId = await resolveSpace(spacesCmd);
161
+ const result = await createClient().space(spaceId).displays.list();
162
+ if (jsonRequested(opts))
163
+ return outJson(result);
164
+ if (result.displays.length === 0)
165
+ console.log(` (no shared displays) ${NO_DISPLAY_HINT}`);
166
+ else {
167
+ table(result.displays.map((display) => ({
168
+ ...display,
169
+ size: `${display.width}x${display.height}`,
170
+ abilities: [display.stream && "view", display.capture && "capture", display.input && "control", display.tree && "tree", display.desktop && "desktop"].filter(Boolean).join(", "),
171
+ watching: display.viewers?.length ? `${display.viewers.length}${display.viewers.some((viewer) => viewer.control) ? " (controlling)" : ""}` : "—",
172
+ })), [
173
+ { key: "id", label: "ID" },
174
+ { key: "name", label: "Name" },
175
+ { key: "size", label: "Size" },
176
+ { key: "abilities", label: "Abilities" },
177
+ { key: "watching", label: "Watching" },
178
+ ]);
179
+ for (const display of result.displays) {
180
+ const hint = needsHint(display);
181
+ if (hint)
182
+ console.log(` ${hint}`);
183
+ }
184
+ }
185
+ if (result.virtual)
186
+ console.log(` Virtual screen: ${result.virtual === "running" ? "running (cohub spaces displays stop)" : "available (cohub spaces displays start)"}`);
187
+ }
188
+ catch (e) {
189
+ handleHttp(e);
190
+ }
191
+ });
192
+ displays
193
+ .command("start")
194
+ .description("Start the machine's virtual screen, e.g. in a cloud sandbox")
195
+ .option("--size <WxH>", "Screen size (default 1280x800)")
196
+ .option("--json", "Output as JSON")
197
+ .action(async (opts) => {
198
+ try {
199
+ const spaceId = await resolveSpace(spacesCmd);
200
+ if (opts.size)
201
+ parseSize(opts.size);
202
+ const result = await createClient().space(spaceId).displays.startVirtual(opts.size ? { size: opts.size } : {});
203
+ const url = result.displays[0] ? displayUrl(spaceId, result.displays[0].id) : null;
204
+ if (jsonRequested(opts))
205
+ return outJson({ ...result, url });
206
+ ok("Virtual screen running; programs started from now on draw on it");
207
+ if (url)
208
+ console.log(` Watch and take over: ${url}`);
209
+ }
210
+ catch (e) {
211
+ handleHttp(e);
212
+ }
213
+ });
214
+ displays
215
+ .command("stop")
216
+ .description("Stop the machine's virtual screen")
217
+ .option("--json", "Output as JSON")
218
+ .action(async (opts) => {
219
+ try {
220
+ const spaceId = await resolveSpace(spacesCmd);
221
+ const result = await createClient().space(spaceId).displays.stopVirtual();
222
+ if (jsonRequested(opts))
223
+ return outJson(result);
224
+ ok("Virtual screen stopped");
225
+ }
226
+ catch (e) {
227
+ handleHttp(e);
228
+ }
229
+ });
230
+ displays
231
+ .command("capture")
232
+ .description("Save a screenshot; later coordinates are its pixels")
233
+ .option("-d, --display <id>", "Display id; defaults to the only shared display")
234
+ .option("-o, --output <path>", "Where to write the image (default: <display>-<time>.jpg)")
235
+ .option("--max-size <px>", `Longest edge in pixels (default ${DEFAULT_MAX_SIZE})`)
236
+ .option("--json", "Output as JSON")
237
+ .action(async (opts) => {
238
+ try {
239
+ const { spaceId, api, display } = await target(spacesCmd, opts);
240
+ const maxSize = opts.maxSize ? parseNumber("max-size", opts.maxSize) : DEFAULT_MAX_SIZE;
241
+ const shot = await screenshot(api, spaceId, display, maxSize, opts.output);
242
+ if (jsonRequested(opts))
243
+ return outJson({ display: display.id, ...shot });
244
+ ok(`Saved ${shot.width}x${shot.height} screenshot to ${shot.path}`);
245
+ }
246
+ catch (e) {
247
+ handleHttp(e);
248
+ }
249
+ });
250
+ displays
251
+ .command("tree")
252
+ .description("Read the interface as elements: refs to act on, roles, names and boxes")
253
+ .option("-d, --display <id>", "Display id; defaults to the only shared display")
254
+ .option("--max <n>", "Most elements to return (default 300)")
255
+ .option("--actionable", "Only elements that take an action, flat: fewer tokens when looking for a target")
256
+ .option("--json", "Output as JSON")
257
+ .action(async (opts) => {
258
+ try {
259
+ const { spaceId, api, display } = await target(spacesCmd, opts);
260
+ if (!display.tree)
261
+ return error("This display has no element tree", "Use capture and coordinates instead.");
262
+ const tree = await api.tree(display.id, {
263
+ ...(opts.max ? { maxElements: parseNumber("max", opts.max) } : {}),
264
+ ...(opts.actionable ? { actionable: true } : {}),
265
+ });
266
+ if (jsonRequested(opts))
267
+ return outJson(tree);
268
+ const frame = await frameOf(spaceId, display);
269
+ console.log(formatTree(tree, frame));
270
+ console.log(`\n Boxes are x,y and size in ${frame.width}x${frame.height} pixels. Act with: tap <ref> · type <text> --into <ref> · scroll <direction> --in <ref>`);
271
+ if (tree.truncated)
272
+ console.log(" More elements exist; pass --max to read more.");
273
+ if (display.viewers?.some((viewer) => viewer.control))
274
+ console.log(" A person is watching with control; what you do here pauses while they act.");
275
+ }
276
+ catch (e) {
277
+ handleHttp(e);
278
+ }
279
+ });
280
+ withTarget(displays.command("tap <x> [y]").description("Tap or click a point, or an element by ref"))
281
+ .option("--count <n>", "2 to double-click, 3 to triple-click")
282
+ .option("--button <button>", `Mouse button on a desktop: ${BUTTONS.join(", ")}`)
283
+ .action((x, y, opts) => {
284
+ const button = BUTTONS.find((candidate) => candidate === (opts.button ?? "primary"));
285
+ if (!button)
286
+ return error("Invalid button", `Use one of: ${BUTTONS.join(", ")}`);
287
+ return act(spacesCmd, opts, [{
288
+ type: "tap", ...pointOrRef(x, y, "tap"), button,
289
+ ...(opts.count ? { count: parseNumber("count", opts.count) } : {}),
290
+ }]);
291
+ });
292
+ withTarget(displays.command("long-press <x> [y]").description("Touch and hold a point, or an element by ref"))
293
+ .option("--duration <ms>", "Hold time in milliseconds", "800")
294
+ .action((x, y, opts) => act(spacesCmd, opts, [{ type: "long_press", ...pointOrRef(x, y, "long-press"), duration_ms: parseNumber("duration", opts.duration) }]));
295
+ withTarget(displays.command("swipe <x1> <y1> <x2> <y2>").description("Swipe from one point to another"))
296
+ .option("--duration <ms>", "Swipe time in milliseconds", "300")
297
+ .action((x1, y1, x2, y2, opts) => act(spacesCmd, opts, [{
298
+ type: "swipe",
299
+ x: parseNumber("x1", x1), y: parseNumber("y1", y1),
300
+ to_x: parseNumber("x2", x2), to_y: parseNumber("y2", y2),
301
+ duration_ms: parseNumber("duration", opts.duration),
302
+ }]));
303
+ withTarget(displays.command("scroll <direction>").description("Scroll content up, down, left or right"))
304
+ .option("--amount <fraction>", "Fraction of the display, 0.1–1", "0.5")
305
+ .option("--in <ref>", "Scroll this element of the tree")
306
+ .action((direction, opts) => {
307
+ if (!DIRECTIONS.includes(direction))
308
+ return error("Invalid direction", `Use one of: ${DIRECTIONS.join(", ")}`);
309
+ return act(spacesCmd, opts, [{
310
+ type: "scroll", direction: direction, amount: parseNumber("amount", opts.amount),
311
+ ...(opts.in ? { ref: opts.in } : {}),
312
+ }]);
313
+ });
314
+ withTarget(displays.command("type <text>").description("Type into the focused field, or replace an element's text"))
315
+ .option("--into <ref>", "Replace the text of this element of the tree")
316
+ .action((text, opts) => act(spacesCmd, opts, [{ type: "type", text, ...(opts.into ? { ref: opts.into } : {}) }]));
317
+ withTarget(displays.command("key <key>").description("Press a key or shortcut, e.g. Enter or Control+a"))
318
+ .action((key, opts) => act(spacesCmd, opts, [{ type: "key", key }]));
319
+ withTarget(displays.command("press <button>").description(`Press a system button: ${DISPLAY_SYSTEM_ACTIONS.join(", ")}`))
320
+ .action((button, opts) => {
321
+ const action = DISPLAY_SYSTEM_ACTIONS.find((candidate) => candidate === button);
322
+ if (!action)
323
+ return error("Invalid button", `Use one of: ${DISPLAY_SYSTEM_ACTIONS.join(", ")}`);
324
+ return act(spacesCmd, opts, [{ type: "system", action }]);
325
+ });
326
+ withTarget(displays.command("act").description("Run a JSON array of actions from --actions or stdin"))
327
+ .option("--actions <json>", "Actions, e.g. '[{\"type\":\"tap\",\"x\":540,\"y\":1200},{\"type\":\"tap\",\"ref\":\"e3.12\"}]'")
328
+ .action(async (opts) => {
329
+ let raw = opts.actions ?? "";
330
+ if (!raw && !process.stdin.isTTY) {
331
+ const chunks = [];
332
+ for await (const chunk of process.stdin)
333
+ chunks.push(chunk);
334
+ raw = Buffer.concat(chunks).toString();
335
+ }
336
+ let actions;
337
+ try {
338
+ actions = JSON.parse(raw);
339
+ }
340
+ catch {
341
+ return error("Invalid actions", "Pass a JSON array with --actions or on stdin.");
342
+ }
343
+ if (!Array.isArray(actions) || actions.length === 0)
344
+ return error("Invalid actions", "Pass a non-empty JSON array.");
345
+ return act(spacesCmd, opts, actions);
346
+ });
347
+ }
@@ -0,0 +1,5 @@
1
+ import type { SpaceFsCopyResult, SpaceFsCopyStats } from "@neta-art/cohub";
2
+ import { type Command } from "commander";
3
+ export declare function formatBytes(bytes: number): string;
4
+ export declare function formatCopySummary(result: SpaceFsCopyStats & Pick<SpaceFsCopyResult, "overwritten" | "skipped">): string;
5
+ export declare function registerSpaceFileTransfer(filesCmd: Command, spacesCmd: Command): void;
@@ -0,0 +1,215 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { once } from "node:events";
3
+ import { dirname, posix } from "node:path";
4
+ import { Option } from "commander";
5
+ import { createClient } from "../client.js";
6
+ import { error, json as outJson, jsonRequested, ok } from "../output.js";
7
+ import { explicitSpace, failSpaceTarget, resolveSpace, resolveSpaceRef } from "../space.js";
8
+ import { InvalidFileOperandError, parseCopyOperand, parseSpaceFileOperand, } from "../space-files/operands.js";
9
+ import { checkDestination, copyLocal, CopyAbortError, downloadFromSpace, localNodeType, mergeCopyResults, spaceNodeType, uploadToSpace, } from "../space-files/transfer.js";
10
+ function fail(e) {
11
+ if (e instanceof InvalidFileOperandError || e instanceof CopyAbortError)
12
+ return error(e.message);
13
+ return failSpaceTarget(e);
14
+ }
15
+ const operandSpaceResolver = (spacesCmd) => (space) => space ? resolveSpaceRef(space) : resolveSpace(spacesCmd);
16
+ export function formatBytes(bytes) {
17
+ const units = ["B", "KB", "MB", "GB", "TB"];
18
+ let value = bytes;
19
+ let unit = 0;
20
+ while (value >= 1024 && unit < units.length - 1) {
21
+ value /= 1024;
22
+ unit += 1;
23
+ }
24
+ return unit === 0 ? `${value} B` : `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
25
+ }
26
+ const count = (value, singular, plural = `${singular}s`) => `${value} ${value === 1 ? singular : plural}`;
27
+ export function formatCopySummary(result) {
28
+ const parts = [count(result.files, "file")];
29
+ if (result.dirs)
30
+ parts.push(count(result.dirs, "directory", "directories"));
31
+ if (result.symlinks)
32
+ parts.push(count(result.symlinks, "symlink"));
33
+ let summary = `${parts.join(", ")} (${formatBytes(result.bytes)})`;
34
+ if (result.overwritten)
35
+ summary += `, ${result.overwritten} overwritten`;
36
+ if (result.skipped)
37
+ summary += `, ${result.skipped} skipped`;
38
+ return summary;
39
+ }
40
+ function createProgressLine() {
41
+ if (!process.stderr.isTTY)
42
+ return { update: () => undefined, clear: () => undefined };
43
+ let shown = false;
44
+ return {
45
+ update: (progress) => {
46
+ shown = true;
47
+ process.stderr.write(`\r\x1b[2K Copying… ${count(progress.files, "file")} (${formatBytes(progress.bytes)})`);
48
+ },
49
+ clear: () => {
50
+ if (shown)
51
+ process.stderr.write("\r\x1b[2K");
52
+ },
53
+ };
54
+ }
55
+ const spaceParent = (path) => (posix.dirname(path) === "." ? "" : posix.dirname(path));
56
+ async function copyFiles(spacesCmd, raw, opts) {
57
+ if (raw.length < 2)
58
+ return error("missing destination operand", "Usage: cohub spaces files cp [-r] <source>... <destination>");
59
+ const client = createClient();
60
+ const resolveSpaceId = operandSpaceResolver(spacesCmd);
61
+ const progress = createProgressLine();
62
+ const options = {
63
+ recursive: Boolean(opts.recursive || opts.R),
64
+ noClobber: opts.clobber === false,
65
+ preserveTimestamps: Boolean(opts.preserve),
66
+ };
67
+ try {
68
+ const declared = explicitSpace(spacesCmd) !== null;
69
+ const operands = raw.map((value) => parseCopyOperand(value, declared));
70
+ const destination = operands.pop();
71
+ const display = raw.at(-1);
72
+ const spaceSources = operands.filter((operand) => operand.kind === "space");
73
+ const localSources = operands.filter((operand) => operand.kind === "local").map((operand) => operand.path);
74
+ const results = [];
75
+ if (destination.kind === "space") {
76
+ const files = client.space(await resolveSpaceId(destination.space)).files;
77
+ const intoDirectory = await checkDestination({
78
+ sourceCount: operands.length,
79
+ display,
80
+ path: destination.path,
81
+ parent: spaceParent(destination.path),
82
+ stat: (path) => spaceNodeType(files, path),
83
+ });
84
+ if (spaceSources.length > 0) {
85
+ const started = await files.copy({
86
+ sources: await Promise.all(spaceSources.map(async (source) => ({ spaceId: await resolveSpaceId(source.space), path: source.path }))),
87
+ destination: destination.path,
88
+ ...options,
89
+ mutationId: randomUUID(),
90
+ });
91
+ results.push(await files.waitForCopy(started, { pollMs: process.stderr.isTTY ? 2_000 : 10_000, onProgress: progress.update }));
92
+ }
93
+ if (localSources.length > 0) {
94
+ results.push(await uploadToSpace({ files, sources: localSources, destination: destination.path, intoDirectory, options, onProgress: progress.update }));
95
+ }
96
+ }
97
+ else {
98
+ const intoDirectory = await checkDestination({
99
+ sourceCount: operands.length,
100
+ display,
101
+ path: destination.path,
102
+ parent: dirname(destination.path),
103
+ stat: localNodeType,
104
+ });
105
+ if (spaceSources.length > 0) {
106
+ const sources = await Promise.all(spaceSources.map(async (source) => ({
107
+ files: client.space(await resolveSpaceId(source.space)).files,
108
+ path: source.path,
109
+ })));
110
+ results.push(await downloadFromSpace({ sources, destination: destination.path, intoDirectory, options, onProgress: progress.update }));
111
+ }
112
+ if (localSources.length > 0) {
113
+ results.push(await copyLocal({ sources: localSources, destination: destination.path, intoDirectory, options }));
114
+ }
115
+ }
116
+ progress.clear();
117
+ const result = mergeCopyResults(results);
118
+ if (jsonRequested(opts)) {
119
+ outJson(result);
120
+ }
121
+ else {
122
+ for (const failure of result.errors)
123
+ process.stderr.write(`cp: ${failure.message}\n`);
124
+ if (result.errors.length === 0 || result.files + result.dirs + result.symlinks > 0)
125
+ ok(`Copied ${formatCopySummary(result)}`);
126
+ }
127
+ if (result.errors.length > 0)
128
+ process.exit(1);
129
+ }
130
+ catch (e) {
131
+ progress.clear();
132
+ fail(e);
133
+ }
134
+ }
135
+ async function writeFileBody(response, path) {
136
+ const reader = response.body?.getReader();
137
+ if (!reader)
138
+ return;
139
+ let first = true;
140
+ while (true) {
141
+ const { done, value } = await reader.read();
142
+ if (done)
143
+ return;
144
+ if (first && process.stdout.isTTY && value.includes(0)) {
145
+ await reader.cancel();
146
+ return error(`'${path}' is a binary file`, `Redirect it to a file: cohub spaces files cat ${path} > <file>`);
147
+ }
148
+ first = false;
149
+ if (!process.stdout.write(value))
150
+ await once(process.stdout, "drain");
151
+ }
152
+ }
153
+ async function catFiles(spacesCmd, paths, opts) {
154
+ process.stdout.on("error", (e) => {
155
+ if (e.code === "EPIPE")
156
+ process.exit(0);
157
+ error(e.message);
158
+ });
159
+ const client = createClient();
160
+ const resolveSpaceId = operandSpaceResolver(spacesCmd);
161
+ try {
162
+ const operands = paths.map(parseSpaceFileOperand);
163
+ if (jsonRequested(opts)) {
164
+ const files = await Promise.all(operands.map(async (operand) => client.space(await resolveSpaceId(operand.space)).files.read(operand.path)));
165
+ return outJson(files.length === 1 ? files[0] : files);
166
+ }
167
+ for (const operand of operands) {
168
+ const file = await client.space(await resolveSpaceId(operand.space)).files.open(operand.path);
169
+ await writeFileBody(file.response, operand.path);
170
+ }
171
+ }
172
+ catch (e) {
173
+ fail(e);
174
+ }
175
+ }
176
+ export function registerSpaceFileTransfer(filesCmd, spacesCmd) {
177
+ filesCmd
178
+ .command("cat <paths...>")
179
+ .description("Print file contents; binary-safe when redirected")
180
+ .option("--json", "Output file metadata and content as JSON")
181
+ .addHelpText("after", `
182
+ Paths may name another Space as <space>:<path>, where <space> is an id,
183
+ username/slug, or the slug of a Space you own.
184
+
185
+ Examples:
186
+ cohub spaces files cat README.md
187
+ cohub spaces files cat assets/logo.png > logo.png
188
+ cohub spaces files cat alice/notes:todo.md`)
189
+ .action((paths, opts) => catFiles(spacesCmd, paths, opts));
190
+ filesCmd
191
+ .command("cp <paths...>")
192
+ .description("Copy files between Spaces and the local filesystem, like scp")
193
+ .option("-r, --recursive", "Copy directories recursively")
194
+ .addOption(new Option("-R", "Same as --recursive").hideHelp())
195
+ .option("-n, --no-clobber", "Do not overwrite existing files")
196
+ .option("-p, --preserve", "Preserve modification times (not kept by uploads)")
197
+ .option("--json", "Output the copy result as JSON")
198
+ .addHelpText("after", `
199
+ Works like scp and cp: the last path is the destination, existing files are
200
+ overwritten and directories merged. <space>:<path> addresses a Space, where
201
+ <space> is an id, username/slug, or the slug of a Space you own; write
202
+ ./name for a path that contains a colon.
203
+
204
+ Bare paths address the current Space when one is declared with -s or
205
+ COHUB_SPACE_ID, as inside a Cohub sandbox; otherwise they are local files.
206
+ Absolute paths outside /workspace are always local. Copies between Spaces
207
+ run server-side; local files are uploaded or downloaded.
208
+
209
+ Examples:
210
+ cohub spaces files cp -r alice/templates:starter ./starter
211
+ cohub spaces files cp report.pdf notes:inbox/
212
+ cohub -s <spaceId> spaces files cp -r other:docs docs
213
+ cohub spaces files cp /tmp/chart.png <spaceId>:assets/`)
214
+ .action((paths, opts) => copyFiles(spacesCmd, paths, opts));
215
+ }
@@ -1,5 +1,6 @@
1
1
  import { buildSpaceInvitePath, } from "@neta-art/cohub";
2
2
  import { createClient } from "../client.js";
3
+ import { webUrl } from "../web.js";
3
4
  import { error, handleHttp, json as outJson, jsonRequested, ok, table, } from "../output.js";
4
5
  import { resolveSpace } from "../space.js";
5
6
  const SPACE_ROLES = ["host", "builder", "guest"];
@@ -34,13 +35,12 @@ export function parseSpaceInvitationCreateOptions(options) {
34
35
  return { role, ttlSeconds: days * 24 * 60 * 60, maxUses };
35
36
  }
36
37
  function invitationUrl(invitation) {
37
- const origin = process.env.COHUB_WEB_URL?.replace(/\/+$/, "") ?? "https://cohub.live";
38
- return `${origin}${buildSpaceInvitePath({
38
+ return webUrl(buildSpaceInvitePath({
39
39
  spaceId: invitation.spaceId,
40
40
  ownerUsername: invitation.ownerUsername,
41
41
  spaceSlug: invitation.spaceSlug,
42
42
  inviteCode: invitation.token,
43
- })}`;
43
+ }));
44
44
  }
45
45
  async function confirmRevoke(options) {
46
46
  if (options.yes)
@@ -1,5 +1,6 @@
1
1
  import type { CohubHttpClient, CreateSpaceInput } from "@neta-art/cohub";
2
2
  import type { Command } from "commander";
3
+ import { type LocalUploadEntry } from "../space-files/upload.js";
3
4
  export type SpaceCreateOptions = {
4
5
  name: string;
5
6
  description?: string;
@@ -9,20 +10,11 @@ export type SpaceCreateOptions = {
9
10
  spec?: string;
10
11
  json?: boolean;
11
12
  };
12
- type UploadFile = {
13
- id: string;
14
- localPath: string;
15
- relativePath: string;
16
- name: string;
17
- size: number;
18
- mimeType: string | null;
19
- };
20
13
  export declare function buildSpaceCreateInput(opts: SpaceCreateOptions): CreateSpaceInput;
21
- export declare const planUploadInput: (input: string) => Promise<UploadFile[]>;
22
- export declare function collectUploadFiles(paths: string[]): Promise<UploadFile[]>;
14
+ export declare const planUploadInput: (input: string) => Promise<LocalUploadEntry[]>;
15
+ export declare function collectUploadFiles(paths: string[]): Promise<LocalUploadEntry[]>;
23
16
  export declare function registerPrompt(program: Command): void;
24
17
  export declare function registerSpaceCreate(spacesCmd: Command, dependencies?: {
25
18
  createClient?: () => CohubHttpClient;
26
19
  }): Command;
27
20
  export declare function registerSpaces(program: Command): void;
28
- export {};