@knolo/cli 5.1.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 +75 -0
- package/bin/knolo.mjs +33 -3
- package/bin/registry/artifacts.mjs +159 -0
- package/bin/registry/commands.mjs +776 -0
- package/bin/registry/config.mjs +46 -0
- package/bin/registry/credentials.mjs +93 -0
- package/bin/registry/errors.mjs +30 -0
- package/bin/registry/http.mjs +94 -0
- package/bin/registry/lockfile.mjs +89 -0
- package/bin/registry/manifest.mjs +47 -0
- package/bin/registry/specs.mjs +20 -0
- package/package.json +3 -2
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import { chmod, copyFile, lstat, mkdir, readFile, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { registryApiUrl, resolveRegistryUrl } from './config.mjs';
|
|
6
|
+
import { downloadBlobToTemp, MAX_ARTIFACT_BYTES, readArtifact, stageCachedArtifact } from './artifacts.mjs';
|
|
7
|
+
import { RegistryError } from './errors.mjs';
|
|
8
|
+
import { getJson, postJson, putBytes } from './http.mjs';
|
|
9
|
+
import { cachePath, stageLockfileUpdate, validateLockfileUpdate } from './lockfile.mjs';
|
|
10
|
+
import { validatePackManifest } from './manifest.mjs';
|
|
11
|
+
import { parsePackName, parsePackSpec } from './specs.mjs';
|
|
12
|
+
import { loadCredentials, promptForToken, removeCredentials, saveCredentials, validateToken } from './credentials.mjs';
|
|
13
|
+
|
|
14
|
+
const PACK_SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
15
|
+
|
|
16
|
+
export async function runHubSearch(args, { env = process.env, fetchImpl = globalThis.fetch, print = console.log } = {}) {
|
|
17
|
+
const { query, flags } = parseSearchArgs(args);
|
|
18
|
+
const registry = resolveRegistryUrl({ value: flags.registry, env });
|
|
19
|
+
const url = registryApiUrl(registry, ['packs'], {
|
|
20
|
+
q: query,
|
|
21
|
+
format: flags.format,
|
|
22
|
+
license: flags.license,
|
|
23
|
+
official: flags.official ? 'true' : undefined,
|
|
24
|
+
agents: flags.agents ? 'true' : undefined,
|
|
25
|
+
});
|
|
26
|
+
const body = await getJson(url, { fetchImpl });
|
|
27
|
+
validatePacksResponse(body);
|
|
28
|
+
|
|
29
|
+
if (flags.json) print(JSON.stringify(body, null, 2));
|
|
30
|
+
else print(renderSearchTable(body.packs));
|
|
31
|
+
|
|
32
|
+
return { registry, url: String(url), body };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runHubInfo(args, { env = process.env, fetchImpl = globalThis.fetch, print = console.log } = {}) {
|
|
36
|
+
const { positional, flags } = parseInfoArgs(args);
|
|
37
|
+
const pack = parsePackName(positional[0]);
|
|
38
|
+
const registry = resolveRegistryUrl({ value: flags.registry, env });
|
|
39
|
+
const url = registryApiUrl(registry, ['packs', pack.publisher, pack.slug]);
|
|
40
|
+
const body = await getJson(url, { fetchImpl });
|
|
41
|
+
validatePackInfoResponse(body);
|
|
42
|
+
|
|
43
|
+
if (flags.json) print(JSON.stringify(body, null, 2));
|
|
44
|
+
else print(renderPackInfo(body));
|
|
45
|
+
|
|
46
|
+
return { registry, url: String(url), body };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runHubAdd(args, {
|
|
50
|
+
env = process.env,
|
|
51
|
+
fetchImpl = globalThis.fetch,
|
|
52
|
+
core,
|
|
53
|
+
cwd = process.cwd(),
|
|
54
|
+
homeDir,
|
|
55
|
+
print = console.log,
|
|
56
|
+
warn = console.error,
|
|
57
|
+
} = {}) {
|
|
58
|
+
const { spec, flags } = parseAddArgs(args);
|
|
59
|
+
if (!core) throw new Error('Could not load @knolo/core for Hub artifact validation.');
|
|
60
|
+
const registry = resolveRegistryUrl({ value: flags.registry, env });
|
|
61
|
+
const manifestUrl = registryApiUrl(registry, ['packs', spec.publisher, spec.slug, spec.version]);
|
|
62
|
+
|
|
63
|
+
let manifest;
|
|
64
|
+
let yanked = false;
|
|
65
|
+
try {
|
|
66
|
+
const body = await getJson(manifestUrl, { fetchImpl });
|
|
67
|
+
manifest = validatePackManifest(body, { expectedName: spec.name, requestedVersion: spec.version });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof RegistryError && (error.status === 404 || error.code === 'not_found')) {
|
|
70
|
+
throw new Error('pack version not found');
|
|
71
|
+
}
|
|
72
|
+
if (error instanceof RegistryError && (error.status === 410 || error.code === 'yanked')) {
|
|
73
|
+
manifest = validatePackManifest(error.body, { expectedName: spec.name, requestedVersion: spec.version });
|
|
74
|
+
yanked = true;
|
|
75
|
+
} else {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (manifest.yanked) yanked = true;
|
|
81
|
+
if (yanked && !flags.force) throw new Error('version yanked; use --force to download yanked bytes');
|
|
82
|
+
if (yanked) warn(`warning: downloading yanked version ${manifest.name}@${manifest.version}`);
|
|
83
|
+
if (!manifest.url) throw new Error('artifact bytes are not stored yet');
|
|
84
|
+
if (!PACK_SHA256_PATTERN.test(manifest.sha256)) throw new Error('manifest sha256 is invalid');
|
|
85
|
+
|
|
86
|
+
const artifactHome = homeDir || homedir();
|
|
87
|
+
await validateLockfileUpdate({ cwd, registry, manifest, force: flags.force });
|
|
88
|
+
const finalCachePath = cachePath(artifactHome, manifest.sha256);
|
|
89
|
+
const tempDir = path.dirname(finalCachePath);
|
|
90
|
+
const download = await downloadBlobToTemp(manifest.url, {
|
|
91
|
+
tempDir,
|
|
92
|
+
expectedSize: manifest.sizeBytes,
|
|
93
|
+
fetchImpl,
|
|
94
|
+
});
|
|
95
|
+
const stagedFiles = [];
|
|
96
|
+
try {
|
|
97
|
+
if (download.sha256 !== manifest.sha256) {
|
|
98
|
+
throw new Error(`artifact sha256 mismatch: expected ${manifest.sha256}, got ${download.sha256}`);
|
|
99
|
+
}
|
|
100
|
+
const bytes = await readArtifact(download.tempPath);
|
|
101
|
+
const validation = await validateKnowledgeImage(core, bytes);
|
|
102
|
+
if (manifest.format && String(manifest.format).toUpperCase() !== validation.format) {
|
|
103
|
+
throw new Error(`artifact format mismatch: manifest=${manifest.format}, bytes=${validation.format}`);
|
|
104
|
+
}
|
|
105
|
+
if (validation.format === 'V5' && !manifest.stateRoot) {
|
|
106
|
+
throw new Error('V5 artifact requires manifest stateRoot.');
|
|
107
|
+
}
|
|
108
|
+
if (manifest.stateRoot !== undefined && manifest.stateRoot !== validation.stateRoot) {
|
|
109
|
+
throw new Error(`artifact stateRoot mismatch: expected ${manifest.stateRoot}, got ${validation.stateRoot || 'none'}`);
|
|
110
|
+
}
|
|
111
|
+
const stagedCachePath = await stageCachedArtifact(download.tempPath, finalCachePath);
|
|
112
|
+
stagedFiles.push({ targetPath: finalCachePath, tempPath: stagedCachePath });
|
|
113
|
+
let outputPath = finalCachePath;
|
|
114
|
+
if (flags.out) {
|
|
115
|
+
const stagedOutput = await stageArtifactCopy(download.tempPath, flags.out, cwd);
|
|
116
|
+
stagedFiles.push(stagedOutput);
|
|
117
|
+
outputPath = stagedOutput.targetPath;
|
|
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 };
|
|
123
|
+
const result = { ...manifest, path: outputPath, lockfile: lock.path };
|
|
124
|
+
if (flags.json) print(JSON.stringify(result, null, 2));
|
|
125
|
+
else {
|
|
126
|
+
print(`added ${manifest.name}@${manifest.version}`);
|
|
127
|
+
print(`sha256 ${manifest.sha256}`);
|
|
128
|
+
print(`url ${manifest.url}`);
|
|
129
|
+
print(`path ${outputPath}`);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
} finally {
|
|
133
|
+
await unlinkIfPresent(download.tempPath);
|
|
134
|
+
await Promise.all(stagedFiles.map(({ tempPath }) => unlinkIfPresent(tempPath)));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runHubLogin(args, {
|
|
139
|
+
env = process.env,
|
|
140
|
+
homeDir,
|
|
141
|
+
input = process.stdin,
|
|
142
|
+
output = process.stderr,
|
|
143
|
+
print = console.log,
|
|
144
|
+
} = {}) {
|
|
145
|
+
const { flags } = parseLoginArgs(args);
|
|
146
|
+
const registry = resolveRegistryUrl({ value: flags.registry, env });
|
|
147
|
+
print(`Create a token at ${registry}/dashboard/tokens (Sign in with GitHub).`);
|
|
148
|
+
const token = (flags.token ?? await promptForToken({ input, output, forceStdin: flags.stdin })).trim();
|
|
149
|
+
validateToken(token);
|
|
150
|
+
const saved = await saveCredentials({ registry, token, env, homeDir });
|
|
151
|
+
print(`logged in to ${saved.registry}`);
|
|
152
|
+
print(`token ${saved.prefix}`);
|
|
153
|
+
print('Authorization: Bearer kno_…');
|
|
154
|
+
return saved;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function runHubWhoami(args, { env = process.env, homeDir, print = console.log } = {}) {
|
|
158
|
+
if (args.length) throw new Error('Usage: knolo whoami');
|
|
159
|
+
const credentials = await loadCredentials({ env, homeDir });
|
|
160
|
+
print(`registry ${credentials.registry}`);
|
|
161
|
+
print(`token ${credentials.prefix}`);
|
|
162
|
+
return { registry: credentials.registry, prefix: credentials.prefix };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function runHubLogout(args, { env = process.env, homeDir, print = console.log } = {}) {
|
|
166
|
+
if (args.length) throw new Error('Usage: knolo logout');
|
|
167
|
+
const result = await removeCredentials({ env, homeDir });
|
|
168
|
+
print(result.removed ? 'logged out' : 'not logged in');
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
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));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function parseAddArgs(args) {
|
|
492
|
+
const positional = [];
|
|
493
|
+
const flags = {};
|
|
494
|
+
for (let i = 0; i < args.length; i++) {
|
|
495
|
+
const arg = args[i];
|
|
496
|
+
if (arg === '--force' || arg === '--json') {
|
|
497
|
+
flags[arg.slice(2)] = true;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (['--out', '--registry'].includes(arg)) {
|
|
501
|
+
const value = args[++i];
|
|
502
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
|
|
503
|
+
flags[arg.slice(2)] = value;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (arg.startsWith('--')) throw new Error(`Unknown flag for add: ${arg}`);
|
|
507
|
+
positional.push(arg);
|
|
508
|
+
}
|
|
509
|
+
if (positional.length !== 1) throw new Error('Usage: knolo add <publisher>/<slug>[@<version>] [--out <path>] [--force] [--json] [--registry <url>]');
|
|
510
|
+
return { spec: parsePackSpec(positional[0]), flags };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function parseLoginArgs(args) {
|
|
514
|
+
const flags = {};
|
|
515
|
+
for (let i = 0; i < args.length; i++) {
|
|
516
|
+
const arg = args[i];
|
|
517
|
+
if (arg === '--stdin') {
|
|
518
|
+
flags.stdin = true;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (arg === '--token' || arg === '--registry') {
|
|
522
|
+
const value = args[++i];
|
|
523
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
|
|
524
|
+
flags[arg.slice(2)] = value;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (arg.startsWith('--')) throw new Error(`Unknown flag for login: ${arg}`);
|
|
528
|
+
throw new Error('Usage: knolo login [--token kno_…] [--stdin] [--registry <url>]');
|
|
529
|
+
}
|
|
530
|
+
return { flags };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function isHubAddInvocation(args) {
|
|
534
|
+
if (!args[0] || args[0].startsWith('--')) return false;
|
|
535
|
+
const positional = [];
|
|
536
|
+
for (let i = 0; i < args.length; i++) {
|
|
537
|
+
const arg = args[i];
|
|
538
|
+
if (['--out', '--registry'].includes(arg)) {
|
|
539
|
+
i += 1;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (arg === '--force' || arg === '--json') continue;
|
|
543
|
+
if (arg.startsWith('--')) return false;
|
|
544
|
+
positional.push(arg);
|
|
545
|
+
}
|
|
546
|
+
if (positional.length !== 1) return false;
|
|
547
|
+
try {
|
|
548
|
+
parsePackSpec(positional[0]);
|
|
549
|
+
return true;
|
|
550
|
+
} catch {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function parseSearchArgs(args) {
|
|
556
|
+
const positional = [];
|
|
557
|
+
const flags = {};
|
|
558
|
+
for (let i = 0; i < args.length; i++) {
|
|
559
|
+
const arg = args[i];
|
|
560
|
+
if (arg === '--json' || arg === '--official' || arg === '--agents') {
|
|
561
|
+
flags[arg.slice(2)] = true;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (['--format', '--license', '--registry'].includes(arg)) {
|
|
565
|
+
const value = args[++i];
|
|
566
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
|
|
567
|
+
flags[arg.slice(2)] = value;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (arg.startsWith('--')) throw new Error(`Unknown flag for search: ${arg}`);
|
|
571
|
+
positional.push(arg);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const query = positional.join(' ').trim();
|
|
575
|
+
if (!query) throw new Error('Usage: knolo search <query> [--format V4|V5] [--license <id>] [--official] [--agents] [--json] [--registry <url>]');
|
|
576
|
+
if (flags.format && !['V4', 'V5'].includes(String(flags.format).toUpperCase())) {
|
|
577
|
+
throw new Error('--format must be V4 or V5.');
|
|
578
|
+
}
|
|
579
|
+
if (flags.format) flags.format = String(flags.format).toUpperCase();
|
|
580
|
+
return { query, flags };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function parseInfoArgs(args) {
|
|
584
|
+
const positional = [];
|
|
585
|
+
const flags = {};
|
|
586
|
+
for (let i = 0; i < args.length; i++) {
|
|
587
|
+
const arg = args[i];
|
|
588
|
+
if (arg === '--json') {
|
|
589
|
+
flags.json = true;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (arg === '--registry') {
|
|
593
|
+
const value = args[++i];
|
|
594
|
+
if (value === undefined || value.startsWith('--')) throw new Error('Missing value for --registry');
|
|
595
|
+
flags.registry = value;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (arg.startsWith('--')) throw new Error(`Unknown flag for info: ${arg}`);
|
|
599
|
+
positional.push(arg);
|
|
600
|
+
}
|
|
601
|
+
if (positional.length !== 1) throw new Error('Usage: knolo info <publisher>/<slug> [--json] [--registry <url>]');
|
|
602
|
+
return { positional, flags };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function validatePacksResponse(body) {
|
|
606
|
+
if (!body || typeof body !== 'object' || !Array.isArray(body.packs)) {
|
|
607
|
+
throw new Error('Registry returned an invalid packs response.');
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function validatePackInfoResponse(body) {
|
|
612
|
+
if (!body || typeof body !== 'object' || typeof body.name !== 'string' || !body.pack || typeof body.pack !== 'object') {
|
|
613
|
+
throw new Error('Registry returned an invalid pack response.');
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function validateKnowledgeImage(core, bytes) {
|
|
618
|
+
if (core.isKnowledgeImageV5?.(bytes)) {
|
|
619
|
+
try {
|
|
620
|
+
const verification = core.verifyKnowledgeImageV5(bytes);
|
|
621
|
+
return { format: 'V5', stateRoot: verification.stateRoot };
|
|
622
|
+
} catch (error) {
|
|
623
|
+
throw new Error(`invalid V5 Knowledge Image: ${error instanceof Error ? error.message : String(error)}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
628
|
+
if (core.isPackV4?.(buffer)) {
|
|
629
|
+
try {
|
|
630
|
+
await core.mountPack({ src: bytes });
|
|
631
|
+
return { format: 'V4' };
|
|
632
|
+
} catch (error) {
|
|
633
|
+
throw new Error(`invalid V4 Knowledge Image: ${error instanceof Error ? error.message : String(error)}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
throw new Error('not a Knowledge Image');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function stageArtifactCopy(sourcePath, outputPath, cwd) {
|
|
640
|
+
const targetPath = path.resolve(cwd, outputPath);
|
|
641
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
642
|
+
const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
643
|
+
try {
|
|
644
|
+
await copyFile(sourcePath, tempPath);
|
|
645
|
+
await chmod(tempPath, 0o644);
|
|
646
|
+
return { targetPath, tempPath };
|
|
647
|
+
} catch (error) {
|
|
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;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function pathExists(filePath) {
|
|
707
|
+
return Boolean(await statIfPresent(filePath));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function unlinkIfPresent(filePath) {
|
|
711
|
+
try {
|
|
712
|
+
await unlink(filePath);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function renderSearchTable(packs) {
|
|
719
|
+
if (!packs.length) return 'No packs found.';
|
|
720
|
+
|
|
721
|
+
const rows = packs.map((pack) => [
|
|
722
|
+
pack.id || pack.name || `${pack.publisher || ''}/${pack.slug || ''}`,
|
|
723
|
+
pack.version ?? '',
|
|
724
|
+
pack.format ?? '',
|
|
725
|
+
pack.license ?? '',
|
|
726
|
+
pack.pulls ?? 0,
|
|
727
|
+
pack.description ?? '',
|
|
728
|
+
].map((value) => String(value)));
|
|
729
|
+
const widths = [24, 9, 6, 11, 8];
|
|
730
|
+
const header = ['NAME', 'VERSION', 'FORMAT', 'LICENSE', 'PULLS', 'DESCRIPTION'];
|
|
731
|
+
const formatRow = (row) => [
|
|
732
|
+
pad(truncate(row[0], widths[0]), widths[0]),
|
|
733
|
+
pad(truncate(row[1], widths[1]), widths[1]),
|
|
734
|
+
pad(truncate(row[2], widths[2]), widths[2]),
|
|
735
|
+
pad(truncate(row[3], widths[3]), widths[3]),
|
|
736
|
+
pad(truncate(row[4], widths[4]), widths[4]),
|
|
737
|
+
truncate(row[5], 72),
|
|
738
|
+
].join(' ').trimEnd();
|
|
739
|
+
|
|
740
|
+
return [formatRow(header), ...rows.map(formatRow)].join('\n');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function renderPackInfo(body) {
|
|
744
|
+
const pack = body.pack || {};
|
|
745
|
+
const latest = body.latest || {};
|
|
746
|
+
const lines = [
|
|
747
|
+
`name ${body.name}`,
|
|
748
|
+
`description ${body.description || pack.description || ''}`,
|
|
749
|
+
`publisher ${body.publisher || pack.publisher || ''}`,
|
|
750
|
+
`slug ${body.slug || pack.slug || ''}`,
|
|
751
|
+
`format ${pack.format || ''}`,
|
|
752
|
+
`version ${pack.version || latest.version || ''}`,
|
|
753
|
+
`license ${pack.license || latest.license || ''}`,
|
|
754
|
+
`sizeBytes ${pack.sizeBytes ?? latest.sizeBytes ?? 0}`,
|
|
755
|
+
`docs ${pack.docs ?? 0}`,
|
|
756
|
+
`blocks ${pack.blocks ?? 0}`,
|
|
757
|
+
`namespaces ${Array.isArray(pack.namespaces) ? pack.namespaces.join(', ') : ''}`,
|
|
758
|
+
`pulls ${pack.pulls ?? 0}`,
|
|
759
|
+
`stars ${pack.stars ?? 0}`,
|
|
760
|
+
`topics ${Array.isArray(pack.topics) ? pack.topics.join(', ') : ''}`,
|
|
761
|
+
`latest ${latest.version || ''}`,
|
|
762
|
+
`sha256 ${latest.sha256 || pack.sha256 || ''}`,
|
|
763
|
+
`url ${latest.url || pack.blobUrl || ''}`,
|
|
764
|
+
];
|
|
765
|
+
return lines.join('\n');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function truncate(value, width) {
|
|
769
|
+
if (value.length <= width) return value;
|
|
770
|
+
if (width <= 1) return '…'.slice(0, width);
|
|
771
|
+
return `${value.slice(0, width - 1)}…`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function pad(value, width) {
|
|
775
|
+
return value.padEnd(width, ' ');
|
|
776
|
+
}
|