argsbarg 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ description: Code quality and style rules for TypeScript files
3
3
  globs: **/*.ts
4
4
  ---
5
5
 
6
- # TypeScript Coding Standards
7
-
6
+ - Changes must be summarized in CHANGELOG.md under the UNRELEASED section
8
7
  - All imports must be ordered alphabetically by their source module path (the `from` clause).
9
8
  - Explicit exports using the `export { ... } from "..."` or `export type { ... } from "..."` syntax must be ordered alphabetically by their source module path and placed at the top of the file, immediately below the imports.
9
+ - Types, Interfaces, Functions must have a docstring
package/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.1] - 2026-04-22
11
+
12
+ ### Changed
13
+
14
+ - **`CliPositional`** — `argMin` and `argMax` are optional. When omitted, they behave as `argMin: 1` and `argMax: 1` (one required word). Set `argMax: 0` for an unbounded varargs tail.
15
+ - **Release** — `just release <major|minor|patch>` runs `just test` first, then `scripts/release.ts`, which no longer runs typecheck, lint, or tests itself. The release commit uses `git add -A` so all local changes in the repo are included, not only `package.json` and `CHANGELOG.md`.
16
+
17
+ ## [1.0.0] - 2026-04-22
18
+
19
+ ### Added
20
+
21
+ - `scripts/release.ts` — release automation (`just release <major|minor|patch>`): lint, typecheck, tests, semver bump, CHANGELOG promotion, commit, tag, push, GitHub release, npm publish.
22
+ - `CliPositional` type for entries in `CliCommand.positionals` (name, description, kind, argMin, argMax).
23
+ - `cliPositionalLabel()` for help-style labels of positional slots (exported alongside `cliOptionLabel()`).
24
+
25
+ ### Changed
26
+
27
+ - **`CliCommand.children` → `CliCommand.commands`** — nested subcommands are declared under `commands` everywhere (schema, parser, validation, help, completion, runtime).
28
+ - **Development tasks** — former `package.json` scripts live in the repo `justfile` (e.g. `just test`, `just lint`). `package.json` no longer defines a `scripts` block.
29
+ - **Public barrel (`src/index.ts`)** — re-exports are limited to schema types and enums, `CliSchemaValidationError`, `CliContext`, `cliRun`, and `cliErrWithHelp`. Parsing (`parse`, `postParseValidate`, …), completion script helpers, help renderers, `cliValidateRoot`, and `utils` number helpers are no longer re-exported from the package entry (import from `src/*.ts` paths in this repo, or depend on internal modules if you fork).
30
+
31
+ ### Removed
32
+
33
+ - **`createOption()`** — options and positionals are plain object literals; there is no factory helper.
34
+ - **`CliOptionDef`** — replaced by distinct types (see below).
35
+
36
+ ### Breaking
37
+
38
+ - **`CliOption`** is only for named flags and value options (`options`). It no longer includes `positional`, `argMin`, or `argMax`. Use **`CliPositional`** on `positionals` for ordered arguments and varargs tails.
39
+ - **`CliCommand.positionals`** is now `CliPositional[]`, not `CliOption[]`.
40
+ - Migrate schemas: rename every `children` property to **`commands`**; move positional definitions to **`CliPositional`** objects on `positionals` and strip `positional` / `argMin` / `argMax` from flag definitions under `options` (flags only carry `name`, `description`, `kind`, and optional `shortName`).
41
+ - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
42
+
43
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.0.1...HEAD
44
+ [1.0.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.0.1
45
+ [1.0.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.0.0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![Logo](logo.png)
1
+ ![Logo](https://github.com/bdombro/bun-argsbarg/blob/main/logo.png)
2
2
  <!-- Big money NE - https://patorjk.com/software/taag/#p=testall&f=Bulbhead&t=shebangsy&x=none&v=4&h=4&w=80&we=false> -->
3
3
 
4
4
  [![GitHub](https://img.shields.io/badge/GitHub-bdombro%2Fbun--argsbarg-181717?logo=github)](https://github.com/bdombro/bun-argsbarg)
@@ -17,35 +17,40 @@ Why another CLI parser?
17
17
  Also checkout ArgsBarg for [cpp](https://github.com/bdombro/cpp-argsbarg), [nim](https://github.com/bdombro/nim-argsbarg), and [swift](https://github.com/bdombro/swift-argsbarg)!
18
18
 
19
19
  Halps! -->
20
- ![help-preview.png](docs/help-preview.png)
20
+ ![help-preview.png](https://github.com/bdombro/bun-argsbarg/blob/main/docs/help-preview.png)
21
21
 
22
22
  Sub-level Halps! -->
23
- ![help-l2-preview.png](docs/help-l2-preview.png)
23
+ ![help-l2-preview.png](https://github.com/bdombro/bun-argsbarg/blob/main/docs/help-l2-preview.png)
24
24
 
25
25
  Shell completions! -->
26
- ![completions-preview.png](docs/completions-preview.png)
26
+ ![completions-preview.png](https://github.com/bdombro/bun-argsbarg/blob/main/docs/completions-preview.png)
27
27
 
28
28
 
29
29
  ## Usage
30
30
 
31
31
  ```typescript
32
- import { cliRun, CliCommand, createOption, CliOptionKind, CliFallbackMode } from "argsbarg";
32
+ import { cliRun, CliCommand, CliOptionKind, CliFallbackMode } from "argsbarg";
33
33
 
34
34
  const cli: CliCommand = {
35
35
  key: "helloapp",
36
36
  description: "Tiny demo.",
37
- children: [
37
+ commands: [
38
38
  {
39
39
  key: "hello",
40
40
  description: "Say hello.",
41
41
  options: [
42
- createOption("name", "Who to greet.", {
42
+ {
43
+ name: "name",
44
+ description: "Who to greet.",
43
45
  kind: CliOptionKind.String,
44
46
  shortName: "n",
45
- }),
46
- createOption("verbose", "Enable extra logging.", {
47
+ },
48
+ {
49
+ name: "verbose",
50
+ description: "Enable extra logging.",
51
+ kind: CliOptionKind.Presence,
47
52
  shortName: "v",
48
- }),
53
+ },
49
54
  ],
50
55
  handler: async (ctx) => {
51
56
  const name = ctx.stringOpt("name") ?? "world";
@@ -71,10 +76,10 @@ await cliRun(cli);
71
76
 
72
77
  Everything you need for a first-class CLI:
73
78
 
74
- - **Nested subcommands** (`CliCommand` with `children` for groups, `handler` for leaves)
79
+ - **Nested subcommands** (`CliCommand` with `commands` for groups, `handler` for leaves)
75
80
  - **POSIX-style options** (`-x`, `--long`, `--long=value`)
76
81
  - **Bundled presence flags** (`-abc`)
77
- - **Positional arguments and varargs tails** (`CliOptionDef` with `positional: true`)
82
+ - **Positional arguments and varargs tails** (`CliPositional` objects on `positionals`)
78
83
  - **Scoped help** at any routing depth (`-h` / `--help`)
79
84
  - **Default-command fallback** (`CliFallbackMode`)
80
85
  - **Option separator** (`--` to stop option parsing)
@@ -115,7 +120,7 @@ bun add bun-argsbarg
115
120
 
116
121
  ## How it works
117
122
 
118
- 1. Build a **program root** `CliCommand` using pure TypeScript objects: `key` is the app/binary name, `children` are top-level subcommands, `options` are global flags. The root must not set `handler` or declare `positionals` (validated at startup). Use `fallbackCommand` / `fallbackMode` on the root only for default top-level routing.
123
+ 1. Build a **program root** `CliCommand` using pure TypeScript objects: `key` is the app/binary name, `commands` are top-level subcommands, `options` are global flags. The root must not set `handler` or declare `positionals` (validated at startup). Use `fallbackCommand` / `fallbackMode` on the root only for default top-level routing.
119
124
  2. Call `await cliRun(root)` with that root — validates, parses argv, renders help or errors, invokes the leaf handler, and `process.exit`s with status **0** on success, **1** on implicit help or error (explicit `--help` → **0**).
120
125
  3. From a handler, `cliErrWithHelp(ctx, "message")` prints a red error line plus contextual help on stderr and exits **1**.
121
126
 
@@ -131,14 +136,14 @@ With `MissingOrUnknown` / `UnknownOnly`, unrecognized **root** flags stop root-f
131
136
 
132
137
  ### Positionals (help labels)
133
138
 
134
- Use `createOption` with `positional: true`. With `argMax: 0`, the tail accepts at least `argMin` tokens and has no upper bound unless you set `argMax` > 0.
139
+ Add `CliPositional` entries to the command’s `positionals` list (separate from `CliOption` flags). With `argMax: 0`, the tail accepts at least `argMin` tokens and has no upper bound unless you set `argMax` > 0.
135
140
 
136
141
  | Fields | Label |
137
142
  | --- | --- |
138
- | `positional: true`, default `argMin`/`argMax` | `<n>` |
139
- | `positional: true`, `argMin: 0`, `argMax: 1` | `[n]` |
140
- | `positional: true`, `argMin: 0`, `argMax: 0` | `[n...]` |
141
- | `positional: true`, `argMin: 1`, `argMax: 0` | `<n...>` |
143
+ | omit `argMin` / `argMax` (defaults `1` / `1`, one required word) | `<n>` |
144
+ | `argMin: 0`, `argMax: 1` | `[n]` |
145
+ | `argMin: 0`, `argMax: 0` | `[n...]` |
146
+ | `argMin: 1`, `argMax: 0` | `<n...>` |
142
147
 
143
148
  ### Reading values (`CliContext`)
144
149
 
@@ -160,24 +165,31 @@ Check the `examples/` directory for full working scripts:
160
165
  | `ArgsBargNested` | `examples/nested.ts` | Nested `CliCommand` tree, positional tails, async handlers. |
161
166
 
162
167
  ```bash
163
- bun examples/minimal.ts --help
164
- bun examples/minimal.ts hello --name world
165
- bun examples/nested.ts stat owner lookup -u alice ./README.md
166
- bun examples/nested.ts read ./README.md
168
+ export PATH="$PATH:$(pwd)/examples"
169
+
170
+ eval "$(minimal.ts completion zsh)"
171
+ minimal.ts --help
172
+ minimal.ts hello --name world
173
+
174
+ eval "$(nested.ts completion zsh)"
175
+ nested.ts stat owner lookup -u alice ./README.md
176
+ nested.ts read ./README.md
167
177
  ```
168
178
 
169
179
 
170
180
 
171
181
  ## Public API overview
172
182
 
183
+ The package root (`argsbarg` / `src/index.ts`) exports the types and runtime you need to define a schema and run it. Parsing, completion script generation, help rendering, and schema pre-validation live in other modules under `src/` for tests and advanced integrations.
184
+
173
185
  | Symbol | Role |
174
186
  | --- | --- |
175
- | `CliCommand`, `CliOptionDef`, `CliOptionKind`, `CliFallbackMode` | Schema types. |
176
- | `createOption()` | Factory helper for options with sensible defaults. |
177
- | `CliContext`, `CliHandler` | Handler context and async-compatible closure type. |
178
- | `cliRun(root, [argv])` | Parse argv, dispatch, exit. |
179
- | `cliErrWithHelp(ctx, msg)` | Error + scoped help, exit 1. |
180
- | `cliHelpRender(schema, helpPath, useStderr)` | Render help (`schema` is the program root `CliCommand`). |
187
+ | `CliCommand`, `CliOption`, `CliPositional`, `CliHandler` | Schema and handler types. |
188
+ | `CliOptionKind`, `CliFallbackMode` | Option kinds and root fallback behavior. |
189
+ | `CliSchemaValidationError` | Thrown when the static command tree violates schema rules. |
190
+ | `CliContext` | Handler context (`ctx.flag`, `ctx.stringOpt`, `ctx.args`, …). |
191
+ | `cliRun(root, [argv])` | Validate, parse argv, dispatch, exit. |
192
+ | `cliErrWithHelp(ctx, msg)` | Print error + scoped help on stderr, exit 1. |
181
193
 
182
194
  Reserved identifier (validated at startup): root command **`completion`**.
183
195
 
@@ -7,27 +7,32 @@ readers can copy the pattern into their own scripts quickly.
7
7
  It demonstrates the minimal Bun integration path.
8
8
  */
