@snappedly-tools/shipyard 0.4.1 → 0.4.4-staging.35699051936

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/dist/main.js CHANGED
@@ -1,20 +1,22 @@
1
1
  #!/usr/bin/env node
2
- import { CODEX_MODELS, withFriendlyErrors } from './chunk-VDHZO5PP.js';
3
- import { buildImage, removeImage } from './chunk-OCJKONZ7.js';
4
- import { SANDBOX_REPO_DIR, Display, InitError, PRODUCT_NAME, CONFIG_DIR, CLI_NAME, ClackDisplay, defaultImageName, ExecHostError, LOCKS_DIR, ConfigDirError } from './chunk-2GT3OPO5.js';
2
+ import { CODEX_MODELS, withFriendlyErrors, resolveEnv } from './chunk-BYXKYXK6.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-DP5MP533.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, defaultImageName, ExecHostError, LOCKS_DIR, ConfigDirError } from './chunk-KPJFCYX5.js';
5
5
  import './chunk-KMGNFXKN.js';
6
6
  import './chunk-WA7QPAXO.js';
7
- import { NodeContext, NodeRuntime } from '@effect/platform-node';
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,7 +264,7 @@ 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 ${PRODUCT_NAME} --limit 100 --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'`,
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
@@ -270,6 +272,7 @@ var ISSUE_TRACKER_REGISTRY = [
270
272
  envExample: `# GitHub personal access token \u2014 the agent uses it to read and manage GitHub Issues
271
273
  # Create a fine-grained token: https://github.com/settings/personal-access-tokens/new
272
274
  # Required repository permissions: Issues (Read and write) and Metadata (Read)
275
+ # Or leave blank and run: GH_TOKEN="$(gh auth token)" npx shipyard run
273
276
  GH_TOKEN=`
274
277
  }
275
278
  ];
@@ -425,27 +428,6 @@ var rewriteMainTs = (configDir, agent, model, sandboxProvider, mainFilename, cod
425
428
  content = content.replace(/\bdocker\b/g, sandboxProvider.name);
426
429
  yield* fs.writeFileString(mainTsPath, content).pipe(Effect.mapError((e) => new Error(e.message)));
427
430
  });
428
- var rewritePromptFiles = (configDir) => Effect.gen(function* () {
429
- const fs = yield* FileSystem.FileSystem;
430
- const files = yield* fs.readDirectory(configDir).pipe(Effect.mapError((e) => new Error(e.message)));
431
- const mdFiles = files.filter((f) => f.endsWith(".md"));
432
- yield* Effect.all(
433
- mdFiles.map(
434
- (f) => Effect.gen(function* () {
435
- const filePath = join(configDir, f);
436
- const content = yield* fs.readFileString(filePath).pipe(Effect.mapError((e) => new Error(e.message)));
437
- const updated = content.replace(
438
- new RegExp(` --label ${PRODUCT_NAME}`, "g"),
439
- ""
440
- );
441
- if (updated !== content) {
442
- yield* fs.writeFileString(filePath, updated).pipe(Effect.mapError((e) => new Error(e.message)));
443
- }
444
- })
445
- ),
446
- { concurrency: "unbounded" }
447
- );
448
- });
449
431
  var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
450
432
  ".md",
451
433
  ".txt",
@@ -504,7 +486,6 @@ var scaffold = (repoDir, options) => Effect.gen(function* () {
504
486
  agent,
505
487
  model,
506
488
  templateName = "blank",
507
- createLabel = true,
508
489
  issueTracker = ISSUE_TRACKER_REGISTRY[0],
509
490
  // default: github-issues
510
491
  sandboxProvider = SANDBOX_PROVIDER_REGISTRY[0],
@@ -552,11 +533,31 @@ var scaffold = (repoDir, options) => Effect.gen(function* () {
552
533
  codexAuth
553
534
  );
554
535
  yield* substituteTemplateArgs(configDir, issueTracker);
555
- if (!createLabel) {
556
- yield* rewritePromptFiles(configDir);
557
- }
558
536
  return { mainFilename };
559
537
  });
538
+ var resolveCodexAuthMode = async (options) => {
539
+ if (options.agentName !== "codex") {
540
+ if (options.requested === "chatgpt") {
541
+ throw new InitError({
542
+ message: "--codex-auth chatgpt can only be used with --agent codex."
543
+ });
544
+ }
545
+ return "api-key";
546
+ }
547
+ if (options.requested !== void 0) return options.requested;
548
+ if (!options.interactive) {
549
+ throw new InitError({
550
+ message: "--codex-auth is required in non-interactive mode (no TTY detected)."
551
+ });
552
+ }
553
+ const selected = await options.select();
554
+ if (selected === void 0) {
555
+ throw new InitError({
556
+ message: "Codex authentication selection cancelled."
557
+ });
558
+ }
559
+ return selected;
560
+ };
560
561
  var codexAuthFilePath = (authHome = join(homedir(), ".codex")) => join(authHome, "auth.json");
561
562
  var isRegularFile = (path) => {
562
563
  try {
@@ -610,7 +611,1598 @@ var ensureCodexChatGptAuth = (options) => {
610
611
  });
611
612
  }
612
613
  };
