import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import type { Command } from "effect/unstable/cli"; import { Prompt } from "effect/unstable/cli"; import type { BundleContext, PlannedOutput, Resolution } from "@packall/core"; import { plannedOutputs } from "@packall/core"; import type { Config } from "../config.js"; import { ForceMode } from "../enums/force-mode.js"; import { OverwriteAnswer } from "../enums/overwrite-answer.js"; /** How `--force` was answered. */ export type ForcePolicy = { /** What to do about collisions not named in `specs`. */ readonly global: ForceMode; /** Specs always replaced, whatever `global` says. */ readonly specs: ReadonlyArray; }; /** What a run should do about output files that already exist. */ export type OverwritePlan = { /** True once the answer is "always", or `--force true`. */ readonly force: boolean; /** Outputs the user chose to leave alone; these are never produced. */ readonly skipExisting: ReadonlySet; /** Outputs cleared for replacement without forcing the whole run. */ readonly overwrite: ReadonlySet; }; /** * Reads `--force`, which is deliberately overloaded. * * `true` / `false` / `prompt` are the whole-run policies. Anything else is read * as a spec: `--force tsdown esbuild@0.21.5` replaces exactly those two and * still asks about the rest, which is what you want in a directory holding a * dozen bundles when only one is stale. * * Separators are commas or whitespace, for the same PowerShell reason as * `--platform`. */ export const parseForce = ( force: Option.Option, spec: ReadonlyArray, ): ForcePolicy => ({ // Absent is `None`, not `false` — which is why the boolean is wrapped in // `Flag.optional`. Without it, omitting the flag would mean "refuse" rather // than "ask", turning the safe default into a hard failure on every run into // a directory that already holds something. global: Option.isSome(force) ? force.value ? ForceMode.All : ForceMode.None : ForceMode.Prompt, specs: spec.flatMap((value) => value.split(/[,\s]+/)).filter((value) => value.length > 0), }); export const resolveOverwrites = ( resolution: Resolution, options: BundleContext, config: Config, interactive: boolean, ): Effect.Effect => Effect.gen(function* () { const policy = parseForce(config.force, config.forceSpec); const empty: ReadonlySet = new Set(); if (policy.global === ForceMode.All) { return { force: true, skipExisting: empty, overwrite: empty }; } if (options.dryRun) return { force: false, skipExisting: empty, overwrite: empty }; const fs = yield* FileSystem.FileSystem; const existing: Array = []; for (const planned of plannedOutputs(resolution, options)) { const exists = yield* fs .exists(`${options.outDir}/${planned.file}`) .pipe(Effect.orElseSucceed(() => false)); if (exists) existing.push(planned); } if (existing.length === 0) return { force: false, skipExisting: empty, overwrite: empty }; // --force-spec is settled before anything is asked, so a named spec is // never prompted about — that is the point of naming it. const overwrite = new Set( existing.flatMap((planned) => policy.specs.some((spec) => forcesOutput(spec, planned)) ? planned.file : [], ), ); const rest = existing.filter((planned) => !overwrite.has(planned.file)); // --no-force governs everything not named: the engine reports those by // name and exits non-zero, while the named ones still get replaced. if (policy.global === ForceMode.None) { return { force: false, skipExisting: empty, overwrite }; } // Nothing left to ask about, or nothing to ask with. if (rest.length === 0 || !interactive || config.yes || config.json) { return { force: false, skipExisting: empty, overwrite }; } return yield* askEach( rest.map((planned) => planned.file), options, overwrite, ); }); /** Does `spec` (a name, or `name@version`) name this planned output? */ const forcesOutput = (spec: string, planned: PlannedOutput): boolean => { if (planned.name === undefined) return false; const at = spec.lastIndexOf("@"); const hasVersion = at > 0; const name = hasVersion ? spec.slice(0, at) : spec; if (name !== planned.name) return false; return hasVersion ? spec.slice(at + 1) === planned.version : true; }; /** * Asks about each colliding file in turn. * * "Always" ends the questions by switching the rest of the run to overwrite, so * re-bundling forty packages is not forty prompts. */ const askEach = ( files: ReadonlyArray, options: BundleContext, alreadyCleared: ReadonlySet, ): Effect.Effect => Effect.gen(function* () { const skip = new Set(); const overwrite = new Set(alreadyCleared); for (const [index, name] of files.entries()) { const answer = yield* Prompt.run( Prompt.select({ message: `${name} already exists in ${options.outDir}` + (files.length > 1 ? ` (${index + 1} of ${files.length})` : "") + ". Replace it?", choices: [ { title: "Replace it", value: OverwriteAnswer.Overwrite }, { title: "Keep the existing file", value: OverwriteAnswer.Keep, description: "This bundle is not built; everything else still is.", }, { title: "Replace this and all remaining", value: OverwriteAnswer.Always, description: "Stop asking for the rest of this run.", }, ], }), ).pipe( // Ctrl-C at the question, or no real TTY after all. Keeping the // existing file is the answer that destroys nothing. Effect.orElseSucceed((): OverwriteAnswer => OverwriteAnswer.Keep), ); if (answer === OverwriteAnswer.Always) { return { force: true, skipExisting: skip, overwrite }; } if (answer === OverwriteAnswer.Keep) skip.add(name); else overwrite.add(name); } return { force: false, skipExisting: skip, overwrite }; });