@corvio/cli 0.1.0-beta.2 → 0.1.0-beta.4

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
@@ -3,10 +3,15 @@
3
3
  Official CLI for a user's Corvio Workspace. It supports an existing `CORVIO_API_KEY` and a browser device-link login.
4
4
 
5
5
  ```bash
6
- npm install --global @corvio/cli
7
- export CORVIO_API_KEY='cvk_...'
6
+ npm install --global @corvio/cli@beta
7
+ export CORVIO_API_KEY='cvu_...'
8
8
  corvio auth status
9
+ corvio workspaces list
10
+ corvio workspaces use <workspace_id>
9
11
  corvio ask --prompt 'Summarize recurring launch risks' --json
12
+ corvio files upload --file ./research.pdf --json
13
+ corvio sync init --dir ./knowledge --root-node-id <node_id> --json
14
+ corvio update check --json
10
15
  ```
11
16
 
12
- When no environment key is configured, run `corvio auth login`. The environment key always takes precedence. See [the Corvio CLI guide](https://corvio.ai/developers/cli) for the supported command and receipt contract.
17
+ When no environment key is configured, run `corvio auth login`. Login creates one user-level `cvu_` credential; it does not freeze the browser's current Workspace. Select each operation's Workspace with `corvio workspaces use`, `--workspace`, or `CORVIO_WORKSPACE_ID`. Legacy Workspace-bound `cvk_` keys remain supported and cannot be widened to another Workspace. The environment key always takes precedence. Local sync stores only credential-free handles and revision/hash baselines in `.corvio/state.json`; Markdown lives below `corvio_docs/`, and conflicts require explicit resolution. Update checks compare npm's published `beta` tag with the API compatibility policy but never self-install. See [the Corvio CLI guide](https://corvio.ai/developers/cli) for the full command and receipt contract.
package/dist/cli.js CHANGED
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { lstat, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
7
+
3
8
  import {
4
9
  ApiClient,
5
10
  CliError,
6
11
  EXIT,
12
+ clearWorkspaceContext,
7
13
  clearWorkspaceCredential,
8
14
  idempotencyKey,
9
15
  listOption,
@@ -12,16 +18,20 @@ import {
12
18
  openBrowser,
13
19
  option,
14
20
  readTextFile,
21
+ readWorkspaceContext,
15
22
  readWorkspaceCredential,
16
23
  requireConfirmation,
24
+ resolveNpmUpdateStatus,
17
25
  runMain,
18
26
  sleep,
27
+ storeWorkspaceContext,
19
28
  storeWorkspaceCredential,
20
29
  writeResult,
30
+ writeBinaryFile,
21
31
  writeTextFile,
22
32
  } from "./core.js";
23
33
 
24
- const VERSION = "0.1.0-beta.2";
34
+ const VERSION = "0.1.0-beta.4";
25
35
  const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
26
36
  const DEFAULT_APP_BASE = "https://corvio.ai";
27
37
  const USER_AGENT = `corvio-cli/${VERSION}`;
@@ -30,18 +40,24 @@ const HELP = `Corvio Workspace CLI ${VERSION}
30
40
 
31
41
  Usage:
32
42
  corvio auth login|status|logout
43
+ corvio workspaces list|use|current [id]
33
44
  corvio capabilities
34
45
  corvio ask --prompt <text> [--conversation-id <id>] [--allow-actions]
35
46
  corvio questions list|get [id]
36
47
  corvio conversations list|get [id]
37
48
  corvio search <query>
38
49
  corvio docs list|get|create|update|archive|share|open [id]
50
+ corvio files list|get|download|upload|organize|open [id]
51
+ corvio sync init|status|plan|pull|push|resolve
52
+ corvio update check
39
53
 
40
54
  Global options:
41
- --api-base <url> --json --no-input --yes --help --version
55
+ --api-base <url> --workspace <id> --json --no-input --yes --help --version
42
56
 
43
57
  Authentication:
44
- CORVIO_API_KEY=cvk_... takes precedence over the stored device-link login.
58
+ CORVIO_API_KEY=cvu_... takes precedence over the stored device-link login.
59
+ Legacy Workspace-bound cvk_ keys remain supported.
60
+ Select the target with 'corvio workspaces use <id>', --workspace, or CORVIO_WORKSPACE_ID.
45
61
  Run 'corvio auth login' when no environment key is configured.
46
62
  `;
47
63
 
@@ -49,7 +65,23 @@ function baseUrl(options) {
49
65
  return normalizeApiBase(options["api-base"] || process.env.CORVIO_API_BASE, DEFAULT_API_BASE);
50
66
  }
51
67
 
52
- async function client(options, { auth = true } = {}) {
68
+ async function resolveWorkspaceSelection(options, credential) {
69
+ const explicit = String(options.workspace || options["workspace-id"] || "").trim();
70
+ if (explicit) return { workspaceId: explicit, workspaceName: null, source: "option" };
71
+ const legacyWorkspaceId = String(credential?.metadata?.workspace_id || "").trim();
72
+ if (legacyWorkspaceId) {
73
+ return {
74
+ workspaceId: legacyWorkspaceId,
75
+ workspaceName: String(credential?.metadata?.workspace_name || "").trim() || null,
76
+ source: "legacy_credential",
77
+ };
78
+ }
79
+ const context = await readWorkspaceContext();
80
+ if (context?.workspaceId) return context;
81
+ return null;
82
+ }
83
+
84
+ async function client(options, { auth = true, workspace = "required" } = {}) {
53
85
  const credential = auth ? await readWorkspaceCredential() : null;
54
86
  if (auth && !credential) {
55
87
  throw new CliError("No Corvio credential found. Set CORVIO_API_KEY or run `corvio auth login`.", {
@@ -57,16 +89,31 @@ async function client(options, { auth = true } = {}) {
57
89
  exitCode: EXIT.AUTH,
58
90
  });
59
91
  }
92
+ const selection = auth && workspace !== "none"
93
+ ? await resolveWorkspaceSelection(options, credential)
94
+ : null;
95
+ if (auth && workspace === "required" && credential?.key?.startsWith("cvu_") && !selection?.workspaceId) {
96
+ throw new CliError(
97
+ "No Corvio Workspace selected. Run `corvio workspaces list` then `corvio workspaces use <id>`.",
98
+ { code: "workspace_selection_required", exitCode: EXIT.AUTH },
99
+ );
100
+ }
60
101
  return {
61
- api: new ApiClient({ baseUrl: baseUrl(options), credential: credential?.key, userAgent: USER_AGENT }),
102
+ api: new ApiClient({
103
+ baseUrl: baseUrl(options),
104
+ credential: credential?.key,
105
+ workspaceId: selection?.workspaceId,
106
+ userAgent: USER_AGENT,
107
+ }),
62
108
  credential,
109
+ selection,
63
110
  };
64
111
  }
65
112
 
66
113
  async function authLogin(options) {
67
114
  const current = await readWorkspaceCredential();
68
115
  if (current?.source === "environment" && !options.force) {
69
- const { api } = await client(options);
116
+ const api = new ApiClient({ baseUrl: baseUrl(options), credential: current.key, userAgent: USER_AGENT });
70
117
  const identity = await api.request("/me");
71
118
  writeResult({ authenticated: true, source: "environment", identity }, { json: options.json });
72
119
  return;
@@ -85,6 +132,10 @@ async function authLogin(options) {
85
132
  "documents:read",
86
133
  "documents:write",
87
134
  "documents:share",
135
+ "assets:read",
136
+ "assets:write",
137
+ "sync:read",
138
+ "sync:write",
88
139
  ],
89
140
  },
90
141
  });
@@ -127,7 +178,9 @@ async function authLogin(options) {
127
178
  authenticated: true,
128
179
  source: "device_link",
129
180
  profile: stored.profile,
181
+ user: identity.user,
130
182
  workspace: identity.workspace,
183
+ workspaces: identity.workspaces || [],
131
184
  scopes: identity.api_key?.scopes || [],
132
185
  }, { json: options.json });
133
186
  }
@@ -138,7 +191,12 @@ async function authStatus(options) {
138
191
  writeResult({ authenticated: false, sources: ["CORVIO_API_KEY", "device_link"] }, { json: options.json });
139
192
  return;
140
193
  }
141
- const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
194
+ const selection = await resolveWorkspaceSelection(options, credential);
195
+ const api = new ApiClient({
196
+ baseUrl: baseUrl(options),
197
+ credential: credential.key,
198
+ userAgent: USER_AGENT,
199
+ });
142
200
  const identity = await api.request("/me");
143
201
  writeResult({
144
202
  authenticated: true,
@@ -146,6 +204,8 @@ async function authStatus(options) {
146
204
  profile: credential.profile,
147
205
  user: identity.user,
148
206
  workspace: identity.workspace,
207
+ workspaces: identity.workspaces || [],
208
+ selection,
149
209
  api_key: identity.api_key,
150
210
  }, { json: options.json });
151
211
  }
@@ -165,24 +225,80 @@ async function authLogout(options) {
165
225
  const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
166
226
  await api.request("/auth/credentials/current:revoke", { method: "POST" });
167
227
  await clearWorkspaceCredential();
228
+ await clearWorkspaceContext();
168
229
  writeResult({ authenticated: false, revoked: true, profile: credential.profile }, { json: options.json });
169
230
  }
170
231
 
171
232
  async function capabilities(options) {
172
- const { api, credential } = await client(options);
233
+ const { api, credential, selection } = await client(options, { workspace: "optional" });
173
234
  const [index, identity] = await Promise.all([api.request("", { auth: false }), api.request("/me")]);
174
235
  writeResult({
175
236
  cli_version: VERSION,
176
237
  auth_source: credential.source,
238
+ workspace_selection: selection,
177
239
  api: index,
178
240
  principal: identity,
179
- unavailable: {
180
- files: "Public Workspace Asset upload/organization is not advertised by /v1 yet.",
181
- sync: "Public Local Sync adapters are not advertised by /v1 yet.",
182
- },
241
+ update: await buildUpdateStatus(index, { allowRegistryFailure: true }),
183
242
  }, { json: options.json });
184
243
  }
185
244
 
245
+ async function workspaces(options, action, id) {
246
+ const credential = await readWorkspaceCredential();
247
+ if (!credential) {
248
+ throw new CliError("No Corvio credential found. Run `corvio auth login`.", {
249
+ code: "auth_required",
250
+ exitCode: EXIT.AUTH,
251
+ });
252
+ }
253
+ if (action === "list") {
254
+ const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
255
+ if (credential.key.startsWith("cvk_")) {
256
+ const identity = await api.request("/me");
257
+ return writeResult({ data: identity.workspace ? [identity.workspace] : [], legacy_bound: true }, { json: options.json });
258
+ }
259
+ return writeResult(await api.request("/workspaces"), { json: options.json });
260
+ }
261
+ const selection = await resolveWorkspaceSelection(options, credential);
262
+ if (action === "current") {
263
+ if (!selection?.workspaceId) {
264
+ if (credential.key.startsWith("cvk_")) {
265
+ const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
266
+ const identity = await api.request("/me");
267
+ return writeResult(
268
+ { selected: Boolean(identity.workspace), workspace: identity.workspace, source: "legacy_credential" },
269
+ { json: options.json },
270
+ );
271
+ }
272
+ return writeResult({ selected: false, workspace: null }, { json: options.json });
273
+ }
274
+ const api = new ApiClient({
275
+ baseUrl: baseUrl(options),
276
+ credential: credential.key,
277
+ workspaceId: selection.workspaceId,
278
+ userAgent: USER_AGENT,
279
+ });
280
+ const identity = await api.request("/me");
281
+ return writeResult({ selected: true, workspace: identity.workspace, source: selection.source }, { json: options.json });
282
+ }
283
+ if (action === "use") {
284
+ const workspaceId = String(id || options.id || options.workspace || options["workspace-id"] || "").trim();
285
+ if (!workspaceId) throw new CliError("Provide a Workspace ID.", { code: "workspace_selection_required" });
286
+ const api = new ApiClient({
287
+ baseUrl: baseUrl(options),
288
+ credential: credential.key,
289
+ workspaceId,
290
+ userAgent: USER_AGENT,
291
+ });
292
+ const identity = await api.request("/me");
293
+ if (!identity.workspace?.id) {
294
+ throw new CliError("Corvio did not resolve the selected Workspace.", { code: "workspace_selection_failed" });
295
+ }
296
+ await storeWorkspaceContext({ workspaceId: identity.workspace.id, workspaceName: identity.workspace.name });
297
+ return writeResult({ selected: true, workspace: identity.workspace }, { json: options.json });
298
+ }
299
+ throw new CliError("workspaces action must be list, use, or current.");
300
+ }
301
+
186
302
  async function ask(options, positionals) {
187
303
  const prompt = String(options.prompt || positionals.join(" ")).trim();
188
304
  if (!prompt) throw new CliError("Provide --prompt <text> or a positional prompt.");
@@ -295,28 +411,530 @@ async function docs(options, action, id) {
295
411
  writeResult(result, { json: options.json });
296
412
  }
297
413
 
414
+ async function buildUpdateStatus(index, { allowRegistryFailure = false } = {}) {
415
+ return resolveNpmUpdateStatus({
416
+ currentVersion: VERSION,
417
+ policy: index?.distribution?.workspace_cli || {
418
+ package: "@corvio/cli",
419
+ dist_tag: "beta",
420
+ },
421
+ userAgent: USER_AGENT,
422
+ allowRegistryFailure,
423
+ });
424
+ }
425
+
426
+ function updateCachePath() {
427
+ const root = process.env.CORVIO_CONFIG_DIR
428
+ ? resolve(process.env.CORVIO_CONFIG_DIR)
429
+ : process.env.XDG_CACHE_HOME
430
+ ? join(resolve(process.env.XDG_CACHE_HOME), "corvio")
431
+ : join(homedir(), ".cache", "corvio");
432
+ return join(root, "update-check.json");
433
+ }
434
+
435
+ async function readUpdateCache() {
436
+ try {
437
+ return JSON.parse(await readFile(updateCachePath(), "utf8"));
438
+ } catch {
439
+ return null;
440
+ }
441
+ }
442
+
443
+ async function writeUpdateCache(payload) {
444
+ const target = updateCachePath();
445
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
446
+ const temporary = `${target}.${randomUUID()}.tmp`;
447
+ await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
448
+ await rename(temporary, target);
449
+ }
450
+
451
+ async function updateCheck(options) {
452
+ const { api } = await client(options, { auth: false });
453
+ const index = await api.request("", { auth: false });
454
+ const result = await buildUpdateStatus(index);
455
+ await writeUpdateCache({ checked_at: new Date().toISOString(), result }).catch(() => {});
456
+ writeResult(result, { json: options.json });
457
+ }
458
+
459
+ async function maybeNotifyUpdate(options) {
460
+ if (
461
+ options.json
462
+ || options["no-input"]
463
+ || process.env.CI
464
+ || process.env.CORVIO_DISABLE_UPDATE_CHECK === "1"
465
+ ) return;
466
+ const cache = await readUpdateCache();
467
+ const checkedAt = Date.parse(String(cache?.checked_at || ""));
468
+ let result = cache?.result;
469
+ if (!Number.isFinite(checkedAt) || Date.now() - checkedAt > 24 * 60 * 60 * 1000) {
470
+ try {
471
+ const { api } = await client(options, { auth: false });
472
+ const index = await api.request("", { auth: false });
473
+ result = await buildUpdateStatus(index, { allowRegistryFailure: true });
474
+ await writeUpdateCache({ checked_at: new Date().toISOString(), result }).catch(() => {});
475
+ } catch {
476
+ return;
477
+ }
478
+ }
479
+ if (result?.update_available) {
480
+ process.stderr.write(
481
+ `Update available: @corvio/cli ${result.current_version} → ${result.latest_published_version}. `
482
+ + `Run: ${result.install_command}\n`,
483
+ );
484
+ } else if (result?.supported === false) {
485
+ process.stderr.write(
486
+ `This Corvio CLI (${VERSION}) is no longer supported. Run: ${result.install_command}\n`,
487
+ );
488
+ }
489
+ }
490
+
491
+ function mimeTypeFor(path) {
492
+ const extension = extname(String(path)).toLowerCase();
493
+ return ({
494
+ ".md": "text/markdown",
495
+ ".txt": "text/plain",
496
+ ".csv": "text/csv",
497
+ ".json": "application/json",
498
+ ".pdf": "application/pdf",
499
+ ".png": "image/png",
500
+ ".jpg": "image/jpeg",
501
+ ".jpeg": "image/jpeg",
502
+ ".gif": "image/gif",
503
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
504
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
505
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
506
+ })[extension] || "application/octet-stream";
507
+ }
508
+
509
+ async function files(options, action, id) {
510
+ const { api } = await client(options);
511
+ const assetId = id || options.id;
512
+ let result;
513
+ if (action === "list") {
514
+ result = await api.request("/files", { query: { limit: numberOption(options, "limit", 50) } });
515
+ } else if (action === "get") {
516
+ result = await api.request(`/files/${assetId || option(options, "id", { required: true })}`);
517
+ } else if (action === "download") {
518
+ const targetId = assetId || option(options, "id", { required: true });
519
+ const metadata = await api.request(`/files/${targetId}`);
520
+ const bytes = await api.requestBytes(`/files/${targetId}/content`);
521
+ const output = options.output || metadata.original_file_name || `${targetId}.bin`;
522
+ const written = await writeBinaryFile(output, bytes);
523
+ result = { ...metadata, output: written };
524
+ } else if (action === "upload") {
525
+ const input = resolve(String(option(options, "file", { required: true })));
526
+ const info = await stat(input);
527
+ if (!info.isFile()) throw new CliError("--file must point to a regular file.", { code: "upload_file_invalid" });
528
+ const bytes = await readFile(input);
529
+ const fileName = basename(input);
530
+ const mimeType = String(options["mime-type"] || mimeTypeFor(input));
531
+ const initialized = await api.request("/files/uploads", {
532
+ method: "POST",
533
+ body: { file_name: fileName, mime_type: mimeType, size_bytes: bytes.length },
534
+ });
535
+ await api.putUrl(initialized.upload_url, bytes, { headers: initialized.upload_headers || {} });
536
+ result = await api.request("/files/uploads:finalize", {
537
+ method: "POST",
538
+ body: {
539
+ upload_id: initialized.upload_id,
540
+ blob_name: initialized.blob_name,
541
+ file_name: fileName,
542
+ mime_type: mimeType,
543
+ size_bytes: bytes.length,
544
+ },
545
+ });
546
+ } else if (action === "organize") {
547
+ requireConfirmation(options, "Starting a file organization run");
548
+ result = await api.request(`/files/${assetId || option(options, "id", { required: true })}:organize`, {
549
+ method: "POST",
550
+ body: {
551
+ instruction: option(options, "instruction", { required: true }),
552
+ target_root_node_id: options["target-root-node-id"] || null,
553
+ },
554
+ });
555
+ } else if (action === "open") {
556
+ const targetId = assetId || option(options, "id", { required: true });
557
+ const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/a/${targetId}`;
558
+ result = { file_id: targetId, url: target, opened: options["no-input"] ? false : await openBrowser(target) };
559
+ } else {
560
+ throw new CliError("files action must be list, get, download, upload, organize, or open.");
561
+ }
562
+ writeResult(result, { json: options.json });
563
+ }
564
+
565
+ function sha256(content) {
566
+ return createHash("sha256").update(content).digest("hex");
567
+ }
568
+
569
+ function syncRoot(options) {
570
+ return resolve(String(options.dir || process.cwd()));
571
+ }
572
+
573
+ function syncStatePath(root) {
574
+ return join(root, ".corvio", "state.json");
575
+ }
576
+
577
+ async function readSyncState(options) {
578
+ const root = syncRoot(options);
579
+ let state;
580
+ try {
581
+ state = JSON.parse(await readFile(syncStatePath(root), "utf8"));
582
+ } catch (cause) {
583
+ if (cause?.code === "ENOENT") {
584
+ throw new CliError("This directory is not initialized. Run `corvio sync init --root-node-id <id>`.", {
585
+ code: "sync_not_initialized",
586
+ });
587
+ }
588
+ throw new CliError(".corvio/state.json is invalid.", { code: "sync_state_invalid" });
589
+ }
590
+ if (state.version !== 1 || !state.source_id) {
591
+ throw new CliError(".corvio/state.json has an unsupported format.", { code: "sync_state_invalid" });
592
+ }
593
+ const requestedBase = baseUrl(options);
594
+ if (state.api_base && state.api_base !== requestedBase) {
595
+ throw new CliError(
596
+ `This directory belongs to ${state.api_base}; use the matching --api-base or reinitialize it.`,
597
+ { code: "sync_api_base_mismatch" },
598
+ );
599
+ }
600
+ return { root, state };
601
+ }
602
+
603
+ async function writeSyncState(root, state) {
604
+ const target = syncStatePath(root);
605
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
606
+ const temporary = `${target}.${randomUUID()}.tmp`;
607
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
608
+ await rename(temporary, target);
609
+ }
610
+
611
+ function localSyncPath(root, relativePath) {
612
+ const parts = String(relativePath).replaceAll("\\", "/").split("/").filter(Boolean);
613
+ if (parts[0] !== "corvio_docs" || parts.some((part) => part === "." || part === "..")) {
614
+ throw new CliError(`Unsafe sync path returned by Corvio: ${relativePath}`, { code: "sync_path_unsafe" });
615
+ }
616
+ const target = resolve(root, ...parts);
617
+ if (target !== root && !target.startsWith(`${root}${sep}`)) {
618
+ throw new CliError(`Unsafe sync path returned by Corvio: ${relativePath}`, { code: "sync_path_unsafe" });
619
+ }
620
+ return target;
621
+ }
622
+
623
+ async function scanMarkdown(root) {
624
+ const mirror = join(root, "corvio_docs");
625
+ await mkdir(mirror, { recursive: true });
626
+ const manifest = [];
627
+ async function walk(directory) {
628
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
629
+ const path = join(directory, entry.name);
630
+ if (entry.isSymbolicLink()) continue;
631
+ if (entry.isDirectory()) {
632
+ await walk(path);
633
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
634
+ const bytes = await readFile(path);
635
+ const info = await lstat(path);
636
+ manifest.push({
637
+ relative_path: relative(root, path).split(sep).join("/"),
638
+ content_sha256: sha256(bytes),
639
+ size_bytes: bytes.length,
640
+ mtime_ms: Math.trunc(info.mtimeMs),
641
+ });
642
+ }
643
+ }
644
+ }
645
+ await walk(mirror);
646
+ manifest.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
647
+ return manifest;
648
+ }
649
+
650
+ async function syncManifest(root, state) {
651
+ const manifest = await scanMarkdown(root);
652
+ const mappings = Object.entries(state.mappings || {}).map(([pageId, mapping]) => ({ pageId, ...mapping }));
653
+ const pageByPath = new Map(mappings.map((mapping) => [mapping.relative_path, mapping.pageId]));
654
+ const presentPaths = new Set(manifest.map((item) => item.relative_path));
655
+ for (const item of manifest) {
656
+ const pageId = pageByPath.get(item.relative_path);
657
+ if (pageId) item.page_id = pageId;
658
+ }
659
+
660
+ // Treat a local move as the same Page only when the last-common hash gives
661
+ // one unambiguous missing-old-path -> new-path match. Duplicate content
662
+ // stays unbound and the server reports separate missing/new actions.
663
+ const unboundByHash = new Map();
664
+ for (const item of manifest.filter((candidate) => !candidate.page_id)) {
665
+ const values = unboundByHash.get(item.content_sha256) || [];
666
+ values.push(item);
667
+ unboundByHash.set(item.content_sha256, values);
668
+ }
669
+ const missingByHash = new Map();
670
+ for (const mapping of mappings.filter((candidate) => !presentPaths.has(candidate.relative_path))) {
671
+ const values = missingByHash.get(mapping.content_sha256) || [];
672
+ values.push(mapping);
673
+ missingByHash.set(mapping.content_sha256, values);
674
+ }
675
+ for (const [hash, items] of unboundByHash.entries()) {
676
+ const candidates = missingByHash.get(hash) || [];
677
+ if (items.length === 1 && candidates.length === 1) items[0].page_id = candidates[0].pageId;
678
+ }
679
+ return manifest;
680
+ }
681
+
682
+ async function syncPlan(api, state, root) {
683
+ return api.request("/sync/plan", {
684
+ method: "POST",
685
+ body: { source_id: state.source_id, local: await syncManifest(root, state) },
686
+ });
687
+ }
688
+
689
+ async function syncCommand(options, action) {
690
+ const { api, selection } = await client(options);
691
+ if (action === "init") {
692
+ const root = syncRoot(options);
693
+ await mkdir(root, { recursive: true });
694
+ if (!options.force) {
695
+ try {
696
+ await readFile(syncStatePath(root), "utf8");
697
+ throw new CliError("This directory is already initialized; pass --force to bind it again.", {
698
+ code: "sync_already_initialized",
699
+ });
700
+ } catch (cause) {
701
+ if (cause instanceof CliError) throw cause;
702
+ if (cause?.code !== "ENOENT") throw cause;
703
+ }
704
+ }
705
+ const clientId = `corvio-cli-${randomUUID()}`;
706
+ const source = await api.request("/sync/sources", {
707
+ method: "POST",
708
+ body: {
709
+ root_node_id: option(options, "root-node-id", { required: true }),
710
+ folder_name: String(options.name || basename(root)),
711
+ client_id: clientId,
712
+ },
713
+ });
714
+ const state = {
715
+ version: 1,
716
+ source_id: source.id,
717
+ root_node_id: source.root_node_id,
718
+ workspace_id: source.workspace_id || selection?.workspaceId || null,
719
+ client_id: clientId,
720
+ api_base: baseUrl(options),
721
+ mirror_root_dir: source.mirror_root_dir || "corvio_docs",
722
+ mappings: {},
723
+ initialized_at: new Date().toISOString(),
724
+ };
725
+ await mkdir(join(root, state.mirror_root_dir), { recursive: true });
726
+ await writeSyncState(root, state);
727
+ return writeResult({ source, directory: root, state_file: syncStatePath(root) }, { json: options.json });
728
+ }
729
+
730
+ const { root, state } = await readSyncState(options);
731
+ if (state.workspace_id && selection?.workspaceId && state.workspace_id !== selection.workspaceId) {
732
+ throw new CliError(
733
+ `This sync directory belongs to Workspace ${state.workspace_id}; select it before syncing.`,
734
+ { code: "sync_workspace_mismatch" },
735
+ );
736
+ }
737
+ if (action === "status") {
738
+ const remote = await api.request(`/sync/sources/${state.source_id}`);
739
+ return writeResult({ ...remote, directory: root, state_file: syncStatePath(root) }, { json: options.json });
740
+ }
741
+ if (action === "plan") {
742
+ return writeResult(await syncPlan(api, state, root), { json: options.json });
743
+ }
744
+ if (action === "pull") {
745
+ const plan = await syncPlan(api, state, root);
746
+ const conflicts = plan.actions.filter((item) => item.action === "conflict");
747
+ if (conflicts.length) {
748
+ throw new CliError("Sync has conflicts. Run `corvio sync plan --json` and resolve each conflict.", {
749
+ code: "sync_conflict",
750
+ exitCode: EXIT.CONFLICT,
751
+ details: { conflicts },
752
+ });
753
+ }
754
+ const committed = [];
755
+ const mappings = { ...(state.mappings || {}) };
756
+ for (const actionItem of plan.actions.filter((item) => item.action === "pull")) {
757
+ const item = actionItem.remote;
758
+ if (!item) continue;
759
+ const target = localSyncPath(root, item.relative_path);
760
+ const previous = mappings[item.page_id];
761
+ if (previous?.relative_path && previous.relative_path !== item.relative_path) {
762
+ const oldTarget = localSyncPath(root, previous.relative_path);
763
+ try {
764
+ const oldBytes = await readFile(oldTarget);
765
+ if (sha256(oldBytes) === previous.content_sha256) await unlink(oldTarget);
766
+ } catch (cause) {
767
+ if (cause?.code !== "ENOENT") throw cause;
768
+ }
769
+ }
770
+ await writeTextFile(target, item.markdown || "");
771
+ const info = await stat(target);
772
+ committed.push({
773
+ page_id: item.page_id,
774
+ node_id: item.node_id,
775
+ relative_path: item.relative_path,
776
+ content_sha256: item.content_sha256,
777
+ mtime_ms: Math.trunc(info.mtimeMs),
778
+ });
779
+ mappings[item.page_id] = {
780
+ node_id: item.node_id,
781
+ relative_path: item.relative_path,
782
+ content_sha256: item.content_sha256,
783
+ content_revision: item.content_revision,
784
+ };
785
+ }
786
+ if (committed.length) {
787
+ await api.request("/sync/commit", {
788
+ method: "POST",
789
+ body: { source_id: state.source_id, items: committed },
790
+ });
791
+ }
792
+ state.mappings = mappings;
793
+ state.last_pull_at = new Date().toISOString();
794
+ await writeSyncState(root, state);
795
+ return writeResult({
796
+ pulled: committed.length,
797
+ unresolved_local_missing: plan.actions.filter((item) => item.action === "local_missing"),
798
+ directory: root,
799
+ }, { json: options.json });
800
+ }
801
+ if (action === "push") {
802
+ requireConfirmation(options, "Pushing local Markdown changes to Corvio");
803
+ const plan = await syncPlan(api, state, root);
804
+ const conflicts = plan.actions.filter((item) => item.action === "conflict");
805
+ if (conflicts.length) {
806
+ throw new CliError("Sync has conflicts. Resolve them before pushing.", {
807
+ code: "sync_conflict",
808
+ exitCode: EXIT.CONFLICT,
809
+ details: { conflicts },
810
+ });
811
+ }
812
+ const results = [];
813
+ const mappings = { ...(state.mappings || {}) };
814
+ for (const actionItem of plan.actions.filter((item) => item.action === "push")) {
815
+ const path = localSyncPath(root, actionItem.relative_path);
816
+ const bytes = await readFile(path);
817
+ const info = await stat(path);
818
+ const previous = actionItem.page_id ? mappings[actionItem.page_id] : null;
819
+ const result = await api.request("/sync/push", {
820
+ method: "POST",
821
+ body: {
822
+ source_id: state.source_id,
823
+ relative_path: actionItem.relative_path,
824
+ markdown: bytes.toString("utf8"),
825
+ content_sha256: sha256(bytes),
826
+ mtime_ms: Math.trunc(info.mtimeMs),
827
+ page_id: actionItem.page_id || null,
828
+ expected_content_revision: previous?.content_revision ?? null,
829
+ },
830
+ });
831
+ results.push(result);
832
+ mappings[result.item.page_id] = {
833
+ node_id: result.item.node_id,
834
+ relative_path: result.item.relative_path,
835
+ content_sha256: result.item.content_sha256,
836
+ content_revision: result.item.content_revision,
837
+ };
838
+ }
839
+ state.mappings = mappings;
840
+ state.last_push_at = new Date().toISOString();
841
+ await writeSyncState(root, state);
842
+ return writeResult({
843
+ pushed: results.length,
844
+ results,
845
+ unresolved_local_missing: plan.actions.filter((item) => item.action === "local_missing"),
846
+ }, { json: options.json });
847
+ }
848
+ if (action === "resolve") {
849
+ requireConfirmation(options, "Resolving a sync conflict");
850
+ const conflictId = option(options, "conflict-id", { required: true });
851
+ const strategy = option(options, "strategy", { required: true });
852
+ const conflict = await api.request(`/sync/conflicts/${conflictId}`);
853
+ if (strategy === "use-remote") {
854
+ if (!conflict.remote) throw new CliError("The remote document is no longer available.", { code: "sync_remote_missing" });
855
+ const item = conflict.remote;
856
+ const target = localSyncPath(root, item.relative_path);
857
+ await writeTextFile(target, item.markdown || "");
858
+ const info = await stat(target);
859
+ await api.request("/sync/commit", {
860
+ method: "POST",
861
+ body: {
862
+ source_id: state.source_id,
863
+ items: [{
864
+ page_id: item.page_id,
865
+ node_id: item.node_id,
866
+ relative_path: item.relative_path,
867
+ content_sha256: item.content_sha256,
868
+ mtime_ms: Math.trunc(info.mtimeMs),
869
+ }],
870
+ },
871
+ });
872
+ const result = await api.request(`/sync/conflicts/${conflictId}:resolve`, {
873
+ method: "POST",
874
+ body: { strategy: "use_remote" },
875
+ });
876
+ state.mappings[item.page_id] = {
877
+ node_id: item.node_id,
878
+ relative_path: item.relative_path,
879
+ content_sha256: item.content_sha256,
880
+ content_revision: item.content_revision,
881
+ };
882
+ await writeSyncState(root, state);
883
+ return writeResult(result, { json: options.json });
884
+ }
885
+ if (strategy === "keep-local") {
886
+ const target = localSyncPath(root, conflict.relative_path);
887
+ const bytes = await readFile(target);
888
+ const info = await stat(target);
889
+ const result = await api.request("/sync/push", {
890
+ method: "POST",
891
+ body: {
892
+ source_id: state.source_id,
893
+ relative_path: conflict.relative_path,
894
+ markdown: bytes.toString("utf8"),
895
+ content_sha256: sha256(bytes),
896
+ mtime_ms: Math.trunc(info.mtimeMs),
897
+ page_id: conflict.remote?.page_id || null,
898
+ conflict_id: conflictId,
899
+ },
900
+ });
901
+ state.mappings[result.item.page_id] = {
902
+ node_id: result.item.node_id,
903
+ relative_path: result.item.relative_path,
904
+ content_sha256: result.item.content_sha256,
905
+ content_revision: result.item.content_revision,
906
+ };
907
+ await writeSyncState(root, state);
908
+ return writeResult(result, { json: options.json });
909
+ }
910
+ throw new CliError("--strategy must be use-remote or keep-local.", { code: "sync_strategy_invalid" });
911
+ }
912
+ throw new CliError("sync action must be init, status, plan, pull, push, or resolve.");
913
+ }
914
+
298
915
  async function main({ options, positionals }) {
299
916
  const [command, action, id, ...rest] = positionals;
300
917
  if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
301
918
  if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
919
+ if (command !== "update") await maybeNotifyUpdate(options);
302
920
  if (command === "auth") {
303
921
  if (action === "login") return authLogin(options);
304
922
  if (action === "status") return authStatus(options);
305
923
  if (action === "logout") return authLogout(options);
306
924
  throw new CliError("auth action must be login, status, or logout.");
307
925
  }
926
+ if (command === "workspaces" || command === "workspace") {
927
+ return workspaces(options, action || "list", id);
928
+ }
308
929
  if (command === "capabilities") return capabilities(options);
309
930
  if (command === "ask") return ask(options, [action, id, ...rest].filter(Boolean));
310
931
  if (command === "questions") return questions(options, action || "list", id);
311
932
  if (command === "conversations") return conversations(options, action || "list", id);
312
933
  if (command === "search") return search(options, [action, id, ...rest].filter(Boolean));
313
934
  if (command === "docs") return docs(options, action || "list", id);
314
- if (command === "files" || command === "sync") {
315
- throw new CliError(`${command} is not available until the public /v1 capability is released.`, {
316
- code: `${command}_capability_unavailable`,
317
- exitCode: EXIT.UNAVAILABLE,
318
- });
319
- }
935
+ if (command === "files") return files(options, action || "list", id);
936
+ if (command === "sync") return syncCommand(options, action || "status");
937
+ if (command === "update" && (action || "check") === "check") return updateCheck(options);
320
938
  throw new CliError(`Unknown command: ${command}`);
321
939
  }
322
940
 
package/dist/core.js CHANGED
@@ -105,10 +105,92 @@ export function normalizeApiBase(value, fallback) {
105
105
  return raw.endsWith("/v1") ? raw : `${raw}/v1`;
106
106
  }
107
107
 
108
+ function parseSemver(value) {
109
+ const match = String(value || "").trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
110
+ if (!match) return null;
111
+ return {
112
+ core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
113
+ pre: match[4] ? match[4].split(".") : [],
114
+ };
115
+ }
116
+
117
+ export function compareVersions(left, right) {
118
+ const a = parseSemver(left);
119
+ const b = parseSemver(right);
120
+ if (!a || !b) return 0;
121
+ for (let index = 0; index < 3; index += 1) {
122
+ if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
123
+ }
124
+ if (a.pre.length === 0 && b.pre.length > 0) return 1;
125
+ if (b.pre.length === 0 && a.pre.length > 0) return -1;
126
+ for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
127
+ if (a.pre[index] === undefined) return -1;
128
+ if (b.pre[index] === undefined) return 1;
129
+ if (a.pre[index] === b.pre[index]) continue;
130
+ const aNumber = /^\d+$/.test(a.pre[index]) ? Number.parseInt(a.pre[index], 10) : null;
131
+ const bNumber = /^\d+$/.test(b.pre[index]) ? Number.parseInt(b.pre[index], 10) : null;
132
+ if (aNumber !== null && bNumber !== null) return aNumber > bNumber ? 1 : -1;
133
+ if (aNumber !== null) return -1;
134
+ if (bNumber !== null) return 1;
135
+ return a.pre[index] > b.pre[index] ? 1 : -1;
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ export async function resolveNpmUpdateStatus({
141
+ currentVersion,
142
+ policy,
143
+ userAgent,
144
+ allowRegistryFailure = false,
145
+ fetchImpl = globalThis.fetch,
146
+ registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
147
+ }) {
148
+ const packageName = String(policy?.package || "").trim();
149
+ const distTag = String(policy?.dist_tag || "beta").trim();
150
+ if (!packageName) throw new CliError("The API did not advertise an npm package.", { code: "update_policy_invalid" });
151
+ const encoded = encodeURIComponent(packageName).replace("%40", "@");
152
+ let latestVersion = null;
153
+ let registryError = null;
154
+ try {
155
+ const registry = String(registryBase).replace(/\/+$/, "");
156
+ const response = await fetchImpl(`${registry}/${encoded}`, {
157
+ headers: { accept: "application/json", "user-agent": userAgent },
158
+ signal: AbortSignal.timeout(2500),
159
+ });
160
+ if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
161
+ const metadata = await response.json();
162
+ latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
163
+ } catch (error) {
164
+ registryError = error instanceof Error ? error.message : String(error);
165
+ if (!allowRegistryFailure) {
166
+ throw new CliError(`Unable to check npm for updates: ${registryError}`, {
167
+ code: "update_check_failed",
168
+ exitCode: EXIT.REMOTE,
169
+ });
170
+ }
171
+ }
172
+ const minimum = String(policy.minimum_supported_version || "").trim() || null;
173
+ return {
174
+ package: packageName,
175
+ channel: String(policy.channel || distTag),
176
+ dist_tag: distTag,
177
+ current_version: currentVersion,
178
+ latest_published_version: latestVersion,
179
+ reviewed_version: String(policy.reviewed_version || "").trim() || null,
180
+ minimum_supported_version: minimum,
181
+ update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
182
+ supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
183
+ install_command: String(policy.install_command || `npm install --global ${packageName}@${distTag}`),
184
+ automatic_install: false,
185
+ ...(registryError ? { registry_error: registryError } : {}),
186
+ };
187
+ }
188
+
108
189
  export class ApiClient {
109
- constructor({ baseUrl, credential = null, userAgent, fetchImpl = globalThis.fetch }) {
190
+ constructor({ baseUrl, credential = null, workspaceId = null, userAgent, fetchImpl = globalThis.fetch }) {
110
191
  this.baseUrl = baseUrl.replace(/\/+$/, "");
111
192
  this.credential = credential;
193
+ this.workspaceId = String(workspaceId || "").trim() || null;
112
194
  this.userAgent = userAgent;
113
195
  this.fetchImpl = fetchImpl;
114
196
  }
@@ -129,6 +211,10 @@ export class ApiClient {
129
211
  throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
130
212
  }
131
213
  requestHeaders.authorization = `Bearer ${this.credential}`;
214
+ if (this.workspaceId) {
215
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
216
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
217
+ }
132
218
  }
133
219
  let response;
134
220
  try {
@@ -163,6 +249,80 @@ export class ApiClient {
163
249
  }
164
250
  return payload;
165
251
  }
252
+
253
+ async requestBytes(path, { query, headers = {}, auth = true } = {}) {
254
+ const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
255
+ for (const [key, value] of Object.entries(query || {})) {
256
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
257
+ }
258
+ const requestHeaders = {
259
+ accept: "application/zip, application/octet-stream",
260
+ "user-agent": this.userAgent,
261
+ ...headers,
262
+ };
263
+ if (auth) {
264
+ if (!this.credential) {
265
+ throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
266
+ }
267
+ requestHeaders.authorization = `Bearer ${this.credential}`;
268
+ if (this.workspaceId) {
269
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
270
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
271
+ }
272
+ }
273
+ let response;
274
+ try {
275
+ response = await this.fetchImpl(url, { method: "GET", headers: requestHeaders });
276
+ } catch (cause) {
277
+ throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
278
+ code: "network_error",
279
+ exitCode: EXIT.REMOTE,
280
+ });
281
+ }
282
+ const bytes = new Uint8Array(await response.arrayBuffer());
283
+ if (!response.ok) {
284
+ const text = new TextDecoder().decode(bytes);
285
+ let payload = {};
286
+ try {
287
+ payload = JSON.parse(text);
288
+ } catch {
289
+ payload = { message: text.slice(0, 500) };
290
+ }
291
+ const envelope = payload?.error || payload?.detail || payload || {};
292
+ throw new CliError(String(envelope.message || envelope.code || `Corvio request failed (${response.status}).`), {
293
+ code: String(envelope.code || `http_${response.status}`),
294
+ status: response.status,
295
+ exitCode: exitCodeForStatus(response.status),
296
+ details: envelope.details || null,
297
+ });
298
+ }
299
+ return bytes;
300
+ }
301
+
302
+ async putUrl(url, content, { headers = {} } = {}) {
303
+ let response;
304
+ try {
305
+ response = await this.fetchImpl(String(url), {
306
+ method: "PUT",
307
+ headers,
308
+ body: content,
309
+ });
310
+ } catch (cause) {
311
+ throw new CliError(`Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`, {
312
+ code: "upload_network_error",
313
+ exitCode: EXIT.REMOTE,
314
+ });
315
+ }
316
+ if (!response.ok) {
317
+ const message = (await response.text()).slice(0, 500);
318
+ throw new CliError(message || `File upload failed (${response.status}).`, {
319
+ code: "upload_failed",
320
+ status: response.status,
321
+ exitCode: EXIT.REMOTE,
322
+ });
323
+ }
324
+ return { ok: true, status: response.status };
325
+ }
166
326
  }
167
327
 
168
328
  function configRoot(env = process.env) {
@@ -175,11 +335,19 @@ export function workspaceCredentialPath(env = process.env) {
175
335
  return join(configRoot(env), "credentials.json");
176
336
  }
177
337
 
338
+ export function workspaceContextPath(env = process.env) {
339
+ return join(configRoot(env), "workspace-context.json");
340
+ }
341
+
342
+ export function adminCredentialPath(env = process.env) {
343
+ return join(configRoot(env), "admin-credentials.json");
344
+ }
345
+
178
346
  export async function readWorkspaceCredential(env = process.env) {
179
347
  const environmentKey = String(env.CORVIO_API_KEY || "").trim();
180
348
  if (environmentKey) {
181
- if (!environmentKey.startsWith("cvk_")) {
182
- throw new CliError("CORVIO_API_KEY must be a Workspace cvk_ key.", {
349
+ if (!environmentKey.startsWith("cvu_") && !environmentKey.startsWith("cvk_")) {
350
+ throw new CliError("CORVIO_API_KEY must be a user cvu_ key or legacy Workspace cvk_ key.", {
183
351
  code: "invalid_workspace_key",
184
352
  exitCode: EXIT.AUTH,
185
353
  });
@@ -196,14 +364,15 @@ export async function readWorkspaceCredential(env = process.env) {
196
364
  }
197
365
  const profileName = String(parsed.active_profile || "default");
198
366
  const profile = parsed.profiles?.[profileName];
199
- const key = String(profile?.api_key || "").trim();
200
- if (!key.startsWith("cvk_")) {
367
+ const metadata = String(parsed.api_key || "").trim() ? parsed : profile;
368
+ const key = String(metadata?.api_key || "").trim();
369
+ if (!key.startsWith("cvu_") && !key.startsWith("cvk_")) {
201
370
  throw new CliError("Stored Corvio credentials are invalid; run `corvio auth login` again.", {
202
371
  code: "credential_store_invalid",
203
372
  exitCode: EXIT.AUTH,
204
373
  });
205
374
  }
206
- return { key, source: "device_link", profile: profileName, metadata: profile };
375
+ return { key, source: "device_link", profile: profileName, metadata };
207
376
  }
208
377
 
209
378
  export async function storeWorkspaceCredential(payload, env = process.env) {
@@ -211,28 +380,73 @@ export async function storeWorkspaceCredential(payload, env = process.env) {
211
380
  const root = dirname(path);
212
381
  await mkdir(root, { recursive: true, mode: 0o700 });
213
382
  await chmod(root, 0o700).catch(() => {});
214
- const profileName = String(payload.workspace_id || "default");
383
+ const document = {
384
+ version: 2,
385
+ api_key: payload.secret,
386
+ key_id: payload.key_id,
387
+ audience: payload.audience || "user",
388
+ scopes: payload.scopes || [],
389
+ expires_at: payload.expires_at || null,
390
+ created_at: payload.created_at || new Date().toISOString(),
391
+ source: "device_link",
392
+ };
393
+ const temporary = `${path}.${randomUUID()}.tmp`;
394
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
395
+ await chmod(temporary, 0o600);
396
+ await rename(temporary, path);
397
+ await chmod(path, 0o600);
398
+ return { path, profile: "default" };
399
+ }
400
+
401
+ export async function readWorkspaceContext(env = process.env) {
402
+ const environmentWorkspaceId = String(env.CORVIO_WORKSPACE_ID || "").trim();
403
+ if (environmentWorkspaceId) {
404
+ return { workspaceId: environmentWorkspaceId, workspaceName: null, source: "environment" };
405
+ }
406
+ const path = workspaceContextPath(env);
407
+ try {
408
+ const parsed = JSON.parse(await readFile(path, "utf8"));
409
+ const workspaceId = String(parsed.active_workspace_id || "").trim();
410
+ if (!workspaceId) return null;
411
+ return {
412
+ workspaceId,
413
+ workspaceName: String(parsed.active_workspace_name || "").trim() || null,
414
+ source: "stored",
415
+ };
416
+ } catch (cause) {
417
+ if (cause?.code === "ENOENT") return null;
418
+ throw new CliError("Stored Corvio Workspace selection is unreadable.", {
419
+ code: "workspace_context_store_invalid",
420
+ exitCode: EXIT.AUTH,
421
+ });
422
+ }
423
+ }
424
+
425
+ export async function storeWorkspaceContext({ workspaceId, workspaceName = null }, env = process.env) {
426
+ const normalizedId = String(workspaceId || "").trim();
427
+ if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
428
+ const path = workspaceContextPath(env);
429
+ const root = dirname(path);
430
+ await mkdir(root, { recursive: true, mode: 0o700 });
431
+ await chmod(root, 0o700).catch(() => {});
215
432
  const document = {
216
433
  version: 1,
217
- active_profile: profileName,
218
- profiles: {
219
- [profileName]: {
220
- api_key: payload.secret,
221
- key_id: payload.key_id,
222
- workspace_id: payload.workspace_id,
223
- workspace_name: payload.workspace_name,
224
- scopes: payload.scopes || [],
225
- created_at: payload.created_at || new Date().toISOString(),
226
- source: "device_link",
227
- },
228
- },
434
+ active_workspace_id: normalizedId,
435
+ active_workspace_name: String(workspaceName || "").trim() || null,
436
+ updated_at: new Date().toISOString(),
229
437
  };
230
438
  const temporary = `${path}.${randomUUID()}.tmp`;
231
439
  await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
232
440
  await chmod(temporary, 0o600);
233
441
  await rename(temporary, path);
234
442
  await chmod(path, 0o600);
235
- return { path, profile: profileName };
443
+ return { path, workspaceId: normalizedId };
444
+ }
445
+
446
+ export async function clearWorkspaceContext(env = process.env) {
447
+ const path = workspaceContextPath(env);
448
+ await rm(path, { force: true });
449
+ return path;
236
450
  }
237
451
 
238
452
  export async function clearWorkspaceCredential(env = process.env) {
@@ -241,16 +455,66 @@ export async function clearWorkspaceCredential(env = process.env) {
241
455
  return path;
242
456
  }
243
457
 
244
- export function readAdminCredential(env = process.env) {
458
+ export async function readAdminCredential(env = process.env) {
245
459
  const key = String(env.CORVIO_ADMIN_API_KEY || "").trim();
246
- if (!key) return null;
247
- if (!key.startsWith("cva_")) {
248
- throw new CliError("CORVIO_ADMIN_API_KEY must be a platform administrator cva_ key.", {
249
- code: "invalid_admin_key",
460
+ if (key) {
461
+ if (!key.startsWith("cva_")) {
462
+ throw new CliError("CORVIO_ADMIN_API_KEY must be a platform administrator cva_ key.", {
463
+ code: "invalid_admin_key",
464
+ exitCode: EXIT.AUTH,
465
+ });
466
+ }
467
+ return { key, source: "environment", metadata: null };
468
+ }
469
+ const path = adminCredentialPath(env);
470
+ let parsed;
471
+ try {
472
+ parsed = JSON.parse(await readFile(path, "utf8"));
473
+ } catch (cause) {
474
+ if (cause?.code === "ENOENT") return null;
475
+ throw new CliError("Stored Corvio administrator credentials are unreadable.", {
476
+ code: "admin_credential_store_invalid",
250
477
  exitCode: EXIT.AUTH,
251
478
  });
252
479
  }
253
- return { key, source: "environment" };
480
+ const storedKey = String(parsed.api_key || "").trim();
481
+ if (!storedKey.startsWith("cva_")) {
482
+ throw new CliError("Stored Corvio administrator credentials are invalid; run `corvio-admin auth login` again.", {
483
+ code: "admin_credential_store_invalid",
484
+ exitCode: EXIT.AUTH,
485
+ });
486
+ }
487
+ return { key: storedKey, source: "device_link", metadata: parsed };
488
+ }
489
+
490
+ export async function storeAdminCredential(payload, env = process.env) {
491
+ const path = adminCredentialPath(env);
492
+ const root = dirname(path);
493
+ await mkdir(root, { recursive: true, mode: 0o700 });
494
+ await chmod(root, 0o700).catch(() => {});
495
+ const document = {
496
+ version: 1,
497
+ api_key: payload.secret,
498
+ key_id: payload.key_id,
499
+ audience: payload.audience,
500
+ api_base: payload.api_base,
501
+ scopes: payload.scopes || [],
502
+ expires_at: payload.expires_at,
503
+ created_at: payload.created_at || new Date().toISOString(),
504
+ source: "device_link",
505
+ };
506
+ const temporary = `${path}.${randomUUID()}.tmp`;
507
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
508
+ await chmod(temporary, 0o600);
509
+ await rename(temporary, path);
510
+ await chmod(path, 0o600);
511
+ return { path };
512
+ }
513
+
514
+ export async function clearAdminCredential(env = process.env) {
515
+ const path = adminCredentialPath(env);
516
+ await rm(path, { force: true });
517
+ return path;
254
518
  }
255
519
 
256
520
  export function writeResult(value, { json = false, stdout = process.stdout } = {}) {
@@ -289,13 +553,24 @@ export async function openBrowser(url) {
289
553
  const target = String(url);
290
554
  const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
291
555
  const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
292
- try {
293
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
294
- child.unref();
295
- return true;
296
- } catch {
297
- return false;
298
- }
556
+ return new Promise((resolvePromise) => {
557
+ let settled = false;
558
+ const finish = (opened) => {
559
+ if (settled) return;
560
+ settled = true;
561
+ resolvePromise(opened);
562
+ };
563
+ try {
564
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
565
+ child.once("error", () => finish(false));
566
+ child.once("spawn", () => {
567
+ child.unref();
568
+ finish(true);
569
+ });
570
+ } catch {
571
+ finish(false);
572
+ }
573
+ });
299
574
  }
300
575
 
301
576
  export async function sleep(milliseconds) {
@@ -313,6 +588,13 @@ export async function writeTextFile(path, content) {
313
588
  return target;
314
589
  }
315
590
 
591
+ export async function writeBinaryFile(path, content) {
592
+ const target = resolve(String(path));
593
+ await mkdir(dirname(target), { recursive: true });
594
+ await writeFile(target, content);
595
+ return target;
596
+ }
597
+
316
598
  export function requireConfirmation(options, action) {
317
599
  if (!options.yes) {
318
600
  throw new CliError(`${action} requires --yes.`, { code: "confirmation_required" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corvio/cli",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Official Corvio Workspace CLI for questions, knowledge search, documents, and Agent workflows.",
5
5
  "type": "module",
6
6
  "bin": {