/** * Flag design follows one rule: **the unflagged command should produce a bundle * that actually installs offline on a machine that is not this one.** * * Every switch in this file either makes the bundle smaller or makes the run * faster, and both are things a user should opt into knowingly. The rule cannot * sit on any single descriptor, because it is what decides each default * relative to all the others. */ import { Layouts } from "@packall/core"; import type { ValueOf } from "@packall/core"; /** * Above this many archives, per-spec means importing a lot of separate * tarballs, which is worth a second thought before writing them. */ export const DEFAULT_LAYOUT_PROMPT_THRESHOLD = 20; /** Sections of the surface, in the order `--help` presents them. */ export const FlagGroup = { Input: "input", Output: "output", Selection: "selection", Registry: "registry", Other: "other", } as const; export type FlagGroup = ValueOf; /** * What a flag accepts, and what it is when absent. * * A `default` of `undefined` means the flag is genuinely optional — absent is * distinguishable from any value it could carry. That distinction is not * decoration: `--force` relies on it to tell "replace everything" from "refuse" * from "ask me per file". */ export type FlagType = | { readonly _tag: "Boolean"; readonly default: boolean | undefined } | { readonly _tag: "Integer"; readonly default: number | undefined } | { readonly _tag: "Text"; readonly default: string | undefined } /** Repeatable, and absent means an empty list rather than nothing. */ | { readonly _tag: "TextList" } | { readonly _tag: "Choice"; readonly choices: ReadonlyArray } | { readonly _tag: "File"; readonly mustExist: boolean } | { readonly _tag: "Directory"; readonly default: string }; /** When a flag only means something in the presence of another. */ export type FlagDependency = { readonly flag: string; /** Required value of the controlling flag; presence alone when absent. */ readonly value?: string | boolean | undefined; }; export type FlagDescriptor = { readonly name: string; readonly alias?: string | undefined; /** Placeholder shown in help, e.g. `PATH` in `--file PATH`. */ readonly metavar?: string | undefined; readonly description: string; readonly group: FlagGroup; readonly type: FlagType; readonly dependsOn?: FlagDependency | undefined; }; export const specsDescriptor = { name: "spec", metavar: "SPEC", description: "Packages to bundle: lodash, react@18.2.0, @babel/core@^7, typescript@next", } as const; export const descriptors = { file: { name: "file", alias: "f", metavar: "PATH", description: "Read specs from a file: a package.json (bundles its dependencies), a lockfile (bundles the versions it pins), or one spec per line with # and // comments. Detected by content, not extension", group: FlagGroup.Input, type: { _tag: "File", mustExist: true }, }, prod: { name: "prod", description: "With --file , skip devDependencies", group: FlagGroup.Input, type: { _tag: "Boolean", default: false }, dependsOn: { flag: "file" }, }, // Defaults to detecting, because a range resolved fresh is only accidentally // the version that was installed, and a lockfile sitting next to the // package.json is an unambiguous statement of which one that was. Nothing is // silent about it: the run says which file pinned it, and `--lockfile off` // is one flag away. lockfile: { name: "lockfile", metavar: "MODE", description: "With --file : detect finds a sibling lockfile (default) · off never does · npm, pnpm or bun requires that one · or pass a path", group: FlagGroup.Input, type: { _tag: "Text", default: "detect" }, }, // `Flag.directory` resolves a value that was actually typed to an absolute // path before the handler sees it. The *default* is not resolved — a default // short-circuits the primitive — so `.` arrives as `.`, and both routes end // up echoing the way you typed them. out: { name: "out", alias: "o", metavar: "DIR", description: "Directory to write bundles into (default: the current directory)", group: FlagGroup.Output, type: { _tag: "Directory", default: "." }, }, layout: { name: "layout", description: "per-spec: one tarball per package (default) · single: one deduplicated tarball · " + "dir: unpacked tree · flat: one directory of tarballs, no nesting", group: FlagGroup.Output, type: { _tag: "Choice", choices: Layouts }, }, // Optional rather than defaulted, because absence and the number 20 mean // different things. Left alone, the run *asks* at 20. Given a number, the // question has already been answered — switch at that many archives and say // so, which is what somebody scripting this wants. `0` declines both. layoutThreshold: { name: "layout-threshold", metavar: "N", description: `Switch to --layout single above N archives, or at 2x duplication, without asking. ` + `Omit to be asked instead at ${DEFAULT_LAYOUT_PROMPT_THRESHOLD}; pass 0 to neither ask nor switch`, group: FlagGroup.Output, type: { _tag: "Integer", default: undefined }, }, archiveName: { name: "archive-name", metavar: "NAME", description: "Base name for the archive in --layout single", group: FlagGroup.Output, type: { _tag: "Text", default: undefined }, dependsOn: { flag: "layout", value: "single" }, }, allVersions: { name: "all-versions", description: "Bundle every published version matching a range, not just the best match", group: FlagGroup.Selection, type: { _tag: "Boolean", default: false }, }, maxVersions: { name: "max-versions", metavar: "N", description: "With --all-versions, keep at most the N newest matching versions", group: FlagGroup.Selection, type: { _tag: "Integer", default: undefined }, dependsOn: { flag: "all-versions", value: true }, }, includePrerelease: { name: "include-prerelease", description: "Let prereleases satisfy ranges", group: FlagGroup.Selection, type: { _tag: "Boolean", default: false }, }, optional: { name: "optional", description: "Follow optionalDependencies. On by default — this is where per-platform native binaries live", group: FlagGroup.Selection, type: { _tag: "Boolean", default: true }, }, peer: { name: "peer", description: "Follow non-optional peerDependencies", group: FlagGroup.Selection, type: { _tag: "Boolean", default: true }, }, // Repeatable, and each occurrence may itself list several targets. PowerShell // parses an unquoted `a,b` as an array literal and joins it with a space when // handing it to a native command, so `--platform win32,linux` arrives as the // single argument "win32 linux" — hence separators are commas *or* whitespace, // and `--platform win32 --platform linux` works too. platform: { name: "platform", metavar: "LIST", description: "Restrict optional deps to these platforms. An OS alone takes every arch: win32,linux · linux-x64 · linux-x64-musl (default: all)", group: FlagGroup.Selection, type: { _tag: "TextList" }, }, registry: { name: "registry", alias: "r", metavar: "URL", description: "Registry to fetch from. Outranks every other source, including npm_config_registry (default: your .npmrc)", group: FlagGroup.Registry, type: { _tag: "Text", default: undefined }, }, // The second sentence is not pedantry: `npm run packall` exports // `npm_config_registry`, so a run from inside a package script silently uses // the package manager's registry rather than the file named right here. That // surfaces as "not found on registry.npmjs.org" with a correct-looking // .npmrc, and it is worth a line of `--help` to save the investigation. npmrc: { name: "npmrc", metavar: "PATH", description: "Explicit .npmrc to read registries and credentials from. npm_config_registry in the environment still outranks its registry= line", group: FlagGroup.Registry, type: { _tag: "File", mustExist: true }, }, rewriteTarballHost: { name: "rewrite-tarball-host", description: "Fetch tarballs from --registry rather than the URL the registry returned", group: FlagGroup.Registry, type: { _tag: "Boolean", default: false }, }, // The one deadline in the tool: connect, preflight and each request are all // bounded by it. It bounds how long the registry may stay *silent*, not how // long a download may take — the response body is read outside the ceiling — // so ten seconds is long enough for a slow registry and short enough that a // dead one is reported while somebody is still watching. timeout: { name: "timeout", metavar: "MS", description: "Ceiling on every network wait: connecting, the preflight check, and each request. Prevents an unreachable registry hanging the run", group: FlagGroup.Registry, type: { _tag: "Integer", default: 10_000 }, }, retries: { name: "retries", metavar: "N", description: "Retries for transient failures (5xx, 429, dropped connections)", group: FlagGroup.Registry, type: { _tag: "Integer", default: 3 }, }, concurrency: { name: "concurrency", alias: "c", metavar: "N", description: "Simultaneous registry requests", group: FlagGroup.Registry, type: { _tag: "Integer", default: 10 }, }, dryRun: { name: "dry-run", description: "Resolve and report what would be bundled, downloading nothing", group: FlagGroup.Other, type: { _tag: "Boolean", default: false }, }, verify: { name: "verify", description: "Check every tarball against the registry's checksum", group: FlagGroup.Other, type: { _tag: "Boolean", default: true }, }, // Two flags rather than one overloaded flag. A Flag.orElse union of // boolean | specs under a single name cannot work in Effect v4: Flag.boolean // never fails, so absent parses as false and is indistinguishable from // --no-force; and with the string branch first, a value-less --force is a // hard parse error orElse will not recover. Splitting the names avoids both, // and costs nothing -- the policies were never really one value. // // The absent default is what makes absent distinguishable from --no-force, // which is what keeps the default at prompt rather than silently refusing. force: { name: "force", description: "Replace any existing output file. --no-force refuses instead. Omit to be asked per file", group: FlagGroup.Output, type: { _tag: "Boolean", default: undefined }, }, // Composes with --force rather than overriding it: these are always // replaced, and --force governs everything else. So --no-force --force-spec // tsdown means "refuse, except tsdown". // // No value here is reserved, so a package really called prompt, true or // false can be named -- all three exist on npm. forceSpec: { name: "force-spec", metavar: "SPEC", description: "Always replace these specs, whatever --force says about the rest, e.g. --force-spec tsdown esbuild@0.21.5", group: FlagGroup.Output, type: { _tag: "TextList" }, }, printAbsolutePath: { name: "print-absolute-path", description: "Report full paths instead of ones relative to the current directory", group: FlagGroup.Output, type: { _tag: "Boolean", default: false }, }, json: { name: "json", description: "Emit a machine-readable summary on stdout", group: FlagGroup.Output, type: { _tag: "Boolean", default: false }, }, quiet: { name: "quiet", alias: "q", description: "Only report warnings and errors", group: FlagGroup.Other, type: { _tag: "Boolean", default: false }, }, interactive: { name: "interactive", alias: "i", description: "Choose from what the resolution actually found — platforms, layout — instead of guessing flags up front", group: FlagGroup.Other, type: { _tag: "Boolean", default: false }, }, yes: { name: "yes", alias: "y", description: "Never prompt; take the default for any question", group: FlagGroup.Other, type: { _tag: "Boolean", default: false }, }, } as const satisfies Record; /** Every flag, in declaration order. */ export const flagDescriptors: ReadonlyArray = Object.values(descriptors);