@neta-art/cohub-cli 8.4.1 → 9.1.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 (70) hide show
  1. package/README.md +66 -30
  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 +365 -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 +14 -9
  24. package/dist/commands/sandboxd-binary.d.ts +1 -1
  25. package/dist/commands/sandboxd-binary.js +2 -23
  26. package/dist/commands/search.js +15 -7
  27. package/dist/commands/space-activity.js +3 -5
  28. package/dist/commands/space-displays.d.ts +2 -0
  29. package/dist/commands/space-displays.js +347 -0
  30. package/dist/commands/space-files.d.ts +5 -0
  31. package/dist/commands/space-files.js +215 -0
  32. package/dist/commands/space-invitations.js +3 -3
  33. package/dist/commands/spaces.d.ts +3 -11
  34. package/dist/commands/spaces.js +146 -75
  35. package/dist/index.js +8 -5
  36. package/dist/runtime/launch.d.ts +2 -0
  37. package/dist/runtime/launch.js +38 -8
  38. package/dist/runtime/presentation.d.ts +1 -0
  39. package/dist/runtime/presentation.js +3 -2
  40. package/dist/runtime/supervisor.d.ts +1 -0
  41. package/dist/runtime/supervisor.js +2 -1
  42. package/dist/space-files/limit.d.ts +2 -0
  43. package/dist/space-files/limit.js +16 -0
  44. package/dist/space-files/operands.d.ts +16 -0
  45. package/dist/space-files/operands.js +33 -0
  46. package/dist/space-files/transfer.d.ts +53 -0
  47. package/dist/space-files/transfer.js +296 -0
  48. package/dist/space-files/upload.d.ts +14 -0
  49. package/dist/space-files/upload.js +53 -0
  50. package/dist/space.d.ts +18 -16
  51. package/dist/space.js +86 -39
  52. package/dist/web.d.ts +1 -0
  53. package/dist/web.js +6 -0
  54. package/package.json +2 -2
  55. package/dist/commands/board-domain.d.ts +0 -2
  56. package/dist/commands/board-domain.js +0 -16
  57. package/dist/commands/boards/animation.d.ts +0 -2
  58. package/dist/commands/boards/animation.js +0 -171
  59. package/dist/commands/boards/appearance.d.ts +0 -2
  60. package/dist/commands/boards/appearance.js +0 -127
  61. package/dist/commands/boards/batch.d.ts +0 -4
  62. package/dist/commands/boards/batch.js +0 -62
  63. package/dist/commands/boards/connections.d.ts +0 -2
  64. package/dist/commands/boards/connections.js +0 -95
  65. package/dist/commands/boards/context.d.ts +0 -31
  66. package/dist/commands/boards/context.js +0 -71
  67. package/dist/commands/boards/items.d.ts +0 -2
  68. package/dist/commands/boards/items.js +0 -184
  69. package/dist/commands/boards/transactions.d.ts +0 -12
  70. package/dist/commands/boards/transactions.js +0 -69
