@celilo/cli 0.3.16 → 0.3.17

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.ts +77 -45
  3. package/src/cli/command-registry.ts +12 -12
  4. package/src/cli/commands/completion.ts +12 -11
  5. package/src/cli/commands/module-check.ts +158 -0
  6. package/src/cli/commands/module-import.ts +5 -5
  7. package/src/cli/commands/module-publish.test.ts +3 -90
  8. package/src/cli/commands/module-publish.ts +14 -118
  9. package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
  10. package/src/cli/commands/proxmox-template-selection.ts +258 -0
  11. package/src/cli/commands/service-add-proxmox.ts +49 -127
  12. package/src/cli/commands/service-reconfigure.ts +36 -79
  13. package/src/cli/commands/service-verify.ts +20 -79
  14. package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
  15. package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
  16. package/src/cli/completion.ts +29 -2
  17. package/src/cli/index.ts +16 -7
  18. package/src/module/import.ts +4 -2
  19. package/src/registry/client.ts +14 -1
  20. package/src/services/module-deploy.ts +19 -1
  21. package/src/services/module-validator/capability-versions.test.ts +90 -0
  22. package/src/services/module-validator/capability-versions.ts +115 -0
  23. package/src/services/module-validator/contract-version.test.ts +24 -0
  24. package/src/services/module-validator/contract-version.ts +69 -0
  25. package/src/services/module-validator/git-hygiene.test.ts +141 -0
  26. package/src/services/module-validator/git-hygiene.ts +144 -0
  27. package/src/services/module-validator/index.test.ts +67 -0
  28. package/src/services/module-validator/index.ts +74 -0
  29. package/src/services/module-validator/manifest-schema.ts +42 -0
  30. package/src/services/module-validator/types.ts +43 -0
  31. package/src/services/module-validator/typescript-build.test.ts +58 -0
  32. package/src/services/module-validator/typescript-build.ts +115 -0
  33. package/src/services/module-validator/workspace-deps.test.ts +137 -0
  34. package/src/services/module-validator/workspace-deps.ts +187 -0
  35. package/src/system/prereqs.test.ts +374 -0
  36. package/src/system/prereqs.ts +377 -0
