@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.
@@ -0,0 +1,46 @@
1
+ export const DEFAULT_PRODUCTION_REGISTRY = 'https://hub.knolo.dev';
2
+ export const DEFAULT_DEVELOPMENT_REGISTRY = 'http://localhost:3000';
3
+
4
+ export function resolveRegistryUrl({ value, env = process.env } = {}) {
5
+ const configured = value || env.KNOLO_HUB_URL;
6
+ if (configured) return normalizeRegistryUrl(configured);
7
+
8
+ const development = env.NODE_ENV === 'development' || env.KNOLO_ENV === 'development';
9
+ return normalizeRegistryUrl(development ? DEFAULT_DEVELOPMENT_REGISTRY : DEFAULT_PRODUCTION_REGISTRY);
10
+ }
11
+
12
+ export function normalizeRegistryUrl(value) {
13
+ if (typeof value !== 'string' || !value.trim()) {
14
+ throw new Error('Registry URL must be a non-empty HTTP(S) URL.');
15
+ }
16
+
17
+ let url;
18
+ try {
19
+ url = new URL(value.trim());
20
+ } catch {
21
+ throw new Error(`Invalid registry URL: ${value}`);
22
+ }
23
+
24
+ if (!['http:', 'https:'].includes(url.protocol)) {
25
+ throw new Error(`Registry URL must use http or https: ${value}`);
26
+ }
27
+ if (url.username || url.password || url.search || url.hash) {
28
+ throw new Error('Registry URL must be an origin without credentials, query parameters, or fragments.');
29
+ }
30
+ if (url.pathname !== '/' && url.pathname !== '') {
31
+ throw new Error('Registry URL must be an origin without a path.');
32
+ }
33
+
34
+ return url.origin;
35
+ }
36
+
37
+ export function registryApiUrl(registry, segments, searchParams) {
38
+ const url = new URL(normalizeRegistryUrl(registry));
39
+ url.pathname = `/api/v1/${segments.map((segment) => encodeURIComponent(String(segment))).join('/')}`;
40
+ if (searchParams) {
41
+ for (const [key, value] of Object.entries(searchParams)) {
42
+ if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
43
+ }
44
+ }
45
+ return url;
46
+ }
@@ -0,0 +1,93 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+
7
+ const TOKEN_PATTERN = /^kno_[A-Za-z0-9_-]+$/;
8
+
9
+ export function credentialsPath({ env = process.env, homeDir = homedir() } = {}) {
10
+ const configHome = env.XDG_CONFIG_HOME ? path.resolve(env.XDG_CONFIG_HOME) : path.join(homeDir, '.config');
11
+ return path.join(configHome, 'knolo', 'credentials.json');
12
+ }
13
+
14
+ export async function saveCredentials({ registry, token, env = process.env, homeDir = homedir() } = {}) {
15
+ const normalizedToken = typeof token === 'string' ? token.trim() : token;
16
+ validateToken(normalizedToken);
17
+ if (typeof registry !== 'string' || !registry) throw new Error('Credential registry is required.');
18
+ const targetPath = credentialsPath({ env, homeDir });
19
+ const directory = path.dirname(targetPath);
20
+ await mkdir(directory, { recursive: true, mode: 0o700 });
21
+ await chmod(directory, 0o700);
22
+
23
+ const value = {
24
+ registry,
25
+ token: normalizedToken,
26
+ prefix: normalizedToken.slice(0, 12),
27
+ };
28
+ const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`;
29
+ try {
30
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
31
+ await chmod(tempPath, 0o600);
32
+ await rename(tempPath, targetPath);
33
+ await chmod(targetPath, 0o600);
34
+ } finally {
35
+ await unlink(tempPath).catch(() => {});
36
+ }
37
+ return { path: targetPath, registry, prefix: value.prefix };
38
+ }
39
+
40
+ export async function loadCredentials({ env = process.env, homeDir = homedir() } = {}) {
41
+ const filePath = credentialsPath({ env, homeDir });
42
+ let text;
43
+ try {
44
+ text = await readFile(filePath, 'utf8');
45
+ } catch (error) {
46
+ if (error?.code === 'ENOENT') throw new Error('Not logged in. Run "knolo login" first.');
47
+ throw error;
48
+ }
49
+
50
+ let value;
51
+ try {
52
+ value = JSON.parse(text);
53
+ } catch {
54
+ throw new Error('Invalid JSON in knolo credentials.');
55
+ }
56
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Knolo credentials must be a JSON object.');
57
+ if (typeof value.registry !== 'string' || !value.registry) throw new Error('Knolo credentials are missing registry.');
58
+ validateToken(value.token);
59
+ if (typeof value.prefix !== 'string' || value.prefix !== value.token.slice(0, 12)) throw new Error('Knolo credentials have an invalid token prefix.');
60
+ return { path: filePath, registry: value.registry, token: value.token, prefix: value.prefix };
61
+ }
62
+
63
+ export async function removeCredentials({ env = process.env, homeDir = homedir() } = {}) {
64
+ const filePath = credentialsPath({ env, homeDir });
65
+ try {
66
+ await unlink(filePath);
67
+ return { path: filePath, removed: true };
68
+ } catch (error) {
69
+ if (error?.code === 'ENOENT') return { path: filePath, removed: false };
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ export async function promptForToken({ input = process.stdin, output = process.stderr, forceStdin = false } = {}) {
75
+ if (forceStdin || !input.isTTY) {
76
+ const chunks = [];
77
+ for await (const chunk of input) chunks.push(Buffer.from(chunk));
78
+ return Buffer.concat(chunks).toString('utf8').trim();
79
+ }
80
+
81
+ const readline = createInterface({ input, output });
82
+ try {
83
+ return (await readline.question('Hub token (kno_…): ')).trim();
84
+ } finally {
85
+ readline.close();
86
+ }
87
+ }
88
+
89
+ export function validateToken(token) {
90
+ if (typeof token !== 'string' || !TOKEN_PATTERN.test(token.trim())) {
91
+ throw new Error('Token must start with kno_ and contain only base64url characters.');
92
+ }
93
+ }
@@ -0,0 +1,30 @@
1
+ export class RegistryError extends Error {
2
+ constructor(message, { status, code, url, body, cause } = {}) {
3
+ super(message, cause ? { cause } : undefined);
4
+ this.name = 'RegistryError';
5
+ this.status = status;
6
+ this.code = code;
7
+ this.url = url;
8
+ this.body = body;
9
+ }
10
+ }
11
+
12
+ export class RegistryNetworkError extends Error {
13
+ constructor(message, { url, cause } = {}) {
14
+ super(message, cause ? { cause } : undefined);
15
+ this.name = 'RegistryNetworkError';
16
+ this.url = url;
17
+ }
18
+ }
19
+
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
+ }
24
+ if (typeof body?.error === 'string' && body.error.trim()) return body.error.trim();
25
+ if (status === 404 || code === 'not_found') return 'Registry resource not found.';
26
+ if (status === 410 || code === 'yanked') return 'Registry version is yanked.';
27
+ if (status === 413 || code === 'too_large') return 'Registry artifact is too large.';
28
+ if (status === 503 || code === 'unconfigured') return 'Registry is not configured.';
29
+ return `Registry request failed${status ? ` with HTTP ${status}` : ''}${url ? `: ${url}` : '.'}`;
30
+ }
@@ -0,0 +1,94 @@
1
+ import { RegistryError, RegistryNetworkError, registryErrorMessage } from './errors.mjs';
2
+
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 } = {}) {
39
+ if (typeof fetchImpl !== 'function') throw new Error('This Node runtime does not provide fetch.');
40
+
41
+ let response;
42
+ try {
43
+ response = await fetchImpl(url, {
44
+ method,
45
+ redirect: 'follow',
46
+ headers: { accept: 'application/json', ...(requestBody !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
47
+ ...(requestBody !== undefined ? { body: JSON.stringify(requestBody) } : {}),
48
+ });
49
+ } catch (error) {
50
+ throw new RegistryNetworkError(`Could not reach registry at ${new URL(url).origin}.`, { url: String(url), cause: error });
51
+ }
52
+
53
+ const text = await readResponseText(response, url);
54
+ let body;
55
+ if (text) {
56
+ try {
57
+ body = JSON.parse(text);
58
+ } catch {
59
+ if (!response.ok) {
60
+ throw new RegistryError(`Registry returned HTTP ${response.status} with a non-JSON response.`, {
61
+ status: response.status,
62
+ url: String(url),
63
+ body: text.slice(0, 240),
64
+ });
65
+ }
66
+ throw new RegistryError('Registry returned invalid JSON.', {
67
+ status: response.status,
68
+ code: 'invalid_response',
69
+ url: String(url),
70
+ body: text.slice(0, 240),
71
+ });
72
+ }
73
+ }
74
+
75
+ if (!response.ok) {
76
+ const code = typeof body?.code === 'string' ? body.code : undefined;
77
+ throw new RegistryError(registryErrorMessage({ status: response.status, code, body, url: String(url) }), {
78
+ status: response.status,
79
+ code,
80
+ url: String(url),
81
+ body,
82
+ });
83
+ }
84
+
85
+ return body;
86
+ }
87
+
88
+ async function readResponseText(response, url) {
89
+ try {
90
+ return await response.text();
91
+ } catch (error) {
92
+ throw new RegistryNetworkError(`Could not read registry response from ${new URL(url).origin}.`, { url: String(url), cause: error });
93
+ }
94
+ }
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const LOCKFILE_NAME = 'knolo.lock.json';
6
+
7
+ export function lockfilePath(cwd = process.cwd()) {
8
+ return path.resolve(cwd, LOCKFILE_NAME);
9
+ }
10
+
11
+ export function cachePath(homeDir, sha256) {
12
+ return path.join(homeDir, '.knolo', 'cache', 'sha256', `${sha256}.knolo`);
13
+ }
14
+
15
+ export async function validateLockfileUpdate({ cwd = process.cwd(), registry, manifest, force = false } = {}) {
16
+ const targetPath = lockfilePath(cwd);
17
+ const current = await readLockfile(targetPath);
18
+ if (current.registry && current.registry !== registry && !force) {
19
+ throw new Error(`Lockfile registry is ${current.registry}; refusing to mix registries without --force.`);
20
+ }
21
+
22
+ const packs = current.packs && typeof current.packs === 'object' && !Array.isArray(current.packs)
23
+ ? current.packs
24
+ : {};
25
+ const existing = packs[manifest.name];
26
+ if (existing?.sha256 && existing.sha256 !== manifest.sha256 && !force) {
27
+ throw new Error(`Lockfile already pins ${manifest.name} to a different digest; use --force to replace it.`);
28
+ }
29
+
30
+ const entry = {
31
+ version: manifest.version,
32
+ sha256: manifest.sha256,
33
+ ...(manifest.stateRoot !== undefined ? { stateRoot: manifest.stateRoot } : {}),
34
+ ...(manifest.license !== undefined ? { license: manifest.license } : {}),
35
+ };
36
+ return { targetPath, current, entry, packs };
37
+ }
38
+
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 } = {}) {
50
+ const { targetPath, entry, packs } = await validateLockfileUpdate({ cwd, registry, manifest, force });
51
+ const next = {
52
+ registry,
53
+ packs: { ...packs, [manifest.name]: entry },
54
+ };
55
+ const tempPath = await writeJsonTemp(targetPath, next);
56
+ return { path: targetPath, tempPath, entry };
57
+ }
58
+
59
+ async function readLockfile(filePath) {
60
+ let text;
61
+ try {
62
+ text = await readFile(filePath, 'utf8');
63
+ } catch (error) {
64
+ if (error?.code === 'ENOENT') return { packs: {} };
65
+ throw error;
66
+ }
67
+ let value;
68
+ try {
69
+ value = JSON.parse(text);
70
+ } catch {
71
+ throw new Error(`Invalid JSON in ${LOCKFILE_NAME}.`);
72
+ }
73
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${LOCKFILE_NAME} must be a JSON object.`);
74
+ if (value.registry !== undefined && typeof value.registry !== 'string') throw new Error(`${LOCKFILE_NAME}.registry must be a string.`);
75
+ if (value.packs !== undefined && (typeof value.packs !== 'object' || Array.isArray(value.packs))) throw new Error(`${LOCKFILE_NAME}.packs must be an object.`);
76
+ return value;
77
+ }
78
+
79
+ async function writeJsonTemp(filePath, value) {
80
+ await mkdir(path.dirname(filePath), { recursive: true });
81
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
82
+ try {
83
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o644 });
84
+ return tempPath;
85
+ } catch (error) {
86
+ await unlink(tempPath).catch(() => {});
87
+ throw error;
88
+ }
89
+ }
@@ -0,0 +1,47 @@
1
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
2
+
3
+ export function validatePackManifest(value, { expectedName, requestedVersion } = {}) {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
5
+ throw new Error('Registry returned an invalid pack manifest.');
6
+ }
7
+ if (typeof value.name !== 'string' || !value.name) throw new Error('Registry manifest is missing name.');
8
+ if (expectedName && value.name !== expectedName) throw new Error(`Registry manifest name mismatch: expected ${expectedName}, got ${value.name}.`);
9
+ if (typeof value.version !== 'string' || !value.version) throw new Error('Registry manifest is missing version.');
10
+ if (requestedVersion && requestedVersion !== 'latest' && value.version !== requestedVersion) {
11
+ throw new Error(`Registry manifest version mismatch: expected ${requestedVersion}, got ${value.version}.`);
12
+ }
13
+ if (typeof value.sha256 !== 'string' || !SHA256_PATTERN.test(value.sha256)) {
14
+ throw new Error('Registry manifest sha256 must be 64 lowercase hexadecimal characters.');
15
+ }
16
+ if (typeof value.url !== 'string') throw new Error('Registry manifest is missing url.');
17
+ if (!Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0) {
18
+ throw new Error('Registry manifest sizeBytes must be a non-negative integer.');
19
+ }
20
+ if (value.stateRoot !== undefined && typeof value.stateRoot !== 'string') {
21
+ throw new Error('Registry manifest stateRoot must be a string when present.');
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
+ }
29
+ if (value.license !== undefined && typeof value.license !== 'string') {
30
+ throw new Error('Registry manifest license must be a string when present.');
31
+ }
32
+ if (value.yanked !== undefined && typeof value.yanked !== 'boolean') {
33
+ throw new Error('Registry manifest yanked must be a boolean when present.');
34
+ }
35
+
36
+ return {
37
+ name: value.name,
38
+ version: value.version,
39
+ sha256: value.sha256,
40
+ ...(value.stateRoot !== undefined ? { stateRoot: value.stateRoot } : {}),
41
+ url: value.url,
42
+ ...(value.license !== undefined ? { license: value.license } : {}),
43
+ sizeBytes: value.sizeBytes,
44
+ yanked: value.yanked === true,
45
+ ...(value.format !== undefined ? { format: value.format } : {}),
46
+ };
47
+ }
@@ -0,0 +1,20 @@
1
+ const PACK_SPEC_PATTERN = /^(?<publisher>[a-z0-9-]+)\/(?<slug>[a-z0-9-]+)(?:@(?<version>[^@]+))?$/;
2
+
3
+ export function parsePackSpec(value) {
4
+ if (typeof value !== 'string' || !value.trim()) throw new Error('Pack spec must be publisher/slug[@version].');
5
+ const match = PACK_SPEC_PATTERN.exec(value.trim());
6
+ if (!match) throw new Error(`Invalid pack spec: ${value}. Expected publisher/slug[@version].`);
7
+
8
+ return {
9
+ publisher: match.groups.publisher,
10
+ slug: match.groups.slug,
11
+ version: match.groups.version || 'latest',
12
+ name: `${match.groups.publisher}/${match.groups.slug}`,
13
+ };
14
+ }
15
+
16
+ export function parsePackName(value) {
17
+ const parsed = parsePackSpec(value);
18
+ if (value.includes('@')) throw new Error(`Pack name must not include a version: ${value}`);
19
+ return parsed;
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knolo/cli",
3
- "version": "5.1.0",
3
+ "version": "5.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "knolo": "bin/knolo.mjs"
@@ -28,7 +28,8 @@
28
28
  },
29
29
  "scripts": {
30
30
  "build": "node -e \"console.log('cli is executable via bin/knolo.mjs')\"",
31
- "test": "npm run build --prefix ../core && node --test test/cli.test.mjs"
31
+ "test": "npm run build --prefix ../core && node --test test/cli.test.mjs test/hub.integration.test.mjs",
32
+ "test:hub": "node --test test/hub.integration.test.mjs"
32
33
  },
33
34
  "dependencies": {
34
35
  "@knolo/core": "^5.1.0"