291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
/**
|
|
* ops-cli install-invariants smoke (cp161 verification).
|
|
*
|
|
* Locks the install contract that an operator depends on for
|
|
* `npx morphit-ops init` / `register` / `edit` / `upgrade` to
|
|
* work on a freshly-provisioned host. An operator hit
|
|
* "command not found" (cp161); the root cause was a combination
|
|
* of workspace-bin fragility + tsx being a devDependency. This
|
|
* smoke pins every invariant whose regression would reproduce
|
|
* that failure, so a future package.json / Ansible / shebang
|
|
* edit that breaks the install fails CI here instead of on the
|
|
* operator's box.
|
|
*
|
|
* Invariants checked:
|
|
* 1. tsx is a PRODUCTION dependency of apps/ops-cli (not dev).
|
|
* The bin runs from TS source via tsx; if tsx were dev-only,
|
|
* `npm install --omit=dev` / NODE_ENV=production would strip
|
|
* it and the bin would break.
|
|
* 2. The bin shebang invokes tsx via npx (robust across the
|
|
* operator's invocation paths — bare shell + npm-managed).
|
|
* 3. The bin field points at the source entry that exists.
|
|
* 4. The Ansible build task does NOT claim to build ops-cli
|
|
* (it has no build script; the comment must not mislead).
|
|
* 5. The Ansible post-install verify task exists and uses the
|
|
* genuinely-offline `npm exec --offline --workspace` form
|
|
* (NOT `npx --no-install`, which still does a registry
|
|
* lookup and is not a real offline guarantee).
|
|
* 6. The Node engines requirement matches the Node major the
|
|
* Ansible playbook installs (a mismatch would let install
|
|
* proceed on a too-old Node and fail mysteriously later).
|
|
* 7. The three operator-facing docs document the
|
|
* run-from-repo-root + npm-install requirement.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
|
|
const ANSI_GREEN = '\x1b[32m';
|
|
const ANSI_RED = '\x1b[31m';
|
|
const ANSI_RESET = '\x1b[0m';
|
|
|
|
// repo root is three levels up from apps/ops-cli/scripts/
|
|
const REPO = resolve(new URL('../../..', import.meta.url).pathname);
|
|
|
|
interface Result {
|
|
name: string;
|
|
passed: boolean;
|
|
detail?: string;
|
|
}
|
|
const results: Result[] = [];
|
|
function pass(name: string) {
|
|
results.push({ name, passed: true });
|
|
}
|
|
function fail(name: string, detail: string) {
|
|
results.push({ name, passed: false, detail });
|
|
}
|
|
|
|
function readJson(rel: string): Record<string, unknown> {
|
|
return JSON.parse(readFileSync(resolve(REPO, rel), 'utf8')) as Record<string, unknown>;
|
|
}
|
|
function readText(rel: string): string {
|
|
return readFileSync(resolve(REPO, rel), 'utf8');
|
|
}
|
|
|
|
/* ---------------- invariant 1: tsx is a production dependency ---------------- */
|
|
|
|
const opsPkg = readJson('apps/ops-cli/package.json');
|
|
const deps = (opsPkg.dependencies ?? {}) as Record<string, string>;
|
|
const devDeps = (opsPkg.devDependencies ?? {}) as Record<string, string>;
|
|
|
|
if (deps.tsx !== undefined && devDeps.tsx === undefined) {
|
|
pass('tsx is a PRODUCTION dependency of apps/ops-cli (powers the shim fallback path under --omit=dev)');
|
|
} else if (deps.tsx !== undefined && devDeps.tsx !== undefined) {
|
|
fail(
|
|
'tsx is a PRODUCTION dependency only',
|
|
'tsx appears in BOTH dependencies and devDependencies — remove the devDependencies entry'
|
|
);
|
|
} else {
|
|
fail(
|
|
'tsx is a PRODUCTION dependency of apps/ops-cli',
|
|
`tsx not in dependencies (the bin shim falls back to running TS source via tsx when dist/ is absent; as a devDependency it would be stripped by a production install and the fallback would break). dependencies=${JSON.stringify(Object.keys(deps))}`
|
|
);
|
|
}
|
|
|
|
/* ---------------- invariant 2: bin launcher shim exists with a node shebang ---------------- */
|
|
|
|
// cp162: the published bin is a launcher shim (bin/morphit-ops.mjs)
|
|
// that runs the compiled dist/main.js under plain node when present,
|
|
// and falls back to the tsx source otherwise. The shim itself is
|
|
// plain JS with a node shebang so it always runs under node.
|
|
const shimRel = 'apps/ops-cli/bin/morphit-ops.mjs';
|
|
let shimSrc = '';
|
|
try {
|
|
shimSrc = readText(shimRel);
|
|
} catch {
|
|
shimSrc = '';
|
|
}
|
|
const shimShebang = shimSrc.split('\n')[0] ?? '';
|
|
if (shimSrc && /^#!\/usr\/bin\/env node$/.test(shimShebang)) {
|
|
// The shim must implement BOTH paths: compiled dist + tsx fallback.
|
|
const hasDistPath = /dist\/main\.js/.test(shimSrc) && /process\.execPath/.test(shimSrc);
|
|
const hasTsxFallback = /tsx/.test(shimSrc) && /src\/main\.ts/.test(shimSrc);
|
|
if (hasDistPath && hasTsxFallback) {
|
|
pass('bin launcher shim has node shebang + compiled-dist path + tsx-source fallback');
|
|
} else {
|
|
fail(
|
|
'bin launcher shim implements both compiled + fallback paths',
|
|
`hasDistPath=${hasDistPath} hasTsxFallback=${hasTsxFallback} — the shim must run dist/main.js under node when present and fall back to tsx source otherwise`
|
|
);
|
|
}
|
|
} else {
|
|
fail(
|
|
'bin launcher shim exists with a node shebang',
|
|
`expected ${shimRel} with "#!/usr/bin/env node"; got shebang ${JSON.stringify(shimShebang)}`
|
|
);
|
|
}
|
|
|
|
/* ---------------- invariant 2b: source main.ts keeps its tsx shebang (dev/fallback path) ---------------- */
|
|
|
|
const mainSrc = readText('apps/ops-cli/src/main.ts');
|
|
const srcShebang = mainSrc.split('\n')[0] ?? '';
|
|
// src/main.ts is still run directly by tsx in the dev path (`npm
|
|
// start`/`dev`) and the shim's fallback. Its shebang stays the
|
|
// robust npx-tsx form so `tsx src/main.ts` and bare-shell runs work.
|
|
if (/^#!\/usr\/bin\/env -S npx tsx$/.test(srcShebang)) {
|
|
pass('src/main.ts keeps its "#!/usr/bin/env -S npx tsx" shebang (dev + fallback path)');
|
|
} else {
|
|
fail(
|
|
'src/main.ts keeps its tsx shebang',
|
|
`shebang is ${JSON.stringify(srcShebang)}; expected "#!/usr/bin/env -S npx tsx" for the dev/fallback run path`
|
|
);
|
|
}
|
|
|
|
/* ---------------- invariant 3: bin field points at the shim; build script + files present ---------------- */
|
|
|
|
const binField = opsPkg.bin as Record<string, string> | undefined;
|
|
const scripts = (opsPkg.scripts ?? {}) as Record<string, string>;
|
|
const filesField = (opsPkg.files ?? []) as string[];
|
|
|
|
const binOk = binField?.['morphit-ops'] === 'bin/morphit-ops.mjs';
|
|
const buildOk = typeof scripts.build === 'string' && scripts.build.includes('build.mjs');
|
|
const filesOk = filesField.includes('dist/') && filesField.includes('bin/') && filesField.includes('src/');
|
|
|
|
if (binOk && buildOk && filesOk) {
|
|
pass('bin → shim, build script present (esbuild bundle), files includes bin/+dist/+src/');
|
|
} else {
|
|
fail(
|
|
'bin → shim + build script + files field',
|
|
`bin=${JSON.stringify(binField)} (want bin/morphit-ops.mjs) · build=${JSON.stringify(scripts.build)} (want node scripts/build.mjs) · files=${JSON.stringify(filesField)} (want bin/ dist/ src/)`
|
|
);
|
|
}
|
|
|
|
/* ---------------- invariant 3b: esbuild is available to the build ---------------- */
|
|
|
|
const esbuildDep =
|
|
(devDeps as Record<string, string>).esbuild !== undefined ||
|
|
(deps as Record<string, string>).esbuild !== undefined;
|
|
if (esbuildDep) {
|
|
pass('esbuild is a declared dependency (the build script needs it)');
|
|
} else {
|
|
fail(
|
|
'esbuild is a declared dependency',
|
|
'scripts/build.mjs imports esbuild but it is not in dependencies/devDependencies — the build would fail on a clean install'
|
|
);
|
|
}
|
|
|
|
/* ---------------- invariant 4: Ansible build task builds ops-cli (now has a build script) ---------------- */
|
|
|
|
const cloneBuild = readText('ops/ansible/roles/morphit/tasks/clone_and_build.yml');
|
|
|
|
// cp162: ops-cli now HAS a build script, so `--if-present` builds
|
|
// it. The build task must run AFTER the full npm install (dev deps
|
|
// incl. esbuild present). Verify the build command is present and
|
|
// uses --if-present (which now picks up ops-cli).
|
|
const hasIfPresentBuild = /npm run build --workspaces --if-present/.test(cloneBuild);
|
|
if (hasIfPresentBuild) {
|
|
pass('Ansible build task uses `npm run build --workspaces --if-present` (now builds ops-cli dist)');
|
|
} else {
|
|
fail(
|
|
'Ansible build task builds ops-cli via --if-present',
|
|
'expected `npm run build --workspaces --if-present` in clone_and_build.yml — ops-cli now has a build script that this picks up'
|
|
);
|
|
}
|
|
|
|
/* ---------------- invariant 5: Ansible verify task uses the offline npm exec form ---------------- */
|
|
|
|
const hasVerifyTask = /morphit-ops CLI is runnable/.test(cloneBuild);
|
|
const usesOfflineExec = /npm exec --offline --workspace apps\/ops-cli morphit-ops/.test(cloneBuild);
|
|
const usesWeakNoInstall = /npx --no-install morphit-ops/.test(cloneBuild);
|
|
|
|
if (hasVerifyTask && usesOfflineExec && !usesWeakNoInstall) {
|
|
pass('Ansible post-install verify task exists and uses the genuinely-offline `npm exec --offline --workspace` form');
|
|
} else if (!hasVerifyTask) {
|
|
fail(
|
|
'Ansible has a morphit-ops post-install verify task',
|
|
'no "morphit-ops CLI is runnable" verify task found in clone_and_build.yml — a broken install would surface on the operator box, not in the play'
|
|
);
|
|
} else if (usesWeakNoInstall) {
|
|
fail(
|
|
'Ansible verify task uses the offline npm exec form, not npx --no-install',
|
|
'`npx --no-install` still performs a registry lookup before erroring (verified cp161-verify), so it is not a reliable offline guarantee. Use `npm exec --offline --workspace apps/ops-cli morphit-ops -- --help`.'
|
|
);
|
|
} else {
|
|
fail(
|
|
'Ansible verify task uses `npm exec --offline --workspace`',
|
|
'verify task present but does not use the expected offline-exec command shape'
|
|
);
|
|
}
|
|
|
|
/* ---------------- invariant 6: engines Node major matches Ansible-installed Node ---------------- */
|
|
|
|
const engines = (opsPkg.engines ?? {}) as Record<string, string>;
|
|
const engineNode = engines.node ?? '';
|
|
const engineMajorMatch = engineNode.match(/(\d+)/);
|
|
const engineMajor = engineMajorMatch ? engineMajorMatch[1] : null;
|
|
|
|
const allVars = readText('ops/ansible/group_vars/all.yml');
|
|
const nodeVarMatch = allVars.match(/morphit_node_version:\s*["']?(\d+)["']?/);
|
|
const ansibleNodeMajor = nodeVarMatch ? nodeVarMatch[1] : null;
|
|
|
|
if (engineMajor !== null && ansibleNodeMajor !== null && engineMajor === ansibleNodeMajor) {
|
|
pass(`engines.node (>=${engineMajor}) matches Ansible morphit_node_version (${ansibleNodeMajor})`);
|
|
} else {
|
|
fail(
|
|
'engines.node major matches the Node the Ansible playbook installs',
|
|
`engines.node=${JSON.stringify(engineNode)} (major ${engineMajor}) vs ansible morphit_node_version=${ansibleNodeMajor}. A mismatch lets install proceed on the wrong Node and fail later.`
|
|
);
|
|
}
|
|
|
|
/* ---------------- invariant 7: docs document the run-from-repo + npm-install requirement ---------------- */
|
|
|
|
interface DocCheck {
|
|
file: string;
|
|
mustContain: string[];
|
|
}
|
|
const docChecks: DocCheck[] = [
|
|
{
|
|
file: 'docs/OPERATIONS.md',
|
|
mustContain: ['command not found', 'npm install', 'inside the Morphit repo']
|
|
},
|
|
{
|
|
file: 'docs/RUN-A-MORPHIT-NODE.md',
|
|
mustContain: ['command not found', 'npm install', 'inside the Morphit directory']
|
|
},
|
|
{
|
|
file: 'ops/ansible/morphit-sysadmin-handoff.txt',
|
|
mustContain: ['command not found', 'npm install', 'npm exec --offline']
|
|
}
|
|
];
|
|
|
|
let docFails = 0;
|
|
const docMissing: string[] = [];
|
|
for (const dc of docChecks) {
|
|
const txt = readText(dc.file);
|
|
for (const needle of dc.mustContain) {
|
|
if (!txt.includes(needle)) {
|
|
docFails++;
|
|
docMissing.push(`${dc.file}: missing ${JSON.stringify(needle)}`);
|
|
}
|
|
}
|
|
}
|
|
if (docFails === 0) {
|
|
pass('all three operator-entry docs document the command-not-found fix (repo-dir + npm install)');
|
|
} else {
|
|
fail(
|
|
'all three operator-entry docs document the command-not-found fix',
|
|
docMissing.join('\n ')
|
|
);
|
|
}
|
|
|
|
/* ---------------- report ---------------- */
|
|
|
|
let failed = 0;
|
|
for (const r of results) {
|
|
if (r.passed) {
|
|
console.log(' ' + ANSI_GREEN + '✓' + ANSI_RESET + ' ' + r.name);
|
|
} else {
|
|
console.log(' ' + ANSI_RED + '✗' + ANSI_RESET + ' ' + r.name);
|
|
if (r.detail) console.log(' ' + r.detail);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
console.log();
|
|
console.log('──────────────────────────────────────────────────────');
|
|
if (failed > 0) {
|
|
console.log('✗ ' + failed + ' of ' + results.length + ' scenarios failed');
|
|
process.exit(1);
|
|
} else {
|
|
console.log('✓ all ' + results.length + ' scenarios passed');
|
|
}
|