@knolo/cli 5.2.0 → 5.2.1

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
@@ -47,10 +47,49 @@ An explicit `--registry` value takes precedence over `KNOLO_HUB_URL`. In
47
47
  development mode (`NODE_ENV=development`), the default is
48
48
  `http://localhost:3000`; otherwise the default is `https://hub.knolo.dev`.
49
49
 
50
- The current Hub surface is read-only discovery and verified installation. Local
51
- `knolo add <name> <path>` continues to update a project’s local source
52
- configuration. A Hub pack is installed only after its manifest, Blob bytes,
53
- digest, size, and local Knowledge Image structure have all been verified:
50
+ Hub writes use a dashboard token as an HTTP Bearer credential. GitHub sign-in
51
+ is needed only to mint that token at
52
+ `https://hub.knolo.dev/dashboard/tokens`; `knolo login` is local-only and does
53
+ not call a Hub login or token-minting endpoint:
54
+
55
+ ```bash
56
+ knolo login --token kno_…
57
+ # write requests use: Authorization: Bearer kno_…
58
+ ```
59
+
60
+ `knolo publish` uploads bytes directly to the public Blob URL returned by Hub,
61
+ waits for verification, creates an attested draft, and releases it. The
62
+ `--slug`, `--version`, and SPDX `--license` are required:
63
+
64
+ ```bash
65
+ knolo publish ./dist/knowledge.knolo \
66
+ --slug refund-policy --version 1.2.0 --license Apache-2.0
67
+ knolo yank acme/refund-policy@1.2.0
68
+ ```
69
+
70
+ Only public Blob URLs are accepted because Hub verification fetches the
71
+ artifact itself. The CLI never sends the `kno_…` token to Blob, in a query
72
+ string, cookie, or pack bytes. A 401 usually means the `Bearer` word is
73
+ missing or the token was revoked; it does not mean the Hub write API requires
74
+ a GitHub browser session.
75
+
76
+ The older `@knolo/cli@5.2.0` publish/yank command was an unimplemented stub;
77
+ current versions use the live Hub HTTP sequence. For a direct smoke test of a
78
+ stored credential, request the account endpoint with the same header:
79
+
80
+ ```bash
81
+ curl -sS \
82
+ -H "Authorization: Bearer kno_…" \
83
+ -H "Accept: application/json" \
84
+ https://hub.knolo.dev/api/v1/account
85
+ ```
86
+
87
+ Do not use `POST /api/v1/tokens` as a CLI login endpoint; it is for the
88
+ dashboard/GitHub session that mints tokens.
89
+
90
+ Hub pack installation remains available without credentials and only happens
91
+ after its manifest, Blob bytes, digest, size, and local Knowledge Image
92
+ structure have all been verified:
54
93
 
