// `gjsify install [pkg...]` — install packages with gjsify-aware post-checks.
//
// Modes:
//   gjsify install                    → project install (native, reads pkg.json)
//   gjsify install <pkg> [<pkg>...]   → add package(s) to project (native)
//   gjsify install -g <pkg> [...]     → user-global install (XDG, GJS-runnable bin)
//
// All three modes route through `@gjsify/{semver,npm-registry,tar}` via
// `installPackagesNative` — no Node/npm required at runtime. Pass
// `--backend=npm` (or set the legacy `GJSIFY_INSTALL_BACKEND=npm` env var)
// to opt back into the `npm install` subprocess flow — useful as an
// escape hatch for projects that hit a missing native-backend feature
// (Yarn PnP repos, lifecycle scripts, npm's `overrides` quirks).
//
// Workspace install (`gjsify install` in a monorepo root with a
// `"workspaces"` field) hoists every workspace's externals into the root
// `node_modules/` and symlinks `workspace:*` / `workspace:^` / `workspace:~`
// refs to their target source. Open follow-ups (see STATUS.md "Open TODOs"):
// per-workspace dedup of conflicting transitive version ranges (first-match
// wins today).
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { discoverWorkspaces } from '@gjsify/workspace';
import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
import { detectNativePackages } from '../utils/detect-native-packages.js';
import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
import { binDirOnPath, defaultGlobalLayout, linkGlobalBins, specToPackageName } from '../utils/install-global.js';
import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
// Default 30min wall-clock budget for the full install. Big workspaces
// (212+ sub-packages × 600+ external deps in the gjsify monorepo itself)
// can legitimately take 10-20 min on a fresh CI install when the npm CDN
// is slow — a 5-min default would false-positive on those legitimate
// flows. Per-fetch timeout (30s, retried) catches the truly stuck case
// inside this budget. Set --timeout 0 to disable the wall-clock guard
// entirely.
const DEFAULT_INSTALL_TIMEOUT_MS = 1_800_000;
export const installCommand = {
    command: 'install [packages..]',
    description: 'Install npm dependencies in the current project (or globally with -g), then run gjsify-aware post-checks.',
    builder: (yargs) => yargs
        .positional('packages', {
        description: 'Optional package specs. With none, runs a full project install.',
        type: 'string',
        array: true,
    })
        .option('global', {
        description: 'Install into the user-global XDG location and symlink bins into ~/.local/bin.',
        type: 'boolean',
        alias: 'g',
        default: false,
    })
        .option('save-dev', { type: 'boolean', alias: 'D' })
        .option('save-peer', { type: 'boolean' })
        .option('save-optional', { type: 'boolean', alias: 'O' })
        .option('immutable', {
        description: 'CI mode: install strictly from gjsify-lock.json, fail if the lockfile is missing or stale. Equivalent to yarn --immutable / npm ci --frozen-lockfile.',
        type: 'boolean',
        default: false,
    })
        .option('verbose', {
        description: 'Verbose install logging.',
        type: 'boolean',
        default: false,
    })
        .option('quiet', {
        description: 'Silence the progress bar.',
        type: 'boolean',
        default: false,
    })
        .option('progress', {
        description: 'Show a TTY-aware progress bar for resolve / download / extract phases. Auto-enabled when stderr is a TTY (override with --no-progress). Implicitly off under --verbose (per-package log lines replace the bar) or --quiet.',
        type: 'boolean',
        default: true,
    })
        .option('backend', {
        description: 'Install backend. `native` (default) routes through `@gjsify/{semver,npm-registry,tar}` — no Node/npm at runtime. `npm` shells out to `npm install` as an escape hatch for cases the native backend does not yet model (Yarn PnP repos, lifecycle scripts). Overrides `GJSIFY_INSTALL_BACKEND` if both are set.',
        type: 'string',
        choices: ['native', 'npm'],
    })
        .option('timeout', {
        description: 'Overall install wall-clock timeout in ms (default 1800000 = 30 min). On timeout, all in-flight registry fetches are aborted and the install exits non-zero with a clear "install timed out — likely a registry slowdown" message. Per-request timeouts in @gjsify/npm-registry (default 30s) still apply within this budget. Set to 0 to disable the overall budget.',
        type: 'number',
        default: DEFAULT_INSTALL_TIMEOUT_MS,
    }),
    handler: async (args) => {
        // --immutable is incompatible with explicit `<pkg>` adds and with
        // `--global` (which has no lockfile concept). Matches yarn's
        // behavior: `yarn add --immutable` is a hard error.
        if (args.immutable) {
            if (args.packages && args.packages.length > 0) {
                console.error('gjsify install --immutable does not accept package arguments. ' +
                    'Remove the package names or drop --immutable.');
                process.exit(1);
            }
            if (args.global) {
                console.error('gjsify install --immutable is incompatible with --global.');
                process.exit(1);
            }
        }
        if (args.global) {
            if (!args.packages || args.packages.length === 0) {
                console.error('gjsify install --global requires at least one <pkg> argument.');
                process.exit(1);
            }
            for (const flag of ['save-dev', 'save-peer', 'save-optional']) {
                if (args[flag]) {
                    console.warn(`gjsify install --global ignores --${flag}: global installs do not modify a project package.json.`);
                }
            }
            await installGlobalAndLink(args.packages, { verbose: args.verbose });
            return;
        }
        // Backend selection (in precedence order):
        //   1. --backend flag (explicit user choice)
        //   2. GJSIFY_INSTALL_BACKEND env (back-compat shape from pre-flag era)
        //   3. native (default)
        const backend = args.backend ?? process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
        if (backend === 'npm') {
            await projectInstallViaNpm(args);
            await runPostInstallChecks();
            return;
        }
        // Overall wall-clock budget for the install (default 5 min). On
        // timeout we abort every in-flight registry fetch via this controller
        // so the process exits cleanly with an actionable message instead of
        // a silent hang. Per-request timeouts inside @gjsify/npm-registry
        // (default 30s, retried) still apply within this budget.
        const overallTimeoutMs = args.timeout > 0 ? args.timeout : 0;
        const overallController = overallTimeoutMs > 0 ? new AbortController() : null;
        const overallTimerId = overallController !== null
            ? setTimeout(() => overallController.abort(new Error('install-overall-timeout')), overallTimeoutMs)
            : null;
        try {
            await projectInstallNative(args, overallController?.signal);
            await runPostInstallChecks();
        }
        catch (err) {
            if (overallController !== null && overallController.signal.aborted && isAbortedFromOverallTimeout(err)) {
                const secs = Math.round(overallTimeoutMs / 100) / 10;
                console.error(`gjsify install: timed out after ${secs}s — likely a registry slowdown.\n` +
                    `Re-run, or override with --timeout <ms> (set --timeout 0 to disable the overall budget).`);
                process.exit(1);
            }
            throw err;
        }
        finally {
            if (overallTimerId !== null)
                clearTimeout(overallTimerId);
        }
    },
};
/**
 * Heuristic: was this error raised because the overall-install AbortSignal
 * fired? The signal's `reason` is the sentinel `Error('install-overall-timeout')`
 * we installed above; the abort surfaces either as that exact reason or as
 * any AbortError thrown by a downstream fetch / setTimeout-on-abort path.
 * We match permissively because intermediate layers (fetch, GJS Soup, our
 * own delay()) re-wrap the reason in their own AbortError instances.
 */
