From 9b977d96e198e51005baf9884e27640420fc6416 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Tue, 11 Aug 2026 08:24:41 +0700 Subject: [PATCH 1/2] fix(e2e): unblock the local-monorepo smoke (git dubious ownership + phase1 server-light kill) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-assets-e2e local-monorepo smoke (`run.sh --mode=local --monorepo=local`) hit two independent entrypoint issues that prevented the stack container from reaching the daemon-registration smoke. 1. git dubious ownership of the bind-mounted repo. The host repo is mounted read-only into the container as /repo, owned by the host user. git refuses to read it inside the container (different UID) -> `git clone ` in hstack setup fails with "detected dubious ownership" + "Could not read from remote repository". Set `safe.directory '*'` when HSTACK_HAPPIER_REPO is wired so the clone succeeds. 2. kill_phase1_server_light missed the from-source server-light invocation. The function matched `--import tsx ./sources/main.light.ts` (the dev invocation), but from-source the server-light runs via `tsx --tsconfig ./tsconfig.json ./sources/main.light.ts` (apps/server `start:light`), so the awk found nothing and the function returned early with NO fallback. The phase1 server-light survived into phase2, kept port 3005 occupied, and phase2's `start --restart` tripped decideDevStartupTopology's guard (`ESERVERTOPOLOGYUNOWNED: healthy-unowned + restart`). Match on the stable `main.light.ts` (covers dev + from-source) and add a pkill fallback mirroring kill_phase1_no_ui_supervisor. Validated end-to-end: `run.sh --mode=local --monorepo=local` now reaches `[npm-e2e-smoke] OK` (exit 0) — the stack container builds from source, the phase1 server-light is killed (`killing phase1 server-light: `), phase2 starts cleanly, and the daemon registers + passes the connectivity smoke. (Requires the companion @happier-dev/stack postpack fix for the escaped-import packaging bug to build at all.) --- .../release-assets-e2e/bin/stack-entrypoint.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/release/release-assets-e2e/bin/stack-entrypoint.sh b/scripts/release/release-assets-e2e/bin/stack-entrypoint.sh index a3c48b31de..9895b89261 100755 --- a/scripts/release/release-assets-e2e/bin/stack-entrypoint.sh +++ b/scripts/release/release-assets-e2e/bin/stack-entrypoint.sh @@ -119,6 +119,9 @@ setup_args=( if [[ -n "$HSTACK_HAPPIER_REPO" ]]; then setup_args+=( "--happier-repo=$HSTACK_HAPPIER_REPO" ) + # Bind-mounted repos are owned by the host user; git refuses to read them inside the container + # (dubious ownership), which breaks the `git clone ` in hstack setup. Trust them here. + git config --global --add safe.directory '*' 2>/dev/null || true fi if [[ "$HSTACK_E2E_WITH_UI" != "1" ]]; then @@ -218,11 +221,20 @@ kill_phase1_no_ui_supervisor() { kill_phase1_server_light() { # If the phase1 supervisor is killed abruptly, the server-light process can linger and keep the port busy. + # Match on `main.light.ts` (stable across invocations): dev uses `--import tsx .../main.light.ts`, + # from-source uses `tsx --tsconfig ... ./sources/main.light.ts`. The old `--import tsx ./sources/main.light.ts` + # pattern missed the from-source invocation, leaving port 3005 occupied and tripping phase2's topology guard + # (ESERVERTOPOLOGYUNOWNED: healthy-unowned + --restart). local pids_raw local pids - pids_raw="$(ps -eo pid,args -ww | awk '/--import tsx \.\/sources\/main\.light\.ts/ {print $1}' || true)" + pids_raw="$(ps -eo pid,args -ww | awk '/main\.light\.ts/ {print $1}' || true)" pids="$(echo "$pids_raw" | tr '\n' ' ' | xargs echo 2>/dev/null || true)" if [[ -z "$pids" ]]; then + # Fall back to an anchored pkill (procps) if ps parsing missed it. + if pkill -9 -f 'main\.light\.ts' >/dev/null 2>&1; then + echo "[stack] killing phase1 server-light (pkill fallback)" + sleep 1 + fi return 0 fi echo "[stack] killing phase1 server-light: $pids" From 8478bb0ea122bb64c6a038cefce6fd6404f47821 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Mon, 10 Aug 2026 16:01:04 +0700 Subject: [PATCH 2/2] fix(stack): vendor all repo-root escape targets into the packed tarball via postpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/stack/scripts/ reaches repo-root siblings through `../../../../../` imports that resolve at the monorepo root in dev but, once @happier-dev/stack is `npm pack`-ed and installed standalone, escape the package to the npm install root — ENOENT. npm's `files` glob also cannot traverse parent dirs to include those files, so the packed tarball crashes on `hstack setup`/`start` (any standalone install); the e2e local-monorepo smoke catches it. Two escape targets exist (comprehensive grep of apps/stack/ found no others): - scripts/utils/proc/pm.mjs (+ the packed test) -> scripts/workspaces/{ensureWorkspacePackagesBuilt,execYarnCommand,workspacePackageBuildLock}.mjs - scripts/utils/stack/runtime_daemon_state.mjs -> packages/cli-common/processInstance.mjs (processInstance.mjs is not bundled/exported by cli-common, so it must be vendored too) Add a postpack (mirroring apps/cli's patchPackedTarballForBun) that unpacks the tarball produced by `npm pack`, copies each escaped file into package/scripts/utils/workspaces/ (basename preserved), rewrites every escaping import to the in-package vendored copy, and rewrites the helpers' own back-imports into apps/stack/scripts/utils/* to in-package relative paths, then repacks. Repo-root sources stay canonical for monorepo builds; only the published tarball is made self-contained. Cross-device safe via copyFileSync (renameSync throws EXDEV when the pack destination and tmpdir are on different mounts). Validated: node:test suite (6 cases: rewrite fns, escape map, extracted-dir transform, real tarball round-trip, source-drift guards for BOTH escaping imports), a real `npm pack` of apps/stack (both escapes fixed, all 4 files vendored), and the e2e local-monorepo smoke — the stack container installed hstack and progressed into setup, past the pm.mjs and runtime_daemon_state crashes that blocked every prior run. (The smoke then failed on an unrelated git dubious-ownership / no-network issue inside the container, beyond this fix.) The escaping imports were introduced by ed1cf5955 ("refactor(stack): centralize dependency refresh admission") and later commits; the published 0.2.1-preview predates them, so the next release from current source would ship broken without this. --- apps/stack/package.json | 1 + .../patchPackedTarballForWorkspaces.mjs | 229 ++++++++++++++++++ .../patchPackedTarballForWorkspaces.test.mjs | 198 +++++++++++++++ 3 files changed, 428 insertions(+) create mode 100644 apps/stack/scripts/postpack/patchPackedTarballForWorkspaces.mjs create mode 100644 apps/stack/scripts/postpack/patchPackedTarballForWorkspaces.test.mjs diff --git a/apps/stack/package.json b/apps/stack/package.json index 4cba4c7ba9..469c058a5a 100644 --- a/apps/stack/package.json +++ b/apps/stack/package.json @@ -74,6 +74,7 @@ "menubar:uninstall": "node ./scripts/menubar.mjs uninstall", "menubar:open": "bash -lc 'DIR=\"$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)\"; if [[ -z \"$DIR\" ]]; then DIR=\"$HOME/Library/Application Support/SwiftBar/Plugins\"; fi; open \"$DIR\"'", "prepack": "node ./scripts/bundleWorkspaceDeps.mjs", + "postpack": "node ./scripts/postpack/patchPackedTarballForWorkspaces.mjs", "test": "yarn -s test:unit", "test:unit": "node ./scripts/test_ci.mjs", "test:integration": "node ./scripts/test_integration.mjs", diff --git a/apps/stack/scripts/postpack/patchPackedTarballForWorkspaces.mjs b/apps/stack/scripts/postpack/patchPackedTarballForWorkspaces.mjs new file mode 100644 index 0000000000..660e177ee6 --- /dev/null +++ b/apps/stack/scripts/postpack/patchPackedTarballForWorkspaces.mjs @@ -0,0 +1,229 @@ +// postpack: vendor the repo-root build-system helpers that apps/stack/scripts/ reaches via +// `../../../../../` imports into the packed @happier-dev/stack tarball, and rewrite those escaping +// imports to the in-package vendored copies. +// +// Background: several apps/stack/scripts/ files import repo-root siblings through paths like +// `../../../../../scripts/workspaces/...` or `../../../../../packages/cli-common/...`. These +// resolve at the monorepo root in dev but, once @happier-dev/stack is `npm pack`-ed and installed +// standalone, the five `../` escape the package to the npm install root +// (`/node_modules/scripts/...` / `.../packages/...`) — ENOENT. npm's `files` glob also +// cannot traverse parent dirs to include those files. So the packed tarball is broken for +// standalone consumers (hstack setup/start crash). This postpack (mirroring +// apps/cli/scripts/postpack/patchPackedTarballForBun.mjs) unpacks the tarball produced by +// `npm pack`, copies each escaped helper into package/scripts/utils/workspaces/, rewrites every +// escaping import to the in-package vendored copy, rewrites the helpers' own back-imports into +// apps/stack/scripts/utils/* to in-package relative paths, and repacks. The repo-root sources stay +// canonical for monorepo builds; only the published tarball is made self-contained. +import { execFileSync } from 'node:child_process'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Repo-root-relative paths of the files that apps/stack/scripts/ reaches through escaping imports. +// Each is vendored into package/scripts/utils/workspaces/ (basename preserved). Keep in sync with +// the actual escaping imports in apps/stack/scripts/ (guarded by a source-drift test). +export const VENDOR_SPECS = Object.freeze([ + 'scripts/workspaces/ensureWorkspacePackagesBuilt.mjs', + 'scripts/workspaces/execYarnCommand.mjs', + 'scripts/workspaces/workspacePackageBuildLock.mjs', + 'packages/cli-common/processInstance.mjs', +]); + +// Escaping import prefix: five `../` from package/scripts/utils// reaches the npm install +// root, escaping the @happier-dev/stack package. Each escaping import +// `` is rewritten to the in-package ``. +const ESCAPE_IMPORT_PREFIX = '../../../../../'; +const VENDORED_PREFIX = '../workspaces/'; + +// Vendored helpers (the scripts/workspaces/ ones) reach back into apps/stack/scripts/utils/* via +// this prefix; from their new in-package location (scripts/utils/workspaces/) the same targets are +// one level up. Applied to every vendored file (a no-op for files without such imports). +const BACK_TO_UTILS_IMPORT = '../../apps/stack/scripts/utils/'; +const VENDORED_TO_UTILS_IMPORT = '../'; + +const VENDORED_DIR_REL = path.join('scripts', 'utils', 'workspaces'); + +export function rewriteBackToUtilsImport(content) { + return content.split(BACK_TO_UTILS_IMPORT).join(VENDORED_TO_UTILS_IMPORT); +} + +/** + * Build the escaping-import → vendored-import rewrite map. Each spec's escaping import + * `${ESCAPE_PREFIX}` maps to `${VENDORED_PREFIX}`. + */ +export function buildEscapeRewriteMap() { + const map = new Map(); + for (const spec of VENDOR_SPECS) { + const basename = path.basename(spec); + map.set(`${ESCAPE_IMPORT_PREFIX}${spec}`, `${VENDORED_PREFIX}${basename}`); + } + return map; +} + +export function applyRewriteMap(content, rewriteMap) { + let out = content; + for (const [from, to] of rewriteMap) { + out = out.split(from).join(to); + } + return out; +} + +function listMjsFilesRecursive(dir) { + const out = []; + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listMjsFilesRecursive(full)); + } else if (entry.isFile() && entry.name.endsWith('.mjs')) { + out.push(full); + } + } + return out; +} + +/** + * Vendor the escaped helpers into an already-extracted `package/` dir and rewrite the affected + * imports in place. Pure filesystem transform — no tarball logic — so it is unit-testable. + */ +export function patchExtractedPackage({ extractedPackageDir, monorepoRoot }) { + if (!existsSync(extractedPackageDir)) { + throw new Error(`[postpack] extracted package dir missing: ${extractedPackageDir}`); + } + + const vendoredDir = path.join(extractedPackageDir, ...VENDORED_DIR_REL.split(path.sep)); + mkdirSync(vendoredDir, { recursive: true }); + + for (const spec of VENDOR_SPECS) { + const src = path.join(monorepoRoot, ...spec.split('/')); + if (!existsSync(src)) { + throw new Error(`[postpack] missing vendor source: ${src}`); + } + const content = readFileSync(src, 'utf8'); + writeFileSync(path.join(vendoredDir, path.basename(spec)), rewriteBackToUtilsImport(content)); + } + + const rewriteMap = buildEscapeRewriteMap(); + for (const file of listMjsFilesRecursive(path.join(extractedPackageDir, 'scripts'))) { + const original = readFileSync(file, 'utf8'); + const rewritten = applyRewriteMap(original, rewriteMap); + if (rewritten !== original) { + writeFileSync(file, rewritten); + } + } +} + +export function findMonorepoRootFrom(startDir) { + let dir = path.resolve(startDir); + for (let i = 0; i < 12; i++) { + if (existsSync(path.join(dir, 'package.json')) && existsSync(path.join(dir, 'yarn.lock'))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function normalizePackNameForFilename(name) { + const raw = String(name ?? '').trim(); + if (!raw) return ''; + return raw.replace(/^@/, '').replaceAll('/', '-'); +} + +export function resolveTarballPathFromEnv(env, cwd = process.cwd()) { + const destRaw = String(env?.npm_config_pack_destination ?? '').trim(); + const destDir = destRaw ? path.resolve(cwd, destRaw) : cwd; + const name = normalizePackNameForFilename(env?.npm_package_name); + const version = String(env?.npm_package_version ?? '').trim(); + if (name && version) { + const candidate = path.join(destDir, `${name}-${version}.tgz`); + if (existsSync(candidate)) return candidate; + } + try { + const newest = readdirSync(destDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.tgz')) + .map((entry) => ({ name: entry.name, mtimeMs: statSync(path.join(destDir, entry.name)).mtimeMs })) + .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]; + if (newest) return path.join(destDir, newest.name); + } catch { + // ignore + } + return ''; +} + +/** + * Patch the tarball produced by `npm pack` in place. Invoked as the stack package's `postpack` + * lifecycle script (npm sets npm_package_name / npm_package_version / npm_config_pack_destination). + * Also accepts explicit options for testing. + */ +export function patchPackedTarballForWorkspaces(options = {}) { + const env = options.env ?? process.env; + const tarballPath = String(options.tarballPath ?? '').trim() || resolveTarballPathFromEnv(env); + if (!tarballPath) { + throw new Error('[postpack] could not resolve packed tarball path (missing npm env?)'); + } + if (!existsSync(tarballPath)) { + throw new Error(`[postpack] packed tarball not found: ${tarballPath}`); + } + + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const monorepoRoot = options.monorepoRoot ?? findMonorepoRootFrom(scriptDir); + if (!monorepoRoot) { + throw new Error('[postpack] could not locate monorepo root (package.json + yarn.lock)'); + } + + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'happier-stack-postpack-')); + try { + execFileSync('tar', ['-xzf', tarballPath, '-C', tmpDir], { stdio: 'pipe' }); + const extractedPackageDir = path.join(tmpDir, 'package'); + if (!existsSync(extractedPackageDir)) { + throw new Error(`[postpack] tarball did not contain package/: ${tarballPath}`); + } + + patchExtractedPackage({ extractedPackageDir, monorepoRoot }); + + const outTarball = path.join(tmpDir, path.basename(tarballPath)); + execFileSync('tar', ['-czf', outTarball, '-C', tmpDir, 'package'], { stdio: 'pipe' }); + + // Replace the original tarball. copyFileSync + unlink is cross-device safe (the tmp dir may be + // on a different mount than the pack destination); fs.renameSync would throw EXDEV there. + copyFileSync(outTarball, tarballPath); + unlinkSync(outTarball); + + return { tarballPath, vendored: VENDOR_SPECS.map((spec) => path.basename(spec)) }; + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +const invokedAsMain = (() => { + const argv1 = process.argv[1]; + if (!argv1) return false; + return path.resolve(argv1) === path.resolve(fileURLToPath(import.meta.url)); +})(); + +if (invokedAsMain) { + try { + const result = patchPackedTarballForWorkspaces(); + // eslint-disable-next-line no-console + console.log(`[postpack] vendored workspace helpers into ${path.basename(result.tarballPath)}: ${result.vendored.join(', ')}`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/apps/stack/scripts/postpack/patchPackedTarballForWorkspaces.test.mjs b/apps/stack/scripts/postpack/patchPackedTarballForWorkspaces.test.mjs new file mode 100644 index 0000000000..5f1f545c0b --- /dev/null +++ b/apps/stack/scripts/postpack/patchPackedTarballForWorkspaces.test.mjs @@ -0,0 +1,198 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applyRewriteMap, + buildEscapeRewriteMap, + findMonorepoRootFrom, + patchExtractedPackage, + patchPackedTarballForWorkspaces, + rewriteBackToUtilsImport, + VENDOR_SPECS, +} from './patchPackedTarballForWorkspaces.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function tarCreate(outTarball, cwd) { + execFileSync('tar', ['-czf', outTarball, '-C', cwd, 'package'], { stdio: 'pipe' }); +} + +function tarExtract(tarball, cwd) { + execFileSync('tar', ['-xzf', tarball, '-C', cwd], { stdio: 'pipe' }); +} + +const PM_ESCAPE = "from '../../../../../scripts/workspaces/ensureWorkspacePackagesBuilt.mjs';"; +const PM_REWRITTEN = "from '../workspaces/ensureWorkspacePackagesBuilt.mjs';"; +const PROCINST_ESCAPE = "from '../../../../../packages/cli-common/processInstance.mjs';"; +const PROCINST_REWRITTEN = "from '../workspaces/processInstance.mjs';"; +const BACK_IMPORT = "from '../../apps/stack/scripts/utils/paths/paths.mjs';"; +const BACK_REWRITTEN = "from '../paths/paths.mjs';"; + +// Build a fake monorepo root (with the escaped source files) + a fake extracted package/ (with the +// escaping importers). Mirrors the real layout: source files live under scripts/workspaces/ and +// packages/cli-common/, importers under apps/stack's scripts/utils/{proc,stack}/. +function buildFixture() { + const root = mkdtempSync(path.join(os.tmpdir(), 'happier-stack-postpack-test-')); + const monorepoRoot = path.join(root, 'source-root'); + + // Escaped source files (repo-root-relative per VENDOR_SPECS). + mkdirSync(path.join(monorepoRoot, 'scripts', 'workspaces'), { recursive: true }); + writeFileSync( + path.join(monorepoRoot, 'scripts', 'workspaces', 'ensureWorkspacePackagesBuilt.mjs'), + `import { resolveYarnCommandInvocation } from './execYarnCommand.mjs'\nimport { coerceHappyMonorepoRootFromPath } ${BACK_IMPORT}\nexport async function ensureWorkspacePackagesBuiltForComponent() {}\n`, + ); + writeFileSync( + path.join(monorepoRoot, 'scripts', 'workspaces', 'execYarnCommand.mjs'), + `import { execFileSync } from 'node:child_process';\nexport function resolveYarnCommandInvocation() {}\n`, + ); + writeFileSync( + path.join(monorepoRoot, 'scripts', 'workspaces', 'workspacePackageBuildLock.mjs'), + `import { coerceHappyMonorepoRootFromPath } ${BACK_IMPORT}\nexport function resolveWorkspacePackageBuildLockPath() {}\n`, + ); + mkdirSync(path.join(monorepoRoot, 'packages', 'cli-common'), { recursive: true }); + writeFileSync( + path.join(monorepoRoot, 'packages', 'cli-common', 'processInstance.mjs'), + `import { spawnSync } from 'node:child_process';\nimport { readFileSync } from 'node:fs';\nexport function readProcessInstanceFingerprintSync() {}\n`, + ); + + // Fake packed package/ tree with the escaping importers. + const packageDir = path.join(root, 'package'); + const procDir = path.join(packageDir, 'scripts', 'utils', 'proc'); + const stackDir = path.join(packageDir, 'scripts', 'utils', 'stack'); + mkdirSync(procDir, { recursive: true }); + mkdirSync(stackDir, { recursive: true }); + writeFileSync(path.join(packageDir, 'package.json'), '{"name":"@happier-dev/stack","version":"0.0.0"}\n'); + writeFileSync( + path.join(procDir, 'pm.mjs'), + `import { ensureWorkspacePackagesBuiltForComponent } ${PM_ESCAPE}\nexport { ensureWorkspacePackagesBuiltForComponent };\n`, + ); + writeFileSync( + path.join(procDir, 'ensureWorkspacePackagesBuilt.test.mjs'), + `import { ensureWorkspacePackagesBuiltForComponent } ${PM_ESCAPE}\nimport { test } from 'node:test';\n`, + ); + writeFileSync( + path.join(stackDir, 'runtime_daemon_state.mjs'), + `import { readProcessInstanceFingerprintSync } ${PROCINST_ESCAPE}\nexport function readRuntimeDaemonState() {}\n`, + ); + + return { root, monorepoRoot, packageDir }; +} + +test('rewriteBackToUtilsImport rewrites the apps/stack back-import to an in-package relative path', () => { + const out = rewriteBackToUtilsImport(`import { x } ${BACK_IMPORT}\n`); + assert.equal(out, `import { x } ${BACK_REWRITTEN}\n`); +}); + +test('buildEscapeRewriteMap maps every VENDOR_SPEC escape to its vendored import', () => { + const map = buildEscapeRewriteMap(); + assert.equal(map.size, VENDOR_SPECS.length); + assert.equal(map.get("../../../../../scripts/workspaces/ensureWorkspacePackagesBuilt.mjs"), '../workspaces/ensureWorkspacePackagesBuilt.mjs'); + assert.equal(map.get("../../../../../packages/cli-common/processInstance.mjs"), '../workspaces/processInstance.mjs'); +}); + +test('applyRewriteMap rewrites all known escapes in a single pass', () => { + const out = applyRewriteMap(`a ${PM_ESCAPE}\nb ${PROCINST_ESCAPE}\n`, buildEscapeRewriteMap()); + assert.ok(out.includes(PM_REWRITTEN)); + assert.ok(out.includes(PROCINST_REWRITTEN)); + assert.ok(!out.includes('../../../../../')); +}); + +test('patchExtractedPackage vendors every spec and rewrites every escaping import', () => { + const { root, monorepoRoot, packageDir } = buildFixture(); + try { + patchExtractedPackage({ extractedPackageDir: packageDir, monorepoRoot }); + + const vendoredDir = path.join(packageDir, 'scripts', 'utils', 'workspaces'); + for (const spec of VENDOR_SPECS) { + assert.ok(existsSync(path.join(vendoredDir, path.basename(spec))), `vendored file missing: ${spec}`); + } + + // Vendored helpers had their back-imports rewritten (processInstance.mjs has none — unchanged). + const vendoredEnsure = readFileSync(path.join(vendoredDir, 'ensureWorkspacePackagesBuilt.mjs'), 'utf8'); + assert.ok(!vendoredEnsure.includes('../../apps/stack/scripts/utils/')); + assert.ok(vendoredEnsure.includes('../paths/paths.mjs')); + const vendoredProcInst = readFileSync(path.join(vendoredDir, 'processInstance.mjs'), 'utf8'); + assert.ok(vendoredProcInst.includes("from 'node:child_process'")); + + // pm.mjs + packed test had their scripts/workspaces escape rewritten. + const pm = readFileSync(path.join(packageDir, 'scripts', 'utils', 'proc', 'pm.mjs'), 'utf8'); + assert.ok(pm.includes('../workspaces/ensureWorkspacePackagesBuilt.mjs')); + assert.ok(!pm.includes('../../../../../scripts/workspaces/')); + + const packedTest = readFileSync(path.join(packageDir, 'scripts', 'utils', 'proc', 'ensureWorkspacePackagesBuilt.test.mjs'), 'utf8'); + assert.ok(packedTest.includes('../workspaces/ensureWorkspacePackagesBuilt.mjs')); + + // runtime_daemon_state.mjs had its packages/cli-common escape rewritten. + const rds = readFileSync(path.join(packageDir, 'scripts', 'utils', 'stack', 'runtime_daemon_state.mjs'), 'utf8'); + assert.ok(rds.includes('../workspaces/processInstance.mjs')); + assert.ok(!rds.includes('../../../../../packages/cli-common/')); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('patchPackedTarballForWorkspaces round-trips a real tarball: all specs vendored + imports rewritten', () => { + const { root, monorepoRoot, packageDir } = buildFixture(); + const tarballPath = path.join(root, 'artifact.tgz'); + tarCreate(tarballPath, root); + + const extractDir = mkdtempSync(path.join(os.tmpdir(), 'happier-stack-postpack-extract-')); + try { + const result = patchPackedTarballForWorkspaces({ tarballPath, monorepoRoot }); + assert.equal(result.tarballPath, tarballPath); + + tarExtract(tarballPath, extractDir); + const extractedPackage = path.join(extractDir, 'package'); + const vendoredDir = path.join(extractedPackage, 'scripts', 'utils', 'workspaces'); + for (const spec of VENDOR_SPECS) { + assert.ok(existsSync(path.join(vendoredDir, path.basename(spec))), `vendored missing in tarball: ${spec}`); + } + + const pm = readFileSync(path.join(extractedPackage, 'scripts', 'utils', 'proc', 'pm.mjs'), 'utf8'); + assert.ok(pm.includes('../workspaces/ensureWorkspacePackagesBuilt.mjs')); + const rds = readFileSync(path.join(extractedPackage, 'scripts', 'utils', 'stack', 'runtime_daemon_state.mjs'), 'utf8'); + assert.ok(rds.includes('../workspaces/processInstance.mjs')); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(extractDir, { recursive: true, force: true }); + } +}); + +// Guard: the real repo files must still use the import prefixes this postpack rewrites. If someone +// changes the import style in pm.mjs / runtime_daemon_state.mjs or the vendored sources, this fails +// loudly so the postpack transform stays in sync with the source. +test('real repo escaping imports + vendor sources still use the prefixes this postpack rewrites', () => { + const repoRoot = findMonorepoRootFrom(__dirname); + assert.ok(repoRoot, 'could not locate monorepo root from postpack test'); + + const realPm = path.join(repoRoot, 'apps', 'stack', 'scripts', 'utils', 'proc', 'pm.mjs'); + const realRds = path.join(repoRoot, 'apps', 'stack', 'scripts', 'utils', 'stack', 'runtime_daemon_state.mjs'); + const realEnsure = path.join(repoRoot, 'scripts', 'workspaces', 'ensureWorkspacePackagesBuilt.mjs'); + assert.ok(existsSync(realPm), `real pm.mjs missing at ${realPm}`); + assert.ok(existsSync(realRds), `real runtime_daemon_state.mjs missing at ${realRds}`); + assert.ok(existsSync(realEnsure), `real ensureWorkspacePackagesBuilt.mjs missing at ${realEnsure}`); + + const pmContent = readFileSync(realPm, 'utf8'); + assert.ok( + pmContent.includes('../../../../../scripts/workspaces/ensureWorkspacePackagesBuilt.mjs'), + 'pm.mjs no longer imports the repo-root workspace helper — postpack rewrite may be stale', + ); + + const rdsContent = readFileSync(realRds, 'utf8'); + assert.ok( + rdsContent.includes('../../../../../packages/cli-common/processInstance.mjs'), + 'runtime_daemon_state.mjs no longer imports the repo-root cli-common helper — postpack rewrite may be stale', + ); + + const ensureContent = readFileSync(realEnsure, 'utf8'); + assert.ok( + ensureContent.includes('../../apps/stack/scripts/utils/'), + 'ensureWorkspacePackagesBuilt.mjs no longer reaches back into apps/stack/scripts/utils — postpack rewrite may be stale', + ); +});