@celilo/cli 0.1.4 → 0.1.6
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/drizzle/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +9 -8
- package/src/ansible/inventory.ts +9 -7
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +45 -12
- package/src/capabilities/registration.test.ts +6 -6
- package/src/capabilities/well-known.test.ts +2 -2
- package/src/capabilities/well-known.ts +5 -5
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-show.ts +1 -1
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.test.ts +3 -3
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +135 -72
- package/src/hooks/define-hook.test.ts +11 -3
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/template-validator.ts +1 -1
- package/src/manifest/validate.test.ts +1 -1
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +121 -25
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-planner.ts +5 -5
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/dns-auto-register.ts +4 -4
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +372 -61
- package/src/services/proxmox-state-recovery.ts +6 -6
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +3 -3
- package/src/templates/generator.ts +43 -2
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.test.ts +31 -31
- package/src/variables/context.ts +65 -17
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +64 -9
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/resolver.ts +27 -9
- package/src/variables/types.ts +1 -1
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { type GitCommandRunner, buildReleaseMetadata, collectGitInfo } from './release-metadata';
|
|
3
|
+
|
|
4
|
+
describe('buildReleaseMetadata', () => {
|
|
5
|
+
test('produces a stable shape from injected inputs', () => {
|
|
6
|
+
const meta = buildReleaseMetadata({
|
|
7
|
+
moduleId: 'caddy',
|
|
8
|
+
version: '1.2.0+3',
|
|
9
|
+
git: { sha: 'abc1234', branch: 'main', dirty: false },
|
|
10
|
+
cliVersion: '0.1.5',
|
|
11
|
+
message: 'Fix DNS race',
|
|
12
|
+
publishedAt: new Date('2026-04-25T18:30:00Z'),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(meta).toEqual({
|
|
16
|
+
module_id: 'caddy',
|
|
17
|
+
version: '1.2.0+3',
|
|
18
|
+
git_sha: 'abc1234',
|
|
19
|
+
git_branch: 'main',
|
|
20
|
+
git_dirty: false,
|
|
21
|
+
published_at: '2026-04-25T18:30:00.000Z',
|
|
22
|
+
published_by_cli_version: '0.1.5',
|
|
23
|
+
message: 'Fix DNS race',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('null message is preserved (no defaulting to empty string)', () => {
|
|
28
|
+
const meta = buildReleaseMetadata({
|
|
29
|
+
moduleId: 'x',
|
|
30
|
+
version: '1.0.0+1',
|
|
31
|
+
git: { sha: null, branch: null, dirty: false },
|
|
32
|
+
cliVersion: '0.1.5',
|
|
33
|
+
message: null,
|
|
34
|
+
publishedAt: new Date('2026-04-25T00:00:00Z'),
|
|
35
|
+
});
|
|
36
|
+
expect(meta.message).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('defaults publishedAt to now when omitted', () => {
|
|
40
|
+
const before = Date.now();
|
|
41
|
+
const meta = buildReleaseMetadata({
|
|
42
|
+
moduleId: 'x',
|
|
43
|
+
version: '1.0.0+1',
|
|
44
|
+
git: { sha: null, branch: null, dirty: false },
|
|
45
|
+
cliVersion: '0.1.5',
|
|
46
|
+
message: null,
|
|
47
|
+
});
|
|
48
|
+
const stamped = new Date(meta.published_at).getTime();
|
|
49
|
+
expect(stamped).toBeGreaterThanOrEqual(before);
|
|
50
|
+
expect(stamped).toBeLessThanOrEqual(Date.now());
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('collectGitInfo', () => {
|
|
55
|
+
test('returns nulls + clean when not in a git checkout', () => {
|
|
56
|
+
const run: GitCommandRunner = () => null;
|
|
57
|
+
expect(collectGitInfo('/tmp/x', run)).toEqual({
|
|
58
|
+
sha: null,
|
|
59
|
+
branch: null,
|
|
60
|
+
dirty: false,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('reads sha + branch + clean status', () => {
|
|
65
|
+
const calls: string[][] = [];
|
|
66
|
+
const run: GitCommandRunner = (args) => {
|
|
67
|
+
calls.push(args);
|
|
68
|
+
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234';
|
|
69
|
+
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main';
|
|
70
|
+
if (args[0] === 'status') return '';
|
|
71
|
+
return null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const info = collectGitInfo('/tmp/x', run);
|
|
75
|
+
|
|
76
|
+
expect(info).toEqual({ sha: 'abc1234', branch: 'main', dirty: false });
|
|
77
|
+
expect(calls).toEqual([
|
|
78
|
+
['rev-parse', 'HEAD'],
|
|
79
|
+
['rev-parse', '--abbrev-ref', 'HEAD'],
|
|
80
|
+
['status', '--porcelain'],
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('detached HEAD reports null branch', () => {
|
|
85
|
+
const run: GitCommandRunner = (args) => {
|
|
86
|
+
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234';
|
|
87
|
+
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'HEAD';
|
|
88
|
+
if (args[0] === 'status') return '';
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
expect(collectGitInfo('/tmp/x', run).branch).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('non-empty status output → dirty=true', () => {
|
|
95
|
+
const run: GitCommandRunner = (args) => {
|
|
96
|
+
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234';
|
|
97
|
+
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main';
|
|
98
|
+
if (args[0] === 'status') return ' M src/foo.ts\n?? new-file.txt';
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
expect(collectGitInfo('/tmp/x', run).dirty).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release metadata stamped into every .netapp at publish time.
|
|
3
|
+
*
|
|
4
|
+
* Per CELILO_UPDATE D5, each published package carries a `release.json`
|
|
5
|
+
* recording git SHA, branch, dirty flag, publish timestamp, the CLI
|
|
6
|
+
* version that produced the build, and an optional one-line release
|
|
7
|
+
* note. `system audit` and `system update` surface this to give the
|
|
8
|
+
* user a clear "what changed?" signal without requiring a curated
|
|
9
|
+
* CHANGELOG.
|
|
10
|
+
*
|
|
11
|
+
* Pure helpers for building / parsing live here. The actual `release.json`
|
|
12
|
+
* write happens inside `buildModule` after the staged copy is set up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
export interface ReleaseMetadata {
|
|
20
|
+
/** Module ID (matches manifest.id) */
|
|
21
|
+
module_id: string;
|
|
22
|
+
/** Module version including revision suffix (e.g. "1.2.0+3") */
|
|
23
|
+
version: string;
|
|
24
|
+
/** Git commit SHA of the source tree at build time, or null if not in a git checkout */
|
|
25
|
+
git_sha: string | null;
|
|
26
|
+
/** Git branch name, or null */
|
|
27
|
+
git_branch: string | null;
|
|
28
|
+
/** True if the working tree had uncommitted changes when this was built */
|
|
29
|
+
git_dirty: boolean;
|
|
30
|
+
/** ISO-8601 UTC timestamp */
|
|
31
|
+
published_at: string;
|
|
32
|
+
/** Version of @celilo/cli that produced the build */
|
|
33
|
+
published_by_cli_version: string;
|
|
34
|
+
/** Optional one-line release note from --message */
|
|
35
|
+
message: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pure constructor — takes pre-collected git state and assembles the
|
|
40
|
+
* structured metadata. The `gitInfo` and `cliVersion` come from the
|
|
41
|
+
* caller; this keeps the function trivially testable.
|
|
42
|
+
*/
|
|
43
|
+
export function buildReleaseMetadata(opts: {
|
|
44
|
+
moduleId: string;
|
|
45
|
+
version: string;
|
|
46
|
+
git: GitInfo;
|
|
47
|
+
cliVersion: string;
|
|
48
|
+
message: string | null;
|
|
49
|
+
publishedAt?: Date;
|
|
50
|
+
}): ReleaseMetadata {
|
|
51
|
+
return {
|
|
52
|
+
module_id: opts.moduleId,
|
|
53
|
+
version: opts.version,
|
|
54
|
+
git_sha: opts.git.sha,
|
|
55
|
+
git_branch: opts.git.branch,
|
|
56
|
+
git_dirty: opts.git.dirty,
|
|
57
|
+
published_at: (opts.publishedAt ?? new Date()).toISOString(),
|
|
58
|
+
published_by_cli_version: opts.cliVersion,
|
|
59
|
+
message: opts.message,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface GitInfo {
|
|
64
|
+
sha: string | null;
|
|
65
|
+
branch: string | null;
|
|
66
|
+
dirty: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the running CLI version by reading the bundled package.json.
|
|
71
|
+
* Returns "0.0.0" if the file isn't found (dev-from-source without a
|
|
72
|
+
* package.json shouldn't ever happen in practice, but we shouldn't
|
|
73
|
+
* crash a publish on it).
|
|
74
|
+
*/
|
|
75
|
+
export function readInstalledCliVersion(): string {
|
|
76
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
77
|
+
const candidates = [
|
|
78
|
+
join(here, '..', '..', '..', 'package.json'),
|
|
79
|
+
join(process.cwd(), 'package.json'),
|
|
80
|
+
];
|
|
81
|
+
for (const path of candidates) {
|
|
82
|
+
if (existsSync(path)) {
|
|
83
|
+
try {
|
|
84
|
+
const pkg = JSON.parse(readFileSync(path, 'utf-8')) as { version?: string };
|
|
85
|
+
if (pkg.version) return pkg.version;
|
|
86
|
+
} catch {
|
|
87
|
+
// try the next candidate
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return '0.0.0';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type GitCommandRunner = (args: string[], cwd: string) => string | null;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Default runner: invoke the system `git` binary, return stdout, or
|
|
98
|
+
* null on any failure (not a git repo, git not installed, etc.).
|
|
99
|
+
*/
|
|
100
|
+
export function makeRealGitRunner(): GitCommandRunner {
|
|
101
|
+
// Defer the import so test environments without bun:child_process aren't
|
|
102
|
+
// affected, and so this file stays pure for the unit tests.
|
|
103
|
+
const { execFileSync } = require('node:child_process') as typeof import('node:child_process');
|
|
104
|
+
return (args, cwd) => {
|
|
105
|
+
try {
|
|
106
|
+
return execFileSync('git', args, {
|
|
107
|
+
cwd,
|
|
108
|
+
encoding: 'utf-8',
|
|
109
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
110
|
+
})
|
|
111
|
+
.toString()
|
|
112
|
+
.trim();
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Collect git SHA, branch, and dirty state from the working tree at
|
|
121
|
+
* `sourceDir`. Returns nulls / false if the directory isn't in a git
|
|
122
|
+
* checkout (or git isn't available).
|
|
123
|
+
*/
|
|
124
|
+
export function collectGitInfo(sourceDir: string, run: GitCommandRunner): GitInfo {
|
|
125
|
+
const sha = run(['rev-parse', 'HEAD'], sourceDir);
|
|
126
|
+
if (!sha) return { sha: null, branch: null, dirty: false };
|
|
127
|
+
|
|
128
|
+
const branch = run(['rev-parse', '--abbrev-ref', 'HEAD'], sourceDir);
|
|
129
|
+
// `git status --porcelain` prints one line per modified/untracked file.
|
|
130
|
+
// Empty output = clean. Null (command failure) is treated as not-dirty
|
|
131
|
+
// because we don't want to falsely block a publish.
|
|
132
|
+
const status = run(['status', '--porcelain'], sourceDir);
|
|
133
|
+
const dirty = status !== null && status.length > 0;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
sha,
|
|
137
|
+
branch: branch === 'HEAD' ? null : branch, // detached HEAD reports "HEAD"
|
|
138
|
+
dirty,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Filename for the metadata file at the root of every .netapp.
|
|
144
|
+
*/
|
|
145
|
+
export const RELEASE_METADATA_FILENAME = 'release.json';
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
|
2
|
+
import type { IndexEntry } from './client';
|
|
3
|
+
import { DEFAULT_REGISTRY, RegistryClient } from './client';
|
|
4
|
+
|
|
5
|
+
// ── sparseIndexPath ───────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe('RegistryClient.sparseIndexPath', () => {
|
|
8
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
9
|
+
|
|
10
|
+
test('1-char name → index/1/{name}', () => {
|
|
11
|
+
expect(client.sparseIndexPath('a')).toBe('https://reg.example.com/index/1/a');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('2-char name → index/2/{name}', () => {
|
|
15
|
+
expect(client.sparseIndexPath('ab')).toBe('https://reg.example.com/index/2/ab');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('3-char name → index/3/{first2}/{name}', () => {
|
|
19
|
+
expect(client.sparseIndexPath('abc')).toBe('https://reg.example.com/index/3/ab/abc');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('4-char name → index/{01}/{23}/{name}', () => {
|
|
23
|
+
expect(client.sparseIndexPath('abcd')).toBe('https://reg.example.com/index/ab/cd/abcd');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('long name → index/{01}/{23}/{name}', () => {
|
|
27
|
+
expect(client.sparseIndexPath('homebridge')).toBe(
|
|
28
|
+
'https://reg.example.com/index/ho/me/homebridge',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('real module names produce expected paths', () => {
|
|
33
|
+
expect(client.sparseIndexPath('caddy')).toBe('https://reg.example.com/index/ca/dd/caddy');
|
|
34
|
+
expect(client.sparseIndexPath('dns-external')).toBe(
|
|
35
|
+
'https://reg.example.com/index/dn/s-/dns-external',
|
|
36
|
+
);
|
|
37
|
+
expect(client.sparseIndexPath('celilo-registry')).toBe(
|
|
38
|
+
'https://reg.example.com/index/ce/li/celilo-registry',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ── constructor / baseUrl ─────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe('RegistryClient constructor', () => {
|
|
46
|
+
const origEnv = process.env.CELILO_REGISTRY_URL;
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
if (origEnv === undefined) {
|
|
50
|
+
process.env.CELILO_REGISTRY_URL = undefined;
|
|
51
|
+
} else {
|
|
52
|
+
process.env.CELILO_REGISTRY_URL = origEnv;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('uses DEFAULT_REGISTRY when no arg and no env var', () => {
|
|
57
|
+
process.env.CELILO_REGISTRY_URL = undefined;
|
|
58
|
+
const client = new RegistryClient();
|
|
59
|
+
expect(client.baseUrl).toBe(DEFAULT_REGISTRY);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('prefers explicit arg over env var', () => {
|
|
63
|
+
process.env.CELILO_REGISTRY_URL = 'https://env.example.com';
|
|
64
|
+
const client = new RegistryClient('https://arg.example.com');
|
|
65
|
+
expect(client.baseUrl).toBe('https://arg.example.com');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('falls back to env var when no arg', () => {
|
|
69
|
+
process.env.CELILO_REGISTRY_URL = 'https://env.example.com';
|
|
70
|
+
const client = new RegistryClient();
|
|
71
|
+
expect(client.baseUrl).toBe('https://env.example.com');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('strips trailing slashes', () => {
|
|
75
|
+
const client = new RegistryClient('https://reg.example.com///');
|
|
76
|
+
expect(client.baseUrl).toBe('https://reg.example.com');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── latestVersion ─────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe('RegistryClient.latestVersion', () => {
|
|
83
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
84
|
+
|
|
85
|
+
function entry(vers: string, yanked = false): IndexEntry {
|
|
86
|
+
return { name: 'test', vers, deps: [], cksum: '', yanked };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('returns undefined for empty entries', () => {
|
|
90
|
+
expect(client.latestVersion([])).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('returns the last non-yanked entry', () => {
|
|
94
|
+
const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('1.0.0+3')];
|
|
95
|
+
expect(client.latestVersion(entries)?.vers).toBe('1.0.0+3');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('skips yanked versions at the end', () => {
|
|
99
|
+
const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('1.0.0+3', true)];
|
|
100
|
+
expect(client.latestVersion(entries)?.vers).toBe('1.0.0+2');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('returns undefined when all yanked', () => {
|
|
104
|
+
const entries = [entry('1.0.0+1', true), entry('1.0.0+2', true)];
|
|
105
|
+
expect(client.latestVersion(entries)).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('does not mutate the original array', () => {
|
|
109
|
+
const entries = [entry('1.0.0+1'), entry('1.0.0+2')];
|
|
110
|
+
client.latestVersion(entries);
|
|
111
|
+
expect(entries[0].vers).toBe('1.0.0+1');
|
|
112
|
+
expect(entries[1].vers).toBe('1.0.0+2');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── getIndex ──────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe('RegistryClient.getIndex', () => {
|
|
119
|
+
let fetchSpy: ReturnType<typeof spyOn>;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
fetchSpy = spyOn(globalThis, 'fetch');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
fetchSpy.mockRestore();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function makeNdJson(entries: IndexEntry[]): string {
|
|
130
|
+
return `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test('returns empty array on 404', async () => {
|
|
134
|
+
fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
|
|
135
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
136
|
+
const result = await client.getIndex('homebridge');
|
|
137
|
+
expect(result).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('throws on non-404 error status', async () => {
|
|
141
|
+
fetchSpy.mockResolvedValue(new Response(null, { status: 500 }));
|
|
142
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
143
|
+
await expect(client.getIndex('homebridge')).rejects.toThrow('Registry index error: HTTP 500');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('parses NDJSON index correctly', async () => {
|
|
147
|
+
const entries: IndexEntry[] = [
|
|
148
|
+
{ name: 'homebridge', vers: '1.4.2+1', deps: [], cksum: 'abc', yanked: false },
|
|
149
|
+
{ name: 'homebridge', vers: '1.4.2+2', deps: [], cksum: 'def', yanked: false },
|
|
150
|
+
];
|
|
151
|
+
fetchSpy.mockResolvedValue(new Response(makeNdJson(entries), { status: 200 }));
|
|
152
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
153
|
+
const result = await client.getIndex('homebridge');
|
|
154
|
+
expect(result).toHaveLength(2);
|
|
155
|
+
expect(result[0].vers).toBe('1.4.2+1');
|
|
156
|
+
expect(result[1].vers).toBe('1.4.2+2');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('calls the correct sparse index URL', async () => {
|
|
160
|
+
fetchSpy.mockResolvedValue(new Response('', { status: 200 }));
|
|
161
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
162
|
+
await client.getIndex('homebridge');
|
|
163
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
164
|
+
'https://reg.example.com/index/ho/me/homebridge',
|
|
165
|
+
expect.anything(),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── publish binary protocol ───────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe('RegistryClient.publish — Cargo binary protocol', () => {
|
|
173
|
+
let fetchSpy: ReturnType<typeof spyOn>;
|
|
174
|
+
let tmpFile: string;
|
|
175
|
+
|
|
176
|
+
beforeEach(async () => {
|
|
177
|
+
fetchSpy = spyOn(globalThis, 'fetch');
|
|
178
|
+
tmpFile = `/tmp/test-publish-${Date.now()}.netapp`;
|
|
179
|
+
await Bun.write(tmpFile, 'fake netapp content');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(async () => {
|
|
183
|
+
fetchSpy.mockRestore();
|
|
184
|
+
if (await Bun.file(tmpFile).exists()) await Bun.write(tmpFile, '');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('constructs correct Cargo binary framing', async () => {
|
|
188
|
+
fetchSpy.mockResolvedValue(
|
|
189
|
+
new Response(JSON.stringify({ ok: true, name: 'my-mod', vers: '1.0.0+1' }), { status: 200 }),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
193
|
+
await client.publish({ name: 'my-mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' });
|
|
194
|
+
|
|
195
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
196
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
197
|
+
expect(url).toBe('https://reg.example.com/api/v1/modules/new');
|
|
198
|
+
expect((init.headers as Record<string, string>).Authorization).toBe('tok');
|
|
199
|
+
|
|
200
|
+
const body = init.body as Buffer;
|
|
201
|
+
const metaLen = body.readUInt32LE(0);
|
|
202
|
+
const metaJson = JSON.parse(body.subarray(4, 4 + metaLen).toString('utf-8')) as {
|
|
203
|
+
name: string;
|
|
204
|
+
vers: string;
|
|
205
|
+
deps: unknown[];
|
|
206
|
+
cksum: string;
|
|
207
|
+
};
|
|
208
|
+
expect(metaJson.name).toBe('my-mod');
|
|
209
|
+
expect(metaJson.vers).toBe('1.0.0+1');
|
|
210
|
+
expect(metaJson.deps).toEqual([]);
|
|
211
|
+
expect(metaJson.cksum).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
212
|
+
|
|
213
|
+
const fileLen = body.readUInt32LE(4 + metaLen);
|
|
214
|
+
const fileBytes = body.subarray(4 + metaLen + 4);
|
|
215
|
+
expect(fileLen).toBe(fileBytes.length);
|
|
216
|
+
expect(fileBytes.toString('utf-8')).toBe('fake netapp content');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('throws with registry error detail on non-200', async () => {
|
|
220
|
+
fetchSpy.mockResolvedValue(
|
|
221
|
+
new Response(JSON.stringify({ errors: [{ detail: 'token invalid' }] }), { status: 403 }),
|
|
222
|
+
);
|
|
223
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
224
|
+
await expect(
|
|
225
|
+
client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'bad' }),
|
|
226
|
+
).rejects.toThrow('token invalid');
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Celilo Registry Client
|
|
3
|
+
*
|
|
4
|
+
* Implements the Cargo sparse registry protocol for fetching and publishing
|
|
5
|
+
* modules. Default registry is celilo.computer; can be overridden via
|
|
6
|
+
* CELILO_REGISTRY_URL or the --registry flag.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_REGISTRY = 'https://celilo.computer/registry';
|
|
13
|
+
|
|
14
|
+
export interface SparseConfig {
|
|
15
|
+
dl: string;
|
|
16
|
+
api: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IndexEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
vers: string;
|
|
22
|
+
deps: string[];
|
|
23
|
+
cksum: string;
|
|
24
|
+
yanked: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchResult {
|
|
28
|
+
name: string;
|
|
29
|
+
max_version: string;
|
|
30
|
+
description: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SearchResponse {
|
|
34
|
+
modules: SearchResult[];
|
|
35
|
+
total: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ModuleMetadata {
|
|
39
|
+
name: string;
|
|
40
|
+
versions: Array<{ num: string; yanked: boolean; created_at: string }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RegistryClient {
|
|
44
|
+
readonly baseUrl: string;
|
|
45
|
+
|
|
46
|
+
constructor(registryUrl?: string) {
|
|
47
|
+
// Strip trailing slash for consistent path joining
|
|
48
|
+
const raw = registryUrl ?? process.env.CELILO_REGISTRY_URL ?? DEFAULT_REGISTRY;
|
|
49
|
+
this.baseUrl = raw.replace(/\/+$/, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Sparse index ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async config(): Promise<SparseConfig> {
|
|
55
|
+
const resp = await this.get(`${this.baseUrl}/index/config.json`);
|
|
56
|
+
return resp.json() as Promise<SparseConfig>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
sparseIndexPath(name: string): string {
|
|
60
|
+
if (name.length === 1) return `${this.baseUrl}/index/1/${name}`;
|
|
61
|
+
if (name.length === 2) return `${this.baseUrl}/index/2/${name}`;
|
|
62
|
+
if (name.length === 3) return `${this.baseUrl}/index/3/${name.slice(0, 2)}/${name}`;
|
|
63
|
+
return `${this.baseUrl}/index/${name.slice(0, 2)}/${name.slice(2, 4)}/${name}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getIndex(name: string): Promise<IndexEntry[]> {
|
|
67
|
+
const resp = await fetch(this.sparseIndexPath(name), {
|
|
68
|
+
signal: AbortSignal.timeout(15_000),
|
|
69
|
+
});
|
|
70
|
+
if (resp.status === 404) return [];
|
|
71
|
+
if (!resp.ok) throw new Error(`Registry index error: HTTP ${resp.status}`);
|
|
72
|
+
const text = await resp.text();
|
|
73
|
+
return text
|
|
74
|
+
.split('\n')
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.map((line) => JSON.parse(line) as IndexEntry);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Returns the latest non-yanked version, or undefined if none. */
|
|
80
|
+
latestVersion(entries: IndexEntry[]): IndexEntry | undefined {
|
|
81
|
+
return [...entries].reverse().find((e) => !e.yanked);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── API ───────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
async search(query: string, perPage = 25): Promise<SearchResponse> {
|
|
87
|
+
const params = new URLSearchParams({ per_page: String(perPage) });
|
|
88
|
+
if (query) params.set('q', query);
|
|
89
|
+
const resp = await this.get(`${this.baseUrl}/api/v1/modules?${params}`);
|
|
90
|
+
return resp.json() as Promise<SearchResponse>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getModule(name: string): Promise<ModuleMetadata> {
|
|
94
|
+
const resp = await this.get(`${this.baseUrl}/api/v1/modules/${encodeURIComponent(name)}`);
|
|
95
|
+
return resp.json() as Promise<ModuleMetadata>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
downloadUrl(name: string, version: string): string {
|
|
99
|
+
return `${this.baseUrl}/api/v1/modules/${encodeURIComponent(name)}/${encodeURIComponent(version)}/download`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async download(name: string, version: string): Promise<ArrayBuffer> {
|
|
103
|
+
const resp = await this.get(this.downloadUrl(name, version));
|
|
104
|
+
return resp.arrayBuffer();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Publish a module to the registry.
|
|
109
|
+
*
|
|
110
|
+
* Follows the Cargo binary protocol:
|
|
111
|
+
* [4-byte LE uint32: JSON length] [JSON metadata] [4-byte LE uint32: file length] [.netapp bytes]
|
|
112
|
+
*/
|
|
113
|
+
async publish(opts: {
|
|
114
|
+
name: string;
|
|
115
|
+
version: string;
|
|
116
|
+
netappPath: string;
|
|
117
|
+
token: string;
|
|
118
|
+
}): Promise<{ ok: boolean; name: string; vers: string }> {
|
|
119
|
+
const fileData = await readFile(opts.netappPath);
|
|
120
|
+
const cksum = `sha256:${createHash('sha256').update(fileData).digest('hex')}`;
|
|
121
|
+
|
|
122
|
+
const meta = JSON.stringify({ name: opts.name, vers: opts.version, deps: [], cksum });
|
|
123
|
+
const metaBuf = Buffer.from(meta, 'utf-8');
|
|
124
|
+
|
|
125
|
+
const body = Buffer.allocUnsafe(4 + metaBuf.length + 4 + fileData.length);
|
|
126
|
+
body.writeUInt32LE(metaBuf.length, 0);
|
|
127
|
+
metaBuf.copy(body, 4);
|
|
128
|
+
body.writeUInt32LE(fileData.length, 4 + metaBuf.length);
|
|
129
|
+
fileData.copy(body, 4 + metaBuf.length + 4);
|
|
130
|
+
|
|
131
|
+
const resp = await fetch(`${this.baseUrl}/api/v1/modules/new`, {
|
|
132
|
+
method: 'PUT',
|
|
133
|
+
headers: { Authorization: opts.token, 'Content-Type': 'application/octet-stream' },
|
|
134
|
+
body,
|
|
135
|
+
signal: AbortSignal.timeout(120_000),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!resp.ok) {
|
|
139
|
+
const err = (await resp.json().catch(() => ({ errors: [{ detail: resp.statusText }] }))) as {
|
|
140
|
+
errors: Array<{ detail: string }>;
|
|
141
|
+
};
|
|
142
|
+
throw new Error(err.errors?.[0]?.detail ?? `HTTP ${resp.status}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return resp.json() as Promise<{ ok: boolean; name: string; vers: string }>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
private async get(url: string): Promise<Response> {
|
|
151
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
152
|
+
if (!resp.ok) {
|
|
153
|
+
throw new Error(`Registry error at ${url}: HTTP ${resp.status}`);
|
|
154
|
+
}
|
|
155
|
+
return resp;
|
|
156
|
+
}
|
|
157
|
+
}
|