55
94
  ```bash
56
95
  knolo add acme/refund-policy@1.2.0
@@ -60,6 +99,7 @@ Successful installs are cached by SHA-256 and recorded in
60
99
  `knolo.lock.json`. A yanked version or an existing conflicting pin requires
61
100
  `--force`; digest and artifact validation cannot be bypassed.
62
101
 
102
+
63
103
  ---
64
104
 
65
105
  ## 🚀 Commands
package/bin/knolo.mjs CHANGED
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import process from 'node:process';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
  import { createRequire } from 'node:module';
23
- import { isHubAddInvocation, runHubAdd, runHubInfo, runHubLogin, runHubLogout, runHubPublishStub, runHubSearch, runHubWhoami } from './registry/commands.mjs';
23
+ import { isHubAddInvocation, runHubAdd, runHubInfo, runHubLogin, runHubLogout, runHubPublish, runHubSearch, runHubWhoami, runHubYank } from './registry/commands.mjs';
24
24
 
25
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
26
  const require = createRequire(import.meta.url);
@@ -34,7 +34,7 @@ const DEFAULT_CONFIG = {
34
34
  };
35
35
  const SUPPORTED_EXTENSIONS = new Set(['.md', '.txt', '.json']);
36
36
  const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.dfx', '.cargo-target']);
37
- const SUBCOMMANDS = new Set(['init', 'add', 'build', 'query', 'inspect', 'migrate', 'verify', 'explain', 'diff', 'dev', 'v5', 'search', 'info', 'login', 'whoami', 'logout', 'publish', 'semantic:index', 'semantic:inspect', 'semantic:validate']);
37
+ const SUBCOMMANDS = new Set(['init', 'add', 'build', 'query', 'inspect', 'migrate', 'verify', 'explain', 'diff', 'dev', 'v5', 'search', 'info', 'login', 'whoami', 'logout', 'publish', 'yank', 'semantic:index', 'semantic:inspect', 'semantic:validate']);
38
38
  const ICP_SUBCOMMANDS = new Set(['init', 'build-pack', 'upload', 'query', 'health', 'info', 'clear']);
39
39
  const ICP_TEMPLATE_CANDIDATES = [
40
40
  path.resolve(__dirname, '../templates/icp-knowledge-canister'),
@@ -86,7 +86,8 @@ Commands:
86
86
  login Store a Hub token locally
87
87
  whoami Show the stored Hub token prefix
88
88
  logout Remove the stored Hub token
89
- publish Publish support is waiting for Hub write APIs
89
+ publish Publish a pack with a Hub Bearer token
90
+ yank <pack>@<version> Yank a released pack with a Hub Bearer token
90
91
  build Build a .knolo pack from configured sources
91
92
  query <question> Query a built pack and print top hits
92
93
  inspect <pack> Inspect pack format, sections, and manifest
@@ -111,10 +112,11 @@ function printCommandHelp(command) {
111
112
  add: 'Usage: knolo add <name> <path> | <publisher>/<slug>[@<version>] [--out <path>] [--force] [--json] [--registry <url>]',
112
113
  search: 'Usage: knolo search <query> [--format V4|V5] [--license <id>] [--official] [--agents] [--json] [--registry <url>]',
113
114
  info: 'Usage: knolo info <publisher>/<slug> [--json] [--registry <url>]',
114
- login: 'Usage: knolo login [--token kno_…] [--stdin] [--registry <url>]',
115
+ login: 'Usage: knolo login [--token kno_…] [--stdin] [--registry <url>]\n\nStores the raw token locally. Hub write calls send Authorization: Bearer kno_….\nMint tokens at https://hub.knolo.dev/dashboard/tokens; login does not call POST /api/v1/tokens.',
115
116
  whoami: 'Usage: knolo whoami',
116
117
  logout: 'Usage: knolo logout',
117
- publish: 'Usage: knolo publish <pack.knolo> (not available until Hub write APIs support CLI tokens)',
118
+ publish: 'Usage: knolo publish <pack.knolo> --slug <slug> --version <version> --license <SPDX> [--publisher <handle>] [--readme <text>] [--sources <text>] [--intended-use <text>] [--json] [--registry <url>]\n\nUses the stored token as Authorization: Bearer kno_…. Uploads go directly to a public Blob URL; Hub verifies before release.\nOlder @knolo/cli 5.2.0 builds were unimplemented; Hub has always accepted Bearer CLI tokens.',
119
+ yank: 'Usage: knolo yank <publisher>/<slug>@<version> [--json] [--registry <url>]\n\nUses the stored token as Authorization: Bearer kno_…. Yank is owner-only and leaves the public Blob in place.',
118
120
  build: 'Usage: knolo build',
119
121
  query: 'Usage: knolo query <question> [--pack <path>] [--k <number>] [--receipt <file>] [--json]',
120
122
  inspect: 'Usage: knolo inspect <pack.knolo>',
@@ -1195,7 +1197,8 @@ async function main() {
1195
1197
  if (command === 'login') return await runHubLogin(commandArgs);
1196
1198
  if (command === 'whoami') return await runHubWhoami(commandArgs);
1197
1199
  if (command === 'logout') return await runHubLogout(commandArgs);
1198
- if (command === 'publish') return runHubPublishStub(commandArgs);
1200
+ if (command === 'publish') return runHubPublish(commandArgs);
1201
+ if (command === 'yank') return runHubYank(commandArgs);
1199
1202
 
1200
1203
  const core = await loadCore();
1201
1204
  if (command === 'build') return await cmdBuild(core);
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { createWriteStream } from 'node:fs';
3
- import { chmod, mkdir, readFile, rename, unlink } from 'node:fs/promises';
3
+ import { chmod, copyFile, mkdir, readFile, rename, unlink } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { Readable } from 'node:stream';
6
6
 
@@ -86,11 +86,27 @@ export async function readArtifact(tempPath) {
86
86
  }
87
87
 
88
88
  export async function installCachedArtifact(tempPath, cachePath) {
89
+ const stagedPath = await stageCachedArtifact(tempPath, cachePath);
90
+ try {
91
+ await rename(stagedPath, cachePath);
92
+ await chmod(cachePath, 0o644);
93
+ return cachePath;
94
+ } finally {
95
+ await unlink(stagedPath).catch(() => {});
96
+ }
97
+ }
98
+
99
+ export async function stageCachedArtifact(sourcePath, cachePath) {
89
100
  await mkdir(path.dirname(cachePath), { recursive: true });
90
- await chmod(tempPath, 0o644);
91
- await rename(tempPath, cachePath);
92
- await chmod(cachePath, 0o644);
93
- return cachePath;
101
+ const stagedPath = `${cachePath}.tmp-${process.pid}-${randomUUID()}`;
102
+ try {
103
+ await copyFile(sourcePath, stagedPath);
104
+ await chmod(stagedPath, 0o644);
105
+ return stagedPath;
106
+ } catch (error) {
107
+ await unlink(stagedPath).catch(() => {});
108
+ throw error;
109
+ }
94
110
  }
95
111
 
96
112
  async function writeChunk(stream, chunk) {
@@ -1,11 +1,12 @@
1
- import { copyFile, mkdir, rename, unlink } from 'node:fs/promises';
1
+ import { chmod, copyFile, lstat, mkdir, readFile, rename, unlink } from 'node:fs/promises';
2
+ import { createHash, randomUUID } from 'node:crypto';
2
3
  import { homedir } from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { registryApiUrl, resolveRegistryUrl } from './config.mjs';
5
- import { downloadBlobToTemp, installCachedArtifact, readArtifact } from './artifacts.mjs';
6
+ import { downloadBlobToTemp, MAX_ARTIFACT_BYTES, readArtifact, stageCachedArtifact } from './artifacts.mjs';
6
7
  import { RegistryError } from './errors.mjs';
7
- import { getJson } from './http.mjs';
8
- import { cachePath, upsertLockfile, validateLockfileUpdate } from './lockfile.mjs';
8
+ import { getJson, postJson, putBytes } from './http.mjs';
9
+ import { cachePath, stageLockfileUpdate, validateLockfileUpdate } from './lockfile.mjs';
9
10
  import { validatePackManifest } from './manifest.mjs';
10
11
  import { parsePackName, parsePackSpec } from './specs.mjs';
11
12
  import { loadCredentials, promptForToken, removeCredentials, saveCredentials, validateToken } from './credentials.mjs';
@@ -91,7 +92,7 @@ export async function runHubAdd(args, {
91
92
  expectedSize: manifest.sizeBytes,
92
93
  fetchImpl,
93
94
  });
94
- let installed = false;
95
+ const stagedFiles = [];
95
96
  try {
96
97
  if (download.sha256 !== manifest.sha256) {
97
98
  throw new Error(`artifact sha256 mismatch: expected ${manifest.sha256}, got ${download.sha256}`);
@@ -101,16 +102,24 @@ export async function runHubAdd(args, {
101
102
  if (manifest.format && String(manifest.format).toUpperCase() !== validation.format) {
102
103
  throw new Error(`artifact format mismatch: manifest=${manifest.format}, bytes=${validation.format}`);
103
104
  }
105
+ if (validation.format === 'V5' && !manifest.stateRoot) {
106
+ throw new Error('V5 artifact requires manifest stateRoot.');
107
+ }
104
108
  if (manifest.stateRoot !== undefined && manifest.stateRoot !== validation.stateRoot) {
105
109
  throw new Error(`artifact stateRoot mismatch: expected ${manifest.stateRoot}, got ${validation.stateRoot || 'none'}`);
106
110
  }
107
- const cache = await installCachedArtifact(download.tempPath, finalCachePath);
108
- installed = true;
109
- const lock = await upsertLockfile({ cwd, registry, manifest, force: flags.force });
110
- let outputPath = cache;
111
+ const stagedCachePath = await stageCachedArtifact(download.tempPath, finalCachePath);
112
+ stagedFiles.push({ targetPath: finalCachePath, tempPath: stagedCachePath });
113
+ let outputPath = finalCachePath;
111
114
  if (flags.out) {
112
- outputPath = await copyArtifact(cache, flags.out, cwd);
115
+ const stagedOutput = await stageArtifactCopy(download.tempPath, flags.out, cwd);
116
+ stagedFiles.push(stagedOutput);
117
+ outputPath = stagedOutput.targetPath;
113
118
  }
119
+ const stagedLock = await stageLockfileUpdate({ cwd, registry, manifest, force: flags.force });
120
+ stagedFiles.push({ targetPath: stagedLock.path, tempPath: stagedLock.tempPath });
121
+ await commitStagedFiles(stagedFiles);
122
+ const lock = { path: stagedLock.path, entry: stagedLock.entry };
114
123
  const result = { ...manifest, path: outputPath, lockfile: lock.path };
115
124
  if (flags.json) print(JSON.stringify(result, null, 2));
116
125
  else {
@@ -121,7 +130,8 @@ export async function runHubAdd(args, {
121
130
  }
122
131
  return result;
123
132
  } finally {
124
- if (!installed) await unlinkIfPresent(download.tempPath);
133
+ await unlinkIfPresent(download.tempPath);
134
+ await Promise.all(stagedFiles.map(({ tempPath }) => unlinkIfPresent(tempPath)));
125
135
  }
126
136
  }
127
137
 
@@ -140,6 +150,7 @@ export async function runHubLogin(args, {
140
150
  const saved = await saveCredentials({ registry, token, env, homeDir });
141
151
  print(`logged in to ${saved.registry}`);
142
152
  print(`token ${saved.prefix}`);
153
+ print('Authorization: Bearer kno_…');
143
154
  return saved;
144
155
  }
145
156
 
@@ -158,8 +169,323 @@ export async function runHubLogout(args, { env = process.env, homeDir, print = c
158
169
  return result;
159
170
  }
160
171
 
161
- export function runHubPublishStub() {
162
- throw new Error('Hub write APIs do not accept CLI tokens yet');
172
+ export async function runHubPublish(args, {
173
+ env = process.env,
174
+ fetchImpl = globalThis.fetch,
175
+ cwd = process.cwd(),
176
+ homeDir,
177
+ print = console.log,
178
+ sleep = wait,
179
+ pollIntervalMs = 1000,
180
+ maxPollAttempts = 120,
181
+ } = {}) {
182
+ const { packPath, flags } = parsePublishArgs(args);
183
+ const credentials = await loadCredentials({ env, homeDir });
184
+ const registry = resolveRegistryUrl({ value: flags.registry || credentials.registry, env });
185
+ const authHeaders = authorizationHeaders(credentials.token);
186
+
187
+ // This request is deliberately first: it proves the stored dashboard token
188
+ // is a Hub Bearer credential before any upload or publish mutation starts.
189
+ const account = await getJson(registryApiUrl(registry, ['account']), { fetchImpl, headers: authHeaders });
190
+ const accountPublisher = extractPublisherHandle(account);
191
+ if (flags.publisher && accountPublisher && flags.publisher !== accountPublisher) {
192
+ throw new Error(`Token is scoped to publisher ${accountPublisher}, not ${flags.publisher}.`);
193
+ }
194
+ const publisher = flags.publisher || accountPublisher;
195
+
196
+ const packFile = path.resolve(cwd, packPath);
197
+ const bytes = await readFile(packFile);
198
+ if (bytes.length > MAX_ARTIFACT_BYTES) throw new Error('Artifact exceeds Hub’s 250 MB limit.');
199
+ const sha256 = createSha256(bytes);
200
+
201
+ const uploadToken = await postJson(hubApiUrl(registry, ['upload', 'token']), {
202
+ sha256,
203
+ contentLength: bytes.length,
204
+ }, { fetchImpl, headers: authHeaders });
205
+ const upload = validateUploadTokenResponse(uploadToken, sha256);
206
+ assertPublicBlobUrl(upload.url, 'upload');
207
+ const uploaded = await putBytes(upload.url, bytes, {
208
+ fetchImpl,
209
+ headers: { 'content-length': String(bytes.length) },
210
+ });
211
+ assertPublicBlobUrl(uploaded.url, 'uploaded artifact');
212
+
213
+ const completed = await postJson(hubApiUrl(registry, ['upload', 'complete']), {
214
+ sha256,
215
+ url: upload.url,
216
+ sizeBytes: bytes.length,
217
+ pathname: upload.pathname,
218
+ }, { fetchImpl, headers: authHeaders });
219
+ const blobUrl = extractBlobUrl(completed) || upload.url;
220
+ assertPublicBlobUrl(blobUrl, 'completed upload');
221
+
222
+ const verifyResponse = await postJson(registryApiUrl(registry, ['publish', 'verify']), { sha256 }, {
223
+ fetchImpl,
224
+ headers: authHeaders,
225
+ });
226
+ const jobId = extractId(verifyResponse, ['jobId', 'id', 'job']);
227
+ if (!jobId) throw new Error('Hub verify response did not include a publish job id.');
228
+ const job = await waitForPublishJob(registry, jobId, {
229
+ fetchImpl,
230
+ headers: authHeaders,
231
+ sleep,
232
+ pollIntervalMs,
233
+ maxPollAttempts,
234
+ });
235
+
236
+ const draftPayload = {
237
+ sha256,
238
+ slug: flags.slug,
239
+ version: flags.version,
240
+ license: flags.license,
241
+ attested: true,
242
+ ...(flags.readme !== undefined ? { readme: flags.readme } : {}),
243
+ ...(flags.sources !== undefined ? { sources: flags.sources } : {}),
244
+ ...(flags.intendedUse !== undefined ? { intendedUse: flags.intendedUse } : {}),
245
+ };
246
+ const draftResponse = await postJson(registryApiUrl(registry, ['publish', 'drafts']), draftPayload, {
247
+ fetchImpl,
248
+ headers: authHeaders,
249
+ });
250
+ const draftId = extractId(draftResponse, ['draftId', 'id', 'draft']);
251
+ if (!draftId) throw new Error('Hub draft response did not include a draft id.');
252
+
253
+ const releaseResponse = await postJson(registryApiUrl(registry, ['publish', 'drafts', draftId, 'release']), undefined, {
254
+ fetchImpl,
255
+ headers: authHeaders,
256
+ });
257
+ const name = `${publisher ? `${publisher}/` : ''}${flags.slug}`;
258
+ const result = {
259
+ registry,
260
+ ...(publisher ? { publisher } : {}),
261
+ name,
262
+ slug: flags.slug,
263
+ version: flags.version,
264
+ sha256,
265
+ sizeBytes: bytes.length,
266
+ pathname: upload.pathname,
267
+ url: blobUrl,
268
+ jobId: String(jobId),
269
+ draftId: String(draftId),
270
+ verification: job,
271
+ release: releaseResponse,
272
+ };
273
+
274
+ if (flags.json) print(JSON.stringify(result, null, 2));
275
+ else {
276
+ print(`published ${name}@${flags.version}`);
277
+ print(`sha256 ${sha256}`);
278
+ print(`url ${blobUrl}`);
279
+ print(`job ${jobId}`);
280
+ }
281
+ return result;
282
+ }
283
+
284
+ export async function runHubYank(args, {
285
+ env = process.env,
286
+ fetchImpl = globalThis.fetch,
287
+ homeDir,
288
+ print = console.log,
289
+ } = {}) {
290
+ const { spec, flags } = parseYankArgs(args);
291
+ const credentials = await loadCredentials({ env, homeDir });
292
+ const registry = resolveRegistryUrl({ value: flags.registry || credentials.registry, env });
293
+ const response = await postJson(registryApiUrl(registry, ['packs', spec.publisher, spec.slug, spec.version, 'yank']), undefined, {
294
+ fetchImpl,
295
+ headers: authorizationHeaders(credentials.token),
296
+ });
297
+ const result = {
298
+ registry,
299
+ name: spec.name,
300
+ version: spec.version,
301
+ response,
302
+ };
303
+ if (flags.json) print(JSON.stringify(result, null, 2));
304
+ else print(`yanked ${spec.name}@${spec.version}`);
305
+ return result;
306
+ }
307
+
308
+ export function parsePublishArgs(args) {
309
+ const positional = [];
310
+ const flags = {};
311
+ const valueFlags = new Map([
312
+ ['--slug', 'slug'],
313
+ ['--version', 'version'],
314
+ ['--license', 'license'],
315
+ ['--publisher', 'publisher'],
316
+ ['--readme', 'readme'],
317
+ ['--sources', 'sources'],
318
+ ['--intended-use', 'intendedUse'],
319
+ ['--registry', 'registry'],
320
+ ]);
321
+ for (let i = 0; i < args.length; i++) {
322
+ const arg = args[i];
323
+ if (arg === '--json') {
324
+ flags.json = true;
325
+ continue;
326
+ }
327
+ if (valueFlags.has(arg)) {
328
+ const value = args[++i];
329
+ if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
330
+ flags[valueFlags.get(arg)] = value;
331
+ continue;
332
+ }
333
+ if (arg.startsWith('--')) throw new Error(`Unknown flag for publish: ${arg}`);
334
+ positional.push(arg);
335
+ }
336
+
337
+ if (positional.length !== 1 || !flags.slug || !flags.version || !flags.license) {
338
+ throw new Error('Usage: knolo publish <pack.knolo> --slug <slug> --version <version> --license <SPDX> [--publisher <handle>] [--readme <text>] [--sources <text>] [--intended-use <text>] [--json] [--registry <url>]');
339
+ }
340
+ if (!/^[a-z0-9-]+$/.test(flags.slug)) throw new Error('Publish slug must contain lowercase letters, numbers, and hyphens.');
341
+ if (!flags.version.trim() || flags.version.includes('@')) throw new Error('Publish version must be a non-empty version without @.');
342
+ if (!flags.license.trim()) throw new Error('Publish license is required.');
343
+ if (flags.publisher && !/^[a-z0-9-]+$/.test(flags.publisher)) throw new Error('Publish publisher must contain lowercase letters, numbers, and hyphens.');
344
+ return { packPath: positional[0], flags };
345
+ }
346
+
347
+ export function parseYankArgs(args) {
348
+ const positional = [];
349
+ const flags = {};
350
+ for (let i = 0; i < args.length; i++) {
351
+ const arg = args[i];
352
+ if (arg === '--json') {
353
+ flags.json = true;
354
+ continue;
355
+ }
356
+ if (arg === '--registry') {
357
+ const value = args[++i];
358
+ if (value === undefined || value.startsWith('--')) throw new Error('Missing value for --registry');
359
+ flags.registry = value;
360
+ continue;
361
+ }
362
+ if (arg.startsWith('--')) throw new Error(`Unknown flag for yank: ${arg}`);
363
+ positional.push(arg);
364
+ }
365
+ if (positional.length !== 1) throw new Error('Usage: knolo yank <publisher>/<slug>@<version> [--json] [--registry <url>]');
366
+ const spec = parsePackSpec(positional[0]);
367
+ if (spec.version === 'latest') throw new Error('Yank requires an exact version: <publisher>/<slug>@<version>.');
368
+ return { spec, flags };
369
+ }
370
+
371
+ function authorizationHeaders(token) {
372
+ return { Authorization: `Bearer ${token}` };
373
+ }
374
+
375
+ function hubApiUrl(registry, segments) {
376
+ const url = new URL(registry);
377
+ url.pathname = `/api/${segments.map((segment) => encodeURIComponent(String(segment))).join('/')}`;
378
+ return url;
379
+ }
380
+
381
+ function createSha256(bytes) {
382
+ return createHash('sha256').update(bytes).digest('hex');
383
+ }
384
+
385
+ function extractPublisherHandle(account) {
386
+ const candidates = [
387
+ account?.publisherHandle,
388
+ account?.publisher?.handle,
389
+ account?.publisher?.slug,
390
+ account?.handle,
391
+ account?.account?.publisherHandle,
392
+ account?.account?.publisher?.handle,
393
+ account?.user?.publisherHandle,
394
+ account?.publishers?.[0]?.handle,
395
+ account?.publishers?.[0]?.slug,
396
+ typeof account?.publisher === 'string' ? account.publisher : undefined,
397
+ ];
398
+ return candidates.find((value) => typeof value === 'string' && value.trim())?.trim();
399
+ }
400
+
401
+ function validateUploadTokenResponse(body, sha256) {
402
+ const upload = body?.upload && typeof body.upload === 'object' ? body.upload : body;
403
+ if (!upload || typeof upload !== 'object' || typeof upload.url !== 'string' || !upload.url) {
404
+ if (upload?.clientToken || body?.clientToken) {
405
+ throw new Error('Hub returned a Blob client token; this CLI requires an upload.url for direct public-Blob PUT.');
406
+ }
407
+ throw new Error('Hub upload token response is missing upload.url.');
408
+ }
409
+ const pathname = upload.pathname || body?.pathname;
410
+ if (typeof pathname !== 'string' || !pathname) throw new Error('Hub upload token response is missing upload.pathname.');
411
+ const expectedPathname = `sha256/${sha256}.knolo`;
412
+ if (pathname !== expectedPathname) {
413
+ throw new Error(`Hub upload pathname is not locked to ${expectedPathname}.`);
414
+ }
415
+ return { url: upload.url, pathname };
416
+ }
417
+
418
+ function extractBlobUrl(body) {
419
+ const candidates = [
420
+ body?.url,
421
+ body?.publicUrl,
422
+ body?.blobUrl,
423
+ body?.artifact?.url,
424
+ body?.artifact?.publicUrl,
425
+ body?.upload?.url,
426
+ body?.upload?.publicUrl,
427
+ body?.pack?.url,
428
+ ];
429
+ return candidates.find((value) => typeof value === 'string' && value.trim())?.trim();
430
+ }
431
+
432
+ function assertPublicBlobUrl(value, label) {
433
+ let url;
434
+ try {
435
+ url = new URL(value);
436
+ } catch {
437
+ throw new Error(`Hub ${label} URL is invalid.`);
438
+ }
439
+ const hostname = url.hostname.toLowerCase();
440
+ if (url.protocol !== 'https:') throw new Error(`Hub ${label} URL must use HTTPS.`);
441
+ if (hostname.includes('.private.') || hostname.startsWith('private.')) {
442
+ throw new Error(`Refusing private Blob URL for ${label}; Hub verification requires a public Blob URL.`);
443
+ }
444
+ return url;
445
+ }
446
+
447
+ function extractId(body, keys) {
448
+ for (const key of keys) {
449
+ const value = body?.[key];
450
+ if (typeof value === 'string' && value) return value;
451
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
452
+ if (value && typeof value === 'object') {
453
+ if (typeof value.id === 'string' && value.id) return value.id;
454
+ if (typeof value.id === 'number' && Number.isSafeInteger(value.id)) return value.id;
455
+ if (typeof value.jobId === 'string' && value.jobId) return value.jobId;
456
+ if (typeof value.draftId === 'string' && value.draftId) return value.draftId;
457
+ }
458
+ }
459
+ return undefined;
460
+ }
461
+
462
+ async function waitForPublishJob(registry, jobId, {
463
+ fetchImpl,
464
+ headers,
465
+ sleep: sleepImpl = wait,
466
+ pollIntervalMs = 1000,
467
+ maxPollAttempts = 120,
468
+ } = {}) {
469
+ if (!Number.isSafeInteger(maxPollAttempts) || maxPollAttempts < 1) throw new Error('maxPollAttempts must be a positive integer.');
470
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) throw new Error('pollIntervalMs must be a non-negative number.');
471
+
472
+ for (let attempt = 0; attempt < maxPollAttempts; attempt++) {
473
+ if (attempt > 0 && pollIntervalMs > 0) await sleepImpl(pollIntervalMs);
474
+ const body = await getJson(registryApiUrl(registry, ['publish', 'jobs', jobId]), { fetchImpl, headers });
475
+ const job = body?.job && typeof body.job === 'object' ? body.job : body;
476
+ const status = String(job?.status || job?.state || '').toLowerCase();
477
+ if (['passed', 'pass', 'complete', 'completed', 'succeeded', 'success'].includes(status)) return job;
478
+ if (['failed', 'failure', 'error', 'rejected'].includes(status)) {
479
+ const detail = job?.error || job?.message || body?.error;
480
+ throw new Error(`Hub publish verification failed${detail ? `: ${detail}` : '.'}`);
481
+ }
482
+ }
483
+
484
+ throw new Error(`Timed out waiting for Hub publish verification job ${jobId}.`);
485
+ }
486
+
487
+ function wait(milliseconds) {
488
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
163
489
  }
164
490
 
165
491
  export function parseAddArgs(args) {
@@ -310,17 +636,75 @@ async function validateKnowledgeImage(core, bytes) {
310
636
  throw new Error('not a Knowledge Image');
311
637
  }
312
638
 
313
- async function copyArtifact(sourcePath, outputPath, cwd) {
639
+ async function stageArtifactCopy(sourcePath, outputPath, cwd) {
314
640
  const targetPath = path.resolve(cwd, outputPath);
315
- const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
316
641
  await mkdir(path.dirname(targetPath), { recursive: true });
642
+ const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`;
317
643
  try {
318
644
  await copyFile(sourcePath, tempPath);
319
- await rename(tempPath, targetPath);
320
- } finally {
645
+ await chmod(tempPath, 0o644);
646
+ return { targetPath, tempPath };
647
+ } catch (error) {
321
648
  await unlinkIfPresent(tempPath);
649
+ throw error;
650
+ }
651
+ }
652
+
653
+ async function commitStagedFiles(entries) {
654
+ const targets = new Set();
655
+ const records = entries.map((entry) => {
656
+ if (targets.has(entry.targetPath)) throw new Error(`Install paths must be unique: ${entry.targetPath}`);
657
+ targets.add(entry.targetPath);
658
+ return {
659
+ ...entry,
660
+ backupPath: `${entry.targetPath}.backup-${process.pid}-${randomUUID()}`,
661
+ hadOriginal: false,
662
+ committed: false,
663
+ };
664
+ });
665
+
666
+ try {
667
+ for (const record of records) {
668
+ const existing = await statIfPresent(record.targetPath);
669
+ if (existing?.isDirectory()) throw new Error(`Cannot replace directory at ${record.targetPath}.`);
670
+ }
671
+
672
+ for (const record of records) {
673
+ if (await pathExists(record.targetPath)) {
674
+ record.hadOriginal = true;
675
+ await rename(record.targetPath, record.backupPath);
676
+ }
677
+ await rename(record.tempPath, record.targetPath);
678
+ record.committed = true;
679
+ }
680
+
681
+ } catch (error) {
682
+ for (const record of [...records].reverse()) {
683
+ if (record.committed) await unlinkIfPresent(record.targetPath);
684
+ if (record.hadOriginal && await pathExists(record.backupPath)) {
685
+ await unlinkIfPresent(record.targetPath);
686
+ await rename(record.backupPath, record.targetPath);
687
+ }
688
+ }
689
+ throw error;
690
+ }
691
+
692
+ for (const record of records) {
693
+ if (record.hadOriginal) await unlinkIfPresent(record.backupPath).catch(() => {});
694
+ }
695
+ }
696
+
697
+ async function statIfPresent(filePath) {
698
+ try {
699
+ return await lstat(filePath);
700
+ } catch (error) {
701
+ if (error?.code === 'ENOENT') return undefined;
702
+ throw error;
322
703
  }
323
- return targetPath;
704
+ }
705
+
706
+ async function pathExists(filePath) {
707
+ return Boolean(await statIfPresent(filePath));
324
708
  }