613
- var VERSION = "0.4.1" ;
614
+ var REPOSITORY_RUNNER_WORKFLOW_PATH = ".github/workflows/shipyard-wake.yml";
615
+ var REPOSITORY_RUNNER_WAKE_EXECUTABLE = "shipyard-wake";
616
+ var REPOSITORY_RUNNER_WORKFLOW = `name: Shipyard wake-up
617
+
618
+ on:
619
+ issues:
620
+ types: [labeled]
621
+ workflow_dispatch:
622
+
623
+ permissions: {}
624
+
625
+ jobs:
626
+ filter:
627
+ name: Check wake eligibility
628
+ runs-on: ubuntu-latest
629
+ outputs:
630
+ wake: \${{ steps.filter.outputs.wake }}
631
+ steps:
632
+ - name: Match the wake-up trigger
633
+ id: filter
634
+ shell: bash
635
+ env:
636
+ EVENT_NAME: \${{ github.event_name }}
637
+ LABEL_NAME: \${{ github.event.label.name }}
638
+ run: |
639
+ if [[ "$EVENT_NAME" == "workflow_dispatch" ]] || [[ "$EVENT_NAME" == "issues" && "$LABEL_NAME" == "shipyard" ]]; then
640
+ echo "wake=true" >> "$GITHUB_OUTPUT"
641
+ else
642
+ echo "wake=false" >> "$GITHUB_OUTPUT"
643
+ fi
644
+
645
+ wake:
646
+ name: Deliver wake-up
647
+ needs: filter
648
+ if: needs.filter.outputs.wake == 'true'
649
+ runs-on: [self-hosted, macOS, shipyard]
650
+ steps:
651
+ - name: Signal the foreground repository runner
652
+ shell: bash
653
+ run: '"$RUNNER_TEMP/../../shipyard-wake"'
654
+ `;
655
+ var REPOSITORY_RUNNER_WAKE_SCRIPT = `#!/bin/bash
656
+ set -euo pipefail
657
+
658
+ runner_dir="$(cd -- "$(dirname -- "$0")" && pwd)"
659
+ lock_path="$runner_dir/.shipyard-controller.lock"
660
+
661
+ if [[ ! -r "$lock_path" ]]; then
662
+ echo "::error::The Shipyard repository runner controller is unavailable. Start it with npx shipyard runner start."
663
+ exit 1
664
+ fi
665
+
666
+ controller_pid="$(sed -n 's/.*"pid"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' "$lock_path" | head -n 1)"
667
+ controller_started_at="$(sed -n 's/.*"processStartedAt"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$lock_path" | head -n 1)"
668
+ if [[ -z "$controller_pid" ]] || [[ -z "$controller_started_at" ]]; then
669
+ echo "::error::The Shipyard repository runner controller is unavailable. Start it with npx shipyard runner start."
670
+ exit 1
671
+ fi
672
+ current_started_at="$(ps -p "$controller_pid" -o lstart= 2>/dev/null | awk '{$1=$1; print}' || true)"
673
+ if [[ "$current_started_at" != "$controller_started_at" ]] || ! kill -0 "$controller_pid" 2>/dev/null; then
674
+ echo "::error::The Shipyard repository runner controller is unavailable. Start it with npx shipyard runner start."
675
+ exit 1
676
+ fi
677
+
678
+ if ! kill -USR1 "$controller_pid"; then
679
+ echo "::error::The Shipyard repository runner controller could not receive the wake-up."
680
+ exit 1
681
+ fi
682
+
683
+ echo "Wake-up delivered to the Shipyard repository runner controller."
684
+ `;
685
+ var RepositoryRunnerWakeError = class extends Error {
686
+ name = "RepositoryRunnerWakeError";
687
+ };
688
+ var workflowCollisionMessage = () => `Refusing to overwrite the differing workflow at ${REPOSITORY_RUNNER_WORKFLOW_PATH}. Replace it with the required Shipyard wake-only workflow, then retry:
689
+
690
+ ${REPOSITORY_RUNNER_WORKFLOW}`;
691
+ var assertRepositoryRunnerWorkflowCanBeInstalled = async (repoDir, adapters) => {
692
+ const workflowPath = join(repoDir, REPOSITORY_RUNNER_WORKFLOW_PATH);
693
+ if (!await adapters.exists(workflowPath)) return;
694
+ if (await adapters.readText(workflowPath) !== REPOSITORY_RUNNER_WORKFLOW) {
695
+ throw new RepositoryRunnerWakeError(workflowCollisionMessage());
696
+ }
697
+ };
698
+ var ensureDirectory = async (path, adapters) => {
699
+ if (!await adapters.exists(path)) await adapters.makeDirectory(path);
700
+ };
701
+ var installRepositoryRunnerWakeFiles = async (options, adapters) => {
702
+ await assertRepositoryRunnerWorkflowCanBeInstalled(options.repoDir, adapters);
703
+ const githubDir = join(options.repoDir, ".github");
704
+ const workflowsDir = join(githubDir, "workflows");
705
+ await ensureDirectory(githubDir, adapters);
706
+ await ensureDirectory(workflowsDir, adapters);
707
+ await adapters.writeText(
708
+ join(options.repoDir, REPOSITORY_RUNNER_WORKFLOW_PATH),
709
+ REPOSITORY_RUNNER_WORKFLOW
710
+ );
711
+ const wakeExecutable = join(
712
+ options.runnerDir,
713
+ REPOSITORY_RUNNER_WAKE_EXECUTABLE
714
+ );
715
+ await adapters.writeText(wakeExecutable, REPOSITORY_RUNNER_WAKE_SCRIPT);
716
+ await adapters.chmod(wakeExecutable, 448);
717
+ };
718
+ var requirePublishedRepositoryRunnerWorkflow = async (options, adapter) => {
719
+ let defaultBranch;
720
+ try {
721
+ const result = await adapter.run(
722
+ "gh",
723
+ ["api", `repos/${options.repository}`, "--jq", ".default_branch"],
724
+ { cwd: options.repoDir, env: options.environment }
725
+ );
726
+ defaultBranch = result.stdout.trim();
727
+ if (defaultBranch.length === 0) throw new Error("empty default branch");
728
+ } catch (error) {
729
+ throw new RepositoryRunnerWakeError(
730
+ `Could not determine the GitHub default branch for ${options.repository}: ${error instanceof Error ? error.message : String(error)}`
731
+ );
732
+ }
733
+ let publishedWorkflow;
734
+ try {
735
+ const result = await adapter.run(
736
+ "gh",
737
+ [
738
+ "api",
739
+ "--method",
740
+ "GET",
741
+ "-H",
742
+ "Accept: application/vnd.github.raw+json",
743
+ `repos/${options.repository}/contents/${REPOSITORY_RUNNER_WORKFLOW_PATH}?ref=${encodeURIComponent(defaultBranch)}`
744
+ ],
745
+ { cwd: options.repoDir, env: options.environment }
746
+ );
747
+ publishedWorkflow = result.stdout;
748
+ } catch (error) {
749
+ throw new RepositoryRunnerWakeError(
750
+ `The Shipyard wake workflow is not active for ${options.repository}. Commit and push ${REPOSITORY_RUNNER_WORKFLOW_PATH} to the default branch (${defaultBranch}), then start the runner again. GitHub reported: ${error instanceof Error ? error.message : String(error)}`
751
+ );
752
+ }
753
+ if (publishedWorkflow !== REPOSITORY_RUNNER_WORKFLOW) {
754
+ throw new RepositoryRunnerWakeError(
755
+ `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.
756
+
757
+ ${REPOSITORY_RUNNER_WORKFLOW}`
758
+ );
759
+ }
760
+ };
761
+ var createRepositoryRunnerWakeSubscription = (subscribe = (listener) => {
762
+ process.on("SIGUSR1", listener);
763
+ return () => process.removeListener("SIGUSR1", listener);
764
+ }) => {
765
+ let pending = false;
766
+ let closed = false;
767
+ let resolveNext;
768
+ const unsubscribe = subscribe(() => {
769
+ if (closed || pending) return;
770
+ pending = true;
771
+ resolveNext?.();
772
+ resolveNext = void 0;
773
+ });
774
+ return {
775
+ next: async () => {
776
+ if (closed) return;
777
+ if (!pending) {
778
+ await new Promise((resolve2) => {
779
+ resolveNext = resolve2;
780
+ });
781
+ }
782
+ pending = false;
783
+ },
784
+ close: () => {
785
+ if (closed) return;
786
+ closed = true;
787
+ unsubscribe();
788
+ resolveNext?.();
789
+ resolveNext = void 0;
790
+ }
791
+ };
792
+ };
793
+
794
+ // src/RepositoryRunner.ts
795
+ var execFileAsync = promisify(execFile);
796
+ var RUNNER_ARCHIVE = "runner.tgz";
797
+ var RELEASES_API = "https://api.github.com/repos/actions/runner/releases/latest";
798
+ var RunnerInstallError = class extends Error {
799
+ name = "RunnerInstallError";
800
+ };
801
+ var defaultAdapters = {
802
+ platform: () => process.platform,
803
+ arch: () => process.arch,
804
+ hostname,
805
+ environment: () => process.env,
806
+ exists: async (path) => {
807
+ try {
808
+ await access(path, constants.F_OK);
809
+ return true;
810
+ } catch {
811
+ return false;
812
+ }
813
+ },
814
+ readText: (path) => readFile(path, "utf8"),
815
+ writeText: (path, content) => writeFile(path, content, { mode: 384 }),
816
+ writeBytes: (path, content) => writeFile(path, content, { mode: 384 }),
817
+ makeDirectory: (path) => mkdir(path, { recursive: false, mode: 448 }),
818
+ remove: (path) => rm(path, { recursive: true, force: true }),
819
+ chmod,
820
+ commandExists: async (command) => {
821
+ try {
822
+ await execFileAsync("which", [command]);
823
+ return true;
824
+ } catch {
825
+ return false;
826
+ }
827
+ },
828
+ inspectDirectory: async (path) => {
829
+ const [stat, resolved] = await Promise.all([lstat(path), realpath(path)]);
830
+ return {
831
+ realPath: resolved,
832
+ directory: stat.isDirectory(),
833
+ symbolicLink: stat.isSymbolicLink()
834
+ };
835
+ },
836
+ fetchJson: async (url) => {
837
+ const response = await fetch(url, {
838
+ headers: {
839
+ Accept: "application/vnd.github+json",
840
+ "User-Agent": "shipyard-runner-installer",
841
+ "X-GitHub-Api-Version": "2022-11-28"
842
+ }
843
+ });
844
+ if (!response.ok) {
845
+ throw new Error(`GitHub returned HTTP ${response.status}`);
846
+ }
847
+ return response.json();
848
+ },
849
+ fetchBytes: async (url) => {
850
+ const response = await fetch(url, {
851
+ headers: { "User-Agent": "shipyard-runner-installer" }
852
+ });
853
+ if (!response.ok) {
854
+ throw new Error(`GitHub returned HTTP ${response.status}`);
855
+ }
856
+ return new Uint8Array(await response.arrayBuffer());
857
+ },
858
+ run: async (command, args, options) => {
859
+ const result = await execFileAsync(command, [...args], {
860
+ cwd: options.cwd,
861
+ env: options.env,
862
+ maxBuffer: 10 * 1024 * 1024
863
+ });
864
+ return { stdout: result.stdout, stderr: result.stderr };
865
+ }
866
+ };
867
+ var parseGitHubRepository = (remote) => {
868
+ const trimmed = remote.trim();
869
+ const sshMatch = trimmed.match(
870
+ /^(?:git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/
871
+ );
872
+ const httpsMatch = trimmed.match(
873
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/
874
+ );
875
+ const match = sshMatch ?? httpsMatch;
876
+ if (!match) {
877
+ throw new RunnerInstallError(
878
+ "The origin remote must be a GitHub.com repository (github.com/owner/repository). GitHub Enterprise and other hosts are not supported yet."
879
+ );
880
+ }
881
+ return { owner: match[1], repository: match[2] };
882
+ };
883
+ var normalizeNamePart = (value) => {
884
+ const normalized = value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
885
+ return normalized || "unknown";
886
+ };
887
+ var requireCommand = async (adapters, command, purpose) => {
888
+ if (!await adapters.commandExists(command)) {
889
+ throw new RunnerInstallError(
890
+ `Missing required command \`${command}\` (${purpose}). Install it and retry.`
891
+ );
892
+ }
893
+ };
894
+ var asRelease = (value) => {
895
+ if (typeof value !== "object" || value === null) {
896
+ throw new RunnerInstallError(
897
+ "GitHub returned invalid runner release metadata."
898
+ );
899
+ }
900
+ const release = value;
901
+ if (typeof release.tag_name !== "string" || !Array.isArray(release.assets)) {
902
+ throw new RunnerInstallError(
903
+ "GitHub returned invalid runner release metadata."
904
+ );
905
+ }
906
+ return release;
907
+ };
908
+ var selectArm64Asset = (release) => {
909
+ const asset = release.assets.find(
910
+ (candidate) => /^actions-runner-osx-arm64-[\d.]+\.tar\.gz$/.test(candidate.name)
911
+ );
912
+ if (!asset || !asset.browser_download_url.startsWith(
913
+ "https://github.com/actions/runner/releases/download/"
914
+ ) || !/^sha256:[a-f0-9]{64}$/i.test(asset.digest)) {
915
+ throw new RunnerInstallError(
916
+ "The latest official GitHub Actions runner release has no verifiable macOS ARM64 archive."
917
+ );
918
+ }
919
+ return asset;
920
+ };
921
+ var appendRunnerIgnores = async (gitignorePath, adapters) => {
922
+ const current = await adapters.exists(gitignorePath) ? await adapters.readText(gitignorePath) : "";
923
+ const lines = new Set(current.split(/\r?\n/));
924
+ const additions = [`${RUNNER_DIR}/`, `${RUNNER_SANDBOX_MASK_DIR}/`].filter(
925
+ (line) => !lines.has(line)
926
+ );
927
+ if (additions.length === 0) return;
928
+ const prefix = current.length === 0 || current.endsWith("\n") ? current : `${current}
929
+ `;
930
+ await adapters.writeText(gitignorePath, `${prefix}${additions.join("\n")}
931
+ `);
932
+ };
933
+ var commandFailure = (purpose, error) => new RunnerInstallError(
934
+ `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
935
+ );
936
+ var installRepositoryRunner = async (options, adapters = defaultAdapters) => {
937
+ if (adapters.platform() !== "darwin" || adapters.arch() !== "arm64") {
938
+ throw new RunnerInstallError(
939
+ "Repository runners currently require Apple Silicon macOS (darwin/arm64)."
940
+ );
941
+ }
942
+ const configDir = join(options.repoDir, CONFIG_DIR);
943
+ const runnerDir = join(configDir, RUNNER_DIR);
944
+ const maskDir = join(configDir, RUNNER_SANDBOX_MASK_DIR);
945
+ if (!await adapters.exists(configDir)) {
946
+ throw new RunnerInstallError(
947
+ `No ${CONFIG_DIR}/ found. Run \`shipyard init\` in this repository first.`
948
+ );
949
+ }
950
+ const runnerExists = await adapters.exists(runnerDir);
951
+ try {
952
+ await assertRepositoryRunnerWorkflowCanBeInstalled(
953
+ options.repoDir,
954
+ adapters
955
+ );
956
+ } catch (error) {
957
+ throw new RunnerInstallError(
958
+ error instanceof Error ? error.message : String(error)
959
+ );
960
+ }
961
+ await requireCommand(adapters, "git", "reading the repository remote");
962
+ if (!runnerExists) {
963
+ await requireCommand(adapters, "tar", "extracting the runner archive");
964
+ }
965
+ await requireCommand(adapters, "gh", "checking repository runner uniqueness");
966
+ const hostEnv = adapters.environment();
967
+ const gitRemote = await adapters.run("git", ["remote", "get-url", "origin"], {
968
+ cwd: options.repoDir,
969
+ env: hostEnv
970
+ }).catch((error) => {
971
+ throw commandFailure("Reading the origin remote", error);
972
+ });
973
+ const identity = parseGitHubRepository(gitRemote.stdout);
974
+ const repository = `${identity.owner}/${identity.repository}`;
975
+ const repoUrl = `https://github.com/${repository}`;
976
+ const runnerName = `shipyard-${normalizeNamePart(identity.repository)}-${normalizeNamePart(adapters.hostname())}`;
977
+ await adapters.run("gh", ["auth", "status", "--hostname", "github.com"], {
978
+ cwd: options.repoDir,
979
+ env: hostEnv
980
+ }).catch((error) => {
981
+ throw commandFailure(
982
+ "GitHub CLI authentication (repository administration is required)",
983
+ error
984
+ );
985
+ });
986
+ if (runnerExists) {
987
+ let metadata;
988
+ try {
989
+ metadata = await validateExistingRepositoryRunner(
990
+ {
991
+ repoDir: options.repoDir,
992
+ runnerDir,
993
+ maskDir,
994
+ repository,
995
+ runnerName
996
+ },
997
+ adapters
998
+ );
999
+ } catch (error) {
1000
+ throw new RunnerInstallError(
1001
+ error instanceof RunnerLifecycleError ? error.message : `Validating the existing repository runner failed: ${error instanceof Error ? error.message : String(error)}`
1002
+ );
1003
+ }
1004
+ await appendRunnerIgnores(join(configDir, ".gitignore"), adapters);
1005
+ await adapters.chmod(runnerDir, 448);
1006
+ await adapters.chmod(maskDir, 448);
1007
+ await assertProtectedDirectoryIdentities(
1008
+ runnerDir,
1009
+ maskDir,
1010
+ adapters.inspectDirectory
1011
+ ).catch((error) => {
1012
+ throw commandFailure("Validating protected runner directories", error);
1013
+ });
1014
+ try {
1015
+ await installRepositoryRunnerWakeFiles(
1016
+ { repoDir: options.repoDir, runnerDir },
1017
+ adapters
1018
+ );
1019
+ } catch (error) {
1020
+ throw new RunnerInstallError(
1021
+ `The existing runner is valid, but its wake-up files could not be refreshed: ${error instanceof Error ? error.message : String(error)}`
1022
+ );
1023
+ }
1024
+ return {
1025
+ name: metadata.name,
1026
+ repository: metadata.repository,
1027
+ version: metadata.version,
1028
+ runnerDir
1029
+ };
1030
+ }
1031
+ const existing = await adapters.run(
1032
+ "gh",
1033
+ [
1034
+ "api",
1035
+ "--paginate",
1036
+ `repos/${repository}/actions/runners`,
1037
+ "--jq",
1038
+ `.runners[] | select(any(.labels[]; .name == "${ACTIVATION_LABEL}")) | .name`
1039
+ ],
1040
+ { cwd: options.repoDir, env: hostEnv }
1041
+ ).catch((error) => {
1042
+ throw commandFailure(
1043
+ "Checking existing repository runners (repository administration is required)",
1044
+ error
1045
+ );
1046
+ });
1047
+ if (existing.stdout.trim().length > 0) {
1048
+ throw new RunnerInstallError(
1049
+ `A repository runner labeled \`${ACTIVATION_LABEL}\` is already registered for ${repository}. Remove it before installing another.`
1050
+ );
1051
+ }
1052
+ let registrationToken = options.registrationToken;
1053
+ if (!registrationToken) {
1054
+ const tokenResult = await adapters.run(
1055
+ "gh",
1056
+ [
1057
+ "api",
1058
+ "--method",
1059
+ "POST",
1060
+ `repos/${repository}/actions/runners/registration-token`
1061
+ ],
1062
+ { cwd: options.repoDir, env: hostEnv }
1063
+ ).catch((error) => {
1064
+ throw commandFailure(
1065
+ "Requesting a one-time runner registration token (repository administration is required)",
1066
+ error
1067
+ );
1068
+ });
1069
+ try {
1070
+ const parsed = JSON.parse(tokenResult.stdout);
1071
+ if (typeof parsed.token !== "string" || parsed.token.length === 0) {
1072
+ throw new Error("response did not contain a token");
1073
+ }
1074
+ registrationToken = parsed.token;
1075
+ } catch (error) {
1076
+ throw commandFailure("Reading the one-time registration token", error);
1077
+ }
1078
+ }
1079
+ let release;
1080
+ try {
1081
+ release = asRelease(await adapters.fetchJson(RELEASES_API));
1082
+ } catch (error) {
1083
+ if (error instanceof RunnerInstallError) throw error;
1084
+ throw commandFailure("Fetching official runner release metadata", error);
1085
+ }
1086
+ const asset = selectArm64Asset(release);
1087
+ const version = release.tag_name.replace(/^v/, "");
1088
+ let archive;
1089
+ try {
1090
+ archive = await adapters.fetchBytes(asset.browser_download_url);
1091
+ } catch (error) {
1092
+ throw commandFailure("Downloading the official runner archive", error);
1093
+ }
1094
+ const actualDigest = createHash("sha256").update(archive).digest("hex");
1095
+ const expectedDigest = asset.digest.slice("sha256:".length).toLowerCase();
1096
+ if (actualDigest !== expectedDigest) {
1097
+ throw new RunnerInstallError(
1098
+ `Runner archive digest mismatch: expected ${expectedDigest}, received ${actualDigest}. Nothing was installed.`
1099
+ );
1100
+ }
1101
+ await appendRunnerIgnores(join(configDir, ".gitignore"), adapters);
1102
+ await adapters.makeDirectory(runnerDir).catch((error) => {
1103
+ throw commandFailure("Creating the protected runner directory", error);
1104
+ });
1105
+ if (!await adapters.exists(maskDir)) {
1106
+ await adapters.makeDirectory(maskDir);
1107
+ }
1108
+ await assertProtectedDirectoryIdentities(
1109
+ runnerDir,
1110
+ maskDir,
1111
+ adapters.inspectDirectory
1112
+ ).catch((error) => {
1113
+ throw commandFailure("Validating protected runner directories", error);
1114
+ });
1115
+ await adapters.chmod(runnerDir, 448);
1116
+ await adapters.chmod(maskDir, 448);
1117
+ const archivePath = join(runnerDir, RUNNER_ARCHIVE);
1118
+ await adapters.writeBytes(archivePath, archive);
1119
+ try {
1120
+ await adapters.run("tar", ["-xzf", archivePath, "--directory", runnerDir], {
1121
+ cwd: options.repoDir,
1122
+ env: repositoryRunnerEnvironment(hostEnv)
1123
+ });
1124
+ } catch (error) {
1125
+ await adapters.remove(runnerDir);
1126
+ throw commandFailure("Extracting the verified runner archive", error);
1127
+ } finally {
1128
+ await adapters.remove(archivePath);
1129
+ }
1130
+ try {
1131
+ await adapters.run(
1132
+ "./config.sh",
1133
+ [
1134
+ "--url",
1135
+ repoUrl,
1136
+ "--token",
1137
+ registrationToken,
1138
+ "--name",
1139
+ runnerName,
1140
+ "--labels",
1141
+ ACTIVATION_LABEL,
1142
+ "--work",
1143
+ "_work",
1144
+ "--unattended"
1145
+ ],
1146
+ { cwd: runnerDir, env: repositoryRunnerEnvironment(hostEnv) }
1147
+ );
1148
+ } catch {
1149
+ const cleanup = await Promise.allSettled([
1150
+ adapters.remove(runnerDir),
1151
+ adapters.remove(maskDir)
1152
+ ]);
1153
+ const cleanupSucceeded = cleanup.every(
1154
+ (result) => result.status === "fulfilled"
1155
+ );
1156
+ throw new RunnerInstallError(
1157
+ 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.`
1158
+ );
1159
+ }
1160
+ await adapters.writeText(
1161
+ join(runnerDir, RUNNER_INSTALL_METADATA),
1162
+ `${JSON.stringify(
1163
+ {
1164
+ schemaVersion: 1,
1165
+ repository,
1166
+ repositoryUrl: repoUrl,
1167
+ name: runnerName,
1168
+ label: ACTIVATION_LABEL,
1169
+ version
1170
+ },
1171
+ null,
1172
+ 2
1173
+ )}
1174
+ `
1175
+ );
1176
+ try {
1177
+ await installRepositoryRunnerWakeFiles(
1178
+ { repoDir: options.repoDir, runnerDir },
1179
+ adapters
1180
+ );
1181
+ } catch (error) {
1182
+ throw new RunnerInstallError(
1183
+ `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)}`
1184
+ );
1185
+ }
1186
+ return { name: runnerName, repository, version, runnerDir };
1187
+ };
1188
+
1189
+ // src/InitRepositoryRunner.ts
1190
+ var initializeRepositoryRunner = async (options) => {
1191
+ let shouldInstall = options.requested;
1192
+ if (shouldInstall === void 0) {
1193
+ shouldInstall = options.interactive ? await options.confirm({
1194
+ message: "Install a foreground repository runner for labelled GitHub issues?",
1195
+ initialValue: false
1196
+ }) : false;
1197
+ }
1198
+ if (!shouldInstall) return { status: "declined" };
1199
+ try {
1200
+ return { status: "installed", result: await options.install() };
1201
+ } catch (error) {
1202
+ return {
1203
+ status: "failed",
1204
+ message: error instanceof Error ? error.message : String(error)
1205
+ };
1206
+ }
1207
+ };
1208
+ var repositoryRunnerNextSteps = () => [
1209
+ `First, commit and push ${REPOSITORY_RUNNER_WORKFLOW_PATH} to the repository's default branch`,
1210
+ `Keep the foreground controller open with \`npx ${CLI_NAME} runner start\``,
1211
+ `Inspect it from another terminal with \`npx ${CLI_NAME} runner status\``,
1212
+ `Stop it with \`npx ${CLI_NAME} runner stop\``,
1213
+ `Unregister and delete its protected files with \`npx ${CLI_NAME} runner remove\``
1214
+ ];
1215
+ var execFileAsync2 = promisify(execFile);
1216
+ var CONTROLLER_STATE = ".shipyard-state.json";
1217
+ var LAST_FAILURE = ".shipyard-last-failure.json";
1218
+ var RunnerControlError = class extends Error {
1219
+ name = "RunnerControlError";
1220
+ };
1221
+ var retainedFailures = /* @__PURE__ */ new WeakSet();
1222
+ var spawnForegroundChild = (command, args, options) => {
1223
+ const child = spawn(command, [...args], {
1224
+ cwd: options.cwd,
1225
+ env: options.env,
1226
+ stdio: "inherit",
1227
+ detached: true
1228
+ });
1229
+ const pid = child.pid;
1230
+ if (pid === void 0) {
1231
+ throw new RunnerControlError(`Could not start ${command}.`);
1232
+ }
1233
+ const result = new Promise((resolve2, reject) => {
1234
+ child.once("error", reject);
1235
+ child.once("close", (code, signal) => resolve2({ code, signal }));
1236
+ });
1237
+ return {
1238
+ pid,
1239
+ wait: () => result,
1240
+ terminate: (signal) => {
1241
+ try {
1242
+ process.kill(-pid, signal);
1243
+ } catch {
1244
+ child.kill(signal);
1245
+ }
1246
+ }
1247
+ };
1248
+ };
1249
+ var registerRunnerShutdown = (handler) => {
1250
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
1251
+ let handling = false;
1252
+ const listeners = /* @__PURE__ */ new Map();
1253
+ const unregister = () => {
1254
+ for (const [signal, listener] of listeners) {
1255
+ process.removeListener(signal, listener);
1256
+ }
1257
+ listeners.clear();
1258
+ };
1259
+ for (const signal of signals) {
1260
+ const listener = () => {
1261
+ if (handling) return;
1262
+ handling = true;
1263
+ void handler().finally(() => {
1264
+ unregister();
1265
+ process.exit(
1266
+ signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 129
1267
+ );
1268
+ });
1269
+ };
1270
+ listeners.set(signal, listener);
1271
+ process.once(signal, listener);
1272
+ }
1273
+ return unregister;
1274
+ };
1275
+ var defaultAdapters2 = {
1276
+ platform: () => process.platform,
1277
+ arch: () => process.arch,
1278
+ environment: () => process.env,
1279
+ currentPid: () => process.pid,
1280
+ processIdentity: async (pid) => {
1281
+ try {
1282
+ const result = await execFileAsync2("ps", [
1283
+ "-p",
1284
+ String(pid),
1285
+ "-o",
1286
+ "lstart="
1287
+ ]);
1288
+ const identity = result.stdout.trim().replace(/\s+/g, " ");
1289
+ return identity.length > 0 ? identity : void 0;
1290
+ } catch {
1291
+ return void 0;
1292
+ }
1293
+ },
1294
+ exists: async (path) => {
1295
+ try {
1296
+ await access(path, constants.F_OK);
1297
+ return true;
1298
+ } catch {
1299
+ return false;
1300
+ }
1301
+ },
1302
+ inspectDirectory: async (path) => {
1303
+ const [stat, resolved] = await Promise.all([lstat(path), realpath(path)]);
1304
+ return {
1305
+ realPath: resolved,
1306
+ directory: stat.isDirectory(),
1307
+ symbolicLink: stat.isSymbolicLink()
1308
+ };
1309
+ },
1310
+ readText: (path) => readFile(path, "utf8"),
1311
+ writeText: (path, content) => writeFile(path, content, { mode: 384 }),
1312
+ writeExclusive: (path, content) => writeFile(path, content, { encoding: "utf8", mode: 384, flag: "wx" }),
1313
+ remove: (path) => rm(path, { force: true }),
1314
+ makeDirectory: async (path) => {
1315
+ await mkdir(path, { recursive: true, mode: 448 });
1316
+ },
1317
+ removeTree: (path) => rm(path, { recursive: true, force: true }),
1318
+ commandExists: async (command) => {
1319
+ try {
1320
+ await execFileAsync2("which", [command]);
1321
+ return true;
1322
+ } catch {
1323
+ return false;
1324
+ }
1325
+ },
1326
+ resolveEnvironment: (repoDir) => Effect.runPromise(
1327
+ resolveEnv(repoDir).pipe(Effect.provide(NodeFileSystem.layer))
1328
+ ),
1329
+ run: async (command, args, options) => {
1330
+ const result = await execFileAsync2(command, [...args], {
1331
+ cwd: options.cwd,
1332
+ env: options.env,
1333
+ maxBuffer: 10 * 1024 * 1024
1334
+ });
1335
+ return { stdout: result.stdout, stderr: result.stderr };
1336
+ },
1337
+ spawn: spawnForegroundChild,
1338
+ isProcessRunning: (pid) => {
1339
+ try {
1340
+ process.kill(pid, 0);
1341
+ return true;
1342
+ } catch {
1343
+ return false;
1344
+ }
1345
+ },
1346
+ signalProcess: (pid, signal) => process.kill(pid, signal),
1347
+ onShutdown: registerRunnerShutdown,
1348
+ pause: (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)),
1349
+ now: () => /* @__PURE__ */ new Date(),
1350
+ report: (message) => console.log(message)
1351
+ };
1352
+ var readJson = async (path, adapters) => {
1353
+ if (!await adapters.exists(path)) return void 0;
1354
+ try {
1355
+ return JSON.parse(await adapters.readText(path));
1356
+ } catch (error) {
1357
+ throw new RunnerControlError(
1358
+ `Could not read repository runner state at ${path}: ${error instanceof Error ? error.message : String(error)}`
1359
+ );
1360
+ }
1361
+ };
1362
+ var writeJson = (path, value, adapters) => adapters.writeText(path, `${JSON.stringify(value, null, 2)}
1363
+ `);
1364
+ var controlFailure = (purpose, error) => error instanceof RunnerControlError ? error : new RunnerControlError(
1365
+ `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
1366
+ );
1367
+ var runCommand = async (adapters, purpose, command, args, options) => {
1368
+ try {
1369
+ return await adapters.run(command, args, options);
1370
+ } catch (error) {
1371
+ throw controlFailure(purpose, error);
1372
+ }
1373
+ };
1374
+ var requireCommand2 = async (adapters, command, purpose) => {
1375
+ if (!await adapters.commandExists(command)) {
1376
+ throw new RunnerControlError(
1377
+ `Missing required command \`${command}\` (${purpose}). Install it and retry.`
1378
+ );
1379
+ }
1380
+ };
1381
+ var requireRunnerContext = async (repoDir, adapters) => {
1382
+ if (adapters.platform() !== "darwin" || adapters.arch() !== "arm64") {
1383
+ throw new RunnerControlError(
1384
+ "Repository runners currently require Apple Silicon macOS (darwin/arm64)."
1385
+ );
1386
+ }
1387
+ const configDir = join(repoDir, CONFIG_DIR);
1388
+ const runnerDir = join(configDir, RUNNER_DIR);
1389
+ const maskDir = join(configDir, RUNNER_SANDBOX_MASK_DIR);
1390
+ if (!await adapters.exists(configDir)) {
1391
+ throw new RunnerControlError(
1392
+ `No ${CONFIG_DIR}/ found. Run \`shipyard init\` in this repository first.`
1393
+ );
1394
+ }
1395
+ if (!await adapters.exists(runnerDir)) {
1396
+ throw new RunnerControlError(
1397
+ `No repository runner is installed at ${runnerDir}. Run \`shipyard runner install\` first.`
1398
+ );
1399
+ }
1400
+ if (adapters.inspectDirectory) {
1401
+ const runnerIdentity = await adapters.inspectDirectory(runnerDir).catch((error) => {
1402
+ throw controlFailure(
1403
+ "Inspecting the protected runner directory",
1404
+ error
1405
+ );
1406
+ });
1407
+ if (!runnerIdentity.directory || runnerIdentity.symbolicLink) {
1408
+ throw new RunnerControlError(
1409
+ `The protected repository runner path must be a real directory: ${runnerDir}`
1410
+ );
1411
+ }
1412
+ }
1413
+ const metadata = await readJson(
1414
+ join(runnerDir, RUNNER_INSTALL_METADATA),
1415
+ adapters
1416
+ );
1417
+ if (metadata?.schemaVersion !== 1 || typeof metadata.repository !== "string" || typeof metadata.name !== "string" || metadata.label !== ACTIVATION_LABEL) {
1418
+ throw new RunnerControlError(
1419
+ "The repository runner installation metadata is missing or invalid. Reinstall the repository runner."
1420
+ );
1421
+ }
1422
+ if (!await adapters.exists(join(runnerDir, "run.sh"))) {
1423
+ throw new RunnerControlError(
1424
+ "The official repository runner executable is missing. Reinstall the repository runner."
1425
+ );
1426
+ }
1427
+ for (const [command, purpose] of [
1428
+ ["git", "reading the repository remote"],
1429
+ ["npx", "running the local Shipyard package"],
1430
+ ["docker", "checking the Docker runtime"],
1431
+ ["gh", "checking GitHub access"]
1432
+ ]) {
1433
+ await requireCommand2(adapters, command, purpose);
1434
+ }
1435
+ const hostEnvironment = adapters.environment();
1436
+ const resolvedEnvironment = await adapters.resolveEnvironment(repoDir).catch((error) => {
1437
+ throw controlFailure("Resolving repository credentials", error);
1438
+ });
1439
+ const runtimeEnvironment = {
1440
+ ...hostEnvironment,
1441
+ ...resolvedEnvironment,
1442
+ [REPOSITORY_RUNNER_OWNER_ENV]: metadata.repository
1443
+ };
1444
+ const remote = await runCommand(
1445
+ adapters,
1446
+ "Reading the origin remote",
1447
+ "git",
1448
+ ["remote", "get-url", "origin"],
1449
+ { cwd: repoDir, env: runtimeEnvironment }
1450
+ );
1451
+ let identity;
1452
+ try {
1453
+ identity = parseGitHubRepository(remote.stdout);
1454
+ } catch (error) {
1455
+ throw new RunnerControlError(
1456
+ error instanceof Error ? error.message : String(error)
1457
+ );
1458
+ }
1459
+ const repository = `${identity.owner}/${identity.repository}`;
1460
+ if (repository !== metadata.repository) {
1461
+ throw new RunnerControlError(
1462
+ `The installed repository runner belongs to ${metadata.repository}, but this repository origin is ${repository}. Reinstall it for this repository.`
1463
+ );
1464
+ }
1465
+ await runCommand(
1466
+ adapters,
1467
+ "Checking the local Shipyard package",
1468
+ "npx",
1469
+ ["--no-install", "shipyard", "--version"],
1470
+ { cwd: repoDir, env: runtimeEnvironment }
1471
+ );
1472
+ await runCommand(
1473
+ adapters,
1474
+ "Checking Docker availability",
1475
+ "docker",
1476
+ ["info"],
1477
+ { cwd: repoDir, env: runtimeEnvironment }
1478
+ );
1479
+ await runCommand(
1480
+ adapters,
1481
+ "Checking GitHub authentication",
1482
+ "gh",
1483
+ ["auth", "status", "--hostname", "github.com"],
1484
+ { cwd: repoDir, env: runtimeEnvironment }
1485
+ );
1486
+ const label = await runCommand(
1487
+ adapters,
1488
+ "Checking the activation label",
1489
+ "gh",
1490
+ [
1491
+ "label",
1492
+ "list",
1493
+ "--repo",
1494
+ repository,
1495
+ "--json",
1496
+ "name",
1497
+ "--jq",
1498
+ `.[] | select(.name == "${ACTIVATION_LABEL}") | .name`
1499
+ ],
1500
+ { cwd: repoDir, env: runtimeEnvironment }
1501
+ );
1502
+ if (label.stdout.trim() !== ACTIVATION_LABEL) {
1503
+ await runCommand(
1504
+ adapters,
1505
+ "Creating the activation label",
1506
+ "gh",
1507
+ [
1508
+ "label",
1509
+ "create",
1510
+ ACTIVATION_LABEL,
1511
+ "--repo",
1512
+ repository,
1513
+ "--color",
1514
+ "1D76DB",
1515
+ "--description",
1516
+ "Tasks available to the Shipyard repository runner",
1517
+ "--force"
1518
+ ],
1519
+ { cwd: repoDir, env: runtimeEnvironment }
1520
+ );
1521
+ }
1522
+ await requirePublishedRepositoryRunnerWorkflow(
1523
+ {
1524
+ repoDir,
1525
+ repository,
1526
+ environment: runtimeEnvironment
1527
+ },
1528
+ adapters
1529
+ ).catch((error) => {
1530
+ throw new RunnerControlError(
1531
+ error instanceof Error ? error.message : String(error)
1532
+ );
1533
+ });
1534
+ return {
1535
+ runnerDir,
1536
+ maskDir,
1537
+ repository,
1538
+ metadata,
1539
+ hostEnvironment,
1540
+ runtimeEnvironment
1541
+ };
1542
+ };
1543
+ var childFailure = (command, result) => new RunnerControlError(
1544
+ result.signal === null ? `${command} exited with code ${result.code ?? "unknown"}.` : `${command} was terminated by ${result.signal}.`
1545
+ );
1546
+ var stopChildBounded = async (child, pause) => {
1547
+ child.terminate("SIGTERM");
1548
+ const stopped = await Promise.race([
1549
+ child.wait().then(() => true),
1550
+ pause(5e3).then(() => false)
1551
+ ]);
1552
+ if (stopped) return;
1553
+ child.terminate("SIGKILL");
1554
+ const killed = await Promise.race([
1555
+ child.wait().then(() => true),
1556
+ pause(1e3).then(() => false)
1557
+ ]);
1558
+ if (!killed) {
1559
+ throw new RunnerControlError(
1560
+ `Process ${child.pid} did not exit after SIGKILL.`
1561
+ );
1562
+ }
1563
+ };
1564
+ 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;
1565
+ var bestEffort = async (operation) => {
1566
+ try {
1567
+ await operation();
1568
+ } catch {
1569
+ }
1570
+ };
1571
+ var startRepositoryRunnerManaged = async (options, adapters = defaultAdapters2) => {
1572
+ const context = await requireRunnerContext(options.repoDir, adapters);
1573
+ const lifecycleAdapters = {
1574
+ environment: adapters.environment,
1575
+ exists: adapters.exists,
1576
+ readText: adapters.readText,
1577
+ makeDirectory: adapters.makeDirectory,
1578
+ inspectDirectory: adapters.inspectDirectory,
1579
+ remove: adapters.removeTree,
1580
+ run: adapters.run,
1581
+ isProcessRunning: adapters.isProcessRunning,
1582
+ signalProcess: adapters.signalProcess,
1583
+ pause: adapters.pause,
1584
+ processIdentity: adapters.processIdentity
1585
+ };
1586
+ await recoverRepositoryRunner(
1587
+ {
1588
+ repoDir: options.repoDir,
1589
+ runnerDir: context.runnerDir,
1590
+ maskDir: context.maskDir,
1591
+ metadata: context.metadata,
1592
+ runnerEnvironment: repositoryRunnerEnvironment(context.hostEnvironment),
1593
+ dockerEnvironment: context.runtimeEnvironment
1594
+ },
1595
+ lifecycleAdapters
1596
+ ).catch((error) => {
1597
+ throw new RunnerControlError(
1598
+ error instanceof RunnerLifecycleError ? error.message : `Repository runner recovery failed: ${error instanceof Error ? error.message : String(error)}`
1599
+ );
1600
+ });
1601
+ const lockPath = join(context.runnerDir, RUNNER_CONTROLLER_LOCK);
1602
+ const statePath = join(context.runnerDir, CONTROLLER_STATE);
1603
+ const failurePath = join(context.runnerDir, LAST_FAILURE);
1604
+ const existingLock = await readJson(lockPath, adapters);
1605
+ if (await lockOwnsProcess(existingLock, adapters)) {
1606
+ throw new RunnerControlError(
1607
+ `The repository runner is already running with process ${existingLock.pid}.`
1608
+ );
1609
+ }
1610
+ if (existingLock) {
1611
+ throw new RunnerControlError(
1612
+ "The repository runner has stale process state. Run it again after recovery or remove the stale installation."
1613
+ );
1614
+ }
1615
+ const controllerPid = adapters.currentPid();
1616
+ const processStartedAt = await adapters.processIdentity(controllerPid);
1617
+ if (!processStartedAt) {
1618
+ throw new RunnerControlError(
1619
+ "Could not establish the repository runner controller process identity."
1620
+ );
1621
+ }
1622
+ const lock = {
1623
+ schemaVersion: 1,
1624
+ pid: controllerPid,
1625
+ repository: context.repository,
1626
+ processStartedAt
1627
+ };
1628
+ try {
1629
+ await adapters.writeExclusive(lockPath, `${JSON.stringify(lock)}
1630
+ `);
1631
+ } catch (error) {
1632
+ throw controlFailure("Claiming the repository runner process", error);
1633
+ }
1634
+ let listenerChild;
1635
+ let shipyardChild;
1636
+ let stopping = false;
1637
+ let lastWake;
1638
+ let shutdownPromise;
1639
+ let primaryFailure;
1640
+ const saveState = async (state, lastOutcome) => {
1641
+ await writeJson(
1642
+ statePath,
1643
+ {
1644
+ schemaVersion: 1,
1645
+ repository: context.repository,
1646
+ state,
1647
+ lastOutcome,
1648
+ ...lastWake ? { lastWake } : {}
1649
+ },
1650
+ adapters
1651
+ );
1652
+ adapters.report(`[repository runner] ${state}: ${lastOutcome}`);
1653
+ };
1654
+ const removeTransientWork = () => adapters.removeTree(join(context.runnerDir, RUNNER_WORK_DIR));
1655
+ const removeOwnedContainers = () => removeOwnedRepositoryRunnerContainers(
1656
+ {
1657
+ repoDir: options.repoDir,
1658
+ repository: context.repository,
1659
+ environment: context.runtimeEnvironment
1660
+ },
1661
+ lifecycleAdapters
1662
+ );
1663
+ const attemptCleanup = async (failures, purpose, operation) => {
1664
+ try {
1665
+ await operation();
1666
+ } catch (error) {
1667
+ const message = `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`;
1668
+ failures.push(message);
1669
+ adapters.report(`[repository runner] cleanup error: ${message}`);
1670
+ }
1671
+ };
1672
+ const persistCleanupFailures = async (failures) => {
1673
+ if (failures.length === 0) return;
1674
+ const cleanupMessage = `Repository runner cleanup failed: ${failures.join("; ")}`;
1675
+ const message = primaryFailure ? `${primaryFailure.message} ${cleanupMessage}` : cleanupMessage;
1676
+ await bestEffort(
1677
+ () => writeJson(
1678
+ failurePath,
1679
+ {
1680
+ schemaVersion: 1,
1681
+ repository: context.repository,
1682
+ message,
1683
+ recordedAt: adapters.now().toISOString()
1684
+ },
1685
+ adapters
1686
+ ).then(() => {
1687
+ if (primaryFailure) retainedFailures.add(primaryFailure);
1688
+ })
1689
+ );
1690
+ await bestEffort(() => saveState("stopped", cleanupMessage));
1691
+ };
1692
+ const shutdown = () => {
1693
+ if (shutdownPromise) return shutdownPromise;
1694
+ shutdownPromise = (async () => {
1695
+ stopping = true;
1696
+ const failures = [];
1697
+ const children = [listenerChild, shipyardChild].filter(
1698
+ (child) => child !== void 0
1699
+ );
1700
+ listenerChild = void 0;
1701
+ shipyardChild = void 0;
1702
+ await Promise.all(
1703
+ children.map(
1704
+ (child) => attemptCleanup(
1705
+ failures,
1706
+ `Stopping process ${child.pid}`,
1707
+ () => stopChildBounded(child, adapters.pause)
1708
+ )
1709
+ )
1710
+ );
1711
+ await attemptCleanup(
1712
+ failures,
1713
+ "Removing repository-runner containers",
1714
+ removeOwnedContainers
1715
+ );
1716
+ await attemptCleanup(
1717
+ failures,
1718
+ "Removing transient runner work",
1719
+ removeTransientWork
1720
+ );
1721
+ await attemptCleanup(
1722
+ failures,
1723
+ "Removing controller lock",
1724
+ () => adapters.remove(lockPath)
1725
+ );
1726
+ if (failures.length === 0) {
1727
+ try {
1728
+ await saveState("stopped", "Stopped by signal");
1729
+ } catch (error) {
1730
+ const message = `Persisting stopped state failed: ${error instanceof Error ? error.message : String(error)}`;
1731
+ failures.push(message);
1732
+ adapters.report(`[repository runner] cleanup error: ${message}`);
1733
+ }
1734
+ }
1735
+ await persistCleanupFailures(failures);
1736
+ })();
1737
+ return shutdownPromise;
1738
+ };
1739
+ const unregisterShutdown = adapters.onShutdown(shutdown);
1740
+ const wakeSubscription = createRepositoryRunnerWakeSubscription(
1741
+ adapters.onWake
1742
+ );
1743
+ let initialWorkFound = false;
1744
+ try {
1745
+ const listEligibleIssueNumbers = async () => {
1746
+ const issues = await runCommand(
1747
+ adapters,
1748
+ "Checking for eligible GitHub issues",
1749
+ "gh",
1750
+ [
1751
+ "api",
1752
+ "--method",
1753
+ "GET",
1754
+ "--paginate",
1755
+ `repos/${context.repository}/issues`,
1756
+ "-f",
1757
+ "state=open",
1758
+ "-f",
1759
+ `labels=${ACTIVATION_LABEL}`,
1760
+ "-f",
1761
+ "per_page=100",
1762
+ "--jq",
1763
+ ".[] | select(.pull_request == null) | .number"
1764
+ ],
1765
+ { cwd: options.repoDir, env: context.runtimeEnvironment }
1766
+ );
1767
+ return [...new Set(issues.stdout.split(/\s+/).filter(Boolean))].sort(
1768
+ (left, right) => Number(left) - Number(right)
1769
+ );
1770
+ };
1771
+ const sameIssueNumbers = (left, right) => left.length === right.length && left.every((issue, index) => issue === right[index]);
1772
+ const processEligibleWork = async () => {
1773
+ let issueNumbers = await listEligibleIssueNumbers();
1774
+ const workFound = issueNumbers.length > 0;
1775
+ while (!stopping && issueNumbers.length > 0) {
1776
+ await saveState("processing", "Shipyard is processing eligible issues");
1777
+ shipyardChild = adapters.spawn("npx", ["shipyard", "run"], {
1778
+ cwd: options.repoDir,
1779
+ env: context.runtimeEnvironment
1780
+ });
1781
+ const runResult = await shipyardChild.wait();
1782
+ shipyardChild = void 0;
1783
+ if (stopping) {
1784
+ await shutdownPromise;
1785
+ return workFound;
1786
+ }
1787
+ if (runResult.code !== 0) {
1788
+ const failure = childFailure("npx shipyard run", runResult);
1789
+ await writeJson(
1790
+ failurePath,
1791
+ {
1792
+ schemaVersion: 1,
1793
+ repository: context.repository,
1794
+ message: failure.message,
1795
+ recordedAt: adapters.now().toISOString()
1796
+ },
1797
+ adapters
1798
+ );
1799
+ await saveState("stopped", failure.message);
1800
+ throw failure;
1801
+ }
1802
+ const nextIssueNumbers = await listEligibleIssueNumbers();
1803
+ if (nextIssueNumbers.length === 0) {
1804
+ await saveState("idle", "Shipyard completed; no eligible issues");
1805
+ break;
1806
+ }
1807
+ if (sameIssueNumbers(issueNumbers, nextIssueNumbers)) {
1808
+ await saveState(
1809
+ "stalled",
1810
+ "Shipyard made no progress; eligible issues are unchanged"
1811
+ );
1812
+ break;
1813
+ }
1814
+ issueNumbers = nextIssueNumbers;
1815
+ }
1816
+ return workFound;
1817
+ };
1818
+ lastWake = {
1819
+ source: "startup",
1820
+ recordedAt: adapters.now().toISOString()
1821
+ };
1822
+ initialWorkFound = await processEligibleWork();
1823
+ if (stopping) {
1824
+ await shutdownPromise;
1825
+ return { repository: context.repository, initialWorkFound };
1826
+ }
1827
+ if (!initialWorkFound) {
1828
+ await saveState("idle", "No eligible issues; listening");
1829
+ }
1830
+ listenerChild = adapters.spawn("./run.sh", [], {
1831
+ cwd: context.runnerDir,
1832
+ env: repositoryRunnerEnvironment(context.hostEnvironment)
1833
+ });
1834
+ const listenerExit = listenerChild.wait().then((result) => ({
1835
+ type: "listener",
1836
+ result
1837
+ }));
1838
+ let listenerResult;
1839
+ while (!listenerResult && !stopping) {
1840
+ const event = await Promise.race([
1841
+ listenerExit,
1842
+ wakeSubscription.next().then(() => ({ type: "wake" }))
1843
+ ]);
1844
+ if (event.type === "listener") {
1845
+ listenerResult = event.result;
1846
+ listenerChild = void 0;
1847
+ break;
1848
+ }
1849
+ if (stopping) break;
1850
+ lastWake = {
1851
+ source: "signal",
1852
+ recordedAt: adapters.now().toISOString()
1853
+ };
1854
+ const workFound = await processEligibleWork();
1855
+ if (!workFound && !stopping) {
1856
+ await saveState("idle", "Wake-up delivered; no eligible issues");
1857
+ }
1858
+ }
1859
+ if (stopping) {
1860
+ await shutdownPromise;
1861
+ return { repository: context.repository, initialWorkFound };
1862
+ }
1863
+ if (!listenerResult) {
1864
+ throw new RunnerControlError(
1865
+ "The repository runner listener stopped without an exit result."
1866
+ );
1867
+ }
1868
+ if (listenerResult.code !== 0) {
1869
+ const failure = childFailure(
1870
+ "Repository runner listener",
1871
+ listenerResult
1872
+ );
1873
+ await writeJson(
1874
+ failurePath,
1875
+ {
1876
+ schemaVersion: 1,
1877
+ repository: context.repository,
1878
+ message: failure.message,
1879
+ recordedAt: adapters.now().toISOString()
1880
+ },
1881
+ adapters
1882
+ );
1883
+ await saveState("stopped", failure.message);
1884
+ throw failure;
1885
+ }
1886
+ await saveState("stopped", "Repository runner listener exited");
1887
+ return { repository: context.repository, initialWorkFound };
1888
+ } catch (error) {
1889
+ const failure = controlFailure("Repository runner controller", error);
1890
+ primaryFailure = failure;
1891
+ await bestEffort(
1892
+ () => writeJson(
1893
+ failurePath,
1894
+ {
1895
+ schemaVersion: 1,
1896
+ repository: context.repository,
1897
+ message: failure.message,
1898
+ recordedAt: adapters.now().toISOString()
1899
+ },
1900
+ adapters
1901
+ ).then(() => retainedFailures.add(failure))
1902
+ );
1903
+ await bestEffort(() => saveState("stopped", failure.message));
1904
+ throw failure;
1905
+ } finally {
1906
+ wakeSubscription.close();
1907
+ unregisterShutdown();
1908
+ if (shutdownPromise) {
1909
+ await shutdownPromise;
1910
+ } else {
1911
+ const failures = [];
1912
+ if (listenerChild) {
1913
+ await attemptCleanup(
1914
+ failures,
1915
+ `Stopping process ${listenerChild.pid}`,
1916
+ () => stopChildBounded(listenerChild, adapters.pause)
1917
+ );
1918
+ }
1919
+ if (shipyardChild) {
1920
+ await attemptCleanup(
1921
+ failures,
1922
+ `Stopping process ${shipyardChild.pid}`,
1923
+ () => stopChildBounded(shipyardChild, adapters.pause)
1924
+ );
1925
+ }
1926
+ await attemptCleanup(
1927
+ failures,
1928
+ "Removing repository-runner containers",
1929
+ removeOwnedContainers
1930
+ );
1931
+ await attemptCleanup(
1932
+ failures,
1933
+ "Removing transient runner work",
1934
+ removeTransientWork
1935
+ );
1936
+ await attemptCleanup(
1937
+ failures,
1938
+ "Removing controller lock",
1939
+ () => adapters.remove(lockPath)
1940
+ );
1941
+ await persistCleanupFailures(failures);
1942
+ }
1943
+ }
1944
+ };
1945
+ var startRepositoryRunner = async (options, adapters = defaultAdapters2) => {
1946
+ try {
1947
+ return await startRepositoryRunnerManaged(options, adapters);
1948
+ } catch (error) {
1949
+ const failure = controlFailure("Repository runner startup", error);
1950
+ if (retainedFailures.has(failure)) throw failure;
1951
+ const runnerDir = join(options.repoDir, CONFIG_DIR, RUNNER_DIR);
1952
+ if (await adapters.exists(runnerDir)) {
1953
+ const metadata = await readJson(
1954
+ join(runnerDir, RUNNER_INSTALL_METADATA),
1955
+ adapters
1956
+ ).catch(() => void 0);
1957
+ const repository = metadata?.repository ?? "unknown";
1958
+ for (const [purpose, operation] of [
1959
+ [
1960
+ "failure diagnostics",
1961
+ () => writeJson(
1962
+ join(runnerDir, LAST_FAILURE),
1963
+ {
1964
+ schemaVersion: 1,
1965
+ repository,
1966
+ message: failure.message,
1967
+ recordedAt: adapters.now().toISOString()
1968
+ },
1969
+ adapters
1970
+ )
1971
+ ],
1972
+ [
1973
+ "stopped state",
1974
+ () => writeJson(
1975
+ join(runnerDir, CONTROLLER_STATE),
1976
+ {
1977
+ schemaVersion: 1,
1978
+ repository,
1979
+ state: "stopped",
1980
+ lastOutcome: failure.message
1981
+ },
1982
+ adapters
1983
+ )
1984
+ ]
1985
+ ]) {
1986
+ try {
1987
+ await operation();
1988
+ } catch (persistenceError) {
1989
+ adapters.report(
1990
+ `[repository runner] Could not persist ${purpose}: ${persistenceError instanceof Error ? persistenceError.message : String(persistenceError)}`
1991
+ );
1992
+ }
1993
+ }
1994
+ }
1995
+ throw failure;
1996
+ }
1997
+ };
1998
+ var getRepositoryRunnerStatus = async (options, adapters = defaultAdapters2) => {
1999
+ const runnerDir = join(options.repoDir, CONFIG_DIR, RUNNER_DIR);
2000
+ const metadata = await readJson(
2001
+ join(runnerDir, RUNNER_INSTALL_METADATA),
2002
+ adapters
2003
+ );
2004
+ if (!metadata) {
2005
+ return {
2006
+ installed: false,
2007
+ running: false,
2008
+ github: "not-installed",
2009
+ state: "not-installed",
2010
+ lastOutcome: "Repository runner is not installed"
2011
+ };
2012
+ }
2013
+ const lock = await readJson(
2014
+ join(runnerDir, RUNNER_CONTROLLER_LOCK),
2015
+ adapters
2016
+ );
2017
+ const state = await readJson(
2018
+ join(runnerDir, CONTROLLER_STATE),
2019
+ adapters
2020
+ );
2021
+ const running = await lockOwnsProcess(lock, adapters);
2022
+ let github = "unreachable";
2023
+ try {
2024
+ const resolvedEnvironment = await adapters.resolveEnvironment(
2025
+ options.repoDir
2026
+ );
2027
+ const response = await adapters.run(
2028
+ "gh",
2029
+ [
2030
+ "api",
2031
+ `repos/${metadata.repository}/actions/runners`,
2032
+ "--jq",
2033
+ `.runners[] | select(.name == "${metadata.name}") | .status`
2034
+ ],
2035
+ {
2036
+ cwd: options.repoDir,
2037
+ env: { ...adapters.environment(), ...resolvedEnvironment }
2038
+ }
2039
+ );
2040
+ github = response.stdout.trim() === "online" ? "online" : "offline";
2041
+ } catch {
2042
+ github = "unreachable";
2043
+ }
2044
+ return {
2045
+ installed: true,
2046
+ running,
2047
+ ...lock ? { pid: lock.pid } : {},
2048
+ github,
2049
+ repository: metadata.repository,
2050
+ state: state?.state ?? (running ? "idle" : "stopped"),
2051
+ lastOutcome: state?.lastOutcome ?? "No repository runner outcome recorded",
2052
+ ...state?.lastWake ? { lastWake: state.lastWake } : {}
2053
+ };
2054
+ };
2055
+ var stopRepositoryRunner = async (options, adapters = defaultAdapters2) => {
2056
+ const lockPath = join(
2057
+ options.repoDir,
2058
+ CONFIG_DIR,
2059
+ RUNNER_DIR,
2060
+ RUNNER_CONTROLLER_LOCK
2061
+ );
2062
+ const lock = await readJson(lockPath, adapters);
2063
+ if (!lock || !Number.isInteger(lock.pid)) {
2064
+ throw new RunnerControlError("The repository runner is not running.");
2065
+ }
2066
+ if (!await lockOwnsProcess(lock, adapters)) {
2067
+ throw new RunnerControlError(
2068
+ `The repository runner process ${lock.pid} no longer matches the recorded controller identity; recovery is required.`
2069
+ );
2070
+ }
2071
+ try {
2072
+ adapters.signalProcess(lock.pid, "SIGTERM");
2073
+ } catch (error) {
2074
+ throw controlFailure("Stopping the repository runner", error);
2075
+ }
2076
+ return { pid: lock.pid };
2077
+ };
2078
+
2079
+ // src/RepositoryRunnerCommands.ts
2080
+ var registrationTokenOption = Options.text("registration-token").pipe(
2081
+ Options.withDescription(
2082
+ "one-time runner registration token (not stored; administrative gh access is still required for uniqueness checks)"
2083
+ ),
2084
+ Options.optional
2085
+ );
2086
+ var installRunnerCommand = Command.make(
2087
+ "install",
2088
+ { registrationToken: registrationTokenOption },
2089
+ ({ registrationToken }) => Effect.gen(function* () {
2090
+ const display = yield* Display;
2091
+ const result = yield* display.spinner(
2092
+ "Installing repository runner...",
2093
+ Effect.tryPromise({
2094
+ try: () => installRepositoryRunner({
2095
+ repoDir: process.cwd(),
2096
+ registrationToken: registrationToken._tag === "Some" ? registrationToken.value : void 0
2097
+ }),
2098
+ catch: (error) => new InitError({
2099
+ message: error instanceof RunnerInstallError ? error.message : `Repository runner installation failed: ${error instanceof Error ? error.message : String(error)}`
2100
+ })
2101
+ })
2102
+ );
2103
+ yield* display.status(
2104
+ `Installed ${result.name} for ${result.repository}.`,
2105
+ "success"
2106
+ );
2107
+ })
2108
+ );
2109
+ var runnerControlEffect = (operation) => Effect.tryPromise({
2110
+ try: operation,
2111
+ catch: (error) => new InitError({
2112
+ message: error instanceof RunnerControlError || error instanceof RunnerLifecycleError ? error.message : `Repository runner operation failed: ${error instanceof Error ? error.message : String(error)}`
2113
+ })
2114
+ });
2115
+ var startRunnerCommand = Command.make(
2116
+ "start",
2117
+ {},
2118
+ () => Effect.gen(function* () {
2119
+ const display = yield* Display;
2120
+ const cwd = process.cwd();
2121
+ yield* display.status(
2122
+ "Starting the repository runner in this terminal...",
2123
+ "info"
2124
+ );
2125
+ const result = yield* runnerControlEffect(
2126
+ () => startRepositoryRunner({ repoDir: cwd })
2127
+ );
2128
+ yield* display.status(
2129
+ result.initialWorkFound ? `Repository runner for ${result.repository} stopped after processing startup work.` : `Repository runner for ${result.repository} stopped.`,
2130
+ "info"
2131
+ );
2132
+ })
2133
+ );
2134
+ var statusRunnerCommand = Command.make(
2135
+ "status",
2136
+ {},
2137
+ () => Effect.gen(function* () {
2138
+ const display = yield* Display;
2139
+ const status = yield* runnerControlEffect(
2140
+ () => getRepositoryRunnerStatus({ repoDir: process.cwd() })
2141
+ );
2142
+ yield* display.summary("Repository runner", {
2143
+ Installed: status.installed ? "yes" : "no",
2144
+ "Local process": status.running ? `running (PID ${status.pid ?? "unknown"})` : "stopped",
2145
+ GitHub: status.github,
2146
+ Repository: status.repository ?? "unknown",
2147
+ State: status.state,
2148
+ "Last outcome": status.lastOutcome,
2149
+ "Last wake": status.lastWake ? `${status.lastWake.source} at ${status.lastWake.recordedAt}` : "none recorded"
2150
+ });
2151
+ })
2152
+ );
2153
+ var stopRunnerCommand = Command.make(
2154
+ "stop",
2155
+ {},
2156
+ () => Effect.gen(function* () {
2157
+ const display = yield* Display;
2158
+ const result = yield* runnerControlEffect(
2159
+ () => stopRepositoryRunner({ repoDir: process.cwd() })
2160
+ );
2161
+ yield* display.status(
2162
+ `Stopping repository runner process ${result.pid}...`,
2163
+ "success"
2164
+ );
2165
+ })
2166
+ );
2167
+ var forceRemoveRunnerOption = Options.boolean("force").pipe(
2168
+ Options.withDescription(
2169
+ "delete local runner files if GitHub unregistration fails"
2170
+ )
2171
+ );
2172
+ var removeRunnerCommand = Command.make(
2173
+ "remove",
2174
+ { force: forceRemoveRunnerOption },
2175
+ ({ force }) => Effect.gen(function* () {
2176
+ const display = yield* Display;
2177
+ const result = yield* runnerControlEffect(
2178
+ () => removeRepositoryRunner({ repoDir: process.cwd(), force })
2179
+ );
2180
+ yield* display.status("Repository runner removed.", "success");
2181
+ if (result.manualCleanup) {
2182
+ yield* display.status(result.manualCleanup, "warn");
2183
+ }
2184
+ })
2185
+ );
2186
+ var runnerCommand = Command.make(
2187
+ "runner",
2188
+ {},
2189
+ () => Effect.gen(function* () {
2190
+ const display = yield* Display;
2191
+ yield* display.status(
2192
+ "Repository runner commands. Use --help to see available subcommands.",
2193
+ "info"
2194
+ );
2195
+ })
2196
+ ).pipe(
2197
+ Command.withSubcommands([
2198
+ installRunnerCommand,
2199
+ startRunnerCommand,
2200
+ statusRunnerCommand,
2201
+ stopRunnerCommand,
2202
+ removeRunnerCommand
2203
+ ])
2204
+ );
2205
+ var VERSION = "0.4.4-staging.35699051936" ;
614
2206
 
