@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 +8 -3
- package/dist/cli.js +636 -18
- package/dist/core.js +315 -33
- package/package.json +1 -1
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='
|
|
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
|
|
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.
|
|
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=
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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"
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
200
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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,
|
|
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 (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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" });
|