From 0eafc44d907647ab2bfec840ec4f43878ec32983 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 6 Oct 2026 23:47:19 -0400 Subject: [PATCH] fix: fence native project inputs before legacy operations --- docs/reference/native-config-compiler.md | 21 +- src/commands/config.ts | 11 + src/commands/doctor.ts | 9 + src/commands/project.ts | 157 ++++++++----- src/commands/projects.ts | 41 +++- src/daemon/runtime-cache.ts | 2 +- src/lib/cli-result.ts | 2 + src/lib/config.ts | 18 ++ src/lib/project-domain-migration.ts | 26 ++- src/lib/project-env-config.ts | 61 +++-- src/lib/project-input-selection.ts | 191 ++++++++++++++++ src/lib/project-meta.ts | 2 + src/lib/project-views.ts | 55 +++++ src/lib/project.ts | 76 ++++--- src/lib/projects-registry.ts | 80 +++---- src/lib/runtime-projects.ts | 38 +++- tests/config-command.test.ts | 62 +++++ tests/doctor-command.test.ts | 73 +++++- tests/env-get.test.ts | 17 ++ tests/init-with-command.test.ts | 211 ++++++++++++++++- tests/project-domain-migration.test.ts | 105 ++++++++- tests/project-env-config.test.ts | 105 +++++++++ tests/project-input-selection.test.ts | 276 +++++++++++++++++++++++ tests/project-meta.test.ts | 23 +- tests/project-views.test.ts | 109 ++++++++- tests/projects-registry-identity.test.ts | 74 +++++- tests/runtime-cache.test.ts | 25 +- tests/runtime-projects.test.ts | 125 +++++++++- 28 files changed, 1829 insertions(+), 166 deletions(-) create mode 100644 src/lib/project-input-selection.ts create mode 100644 tests/project-input-selection.test.ts diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index 35254ff04..ca436db34 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -1,11 +1,30 @@ # Native config compiler foundation This is an experimental, pure compiler for a bounded subset of the planned -`.hack/hack.project.json` format. It does not discover projects, execute workloads, +`.hack/hack.project.json` format. The compiler does not discover projects, execute workloads, import Compose, migrate data, decrypt environment values, perform host admission, or change how existing projects run. A successful compile is syntax and semantic validation, not backend capability or application acceptance. +The CLI recognizes this filename as a project boundary. Native runtime and adoption +are not enabled yet: legacy project commands refuse with +`E_NATIVE_PROJECT_UNSUPPORTED`. If active `.hack/` or `.dev/` Compose, JSON or TOML +inputs also exist, they refuse with `E_NATIVE_PROJECT_CONFLICT`. The marker still +blocks fallback when malformed, a future version, a directory or a dangling link. +Filesystem inspection failures also refuse. Generated internal files, branch files, +backups and a root-level Compose file are not competing authored Hack inputs. + +Discovery never crosses a native boundary to select an ancestor Compose project. +Registered name/ID lookup checks the stored root directly. Project listing marks +blocked registered entries `unavailable`, includes `input_diagnostic`, and continues +listing other entries without parsing the blocked legacy configuration. Global +configuration and explicit offline validation remain available. Init, legacy env +repair, config writes and domain migration/rollback refuse before modifying native +input; a refused rollback preserves its recovery journal. Selection is checked +again at mutation boundaries, but this is not atomic protection against concurrent +external edits. Locked adoption belongs to a later integration step. +`hack env get` retains its fixed redacted failure message and empty stdout. + The standalone `packages/config-compiler` Rust package has no dependency on the native runtime, virtualization, Docker, or platform provider APIs. It uses the repository's pinned Rust 1.97.1 and committed Cargo lockfile when building. The diff --git a/src/commands/config.ts b/src/commands/config.ts index 6763d29d6..378db23e1 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -22,6 +22,7 @@ import { ensureDir, readTextFile, writeTextFileIfChanged } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import type { ProjectContext } from "../lib/project.ts"; import { findProjectContext } from "../lib/project.ts"; +import { assertLegacyProjectDirectory } from "../lib/project-input-selection.ts"; import { normalizeProjectName } from "../lib/project-name.ts"; import { resolveRegisteredProjectByName, @@ -187,6 +188,10 @@ const handleConfigSet: CommandHandlerFor = async ({ const nextText = `${JSON.stringify(read.value, null, 2)}\n`; if (project.scope === "global") { await ensureDir(dirname(read.path)); + } else { + await assertLegacyProjectDirectory({ + projectDir: project.project.projectDir, + }); } const result = await writeTextFileIfChanged(read.path, nextText); @@ -329,6 +334,9 @@ async function readConfigObject(opts: { opts.target.project.projectDir, PROJECT_CONFIG_FILENAME ); + await assertLegacyProjectDirectory({ + projectDir: opts.target.project.projectDir, + }); const jsonText = await readTextFile(jsonPath); if (jsonText !== null) { const parsed = parseJsonObject({ text: jsonText, path: jsonPath }); @@ -381,6 +389,9 @@ async function readConfigJsonForSet(opts: { opts.target.project.projectDir, PROJECT_CONFIG_FILENAME ); + await assertLegacyProjectDirectory({ + projectDir: opts.target.project.projectDir, + }); const jsonText = await readTextFile(jsonPath); if (jsonText === null) { const tomlPath = resolve( diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 5dfc37539..ac9a19896 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -121,6 +121,7 @@ import { repairLegacyComposeEnvFileReferences, resolveProjectEnvConfig, } from "../lib/project-env-config.ts"; +import { assertLegacyProjectDiscovery } from "../lib/project-input-selection.ts"; import { inspectProjectLifecycleHygiene, repairProjectLifecycleSessions, @@ -443,6 +444,9 @@ async function maybeRunNativeMappingRecovery( const handleDoctor: CommandHandlerFor = async ({ args, }): Promise => { + await assertLegacyProjectDiscovery({ + startDir: resolveDoctorStartDir(args.options.path), + }); const mappingResult = await maybeRunNativeMappingRecovery(args); if (mappingResult !== null) { return mappingResult; @@ -889,6 +893,10 @@ const handleDoctor: CommandHandlerFor = async ({ return 0; }; +function resolveDoctorStartDir(pathOption: string | undefined): string { + return resolve(process.cwd(), pathOption ?? "."); +} + async function handleNativeDoctor(opts: { readonly runtime: NativeRuntimeSelection; readonly browser: ReturnType; @@ -3181,6 +3189,7 @@ async function maybeMigrateProjectEnvConfig(opts: { const removed = await removeLegacyProjectEnvArtifacts({ paths: cleanupCandidates, + projectRoot: project.projectRoot, }); if (removed.length > 0) { note(`Removed ${removed.join(", ")}`, "env cleanup"); diff --git a/src/commands/project.ts b/src/commands/project.ts index 673ba97d5..4504b7d86 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -223,6 +223,10 @@ import { selectProjectEnvValuesForExecutionTarget, } from "../lib/project-env-config.ts"; import { resolveProjectExecutionTarget } from "../lib/project-execution.ts"; +import { + assertLegacyProjectDiscovery, + assertLegacyProjectInputFamily, +} from "../lib/project-input-selection.ts"; import { readProcessSnapshot, resolveLifecycleProcessGroupIdsForTmuxState, @@ -3891,6 +3895,7 @@ async function promptInitUseDiscovery(opts: { async function ensureInitHackDir(opts: { readonly hackDir: string; }): Promise<"proceed" | "skip" | null> { + await assertLegacyProjectInputFamily({ projectRoot: dirname(opts.hackDir) }); if (await pathExists(opts.hackDir)) { const ok = await confirm({ message: `${HACK_PROJECT_DIR_PRIMARY}/ already exists. Overwrite scaffold files?`, @@ -3899,13 +3904,15 @@ async function ensureInitHackDir(opts: { if (isCancel(ok)) { return null; } + await assertLegacyProjectInputFamily({ + projectRoot: dirname(opts.hackDir), + }); if (!ok) { return "skip"; } return "proceed"; } - await ensureDir(opts.hackDir); return "proceed"; } @@ -3917,11 +3924,16 @@ async function handleInit({ readonly args: InitArgs; }): Promise { const withValue = resolveInitWithOption({ withRaw: args.options.with }); + const startDir = resolveStartDir(ctx, args.options.path); + await assertLegacyProjectDiscovery({ startDir }); if (args.options.auto) { return await handleInitAuto({ ctx, args, withValue }); } + const repoRoot = await findRepoRootForInit(startDir); + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); + if (!canPrompt()) { requireInteractive({ what: "hack init asks for project name, dev host, and discovery choices", @@ -3929,9 +3941,6 @@ async function handleInit({ }); } - const startDir = resolveStartDir(ctx, args.options.path); - const repoRoot = await findRepoRootForInit(startDir); - const slug = await promptInitProjectSlug({ repoRoot, nameOption: args.options.name, @@ -3994,6 +4003,7 @@ async function handleInit({ ".hack/ already exists — handing off the onboarding prompt for the existing setup.", }); await runInitOnboardingHandoff({ + repoRoot, withValue, mode: "existing-project", projectName: slug, @@ -4003,19 +4013,7 @@ async function handleInit({ return 0; } - // Committed, hack-owned ignore file for machine-local generated files - // (.internal/, .branch/, .env, env state, env-local overrides). - await ensureHackDirGitignore({ projectDir: hackDir }); - - await writeTextFileIfChanged( - configFile, - renderProjectConfigJson({ - name: slug, - devHost, - oauth: { enabled: oauth.enabled, tld: oauth.tld }, - }) - ); - + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); const compose = useDiscovery ? await buildDiscoveredCompose({ repoRoot, @@ -4030,21 +4028,45 @@ async function handleInit({ projectSlug: slug, oauth: { enabled: oauth.enabled, tld: oauth.tld }, }); - await writeTextFileIfChanged(composeFile, compose); + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); + await ensureDir(hackDir); - await writeTextFileIfChanged( - resolve(hackDir, "README.md"), - renderHackFolderReadme({ + // Committed, hack-owned ignore file for machine-local generated files + // (.internal/, .branch/, .env, env state, env-local overrides). + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); + await ensureHackDirGitignore({ projectDir: hackDir }); + + await writeInitScaffoldFile({ + repoRoot, + file: configFile, + content: renderProjectConfigJson({ + name: slug, devHost, oauth: { enabled: oauth.enabled, tld: oauth.tld }, - }) - ); + }), + }); + await writeInitScaffoldFile({ + repoRoot, + file: composeFile, + content: compose, + }); - await writeTextFileIfChanged( - resolve(hackDir, PROJECT_ENV_CONFIG_DEFAULT_FILENAME), - renderProjectEnvConfigYaml() - ); + await writeInitScaffoldFile({ + repoRoot, + file: resolve(hackDir, "README.md"), + content: renderHackFolderReadme({ + devHost, + oauth: { enabled: oauth.enabled, tld: oauth.tld }, + }), + }); + + await writeInitScaffoldFile({ + repoRoot, + file: resolve(hackDir, PROJECT_ENV_CONFIG_DEFAULT_FILENAME), + content: renderProjectEnvConfigYaml(), + }); + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); const registration = await upsertProjectRegistration({ project: { projectRoot: repoRoot, @@ -4085,6 +4107,7 @@ async function handleInit({ if (withValue) { await runInitOnboardingHandoff({ + repoRoot, withValue, mode: "new-project", projectName: slug, @@ -4106,6 +4129,7 @@ async function handleInitAuto({ }): Promise { const startDir = resolveStartDir(ctx, args.options.path); const repoRoot = await findRepoRootForInit(startDir); + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); const slug = resolveInitSlug({ repoRoot, @@ -4141,12 +4165,14 @@ async function handleInitAuto({ const composeFile = resolve(hackDir, PROJECT_COMPOSE_FILENAME); const configFile = resolve(hackDir, PROJECT_CONFIG_FILENAME); + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); if (await pathExists(hackDir)) { if (withValue) { logger.info({ message: `${HACK_PROJECT_DIR_PRIMARY}/ already exists — skipping init and handing off the onboarding prompt for the existing setup.`, }); await runInitOnboardingHandoff({ + repoRoot, withValue, mode: "existing-project", projectName: slug, @@ -4159,21 +4185,6 @@ async function handleInitAuto({ ); } - await ensureDir(hackDir); - - // Committed, hack-owned ignore file for machine-local generated files - // (.internal/, .branch/, .env, env state, env-local overrides). - await ensureHackDirGitignore({ projectDir: hackDir }); - - await writeTextFileIfChanged( - configFile, - renderProjectConfigJson({ - name: slug, - devHost, - oauth: { enabled: oauth.enabled, tld: oauth.tld }, - }) - ); - const compose = useDiscovery ? await buildDiscoveredComposeAuto({ repoRoot, @@ -4188,21 +4199,45 @@ async function handleInitAuto({ projectSlug: slug, oauth, }); - await writeTextFileIfChanged(composeFile, compose); + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); + await ensureDir(hackDir); + + // Committed, hack-owned ignore file for machine-local generated files + // (.internal/, .branch/, .env, env state, env-local overrides). + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); + await ensureHackDirGitignore({ projectDir: hackDir }); + + await writeInitScaffoldFile({ + repoRoot, + file: configFile, + content: renderProjectConfigJson({ + name: slug, + devHost, + oauth: { enabled: oauth.enabled, tld: oauth.tld }, + }), + }); + await writeInitScaffoldFile({ + repoRoot, + file: composeFile, + content: compose, + }); - await writeTextFileIfChanged( - resolve(hackDir, "README.md"), - renderHackFolderReadme({ + await writeInitScaffoldFile({ + repoRoot, + file: resolve(hackDir, "README.md"), + content: renderHackFolderReadme({ devHost, oauth: { enabled: oauth.enabled, tld: oauth.tld }, - }) - ); + }), + }); - await writeTextFileIfChanged( - resolve(hackDir, PROJECT_ENV_CONFIG_DEFAULT_FILENAME), - renderProjectEnvConfigYaml() - ); + await writeInitScaffoldFile({ + repoRoot, + file: resolve(hackDir, PROJECT_ENV_CONFIG_DEFAULT_FILENAME), + content: renderProjectEnvConfigYaml(), + }); + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); const registration = await upsertProjectRegistration({ project: { projectRoot: repoRoot, @@ -4235,6 +4270,7 @@ async function handleInitAuto({ if (withValue) { await runInitOnboardingHandoff({ + repoRoot, withValue, mode: "new-project", projectName: slug, @@ -4245,6 +4281,15 @@ async function handleInitAuto({ return 0; } +async function writeInitScaffoldFile(opts: { + readonly repoRoot: string; + readonly file: string; + readonly content: string; +}): Promise { + await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); + await writeTextFileIfChanged(opts.file, opts.content); +} + /** * Validate the raw `--with` init option. * @@ -4273,11 +4318,13 @@ function resolveInitWithOption(opts: { * instead. Init success is never rolled back by handoff problems. */ async function runInitOnboardingHandoff(opts: { + readonly repoRoot: string; readonly withValue: OnboardingWith; readonly mode: OnboardingMode; readonly projectName: string; readonly devHost: string; }): Promise { + await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); const prompt = renderOnboardingPrompt({ mode: opts.mode, projectName: opts.projectName, @@ -4388,6 +4435,7 @@ type SetupIntegration = "cursor" | "claude" | "codex" | "agents" | "mcp"; async function maybeSetupAgentIntegrations(opts: { readonly repoRoot: string; }): Promise { + await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); if (!canPrompt()) { return; } @@ -4421,6 +4469,7 @@ async function maybeSetupAgentIntegrations(opts: { const selection = new Set(selected); if (selection.has("cursor")) { + await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); const result = await installCursorRules({ scope: "project", projectRoot: opts.repoRoot, @@ -4434,6 +4483,7 @@ async function maybeSetupAgentIntegrations(opts: { } if (selection.has("claude")) { + await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); const result = await installClaudeHooks({ scope: "project", projectRoot: opts.repoRoot, @@ -4447,6 +4497,7 @@ async function maybeSetupAgentIntegrations(opts: { } if (selection.has("codex")) { + await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); const result = await installCodexSkill({ scope: "project", projectRoot: opts.repoRoot, @@ -4460,6 +4511,7 @@ async function maybeSetupAgentIntegrations(opts: { } if (selection.has("agents")) { + await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); const results = await upsertAgentDocs({ projectRoot: opts.repoRoot, targets: ["agents", "claude"], @@ -4475,6 +4527,7 @@ async function maybeSetupAgentIntegrations(opts: { } if (selection.has("mcp")) { + await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); const targetHints = selected.filter( (value) => value === "cursor" || value === "claude" || value === "codex" ); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 08aa68f62..c77759f83 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -20,6 +20,7 @@ import { type OperationTimings, } from "../lib/operation-timings.ts"; import { findProjectContext } from "../lib/project.ts"; +import { ProjectInputSelectionError } from "../lib/project-input-selection.ts"; import { type ProjectMeta, resolveProjectMeta } from "../lib/project-meta.ts"; import { AmbiguousProjectNameError, @@ -216,7 +217,15 @@ const handleProjects: CommandHandlerFor = async ({ async function touchCwdProjectRegistration(opts: { readonly cwd: string; }): Promise { - const project = await findProjectContext(opts.cwd); + let project: Awaited>; + try { + project = await findProjectContext(opts.cwd); + } catch (error: unknown) { + if (error instanceof ProjectInputSelectionError) { + return; + } + throw error; + } if (!project) { return; } @@ -568,6 +577,8 @@ async function runProjects(opts: { }), }); + await renderProjectInputDiagnostics(views); + if (opts.details) { const caddyIp = await resolveGlobalCaddyIp(); for (const p of views) { @@ -583,6 +594,23 @@ async function runProjects(opts: { return 0; } +async function renderProjectInputDiagnostics( + views: readonly ProjectView[] +): Promise { + const lines = views.flatMap((project) => + project.inputDiagnostic + ? [`${project.name}: ${project.inputDiagnostic.message}`] + : [] + ); + if (lines.length > 0) { + await display.panel({ + title: "Project input diagnostics", + tone: "warn", + lines, + }); + } +} + async function outputDaemonProjects({ opts, profiler, @@ -743,6 +771,9 @@ async function renderProjectDetails(opts: { const meta: Array = []; meta.push(["Status", p.status]); + if (p.inputDiagnostic) { + meta.push(["Input diagnostic", p.inputDiagnostic.message]); + } if (p.projectId) { meta.push(["Project id", p.projectId]); } @@ -922,7 +953,13 @@ async function buildMetaByProjectName(opts: { }): Promise> { const out = new Map(); const tasks = opts.views - .filter((p) => p.kind === "registered" && p.repoRoot && p.projectDir) + .filter( + (p) => + p.kind === "registered" && + p.repoRoot && + p.projectDir && + !p.inputDiagnostic + ) .map(async (p) => { if (!(p.repoRoot && p.projectDir)) { return; diff --git a/src/daemon/runtime-cache.ts b/src/daemon/runtime-cache.ts index 28dbe83b1..d8cb76dac 100644 --- a/src/daemon/runtime-cache.ts +++ b/src/daemon/runtime-cache.ts @@ -335,7 +335,7 @@ export function createRuntimeCache(opts: { ? await profiler.measure("metadata_ms", () => Promise.all( views.map(async (view) => { - if (view.kind !== "registered") { + if (view.kind !== "registered" || view.inputDiagnostic) { return null; } const reg = registryByName.get(view.name) ?? null; diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 007848f22..6d73c8cf8 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -19,6 +19,8 @@ export type HackErrorCode = | "E_CONFIG_PARSE" | "E_CONFIG_INVALID" | "E_PROJECT_NOT_FOUND" + | "E_NATIVE_PROJECT_CONFLICT" + | "E_NATIVE_PROJECT_UNSUPPORTED" | "E_SERVICE_NOT_FOUND" | "E_COMPOSE_FAILED" | "E_STARTUP_INCOMPLETE" diff --git a/src/lib/config.ts b/src/lib/config.ts index 15f2ebedb..d36469b79 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,6 +3,7 @@ import { PROJECT_CONFIG_FILENAME } from "../constants.ts"; import { resolveGlobalConfigPath } from "./config-paths.ts"; import { ensureDir, readTextFile, writeTextFileIfChanged } from "./fs.ts"; import { isRecord } from "./guards.ts"; +import { assertLegacyProjectDirectory } from "./project-input-selection.ts"; /** * Updates a value in the global config file at ~/.hack/hack.config.json. @@ -48,6 +49,7 @@ export async function updateProjectConfig({ configPath, path, value, + projectDir, }); } @@ -72,6 +74,7 @@ export async function updateProjectConfigBatch({ return await updateConfigFileValuesAtPath({ configPath, values, + projectDir, }); } @@ -79,10 +82,12 @@ async function updateConfigFileAtPath({ configPath, path, value, + projectDir, }: { readonly configPath: string; readonly path: string; readonly value: unknown; + readonly projectDir?: string; }): Promise<{ readonly changed: boolean }> { const parsedPath = parseKeyPath({ raw: path }); @@ -93,14 +98,17 @@ async function updateConfigFileAtPath({ return await updateConfigFileValuesAtPath({ configPath, values: [{ path, value }], + projectDir, }); } async function updateConfigFileValuesAtPath({ configPath, values, + projectDir, }: { readonly configPath: string; + readonly projectDir?: string; readonly values: ReadonlyArray<{ readonly path: string; readonly value: unknown; @@ -110,6 +118,10 @@ async function updateConfigFileValuesAtPath({ return { changed: false }; } + if (projectDir) { + await assertLegacyProjectDirectory({ projectDir }); + } + for (const entry of values) { const parsedPath = parseKeyPath({ raw: entry.path }); if (parsedPath.length === 0) { @@ -128,7 +140,13 @@ async function updateConfigFileValuesAtPath({ } const nextText = `${JSON.stringify(config, null, 2)}\n`; + if (projectDir) { + await assertLegacyProjectDirectory({ projectDir }); + } await ensureDir(dirname(configPath)); + if (projectDir) { + await assertLegacyProjectDirectory({ projectDir }); + } const result = await writeTextFileIfChanged(configPath, nextText); return { changed: result.changed }; diff --git a/src/lib/project-domain-migration.ts b/src/lib/project-domain-migration.ts index 448f0bb5d..d66dd0567 100644 --- a/src/lib/project-domain-migration.ts +++ b/src/lib/project-domain-migration.ts @@ -12,6 +12,10 @@ import { import { join, parse, resolve } from "node:path"; import { isRecord } from "./guards.ts"; import { planProjectDomainMigration } from "./project-domain-plan.ts"; +import { + assertLegacyProjectDirectory, + ProjectInputSelectionError, +} from "./project-input-selection.ts"; const FILES = ["hack.config.json", "docker-compose.yml"] as const; const LIMIT = 1024 * 1024; @@ -142,6 +146,7 @@ export async function previewProjectDomainMigration(opts: { readonly projectDir: string; readonly claimedHosts?: readonly string[]; }): Promise { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); try { const projectDir = resolve(opts.projectDir); const info = await directory(projectDir); @@ -162,7 +167,10 @@ export async function previewProjectDomainMigration(opts: { directory: { dev: info.dev, ino: info.ino }, files: values.map((value) => value.identity), }; - } catch { + } catch (error: unknown) { + if (error instanceof ProjectInputSelectionError) { + throw error; + } throw failure(); } } @@ -225,6 +233,7 @@ async function withLock( recover = false ): Promise { await directory(projectDir); + await assertLegacyProjectDirectory({ projectDir }); const internal = join(projectDir, ".internal"); try { await mkdir(internal, { mode: 0o700 }); @@ -301,6 +310,7 @@ async function replace( mode: number, expected: Identity | undefined ) { + await assertLegacyProjectDirectory({ projectDir }); await directory(projectDir); const temporary = join( join(projectDir, ".internal/domain-migration/staging"), @@ -315,6 +325,7 @@ async function replace( ) { throw failure(); } + await assertLegacyProjectDirectory({ projectDir }); await rename(temporary, join(projectDir, name)); await syncDir(projectDir); } finally { @@ -329,6 +340,7 @@ export async function applyProjectDomainMigration(opts: { readonly projectDir: string; readonly plan: ProjectDomainMigrationPreview; }): Promise { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); try { const projectDir = resolve(opts.projectDir); if (projectDir !== opts.plan.projectDir) { @@ -373,6 +385,7 @@ export async function applyProjectDomainMigration(opts: { mode: value.identity.mode, }; }); + await assertLegacyProjectDirectory({ projectDir }); const journal = join(internal, "domain-migration"); await mkdir(journal, { mode: 0o700 }); // Publish and sync the ignore rule before any potentially sensitive bytes. @@ -418,7 +431,10 @@ export async function applyProjectDomainMigration(opts: { ); } }); - } catch { + } catch (error: unknown) { + if (error instanceof ProjectInputSelectionError) { + throw error; + } throw failure(); } } @@ -494,6 +510,7 @@ async function validateStaging(journal: string): Promise { export async function rollbackProjectDomainMigration(opts: { readonly projectDir: string; }): Promise { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); try { const projectDir = resolve(opts.projectDir); await withLock( @@ -561,7 +578,10 @@ export async function rollbackProjectDomainMigration(opts: { }, true ); - } catch { + } catch (error: unknown) { + if (error instanceof ProjectInputSelectionError) { + throw error; + } throw failure(); } } diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 68abb7845..a67802757 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -42,6 +42,11 @@ import { import { getRecord, getString, isRecord } from "./guards.ts"; import { readHackEnvContract, resolveHackEnv } from "./hack-env.ts"; import { readProjectDefaultEnvConfig } from "./project.ts"; +import { + assertLegacyProjectDirectory, + assertLegacyProjectInputFamily, + ProjectInputSelectionError, +} from "./project-input-selection.ts"; import { resolvePrimaryLocalProjectDir, validatePrimaryLocalFile, @@ -341,7 +346,9 @@ export async function resolveProjectEnvSharedKeyPath(opts: { export async function ensureHackDirGitignore(opts: { readonly projectDir: string; }): Promise<{ readonly changed: boolean }> { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); await ensureDir(opts.projectDir); + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); return await ensureManagedGitignoreBlock({ gitignorePath: resolve(opts.projectDir, ".gitignore"), beginMarker: HACK_DIR_GITIGNORE_BEGIN_MARKER, @@ -1226,6 +1233,8 @@ export async function setProjectEnvValue(opts: { readonly secret: boolean; readonly local?: boolean; }): Promise { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); if (!PROJECT_ENV_KEY_PATTERN.test(opts.key)) { throw new Error(`Invalid env key: ${opts.key}`); } @@ -1313,6 +1322,8 @@ export async function unsetProjectEnvValue(opts: { readonly key: string; readonly local?: boolean; }): Promise<{ readonly changed: boolean; readonly filePath: string }> { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); const filePath = opts.local === true ? await resolveProjectEnvEffectiveLocalConfigPath({ @@ -1376,6 +1387,7 @@ async function writeProjectEnvConfigFile(opts: { }): Promise { const yaml = YAML.stringify(opts.config, null, 2); const text = yaml.endsWith("\n") ? yaml : `${yaml}\n`; + await assertLegacyProjectDirectory({ projectDir: dirname(opts.path) }); return (await writeTextFileIfChanged(opts.path, text)).changed; } @@ -1390,6 +1402,8 @@ export async function materializeProjectEnv(opts: { readonly changed: boolean; readonly effectiveEnvName: string | null; }> { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); const resolved = await resolveProjectEnvConfig({ projectRoot: opts.projectRoot, projectDir: opts.projectDir, @@ -1406,25 +1420,26 @@ export async function materializeProjectEnv(opts: { }); const envPath = resolve(opts.projectDir, PROJECT_ENV_FILENAME); const text = serializeDotEnv(selectedEnv); + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); const changed = (await writeTextFileIfChanged(envPath, text)).changed; await ensureDir( dirname(resolveProjectEnvStatePath({ projectDir: opts.projectDir })) ); + const stateText = `${JSON.stringify( + { + version: 1, + selectedOverlay: resolved.selection.effectiveEnv, + selectedService: opts.serviceName ?? null, + generatedAt: new Date().toISOString(), + inputs: await buildProjectEnvStateDigests({ files: resolved.files }), + }, + null, + 2 + )}\n`; + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); await writeTextFile( resolveProjectEnvStatePath({ projectDir: opts.projectDir }), - `${JSON.stringify( - { - version: 1, - selectedOverlay: resolved.selection.effectiveEnv, - selectedService: opts.serviceName ?? null, - generatedAt: new Date().toISOString(), - inputs: await buildProjectEnvStateDigests({ - files: resolved.files, - }), - }, - null, - 2 - )}\n` + stateText ); return { envPath, @@ -1760,6 +1775,7 @@ export type EnsureProjectEnvSecretKeyResult = { export async function ensureProjectEnvSecretKey(opts: { readonly projectRoot: string; }): Promise { + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); const keyPath = resolveProjectEnvKeyPath({ projectRoot: opts.projectRoot }); const sharedLocation = await resolveProjectEnvSharedKeyLocation({ projectRoot: opts.projectRoot, @@ -1853,11 +1869,15 @@ async function writeProjectEnvKeyWithFallback(opts: { if (opts.preferredKeyPath !== null) { try { await writeProjectEnvKeyFile({ + projectRoot: opts.projectRoot, path: opts.preferredKeyPath, keyText: opts.keyText, }); return { keyPath: opts.preferredKeyPath, warnings }; } catch (error: unknown) { + if (error instanceof ProjectInputSelectionError) { + throw error; + } const message = error instanceof Error ? error.message : String(error); warnings.push( `Failed to write shared env key at ${opts.preferredKeyPath} (${message}); falling back to a checkout-local key at ${opts.localKeyPath}. Sibling git worktrees will NOT share this key and secrets encrypted here may not decrypt elsewhere.` @@ -1873,9 +1893,11 @@ async function writeProjectEnvKeyWithFallback(opts: { } await writeProjectEnvKeyFile({ + projectRoot: opts.projectRoot, path: opts.localKeyPath, keyText: opts.keyText, }); + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); await ensureGitignoreEntry({ gitignorePath: resolve(opts.projectRoot, ".gitignore"), entry: PROJECT_ENV_KEY_FILENAME, @@ -1886,10 +1908,13 @@ async function writeProjectEnvKeyWithFallback(opts: { } async function writeProjectEnvKeyFile(opts: { + readonly projectRoot: string; readonly path: string; readonly keyText: string; }): Promise { + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); await ensureDir(dirname(opts.path)); + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); await writeTextFile(opts.path, `${opts.keyText}\n`); await chmod(opts.path, 0o600); } @@ -2047,6 +2072,7 @@ export async function repairLegacyComposeEnvFileReferences(opts: { readonly changed: boolean; readonly removed: readonly LegacyComposeEnvFileReference[]; }> { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); const text = await readTextFile(opts.composeFile); if (!text) { return { changed: false, removed: [] }; @@ -2094,6 +2120,7 @@ export async function repairLegacyComposeEnvFileReferences(opts: { const nextYaml = YAML.stringify(parsed, null, 2); const nextText = nextYaml.endsWith("\n") ? nextYaml : `${nextYaml}\n`; + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); const result = await writeTextFileIfChanged(opts.composeFile, nextText); return { changed: result.changed, @@ -2265,6 +2292,8 @@ export async function migrateLegacyProjectEnv(opts: { readonly blockedCleanupCandidates: readonly string[]; readonly composeEnvFileReferences: readonly LegacyComposeEnvFileReference[]; }> { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); const contract = await readHackEnvContract({ projectDir: opts.projectDir }); if (!contract.exists) { return { @@ -2390,13 +2419,16 @@ export async function migrateLegacyProjectEnv(opts: { } export async function removeLegacyProjectEnvArtifacts(opts: { + readonly projectRoot: string; readonly paths: readonly string[]; }): Promise { + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); const removed: string[] = []; for (const path of opts.paths) { if (!(await pathExists(path))) { continue; } + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); await rm(path, { recursive: false, force: true }); removed.push(path); } @@ -2549,6 +2581,8 @@ async function migrateLegacyProjectConfig(opts: { readonly changed: boolean; readonly cleanupCandidates: readonly string[]; }> { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); + await assertLegacyProjectInputFamily({ projectRoot: opts.projectRoot }); const configPath = resolve(opts.projectDir, PROJECT_CONFIG_FILENAME); const text = await readTextFile(configPath); if (text === null) { @@ -2578,6 +2612,7 @@ async function migrateLegacyProjectConfig(opts: { } const nextText = `${JSON.stringify(topLevel.config, null, 2)}\n`; + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); const result = await writeTextFileIfChanged(configPath, nextText); return { changed: result.changed, diff --git a/src/lib/project-input-selection.ts b/src/lib/project-input-selection.ts new file mode 100644 index 000000000..d627898c7 --- /dev/null +++ b/src/lib/project-input-selection.ts @@ -0,0 +1,191 @@ +import { lstat } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { + HACK_PROJECT_DIR_LEGACY, + HACK_PROJECT_DIR_PRIMARY, + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, +} from "../constants.ts"; +import { HackCliError } from "./cli-result.ts"; +import { pathExists } from "./fs.ts"; +import { isRecord } from "./guards.ts"; + +export const NATIVE_PROJECT_FILENAME = "hack.project.json"; +type ProjectDirName = + | typeof HACK_PROJECT_DIR_PRIMARY + | typeof HACK_PROJECT_DIR_LEGACY; +const PROJECT_DIRECTORIES = [ + HACK_PROJECT_DIR_PRIMARY, + HACK_PROJECT_DIR_LEGACY, +] as const; +const LEGACY_FILES = [ + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, +] as const; + +export interface ProjectInputSelection { + readonly kind: "none" | "legacy" | "native" | "conflict"; + readonly projectRoot: string; + readonly nativeFile: string; + readonly legacyFiles: readonly string[]; + readonly composeDirectories: readonly ProjectDirName[]; +} + +/** Stable refusal before native input can be interpreted by a legacy reader or writer. */ +export class ProjectInputSelectionError extends HackCliError { + override readonly code: + | "E_NATIVE_PROJECT_CONFLICT" + | "E_NATIVE_PROJECT_UNSUPPORTED"; + + constructor(kind: "native" | "conflict") { + const code = + kind === "conflict" + ? "E_NATIVE_PROJECT_CONFLICT" + : "E_NATIVE_PROJECT_UNSUPPORTED"; + super({ + code, + message: + kind === "conflict" + ? `${code}: Native hack.project.json and active legacy Hack inputs coexist. Reconcile them before using project commands; no input was changed.` + : `${code}: Native hack.project.json marks this project. Runtime/adoption support is not available yet. Use explicit hack config validate --file for offline validation.`, + }); + this.name = "ProjectInputSelectionError"; + this.code = code; + } +} + +/** + * Inspect only the named root, without parsing, following authored-file links, + * searching ancestors, touching registration, or normalizing configuration. + * Any native marker is a boundary, including malformed/nonregular input. Only + * ENOENT means absence; denied/invalid filesystem access cannot enable fallback. + */ +export async function inspectProjectInputsAtRoot(opts: { + readonly projectRoot: string; +}): Promise { + const projectRoot = resolve(opts.projectRoot); + const nativeFile = resolve( + projectRoot, + HACK_PROJECT_DIR_PRIMARY, + NATIVE_PROJECT_FILENAME + ); + const nativePresent = await inputPresent(nativeFile); + const legacyFiles: string[] = []; + const composeDirectories: ProjectDirName[] = []; + for (const directory of PROJECT_DIRECTORIES) { + for (const name of LEGACY_FILES) { + const file = resolve(projectRoot, directory, name); + if (await inputPresent(file)) { + legacyFiles.push(file); + // Presence still conflicts with native input, but legacy discovery keeps + // its existing stat-based behavior for dangling Compose links. + if (name === PROJECT_COMPOSE_FILENAME && (await pathExists(file))) { + composeDirectories.push(directory); + } + } + } + } + let kind: ProjectInputSelection["kind"] = "none"; + if (nativePresent) { + kind = legacyFiles.length > 0 ? "conflict" : "native"; + } else if (legacyFiles.length > 0) { + kind = "legacy"; + } + return { kind, projectRoot, nativeFile, legacyFiles, composeDirectories }; +} + +/** + * Preserve legacy .hack-before-.dev discovery, bounded by the first native + * root. A nested legacy project may be selected below that boundary; discovery + * never crosses it to select an ancestor Compose project instead. + */ +export async function discoverProjectInputs(opts: { + readonly startDir: string; +}): Promise { + let current = resolve(opts.startDir); + let nearestLegacy: ProjectInputSelection | null = null; + while (true) { + const selected = await inspectProjectInputsAtRoot({ projectRoot: current }); + if (selected.kind === "native" || selected.kind === "conflict") { + return nearestLegacy ?? selected; + } + if (selected.composeDirectories.includes(HACK_PROJECT_DIR_PRIMARY)) { + return selected; + } + if ( + !nearestLegacy && + selected.composeDirectories.includes(HACK_PROJECT_DIR_LEGACY) + ) { + nearestLegacy = selected; + } + const parent = dirname(current); + if (parent === current) { + return nearestLegacy; + } + current = parent; + } +} + +export function requireLegacyProjectInputs( + selection: ProjectInputSelection +): void { + if (selection.kind === "native" || selection.kind === "conflict") { + throw new ProjectInputSelectionError(selection.kind); + } +} + +/** A read/write preflight, not an atomic fence against concurrent external edits. */ +export async function assertLegacyProjectInputFamily(opts: { + readonly projectRoot: string; +}): Promise { + // Legacy reads/writes need only one strict marker check on their common path. + // Full authored-family classification is needed when the marker is present. + if ( + !(await inputPresent( + resolve( + opts.projectRoot, + HACK_PROJECT_DIR_PRIMARY, + NATIVE_PROJECT_FILENAME + ) + )) + ) { + return; + } + requireLegacyProjectInputs(await inspectProjectInputsAtRoot(opts)); +} + +/** Guard existing .hack/.dev directory-based owners without synthesizing native contexts. */ +export async function assertLegacyProjectDirectory(opts: { + readonly projectDir: string; +}): Promise { + await assertLegacyProjectInputFamily({ + projectRoot: dirname(resolve(opts.projectDir)), + }); +} + +/** Stop init/discovery before it can choose an ancestor repository or start onboarding. */ +export async function assertLegacyProjectDiscovery(opts: { + readonly startDir: string; +}): Promise { + const selected = await discoverProjectInputs(opts); + if (selected) { + requireLegacyProjectInputs(selected); + } +} + +async function inputPresent(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error: unknown) { + if (isRecord(error) && error.code === "ENOENT") { + return false; + } + throw new Error( + "Cannot inspect Hack project inputs. Check filesystem permissions and paths before retrying.", + { cause: error } + ); + } +} diff --git a/src/lib/project-meta.ts b/src/lib/project-meta.ts index e45f39605..bc153cb8a 100644 --- a/src/lib/project-meta.ts +++ b/src/lib/project-meta.ts @@ -16,6 +16,7 @@ import { projectEnvConfigExists, resolveProjectEnvConfig, } from "./project-env-config.ts"; +import { assertLegacyProjectDirectory } from "./project-input-selection.ts"; import { exec } from "./shell.ts"; export type GitWorktreeMeta = { @@ -98,6 +99,7 @@ export async function resolveProjectMeta(opts: { readonly projectDir: string; readonly composeFile: string; }): Promise { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); const [config, git, hackBranches, env, sessions, composeBuild] = await Promise.all([ readProjectConfig({ diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index 7ae1dc8aa..40d95b80d 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -18,6 +18,10 @@ import { type ProjectOwnershipConfig, readProjectConfig, } from "./project.ts"; +import { + assertLegacyProjectInputFamily, + ProjectInputSelectionError, +} from "./project-input-selection.ts"; import { normalizeProjectName } from "./project-name.ts"; import type { RegisteredProject, @@ -91,6 +95,10 @@ export type ProjectView = { readonly serviceHosts: Readonly> | null; readonly runtimeConfigured: boolean | null; readonly runtimeStatus: ProjectRuntimeStatus; + readonly inputDiagnostic?: { + readonly code: ProjectInputSelectionError["code"]; + readonly message: string; + }; readonly runtime: RuntimeProject | null; readonly branchRuntime: readonly BranchRuntime[]; readonly sessions: readonly ProjectSession[]; @@ -102,6 +110,7 @@ export type ProjectView = { | "stopped" | "missing" | "unregistered" + | "unavailable" | "unknown"; }; @@ -110,6 +119,7 @@ export type ProjectRuntimeStatus = | "stopped" | "missing" | "unknown" + | "unavailable" | "not_configured"; type BuildProjectViewsOptions = { @@ -240,6 +250,20 @@ async function buildRegisteredProjectView(opts: { readonly runtimeOk: boolean; readonly muxSessions: readonly MuxSession[]; }): Promise { + try { + await assertLegacyProjectInputFamily({ + projectRoot: opts.registration.repoRoot, + }); + } catch (error: unknown) { + if (!(error instanceof ProjectInputSelectionError)) { + throw error; + } + return buildUnavailableProjectView({ + name: opts.name, + registration: opts.registration, + error, + }); + } const projectDirOk = await pathExists(opts.registration.projectDir); const composeFile = resolve( opts.registration.projectDir, @@ -309,6 +333,35 @@ async function buildRegisteredProjectView(opts: { }; } +function buildUnavailableProjectView(opts: { + readonly name: string; + readonly registration: RegisteredProject; + readonly error: ProjectInputSelectionError; +}): ProjectView { + return { + projectId: opts.registration.id, + name: opts.name, + devHost: opts.registration.devHost ?? null, + repoRoot: opts.registration.repoRoot, + projectDir: opts.registration.projectDir, + ownership: null, + definedServices: null, + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: null, + runtimeStatus: "unavailable", + inputDiagnostic: { code: opts.error.code, message: opts.error.message }, + runtime: null, + branchRuntime: [], + sessions: [], + lifecycle: null, + worktrees: opts.registration.worktrees ?? null, + kind: "registered", + status: "unavailable", + }; +} + function buildUnregisteredProjectView(opts: { readonly name: string; readonly runtime: RuntimeProject | null; @@ -359,6 +412,7 @@ export function serializeProjectSummary( dev_host: view.devHost, status: view.status, runtime_status: view.runtimeStatus, + ...(view.inputDiagnostic ? { input_diagnostic: view.inputDiagnostic } : {}), defined_service_count: view.definedServices?.length ?? null, host_process_count: containers.filter( (container) => container.labels?.["hack.lifecycle.process"] === "true" @@ -399,6 +453,7 @@ export function serializeProjectView( service_hosts: view.serviceHosts ?? null, runtime_configured: view.runtimeConfigured ?? null, runtime_status: view.runtimeStatus, + ...(view.inputDiagnostic ? { input_diagnostic: view.inputDiagnostic } : {}), runtime: view.runtime ? serializeRuntimeProject(view.runtime) : null, branch_runtime: view.branchRuntime.map((entry) => ({ branch: entry.branch, diff --git a/src/lib/project.ts b/src/lib/project.ts index e55d5c5ec..ba163a112 100644 --- a/src/lib/project.ts +++ b/src/lib/project.ts @@ -3,7 +3,7 @@ import { DEFAULT_NEW_PROJECT_TLD, DEFAULT_OAUTH_ALIAS_TLD, DEFAULT_PROJECT_TLD, - HACK_PROJECT_DIR_LEGACY, + type HACK_PROJECT_DIR_LEGACY, HACK_PROJECT_DIR_PRIMARY, PROJECT_COMPOSE_FILENAME, PROJECT_CONFIG_FILENAME, @@ -17,7 +17,12 @@ import { type OpenHostPreference, parseOpenHostPreference, } from "./open-host.ts"; -import { findUpFile } from "./path.ts"; +import { + assertLegacyProjectDirectory, + discoverProjectInputs, + inspectProjectInputsAtRoot, + requireLegacyProjectInputs, +} from "./project-input-selection.ts"; import { normalizeProjectName } from "./project-name.ts"; export type ProjectDirName = @@ -36,23 +41,28 @@ export interface ProjectContext { export async function findProjectContext( startDir: string ): Promise { - const primaryRoot = await findUpFile( - startDir, - `${HACK_PROJECT_DIR_PRIMARY}/${PROJECT_COMPOSE_FILENAME}` - ); - if (primaryRoot) { - return buildProjectContext(primaryRoot, HACK_PROJECT_DIR_PRIMARY); - } - - const legacyRoot = await findUpFile( - startDir, - `${HACK_PROJECT_DIR_LEGACY}/${PROJECT_COMPOSE_FILENAME}` - ); - if (legacyRoot) { - return buildProjectContext(legacyRoot, HACK_PROJECT_DIR_LEGACY); + const selected = await discoverProjectInputs({ startDir }); + if (!selected) { + return null; } + requireLegacyProjectInputs(selected); + const directory = selected.composeDirectories[0]; + return directory + ? buildProjectContext(selected.projectRoot, directory) + : null; +} - return null; +/** Resolve a registered exact root, never an ancestor or a native fake Compose context. */ +export async function findProjectContextAtRoot(opts: { + readonly projectRoot: string; + readonly projectDirName?: ProjectDirName; +}): Promise { + const selected = await inspectProjectInputsAtRoot(opts); + requireLegacyProjectInputs(selected); + const directory = opts.projectDirName ?? selected.composeDirectories[0]; + return directory && selected.composeDirectories.includes(directory) + ? buildProjectContext(selected.projectRoot, directory) + : null; } function buildProjectContext( @@ -71,17 +81,28 @@ function buildProjectContext( } export async function findRepoRootForInit(startDir: string): Promise { - const byPackageJson = await findUpFile(startDir, "package.json"); - if (byPackageJson) { - return byPackageJson; - } - - const byGit = await findUpFile(startDir, ".git"); - if (byGit) { - return byGit; + let current = resolve(startDir); + let nearestGit: string | null = null; + while (true) { + const selected = await inspectProjectInputsAtRoot({ projectRoot: current }); + if (selected.kind === "native" || selected.kind === "conflict") { + if (nearestGit) { + return nearestGit; + } + requireLegacyProjectInputs(selected); + } + if (await pathExists(resolve(current, "package.json"))) { + return current; + } + if (!nearestGit && (await pathExists(resolve(current, ".git")))) { + nearestGit = current; + } + const parent = dirname(current); + if (parent === current) { + return nearestGit ?? resolve(startDir); + } + current = parent; } - - return resolve(startDir); } export function sanitizeProjectSlug(input: string): string { @@ -400,6 +421,7 @@ export function resolveProjectRouteBaseHosts(opts: { export async function readProjectConfig( ctx: ProjectContext ): Promise { + await assertLegacyProjectDirectory({ projectDir: ctx.projectDir }); const jsonPath = resolve(ctx.projectDir, PROJECT_CONFIG_FILENAME); const jsonText = await readTextFile(jsonPath); if (jsonText !== null) { diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index f0add8a00..e190e5cc6 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -1,12 +1,7 @@ import { createHash } from "node:crypto"; import { realpath } from "node:fs/promises"; import { dirname, resolve } from "node:path"; -import { - GLOBAL_PROJECTS_REGISTRY_FILENAME, - PROJECT_COMPOSE_FILENAME, - PROJECT_CONFIG_FILENAME, - PROJECT_ENV_FILENAME, -} from "../constants.ts"; +import { GLOBAL_PROJECTS_REGISTRY_FILENAME } from "../constants.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { ensureDir, pathExists, readTextFile } from "./fs.ts"; import { @@ -16,7 +11,15 @@ import { } from "./git-worktree.ts"; import { getString, isRecord } from "./guards.ts"; import type { ProjectContext, ProjectDirName } from "./project.ts"; -import { defaultProjectSlugFromPath, readProjectConfig } from "./project.ts"; +import { + defaultProjectSlugFromPath, + findProjectContextAtRoot, + readProjectConfig, +} from "./project.ts"; +import { + assertLegacyProjectDirectory, + ProjectInputSelectionError, +} from "./project-input-selection.ts"; import { AmbiguousProjectNameError, normalizeProjectName, @@ -142,6 +145,7 @@ async function upsertObservedRegistration(input: { readonly observation?: RegistrationObservation; }): Promise { const opts = input.options; + await assertLegacyProjectDirectory({ projectDir: opts.project.projectDir }); const nowIso = opts.nowIso ?? new Date().toISOString(); const registryPath = getRegistryPath(); const registryDir = dirname(registryPath); @@ -165,6 +169,9 @@ async function upsertObservedRegistration(input: { return await withRegistryLock( async () => { + await assertLegacyProjectDirectory({ + projectDir: opts.project.projectDir, + }); const current = await readProjectsRegistry(); const update = await upsertInMemory({ current, @@ -180,6 +187,7 @@ async function upsertObservedRegistration(input: { update, registryPath, signal: opts.signal, + projectDir: opts.project.projectDir, }); }, { waitForLock: opts.waitForLock, signal: opts.signal } @@ -190,12 +198,14 @@ async function commitRegistrationUpdate(opts: { readonly update: Awaited>; readonly registryPath: string; readonly signal?: AbortSignal; + readonly projectDir: string; }): Promise { const { project, status } = opts.update; if (status.status === "conflict") { return status; } if (status.status !== "noop" || status.changed) { + await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); await writeRegistryAtomic( opts.registryPath, { version: REGISTRY_VERSION, projects: status.projects }, @@ -241,10 +251,16 @@ async function refreshUnchangedPrimary(opts: { readonly nowIso: string; }): Promise { const registryPath = getRegistryPath(); + await assertLegacyProjectDirectory({ + projectDir: opts.observation.projectDir, + }); await deferIfProjectsRegistryBusy({ lockPath: getRegistryLockPath() }); await ensureDir(dirname(registryPath)); return await withRegistryLock( async () => { + await assertLegacyProjectDirectory({ + projectDir: opts.observation.projectDir, + }); const current = await readProjectsRegistry(); const primary = selectUnchangedPrimary({ registry: current, @@ -260,6 +276,7 @@ async function refreshUnchangedPrimary(opts: { } return await commitRegistrationUpdate({ registryPath, + projectDir: opts.observation.projectDir, update: await updatePrimaryRegistration({ current: current.projects, existing: primary, @@ -288,6 +305,7 @@ export async function touchProjectRegistration(opts: { readonly project: ProjectContext; readonly nowIso?: string; }): Promise { + await assertLegacyProjectDirectory({ projectDir: opts.project.projectDir }); try { const nowIso = opts.nowIso ?? new Date().toISOString(); const [registry, observation] = await Promise.all([ @@ -314,7 +332,10 @@ export async function touchProjectRegistration(opts: { options: { project: opts.project, nowIso, waitForLock: false }, observation, }); - } catch { + } catch (error: unknown) { + if (error instanceof ProjectInputSelectionError) { + throw error; + } return null; } } @@ -389,26 +410,10 @@ export async function resolveRegisteredProjectByName(opts: { return null; } - if (!(await pathExists(match.projectDir))) { - return null; - } - - const composeFile = resolve(match.projectDir, PROJECT_COMPOSE_FILENAME); - const configFile = resolve(match.projectDir, PROJECT_CONFIG_FILENAME); - const envFile = resolve(match.projectDir, PROJECT_ENV_FILENAME); - - if (!(await pathExists(composeFile))) { - return null; - } - - return { + return await findProjectContextAtRoot({ projectRoot: match.repoRoot, projectDirName: match.projectDirName, - projectDir: match.projectDir, - composeFile, - envFile, - configFile, - }; + }); } /** Select one canonical name/legacy alias without filtering out stale contenders. */ @@ -457,28 +462,17 @@ export async function resolveRegisteredProjectById(opts: { return null; } - if (!(await pathExists(match.projectDir))) { - return null; - } - - const composeFile = resolve(match.projectDir, PROJECT_COMPOSE_FILENAME); - const configFile = resolve(match.projectDir, PROJECT_CONFIG_FILENAME); - const envFile = resolve(match.projectDir, PROJECT_ENV_FILENAME); - - if (!(await pathExists(composeFile))) { + const project = await findProjectContextAtRoot({ + projectRoot: match.repoRoot, + projectDirName: match.projectDirName, + }); + if (!project) { return null; } return { registration: match, - project: { - projectRoot: match.repoRoot, - projectDirName: match.projectDirName, - projectDir: match.projectDir, - composeFile, - envFile, - configFile, - }, + project, }; } diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index 03735b2be..d85a30822 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -13,6 +13,11 @@ import { createOperationTimings, type OperationTimings, } from "./operation-timings.ts"; +import { + assertLegacyProjectInputFamily, + inspectProjectInputsAtRoot, + ProjectInputSelectionError, +} from "./project-input-selection.ts"; import { upsertProjectRegistration } from "./projects-registry.ts"; import { exec, findExecutableInPath } from "./shell.ts"; @@ -324,21 +329,34 @@ export async function autoRegisterRuntimeHackProjects(opts: { const projectDir = wd; const repoRoot = resolve(projectDir, ".."); + const selection = await inspectProjectInputsAtRoot({ + projectRoot: repoRoot, + }); + if (selection.kind === "native" || selection.kind === "conflict") { + continue; + } const composeFile = resolve(projectDir, PROJECT_COMPOSE_FILENAME); if (!(await pathExists(composeFile))) { continue; } - await upsertProjectRegistration({ - project: { - projectRoot: repoRoot, - projectDirName: dirName, - projectDir, - composeFile, - envFile: resolve(projectDir, PROJECT_ENV_FILENAME), - configFile: resolve(projectDir, PROJECT_CONFIG_FILENAME), - }, - }); + try { + await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); + await upsertProjectRegistration({ + project: { + projectRoot: repoRoot, + projectDirName: dirName, + projectDir, + composeFile, + envFile: resolve(projectDir, PROJECT_ENV_FILENAME), + configFile: resolve(projectDir, PROJECT_CONFIG_FILENAME), + }, + }); + } catch (error: unknown) { + if (!(error instanceof ProjectInputSelectionError)) { + throw error; + } + } } } diff --git a/tests/config-command.test.ts b/tests/config-command.test.ts index 226ffb462..1ffd78173 100644 --- a/tests/config-command.test.ts +++ b/tests/config-command.test.ts @@ -3,6 +3,7 @@ import { access, mkdir, mkdtemp, + readdir, readFile, rm, writeFile, @@ -181,6 +182,67 @@ test("config get does not create or lock the global project registry", async () expect(await exists(registryPath)).toBe(false); }); +for (const mixed of [false, true]) { + test(`config get/set refuses native project paths without creating legacy files or registration (mixed=${mixed})`, async () => { + const projectRoot = join(tempDir!, "native"); + const projectDir = join(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + const marker = join(projectDir, "hack.project.json"); + await writeFile(marker, "{invalid-native"); + const legacy = join(projectDir, "hack.config.json"); + if (mixed) { + await writeFile(legacy, '{"name":"original"}\n'); + await writeFile(join(projectDir, "docker-compose.yml"), "services: {}\n"); + } + const before = await readdir(projectDir); + const { runCli } = await import("../src/cli/run.ts"); + for (const args of [ + ["config", "get", "--path", projectRoot, "name"], + ["config", "set", "--path", projectRoot, "name", "changed"], + ]) { + expect(await runCli(args)).not.toBe(0); + } + expect(await readdir(projectDir)).toEqual(before); + expect(await readFile(marker, "utf8")).toBe("{invalid-native"); + if (mixed) { + expect(await readFile(legacy, "utf8")).toBe('{"name":"original"}\n'); + } + expect(await exists(join(tempDir!, ".hack"))).toBe(false); + }); +} + +test("config registered selection refuses a native marker and preserves registry bytes", async () => { + const projectRoot = join(tempDir!, "native"); + const projectDir = join(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, "hack.project.json"), "{}"); + const registry = join(tempDir!, ".hack", "projects.json"); + await mkdir(dirname(registry), { recursive: true }); + const before = JSON.stringify({ + version: 1, + projects: [ + { + id: "native-id", + name: "native", + repoRoot: projectRoot, + projectDir, + projectDirName: ".hack", + createdAt: "2026-10-06T00:00:00Z", + }, + ], + }); + await writeFile(registry, before); + const { runCli } = await import("../src/cli/run.ts"); + expect( + await runCli(["config", "get", "--project", "native", "name"]) + ).not.toBe(0); + expect( + await runCli(["config", "set", "--project", "native", "name", "changed"]) + ).not.toBe(0); + expect(await readFile(registry, "utf8")).toBe(before); + expect(await readdir(dirname(registry))).toEqual(["projects.json"]); +}); + async function exists(path: string): Promise { try { await access(path); diff --git a/tests/doctor-command.test.ts b/tests/doctor-command.test.ts index d0d1c5467..d67bc805b 100644 --- a/tests/doctor-command.test.ts +++ b/tests/doctor-command.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { HACK_AGENT_INTEGRATION_CONTENT_REVISION } from "../src/agents/integration-revision.ts"; import { assertDoctorOptionCompatibility, @@ -42,6 +42,75 @@ async function createDoctorTestProject(opts: { return root; } +test("doctor refuses native input before probing or repairing global runtime", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-doctor-native-")); + try { + const project = join(root, "project"); + const tools = join(root, "tools"); + await mkdir(join(project, ".hack"), { recursive: true }); + await mkdir(tools); + const cache = join(root, "cache"); + await mkdir(cache); + await writeFile( + join(project, ".hack", "hack.project.json"), + "{invalid-native" + ); + const sentinel = join(root, "runtime-called"); + await writeFile( + join(tools, "docker"), + '#!/bin/sh\nprintf called > "$RUNTIME_SENTINEL"\nexit 99\n', + { mode: 0o700 } + ); + const cli = resolve(import.meta.dir, "..", "index.ts"); + for (const flags of [[], ["--fix"], ["--migrate-env-config"], ["--json"]]) { + const child = Bun.spawn( + [process.execPath, cli, "doctor", "--path", project, ...flags], + { + cwd: project, + env: { + ...process.env, + HOME: root, + HACK_HOME: join(root, "state"), + HACK_GLOBAL_CONFIG_PATH: join(root, "state", "hack.config.json"), + HACK_DAEMON_DISABLE: "1", + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + XDG_CACHE_HOME: cache, + PATH: `${tools}:/usr/bin:/bin`, + RUNTIME_SENTINEL: sentinel, + }, + stdout: "pipe", + stderr: "pipe", + } + ); + const [exit, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exit).not.toBe(0); + expect(stdout + stderr).toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + if (flags.includes("--json")) { + expect(JSON.parse(stdout)).toMatchObject({ + ok: false, + error: { code: "E_NATIVE_PROJECT_UNSUPPORTED" }, + }); + } + expect(await Bun.file(sentinel).exists()).toBe(false); + expect((await readdir(root)).sort()).toEqual([ + "cache", + "project", + "tools", + ]); + expect(await readdir(join(project, ".hack"))).toEqual([ + "hack.project.json", + ]); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("doctor guidance distinguishes restartable proxy drift from deeper repair", () => { const guidance = buildDoctorRecoveryGuidance({ results: [ diff --git a/tests/env-get.test.ts b/tests/env-get.test.ts index d283abb08..08ae73132 100644 --- a/tests/env-get.test.ts +++ b/tests/env-get.test.ts @@ -83,6 +83,23 @@ async function get( return await capture(["env", "get", key, "--path", path, ...extra]); } +for (const mixed of [false, true]) { + test(`get refuses native inputs with a redacted error and no filesystem changes (mixed=${mixed})`, async () => { + if (!mixed) { + await rm(join(projectDir, "hack.config.json")); + await rm(join(projectDir, "docker-compose.yml")); + } + const marker = join(projectDir, "hack.project.json"); + await writeFile(marker, "{synthetic-private-canary"); + const before = await readdir(projectDir); + const result = await get("VALUE"); + expectFailure(result); + expect(result.stderr.toString()).toContain("Unable to read env value."); + expect(await readdir(projectDir)).toEqual(before); + expect(await readFile(marker, "utf8")).toBe("{synthetic-private-canary"); + }); +} + async function capture(args: readonly string[]) { const stdout: Buffer[] = []; const stderr: Buffer[] = []; diff --git a/tests/init-with-command.test.ts b/tests/init-with-command.test.ts index b9363eaa6..c9b321ef5 100644 --- a/tests/init-with-command.test.ts +++ b/tests/init-with-command.test.ts @@ -1,7 +1,15 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; /** * CLI-level coverage for `hack init --with `. @@ -20,12 +28,50 @@ type CapturedRunResult = { type SavedEnv = Record; -const ENV_KEYS = ["PATH", "HACK_HOME", "HACK_NO_INTERACTIVE"] as const; +const ENV_KEYS = [ + "PATH", + "HACK_HOME", + "HACK_NO_INTERACTIVE", + "HACK_LOGGER", +] as const; let tempDir: string | null = null; let savedEnv: SavedEnv = {}; +let discoveryMarkerRoot: string | null = null; +let markerDiscoveryCall = 0; +let discoveryCalls = 0; + +const { discoverRepo: realDiscoverRepo } = await import( + "../src/init/discovery.ts" +); +const discoveryMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/init/discovery.ts", + overrides: { + discoverRepo: async (repoRoot: string) => { + const result = await realDiscoverRepo(repoRoot); + if (repoRoot === discoveryMarkerRoot) { + discoveryCalls += 1; + if (discoveryCalls === markerDiscoveryCall) { + await Bun.write(join(repoRoot, ".hack", "hack.project.json"), "{}\n"); + } + } + return result; + }, + }, +}); + +beforeAll(() => { + discoveryMock.activate(); +}); + +afterAll(() => { + discoveryMock.deactivate(); +}); beforeEach(async () => { + discoveryMarkerRoot = null; + discoveryCalls = 0; savedEnv = {}; for (const key of ENV_KEYS) { savedEnv[key] = process.env[key]; @@ -37,6 +83,7 @@ beforeEach(async () => { process.env.PATH = join(tempDir, "empty-path"); process.env.HACK_HOME = join(tempDir, "hack-home"); process.env.HACK_NO_INTERACTIVE = "1"; + process.env.HACK_LOGGER = "console"; }); afterEach(async () => { @@ -122,6 +169,164 @@ test("hack init --auto --with proceeds to handoff when .hack already exists", as ).toBe("existing.hack"); }); +for (const nativeKind of [ + "valid", + "invalid", + "directory", + "symlink", +] as const) { + for (const auto of [false, true]) { + test(`hack init ${auto ? "--auto --with" : "interactive"} refuses ${nativeKind} native markers before prompting or handoff`, async () => { + const repoRoot = await setupTempRepo(); + const nativeFile = join(repoRoot, ".hack", "hack.project.json"); + if (nativeKind === "directory") { + await mkdir(nativeFile, { recursive: true }); + } else if (nativeKind === "symlink") { + await mkdir(join(repoRoot, ".hack"), { recursive: true }); + await symlink(join(repoRoot, "absent-native.json"), nativeFile); + } else { + await Bun.write( + nativeFile, + nativeKind === "valid" ? "{}\n" : "{broken\n" + ); + } + + const result = await runCliWithCapturedOutput([ + "init", + ...(auto ? ["--auto", "--with", "codex"] : []), + "--path", + repoRoot, + ]); + + expect(result.exitCode).toBe(1); + const output = `${result.stdout}${result.stderr}`; + expect(output).toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(output).not.toContain("hack onboarding"); + expect(output).not.toContain("asks for project name"); + for (const file of [ + "hack.config.json", + "docker-compose.yml", + ".gitignore", + "README.md", + ]) { + expect(await Bun.file(join(repoRoot, ".hack", file)).exists()).toBe( + false + ); + } + expect( + await Bun.file( + join(process.env.HACK_HOME ?? "", "projects.json") + ).exists() + ).toBe(false); + if (nativeKind === "valid" || nativeKind === "invalid") { + expect(await Bun.file(nativeFile).text()).toBe( + nativeKind === "valid" ? "{}\n" : "{broken\n" + ); + } + }); + } +} + +for (const legacyDir of [".hack", ".dev"] as const) { + test(`hack init --auto --with refuses native plus ${legacyDir} inputs without changes`, async () => { + const repoRoot = await setupTempRepo(); + const nativeFile = join(repoRoot, ".hack", "hack.project.json"); + const composeFile = join(repoRoot, legacyDir, "docker-compose.yml"); + const configFile = join(repoRoot, legacyDir, "hack.config.json"); + const compose = "name: existing\nservices:\n app: {}\n"; + const config = '{"name":"existing","dev_host":"existing.hack"}\n'; + await Bun.write(nativeFile, "{broken\n"); + await Bun.write(composeFile, compose); + await Bun.write(configFile, config); + + const result = await runCliWithCapturedOutput([ + "init", + "--auto", + "--with", + "claude", + "--path", + repoRoot, + ]); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain( + "E_NATIVE_PROJECT_CONFLICT" + ); + expect(result.stdout).not.toContain("hack onboarding"); + expect(await Bun.file(nativeFile).text()).toBe("{broken\n"); + expect(await Bun.file(composeFile).text()).toBe(compose); + expect(await Bun.file(configFile).text()).toBe(config); + expect(await Bun.file(join(repoRoot, ".hack", ".gitignore")).exists()).toBe( + false + ); + expect( + await Bun.file( + join(process.env.HACK_HOME ?? "", "projects.json") + ).exists() + ).toBe(false); + }); +} + +test("hack init from a nested native project refuses before choosing an ancestor package root", async () => { + const outerRoot = await setupTempRepo(); + const nativeRoot = join(outerRoot, "native"); + const startDir = join(nativeRoot, "src", "nested"); + await mkdir(startDir, { recursive: true }); + await Bun.write(join(nativeRoot, ".hack", "hack.project.json"), "{}\n"); + + const result = await runCliWithCapturedOutput([ + "init", + "--auto", + "--with", + "codex", + "--path", + startDir, + ]); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain( + "E_NATIVE_PROJECT_UNSUPPORTED" + ); + expect(result.stdout).not.toContain("hack onboarding"); + expect( + await Bun.file(join(outerRoot, ".hack", "hack.config.json")).exists() + ).toBe(false); +}); + +for (const discoveryCall of [1, 2]) { + test(`hack init rechecks native inputs after discovery pass ${discoveryCall} before any scaffold write`, async () => { + const repoRoot = await setupTempRepo(); + discoveryMarkerRoot = repoRoot; + markerDiscoveryCall = discoveryCall; + + const result = await runCliWithCapturedOutput([ + "init", + "--auto", + "--with", + "codex", + "--path", + repoRoot, + ]); + + expect(result.exitCode).toBe(1); + expect(discoveryCalls).toBe(discoveryCall); + expect(`${result.stdout}${result.stderr}`).toContain( + "E_NATIVE_PROJECT_UNSUPPORTED" + ); + expect(result.stdout).not.toContain("hack onboarding"); + for (const file of [ + "hack.config.json", + "docker-compose.yml", + ".gitignore", + "README.md", + ]) { + expect(await Bun.file(join(repoRoot, ".hack", file)).exists()).toBe( + false + ); + } + }); +} + test("new default routes and README retain the canonical OAuth alias", async () => { const repoRoot = await setupTempRepo(); const result = await runCliWithCapturedOutput([ diff --git a/tests/project-domain-migration.test.ts b/tests/project-domain-migration.test.ts index 57b6db596..f264b2c2c 100644 --- a/tests/project-domain-migration.test.ts +++ b/tests/project-domain-migration.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; import { chmod, lstat, @@ -23,10 +24,12 @@ const config = '{"dev_host":"demo.hack", "custom":"preserve"}\n'; const compose = "# preserved\nservices:\n web:\n labels:\n caddy: demo.hack\n"; async function fixture() { - const root = await realpath( + const tempRoot = await realpath( await mkdtemp(join(tmpdir(), "domain-migration-")) ); - roots.push(root); + roots.push(tempRoot); + const root = join(tempRoot, ".hack"); + await mkdir(root); await writeFile(join(root, "hack.config.json"), config, { mode: 0o640 }); await writeFile(join(root, "docker-compose.yml"), compose, { mode: 0o600 }); // Mode restoration needs exact starting permissions, independent of the caller's umask. @@ -283,3 +286,101 @@ test("rollback refuses altered ignore rule and foreign staging entries", async ( plan.configText ); }); + +test.each([ + false, + true, +])("domain migration refuses native input before effects (legacy conflict: %s)", async (legacy) => { + const projectDir = await fixture(); + const plan = await preview({ projectDir }); + const native = join(projectDir, "hack.project.json"); + await writeFile(native, '{"schema_version":1,"name":"native"}\n'); + if (!legacy) { + await rm(join(projectDir, "hack.config.json")); + await rm(join(projectDir, "docker-compose.yml")); + } + const code = legacy + ? "E_NATIVE_PROJECT_CONFLICT" + : "E_NATIVE_PROJECT_UNSUPPORTED"; + for (const operation of [ + () => preview({ projectDir }), + () => apply({ projectDir, plan }), + () => rollback({ projectDir }), + ]) { + await expect(operation()).rejects.toThrow(code); + } + expect(await readFile(native, "utf8")).toBe( + '{"schema_version":1,"name":"native"}\n' + ); + expect( + await lstat(join(projectDir, ".internal")).catch(() => null) + ).toBeNull(); + if (legacy) { + expect(await readFile(join(projectDir, "hack.config.json"), "utf8")).toBe( + config + ); + expect(await readFile(join(projectDir, "docker-compose.yml"), "utf8")).toBe( + compose + ); + } else { + expect( + await lstat(join(projectDir, "hack.config.json")).catch(() => null) + ).toBeNull(); + expect( + await lstat(join(projectDir, "docker-compose.yml")).catch(() => null) + ).toBeNull(); + } +}); + +test("domain rollback preserves migrated bytes and journal when native input appears", async () => { + const projectDir = await fixture(); + const plan = await preview({ projectDir }); + await apply({ projectDir, plan }); + const journal = join(projectDir, ".internal/domain-migration/record.json"); + const saved = await readFile(journal); + await writeFile( + join(projectDir, "hack.project.json"), + "{invalid native marker\n" + ); + await expect(rollback({ projectDir })).rejects.toThrow( + "E_NATIVE_PROJECT_CONFLICT" + ); + expect(await readFile(join(projectDir, "hack.config.json"), "utf8")).toBe( + plan.configText + ); + expect(await readFile(join(projectDir, "docker-compose.yml"), "utf8")).toBe( + plan.composeText + ); + expect(await readFile(journal)).toEqual(saved); + expect( + await lstat(join(projectDir, ".internal/domain-migration.lock")).catch( + () => null + ) + ).toBeNull(); +}); + +test("domain apply rechecks native selection after plan preparation before publishing its journal", async () => { + const projectDir = await fixture(); + const plan = await preview({ projectDir }); + const changedPlan = { + ...plan, + get configText() { + writeFileSync(join(projectDir, "hack.project.json"), "{}\n"); + return plan.configText; + }, + }; + await expect(apply({ projectDir, plan: changedPlan })).rejects.toThrow( + "E_NATIVE_PROJECT_CONFLICT" + ); + expect(await readFile(join(projectDir, "hack.config.json"), "utf8")).toBe( + config + ); + expect(await readFile(join(projectDir, "docker-compose.yml"), "utf8")).toBe( + compose + ); + expect( + await lstat(join(projectDir, ".internal/domain-migration")).catch( + () => null + ) + ).toBeNull(); +}); diff --git a/tests/project-env-config.test.ts b/tests/project-env-config.test.ts index 1ee539fa7..0ed20d6df 100644 --- a/tests/project-env-config.test.ts +++ b/tests/project-env-config.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; import { mkdir, mkdtemp, @@ -22,6 +23,7 @@ import { upsertDotEnvValue } from "../src/lib/hack-env.ts"; import { readProjectDefaultEnvConfig } from "../src/lib/project.ts"; import { assertValidProjectEnvScopeName, + ensureHackDirGitignore, ensureProjectEnvSecretKey, inspectLegacyComposeEnvFileReferences, inspectProjectEnvMaterialization, @@ -29,6 +31,7 @@ import { materializeProjectEnv, migrateLegacyProjectEnv, parseProjectEnvTarget, + removeLegacyProjectEnvArtifacts, repairLegacyComposeEnvFileReferences, resolveProjectEnvConfig, resolveProjectEnvLocalConfigPath, @@ -1443,3 +1446,105 @@ async function initializeGitRepo(opts: { await runGit(["add", "."], opts.projectRoot); await runGit(["commit", "-m", "test: seed env fixture"], opts.projectRoot); } + +test.each([ + false, + true, +])("legacy env mutation owners refuse native input without changing bytes (legacy conflict: %s)", async (legacy) => { + const repo = await createRepo(); + const nativePath = join(repo.projectDir, "hack.project.json"); + const envPath = join(repo.projectDir, ".env"); + const contractPath = join(repo.projectDir, PROJECT_ENV_CONTRACT_FILENAME); + await writeFile(nativePath, "{malformed native marker\n"); + await writeFile(envPath, "PUBLIC_FIXTURE=preserve\n"); + await writeFile( + contractPath, + JSON.stringify({ + version: 1, + vars: [{ key: "PUBLIC_FIXTURE", source: "plain_env" }], + }) + ); + if (!legacy) { + await unlink(repo.composeFile); + await unlink(repo.configFile); + } + const existingPaths = [ + nativePath, + envPath, + contractPath, + ...(legacy ? [repo.composeFile, repo.configFile] : []), + ]; + const before = await Promise.all( + existingPaths.map(async (path) => ({ path, bytes: await readFile(path) })) + ); + const mutation = { + projectRoot: repo.projectRoot, + projectDir: repo.projectDir, + envName: null, + scope: "global", + key: "PUBLIC_FIXTURE", + }; + const operations = [ + () => + migrateLegacyProjectEnv({ + ...repo, + projectName: "fixture", + serviceNames: ["api"], + materialize: true, + }), + () => + repairLegacyComposeEnvFileReferences({ + composeFile: repo.composeFile, + projectDir: repo.projectDir, + }), + () => + setProjectEnvValue({ + ...mutation, + value: "changed", + secret: true, + local: true, + }), + () => unsetProjectEnvValue(mutation), + () => materializeProjectEnv({ ...repo, serviceNames: ["api"] }), + () => ensureProjectEnvSecretKey({ projectRoot: repo.projectRoot }), + () => ensureHackDirGitignore({ projectDir: repo.projectDir }), + () => + removeLegacyProjectEnvArtifacts({ + projectRoot: repo.projectRoot, + paths: [envPath, contractPath], + }), + ]; + for (const operation of operations) { + await expect(operation()).rejects.toThrow( + legacy ? "E_NATIVE_PROJECT_CONFLICT" : "E_NATIVE_PROJECT_UNSUPPORTED" + ); + for (const file of before) { + expect(await readFile(file.path)).toEqual(file.bytes); + } + } + for (const path of [ + join(repo.projectRoot, PROJECT_ENV_KEY_FILENAME), + join(repo.projectRoot, ".gitignore"), + join(repo.projectDir, ".gitignore"), + join(repo.projectDir, "hack.env.default.yaml"), + join(repo.projectDir, PROJECT_ENV_STATE_FILENAME), + ]) { + expect(await Bun.file(path).exists()).toBe(false); + } +}); + +test("Compose env repair rechecks native selection after reading the repair input", async () => { + const repo = await createRepo(); + const contents = "services:\n api:\n env_file: .env\n"; + await writeFile(repo.composeFile, contents); + await expect( + repairLegacyComposeEnvFileReferences({ + projectDir: repo.projectDir, + get composeFile() { + writeFileSync(join(repo.projectDir, "hack.project.json"), "{}\n"); + return repo.composeFile; + }, + }) + ).rejects.toThrow("E_NATIVE_PROJECT_CONFLICT"); + expect(await readFile(repo.composeFile, "utf8")).toBe(contents); +}); diff --git a/tests/project-input-selection.test.ts b/tests/project-input-selection.test.ts new file mode 100644 index 000000000..1108f20be --- /dev/null +++ b/tests/project-input-selection.test.ts @@ -0,0 +1,276 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + updateProjectConfig, + updateProjectConfigBatch, +} from "../src/lib/config.ts"; +import { + findProjectContext, + findProjectContextAtRoot, + findRepoRootForInit, + readProjectConfig, +} from "../src/lib/project.ts"; +import { + discoverProjectInputs, + inspectProjectInputsAtRoot, + NATIVE_PROJECT_FILENAME, +} from "../src/lib/project-input-selection.ts"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "hack-input-selection-")); + roots.push(root); + return root; +} + +async function file( + root: string, + relative: string, + text = "services: {}\n" +): Promise { + const path = join(root, relative); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, text); + return path; +} + +async function native( + root: string, + text = '{"schema_version":1,"name":"test"}' +): Promise { + return await file(root, `.hack/${NATIVE_PROJECT_FILENAME}`, text); +} + +test("native nested discovery refuses ancestor Compose without registration or writes", async () => { + const outer = await fixture(); + await file(outer, ".hack/docker-compose.yml"); + const inner = join(outer, "native"); + const marker = await native(inner); + const nested = join(inner, "src/deep"); + await mkdir(nested, { recursive: true }); + expect((await discoverProjectInputs({ startDir: nested }))?.projectRoot).toBe( + inner + ); + await expect(findProjectContext(nested)).rejects.toThrow( + "E_NATIVE_PROJECT_UNSUPPORTED" + ); + expect(await readFile(marker, "utf8")).toBe( + '{"schema_version":1,"name":"test"}' + ); + expect(await Bun.file(join(inner, ".hack/hack.config.json")).exists()).toBe( + false + ); +}); + +for (const directory of [".hack", ".dev"]) { + for (const filename of [ + "docker-compose.yml", + "hack.config.json", + "hack.toml", + ]) { + test(`native plus active ${directory}/${filename} conflicts before parsing`, async () => { + const root = await fixture(); + await native(root); + await file(root, `${directory}/${filename}`, "not parseable input"); + expect( + (await inspectProjectInputsAtRoot({ projectRoot: root })).kind + ).toBe("conflict"); + await expect(findProjectContext(root)).rejects.toThrow( + "E_NATIVE_PROJECT_CONFLICT" + ); + }); + } +} + +test("generated backend files, backups and root Compose are not authored legacy inputs", async () => { + const root = await fixture(); + await native(root); + for (const name of [ + "docker-compose.yml", + ".hack/.internal/docker-compose.yml", + ".hack/.branch/dev/docker-compose.yml", + ".hack/hack.config.json.backup", + ".hack/docker-compose.yml.old", + ]) { + await file(root, name); + } + expect((await inspectProjectInputsAtRoot({ projectRoot: root })).kind).toBe( + "native" + ); +}); + +for (const text of ["{broken", '{"schema_version":999}', "null"]) { + test(`native presence stops discovery independently of parsed contents ${text}`, async () => { + const outer = await fixture(); + await file(outer, ".dev/docker-compose.yml"); + const inner = join(outer, "child"); + await native(inner, text); + await expect(findProjectContext(inner)).rejects.toThrow( + "E_NATIVE_PROJECT_UNSUPPORTED" + ); + }); +} + +test("directory and dangling-link native markers never disappear into absence", async () => { + const outer = await fixture(); + await file(outer, ".hack/docker-compose.yml"); + const directoryRoot = join(outer, "directory"); + await mkdir(join(directoryRoot, ".hack", NATIVE_PROJECT_FILENAME), { + recursive: true, + }); + await expect(findProjectContext(directoryRoot)).rejects.toThrow( + "E_NATIVE_PROJECT_UNSUPPORTED" + ); + const linkRoot = join(outer, "link"); + await mkdir(join(linkRoot, ".hack"), { recursive: true }); + await symlink( + join(outer, "missing"), + join(linkRoot, ".hack", NATIVE_PROJECT_FILENAME) + ); + await expect(findProjectContext(linkRoot)).rejects.toThrow( + "E_NATIVE_PROJECT_UNSUPPORTED" + ); +}); + +test("unreadable native input remains a boundary without reading its values", async () => { + const root = await fixture(); + const marker = await native(root); + await chmod(marker, 0); + try { + await expect(findProjectContext(root)).rejects.toThrow( + "E_NATIVE_PROJECT_UNSUPPORTED" + ); + } finally { + await chmod(marker, 0o600); + } +}); + +test("invalid project directory access refuses rather than selecting ancestor Compose", async () => { + const outer = await fixture(); + await file(outer, ".hack/docker-compose.yml"); + const inner = join(outer, "child"); + await file(inner, ".hack", "not a directory"); + await expect(findProjectContext(inner)).rejects.toThrow( + "Cannot inspect Hack project inputs" + ); +}); + +test("supported legacy discovery keeps primary-before-legacy precedence", async () => { + const outer = await fixture(); + await file(outer, ".hack/docker-compose.yml"); + const child = join(outer, "child"); + await file(child, ".dev/docker-compose.yml"); + expect((await findProjectContext(child))?.projectRoot).toBe(outer); +}); + +test("dangling legacy Compose is not a discovery candidate but still conflicts with native", async () => { + const outer = await fixture(); + await file(outer, ".hack/docker-compose.yml"); + const child = join(outer, "child"); + await mkdir(join(child, ".hack"), { recursive: true }); + await symlink( + join(child, "missing"), + join(child, ".hack/docker-compose.yml") + ); + expect((await findProjectContext(child))?.projectRoot).toBe(outer); + await native(child); + await expect(findProjectContext(child)).rejects.toThrow( + "E_NATIVE_PROJECT_CONFLICT" + ); +}); + +test("a nested legacy project can run below a native ancestor without crossing it", async () => { + const outer = await fixture(); + await native(outer); + const child = join(outer, "child"); + await file(child, ".dev/docker-compose.yml"); + expect((await findProjectContext(child))?.projectRoot).toBe(child); +}); + +test("exact-root lookup never adopts an ancestor project", async () => { + const outer = await fixture(); + await file(outer, ".hack/docker-compose.yml"); + const child = join(outer, "child"); + await mkdir(child); + expect(await findProjectContextAtRoot({ projectRoot: child })).toBeNull(); + await native(child); + await expect( + findProjectContextAtRoot({ projectRoot: child }) + ).rejects.toThrow("E_NATIVE_PROJECT_UNSUPPORTED"); +}); + +test("init cannot cross an intervening native root to an ancestor package", async () => { + const outer = await fixture(); + await file(outer, "package.json", "{}"); + const inner = join(outer, "native"); + await native(inner); + const child = join(inner, "child"); + await file(child, ".dev/docker-compose.yml"); + await expect(findRepoRootForInit(child)).rejects.toThrow( + "E_NATIVE_PROJECT_UNSUPPORTED" + ); + await file(child, ".git", "gitdir: unused"); + expect(await findRepoRootForInit(child)).toBe(child); + await file(child, "package.json", "{}"); + expect(await findRepoRootForInit(child)).toBe(child); +}); + +test("constructed-context read and both project update helpers refuse without changing bytes", async () => { + const root = await fixture(); + await native(root); + const configFile = await file( + root, + ".hack/hack.config.json", + '{"name":"keep"}' + ); + const projectDir = join(root, ".hack"); + const context = { + projectRoot: root, + projectDirName: ".hack" as const, + projectDir, + configFile, + composeFile: join(projectDir, "docker-compose.yml"), + envFile: join(projectDir, ".env"), + }; + await expect(readProjectConfig(context)).rejects.toThrow( + "E_NATIVE_PROJECT_CONFLICT" + ); + await expect( + updateProjectConfig({ projectDir, path: "name", value: "overwrite" }) + ).rejects.toThrow("E_NATIVE_PROJECT_CONFLICT"); + await expect( + updateProjectConfigBatch({ + projectDir, + values: [{ path: "name", value: "overwrite" }], + }) + ).rejects.toThrow("E_NATIVE_PROJECT_CONFLICT"); + expect(await readFile(configFile, "utf8")).toBe('{"name":"keep"}'); +}); + +test("native-only project update cannot create a competing legacy definition", async () => { + const root = await fixture(); + await native(root); + const projectDir = join(root, ".hack"); + await expect( + updateProjectConfig({ projectDir, path: "name", value: "legacy" }) + ).rejects.toThrow("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(await Bun.file(join(projectDir, "hack.config.json")).exists()).toBe( + false + ); +}); diff --git a/tests/project-meta.test.ts b/tests/project-meta.test.ts index 62760c86b..69444c5a2 100644 --- a/tests/project-meta.test.ts +++ b/tests/project-meta.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -19,6 +19,27 @@ afterEach(async () => { tempDirs.clear(); }); +test("direct project metadata refuses mixed native inputs before legacy parsing", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "hack-project-meta-native-")); + tempDirs.add(repoRoot); + const projectDir = join(repoRoot, ".hack"); + await mkdir(projectDir); + const composeFile = join(projectDir, PROJECT_COMPOSE_FILENAME); + await writeFile(composeFile, "invalid legacy compose:"); + const marker = join(projectDir, "hack.project.json"); + await writeFile(marker, "{invalid-native"); + await expect( + resolveProjectMeta({ + projectName: "native", + repoRoot, + projectDir, + composeFile, + }) + ).rejects.toThrow("E_NATIVE_PROJECT_CONFLICT"); + expect(await readFile(marker, "utf8")).toBe("{invalid-native"); + expect(await readFile(composeFile, "utf8")).toBe("invalid legacy compose:"); +}); + test("resolveProjectMeta reads modern env config repos without a legacy contract", async () => { const repoRoot = await mkdtemp(join(tmpdir(), "hack-project-meta-")); tempDirs.add(repoRoot); diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index de54b8ce6..f67ea61e4 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -1,4 +1,11 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +15,7 @@ import { } from "../src/constants.ts"; import { buildProjectViews, + serializeProjectSummary, serializeProjectView, } from "../src/lib/project-views.ts"; @@ -18,11 +26,42 @@ import type { RuntimeService, } from "../src/lib/runtime-projects.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; let tempDir: string | null = null; let originalGlobalConfigPath: string | undefined; +let blockedReadRoot: string | null = null; +const blockedReadPaths: string[] = []; + +const projectReadMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/fs.ts", + overrides: { + readTextFile: async (file: string) => { + if (blockedReadRoot && file.startsWith(`${blockedReadRoot}/`)) { + blockedReadPaths.push(file); + throw new Error("Legacy input must not be read under a native marker"); + } + try { + return await Bun.file(file).text(); + } catch { + return null; + } + }, + }, +}); + +beforeAll(() => { + projectReadMock.activate(); +}); + +afterAll(() => { + projectReadMock.deactivate(); +}); beforeEach(async () => { + blockedReadRoot = null; + blockedReadPaths.length = 0; tempDir = await mkdtemp(join(tmpdir(), "hack-views-")); originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; process.env.HACK_GLOBAL_CONFIG_PATH = join(tempDir, "global.config.json"); @@ -223,6 +262,74 @@ test("buildProjectViews filters retired extensions from upgraded configs", async expect(views[0]?.features).toEqual(["dance.example.custom"]); }); +for (const legacyDir of [null, ".hack", ".dev"] as const) { + test(`native ${legacyDir ? `plus ${legacyDir}` : "only"} registered roots are unavailable without reading legacy files`, async () => { + const blocked = await createProject({ name: "blocked", services: [] }); + const healthy = await createProject({ name: "healthy", services: ["web"] }); + await rm(join(blocked.projectDir, PROJECT_COMPOSE_FILENAME)); + await mkdir(join(blocked.projectDir, "hack.project.json")); + if (legacyDir) { + const legacyProjectDir = join(blocked.repoRoot, legacyDir); + await mkdir(join(legacyProjectDir, PROJECT_COMPOSE_FILENAME), { + recursive: true, + }); + await mkdir(join(legacyProjectDir, PROJECT_CONFIG_FILENAME), { + recursive: true, + }); + } + const runtime = makeRuntimeProject({ + name: blocked.name, + workingDir: blocked.projectDir, + containersByService: { + web: [ + makeContainer({ + project: blocked.name, + service: "web", + name: "old-web", + state: "running", + }), + ], + }, + }); + blockedReadRoot = blocked.repoRoot; + + const views = await buildProjectViews({ + registryProjects: [blocked, healthy], + runtime: [runtime], + runtimeOk: true, + filter: null, + includeUnregistered: true, + muxSessions: [], + }); + + expect(views).toHaveLength(2); + expect(blockedReadPaths).toEqual([]); + const blockedView = views.find((view) => view.name === blocked.name); + expect(blockedView?.status).toBe("unavailable"); + expect(blockedView?.runtimeStatus).toBe("unavailable"); + expect(blockedView?.inputDiagnostic?.code).toBe( + legacyDir ? "E_NATIVE_PROJECT_CONFLICT" : "E_NATIVE_PROJECT_UNSUPPORTED" + ); + expect(blockedView?.definedServices).toBeNull(); + expect(blockedView?.extensionsEnabled).toBeNull(); + expect(blockedView?.lifecycle).toBeNull(); + expect(blockedView?.runtime).toBeNull(); + expect( + views.find((view) => view.name === healthy.name)?.definedServices + ).toEqual(["web"]); + if (!blockedView) { + throw new Error("Missing blocked project view"); + } + for (const serialized of [ + serializeProjectView(blockedView), + serializeProjectSummary(blockedView), + ]) { + expect(serialized.status).toBe("unavailable"); + expect(serialized.input_diagnostic).toEqual(blockedView.inputDiagnostic); + } + }); +} + test("buildProjectViews includes explicit project ownership metadata", async () => { const alpha = await createProject({ name: "alpha", diff --git a/tests/projects-registry-identity.test.ts b/tests/projects-registry-identity.test.ts index 21e12400d..e86e50ab3 100644 --- a/tests/projects-registry-identity.test.ts +++ b/tests/projects-registry-identity.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { readdir, readFile, rm, stat } from "node:fs/promises"; +import { + mkdir, + readdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; import type { ProjectContext } from "../src/lib/project.ts"; import { findDeadProjectRegistrations, @@ -40,6 +48,70 @@ async function expectUnchanged(before: string) { expect(await readdir(fixture.state)).toEqual(["projects.json"]); } +for (const mixed of [false, true]) { + test(`registered native input refuses name/ID lookup and updates without changing registration (mixed=${mixed})`, async () => { + const { project, entry } = await fixture.createProject("native", "native"); + await fixture.writeRegistry([entry]); + if (!mixed) { + await rm(project.composeFile); + await rm(project.configFile); + } + await writeFile( + join(project.projectDir, "hack.project.json"), + "{invalid-native" + ); + const before = await readFile(fixture.registryPath, "utf8"); + const code = mixed + ? "E_NATIVE_PROJECT_CONFLICT" + : "E_NATIVE_PROJECT_UNSUPPORTED"; + await expect( + resolveRegisteredProjectByName({ name: entry.name }) + ).rejects.toThrow(code); + await expect( + resolveRegisteredProjectById({ id: entry.id }) + ).rejects.toThrow(code); + await expect( + upsertProjectRegistration({ project, nowIso: NOW }) + ).rejects.toThrow(code); + await expect( + touchProjectRegistration({ project, nowIso: NOW }) + ).rejects.toThrow(code); + await expectUnchanged(before); + expect( + await readFile(join(project.projectDir, "hack.project.json"), "utf8") + ).toBe("{invalid-native"); + }); +} + +test("native refusal happens before creating a global registry directory", async () => { + const { project } = await fixture.createProject("native", "native"); + await writeFile(join(project.projectDir, "hack.project.json"), "{}"); + await rm(fixture.state, { recursive: true, force: true }); + await expect( + upsertProjectRegistration({ project, nowIso: NOW }) + ).rejects.toThrow("E_NATIVE_PROJECT_CONFLICT"); + await expect( + touchProjectRegistration({ project, nowIso: NOW }) + ).rejects.toThrow("E_NATIVE_PROJECT_CONFLICT"); + expect(await Bun.file(fixture.registryPath).exists()).toBe(false); + await expect(readdir(fixture.state)).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +test("registered lookup stays at its stored root instead of an ancestor Compose project", async () => { + const { project, entry } = await fixture.createProject("parent", "parent"); + const child = join(project.projectRoot, "child"); + await mkdir(child, { recursive: true }); + await fixture.writeRegistry([ + { ...entry, repoRoot: child, projectDir: join(child, ".hack") }, + ]); + const before = await readFile(fixture.registryPath, "utf8"); + expect(await resolveRegisteredProjectByName({ name: entry.name })).toBeNull(); + expect(await resolveRegisteredProjectById({ id: entry.id })).toBeNull(); + await expectUnchanged(before); +}); + function git(cwd: string, args: readonly string[]) { const result = Bun.spawnSync(["git", "-C", cwd, ...args], { env: fixture.env, diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 0a966352a..d1c84660d 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -217,6 +217,14 @@ test("getProjectsPayload keeps working when resolveProjectMeta fails for one pro projectDir: "/tmp/bad/.hack", createdAt, }, + { + id: "native", + name: "native", + repoRoot: "/tmp/native", + projectDirName: HACK_PROJECT_DIR_PRIMARY, + projectDir: "/tmp/native/.hack", + createdAt, + }, ]; const makeView = (name: string): ProjectView => ({ @@ -240,12 +248,23 @@ test("getProjectsPayload keeps working when resolveProjectMeta fails for one pro status: "unknown", }); + const metaCalls: string[] = []; + const blocked: ProjectView = { + ...makeView("native"), + status: "unavailable", + runtimeStatus: "unavailable", + inputDiagnostic: { + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: "Native project is not supported yet.", + }, + }; const cache = createRuntimeCache({ deps: { readProjectsRegistry: async () => ({ version: 1, projects }), - buildProjectViews: async () => [makeView("ok"), makeView("bad")], + buildProjectViews: async () => [makeView("ok"), makeView("bad"), blocked], serializeProjectView: (view) => ({ name: view.name, kind: view.kind }), resolveProjectMeta: async (opts) => { + metaCalls.push(opts.projectName); if (opts.projectName === "bad") { throw new Error("boom"); } @@ -287,7 +306,7 @@ test("getProjectsPayload keeps working when resolveProjectMeta fails for one pro includeMeta: true, }); - expect(payload.projects.length).toBe(2); + expect(payload.projects.length).toBe(3); expect(payload.projects[0]).toMatchObject({ name: "ok", meta: { @@ -317,6 +336,8 @@ test("getProjectsPayload keeps working when resolveProjectMeta fails for one pro name: "bad", meta: null, }); + expect(payload.projects[2]).toMatchObject({ name: "native", meta: null }); + expect(metaCalls).toEqual(["ok", "bad"]); }); test("runtime cache retains last runtime on failure", async () => { diff --git a/tests/runtime-projects.test.ts b/tests/runtime-projects.test.ts index 2fe046f9b..06b69b7c2 100644 --- a/tests/runtime-projects.test.ts +++ b/tests/runtime-projects.test.ts @@ -1,4 +1,17 @@ -import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readProjectsRegistry } from "../src/lib/projects-registry.ts"; +import type { RuntimeProject } from "../src/lib/runtime-projects.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; @@ -6,6 +19,30 @@ let dockerAvailable = false; let currentIds: string[] = []; let inspectExitCode = 0; const inspectCalls: string[][] = []; +let tempDir: string | null = null; +let originalHackHome: string | undefined; +let markerBeforeRegistrationFile: string | null = null; + +const projectFileMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/fs.ts", + overrides: { + pathExists: async (file: string) => { + let exists = false; + try { + await stat(file); + exists = true; + } catch { + exists = false; + } + if (file === markerBeforeRegistrationFile) { + await Bun.write(join(file, "..", "hack.project.json"), "{}\n"); + markerBeforeRegistrationFile = null; + } + return exists; + }, + }, +}); const shellMock = await registerScopedModuleMock({ importerPath: import.meta.path, @@ -44,6 +81,7 @@ const shellMock = await registerScopedModuleMock({ }); const { + autoRegisterRuntimeHackProjects, createRuntimeInspectCache, getRuntimeInspectCacheDiagnostics, readRuntimeProjects, @@ -51,17 +89,33 @@ const { beforeAll(() => { shellMock.activate(); + projectFileMock.activate(); }); -beforeEach(() => { +beforeEach(async () => { + originalHackHome = process.env.HACK_HOME; + tempDir = await realpath( + await mkdtemp(join(tmpdir(), "hack-runtime-projects-")) + ); + process.env.HACK_HOME = join(tempDir, "hack-home"); + markerBeforeRegistrationFile = null; dockerAvailable = false; currentIds = []; inspectExitCode = 0; inspectCalls.length = 0; }); +afterEach(async () => { + restoreEnv("HACK_HOME", originalHackHome); + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } +}); + afterAll(() => { shellMock.deactivate(); + projectFileMock.deactivate(); }); test("readRuntimeProjects reports docker absence instead of throwing", async () => { @@ -130,6 +184,73 @@ test("runtime inspection keeps valid stdout when another container disappears", expect(app?.containers[0]?.networks[0]?.name).toBe("hack-dev"); }); +test("runtime auto-registration skips native/mixed roots and still registers legacy peers", async () => { + if (!tempDir) { + throw new Error("Missing temp directory"); + } + const roots = ["native", "mixed-primary", "mixed-legacy", "legacy"]; + const runtime: RuntimeProject[] = []; + for (const name of roots) { + const projectRoot = join(tempDir, name); + const projectDirName = name === "mixed-legacy" ? ".dev" : ".hack"; + const projectDir = join(projectRoot, projectDirName); + await mkdir(projectDir, { recursive: true }); + if (name !== "native") { + await Bun.write( + join(projectDir, "docker-compose.yml"), + `name: ${name}\nservices:\n web: {}\n` + ); + await Bun.write( + join(projectDir, "hack.config.json"), + JSON.stringify({ name }) + ); + } + if (name !== "legacy") { + await mkdir(join(projectRoot, ".hack", "hack.project.json"), { + recursive: true, + }); + } + runtime.push({ + project: name, + workingDir: projectDir, + services: new Map(), + isGlobal: false, + }); + } + + await autoRegisterRuntimeHackProjects({ runtime }); + + const registry = await readProjectsRegistry(); + expect(registry.projects.map((project) => project.name)).toEqual(["legacy"]); + expect(registry.projects[0]?.repoRoot).toBe(join(tempDir, "legacy")); +}); + +test("runtime auto-registration skips an input family that changes before registration", async () => { + if (!tempDir) { + throw new Error("Missing temp directory"); + } + const projectDir = join(tempDir, "changed", ".hack"); + const composeFile = join(projectDir, "docker-compose.yml"); + await Bun.write(composeFile, "name: changed\nservices:\n web: {}\n"); + markerBeforeRegistrationFile = composeFile; + + await autoRegisterRuntimeHackProjects({ + runtime: [ + { + project: "changed", + workingDir: projectDir, + services: new Map(), + isGlobal: false, + }, + ], + }); + + expect(await Bun.file(join(projectDir, "hack.project.json")).exists()).toBe( + true + ); + expect((await readProjectsRegistry()).projects).toEqual([]); +}); + function makePsRow(opts: { readonly id: string }): Record { return { ID: opts.id,