diff --git a/examples/llm-memory-db-mssql/.noorm/settings.yml b/examples/llm-memory-db-mssql/.noorm/settings.yml index 1005f706..f4ae761d 100644 --- a/examples/llm-memory-db-mssql/.noorm/settings.yml +++ b/examples/llm-memory-db-mssql/.noorm/settings.yml @@ -4,16 +4,16 @@ paths: build: include: - - sql/00_types - - sql/01_reference - - sql/02_tables - - sql/03_validators - - sql/04_subtypes - - sql/05_binary - - sql/06_seeds - - sql/07_functions - - sql/08_views - - sql/09_procedures + - 00_types + - 01_reference + - 02_tables + - 03_validators + - 04_subtypes + - 05_binary + - 06_seeds + - 07_functions + - 08_views + - 09_procedures exclude: [] stages: @@ -46,7 +46,7 @@ rules: match: isTest: true include: - - sql/06_seeds + - 06_seeds strict: enabled: false diff --git a/packages/cli/scripts/postinstall.js b/packages/cli/scripts/postinstall.js index 8a3f97d5..3a8cb7df 100644 --- a/packages/cli/scripts/postinstall.js +++ b/packages/cli/scripts/postinstall.js @@ -9,7 +9,7 @@ */ import { createHash } from 'crypto'; -import { createReadStream, createWriteStream, existsSync, chmodSync, mkdirSync, renameSync, unlinkSync } from 'fs'; +import { createReadStream, createWriteStream, existsSync, chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync } from 'fs'; import { get as httpsGet } from 'https'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -19,6 +19,8 @@ const PACKAGE_ROOT = resolve(__dirname, '..'); const BIN_DIR = resolve(PACKAGE_ROOT, 'bin'); const REPO = 'noormdev/noorm'; +const ROOT_PACKAGE_NAME = '@noormdev/main'; + const BINARY_NAME = process.platform === 'win32' ? 'noorm.exe' : 'noorm'; const DEST = resolve(BIN_DIR, BINARY_NAME); const DOWNLOAD_DEST = `${DEST}.download`; @@ -40,6 +42,62 @@ class ChecksumFailure extends Error { } +/** + * Local mirror of `attemptSync` from `@logosdx/utils`, which this script + * cannot import: postinstall runs before the workspace is built, and in a + * consumer's tree the dependency need not be installed at all. + * + * Exists so the callers below keep the repo's error-tuple convention instead + * of ad-hoc try/catch control flow -- the one unavoidable try/catch is + * encapsulated here, exactly as the real utility encapsulates it. + */ +function attemptSync(fn) { + + try { + return [fn(), null]; + } + catch (err) { + return [null, err]; + } + +} + +/** + * Detects the noorm monorepo source checkout, where this script must not run. + * + * The download is pinned to the release tag matching this package's version, + * which in a source checkout is routinely not a published release -- every + * commit after a version bump points at a tag that does not exist yet, and + * tags cut before the release workflow started emitting checksums.txt have + * nothing to verify against. Either way the download fails, and a failed + * verification is a hard failure by design (see main().catch below), so it + * takes `bun install` down with it. + * + * Nothing in the repo needs it: the CLI is built from source here + * (`bun run build:packages`, or `bun run build:binary` for a real binary) and + * the downloaded asset is never executed. Skipping is the whole fix. + * + * Keyed on the private workspace root two levels up, which the npm tarball + * never ships -- an installed copy lives at node_modules/@noormdev/cli, whose + * grandparent is node_modules itself and has no package.json. + */ +function isSourceCheckout() { + + const rootPackage = resolve(PACKAGE_ROOT, '..', '..', 'package.json'); + + if (!existsSync(rootPackage)) { + return false; + } + + // An unreadable or malformed package.json two levels up says nothing about + // being in the source repo -- fall through to the normal download path + // rather than silently skipping a consumer's install. + const [pkg] = attemptSync(() => JSON.parse(readFileSync(rootPackage, 'utf8'))); + + return pkg?.name === ROOT_PACKAGE_NAME; + +} + /** * Resolves the platform suffix used in binary asset names. * @@ -290,6 +348,11 @@ async function verifyChecksum(checksumsUrl, assetName) { */ async function main() { + if (isSourceCheckout()) { + console.log('noorm source checkout detected - skipping release binary download (build one with `bun run build:binary`).'); + return; + } + const suffix = getPlatformSuffix(); const version = await getVersion(); const assetName = `noorm-${suffix}`; diff --git a/tests/cli/postinstall-source-checkout.test.ts b/tests/cli/postinstall-source-checkout.test.ts new file mode 100644 index 00000000..9a0d794b --- /dev/null +++ b/tests/cli/postinstall-source-checkout.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for the npm postinstall binary download (`packages/cli/scripts/postinstall.js`). + * + * The script is spawned as a real `node` process rather than imported: it is + * plain ESM shipped in the npm tarball (the repo does not set `allowJs`), and + * spawning exercises the exact invocation a package manager performs. + * + * The invariant under test is a split one, and both halves matter: + * + * - Inside this monorepo the release binary must NEVER be downloaded. The + * script pins its download to the release tag matching packages/cli's + * version, which in a source checkout is routinely unreleased (any commit + * after a version bump, and every commit before that version is cut). CI + * builds the CLI from source and never executes the downloaded binary, so + * the download is dead weight that can only ever break `bun install`. + * + * - For an npm consumer the download must still happen. A skip check that is + * too broad would silently install the wrapper with no binary behind it, + * which is a worse failure than the one it fixes -- it surfaces at first + * use rather than at install time. + * + * Both cases run against a fabricated directory tree rather than the real + * repo, so neither depends on whether packages/cli/bin happens to be populated + * on the machine running the tests. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtemp, mkdir, rm, writeFile, copyFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; + +const REAL_SCRIPT = resolve(import.meta.dir, '../../packages/cli/scripts/postinstall.js'); + +// Matched loosely (case-insensitive substring) so the test pins the behavior, +// not the exact prose of the console message. +const SKIP_MARKER = 'source checkout'; +const DOWNLOAD_MARKER = 'Downloading noorm'; + +let workDir: string; + +/** + * Writes a package tree containing a copy of the real postinstall script and + * returns the directory the script lands in. + * + * @param layout directory path, relative to workDir, that plays the role of + * packages/cli -- i.e. where package.json and scripts/postinstall.js go. + * @param rootPkg package.json to write two levels above `layout`, or null to + * leave that level empty (the npm-consumer case). + */ +const fabricate = async (layout: string, rootPkg: Record | null) => { + + const pkgDir = join(workDir, layout); + + await mkdir(join(pkgDir, 'scripts'), { recursive: true }); + await copyFile(REAL_SCRIPT, join(pkgDir, 'scripts', 'postinstall.js')); + + await writeFile( + join(pkgDir, 'package.json'), + JSON.stringify({ name: '@noormdev/cli', version: '1.0.0-alpha.39' }), + ); + + if (rootPkg) { + + await writeFile( + join(pkgDir, '..', '..', 'package.json'), + JSON.stringify(rootPkg), + ); + + } + + return pkgDir; + +}; + +/** + * Runs the fabricated script to completion and returns its output. + * + * Only safe for cases that terminate without network access -- see + * `runUntilDownload` for the consumer case, which does reach the network. + */ +const run = async (pkgDir: string) => { + + const proc = Bun.spawn(['node', join(pkgDir, 'scripts', 'postinstall.js')], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, NOORM_INSECURE: '' }, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout, stderr, exitCode, output: stdout + stderr }; + +}; + +/** + * Runs the script only until it announces the download, then kills it. + * + * The consumer path deliberately hits GitHub Releases, which a test must not + * wait on. Reading up to the announcement is enough to prove the skip check + * did not swallow this case, and killing early keeps the test hermetic: the + * assertion holds whether or not the machine has network. + */ +const runUntilDownload = async (pkgDir: string) => { + + const proc = Bun.spawn(['node', join(pkgDir, 'scripts', 'postinstall.js')], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, NOORM_INSECURE: '' }, + }); + + let seen = ''; + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + + while (!seen.includes(DOWNLOAD_MARKER)) { + + const { done, value } = await reader.read(); + + if (done) break; + + seen += decoder.decode(value, { stream: true }); + + } + + proc.kill(); + await proc.exited; + + return seen; + +}; + +beforeAll(async () => { + + workDir = await mkdtemp(join(tmpdir(), 'noorm-postinstall-')); + +}); + +afterAll(async () => { + + await rm(workDir, { recursive: true, force: true }); + +}); + +describe('postinstall: binary download', () => { + + it('skips the download inside the noorm monorepo source checkout', async () => { + + const pkgDir = await fabricate('repo/packages/cli', { + name: '@noormdev/main', + workspaces: ['packages/*'], + }); + + const { output, exitCode } = await run(pkgDir); + + expect(output.toLowerCase()).toContain(SKIP_MARKER); + expect(output).not.toContain(DOWNLOAD_MARKER); + + // The whole point: a source checkout must not be able to fail an install. + expect(exitCode).toBe(0); + + expect(existsSync(join(pkgDir, 'bin', 'noorm'))).toBe(false); + + }); + + it('still downloads for an npm consumer outside the monorepo', async () => { + + const pkgDir = await fabricate('consumer/node_modules/@noormdev/cli', null); + + const seen = await runUntilDownload(pkgDir); + + expect(seen).toContain(DOWNLOAD_MARKER); + expect(seen.toLowerCase()).not.toContain(SKIP_MARKER); + + }); + + it('does not mistake an unrelated parent package for the monorepo root', async () => { + + const pkgDir = await fabricate('other/packages/cli', { + name: '@someoneelse/monorepo', + workspaces: ['packages/*'], + }); + + const seen = await runUntilDownload(pkgDir); + + expect(seen).toContain(DOWNLOAD_MARKER); + expect(seen.toLowerCase()).not.toContain(SKIP_MARKER); + + }); + +}); diff --git a/tests/sdk/destructive-ops.test.ts b/tests/sdk/destructive-ops.test.ts index 0a112269..6fd5dc05 100644 --- a/tests/sdk/destructive-ops.test.ts +++ b/tests/sdk/destructive-ops.test.ts @@ -72,7 +72,11 @@ function makeState(access: ConfigAccess, options: ContextState['options'] = {}): } -/** Fake Change fixture for delete() gate tests. rm({ force: true }) on a nonexistent path silently no-ops, so a placeholder path is safe for every test except the dedicated disk-mutation block below. */ +/** + * Fake Change fixture for delete() gate tests. rm({ force: true }) on a + * nonexistent path silently no-ops, so a placeholder path is safe for every + * test except the dedicated disk-mutation block below. + */ function makeFakeChange(changePath: string = join(tmpdir(), 'sample-change')): Change { return {