@lnilluv/pi-ralph-loop 0.3.0 → 1.0.0
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.
- package/.github/workflows/release.yml +8 -39
- package/README.md +50 -160
- package/package.json +2 -2
- package/scripts/version-helper.ts +210 -0
- package/src/index.ts +1085 -188
- package/src/ralph-draft-context.ts +618 -0
- package/src/ralph-draft-llm.ts +297 -0
- package/src/ralph-draft.ts +33 -0
- package/src/ralph.ts +917 -102
- package/src/runner-rpc.ts +434 -0
- package/src/runner-state.ts +822 -0
- package/src/runner.ts +957 -0
- package/src/secret-paths.ts +66 -0
- package/src/shims.d.ts +0 -3
- package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/migrate/RALPH.md +27 -0
- package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
- package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
- package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
- package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
- package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
- package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/research/RALPH.md +45 -0
- package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
- package/tests/fixtures/parity/research/expected-outputs.md +22 -0
- package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
- package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
- package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
- package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
- package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
- package/tests/fixtures/parity/research/source-manifest.md +20 -0
- package/tests/index.test.ts +3529 -0
- package/tests/parity/README.md +9 -0
- package/tests/parity/harness.py +526 -0
- package/tests/parity-harness.test.ts +42 -0
- package/tests/parity-research-fixture.test.ts +34 -0
- package/tests/ralph-draft-context.test.ts +672 -0
- package/tests/ralph-draft-llm.test.ts +434 -0
- package/tests/ralph-draft.test.ts +168 -0
- package/tests/ralph.test.ts +1389 -19
- package/tests/runner-event-contract.test.ts +235 -0
- package/tests/runner-rpc.test.ts +358 -0
- package/tests/runner-state.test.ts +553 -0
- package/tests/runner.test.ts +1347 -0
- package/tests/secret-paths.test.ts +55 -0
- package/tests/version-helper.test.ts +75 -0
|
@@ -71,54 +71,23 @@ jobs:
|
|
|
71
71
|
fi
|
|
72
72
|
|
|
73
73
|
branch="${GITHUB_REF_NAME}"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const match = current.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
78
|
-
if (!match) throw new Error(`Unsupported version: ${current}`);
|
|
79
|
-
|
|
80
|
-
let major = Number(match[1]);
|
|
81
|
-
let minor = Number(match[2]);
|
|
82
|
-
let patch = Number(match[3]);
|
|
83
|
-
const prerelease = match[4];
|
|
84
|
-
|
|
85
|
-
if (branch === 'dev') {
|
|
86
|
-
if (prerelease && prerelease.startsWith('dev.')) {
|
|
87
|
-
const currentN = Number(prerelease.split('.')[1] ?? '0');
|
|
88
|
-
process.stdout.write(`${major}.${minor}.${patch}-dev.${currentN + 1}`);
|
|
89
|
-
} else {
|
|
90
|
-
if (bump === 'major') { major += 1; minor = 0; patch = 0; }
|
|
91
|
-
else if (bump === 'minor') { minor += 1; patch = 0; }
|
|
92
|
-
else { patch += 1; }
|
|
93
|
-
process.stdout.write(`${major}.${minor}.${patch}-dev.0`);
|
|
94
|
-
}
|
|
95
|
-
} else {
|
|
96
|
-
if (bump === 'major') { major += 1; minor = 0; patch = 0; }
|
|
97
|
-
else if (bump === 'minor') { minor += 1; patch = 0; }
|
|
98
|
-
else { patch += 1; }
|
|
99
|
-
process.stdout.write(`${major}.${minor}.${patch}`);
|
|
100
|
-
}
|
|
101
|
-
NODE
|
|
102
|
-
)"
|
|
74
|
+
npm_versions="$(npm view @lnilluv/pi-ralph-loop versions --json)"
|
|
75
|
+
git_tags="$(git tag --list)"
|
|
76
|
+
new_version="$(node --experimental-strip-types ./scripts/version-helper.ts "$branch" "$bump" "$npm_versions" "$git_tags")"
|
|
103
77
|
|
|
104
78
|
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
|
105
79
|
echo "bump=$bump" >> "$GITHUB_OUTPUT"
|
|
106
80
|
echo "new_version=$new_version" >> "$GITHUB_OUTPUT"
|
|
107
81
|
|
|
108
|
-
- name: Bump version
|
|
109
|
-
if: steps.bump.outputs.should_release == 'true'
|
|
110
|
-
run: npm version ${{ steps.bump.outputs.
|
|
111
|
-
|
|
112
|
-
- name: Bump version (dev)
|
|
113
|
-
if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'dev'
|
|
114
|
-
run: npm version prerelease --preid dev --no-git-tag-version
|
|
82
|
+
- name: Bump version
|
|
83
|
+
if: steps.bump.outputs.should_release == 'true'
|
|
84
|
+
run: npm version ${{ steps.bump.outputs.new_version }} --no-git-tag-version
|
|
115
85
|
|
|
116
86
|
- name: Capture version tag
|
|
117
87
|
if: steps.bump.outputs.should_release == 'true'
|
|
118
88
|
id: version
|
|
119
89
|
run: |
|
|
120
|
-
|
|
121
|
-
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
|
90
|
+
echo "tag=v${{ steps.bump.outputs.new_version }}" >> "$GITHUB_OUTPUT"
|
|
122
91
|
|
|
123
92
|
- name: Publish (main)
|
|
124
93
|
if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'main'
|
|
@@ -133,7 +102,7 @@ jobs:
|
|
|
133
102
|
run: |
|
|
134
103
|
git config user.name "github-actions[bot]"
|
|
135
104
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
136
|
-
VERSION
|
|
105
|
+
VERSION="${{ steps.bump.outputs.new_version }}"
|
|
137
106
|
git add package.json package-lock.json
|
|
138
107
|
git commit -m "chore(release): v${VERSION}"
|
|
139
108
|
git tag "v${VERSION}"
|
package/README.md
CHANGED
|
@@ -1,188 +1,78 @@
|
|
|
1
1
|
# pi-ralph
|
|
2
|
+
Autonomous coding loops for pi with task folders, editable drafts, durable state, and per-iteration supervision.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
## Why use it
|
|
5
|
+
- Keep work in a task folder instead of a single chat turn.
|
|
6
|
+
- Re-run commands each iteration and feed the output back into the prompt.
|
|
7
|
+
- Keep short rolling memory in `RALPH_PROGRESS.md`.
|
|
8
|
+
- Store durable loop state in `.ralph-runner/`.
|
|
9
|
+
- Draft from plain language, then review before starting.
|
|
4
10
|
|
|
5
11
|
## Install
|
|
6
|
-
|
|
7
12
|
```bash
|
|
8
13
|
pi install npm:@lnilluv/pi-ralph-loop
|
|
9
14
|
```
|
|
10
15
|
|
|
11
16
|
## Quick start
|
|
17
|
+
1. Create `work/RALPH.md`.
|
|
18
|
+
2. Run `/ralph --path work --arg owner="Ada Lovelace"`.
|
|
19
|
+
3. If you want a draft first, use `/ralph-draft fix flaky auth tests`.
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
## Concise `RALPH.md`
|
|
15
22
|
```md
|
|
16
|
-
# my-task/RALPH.md
|
|
17
23
|
---
|
|
24
|
+
args:
|
|
25
|
+
- owner
|
|
18
26
|
commands:
|
|
19
27
|
- name: tests
|
|
20
28
|
run: npm test
|
|
21
29
|
timeout: 60
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
{{ commands.tests }}
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Run:
|
|
29
|
-
|
|
30
|
-
```text
|
|
31
|
-
/ralph my-task
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
### Draft a loop from natural language
|
|
35
|
-
|
|
36
|
-
```text
|
|
37
|
-
/ralph reverse engineer this app
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
pi drafts `./reverse-engineer-this-app/RALPH.md`, shows a short Mission Brief, lets you edit the file, and only starts after you confirm.
|
|
41
|
-
|
|
42
|
-
### Draft without starting
|
|
43
|
-
|
|
44
|
-
```text
|
|
45
|
-
/ralph-draft fix flaky auth tests
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
That saves the draft but does not launch the loop.
|
|
49
|
-
|
|
50
|
-
## How it works
|
|
51
|
-
|
|
52
|
-
On each iteration, pi-ralph reads `RALPH.md`, runs the configured commands, injects their output into the prompt through `{{ commands.<name> }}` placeholders, starts a fresh session, sends the prompt, and waits for completion. Failed command output appears in the next iteration, which creates a self-healing loop.
|
|
53
|
-
|
|
54
|
-
## Smart `/ralph` behavior
|
|
55
|
-
|
|
56
|
-
`/ralph` is path-first:
|
|
57
|
-
|
|
58
|
-
- task folder with `RALPH.md` -> runs it
|
|
59
|
-
- direct `RALPH.md` path -> runs it
|
|
60
|
-
- no args in a folder without `RALPH.md` -> asks what the loop should work on, drafts `./RALPH.md`, then asks before starting
|
|
61
|
-
- natural-language task -> drafts `./<slug>/RALPH.md`, then asks before starting
|
|
62
|
-
- unresolved path-like input like `foo/bar` or `notes.md` -> offers recovery choices and normalizes missing markdown targets to `./<folder>/RALPH.md`
|
|
63
|
-
- arbitrary markdown files like `README.md` -> rejected instead of auto-run
|
|
64
|
-
|
|
65
|
-
### Explicit flags
|
|
66
|
-
|
|
67
|
-
Use these when you want to skip heuristics:
|
|
68
|
-
|
|
69
|
-
```text
|
|
70
|
-
/ralph --path my-task
|
|
71
|
-
/ralph --task "reverse engineer the billing flow"
|
|
72
|
-
/ralph-draft --path my-task
|
|
73
|
-
/ralph-draft --task "fix flaky auth tests"
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### Interactive review
|
|
77
|
-
|
|
78
|
-
Draft flows require an interactive UI because the extension uses a Mission Brief and editor dialog before saving or starting. In non-interactive contexts, pass an existing task folder or `RALPH.md` path instead.
|
|
79
|
-
|
|
80
|
-
## RALPH.md format
|
|
81
|
-
|
|
82
|
-
```md
|
|
83
|
-
---
|
|
84
|
-
commands:
|
|
85
|
-
- name: tests
|
|
86
|
-
run: npm test
|
|
87
|
-
timeout: 90
|
|
88
|
-
- name: lint
|
|
89
|
-
run: npm run lint
|
|
30
|
+
- name: verify
|
|
31
|
+
run: ./scripts/verify.sh
|
|
90
32
|
timeout: 60
|
|
91
33
|
max_iterations: 25
|
|
92
34
|
timeout: 300
|
|
93
|
-
completion_promise:
|
|
94
|
-
guardrails:
|
|
95
|
-
block_commands:
|
|
96
|
-
- "rm\\s+-rf\\s+/"
|
|
97
|
-
- "git\\s+push"
|
|
98
|
-
protected_files:
|
|
99
|
-
- ".env*"
|
|
100
|
-
- "**/secrets/**"
|
|
35
|
+
completion_promise: DONE
|
|
101
36
|
---
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<!-- This comment is stripped before sending to the agent -->
|
|
105
|
-
|
|
106
|
-
Latest test output:
|
|
107
|
-
{{ commands.tests }}
|
|
37
|
+
Fix the failing auth tests for {{ args.owner }}.
|
|
108
38
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Iteration {{ ralph.iteration }} of {{ ralph.name }}.
|
|
113
|
-
Apply the smallest safe fix and explain why it works.
|
|
39
|
+
Use {{ commands.tests }} and {{ commands.verify }} as evidence.
|
|
40
|
+
Stop with <promise>DONE</promise> only when the gate passes.
|
|
114
41
|
```
|
|
115
42
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
| `guardrails.protected_files` | string[] | `[]` | Glob patterns enforced on `write`/`edit` tool calls |
|
|
127
|
-
|
|
128
|
-
### Placeholders
|
|
129
|
-
|
|
130
|
-
| Placeholder | Description |
|
|
131
|
-
|-------------|-------------|
|
|
132
|
-
| `{{ commands.<name> }}` | Output from the named command |
|
|
133
|
-
| `{{ ralph.iteration }}` | Current 1-based iteration number |
|
|
134
|
-
| `{{ ralph.name }}` | Directory name containing the `RALPH.md` |
|
|
135
|
-
|
|
136
|
-
HTML comments (`<!-- ... -->`) are stripped from the prompt body after placeholder resolution, so you can annotate your `RALPH.md` freely. Generated drafts also escape literal `<!--` and `-->` in the visible task line, and the leading metadata comment is URL-encoded so task text can safely contain comment-like sequences.
|
|
43
|
+
## Key features
|
|
44
|
+
- `/ralph` runs an existing task folder or `RALPH.md`, or drafts a new loop from plain language.
|
|
45
|
+
- `/ralph-draft` saves the draft without starting the loop.
|
|
46
|
+
- `/ralph-stop` writes a stop flag under `.ralph-runner/` so the loop exits after the current iteration.
|
|
47
|
+
- Frontmatter can declare `args` and `{{ args.name }}` placeholders; `--arg name=value` fills them when you run an existing task folder with `/ralph --path`.
|
|
48
|
+
- Commands that start with `./` run from the task directory, so checked-in helper scripts work.
|
|
49
|
+
- `RALPH_PROGRESS.md` is injected as short rolling memory and excluded from progress snapshots.
|
|
50
|
+
- The runner stores status, iteration records, events, transcripts, and stop signals in `.ralph-runner/`.
|
|
51
|
+
- Completion gating only stops early when the promise is seen and the readiness checks pass; a clear no-progress result will not trigger early stop.
|
|
52
|
+
- The loop can use a selected model and thinking level; if interactive draft strengthening has no authenticated model, it falls back to the deterministic draft path.
|
|
137
53
|
|
|
138
54
|
## Commands
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
When `completion_promise` is set (for example, `"DONE"`), the loop scans the agent's messages for `<promise>DONE</promise>` after each iteration. If found, the loop stops early.
|
|
161
|
-
|
|
162
|
-
### Iteration timeout
|
|
163
|
-
|
|
164
|
-
Each iteration has a configurable timeout (default 300 seconds). If the agent is stuck and doesn't become idle within the timeout, the loop stops with a warning.
|
|
165
|
-
|
|
166
|
-
### Input validation
|
|
167
|
-
|
|
168
|
-
The extension validates `RALPH.md` frontmatter before starting and on each re-parse: `max_iterations` must be a positive integer, `timeout` must be positive, `block_commands` regexes must compile, and commands must have non-empty names and run strings with positive timeouts.
|
|
169
|
-
|
|
170
|
-
## Comparison table
|
|
171
|
-
|
|
172
|
-
| Feature | **@lnilluv/pi-ralph-loop** | pi-ralph | pi-ralph-wiggum | ralphi | ralphify |
|
|
173
|
-
|---------|----------------------------|----------|-----------------|--------|----------|
|
|
174
|
-
| Command output injection | ✓ | ✗ | ✗ | ✗ | ✓ |
|
|
175
|
-
| Fresh-context sessions | ✓ | ✓ | ✗ | ✓ | ✓ |
|
|
176
|
-
| Mid-turn guardrails | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
177
|
-
| Cross-iteration memory | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
178
|
-
| Mid-turn steering | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
179
|
-
| Guided task drafting | ✓ | ✗ | ✗ | ✗ | separate scaffold |
|
|
180
|
-
| Completion promise | ✓ | ✗ | ✗ | ✗ | ✓ |
|
|
181
|
-
| Iteration timeout | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
182
|
-
| Session-scoped hooks | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
183
|
-
| Input validation | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
184
|
-
| Setup required | task folder or draft flow | config | RALPH.md | PRD pipeline | scaffold + RALPH.md |
|
|
55
|
+
| Command | Use |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `/ralph [path-or-task]` | Run an existing task folder or `RALPH.md`, or draft a new loop from a task description. |
|
|
58
|
+
| `/ralph-draft [path-or-task]` | Draft or edit a loop without starting it. |
|
|
59
|
+
| `/ralph-stop [path-or-task]` | Request a graceful stop after the current iteration. |
|
|
60
|
+
|
|
61
|
+
## Config reference
|
|
62
|
+
| Field | Purpose |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `commands` | Shell commands to run each iteration. |
|
|
65
|
+
| `args` | Declared runtime parameters for `--arg`. |
|
|
66
|
+
| `max_iterations` | Maximum iterations, from 1 to 50. |
|
|
67
|
+
| `inter_iteration_delay` | Delay between iterations, in seconds. |
|
|
68
|
+
| `timeout` | Per-iteration timeout, up to 300 seconds. |
|
|
69
|
+
| `completion_promise` | Early-stop marker such as `DONE`. |
|
|
70
|
+
| `required_outputs` | Files that must exist before early stop. |
|
|
71
|
+
| `guardrails.block_commands` | Regexes blocked in bash commands. |
|
|
72
|
+
| `guardrails.protected_files` | File globs protected from `write` and `edit`. |
|
|
73
|
+
| Model selection | Use a selected model and optional thinking level; the runner applies it before the prompt. |
|
|
74
|
+
|
|
75
|
+
Advanced behavior, validation, and edge cases live in `src/runner.ts`, `src/runner-state.ts`, `src/runner-rpc.ts`, `src/ralph.ts`, and `tests/`.
|
|
185
76
|
|
|
186
77
|
## License
|
|
187
|
-
|
|
188
78
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lnilluv/pi-ralph-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Pi-native ralph loop — autonomous coding iterations with mid-turn supervision",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"pi": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"url": "git+https://github.com/lnilluv/pi-ralph-loop.git"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"@mariozechner/pi-ai": "0.66.1",
|
|
23
24
|
"minimatch": "10.2.3",
|
|
24
25
|
"yaml": "2.8.3"
|
|
25
26
|
},
|
|
@@ -28,7 +29,6 @@
|
|
|
28
29
|
},
|
|
29
30
|
"keywords": [
|
|
30
31
|
"pi-extension",
|
|
31
|
-
"pi-package",
|
|
32
32
|
"ralph",
|
|
33
33
|
"autonomous",
|
|
34
34
|
"loop"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export type VersionBump = "major" | "minor" | "patch";
|
|
5
|
+
export type ReleaseBranch = "main" | "dev";
|
|
6
|
+
|
|
7
|
+
export interface ReleaseVersionRequest {
|
|
8
|
+
branch: ReleaseBranch;
|
|
9
|
+
bump: VersionBump;
|
|
10
|
+
npmVersions: readonly string[] | string;
|
|
11
|
+
gitTags: readonly string[] | string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ParsedVersion = {
|
|
15
|
+
major: number;
|
|
16
|
+
minor: number;
|
|
17
|
+
patch: number;
|
|
18
|
+
prerelease?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const STABLE_VERSION = /^\d+\.\d+\.\d+$/;
|
|
22
|
+
const SEMVER_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
23
|
+
const DEV_PRERELEASE = /^dev\.(\d+)$/;
|
|
24
|
+
|
|
25
|
+
function normalizeVersionList(input: readonly string[] | string): string[] {
|
|
26
|
+
if (Array.isArray(input)) {
|
|
27
|
+
return input.map((version) => version.trim()).filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const trimmed = input.trim();
|
|
31
|
+
if (!trimmed) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
37
|
+
if (Array.isArray(parsed)) {
|
|
38
|
+
return parsed.map((value) => String(value).trim()).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof parsed === "string") {
|
|
42
|
+
return parsed.trim() ? [parsed.trim()] : [];
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Fall through to line-based parsing.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return trimmed
|
|
49
|
+
.split(/[\r\n,]+/)
|
|
50
|
+
.map((version) => version.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stripGitTagPrefix(version: string): string {
|
|
55
|
+
return version.startsWith("v") ? version.slice(1) : version;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseVersion(version: string): ParsedVersion | null {
|
|
59
|
+
const match = version.match(SEMVER_VERSION);
|
|
60
|
+
if (!match) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
major: Number(match[1]),
|
|
66
|
+
minor: Number(match[2]),
|
|
67
|
+
patch: Number(match[3]),
|
|
68
|
+
prerelease: match[4],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isStableVersion(version: string): boolean {
|
|
73
|
+
return STABLE_VERSION.test(version);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compareVersions(left: string, right: string): number {
|
|
77
|
+
const leftParsed = parseVersion(left);
|
|
78
|
+
const rightParsed = parseVersion(right);
|
|
79
|
+
|
|
80
|
+
if (!leftParsed || !rightParsed) {
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (leftParsed.major !== rightParsed.major) {
|
|
85
|
+
return leftParsed.major - rightParsed.major;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (leftParsed.minor !== rightParsed.minor) {
|
|
89
|
+
return leftParsed.minor - rightParsed.minor;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return leftParsed.patch - rightParsed.patch;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function maxVersion(versions: string[]): string | null {
|
|
96
|
+
return versions.reduce<string | null>((currentMax, version) => {
|
|
97
|
+
if (!currentMax) {
|
|
98
|
+
return version;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return compareVersions(version, currentMax) > 0 ? version : currentMax;
|
|
102
|
+
}, null);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function incStable(version: string, bump: VersionBump): string {
|
|
106
|
+
const parsed = parseVersion(version);
|
|
107
|
+
if (!parsed) {
|
|
108
|
+
throw new Error(`Unsupported version: ${version}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (bump === "major") {
|
|
112
|
+
return `${parsed.major + 1}.0.0`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (bump === "minor") {
|
|
116
|
+
return `${parsed.major}.${parsed.minor + 1}.0`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collectStableVersions(input: readonly string[] | string): string[] {
|
|
123
|
+
return normalizeVersionList(input)
|
|
124
|
+
.map(stripGitTagPrefix)
|
|
125
|
+
.filter(isStableVersion);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function highestStableVersion(npmVersions: readonly string[] | string, gitTags: readonly string[] | string): string {
|
|
129
|
+
const stableNpm = collectStableVersions(npmVersions);
|
|
130
|
+
const stableTags = collectStableVersions(gitTags);
|
|
131
|
+
const highest = maxVersion([...stableNpm, ...stableTags]);
|
|
132
|
+
return highest ?? "0.0.0";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stableReleaseExistsAtOrAboveOne(npmVersions: readonly string[] | string, gitTags: readonly string[] | string): boolean {
|
|
136
|
+
return [...collectStableVersions(npmVersions), ...collectStableVersions(gitTags)].some(
|
|
137
|
+
(version) => compareVersions(version, "1.0.0") >= 0,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function nextPrereleaseNumber(targetStable: string, npmVersions: readonly string[] | string, gitTags: readonly string[] | string): number {
|
|
142
|
+
const used = new Set<number>();
|
|
143
|
+
|
|
144
|
+
for (const rawVersion of [...normalizeVersionList(npmVersions), ...normalizeVersionList(gitTags)]) {
|
|
145
|
+
const version = stripGitTagPrefix(rawVersion);
|
|
146
|
+
const parsed = parseVersion(version);
|
|
147
|
+
if (!parsed || `${parsed.major}.${parsed.minor}.${parsed.patch}` !== targetStable) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const prerelease = parsed.prerelease;
|
|
152
|
+
if (!prerelease) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const match = prerelease.match(DEV_PRERELEASE);
|
|
157
|
+
if (!match) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
used.add(Number(match[1]));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let next = 0;
|
|
165
|
+
while (used.has(next)) {
|
|
166
|
+
next += 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return next;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function computeReleaseVersion({ branch, bump, npmVersions, gitTags }: ReleaseVersionRequest): string {
|
|
173
|
+
const baseStable = highestStableVersion(npmVersions, gitTags);
|
|
174
|
+
let targetStable = incStable(baseStable, bump);
|
|
175
|
+
|
|
176
|
+
if (!stableReleaseExistsAtOrAboveOne(npmVersions, gitTags)) {
|
|
177
|
+
targetStable = compareVersions(targetStable, "1.0.0") < 0 ? "1.0.0" : targetStable;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (branch === "main") {
|
|
181
|
+
return targetStable;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const prereleaseNumber = nextPrereleaseNumber(targetStable, npmVersions, gitTags);
|
|
185
|
+
return `${targetStable}-dev.${prereleaseNumber}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const nextReleaseVersion = computeReleaseVersion;
|
|
189
|
+
|
|
190
|
+
function isReleaseBranch(value: string): value is ReleaseBranch {
|
|
191
|
+
return value === "main" || value === "dev";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isVersionBump(value: string): value is VersionBump {
|
|
195
|
+
return value === "major" || value === "minor" || value === "patch";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function main(argv: string[]): void {
|
|
199
|
+
const [branch, bump, npmVersions, gitTags] = argv;
|
|
200
|
+
|
|
201
|
+
if (!branch || !bump || !npmVersions || !gitTags || !isReleaseBranch(branch) || !isVersionBump(bump)) {
|
|
202
|
+
throw new Error("Usage: version-helper <main|dev> <major|minor|patch> <npm-versions> <git-tags>");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
process.stdout.write(computeReleaseVersion({ branch, bump, npmVersions, gitTags }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
209
|
+
main(process.argv.slice(2));
|
|
210
|
+
}
|