function isAbortedFromOverallTimeout(err) {
    if (!err || typeof err !== 'object')
        return false;
    const name = err.name;
    if (name === 'AbortError')
        return true;
    const message = err.message;
    if (typeof message === 'string' && message.includes('install-overall-timeout'))
        return true;
    // RegistryTimeoutError surfaces the per-request budget — distinct from
    // the overall budget but typically the symptom the overall timer reports.
    if (name === 'RegistryTimeoutError')
        return true;
    return false;
}
function isWorkspaceRoot(cwd) {
    const pkgPath = join(cwd, 'package.json');
    const pkg = readPackageJson(pkgPath);
    if (!pkg)
        return false;
    return pkg.workspaces !== undefined;
}
function depKindFromArgs(args) {
    if (args['save-dev'])
        return 'devDependencies';
    if (args['save-peer'])
        return 'peerDependencies';
    if (args['save-optional'])
        return 'optionalDependencies';
    return 'dependencies';
}
async function projectInstallNative(args, signal) {
    const cwd = process.cwd();
    const pkgPath = join(cwd, 'package.json');
    // gjsify install is a node_modules-linker installer (like `npm install`
    // or `yarn --nodeLinker node-modules`): it materialises a node_modules/
    // tree + gjsify-lock.json. It deliberately does NOT produce a Yarn PnP
    // install, and the two resolution strategies cannot coexist — a leftover
    // `.pnp.cjs` makes Node ignore the node_modules/ tree we write, leaving a
    // silently broken project.
    //
    // Native PnP *generation* (a byte-identical `.pnp.cjs` + `.yarn/cache/*.zip`
    // that yarn itself would accept) is intentionally out of scope: it would
    // mean replicating yarn's libzip-deterministic archives, SHA-512 cache
    // keys, cache-version tracking and virtual-locator synthesis, and would
    // only ever match a single yarn release. So we stop here with actionable
    // guidance instead of half-doing it.
    //
    // We do NOT point users at `GJSIFY_INSTALL_BACKEND=npm` from this error:
    // that backend chokes on `workspace:` specs (`EUNSUPPORTEDPROTOCOL`),
    // which is exactly what the workspace repos that reach this branch have.
    const pnpResidue = ['.pnp.cjs', '.pnp.loader.mjs'].filter((f) => existsSync(join(cwd, f)));
    if (pnpResidue.length > 0) {
        throw new Error(`gjsify install uses the node_modules linker and cannot run in a Yarn PnP ` +
            `project (found ${pnpResidue.join(', ')} in ${cwd}).\n\n` +
            `• If this project uses \`gjsify install\` to manage dependencies, these PnP ` +
            `files are stale residue from an earlier \`yarn install\` — remove them and re-run:\n` +
            `      rm -f .pnp.cjs .pnp.loader.mjs && rm -rf .yarn/cache .yarn/unplugged\n` +
            `      gjsify install\n\n` +
            `• If instead you want Yarn to manage dependencies, set \`nodeLinker: node-modules\` ` +
            `in .yarnrc.yml and run \`yarn install\` (not \`gjsify install\`).`);
    }
    // Workspace install (no args, root pkg.json has `workspaces`).
    // Project-local `gjsify install <pkg>` inside a workspace child still
    // goes through the single-package code path below (this branch only
    // fires for the root no-args case, which is the `yarn install`
    // equivalent).
    if ((!args.packages || args.packages.length === 0) && isWorkspaceRoot(cwd)) {
        await workspaceInstall(cwd, args, signal);
        return;
    }
    let specs;
    const pkg = readPackageJson(pkgPath);
    const existingSpecs = pkg ? projectSpecsFromPackageJson(pkg) : [];
    if (args.packages && args.packages.length > 0) {
        // Combine new specs with existing manifest deps so a single
        // `gjsify install <new>` doesn't churn the lockfile (would drop
        // every previously-pinned entry otherwise). New specs with the
        // same name as an existing dep override.
        const newNames = new Set(args.packages.map((s) => parseSpec(s).name));
        const carryover = existingSpecs.filter((s) => !newNames.has(parseSpec(s).name));
        specs = [...carryover, ...args.packages];
    }
    else {
        if (!pkg) {
            throw new Error(`gjsify install: no package.json in ${cwd}`);
        }
        specs = existingSpecs;
        if (specs.length === 0) {
            console.log('gjsify install: no dependencies declared in package.json — nothing to do.');
            return;
        }
    }
    mkdirSync(cwd, { recursive: true });
    // Progress bar is auto-enabled when stderr is a TTY (and `--verbose` /
    // `--quiet` / `--no-progress` aren't set). When piped to a log file the
    // reporter falls back to one line per phase begin/end.
    const progress = makeProgressReporter({
        enabled: !args.verbose && !args.quiet && args.progress !== false,
    });
    const result = await installPackages({
        prefix: cwd,
        specs,
        verbose: args.verbose,
        // --immutable consumes the lockfile verbatim and must NOT rewrite
        // it (the whole point is byte-stability under CI).
        lockfile: !args.immutable,
        frozen: args.immutable,
        signal,
        progress,
    });
    // Update package.json only when the user passed explicit packages
    // (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
    // flow doesn't mutate manifest entries.
    if (args.packages && args.packages.length > 0 && pkg) {
        const kind = depKindFromArgs(args);
        for (const spec of args.packages) {
            const { name, range } = parseSpec(spec);
            const installed = result.installed.find((r) => r.name === name);
            const finalRange = range ?? (installed ? defaultRangeFromVersion(installed.version) : 'latest');
            addDependencyEntry(pkg, name, finalRange, kind);
        }
        writePackageJson(pkgPath, pkg);
        // Re-sync the lockfile's `requested` field with what
        // `projectSpecsFromPackageJson()` will return on the next
        // invocation. Without this, a `gjsify install foo` (bare name,
        // lockfile records `"foo"`) followed by `gjsify install
        // --immutable` (reads package.json → spec `"foo@^1.2.3"`) would
        // surface a spurious drift error.
        if (!args.immutable) {
            syncLockfileRequested(cwd, projectSpecsFromPackageJson(pkg));
        }
    }
}
function syncLockfileRequested(cwd, specs) {
    const lockPath = join(cwd, 'gjsify-lock.json');
    if (!existsSync(lockPath))
        return;
    try {
        const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
        const sorted = [...specs].sort();
        const current = [...(lock.requested ?? [])].sort();
        if (sorted.length === current.length && sorted.every((s, i) => s === current[i])) {
            return; // Already in sync; preserve byte-stability.
        }
        lock.requested = specs;
        writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
    }
    catch {
        // Best-effort sync; if the lockfile is malformed, the next
        // non-immutable install will rewrite it from scratch.
    }
}
/**
 * Workspace-aware install. Mirrors what `yarn install` does at a monorepo root:
 *   1. Discover every workspace under the root.
 *   2. Aggregate the union of their external (non-`workspace:`) deps.
 *   3. Run the native install backend ONCE at the root prefix so all
 *      externals land in a single `node_modules/` (poor-man's hoisting —
 *      we don't deduplicate version-range conflicts yet, the BFS resolver
 *      picks first-match).
 *   4. For every `workspace:` reference, symlink the target workspace's
 *      directory into the requesting workspace's `node_modules/<dep>`
 *      so `import '@gjsify/utils'` resolves to the local source.
 *
 * Hoisting strategy is intentionally minimal — per-workspace dedup +
 * nested `node_modules/` for version conflicts are tracked as a follow-up
 * in STATUS.md "Open TODOs".
 */