@@ -0,0 +1,53 @@
1
+ import { type SpaceFsCopyResult, type SpaceFsCopyStats, type SpaceFsTreeResponse } from "@neta-art/cohub";
2
+ import { type SpaceFiles } from "./upload.js";
3
+ export type CopyOptions = {
4
+ recursive: boolean;
5
+ noClobber: boolean;
6
+ preserveTimestamps: boolean;
7
+ };
8
+ export declare class CopyAbortError extends Error {
9
+ name: string;
10
+ }
11
+ export declare const emptyCopyResult: () => SpaceFsCopyResult;
12
+ export declare function mergeCopyResults(results: SpaceFsCopyResult[]): SpaceFsCopyResult;
13
+ type SpaceNode = {
14
+ type: "dir";
15
+ tree: SpaceFsTreeResponse;
16
+ } | {
17
+ type: "file";
18
+ } | null;
19
+ export declare function statSpacePath(files: SpaceFiles, path: string): Promise<SpaceNode>;
20
+ export declare function checkDestination(input: {
21
+ sourceCount: number;
22
+ display: string;
23
+ stat: (path: string) => Promise<"dir" | "file" | null>;
24
+ path: string;
25
+ parent: string;
26
+ }): Promise<boolean>;
27
+ export declare const spaceNodeType: (files: SpaceFiles, path: string) => Promise<"dir" | "file" | null>;
28
+ export declare const localNodeType: (path: string) => Promise<"dir" | "file" | null>;
29
+ export declare function uploadToSpace(input: {
30
+ files: SpaceFiles;
31
+ sources: string[];
32
+ destination: string;
33
+ intoDirectory: boolean;
34
+ options: CopyOptions;
35
+ onProgress: (stats: SpaceFsCopyStats) => void;
36
+ }): Promise<SpaceFsCopyResult>;
37
+ export declare function downloadFromSpace(input: {
38
+ sources: Array<{
39
+ files: SpaceFiles;
40
+ path: string;
41
+ }>;
42
+ destination: string;
43
+ intoDirectory: boolean;
44
+ options: CopyOptions;
45
+ onProgress: (stats: SpaceFsCopyStats) => void;
46
+ }): Promise<SpaceFsCopyResult>;
47
+ export declare function copyLocal(input: {
48
+ sources: string[];
49
+ destination: string;
50
+ intoDirectory: boolean;
51
+ options: CopyOptions;
52
+ }): Promise<SpaceFsCopyResult>;
53
+ export {};
@@ -0,0 +1,296 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createWriteStream } from "node:fs";
3
+ import { cp, lstat, mkdir, readdir, rename, rm, stat, utimes } from "node:fs/promises";
4
+ import { dirname, join, posix } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { UPLOAD_MAX_FILE_BYTES } from "@neta-art/cohub";
8
+ import { createLimiter } from "./limit.js";
9
+ import { uploadLocalFiles } from "./upload.js";
10
+ export class CopyAbortError extends Error {
11
+ name = "CopyAbortError";
12
+ }
13
+ export const emptyCopyResult = () => ({
14
+ files: 0, dirs: 0, symlinks: 0, bytes: 0, paths: [], overwritten: 0, skipped: 0, errors: [],
15
+ });
16
+ export function mergeCopyResults(results) {
17
+ const merged = emptyCopyResult();
18
+ for (const result of results) {
19
+ for (const key of ["files", "dirs", "symlinks", "bytes", "overwritten", "skipped"])
20
+ merged[key] += result[key];
21
+ merged.paths.push(...result.paths);
22
+ merged.errors.push(...result.errors);
23
+ }
24
+ return merged;
25
+ }
26
+ const display = (path) => path || ".";
27
+ const targetOf = (source, destination, intoDirectory, join) => {
28
+ const name = posix.basename(source);
29
+ return intoDirectory && name ? join(destination, name) : destination;
30
+ };
31
+ const joinSpacePath = (parent, name) => (parent ? `${parent}/${name}` : name);
32
+ const lstatOrNull = (path) => lstat(path).catch(() => null);
33
+ export async function statSpacePath(files, path) {
34
+ try {
35
+ return { type: "dir", tree: await files.list(path) };
36
+ }
37
+ catch (error) {
38
+ const { status, code } = error;
39
+ if (status === 404)
40
+ return null;
41
+ if (code === "not_a_directory")
42
+ return { type: "file" };
43
+ throw error;
44
+ }
45
+ }
46
+ export async function checkDestination(input) {
47
+ const node = await input.stat(input.path);
48
+ if (input.sourceCount > 1 && node !== "dir")
49
+ throw new CopyAbortError(`target '${input.display}' is not a directory`);
50
+ if (!node && input.parent !== input.path && (await input.stat(input.parent)) !== "dir") {
51
+ throw new CopyAbortError(`cannot create '${input.display}': No such file or directory`);
52
+ }
53
+ return node === "dir";
54
+ }
55
+ export const spaceNodeType = async (files, path) => (await statSpacePath(files, path))?.type ?? null;
56
+ export const localNodeType = async (path) => {
57
+ const stats = await lstatOrNull(path);
58
+ return stats ? (stats.isDirectory() ? "dir" : "file") : null;
59
+ };
60
+ const isUploadableName = (name) => name.length > 0 && name.length <= 255 && name !== "." && name !== ".." && name.trim() === name &&
61
+ !/[<>:"/\\|?*]/.test(name) && ![...name].some((char) => char.charCodeAt(0) <= 0x1f);
62
+ export async function uploadToSpace(input) {
63
+ const { files, options } = input;
64
+ const result = emptyCopyResult();
65
+ const fail = (path, code, message) => {
66
+ result.errors.push({ path, code, message });
67
+ };
68
+ const entries = [];
69
+ const walk = async (localPath, target, stats) => {
70
+ if (stats.isFile()) {
71
+ entries.push({ localPath, relativePath: target, size: stats.size });
72
+ return;
73
+ }
74
+ if (!stats.isDirectory()) {
75
+ result.skipped += 1;
76
+ return;
77
+ }
78
+ const names = await readdir(localPath);
79
+ await Promise.all(names.map(async (name) => {
80
+ const child = join(localPath, name);
81
+ const childStats = await stat(child).catch(() => null);
82
+ if (childStats)
83
+ await walk(child, joinSpacePath(target, name), childStats);
84
+ }));
85
+ };
86
+ for (const source of input.sources) {
87
+ const stats = await stat(source).catch(() => null);
88
+ if (!stats) {
89
+ fail(source, "path_not_found", `cannot stat '${source}': No such file or directory`);
90
+ continue;
91
+ }
92
+ if (stats.isDirectory() && !options.recursive) {
93
+ fail(source, "is_a_directory", `-r not specified; omitting directory '${source}'`);
94
+ continue;
95
+ }
96
+ const target = targetOf(source, input.destination, input.intoDirectory, joinSpacePath);
97
+ await walk(source, target, stats);
98
+ result.paths.push(target);
99
+ }
100
+ const parents = [...new Set(entries.map((entry) => posix.dirname(entry.relativePath)).map((dir) => (dir === "." ? "" : dir)))];
101
+ const limit = createLimiter(8);
102
+ const existing = new Map();
103
+ await Promise.all(parents.map((dir) => limit(async () => {
104
+ const node = await statSpacePath(files, dir);
105
+ if (node?.type === "dir")
106
+ for (const entry of node.tree.entries)
107
+ existing.set(entry.path, entry.type);
108
+ })));
109
+ const upload = entries.filter((entry) => {
110
+ const current = existing.get(entry.relativePath);
111
+ if (current === "dir") {
112
+ fail(entry.relativePath, "is_a_directory", `cannot overwrite directory '${entry.relativePath}' with non-directory`);
113
+ return false;
114
+ }
115
+ if (current && options.noClobber) {
116
+ result.skipped += 1;
117
+ return false;
118
+ }
119
+ if (!entry.relativePath.split("/").every(isUploadableName)) {
120
+ fail(entry.relativePath, "name_invalid", `cannot upload '${entry.localPath}': unsupported file name`);
121
+ return false;
122
+ }
123
+ if (entry.size > UPLOAD_MAX_FILE_BYTES) {
124
+ fail(entry.relativePath, "file_too_large", `cannot upload '${entry.localPath}': file exceeds the upload size limit`);
125
+ return false;
126
+ }
127
+ if (current)
128
+ result.overwritten += 1;
129
+ return true;
130
+ });
131
+ const progress = { files: 0, dirs: 0, symlinks: 0, bytes: 0 };
132
+ await uploadLocalFiles(files, upload, {
133
+ onUploaded: (entry) => {
134
+ progress.files += 1;
135
+ progress.bytes += entry.size;
136
+ input.onProgress(progress);
137
+ },
138
+ });
139
+ return { ...result, ...progress };
140
+ }
141
+ export async function downloadFromSpace(input) {
142
+ const { options } = input;
143
+ const result = emptyCopyResult();
144
+ const fail = (path, code, message) => {
145
+ result.errors.push({ path, code, message });
146
+ };
147
+ const limit = createLimiter(6);
148
+ const report = () => input.onProgress(result);
149
+ const downloadFile = async (files, path, target, mtimeMs) => {
150
+ const current = await lstatOrNull(target);
151
+ if (current?.isDirectory())
152
+ return fail(target, "is_a_directory", `cannot overwrite directory '${target}' with non-directory`);
153
+ if (current && options.noClobber) {
154
+ result.skipped += 1;
155
+ return;
156
+ }
157
+ const temp = join(dirname(target), `.cohub-download.${randomUUID().slice(0, 8)}`);
158
+ try {
159
+ const bytes = await limit(async () => {
160
+ const { response } = await files.open(path);
161
+ let written = 0;
162
+ if (response.body) {
163
+ const body = Readable.fromWeb(response.body);
164
+ body.on("data", (chunk) => {
165
+ written += chunk.length;
166
+ });
167
+ await pipeline(body, createWriteStream(temp, { flags: "wx" }));
168
+ }
169
+ else {
170
+ await pipeline(Readable.from([]), createWriteStream(temp, { flags: "wx" }));
171
+ }
172
+ return written;
173
+ });
174
+ if (options.preserveTimestamps && mtimeMs !== undefined)
175
+ await utimes(temp, new Date(), new Date(mtimeMs));
176
+ await rename(temp, target);
177
+ result.files += 1;
178
+ result.bytes += bytes;
179
+ if (current)
180
+ result.overwritten += 1;
181
+ report();
182
+ }
183
+ catch (error) {
184
+ await rm(temp, { force: true });
185
+ fail(target, "download_failed", `cannot download '${display(path)}': ${error.message}`);
186
+ }
187
+ };
188
+ const downloadDir = async (files, tree, target) => {
189
+ const current = await lstatOrNull(target);
190
+ if (current && !current.isDirectory()) {
191
+ return fail(target, "not_a_directory", `cannot overwrite non-directory '${target}' with directory '${display(tree.path)}'`);
192
+ }
193
+ if (!current)
194
+ await mkdir(target);
195
+ result.dirs += 1;
196
+ if (tree.entries.length >= 1000) {
197
+ fail(target, "listing_truncated", `cannot copy all of '${display(tree.path)}': directories with 1000 or more entries are not fully listed`);
198
+ }
199
+ await Promise.all(tree.entries.map(async (entry) => {
200
+ const child = join(target, entry.name);
201
+ if (entry.type === "file")
202
+ return downloadFile(files, entry.path, child, entry.mtimeMs);
203
+ if (entry.type === "symlink") {
204
+ result.skipped += 1;
205
+ return;
206
+ }
207
+ const node = await limit(() => statSpacePath(files, entry.path));
208
+ if (node?.type === "dir")
209
+ await downloadDir(files, node.tree, child);
210
+ }));
211
+ };
212
+ for (const { files, path } of input.sources) {
213
+ const node = await statSpacePath(files, path);
214
+ if (!node) {
215
+ fail(path, "path_not_found", `cannot stat '${display(path)}': No such file or directory`);
216
+ continue;
217
+ }
218
+ if (node.type === "dir" && !options.recursive) {
219
+ fail(path, "is_a_directory", `-r not specified; omitting directory '${display(path)}'`);
220
+ continue;
221
+ }
222
+ const target = targetOf(path, input.destination, input.intoDirectory, join);
223
+ if (node.type === "dir") {
224
+ await downloadDir(files, node.tree, target);
225
+ }
226
+ else {
227
+ const parent = options.preserveTimestamps ? await statSpacePath(files, posix.dirname(path) === "." ? "" : posix.dirname(path)) : null;
228
+ const mtimeMs = parent?.type === "dir" ? parent.tree.entries.find((entry) => entry.path === path)?.mtimeMs : undefined;
229
+ await downloadFile(files, path, target, mtimeMs);
230
+ }
231
+ result.paths.push(target);
232
+ }
233
+ return result;
234
+ }
235
+ export async function copyLocal(input) {
236
+ const { options } = input;
237
+ const result = emptyCopyResult();
238
+ const fail = (path, code, message) => {
239
+ result.errors.push({ path, code, message });
240
+ };
241
+ for (const source of input.sources) {
242
+ const stats = await lstatOrNull(source);
243
+ if (!stats) {
244
+ fail(source, "path_not_found", `cannot stat '${source}': No such file or directory`);
245
+ continue;
246
+ }
247
+ if (stats.isDirectory() && !options.recursive) {
248
+ fail(source, "is_a_directory", `-r not specified; omitting directory '${source}'`);
249
+ continue;
250
+ }
251
+ const target = targetOf(source, input.destination, input.intoDirectory, join);
252
+ if (target === source) {
253
+ fail(source, "same_file", `'${source}' and '${target}' are the same file`);
254
+ continue;
255
+ }
256
+ if (stats.isDirectory() && target.startsWith(`${source}/`)) {
257
+ fail(source, "copy_into_itself", `cannot copy a directory, '${source}', into itself, '${target}'`);
258
+ continue;
259
+ }
260
+ try {
261
+ await cp(source, target, {
262
+ recursive: true,
263
+ force: !options.noClobber,
264
+ errorOnExist: false,
265
+ preserveTimestamps: options.preserveTimestamps,
266
+ verbatimSymlinks: true,
267
+ filter: async (from, to) => {
268
+ const node = await lstat(from);
269
+ if (node.isDirectory()) {
270
+ result.dirs += 1;
271
+ return true;
272
+ }
273
+ const existing = await lstatOrNull(to);
274
+ if (existing && options.noClobber) {
275
+ result.skipped += 1;
276
+ return false;
277
+ }
278
+ if (existing)
279
+ result.overwritten += 1;
280
+ if (node.isSymbolicLink())
281
+ result.symlinks += 1;
282
+ else {
283
+ result.files += 1;
284
+ result.bytes += node.size;
285
+ }
286
+ return true;
287
+ },
288
+ });
289
+ result.paths.push(target);
290
+ }
291
+ catch (error) {
292
+ fail(target, "copy_failed", `cannot copy '${source}': ${error.message}`);
293
+ }
294
+ }
295
+ return result;
296
+ }
@@ -0,0 +1,14 @@
1
+ import { type CohubHttpClient, type SpaceFsUploadEntry } from "@neta-art/cohub";
2
+ export type SpaceFiles = ReturnType<CohubHttpClient["space"]>["files"];
3
+ export type LocalUploadEntry = {
4
+ localPath: string;
5
+ relativePath: string;
6
+ size: number;
7
+ };
8
+ export declare function uploadLocalFiles(files: SpaceFiles, entries: LocalUploadEntry[], options?: {
9
+ targetDir?: string;
10
+ onUploaded?: (entry: LocalUploadEntry) => void;
11
+ }): Promise<{
12
+ uploaded: SpaceFsUploadEntry[];
13
+ uploadIds: string[];
14
+ }>;
@@ -0,0 +1,53 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { UPLOAD_MAX_BATCH_BYTES, UPLOAD_MAX_BATCH_FILES, } from "@neta-art/cohub";
3
+ import { putLocalFile } from "../http-put.js";
4
+ import { createLimiter } from "./limit.js";
5
+ const PUT_CONCURRENCY = 4;
6
+ function batches(entries) {
7
+ const result = [];
8
+ let batch = [];
9
+ let bytes = 0;
10
+ for (const entry of entries) {
11
+ if (batch.length > 0 && (batch.length >= UPLOAD_MAX_BATCH_FILES || bytes + entry.size > UPLOAD_MAX_BATCH_BYTES)) {
12
+ result.push(batch);
13
+ batch = [];
14
+ bytes = 0;
15
+ }
16
+ batch.push(entry);
17
+ bytes += entry.size;
18
+ }
19
+ if (batch.length > 0)
20
+ result.push(batch);
21
+ return result;
22
+ }
23
+ export async function uploadLocalFiles(files, entries, options = {}) {
24
+ const uploaded = [];
25
+ const uploadIds = [];
26
+ const limit = createLimiter(PUT_CONCURRENCY);
27
+ for (const batch of batches(entries)) {
28
+ const byId = new Map(batch.map((entry) => [randomUUID(), entry]));
29
+ const plan = await files.createUpload({
30
+ destination: { kind: "workspace", targetDir: options.targetDir },
31
+ entries: [...byId].map(([id, entry]) => ({
32
+ id,
33
+ name: entry.relativePath.split("/").at(-1) ?? entry.relativePath,
34
+ relativePath: entry.relativePath,
35
+ size: entry.size,
36
+ mimeType: null,
37
+ })),
38
+ });
39
+ await Promise.all(plan.entries.map((item) => limit(async () => {
40
+ const entry = byId.get(item.id);
41
+ if (!entry)
42
+ throw new Error(`Missing upload entry: ${item.id}`);
43
+ if (item.uploadUrl) {
44
+ await putLocalFile({ url: item.uploadUrl, filePath: entry.localPath, size: entry.size, headers: item.headers, label: entry.relativePath });
45
+ }
46
+ options.onUploaded?.(entry);
47
+ })));
48
+ const result = await files.completeUpload(plan.uploadId, { entries: plan.entries.map((item) => ({ id: item.id })) });
49
+ uploaded.push(...result.uploaded);
50
+ uploadIds.push(plan.uploadId);
51
+ }
52
+ return { uploaded, uploadIds };
53
+ }
package/dist/space.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import type { Command } from "commander";
2
+ export declare class SpaceTargetError extends Error {
3
+ readonly detail?: string | undefined;
4
+ name: string;
5
+ constructor(message: string, detail?: string | undefined);
6
+ }
2
7
  /**
3
8
  * Cache key aligned with auth: execution token is exclusive (same as
4
9
  * `resolveAccessToken`) and never falls back to a local Logto session.
@@ -12,29 +17,26 @@ export declare function identityKeyFrom(input: {
12
17
  }): string | null;
13
18
  export declare function currentIdentityKey(): string | null;
14
19
  /** Exported for tests; production always uses `CACHE_PATH`. */
15
- export declare function readDefaultSpaceCache(path: string, key: string, now?: number): string | null;
16
- export declare function clearDefaultSpaceCache(): void;
20
+ export declare function readHomeSpaceCache(path: string, key: string, now?: number): string | null;
21
+ export declare function clearHomeSpaceCache(): void;
17
22
  /** Explicit target from `-s/--space` (any ancestor) or `COHUB_SPACE_ID`, else null. */
18
23
  export declare function explicitSpace(program: Command): string | null;
19
- /**
20
- * Resolve the user's home space when no target is given. Cached locally per
21
- * identity so repeated invocations skip the network entirely, and memoized
22
- * in-process so preAction hooks and actions share a single lookup.
23
- * Network and auth failures propagate so callers can report them faithfully.
24
- */
25
- export declare function resolveDefaultSpace(): Promise<string | null>;
24
+ /** Cached per identity and memoized per process. */
25
+ export declare function resolveHomeSpace(): Promise<string>;
26
+ export declare function resolveSpaceRef(value: string): Promise<string>;
27
+ export declare function failSpaceTarget(e: unknown): never;
26
28
  /** Shared exit for commands that need a space but resolved none. */
27
29
  export declare function missingSpaceError(): never;
28
- /**
29
- * Resolve a Space from an optional explicit target, the current directory
30
- * binding, and finally the user's Home Space.
31
- */
32
30
  export declare function resolveBoundSpace(options?: {
33
31
  cwd?: string;
34
32
  bindingsPath?: string;
35
33
  }): Promise<string | null>;
36
- export declare function resolveSpaceTarget(target?: string | null, options?: {
34
+ export type SpaceTargetOptions = {
35
+ /** Only for commands that start new work. */
36
+ home?: boolean;
37
37
  cwd?: string;
38
38
  bindingsPath?: string;
39
- }): Promise<string>;
40
- export declare function resolveSpace(program: Command): Promise<string>;
39
+ };
40
+ /** Explicit target or `COHUB_SPACE_ID`, then the directory binding, then Home when allowed. */
41
+ export declare function resolveSpaceTarget(target?: string | null, options?: SpaceTargetOptions): Promise<string>;
42
+ export declare function resolveSpace(command: Command, options?: Pick<SpaceTargetOptions, "home">): Promise<string>;
package/dist/space.js CHANGED
@@ -1,15 +1,26 @@
1
1
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { resolveCohubEnvironment } from "@neta-art/cohub";
4
+ import { HOME_SPACE_SLUG, parseSpaceRef, resolveCohubEnvironment } from "@neta-art/cohub";
5
5
  import { readAuthSession } from "./auth.js";
6
6
  import { createClient } from "./client.js";
7
7
  import { getRuntimeSpaceBinding } from "./runtime/space-binding.js";
8
8
  import { error, handleHttp } from "./output.js";
9
9
  const CONFIG_DIR = join(homedir(), ".config", "cohub");
10
- const CACHE_PATH = join(CONFIG_DIR, "default-space.json");
10
+ const CACHE_PATH = join(CONFIG_DIR, "home-space.json");
11
+ /** Legacy cache that may name another user's Home; never read. */
12
+ const LEGACY_CACHE_PATH = join(CONFIG_DIR, "default-space.json");
11
13
  /** Home space is stable; a one-day TTL bounds how long a stale hit survives. */
12
14
  const CACHE_TTL_MS = 86_400_000;
15
+ const SPACE_REF_HINT = "Use a Space ID, a slug you own such as home, or username/slug.";
16
+ export class SpaceTargetError extends Error {
17
+ detail;
18
+ name = "SpaceTargetError";
19
+ constructor(message, detail) {
20
+ super(message);
21
+ this.detail = detail;
22
+ }
23
+ }
13
24
  function jwtClaim(token, key) {
14
25
  const payload = token?.split(".")[1];
15
26
  if (!payload)
@@ -46,7 +57,7 @@ export function currentIdentityKey() {
46
57
  });
47
58
  }
