@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 +44 -4
- package/bin/knolo.mjs +9 -6
- package/bin/registry/artifacts.mjs +21 -5
- package/bin/registry/commands.mjs +402 -18
- package/bin/registry/errors.mjs +3 -1
- package/bin/registry/http.mjs +38 -2
- package/bin/registry/lockfile.mjs +16 -5
- package/bin/registry/manifest.mjs +6 -0
- package/package.json +1 -1
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
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
|
|
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>
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
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
|
|
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
|
|
320
|
-
|
|
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
|
-
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function pathExists(filePath) {
|
|
707
|
+
return Boolean(await statIfPresent(filePath));
|
|
324
708
|
}
|
|
325
709
|
|
|
326
710
|
async function unlinkIfPresent(filePath) {
|
package/bin/registry/errors.mjs
CHANGED
|
@@ -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.';
|
package/bin/registry/http.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
75
|
-
}
|
|
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
|
}
|