async function workspaceInstall(cwd, args, signal) {
    const workspaces = discoverWorkspaces(cwd, { includeRoot: true });
    if (workspaces.length === 0) {
        throw new Error(`gjsify install: ${cwd} has a "workspaces" field but no workspaces were discovered`);
    }
    const byName = new Map(workspaces.map((w) => [w.name, w]));
    const externalSpecs = new Set();
    const symlinks = [];
    for (const ws of workspaces) {
        const m = ws.manifest;
        for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
            const block = m[kind];
            if (!block)
                continue;
            for (const [depName, spec] of Object.entries(block)) {
                if (typeof spec !== 'string')
                    continue;
                // A dependency whose NAME matches a local workspace is always
                // satisfied by that workspace — never by a same-named package on
                // npm. This holds regardless of the spec form: an explicit
                // `workspace:` protocol, a plain semver range (`^1.2.3`), or even
                // a dist-tag. Yarn/npm resolve a workspace-named dep to the local
                // package first; we MUST do the same. Routing such a dep into the
                // native fetch/extract queue is the data-loss bug this guards
                // against — `extractOne` would `rmSync` + drop the published
                // tarball over the workspace's OWN source tree (the published
                // tarball ships only `files`, so `src/**` gets wiped). Symlink
                // it instead, exactly like a `workspace:` ref.
                const localWorkspace = byName.get(depName);
                if (localWorkspace) {
                    // Only an EXPLICIT out-of-workspace protocol (link:/file:/
                    // portal:/git+/http(s):) opts out of the local workspace. Any
                    // other shape — `workspace:` or a plain semver range/dist-tag
                    // (`^1.2.3`, `*`, `latest`) — resolves to the local package.
                    const explicitOverride = /^(link|file|portal|git\+|https?):/.test(spec);
                    if (!explicitOverride) {
                        symlinks.push({
                            fromWorkspaceName: ws.name,
                            depName,
                            targetLocation: localWorkspace.location,
                        });
                        continue;
                    }
                    // explicit override → fall through to the existing handling.
                }
                if (spec.startsWith('workspace:')) {
                    // `workspace:` against a name that is NOT a discovered
                    // workspace is a hard error (typo / missing package).
                    throw new Error(`gjsify install: ${ws.name} declares "${depName}: ${spec}" but ` +
                        `no workspace with that name exists`);
                }
                if (/^(link|file|portal|git\+|https?):/.test(spec))
                    continue;
                externalSpecs.add(`${depName}@${spec}`);
            }
        }
    }
    console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
    // Read top-level package.json's `overrides` (npm-native) or `resolutions`
    // (yarn-native, kept as the existing field name in pre-Phase-D.8 repos).
    // Flat `name → range` entries become global overrides applied to every
    // workspace; nested `<workspace> → {dep → range}` entries become
    // workspace-local installs that place the overridden dep inside that
    // workspace's own `node_modules/`. Lets a monorepo pin one workspace to
    // an older `typescript` (e.g. a downstream integration test) without
    // forcing the rest of the tree to the same version.
    const rootManifest = workspaces.find((w) => w.location === cwd)?.manifest;
    const extracted = extractOverrides(rootManifest);
    const overrides = extracted?.global;
    // Second pass: pluck specs that have a workspace-scoped override out of
    // `externalSpecs` and re-collect them into a per-workspace map. Those
    // specs will be installed into the workspace's own `node_modules/` after
    // the root install completes, so the resolver in the root pass does NOT
    // see the conflicting versions.
    const wsLocalSpecs = new Map(); // wsLocation → name@range set
    const droppedFromExternal = new Set();
    if (extracted && extracted.scoped.size > 0) {
        for (const ws of workspaces) {
            const wsManifest = ws.manifest;
            for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
                const deps = wsManifest[kind];
                if (!deps)
                    continue;
                for (const [depName, spec] of Object.entries(deps)) {
                    const override = scopedOverrideFor(ws, depName, extracted);
                    if (!override)
                        continue;
                    // Re-route this dep to the workspace's own install
                    const targetRange = override;
                    const wsKey = ws.location;
                    let bucket = wsLocalSpecs.get(wsKey);
                    if (!bucket) {
                        bucket = new Set();
                        wsLocalSpecs.set(wsKey, bucket);
                    }
                    bucket.add(`${depName}@${targetRange}`);
                    // Drop the un-overridden version from the root spec set:
                    // the workspace will see its scoped version via parent-walk
                    // resolution. Note we only drop the EXACT `name@spec` the
                    // workspace declared; other workspaces' instances of the
                    // same name+spec stay in the root set.
                    droppedFromExternal.add(`${depName}@${spec}`);
                }
            }
        }
        // Apply the drops only when no OTHER workspace declared the same spec
        // — otherwise it has legitimate root requesters and must stay.
        const stillNeeded = new Set();
        for (const ws of workspaces) {
            for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
                const deps = ws.manifest[kind];
                if (!deps)
                    continue;
                for (const [depName, spec] of Object.entries(deps)) {
                    if (scopedOverrideFor(ws, depName, extracted))
                        continue;
                    stillNeeded.add(`${depName}@${spec}`);
                }
            }
        }
        for (const dropped of droppedFromExternal) {
            if (!stillNeeded.has(dropped))
                externalSpecs.delete(dropped);
        }
        if (wsLocalSpecs.size > 0) {
            console.log(`gjsify install: ${wsLocalSpecs.size} workspace(s) have scoped overrides — they will install their overridden deps locally after the root install`);
        }
    }
    if (externalSpecs.size > 0) {
        const progress = makeProgressReporter({
            enabled: !args.verbose && !args.quiet && args.progress !== false,
        });
        await installPackages({
            prefix: cwd,
            specs: [...externalSpecs],
            verbose: args.verbose,
            lockfile: !args.immutable,
            frozen: args.immutable,
            overrides,
            signal,
            progress,
        });
    }
    else if (args.verbose) {
        console.log('gjsify install: no external deps to fetch');
    }
    // Workspace-local installs for scoped overrides. Each runs as its own
    // `installPackages` call inside the workspace location — the resulting
    // `node_modules/<dep>` shadows the root-hoisted version via standard
    // Node parent-walk resolution.
    for (const [wsLocation, specSet] of wsLocalSpecs) {
        if (specSet.size === 0)
            continue;
        const wsName = workspaces.find((w) => w.location === wsLocation)?.name ?? wsLocation;
        if (args.verbose) {
            console.log(`gjsify install: ${wsName} — installing ${specSet.size} scoped-override spec(s) into ${wsLocation}/node_modules/`);
        }
        await installPackages({
            prefix: wsLocation,
            specs: [...specSet],
            verbose: args.verbose,
            // Per-workspace installs get a thin lockfile next to the workspace
            // package.json. Same `--immutable` semantics as the root install.
            lockfile: !args.immutable,
            frozen: args.immutable,
            signal,
        });
    }
    // Workspace symlink wiring — pre-dedup the parent-dir mkdirs (every
    // symlink for the same workspace shares a single `node_modules` parent),
    // then run the per-link rm + symlink steps with bounded concurrency.
    // Pure sync loops here used to dominate the tail of large installs
    // (~793 symlinks × ~10ms each for mkdir+rm+symlink = ~24s of serial
    // syscalls). With async + a 32-wide pool the same set lands in 1-2s.
    if (symlinks.length > 0) {
        const fsp = await import('node:fs/promises');
        const parentDirs = new Set();
        const plans = [];
        for (const link of symlinks) {
            const target = byName.get(link.fromWorkspaceName);
            if (!target)
                continue;
            const linkPath = join(target.location, 'node_modules', link.depName);
            parentDirs.add(dirname(linkPath));
            const relTarget = relative(dirname(linkPath), link.targetLocation);
            plans.push({ linkPath, relTarget });
        }
        // Phase 1: one mkdir per unique parent (max ~213 instead of ~793).
        await Promise.all([...parentDirs].map((dir) => fsp.mkdir(dir, { recursive: true })));
        // Phase 2: per-link rm + symlink, pooled. A semaphore-style cursor
        // keeps the concurrent in-flight count bounded so we don't blow up
        // the file-descriptor table on huge monorepos.
        const SYMLINK_CONCURRENCY = 32;
        let cursor = 0;
        const workers = [];
        const wireOne = async (linkPath, relTarget) => {
            // Remove any prior entry — regular dir, broken symlink, file, or
            // a normal symlink left over from a previous install.
            // `{ recursive: true, force: true }` handles every shape (rm
            // no-ops on missing paths under force; recursive covers dirs).
            try {
                await fsp.rm(linkPath, { recursive: true, force: true });
            }
            catch {
                /* unexpected; symlink call below will surface the real
                   cause if it persists. */
            }
            await fsp.symlink(relTarget, linkPath);
        };
        for (let i = 0; i < Math.min(SYMLINK_CONCURRENCY, plans.length); i++) {
            workers.push((async () => {
                while (true) {
                    const idx = cursor++;
                    if (idx >= plans.length)
                        return;
                    const p = plans[idx];
                    if (!p)
                        return;
                    await wireOne(p.linkPath, p.relTarget);
                }
            })());
        }
        await Promise.all(workers);
        console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
    }
    // Hoist EVERY workspace package to the repo root's `node_modules/` so
    // transitive workspace deps are reachable from any descendant via
    // standard Node parent-walk resolution. yarn's `nodeLinker: node-modules`
    // does the same thing — the entire workspace graph is materialised at
    // the root, which is how rolldown's resolver finds e.g.
    // `@gjsify/abort-controller/register` injected from a deeply-nested
    // package's `node_modules/.cache/gjsify/` cache file when the consumer
    // didn't declare a direct dep on it (auto-globals injection at build
    // time).
    //
    // Without this hoist, each workspace's `node_modules/` only contains
    // its direct declared deps, and any auto-injected register import for
    // a workspace package the consumer didn't list as a dep externalises
    // and the bundle fails at runtime with `Module not found`.
    const rootBinDir = join(cwd, 'node_modules');
    let rootHoisted = 0;
    for (const ws of workspaces) {
        // Skip the root workspace itself (its location IS cwd; it can't
        // symlink itself into its own node_modules).
        if (ws.location === cwd)
            continue;
        if (!ws.name)
            continue;
        const linkPath = join(rootBinDir, ws.name);
        // If a symlink already exists here (from the per-workspace loop
        // above when the root workspace declared this dep directly), it
        // already points at the right place — skip. We don't try to
        // remove + recreate because under GJS's Gio-backed fs polyfill,
        // `rmSync` on a symlink can race with `symlinkSync` and surface
        // EEXIST. A real directory at this path is also left alone —
        // someone else (npm, yarn) seeded it and we shouldn't clobber.
        let existsHere = false;
        try {
            lstatSync(linkPath);
            existsHere = true;
        }
        catch {
            /* ENOENT */
        }
        if (existsHere)
            continue;
        mkdirSync(dirname(linkPath), { recursive: true });
        const relTarget = relative(dirname(linkPath), ws.location);
        symlinkSync(relTarget, linkPath);
        rootHoisted++;
    }
    if (rootHoisted > 0) {
        console.log(`gjsify install: hoisted ${rootHoisted} workspace(s) to root node_modules/`);
    }
    // Link workspace bins into `node_modules/.bin/`. Without this,
    // `npm run <script>` (or any `node_modules/.bin`-PATH consumer)
    // cannot find the `gjsify` binary on a fresh checkout — yarn
    // creates these shims at install time; we need to match.
    //
    // Each workspace's `bin` entry maps `<binName>` → `<relative-target>`.
    // For GJS-runnable bins, `gjsify.bin` is preferred — its target is the
    // committed `dist/cli.gjs.mjs` bundle that exists on a fresh checkout,
    // versus the `bin` field which typically points at `lib/index.js`
    // (a build artifact that may not yet exist). The shim wraps the
    // target in a shell script that picks the right interpreter (`gjs -m`
    // for `.mjs` bundles, `node` for `.js` files).
    const wsBinDir = join(cwd, 'node_modules', '.bin');
    // Discover native prebuilds reachable from the workspace cwd so the
    // workspace-local `node_modules/.bin/gjsify` shim sets GI_TYPELIB_PATH /
    // LD_LIBRARY_PATH for them. Same rationale as the global launcher in
    // install-global.ts — the bin shim invokes the CLI bundle via `gjs -m`
    // directly, with no chance to set env after the fact, so without this
    // preamble `imports.gi.GjsifyTerminal` etc. fail and process.stdout
    // collapses to no-color, 80-col defaults.
    const nativePrebuildDirs = detectNativePackages(cwd).map((p) => p.prebuildsDir);
    let wsBinsCreated = 0;
    for (const ws of workspaces) {
        const m = ws.manifest;
        const gjsifyBin = m.gjsify?.bin;
        const nodeBin = m.bin;
        // For each bin name, collect both the Node-target and GJS-target
        // when they exist. The shim prefers Node at invocation time
        // because Node's child_process is more reliable than GJS's
        // Gio.Subprocess polyfill (parallel-spawn close-event delivery
        // races under heavy concurrency); GJS is the fallback for fresh
        // checkouts where the Node target hasn't been built yet.
        const merged = mergeWorkspaceBins(ws.name, gjsifyBin, nodeBin);
        if (merged.size === 0)
            continue;
        mkdirSync(wsBinDir, { recursive: true });
        for (const [binName, { nodeTarget, gjsTarget }] of merged) {
            const linkPath = join(wsBinDir, binName);
            try {
                rmSync(linkPath, { force: true });
            }
            catch {
                /* fine */
            }
            writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs), {
                mode: 0o755,
            });
            chmodSync(linkPath, 0o755);
            wsBinsCreated++;
        }
    }
    if (wsBinsCreated > 0) {
        console.log(`gjsify install: linked ${wsBinsCreated} workspace bin(s) into node_modules/.bin/`);
    }
}
function extractOverrides(rootManifest) {
    if (!rootManifest)
        return undefined;
    const global = {};
    const scoped = new Map();
    const merge = (source, fieldName) => {
        if (!source)
            return;
        for (const [key, value] of Object.entries(source)) {
            if (key.startsWith('_'))
                continue;
            if (typeof value === 'string') {
                // Flat `name → range` entry. Normalise pattern keys (`name@*`,
                // `name@^range`) → bare name. For scoped packages preserve the
                // leading `@`.
                let name = key;
                const atIdx = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@');
                if (atIdx > 0)
                    name = key.slice(0, atIdx);
                global[name] = value;
                continue;
            }
            if (value && typeof value === 'object' && !Array.isArray(value)) {
                // Scoped entry — `<workspace> → {dep → range}`. This is the
                // npm-overrides nested shape and yarn's resolutions
                // selectors collapsed to per-workspace level.
                const sub = {};
                for (const [depKey, depValue] of Object.entries(value)) {
                    if (depKey.startsWith('_'))
                        continue;
                    if (typeof depValue !== 'string') {
                        console.warn(`gjsify install: ${fieldName}["${key}"]["${depKey}"] is not a string — only one level of nesting is supported, skipping`);
                        continue;
                    }
                    let depName = depKey;
                    const atIdx = depKey.startsWith('@') ? depKey.indexOf('@', 1) : depKey.indexOf('@');
                    if (atIdx > 0)
                        depName = depKey.slice(0, atIdx);
                    sub[depName] = depValue;
                }
                if (Object.keys(sub).length > 0) {
                    const existing = scoped.get(key) ?? {};
                    scoped.set(key, { ...existing, ...sub });
                }
                continue;
            }
            console.warn(`gjsify install: ${fieldName}["${key}"] is not a string or object — skipping`);
        }
    };
    merge(rootManifest.overrides, 'overrides');
    merge(rootManifest.resolutions, 'resolutions');
    if (Object.keys(global).length === 0 && scoped.size === 0)
        return undefined;
    return { global, scoped };
}
/**
 * Look up the scoped override for a given workspace + dep. Matches by
 * workspace name OR relative location (both accepted for readability).
 */