325
709
 
326
710
  async function unlinkIfPresent(filePath) {
@@ -18,8 +18,10 @@ export class RegistryNetworkError extends Error {
18
18
  }
19
19
 
20
20
  export function registryErrorMessage({ status, code, body, url }) {
21
+ if (status === 401 || code === 'unauthenticated') {
22
+ return 'Hub authentication failed. Use Authorization: Bearer kno_…; a revoked token can also return 401. GitHub sign-in is only required to mint tokens.';
23
+ }
21
24
  if (typeof body?.error === 'string' && body.error.trim()) return body.error.trim();
22
- if (status === 401 || code === 'unauthenticated') return 'Registry authentication is required.';
23
25
  if (status === 404 || code === 'not_found') return 'Registry resource not found.';
24
26
  if (status === 410 || code === 'yanked') return 'Registry version is yanked.';
25
27
  if (status === 413 || code === 'too_large') return 'Registry artifact is too large.';
@@ -1,14 +1,50 @@
1
1
  import { RegistryError, RegistryNetworkError, registryErrorMessage } from './errors.mjs';
2
2
 
3
3
  export async function getJson(url, { fetchImpl = globalThis.fetch, headers = {} } = {}) {
4
+ return requestJson(url, { fetchImpl, headers, method: 'GET' });
5
+ }
6
+
7
+ export async function postJson(url, body, { fetchImpl = globalThis.fetch, headers = {} } = {}) {
8
+ return requestJson(url, { fetchImpl, headers, method: 'POST', body });
9
+ }
10
+
11
+ export async function putBytes(url, bytes, { fetchImpl = globalThis.fetch, headers = {} } = {}) {
12
+ if (typeof fetchImpl !== 'function') throw new Error('This Node runtime does not provide fetch.');
13
+
14
+ let response;
15
+ try {
16
+ response = await fetchImpl(url, {
17
+ method: 'PUT',
18
+ redirect: 'follow',
19
+ headers: { 'content-type': 'application/octet-stream', ...headers },
20
+ body: bytes,
21
+ });
22
+ } catch (error) {
23
+ throw new RegistryNetworkError(`Could not upload artifact to ${new URL(url).origin}.`, { url: String(url), cause: error });
24
+ }
25
+
26
+ if (!response.ok) {
27
+ const detail = await readResponseText(response, url);
28
+ throw new RegistryError(`Blob upload failed with HTTP ${response.status}${detail ? `: ${detail}` : '.'}`, {
29
+ status: response.status,
30
+ url: String(url),
31
+ body: detail,
32
+ });
33
+ }
34
+
35
+ return { status: response.status, url: response.url || String(url) };
36
+ }
37
+
38
+ async function requestJson(url, { fetchImpl = globalThis.fetch, headers = {}, method = 'GET', body: requestBody } = {}) {
4
39
  if (typeof fetchImpl !== 'function') throw new Error('This Node runtime does not provide fetch.');
5
40
 
6
41
  let response;
7
42
  try {
8
43
  response = await fetchImpl(url, {
9
- method: 'GET',
44
+ method,
10
45
  redirect: 'follow',
11
- headers: { accept: 'application/json', ...headers },
46
+ headers: { accept: 'application/json', ...(requestBody !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
47
+ ...(requestBody !== undefined ? { body: JSON.stringify(requestBody) } : {}),
12
48
  });
13
49
  } catch (error) {
14
50
  throw new RegistryNetworkError(`Could not reach registry at ${new URL(url).origin}.`, { url: String(url), cause: error });
@@ -37,13 +37,23 @@ export async function validateLockfileUpdate({ cwd = process.cwd(), registry, ma
37
37
  }
38
38
 
39
39
  export async function upsertLockfile({ cwd = process.cwd(), registry, manifest, force = false } = {}) {
40
+ const staged = await stageLockfileUpdate({ cwd, registry, manifest, force });
41
+ try {
42
+ await rename(staged.tempPath, staged.path);
43
+ } finally {
44
+ await unlink(staged.tempPath).catch(() => {});
45
+ }
46
+ return { path: staged.path, entry: staged.entry };
47
+ }
48
+
49
+ export async function stageLockfileUpdate({ cwd = process.cwd(), registry, manifest, force = false } = {}) {
40
50
  const { targetPath, entry, packs } = await validateLockfileUpdate({ cwd, registry, manifest, force });
41
51
  const next = {
42
52
  registry,
43
53
  packs: { ...packs, [manifest.name]: entry },
44
54
  };
45
- await atomicWriteJson(targetPath, next);
46
- return { path: targetPath, entry };
55
+ const tempPath = await writeJsonTemp(targetPath, next);
56
+ return { path: targetPath, tempPath, entry };
47
57
  }
48
58
 
49
59
  async function readLockfile(filePath) {
@@ -66,13 +76,14 @@ async function readLockfile(filePath) {
66
76
  return value;
67
77
  }
68
78
 
69
- async function atomicWriteJson(filePath, value) {
79
+ async function writeJsonTemp(filePath, value) {
70
80
  await mkdir(path.dirname(filePath), { recursive: true });
71
81
  const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
72
82
  try {
73
83
  await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o644 });
74
- await rename(tempPath, filePath);
75
- } finally {
84
+ return tempPath;
85
+ } catch (error) {
76
86
  await unlink(tempPath).catch(() => {});
87
+ throw error;
77
88
  }
78
89
  }
@@ -20,6 +20,12 @@ export function validatePackManifest(value, { expectedName, requestedVersion } =
20
20
  if (value.stateRoot !== undefined && typeof value.stateRoot !== 'string') {
21
21
  throw new Error('Registry manifest stateRoot must be a string when present.');
22
22
  }
23
+ if (value.format !== undefined && !['V4', 'V5'].includes(value.format)) {
24
+ throw new Error('Registry manifest format must be V4 or V5 when present.');
25
+ }
26
+ if (value.format === 'V5' && (!value.stateRoot || typeof value.stateRoot !== 'string')) {
27
+ throw new Error('Registry manifest stateRoot is required for V5 manifests.');
28
+ }
23
29
  if (value.license !== undefined && typeof value.license !== 'string') {
24
30
  throw new Error('Registry manifest license must be a string when present.');
25
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knolo/cli",
3
- "version": "5.2.0",
3
+ "version": "5.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "knolo": "bin/knolo.mjs"