@valescoagency/runway 0.1.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.
@@ -0,0 +1,421 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { execa } from "execa";
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ // runway/src/commands/init.ts → runway/templates/
7
+ const TEMPLATES_DIR = join(__dirname, "..", "..", "templates");
8
+ export function printInitUsage() {
9
+ console.log(`runway init — scaffold a target repo for runway consumption
10
+
11
+ Wraps \`npx sandcastle init\` and layers on Valesco's customizations:
12
+ varlock + 1Password CLI inside the container, claude shim, drop-the-.env
13
+ posture. Run from the target repo's root.
14
+
15
+ USAGE
16
+ cd /path/to/your/target/repo
17
+ runway init [--tier=2] [--op-vault=... --anthropic-item=... --gh-token-item=...]
18
+ [--allow-dirty] [--force] [--skip-build]
19
+
20
+ OPTIONS
21
+ --tier=1 Plain sandcastle scaffold. No varlock; secrets need
22
+ another mechanism (env vars, repo .env, etc.).
23
+ --tier=2 DEFAULT. Adds varlock + 1Password CLI inside the
24
+ container. Zero secrets at rest.
25
+ --op-vault=NAME 1Password vault name (e.g. "runway"). Required for tier 2.
26
+ --anthropic-item=N Item name in the vault that holds ANTHROPIC_API_KEY. Required for tier 2.
27
+ --gh-token-item=N Item name in the vault that holds GH_TOKEN. Required for tier 2.
28
+ --allow-dirty Skip the "working tree clean" preflight check.
29
+ --force Overwrite an existing .sandcastle/Dockerfile.
30
+ --skip-build Don't \`docker build\` the agent image. Faster init,
31
+ slower first \`runway run\` (image builds then).
32
+ --help, -h Show this help.
33
+
34
+ NOTE
35
+ No --op-account flag — runway uses 1Password service-account auth
36
+ exclusively (OP_SERVICE_ACCOUNT_TOKEN). The token already encodes
37
+ which 1Password tenant to talk to, so the op:// URI omits the
38
+ account segment: \`op://<vault>/<item>\` rather than
39
+ \`op://<account>/<vault>/<item>\`.
40
+
41
+ WHAT THIS COMMAND DOES
42
+ 1. Preflight: docker, gh, node, (tier 2) varlock + op CLI, git state.
43
+ 2. Write .sandcastle/Dockerfile (vendored claude-code base; tier 2
44
+ also splices in the varlock + op CLI + claude shim layer).
45
+ 3. (Tier 2) Write .env.schema at repo root with op:// references.
46
+ 4. \`docker build\` the agent image (skip with --skip-build).
47
+ 5. Verify: no inline secrets, Dockerfile correct shape, image present.
48
+
49
+ WHAT IT DOES NOT DO
50
+ - Run \`sandcastle init\`. Sandcastle's interactive prompts hang in
51
+ non-TTY environments, and runway uses sandcastle's programmatic
52
+ \`run()\` API which only needs the Dockerfile + image — none of
53
+ the prompt.md / main.ts / config.json artifacts \`init\` produces.
54
+ - Push or open a PR. You review and ship.
55
+ - Touch 1Password. You're responsible for putting items in your vault.
56
+ - Modify .github/workflows/.
57
+ `);
58
+ }
59
+ function parseInitArgs(argv) {
60
+ let tier = 2;
61
+ let opVault;
62
+ let anthropicItem;
63
+ let ghTokenItem;
64
+ let allowDirty = false;
65
+ let force = false;
66
+ let skipBuild = false;
67
+ for (const arg of argv) {
68
+ if (arg === "--help" || arg === "-h") {
69
+ printInitUsage();
70
+ process.exit(0);
71
+ }
72
+ else if (arg === "--tier=1") {
73
+ tier = 1;
74
+ }
75
+ else if (arg === "--tier=2") {
76
+ tier = 2;
77
+ }
78
+ else if (arg === "--allow-dirty") {
79
+ allowDirty = true;
80
+ }
81
+ else if (arg === "--force") {
82
+ force = true;
83
+ }
84
+ else if (arg === "--skip-build") {
85
+ skipBuild = true;
86
+ }
87
+ else if (arg.startsWith("--op-vault=")) {
88
+ opVault = arg.slice("--op-vault=".length);
89
+ }
90
+ else if (arg.startsWith("--anthropic-item=")) {
91
+ anthropicItem = arg.slice("--anthropic-item=".length);
92
+ }
93
+ else if (arg.startsWith("--gh-token-item=")) {
94
+ ghTokenItem = arg.slice("--gh-token-item=".length);
95
+ }
96
+ else {
97
+ throw new Error(`unknown argument: ${arg}`);
98
+ }
99
+ }
100
+ if (tier === 2) {
101
+ const missing = [];
102
+ if (!opVault)
103
+ missing.push("--op-vault");
104
+ if (!anthropicItem)
105
+ missing.push("--anthropic-item");
106
+ if (!ghTokenItem)
107
+ missing.push("--gh-token-item");
108
+ if (missing.length) {
109
+ throw new Error(`tier 2 requires ${missing.join(", ")} (or pass --tier=1 to skip varlock)`);
110
+ }
111
+ }
112
+ return {
113
+ tier,
114
+ opVault,
115
+ anthropicItem,
116
+ ghTokenItem,
117
+ allowDirty,
118
+ force,
119
+ skipBuild,
120
+ };
121
+ }
122
+ export async function initCommand(argv) {
123
+ const opts = parseInitArgs(argv);
124
+ const cwd = process.cwd();
125
+ await preflight(cwd, opts);
126
+ await writeDockerfile(cwd, opts);
127
+ if (opts.tier === 2) {
128
+ await applyVarlockLayer(cwd, opts);
129
+ }
130
+ if (!opts.skipBuild) {
131
+ await buildAgentImage(cwd);
132
+ }
133
+ await verify(cwd, opts);
134
+ console.log(`[runway init] done — tier ${opts.tier} scaffold applied`);
135
+ console.log("[runway init] next steps:");
136
+ console.log(" 1. Review the diff (`git diff` and `git status`)");
137
+ console.log(" 2. Create a feature branch and commit");
138
+ console.log(" 3. Open a PR; merge after review");
139
+ if (opts.tier === 2) {
140
+ console.log(" 4. Confirm the op:// items exist in your 1Password vault before the first runway run");
141
+ }
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Preflight
145
+ // ---------------------------------------------------------------------------
146
+ export async function preflight(cwd, opts) {
147
+ console.log(`[runway init] preflight (tier ${opts.tier})`);
148
+ const ok = (msg) => console.log(` ✓ ${msg}`);
149
+ const fail = (msg) => {
150
+ throw new Error(`PREFLIGHT FAIL: ${msg}`);
151
+ };
152
+ await checkBinary("git", "git not on PATH");
153
+ ok("git");
154
+ const nodeVersion = process.versions.node;
155
+ const nodeMajor = Number.parseInt(nodeVersion.split(".")[0] ?? "0", 10);
156
+ if (nodeMajor < 22)
157
+ fail(`node ≥ 22 required, found v${nodeVersion}`);
158
+ ok(`node v${nodeVersion}`);
159
+ await checkBinary("docker", "docker not on PATH (Docker Desktop or Podman)");
160
+ await execa("docker", ["info"], { cwd }).catch(() => fail("docker daemon not running — start Docker Desktop"));
161
+ ok("docker daemon up");
162
+ await checkBinary("gh", "gh not on PATH");
163
+ await execa("gh", ["auth", "status"], { cwd }).catch(() => fail("gh not authenticated — run `gh auth login`"));
164
+ ok("gh authenticated");
165
+ if (opts.tier === 2) {
166
+ await checkBinary("varlock", "varlock not on PATH (`brew install varlock` or `pnpm add -g varlock`)");
167
+ ok("varlock");
168
+ await checkBinary("op", "1Password CLI (`op`) not on PATH");
169
+ ok("op");
170
+ }
171
+ await execa("git", ["rev-parse", "--git-dir"], { cwd }).catch(() => fail("not inside a git repo"));
172
+ const { stdout: top } = await execa("git", ["rev-parse", "--show-toplevel"], {
173
+ cwd,
174
+ });
175
+ ok(`git repo: ${top}`);
176
+ if (!opts.allowDirty) {
177
+ const { stdout: porcelain } = await execa("git", ["status", "--porcelain"], {
178
+ cwd,
179
+ });
180
+ if (porcelain.trim().length) {
181
+ fail("working tree is dirty — commit/stash first, or pass --allow-dirty");
182
+ }
183
+ }
184
+ ok("working tree clean (or --allow-dirty)");
185
+ // Empty-repo case: no HEAD yet → that's fine.
186
+ try {
187
+ const { stdout: head } = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
188
+ if (head === "main" || head === "master") {
189
+ console.log(` ⚠ on default branch (${head}) — caller must create a feature branch before staging`);
190
+ }
191
+ }
192
+ catch {
193
+ console.log(" ⚠ repo has no commits yet — caller will commit on a fresh branch");
194
+ }
195
+ console.log("[runway init] preflight OK");
196
+ }
197
+ async function checkBinary(bin, errMsg) {
198
+ try {
199
+ await execa("command", ["-v", bin], { shell: true });
200
+ }
201
+ catch {
202
+ throw new Error(`PREFLIGHT FAIL: ${errMsg}`);
203
+ }
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // Dockerfile
207
+ // ---------------------------------------------------------------------------
208
+ /**
209
+ * Write `.sandcastle/Dockerfile` directly from the vendored
210
+ * `templates/Dockerfile.claude-code.base`. We don't invoke
211
+ * `sandcastle init` because it has unconditional interactive prompts
212
+ * (sandbox provider, backlog manager, label creation) with no CLI
213
+ * flag override — they hang in non-TTY environments. Runway only
214
+ * uses sandcastle's programmatic `run()` API, which needs the
215
+ * Dockerfile + a built image; nothing else `init` produces matters
216
+ * for our use case.
217
+ */
218
+ export async function writeDockerfile(cwd, opts) {
219
+ const sandcastleDir = join(cwd, ".sandcastle");
220
+ const dockerfilePath = join(sandcastleDir, "Dockerfile");
221
+ if (existsSync(dockerfilePath) && !opts.force) {
222
+ console.log("[runway init] .sandcastle/Dockerfile already exists — skipping (pass --force to overwrite)");
223
+ return;
224
+ }
225
+ if (!existsSync(sandcastleDir)) {
226
+ mkdirSync(sandcastleDir, { recursive: true });
227
+ }
228
+ const base = readFileSync(join(TEMPLATES_DIR, "Dockerfile.claude-code.base"), "utf8");
229
+ writeFileSync(dockerfilePath, base);
230
+ console.log(` ✓ wrote ${dockerfilePath}`);
231
+ }
232
+ // ---------------------------------------------------------------------------
233
+ // Build agent image
234
+ // ---------------------------------------------------------------------------
235
+ /**
236
+ * `docker build` the .sandcastle/ Dockerfile so the image is ready
237
+ * for the first `runway run`. Skipped if --skip-build was passed.
238
+ *
239
+ * Image name follows sandcastle's convention: `sandcastle:<sanitized-dirname>`
240
+ * (defaultImageName from @ai-hero/sandcastle's mountUtils.ts). Build args
241
+ * align AGENT_UID/AGENT_GID to the host user so bind-mounts don't trip on
242
+ * permissions. Mirrors what `sandcastle docker build-image` does.
243
+ */
244
+ export async function buildAgentImage(cwd) {
245
+ const dirName = cwd
246
+ .replace(/[\\/]+$/, "")
247
+ .split(/[\\/]/)
248
+ .pop() ?? "local";
249
+ const sanitized = dirName.toLowerCase().replace(/[^a-z0-9_.-]/g, "-") || "local";
250
+ const imageName = `sandcastle:${sanitized}`;
251
+ const uid = String(process.getuid?.() ?? 1000);
252
+ const gid = String(process.getgid?.() ?? 1000);
253
+ console.log(`[runway init] building agent image ${imageName} (this can take ~2–5 min on first run)`);
254
+ await execa("docker", [
255
+ "build",
256
+ "--build-arg",
257
+ `AGENT_UID=${uid}`,
258
+ "--build-arg",
259
+ `AGENT_GID=${gid}`,
260
+ "-t",
261
+ imageName,
262
+ ".sandcastle",
263
+ ], { cwd, stdio: "inherit" });
264
+ console.log(` ✓ built ${imageName}`);
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // Varlock layer (tier 2 only)
268
+ // ---------------------------------------------------------------------------
269
+ export async function applyVarlockLayer(cwd, opts) {
270
+ console.log("[runway init] applying tier 2 (varlock + 1Password) layer");
271
+ // 1. .env.schema at repo root.
272
+ const schemaPath = join(cwd, ".env.schema");
273
+ if (existsSync(schemaPath)) {
274
+ console.log(" ⚠ .env.schema already exists — backing up to .env.schema.bak");
275
+ writeFileSync(`${schemaPath}.bak`, readFileSync(schemaPath, "utf8"));
276
+ }
277
+ const schemaTemplate = readFileSync(join(TEMPLATES_DIR, ".env.schema.target-repo"), "utf8");
278
+ const rendered = schemaTemplate
279
+ .replaceAll("{{OP_VAULT}}", opts.opVault)
280
+ .replaceAll("{{ANTHROPIC_ITEM}}", opts.anthropicItem)
281
+ .replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem);
282
+ writeFileSync(schemaPath, rendered);
283
+ console.log(` ✓ wrote .env.schema (op://${opts.opVault}/...)`);
284
+ // 2. Patch Dockerfile.
285
+ const dockerfilePath = join(cwd, ".sandcastle", "Dockerfile");
286
+ if (!existsSync(dockerfilePath)) {
287
+ throw new Error(`.sandcastle/Dockerfile missing at ${dockerfilePath}`);
288
+ }
289
+ const dockerfile = readFileSync(dockerfilePath, "utf8");
290
+ if (dockerfile.includes("varlock run --env-file")) {
291
+ console.log(" ✓ Dockerfile already patched (idempotent skip)");
292
+ }
293
+ else {
294
+ const entrypointRe = /^ENTRYPOINT \["sleep", "infinity"\]$/m;
295
+ if (!entrypointRe.test(dockerfile)) {
296
+ throw new Error(`expected \`ENTRYPOINT ["sleep", "infinity"]\` line in ${dockerfilePath}.\n` +
297
+ "Sandcastle may have changed its template; update templates/dockerfile-varlock.snippet");
298
+ }
299
+ const snippet = readFileSync(join(TEMPLATES_DIR, "dockerfile-varlock.snippet"), "utf8");
300
+ const patched = dockerfile.replace(entrypointRe, `${snippet.trimEnd()}\n\nENTRYPOINT ["sleep", "infinity"]`);
301
+ writeFileSync(dockerfilePath, patched);
302
+ console.log(` ✓ patched ${dockerfilePath} (varlock + op CLI + claude shim)`);
303
+ }
304
+ // 3. Drop .sandcastle/.env and .env.example.
305
+ for (const f of [".sandcastle/.env", ".sandcastle/.env.example"]) {
306
+ const p = join(cwd, f);
307
+ if (existsSync(p)) {
308
+ rmSync(p);
309
+ console.log(` ✓ removed ${f}`);
310
+ }
311
+ }
312
+ // 4. .gitignore: keep .env.schema.bak out of commits.
313
+ const gitignorePath = join(cwd, ".gitignore");
314
+ if (existsSync(gitignorePath)) {
315
+ const gi = readFileSync(gitignorePath, "utf8");
316
+ if (!gi.split("\n").includes(".env.schema.bak")) {
317
+ writeFileSync(gitignorePath, gi.endsWith("\n")
318
+ ? `${gi}.env.schema.bak\n`
319
+ : `${gi}\n.env.schema.bak\n`);
320
+ console.log(" ✓ appended .env.schema.bak to .gitignore");
321
+ }
322
+ }
323
+ }
324
+ // ---------------------------------------------------------------------------
325
+ // Verify
326
+ // ---------------------------------------------------------------------------
327
+ export async function verify(cwd, opts) {
328
+ console.log(`[runway init] verify (tier ${opts.tier})`);
329
+ const ok = (msg) => console.log(` ✓ ${msg}`);
330
+ const fail = (msg) => {
331
+ throw new Error(`VERIFY FAIL: ${msg}`);
332
+ };
333
+ if (!existsSync(join(cwd, ".sandcastle")))
334
+ fail(".sandcastle/ missing");
335
+ if (!existsSync(join(cwd, ".sandcastle", "Dockerfile")))
336
+ fail(".sandcastle/Dockerfile missing");
337
+ ok(".sandcastle/Dockerfile present");
338
+ // Image presence — if we built it, confirm `docker images` sees it.
339
+ if (!opts.skipBuild) {
340
+ const dirName = cwd
341
+ .replace(/[\\/]+$/, "")
342
+ .split(/[\\/]/)
343
+ .pop() ?? "local";
344
+ const imageName = `sandcastle:${dirName.toLowerCase().replace(/[^a-z0-9_.-]/g, "-") || "local"}`;
345
+ try {
346
+ await execa("docker", ["image", "inspect", imageName], { cwd });
347
+ ok(`docker image ${imageName} built`);
348
+ }
349
+ catch {
350
+ fail(`docker image ${imageName} not found — build step must have failed`);
351
+ }
352
+ }
353
+ if (opts.tier === 1) {
354
+ console.log("[runway init] verify OK (tier 1)");
355
+ return;
356
+ }
357
+ // Tier 2 only.
358
+ const schemaPath = join(cwd, ".env.schema");
359
+ if (!existsSync(schemaPath))
360
+ fail(".env.schema missing at repo root (tier 2 requires it)");
361
+ const schema = readFileSync(schemaPath, "utf8");
362
+ if (!schema.includes("ANTHROPIC_API_KEY="))
363
+ fail(".env.schema missing ANTHROPIC_API_KEY");
364
+ if (!schema.includes("GH_TOKEN="))
365
+ fail(".env.schema missing GH_TOKEN");
366
+ ok(".env.schema declares ANTHROPIC_API_KEY + GH_TOKEN");
367
+ // Inline secret shape check.
368
+ const secretRe = /(sk-ant-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{20,}|lin_api_[A-Za-z0-9]{20,})/;
369
+ if (secretRe.test(schema)) {
370
+ fail(".env.schema appears to contain a real secret value — refactor to use op:// references");
371
+ }
372
+ ok(".env.schema contains no inline secrets");
373
+ const dockerfile = readFileSync(join(cwd, ".sandcastle", "Dockerfile"), "utf8");
374
+ if (!dockerfile.includes("varlock run --env-file"))
375
+ fail("Dockerfile not patched with varlock shim");
376
+ if (!dockerfile.includes("/home/agent/.local/bin/claude.real"))
377
+ fail("Dockerfile shim not in expected layout");
378
+ ok("Dockerfile patched with varlock + claude shim");
379
+ if (existsSync(join(cwd, ".sandcastle", ".env"))) {
380
+ fail(".sandcastle/.env still on disk — tier 2 deletes it");
381
+ }
382
+ ok(".sandcastle/.env not on disk");
383
+ // Scan tracked files for literal secrets (best-effort, skip .env.schema).
384
+ try {
385
+ const { stdout } = await execa("git", ["ls-files"], { cwd });
386
+ const tracked = stdout.split("\n").filter(Boolean);
387
+ for (const file of tracked) {
388
+ if (file === ".env.schema")
389
+ continue;
390
+ const full = join(cwd, file);
391
+ try {
392
+ if (!statSync(full).isFile())
393
+ continue;
394
+ }
395
+ catch {
396
+ continue;
397
+ }
398
+ let content;
399
+ try {
400
+ content = readFileSync(full, "utf8");
401
+ }
402
+ catch {
403
+ continue;
404
+ }
405
+ if (secretRe.test(content)) {
406
+ fail(`found literal secret value in tracked file: ${file}`);
407
+ }
408
+ }
409
+ ok("no literal secret values in tracked files");
410
+ }
411
+ catch (err) {
412
+ // git ls-files can fail on a brand-new repo with no commits — that's fine.
413
+ if (err.exitCode !== undefined) {
414
+ console.log(" ⚠ skipped tracked-file secret scan (no git index yet)");
415
+ }
416
+ else {
417
+ throw err;
418
+ }
419
+ }
420
+ console.log("[runway init] verify OK (tier 2)");
421
+ }
@@ -0,0 +1,61 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { createLinearGateway } from "../linear.js";
3
+ import { createGithubGateway } from "../github.js";
4
+ import { assertSandcastleInitialised, drainQueue, } from "../orchestrator.js";
5
+ function parseRunArgs(argv) {
6
+ const opts = {};
7
+ for (let i = 0; i < argv.length; i += 1) {
8
+ const a = argv[i];
9
+ if (a === "--max" || a === "-n") {
10
+ const v = argv[i + 1];
11
+ if (!v)
12
+ throw new Error("--max requires a number");
13
+ const n = Number.parseInt(v, 10);
14
+ if (!Number.isFinite(n) || n <= 0) {
15
+ throw new Error(`--max must be a positive integer, got "${v}"`);
16
+ }
17
+ opts.max = n;
18
+ i += 1;
19
+ }
20
+ else if (a === "--help" || a === "-h") {
21
+ printRunUsage();
22
+ process.exit(0);
23
+ }
24
+ else if (a) {
25
+ throw new Error(`unknown argument: ${a}`);
26
+ }
27
+ }
28
+ return opts;
29
+ }
30
+ export function printRunUsage() {
31
+ console.log(`runway run — drain a Linear queue against the cwd repo
32
+
33
+ USAGE
34
+ cd /path/to/your/repo
35
+ runway run [--max N]
36
+
37
+ OPTIONS
38
+ --max, -n N Process at most N issues then exit. Default: drain queue.
39
+ --help, -h Show this help.
40
+
41
+ ENVIRONMENT
42
+ LINEAR_API_KEY required
43
+ RUNWAY_LINEAR_TEAM default "VA"
44
+ RUNWAY_READY_STATUS default "Todo"
45
+ RUNWAY_IN_PROGRESS_STATUS default "In Progress"
46
+ RUNWAY_IN_REVIEW_STATUS default "In Review"
47
+ RUNWAY_HITL_LABEL default "needs-human"
48
+ RUNWAY_MAX_ITERATIONS default 5
49
+ `);
50
+ }
51
+ export async function runCommand(argv) {
52
+ const opts = parseRunArgs(argv);
53
+ const cwd = process.cwd();
54
+ assertSandcastleInitialised(cwd);
55
+ const config = loadConfig();
56
+ const linear = createLinearGateway(config);
57
+ const github = createGithubGateway();
58
+ console.log(`[runway] draining queue from team ${config.linearTeam} (status="${config.readyStatus}") against ${cwd}`);
59
+ const result = await drainQueue({ config, linear, github, cwd }, { max: opts.max });
60
+ console.log(`[runway] done — processed=${result.processed} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
61
+ }