function scopedOverrideFor(ws, depName, extracted) {
    if (!extracted || extracted.scoped.size === 0)
        return undefined;
    const candidates = [ws.name, ws.relativeLocation];
    for (const key of candidates) {
        const entry = extracted.scoped.get(key);
        if (entry?.[depName])
            return entry[depName];
    }
    return undefined;
}
function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []) {
    const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
    const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
    // GJS-only env preamble — Node ignores GI_TYPELIB_PATH so we scope the
    // export to the gjs branch, keeping the shim minimal when no native pkgs
    // exist or only the Node bin is in play.
    const gjsPreamble = nativePrebuildDirs.length === 0
        ? ''
        : (() => {
            const joined = `'${nativePrebuildDirs.join(':').replace(/'/g, `'\\''`)}'`;
            return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
                `LD_LIBRARY_PATH=${joined}\${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}\n` +
                `export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
        })();
    if (nodeAbs && gjsAbs) {
        return `#!/bin/sh\nif [ -f "${nodeAbs}" ]; then\n  exec node "${nodeAbs}" "$@"\nfi\n${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n`;
    }
    if (nodeAbs)
        return `#!/bin/sh\nexec node "${nodeAbs}" "$@"\n`;
    if (gjsAbs)
        return `#!/bin/sh\n${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n`;
    throw new Error('buildBinShim: either nodeTarget or gjsTarget must be provided');
}
/**
 * Walk a workspace's `bin` (Node) + `gjsify.bin` (GJS) declarations
 * into a unified `<binName> → {nodeTarget?, gjsTarget?}` map. The
 * shim built from this picks Node at runtime when its target exists,
 * GJS otherwise.
 */
