cueline 0.2.2 → 0.3.0
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.ja.md +12 -12
- package/README.ko.md +12 -12
- package/README.md +12 -12
- package/README.zh-CN.md +12 -12
- package/README.zh-TW.md +12 -12
- package/dist/src/api-run-prune.d.ts +41 -0
- package/dist/src/api-run-prune.js +218 -0
- package/dist/src/api-run-prune.js.map +1 -0
- package/dist/src/api.d.ts +3 -0
- package/dist/src/api.js +3 -0
- package/dist/src/api.js.map +1 -1
- package/dist/src/cli/health-commands.js +35 -0
- package/dist/src/cli/health-commands.js.map +1 -1
- package/dist/src/cli/main.js +95 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/observation-commands.js +94 -1
- package/dist/src/cli/observation-commands.js.map +1 -1
- package/dist/src/diagnostics/offline-self-test.d.ts +23 -0
- package/dist/src/diagnostics/offline-self-test.js +163 -0
- package/dist/src/diagnostics/offline-self-test.js.map +1 -0
- package/dist/src/diagnostics/secret-audit.d.ts +25 -0
- package/dist/src/diagnostics/secret-audit.js +114 -0
- package/dist/src/diagnostics/secret-audit.js.map +1 -0
- package/dist/src/diagnostics/upgrade-preflight.d.ts +41 -0
- package/dist/src/diagnostics/upgrade-preflight.js +182 -0
- package/dist/src/diagnostics/upgrade-preflight.js.map +1 -0
- package/dist/src/observation/run-bundle.d.ts +27 -0
- package/dist/src/observation/run-bundle.js +55 -0
- package/dist/src/observation/run-bundle.js.map +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/docs/1.0/ideas/machine-output-contracts.md +47 -0
- package/docs/1.0/ideas/node26-contract.md +50 -0
- package/docs/1.0/ideas/offline-self-test.md +52 -0
- package/docs/1.0/ideas/upgrade-preflight.md +46 -0
- package/docs/compatibility.md +5 -5
- package/package.json +12 -2
- package/schemas/cli-doctor.schema.json +77 -0
- package/schemas/cli-routing-explain.schema.json +88 -0
- package/schemas/cli-routing.schema.json +65 -0
- package/schemas/cli-run-audit-secrets.schema.json +72 -0
- package/schemas/cli-run-export.schema.json +410 -0
- package/schemas/cli-runs-prune.schema.json +93 -0
- package/scripts/artifact-integrity.mjs +114 -0
- package/scripts/release-check.mjs +118 -0
- package/scripts/validate-cli-contracts.mjs +59 -0
- package/scripts/validate-doc-versions.mjs +79 -0
- package/scripts/validate-node-support.mjs +118 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://cueline.dev/schemas/cli-runs-prune.schema.json",
|
|
4
|
+
"title": "cueline runs prune --json output",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schema",
|
|
9
|
+
"version",
|
|
10
|
+
"home",
|
|
11
|
+
"apply",
|
|
12
|
+
"olderThanMs",
|
|
13
|
+
"cutoff",
|
|
14
|
+
"states",
|
|
15
|
+
"decisions",
|
|
16
|
+
"prunedRuns",
|
|
17
|
+
"eligibleRuns",
|
|
18
|
+
"keptRuns",
|
|
19
|
+
"removedJobRecords",
|
|
20
|
+
"errors"
|
|
21
|
+
],
|
|
22
|
+
"properties": {
|
|
23
|
+
"schema": { "const": "cueline-runs-prune/1" },
|
|
24
|
+
"version": { "type": "string", "minLength": 1 },
|
|
25
|
+
"home": { "type": "string", "minLength": 1 },
|
|
26
|
+
"apply": { "type": "boolean" },
|
|
27
|
+
"olderThanMs": { "type": "integer", "minimum": 0 },
|
|
28
|
+
"cutoff": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$"
|
|
31
|
+
},
|
|
32
|
+
"states": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"minItems": 1,
|
|
35
|
+
"uniqueItems": true,
|
|
36
|
+
"items": { "enum": ["complete", "blocked", "cancelled"] }
|
|
37
|
+
},
|
|
38
|
+
"decisions": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": false,
|
|
43
|
+
"required": ["runId", "decision"],
|
|
44
|
+
"allOf": [
|
|
45
|
+
{
|
|
46
|
+
"if": {
|
|
47
|
+
"properties": { "decision": { "const": "kept" } },
|
|
48
|
+
"required": ["decision"]
|
|
49
|
+
},
|
|
50
|
+
"then": { "required": ["reason"], "properties": { "reason": {} } },
|
|
51
|
+
"else": { "not": { "required": ["reason"], "properties": { "reason": {} } } }
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"properties": {
|
|
55
|
+
"runId": { "type": "string", "minLength": 1 },
|
|
56
|
+
"decision": { "enum": ["pruned", "eligible", "kept"] },
|
|
57
|
+
"reason": {
|
|
58
|
+
"enum": [
|
|
59
|
+
"unreadable",
|
|
60
|
+
"non_terminal",
|
|
61
|
+
"state_excluded",
|
|
62
|
+
"runtime_active",
|
|
63
|
+
"active_jobs",
|
|
64
|
+
"too_recent",
|
|
65
|
+
"delete_failed"
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"status": { "type": "string", "minLength": 1 },
|
|
69
|
+
"lastEventAt": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"prunedRuns": { "type": "integer", "minimum": 0 },
|
|
77
|
+
"eligibleRuns": { "type": "integer", "minimum": 0 },
|
|
78
|
+
"keptRuns": { "type": "integer", "minimum": 0 },
|
|
79
|
+
"removedJobRecords": { "type": "integer", "minimum": 0 },
|
|
80
|
+
"errors": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"items": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"additionalProperties": false,
|
|
85
|
+
"required": ["runId", "message"],
|
|
86
|
+
"properties": {
|
|
87
|
+
"runId": { "type": "string", "minLength": 1 },
|
|
88
|
+
"message": { "type": "string", "minLength": 1 }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const execFile = promisify(execFileCallback);
|
|
10
|
+
const SCHEMA = "cueline-artifact-manifest/1";
|
|
11
|
+
|
|
12
|
+
export async function sha256File(file) {
|
|
13
|
+
const hash = createHash("sha256");
|
|
14
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
15
|
+
return hash.digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isInside(parent, candidate) {
|
|
19
|
+
return candidate.startsWith(`${parent}${path.sep}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function prepareOutputDirectory(root, requested = "release-artifacts") {
|
|
23
|
+
const rootReal = await realpath(root);
|
|
24
|
+
const output = path.resolve(rootReal, requested);
|
|
25
|
+
if (!isInside(rootReal, output)) {
|
|
26
|
+
throw new Error("artifact output must be a child directory of the repository");
|
|
27
|
+
}
|
|
28
|
+
await mkdir(output, { recursive: true });
|
|
29
|
+
if ((await lstat(output)).isSymbolicLink()) {
|
|
30
|
+
throw new Error("artifact output directory must not be a symbolic link");
|
|
31
|
+
}
|
|
32
|
+
const outputReal = await realpath(output);
|
|
33
|
+
if (!isInside(rootReal, outputReal)) {
|
|
34
|
+
throw new Error("artifact output resolved outside the repository");
|
|
35
|
+
}
|
|
36
|
+
return outputReal;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeArtifactName(value) {
|
|
40
|
+
return typeof value === "string" && value !== "" && value === path.basename(value) && !value.includes("..") && value.endsWith(".tgz");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function verifyArtifactManifest(manifestPath) {
|
|
44
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
45
|
+
if (manifest.schema !== SCHEMA) throw new Error("unsupported artifact manifest schema");
|
|
46
|
+
if (!safeArtifactName(manifest.artifact?.filename)) throw new Error("unsafe artifact filename");
|
|
47
|
+
if (!/^[0-9a-f]{64}$/.test(manifest.artifact.sha256)) throw new Error("invalid artifact sha256");
|
|
48
|
+
const directory = path.dirname(manifestPath);
|
|
49
|
+
const artifactPath = path.join(directory, manifest.artifact.filename);
|
|
50
|
+
const checksumPath = `${artifactPath}.sha256`;
|
|
51
|
+
const actual = await sha256File(artifactPath);
|
|
52
|
+
if (actual !== manifest.artifact.sha256) throw new Error("artifact sha256 mismatch");
|
|
53
|
+
const checksum = await readFile(checksumPath, "utf8");
|
|
54
|
+
if (checksum !== `${actual} ${manifest.artifact.filename}\n`) throw new Error("checksum file mismatch");
|
|
55
|
+
const size = (await lstat(artifactPath)).size;
|
|
56
|
+
if (size !== manifest.artifact.size) throw new Error("artifact size mismatch");
|
|
57
|
+
return { schema: SCHEMA, status: "ok", filename: manifest.artifact.filename, sha256: actual, size };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function buildArtifact(root, requestedOutput = "release-artifacts") {
|
|
61
|
+
const output = await prepareOutputDirectory(root, requestedOutput);
|
|
62
|
+
const { stdout } = await execFile("npm", ["pack", "--json", "--pack-destination", output], {
|
|
63
|
+
cwd: root,
|
|
64
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
65
|
+
});
|
|
66
|
+
const report = JSON.parse(stdout)[0];
|
|
67
|
+
if (!safeArtifactName(report?.filename)) throw new Error("npm pack returned an unsafe filename");
|
|
68
|
+
const artifactPath = path.join(output, report.filename);
|
|
69
|
+
const sha256 = await sha256File(artifactPath);
|
|
70
|
+
const files = [...(report.files ?? [])]
|
|
71
|
+
.map(({ path: file, size, mode }) => ({ path: file, size, mode }))
|
|
72
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
73
|
+
const manifest = {
|
|
74
|
+
schema: SCHEMA,
|
|
75
|
+
package: { name: report.name, version: report.version },
|
|
76
|
+
artifact: {
|
|
77
|
+
filename: report.filename,
|
|
78
|
+
sha256,
|
|
79
|
+
npmShasum: report.shasum,
|
|
80
|
+
npmIntegrity: report.integrity,
|
|
81
|
+
size: report.size,
|
|
82
|
+
unpackedSize: report.unpackedSize,
|
|
83
|
+
fileCount: files.length,
|
|
84
|
+
},
|
|
85
|
+
files,
|
|
86
|
+
};
|
|
87
|
+
const manifestPath = `${artifactPath}.manifest.json`;
|
|
88
|
+
await writeFile(`${artifactPath}.sha256`, `${sha256} ${report.filename}\n`, { mode: 0o600 });
|
|
89
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
90
|
+
const verification = await verifyArtifactManifest(manifestPath);
|
|
91
|
+
return { ...verification, manifestPath, checksumPath: `${artifactPath}.sha256`, fileCount: files.length };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseArguments(args) {
|
|
95
|
+
const [command, value] = args;
|
|
96
|
+
if (command === "build" && args.length <= 2) return { command, output: value ?? "release-artifacts" };
|
|
97
|
+
if (command === "verify" && args.length === 2) return { command, manifest: value };
|
|
98
|
+
throw new Error("usage: artifact-integrity.mjs build [output-directory] | verify <manifest-path>");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const invokedPath = process.argv[1] === undefined ? "" : path.resolve(process.argv[1]);
|
|
102
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
103
|
+
try {
|
|
104
|
+
const root = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
105
|
+
const args = parseArguments(process.argv.slice(2));
|
|
106
|
+
const report = args.command === "build"
|
|
107
|
+
? await buildArtifact(root, args.output)
|
|
108
|
+
: await verifyArtifactManifest(path.resolve(root, args.manifest));
|
|
109
|
+
console.log(JSON.stringify(report, null, 2));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(JSON.stringify({ schema: SCHEMA, status: "error", message: error instanceof Error ? error.message : "artifact operation failed" }));
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const execFile = promisify(execFileCallback);
|
|
8
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
9
|
+
const REQUIRED_FILES = [
|
|
10
|
+
".claude-plugin/plugin.json",
|
|
11
|
+
".codex-plugin/plugin.json",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md",
|
|
15
|
+
"bin/cueline",
|
|
16
|
+
"config/routing.default.json",
|
|
17
|
+
"dist/src/api.d.ts",
|
|
18
|
+
"dist/src/api.js",
|
|
19
|
+
"skills/cueline/SKILL.md",
|
|
20
|
+
];
|
|
21
|
+
const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\..*)?|events\.jsonl|runtime\.json|snapshot\.json|cancel\.json|[^/]+\.(?:pem|key|p12))$/;
|
|
22
|
+
|
|
23
|
+
function finding(code, surface, message) {
|
|
24
|
+
return { code, surface, message };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function evaluateReleaseCandidate(input) {
|
|
28
|
+
const findings = [];
|
|
29
|
+
const version = input.packageJson.version;
|
|
30
|
+
if (typeof version !== "string" || !SEMVER.test(version)) {
|
|
31
|
+
findings.push(finding("VERSION_INVALID", "package", "package version must be strict semver"));
|
|
32
|
+
}
|
|
33
|
+
if (input.packageJson.private !== false) {
|
|
34
|
+
findings.push(finding("PACKAGE_NOT_PUBLIC", "package", "package must explicitly set private to false"));
|
|
35
|
+
}
|
|
36
|
+
for (const [surface, candidate] of [
|
|
37
|
+
["package-lock", input.packageLockVersion],
|
|
38
|
+
["codex-plugin", input.codexPluginVersion],
|
|
39
|
+
["claude-plugin", input.claudePluginVersion],
|
|
40
|
+
]) {
|
|
41
|
+
if (candidate !== version) {
|
|
42
|
+
findings.push(finding("VERSION_MISMATCH", surface, `${surface} version must match package.json`));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (typeof version === "string" && !input.changelog.includes(`## ${version} - `)) {
|
|
46
|
+
findings.push(finding("CHANGELOG_ENTRY_MISSING", "changelog", "current package version needs a dated changelog heading"));
|
|
47
|
+
}
|
|
48
|
+
if (!input.allowDirty && input.gitStatus.trim() !== "") {
|
|
49
|
+
findings.push(finding("GIT_DIRTY", "git", "release check requires a clean worktree"));
|
|
50
|
+
}
|
|
51
|
+
const packaged = new Set(input.packFiles);
|
|
52
|
+
for (const required of REQUIRED_FILES) {
|
|
53
|
+
if (!packaged.has(required)) {
|
|
54
|
+
findings.push(finding("PACKAGE_FILE_MISSING", "package", `required package file is missing: ${required}`));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const file of packaged) {
|
|
58
|
+
if (FORBIDDEN_PACKAGE_PATH.test(file) || file.startsWith("node_modules/") || file.startsWith(".git/")) {
|
|
59
|
+
findings.push(finding("PACKAGE_FILE_FORBIDDEN", "package", `forbidden package file: ${file}`));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return findings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readJson(root, relativePath) {
|
|
66
|
+
return JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function collectReleaseReadiness(root, options = {}) {
|
|
70
|
+
const [packageJson, packageLock, codexPlugin, claudePlugin, changelog, git, pack] = await Promise.all([
|
|
71
|
+
readJson(root, "package.json"),
|
|
72
|
+
readJson(root, "package-lock.json"),
|
|
73
|
+
readJson(root, ".codex-plugin/plugin.json"),
|
|
74
|
+
readJson(root, ".claude-plugin/plugin.json"),
|
|
75
|
+
readFile(path.join(root, "CHANGELOG.md"), "utf8"),
|
|
76
|
+
execFile("git", ["status", "--porcelain"], { cwd: root }),
|
|
77
|
+
execFile("npm", ["pack", "--dry-run", "--json"], { cwd: root, maxBuffer: 10 * 1024 * 1024 }),
|
|
78
|
+
]);
|
|
79
|
+
const packReport = JSON.parse(pack.stdout)[0];
|
|
80
|
+
const packFiles = Array.isArray(packReport?.files) ? packReport.files.map((entry) => entry.path) : [];
|
|
81
|
+
const findings = evaluateReleaseCandidate({
|
|
82
|
+
packageJson,
|
|
83
|
+
packageLockVersion: packageLock?.packages?.[""]?.version,
|
|
84
|
+
codexPluginVersion: codexPlugin.version,
|
|
85
|
+
claudePluginVersion: claudePlugin.version,
|
|
86
|
+
changelog,
|
|
87
|
+
gitStatus: git.stdout,
|
|
88
|
+
packFiles,
|
|
89
|
+
allowDirty: options.allowDirty === true,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
schema: "cueline-release-check/1",
|
|
93
|
+
version: packageJson.version,
|
|
94
|
+
status: findings.length === 0 ? "ok" : "blocked",
|
|
95
|
+
package: {
|
|
96
|
+
filename: packReport?.filename ?? null,
|
|
97
|
+
files: packFiles.length,
|
|
98
|
+
size: packReport?.size ?? null,
|
|
99
|
+
unpackedSize: packReport?.unpackedSize ?? null,
|
|
100
|
+
},
|
|
101
|
+
findings,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const invokedPath = process.argv[1] === undefined ? "" : path.resolve(process.argv[1]);
|
|
106
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
107
|
+
const root = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
108
|
+
const allowed = new Set(["--allow-dirty"]);
|
|
109
|
+
const unknown = process.argv.slice(2).filter((arg) => !allowed.has(arg));
|
|
110
|
+
if (unknown.length > 0) {
|
|
111
|
+
console.error(JSON.stringify({ schema: "cueline-release-check/1", status: "error", code: "USAGE", message: "usage: npm run release:check -- [--allow-dirty]" }));
|
|
112
|
+
process.exitCode = 2;
|
|
113
|
+
} else {
|
|
114
|
+
const report = await collectReleaseReadiness(root, { allowDirty: process.argv.includes("--allow-dirty") });
|
|
115
|
+
console.log(JSON.stringify(report, null, 2));
|
|
116
|
+
if (report.status !== "ok") process.exitCode = 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
7
|
+
|
|
8
|
+
import { main } from "../dist/src/cli/main.js";
|
|
9
|
+
|
|
10
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
11
|
+
const contracts = [
|
|
12
|
+
{ id: "doctor", args: ["doctor", "--json"], schema: "cli-doctor.schema.json" },
|
|
13
|
+
{ id: "routing", args: ["routing", "--json"], schema: "cli-routing.schema.json" },
|
|
14
|
+
{
|
|
15
|
+
id: "routing-explain",
|
|
16
|
+
args: ["routing", "explain", "--json"],
|
|
17
|
+
schema: "cli-routing-explain.schema.json",
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
async function invoke(args, environment) {
|
|
22
|
+
const stdout = [];
|
|
23
|
+
const stderr = [];
|
|
24
|
+
const exitCode = await main(args, environment, {
|
|
25
|
+
stdout: (line) => stdout.push(line),
|
|
26
|
+
stderr: (line) => stderr.push(line),
|
|
27
|
+
});
|
|
28
|
+
if (stdout.length !== 1 || stderr.length !== 0) {
|
|
29
|
+
throw new Error("CLI contract command did not emit one clean JSON document");
|
|
30
|
+
}
|
|
31
|
+
return { exitCode, value: JSON.parse(stdout[0]) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const environment = {
|
|
35
|
+
...process.env,
|
|
36
|
+
CUELINE_CONFIG: `${root}/config/routing.default.json`,
|
|
37
|
+
};
|
|
38
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
|
39
|
+
const results = [];
|
|
40
|
+
for (const contract of contracts) {
|
|
41
|
+
const schema = JSON.parse(
|
|
42
|
+
await readFile(`${root}/schemas/${contract.schema}`, "utf8"),
|
|
43
|
+
);
|
|
44
|
+
const validate = ajv.compile(schema);
|
|
45
|
+
const invocation = await invoke(contract.args, environment);
|
|
46
|
+
const valid = validate(invocation.value);
|
|
47
|
+
results.push({ id: contract.id, valid, exitCode: invocation.exitCode });
|
|
48
|
+
}
|
|
49
|
+
const passed = results.filter((result) => result.valid).length;
|
|
50
|
+
const report = {
|
|
51
|
+
schema: "cueline-cli-contract-validation/1",
|
|
52
|
+
status: passed === results.length ? "passed" : "failed",
|
|
53
|
+
total: results.length,
|
|
54
|
+
passed,
|
|
55
|
+
failed: results.length - passed,
|
|
56
|
+
commands: results,
|
|
57
|
+
};
|
|
58
|
+
console.log(JSON.stringify(report, null, 2));
|
|
59
|
+
process.exitCode = report.status === "passed" ? 0 : 1;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const README_FILES = [
|
|
6
|
+
"README.md",
|
|
7
|
+
"README.zh-TW.md",
|
|
8
|
+
"README.zh-CN.md",
|
|
9
|
+
"README.ja.md",
|
|
10
|
+
"README.ko.md",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function collectVersions(text, pattern) {
|
|
14
|
+
return [...text.matchAll(pattern)].map((match) => match[1]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireCurrentVersion(issues, file, label, versions, expected) {
|
|
18
|
+
if (versions.length === 0) {
|
|
19
|
+
issues.push(`${file}: missing ${label}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const version of versions) {
|
|
23
|
+
if (version !== expected) {
|
|
24
|
+
issues.push(`${file}: ${label} uses ${version}; expected ${expected}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function validateDocVersions(root) {
|
|
30
|
+
const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
31
|
+
const expected = manifest.version;
|
|
32
|
+
const issues = [];
|
|
33
|
+
|
|
34
|
+
for (const file of README_FILES) {
|
|
35
|
+
const text = await readFile(path.join(root, file), "utf8");
|
|
36
|
+
requireCurrentVersion(
|
|
37
|
+
issues,
|
|
38
|
+
file,
|
|
39
|
+
"npm install command",
|
|
40
|
+
collectVersions(text, /npm install -g cueline@(\d+\.\d+\.\d+)/g),
|
|
41
|
+
expected,
|
|
42
|
+
);
|
|
43
|
+
requireCurrentVersion(
|
|
44
|
+
issues,
|
|
45
|
+
file,
|
|
46
|
+
"release tarball URL",
|
|
47
|
+
collectVersions(text, /releases\/download\/v\d+\.\d+\.\d+\/cueline-(\d+\.\d+\.\d+)\.tgz/g),
|
|
48
|
+
expected,
|
|
49
|
+
);
|
|
50
|
+
requireCurrentVersion(
|
|
51
|
+
issues,
|
|
52
|
+
file,
|
|
53
|
+
"doctor output",
|
|
54
|
+
collectVersions(text, /^CueLine (\d+\.\d+\.\d+)$/gm),
|
|
55
|
+
expected,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const compatibility = await readFile(path.join(root, "docs/compatibility.md"), "utf8");
|
|
60
|
+
for (const pattern of [/v0\.\d+ status/, /^## Supported in v0\.\d+$/m, /^## Not supported in v0\.\d+$/m]) {
|
|
61
|
+
if (pattern.test(compatibility)) {
|
|
62
|
+
issues.push(`docs/compatibility.md: release-specific contract heading ${pattern}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return issues;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const invokedPath = process.argv[1] === undefined ? "" : path.resolve(process.argv[1]);
|
|
69
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
70
|
+
const root = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
71
|
+
const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
72
|
+
const issues = await validateDocVersions(root);
|
|
73
|
+
if (issues.length > 0) {
|
|
74
|
+
for (const issue of issues) console.error(issue);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
} else {
|
|
77
|
+
console.log(`Documentation versions match package ${manifest.version}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const EXPECTED_NODE_MAJORS = [22, 24, 26];
|
|
8
|
+
const README_FILES = [
|
|
9
|
+
"README.md",
|
|
10
|
+
"README.zh-TW.md",
|
|
11
|
+
"README.zh-CN.md",
|
|
12
|
+
"README.ja.md",
|
|
13
|
+
"README.ko.md",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function parseArgs(args) {
|
|
17
|
+
let root = fileURLToPath(new URL("..", import.meta.url));
|
|
18
|
+
let json = false;
|
|
19
|
+
let rootSeen = false;
|
|
20
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
21
|
+
if (args[index] === "--json" && !json) {
|
|
22
|
+
json = true;
|
|
23
|
+
} else if (
|
|
24
|
+
args[index] === "--root" &&
|
|
25
|
+
!rootSeen &&
|
|
26
|
+
typeof args[index + 1] === "string"
|
|
27
|
+
) {
|
|
28
|
+
root = path.resolve(args[index + 1]);
|
|
29
|
+
rootSeen = true;
|
|
30
|
+
index += 1;
|
|
31
|
+
} else {
|
|
32
|
+
throw new Error("usage: node scripts/validate-node-support.mjs [--root <path>] [--json]");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { root, json };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sameNumbers(left, right) {
|
|
39
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function validateNodeSupport(root) {
|
|
43
|
+
const findings = [];
|
|
44
|
+
const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
45
|
+
const engine = manifest.engines?.node ?? null;
|
|
46
|
+
if (engine !== ">=22") {
|
|
47
|
+
findings.push({
|
|
48
|
+
code: "ENGINE_REQUIREMENT_MISMATCH",
|
|
49
|
+
file: "package.json",
|
|
50
|
+
message: "engines.node must remain >=22.",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const workflow = await readFile(path.join(root, ".github/workflows/ci.yml"), "utf8");
|
|
55
|
+
const nodeMatrix = /node:\s*\[([^\]]+)\]/.exec(workflow)?.[1]
|
|
56
|
+
?.split(",")
|
|
57
|
+
.map((value) => Number(value.trim())) ?? [];
|
|
58
|
+
if (!sameNumbers(nodeMatrix, EXPECTED_NODE_MAJORS)) {
|
|
59
|
+
findings.push({
|
|
60
|
+
code: "CI_NODE_MATRIX_MISMATCH",
|
|
61
|
+
file: ".github/workflows/ci.yml",
|
|
62
|
+
message: "CI Node matrix must be exactly 22, 24, 26.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const osMatrix = /os:\s*\[([^\]]+)\]/.exec(workflow)?.[1]
|
|
66
|
+
?.split(",")
|
|
67
|
+
.map((value) => value.trim()) ?? [];
|
|
68
|
+
if (!sameNumbers(osMatrix, ["ubuntu-latest", "macos-latest"])) {
|
|
69
|
+
findings.push({
|
|
70
|
+
code: "CI_OS_MATRIX_MISMATCH",
|
|
71
|
+
file: ".github/workflows/ci.yml",
|
|
72
|
+
message: "CI OS matrix must cover Ubuntu and macOS.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const file of README_FILES) {
|
|
77
|
+
const content = await readFile(path.join(root, file), "utf8");
|
|
78
|
+
if (!/CI[^\n]*Node[^\n]*22[^\n]*24[^\n]*26/.test(content)) {
|
|
79
|
+
findings.push({
|
|
80
|
+
code: "README_NODE_MATRIX_STALE",
|
|
81
|
+
file,
|
|
82
|
+
message: "README CI support line must name Node 22, 24, and 26.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const compatibility = await readFile(path.join(root, "docs/compatibility.md"), "utf8");
|
|
88
|
+
if (!/Node\.js 22\+ ESM[^\n]*CI: 22, 24, 26/.test(compatibility)) {
|
|
89
|
+
findings.push({
|
|
90
|
+
code: "COMPATIBILITY_NODE_MATRIX_STALE",
|
|
91
|
+
file: "docs/compatibility.md",
|
|
92
|
+
message: "Compatibility matrix must name the tested Node majors.",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
schema: "cueline-node-support-validation/1",
|
|
98
|
+
status: findings.length === 0 ? "passed" : "failed",
|
|
99
|
+
engine,
|
|
100
|
+
ciNodeMajors: nodeMatrix,
|
|
101
|
+
ciOperatingSystems: osMatrix,
|
|
102
|
+
currentNode: process.versions.node,
|
|
103
|
+
findings,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const options = parseArgs(process.argv.slice(2));
|
|
109
|
+
const report = await validateNodeSupport(options.root);
|
|
110
|
+
if (options.json) console.log(JSON.stringify(report, null, 2));
|
|
111
|
+
else console.log(`Node support contract ${report.status}: ${report.ciNodeMajors.join(", ")}`);
|
|
112
|
+
process.exitCode = report.status === "passed" ? 0 : 1;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(
|
|
115
|
+
`Node support validation failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
116
|
+
);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
}
|