@snappedly-tools/shipyard 0.4.4 → 0.5.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 +99 -146
- package/dist/chunk-56TDSWFU.js +255 -0
- package/dist/chunk-56TDSWFU.js.map +1 -0
- package/dist/chunk-ACD46ZM4.js +136 -0
- package/dist/chunk-ACD46ZM4.js.map +1 -0
- package/dist/{chunk-2GT3OPO5.js → chunk-FDYOTN55.js} +196 -6
- package/dist/chunk-FDYOTN55.js.map +1 -0
- package/dist/chunk-SOJTAJTF.js +78 -0
- package/dist/chunk-SOJTAJTF.js.map +1 -0
- package/dist/chunk-WAXEMZUV.js +877 -0
- package/dist/chunk-WAXEMZUV.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +58 -118
- package/dist/index.js.map +1 -1
- package/dist/main.js +1831 -143
- package/dist/main.js.map +1 -1
- package/dist/sandboxes/docker.js +3 -3
- package/dist/sandboxes/no-sandbox.js +2 -2
- package/dist/sandboxes/vercel.js +15 -35
- package/dist/sandboxes/vercel.js.map +1 -1
- package/dist/templates/parallel-planner/main.mts +11 -8
- package/dist/templates/parallel-planner-with-review/main.mts +23 -13
- package/package.json +3 -2
- package/dist/chunk-2GT3OPO5.js.map +0 -1
- package/dist/chunk-OCJKONZ7.js +0 -475
- package/dist/chunk-OCJKONZ7.js.map +0 -1
- package/dist/chunk-VDHZO5PP.js +0 -113
- package/dist/chunk-VDHZO5PP.js.map +0 -1
- package/dist/chunk-VW4MI5GJ.js +0 -126
- package/dist/chunk-VW4MI5GJ.js.map +0 -1
- package/dist/chunk-WA7QPAXO.js +0 -50
- package/dist/chunk-WA7QPAXO.js.map +0 -1
package/dist/main.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { CODEX_MODELS, withFriendlyErrors } from './chunk-
|
|
3
|
-
import { buildImage, removeImage } from './chunk-
|
|
4
|
-
import { SANDBOX_REPO_DIR, Display, InitError, PRODUCT_NAME, CONFIG_DIR, CLI_NAME, ClackDisplay, defaultImageName, ExecHostError, LOCKS_DIR
|
|
2
|
+
import { CODEX_MODELS, purgeRunLogs, withFriendlyErrors, DEFAULT_LOG_RETENTION_DAYS, resolveEnv } from './chunk-56TDSWFU.js';
|
|
3
|
+
import { buildImage, removeImage, validateExistingRepositoryRunner, RunnerLifecycleError, RUNNER_INSTALL_METADATA, removeRepositoryRunner, RUNNER_CONTROLLER_LOCK, recoverRepositoryRunner, REPOSITORY_RUNNER_OWNER_ENV, removeOwnedRepositoryRunnerContainers, RUNNER_WORK_DIR } from './chunk-WAXEMZUV.js';
|
|
4
|
+
import { SANDBOX_REPO_DIR, Display, InitError, ACTIVATION_LABEL, PRODUCT_NAME, CONFIG_DIR, CLI_NAME, ClackDisplay, RUNNER_DIR, RUNNER_SANDBOX_MASK_DIR, assertProtectedDirectoryIdentities, repositoryRunnerEnvironment, ConfigDirError, defaultImageName, ExecHostError, LOCKS_DIR } from './chunk-FDYOTN55.js';
|
|
5
5
|
import './chunk-KMGNFXKN.js';
|
|
6
|
-
import './chunk-
|
|
7
|
-
import { NodeContext, NodeRuntime } from '@effect/platform-node';
|
|
6
|
+
import './chunk-ACD46ZM4.js';
|
|
7
|
+
import { NodeContext, NodeRuntime, NodeFileSystem } from '@effect/platform-node';
|
|
8
8
|
import { Effect, Layer, Option } from 'effect';
|
|
9
9
|
import { Options, Command } from '@effect/cli';
|
|
10
10
|
import * as clack from '@clack/prompts';
|
|
11
|
-
import { execSync, spawn, execFileSync } from 'child_process';
|
|
12
|
-
import { existsSync, statSync } from 'fs';
|
|
11
|
+
import { execFile, execSync, spawn, execFileSync } from 'child_process';
|
|
12
|
+
import { existsSync, constants, statSync } from 'fs';
|
|
13
13
|
import { join, resolve, dirname } from 'path';
|
|
14
|
-
import { styleText } from 'util';
|
|
14
|
+
import { promisify, styleText } from 'util';
|
|
15
15
|
import { FileSystem } from '@effect/platform';
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
|
-
import { homedir } from 'os';
|
|
17
|
+
import { hostname, homedir } from 'os';
|
|
18
|
+
import { createHash } from 'crypto';
|
|
19
|
+
import { chmod, lstat, realpath, rm, mkdir, writeFile, readFile, access } from 'fs/promises';
|
|
18
20
|
import 'module';
|
|
19
21
|
|
|
20
22
|
var pathExists = (path) => Effect.gen(function* () {
|
|
@@ -262,16 +264,19 @@ var ISSUE_TRACKER_REGISTRY = [
|
|
|
262
264
|
name: "github-issues",
|
|
263
265
|
label: "GitHub Issues",
|
|
264
266
|
templateArgs: {
|
|
265
|
-
LIST_TASKS_COMMAND: `gh issue list --state open --label ${
|
|
267
|
+
LIST_TASKS_COMMAND: `gh issue list --state open --label ${ACTIVATION_LABEL} --limit 100 --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'`,
|
|
266
268
|
VIEW_TASK_COMMAND: "gh issue view <ID>",
|
|
267
269
|
CLOSE_TASK_COMMAND: `gh issue close <ID> --comment "Completed by ${PRODUCT_NAME}"`,
|
|
268
270
|
ISSUE_TRACKER_TOOLS: GITHUB_CLI_TOOLS
|
|
269
271
|
},
|
|
270
272
|
envExample: `# GitHub personal access token \u2014 the agent uses it to read and manage GitHub Issues
|
|
273
|
+
# Runner installation/start uses the host \`gh\` login for repository administration.
|
|
271
274
|
# Create a fine-grained token: https://github.com/settings/personal-access-tokens/new
|
|
272
275
|
# Required repository permissions: Issues (Read and write) and Metadata (Read)
|
|
273
276
|
# Or leave blank and run: GH_TOKEN="$(gh auth token)" npx shipyard run
|
|
274
|
-
GH_TOKEN
|
|
277
|
+
GH_TOKEN=
|
|
278
|
+
# GitHub repository (owner/repository)
|
|
279
|
+
GH_REPO=`
|
|
275
280
|
}
|
|
276
281
|
];
|
|
277
282
|
var listIssueTrackers = () => ISSUE_TRACKER_REGISTRY;
|
|
@@ -287,69 +292,15 @@ var SANDBOX_PROVIDER_REGISTRY = [
|
|
|
287
292
|
];
|
|
288
293
|
var listSandboxProviders = () => SANDBOX_PROVIDER_REGISTRY;
|
|
289
294
|
var getSandboxProvider = (name) => SANDBOX_PROVIDER_REGISTRY.find((p) => p.name === name);
|
|
290
|
-
function getNextStepsLines(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
` Also set any issue-tracker variables shown in ${CONFIG_DIR}/.env.example.`
|
|
300
|
-
];
|
|
301
|
-
}
|
|
302
|
-
return [
|
|
303
|
-
`${step}. Use your ChatGPT subscription with Codex (recommended): run \`codex login\` on the host, then initialize with \`--codex-auth chatgpt\` so Shipyard can mount \`~/.codex/auth.json\` read-only`,
|
|
304
|
-
` If subscription auth does not work, use API-key billing instead by setting \`OPENAI_API_KEY\` in ${CONFIG_DIR}/.env (see ${CONFIG_DIR}/.env.example).`,
|
|
305
|
-
` Also set any issue-tracker variables shown in ${CONFIG_DIR}/.env.example.`
|
|
306
|
-
];
|
|
307
|
-
}
|
|
308
|
-
if (agent.name === "claude-code") {
|
|
309
|
-
return [
|
|
310
|
-
`${step}. Use your Claude subscription (recommended): run \`claude setup-token\` on the host and paste the result into \`CLAUDE_CODE_OAUTH_TOKEN\` in ${CONFIG_DIR}/.env`,
|
|
311
|
-
` If subscription auth does not work, use API-key billing instead by uncommenting \`ANTHROPIC_API_KEY\` in ${CONFIG_DIR}/.env and setting it to your key.`,
|
|
312
|
-
` Also set any issue-tracker variables shown in ${CONFIG_DIR}/.env.example.`
|
|
313
|
-
];
|
|
314
|
-
}
|
|
315
|
-
return [
|
|
316
|
-
`${step}. Set the required env vars in ${CONFIG_DIR}/.env (see ${CONFIG_DIR}/.env.example)`
|
|
317
|
-
];
|
|
318
|
-
};
|
|
319
|
-
if (template === "blank") {
|
|
320
|
-
const lines = ["Next steps:", ...getAuthLines(1)];
|
|
321
|
-
lines.push(
|
|
322
|
-
`2. Read and customize ${CONFIG_DIR}/prompt.md to describe what you want the agent to do`,
|
|
323
|
-
`3. Customize ${CONFIG_DIR}/${mainFilename} \u2014 it uses the JS API (\`run()\`) to control how the agent runs`,
|
|
324
|
-
`4. Run \`npx ${CLI_NAME} run\` to build the sandbox image and start the agent`
|
|
325
|
-
);
|
|
326
|
-
return lines;
|
|
327
|
-
} else {
|
|
328
|
-
const hasReviewer = template.includes("review");
|
|
329
|
-
const usesPlanSchema = getTemplateDependencies(template).includes("zod");
|
|
330
|
-
let step = 1;
|
|
331
|
-
const lines = ["Next steps:", ...getAuthLines(step++)];
|
|
332
|
-
lines.push(
|
|
333
|
-
`${step++}. Templates use \`copyToWorktree: ["node_modules"]\` to copy your host node_modules into the sandbox for fast startup \u2014 the \`npm install\` in the onSandboxReady hook is a safety net for platform-specific binaries. Adjust both if you use a different package manager`
|
|
334
|
-
);
|
|
335
|
-
if (usesPlanSchema) {
|
|
336
|
-
lines.push(
|
|
337
|
-
`${step++}. Install a schema validator for the planner's \`<plan>\` output \u2014 the template uses Zod (\`${addDependencyCommand(packageManager, "zod")}\`), but Valibot, ArkType, or any Standard Schema library works (https://standardschema.dev)`
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
lines.push(
|
|
341
|
-
`${step++}. Read and customize the prompt files in ${CONFIG_DIR}/ \u2014 they shape what the agent does`
|
|
342
|
-
);
|
|
343
|
-
if (hasReviewer) {
|
|
344
|
-
lines.push(
|
|
345
|
-
`${step++}. Customize ${CONFIG_DIR}/CODING_STANDARDS.md with your project's standards \u2014 the reviewer agent loads it during review`
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
lines.push(
|
|
349
|
-
`${step++}. Run \`npx ${CLI_NAME} run\` to build the sandbox image and start the workflow`
|
|
350
|
-
);
|
|
351
|
-
return lines;
|
|
352
|
-
}
|
|
295
|
+
function getNextStepsLines() {
|
|
296
|
+
return [
|
|
297
|
+
"Next steps:",
|
|
298
|
+
`1. Fill in the values you need in \`${CONFIG_DIR}/.env\`.`,
|
|
299
|
+
"2. If using a model subscription, sign in. For Codex:",
|
|
300
|
+
` codex --config 'cli_auth_credentials_store="file"' login`,
|
|
301
|
+
" test -f ~/.codex/auth.json",
|
|
302
|
+
`3. Start with \`npx ${CLI_NAME} runner start\` (if installed) or \`npx ${CLI_NAME} run\``
|
|
303
|
+
];
|
|
353
304
|
}
|
|
354
305
|
function getTemplatesDir() {
|
|
355
306
|
const thisFile = fileURLToPath(import.meta.url);
|
|
@@ -426,27 +377,6 @@ var rewriteMainTs = (configDir, agent, model, sandboxProvider, mainFilename, cod
|
|
|
426
377
|
content = content.replace(/\bdocker\b/g, sandboxProvider.name);
|
|
427
378
|
yield* fs.writeFileString(mainTsPath, content).pipe(Effect.mapError((e) => new Error(e.message)));
|
|
428
379
|
});
|
|
429
|
-
var rewritePromptFiles = (configDir) => Effect.gen(function* () {
|
|
430
|
-
const fs = yield* FileSystem.FileSystem;
|
|
431
|
-
const files = yield* fs.readDirectory(configDir).pipe(Effect.mapError((e) => new Error(e.message)));
|
|
432
|
-
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
433
|
-
yield* Effect.all(
|
|
434
|
-
mdFiles.map(
|
|
435
|
-
(f) => Effect.gen(function* () {
|
|
436
|
-
const filePath = join(configDir, f);
|
|
437
|
-
const content = yield* fs.readFileString(filePath).pipe(Effect.mapError((e) => new Error(e.message)));
|
|
438
|
-
const updated = content.replace(
|
|
439
|
-
new RegExp(` --label ${PRODUCT_NAME}`, "g"),
|
|
440
|
-
""
|
|
441
|
-
);
|
|
442
|
-
if (updated !== content) {
|
|
443
|
-
yield* fs.writeFileString(filePath, updated).pipe(Effect.mapError((e) => new Error(e.message)));
|
|
444
|
-
}
|
|
445
|
-
})
|
|
446
|
-
),
|
|
447
|
-
{ concurrency: "unbounded" }
|
|
448
|
-
);
|
|
449
|
-
});
|
|
450
380
|
var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
451
381
|
".md",
|
|
452
382
|
".txt",
|
|
@@ -505,7 +435,6 @@ var scaffold = (repoDir, options) => Effect.gen(function* () {
|
|
|
505
435
|
agent,
|
|
506
436
|
model,
|
|
507
437
|
templateName = "blank",
|
|
508
|
-
createLabel = true,
|
|
509
438
|
issueTracker = ISSUE_TRACKER_REGISTRY[0],
|
|
510
439
|
// default: github-issues
|
|
511
440
|
sandboxProvider = SANDBOX_PROVIDER_REGISTRY[0],
|
|
@@ -532,6 +461,7 @@ var scaffold = (repoDir, options) => Effect.gen(function* () {
|
|
|
532
461
|
envExampleParts.push(issueTracker.envExample);
|
|
533
462
|
}
|
|
534
463
|
const envExampleContent = envExampleParts.join("\n") + "\n";
|
|
464
|
+
const envExamplePath = join(configDir, ".env.example");
|
|
535
465
|
yield* Effect.all(
|
|
536
466
|
[
|
|
537
467
|
fs.writeFileString(
|
|
@@ -539,7 +469,8 @@ var scaffold = (repoDir, options) => Effect.gen(function* () {
|
|
|
539
469
|
agent.dockerfileTemplate
|
|
540
470
|
).pipe(Effect.mapError((e) => new Error(e.message))),
|
|
541
471
|
fs.writeFileString(join(configDir, ".gitignore"), GITIGNORE).pipe(Effect.mapError((e) => new Error(e.message))),
|
|
542
|
-
fs.writeFileString(
|
|
472
|
+
fs.writeFileString(envExamplePath, envExampleContent).pipe(Effect.mapError((e) => new Error(e.message))),
|
|
473
|
+
fs.writeFileString(join(configDir, ".env"), envExampleContent).pipe(Effect.mapError((e) => new Error(e.message))),
|
|
543
474
|
copyTemplateFiles(templateDir, configDir, mainFilename)
|
|
544
475
|
],
|
|
545
476
|
{ concurrency: "unbounded" }
|
|
@@ -553,9 +484,6 @@ var scaffold = (repoDir, options) => Effect.gen(function* () {
|
|
|
553
484
|
codexAuth
|
|
554
485
|
);
|
|
555
486
|
yield* substituteTemplateArgs(configDir, issueTracker);
|
|
556
|
-
if (!createLabel) {
|
|
557
|
-
yield* rewritePromptFiles(configDir);
|
|
558
|
-
}
|
|
559
487
|
return { mainFilename };
|
|
560
488
|
});
|
|
561
489
|
var resolveCodexAuthMode = async (options) => {
|
|
@@ -634,7 +562,1704 @@ var ensureCodexChatGptAuth = (options) => {
|
|
|
634
562
|
});
|
|
635
563
|
}
|
|
636
564
|
};
|
|
637
|
-
var
|
|
565
|
+
var REPOSITORY_RUNNER_WORKFLOW_PATH = ".github/workflows/shipyard-wake.yml";
|
|
566
|
+
var REPOSITORY_RUNNER_WAKE_EXECUTABLE = "shipyard-wake";
|
|
567
|
+
var REPOSITORY_RUNNER_WORKFLOW = `name: Shipyard wake-up
|
|
568
|
+
|
|
569
|
+
on:
|
|
570
|
+
issues:
|
|
571
|
+
types: [labeled]
|
|
572
|
+
workflow_dispatch:
|
|
573
|
+
|
|
574
|
+
permissions: {}
|
|
575
|
+
|
|
576
|
+
jobs:
|
|
577
|
+
filter:
|
|
578
|
+
name: Check wake eligibility
|
|
579
|
+
runs-on: ubuntu-latest
|
|
580
|
+
outputs:
|
|
581
|
+
wake: \${{ steps.filter.outputs.wake }}
|
|
582
|
+
steps:
|
|
583
|
+
- name: Match the wake-up trigger
|
|
584
|
+
id: filter
|
|
585
|
+
shell: bash
|
|
586
|
+
env:
|
|
587
|
+
EVENT_NAME: \${{ github.event_name }}
|
|
588
|
+
LABEL_NAME: \${{ github.event.label.name }}
|
|
589
|
+
run: |
|
|
590
|
+
if [[ "$EVENT_NAME" == "workflow_dispatch" ]] || [[ "$EVENT_NAME" == "issues" && "$LABEL_NAME" == "shipyard" ]]; then
|
|
591
|
+
echo "wake=true" >> "$GITHUB_OUTPUT"
|
|
592
|
+
else
|
|
593
|
+
echo "wake=false" >> "$GITHUB_OUTPUT"
|
|
594
|
+
fi
|
|
595
|
+
|
|
596
|
+
wake:
|
|
597
|
+
name: Deliver wake-up
|
|
598
|
+
needs: filter
|
|
599
|
+
if: needs.filter.outputs.wake == 'true'
|
|
600
|
+
runs-on: [self-hosted, macOS, shipyard]
|
|
601
|
+
steps:
|
|
602
|
+
- name: Signal the foreground repository runner
|
|
603
|
+
shell: bash
|
|
604
|
+
run: '"$RUNNER_TEMP/../../shipyard-wake"'
|
|
605
|
+
`;
|
|
606
|
+
var REPOSITORY_RUNNER_WAKE_SCRIPT = `#!/bin/bash
|
|
607
|
+
set -euo pipefail
|
|
608
|
+
|
|
609
|
+
runner_dir="$(cd -- "$(dirname -- "$0")" && pwd)"
|
|
610
|
+
lock_path="$runner_dir/.shipyard-controller.lock"
|
|
611
|
+
|
|
612
|
+
if [[ ! -r "$lock_path" ]]; then
|
|
613
|
+
echo "::error::The Shipyard repository runner controller is unavailable. Start it with npx shipyard runner start."
|
|
614
|
+
exit 1
|
|
615
|
+
fi
|
|
616
|
+
|
|
617
|
+
controller_pid="$(sed -n 's/.*"pid"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' "$lock_path" | head -n 1)"
|
|
618
|
+
controller_started_at="$(sed -n 's/.*"processStartedAt"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$lock_path" | head -n 1)"
|
|
619
|
+
if [[ -z "$controller_pid" ]] || [[ -z "$controller_started_at" ]]; then
|
|
620
|
+
echo "::error::The Shipyard repository runner controller is unavailable. Start it with npx shipyard runner start."
|
|
621
|
+
exit 1
|
|
622
|
+
fi
|
|
623
|
+
current_started_at="$(ps -p "$controller_pid" -o lstart= 2>/dev/null | awk '{$1=$1; print}' || true)"
|
|
624
|
+
if [[ "$current_started_at" != "$controller_started_at" ]] || ! kill -0 "$controller_pid" 2>/dev/null; then
|
|
625
|
+
echo "::error::The Shipyard repository runner controller is unavailable. Start it with npx shipyard runner start."
|
|
626
|
+
exit 1
|
|
627
|
+
fi
|
|
628
|
+
|
|
629
|
+
if ! kill -USR1 "$controller_pid"; then
|
|
630
|
+
echo "::error::The Shipyard repository runner controller could not receive the wake-up."
|
|
631
|
+
exit 1
|
|
632
|
+
fi
|
|
633
|
+
|
|
634
|
+
echo "Wake-up delivered to the Shipyard repository runner controller."
|
|
635
|
+
`;
|
|
636
|
+
var RepositoryRunnerWakeError = class extends Error {
|
|
637
|
+
name = "RepositoryRunnerWakeError";
|
|
638
|
+
};
|
|
639
|
+
var workflowCollisionMessage = () => `Refusing to overwrite the differing workflow at ${REPOSITORY_RUNNER_WORKFLOW_PATH}. Replace it with the required Shipyard wake-only workflow, then retry:
|
|
640
|
+
|
|
641
|
+
${REPOSITORY_RUNNER_WORKFLOW}`;
|
|
642
|
+
var publishedWorkflowFailureMessage = (repository, defaultBranch, error) => {
|
|
643
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
644
|
+
if (/HTTP 403|Resource not accessible by personal access token/i.test(details)) {
|
|
645
|
+
return `The Shipyard wake workflow could not be read for ${repository} on the default branch (${defaultBranch}). GitHub denied Contents: Read access for the host gh login. Authenticate gh with repository administration access, then start the runner again. GitHub reported: ${details}`;
|
|
646
|
+
}
|
|
647
|
+
return `The Shipyard wake workflow is not active for ${repository}. Commit and push ${REPOSITORY_RUNNER_WORKFLOW_PATH} to the default branch (${defaultBranch}), then start the runner again. GitHub reported: ${details}`;
|
|
648
|
+
};
|
|
649
|
+
var assertRepositoryRunnerWorkflowCanBeInstalled = async (repoDir, adapters) => {
|
|
650
|
+
const workflowPath = join(repoDir, REPOSITORY_RUNNER_WORKFLOW_PATH);
|
|
651
|
+
if (!await adapters.exists(workflowPath)) return;
|
|
652
|
+
if (await adapters.readText(workflowPath) !== REPOSITORY_RUNNER_WORKFLOW) {
|
|
653
|
+
throw new RepositoryRunnerWakeError(workflowCollisionMessage());
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
var ensureDirectory = async (path, adapters) => {
|
|
657
|
+
if (!await adapters.exists(path)) await adapters.makeDirectory(path);
|
|
658
|
+
};
|
|
659
|
+
var installRepositoryRunnerWakeFiles = async (options, adapters) => {
|
|
660
|
+
await assertRepositoryRunnerWorkflowCanBeInstalled(options.repoDir, adapters);
|
|
661
|
+
const githubDir = join(options.repoDir, ".github");
|
|
662
|
+
const workflowsDir = join(githubDir, "workflows");
|
|
663
|
+
await ensureDirectory(githubDir, adapters);
|
|
664
|
+
await ensureDirectory(workflowsDir, adapters);
|
|
665
|
+
await adapters.writeText(
|
|
666
|
+
join(options.repoDir, REPOSITORY_RUNNER_WORKFLOW_PATH),
|
|
667
|
+
REPOSITORY_RUNNER_WORKFLOW
|
|
668
|
+
);
|
|
669
|
+
const wakeExecutable = join(
|
|
670
|
+
options.runnerDir,
|
|
671
|
+
REPOSITORY_RUNNER_WAKE_EXECUTABLE
|
|
672
|
+
);
|
|
673
|
+
await adapters.writeText(wakeExecutable, REPOSITORY_RUNNER_WAKE_SCRIPT);
|
|
674
|
+
await adapters.chmod(wakeExecutable, 448);
|
|
675
|
+
};
|
|
676
|
+
var requirePublishedRepositoryRunnerWorkflow = async (options, adapter) => {
|
|
677
|
+
let defaultBranch;
|
|
678
|
+
try {
|
|
679
|
+
const result = await adapter.run(
|
|
680
|
+
"gh",
|
|
681
|
+
["api", `repos/${options.repository}`, "--jq", ".default_branch"],
|
|
682
|
+
{ cwd: options.repoDir, env: options.environment }
|
|
683
|
+
);
|
|
684
|
+
defaultBranch = result.stdout.trim();
|
|
685
|
+
if (defaultBranch.length === 0) throw new Error("empty default branch");
|
|
686
|
+
} catch (error) {
|
|
687
|
+
throw new RepositoryRunnerWakeError(
|
|
688
|
+
`Could not determine the GitHub default branch for ${options.repository}: ${error instanceof Error ? error.message : String(error)}`
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
let publishedWorkflow;
|
|
692
|
+
try {
|
|
693
|
+
const result = await adapter.run(
|
|
694
|
+
"gh",
|
|
695
|
+
[
|
|
696
|
+
"api",
|
|
697
|
+
"--method",
|
|
698
|
+
"GET",
|
|
699
|
+
"-H",
|
|
700
|
+
"Accept: application/vnd.github.raw+json",
|
|
701
|
+
`repos/${options.repository}/contents/${REPOSITORY_RUNNER_WORKFLOW_PATH}?ref=${encodeURIComponent(defaultBranch)}`
|
|
702
|
+
],
|
|
703
|
+
{ cwd: options.repoDir, env: options.environment }
|
|
704
|
+
);
|
|
705
|
+
publishedWorkflow = result.stdout;
|
|
706
|
+
} catch (error) {
|
|
707
|
+
throw new RepositoryRunnerWakeError(
|
|
708
|
+
publishedWorkflowFailureMessage(options.repository, defaultBranch, error)
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
if (publishedWorkflow !== REPOSITORY_RUNNER_WORKFLOW) {
|
|
712
|
+
throw new RepositoryRunnerWakeError(
|
|
713
|
+
`The Shipyard wake workflow on the default branch (${defaultBranch}) does not match the required workflow. Update and push ${REPOSITORY_RUNNER_WORKFLOW_PATH}, then start the runner again.
|
|
714
|
+
|
|
715
|
+
${REPOSITORY_RUNNER_WORKFLOW}`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
var createRepositoryRunnerWakeSubscription = (subscribe = (listener) => {
|
|
720
|
+
process.on("SIGUSR1", listener);
|
|
721
|
+
return () => process.removeListener("SIGUSR1", listener);
|
|
722
|
+
}) => {
|
|
723
|
+
let pending = false;
|
|
724
|
+
let closed = false;
|
|
725
|
+
let resolveNext;
|
|
726
|
+
const unsubscribe = subscribe(() => {
|
|
727
|
+
if (closed || pending) return;
|
|
728
|
+
pending = true;
|
|
729
|
+
resolveNext?.();
|
|
730
|
+
resolveNext = void 0;
|
|
731
|
+
});
|
|
732
|
+
return {
|
|
733
|
+
next: async () => {
|
|
734
|
+
if (closed) return;
|
|
735
|
+
if (!pending) {
|
|
736
|
+
await new Promise((resolve2) => {
|
|
737
|
+
resolveNext = resolve2;
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
pending = false;
|
|
741
|
+
},
|
|
742
|
+
close: () => {
|
|
743
|
+
if (closed) return;
|
|
744
|
+
closed = true;
|
|
745
|
+
unsubscribe();
|
|
746
|
+
resolveNext?.();
|
|
747
|
+
resolveNext = void 0;
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// src/RepositoryRunner.ts
|
|
753
|
+
var execFileAsync = promisify(execFile);
|
|
754
|
+
var RUNNER_ARCHIVE = "runner.tgz";
|
|
755
|
+
var RELEASES_API = "https://api.github.com/repos/actions/runner/releases/latest";
|
|
756
|
+
var RunnerInstallError = class extends Error {
|
|
757
|
+
name = "RunnerInstallError";
|
|
758
|
+
};
|
|
759
|
+
var defaultAdapters = {
|
|
760
|
+
platform: () => process.platform,
|
|
761
|
+
arch: () => process.arch,
|
|
762
|
+
hostname,
|
|
763
|
+
environment: () => process.env,
|
|
764
|
+
exists: async (path) => {
|
|
765
|
+
try {
|
|
766
|
+
await access(path, constants.F_OK);
|
|
767
|
+
return true;
|
|
768
|
+
} catch {
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
readText: (path) => readFile(path, "utf8"),
|
|
773
|
+
writeText: (path, content) => writeFile(path, content, { mode: 384 }),
|
|
774
|
+
writeBytes: (path, content) => writeFile(path, content, { mode: 384 }),
|
|
775
|
+
makeDirectory: (path) => mkdir(path, { recursive: false, mode: 448 }),
|
|
776
|
+
remove: (path) => rm(path, { recursive: true, force: true }),
|
|
777
|
+
chmod,
|
|
778
|
+
commandExists: async (command) => {
|
|
779
|
+
try {
|
|
780
|
+
await execFileAsync("which", [command]);
|
|
781
|
+
return true;
|
|
782
|
+
} catch {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
inspectDirectory: async (path) => {
|
|
787
|
+
const [stat, resolved] = await Promise.all([lstat(path), realpath(path)]);
|
|
788
|
+
return {
|
|
789
|
+
realPath: resolved,
|
|
790
|
+
directory: stat.isDirectory(),
|
|
791
|
+
symbolicLink: stat.isSymbolicLink()
|
|
792
|
+
};
|
|
793
|
+
},
|
|
794
|
+
fetchJson: async (url) => {
|
|
795
|
+
const response = await fetch(url, {
|
|
796
|
+
headers: {
|
|
797
|
+
Accept: "application/vnd.github+json",
|
|
798
|
+
"User-Agent": "shipyard-runner-installer",
|
|
799
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
if (!response.ok) {
|
|
803
|
+
throw new Error(`GitHub returned HTTP ${response.status}`);
|
|
804
|
+
}
|
|
805
|
+
return response.json();
|
|
806
|
+
},
|
|
807
|
+
fetchBytes: async (url) => {
|
|
808
|
+
const response = await fetch(url, {
|
|
809
|
+
headers: { "User-Agent": "shipyard-runner-installer" }
|
|
810
|
+
});
|
|
811
|
+
if (!response.ok) {
|
|
812
|
+
throw new Error(`GitHub returned HTTP ${response.status}`);
|
|
813
|
+
}
|
|
814
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
815
|
+
},
|
|
816
|
+
run: async (command, args, options) => {
|
|
817
|
+
const result = await execFileAsync(command, [...args], {
|
|
818
|
+
cwd: options.cwd,
|
|
819
|
+
env: options.env,
|
|
820
|
+
maxBuffer: 10 * 1024 * 1024
|
|
821
|
+
});
|
|
822
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
var parseGitHubRepository = (remote) => {
|
|
826
|
+
const trimmed = remote.trim();
|
|
827
|
+
const sshMatch = trimmed.match(
|
|
828
|
+
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/
|
|
829
|
+
);
|
|
830
|
+
const httpsMatch = trimmed.match(
|
|
831
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/
|
|
832
|
+
);
|
|
833
|
+
const match = sshMatch ?? httpsMatch;
|
|
834
|
+
if (!match) {
|
|
835
|
+
throw new RunnerInstallError(
|
|
836
|
+
"The origin remote must be a GitHub.com repository (github.com/owner/repository). GitHub Enterprise and other hosts are not supported yet."
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
return { owner: match[1], repository: match[2] };
|
|
840
|
+
};
|
|
841
|
+
var normalizeNamePart = (value) => {
|
|
842
|
+
const normalized = value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
843
|
+
return normalized || "unknown";
|
|
844
|
+
};
|
|
845
|
+
var requireCommand = async (adapters, command, purpose) => {
|
|
846
|
+
if (!await adapters.commandExists(command)) {
|
|
847
|
+
throw new RunnerInstallError(
|
|
848
|
+
`Missing required command \`${command}\` (${purpose}). Install it and retry.`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
var asRelease = (value) => {
|
|
853
|
+
if (typeof value !== "object" || value === null) {
|
|
854
|
+
throw new RunnerInstallError(
|
|
855
|
+
"GitHub returned invalid runner release metadata."
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
const release = value;
|
|
859
|
+
if (typeof release.tag_name !== "string" || !Array.isArray(release.assets)) {
|
|
860
|
+
throw new RunnerInstallError(
|
|
861
|
+
"GitHub returned invalid runner release metadata."
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
return release;
|
|
865
|
+
};
|
|
866
|
+
var selectArm64Asset = (release) => {
|
|
867
|
+
const asset = release.assets.find(
|
|
868
|
+
(candidate) => /^actions-runner-osx-arm64-[\d.]+\.tar\.gz$/.test(candidate.name)
|
|
869
|
+
);
|
|
870
|
+
if (!asset || !asset.browser_download_url.startsWith(
|
|
871
|
+
"https://github.com/actions/runner/releases/download/"
|
|
872
|
+
) || !/^sha256:[a-f0-9]{64}$/i.test(asset.digest)) {
|
|
873
|
+
throw new RunnerInstallError(
|
|
874
|
+
"The latest official GitHub Actions runner release has no verifiable macOS ARM64 archive."
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
return asset;
|
|
878
|
+
};
|
|
879
|
+
var appendRunnerIgnores = async (gitignorePath, adapters) => {
|
|
880
|
+
const current = await adapters.exists(gitignorePath) ? await adapters.readText(gitignorePath) : "";
|
|
881
|
+
const lines = new Set(current.split(/\r?\n/));
|
|
882
|
+
const additions = [`${RUNNER_DIR}/`, `${RUNNER_SANDBOX_MASK_DIR}/`].filter(
|
|
883
|
+
(line) => !lines.has(line)
|
|
884
|
+
);
|
|
885
|
+
if (additions.length === 0) return;
|
|
886
|
+
const prefix = current.length === 0 || current.endsWith("\n") ? current : `${current}
|
|
887
|
+
`;
|
|
888
|
+
await adapters.writeText(gitignorePath, `${prefix}${additions.join("\n")}
|
|
889
|
+
`);
|
|
890
|
+
};
|
|
891
|
+
var commandFailure = (purpose, error) => new RunnerInstallError(
|
|
892
|
+
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
|
|
893
|
+
);
|
|
894
|
+
var reportProgress = (options, current, total, message) => options.onProgress?.({ current, total, message });
|
|
895
|
+
var installRepositoryRunner = async (options, adapters = defaultAdapters) => {
|
|
896
|
+
if (adapters.platform() !== "darwin" || adapters.arch() !== "arm64") {
|
|
897
|
+
throw new RunnerInstallError(
|
|
898
|
+
"Repository runners currently require Apple Silicon macOS (darwin/arm64)."
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
const configDir = join(options.repoDir, CONFIG_DIR);
|
|
902
|
+
const runnerDir = join(configDir, RUNNER_DIR);
|
|
903
|
+
const maskDir = join(configDir, RUNNER_SANDBOX_MASK_DIR);
|
|
904
|
+
if (!await adapters.exists(configDir)) {
|
|
905
|
+
throw new RunnerInstallError(
|
|
906
|
+
`No ${CONFIG_DIR}/ found. Run \`shipyard init\` in this repository first.`
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
const runnerExists = await adapters.exists(runnerDir);
|
|
910
|
+
const progressTotal = runnerExists ? 3 : 8;
|
|
911
|
+
try {
|
|
912
|
+
await assertRepositoryRunnerWorkflowCanBeInstalled(
|
|
913
|
+
options.repoDir,
|
|
914
|
+
adapters
|
|
915
|
+
);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
throw new RunnerInstallError(
|
|
918
|
+
error instanceof Error ? error.message : String(error)
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
await requireCommand(adapters, "git", "reading the repository remote");
|
|
922
|
+
if (!runnerExists) {
|
|
923
|
+
await requireCommand(adapters, "tar", "extracting the runner archive");
|
|
924
|
+
}
|
|
925
|
+
await requireCommand(adapters, "gh", "checking repository runner uniqueness");
|
|
926
|
+
const hostEnv = adapters.environment();
|
|
927
|
+
const gitRemote = await adapters.run("git", ["remote", "get-url", "origin"], {
|
|
928
|
+
cwd: options.repoDir,
|
|
929
|
+
env: hostEnv
|
|
930
|
+
}).catch((error) => {
|
|
931
|
+
throw commandFailure("Reading the origin remote", error);
|
|
932
|
+
});
|
|
933
|
+
const identity = parseGitHubRepository(gitRemote.stdout);
|
|
934
|
+
const repository = `${identity.owner}/${identity.repository}`;
|
|
935
|
+
const repoUrl = `https://github.com/${repository}`;
|
|
936
|
+
const runnerName = `shipyard-${normalizeNamePart(identity.repository)}-${normalizeNamePart(adapters.hostname())}`;
|
|
937
|
+
await adapters.run("gh", ["auth", "status", "--hostname", "github.com"], {
|
|
938
|
+
cwd: options.repoDir,
|
|
939
|
+
env: hostEnv
|
|
940
|
+
}).catch((error) => {
|
|
941
|
+
throw commandFailure(
|
|
942
|
+
"GitHub CLI authentication (repository administration is required)",
|
|
943
|
+
error
|
|
944
|
+
);
|
|
945
|
+
});
|
|
946
|
+
reportProgress(
|
|
947
|
+
options,
|
|
948
|
+
1,
|
|
949
|
+
progressTotal,
|
|
950
|
+
"Validated repository and GitHub access"
|
|
951
|
+
);
|
|
952
|
+
if (runnerExists) {
|
|
953
|
+
let metadata;
|
|
954
|
+
try {
|
|
955
|
+
metadata = await validateExistingRepositoryRunner(
|
|
956
|
+
{
|
|
957
|
+
repoDir: options.repoDir,
|
|
958
|
+
runnerDir,
|
|
959
|
+
maskDir,
|
|
960
|
+
repository,
|
|
961
|
+
runnerName
|
|
962
|
+
},
|
|
963
|
+
adapters
|
|
964
|
+
);
|
|
965
|
+
} catch (error) {
|
|
966
|
+
throw new RunnerInstallError(
|
|
967
|
+
error instanceof RunnerLifecycleError ? error.message : `Validating the existing repository runner failed: ${error instanceof Error ? error.message : String(error)}`
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
await appendRunnerIgnores(join(configDir, ".gitignore"), adapters);
|
|
971
|
+
await adapters.chmod(runnerDir, 448);
|
|
972
|
+
await adapters.chmod(maskDir, 448);
|
|
973
|
+
await assertProtectedDirectoryIdentities(
|
|
974
|
+
runnerDir,
|
|
975
|
+
maskDir,
|
|
976
|
+
adapters.inspectDirectory
|
|
977
|
+
).catch((error) => {
|
|
978
|
+
throw commandFailure("Validating protected runner directories", error);
|
|
979
|
+
});
|
|
980
|
+
reportProgress(options, 2, progressTotal, "Validated existing runner");
|
|
981
|
+
try {
|
|
982
|
+
await installRepositoryRunnerWakeFiles(
|
|
983
|
+
{ repoDir: options.repoDir, runnerDir },
|
|
984
|
+
adapters
|
|
985
|
+
);
|
|
986
|
+
} catch (error) {
|
|
987
|
+
throw new RunnerInstallError(
|
|
988
|
+
`The existing runner is valid, but its wake-up files could not be refreshed: ${error instanceof Error ? error.message : String(error)}`
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
reportProgress(options, 3, progressTotal, "Refreshed wake-up workflow");
|
|
992
|
+
return {
|
|
993
|
+
name: metadata.name,
|
|
994
|
+
repository: metadata.repository,
|
|
995
|
+
version: metadata.version,
|
|
996
|
+
runnerDir
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const existing = await adapters.run(
|
|
1000
|
+
"gh",
|
|
1001
|
+
[
|
|
1002
|
+
"api",
|
|
1003
|
+
"--paginate",
|
|
1004
|
+
`repos/${repository}/actions/runners`,
|
|
1005
|
+
"--jq",
|
|
1006
|
+
`.runners[] | select(any(.labels[]; .name == "${ACTIVATION_LABEL}")) | .name`
|
|
1007
|
+
],
|
|
1008
|
+
{ cwd: options.repoDir, env: hostEnv }
|
|
1009
|
+
).catch((error) => {
|
|
1010
|
+
throw commandFailure(
|
|
1011
|
+
"Checking existing repository runners (repository administration is required)",
|
|
1012
|
+
error
|
|
1013
|
+
);
|
|
1014
|
+
});
|
|
1015
|
+
if (existing.stdout.trim().length > 0) {
|
|
1016
|
+
throw new RunnerInstallError(
|
|
1017
|
+
`A repository runner labeled \`${ACTIVATION_LABEL}\` is already registered for ${repository}. Remove it before installing another.`
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
reportProgress(
|
|
1021
|
+
options,
|
|
1022
|
+
2,
|
|
1023
|
+
progressTotal,
|
|
1024
|
+
"Checked for conflicting repository runners"
|
|
1025
|
+
);
|
|
1026
|
+
let registrationToken = options.registrationToken;
|
|
1027
|
+
if (!registrationToken) {
|
|
1028
|
+
const tokenResult = await adapters.run(
|
|
1029
|
+
"gh",
|
|
1030
|
+
[
|
|
1031
|
+
"api",
|
|
1032
|
+
"--method",
|
|
1033
|
+
"POST",
|
|
1034
|
+
`repos/${repository}/actions/runners/registration-token`
|
|
1035
|
+
],
|
|
1036
|
+
{ cwd: options.repoDir, env: hostEnv }
|
|
1037
|
+
).catch((error) => {
|
|
1038
|
+
throw commandFailure(
|
|
1039
|
+
"Requesting a one-time runner registration token (repository administration is required)",
|
|
1040
|
+
error
|
|
1041
|
+
);
|
|
1042
|
+
});
|
|
1043
|
+
try {
|
|
1044
|
+
const parsed = JSON.parse(tokenResult.stdout);
|
|
1045
|
+
if (typeof parsed.token !== "string" || parsed.token.length === 0) {
|
|
1046
|
+
throw new Error("response did not contain a token");
|
|
1047
|
+
}
|
|
1048
|
+
registrationToken = parsed.token;
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
throw commandFailure("Reading the one-time registration token", error);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
reportProgress(
|
|
1054
|
+
options,
|
|
1055
|
+
3,
|
|
1056
|
+
progressTotal,
|
|
1057
|
+
"Prepared runner registration token"
|
|
1058
|
+
);
|
|
1059
|
+
let release;
|
|
1060
|
+
try {
|
|
1061
|
+
release = asRelease(await adapters.fetchJson(RELEASES_API));
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
if (error instanceof RunnerInstallError) throw error;
|
|
1064
|
+
throw commandFailure("Fetching official runner release metadata", error);
|
|
1065
|
+
}
|
|
1066
|
+
const asset = selectArm64Asset(release);
|
|
1067
|
+
const version = release.tag_name.replace(/^v/, "");
|
|
1068
|
+
let archive;
|
|
1069
|
+
try {
|
|
1070
|
+
archive = await adapters.fetchBytes(asset.browser_download_url);
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
throw commandFailure("Downloading the official runner archive", error);
|
|
1073
|
+
}
|
|
1074
|
+
const actualDigest = createHash("sha256").update(archive).digest("hex");
|
|
1075
|
+
const expectedDigest = asset.digest.slice("sha256:".length).toLowerCase();
|
|
1076
|
+
if (actualDigest !== expectedDigest) {
|
|
1077
|
+
throw new RunnerInstallError(
|
|
1078
|
+
`Runner archive digest mismatch: expected ${expectedDigest}, received ${actualDigest}. Nothing was installed.`
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
reportProgress(
|
|
1082
|
+
options,
|
|
1083
|
+
4,
|
|
1084
|
+
progressTotal,
|
|
1085
|
+
"Downloaded and verified official runner archive"
|
|
1086
|
+
);
|
|
1087
|
+
await appendRunnerIgnores(join(configDir, ".gitignore"), adapters);
|
|
1088
|
+
await adapters.makeDirectory(runnerDir).catch((error) => {
|
|
1089
|
+
throw commandFailure("Creating the protected runner directory", error);
|
|
1090
|
+
});
|
|
1091
|
+
if (!await adapters.exists(maskDir)) {
|
|
1092
|
+
await adapters.makeDirectory(maskDir);
|
|
1093
|
+
}
|
|
1094
|
+
await assertProtectedDirectoryIdentities(
|
|
1095
|
+
runnerDir,
|
|
1096
|
+
maskDir,
|
|
1097
|
+
adapters.inspectDirectory
|
|
1098
|
+
).catch((error) => {
|
|
1099
|
+
throw commandFailure("Validating protected runner directories", error);
|
|
1100
|
+
});
|
|
1101
|
+
await adapters.chmod(runnerDir, 448);
|
|
1102
|
+
await adapters.chmod(maskDir, 448);
|
|
1103
|
+
reportProgress(
|
|
1104
|
+
options,
|
|
1105
|
+
5,
|
|
1106
|
+
progressTotal,
|
|
1107
|
+
"Prepared protected runner directories"
|
|
1108
|
+
);
|
|
1109
|
+
const archivePath = join(runnerDir, RUNNER_ARCHIVE);
|
|
1110
|
+
await adapters.writeBytes(archivePath, archive);
|
|
1111
|
+
try {
|
|
1112
|
+
await adapters.run("tar", ["-xzf", archivePath, "--directory", runnerDir], {
|
|
1113
|
+
cwd: options.repoDir,
|
|
1114
|
+
env: repositoryRunnerEnvironment(hostEnv)
|
|
1115
|
+
});
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
await adapters.remove(runnerDir);
|
|
1118
|
+
throw commandFailure("Extracting the verified runner archive", error);
|
|
1119
|
+
} finally {
|
|
1120
|
+
await adapters.remove(archivePath);
|
|
1121
|
+
}
|
|
1122
|
+
reportProgress(options, 6, progressTotal, "Extracted runner archive");
|
|
1123
|
+
try {
|
|
1124
|
+
await adapters.run(
|
|
1125
|
+
"./config.sh",
|
|
1126
|
+
[
|
|
1127
|
+
"--url",
|
|
1128
|
+
repoUrl,
|
|
1129
|
+
"--token",
|
|
1130
|
+
registrationToken,
|
|
1131
|
+
"--name",
|
|
1132
|
+
runnerName,
|
|
1133
|
+
"--labels",
|
|
1134
|
+
ACTIVATION_LABEL,
|
|
1135
|
+
"--work",
|
|
1136
|
+
"_work",
|
|
1137
|
+
"--unattended"
|
|
1138
|
+
],
|
|
1139
|
+
{ cwd: runnerDir, env: repositoryRunnerEnvironment(hostEnv) }
|
|
1140
|
+
);
|
|
1141
|
+
} catch {
|
|
1142
|
+
const cleanup = await Promise.allSettled([
|
|
1143
|
+
adapters.remove(runnerDir),
|
|
1144
|
+
adapters.remove(maskDir)
|
|
1145
|
+
]);
|
|
1146
|
+
const cleanupSucceeded = cleanup.every(
|
|
1147
|
+
(result) => result.status === "fulfilled"
|
|
1148
|
+
);
|
|
1149
|
+
throw new RunnerInstallError(
|
|
1150
|
+
cleanupSucceeded ? `Registering ${runnerName} failed. Partial local runner files were removed so installation can be retried. Check GitHub Settings > Actions > Runners for an orphan registration; the one-time token was not stored.` : `Registering ${runnerName} failed and partial local runner files could not be fully removed. Remove only ${runnerDir} and ${maskDir}, check GitHub Settings > Actions > Runners for an orphan registration, then retry. The one-time token was not stored.`
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
reportProgress(options, 7, progressTotal, "Registered runner with GitHub");
|
|
1154
|
+
await adapters.writeText(
|
|
1155
|
+
join(runnerDir, RUNNER_INSTALL_METADATA),
|
|
1156
|
+
`${JSON.stringify(
|
|
1157
|
+
{
|
|
1158
|
+
schemaVersion: 1,
|
|
1159
|
+
repository,
|
|
1160
|
+
repositoryUrl: repoUrl,
|
|
1161
|
+
name: runnerName,
|
|
1162
|
+
label: ACTIVATION_LABEL,
|
|
1163
|
+
version
|
|
1164
|
+
},
|
|
1165
|
+
null,
|
|
1166
|
+
2
|
|
1167
|
+
)}
|
|
1168
|
+
`
|
|
1169
|
+
);
|
|
1170
|
+
try {
|
|
1171
|
+
await installRepositoryRunnerWakeFiles(
|
|
1172
|
+
{ repoDir: options.repoDir, runnerDir },
|
|
1173
|
+
adapters
|
|
1174
|
+
);
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
throw new RunnerInstallError(
|
|
1177
|
+
`The runner was registered, but its wake-up files could not be installed. Runner files were retained at ${runnerDir}; fix the error and retry: ${error instanceof Error ? error.message : String(error)}`
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
reportProgress(options, 8, progressTotal, "Installed wake-up workflow");
|
|
1181
|
+
return { name: runnerName, repository, version, runnerDir };
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
// src/InitRepositoryRunner.ts
|
|
1185
|
+
var initializeRepositoryRunner = async (options) => {
|
|
1186
|
+
let shouldInstall = options.requested;
|
|
1187
|
+
if (shouldInstall === void 0) {
|
|
1188
|
+
shouldInstall = options.interactive ? await options.confirm({
|
|
1189
|
+
message: "Install a foreground repository runner for labelled GitHub issues?",
|
|
1190
|
+
initialValue: true
|
|
1191
|
+
}) : false;
|
|
1192
|
+
}
|
|
1193
|
+
if (!shouldInstall) return { status: "declined" };
|
|
1194
|
+
try {
|
|
1195
|
+
return { status: "installed", result: await options.install() };
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
return {
|
|
1198
|
+
status: "failed",
|
|
1199
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
var repositoryRunnerNextSteps = () => [
|
|
1204
|
+
`First, commit and push ${REPOSITORY_RUNNER_WORKFLOW_PATH} to the repository's default branch`,
|
|
1205
|
+
`Keep the foreground controller open with \`npx ${CLI_NAME} runner start\``,
|
|
1206
|
+
`Inspect it from another terminal with \`npx ${CLI_NAME} runner status\``,
|
|
1207
|
+
`Stop it with \`npx ${CLI_NAME} runner stop\``,
|
|
1208
|
+
`Unregister and delete its protected files with \`npx ${CLI_NAME} runner remove\``
|
|
1209
|
+
];
|
|
1210
|
+
var execFileAsync2 = promisify(execFile);
|
|
1211
|
+
var CONTROLLER_STATE = ".shipyard-state.json";
|
|
1212
|
+
var LAST_FAILURE = ".shipyard-last-failure.json";
|
|
1213
|
+
var LOG_PURGE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
1214
|
+
var RunnerControlError = class extends Error {
|
|
1215
|
+
name = "RunnerControlError";
|
|
1216
|
+
};
|
|
1217
|
+
var retainedFailures = /* @__PURE__ */ new WeakSet();
|
|
1218
|
+
var spawnForegroundChild = (command, args, options) => {
|
|
1219
|
+
const child = spawn(command, [...args], {
|
|
1220
|
+
cwd: options.cwd,
|
|
1221
|
+
env: options.env,
|
|
1222
|
+
stdio: "inherit",
|
|
1223
|
+
detached: true
|
|
1224
|
+
});
|
|
1225
|
+
const pid = child.pid;
|
|
1226
|
+
if (pid === void 0) {
|
|
1227
|
+
throw new RunnerControlError(`Could not start ${command}.`);
|
|
1228
|
+
}
|
|
1229
|
+
const result = new Promise((resolve2, reject) => {
|
|
1230
|
+
child.once("error", reject);
|
|
1231
|
+
child.once("close", (code, signal) => resolve2({ code, signal }));
|
|
1232
|
+
});
|
|
1233
|
+
return {
|
|
1234
|
+
pid,
|
|
1235
|
+
wait: () => result,
|
|
1236
|
+
terminate: (signal) => {
|
|
1237
|
+
try {
|
|
1238
|
+
process.kill(-pid, signal);
|
|
1239
|
+
} catch {
|
|
1240
|
+
child.kill(signal);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
};
|
|
1245
|
+
var registerRunnerShutdown = (handler) => {
|
|
1246
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
1247
|
+
let handling = false;
|
|
1248
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
1249
|
+
const unregister = () => {
|
|
1250
|
+
for (const [signal, listener] of listeners) {
|
|
1251
|
+
process.removeListener(signal, listener);
|
|
1252
|
+
}
|
|
1253
|
+
listeners.clear();
|
|
1254
|
+
};
|
|
1255
|
+
for (const signal of signals) {
|
|
1256
|
+
const listener = () => {
|
|
1257
|
+
if (handling) return;
|
|
1258
|
+
handling = true;
|
|
1259
|
+
void handler().finally(() => {
|
|
1260
|
+
unregister();
|
|
1261
|
+
process.exit(
|
|
1262
|
+
signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 129
|
|
1263
|
+
);
|
|
1264
|
+
});
|
|
1265
|
+
};
|
|
1266
|
+
listeners.set(signal, listener);
|
|
1267
|
+
process.once(signal, listener);
|
|
1268
|
+
}
|
|
1269
|
+
return unregister;
|
|
1270
|
+
};
|
|
1271
|
+
var defaultAdapters2 = {
|
|
1272
|
+
platform: () => process.platform,
|
|
1273
|
+
arch: () => process.arch,
|
|
1274
|
+
environment: () => process.env,
|
|
1275
|
+
currentPid: () => process.pid,
|
|
1276
|
+
processIdentity: async (pid) => {
|
|
1277
|
+
try {
|
|
1278
|
+
const result = await execFileAsync2("ps", [
|
|
1279
|
+
"-p",
|
|
1280
|
+
String(pid),
|
|
1281
|
+
"-o",
|
|
1282
|
+
"lstart="
|
|
1283
|
+
]);
|
|
1284
|
+
const identity = result.stdout.trim().replace(/\s+/g, " ");
|
|
1285
|
+
return identity.length > 0 ? identity : void 0;
|
|
1286
|
+
} catch {
|
|
1287
|
+
return void 0;
|
|
1288
|
+
}
|
|
1289
|
+
},
|
|
1290
|
+
exists: async (path) => {
|
|
1291
|
+
try {
|
|
1292
|
+
await access(path, constants.F_OK);
|
|
1293
|
+
return true;
|
|
1294
|
+
} catch {
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
inspectDirectory: async (path) => {
|
|
1299
|
+
const [stat, resolved] = await Promise.all([lstat(path), realpath(path)]);
|
|
1300
|
+
return {
|
|
1301
|
+
realPath: resolved,
|
|
1302
|
+
directory: stat.isDirectory(),
|
|
1303
|
+
symbolicLink: stat.isSymbolicLink()
|
|
1304
|
+
};
|
|
1305
|
+
},
|
|
1306
|
+
readText: (path) => readFile(path, "utf8"),
|
|
1307
|
+
writeText: (path, content) => writeFile(path, content, { mode: 384 }),
|
|
1308
|
+
writeExclusive: (path, content) => writeFile(path, content, { encoding: "utf8", mode: 384, flag: "wx" }),
|
|
1309
|
+
remove: (path) => rm(path, { force: true }),
|
|
1310
|
+
makeDirectory: async (path) => {
|
|
1311
|
+
await mkdir(path, { recursive: true, mode: 448 });
|
|
1312
|
+
},
|
|
1313
|
+
removeTree: (path) => rm(path, { recursive: true, force: true }),
|
|
1314
|
+
commandExists: async (command) => {
|
|
1315
|
+
try {
|
|
1316
|
+
await execFileAsync2("which", [command]);
|
|
1317
|
+
return true;
|
|
1318
|
+
} catch {
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
},
|
|
1322
|
+
resolveEnvironment: (repoDir) => Effect.runPromise(
|
|
1323
|
+
resolveEnv(repoDir).pipe(Effect.provide(NodeFileSystem.layer))
|
|
1324
|
+
),
|
|
1325
|
+
run: async (command, args, options) => {
|
|
1326
|
+
const result = await execFileAsync2(command, [...args], {
|
|
1327
|
+
cwd: options.cwd,
|
|
1328
|
+
env: options.env,
|
|
1329
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1330
|
+
});
|
|
1331
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
1332
|
+
},
|
|
1333
|
+
spawn: spawnForegroundChild,
|
|
1334
|
+
isProcessRunning: (pid) => {
|
|
1335
|
+
try {
|
|
1336
|
+
process.kill(pid, 0);
|
|
1337
|
+
return true;
|
|
1338
|
+
} catch {
|
|
1339
|
+
return false;
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
signalProcess: (pid, signal) => process.kill(pid, signal),
|
|
1343
|
+
onShutdown: registerRunnerShutdown,
|
|
1344
|
+
pause: (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)),
|
|
1345
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
1346
|
+
purgeRunLogs,
|
|
1347
|
+
scheduleRecurring: (task, milliseconds) => {
|
|
1348
|
+
const timer = setInterval(() => {
|
|
1349
|
+
void task();
|
|
1350
|
+
}, milliseconds);
|
|
1351
|
+
return () => clearInterval(timer);
|
|
1352
|
+
},
|
|
1353
|
+
report: (message) => console.log(message)
|
|
1354
|
+
};
|
|
1355
|
+
var readJson = async (path, adapters) => {
|
|
1356
|
+
if (!await adapters.exists(path)) return void 0;
|
|
1357
|
+
try {
|
|
1358
|
+
return JSON.parse(await adapters.readText(path));
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
throw new RunnerControlError(
|
|
1361
|
+
`Could not read repository runner state at ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
var writeJson = (path, value, adapters) => adapters.writeText(path, `${JSON.stringify(value, null, 2)}
|
|
1366
|
+
`);
|
|
1367
|
+
var controlFailure = (purpose, error) => error instanceof RunnerControlError ? error : new RunnerControlError(
|
|
1368
|
+
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1369
|
+
);
|
|
1370
|
+
var runCommand = async (adapters, purpose, command, args, options) => {
|
|
1371
|
+
try {
|
|
1372
|
+
return await adapters.run(command, args, options);
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
throw controlFailure(purpose, error);
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
var requireCommand2 = async (adapters, command, purpose) => {
|
|
1378
|
+
if (!await adapters.commandExists(command)) {
|
|
1379
|
+
throw new RunnerControlError(
|
|
1380
|
+
`Missing required command \`${command}\` (${purpose}). Install it and retry.`
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
var requireRunnerContext = async (repoDir, adapters) => {
|
|
1385
|
+
if (adapters.platform() !== "darwin" || adapters.arch() !== "arm64") {
|
|
1386
|
+
throw new RunnerControlError(
|
|
1387
|
+
"Repository runners currently require Apple Silicon macOS (darwin/arm64)."
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
const configDir = join(repoDir, CONFIG_DIR);
|
|
1391
|
+
const runnerDir = join(configDir, RUNNER_DIR);
|
|
1392
|
+
const maskDir = join(configDir, RUNNER_SANDBOX_MASK_DIR);
|
|
1393
|
+
if (!await adapters.exists(configDir)) {
|
|
1394
|
+
throw new RunnerControlError(
|
|
1395
|
+
`No ${CONFIG_DIR}/ found. Run \`shipyard init\` in this repository first.`
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
if (!await adapters.exists(runnerDir)) {
|
|
1399
|
+
throw new RunnerControlError(
|
|
1400
|
+
`No repository runner is installed at ${runnerDir}. Run \`shipyard runner install\` first.`
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
if (adapters.inspectDirectory) {
|
|
1404
|
+
const runnerIdentity = await adapters.inspectDirectory(runnerDir).catch((error) => {
|
|
1405
|
+
throw controlFailure(
|
|
1406
|
+
"Inspecting the protected runner directory",
|
|
1407
|
+
error
|
|
1408
|
+
);
|
|
1409
|
+
});
|
|
1410
|
+
if (!runnerIdentity.directory || runnerIdentity.symbolicLink) {
|
|
1411
|
+
throw new RunnerControlError(
|
|
1412
|
+
`The protected repository runner path must be a real directory: ${runnerDir}`
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
const metadata = await readJson(
|
|
1417
|
+
join(runnerDir, RUNNER_INSTALL_METADATA),
|
|
1418
|
+
adapters
|
|
1419
|
+
);
|
|
1420
|
+
if (metadata?.schemaVersion !== 1 || typeof metadata.repository !== "string" || typeof metadata.name !== "string" || metadata.label !== ACTIVATION_LABEL) {
|
|
1421
|
+
throw new RunnerControlError(
|
|
1422
|
+
"The repository runner installation metadata is missing or invalid. Reinstall the repository runner."
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
if (!await adapters.exists(join(runnerDir, "run.sh"))) {
|
|
1426
|
+
throw new RunnerControlError(
|
|
1427
|
+
"The official repository runner executable is missing. Reinstall the repository runner."
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
for (const [command, purpose] of [
|
|
1431
|
+
["git", "reading the repository remote"],
|
|
1432
|
+
["npx", "running the local Shipyard package"],
|
|
1433
|
+
["docker", "checking the Docker runtime"],
|
|
1434
|
+
["gh", "checking GitHub access"]
|
|
1435
|
+
]) {
|
|
1436
|
+
await requireCommand2(adapters, command, purpose);
|
|
1437
|
+
}
|
|
1438
|
+
const hostEnvironment = adapters.environment();
|
|
1439
|
+
const resolvedEnvironment = await adapters.resolveEnvironment(repoDir).catch((error) => {
|
|
1440
|
+
throw controlFailure("Resolving repository credentials", error);
|
|
1441
|
+
});
|
|
1442
|
+
const runtimeEnvironment = {
|
|
1443
|
+
...hostEnvironment,
|
|
1444
|
+
...resolvedEnvironment,
|
|
1445
|
+
[REPOSITORY_RUNNER_OWNER_ENV]: metadata.repository
|
|
1446
|
+
};
|
|
1447
|
+
const remote = await runCommand(
|
|
1448
|
+
adapters,
|
|
1449
|
+
"Reading the origin remote",
|
|
1450
|
+
"git",
|
|
1451
|
+
["remote", "get-url", "origin"],
|
|
1452
|
+
{ cwd: repoDir, env: runtimeEnvironment }
|
|
1453
|
+
);
|
|
1454
|
+
let identity;
|
|
1455
|
+
try {
|
|
1456
|
+
identity = parseGitHubRepository(remote.stdout);
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
throw new RunnerControlError(
|
|
1459
|
+
error instanceof Error ? error.message : String(error)
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
const repository = `${identity.owner}/${identity.repository}`;
|
|
1463
|
+
if (repository !== metadata.repository) {
|
|
1464
|
+
throw new RunnerControlError(
|
|
1465
|
+
`The installed repository runner belongs to ${metadata.repository}, but this repository origin is ${repository}. Reinstall it for this repository.`
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
await runCommand(
|
|
1469
|
+
adapters,
|
|
1470
|
+
"Checking the local Shipyard package",
|
|
1471
|
+
"npx",
|
|
1472
|
+
["--no-install", "shipyard", "--version"],
|
|
1473
|
+
{ cwd: repoDir, env: runtimeEnvironment }
|
|
1474
|
+
);
|
|
1475
|
+
await runCommand(
|
|
1476
|
+
adapters,
|
|
1477
|
+
"Checking Docker availability",
|
|
1478
|
+
"docker",
|
|
1479
|
+
["info"],
|
|
1480
|
+
{ cwd: repoDir, env: runtimeEnvironment }
|
|
1481
|
+
);
|
|
1482
|
+
await runCommand(
|
|
1483
|
+
adapters,
|
|
1484
|
+
"Checking GitHub authentication",
|
|
1485
|
+
"gh",
|
|
1486
|
+
["auth", "status", "--hostname", "github.com"],
|
|
1487
|
+
{ cwd: repoDir, env: hostEnvironment }
|
|
1488
|
+
);
|
|
1489
|
+
const label = await runCommand(
|
|
1490
|
+
adapters,
|
|
1491
|
+
"Checking the activation label",
|
|
1492
|
+
"gh",
|
|
1493
|
+
[
|
|
1494
|
+
"label",
|
|
1495
|
+
"list",
|
|
1496
|
+
"--repo",
|
|
1497
|
+
repository,
|
|
1498
|
+
"--json",
|
|
1499
|
+
"name",
|
|
1500
|
+
"--jq",
|
|
1501
|
+
`.[] | select(.name == "${ACTIVATION_LABEL}") | .name`
|
|
1502
|
+
],
|
|
1503
|
+
{ cwd: repoDir, env: hostEnvironment }
|
|
1504
|
+
);
|
|
1505
|
+
if (label.stdout.trim() !== ACTIVATION_LABEL) {
|
|
1506
|
+
await runCommand(
|
|
1507
|
+
adapters,
|
|
1508
|
+
"Creating the activation label",
|
|
1509
|
+
"gh",
|
|
1510
|
+
[
|
|
1511
|
+
"label",
|
|
1512
|
+
"create",
|
|
1513
|
+
ACTIVATION_LABEL,
|
|
1514
|
+
"--repo",
|
|
1515
|
+
repository,
|
|
1516
|
+
"--color",
|
|
1517
|
+
"1D76DB",
|
|
1518
|
+
"--description",
|
|
1519
|
+
"Tasks available to the Shipyard repository runner",
|
|
1520
|
+
"--force"
|
|
1521
|
+
],
|
|
1522
|
+
{ cwd: repoDir, env: hostEnvironment }
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
await requirePublishedRepositoryRunnerWorkflow(
|
|
1526
|
+
{
|
|
1527
|
+
repoDir,
|
|
1528
|
+
repository,
|
|
1529
|
+
// The published workflow is an administrative runner preflight. Keep
|
|
1530
|
+
// it on the host's gh login instead of the issue-agent token from
|
|
1531
|
+
// .shipyard/.env, which intentionally only needs issue permissions.
|
|
1532
|
+
environment: hostEnvironment
|
|
1533
|
+
},
|
|
1534
|
+
adapters
|
|
1535
|
+
).catch((error) => {
|
|
1536
|
+
throw new RunnerControlError(
|
|
1537
|
+
error instanceof Error ? error.message : String(error)
|
|
1538
|
+
);
|
|
1539
|
+
});
|
|
1540
|
+
return {
|
|
1541
|
+
runnerDir,
|
|
1542
|
+
maskDir,
|
|
1543
|
+
repository,
|
|
1544
|
+
metadata,
|
|
1545
|
+
hostEnvironment,
|
|
1546
|
+
runtimeEnvironment
|
|
1547
|
+
};
|
|
1548
|
+
};
|
|
1549
|
+
var childFailure = (command, result) => new RunnerControlError(
|
|
1550
|
+
result.signal === null ? `${command} exited with code ${result.code ?? "unknown"}.` : `${command} was terminated by ${result.signal}.`
|
|
1551
|
+
);
|
|
1552
|
+
var stopChildBounded = async (child, pause) => {
|
|
1553
|
+
child.terminate("SIGTERM");
|
|
1554
|
+
const stopped = await Promise.race([
|
|
1555
|
+
child.wait().then(() => true),
|
|
1556
|
+
pause(5e3).then(() => false)
|
|
1557
|
+
]);
|
|
1558
|
+
if (stopped) return;
|
|
1559
|
+
child.terminate("SIGKILL");
|
|
1560
|
+
const killed = await Promise.race([
|
|
1561
|
+
child.wait().then(() => true),
|
|
1562
|
+
pause(1e3).then(() => false)
|
|
1563
|
+
]);
|
|
1564
|
+
if (!killed) {
|
|
1565
|
+
throw new RunnerControlError(
|
|
1566
|
+
`Process ${child.pid} did not exit after SIGKILL.`
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
};
|
|
1570
|
+
var lockOwnsProcess = async (lock, adapters) => lock !== void 0 && Number.isInteger(lock.pid) && typeof lock.processStartedAt === "string" && lock.processStartedAt.length > 0 && adapters.isProcessRunning(lock.pid) && await adapters.processIdentity(lock.pid) === lock.processStartedAt;
|
|
1571
|
+
var bestEffort = async (operation) => {
|
|
1572
|
+
try {
|
|
1573
|
+
await operation();
|
|
1574
|
+
} catch {
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
var startRepositoryRunnerManaged = async (options, adapters = defaultAdapters2) => {
|
|
1578
|
+
const context = await requireRunnerContext(options.repoDir, adapters);
|
|
1579
|
+
const lifecycleAdapters = {
|
|
1580
|
+
environment: adapters.environment,
|
|
1581
|
+
exists: adapters.exists,
|
|
1582
|
+
readText: adapters.readText,
|
|
1583
|
+
makeDirectory: adapters.makeDirectory,
|
|
1584
|
+
inspectDirectory: adapters.inspectDirectory,
|
|
1585
|
+
remove: adapters.removeTree,
|
|
1586
|
+
run: adapters.run,
|
|
1587
|
+
isProcessRunning: adapters.isProcessRunning,
|
|
1588
|
+
signalProcess: adapters.signalProcess,
|
|
1589
|
+
pause: adapters.pause,
|
|
1590
|
+
processIdentity: adapters.processIdentity
|
|
1591
|
+
};
|
|
1592
|
+
await recoverRepositoryRunner(
|
|
1593
|
+
{
|
|
1594
|
+
repoDir: options.repoDir,
|
|
1595
|
+
runnerDir: context.runnerDir,
|
|
1596
|
+
maskDir: context.maskDir,
|
|
1597
|
+
metadata: context.metadata,
|
|
1598
|
+
runnerEnvironment: repositoryRunnerEnvironment(context.hostEnvironment),
|
|
1599
|
+
dockerEnvironment: context.runtimeEnvironment
|
|
1600
|
+
},
|
|
1601
|
+
lifecycleAdapters
|
|
1602
|
+
).catch((error) => {
|
|
1603
|
+
throw new RunnerControlError(
|
|
1604
|
+
error instanceof RunnerLifecycleError ? error.message : `Repository runner recovery failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1605
|
+
);
|
|
1606
|
+
});
|
|
1607
|
+
const lockPath = join(context.runnerDir, RUNNER_CONTROLLER_LOCK);
|
|
1608
|
+
const statePath = join(context.runnerDir, CONTROLLER_STATE);
|
|
1609
|
+
const failurePath = join(context.runnerDir, LAST_FAILURE);
|
|
1610
|
+
const existingLock = await readJson(lockPath, adapters);
|
|
1611
|
+
if (await lockOwnsProcess(existingLock, adapters)) {
|
|
1612
|
+
throw new RunnerControlError(
|
|
1613
|
+
`The repository runner is already running with process ${existingLock.pid}.`
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
if (existingLock) {
|
|
1617
|
+
throw new RunnerControlError(
|
|
1618
|
+
"The repository runner has stale process state. Run it again after recovery or remove the stale installation."
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
const controllerPid = adapters.currentPid();
|
|
1622
|
+
const processStartedAt = await adapters.processIdentity(controllerPid);
|
|
1623
|
+
if (!processStartedAt) {
|
|
1624
|
+
throw new RunnerControlError(
|
|
1625
|
+
"Could not establish the repository runner controller process identity."
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
const lock = {
|
|
1629
|
+
schemaVersion: 1,
|
|
1630
|
+
pid: controllerPid,
|
|
1631
|
+
repository: context.repository,
|
|
1632
|
+
processStartedAt
|
|
1633
|
+
};
|
|
1634
|
+
try {
|
|
1635
|
+
await adapters.writeExclusive(lockPath, `${JSON.stringify(lock)}
|
|
1636
|
+
`);
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
throw controlFailure("Claiming the repository runner process", error);
|
|
1639
|
+
}
|
|
1640
|
+
let listenerChild;
|
|
1641
|
+
let shipyardChild;
|
|
1642
|
+
let cancelLogPurge;
|
|
1643
|
+
let stopping = false;
|
|
1644
|
+
let lastWake;
|
|
1645
|
+
let shutdownPromise;
|
|
1646
|
+
let primaryFailure;
|
|
1647
|
+
const saveState = async (state, lastOutcome) => {
|
|
1648
|
+
await writeJson(
|
|
1649
|
+
statePath,
|
|
1650
|
+
{
|
|
1651
|
+
schemaVersion: 1,
|
|
1652
|
+
repository: context.repository,
|
|
1653
|
+
state,
|
|
1654
|
+
lastOutcome,
|
|
1655
|
+
...lastWake ? { lastWake } : {}
|
|
1656
|
+
},
|
|
1657
|
+
adapters
|
|
1658
|
+
);
|
|
1659
|
+
adapters.report(`[repository runner] ${state}: ${lastOutcome}`);
|
|
1660
|
+
};
|
|
1661
|
+
const purgeLogs = async () => {
|
|
1662
|
+
if (stopping || shipyardChild !== void 0) return;
|
|
1663
|
+
try {
|
|
1664
|
+
const result = await adapters.purgeRunLogs({
|
|
1665
|
+
repoDir: options.repoDir,
|
|
1666
|
+
retentionDays: DEFAULT_LOG_RETENTION_DAYS,
|
|
1667
|
+
now: adapters.now()
|
|
1668
|
+
});
|
|
1669
|
+
const removed = result.removedCount;
|
|
1670
|
+
if (removed > 0) {
|
|
1671
|
+
adapters.report(
|
|
1672
|
+
`[repository runner] purged ${removed} outdated run-log ${removed === 1 ? "entry" : "entries"}`
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
adapters.report(
|
|
1677
|
+
`[repository runner] log purge skipped: ${error instanceof Error ? error.message : String(error)}`
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
const removeTransientWork = () => adapters.removeTree(join(context.runnerDir, RUNNER_WORK_DIR));
|
|
1682
|
+
const removeOwnedContainers = () => removeOwnedRepositoryRunnerContainers(
|
|
1683
|
+
{
|
|
1684
|
+
repoDir: options.repoDir,
|
|
1685
|
+
repository: context.repository,
|
|
1686
|
+
environment: context.runtimeEnvironment
|
|
1687
|
+
},
|
|
1688
|
+
lifecycleAdapters
|
|
1689
|
+
);
|
|
1690
|
+
const attemptCleanup = async (failures, purpose, operation) => {
|
|
1691
|
+
try {
|
|
1692
|
+
await operation();
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
const message = `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1695
|
+
failures.push(message);
|
|
1696
|
+
adapters.report(`[repository runner] cleanup error: ${message}`);
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
const persistCleanupFailures = async (failures) => {
|
|
1700
|
+
if (failures.length === 0) return;
|
|
1701
|
+
const cleanupMessage = `Repository runner cleanup failed: ${failures.join("; ")}`;
|
|
1702
|
+
const message = primaryFailure ? `${primaryFailure.message} ${cleanupMessage}` : cleanupMessage;
|
|
1703
|
+
await bestEffort(
|
|
1704
|
+
() => writeJson(
|
|
1705
|
+
failurePath,
|
|
1706
|
+
{
|
|
1707
|
+
schemaVersion: 1,
|
|
1708
|
+
repository: context.repository,
|
|
1709
|
+
message,
|
|
1710
|
+
recordedAt: adapters.now().toISOString()
|
|
1711
|
+
},
|
|
1712
|
+
adapters
|
|
1713
|
+
).then(() => {
|
|
1714
|
+
if (primaryFailure) retainedFailures.add(primaryFailure);
|
|
1715
|
+
})
|
|
1716
|
+
);
|
|
1717
|
+
await bestEffort(() => saveState("stopped", cleanupMessage));
|
|
1718
|
+
};
|
|
1719
|
+
const shutdown = () => {
|
|
1720
|
+
if (shutdownPromise) return shutdownPromise;
|
|
1721
|
+
shutdownPromise = (async () => {
|
|
1722
|
+
stopping = true;
|
|
1723
|
+
const failures = [];
|
|
1724
|
+
const children = [listenerChild, shipyardChild].filter(
|
|
1725
|
+
(child) => child !== void 0
|
|
1726
|
+
);
|
|
1727
|
+
listenerChild = void 0;
|
|
1728
|
+
shipyardChild = void 0;
|
|
1729
|
+
await Promise.all(
|
|
1730
|
+
children.map(
|
|
1731
|
+
(child) => attemptCleanup(
|
|
1732
|
+
failures,
|
|
1733
|
+
`Stopping process ${child.pid}`,
|
|
1734
|
+
() => stopChildBounded(child, adapters.pause)
|
|
1735
|
+
)
|
|
1736
|
+
)
|
|
1737
|
+
);
|
|
1738
|
+
await attemptCleanup(
|
|
1739
|
+
failures,
|
|
1740
|
+
"Removing repository-runner containers",
|
|
1741
|
+
removeOwnedContainers
|
|
1742
|
+
);
|
|
1743
|
+
await attemptCleanup(
|
|
1744
|
+
failures,
|
|
1745
|
+
"Removing transient runner work",
|
|
1746
|
+
removeTransientWork
|
|
1747
|
+
);
|
|
1748
|
+
await attemptCleanup(
|
|
1749
|
+
failures,
|
|
1750
|
+
"Removing controller lock",
|
|
1751
|
+
() => adapters.remove(lockPath)
|
|
1752
|
+
);
|
|
1753
|
+
if (failures.length === 0) {
|
|
1754
|
+
try {
|
|
1755
|
+
await saveState("stopped", "Stopped by signal");
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
const message = `Persisting stopped state failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1758
|
+
failures.push(message);
|
|
1759
|
+
adapters.report(`[repository runner] cleanup error: ${message}`);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
await persistCleanupFailures(failures);
|
|
1763
|
+
})();
|
|
1764
|
+
return shutdownPromise;
|
|
1765
|
+
};
|
|
1766
|
+
const unregisterShutdown = adapters.onShutdown(shutdown);
|
|
1767
|
+
const wakeSubscription = createRepositoryRunnerWakeSubscription(
|
|
1768
|
+
adapters.onWake
|
|
1769
|
+
);
|
|
1770
|
+
let initialWorkFound = false;
|
|
1771
|
+
try {
|
|
1772
|
+
await purgeLogs();
|
|
1773
|
+
cancelLogPurge = adapters.scheduleRecurring(
|
|
1774
|
+
purgeLogs,
|
|
1775
|
+
LOG_PURGE_CHECK_INTERVAL_MS
|
|
1776
|
+
);
|
|
1777
|
+
const listEligibleIssueNumbers = async () => {
|
|
1778
|
+
const issues = await runCommand(
|
|
1779
|
+
adapters,
|
|
1780
|
+
"Checking for eligible GitHub issues",
|
|
1781
|
+
"gh",
|
|
1782
|
+
[
|
|
1783
|
+
"api",
|
|
1784
|
+
"--method",
|
|
1785
|
+
"GET",
|
|
1786
|
+
"--paginate",
|
|
1787
|
+
`repos/${context.repository}/issues`,
|
|
1788
|
+
"-f",
|
|
1789
|
+
"state=open",
|
|
1790
|
+
"-f",
|
|
1791
|
+
`labels=${ACTIVATION_LABEL}`,
|
|
1792
|
+
"-f",
|
|
1793
|
+
"per_page=100",
|
|
1794
|
+
"--jq",
|
|
1795
|
+
".[] | select(.pull_request == null) | .number"
|
|
1796
|
+
],
|
|
1797
|
+
{ cwd: options.repoDir, env: context.runtimeEnvironment }
|
|
1798
|
+
);
|
|
1799
|
+
return [...new Set(issues.stdout.split(/\s+/).filter(Boolean))].sort(
|
|
1800
|
+
(left, right) => Number(left) - Number(right)
|
|
1801
|
+
);
|
|
1802
|
+
};
|
|
1803
|
+
const sameIssueNumbers = (left, right) => left.length === right.length && left.every((issue, index) => issue === right[index]);
|
|
1804
|
+
const processEligibleWork = async () => {
|
|
1805
|
+
let issueNumbers = await listEligibleIssueNumbers();
|
|
1806
|
+
const workFound = issueNumbers.length > 0;
|
|
1807
|
+
while (!stopping && issueNumbers.length > 0) {
|
|
1808
|
+
await saveState("processing", "Shipyard is processing eligible issues");
|
|
1809
|
+
shipyardChild = adapters.spawn("npx", ["shipyard", "run"], {
|
|
1810
|
+
cwd: options.repoDir,
|
|
1811
|
+
env: context.runtimeEnvironment
|
|
1812
|
+
});
|
|
1813
|
+
const runResult = await shipyardChild.wait();
|
|
1814
|
+
shipyardChild = void 0;
|
|
1815
|
+
if (stopping) {
|
|
1816
|
+
await shutdownPromise;
|
|
1817
|
+
return workFound;
|
|
1818
|
+
}
|
|
1819
|
+
if (runResult.code !== 0) {
|
|
1820
|
+
const failure = childFailure("npx shipyard run", runResult);
|
|
1821
|
+
await writeJson(
|
|
1822
|
+
failurePath,
|
|
1823
|
+
{
|
|
1824
|
+
schemaVersion: 1,
|
|
1825
|
+
repository: context.repository,
|
|
1826
|
+
message: failure.message,
|
|
1827
|
+
recordedAt: adapters.now().toISOString()
|
|
1828
|
+
},
|
|
1829
|
+
adapters
|
|
1830
|
+
);
|
|
1831
|
+
await saveState("stopped", failure.message);
|
|
1832
|
+
throw failure;
|
|
1833
|
+
}
|
|
1834
|
+
const nextIssueNumbers = await listEligibleIssueNumbers();
|
|
1835
|
+
if (nextIssueNumbers.length === 0) {
|
|
1836
|
+
await saveState("idle", "Shipyard completed; no eligible issues");
|
|
1837
|
+
break;
|
|
1838
|
+
}
|
|
1839
|
+
if (sameIssueNumbers(issueNumbers, nextIssueNumbers)) {
|
|
1840
|
+
await saveState(
|
|
1841
|
+
"stalled",
|
|
1842
|
+
"Shipyard made no progress; eligible issues are unchanged"
|
|
1843
|
+
);
|
|
1844
|
+
break;
|
|
1845
|
+
}
|
|
1846
|
+
issueNumbers = nextIssueNumbers;
|
|
1847
|
+
}
|
|
1848
|
+
return workFound;
|
|
1849
|
+
};
|
|
1850
|
+
lastWake = {
|
|
1851
|
+
source: "startup",
|
|
1852
|
+
recordedAt: adapters.now().toISOString()
|
|
1853
|
+
};
|
|
1854
|
+
initialWorkFound = await processEligibleWork();
|
|
1855
|
+
if (stopping) {
|
|
1856
|
+
await shutdownPromise;
|
|
1857
|
+
return { repository: context.repository, initialWorkFound };
|
|
1858
|
+
}
|
|
1859
|
+
if (!initialWorkFound) {
|
|
1860
|
+
await saveState("idle", "No eligible issues; listening");
|
|
1861
|
+
}
|
|
1862
|
+
listenerChild = adapters.spawn("./run.sh", [], {
|
|
1863
|
+
cwd: context.runnerDir,
|
|
1864
|
+
env: repositoryRunnerEnvironment(context.hostEnvironment)
|
|
1865
|
+
});
|
|
1866
|
+
const listenerExit = listenerChild.wait().then((result) => ({
|
|
1867
|
+
type: "listener",
|
|
1868
|
+
result
|
|
1869
|
+
}));
|
|
1870
|
+
let listenerResult;
|
|
1871
|
+
while (!listenerResult && !stopping) {
|
|
1872
|
+
const event = await Promise.race([
|
|
1873
|
+
listenerExit,
|
|
1874
|
+
wakeSubscription.next().then(() => ({ type: "wake" }))
|
|
1875
|
+
]);
|
|
1876
|
+
if (event.type === "listener") {
|
|
1877
|
+
listenerResult = event.result;
|
|
1878
|
+
listenerChild = void 0;
|
|
1879
|
+
break;
|
|
1880
|
+
}
|
|
1881
|
+
if (stopping) break;
|
|
1882
|
+
lastWake = {
|
|
1883
|
+
source: "signal",
|
|
1884
|
+
recordedAt: adapters.now().toISOString()
|
|
1885
|
+
};
|
|
1886
|
+
const workFound = await processEligibleWork();
|
|
1887
|
+
if (!workFound && !stopping) {
|
|
1888
|
+
await saveState("idle", "Wake-up delivered; no eligible issues");
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (stopping) {
|
|
1892
|
+
await shutdownPromise;
|
|
1893
|
+
return { repository: context.repository, initialWorkFound };
|
|
1894
|
+
}
|
|
1895
|
+
if (!listenerResult) {
|
|
1896
|
+
throw new RunnerControlError(
|
|
1897
|
+
"The repository runner listener stopped without an exit result."
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
if (listenerResult.code !== 0) {
|
|
1901
|
+
const failure = childFailure(
|
|
1902
|
+
"Repository runner listener",
|
|
1903
|
+
listenerResult
|
|
1904
|
+
);
|
|
1905
|
+
await writeJson(
|
|
1906
|
+
failurePath,
|
|
1907
|
+
{
|
|
1908
|
+
schemaVersion: 1,
|
|
1909
|
+
repository: context.repository,
|
|
1910
|
+
message: failure.message,
|
|
1911
|
+
recordedAt: adapters.now().toISOString()
|
|
1912
|
+
},
|
|
1913
|
+
adapters
|
|
1914
|
+
);
|
|
1915
|
+
await saveState("stopped", failure.message);
|
|
1916
|
+
throw failure;
|
|
1917
|
+
}
|
|
1918
|
+
await saveState("stopped", "Repository runner listener exited");
|
|
1919
|
+
return { repository: context.repository, initialWorkFound };
|
|
1920
|
+
} catch (error) {
|
|
1921
|
+
const failure = controlFailure("Repository runner controller", error);
|
|
1922
|
+
primaryFailure = failure;
|
|
1923
|
+
await bestEffort(
|
|
1924
|
+
() => writeJson(
|
|
1925
|
+
failurePath,
|
|
1926
|
+
{
|
|
1927
|
+
schemaVersion: 1,
|
|
1928
|
+
repository: context.repository,
|
|
1929
|
+
message: failure.message,
|
|
1930
|
+
recordedAt: adapters.now().toISOString()
|
|
1931
|
+
},
|
|
1932
|
+
adapters
|
|
1933
|
+
).then(() => retainedFailures.add(failure))
|
|
1934
|
+
);
|
|
1935
|
+
await bestEffort(() => saveState("stopped", failure.message));
|
|
1936
|
+
throw failure;
|
|
1937
|
+
} finally {
|
|
1938
|
+
if (cancelLogPurge !== void 0) {
|
|
1939
|
+
cancelLogPurge();
|
|
1940
|
+
cancelLogPurge = void 0;
|
|
1941
|
+
}
|
|
1942
|
+
wakeSubscription.close();
|
|
1943
|
+
unregisterShutdown();
|
|
1944
|
+
if (shutdownPromise) {
|
|
1945
|
+
await shutdownPromise;
|
|
1946
|
+
} else {
|
|
1947
|
+
const failures = [];
|
|
1948
|
+
if (listenerChild) {
|
|
1949
|
+
await attemptCleanup(
|
|
1950
|
+
failures,
|
|
1951
|
+
`Stopping process ${listenerChild.pid}`,
|
|
1952
|
+
() => stopChildBounded(listenerChild, adapters.pause)
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
if (shipyardChild) {
|
|
1956
|
+
await attemptCleanup(
|
|
1957
|
+
failures,
|
|
1958
|
+
`Stopping process ${shipyardChild.pid}`,
|
|
1959
|
+
() => stopChildBounded(shipyardChild, adapters.pause)
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
await attemptCleanup(
|
|
1963
|
+
failures,
|
|
1964
|
+
"Removing repository-runner containers",
|
|
1965
|
+
removeOwnedContainers
|
|
1966
|
+
);
|
|
1967
|
+
await attemptCleanup(
|
|
1968
|
+
failures,
|
|
1969
|
+
"Removing transient runner work",
|
|
1970
|
+
removeTransientWork
|
|
1971
|
+
);
|
|
1972
|
+
await attemptCleanup(
|
|
1973
|
+
failures,
|
|
1974
|
+
"Removing controller lock",
|
|
1975
|
+
() => adapters.remove(lockPath)
|
|
1976
|
+
);
|
|
1977
|
+
await persistCleanupFailures(failures);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
var startRepositoryRunner = async (options, adapters = defaultAdapters2) => {
|
|
1982
|
+
try {
|
|
1983
|
+
return await startRepositoryRunnerManaged(options, adapters);
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
const failure = controlFailure("Repository runner startup", error);
|
|
1986
|
+
if (retainedFailures.has(failure)) throw failure;
|
|
1987
|
+
const runnerDir = join(options.repoDir, CONFIG_DIR, RUNNER_DIR);
|
|
1988
|
+
if (await adapters.exists(runnerDir)) {
|
|
1989
|
+
const metadata = await readJson(
|
|
1990
|
+
join(runnerDir, RUNNER_INSTALL_METADATA),
|
|
1991
|
+
adapters
|
|
1992
|
+
).catch(() => void 0);
|
|
1993
|
+
const repository = metadata?.repository ?? "unknown";
|
|
1994
|
+
for (const [purpose, operation] of [
|
|
1995
|
+
[
|
|
1996
|
+
"failure diagnostics",
|
|
1997
|
+
() => writeJson(
|
|
1998
|
+
join(runnerDir, LAST_FAILURE),
|
|
1999
|
+
{
|
|
2000
|
+
schemaVersion: 1,
|
|
2001
|
+
repository,
|
|
2002
|
+
message: failure.message,
|
|
2003
|
+
recordedAt: adapters.now().toISOString()
|
|
2004
|
+
},
|
|
2005
|
+
adapters
|
|
2006
|
+
)
|
|
2007
|
+
],
|
|
2008
|
+
[
|
|
2009
|
+
"stopped state",
|
|
2010
|
+
() => writeJson(
|
|
2011
|
+
join(runnerDir, CONTROLLER_STATE),
|
|
2012
|
+
{
|
|
2013
|
+
schemaVersion: 1,
|
|
2014
|
+
repository,
|
|
2015
|
+
state: "stopped",
|
|
2016
|
+
lastOutcome: failure.message
|
|
2017
|
+
},
|
|
2018
|
+
adapters
|
|
2019
|
+
)
|
|
2020
|
+
]
|
|
2021
|
+
]) {
|
|
2022
|
+
try {
|
|
2023
|
+
await operation();
|
|
2024
|
+
} catch (persistenceError) {
|
|
2025
|
+
adapters.report(
|
|
2026
|
+
`[repository runner] Could not persist ${purpose}: ${persistenceError instanceof Error ? persistenceError.message : String(persistenceError)}`
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
throw failure;
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
var getRepositoryRunnerStatus = async (options, adapters = defaultAdapters2) => {
|
|
2035
|
+
const runnerDir = join(options.repoDir, CONFIG_DIR, RUNNER_DIR);
|
|
2036
|
+
const metadata = await readJson(
|
|
2037
|
+
join(runnerDir, RUNNER_INSTALL_METADATA),
|
|
2038
|
+
adapters
|
|
2039
|
+
);
|
|
2040
|
+
if (!metadata) {
|
|
2041
|
+
return {
|
|
2042
|
+
installed: false,
|
|
2043
|
+
running: false,
|
|
2044
|
+
github: "not-installed",
|
|
2045
|
+
state: "not-installed",
|
|
2046
|
+
lastOutcome: "Repository runner is not installed"
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
const lock = await readJson(
|
|
2050
|
+
join(runnerDir, RUNNER_CONTROLLER_LOCK),
|
|
2051
|
+
adapters
|
|
2052
|
+
);
|
|
2053
|
+
const state = await readJson(
|
|
2054
|
+
join(runnerDir, CONTROLLER_STATE),
|
|
2055
|
+
adapters
|
|
2056
|
+
);
|
|
2057
|
+
const running = await lockOwnsProcess(lock, adapters);
|
|
2058
|
+
let github = "unreachable";
|
|
2059
|
+
try {
|
|
2060
|
+
const response = await adapters.run(
|
|
2061
|
+
"gh",
|
|
2062
|
+
[
|
|
2063
|
+
"api",
|
|
2064
|
+
`repos/${metadata.repository}/actions/runners`,
|
|
2065
|
+
"--jq",
|
|
2066
|
+
`.runners[] | select(.name == "${metadata.name}") | .status`
|
|
2067
|
+
],
|
|
2068
|
+
{
|
|
2069
|
+
cwd: options.repoDir,
|
|
2070
|
+
// Runner connectivity is an administrative check. Do not let the
|
|
2071
|
+
// issue-agent token from .shipyard/.env shadow the host gh login.
|
|
2072
|
+
env: adapters.environment()
|
|
2073
|
+
}
|
|
2074
|
+
);
|
|
2075
|
+
github = response.stdout.trim() === "online" ? "online" : "offline";
|
|
2076
|
+
} catch {
|
|
2077
|
+
github = "unreachable";
|
|
2078
|
+
}
|
|
2079
|
+
return {
|
|
2080
|
+
installed: true,
|
|
2081
|
+
running,
|
|
2082
|
+
...lock ? { pid: lock.pid } : {},
|
|
2083
|
+
github,
|
|
2084
|
+
repository: metadata.repository,
|
|
2085
|
+
state: state?.state ?? (running ? "idle" : "stopped"),
|
|
2086
|
+
lastOutcome: state?.lastOutcome ?? "No repository runner outcome recorded",
|
|
2087
|
+
...state?.lastWake ? { lastWake: state.lastWake } : {}
|
|
2088
|
+
};
|
|
2089
|
+
};
|
|
2090
|
+
var stopRepositoryRunner = async (options, adapters = defaultAdapters2) => {
|
|
2091
|
+
const lockPath = join(
|
|
2092
|
+
options.repoDir,
|
|
2093
|
+
CONFIG_DIR,
|
|
2094
|
+
RUNNER_DIR,
|
|
2095
|
+
RUNNER_CONTROLLER_LOCK
|
|
2096
|
+
);
|
|
2097
|
+
const lock = await readJson(lockPath, adapters);
|
|
2098
|
+
if (!lock || !Number.isInteger(lock.pid)) {
|
|
2099
|
+
throw new RunnerControlError("The repository runner is not running.");
|
|
2100
|
+
}
|
|
2101
|
+
if (!adapters.isProcessRunning(lock.pid)) {
|
|
2102
|
+
throw new RunnerControlError(
|
|
2103
|
+
`The recorded repository runner process ${lock.pid} is no longer running.`
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
try {
|
|
2107
|
+
adapters.signalProcess(lock.pid, "SIGTERM");
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
throw controlFailure("Stopping the repository runner", error);
|
|
2110
|
+
}
|
|
2111
|
+
return { pid: lock.pid };
|
|
2112
|
+
};
|
|
2113
|
+
|
|
2114
|
+
// src/RepositoryRunnerCommands.ts
|
|
2115
|
+
var registrationTokenOption = Options.text("registration-token").pipe(
|
|
2116
|
+
Options.withDescription(
|
|
2117
|
+
"one-time runner registration token (not stored; administrative gh access is still required for uniqueness checks)"
|
|
2118
|
+
),
|
|
2119
|
+
Options.optional
|
|
2120
|
+
);
|
|
2121
|
+
var installRunnerCommand = Command.make(
|
|
2122
|
+
"install",
|
|
2123
|
+
{ registrationToken: registrationTokenOption },
|
|
2124
|
+
({ registrationToken }) => Effect.gen(function* () {
|
|
2125
|
+
const display = yield* Display;
|
|
2126
|
+
const result = yield* display.progress(
|
|
2127
|
+
"Installing repository runner",
|
|
2128
|
+
(report) => Effect.tryPromise({
|
|
2129
|
+
try: () => installRepositoryRunner({
|
|
2130
|
+
repoDir: process.cwd(),
|
|
2131
|
+
registrationToken: registrationToken._tag === "Some" ? registrationToken.value : void 0,
|
|
2132
|
+
onProgress: report
|
|
2133
|
+
}),
|
|
2134
|
+
catch: (error) => new InitError({
|
|
2135
|
+
message: error instanceof RunnerInstallError ? error.message : `Repository runner installation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2136
|
+
})
|
|
2137
|
+
})
|
|
2138
|
+
);
|
|
2139
|
+
yield* display.status(
|
|
2140
|
+
`Installed ${result.name} for ${result.repository}.`,
|
|
2141
|
+
"success"
|
|
2142
|
+
);
|
|
2143
|
+
})
|
|
2144
|
+
);
|
|
2145
|
+
var runnerControlEffect = (operation) => Effect.tryPromise({
|
|
2146
|
+
try: operation,
|
|
2147
|
+
catch: (error) => new InitError({
|
|
2148
|
+
message: error instanceof RunnerControlError || error instanceof RunnerLifecycleError ? error.message : `Repository runner operation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2149
|
+
})
|
|
2150
|
+
});
|
|
2151
|
+
var startRunnerCommand = Command.make(
|
|
2152
|
+
"start",
|
|
2153
|
+
{},
|
|
2154
|
+
() => Effect.gen(function* () {
|
|
2155
|
+
const display = yield* Display;
|
|
2156
|
+
const cwd = process.cwd();
|
|
2157
|
+
yield* display.status(
|
|
2158
|
+
"Starting the repository runner in this terminal...",
|
|
2159
|
+
"info"
|
|
2160
|
+
);
|
|
2161
|
+
const result = yield* runnerControlEffect(
|
|
2162
|
+
() => startRepositoryRunner({ repoDir: cwd })
|
|
2163
|
+
);
|
|
2164
|
+
yield* display.status(
|
|
2165
|
+
result.initialWorkFound ? `Repository runner for ${result.repository} stopped after processing startup work.` : `Repository runner for ${result.repository} stopped.`,
|
|
2166
|
+
"info"
|
|
2167
|
+
);
|
|
2168
|
+
})
|
|
2169
|
+
);
|
|
2170
|
+
var statusRunnerCommand = Command.make(
|
|
2171
|
+
"status",
|
|
2172
|
+
{},
|
|
2173
|
+
() => Effect.gen(function* () {
|
|
2174
|
+
const display = yield* Display;
|
|
2175
|
+
const status = yield* runnerControlEffect(
|
|
2176
|
+
() => getRepositoryRunnerStatus({ repoDir: process.cwd() })
|
|
2177
|
+
);
|
|
2178
|
+
yield* display.summary("Repository runner", {
|
|
2179
|
+
Installed: status.installed ? "yes" : "no",
|
|
2180
|
+
"Local process": status.running ? `running (PID ${status.pid ?? "unknown"})` : "stopped",
|
|
2181
|
+
GitHub: status.github,
|
|
2182
|
+
Repository: status.repository ?? "unknown",
|
|
2183
|
+
State: status.state,
|
|
2184
|
+
"Last outcome": status.lastOutcome,
|
|
2185
|
+
"Last wake": status.lastWake ? `${status.lastWake.source} at ${status.lastWake.recordedAt}` : "none recorded"
|
|
2186
|
+
});
|
|
2187
|
+
})
|
|
2188
|
+
);
|
|
2189
|
+
var stopRunnerCommand = Command.make(
|
|
2190
|
+
"stop",
|
|
2191
|
+
{},
|
|
2192
|
+
() => Effect.gen(function* () {
|
|
2193
|
+
const display = yield* Display;
|
|
2194
|
+
const result = yield* runnerControlEffect(
|
|
2195
|
+
() => stopRepositoryRunner({ repoDir: process.cwd() })
|
|
2196
|
+
);
|
|
2197
|
+
yield* display.status(
|
|
2198
|
+
`Stopping repository runner process ${result.pid}...`,
|
|
2199
|
+
"success"
|
|
2200
|
+
);
|
|
2201
|
+
})
|
|
2202
|
+
);
|
|
2203
|
+
var forceRemoveRunnerOption = Options.boolean("force").pipe(
|
|
2204
|
+
Options.withDescription(
|
|
2205
|
+
"delete local runner files if GitHub unregistration fails"
|
|
2206
|
+
)
|
|
2207
|
+
);
|
|
2208
|
+
var removeRunnerCommand = Command.make(
|
|
2209
|
+
"remove",
|
|
2210
|
+
{ force: forceRemoveRunnerOption },
|
|
2211
|
+
({ force }) => Effect.gen(function* () {
|
|
2212
|
+
const display = yield* Display;
|
|
2213
|
+
const result = yield* runnerControlEffect(
|
|
2214
|
+
() => removeRepositoryRunner({ repoDir: process.cwd(), force })
|
|
2215
|
+
);
|
|
2216
|
+
yield* display.status("Repository runner removed.", "success");
|
|
2217
|
+
if (result.manualCleanup) {
|
|
2218
|
+
yield* display.status(result.manualCleanup, "warn");
|
|
2219
|
+
}
|
|
2220
|
+
})
|
|
2221
|
+
);
|
|
2222
|
+
var purgeRunnerCommand = Command.make(
|
|
2223
|
+
"purge",
|
|
2224
|
+
{},
|
|
2225
|
+
() => Effect.gen(function* () {
|
|
2226
|
+
const display = yield* Display;
|
|
2227
|
+
const repoDir = process.cwd();
|
|
2228
|
+
yield* requireCanonicalConfigDir(repoDir);
|
|
2229
|
+
const result = yield* Effect.tryPromise({
|
|
2230
|
+
try: () => purgeRunLogs({ repoDir }),
|
|
2231
|
+
catch: (error) => new InitError({
|
|
2232
|
+
message: `Run-log purge failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2233
|
+
})
|
|
2234
|
+
});
|
|
2235
|
+
const removed = result.removedCount;
|
|
2236
|
+
yield* display.status(
|
|
2237
|
+
removed === 0 ? "No run logs found." : `Purged ${removed} run-log ${removed === 1 ? "entry" : "entries"}.`,
|
|
2238
|
+
"success"
|
|
2239
|
+
);
|
|
2240
|
+
})
|
|
2241
|
+
);
|
|
2242
|
+
var runnerCommand = Command.make(
|
|
2243
|
+
"runner",
|
|
2244
|
+
{},
|
|
2245
|
+
() => Effect.gen(function* () {
|
|
2246
|
+
const display = yield* Display;
|
|
2247
|
+
yield* display.status(
|
|
2248
|
+
"Repository runner and run-log commands. Use --help to see available subcommands.",
|
|
2249
|
+
"info"
|
|
2250
|
+
);
|
|
2251
|
+
})
|
|
2252
|
+
).pipe(
|
|
2253
|
+
Command.withSubcommands([
|
|
2254
|
+
installRunnerCommand,
|
|
2255
|
+
startRunnerCommand,
|
|
2256
|
+
statusRunnerCommand,
|
|
2257
|
+
stopRunnerCommand,
|
|
2258
|
+
removeRunnerCommand,
|
|
2259
|
+
purgeRunnerCommand
|
|
2260
|
+
])
|
|
2261
|
+
);
|
|
2262
|
+
var VERSION = "0.5.0" ;
|
|
638
2263
|
|
|
639
2264
|
// src/cli.ts
|
|
640
2265
|
var imageNameOption = Options.text("image-name").pipe(
|
|
@@ -651,6 +2276,27 @@ var defaultUidBuildArgs = () => {
|
|
|
651
2276
|
return args;
|
|
652
2277
|
};
|
|
653
2278
|
var requireConfigDir = (cwd) => requireCanonicalConfigDir(cwd);
|
|
2279
|
+
var purgeRunLogsBestEffort = (display, repoDir) => Effect.tryPromise({
|
|
2280
|
+
try: () => purgeRunLogs({
|
|
2281
|
+
repoDir,
|
|
2282
|
+
retentionDays: DEFAULT_LOG_RETENTION_DAYS
|
|
2283
|
+
}),
|
|
2284
|
+
catch: (error) => error
|
|
2285
|
+
}).pipe(
|
|
2286
|
+
Effect.flatMap((result) => {
|
|
2287
|
+
const removed = result.removedCount;
|
|
2288
|
+
return removed === 0 ? Effect.void : display.status(
|
|
2289
|
+
`Purged ${removed} outdated run-log ${removed === 1 ? "entry" : "entries"}.`,
|
|
2290
|
+
"info"
|
|
2291
|
+
);
|
|
2292
|
+
}),
|
|
2293
|
+
Effect.catchAll(
|
|
2294
|
+
(error) => display.status(
|
|
2295
|
+
`Automatic run-log purge skipped: ${error instanceof Error ? error.message : String(error)}`,
|
|
2296
|
+
"warn"
|
|
2297
|
+
)
|
|
2298
|
+
)
|
|
2299
|
+
);
|
|
654
2300
|
var runSandboxOption = Options.choice("sandbox", ["docker"]).pipe(
|
|
655
2301
|
Options.withDescription(
|
|
656
2302
|
"Sandbox provider to build (inferred from Dockerfile)"
|
|
@@ -738,7 +2384,7 @@ var executeEntrypoint = (cwd, entrypoint) => Effect.async((resume, signal) => {
|
|
|
738
2384
|
);
|
|
739
2385
|
});
|
|
740
2386
|
});
|
|
741
|
-
var
|
|
2387
|
+
var runCommand2 = Command.make(
|
|
742
2388
|
"run",
|
|
743
2389
|
{
|
|
744
2390
|
imageName: imageNameOption,
|
|
@@ -750,6 +2396,7 @@ var runCommand = Command.make(
|
|
|
750
2396
|
const d = yield* Display;
|
|
751
2397
|
const cwd = process.cwd();
|
|
752
2398
|
const configDir = yield* requireConfigDir(cwd);
|
|
2399
|
+
yield* purgeRunLogsBestEffort(d, cwd);
|
|
753
2400
|
const resolvedEntrypoint = resolveRunEntrypoint(
|
|
754
2401
|
cwd,
|
|
755
2402
|
configDir,
|
|
@@ -811,25 +2458,25 @@ var issueTrackerOption = Options.text("issue-tracker").pipe(
|
|
|
811
2458
|
Options.withDescription("Issue tracker to use (github-issues)"),
|
|
812
2459
|
Options.optional
|
|
813
2460
|
);
|
|
814
|
-
var
|
|
2461
|
+
var buildImageOption = Options.choice("build-image", ["true", "false"]).pipe(
|
|
2462
|
+
Options.withDescription("Whether to build the sandbox image now"),
|
|
2463
|
+
Options.optional
|
|
2464
|
+
);
|
|
2465
|
+
var installTemplateDepsOption = Options.choice("install-template-deps", [
|
|
815
2466
|
"true",
|
|
816
2467
|
"false"
|
|
817
2468
|
]).pipe(
|
|
818
2469
|
Options.withDescription(
|
|
819
|
-
|
|
2470
|
+
"Whether to install the template's host dependencies (e.g. zod for the planner templates)"
|
|
820
2471
|
),
|
|
821
2472
|
Options.optional
|
|
822
2473
|
);
|
|
823
|
-
var
|
|
824
|
-
Options.withDescription("Whether to build the sandbox image now"),
|
|
825
|
-
Options.optional
|
|
826
|
-
);
|
|
827
|
-
var installTemplateDepsOption = Options.choice("install-template-deps", [
|
|
2474
|
+
var installRunnerOption = Options.choice("install-runner", [
|
|
828
2475
|
"true",
|
|
829
2476
|
"false"
|
|
830
2477
|
]).pipe(
|
|
831
2478
|
Options.withDescription(
|
|
832
|
-
"Whether to install
|
|
2479
|
+
"Whether to install a foreground repository runner after scaffolding"
|
|
833
2480
|
),
|
|
834
2481
|
Options.optional
|
|
835
2482
|
);
|
|
@@ -844,9 +2491,9 @@ var initCommand = Command.make(
|
|
|
844
2491
|
model: initModelOption,
|
|
845
2492
|
sandbox: sandboxOption,
|
|
846
2493
|
issueTracker: issueTrackerOption,
|
|
847
|
-
createLabel: createLabelOption,
|
|
848
2494
|
buildImage: buildImageOption,
|
|
849
|
-
installTemplateDeps: installTemplateDepsOption
|
|
2495
|
+
installTemplateDeps: installTemplateDepsOption,
|
|
2496
|
+
installRunner: installRunnerOption
|
|
850
2497
|
},
|
|
851
2498
|
({
|
|
852
2499
|
imageName: imageNameFlag,
|
|
@@ -856,9 +2503,9 @@ var initCommand = Command.make(
|
|
|
856
2503
|
model: modelFlag,
|
|
857
2504
|
sandbox: sandboxFlag,
|
|
858
2505
|
issueTracker: issueTrackerFlag,
|
|
859
|
-
createLabel: createLabelFlag,
|
|
860
2506
|
buildImage: buildImageFlag,
|
|
861
|
-
installTemplateDeps: installTemplateDepsFlag
|
|
2507
|
+
installTemplateDeps: installTemplateDepsFlag,
|
|
2508
|
+
installRunner: installRunnerFlag
|
|
862
2509
|
}) => Effect.gen(function* () {
|
|
863
2510
|
const d = yield* Display;
|
|
864
2511
|
const cwd = process.cwd();
|
|
@@ -897,11 +2544,11 @@ var initCommand = Command.make(
|
|
|
897
2544
|
);
|
|
898
2545
|
}
|
|
899
2546
|
}
|
|
900
|
-
const createLabelChoice = choiceToTriBool(createLabelFlag);
|
|
901
2547
|
const buildImageChoice = choiceToTriBool(buildImageFlag);
|
|
902
2548
|
const installTemplateDepsChoice = choiceToTriBool(
|
|
903
2549
|
installTemplateDepsFlag
|
|
904
2550
|
);
|
|
2551
|
+
const installRunnerChoice = choiceToTriBool(installRunnerFlag);
|
|
905
2552
|
const isInteractive = process.stdin.isTTY === true;
|
|
906
2553
|
const failIfNonInteractive = (flag) => Effect.fail(
|
|
907
2554
|
new InitError({
|
|
@@ -1080,31 +2727,21 @@ var initCommand = Command.make(
|
|
|
1080
2727
|
}
|
|
1081
2728
|
selectedTemplate = selected;
|
|
1082
2729
|
}
|
|
1083
|
-
let shouldCreateLabel = false;
|
|
1084
2730
|
if (selectedIssueTracker.name === "github-issues") {
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
yield* Effect.try({
|
|
1093
|
-
try: () => execSync(
|
|
1094
|
-
`gh label create "${PRODUCT_NAME}" --description "Issues for ${PRODUCT_NAME} to work on" --color "F9A825" 2>/dev/null`,
|
|
1095
|
-
{ cwd, stdio: "ignore" }
|
|
1096
|
-
),
|
|
1097
|
-
catch: () => void 0
|
|
1098
|
-
}).pipe(Effect.ignore);
|
|
1099
|
-
}
|
|
2731
|
+
yield* Effect.try({
|
|
2732
|
+
try: () => execSync(
|
|
2733
|
+
`gh label create "${ACTIVATION_LABEL}" --description "Issues for ${PRODUCT_NAME} to work on" --color "F9A825" --force 2>/dev/null`,
|
|
2734
|
+
{ cwd, stdio: "ignore" }
|
|
2735
|
+
),
|
|
2736
|
+
catch: () => void 0
|
|
2737
|
+
}).pipe(Effect.ignore);
|
|
1100
2738
|
}
|
|
1101
|
-
|
|
2739
|
+
yield* d.spinner(
|
|
1102
2740
|
`Scaffolding ${CONFIG_DIR}/ config directory...`,
|
|
1103
2741
|
scaffold(cwd, {
|
|
1104
2742
|
agent: selectedAgent,
|
|
1105
2743
|
model: selectedModel,
|
|
1106
2744
|
templateName: selectedTemplate,
|
|
1107
|
-
createLabel: shouldCreateLabel,
|
|
1108
2745
|
issueTracker: selectedIssueTracker,
|
|
1109
2746
|
sandboxProvider: selectedSandboxProvider,
|
|
1110
2747
|
codexAuth: selectedCodexAuth
|
|
@@ -1158,20 +2795,66 @@ var initCommand = Command.make(
|
|
|
1158
2795
|
buildArgs: defaultUidBuildArgs()
|
|
1159
2796
|
})
|
|
1160
2797
|
);
|
|
1161
|
-
yield* d.status("
|
|
2798
|
+
yield* d.status("Image built successfully.", "success");
|
|
1162
2799
|
} else {
|
|
1163
2800
|
yield* d.status(
|
|
1164
|
-
`
|
|
2801
|
+
`Run \`${CLI_NAME} ${selectedSandboxProvider.cliNamespace} build-image\` to build the ${providerLabel} image later.`,
|
|
2802
|
+
"info"
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
const runnerInit = yield* Effect.tryPromise({
|
|
2806
|
+
try: () => initializeRepositoryRunner({
|
|
2807
|
+
interactive: isInteractive,
|
|
2808
|
+
requested: installRunnerChoice._tag === "Some" ? installRunnerChoice.value : void 0,
|
|
2809
|
+
confirm: async ({ message, initialValue }) => {
|
|
2810
|
+
const confirmed = await clack.confirm({ message, initialValue });
|
|
2811
|
+
if (clack.isCancel(confirmed)) {
|
|
2812
|
+
throw new InitError({
|
|
2813
|
+
message: "Repository-runner installation selection cancelled."
|
|
2814
|
+
});
|
|
2815
|
+
}
|
|
2816
|
+
return confirmed === true;
|
|
2817
|
+
},
|
|
2818
|
+
install: () => Effect.runPromise(
|
|
2819
|
+
d.progress(
|
|
2820
|
+
"Installing repository runner",
|
|
2821
|
+
(report) => Effect.tryPromise({
|
|
2822
|
+
try: () => installRepositoryRunner({
|
|
2823
|
+
repoDir: cwd,
|
|
2824
|
+
onProgress: report
|
|
2825
|
+
}),
|
|
2826
|
+
catch: (error) => new InitError({
|
|
2827
|
+
message: error instanceof RunnerInstallError ? error.message : `Repository runner installation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2828
|
+
})
|
|
2829
|
+
})
|
|
2830
|
+
)
|
|
2831
|
+
)
|
|
2832
|
+
}),
|
|
2833
|
+
catch: (error) => error instanceof InitError ? error : new InitError({
|
|
2834
|
+
message: `Repository-runner setup failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2835
|
+
})
|
|
2836
|
+
});
|
|
2837
|
+
if (runnerInit.status === "installed") {
|
|
2838
|
+
yield* d.status(
|
|
2839
|
+
`Installed ${runnerInit.result.name} for ${runnerInit.result.repository}.`,
|
|
1165
2840
|
"success"
|
|
1166
2841
|
);
|
|
2842
|
+
yield* d.text("Repository runner next steps:");
|
|
2843
|
+
for (const [index, line] of repositoryRunnerNextSteps().entries()) {
|
|
2844
|
+
yield* d.text(styleText("dim", `${index + 1}. ${line}`));
|
|
2845
|
+
}
|
|
2846
|
+
} else if (runnerInit.status === "failed") {
|
|
2847
|
+
yield* d.status(
|
|
2848
|
+
`Shipyard scaffolding is ready, but repository runner installation failed: ${runnerInit.message}`,
|
|
2849
|
+
"warn"
|
|
2850
|
+
);
|
|
2851
|
+
yield* d.status(
|
|
2852
|
+
`Retry from this repository with \`npx ${CLI_NAME} runner install\`.`,
|
|
2853
|
+
"warn"
|
|
2854
|
+
);
|
|
1167
2855
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
scaffoldResult.mainFilename,
|
|
1171
|
-
selectedAgent,
|
|
1172
|
-
packageManager,
|
|
1173
|
-
selectedCodexAuth
|
|
1174
|
-
);
|
|
2856
|
+
yield* d.status("Init complete!", "success");
|
|
2857
|
+
const nextSteps = getNextStepsLines();
|
|
1175
2858
|
for (const [i, line] of nextSteps.entries()) {
|
|
1176
2859
|
yield* d.text(i === 0 ? line : styleText("dim", line));
|
|
1177
2860
|
}
|
|
@@ -1243,7 +2926,12 @@ var rootCommand = Command.make(
|
|
|
1243
2926
|
})
|
|
1244
2927
|
);
|
|
1245
2928
|
var shipyard = rootCommand.pipe(
|
|
1246
|
-
Command.withSubcommands([
|
|
2929
|
+
Command.withSubcommands([
|
|
2930
|
+
initCommand,
|
|
2931
|
+
runCommand2,
|
|
2932
|
+
dockerCommand,
|
|
2933
|
+
runnerCommand
|
|
2934
|
+
])
|
|
1247
2935
|
);
|
|
1248
2936
|
var cli = Command.run(shipyard, {
|
|
1249
2937
|
name: CLI_NAME,
|