function mergeWorkspaceBins(pkgName, gjsifyBin, nodeBin) {
    const out = new Map();
    const baseName = pkgName.startsWith('@') ? pkgName.slice(pkgName.indexOf('/') + 1) : pkgName;
    const get = (key) => {
        let entry = out.get(key);
        if (!entry) {
            entry = {};
            out.set(key, entry);
        }
        return entry;
    };
    if (typeof nodeBin === 'string') {
        get(baseName).nodeTarget = nodeBin;
    }
    else if (nodeBin && typeof nodeBin === 'object') {
        for (const [k, v] of Object.entries(nodeBin)) {
            if (typeof v === 'string' && v.length > 0)
                get(k).nodeTarget = v;
        }
    }
    if (typeof gjsifyBin === 'string') {
        get(baseName).gjsTarget = gjsifyBin;
    }
    else if (gjsifyBin && typeof gjsifyBin === 'object') {
        for (const [k, v] of Object.entries(gjsifyBin)) {
            if (typeof v === 'string' && v.length > 0)
                get(k).gjsTarget = v;
        }
    }
    return out;
}
async function projectInstallViaNpm(args) {
    const npmArgs = ['install'];
    if (args['save-dev'])
        npmArgs.push('--save-dev');
    if (args['save-peer'])
        npmArgs.push('--save-peer');
    if (args['save-optional'])
        npmArgs.push('--save-optional');
    if (args.verbose)
        npmArgs.push('--loglevel', 'verbose');
    if (args.packages && args.packages.length > 0) {
        npmArgs.push(...args.packages);
    }
    await spawnNpm(npmArgs);
}
async function spawnNpm(npmArgs) {
    return new Promise((resolve, reject) => {
        const child = spawn('npm', npmArgs, { stdio: 'inherit' });
        child.on('close', (code) => {
            if (code === 0)
                resolve();
            else
                reject(new Error(`npm install exited with code ${code}`));
        });
        child.on('error', (err) => {
            const code = err.code;
            const msg = code === 'ENOENT'
                ? 'npm not found on PATH — install Node.js first.'
                : `npm install failed: ${err.message}`;
            reject(new Error(msg));
        });
    }).catch((err) => {
        console.error(err.message);
        process.exit(1);
    });
}
async function installGlobalAndLink(specs, opts) {
    const layout = defaultGlobalLayout();
    mkdirSync(layout.prefix, { recursive: true });
    console.log(`gjsify install --global  → ${layout.prefix}`);
    console.log(`                  bins → ${layout.binDir}`);
    await installPackages({
        prefix: layout.prefix,
        specs,
        verbose: opts.verbose,
    });
    const packageNames = specs.map(specToPackageName);
    const created = linkGlobalBins(packageNames, layout);
    if (created.length === 0) {
        console.warn('\nNo bins declared (neither `gjsify.bin` nor `bin` in package.json) — nothing was symlinked.');
    }
    else {
        console.log(`\nLinked ${created.length} bin(s):`);
        for (const e of created) {
            console.log(`  • ${e.link}  →  ${e.target}`);
        }
    }
    if (created.length > 0 && !binDirOnPath(layout.binDir)) {
        console.warn(`\nNote: ${layout.binDir} is not on your PATH.\n` +
            `Add it to your shell rc file:\n  export PATH="${layout.binDir}:$PATH"`);
    }
}
async function runPostInstallChecks() {
    console.log('\n--- gjsify post-install checks ---');
    // 1. System deps that GJS apps typically need.
    const results = runMinimalChecks();
    const missing = results.filter((r) => !r.found && r.severity === 'required');
    if (missing.length > 0) {
        console.warn('Missing required system dependencies:\n');
        for (const dep of missing) {
            console.warn(`  ✗  ${dep.name}`);
        }
        const pm = detectPackageManager();
        const cmd = buildInstallCommand(pm, missing);
        if (cmd)
            console.warn(`\nInstall with:\n  ${cmd}`);
    }
    else {
        console.log('System dependencies OK.');
    }
    // 2. Surface @gjsify/* packages with native prebuilds — `gjsify run`
    //    will set LD_LIBRARY_PATH / GI_TYPELIB_PATH for these automatically.
    const native = detectNativePackages(process.cwd());
    if (native.length > 0) {
        console.log(`\nDetected ${native.length} @gjsify/* package(s) with native prebuilds:`);
        for (const pkg of native) {
            console.log(`  • ${pkg.name}`);
        }
        console.log('\nUse `gjsify run <bundle>` to launch with LD_LIBRARY_PATH/GI_TYPELIB_PATH set.');
    }
    // 3. Install workspace git hooks (only fires inside the gjsify monorepo
    //    itself, NOT in consumer projects that depend on @gjsify/cli — gated
    //    by the presence of `scripts/install-git-hooks.mjs` + a `.git`
    //    checkout). Idempotent; safe to re-run on every install.
    maybeInstallGitHooks();
}
/**
 * Wire `core.hooksPath = .githooks` when running `gjsify install` inside a
 * git checkout that ships `scripts/install-git-hooks.mjs` (i.e. the gjsify
 * monorepo). Consumer projects that don't ship the script are skipped
 * silently — they wouldn't have hooks to install.
 *
 * The script itself handles its own no-op cases (extracted tarball, already
 * configured, SKIP_GJSIFY_HOOKS=1).
 */
function maybeInstallGitHooks() {
    const cwd = process.cwd();
    const scriptPath = join(cwd, 'scripts', 'install-git-hooks.mjs');
    if (!existsSync(scriptPath))
        return;
    // Need a git checkout — the script also checks, but skipping here
    // avoids spawning a process when we know the answer.
    if (!existsSync(join(cwd, '.git')))
        return;
    try {
        const result = spawnSync(process.execPath, [scriptPath, '--quiet'], {
            cwd,
            stdio: 'inherit',
            env: process.env,
        });
        if (result.status !== 0) {
            console.warn(`[gjsify install] scripts/install-git-hooks.mjs exited ${result.status} — git hooks may not be active.`);
        }
    }
    catch (err) {
        // Hook installation is a quality-of-life touchup, not a hard install
        // requirement. Never let it abort the surrounding install.
        console.warn(`[gjsify install] git hook installation skipped: ${err instanceof Error ? err.message : String(err)}`);
    }
}