48
59
  /** Exported for tests; production always uses `CACHE_PATH`. */
49
- export function readDefaultSpaceCache(path, key, now = Date.now()) {
60
+ export function readHomeSpaceCache(path, key, now = Date.now()) {
50
61
  try {
51
62
  const cache = JSON.parse(readFileSync(path, "utf-8"));
52
63
  if (cache.key !== key || typeof cache.spaceId !== "string" || typeof cache.cachedAt !== "number")
@@ -59,21 +70,25 @@ export function readDefaultSpaceCache(path, key, now = Date.now()) {
59
70
  return null;
60
71
  }
61
72
  }
62
- function writeCachedDefaultSpace(key, spaceId) {
73
+ function writeHomeSpaceCache(key, spaceId) {
63
74
  try {
64
75
  mkdirSync(CONFIG_DIR, { recursive: true });
65
76
  const cache = { key, spaceId, cachedAt: Date.now() };
66
77
  writeFileSync(CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
78
+ rmSync(LEGACY_CACHE_PATH, { force: true });
67
79
  }
68
80
  catch {
69
81
  // Cache is best-effort; never fail the command over it.
70
82
  }
71
83
  }
72
- let defaultSpacePromise = null;
73
- export function clearDefaultSpaceCache() {
74
- defaultSpacePromise = null;
84
+ let homeSpacePromise = null;
85
+ const spaceRefs = new Map();
86
+ export function clearHomeSpaceCache() {
87
+ homeSpacePromise = null;
88
+ spaceRefs.clear();
75
89
  try {
76
90
  rmSync(CACHE_PATH, { force: true });
91
+ rmSync(LEGACY_CACHE_PATH, { force: true });
77
92
  }
78
93
  catch {
79
94
  // Best-effort, same as writes.
@@ -90,48 +105,80 @@ export function explicitSpace(program) {
90
105
  }
91
106
  return process.env.COHUB_SPACE_ID?.trim() || null;
92
107
  }
93
- /**
94
- * Resolve the user's home space when no target is given. Cached locally per
95
- * identity so repeated invocations skip the network entirely, and memoized
96
- * in-process so preAction hooks and actions share a single lookup.
97
- * Network and auth failures propagate so callers can report them faithfully.
98
- */
99
- export function resolveDefaultSpace() {
100
- defaultSpacePromise ??= (async () => {
108
+ /** Cached per identity and memoized per process. */
109
+ export function resolveHomeSpace() {
110
+ homeSpacePromise ??= (async () => {
101
111
  const key = currentIdentityKey();
102
- if (key) {
103
- const cached = readDefaultSpaceCache(CACHE_PATH, key);
104
- if (cached)
105
- return cached;
106
- }
107
- const space = (await createClient().spaces.getDefault()).space ?? null;
108
- // Recent-space fallback from getDefault() is not stable enough to cache.
109
- if (space?.id && space.slug === "home" && key)
110
- writeCachedDefaultSpace(key, space.id);
111
- return space?.id ?? null;
112
+ const cached = key ? readHomeSpaceCache(CACHE_PATH, key) : null;
113
+ if (cached)
114
+ return cached;
115
+ const space = await createClient().spaces.ensureHome();
116
+ if (key)
117
+ writeHomeSpaceCache(key, space.id);
118
+ return space.id;
112
119
  })();
113
- return defaultSpacePromise;
120
+ return homeSpacePromise;
121
+ }
122
+ async function lookupSpaceRef(value) {
123
+ const ref = parseSpaceRef(value);
124
+ if (!ref)
125
+ throw new SpaceTargetError(`Invalid space '${value}'`, SPACE_REF_HINT);
126
+ if (ref.kind === "id")
127
+ return ref.id;
128
+ if (ref.kind === "owned" && ref.slug === HOME_SPACE_SLUG)
129
+ return resolveHomeSpace();
130
+ const spaces = createClient().spaces;
131
+ try {
132
+ const space = ref.kind === "owned"
133
+ ? await spaces.getOwnedBySlug(ref.slug)
134
+ : await spaces.getBySlug(ref.username, ref.slug);
135
+ return space.id;
136
+ }
137
+ catch (e) {
138
+ if (e.status === 404)
139
+ throw new SpaceTargetError(`Space '${value}' not found`, SPACE_REF_HINT);
140
+ throw e;
141
+ }
142
+ }
143
+ export function resolveSpaceRef(value) {
144
+ const key = value.trim();
145
+ let resolved = spaceRefs.get(key);
146
+ if (!resolved) {
147
+ resolved = lookupSpaceRef(key);
148
+ spaceRefs.set(key, resolved);
149
+ }
150
+ return resolved;
151
+ }
152
+ export function failSpaceTarget(e) {
153
+ if (e instanceof SpaceTargetError)
154
+ return error(e.message, e.detail);
155
+ return handleHttp(e);
114
156
  }
115
157
  /** Shared exit for commands that need a space but resolved none. */
116
158
  export function missingSpaceError() {
117
- return error("No target space", "Add -s, --space <id> or set COHUB_SPACE_ID. Run `cohub auth login` to use your home space.");
159
+ return error("No target space", "Pass -s <space> (an ID, home, or username/slug), set COHUB_SPACE_ID, or run in a directory bound with `cohub runtime up`.");
118
160
  }
119
- /**
120
- * Resolve a Space from an optional explicit target, the current directory
121
- * binding, and finally the user's Home Space.
122
- */
123
161
  export async function resolveBoundSpace(options = {}) {
124
162
  const bound = await getRuntimeSpaceBinding(options.cwd ?? process.cwd(), currentIdentityKey(), options.bindingsPath).catch(handleHttp);
125
163
  return bound?.spaceId ?? null;
126
164
  }
165
+ /** Explicit target or `COHUB_SPACE_ID`, then the directory binding, then Home when allowed. */
127
166
  export async function resolveSpaceTarget(target, options = {}) {
128
167
  const explicit = target?.trim() || process.env.COHUB_SPACE_ID?.trim() || null;
129
- if (explicit)
130
- return explicit;
131
- return (await resolveBoundSpace(options))
132
- ?? (await resolveDefaultSpace().catch(handleHttp))
133
- ?? missingSpaceError();
134
- }
135
- export async function resolveSpace(program) {
136
- return resolveSpaceTarget(explicitSpace(program));
168
+ try {
169
+ if (explicit)
170
+ return await resolveSpaceRef(explicit);
171
+ const bound = await resolveBoundSpace(options);
172
+ if (bound)
173
+ return bound;
174
+ if (options.home)
175
+ return await resolveHomeSpace();
176
+ }
177
+ catch (e) {
178
+ return failSpaceTarget(e);
179
+ }
180
+ return missingSpaceError();
181
+ }
182
+ export function resolveSpace(command, options = {}) {
183
+ return resolveSpaceTarget(explicitSpace(command), options);
137
184
  }
package/dist/web.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function webUrl(path: string): string;
package/dist/web.js ADDED
@@ -0,0 +1,6 @@
1
+ import { resolveCohubEnvironment } from "@neta-art/cohub";
2
+ export function webUrl(path) {
3
+ const origin = process.env.COHUB_WEB_URL?.replace(/\/+$/, "") ??
4
+ `https://${resolveCohubEnvironment() === "prod" ? "" : "dev."}cohub.live`;
5
+ return `${origin}${path}`;
6
+ }