diff --git a/.github/scripts/changed-sections.ts b/.github/scripts/changed-sections.ts index 1692f8c7..47f47052 100644 --- a/.github/scripts/changed-sections.ts +++ b/.github/scripts/changed-sections.ts @@ -41,6 +41,9 @@ export const ALL_SELECTING_PREFIXES = [ "src/flows/", "src/github/", "src/action/", + // The CLI runs the same flows the action does, so its change is smoked like the action's. + "src/cli/", + "src/cli.ts", "src/discovery/", "src/report/", // Cross-cutting: gap files define supplemental route typing across sections. @@ -137,7 +140,8 @@ function assertNoComputedImports(text: string, file: string): void { * errors name the file, not this tool, because arch-lint.ts shares this and resolveImport. */ export function scanImports(text: string, file: string): string[] { assertNoComputedImports(text, file); - return TRANSPILER.scanImports(text) + // Bun.Transpiler rejects a shebang line (the bin entry keeps one); oxc above accepts it. + return TRANSPILER.scanImports(text.replace(/^#!.*\n/, "")) .map((entry) => entry.path) .filter((specifier) => specifier.startsWith("./") || specifier.startsWith("../")); } diff --git a/.github/scripts/package-smoke.ts b/.github/scripts/package-smoke.ts index 30e9ddd4..dd5d1989 100644 --- a/.github/scripts/package-smoke.ts +++ b/.github/scripts/package-smoke.ts @@ -5,16 +5,18 @@ * subpath), and compile a TypeScript consumer against the bundled index.d.ts * with skipLibCheck off - a declaration that leaks a devDependency type, or a * type the emitter could not name, fails here instead of on a consumer's - * machine. + * machine. The installed bin is run under Node too: its help must name every + * subcommand, and `validate` must accept a small settings file. * * Usage: `bun .github/scripts/package-smoke.ts` from anywhere; the temp * directory is removed on every path, failure included. */ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { CLI_COMMANDS } from "../../src/cli/program.js"; import { SECTION_KEYS } from "../../src/schema.js"; /** This script lives at .github/scripts/, two levels below the repository root. */ @@ -56,12 +58,29 @@ const result = validateSettings({ labels: [] }); export const ok: boolean = result.isOk() && first === "repository"; `; +/** The settings file the installed CLI validates: one section, valid as written. */ +const SETTINGS_FILE = "labels:\n - name: bug\n color: d73a4a\n"; + +/** The control: a label without its name, which the validator refuses. */ +const INVALID_SETTINGS_FILE = "labels:\n - color: d73a4a\n"; + /** Run a command to completion in `cwd`, streaming its output; a non-zero exit throws. */ function run(command: string, args: string[], cwd: string): void { console.log(`$ ${[command, ...args].join(" ")}`); execFileSync(command, args, { cwd, stdio: "inherit" }); } +/** Run a command expected to fail; any other exit code, zero included, throws. */ +function expectExit(code: number, command: string, args: string[], cwd: string): void { + console.log(`$ ${[command, ...args].join(" ")} # expecting exit ${code}`); + const result = spawnSync(command, args, { cwd, stdio: "inherit" }); + if (result.status !== code) { + throw new Error( + `${[command, ...args].join(" ")} exited ${result.status ?? "by signal"}, expected ${code}`, + ); + } +} + /** Run a command and return its stdout. */ function capture(command: string, args: string[], cwd: string): string { console.log(`$ ${[command, ...args].join(" ")}`); @@ -125,6 +144,18 @@ async function main(): Promise { writeFileSync(join(consumer, "main.mjs"), NODE_CONSUMER); run(CONSUMER_NODE, ["--version"], consumer); run(CONSUMER_NODE, ["main.mjs"], consumer); + // The bin through its installed shim, under the consumer's Node: the package's own runtime. + const bin = join(consumer, "node_modules", ".bin", "gsac"); + const help = capture(CONSUMER_NODE, [bin, "--help"], consumer); + const missing = CLI_COMMANDS.filter((command) => !help.includes(` ${command}`)); + if (missing.length > 0) { + throw new Error(`the installed CLI's help names no ${missing.join(", ")} command`); + } + writeFileSync(join(consumer, "settings.yml"), SETTINGS_FILE); + run(CONSUMER_NODE, [bin, "validate", "settings.yml"], consumer); + // The failing control: a bin that swallowed its exit code would pass the line above. + writeFileSync(join(consumer, "invalid.yml"), INVALID_SETTINGS_FILE); + expectExit(1, CONSUMER_NODE, [bin, "validate", "invalid.yml"], consumer); writeFileSync(join(consumer, "main.ts"), TS_CONSUMER); run( join(REPO_ROOT, "node_modules", ".bin", "tsc"), diff --git a/AGENTS.md b/AGENTS.md index ab12a105..2e77dcc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings: - `lib/settings.schema.json` is the COMMITTED JSON Schema for settings.yml, generated from the zod slices in `src/sections//schema.ts` as composed into the settings document by `src/schema.ts`. Each slice is ONE declaration that produces its config type (z.infer), the section's tolerant runtime shape (the section module derives `loosen()` itself; the factory-minted families - secrets, variables, the two setup sections, and the list sections on `listSection` - get it from their shared factory in `src/sections/shared/`, which does that derivation once), and its part of the published schema (`.meta({id})` names its definitions; the descriptions come from the `schema:` map in the section's `.docs.yml`, keyed `Definition.field`, which the generator attaches and fails on when a site or a key is unmatched); `src/schema.ts` adds only the document-level wrappers and pins each property to its slice with a lockstep type. Regenerate the schema with `bun run build:schema` after a schema-affecting `src/` change - CI's schema-check job fails when it drifts. `lib/index.js`, the bundled entrypoint the action runs (node24), is NOT committed on main: every workflow that executes the action builds it first (`bun run build:bundle`), the e2e runner builds it to a temp path, and a release builds and commits it on a packaged child of the release commit, which is what every `vX.Y.Z` tag and the moving major point at (main itself stays source-only). `lib/` is exempt from the typography check (third-party unicode in the bundle; the schema descriptions are checked at source) and excluded from [biome](https://biomejs.dev). - The npm library (`@vivswan/github-settings-as-code`, built into `lib/pkg/` by `bun run build:lib`, never committed) is described in docs/reference/library.md. One rule the tooling cannot infer: a type a section module exposes must be exported from its home module, or the bundled declarations cannot reach it and the package-smoke job fails. +- `src/cli/` is the command line, a consumer of the public entry like the action: a capability it needs is exported from `src/index.ts` first, never imported across. Guide: docs/start/cli.md. - The import layering of `src/` is declared in `architecture.yml` and enforced by `bun run lint:arch`, so a new cross-layer import is a deliberate edit to that file; the module map in `docs/reference/architecture.md` is generated from it. - The apply/check engine layout: `src/main.ts` is the thin bundled entrypoint; `src/index.ts` is the public entry (declaration-only re-exports, pinned by test/library/public-surface.test.ts); `src/action/` is the GitHub Actions layer and a CONSUMER of that entry: its only import outside itself is `../index.js`, the one edge `architecture.yml` grants it; `src/flows/` holds the run flows the action and a library caller share (single, multi, merge, delivery, redaction); `src/io.ts` is the import-free Io output port; `src/types.ts` holds the leaf type vocabulary; `src/engine/` is the per-repo pipeline (orchestrate, validate, merge, diff, secret references); `src/github/` is the REST client, pagination, and repo-file fetch; `src/discovery/` resolves multi-repo targets; `src/report/` composes and delivers the private report. Under `src/sections/`, each settings section is one self-contained DIRECTORY `src/sections//`: `index.ts` (the `SectionModule` - key, PAT grant advice, the loose shape derived from the slice, handler), `schema.ts` (the zod slice), `mock.ts` (the e2e mock fragment), `generators.ts` (the fuzz fragment; the secret and variable factory families share theirs in `test/e2e/generators.ts` instead), `.test.ts` (absent where a shared factory's suite under `test/sections/` covers the section), and `scenarios/`. Beside the section directories sit `contract/` (the layered section contract: permissions/endpoints/graphql/module/errors/requests/live), `shared/` (the cross-section engines and factories, plus the schema helpers), and `registry.ts` (the single registration point). All GitHub API list calls must go through `listAll()` (bare-array endpoints) or `listAllEnveloped()` (endpoints that wrap the list in a `{total_count, : []}` envelope), both backed by the single page loop in `src/github/paginate.ts`; errors through `call()`/`throwFor()` so the permission policy (`on-missing-permission`, `required-sections`) works. - Adding a section: create `src/sections//` with the six pieces above plus its `.docs.yml` (the Sections table cells for docs/reference/sections.md, the COVERAGE.md prose, and the schema descriptions the generators render; the docs loader fails without it), compose the `SettingsFile` property from the slice and add the key to `SECTION_KEYS` in `src/schema.ts`, and add one line in `src/sections/registry.ts` - the compiler flags each forgotten step (the registry's mapped type, the mock fragment registration, the slice composition pin, and the generator aggregation are all keyed by SectionKey). diff --git a/README.md b/README.md index 08e970fe..1904aee4 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ The [getting started guide](docs/start/getting-started.md) walks the same steps The same engine is the npm package `@vivswan/github-settings-as-code` (ESM, Node 22.14 or newer): validate, merge, check, and apply from your own code. It is built from this repository with `bun run build:lib`; the [library reference](docs/reference/library.md) has the API by group. +The package is also a command: `npx @vivswan/github-settings-as-code@next check --repository o/r --settings-file .github/settings.yml` runs the action's check from a terminal; the [command line guide](docs/start/cli.md) has every command. + ## Docs | Goal | Read | @@ -85,6 +87,7 @@ The same engine is the npm package `@vivswan/github-settings-as-code` (ESM, Node | Move a pin to a new major | [Upgrading](docs/upgrading/README.md) | | See how the code is laid out | [Architecture](docs/reference/architecture.md) | | Use the engine from your own code | [Library](docs/reference/library.md) | +| Run check, apply, or validate from a terminal | [Command line](docs/start/cli.md) | ## Contributing diff --git a/architecture.yml b/architecture.yml index b5c2f13e..3770ca77 100644 --- a/architecture.yml +++ b/architecture.yml @@ -6,6 +6,7 @@ layers: main: [src/main.ts] action: [src/action/] + cli: [src/cli.ts, src/cli/] library: [src/index.ts] flows: [src/flows/] engine: [src/engine/] @@ -28,10 +29,11 @@ exclude: - "src/**/*.d.ts" # from -> the layers it may import; a layer absent here imports nothing outside itself. -# The action is a consumer of the library: its one edge is src/index.ts. +# The action and the CLI are consumers of the library: their one edge is src/index.ts. edges: main: [action] action: [library] + cli: [library] library: [discovery, engine, flows, github, io, problem, report, schema, sections, types] flows: [discovery, engine, github, io, plain-data, private, problem, report, schema, types] engine: [discovery, github, io, plain-data, problem, schema, sections, types] diff --git a/bun.lock b/bun.lock index 7a30592f..2d95bb4a 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,10 @@ "@octokit/types": "18.0.0", "age-encryption": "0.3.1", "bottleneck": "2.19.5", + "commander": "15.0.0", + "consola": "3.4.2", "neverthrow": "8.2.0", + "picocolors": "1.1.1", "yaml": "2.9.0", "zod": "4.5.4", }, @@ -468,10 +471,12 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-type": ["content-type@3.0.0", "", {}, "sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -792,6 +797,8 @@ "@actions/github/@actions/http-client": ["@actions/http-client@3.0.2", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^6.23.0" } }, "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA=="], + "@arethetypeswrong/cli/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], diff --git a/docs/README.md b/docs/README.md index b35af025..82b9033d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ The documentation for GitHub Settings as Code, in five groups. Start here if the | Predict what an apply or a check will do | [reference/semantics.md](reference/semantics.md) | | See how the action works, module by module | [reference/architecture.md](reference/architecture.md) | | Use the engine from your own code | [reference/library.md](reference/library.md) | +| Run check, apply, or validate from a terminal | [start/cli.md](start/cli.md) | | Detect drift on a schedule | [operate/check-mode.md](operate/check-mode.md) | | Fold several settings files into one | [operate/layering.md](operate/layering.md) | | Manage a fleet from one repository | [operate/multi-repo.md](operate/multi-repo.md) | @@ -32,6 +33,7 @@ The documentation for GitHub Settings as Code, in five groups. Start here if the - [Getting started](start/getting-started.md): create the PAT, add the workflow, run your first check, and read the drift output. - [Migrating from the Probot Settings app](start/migrating-from-probot.md): the step-by-step move, including the parts that changed on purpose and an org-scale shadow run. - [Examples](start/examples.md): a settings.yml cookbook, from a minimal file to a full-featured one, including what `null` means where it is meaningful. +- [Command line](start/cli.md): the `github-settings-as-code` and `gsac` commands in the npm package, one per action mode plus `validate` and `permissions`, their flags, outputs, and exit codes. ## reference: the normative model diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index f0e43b0b..aff567ef 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -230,6 +230,7 @@ Each node is one layer of `src/`, labelled with the paths it owns; an arrow mean graph TD main["src/main.ts"] action["src/action/"] + cli["src/cli.ts
src/cli/"] library["src/index.ts"] flows["src/flows/"] engine["src/engine/"] @@ -246,6 +247,7 @@ graph TD upstream_gaps["src/upstream-gaps/"] main --> action action --> library + cli --> library library --> discovery library --> engine library --> flows diff --git a/docs/reference/library.md b/docs/reference/library.md index 33e22ecd..6a1622ba 100644 --- a/docs/reference/library.md +++ b/docs/reference/library.md @@ -155,6 +155,11 @@ const exitCode = await runSingle(client, config, collected.io).match( console.log(exitCode, collected.outputs.result, collected.lines.map((entry) => entry.line)); ``` +## CLI + +The package's `bin` entries, `github-settings-as-code` and `gsac`, run the same flows from a terminal: `check`, `apply`, and `merge` take the action's inputs as `--flags`, +and `validate` and `permissions` read a settings file alone. The [command line guide](../start/cli.md) has every command, the flag rule, and the exit codes. + ## Versioning The package and the action share one version, the one in `.release-please-manifest.json` (release-please rewrites `package.json` from it), so a settings file that validates on the library validates on the action of the same version. diff --git a/docs/start/cli.md b/docs/start/cli.md new file mode 100644 index 00000000..5b1707f0 --- /dev/null +++ b/docs/start/cli.md @@ -0,0 +1,120 @@ +--- +order: 40 +--- + +# Command line + +The engine behind the action is also a command in the npm package `@vivswan/github-settings-as-code`: `github-settings-as-code`, or `gsac` for short. +It runs the same checks, applies, and merges the action runs, from a terminal or any CI, plus two file-only commands the action has no step for: +validate a settings file, and print the PAT grant it needs. + +## Install + +Run it without installing, with npm or bun. Until the first stable release publishes the package, ask for the `next` tag: + +```bash +npx @vivswan/github-settings-as-code@next --help +bunx @vivswan/github-settings-as-code@next --help +``` + +Or install it and call `gsac`: + +```bash +npm install --global @vivswan/github-settings-as-code@next +gsac --help +``` + +The package needs Node 22.14 or newer; `bunx` fetches it the same way and honors the executable's `node` shebang. The command reads its token from `--token` or the `GITHUB_TOKEN` environment variable; the same fine-grained PAT the action uses (see [Token permissions](../reference/permissions.md)). + +## Commands + +| Command | Does | Needs a token | +|---|---|---| +| `check` | Report drift between a settings file and the live repository; exits 1 on any drift | yes | +| `apply` | Apply a settings file to the repository | yes | +| `merge` | Fold an ordered list of settings files into one document | no | +| `validate ` | Validate a settings file against the schema | no | +| `permissions ` | Print the PAT grant each section the file declares needs | no | + +### check + +```bash +export GITHUB_TOKEN=github_pat_... +gsac check --repository octo-org/api --settings-file .github/settings.yml +``` + +Prints the drift lines the action would annotate, then `result: clean` or `result: drift`, and exits 1 on drift, exactly as [check mode](../operate/check-mode.md) describes. + +The defaults are the action's, redaction included: a private repository's lines are hidden unless you pass `--private-repos show` +(a terminal has no `GITHUB_REPOSITORY` to exempt the repository you are standing in; see [private repositories](../operate/private-repositories.md)). + +### apply + +```bash +gsac apply --repository octo-org/api --settings-file .github/settings.yml --on-missing-permission warn +``` + +Every flag of the action's `apply` and `check` inputs is a flag here, spelled the same way, with two exceptions below. `--repos` or `--repos-dir` switches to [multi-repo mode](../operate/multi-repo.md); `--defaults-file` and the discovery filters apply there, as in the action. +Outside GitHub Actions there is no `GITHUB_REPOSITORY`, so single-repo runs need `--repository`. + +### merge + +```bash +gsac merge --settings-file fleet.yml --settings-file team.yml --merged-file merged.yml +``` + +A repeated `--settings-file` builds the layer list, lowest first; a comma-separated value does the same. The merged file is exactly what `apply` would run ([layering](../operate/layering.md)). + +### validate + +```bash +gsac validate .github/settings.yml +``` + +Exits 0 with the sections the file declares, or 1 with the validator's message on stderr. No token, no API call: this is the command to run in a pre-commit hook or a pull request check. + +### permissions + +```bash +gsac permissions .github/settings.yml +``` + +One line per declared section: the grant its declaration names, the same words a permission denial would print. + +## Flags + +The subcommand is the action's `mode` input. Every other input of that mode is a flag named `--`, taking the value the action's `with:` key takes; +the [inputs reference](../reference/inputs.md) lists each one with its default and meaning, and `gsac --help` prints the same descriptions. +A list input (`--settings-file` under merge, `--repos`, `--exclude`, `--topics`, `--affiliation`, `--sections`, `--required-sections`) takes a comma-separated value or the flag repeated; repeating any other value flag, `--token` and `--summary` included, is an error naming it. + +Two inputs have no command-line form: `--private-report artifact` is refused (the artifact upload needs the Actions runner; `issue`, `issue-on-failure`, and `none` work), and `report-public-key`, which only that channel reads, is not a flag. + +Four flags are the command line's own: + +| Flag | Does | +|---|---| +| `--token ` | The token; `GITHUB_TOKEN` when absent | +| `--json` | Print the outputs as one JSON object on stdout; log lines move to stderr | +| `--summary ` | Append the run's markdown summary (the action's step summary) to this file | +| `--verbose` | Show the debug trace on stderr | + +## Output and exit codes + +Log lines go to stdout, annotations to stderr as `level: message` (`notice`, `warning`, `error`), and the action's outputs close the run as `name=value` lines on stdout: + +```text +result: clean +skipped-sections= +result=clean +``` + +With `--json` the outputs are one object instead, and stdout carries nothing else: + +```text +{"skipped-sections":"","result":"clean"} +``` + +The exit codes are the action's: `check` exits 1 on drift or failure, `apply` and `merge` exit 1 on failure only, `validate` exits 1 on an invalid file, `permissions` exits 0 for a valid file. +A flag the mode does not read is unknown to that subcommand and exits 1 with the parser's message, where the action rejects the same input by name; +a value the mode refuses (a filter without `--repos "*"`, a `--repository` that is not a slug) fails with the action's own message, reworded where the action's remedy names the workflow step. +The token passed on the command line is masked as `***` wherever a line would echo it. diff --git a/package.json b/package.json index 661b6457..0d7412c8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "./settings.schema.json": "./lib/settings.schema.json", "./package.json": "./package.json" }, + "bin": { + "github-settings-as-code": "lib/pkg/cli.js", + "gsac": "lib/pkg/cli.js" + }, "files": [ "lib/pkg/", "lib/settings.schema.json", @@ -62,7 +66,10 @@ "@octokit/types": "18.0.0", "age-encryption": "0.3.1", "bottleneck": "2.19.5", + "commander": "15.0.0", + "consola": "3.4.2", "neverthrow": "8.2.0", + "picocolors": "1.1.1", "yaml": "2.9.0", "zod": "4.5.4" }, diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 00000000..1d84a377 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +/** + * The bin entry (lib/pkg/cli.js is built from this file, shebang kept): + * run the command line and map its return code to the process exit code. + * Everything else lives in src/cli/. + */ + +import { main, processHost } from "./cli/program.js"; + +const streams = { stdout: process.stdout, stderr: process.stderr }; +process.exitCode = await main(process.argv, { host: processHost(), streams }); diff --git a/src/cli/commands.ts b/src/cli/commands.ts new file mode 100644 index 00000000..bd713d0a --- /dev/null +++ b/src/cli/commands.ts @@ -0,0 +1,149 @@ +/** + * What each subcommand does once its inputs are parsed: the engine and merge + * runs end exactly where the action's do (concludeRun, concludeMerge, + * failRun), so the exit codes and the outputs are the action's; the two + * file-only commands render a result for the program to print. + */ + +import { + concludeMerge, + concludeRun, + describeProblem, + failRun, + type GithubClient, + INPUT_DECLS, + type Io, + PRIVATE_REPORT_CHANNELS, + type Problem, + type RunConfig, + readSettingsFile, + runMerge, + runMulti, + runSingle, + SECTIONS, + type SectionModule, + sectionGrant, + type ValidatedSettings, + validateSettings, +} from "../index.js"; + +/** What the CLI needs from its process: the environment and a client factory tests can stub. */ +export interface CliHost { + readonly env: Readonly>; + createClient(token: string, io: Io, apiVersion: string): GithubClient; +} + +/** What a file-only command prints; the program picks `lines` or `json` by the --json flag. */ +export interface Rendered { + readonly code: number; + readonly lines: readonly string[]; + readonly json: unknown; +} + +/** Run a parsed config to its conclusion; the CLI has no artifact uploader, so that channel fails loudly. */ +export async function runConfig(cfg: RunConfig, io: Io, host: CliHost): Promise { + if (cfg.kind === "merge") { + return runMerge(cfg, io).match( + (merged) => concludeMerge(io, merged), + (problem) => failRun(io, problem), + ); + } + const api = host.createClient(cfg.token, io, cfg.apiVersion); + if (cfg.kind === "multi") { + return runMulti(api, cfg, io).match( + (targets) => concludeRun(io, { kind: "multi", mode: cfg.mode, targets }), + (problem) => failRun(io, problem), + ); + } + return runSingle(api, cfg, io).match( + (target) => concludeRun(io, { kind: "single", mode: cfg.mode, target }), + (problem) => failRun(io, problem), + ); +} + +/** The section modules a validated document declares, in execution order. */ +function declaredSections(settings: ValidatedSettings): SectionModule[] { + return SECTIONS.filter((section) => settings[section.key] !== undefined); +} + +/** Read and validate one settings file; the warnings go to `io`, the problem is the error. */ +function readValidated(file: string, io: Io) { + return readSettingsFile(file, "settings-file") + .andThen((doc) => validateSettings(doc, { source: file })) + .map(({ settings, warnings }) => { + for (const warning of warnings) { + io.annotate("warning", warning); + } + return settings; + }); +} + +/** `validate `: the schema verdict alone, no token and no API call. */ +export function validateFile(file: string, io: Io): Rendered { + return readValidated(file, io).match( + (settings): Rendered => { + const sections = declaredSections(settings).map((section) => section.key); + return { + code: 0, + lines: [ + `${file} is valid: ${sections.length} section(s) declared (${sections.join(", ")})`, + ], + json: { file, valid: true, sections }, + }; + }, + (problem): Rendered => { + const message = describeProblem(problem); + io.annotate("error", message); + return { code: 1, lines: [], json: { file, valid: false, problem: message } }; + }, + ); +} + +/** `permissions `: the PAT grant each declared section needs, from the section declarations. */ +export function permissionsFor(file: string, io: Io, bold: (text: string) => string): Rendered { + return readValidated(file, io).match( + (settings): Rendered => { + const grants = declaredSections(settings).map( + (section) => [section.key, sectionGrant(section)] as const, + ); + return { + code: 0, + lines: grants.map(([key, grant]) => `${bold(key)}: ${grant}`), + json: Object.fromEntries(grants), + }; + }, + (problem): Rendered => { + const message = describeProblem(problem); + io.annotate("error", message); + return { code: 1, lines: [], json: { file, valid: false, problem: message } }; + }, + ); +} + +/** + * The action's wording for a problem, except where the remedy names the + * workflow step: from a terminal the fix is a flag or an environment variable. + */ +export function describeCliProblem(problem: Problem): string { + switch (problem.code) { + case "input-token-missing": + return "cannot call the GitHub API: no token was provided. Pass --token, or export GITHUB_TOKEN"; + case "input-repository-not-slug": + return `cannot target a repository: "${problem.value}" is not an owner/name slug. Pass --repository owner/name (inside GitHub Actions, GITHUB_REPOSITORY supplies it)`; + default: + return describeProblem(problem); + } +} + +/** + * The one report channel a terminal cannot serve: the artifact upload needs + * the Actions runner. Worded as the unsupported value it is from here. + */ +export const ARTIFACT_REFUSED: Problem = { + code: "input-unsupported-value", + input: "private-report", + value: "artifact", + noun: "private-report channel from the command line (the artifact upload needs the Actions runner)", + allowed: PRIVATE_REPORT_CHANNELS.filter((channel) => channel !== "artifact"), + fallback: INPUT_DECLS["private-report"].default, +}; diff --git a/src/cli/inputs.ts b/src/cli/inputs.ts new file mode 100644 index 00000000..53e6a1e0 --- /dev/null +++ b/src/cli/inputs.ts @@ -0,0 +1,133 @@ +/** + * The CLI's read port over commander: every flag is one INPUT_DECLS entry + * spelled `-- `, so the help text and the action's inputs + * reference come from one declaration. The subcommand is the `mode` input + * and `--token` is a program-level flag; every other input is a flag of the + * subcommands whose mode reads it. parseConfig validates the values; nothing + * here does. + */ + +import { InvalidArgumentError, Option } from "commander"; +import { + INPUT_DECLS, + type InputDecl, + type InputName, + type InputReader, + MERGE_INPUTS, + MODES, + type Mode, +} from "../index.js"; + +/** Declaration order is the help order, as on the inputs reference page. */ +const INPUT_NAMES = Object.keys(INPUT_DECLS) as InputName[]; + +/** The two inputs that are not subcommand flags: the mode is the subcommand, the token is global. */ +const PROGRAM_INPUTS = ["mode", "token"] as const satisfies readonly InputName[]; + +/** + * Inputs no subcommand exposes: the artifact report channel needs the Actions + * artifact service, which a terminal has no upload for, so its key has no use. + */ +export const CLI_UNSUPPORTED_INPUTS = ["report-public-key"] as const satisfies readonly InputName[]; + +/** + * The flags a mode's subcommand takes: the inputs its mode reads. `settings-file` + * is the one input both the merge and the engine modes read. + */ +export function inputsForMode(mode: Mode): InputName[] { + const mergeReads = (name: InputName): boolean => + (MERGE_INPUTS as readonly InputName[]).includes(name); + const hidden: readonly InputName[] = [...PROGRAM_INPUTS, ...CLI_UNSUPPORTED_INPUTS]; + return INPUT_NAMES.filter( + (name) => + !hidden.includes(name) && + (mode === "merge" ? mergeReads(name) : !mergeReads(name) || name === "settings-file"), + ); +} + +/** Every input some subcommand or the program exposes; the mode is the subcommand itself. */ +export function exposedInputs(): InputName[] { + const flags = new Set(["token", ...MODES.flatMap(inputsForMode)]); + return INPUT_NAMES.filter((name) => flags.has(name)); +} + +/** The sentence the action's `repository` description spends on a default a terminal never has. */ +const ACTIONS_DEFAULT_SENTENCE = "Defaults to the current repository."; +const CLI_REPOSITORY_SENTENCE = + "Required unless repos or repos-dir is set (inside GitHub Actions, GITHUB_REPOSITORY supplies it)."; +if (!INPUT_DECLS.repository.description.includes(ACTIONS_DEFAULT_SENTENCE)) { + throw new Error( + `BUG: the repository input's description no longer says "${ACTIONS_DEFAULT_SENTENCE}"; reword the CLI's replacement with it`, + ); +} + +/** The flag's help text: the declaration's, reworded where it assumes the Actions runner. */ +export function inputDescription(name: InputName): string { + const description = INPUT_DECLS[name].description; + return name === "repository" + ? description.replace(ACTIONS_DEFAULT_SENTENCE, CLI_REPOSITORY_SENTENCE) + : description; +} + +/** Whether the declaration is a list; read through InputDecl since only the list members carry the field. */ +export function isList(name: InputName): boolean { + const decl: InputDecl = INPUT_DECLS[name]; + return decl.list === true; +} + +/** A repeated list flag accumulates as a newline-separated list, the form parseConfig splits. */ +function accumulate(value: string, previous?: string): string { + return previous === undefined ? value : `${previous}\n${value}`; +} + +/** A repeated single-value flag is refused: joined, it would form a value the action cannot receive. */ +export function once(flag: string): (value: string, previous?: string) => string { + return (value, previous) => { + if (previous !== undefined) { + throw new InvalidArgumentError(`--${flag} takes one value and was given more than once`); + } + return value; + }; +} + +/** The commander option for one input: `-- `, repeatable when the declaration is a list. */ +export function inputOption(name: InputName): Option { + const parse = isList(name) ? accumulate : once(name); + return new Option(`--${name} `, inputDescription(name)).argParser(parse); +} + +/** Commander's attribute for each flag (camelCase of the name), read from commander itself. */ +const ATTRIBUTE: Readonly> = Object.fromEntries( + INPUT_NAMES.map((name) => [name, inputOption(name).attributeName()]), +) as Record; + +/** A flag value as the runner would hand it over: trimmed, as @actions/core trims every input. */ +function inputValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** + * The read port for a subcommand: `mode` is the subcommand, every other + * input is its parsed flag, empty when unset, so parseConfig sees exactly + * what the action's runner would hand it. + */ +export function argvReader(mode: Mode, values: Readonly>): InputReader { + return (name) => (name === "mode" ? mode : inputValue(values[ATTRIBUTE[name]])); +} + +/** + * Every value `--token` carries in `argv`, in both spellings commander + * accepts, as the reader would read it. Read before parsing, so the token is + * masked before the parser can echo it in a message of its own. + */ +export function tokenValues(argv: readonly string[]): string[] { + const values: string[] = []; + argv.forEach((argument, index) => { + if (argument === "--token") { + values.push(inputValue(argv[index + 1])); + } else if (argument.startsWith("--token=")) { + values.push(inputValue(argument.slice("--token=".length))); + } + }); + return values.filter((value) => value !== ""); +} diff --git a/src/cli/io.ts b/src/cli/io.ts new file mode 100644 index 00000000..785d6e45 --- /dev/null +++ b/src/cli/io.ts @@ -0,0 +1,187 @@ +/** + * The CLI's output boundary and its Io. No runner masks for a terminal, so + * every writer, the parser included, goes through maskedStreams(). + */ + +import { appendFileSync } from "node:fs"; +import { Writable } from "node:stream"; +import { + type ConsolaReporter, + createConsola, + type LogLevel, + LogLevels, + type LogType, +} from "consola"; +import pc from "picocolors"; +import { + type AnnotationLevel, + type Io, + type MaskPair, + maskRegistry, + type OutputName, +} from "../index.js"; + +export interface CliStreams { + readonly stdout: Writable; + readonly stderr: Writable; +} + +/** Streams that redact every masked value on write, with the registry that feeds them. */ +export interface MaskedStreams extends CliStreams, MaskPair { + /** `text` with every masked value replaced, for the writes that bypass the streams. */ + redact(text: string): string; +} + +/** A stream that redacts each chunk before handing it to `target`. */ +class RedactingStream extends Writable { + constructor( + private readonly target: Writable, + private readonly redact: (text: string) => string, + ) { + super({ decodeStrings: false }); + } + + override _write(chunk: unknown, _encoding: BufferEncoding, callback: () => void): void { + // A full target holds the next chunk until it drains, so backpressure reaches the writer. + if (this.target.write(this.redact(String(chunk)))) { + callback(); + } else { + this.target.once("drain", callback); + } + } +} + +/** + * `text` with every occurrence of every masked value replaced by `***`. + * Occurrences are located in the original text and overlapping or touching + * ones are merged, so two values that overlap (a prefix of another, or + * "ABC" and "BCD" across "ABCD") leave no fragment, as replacing one value + * after another would. + */ +function redactRanges(text: string, masked: ReadonlySet): string { + const ranges: Array<[number, number]> = []; + for (const value of masked) { + if (value === "") { + continue; + } + for (let at = text.indexOf(value); at !== -1; at = text.indexOf(value, at + 1)) { + ranges.push([at, at + value.length]); + } + } + ranges.sort((a, b) => a[0] - b[0]); + let out = ""; + let cursor = 0; + let open: [number, number] | undefined; + for (const [start, end] of ranges) { + if (open !== undefined && start <= open[1]) { + open[1] = Math.max(open[1], end); + continue; + } + if (open !== undefined) { + out += `${text.slice(cursor, open[0])}***`; + cursor = open[1]; + } + open = [start, end]; + } + if (open !== undefined) { + out += `${text.slice(cursor, open[0])}***`; + cursor = open[1]; + } + return out + text.slice(cursor); +} + +/** + * Every write to the returned streams is redacted; register a value before + * anything can print it. One registry serves the parser, the Io, and the + * file-only commands alike, so no writer can bypass it. + */ +export function maskedStreams(streams: CliStreams): MaskedStreams { + const registry = maskRegistry(() => {}); + const redact = (text: string): string => redactRanges(text, registry.masked()); + return { + stdout: new RedactingStream(streams.stdout, redact), + stderr: new RedactingStream(streams.stderr, redact), + redact, + ...registry, + }; +} + +export interface CliIoOptions { + readonly streams: MaskedStreams; + /** Print the outputs as one JSON object; log lines move to stderr so stdout is that object alone. */ + readonly json: boolean; + /** Show the debug trace. */ + readonly verbose: boolean; + /** The file summary blocks are appended to; none drops them. */ + readonly summaryFile?: string; + readonly colors: boolean; +} + +export interface CliIo { + readonly io: Io; + /** Print the collected outputs to stdout, in the form the json option picked. */ + flush(): void; +} + +/** The consola type each annotation level logs as; consola gates them by level. */ +const CONSOLA_TYPE: Record = { + notice: "info", + warning: "warn", + error: "error", +}; + +type Colors = ReturnType; +type Paint = (colors: Colors) => Colors["red"]; + +/** The label a consola type prints under, in the action's annotation words. */ +const LABEL: Partial> = { + info: { label: "notice", paint: (colors) => colors.blue }, + warn: { label: "warning", paint: (colors) => colors.yellow }, + error: { label: "error", paint: (colors) => colors.red }, + debug: { label: "debug", paint: (colors) => colors.dim }, +}; + +export function cliIo(options: CliIoOptions): CliIo { + const { streams } = options; + const colors = pc.createColors(options.colors); + const reporter: ConsolaReporter = { + log(logObj) { + const meta = LABEL[logObj.type]; + const text = logObj.args.map(String).join(" "); + const prefix = meta === undefined ? "" : `${meta.paint(colors)(meta.label)}: `; + streams.stderr.write(`${prefix}${text}\n`); + }, + }; + const level: LogLevel = options.verbose ? LogLevels.debug : LogLevels.info; + // throttle: 0 keeps every line: consola otherwise folds repeated identical + // lines within a second into "(repeated N times)", losing drift lines. + const consola = createConsola({ level, reporters: [reporter], throttle: 0 }); + const logStream = options.json ? streams.stderr : streams.stdout; + const outputs = new Map(); + return { + io: { + annotate: (annotation, message) => consola[CONSOLA_TYPE[annotation]](message), + log: (line) => logStream.write(`${line}\n`), + debug: (line) => consola.debug(line), + summary: (markdown) => { + if (options.summaryFile !== undefined) { + appendFileSync(options.summaryFile, `${streams.redact(markdown)}\n`); + } + }, + output: (name, value) => { + outputs.set(name, value); + }, + mask: streams.mask, + masked: streams.masked, + }, + flush: () => { + if (options.json) { + streams.stdout.write(`${JSON.stringify(Object.fromEntries(outputs))}\n`); + return; + } + for (const [name, value] of outputs) { + streams.stdout.write(`${name}=${value}\n`); + } + }, + }; +} diff --git a/src/cli/program.ts b/src/cli/program.ts new file mode 100644 index 00000000..974f50a2 --- /dev/null +++ b/src/cli/program.ts @@ -0,0 +1,212 @@ +/** + * The command tree: check, apply, and merge mirror the action's modes with + * INPUT_DECLS as their flags; validate and permissions read a file alone. + * `--token`, `--json`, `--summary`, and `--verbose` are global. main() runs + * argv to its exit code without touching the process. + */ + +import { Command, CommanderError, Option } from "commander"; +import pc from "picocolors"; +import { + failRun, + GithubApi, + INPUT_DECLS, + type Io, + type Mode, + parseConfig, + type RunConfig, +} from "../index.js"; +import { + ARTIFACT_REFUSED, + type CliHost, + describeCliProblem, + permissionsFor, + type Rendered, + runConfig, + validateFile, +} from "./commands.js"; +import { argvReader, inputOption, inputsForMode, once, tokenValues } from "./inputs.js"; +import { type CliStreams, cliIo, type MaskedStreams, maskedStreams } from "./io.js"; + +/** Every subcommand, in help order; the package smoke asserts the installed help names each. */ +export const CLI_COMMANDS = ["check", "apply", "merge", "validate", "permissions"] as const; + +type CliCommand = (typeof CLI_COMMANDS)[number]; + +const DESCRIPTION: Readonly> = { + check: "Report drift between a settings file and the live repository; exits 1 on any drift", + apply: "Apply a settings file to the repository", + merge: "Fold an ordered list of settings files into one document, with no token and no API call", + validate: "Validate a settings file against the schema; no token, no API call", + permissions: "Print the PAT grant each section a settings file declares needs", +}; + +/** The subcommands that run the engine or the merge, each under its mode. */ +const MODE_COMMANDS = { check: "check", apply: "apply", merge: "merge" } as const satisfies Partial< + Record +>; + +interface Globals { + readonly token?: string; + readonly json?: boolean; + readonly summary?: string; + readonly verbose?: boolean; +} + +export interface ProgramOptions { + readonly host: CliHost; + /** The output boundary every writer, the parser included, goes through. */ + readonly streams: MaskedStreams; + /** Color the level labels and the permissions table; defaults to picocolors' detection. */ + readonly colors?: boolean; + /** Run a parsed config; tests capture the config here instead of running it. */ + readonly execute?: (cfg: RunConfig, io: Io) => Promise; +} + +/** The production host: process.env and the real client. */ +export function processHost(): CliHost { + return { + env: process.env, + createClient: (token, io, apiVersion) => new GithubApi({ token, io, apiVersion }), + }; +} + +/** + * The whole command tree, wired to `options`; the exit code lands in the + * returned holder. The environment's token is masked here, before any writer + * exists; the argv token is main()'s to register, before the parse. + */ +export function buildProgram(options: ProgramOptions): { + program: Command; + exitCode: () => number; +} { + const { host, streams } = options; + const colors = options.colors ?? pc.isColorSupported; + const paint = pc.createColors(colors); + const execute = options.execute ?? ((cfg, io) => runConfig(cfg, io, host)); + const envToken = host.env.GITHUB_TOKEN?.trim(); + if (envToken !== undefined && envToken !== "") { + streams.mask(envToken); + } + let exitCode = 0; + const program = new Command() + .name("github-settings-as-code") + .description( + "Apply, check, merge, and validate declarative GitHub repository settings (also installed as gsac)", + ) + .addOption( + new Option( + "--token ", + `${INPUT_DECLS.token.description} Falls back to GITHUB_TOKEN.`, + ).argParser(once("token")), + ) + .option("--json", "Print the outputs as one JSON object on stdout; log lines move to stderr") + .addOption( + new Option("--summary ", "Append the run's markdown summary to this file").argParser( + once("summary"), + ), + ) + .option("--verbose", "Show the debug trace on stderr") + .exitOverride() + .configureOutput({ + writeOut: (text) => streams.stdout.write(text), + writeErr: (text) => streams.stderr.write(text), + }); + + const openIo = (globals: Globals) => + cliIo({ + streams, + json: globals.json === true, + verbose: globals.verbose === true, + summaryFile: globals.summary, + colors, + }); + + /** Print a file-only command's result the way `--json` asks. */ + const present = (rendered: Rendered, globals: Globals): void => { + if (globals.json === true) { + streams.stdout.write(`${JSON.stringify(rendered.json)}\n`); + return; + } + for (const line of rendered.lines) { + streams.stdout.write(`${line}\n`); + } + }; + + for (const [name, mode] of Object.entries(MODE_COMMANDS) as [CliCommand, Mode][]) { + const command = program.command(name).description(DESCRIPTION[name]); + for (const input of inputsForMode(mode)) { + command.addOption(inputOption(input)); + } + command.action(async function (this: Command) { + const values = this.optsWithGlobals>(); + const { io, flush } = openIo(values); + const read = argvReader(mode, values); + // Refused before parseConfig, which would otherwise ask for the channel's age key first. + exitCode = + read("private-report") === "artifact" + ? failRun(io, ARTIFACT_REFUSED, describeCliProblem) + : await parseConfig(read, host.env).match( + (cfg) => execute(cfg, io), + async (problem) => failRun(io, problem, describeCliProblem), + ); + flush(); + }); + } + + program + .command("validate") + .description(DESCRIPTION.validate) + .argument("", "the settings file to validate") + .action(function (this: Command, file: string) { + const globals = this.optsWithGlobals(); + const { io } = openIo(globals); + const rendered = validateFile(file, io); + present(rendered, globals); + exitCode = rendered.code; + }); + + program + .command("permissions") + .description(DESCRIPTION.permissions) + .argument("", "the settings file whose sections decide the grant") + .action(function (this: Command, file: string) { + const globals = this.optsWithGlobals(); + const { io } = openIo(globals); + const rendered = permissionsFor(file, io, paint.bold); + present(rendered, globals); + exitCode = rendered.code; + }); + + return { program, exitCode: () => exitCode }; +} + +/** Run `argv` (the full process.argv shape) to its exit code; every line, a crash's included, is masked. */ +export async function main( + argv: readonly string[], + options: Omit & { readonly streams: CliStreams }, +): Promise { + const streams = maskedStreams(options.streams); + for (const token of tokenValues(argv)) { + streams.mask(token); + } + const { program, exitCode } = buildProgram({ ...options, streams }); + try { + await program.parseAsync(argv); + } catch (error) { + if (error instanceof CommanderError) { + return error.exitCode; + } + const verbose = program.opts().verbose === true; + const detail = verbose && error instanceof Error && error.stack ? error.stack : String(error); + // Under --verbose the stack is already printed, so asking for it again would loop. + const remedy = verbose + ? "The stack above is the report: if it recurs, file a bug with it attached" + : "Re-run with --verbose for the stack; if it recurs, file a bug with that output attached"; + streams.stderr.write( + `error: github-settings-as-code stopped unexpectedly: ${detail}. ${remedy}\n`, + ); + return 1; + } + return exitCode(); +} diff --git a/src/flows/deliver.ts b/src/flows/deliver.ts index b525e74c..21d35e74 100644 --- a/src/flows/deliver.ts +++ b/src/flows/deliver.ts @@ -214,9 +214,16 @@ export function concludeRun(io: Io, run: FinishedRun): number { return conclude(io, views, run.mode === "check"); } -/** A run that failed before any target ran gets a failed target's conclusion and no summary; the one place a fatal problem becomes text. */ -export function failRun(io: Io, problem: Problem): number { - const message = describeProblem(problem); +/** + * A run that failed before any target ran gets a failed target's conclusion and no summary; the one place a fatal problem becomes text. + * `describe` is the action's wording unless the caller's face (the command line) words a remedy differently. + */ +export function failRun( + io: Io, + problem: Problem, + describe: (problem: Problem) => string = describeProblem, +): number { + const message = describe(problem); io.annotate("error", message); // The mode may be unknown here (a config error); a failure exits 1 under either. return conclude(io, [failedTarget(message)], false); diff --git a/src/flows/inputs.ts b/src/flows/inputs.ts index b458747a..0e737b48 100644 --- a/src/flows/inputs.ts +++ b/src/flows/inputs.ts @@ -50,6 +50,12 @@ export interface InputDecl { * fallback, or the effective value for an empty raw default. */ readonly shownDefault?: string; + /** + * A comma- or newline-separated list. parseConfig reads such an input only + * through its list() port (repos is split by the target resolver instead), + * and the CLI lets the flag repeat; a single-value input has no `list`. + */ + readonly list?: true; } /** @@ -83,6 +89,7 @@ export const INPUT_DECLS = { default: DEFAULT_SETTINGS_FILE, summary: "Settings file path (single-repo mode); in `mode: merge`, the ordered list of layers to fold, low to high", + list: true, }, mode: { description: @@ -122,6 +129,7 @@ export const INPUT_DECLS = { "front, because the run could never attempt it.", default: "", summary: "Sections that must fully apply even under `warn`", + list: true, }, sections: { description: @@ -130,6 +138,7 @@ export const INPUT_DECLS = { summary: "Comma-separated allowlist of sections to process (apply and check only; rejected in `mode: merge`)", shownDefault: "(all declared)", + list: true, }, "api-version": { description: @@ -147,6 +156,7 @@ export const INPUT_DECLS = { default: "", summary: "Multi-repo remote mode: `owner/name` list (comma/newline), or `*` to discover owned repos", + list: true, }, "repos-dir": { description: @@ -256,12 +266,14 @@ export const INPUT_DECLS = { default: "", summary: "Discovery-only: `*` wildcard patterns (name, or `owner/name` if the pattern has a `/`) to drop", + list: true, }, topics: { description: 'Comma- or newline-separated topics; repos: "*" discovery keeps only repositories carrying at least one of them. Unrelated to the topics settings section. Fails if set without repos: "*".', default: "", summary: "Discovery-only: keep repositories carrying at least one listed topic", + list: true, }, affiliation: { description: @@ -272,11 +284,17 @@ export const INPUT_DECLS = { default: "", summary: "Discovery-only: `owner`, `collaborator`, `organization_member` (comma list)", shownDefault: `\`${DEFAULT_DISCOVERY_FILTERS.affiliation.join(",")}\``, + list: true, }, } as const satisfies Record; export type InputName = keyof typeof INPUT_DECLS; +/** The inputs declared `list: true`, the only names the list() port accepts. */ +type ListInput = { + [K in InputName]: (typeof INPUT_DECLS)[K] extends { readonly list: true } ? K : never; +}[InputName]; + /** Empty when unset; parseConfig trims, so a port need not. */ export type InputReader = (name: InputName) => string; @@ -291,12 +309,15 @@ export type ConfigEnv = Readonly>; interface Inputs { readonly value: InputReader; readonly orDefault: (name: InputName) => string; + /** A declared list input, split; the default when unset. */ + readonly list: (name: ListInput) => string[]; } function inputs(read: InputReader): Inputs { // The runner's getInput trims; a CLI's port may not. Trimming here gives every port one rule. const value: InputReader = (name) => read(name).trim(); - return { value, orDefault: (name) => value(name) || INPUT_DECLS[name].default }; + const orDefault = (name: InputName): string => value(name) || INPUT_DECLS[name].default; + return { value, orDefault, list: (name) => splitList(orDefault(name)) }; } export const FILTER_INPUTS = [ @@ -354,7 +375,7 @@ function readSectionSelection(input: Inputs): Result const sectionInputs = ["required-sections", "sections"] as const; const names = sectionInputs.map((name) => ({ input: name, - names: splitList(input.value(name)), + names: input.list(name), })); const knownSections = new Set(SECTION_KEYS); const unknown = names @@ -436,7 +457,7 @@ function parseMergeConfig(input: Inputs): Result input.value(name) !== ""); - const list = (name: FilterInput): string[] => splitList(input.value(name)); const visibility = yield* readEnum( input, "visibility", @@ -534,7 +554,7 @@ export function parseConfig(read: InputReader, env: ConfigEnv): Result !(AFFILIATIONS as readonly string[]).includes(entry), ); @@ -545,7 +565,7 @@ export function parseConfig(read: InputReader, env: ConfigEnv): Result { const parts = pattern.split("/"); return parts.length > 2 || (parts.length === 2 && (!parts[0] || !parts[1])); @@ -558,7 +578,7 @@ export function parseConfig(read: InputReader, env: ConfigEnv): Result 0 ? affiliation : DEFAULT_DISCOVERY_FILTERS.affiliation, - topics: list("topics").map((topic) => topic.toLowerCase()), + topics: input.list("topics").map((topic) => topic.toLowerCase()), exclude, }; diff --git a/test/cli/backpressure.test.ts b/test/cli/backpressure.test.ts new file mode 100644 index 00000000..14f03a4b --- /dev/null +++ b/test/cli/backpressure.test.ts @@ -0,0 +1,59 @@ +/** + * The redacting stream honors the target's backpressure: a chunk the target + * cannot take yet is acknowledged only once it drains, and nothing is lost + * or reordered while the target is full. + */ + +import { describe, expect, test } from "bun:test"; +import { Writable } from "node:stream"; +import { maskedStreams } from "../../src/cli/io.js"; + +/** A target that accepts a chunk only when released; every accepted chunk is kept. */ +function stalledTarget(): { stream: Writable; release(): void; chunks: string[] } { + const chunks: string[] = []; + let pending: (() => void) | null = null; + const stream = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + chunks.push(String(chunk)); + pending = callback; + }, + }); + return { + stream, + chunks, + release() { + const done = pending; + pending = null; + done?.(); + }, + }; +} + +describe("the redacting stream under backpressure", () => { + test("holds the next chunk until the target drains, acknowledging each only when the target took it", async () => { + const target = stalledTarget(); + const streams = maskedStreams({ + stdout: target.stream, + stderr: new Writable({ write: (_c, _e, cb) => cb() }), + }); + streams.mask("secret"); + let acknowledged = 0; + streams.stdout.write("one secret\n", () => acknowledged++); + streams.stdout.write("two\n", () => acknowledged++); + await new Promise((resolve) => setImmediate(resolve)); + // The target is stalled on the first chunk, so the wrapper still holds both + // (the first in flight, the second queued) and has acknowledged nothing; a + // wrapper that acknowledges at once would hold nothing and have fired both. + expect(target.chunks).toEqual(["one ***\n"]); + expect(streams.stdout.writableLength).toBe("one secret\n".length + "two\n".length); + expect(acknowledged).toBe(0); + target.release(); + await new Promise((resolve) => setImmediate(resolve)); + expect(target.chunks).toEqual(["one ***\n", "two\n"]); + expect(acknowledged).toBe(1); + target.release(); + await new Promise((resolve) => setImmediate(resolve)); + expect(acknowledged).toBe(2); + }); +}); diff --git a/test/cli/inputs.test.ts b/test/cli/inputs.test.ts new file mode 100644 index 00000000..e4c8595a --- /dev/null +++ b/test/cli/inputs.test.ts @@ -0,0 +1,544 @@ +/** + * The argv port is the action's env port under another spelling: for a table + * of input sets covering every RunConfig arm, parseConfig over the CLI's + * parsed flags equals parseConfig over a record shaped like the runner's + * inputs, on the ok and the error path alike. The per-mode flag split is + * proven against parseConfig's own acceptance, and the help text is pinned + * to INPUT_DECLS so every input is reachable from exactly the commands whose + * mode reads it. + */ + +import { beforeAll, describe, expect, test } from "bun:test"; +import { generateX25519Identity, identityToRecipient } from "age-encryption"; +import { ARTIFACT_REFUSED, describeCliProblem } from "../../src/cli/commands.js"; +import { + CLI_UNSUPPORTED_INPUTS, + exposedInputs, + inputDescription, + inputsForMode, + isList, +} from "../../src/cli/inputs.js"; +import { maskedStreams } from "../../src/cli/io.js"; +import { buildProgram, CLI_COMMANDS, main } from "../../src/cli/program.js"; +import { + type ConfigEnv, + describeProblem, + INPUT_DECLS, + type InputDecl, + type InputName, + MODES, + type Mode, + parseConfig, + parseReposInput, + type RunConfig, +} from "../../src/index.js"; +import { MockApi } from "../mock-api.js"; +import { memoryStream } from "./streams.js"; + +type Inputs = Partial>; + +/** The action's port: the runner hands back the set input or an empty string. */ +const recordReader = (inputs: Inputs) => (name: InputName) => inputs[name] ?? ""; + +let recipient = ""; +beforeAll(async () => { + recipient = await identityToRecipient(await generateX25519Identity()); +}); + +interface Case { + readonly name: string; + /** The argv after the program name: the subcommand and its flags. */ + readonly argv: readonly string[]; + /** The same inputs as the runner would present them. */ + readonly inputs: Inputs; + readonly env?: ConfigEnv; + /** + * A flag the subcommand does not expose: commander refuses it before + * parseConfig runs, where the action's path refuses the same input by name. + */ + readonly unknownFlag?: InputName; + /** A value the action accepts and only the CLI refuses, with the line it prints. */ + readonly cliRefuses?: string; +} + +/** One case per RunConfig arm and per path a flag can take, plus the rejections. */ +function cases(): Case[] { + return [ + { + name: "check, one repository, token from the environment", + argv: ["check", "--repository", "o/r", "--settings-file", "s.yml"], + inputs: { mode: "check", repository: "o/r", "settings-file": "s.yml" }, + env: { GITHUB_TOKEN: "ghp_env" }, + }, + { + name: "check, the workflow's own repository from GITHUB_REPOSITORY", + argv: ["check", "--token", "ghp_flag"], + inputs: { mode: "check", token: "ghp_flag" }, + env: { + GITHUB_REPOSITORY: "o/self", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_RUN_ID: "7", + }, + }, + { + name: "apply, discovery with every filter and a repeated flag", + argv: [ + "apply", + "--token", + "ghp_flag", + "--repos", + "*", + "--visibility", + "public", + "--archived", + "include", + "--forks", + "exclude", + "--exclude", + "x/*", + "--exclude", + "tmp-*", + "--topics", + "a,b", + "--affiliation", + "owner,collaborator", + "--on-missing-permission", + "warn", + "--required-sections", + "labels", + "--sections", + "labels,milestones", + "--api-version", + "2030-01-01", + "--private-repos", + "redact", + "--private-report", + "issue-on-failure", + "--defaults-file", + "d.yml", + ], + inputs: { + mode: "apply", + token: "ghp_flag", + repos: "*", + visibility: "public", + archived: "include", + forks: "exclude", + exclude: "x/*\ntmp-*", + topics: "a,b", + affiliation: "owner,collaborator", + "on-missing-permission": "warn", + "required-sections": "labels", + sections: "labels,milestones", + "api-version": "2030-01-01", + "private-repos": "redact", + "private-report": "issue-on-failure", + "defaults-file": "d.yml", + }, + env: { GITHUB_REPOSITORY: "o/admin" }, + }, + { + name: "check, central repos-dir with the issue channel", + argv: ["check", "--token", "ghp_flag", "--repos-dir", "repos", "--private-report", "issue"], + inputs: { mode: "check", token: "ghp_flag", "repos-dir": "repos", "private-report": "issue" }, + env: { GITHUB_REPOSITORY: "o/admin" }, + }, + { + name: "refused by the CLI alone: the artifact channel", + argv: [ + "check", + "--token", + "ghp_flag", + "--repos-dir", + "repos", + "--private-report", + "artifact", + ], + inputs: { + mode: "check", + token: "ghp_flag", + "repos-dir": "repos", + "private-report": "artifact", + }, + env: { GITHUB_REPOSITORY: "o/admin" }, + cliRefuses: describeCliProblem(ARTIFACT_REFUSED), + }, + { + name: "merge, layers as repeated flags", + argv: [ + "merge", + "--settings-file", + "a.yml", + "--settings-file", + "b.yml", + "--merged-file", + "out.yml", + "--layering", + "replace", + ], + inputs: { + mode: "merge", + "settings-file": "a.yml\nb.yml", + "merged-file": "out.yml", + layering: "replace", + }, + }, + { + name: "merge, layers as one comma list, a token tolerated", + argv: [ + "merge", + "--token", + "ghp_flag", + "--settings-file", + "a.yml,b.yml", + "--merged-file", + "o.yml", + ], + inputs: { + mode: "merge", + token: "ghp_flag", + "settings-file": "a.yml,b.yml", + "merged-file": "o.yml", + }, + }, + { + name: "rejected: a merge-only flag on check", + argv: ["check", "--token", "ghp_flag", "--merged-file", "out.yml"], + inputs: { mode: "check", token: "ghp_flag", "merged-file": "out.yml" }, + unknownFlag: "merged-file", + }, + { + name: "rejected: an engine flag on merge", + argv: ["merge", "--repository", "o/r", "--merged-file", "out.yml"], + inputs: { mode: "merge", repository: "o/r", "merged-file": "out.yml" }, + unknownFlag: "repository", + }, + { + name: "rejected: no token anywhere", + argv: ["check", "--repository", "o/r"], + inputs: { mode: "check", repository: "o/r" }, + }, + { + name: "rejected: a filter without discovery", + argv: ["apply", "--token", "ghp_flag", "--repository", "o/r", "--visibility", "public"], + inputs: { mode: "apply", token: "ghp_flag", repository: "o/r", visibility: "public" }, + }, + { + name: "rejected: an unsupported enum value", + argv: ["check", "--token", "ghp_flag", "--on-missing-permission", "ignore"], + inputs: { mode: "check", token: "ghp_flag", "on-missing-permission": "ignore" }, + env: { GITHUB_REPOSITORY: "o/self" }, + }, + ]; +} + +/** Drive the CLI on `argv` and capture what its argv port parsed, or what it printed. */ +async function throughArgv(argv: readonly string[], env: ConfigEnv) { + let parsed: RunConfig | undefined; + const stdout = memoryStream(); + const stderr = memoryStream(); + const code = await main(["node", "gsac", ...argv], { + host: { env, createClient: () => new MockApi({}) }, + streams: { stdout: stdout.stream, stderr: stderr.stream }, + colors: false, + execute: async (cfg) => { + parsed = cfg; + return 0; + }, + }); + return { parsed, code, stderr: stderr.text() }; +} + +describe("argv -> config equals env -> config", () => { + test("for every RunConfig arm and every rejection", async () => { + for (const { name, argv, inputs, env = {}, unknownFlag, cliRefuses } of cases()) { + const expected = parseConfig(recordReader(inputs), env); + const actual = await throughArgv(argv, env); + if (cliRefuses !== undefined) { + // The action would ask for the channel's key next; the CLI refuses the channel itself. + expect(actual, name).toEqual({ + parsed: undefined, + code: 1, + stderr: `error: ${cliRefuses}\n`, + }); + continue; + } + if (expected.isOk()) { + expect(actual.parsed, name).toEqual(expected.value); + expect(actual.code, name).toBe(0); + continue; + } + expect(actual.parsed, name).toBeUndefined(); + expect(actual.code, name).toBe(1); + if (unknownFlag === undefined) { + expect(actual.stderr, name).toBe(`error: ${describeCliProblem(expected.error)}\n`); + } else { + expect(actual.stderr, name).toStartWith(`error: unknown option '--${unknownFlag}'`); + } + } + }); + + test("the table reaches every arm, both paths of the token, and the rejections", () => { + // The pin above is only as wide as its table; hold the table to the arms. + const kinds = new Set(); + let envToken = 0; + let rejected = 0; + for (const { argv, inputs, env = {}, cliRefuses } of cases()) { + const result = parseConfig(recordReader(inputs), env); + if (result.isErr() || cliRefuses !== undefined) { + rejected++; + continue; + } + kinds.add(result.value.kind); + if (result.value.kind !== "merge" && !argv.includes("--token")) { + envToken++; + } + } + expect([...kinds].sort()).toEqual(["merge", "multi", "single"]); + expect(envToken).toBeGreaterThan(0); + expect(rejected).toBeGreaterThanOrEqual(5); + }); +}); + +/** A value each input accepts, so a flag can be exercised alone. */ +const VALID: Record, string> = { + repository: "o/r", + "settings-file": "s.yml", + "merged-file": "out.yml", + "on-missing-permission": "warn", + "required-sections": "labels", + sections: "labels", + "api-version": "2030-01-01", + repos: "*", + "repos-dir": "repos", + "defaults-file": "d.yml", + layering: "replace", + "private-repos": "show", + "private-report": "issue", + "report-public-key": "", + visibility: "public", + archived: "only", + forks: "only", + exclude: "x/*", + topics: "a", + affiliation: "collaborator", +}; + +/** The inputs one flag needs beside it to be accepted at all. */ +const COMPANIONS: Partial> = { + "defaults-file": { repos: "*" }, + visibility: { repos: "*" }, + archived: { repos: "*" }, + forks: { repos: "*" }, + exclude: { repos: "*" }, + topics: { repos: "*" }, + affiliation: { repos: "*" }, + "report-public-key": { "private-report": "artifact" }, +}; + +/** The smallest input set each mode accepts. */ +function base(mode: Mode): Inputs { + return mode === "merge" ? { mode, "merged-file": "out.yml" } : { mode, token: "ghp" }; +} + +describe("the per-mode flag split", () => { + test("every flag a subcommand exposes is one its mode accepts, and every hidden one is rejected", () => { + const env: ConfigEnv = { GITHUB_REPOSITORY: "o/self" }; + for (const mode of MODES) { + const exposed = new Set(inputsForMode(mode)); + for (const name of Object.keys(VALID) as (keyof typeof VALID)[]) { + const value = name === "report-public-key" ? recipient : VALID[name]; + if (exposed.has(name)) { + const result = parseConfig( + recordReader({ ...base(mode), ...COMPANIONS[name], [name]: value }), + env, + ); + expect( + result.isOk(), + `${mode} --${name}: ${result.match(() => "", describeProblem)}`, + ).toBe(true); + } else { + const result = parseConfig(recordReader({ ...base(mode), [name]: value }), env); + expect(result.isErr(), `${mode} --${name} should be rejected by parseConfig`).toBe(true); + expect( + result.match( + () => "", + (problem) => problem.code, + ), + `${mode} --${name}`, + ).toMatch(/^input-(merge-only|rejected-in-merge|report-key-unused)$/); + } + } + } + }); + + /** + * One repeated-argv case per input, keyed over every InputName so a new + * input must say whether it repeats: a list input names the subcommand and + * companions, the pair a repeated flag carries, and the library's own + * reading of the parsed config (repos is split by parseReposInput); a + * single-value input is "single" and its repeat must be refused. + */ + interface Repeated { + readonly argv: readonly string[]; + readonly pair: readonly [string, string]; + readonly kept: (cfg: RunConfig) => readonly string[]; + readonly env?: ConfigEnv; + } + const check = ["check", "--token", "ghp", "--repository", "o/r"]; + const discovery = ["check", "--token", "ghp", "--repos", "*"]; + const REPEATED: Record = { + token: "single", + repository: "single", + "settings-file": { + argv: ["merge", "--merged-file", "out.yml"], + pair: ["a.yml", "b.yml"], + kept: (cfg) => (cfg.kind === "merge" ? cfg.settingsFiles : []), + }, + mode: "single", + "merged-file": "single", + "on-missing-permission": "single", + "required-sections": { + argv: check, + pair: ["labels", "milestones"], + kept: (cfg) => (cfg.kind === "merge" ? [] : [...cfg.sections.required]), + }, + sections: { + argv: check, + pair: ["labels", "milestones"], + kept: (cfg) => (cfg.kind === "merge" ? [] : [...cfg.sections.only]), + }, + "api-version": "single", + repos: { + argv: ["check", "--token", "ghp"], + pair: ["o/a", "o/b"], + kept: (cfg) => + cfg.kind === "multi" + ? parseReposInput(cfg.reposInput) + .map((r) => r.slugs) + .unwrapOr([]) + : [], + env: { GITHUB_REPOSITORY: "o/admin" }, + }, + "repos-dir": "single", + "defaults-file": "single", + layering: "single", + "private-repos": "single", + "private-report": "single", + "report-public-key": "single", + visibility: "single", + archived: "single", + forks: "single", + exclude: { + argv: discovery, + pair: ["x/*", "tmp-*"], + kept: (cfg) => (cfg.kind === "multi" ? cfg.discoveryFilters.exclude : []), + }, + topics: { + argv: discovery, + pair: ["a", "b"], + kept: (cfg) => (cfg.kind === "multi" ? cfg.discoveryFilters.topics : []), + }, + affiliation: { + argv: discovery, + pair: ["owner", "collaborator"], + kept: (cfg) => (cfg.kind === "multi" ? cfg.discoveryFilters.affiliation : []), + }, + }; + + test("a repeated flag accumulates exactly for the inputs declared list, and is refused for the rest", async () => { + const exposed = new Set(exposedInputs()); + for (const name of Object.keys(REPEATED) as InputName[]) { + const repeated = REPEATED[name]; + const decl: InputDecl = INPUT_DECLS[name]; + // The record and the declaration agree on which inputs are lists. + expect(repeated !== "single", `${name} declares list: ${decl.list}`).toBe(decl.list === true); + expect(isList(name), name).toBe(decl.list === true); + if (repeated === "single") { + if (!exposed.has(name)) { + continue; // the mode is the subcommand; an unsupported input has no flag + } + // The base argv minus this flag, so the two repeats below are its only occurrences. + const base = inputsForMode("check").includes(name) || name === "token" ? check : ["merge"]; + const flag = base.filter((argument, index) => { + const value = index > 0 && base[index - 1] === `--${name}`; + return argument !== `--${name}` && !value; + }); + // Long values: a token value is masked, and a one-letter mask would eat the message. + const actual = await throughArgv( + [...flag, `--${name}`, "first-value", `--${name}`, "second-value"], + {}, + ); + // The token's echoed value is masked (registered before the parse); the rest print as given. + const echoed = name === "token" ? "***" : "second-value"; + expect(actual.code, name).toBe(1); + expect(actual.stderr, name).toBe( + `error: option '--${name} ' argument '${echoed}' is invalid. --${name} takes one value and was given more than once\n`, + ); + continue; + } + const [first, second] = repeated.pair; + const actual = await throughArgv( + [...repeated.argv, `--${name}`, first, `--${name}`, second], + repeated.env ?? {}, + ); + expect(actual.stderr, name).toBe(""); + expect(actual.parsed, name).toBeDefined(); + expect(actual.parsed === undefined ? [] : repeated.kept(actual.parsed), name).toEqual([ + first, + second, + ]); + } + }); + + test("every declared input is the mode, the global token, a flag of some subcommand, or named unsupported", () => { + expect(new Set([...exposedInputs(), "mode", ...CLI_UNSUPPORTED_INPUTS])).toEqual( + new Set(Object.keys(INPUT_DECLS)), + ); + }); +}); + +describe("the help text", () => { + const { program } = buildProgram({ + host: { env: {}, createClient: () => new MockApi({}) }, + streams: maskedStreams({ stdout: memoryStream().stream, stderr: memoryStream().stream }), + colors: false, + }); + + test("lists every subcommand and the global token flag", () => { + const help = program.helpInformation(); + for (const command of CLI_COMMANDS) { + expect(help, command).toMatch(new RegExp(`^ ${command}\\b`, "m")); + } + expect(help).toContain("--token "); + expect(help).toContain("--json"); + expect(help).toContain("--summary "); + }); + + test("each mode's subcommand carries exactly its INPUT_DECLS flags, each with its full description", () => { + for (const mode of MODES) { + const command = program.commands.find((candidate) => candidate.name() === mode); + if (command === undefined) { + throw new Error(`no ${mode} subcommand`); + } + const options = command.options.filter((option) => option.long !== "--help"); + expect(options.map((option) => option.long).sort(), mode).toEqual( + inputsForMode(mode) + .map((name) => `--${name}`) + .sort(), + ); + for (const option of options) { + const name = option.long?.slice(2) as InputName; + expect(option.flags, `${mode} ${name}`).toBe(`--${name} `); + expect(option.description, `${mode} ${name}`).toBe(inputDescription(name)); + } + } + }); + + test("the repository flag's help names the terminal's requirement, not the runner's default", () => { + const description = inputDescription("repository"); + expect(description).toContain("Required unless repos or repos-dir is set"); + expect(description).not.toContain("Defaults to the current repository"); + expect(description).toStartWith(INPUT_DECLS.repository.description.split(".")[0] ?? ""); + }); +}); diff --git a/test/cli/io.test.ts b/test/cli/io.test.ts new file mode 100644 index 00000000..e786fd05 --- /dev/null +++ b/test/cli/io.test.ts @@ -0,0 +1,159 @@ +/** + * The CLI's Io: where each channel lands, the `level: message` shape, the + * json routing, the summary file, the debug gate, and the masking every + * channel goes through (no runner masks for a terminal). + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type CliIoOptions, cliIo, maskedStreams } from "../../src/cli/io.js"; +import { memoryStream } from "./streams.js"; + +const scratch: string[] = []; +afterEach(() => { + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function open(options: Partial> = {}) { + const stdout = memoryStream(); + const stderr = memoryStream(); + const opened = cliIo({ + streams: maskedStreams({ stdout: stdout.stream, stderr: stderr.stream }), + json: false, + verbose: false, + colors: false, + ...options, + }); + return { ...opened, stdout: stdout.text, stderr: stderr.text }; +} + +/** Every channel written once, the way a run writes them. */ +function exercise(io: ReturnType["io"]): void { + io.log("labels: 2 in sync"); + io.annotate("notice", "nothing to do"); + io.annotate("warning", "skipped teams"); + io.annotate("error", "PATCH /repos/o/r failed: 500 boom"); + io.debug("GET /repos/o/r -> 200"); + io.output("skipped-sections", "teams"); + io.output("result", "partial"); +} + +describe("the CLI Io", () => { + test("logs land on stdout, annotations on stderr as level: message, outputs as name=value lines", () => { + const { io, flush, stdout, stderr } = open(); + exercise(io); + flush(); + expect(stdout()).toBe("labels: 2 in sync\nskipped-sections=teams\nresult=partial\n"); + expect(stderr()).toBe( + "notice: nothing to do\nwarning: skipped teams\nerror: PATCH /repos/o/r failed: 500 boom\n", + ); + }); + + test("--json prints the outputs as one object on stdout and moves the log lines to stderr", () => { + const { io, flush, stdout, stderr } = open({ json: true }); + exercise(io); + flush(); + expect(JSON.parse(stdout())).toEqual({ "skipped-sections": "teams", result: "partial" }); + expect(stdout().split("\n")).toHaveLength(2); + expect(stderr()).toBe( + "labels: 2 in sync\nnotice: nothing to do\nwarning: skipped teams\nerror: PATCH /repos/o/r failed: 500 boom\n", + ); + }); + + test("--verbose shows the debug trace on stderr; without it the trace is dropped", () => { + const quiet = open(); + quiet.io.debug("GET /repos/o/r -> 200"); + expect(quiet.stderr()).toBe(""); + const loud = open({ verbose: true }); + loud.io.debug("GET /repos/o/r -> 200"); + expect(loud.stderr()).toBe("debug: GET /repos/o/r -> 200\n"); + }); + + test("colored labels wrap only the level word", () => { + const { io, stderr } = open({ colors: true }); + io.annotate("error", "boom"); + expect(stderr()).toBe("error: boom\n"); + }); + + test("a masked value is redacted on every channel, the outputs included", () => { + const dir = mkdtempSync(join(tmpdir(), "gsac-io-")); + scratch.push(dir); + const summary = join(dir, "summary.md"); + const { io, flush, stdout, stderr } = open({ summaryFile: summary, verbose: true }); + io.mask("ghp_secret"); + io.log("token ghp_secret in a log line"); + io.annotate("error", "401 for ghp_secret"); + io.debug("Authorization: token ghp_secret"); + io.summary("# run by ghp_secret"); + io.output("result", "failed ghp_secret"); + flush(); + const everything = stdout() + stderr() + readFileSync(summary, "utf8"); + expect(everything).not.toContain("ghp_secret"); + expect(stdout()).toBe("token *** in a log line\nresult=failed ***\n"); + expect(stderr()).toBe("error: 401 for ***\ndebug: Authorization: token ***\n"); + expect(readFileSync(summary, "utf8")).toBe("# run by ***\n"); + }); + + test.each<[string, string[], string, string]>([ + [ + "a prefix of another value", + ["github_pat_ABC", "github_pat_ABCDEF"], + "argument 'github_pat_ABCDEF' repeats github_pat_ABC", + "argument '***' repeats ***", + ], + ["two values overlapping across the text", ["ABC", "BCD"], "x ABCD y", "x *** y"], + ["an infix of another value", ["BC", "xABCDEy"], "see xABCDEy and BC", "see *** and ***"], + ["two values touching end to start", ["ABC", "DEF"], "ABCDEF", "***"], + ["separate occurrences of one value", ["ABC"], "ABC and ABC", "*** and ***"], + ["a value absent from the line", ["ABC"], "nothing here", "nothing here"], + ])("masking leaves no fragment of %s", (_case, masks, line, redacted) => { + // Replacing one value after another would leave "***D" for the overlap and + // "***DEF" for the prefix; the ranges are merged in the original text instead. + const { io, stderr } = open(); + for (const value of masks) { + io.mask(value); + } + io.annotate("error", line); + expect(stderr()).toBe(`error: ${redacted}\n`); + }); + + test("summary blocks append to the named file and are dropped without one", () => { + const dir = mkdtempSync(join(tmpdir(), "gsac-io-")); + scratch.push(dir); + const summary = join(dir, "summary.md"); + const named = open({ summaryFile: summary }); + named.io.summary("## first"); + named.io.summary("## second"); + expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); + // Without a file the block goes nowhere: no stream carries it, and the working + // directory (where a defaulted path would land) stays empty. + const cwd = mkdtempSync(join(tmpdir(), "gsac-io-cwd-")); + scratch.push(cwd); + const previous = process.cwd(); + process.chdir(cwd); + try { + const unnamed = open(); + unnamed.io.summary("## dropped"); + expect(unnamed.stdout() + unnamed.stderr()).toBe(""); + expect(readdirSync(cwd)).toEqual([]); + } finally { + process.chdir(previous); + } + expect(readdirSync(dir)).toEqual(["summary.md"]); + expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); + }); + + test("identical consecutive lines are all printed, never folded", () => { + // consola folds repeats within a second by default; a drift report with + // the same line per target must keep every one. + const { io, stderr } = open(); + for (let i = 0; i < 8; i++) { + io.annotate("warning", "same line"); + } + expect(stderr()).toBe("warning: same line\n".repeat(8)); + }); +}); diff --git a/test/cli/program.test.ts b/test/cli/program.test.ts new file mode 100644 index 00000000..86ea35df --- /dev/null +++ b/test/cli/program.test.ts @@ -0,0 +1,400 @@ +/** + * The command tree end to end: each subcommand through main() against a + * stub client, holding the exit codes and outputs to the action's, the two + * file-only commands to their verdicts, and the token to never appearing in + * what the CLI prints. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { CLI_COMMANDS, main } from "../../src/cli/program.js"; +import { type ConfigEnv, type GithubClient, sectionGrant, sectionModule } from "../../src/index.js"; +import { MockApi } from "../mock-api.js"; +import { memoryStream } from "./streams.js"; + +const ROOT = join(import.meta.dir, "..", ".."); +const SINGLE = join(ROOT, "test", "fixtures", "single.yml"); +const LAYERS = join(ROOT, "test", "fixtures", "layers"); +const TOKEN = "ghp_cli_test_token"; + +const scratch: string[] = []; +afterEach(() => { + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "gsac-cli-")); + scratch.push(dir); + return dir; +} + +async function cli( + args: readonly string[], + api: GithubClient = new MockApi({}), + env: ConfigEnv = {}, +) { + const stdout = memoryStream(); + const stderr = memoryStream(); + const code = await main(["node", "gsac", ...args], { + host: { env, createClient: () => api }, + streams: { stdout: stdout.stream, stderr: stderr.stream }, + colors: false, + }); + return { code, stdout: stdout.text(), stderr: stderr.text() }; +} + +describe("check and apply", () => { + const target = ["--repository", "o/r", "--settings-file", SINGLE, "--token", TOKEN]; + + test("check: clean exits 0 with result=clean, drift exits 1 with result=drift", async () => { + // `private: false` proves the target public, so its lines print in the clear + // under the action's default redaction; a terminal has no GITHUB_REPOSITORY to exempt. + const clean = await cli( + ["check", ...target], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: false, private: false } } }), + ); + expect(clean).toEqual({ + code: 0, + stdout: "result: clean\nskipped-sections=\nresult=clean\n", + stderr: "", + }); + const drifted = await cli( + ["check", ...target], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: true, private: false } } }), + ); + expect(drifted.code).toBe(1); + // Drift lines are log lines: stdout, with the outputs closing the run. + expect(drifted.stdout).toContain("has_wiki"); + expect(drifted.stdout).toEndWith("result: drift\nskipped-sections=\nresult=drift\n"); + expect(drifted.stderr).toBe(""); + }); + + test("a target the probe cannot prove public is redacted, as the action redacts it", async () => { + const result = await cli( + ["check", ...target], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: true } } }), + ); + expect(result.code).toBe(1); + expect(result.stderr).toStartWith( + "warning: private repository: drift - repository. details hidden", + ); + expect(result.stderr).not.toContain("has_wiki"); + }); + + test("apply patches the declared keys, exits 0, and reports applied", async () => { + const api = new MockApi({ "GET /repos/o/r": { data: { has_wiki: true } } }).allowMutations( + "PATCH /repos/o/r", + ); + const applied = await cli(["apply", ...target], api); + expect(applied.code).toBe(0); + expect(applied.stdout).toEndWith("result=applied\n"); + expect(api.mutations()).toEqual([ + { method: "PATCH", path: "/repos/o/r", payload: { has_wiki: false } }, + ]); + }); + + test("--json prints the outputs as one object and nothing else on stdout", async () => { + const result = await cli( + ["check", ...target, "--json"], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: false } } }), + ); + expect(result.code).toBe(0); + expect(result.stderr).toBe("result: clean\n"); + expect(JSON.parse(result.stdout)).toEqual({ "skipped-sections": "", result: "clean" }); + }); + + test("the token comes from GITHUB_TOKEN when no flag names it", async () => { + const result = await cli( + ["check", "--repository", "o/r", "--settings-file", SINGLE], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: false } } }), + { GITHUB_TOKEN: TOKEN }, + ); + expect(result.code).toBe(0); + }); + + test("a config problem fails before any API call, the action's line on stderr", async () => { + const api = new MockApi({}); + const result = await cli(["check", "--repository", "not-a-slug", "--token", TOKEN], api); + expect(result.code).toBe(1); + expect(api.calls).toHaveLength(0); + expect(result.stderr).toBe( + 'error: cannot target a repository: "not-a-slug" is not an owner/name slug. Pass --repository owner/name (inside GitHub Actions, GITHUB_REPOSITORY supplies it)\n', + ); + expect(result.stdout).toBe("result: failed\nskipped-sections=\nresult=failed\n"); + }); + + test.each<[string, string[], string]>([ + [ + "no token", + ["check", "--repository", "o/r"], + "cannot call the GitHub API: no token was provided. Pass --token, or export GITHUB_TOKEN", + ], + [ + "no repository outside Actions", + ["check", "--token", TOKEN], + 'cannot target a repository: "" is not an owner/name slug. Pass --repository owner/name (inside GitHub Actions, GITHUB_REPOSITORY supplies it)', + ], + [ + "the artifact channel", + ["check", ...target, "--private-report", "artifact"], + 'the "private-report" input is "artifact", which is not a supported private-report channel from the command line (the artifact upload needs the Actions runner). Set it to "none" (default), "issue", "issue-on-failure"', + ], + ])("%s fails with a remedy a terminal can follow", async (_case, args, message) => { + const api = new MockApi({}); + const result = await cli(args, api); + expect(api.calls).toHaveLength(0); + expect(result).toEqual({ + code: 1, + stdout: "result: failed\nskipped-sections=\nresult=failed\n", + stderr: `error: ${message}\n`, + }); + }); + + test("the token never appears in what the CLI prints, even echoed by the API", async () => { + const api = new MockApi({ + "GET /repos/o/r": { error: { status: 500, message: `bad token ${TOKEN}`, body: "" } }, + }); + const result = await cli(["check", ...target, "--private-repos", "show"], api); + expect(result.code).toBe(1); + expect(result.stdout + result.stderr).not.toContain(TOKEN); + expect(result.stderr).toContain("bad token ***"); + }); + + test.each<[string, string[], ConfigEnv]>([ + ["the parser's unknown-option message", ["check", `--tokn=${TOKEN}`], { GITHUB_TOKEN: TOKEN }], + ["a file-only command's argument, plain", ["validate", TOKEN], { GITHUB_TOKEN: TOKEN }], + [ + "a file-only command's argument, --json", + ["validate", TOKEN, "--json"], + { GITHUB_TOKEN: TOKEN }, + ], + [ + "a config error echoing a padded --token", + ["check", "--token", ` ${TOKEN} `, "--repository", TOKEN], + {}, + ], + ["a config error echoing --token=", ["check", `--token=${TOKEN}`, "--repository", TOKEN], {}], + ])("the token is masked in %s", async (_where, args, env) => { + // Every writer goes through one boundary registered before the parse, so + // a message no code of ours words (the parser's, a path echo) is masked too. + const result = await cli(args, new MockApi({}), env); + expect(result.code).toBe(1); + expect(result.stdout + result.stderr).not.toContain(TOKEN); + expect(result.stdout + result.stderr).toContain("***"); + }); + + test.each([false, true])( + "a failure after the parse is reported through the mask boundary, exit 1 (verbose: %p)", + async (verbose) => { + // The summary file's directory does not exist, so the write throws inside + // the run; the path carries the token, and the report must not. Without + // --verbose the remedy asks for it; with it the stack is the report. + const summary = join(tempDir(), "missing", `${TOKEN}.md`); + const masked = summary.replaceAll(TOKEN, "***"); + const result = await cli( + ["check", ...target, "--summary", summary, ...(verbose ? ["--verbose"] : [])], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: false, private: false } } }), + ); + expect(result.code).toBe(1); + expect(result.stdout + result.stderr).not.toContain(TOKEN); + const message = `Error: ENOENT: no such file or directory, open '${masked}'`; + if (!verbose) { + expect(result.stderr).toBe( + `error: github-settings-as-code stopped unexpectedly: ${message}. Re-run with --verbose for the stack; if it recurs, file a bug with that output attached\n`, + ); + return; + } + // The stack's frames carry this machine's paths, so the line is pinned around them. + expect(result.stderr).toStartWith( + `error: github-settings-as-code stopped unexpectedly: ${message}\n at `, + ); + expect(result.stderr).toEndWith( + ". The stack above is the report: if it recurs, file a bug with it attached\n", + ); + expect(result.stderr).not.toContain("Re-run with --verbose"); + }, + ); + + test("--summary appends the run's markdown to the named file", async () => { + const summary = join(tempDir(), "summary.md"); + const result = await cli( + ["check", ...target, "--summary", summary], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: false } } }), + ); + expect(result.code).toBe(0); + expect(readFileSync(summary, "utf8")).toContain("clean"); + }); +}); + +describe("merge", () => { + test("folds the layers into the merged file, exits 0, and reports merged", async () => { + const out = join(tempDir(), "merged.yml"); + const result = await cli([ + "merge", + "--settings-file", + join(LAYERS, "fleet.yml"), + "--settings-file", + join(LAYERS, "team.yml"), + "--merged-file", + out, + ]); + expect(result.code).toBe(0); + expect(result.stdout).toEndWith("result: merged\nskipped-sections=\nresult=merged\n"); + // The whole document only the fold produces: team.yml's label and rule join + // fleet.yml's under explicit policy wrappers, its `has_projects: null` removes + // fleet.yml's key, and pages passes through untouched. + expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ + repository: { has_wiki: false }, + labels: { + _undeclared: "delete", + entries: [ + { name: "bug", color: "d73a4a" }, + { name: "docs", color: "0075ca" }, + { name: "team", color: "00ff00" }, + ], + }, + rulesets: { + _undeclared: "keep", + entries: [ + { + name: "main", + target: "branch", + enforcement: "active", + rules: [{ type: "deletion" }, { type: "non_fast_forward" }], + }, + ], + }, + pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, + }); + }); + + test("a merge without merged-file fails with the action's line", async () => { + const result = await cli(["merge", "--settings-file", join(LAYERS, "fleet.yml")]); + expect(result.code).toBe(1); + expect(result.stderr).toStartWith('error: mode: merge needs a "merged-file" input'); + expect(result.stdout).toBe("result: failed\nskipped-sections=\nresult=failed\n"); + }); +}); + +describe("validate and permissions", () => { + test("validate: a valid file exits 0 naming its sections; --json gives the verdict as an object", async () => { + const plain = await cli(["validate", SINGLE]); + expect(plain).toEqual({ + code: 0, + stdout: `${SINGLE} is valid: 1 section(s) declared (repository)\n`, + stderr: "", + }); + const json = await cli(["validate", SINGLE, "--json"]); + expect(json.code).toBe(0); + expect(json.stderr).toBe(""); + expect(JSON.parse(json.stdout)).toEqual({ + file: SINGLE, + valid: true, + sections: ["repository"], + }); + }); + + test("validate: an invalid file exits 1 with the validator's line", async () => { + const file = join(tempDir(), "bad.yml"); + writeFileSync(file, "labels:\n - color: d73a4a\n"); + const result = await cli(["validate", file]); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toStartWith(`error: ${file} has malformed section entries:`); + // The same message, once on stderr and once as the object's problem. + const problem = result.stderr.slice("error: ".length, -1); + const json = await cli(["validate", file, "--json"]); + expect(json.code).toBe(1); + expect(json.stderr).toBe(result.stderr); + expect(JSON.parse(json.stdout)).toEqual({ file, valid: false, problem }); + }); + + test("validate: an unreadable file exits 1 naming the path", async () => { + const result = await cli(["validate", join(tempDir(), "missing.yml")]); + expect(result.code).toBe(1); + expect(result.stderr).toStartWith("error: cannot read settings from "); + }); + + test("permissions prints one grant per declared section, from the section declarations", async () => { + const file = join(tempDir(), "settings.yml"); + writeFileSync( + file, + "labels:\n - name: bug\n color: d73a4a\nrepository:\n has_wiki: false\n", + ); + const result = await cli(["permissions", file]); + expect(result).toEqual({ + code: 0, + stdout: `repository: ${sectionGrant(sectionModule("repository"))}\nlabels: ${sectionGrant(sectionModule("labels"))}\n`, + stderr: "", + }); + const json = await cli(["permissions", file, "--json"]); + expect(json.code).toBe(0); + expect(json.stderr).toBe(""); + expect(JSON.parse(json.stdout)).toEqual({ + repository: sectionGrant(sectionModule("repository")), + labels: sectionGrant(sectionModule("labels")), + }); + }); +}); + +describe("commander's own exits", () => { + test("--help exits 0, prints the bin name, and names every subcommand", async () => { + const result = await cli(["--help"]); + expect(result.code).toBe(0); + expect(result.stdout).toStartWith("Usage: github-settings-as-code [options] [command]"); + expect(result.stdout).toContain("gsac"); + for (const command of CLI_COMMANDS) { + expect(result.stdout).toContain(` ${command}`); + } + }); + + test("a repeated single-value flag exits 1 naming the flag; a repeated list flag accumulates", async () => { + const repeated = await cli( + ["check", "--token", TOKEN, "--repository", "a/b", "--repository", "c/d"], + new MockApi({}), + ); + expect(repeated.code).toBe(1); + expect(repeated.stderr).toBe( + "error: option '--repository ' argument 'c/d' is invalid. --repository takes one value and was given more than once\n", + ); + // Both unknown names must be reported: a last-wins parser would name only the second. + const list = await cli([ + "check", + "--token", + TOKEN, + "--repository", + "o/r", + "--sections", + "nope", + "--sections", + "also", + ]); + expect(list.code).toBe(1); + expect(list.stderr).toStartWith( + 'error: unknown sections "nope", "also" in the "sections" input', + ); + // Both token values were registered before the parse, so the echo is masked. + const token = await cli(["check", "--token", "first", "--token", "second"]); + expect(token.code).toBe(1); + expect(token.stderr).toBe( + "error: option '--token ' argument '***' is invalid. --token takes one value and was given more than once\n", + ); + }); + + test("an unknown flag exits 1 with commander's line on stderr", async () => { + const result = await cli(["check", "--tokn", "x"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain("error: unknown option '--tokn'"); + }); + + test("a missing file argument exits 1", async () => { + const result = await cli(["validate"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain("error: missing required argument 'file'"); + }); +}); diff --git a/test/cli/streams.ts b/test/cli/streams.ts new file mode 100644 index 00000000..ca67f87a --- /dev/null +++ b/test/cli/streams.ts @@ -0,0 +1,22 @@ +/** + * The streams the CLI tests hand the program: everything written is kept, so + * a test reads stdout and stderr back as text. + */ + +import { Writable } from "node:stream"; + +export interface MemoryStream { + readonly stream: Writable; + text(): string; +} + +export function memoryStream(): MemoryStream { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk: Buffer | string, _encoding, callback) { + chunks.push(String(chunk)); + callback(); + }, + }); + return { stream, text: () => chunks.join("") }; +} diff --git a/test/docs/guides.test.ts b/test/docs/guides.test.ts index 663671bf..886c254f 100644 --- a/test/docs/guides.test.ts +++ b/test/docs/guides.test.ts @@ -27,6 +27,7 @@ const REQUIRED_PAGES = [ "start/getting-started.md", "start/examples.md", "start/migrating-from-probot.md", + "start/cli.md", "reference/sections.md", "reference/inputs.md", "reference/semantics.md", diff --git a/test/docs/readme.test.ts b/test/docs/readme.test.ts index 6ddaaf55..39f2ff26 100644 --- a/test/docs/readme.test.ts +++ b/test/docs/readme.test.ts @@ -73,6 +73,7 @@ describe("README front door", () => { "docs/start/getting-started.md", "docs/start/examples.md", "docs/start/migrating-from-probot.md", + "docs/start/cli.md", "docs/reference/sections.md", "docs/reference/architecture.md", "docs/reference/library.md", @@ -97,7 +98,7 @@ describe("README front door", () => { test("stays a front door in size", () => { // The tripwire against the reference tables growing back. - expect(readme.split("\n").length).toBeLessThanOrEqual(100); + expect(readme.split("\n").length).toBeLessThanOrEqual(101); }); }); diff --git a/test/package-json.test.ts b/test/package-json.test.ts index bb8c4ce4..3795ce2a 100644 --- a/test/package-json.test.ts +++ b/test/package-json.test.ts @@ -34,25 +34,31 @@ describe("package.json as the npm manifest", () => { }); }); - test("exports the library build, the schema, and its own manifest", () => { + test("exports the library build, the schema, and its own manifest, and both bin names run the CLI build", () => { expect(pkg.exports).toEqual({ ".": { types: "./lib/pkg/index.d.ts", default: "./lib/pkg/index.js" }, "./settings.schema.json": "./lib/settings.schema.json", "./package.json": "./package.json", }); - // No bin yet: a CLI is its own change, with its own smoke. - expect("bin" in pkg).toBe(false); + expect(pkg.bin).toEqual({ + "github-settings-as-code": "lib/pkg/cli.js", + gsac: "lib/pkg/cli.js", + }); }); test("ships the library build, the schema, the license, and the README only", () => { expect(pkg.files).toEqual(["lib/pkg/", "lib/settings.schema.json", "LICENSE.md", "README.md"]); }); - test("keeps the action-only packages out of the runtime dependencies", () => { + test("keeps the action-only packages out of the runtime dependencies, and the CLI's in", () => { for (const name of ["@actions/artifact", "@actions/core"]) { expect(name in pkg.dependencies, `${name} is a runtime dependency`).toBe(false); expect(name in pkg.devDependencies, `${name} is missing from devDependencies`).toBe(true); } + // The bin runs from the installed package, so its libraries must install with it. + for (const name of ["commander", "consola", "picocolors"]) { + expect(name in pkg.dependencies, `${name} is missing from dependencies`).toBe(true); + } }); test("builds the library after the action bundle, and prepare tolerates a missing lefthook", () => { diff --git a/test/scripts/changed-sections.test.ts b/test/scripts/changed-sections.test.ts index 58b8718e..0a66e1db 100644 --- a/test/scripts/changed-sections.test.ts +++ b/test/scripts/changed-sections.test.ts @@ -194,6 +194,15 @@ describe("changed-sections derived fan-out", () => { expect(scanImports("const t = await import(`./lit.js`);\n", "probe.ts")).toEqual(["./lit.js"]); }); + test("scanImports reads a file that opens with a shebang, as the bin entry does", () => { + expect( + scanImports( + '#!/usr/bin/env node\nimport { main } from "./cli/program.js";\nmain();\n', + "cli.ts", + ), + ).toEqual(["./cli/program.js"]); + }); + test("resolveImport maps .js to .ts, a directory to its index, and .json to itself, and throws on a dangling one", () => { const root = syntheticRepo({ "src/sections/shared/engine.ts": "", diff --git a/tsdown.config.ts b/tsdown.config.ts index c522535b..17690322 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,18 +1,25 @@ /** * The library build: src/index.ts bundled to lib/pkg/ as one ESM module with - * one bundled index.d.ts. Runtime dependencies stay external (the consumer's - * package manager installs them); the action bundle (bun run build:bundle) - * is a separate artifact that inlines everything. + * one bundled index.d.ts, and src/cli.ts beside it as lib/pkg/cli.js (the bin + * entry; tsdown keeps its shebang and marks it executable), importing the + * library module rather than inlining it. Runtime dependencies stay external + * (the consumer's package manager installs them); the action bundle (bun run + * build:bundle) is a separate artifact that inlines everything. */ +import { rmSync } from "node:fs"; import { defineConfig } from "tsdown"; import pkg from "./package.json" with { type: "json" }; export default defineConfig({ - entry: "src/index.ts", + entry: { index: "src/index.ts", cli: "src/cli.ts" }, format: "esm", platform: "node", dts: true, + hooks: { + // The bin has no importable surface; its declaration file would be an empty `export {}`. + "build:done": () => rmSync("lib/pkg/cli.d.ts", { force: true }), + }, outDir: "lib/pkg", deps: { // tsdown already externalizes package.json dependencies and their subpaths