@valescoagency/runway 0.2.0 → 0.4.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/README.md +109 -9
- package/dist/commands/doctor.js +282 -6
- package/dist/commands/init.js +32 -6
- package/dist/commands/run.js +37 -5
- package/dist/commands/upgrade-repo.js +42 -14
- package/dist/config.js +18 -1
- package/dist/git.js +41 -0
- package/dist/github.js +2 -2
- package/dist/linear.js +41 -0
- package/dist/orchestrator.js +262 -57
- package/dist/policy.js +76 -0
- package/dist/prompts.js +44 -1
- package/package.json +10 -3
- package/prompts/implement.md +46 -2
- package/templates/.env.schema.target-repo +8 -1
- package/templates/Dockerfile.claude-code.base +24 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ zero-secrets-at-rest, and the `gh` CLI for PR creation.
|
|
|
14
14
|
|---|---|
|
|
15
15
|
| `runway doctor` | Read-only preflight diagnostic: host tooling, env vars, repo state, and the agent docker image. Use when something stopped working and you want a sanity report. `--json` for CI / scripted health checks. |
|
|
16
16
|
| `runway init` | Scaffold the cwd repo for runway: write `.sandcastle/Dockerfile` + (tier 2) `.env.schema` with op:// references. Run **once per target repo**. |
|
|
17
|
-
| `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `
|
|
17
|
+
| `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `ready-for-human` label). Run **whenever you want a batch of work done**. |
|
|
18
18
|
| `runway upgrade` | Update the runway CLI itself: `git pull` the local clone, `pnpm install`, typecheck. `--check` for a dry-run, `--force` to override dirty/branch refusals. |
|
|
19
19
|
| `runway upgrade-repo` | Re-render the cwd repo's runway scaffold against the current vendored templates. Use after a runway version bump that changed the Dockerfile or template shape — `init` writes them, `upgrade-repo` keeps them current without re-prompting for op:// values. |
|
|
20
20
|
|
|
@@ -50,7 +50,7 @@ runway (this CLI, on your Mac, run from inside the target repo)
|
|
|
50
50
|
│ → REVIEW: APPROVED | REVIEW: REJECTED — <reason>
|
|
51
51
|
│
|
|
52
52
|
├── approved → git push → gh pr create → Linear "In Review"
|
|
53
|
-
└── rejected → Linear label "
|
|
53
|
+
└── rejected → Linear label "ready-for-human", comment with reason
|
|
54
54
|
↓ next issue
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -61,8 +61,12 @@ runway (this CLI, on your Mac, run from inside the target repo)
|
|
|
61
61
|
- Node 22+
|
|
62
62
|
- `gh` CLI authenticated against the org that hosts your target repo
|
|
63
63
|
- Linear API key with read+write on the team you're targeting
|
|
64
|
-
-
|
|
65
|
-
|
|
64
|
+
- A Claude Code credential — **either** an Anthropic API key
|
|
65
|
+
(`sk-ant-api03-…`, pay-per-token) **or** a Pro/Max OAuth token
|
|
66
|
+
(`sk-ant-oat01-…`, generated via `claude setup-token`). The two are
|
|
67
|
+
not interchangeable — see "Claude Code auth modes" below. Stored in
|
|
68
|
+
the **target repo's** `.sandcastle/.env` (tier 1) or 1Password
|
|
69
|
+
(tier 2); never in runway's own env.
|
|
66
70
|
|
|
67
71
|
## One-time setup per target repo
|
|
68
72
|
|
|
@@ -71,7 +75,8 @@ cd /path/to/your/repo
|
|
|
71
75
|
runway init \
|
|
72
76
|
--op-vault=runway \
|
|
73
77
|
--anthropic-item=anthropic-api-key \
|
|
74
|
-
--gh-token-item=gh-token
|
|
78
|
+
--gh-token-item=gh-token \
|
|
79
|
+
--auth-mode=api-key # or --auth-mode=oauth for Pro/Max tokens
|
|
75
80
|
```
|
|
76
81
|
|
|
77
82
|
(No `--op-account` — runway uses 1Password service-account auth
|
|
@@ -92,6 +97,37 @@ and no varlock (faster but secrets land on disk).
|
|
|
92
97
|
|
|
93
98
|
Architecture walkthrough: [`docs/secrets-with-varlock.md`](docs/secrets-with-varlock.md).
|
|
94
99
|
|
|
100
|
+
## Claude Code auth modes
|
|
101
|
+
|
|
102
|
+
Claude Code accepts two distinct credentials, and they are **not
|
|
103
|
+
interchangeable** — passing one as the other yields a generic
|
|
104
|
+
`Invalid API key` inside the container with no useful diagnostic.
|
|
105
|
+
|
|
106
|
+
| Mode | Env var | Token shape | Source |
|
|
107
|
+
|---|---|---|---|
|
|
108
|
+
| `api-key` (default) | `ANTHROPIC_API_KEY` | `sk-ant-api03-…` | [Anthropic console](https://console.anthropic.com), pay-per-token |
|
|
109
|
+
| `oauth` | `CLAUDE_CODE_OAUTH_TOKEN` | `sk-ant-oat01-…` | `claude setup-token` on your Pro/Max account |
|
|
110
|
+
|
|
111
|
+
Pick whichever matches what's stored in your 1Password item:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
runway init --tier=2 --op-vault=runway \
|
|
115
|
+
--anthropic-item=claude-pro-oauth-token \
|
|
116
|
+
--gh-token-item=gh-token \
|
|
117
|
+
--auth-mode=oauth
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The `--anthropic-item` flag is the 1Password item name regardless of
|
|
121
|
+
mode; only the env var written into `.env.schema` changes. `runway
|
|
122
|
+
doctor` surfaces the resolved mode under Environment (`claude auth
|
|
123
|
+
mode: oauth (…)`), and fails fast if `.env.schema` ends up with both
|
|
124
|
+
env vars at once.
|
|
125
|
+
|
|
126
|
+
If you switch modes later, run `runway upgrade-repo` — it extracts
|
|
127
|
+
the existing op:// references, re-renders the template with the new
|
|
128
|
+
mode (detected automatically from the schema), and writes back. You
|
|
129
|
+
do not need to re-pass the op:// flags.
|
|
130
|
+
|
|
95
131
|
## Secrets — recommended: varlock + 1Password
|
|
96
132
|
|
|
97
133
|
If you don't want any secret sitting at rest in any `.env` file,
|
|
@@ -121,13 +157,23 @@ export LINEAR_API_KEY=lin_api_...
|
|
|
121
157
|
# Optional overrides:
|
|
122
158
|
# export RUNWAY_LINEAR_TEAM=VA
|
|
123
159
|
# export RUNWAY_LINEAR_PROJECT=<project-id-or-slug> # optional, scopes queue to one project
|
|
160
|
+
# export RUNWAY_BASE_BRANCH=master # optional, overrides auto-detected default branch
|
|
124
161
|
# export RUNWAY_READY_STATUS="Todo"
|
|
125
162
|
# export RUNWAY_IN_PROGRESS_STATUS="In Progress"
|
|
126
163
|
# export RUNWAY_IN_REVIEW_STATUS="In Review"
|
|
127
|
-
# export RUNWAY_HITL_LABEL="
|
|
164
|
+
# export RUNWAY_HITL_LABEL="ready-for-human"
|
|
128
165
|
# export RUNWAY_MAX_ITERATIONS=5
|
|
129
166
|
```
|
|
130
167
|
|
|
168
|
+
`RUNWAY_HITL_LABEL` defaults to `ready-for-human`, matching the
|
|
169
|
+
[Flightplan](https://github.com/valescoagency/flightplan) canonical
|
|
170
|
+
state-label vocabulary (`needs-triage`, `needs-info`,
|
|
171
|
+
`ready-for-agent`, `ready-for-human`, `wontfix`) that Bedrock and
|
|
172
|
+
other Valesco repos use. Override the env var if your workspace uses
|
|
173
|
+
a different label. `runway doctor` validates that the configured
|
|
174
|
+
team, workflow states, and HITL label all exist before any agent run
|
|
175
|
+
— misconfiguration surfaces immediately instead of mid-drain.
|
|
176
|
+
|
|
131
177
|
### From source (development)
|
|
132
178
|
|
|
133
179
|
```bash
|
|
@@ -140,19 +186,58 @@ pnpm link --global # so `runway` is on your $PATH
|
|
|
140
186
|
|
|
141
187
|
`pnpm dev -- <args>` runs the TypeScript source via `tsx` without building, useful while iterating on runway itself.
|
|
142
188
|
|
|
189
|
+
#### Tests
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
pnpm test # one-shot run, used by CI
|
|
193
|
+
pnpm test:watch # watch mode for local iteration
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Vitest is the harness; tests live colocated with the source as
|
|
197
|
+
`*.test.ts` files (e.g. `src/git.test.ts` next to `src/git.ts`). CI
|
|
198
|
+
runs `pnpm typecheck && pnpm test` on every PR via
|
|
199
|
+
`.github/workflows/ci.yml`.
|
|
200
|
+
|
|
201
|
+
When adding logic that has a sharp pass/fail signal, add a test next
|
|
202
|
+
to it. The seed suite covers `parseRunArgs`, `detectBaseBranch`, the
|
|
203
|
+
`parseOpRefs` regex extraction, and the `drainQueue` error-handler
|
|
204
|
+
branches — copy any of those as a shape for new tests.
|
|
205
|
+
|
|
206
|
+
#### Git hooks (lefthook + commitlint)
|
|
207
|
+
|
|
208
|
+
Hooks install automatically on `pnpm install` via the `prepare`
|
|
209
|
+
script. What runs and when:
|
|
210
|
+
|
|
211
|
+
| Hook | Runs | Why |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `pre-commit` | `pnpm typecheck` | Catch TS errors before they land on a branch. |
|
|
214
|
+
| `commit-msg` | `pnpm exec commitlint --edit` | Reject non-conventional commit messages (CLAUDE.md convention). |
|
|
215
|
+
| `pre-push` | `pnpm test` | Block pushing red. |
|
|
216
|
+
|
|
217
|
+
Skip a single hook invocation with `LEFTHOOK=0 git commit …` (or
|
|
218
|
+
`… git push …`). To re-install after editing `lefthook.yml`, run
|
|
219
|
+
`pnpm exec lefthook install -f`.
|
|
220
|
+
|
|
143
221
|
## Usage
|
|
144
222
|
|
|
145
223
|
```bash
|
|
146
224
|
cd /path/to/the/repo/you/want/agents/working/on
|
|
147
225
|
runway run # drain the entire ready queue
|
|
148
|
-
runway run --max 3 #
|
|
226
|
+
runway run --max 3 # attempt at most 3 issues then exit
|
|
149
227
|
runway --help
|
|
150
228
|
```
|
|
151
229
|
|
|
152
230
|
`runway` (no subcommand) is an alias for `runway run` for back-compat.
|
|
153
231
|
|
|
232
|
+
`--max N` bounds **attempts**, not successes. Every issue picked up
|
|
233
|
+
counts as one attempt, whether it ends in a PR, a `needs-human` label,
|
|
234
|
+
or a revert-to-`Todo` after an infrastructure failure. An issue
|
|
235
|
+
reverted in this invocation will not be re-picked in the same
|
|
236
|
+
invocation — re-run runway after fixing the underlying config to retry
|
|
237
|
+
it.
|
|
238
|
+
|
|
154
239
|
The CLI exits with 0 even if some issues hit HITL or errored — those
|
|
155
|
-
are normal outcomes. Check Linear for the `
|
|
240
|
+
are normal outcomes. Check Linear for the `ready-for-human` label and the
|
|
156
241
|
per-issue comments for what happened.
|
|
157
242
|
|
|
158
243
|
## Linear conventions
|
|
@@ -170,13 +255,28 @@ It transitions them through:
|
|
|
170
255
|
agent has committed to its branch — startup failures before any
|
|
171
256
|
commits revert the issue back to `Todo` rather than stranding it)
|
|
172
257
|
- `In Review` when the PR opens
|
|
173
|
-
- (label `
|
|
258
|
+
- (label `ready-for-human`) if the agent or reviewer can't finish *after*
|
|
174
259
|
the agent has committed real work
|
|
175
260
|
|
|
176
261
|
These names are configurable per env var; the queries match by name so
|
|
177
262
|
your Linear workspace's actual state names need to line up with what
|
|
178
263
|
you set.
|
|
179
264
|
|
|
265
|
+
## Base branch
|
|
266
|
+
|
|
267
|
+
Runway auto-detects the repo's default branch at the start of every
|
|
268
|
+
`runway run` by reading `origin/HEAD` (with `git remote show origin`
|
|
269
|
+
as a fallback for fresh clones). That branch is used for diffing the
|
|
270
|
+
agent's work, counting commits when deciding whether a startup
|
|
271
|
+
failure should revert to `Todo`, and as the `--base` for the PR.
|
|
272
|
+
|
|
273
|
+
Set `RUNWAY_BASE_BRANCH=<name>` to override detection — useful when
|
|
274
|
+
you want runway to target a release branch instead of the default, or
|
|
275
|
+
when `origin/HEAD` isn't set and you don't want to run
|
|
276
|
+
`git remote set-head origin --auto`. `runway doctor` surfaces the
|
|
277
|
+
resolved base branch (detected or overridden) in its Environment
|
|
278
|
+
section.
|
|
279
|
+
|
|
180
280
|
## Sub-agent review
|
|
181
281
|
|
|
182
282
|
Every implementation run is followed by a fresh Sandcastle run with
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { execa } from "execa";
|
|
4
|
+
import { detectBaseBranch } from "../git.js";
|
|
5
|
+
import { loadPolicy } from "../policy.js";
|
|
6
|
+
import { loadConfig } from "../config.js";
|
|
7
|
+
import { validateLinearConfig } from "../linear.js";
|
|
4
8
|
// ---------------------------------------------------------------------------
|
|
5
9
|
// Usage
|
|
6
10
|
// ---------------------------------------------------------------------------
|
|
@@ -83,15 +87,17 @@ export async function doctorCommand(argv) {
|
|
|
83
87
|
const sections = [];
|
|
84
88
|
sections.push(await checkHostTooling(tierForToolingChecks));
|
|
85
89
|
if (initialised || opts.tierOverride !== undefined) {
|
|
86
|
-
sections.push(checkEnvironment(tierForToolingChecks));
|
|
90
|
+
sections.push(await checkEnvironment(tierForToolingChecks, cwd, repo));
|
|
87
91
|
sections.push(await checkRepoState(cwd, repo));
|
|
88
92
|
sections.push(await checkDockerImage(cwd));
|
|
93
|
+
sections.push(await checkLinearConfig());
|
|
89
94
|
}
|
|
90
95
|
else {
|
|
91
96
|
// Push placeholder skipped sections so JSON output stays well-shaped.
|
|
92
97
|
sections.push(skippedSection("Environment"));
|
|
93
98
|
sections.push(skippedSection("Repo state"));
|
|
94
99
|
sections.push(skippedSection("Docker image"));
|
|
100
|
+
sections.push(skippedSection("Linear configuration"));
|
|
95
101
|
}
|
|
96
102
|
// Render
|
|
97
103
|
if (opts.json) {
|
|
@@ -101,8 +107,14 @@ export async function doctorCommand(argv) {
|
|
|
101
107
|
renderText(sections, tier, initialised, opts.detailed);
|
|
102
108
|
}
|
|
103
109
|
// Exit code: required-check failures = 1.
|
|
104
|
-
//
|
|
105
|
-
|
|
110
|
+
// Required: 0 host tooling, 1 environment, 3 docker image, 4 Linear
|
|
111
|
+
// config. Section 2 (repo state) is informational.
|
|
112
|
+
const requiredSections = [
|
|
113
|
+
sections[0],
|
|
114
|
+
sections[1],
|
|
115
|
+
sections[3],
|
|
116
|
+
sections[4],
|
|
117
|
+
];
|
|
106
118
|
const failed = requiredSections.some((s) => s?.ran && [...s.checks.values()].some((c) => c.status === "fail"));
|
|
107
119
|
process.exit(failed ? 1 : 0);
|
|
108
120
|
}
|
|
@@ -113,11 +125,24 @@ function detectRepoState(cwd) {
|
|
|
113
125
|
const hasDockerfile = existsSync(join(cwd, ".sandcastle", "Dockerfile"));
|
|
114
126
|
const hasSchema = existsSync(join(cwd, ".env.schema"));
|
|
115
127
|
let tier = null;
|
|
128
|
+
let authMode = null;
|
|
129
|
+
let hasConflictingAuthVars = false;
|
|
116
130
|
if (hasSchema) {
|
|
117
131
|
try {
|
|
118
132
|
const schema = readFileSync(join(cwd, ".env.schema"), "utf8");
|
|
119
|
-
|
|
133
|
+
const hasApiKey = /ANTHROPIC_API_KEY\s*=\s*exec\(/.test(schema);
|
|
134
|
+
const hasOauth = /CLAUDE_CODE_OAUTH_TOKEN\s*=\s*exec\(/.test(schema);
|
|
135
|
+
if (hasApiKey && hasOauth) {
|
|
136
|
+
tier = 2;
|
|
137
|
+
hasConflictingAuthVars = true;
|
|
138
|
+
}
|
|
139
|
+
else if (hasApiKey) {
|
|
120
140
|
tier = 2;
|
|
141
|
+
authMode = "api-key";
|
|
142
|
+
}
|
|
143
|
+
else if (hasOauth) {
|
|
144
|
+
tier = 2;
|
|
145
|
+
authMode = "oauth";
|
|
121
146
|
}
|
|
122
147
|
else if (hasDockerfile) {
|
|
123
148
|
tier = 1;
|
|
@@ -130,7 +155,7 @@ function detectRepoState(cwd) {
|
|
|
130
155
|
else if (hasDockerfile) {
|
|
131
156
|
tier = 1;
|
|
132
157
|
}
|
|
133
|
-
return { tier, hasDockerfile, hasSchema };
|
|
158
|
+
return { tier, hasDockerfile, hasSchema, authMode, hasConflictingAuthVars };
|
|
134
159
|
}
|
|
135
160
|
// ---------------------------------------------------------------------------
|
|
136
161
|
// Section: Host tooling
|
|
@@ -229,7 +254,7 @@ async function checkGhAuth() {
|
|
|
229
254
|
// ---------------------------------------------------------------------------
|
|
230
255
|
// Section: Environment
|
|
231
256
|
// ---------------------------------------------------------------------------
|
|
232
|
-
function checkEnvironment(tier) {
|
|
257
|
+
async function checkEnvironment(tier, cwd, repo) {
|
|
233
258
|
const checks = new Map();
|
|
234
259
|
checks.set("LINEAR_API_KEY", envSet("LINEAR_API_KEY", "fail"));
|
|
235
260
|
// Informational: which Linear scope a `runway run` would use.
|
|
@@ -242,9 +267,87 @@ function checkEnvironment(tier) {
|
|
|
242
267
|
? `team ${team} / project ${project}`
|
|
243
268
|
: `team ${team} (team-wide — RUNWAY_LINEAR_PROJECT unset)`,
|
|
244
269
|
});
|
|
270
|
+
// Informational: which base branch a `runway run` would diff against
|
|
271
|
+
// and target with PRs. Detection failure here is a real problem —
|
|
272
|
+
// surface it as a fail so the user knows up front.
|
|
273
|
+
const override = process.env.RUNWAY_BASE_BRANCH?.trim();
|
|
274
|
+
if (override) {
|
|
275
|
+
checks.set("base_branch", {
|
|
276
|
+
status: "ok",
|
|
277
|
+
label: "base branch",
|
|
278
|
+
detail: `${override} (RUNWAY_BASE_BRANCH override)`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
try {
|
|
283
|
+
const detected = await detectBaseBranch(cwd);
|
|
284
|
+
checks.set("base_branch", {
|
|
285
|
+
status: "ok",
|
|
286
|
+
label: "base branch",
|
|
287
|
+
detail: `${detected} (detected from origin/HEAD)`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
checks.set("base_branch", {
|
|
292
|
+
status: "fail",
|
|
293
|
+
label: "base branch",
|
|
294
|
+
detail: errMsg(err),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
245
298
|
if (tier === 2) {
|
|
246
299
|
// Tier 2: needed by varlock to resolve op:// refs in the container.
|
|
247
300
|
checks.set("OP_SERVICE_ACCOUNT_TOKEN", envSet("OP_SERVICE_ACCOUNT_TOKEN", "fail"));
|
|
301
|
+
// Surface which Claude Code auth env var the .env.schema declares.
|
|
302
|
+
// ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN aren't
|
|
303
|
+
// interchangeable; a mismatch between this and what's stored in
|
|
304
|
+
// 1Password yields a generic "Invalid API key" inside the
|
|
305
|
+
// container with no useful diagnostic.
|
|
306
|
+
if (repo.hasConflictingAuthVars) {
|
|
307
|
+
checks.set("auth_mode", {
|
|
308
|
+
status: "fail",
|
|
309
|
+
label: "claude auth mode",
|
|
310
|
+
detail: ".env.schema declares both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN — pick one (they are not interchangeable)",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
else if (repo.authMode === "oauth") {
|
|
314
|
+
checks.set("auth_mode", {
|
|
315
|
+
status: "ok",
|
|
316
|
+
label: "claude auth mode",
|
|
317
|
+
detail: "oauth (CLAUDE_CODE_OAUTH_TOKEN — Pro/Max subscription)",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
else if (repo.authMode === "api-key") {
|
|
321
|
+
checks.set("auth_mode", {
|
|
322
|
+
status: "ok",
|
|
323
|
+
label: "claude auth mode",
|
|
324
|
+
detail: "api-key (ANTHROPIC_API_KEY — pay-per-token)",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
checks.set("auth_mode", {
|
|
329
|
+
status: "fail",
|
|
330
|
+
label: "claude auth mode",
|
|
331
|
+
detail: ".env.schema declares neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// VA-352: surface the active impl-pass write-path policy so the
|
|
336
|
+
// operator can see whether an agent run can touch CI workflows, etc.
|
|
337
|
+
try {
|
|
338
|
+
const policy = loadPolicy(cwd);
|
|
339
|
+
checks.set("policy", {
|
|
340
|
+
status: "ok",
|
|
341
|
+
label: "impl policy",
|
|
342
|
+
detail: `${policy.source} (${policy.forbiddenPaths.length} forbidden path${policy.forbiddenPaths.length === 1 ? "" : "s"})`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
checks.set("policy", {
|
|
347
|
+
status: "fail",
|
|
348
|
+
label: "impl policy",
|
|
349
|
+
detail: errMsg(err),
|
|
350
|
+
});
|
|
248
351
|
}
|
|
249
352
|
return { title: "Environment", checks, ran: true };
|
|
250
353
|
}
|
|
@@ -370,6 +473,51 @@ async function checkDockerImage(cwd) {
|
|
|
370
473
|
detail: imageUser ? `User=${imageUser}` : `User unset (root); host=${expected}`,
|
|
371
474
|
});
|
|
372
475
|
}
|
|
476
|
+
// VA-351: container readiness — pnpm on PATH + HOME/cache env
|
|
477
|
+
// baked in. Cheap one-shot run; fails fast if the image is stale.
|
|
478
|
+
try {
|
|
479
|
+
const probe = await execa("docker", [
|
|
480
|
+
"run",
|
|
481
|
+
"--rm",
|
|
482
|
+
imageName,
|
|
483
|
+
"bash",
|
|
484
|
+
"-lc",
|
|
485
|
+
'set -e; which pnpm >/dev/null && printf "HOME=%s\\nXDG_CACHE_HOME=%s\\nTURBO_CACHE_DIR=%s\\n" "$HOME" "$XDG_CACHE_HOME" "$TURBO_CACHE_DIR"',
|
|
486
|
+
], { reject: false });
|
|
487
|
+
const out = probe.stdout ?? "";
|
|
488
|
+
const missing = [];
|
|
489
|
+
if (probe.exitCode !== 0)
|
|
490
|
+
missing.push("pnpm");
|
|
491
|
+
if (!/^HOME=\/home\/agent\s*$/m.test(out))
|
|
492
|
+
missing.push("HOME");
|
|
493
|
+
if (!/^XDG_CACHE_HOME=\/home\/agent\/.cache\s*$/m.test(out)) {
|
|
494
|
+
missing.push("XDG_CACHE_HOME");
|
|
495
|
+
}
|
|
496
|
+
if (!/^TURBO_CACHE_DIR=\/tmp\/turbo-cache\s*$/m.test(out)) {
|
|
497
|
+
missing.push("TURBO_CACHE_DIR");
|
|
498
|
+
}
|
|
499
|
+
if (missing.length === 0) {
|
|
500
|
+
checks.set("container_ready", {
|
|
501
|
+
status: "ok",
|
|
502
|
+
label: "container readiness",
|
|
503
|
+
detail: "pnpm on PATH; HOME, XDG_CACHE_HOME, TURBO_CACHE_DIR set",
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
checks.set("container_ready", {
|
|
508
|
+
status: "warn",
|
|
509
|
+
label: "container readiness",
|
|
510
|
+
detail: `missing or wrong inside container: ${missing.join(", ")} — rebuild via \`runway upgrade-repo && docker build .sandcastle -t ${imageName}\``,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
checks.set("container_ready", {
|
|
516
|
+
status: "warn",
|
|
517
|
+
label: "container readiness",
|
|
518
|
+
detail: `probe failed: ${errMsg(err)}`,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
373
521
|
}
|
|
374
522
|
catch (err) {
|
|
375
523
|
checks.set("image_present", {
|
|
@@ -380,6 +528,134 @@ async function checkDockerImage(cwd) {
|
|
|
380
528
|
}
|
|
381
529
|
return { title: "Docker image", checks, ran: true };
|
|
382
530
|
}
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// Section: Linear configuration (VA-354)
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
/**
|
|
535
|
+
* Validate that the team, workflow states, and HITL label `runway run`
|
|
536
|
+
* would use actually exist on the Linear workspace. Without this,
|
|
537
|
+
* misconfiguration only surfaces deep inside a long agent run — too
|
|
538
|
+
* late to fix without losing the work.
|
|
539
|
+
*/
|
|
540
|
+
async function checkLinearConfig() {
|
|
541
|
+
const checks = new Map();
|
|
542
|
+
// The config loader's only hard requirement is LINEAR_API_KEY; the
|
|
543
|
+
// rest defaults. If the key is missing, the Environment section
|
|
544
|
+
// already fails — surface a skip here rather than re-failing.
|
|
545
|
+
if (!process.env.LINEAR_API_KEY) {
|
|
546
|
+
checks.set("linear_config", {
|
|
547
|
+
status: "skip",
|
|
548
|
+
label: "Linear config",
|
|
549
|
+
detail: "LINEAR_API_KEY unset — skipped",
|
|
550
|
+
});
|
|
551
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
552
|
+
}
|
|
553
|
+
let config;
|
|
554
|
+
try {
|
|
555
|
+
config = loadConfig();
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
checks.set("linear_config", {
|
|
559
|
+
status: "fail",
|
|
560
|
+
label: "Linear config",
|
|
561
|
+
detail: `failed to load runway config: ${errMsg(err)}`,
|
|
562
|
+
});
|
|
563
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
564
|
+
}
|
|
565
|
+
let result;
|
|
566
|
+
try {
|
|
567
|
+
result = await validateLinearConfig(config);
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
checks.set("linear_api", {
|
|
571
|
+
status: "fail",
|
|
572
|
+
label: "Linear API",
|
|
573
|
+
detail: `validation request failed: ${errMsg(err)}`,
|
|
574
|
+
});
|
|
575
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
576
|
+
}
|
|
577
|
+
if (result.team.kind === "missing") {
|
|
578
|
+
checks.set("team", {
|
|
579
|
+
status: "fail",
|
|
580
|
+
label: `team ${config.linearTeam}`,
|
|
581
|
+
detail: `Linear team key "${result.team.key}" not found — set RUNWAY_LINEAR_TEAM`,
|
|
582
|
+
});
|
|
583
|
+
// States/labels are skipped when the team missing; surface
|
|
584
|
+
// explicitly so the user knows they weren't checked.
|
|
585
|
+
checks.set("states", {
|
|
586
|
+
status: "skip",
|
|
587
|
+
label: "workflow states",
|
|
588
|
+
detail: "skipped (team missing)",
|
|
589
|
+
});
|
|
590
|
+
checks.set("hitl_label", {
|
|
591
|
+
status: "skip",
|
|
592
|
+
label: "HITL label",
|
|
593
|
+
detail: "skipped (team missing)",
|
|
594
|
+
});
|
|
595
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
596
|
+
}
|
|
597
|
+
checks.set("team", {
|
|
598
|
+
status: "ok",
|
|
599
|
+
label: `team ${config.linearTeam}`,
|
|
600
|
+
detail: `id=${result.team.id}`,
|
|
601
|
+
});
|
|
602
|
+
for (const [key, configured, state] of [
|
|
603
|
+
["ready_state", config.readyStatus, result.readyStatus],
|
|
604
|
+
["in_progress_state", config.inProgressStatus, result.inProgressStatus],
|
|
605
|
+
["in_review_state", config.inReviewStatus, result.inReviewStatus],
|
|
606
|
+
]) {
|
|
607
|
+
if (state.kind === "ok") {
|
|
608
|
+
checks.set(key, {
|
|
609
|
+
status: "ok",
|
|
610
|
+
label: `workflow state "${configured}"`,
|
|
611
|
+
detail: "present",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
else if (state.kind === "skipped") {
|
|
615
|
+
checks.set(key, {
|
|
616
|
+
status: "skip",
|
|
617
|
+
label: `workflow state "${configured}"`,
|
|
618
|
+
detail: state.reason,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
checks.set(key, {
|
|
623
|
+
status: "fail",
|
|
624
|
+
label: `workflow state "${configured}"`,
|
|
625
|
+
detail: `not found on team; available: ${formatList(state.available)}`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (result.hitlLabel.kind === "ok") {
|
|
630
|
+
checks.set("hitl_label", {
|
|
631
|
+
status: "ok",
|
|
632
|
+
label: `HITL label "${config.hitlLabel}"`,
|
|
633
|
+
detail: "present",
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
else if (result.hitlLabel.kind === "skipped") {
|
|
637
|
+
checks.set("hitl_label", {
|
|
638
|
+
status: "skip",
|
|
639
|
+
label: `HITL label "${config.hitlLabel}"`,
|
|
640
|
+
detail: result.hitlLabel.reason,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
checks.set("hitl_label", {
|
|
645
|
+
status: "fail",
|
|
646
|
+
label: `HITL label "${config.hitlLabel}"`,
|
|
647
|
+
detail: `not found on team — set RUNWAY_HITL_LABEL or create the label. Available: ${formatList(result.hitlLabel.available)}`,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return { title: "Linear configuration", checks, ran: true };
|
|
651
|
+
}
|
|
652
|
+
function formatList(items) {
|
|
653
|
+
if (items.length === 0)
|
|
654
|
+
return "(none)";
|
|
655
|
+
if (items.length <= 8)
|
|
656
|
+
return items.join(", ");
|
|
657
|
+
return `${items.slice(0, 8).join(", ")}, …(+${items.length - 8} more)`;
|
|
658
|
+
}
|
|
383
659
|
/**
|
|
384
660
|
* Sanitize the cwd's basename the same way sandcastle's `defaultImageName`
|
|
385
661
|
* does: lowercase, replace any char outside `[a-z0-9_.-]` with `-`, fall
|
package/dist/commands/init.js
CHANGED
|
@@ -5,6 +5,10 @@ import { execa } from "execa";
|
|
|
5
5
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
// runway/src/commands/init.ts → runway/templates/
|
|
7
7
|
const TEMPLATES_DIR = join(__dirname, "..", "..", "templates");
|
|
8
|
+
const AUTH_MODE_ENV_VAR = {
|
|
9
|
+
"api-key": "ANTHROPIC_API_KEY",
|
|
10
|
+
oauth: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
11
|
+
};
|
|
8
12
|
export function printInitUsage() {
|
|
9
13
|
console.log(`runway init — scaffold a target repo for runway consumption
|
|
10
14
|
|
|
@@ -23,8 +27,18 @@ OPTIONS
|
|
|
23
27
|
--tier=2 DEFAULT. Adds varlock + 1Password CLI inside the
|
|
24
28
|
container. Zero secrets at rest.
|
|
25
29
|
--op-vault=NAME 1Password vault name (e.g. "runway"). Required for tier 2.
|
|
26
|
-
--anthropic-item=N Item name in the vault that holds
|
|
30
|
+
--anthropic-item=N Item name in the vault that holds the Claude Code
|
|
31
|
+
credential (ANTHROPIC_API_KEY or
|
|
32
|
+
CLAUDE_CODE_OAUTH_TOKEN — see --auth-mode).
|
|
33
|
+
Required for tier 2.
|
|
27
34
|
--gh-token-item=N Item name in the vault that holds GH_TOKEN. Required for tier 2.
|
|
35
|
+
--auth-mode=MODE How Claude Code authenticates inside the
|
|
36
|
+
container. \`api-key\` (default) writes the
|
|
37
|
+
ANTHROPIC_API_KEY env var for pay-per-token API
|
|
38
|
+
keys (sk-ant-api03-…). \`oauth\` writes
|
|
39
|
+
CLAUDE_CODE_OAUTH_TOKEN for Pro/Max
|
|
40
|
+
subscription tokens from \`claude setup-token\`
|
|
41
|
+
(sk-ant-oat01-…). They are NOT interchangeable.
|
|
28
42
|
--allow-dirty Skip the "working tree clean" preflight check.
|
|
29
43
|
--force Overwrite an existing .sandcastle/Dockerfile.
|
|
30
44
|
--skip-build Don't \`docker build\` the agent image. Faster init,
|
|
@@ -63,6 +77,7 @@ function parseInitArgs(argv) {
|
|
|
63
77
|
let opVault;
|
|
64
78
|
let anthropicItem;
|
|
65
79
|
let ghTokenItem;
|
|
80
|
+
let authMode = "api-key";
|
|
66
81
|
let allowDirty = false;
|
|
67
82
|
let force = false;
|
|
68
83
|
let skipBuild = false;
|
|
@@ -95,6 +110,13 @@ function parseInitArgs(argv) {
|
|
|
95
110
|
else if (arg.startsWith("--gh-token-item=")) {
|
|
96
111
|
ghTokenItem = arg.slice("--gh-token-item=".length);
|
|
97
112
|
}
|
|
113
|
+
else if (arg.startsWith("--auth-mode=")) {
|
|
114
|
+
const v = arg.slice("--auth-mode=".length);
|
|
115
|
+
if (v !== "api-key" && v !== "oauth") {
|
|
116
|
+
throw new Error(`--auth-mode must be "api-key" or "oauth", got "${v}"`);
|
|
117
|
+
}
|
|
118
|
+
authMode = v;
|
|
119
|
+
}
|
|
98
120
|
else {
|
|
99
121
|
throw new Error(`unknown argument: ${arg}`);
|
|
100
122
|
}
|
|
@@ -116,6 +138,7 @@ function parseInitArgs(argv) {
|
|
|
116
138
|
opVault,
|
|
117
139
|
anthropicItem,
|
|
118
140
|
ghTokenItem,
|
|
141
|
+
authMode,
|
|
119
142
|
allowDirty,
|
|
120
143
|
force,
|
|
121
144
|
skipBuild,
|
|
@@ -277,12 +300,14 @@ export async function applyVarlockLayer(cwd, opts) {
|
|
|
277
300
|
writeFileSync(`${schemaPath}.bak`, readFileSync(schemaPath, "utf8"));
|
|
278
301
|
}
|
|
279
302
|
const schemaTemplate = readFileSync(join(TEMPLATES_DIR, ".env.schema.target-repo"), "utf8");
|
|
303
|
+
const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
|
|
280
304
|
const rendered = schemaTemplate
|
|
281
305
|
.replaceAll("{{OP_VAULT}}", opts.opVault)
|
|
282
306
|
.replaceAll("{{ANTHROPIC_ITEM}}", opts.anthropicItem)
|
|
283
|
-
.replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem)
|
|
307
|
+
.replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem)
|
|
308
|
+
.replaceAll("{{ANTHROPIC_ENV_VAR}}", anthropicEnvVar);
|
|
284
309
|
writeFileSync(schemaPath, rendered);
|
|
285
|
-
console.log(` ✓ wrote .env.schema (op://${opts.opVault}/...)`);
|
|
310
|
+
console.log(` ✓ wrote .env.schema (auth-mode=${opts.authMode}, ${anthropicEnvVar}, op://${opts.opVault}/...)`);
|
|
286
311
|
// 2. Patch Dockerfile.
|
|
287
312
|
const dockerfilePath = join(cwd, ".sandcastle", "Dockerfile");
|
|
288
313
|
if (!existsSync(dockerfilePath)) {
|
|
@@ -361,11 +386,12 @@ export async function verify(cwd, opts) {
|
|
|
361
386
|
if (!existsSync(schemaPath))
|
|
362
387
|
fail(".env.schema missing at repo root (tier 2 requires it)");
|
|
363
388
|
const schema = readFileSync(schemaPath, "utf8");
|
|
364
|
-
|
|
365
|
-
|
|
389
|
+
const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
|
|
390
|
+
if (!schema.includes(`${anthropicEnvVar}=`))
|
|
391
|
+
fail(`.env.schema missing ${anthropicEnvVar} (auth-mode=${opts.authMode})`);
|
|
366
392
|
if (!schema.includes("GH_TOKEN="))
|
|
367
393
|
fail(".env.schema missing GH_TOKEN");
|
|
368
|
-
ok(
|
|
394
|
+
ok(`.env.schema declares ${anthropicEnvVar} + GH_TOKEN`);
|
|
369
395
|
// Inline secret shape check.
|
|
370
396
|
const secretRe = /(sk-ant-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{20,}|lin_api_[A-Za-z0-9]{20,})/;
|
|
371
397
|
if (secretRe.test(schema)) {
|