@celilo/cli 0.3.16 → 0.3.18
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/package.json +1 -1
- package/src/api-clients/proxmox.ts +77 -45
- package/src/cli/command-registry.ts +23 -35
- package/src/cli/commands/completion.ts +12 -11
- package/src/cli/commands/module-check.ts +158 -0
- package/src/cli/commands/module-import-routing.test.ts +52 -0
- package/src/cli/commands/module-import.ts +70 -27
- package/src/cli/commands/module-publish.test.ts +3 -90
- package/src/cli/commands/module-publish.ts +14 -118
- package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
- package/src/cli/commands/proxmox-template-selection.ts +258 -0
- package/src/cli/commands/service-add-proxmox.ts +49 -127
- package/src/cli/commands/service-reconfigure.ts +36 -79
- package/src/cli/commands/service-verify.ts +20 -79
- package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
- package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
- package/src/cli/commands/system-update.ts +1 -1
- package/src/cli/completion.ts +29 -8
- package/src/cli/index.ts +25 -30
- package/src/manifest/schema.ts +9 -1
- package/src/module/import.ts +4 -2
- package/src/registry/client.ts +14 -1
- package/src/services/bus-interview.ts +13 -1
- package/src/services/bus-secret-flow.test.ts +94 -0
- package/src/services/config-interview.ts +66 -6
- package/src/services/module-deploy.ts +19 -1
- package/src/services/module-validator/capability-versions.test.ts +90 -0
- package/src/services/module-validator/capability-versions.ts +115 -0
- package/src/services/module-validator/contract-version.test.ts +24 -0
- package/src/services/module-validator/contract-version.ts +69 -0
- package/src/services/module-validator/git-hygiene.test.ts +141 -0
- package/src/services/module-validator/git-hygiene.ts +144 -0
- package/src/services/module-validator/index.test.ts +67 -0
- package/src/services/module-validator/index.ts +74 -0
- package/src/services/module-validator/manifest-schema.ts +42 -0
- package/src/services/module-validator/types.ts +43 -0
- package/src/services/module-validator/typescript-build.test.ts +58 -0
- package/src/services/module-validator/typescript-build.ts +115 -0
- package/src/services/module-validator/workspace-deps.test.ts +137 -0
- package/src/services/module-validator/workspace-deps.ts +187 -0
- package/src/services/terminal-responder.ts +75 -0
- package/src/system/prereqs.test.ts +374 -0
- package/src/system/prereqs.ts +377 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readdir } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { Check } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns true if the module has at least one TypeScript file under
|
|
9
|
+
* `scripts/` (recursively). Used to decide whether `typescript-build`
|
|
10
|
+
* has anything to check at all.
|
|
11
|
+
*/
|
|
12
|
+
async function hasTypeScriptSources(modulePath: string): Promise<boolean> {
|
|
13
|
+
const scriptsDir = join(modulePath, 'scripts');
|
|
14
|
+
if (!existsSync(scriptsDir)) return false;
|
|
15
|
+
const stack: string[] = [scriptsDir];
|
|
16
|
+
while (stack.length > 0) {
|
|
17
|
+
const dir = stack.pop();
|
|
18
|
+
if (!dir) continue;
|
|
19
|
+
try {
|
|
20
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
stack.push(join(dir, entry.name));
|
|
24
|
+
} else if (entry.name.endsWith('.ts')) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// unreadable directory — ignore and continue
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runs `bunx tsc --noEmit` from the module directory.
|
|
37
|
+
*
|
|
38
|
+
* - Modules with no `tsconfig.json` AND no `.ts` files in `scripts/`
|
|
39
|
+
* are silently skipped (returns ok). Pure YAML+shell modules don't
|
|
40
|
+
* need a TS check.
|
|
41
|
+
* - Modules with `.ts` sources but no tsconfig get a warn telling them
|
|
42
|
+
* to add one.
|
|
43
|
+
* - Otherwise we spawn tsc and report ok / fail with the captured
|
|
44
|
+
* stderr+stdout truncated to a reasonable size.
|
|
45
|
+
*
|
|
46
|
+
* The whole check is skipped when `noBuild` is true; callers use that
|
|
47
|
+
* for fast iteration.
|
|
48
|
+
*/
|
|
49
|
+
export async function checkTypeScriptBuild(
|
|
50
|
+
modulePath: string,
|
|
51
|
+
options: { noBuild?: boolean } = {},
|
|
52
|
+
): Promise<Check> {
|
|
53
|
+
if (options.noBuild) {
|
|
54
|
+
return {
|
|
55
|
+
category: 'typescript_build',
|
|
56
|
+
name: 'tsc --noEmit',
|
|
57
|
+
status: 'ok',
|
|
58
|
+
message: 'skipped (--no-build)',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hasTsConfig = existsSync(join(modulePath, 'tsconfig.json'));
|
|
63
|
+
const hasTsFiles = await hasTypeScriptSources(modulePath);
|
|
64
|
+
|
|
65
|
+
if (!hasTsConfig && !hasTsFiles) {
|
|
66
|
+
return {
|
|
67
|
+
category: 'typescript_build',
|
|
68
|
+
name: 'tsc --noEmit',
|
|
69
|
+
status: 'ok',
|
|
70
|
+
message: 'no TypeScript surface to check',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!hasTsConfig) {
|
|
75
|
+
return {
|
|
76
|
+
category: 'typescript_build',
|
|
77
|
+
name: 'tsc --noEmit',
|
|
78
|
+
status: 'warn',
|
|
79
|
+
message:
|
|
80
|
+
'module has .ts files but no tsconfig.json; add one so we can typecheck against current @celilo types',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!existsSync(join(modulePath, 'node_modules'))) {
|
|
85
|
+
return {
|
|
86
|
+
category: 'typescript_build',
|
|
87
|
+
name: 'tsc --noEmit',
|
|
88
|
+
status: 'fail',
|
|
89
|
+
message: 'no node_modules — run `bun install` in the module directory first',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const r = spawnSync('bunx', ['tsc', '--noEmit'], {
|
|
94
|
+
cwd: modulePath,
|
|
95
|
+
encoding: 'utf-8',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (r.status === 0) {
|
|
99
|
+
return {
|
|
100
|
+
category: 'typescript_build',
|
|
101
|
+
name: 'tsc --noEmit',
|
|
102
|
+
status: 'ok',
|
|
103
|
+
message: 'TypeScript build clean',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const output = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim();
|
|
108
|
+
const truncated = output.length > 800 ? `${output.slice(0, 800)}\n…(truncated)` : output;
|
|
109
|
+
return {
|
|
110
|
+
category: 'typescript_build',
|
|
111
|
+
name: 'tsc --noEmit',
|
|
112
|
+
status: 'fail',
|
|
113
|
+
message: truncated || 'tsc exited non-zero with no output',
|
|
114
|
+
};
|
|
115
|
+
}
|