9
9
 
10
- import { cliRun, CliCommand, createOption, CliOptionKind, CliFallbackMode } from "../src/index.ts";
10
+ import { cliRun, CliCommand, CliOptionKind, CliFallbackMode } from "../src/index.ts";
11
11
 
12
12
  const cli: CliCommand = {
13
13
  key: "minimal.ts",
14
14
  description: "Tiny demo.",
15
- children: [
15
+ commands: [
16
16
  {
17
17
  key: "hello",
18
18
  description: "Say hello.",
19
19
  options: [
20
- createOption("name", "Who to greet.", {
20
+ {
21
+ name: "name",
22
+ description: "Who to greet.",
21
23
  kind: CliOptionKind.String,
22
24
  shortName: "n",
23
- }),
24
- createOption("verbose", "Enable extra logging.", {
25
+ },
26
+ {
27
+ name: "verbose",
28
+ description: "Enable extra logging.",
29
+ kind: CliOptionKind.Presence,
25
30
  shortName: "v",
26
- }),
31
+ },
27
32
  ],
28
33
  handler: (ctx) => {
29
34
  const name = ctx.stringOpt("name") ?? "world";
30
- if (ctx.flag("verbose")) {
35
+ if (ctx.hasFlag("verbose")) {
31
36
  console.log("verbose mode");
32
37
  }
33
38
  console.log(`hello ${name}`);
@@ -7,34 +7,37 @@ and fallback commands fit together in one schema.
7
7
  It demonstrates how the schema scales beyond one command.
8
8
  */
9
9
 
10
- import { cliRun, CliCommand, createOption, CliOptionKind, CliFallbackMode } from "../src/index.ts";
10
+ import { cliRun, CliCommand, CliOptionKind, CliFallbackMode } from "../src/index.ts";
11
11
 
12
12
  const cli: CliCommand = {
13
13
  key: "nested.ts",
14
14
  description: "Nested groups demo.",
15
- children: [
15
+ commands: [
16
16
  {
17
17
  key: "stat",
18
18
  description: "File metadata.",
19
- children: [
19
+ commands: [
20
20
  {
21
21
  key: "owner",
22
22
  description: "Ownership helpers.",
23
- children: [
23
+ commands: [
24
24
  {
25
25
  key: "lookup",
26
26
  description: "Resolve owner info.",
27
27
  options: [
28
- createOption("user-name", "User to look up.", {
28
+ {
29
+ name: "user-name",
30
+ description: "User to look up.",
29
31
  kind: CliOptionKind.String,
30
32
  shortName: "u",
31
- }),
33
+ },
32
34
  ],
33
35
  positionals: [
34
- createOption("path", "File or directory.", {
36
+ {
37
+ name: "path",
38
+ description: "File or directory.",
35
39
  kind: CliOptionKind.String,
36
- positional: true,
37
- }),
40
+ },
38
41
  ],
39
42
  handler: (ctx) => {
40
43
  const user = ctx.stringOpt("user-name") ?? "?";
@@ -55,12 +58,12 @@ const cli: CliCommand = {
55
58
  description: "Print the first line of each file.",
56
59
  notes: "Pass one or more file paths. {app} prints the first line of each.",
57
60
  positionals: [
58
- createOption("files", "Paths to read.", {
61
+ {
62
+ name: "files",
63
+ description: "Paths to read.",
59
64
  kind: CliOptionKind.String,
60
- positional: true,
61
- argMin: 1,
62
65
  argMax: 0,
63
- }),
66
+ },
64
67
  ],
65
68
  handler: async (ctx) => {
66
69
  if (ctx.args.length === 0) {
package/justfile ADDED
@@ -0,0 +1,32 @@
1
+ # https://github.com/casey/just — run `just` to list recipes.
2
+
3
+ _:
4
+ @just --list
5
+
6
+ # typecheck the codebase
7
+ check-types:
8
+ bun x tsc
9
+
10
+ # run the minimal example
11
+ example:
12
+ bun ./examples/minimal.ts
13
+
14
+ # run the minimal example and watch for changes
15
+ example-watch:
16
+ bun --watch ./examples/minimal.ts
17
+
18
+ # format the codebase
19
+ format:
20
+ bun x biome format ./src ./scripts --write
21
+
22
+ # lint the codebase
23
+ lint:
24
+ bun x biome check ./src ./scripts
25
+
26
+ # Typecheck, lint, then run the test suite.
27
+ test: check-types format lint
28
+ bun test
29
+
30
+ # publish to github and npm
31
+ release bump: test
32
+ bun scripts/release.ts {{bump}}
package/package.json CHANGED
@@ -1,25 +1,20 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "0.1.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
+ "scripts": {
6
+ "//just": "echo this app uses justfile for development tasks"
7
+ },
5
8
  "main": "./src/index.ts",
6
9
  "module": "./src/index.ts",
7
10
  "types": "./src/index.ts",
8
11
  "bin": {
9
- "argsbarg": "./src/index.ts"
12
+ "argsbarg": "src/index.ts"
10
13
  },
11
14
  "exports": {
12
15
  ".": "./src/index.ts"
13
16
  },
14
- "scripts": {
15
- "test": "bun check-types && bun lint && bun test",
16
- "dev": "bun --watch ./examples/minimal.ts",
17
- "lint": "bun x biome check ./src",
18
- "format": "bun x biome format ./src --write",
19
- "check-types": "bun x tsc",
20
- "release": "bun x npm publish"
21
- },
22
17
  "devDependencies": {
23
18
  "@types/bun": "^1.3.12"
24
19
  }
25
- }
20
+ }
package/plan.md CHANGED
@@ -13,9 +13,9 @@
13
13
  - **Phase 1**: Core Schema Types (`src/types.ts`)
14
14
  - `CliOptionKind` enum (Presence, String, Number)
15
15
  - `CliFallbackMode` enum (MissingOnly, MissingOrUnknown, UnknownOnly)
16
- - `CliCommand`, `CliOptionDef`, `CliHandler`, `CliContext` interfaces
16
+ - `CliCommand`, `CliOption`, `CliPositional`, `CliHandler`, `CliContext` interfaces
17
17
  - `CliSchemaValidationError` class
18
- - Helper factories: `createOption()`, `createCommand()`
18
+ - `CliOption` on `options`, `CliPositional` on `positionals`, nested routes under `commands`
19
19
 
20
20
  - **Phase 2**: Argument Parser (`src/parse.ts`)
21
21
  - `parse(root, argv)` with long/short options, bundling, equals syntax
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Release workflow: bump semver; update CHANGELOG (trailing release reference links); commit, tag, push,
4
+ * GitHub release, npm publish.
5
+ *
6
+ * **Run `just test` (or the individual `just check-types`, `just lint`, `bun test`) before this
7
+ * script** — it does not typecheck, lint, or run tests. The `just release` recipe runs checks first.
8
+ *
9
+ * Usage: `bun scripts/release.ts <major|minor|patch>` (usually via `just release <bump>`.)
10
+ *
11
+ * Requires: `git`, `gh` (authenticated), `npm` logged in for publish. The release commit stages **all**
12
+ * repo changes (`git add -A`), not only the version and CHANGELOG edits from this script.
13
+ */
14
+
15
+ import { unlink } from "fs/promises";
16
+
17
+ /** Returns the parent directory of an absolute file path. */
18
+ function parentDir(absolute: string): string {
19
+ const s = absolute.replace(/[/\\]+$/, "");
20
+ const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
21
+ if (i <= 0) {
22
+ return s;
23
+ }
24
+ return s.slice(0, i);
25
+ }
26
+
27
+ /** Monorepo root: parent of `scripts/` (directory of this file). */
28
+ const repoRoot = parentDir(import.meta.dir);
29
+
30
+ /** Which semver segment to increment for the next release. */
31
+ type Bump = "major" | "minor" | "patch";
32
+
33
+ /** Prints usage to stderr and exits with status 1. */
34
+ function usage(): never {
35
+ console.error("Usage: bun scripts/release.ts <major|minor|patch>");
36
+ process.exit(1);
37
+ }
38
+
39
+ /** Parses the release bump level from argv; exits on invalid input. */
40
+ function parseBump(s: string | undefined): Bump {
41
+ if (s === "major" || s === "minor" || s === "patch") {
42
+ return s;
43
+ }
44
+ usage();
45
+ }
46
+
47
+ /** Returns the next `x.y.z` version for the given `major` | `minor` | `patch` segment. */
48
+ function bumpSemver(version: string, part: Bump): string {
49
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
50
+ if (!m) {
51
+ throw new Error(`package.json version must be semver x.y.z, got: ${JSON.stringify(version)}`);
52
+ }
53
+ let major = Number(m[1]);
54
+ let minor = Number(m[2]);
55
+ let patch = Number(m[3]);
56
+ if (part === "major") {
57
+ major += 1;
58
+ minor = 0;
59
+ patch = 0;
60
+ } else if (part === "minor") {
61
+ minor += 1;
62
+ patch = 0;
63
+ } else {
64
+ patch += 1;
65
+ }
66
+ return `${major}.${minor}.${patch}`;
67
+ }
68
+
69
+ /** Returns stdout from a successful subprocess, trimmed; throws if the command fails. */
70
+ function runCapture(cmd: string[], cwd: string = repoRoot): string {
71
+ const proc = Bun.spawnSync({ cmd, cwd, stdout: "pipe", stderr: "pipe" });
72
+ if (proc.exitCode !== 0) {
73
+ const err = new TextDecoder().decode(proc.stderr);
74
+ throw new Error(`Command failed: ${cmd.join(" ")}\n${err}`);
75
+ }
76
+ return new TextDecoder().decode(proc.stdout).trimEnd();
77
+ }
78
+
79
+ /** Parses `git remote get-url origin` into `https://github.com/owner/repo`, or null if not GitHub. */
80
+ function githubRepoBaseFromOrigin(origin: string): string | null {
81
+ const trimmed = origin.trim();
82
+ let path = trimmed.replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "");
83
+ if (path.endsWith(".git")) {
84
+ path = path.slice(0, -4);
85
+ }
86
+ if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(path)) {
87
+ return null;
88
+ }
89
+ return `https://github.com/${path}`;
90
+ }
91
+
92
+ /**
93
+ * Collects released semver headings from changelog body in document order (newest first when
94
+ * headings follow Keep a Changelog order after each release).
95
+ */
96
+ function releasedSemverVersionsFromChangelog(md: string): string[] {
97
+ const re = /^## \[(\d+\.\d+\.\d+)\] /gm;
98
+ const out: string[] = [];
99
+ let m: RegExpExecArray | null;
100
+ while ((m = re.exec(md)) !== null) {
101
+ out.push(m[1]!);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Strips optional legacy `## Links` heading and/or trailing Keep-a-Changelog reference link lines
108
+ * (`[...]: http...`) at the end of the file.
109
+ */
110
+ function stripChangelogLinkDefinitions(md: string): string {
111
+ let s = md.replace(/\r?\n## Links\r?\n/, "\n");
112
+ const lines = s.split(/\r?\n/);
113
+ let i = lines.length;
114
+ while (i > 0 && lines[i - 1] === "") {
115
+ i -= 1;
116
+ }
117
+ const refLine = /^\[[^\]]+\]: .+$/;
118
+ while (i > 0 && refLine.test(lines[i - 1]!)) {
119
+ i -= 1;
120
+ }
121
+ while (i > 0 && lines[i - 1] === "") {
122
+ i -= 1;
123
+ }
124
+ if (i > 0 && lines[i - 1] === "## Links") {
125
+ i -= 1;
126
+ }
127
+ while (i > 0 && lines[i - 1] === "") {
128
+ i -= 1;
129
+ }
130
+ if (i === 0) {
131
+ return "";
132
+ }
133
+ return lines.slice(0, i).join("\n");
134
+ }
135
+
136
+ /**
137
+ * Appends reference link definitions (no visible heading): `[Unreleased]` → compare latest tag to `HEAD`,
138
+ * and each released `[x.y.z]` → release tag URL.
139
+ */
140
+ function appendChangelogLinkDefinitions(md: string, repoBase: string): string {
141
+ const body = stripChangelogLinkDefinitions(md).trimEnd();
142
+ const versions = releasedSemverVersionsFromChangelog(body);
143
+ if (versions.length === 0) {
144
+ return `${body}\n`;
145
+ }
146
+ const newest = versions[0]!;
147
+ const lines = [
148
+ "",
149
+ "",
150
+ `[Unreleased]: ${repoBase}/compare/v${newest}...HEAD`,
151
+ ...versions.map((v) => `[${v}]: ${repoBase}/releases/tag/v${v}`),
152
+ "",
153
+ ];
154
+ return `${body}${lines.join("\n")}`;
155
+ }
156
+
157
+ /** Runs a subprocess, inheriting stdio, and exits the process on non-zero status. */
158
+ function run(label: string, cmd: string[], cwd: string = repoRoot): void {
159
+ console.log(`\n→ ${label}: ${cmd.join(" ")}`);
160
+ const proc = Bun.spawnSync({ cmd, cwd, stdout: "inherit", stderr: "inherit" });
161
+ if (proc.exitCode !== 0) {
162
+ console.error(`\n${label} failed (exit ${proc.exitCode})`);
163
+ process.exit(proc.exitCode ?? 1);
164
+ }
165
+ }
166
+
167
+ /** Moves `[Unreleased]` content under a new `## [version] - date` heading and leaves an empty Unreleased. */
168
+ function promoteChangelog(content: string, version: string, date: string): string {
169
+ const header = "## [Unreleased]";
170
+ const idx = content.indexOf(header);
171
+ if (idx === -1) {
172
+ throw new Error("CHANGELOG.md: missing ## [Unreleased] section");
173
+ }
174
+ const lineEnd = content.indexOf("\n", idx);
175
+ if (lineEnd === -1) {
176
+ throw new Error("CHANGELOG.md: malformed after [Unreleased]");
177
+ }
178
+ const bodyStart = lineEnd + 1;
179
+ const nextIdx = content.indexOf("\n## [", bodyStart);
180
+ const body =
181
+ nextIdx === -1 ? content.slice(bodyStart).trimEnd() : content.slice(bodyStart, nextIdx).trimEnd();
182
+ const tail = nextIdx === -1 ? "" : content.slice(nextIdx + 1);
183
+ const before = content.slice(0, idx);
184
+ const newBlock = `${header}\n\n## [${version}] - ${date}\n${body}\n\n`;
185
+ return before + newBlock + tail;
186
+ }
187
+
188
+ /** Returns the markdown body of one version section for `gh release` notes. */
189
+ function extractReleaseNotes(nextChangelog: string, version: string, date: string): string {
190
+ const verHeader = `## [${version}] - ${date}`;
191
+ const ni = nextChangelog.indexOf(verHeader);
192
+ if (ni === -1) {
193
+ throw new Error("internal: promoted version header not found in CHANGELOG");
194
+ }
195
+ const nextHdrAt = nextChangelog.indexOf("\n## [", ni + verHeader.length);
196
+ if (nextHdrAt === -1) {
197
+ return nextChangelog.slice(ni).trimEnd();
198
+ }
199
+ return nextChangelog.slice(ni, nextHdrAt).trimEnd();
200
+ }
201
+
202
+ /** Joins a root path and a file segment with a single path separator. */
203
+ function joinFile(root: string, segment: string): string {
204
+ const r = root.replace(/[/\\]+$/, "");
205
+ return `${r}/${segment}`;
206
+ }
207
+
208
+ const bump = parseBump(process.argv[2]);
209
+
210
+ const pkgPath = joinFile(repoRoot, "package.json");
211
+ const pkgText = await Bun.file(pkgPath).text();
212
+ const pkg = JSON.parse(pkgText) as { name: string; version: string };
213
+ const nextVersion = bumpSemver(pkg.version, bump);
214
+ const releaseDate = new Date().toISOString().slice(0, 10);
215
+
216
+ pkg.version = nextVersion;
217
+ await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
218
+
219
+ const changelogPath = joinFile(repoRoot, "CHANGELOG.md");
220
+ const changelog = await Bun.file(changelogPath).text();
221
+ const promoted = promoteChangelog(changelog, nextVersion, releaseDate);
222
+ const origin = runCapture(["git", "remote", "get-url", "origin"], repoRoot);
223
+ const repoBase = githubRepoBaseFromOrigin(origin);
224
+ if (repoBase === null) {
225
+ throw new Error(`Could not parse GitHub repo URL from origin: ${JSON.stringify(origin)}`);
226
+ }
227
+ const nextChangelog = appendChangelogLinkDefinitions(promoted, repoBase);
228
+ await Bun.write(changelogPath, nextChangelog);
229
+
230
+ const notesPath = joinFile(repoRoot, ".release-notes.tmp.md");
231
+ const notesContent = extractReleaseNotes(nextChangelog, nextVersion, releaseDate);
232
+ await Bun.write(notesPath, `${notesContent}\n`);
233
+
234
+ const tag = `v${nextVersion}`;
235
+ const msg = `release ${tag}`;
236
+
237
+ try {
238
+ run("git add (all)", ["git", "add", "-A"], repoRoot);
239
+ run("git commit", ["git", "commit", "-m", msg], repoRoot);
240
+ run("git tag", ["git", "tag", "-a", tag, "-m", msg], repoRoot);
241
+ run("git push", ["git", "push"], repoRoot);
242
+ run("git push tags", ["git", "push", "--tags"], repoRoot);
243
+ run("gh release", ["gh", "release", "create", tag, "--title", tag, "--notes-file", notesPath], repoRoot);
244
+ run("npm publish", ["npm", "publish"], repoRoot);
245
+ } finally {
246
+ await unlink(notesPath).catch(() => {});
247
+ }
248
+
249
+ console.log(`\nDone: published ${pkg.name}@${nextVersion} (${tag}).`);