615
2207
  // src/cli.ts
616
2208
  var imageNameOption = Options.text("image-name").pipe(
@@ -714,7 +2306,7 @@ var executeEntrypoint = (cwd, entrypoint) => Effect.async((resume, signal) => {
714
2306
  );
715
2307
  });
716
2308
  });
717
- var runCommand = Command.make(
2309
+ var runCommand2 = Command.make(
718
2310
  "run",
719
2311
  {
720
2312
  imageName: imageNameOption,
@@ -787,25 +2379,25 @@ var issueTrackerOption = Options.text("issue-tracker").pipe(
787
2379
  Options.withDescription("Issue tracker to use (github-issues)"),
788
2380
  Options.optional
789
2381
  );
790
- var createLabelOption = Options.choice("create-label", [
2382
+ var buildImageOption = Options.choice("build-image", ["true", "false"]).pipe(
2383
+ Options.withDescription("Whether to build the sandbox image now"),
2384
+ Options.optional
2385
+ );
2386
+ var installTemplateDepsOption = Options.choice("install-template-deps", [
791
2387
  "true",
792
2388
  "false"
793
2389
  ]).pipe(
794
2390
  Options.withDescription(
795
- `Whether to create the "${PRODUCT_NAME}" GitHub label (only meaningful with --issue-tracker github-issues)`
2391
+ "Whether to install the template's host dependencies (e.g. zod for the planner templates)"
796
2392
  ),
797
2393
  Options.optional
798
2394
  );
799
- var buildImageOption = Options.choice("build-image", ["true", "false"]).pipe(
800
- Options.withDescription("Whether to build the sandbox image now"),
801
- Options.optional
802
- );
803
- var installTemplateDepsOption = Options.choice("install-template-deps", [
2395
+ var installRunnerOption = Options.choice("install-runner", [
804
2396
  "true",
805
2397
  "false"
806
2398
  ]).pipe(
807
2399
  Options.withDescription(
808
- "Whether to install the template's host dependencies (e.g. zod for the planner templates)"
2400
+ "Whether to install a foreground repository runner after scaffolding"
809
2401
  ),
810
2402
  Options.optional
811
2403
  );
@@ -820,9 +2412,9 @@ var initCommand = Command.make(
820
2412
  model: initModelOption,
821
2413
  sandbox: sandboxOption,
822
2414
  issueTracker: issueTrackerOption,
823
- createLabel: createLabelOption,
824
2415
  buildImage: buildImageOption,
825
- installTemplateDeps: installTemplateDepsOption
2416
+ installTemplateDeps: installTemplateDepsOption,
2417
+ installRunner: installRunnerOption
826
2418
  },
827
2419
  ({
828
2420
  imageName: imageNameFlag,
@@ -832,9 +2424,9 @@ var initCommand = Command.make(
832
2424
  model: modelFlag,
833
2425
  sandbox: sandboxFlag,
834
2426
  issueTracker: issueTrackerFlag,
835
- createLabel: createLabelFlag,
836
2427
  buildImage: buildImageFlag,
837
- installTemplateDeps: installTemplateDepsFlag
2428
+ installTemplateDeps: installTemplateDepsFlag,
2429
+ installRunner: installRunnerFlag
838
2430
  }) => Effect.gen(function* () {
839
2431
  const d = yield* Display;
840
2432
  const cwd = process.cwd();
@@ -873,11 +2465,11 @@ var initCommand = Command.make(
873
2465
  );
874
2466
  }
875
2467
  }
876
- const createLabelChoice = choiceToTriBool(createLabelFlag);
877
2468
  const buildImageChoice = choiceToTriBool(buildImageFlag);
878
2469
  const installTemplateDepsChoice = choiceToTriBool(
879
2470
  installTemplateDepsFlag
880
2471
  );
2472
+ const installRunnerChoice = choiceToTriBool(installRunnerFlag);
881
2473
  const isInteractive = process.stdin.isTTY === true;
882
2474
  const failIfNonInteractive = (flag) => Effect.fail(
883
2475
  new InitError({
@@ -937,15 +2529,36 @@ var initCommand = Command.make(
937
2529
  }
938
2530
  selectedAgent = getAgent(selected);
939
2531
  }
2532
+ const selectedCodexAuth = yield* Effect.tryPromise({
2533
+ try: () => resolveCodexAuthMode({
2534
+ agentName: selectedAgent.name,
2535
+ requested: codexAuthFlag._tag === "Some" ? codexAuthFlag.value : void 0,
2536
+ interactive: isInteractive,
2537
+ select: async () => {
2538
+ const selected = await clack.select({
2539
+ message: "How will you authenticate Codex?",
2540
+ initialValue: "chatgpt",
2541
+ options: [
2542
+ {
2543
+ value: "chatgpt",
2544
+ label: "Sign in with ChatGPT",
2545
+ hint: "Use your ChatGPT subscription"
2546
+ },
2547
+ {
2548
+ value: "api-key",
2549
+ label: "OpenAI API key",
2550
+ hint: "Use API billing"
2551
+ }
2552
+ ]
2553
+ });
2554
+ return clack.isCancel(selected) ? void 0 : selected;
2555
+ }
2556
+ }),
2557
+ catch: (error) => error instanceof InitError ? error : new InitError({
2558
+ message: `Codex authentication selection failed: ${String(error)}`
2559
+ })
2560
+ });
940
2561
  const selectedModel = modelFlag._tag === "Some" ? modelFlag.value : selectedAgent.defaultModel;
941
- const selectedCodexAuth = codexAuthFlag._tag === "Some" ? codexAuthFlag.value : "api-key";
942
- if (selectedCodexAuth === "chatgpt" && selectedAgent.name !== "codex") {
943
- yield* Effect.fail(
944
- new InitError({
945
- message: "--codex-auth chatgpt can only be used with --agent codex."
946
- })
947
- );
948
- }
949
2562
  if (selectedCodexAuth === "chatgpt") {
950
2563
  yield* Effect.try({
951
2564
  try: () => ensureCodexChatGptAuth({
@@ -1035,23 +2648,14 @@ var initCommand = Command.make(
1035
2648
  }
1036
2649
  selectedTemplate = selected;
1037
2650
  }
1038
- let shouldCreateLabel = false;
1039
2651
  if (selectedIssueTracker.name === "github-issues") {
1040
- shouldCreateLabel = yield* resolveConfirmFlag({
1041
- choice: createLabelChoice,
1042
- flag: "--create-label",
1043
- promptMessage: `Create a "${PRODUCT_NAME}" GitHub label? (Templates filter issues by this label)`,
1044
- cancelMessage: "Label selection cancelled."
1045
- });
1046
- if (shouldCreateLabel) {
1047
- yield* Effect.try({
1048
- try: () => execSync(
1049
- `gh label create "${PRODUCT_NAME}" --description "Issues for ${PRODUCT_NAME} to work on" --color "F9A825" 2>/dev/null`,
1050
- { cwd, stdio: "ignore" }
1051
- ),
1052
- catch: () => void 0
1053
- }).pipe(Effect.ignore);
1054
- }
2652
+ yield* Effect.try({
2653
+ try: () => execSync(
2654
+ `gh label create "${ACTIVATION_LABEL}" --description "Issues for ${PRODUCT_NAME} to work on" --color "F9A825" --force 2>/dev/null`,
2655
+ { cwd, stdio: "ignore" }
2656
+ ),
2657
+ catch: () => void 0
2658
+ }).pipe(Effect.ignore);
1055
2659
  }
1056
2660
  const scaffoldResult = yield* d.spinner(
1057
2661
  `Scaffolding ${CONFIG_DIR}/ config directory...`,
@@ -1059,7 +2663,6 @@ var initCommand = Command.make(
1059
2663
  agent: selectedAgent,
1060
2664
  model: selectedModel,
1061
2665
  templateName: selectedTemplate,
1062
- createLabel: shouldCreateLabel,
1063
2666
  issueTracker: selectedIssueTracker,
1064
2667
  sandboxProvider: selectedSandboxProvider,
1065
2668
  codexAuth: selectedCodexAuth
@@ -1113,13 +2716,52 @@ var initCommand = Command.make(
1113
2716
  buildArgs: defaultUidBuildArgs()
1114
2717
  })
1115
2718
  );
1116
- yield* d.status("Init complete! Image built successfully.", "success");
2719
+ yield* d.status("Image built successfully.", "success");
1117
2720
  } else {
1118
2721
  yield* d.status(
1119
- `Init complete! Run \`${CLI_NAME} ${selectedSandboxProvider.cliNamespace} build-image\` to build the ${providerLabel} image later.`,
2722
+ `Run \`${CLI_NAME} ${selectedSandboxProvider.cliNamespace} build-image\` to build the ${providerLabel} image later.`,
2723
+ "info"
2724
+ );
2725
+ }
2726
+ const runnerInit = yield* Effect.tryPromise({
2727
+ try: () => initializeRepositoryRunner({
2728
+ interactive: isInteractive,
2729
+ requested: installRunnerChoice._tag === "Some" ? installRunnerChoice.value : void 0,
2730
+ confirm: async ({ message, initialValue }) => {
2731
+ const confirmed = await clack.confirm({ message, initialValue });
2732
+ if (clack.isCancel(confirmed)) {
2733
+ throw new InitError({
2734
+ message: "Repository-runner installation selection cancelled."
2735
+ });
2736
+ }
2737
+ return confirmed === true;
2738
+ },
2739
+ install: () => installRepositoryRunner({ repoDir: cwd })
2740
+ }),
2741
+ catch: (error) => error instanceof InitError ? error : new InitError({
2742
+ message: `Repository-runner setup failed: ${error instanceof Error ? error.message : String(error)}`
2743
+ })
2744
+ });
2745
+ if (runnerInit.status === "installed") {
2746
+ yield* d.status(
2747
+ `Installed ${runnerInit.result.name} for ${runnerInit.result.repository}.`,
1120
2748
  "success"
1121
2749
  );
2750
+ yield* d.text("Repository runner next steps:");
2751
+ for (const [index, line] of repositoryRunnerNextSteps().entries()) {
2752
+ yield* d.text(styleText("dim", `${index + 1}. ${line}`));
2753
+ }
2754
+ } else if (runnerInit.status === "failed") {
2755
+ yield* d.status(
2756
+ `Shipyard scaffolding is ready, but repository runner installation failed: ${runnerInit.message}`,
2757
+ "warn"
2758
+ );
2759
+ yield* d.status(
2760
+ `Retry from this repository with \`npx ${CLI_NAME} runner install\`.`,
2761
+ "warn"
2762
+ );
1122
2763
  }
2764
+ yield* d.status("Init complete!", "success");
1123
2765
  const nextSteps = getNextStepsLines(
1124
2766
  selectedTemplate,
1125
2767
  scaffoldResult.mainFilename,
@@ -1198,7 +2840,12 @@ var rootCommand = Command.make(
1198
2840
  })
1199
2841
  );
1200
2842
  var shipyard = rootCommand.pipe(
1201
- Command.withSubcommands([initCommand, runCommand, dockerCommand])
2843
+ Command.withSubcommands([
2844
+ initCommand,
2845
+ runCommand2,
2846
+ dockerCommand,
2847
+ runnerCommand
2848
+ ])
1202
2849
  );
1203
2850
  var cli = Command.run(shipyard, {
1204
2851
  name: CLI_NAME,