Skip to content

Commit e0cb4e0

Browse files
author
settings-as-code-release
committed
feat(cli): github-settings-as-code and gsac commands over the library
The npm package gains a command line built by tsdown from src/cli.ts to lib/pkg/cli.js, with the bin names github-settings-as-code and gsac. It is a second consumer of the public entry: the new cli layer in architecture.yml has the action's one edge, src/index.ts. check, apply, and merge mirror the action's modes: the subcommand is the mode input and every other input the mode reads is a --kebab-case flag generated from INPUT_DECLS, so the help text and the inputs reference share one declaration. The read port is built from commander's parsed options and handed to the library's parseConfig, and test/cli/inputs.test.ts pins argv-to-config equal to a record-backed port shaped like the runner's, over every RunConfig arm and the rejections. validate and permissions read a settings file alone; snapshot and init name the snapshot mode this library build does not carry and exit 1. The Io over consola prints annotations to stderr as level: message, log lines to stdout, outputs as name=value lines or one JSON object under --json, and appends the summary to --summary's file; every channel is masked, and the token is registered before anything can echo it. Packaging: bin in package.json, the three runtime dependencies pinned, a second tsdown entry, the package smoke running the installed gsac --help and validate under Node, and the changed-sections selector listing the cli paths. The smoke's consumers read validateSettings as the Result it returns on this base. Docs: docs/start/cli.md, a CLI section in the library reference, the README paragraph and table row, and the AGENTS.md bullet for the layer.
1 parent 6ea8aa1 commit e0cb4e0

25 files changed

Lines changed: 1640 additions & 14 deletions