@@ -0,0 +1,69 @@
1
+ import { SUPPORTED_CONTRACT_VERSIONS } from '../../manifest/contracts';
2
+ import type { Check } from './types';
3
+
4
+ interface ManifestWithContract {
5
+ celilo_contract?: string;
6
+ }
7
+
8
+ /**
9
+ * Compares the manifest's declared `celilo_contract` against the framework's
10
+ * supported set:
11
+ *
12
+ * - `ok` — claim equals the latest supported contract.
13
+ * - `warn` — claim is older but still supported. The manifest works, but
14
+ * new contract features won't be available.
15
+ * - `fail` — claim is unknown to the framework (typo, future version
16
+ * from a newer celilo, etc.). Manifest schema validation
17
+ * already catches this case more loudly; we still report it
18
+ * so a `module check` against a manifest with a bad contract
19
+ * highlights it as a contract-level problem.
20
+ *
21
+ * Today the framework only ships contract "1.0", so the warn branch is
22
+ * forward-looking; once "1.1" lands, modules still on "1.0" will see a
23
+ * minor-bump suggestion.
24
+ */
25
+ export function checkContractVersion(manifest: ManifestWithContract): Check {
26
+ const claimed = manifest.celilo_contract;
27
+ const supported = SUPPORTED_CONTRACT_VERSIONS;
28
+ const latest = supported[supported.length - 1];
29
+
30
+ if (!claimed) {
31
+ return {
32
+ category: 'contract_version',
33
+ name: 'celilo_contract',
34
+ status: 'fail',
35
+ message: 'manifest is missing the celilo_contract field',
36
+ suggestedValue: latest,
37
+ };
38
+ }
39
+
40
+ if (claimed === latest) {
41
+ return {
42
+ category: 'contract_version',
43
+ name: 'celilo_contract',
44
+ status: 'ok',
45
+ message: `manifest declares celilo_contract: ${claimed} (latest)`,
46
+ currentValue: claimed,
47
+ };
48
+ }
49
+
50
+ if ((supported as readonly string[]).includes(claimed)) {
51
+ return {
52
+ category: 'contract_version',
53
+ name: 'celilo_contract',
54
+ status: 'warn',
55
+ message: `manifest declares celilo_contract: ${claimed} (still supported, but ${latest} is available)`,
56
+ currentValue: claimed,
57
+ suggestedValue: latest,
58
+ };
59
+ }
60
+
61
+ return {
62
+ category: 'contract_version',
63
+ name: 'celilo_contract',
64
+ status: 'fail',
65
+ message: `manifest declares celilo_contract: ${claimed} (unknown to this framework — supported: ${supported.join(', ')})`,
66
+ currentValue: claimed,
67
+ suggestedValue: latest,
68
+ };
69
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { checkGitHygiene, checkModuleStale } from './git-hygiene';
7
+
8
+ interface TempRepo {
9
+ dir: string;
10
+ cleanup: () => void;
11
+ exec: (cmd: string) => string;
12
+ }
13
+
14
+ /**
15
+ * Spin up a fresh git repo in a temp dir. Returns helpers for committing
16
+ * files and a cleanup function. Tests use this to construct exact git
17
+ * histories without polluting the surrounding working tree.
18
+ */
19
+ function makeTempRepo(): TempRepo {
20
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-git-hygiene-'));
21
+ const exec = (cmd: string): string =>
22
+ execSync(cmd, { cwd: dir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
23
+ exec('git init -q');
24
+ exec('git config user.email "test@example.com"');
25
+ exec('git config user.name "Test"');
26
+ exec('git config commit.gpgsign false');
27
+ return {
28
+ dir,
29
+ exec,
30
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
31
+ };
32
+ }
33
+
34
+ describe('checkModuleStale', () => {
35
+ test('null when manifest.yml is the most recently committed file', () => {
36
+ const repo = makeTempRepo();
37
+ try {
38
+ writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n');
39
+ repo.exec('git add install.sh && git commit -q -m "initial src"');
40
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
41
+ repo.exec('git add manifest.yml && git commit -q -m "manifest"');
42
+
43
+ expect(checkModuleStale(repo.dir)).toBeNull();
44
+ } finally {
45
+ repo.cleanup();
46
+ }
47
+ });
48
+
49
+ test('returns issue when src has commits past last manifest commit', () => {
50
+ const repo = makeTempRepo();
51
+ try {
52
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
53
+ repo.exec('git add manifest.yml && git commit -q -m "manifest"');
54
+ writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\necho hi\n');
55
+ repo.exec('git add install.sh && git commit -q -m "src change"');
56
+
57
+ const issue = checkModuleStale(repo.dir);
58
+ expect(issue).not.toBeNull();
59
+ expect(issue?.lastSrcCommit).toMatch(/^[0-9a-f]{40}$/);
60
+ expect(issue?.lastManifestCommit).toMatch(/^[0-9a-f]{40}$/);
61
+ expect(issue?.lastSrcCommit).not.toBe(issue?.lastManifestCommit);
62
+ } finally {
63
+ repo.cleanup();
64
+ }
65
+ });
66
+
67
+ test('null when same commit touched both src and manifest', () => {
68
+ const repo = makeTempRepo();
69
+ try {
70
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
71
+ writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n');
72
+ repo.exec('git add . && git commit -q -m "both"');
73
+
74
+ expect(checkModuleStale(repo.dir)).toBeNull();
75
+ } finally {
76
+ repo.cleanup();
77
+ }
78
+ });
79
+
80
+ test('null when not in a git repo', () => {
81
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-git-hygiene-bare-'));
82
+ try {
83
+ writeFileSync(join(dir, 'manifest.yml'), 'id: x\n');
84
+ expect(checkModuleStale(dir)).toBeNull();
85
+ } finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+ });
90
+
91
+ describe('checkGitHygiene', () => {
92
+ test('all ok on a fresh, clean repo with manifest as latest commit', () => {
93
+ const repo = makeTempRepo();
94
+ try {
95
+ writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n');
96
+ repo.exec('git add . && git commit -q -m "src"');
97
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
98
+ repo.exec('git add . && git commit -q -m "manifest"');
99
+
100
+ const checks = checkGitHygiene(repo.dir);
101
+ expect(checks.every((c) => c.status === 'ok')).toBe(true);
102
+ } finally {
103
+ repo.cleanup();
104
+ }
105
+ });
106
+
107
+ test('fail on stale-version drift, with the same actionable message publish uses', () => {
108
+ const repo = makeTempRepo();
109
+ try {
110
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
111
+ repo.exec('git add . && git commit -q -m "manifest"');
112
+ writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n');
113
+ repo.exec('git add . && git commit -q -m "src after manifest"');
114
+
115
+ const checks = checkGitHygiene(repo.dir);
116
+ const stale = checks.find((c) => c.name === 'stale-version drift');
117
+ expect(stale?.status).toBe('fail');
118
+ expect(stale?.message).toContain('manifest.yml');
119
+ expect(stale?.message).toContain('+N');
120
+ } finally {
121
+ repo.cleanup();
122
+ }
123
+ });
124
+
125
+ test('warn on dirty working tree', () => {
126
+ const repo = makeTempRepo();
127
+ try {
128
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
129
+ repo.exec('git add . && git commit -q -m "manifest"');
130
+ // Edit something without committing — dirty tree.
131
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.1\n');
132
+
133
+ const checks = checkGitHygiene(repo.dir);
134
+ const tree = checks.find((c) => c.name === 'working tree');
135
+ expect(tree?.status).toBe('warn');
136
+ expect(tree?.message).toContain('uncommitted');
137
+ } finally {
138
+ repo.cleanup();
139
+ }
140
+ });
141
+ });
@@ -0,0 +1,144 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { collectGitInfo, makeRealGitRunner } from '../../module/packaging/release-metadata';
4
+ import type { Check } from './types';
5
+
6
+ export interface StalenessIssue {
7
+ moduleDir: string;
8
+ lastSrcCommit: string;
9
+ lastManifestCommit: string;
10
+ }
11
+
12
+ /**
13
+ * SHA of the last commit touching the given pathspecs, or null on any
14
+ * git failure (not a repo, never committed, etc.). Callers treat null
15
+ * as "can't determine — skip the check."
16
+ *
17
+ * Runs git from `cwd` so module-check works regardless of the operator's
18
+ * shell CWD (e.g. `celilo module check ~/hobby/lunacycle` invoked from
19
+ * anywhere should still talk to lunacycle's git history).
20
+ */
21
+ function lastCommitTouching(cwd: string, pathspec: string[]): string | null {
22
+ const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], {
23
+ cwd,
24
+ stdio: ['ignore', 'pipe', 'ignore'],
25
+ encoding: 'utf-8',
26
+ });
27
+ if (r.status !== 0) return null;
28
+ const sha = r.stdout.trim();
29
+ return sha || null;
30
+ }
31
+
32
+ function isAncestor(cwd: string, maybeAncestor: string, descendant: string): boolean {
33
+ const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], {
34
+ cwd,
35
+ stdio: 'ignore',
36
+ });
37
+ return r.status === 0;
38
+ }
39
+
40
+ /**
41
+ * Detect "I edited module src but forgot to bump (or touch) manifest.yml."
42
+ *
43
+ * Returns null when the manifest is the most recently-touched file in the
44
+ * dir (or when neither side has any commit history — e.g. brand-new module
45
+ * not yet committed). Returns a StalenessIssue when src has commits AFTER
46
+ * the last manifest.yml change — the operator must bump the manifest
47
+ * (semver change → reset +N to 1) or just touch it (release-only change →
48
+ * auto-bump +N), then re-publish.
49
+ *
50
+ * Used by both `module publish` (where it refuses the publish) and
51
+ * `module check` (where it surfaces as a fail before the operator goes
52
+ * through publish at all).
53
+ */
54
+ export function checkModuleStale(moduleDir: string): StalenessIssue | null {
55
+ const manifestPath = join(moduleDir, 'manifest.yml');
56
+ const lastManifest = lastCommitTouching(moduleDir, [manifestPath]);
57
+ // Excluding manifest.yml from the "src" pathspec is the whole point — we
58
+ // want to know if anything ELSE in the dir moved past it. node_modules
59
+ // and common build outputs are gitignored already, but be explicit
60
+ // defensively.
61
+ const lastSrc = lastCommitTouching(moduleDir, [
62
+ moduleDir,
63
+ `:(exclude)${manifestPath}`,
64
+ `:(exclude)${moduleDir}/node_modules`,
65
+ `:(exclude)${moduleDir}/dist`,
66
+ ]);
67
+ if (!lastManifest || !lastSrc) return null;
68
+ if (lastSrc === lastManifest) return null;
69
+ if (!isAncestor(moduleDir, lastManifest, lastSrc)) return null;
70
+
71
+ return { moduleDir, lastSrcCommit: lastSrc, lastManifestCommit: lastManifest };
72
+ }
73
+
74
+ /**
75
+ * Publish-readiness checks against the module's git state:
76
+ *
77
+ * - Stale-version drift: src commits past the last manifest.yml commit.
78
+ * Surfaced as fail. Same shape `module publish` enforces.
79
+ * - Working tree dirty: uncommitted changes in the module dir. Surfaced
80
+ * as warn — `--allow-dirty` overrides at publish time but it's still
81
+ * the kind of thing an operator usually wants to know.
82
+ *
83
+ * Both quietly degrade to ok when the module isn't in a git repo at all
84
+ * (brand-new uncommitted module, third-party module shipped as a
85
+ * directory tarball, etc.) — git operations return null/empty and we
86
+ * skip the check.
87
+ */
88
+ export function checkGitHygiene(modulePath: string): Check[] {
89
+ const checks: Check[] = [];
90
+
91
+ const stale = checkModuleStale(modulePath);
92
+ if (stale) {
93
+ checks.push({
94
+ category: 'git_hygiene',
95
+ name: 'stale-version drift',
96
+ status: 'fail',
97
+ message: [
98
+ 'src commits past last manifest.yml change',
99
+ ` src commit: ${stale.lastSrcCommit.slice(0, 12)}`,
100
+ ` manifest.yml commit: ${stale.lastManifestCommit.slice(0, 12)}`,
101
+ ' Bump manifest.yml#version (semver change), or touch it',
102
+ ' (release-only — auto-revision picks the next +N), then commit.',
103
+ ].join('\n'),
104
+ });
105
+ } else {
106
+ checks.push({
107
+ category: 'git_hygiene',
108
+ name: 'stale-version drift',
109
+ status: 'ok',
110
+ message: 'manifest.yml is current with respect to module src',
111
+ });
112
+ }
113
+
114
+ try {
115
+ const gitInfo = collectGitInfo(modulePath, makeRealGitRunner());
116
+ if (gitInfo.dirty) {
117
+ checks.push({
118
+ category: 'git_hygiene',
119
+ name: 'working tree',
120
+ status: 'warn',
121
+ message:
122
+ 'working tree has uncommitted changes; publish refuses unless --allow-dirty is passed',
123
+ });
124
+ } else {
125
+ checks.push({
126
+ category: 'git_hygiene',
127
+ name: 'working tree',
128
+ status: 'ok',
129
+ message: 'working tree clean',
130
+ });
131
+ }
132
+ } catch {
133
+ // Not in a git repo, or git unavailable. Stale-check above already
134
+ // returned ok in that case; we mirror it here for consistency.
135
+ checks.push({
136
+ category: 'git_hygiene',
137
+ name: 'working tree',
138
+ status: 'ok',
139
+ message: 'not a git repo — skipping working-tree check',
140
+ });
141
+ }
142
+
143
+ return checks;
144
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Integration test for runChecks — wires every checker together against
3
+ * a real in-tree module (caddy). The in-tree modules are guaranteed
4
+ * healthy by virtue of CI running their build/import flow on every PR;
5
+ * a regression in runChecks would surface as fail/warn here.
6
+ *
7
+ * The npm fetch is stubbed to keep the test offline.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { resolve } from 'node:path';
12
+ import { runChecks } from '.';
13
+
14
+ const REPO_ROOT = resolve(__dirname, '../../../../..');
15
+ const CADDY_MODULE_PATH = resolve(REPO_ROOT, 'modules/caddy');
16
+
17
+ describe('runChecks (orchestrator)', () => {
18
+ test('healthy in-tree module produces all-ok report', async () => {
19
+ const checks = await runChecks(CADDY_MODULE_PATH, {
20
+ noBuild: true,
21
+ fetchNpmMetadata: async () => null,
22
+ });
23
+
24
+ // git_hygiene is environment-dependent — the in-tree caddy module's
25
+ // git state varies during dev. Stale-version drift, dirty-tree, etc.
26
+ // are real publish gates but not what this test is asserting (which
27
+ // is "the orchestrator wires every checker up cleanly").
28
+ const fails = checks.filter((c) => c.status === 'fail' && c.category !== 'git_hygiene');
29
+ expect(fails).toEqual([]);
30
+
31
+ // Must include checks from every category we expect to fire.
32
+ const categories = new Set(checks.map((c) => c.category));
33
+ expect(categories.has('manifest_schema')).toBe(true);
34
+ expect(categories.has('contract_version')).toBe(true);
35
+ expect(categories.has('capability')).toBe(true);
36
+ expect(categories.has('workspace_dep')).toBe(true);
37
+ expect(categories.has('git_hygiene')).toBe(true);
38
+ expect(categories.has('typescript_build')).toBe(true);
39
+ });
40
+
41
+ test('returns checks in stable category order', async () => {
42
+ const checks = await runChecks(CADDY_MODULE_PATH, {
43
+ noBuild: true,
44
+ fetchNpmMetadata: async () => null,
45
+ });
46
+
47
+ const seenCategories: string[] = [];
48
+ for (const c of checks) {
49
+ if (seenCategories[seenCategories.length - 1] !== c.category) {
50
+ seenCategories.push(c.category);
51
+ }
52
+ }
53
+
54
+ expect(seenCategories[0]).toBe('manifest_schema');
55
+ expect(seenCategories[seenCategories.length - 1]).toBe('typescript_build');
56
+ });
57
+
58
+ test('nonexistent path produces a failed manifest_schema check', async () => {
59
+ const checks = await runChecks('/tmp/celilo-runChecks-doesnotexist', {
60
+ noBuild: true,
61
+ fetchNpmMetadata: async () => null,
62
+ });
63
+
64
+ const schemaCheck = checks.find((c) => c.category === 'manifest_schema');
65
+ expect(schemaCheck?.status).toBe('fail');
66
+ });
67
+ });
@@ -0,0 +1,74 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+ import { type ManifestForCapabilityCheck, checkCapabilityVersions } from './capability-versions';
3
+ import { checkContractVersion } from './contract-version';
4
+ import { checkGitHygiene } from './git-hygiene';
5
+ import { checkManifestSchema, readManifestYaml } from './manifest-schema';
6
+ import type { Check, RunChecksOptions } from './types';
7
+ import { checkTypeScriptBuild } from './typescript-build';
8
+ import { checkWorkspaceDeps, defaultFetchNpmMetadata } from './workspace-deps';
9
+
10
+ export type { Check, CheckCategory, CheckStatus, NpmMetadata, RunChecksOptions } from './types';
11
+ export {
12
+ checkCapabilityVersions,
13
+ validateCapabilityVersions,
14
+ type ManifestForCapabilityCheck,
15
+ } from './capability-versions';
16
+ export { checkContractVersion } from './contract-version';
17
+ export { checkGitHygiene, checkModuleStale, type StalenessIssue } from './git-hygiene';
18
+ export { checkManifestSchema } from './manifest-schema';
19
+ export { checkTypeScriptBuild } from './typescript-build';
20
+ export { checkWorkspaceDeps, defaultFetchNpmMetadata } from './workspace-deps';
21
+
22
+ interface RawManifest extends ManifestForCapabilityCheck {
23
+ celilo_contract?: string;
24
+ }
25
+
26
+ /**
27
+ * Runs every checker against the module at `modulePath` and returns the
28
+ * combined `Check[]` in a stable, human-friendly order:
29
+ *
30
+ * 1. manifest_schema — bedrock; if this fails, downstream checks
31
+ * may not even have a parseable manifest
32
+ * 2. contract_version — top-of-file claim; semantically the framing
33
+ * for everything else
34
+ * 3. capability — manifest's claimed capability versions
35
+ * 4. workspace_dep — npm-fed; the only network-dependent check
36
+ * 5. git_hygiene — publish-readiness gates (stale-version drift,
37
+ * dirty-tree); same shape `module publish`
38
+ * enforces, surfaced here so a clean check
39
+ * really does mean a clean publish
40
+ * 6. typescript_build — slowest, most likely to spew errors
41
+ *
42
+ * Each checker returns its own Check[]; the orchestrator does no
43
+ * filtering or reshaping. CLI presentation is layered on top.
44
+ */
45
+ export async function runChecks(
46
+ modulePath: string,
47
+ options: RunChecksOptions = {},
48
+ ): Promise<Check[]> {
49
+ const fetcher = options.fetchNpmMetadata ?? defaultFetchNpmMetadata;
50
+ const checks: Check[] = [];
51
+
52
+ const schemaCheck = await checkManifestSchema(modulePath);
53
+ checks.push(schemaCheck);
54
+
55
+ let manifest: RawManifest | null = null;
56
+ try {
57
+ const yaml = await readManifestYaml(modulePath);
58
+ manifest = parseYaml(yaml) as RawManifest;
59
+ } catch {
60
+ // Couldn't even read the YAML — manifest_schema check above already
61
+ // reports the failure. Skip the rest of the manifest-derived checks.
62
+ }
63
+
64
+ if (manifest) {
65
+ checks.push(checkContractVersion(manifest));
66
+ checks.push(...checkCapabilityVersions(manifest));
67
+ }
68
+
69
+ checks.push(...(await checkWorkspaceDeps(modulePath, fetcher)));
70
+ checks.push(...checkGitHygiene(modulePath));
71
+ checks.push(await checkTypeScriptBuild(modulePath, { noBuild: options.noBuild }));
72
+
73
+ return checks;
74
+ }
@@ -0,0 +1,42 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readModuleManifest } from '../../module/import';
4
+ import type { Check } from './types';
5
+
6
+ /**
7
+ * Runs the same manifest pipeline `module import` runs (zod schema +
8
+ * zone + derive + hook + cross-cap + capability-name + variable-source
9
+ * checks). Reports a single Check: ok if everything passes, fail with
10
+ * the first error message otherwise.
11
+ *
12
+ * One row instead of N because a broken manifest cascades — fix the
13
+ * first thing and the next run will surface the next thing. Surfacing
14
+ * every individual sub-validator would just spam the report.
15
+ */
16
+ export async function checkManifestSchema(modulePath: string): Promise<Check> {
17
+ const result = await readModuleManifest(modulePath);
18
+ if (result.success) {
19
+ return {
20
+ category: 'manifest_schema',
21
+ name: 'manifest.yml',
22
+ status: 'ok',
23
+ message: 'manifest passes all schema validations',
24
+ };
25
+ }
26
+ return {
27
+ category: 'manifest_schema',
28
+ name: 'manifest.yml',
29
+ status: 'fail',
30
+ message: result.error,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Reads `manifest.yml` from the given module path as raw YAML. Used by
36
+ * checkers that want the manifest's claimed values without paying the
37
+ * full validation cost (e.g. capability-version drift, where a malformed
38
+ * manifest is already failed by checkManifestSchema).
39
+ */
40
+ export async function readManifestYaml(modulePath: string): Promise<string> {
41
+ return readFile(join(modulePath, 'manifest.yml'), 'utf-8');
42
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared types for `celilo module check` and the validators it composes.
3
+ *
4
+ * A `Check` is a single named verdict — one row in the report. The same
5
+ * checks are produced regardless of output format (text or JSON); the
6
+ * format layer is pure presentation.
7
+ */
8
+
9
+ export type CheckStatus = 'ok' | 'warn' | 'fail';
10
+
11
+ export type CheckCategory =
12
+ | 'capability'
13
+ | 'workspace_dep'
14
+ | 'manifest_schema'
15
+ | 'contract_version'
16
+ | 'typescript_build'
17
+ | 'git_hygiene';
18
+
19
+ export interface Check {
20
+ category: CheckCategory;
21
+ name: string;
22
+ status: CheckStatus;
23
+ message: string;
24
+ /** What the module currently says (manifest claim, package.json range, etc.). */
25
+ currentValue?: string;
26
+ /** What we suggest changing it to. Empty when the fix isn't a single value. */
27
+ suggestedValue?: string;
28
+ /** Convention-generated link to a per-package migration page. */
29
+ migrationUrl?: string;
30
+ }
31
+
32
+ export interface RunChecksOptions {
33
+ /** Skip the TypeScript build check unconditionally. */
34
+ noBuild?: boolean;
35
+ /** Injectable npm metadata fetcher; defaults to real registry.npmjs.org. */
36
+ fetchNpmMetadata?: (packageName: string) => Promise<NpmMetadata | null>;
37
+ }
38
+
39
+ export interface NpmMetadata {
40
+ name: string;
41
+ /** "latest" tag — the version a fresh `npm install` would pick up. */
42
+ latestVersion: string;
43
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { checkTypeScriptBuild } from './typescript-build';
6
+
7
+ describe('checkTypeScriptBuild', () => {
8
+ test('skips with ok when noBuild is set', async () => {
9
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-tsc-'));
10
+ try {
11
+ const r = await checkTypeScriptBuild(dir, { noBuild: true });
12
+ expect(r.status).toBe('ok');
13
+ expect(r.message).toContain('skipped');
14
+ } finally {
15
+ rmSync(dir, { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ test('skips with ok when there is no TypeScript surface', async () => {
20
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-tsc-'));
21
+ try {
22
+ // Just YAML and shell — nothing to typecheck.
23
+ writeFileSync(join(dir, 'manifest.yml'), 'id: x\nversion: 1.0.0');
24
+ writeFileSync(join(dir, 'install.sh'), '#!/usr/bin/env bash\n');
25
+ const r = await checkTypeScriptBuild(dir);
26
+ expect(r.status).toBe('ok');
27
+ expect(r.message).toContain('no TypeScript surface');
28
+ } finally {
29
+ rmSync(dir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test('warns when .ts files exist but no tsconfig', async () => {
34
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-tsc-'));
35
+ try {
36
+ mkdirSync(join(dir, 'scripts'));
37
+ writeFileSync(join(dir, 'scripts', 'install.ts'), 'export const x = 1;\n');
38
+ const r = await checkTypeScriptBuild(dir);
39
+ expect(r.status).toBe('warn');
40
+ expect(r.message).toContain('tsconfig.json');
41
+ } finally {
42
+ rmSync(dir, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ test('fail with helpful message when tsconfig present but no node_modules', async () => {
47
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-tsc-'));
48
+ try {
49
+ writeFileSync(join(dir, 'tsconfig.json'), '{}');
50
+ const r = await checkTypeScriptBuild(dir);
51
+ expect(r.status).toBe('fail');
52
+ expect(r.message).toContain('node_modules');
53
+ expect(r.message).toContain('bun install');
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ });
58
+ });