‎.github/scripts/changed-sections.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export const ALL_SELECTING_PREFIXES = [
6767
"src/flows/",
6868
"src/github/",
6969
"src/action/",
70+
// The CLI runs the same flows the action does, so its change is smoked like the action's.
71+
"src/cli/",
72+
"src/cli.ts",
7073
"src/discovery/",
7174
"src/report/",
7275
// Cross-cutting: gap files define supplemental route typing across sections.
@@ -178,7 +181,8 @@ function assertNoComputedImports(text: string, file: string): void {
178181
*/
179182
export function scanImports(text: string, file: string): string[] {
180183
assertNoComputedImports(text, file);
181-
return TRANSPILER.scanImports(text)
184+
// Bun.Transpiler rejects a shebang line (the bin entry keeps one); oxc above accepts it.
185+
return TRANSPILER.scanImports(text.replace(/^#!.*\n/, ""))
182186
.map((entry) => entry.path)
183187
.filter((specifier) => specifier.startsWith("./") || specifier.startsWith("../"));
184188
}

‎.github/scripts/package-smoke.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* subpath), and compile a TypeScript consumer against the bundled index.d.ts
66
* with skipLibCheck off - a declaration that leaks a devDependency type, or a
77
* type the emitter could not name, fails here instead of on a consumer's
8-
* machine.
8+
* machine. The installed bin is run under Node too: its help must name every
9+
* subcommand, and `validate` must accept a small settings file.
910
*
1011
* Usage: `bun .github/scripts/package-smoke.ts` from anywhere; every temp
1112
* directory is removed on every path, failure included.
@@ -15,6 +16,7 @@ import { execFileSync } from "node:child_process";
1516
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1617
import { tmpdir } from "node:os";
1718
import { join } from "node:path";
19+
import { CLI_COMMANDS } from "../../src/cli/program.js";
1820

1921
/** This script lives at .github/scripts/, two levels below the repository root. */
2022
const REPO_ROOT = join(import.meta.dir, "..", "..");
@@ -38,6 +40,9 @@ const result = validateSettings({ labels: [] });
3840
export const ok: boolean = result.isOk() && first === "repository";
3941
`;
4042

43+
/** The settings file the installed CLI validates: one section, valid as written. */
44+
const SETTINGS_FILE = "labels:\n - name: bug\n color: d73a4a\n";
45+
4146
/** Run a command to completion in `cwd`, streaming its output; a non-zero exit throws. */
4247
function run(command: string, args: string[], cwd: string): void {
4348
console.log(`$ ${[command, ...args].join(" ")}`);
@@ -116,6 +121,15 @@ async function main(): Promise<void> {
116121
run("npm", ["install", tarball, "--no-audit", "--no-fund"], consumerDir);
117122
writeFileSync(join(consumerDir, "main.mjs"), NODE_CONSUMER);
118123
run("node", ["main.mjs"], consumerDir);
124+
// The bin through its installed shim, under Node: the package's own runtime.
125+
const bin = join(consumerDir, "node_modules", ".bin", "gsac");
126+
const help = capture("node", [bin, "--help"], consumerDir);
127+
const missing = CLI_COMMANDS.filter((command) => !help.includes(` ${command}`));
128+
if (missing.length > 0) {
129+
throw new Error(`the installed CLI's help names no ${missing.join(", ")} command`);
130+
}
131+
writeFileSync(join(consumerDir, "settings.yml"), SETTINGS_FILE);
132+
run("node", [bin, "validate", "settings.yml"], consumerDir);
119133
writeFileSync(join(consumerDir, "main.ts"), TS_CONSUMER);
120134
run(
121135
join(REPO_ROOT, "node_modules", ".bin", "tsc"),

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings:
3636

3737
- `lib/settings.schema.json` is the COMMITTED JSON Schema for settings.yml, generated from the zod slices in `src/sections/<key>/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(<slice>)` 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 `<key>.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).
3838
- `lib/pkg/` is the npm library build: `bun run build:lib` (tsdown, configured in `tsdown.config.ts`) bundles `src/index.ts` into one ESM `index.js` plus one `index.d.ts` with the runtime dependencies external; like `lib/index.js` it is built, never committed. `package.json` publishes it as `@vivswan/github-settings-as-code` through its `exports` map (`.` to the build, `./settings.schema.json` to the committed schema, `./package.json` to itself; `files` ships only those plus LICENSE.md and README.md), and `test/package-json.test.ts` pins the manifest's field set and holds its `version` equal to `.release-please-manifest.json` (release-please's json extra-file rewrites it). The package-smoke job in `checks.yml` (`bun .github/scripts/package-smoke.ts`) builds the library, runs publint and attw, installs the packed tarball into a fresh consumer, and imports it under Node and under tsc with skipLibCheck off, so a declaration that leaks a devDependency type or a type the emitter cannot name fails there; a type a section module exposes must therefore be exported from its home module. The library reference is docs/reference/library.md.
39+
- `src/cli.ts` and `src/cli/` are the command line, a second CONSUMER of the public entry with the action's one edge, `../index.js`:
40+
a capability the CLI needs is exported from `src/index.ts` first, and `test/cli/inputs.test.ts` pins its argv-to-config equal to the action's env-to-config. Guide: docs/start/cli.md.
3941
- 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.
4042
- 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/<key>/`: `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), `<key>.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, <key>: []}` 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.
4143
- Adding a section: create `src/sections/<key>/` with the six pieces above plus its `<key>.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).

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ The [getting started guide](docs/start/getting-started.md) walks the same steps
6363

6464
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.
6565

66+
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.
67+
6668
## Docs
6769

6870
| Goal | Read |
@@ -85,6 +87,7 @@ The same engine is the npm package `@vivswan/github-settings-as-code` (ESM, Node
8587
| Move a pin to a new major | [Upgrading](docs/upgrading/README.md) |
8688
| See how the code is laid out | [Architecture](docs/reference/architecture.md) |
8789
| Use the engine from your own code | [Library](docs/reference/library.md) |
90+
| Run check, apply, or validate from a terminal | [Command line](docs/start/cli.md) |
8891

8992
## Contributing
9093

‎architecture.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
layers:
1010
main: [src/main.ts]
1111
action: [src/action/]
12+
cli: [src/cli.ts, src/cli/]
1213
library: [src/index.ts]
1314
flows: [src/flows/]
1415
engine: [src/engine/]
@@ -31,10 +32,11 @@ exclude:
3132
- "src/**/*.d.ts"
3233

3334
# from -> the layers it may import; a layer absent here imports nothing outside itself.
34-
# The action is a consumer of the library: its one edge is src/index.ts.
35+
# The action and the CLI are consumers of the library: their one edge is src/index.ts.
3536
edges:
3637
main: [action]
3738
action: [library]
39+
cli: [library]
3840
library: [discovery, engine, flows, github, io, problem, report, schema, sections, types]
3941
flows: [discovery, engine, github, io, plain-data, private, problem, report, schema, types]
4042
engine: [discovery, github, io, plain-data, problem, schema, sections, types]

‎bun.lock‎

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The documentation for GitHub Settings as Code, in five groups. Start here if the
1919
| Predict what an apply or a check will do | [reference/semantics.md](reference/semantics.md) |
2020
| See how the action works, module by module | [reference/architecture.md](reference/architecture.md) |
2121
| Use the engine from your own code | [reference/library.md](reference/library.md) |
22+
| Run check, apply, or validate from a terminal | [start/cli.md](start/cli.md) |
2223
| Detect drift on a schedule | [operate/check-mode.md](operate/check-mode.md) |
2324
| Fold several settings files into one | [operate/layering.md](operate/layering.md) |
2425
| 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
3233
- [Getting started](start/getting-started.md): create the PAT, add the workflow, run your first check, and read the drift output.
3334
- [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.
3435
- [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.
36+
- [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.
3537

3638
## reference: the normative model
3739

‎docs/reference/architecture.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ Each node is one layer of `src/`, labelled with the paths it owns; an arrow mean
230230
graph TD
231231
main["src/main.ts"]
232232
action["src/action/"]
233+
cli["src/cli.ts<br>src/cli/"]
233234
library["src/index.ts"]
234235
flows["src/flows/"]
235236
engine["src/engine/"]
@@ -246,6 +247,7 @@ graph TD
246247
upstream_gaps["src/upstream-gaps/"]
247248
main --> action
248249
action --> library
250+
cli --> library
249251
library --> discovery
250252
library --> engine
251253
library --> flows

‎docs/reference/library.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ const exitCode = await runSingle(client, config, collected.io).match(
146146
console.log(exitCode, collected.outputs.result, collected.lines.map((entry) => entry.line));
147147
```
148148

149+
## CLI
150+
151+
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`,
152+
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.
153+
149154
## Versioning
150155

151156
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.

0 commit comments

Comments
 (0)