@bongos/core 1.19.319 → 1.19.321

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.
Files changed (51) hide show
  1. package/.bongos-core.json +125 -40
  2. package/.claude/skills/merge-mode/SKILL.md +3 -1
  3. package/.devcontainer/init-firewall.sh +41 -5
  4. package/clients/bongos-client/examples/hello-world.mjs +11 -2
  5. package/docs/copy-inventory.md +20 -20
  6. package/docs/copy-registry.json +22 -22
  7. package/modules/dev-box/box-access.js +20 -0
  8. package/modules/hall-ui/public/builders.js +0 -1
  9. package/package-lock.json +2 -2
  10. package/package.json +1 -1
  11. package/scripts/gds/audit-replay.sh +20 -3
  12. package/scripts/gds/box.js +79 -86
  13. package/scripts/gds/db-backup-nightly.sh +17 -4
  14. package/scripts/gds/db-backup-offsite.sh +23 -6
  15. package/scripts/gds/discipline-modes.json +1 -1
  16. package/scripts/gds/doc-cli-guard.js +34 -2
  17. package/scripts/gds/fitness-ratchets.js +54 -6
  18. package/scripts/gds/fitness.js +8 -4
  19. package/scripts/gds/gds-db-backup.service +15 -4
  20. package/scripts/gds/gds-db-backup.timer +12 -1
  21. package/scripts/gds/gds-db-offsite.service +16 -5
  22. package/scripts/gds/gds-db-offsite.timer +12 -1
  23. package/scripts/gds/instance-apex.js +28 -0
  24. package/scripts/gds/newcomer-floor.templates.json +2 -2
  25. package/scripts/gds/provision.js +7 -3
  26. package/scripts/gds/ship-deploy-target.js +388 -0
  27. package/scripts/gds/ship-finish.js +377 -0
  28. package/scripts/gds/ship-flow.js +814 -0
  29. package/scripts/gds/ship-git.js +100 -0
  30. package/scripts/gds/ship-grade-steps.js +376 -0
  31. package/scripts/gds/ship-grade.js +319 -0
  32. package/scripts/gds/ship-io.js +100 -0
  33. package/scripts/gds/ship-land.js +539 -0
  34. package/scripts/gds/ship-memory-sync.js +192 -0
  35. package/scripts/gds/ship-merge.js +297 -0
  36. package/scripts/gds/ship-preflight-steps.js +323 -0
  37. package/scripts/gds/ship-regen.js +286 -0
  38. package/scripts/gds/ship-resume.js +151 -0
  39. package/scripts/gds/ship-session-upload.js +216 -0
  40. package/scripts/gds/ship-state.js +73 -0
  41. package/scripts/gds/ship-surprise.js +84 -0
  42. package/scripts/gds/ship.js +105 -4195
  43. package/scripts/status-probe.mjs +30 -2
  44. package/src/module-api.js +5 -1
  45. package/tests/box_access.mjs +46 -0
  46. package/tests/fitness_ratchets.mjs +82 -0
  47. package/tests/helpers.mjs +13 -0
  48. package/tests/provision.mjs +7 -1
  49. package/tests/regrade_eligibility.mjs +43 -0
  50. package/tests/ship_assurance.mjs +4 -2
  51. package/tests/ship_publish_error.mjs +43 -0
@@ -1,6 +1,17 @@
1
+ # TEMPLATE — not installable as-is (task 1002757). The __TOKENS__ below MUST be
2
+ # substituted at install time; a provisioned instance gets a correct per-instance
3
+ # unit pair generated by scripts/gds/provision.js (task 2213) and never needs this
4
+ # file. Substitute:
5
+ # __SERVICE_USER__ the unix user that owns the instance + its Postgres role
6
+ # __INSTANCE_ROOT__ the instance checkout root (docs live under it)
7
+ # __CORE_SCRIPTS_DIR__ the deployed core's scripts/gds dir:
8
+ # standalone: <instance-root>/node_modules/@bongos/core/scripts/gds
9
+ # full clone: <instance-root>/scripts/gds
10
+ # __CONFIG_DIR__ the instance config dir name (offsite unit only)
11
+ # One-liner: sed -e 's|__SERVICE_USER__|<user>|g' -e … gds-db-backup.service | sudo tee /etc/systemd/system/…
1
12
  [Unit]
2
13
  Description=Weekly timer for GDS DB offsite archive to Spaces (filed by #415)
3
- Documentation=file:///home/lars/amazonprimea/docs/recipes/gds-db-backup.md
14
+ Documentation=file://__INSTANCE_ROOT__/docs/recipes/gds-db-backup.md
4
15
 
5
16
  [Timer]
6
17
  # Run weekly on Sunday at 05:00 UTC. That's one hour after the daily local
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ // instance-apex.js — the instance's own registrable apex + the addresses derived
3
+ // from it, read from its branding pack (task 1002757).
4
+ //
5
+ // WHY THIS EXISTS. Operator-plane defaults used to carry the founder's apex as a
6
+ // literal (box DNS zones, the Cloudflare zone, the provisioner's git identity),
7
+ // so another operator's fleet pointed at the founder's DNS and its commits were
8
+ // attributed to the hub. These resolvers return NULL rather than guessing when the
9
+ // pack names no public origin: every caller treats null as "not configured" and
10
+ // refuses, which fails loudly instead of minting an unreachable host.
11
+ function instanceApex() {
12
+ try {
13
+ const host = new URL(require('../../src/branding').branding().domains.publicOrigin).hostname;
14
+ return host && !/^localhost$/i.test(host) ? host : null;
15
+ } catch { return null; }
16
+ }
17
+
18
+ // The git identity a provisioner commits scaffold/pin changes as: explicit env →
19
+ // bot@<instance apex> → a neutral, deliberately-undeliverable platform address
20
+ // (never a live inbox by accident).
21
+ function provisionerBotEmail(env = process.env) {
22
+ const explicit = String(env.PROVISION_BOT_EMAIL || '').trim();
23
+ if (explicit) return explicit;
24
+ const apex = instanceApex();
25
+ return apex ? `bot@${apex}` : 'bot@cloud-bongos.invalid';
26
+ }
27
+
28
+ module.exports = { instanceApex, provisionerBotEmail };
@@ -49,7 +49,7 @@
49
49
  "est_minutes": 55,
50
50
  "credits_reward": 15,
51
51
  "title": "Generate one new in-world art asset of your choice (a prop, plant, animal, or accessory) via the pixel-art pipeline",
52
- "description": "## What you're doing\n\nAmazonprimea is a young ancient-Greek coastal world, and it always needs more to look at. Your first ship as an artist is to **generate ONE new in-world asset of your own choosing** and add it to the world. You pick the subject — that's the fun part. Good candidates, all in-world (Mediterranean coast just south of Athens, mythic 'discovered secret' tone):\n- **Props**: amphora, fishing net, basket, crate, urn, torch, market awning, oar, clay pot.\n- **Plants**: olive tree, cypress, reeds, grapevine, shrub, wild herbs.\n- **Animals**: goat, chicken, duck, donkey, fish, gull, lizard.\n- **Accessories**: a sandal, a sun hat, a length of rope, a string of beads.\n\nIt is identical for every newcomer and **always valuable**, because a growing world has effectively unbounded demand for new props, flora, fauna, and detail — every newcomer picks a different subject, so two runs never collide.\n\n## How to find your way in\n\n1. Read `art/README.md` and `docs/recipes/pixel-art-pipeline.md`, plus `modules/art-pipeline/template/style_guide.md`, `modules/art-pipeline/template/rubric.json`, and `modules/art-pipeline/template/palette.json` — that's the look you must hit: 16-bit pixel art, early-Pokémon (FireRed) touchstone, top-down, 32×32, locked 64-colour Mediterranean palette (olive greens, warm tans, turquoise sea, weathered marble — NOT spring green, NOT navy, NOT pine).\n2. **Pick ONE subject.** Keep it to a SINGLE 32×32 tile/sprite — not a whole spritesheet or family (that's a bigger task).\n3. **Check the budget first**: `python3 modules/art-pipeline/pipeline/cost_ledger.py status` (the pipeline hard-stops at $80; one asset is ~$0.05–0.45).\n4. **Generate via the pipeline — do not hand-draw or freelance.** Use the `/otb-tile-generate` skill: it appends your new entry to `modules/art-pipeline/template/vocabulary.json`, runs the generate → post-process-to-32×32 → score-against-rubric → retry orchestrator, saves the final PNG to `modules/art-pipeline/generated/tiles/<id>.png`, and logs cost. (This needs the art pipeline's `GOOGLE_AI_STUDIO_KEY`, which resolves from `~/.config/otb/env`; most builder setups have it. If yours doesn't, claim the engineer or ideator newcomer chore instead, or ask an Archon to enable it.)\n5. **Confirm it PASSED** the rubric (`modules/art-pipeline/iterations/run_summary.json` shows `status: \"pass\"`). If it only reached best-effort, regenerate with the fix_hint — never paper over a fail.\n6. **See it in-game**: `node scripts/gds/art-stage.js` rebuilds the atlas and surfaces it on your sandbox preview (`sandbox-<login>.amazonprimea.com`).\n7. Ship via `/builder-ship`; the ship notes name your asset and its rubric score.\n\n## Stay clear of (READ-ONLY)\n\nNever modify the locked palette (`modules/art-pipeline/template/palette.json`) or the rubric (`modules/art-pipeline/template/rubric.json`) — those are theme-defining and changing them needs an ADR (out of scope for a first ship). Touch only art assets/vocabulary under `art/` (and, only if your asset is a genuinely new base-tile TYPE, the `public/game/world/tilePalette.js` mapping). Do not touch any permission/pipeline-core/migration/infra file.",
52
+ "description": "## What you're doing\n\nThis instance's world always needs more to look at. Your first ship as an artist is to **generate ONE new in-world asset of your own choosing** and add it to the world. You pick the subject — that's the fun part. Good candidates, all in-world (Mediterranean coast just south of Athens, mythic 'discovered secret' tone):\n- **Props**: amphora, fishing net, basket, crate, urn, torch, market awning, oar, clay pot.\n- **Plants**: olive tree, cypress, reeds, grapevine, shrub, wild herbs.\n- **Animals**: goat, chicken, duck, donkey, fish, gull, lizard.\n- **Accessories**: a sandal, a sun hat, a length of rope, a string of beads.\n\nIt is identical for every newcomer and **always valuable**, because a growing world has effectively unbounded demand for new props, flora, fauna, and detail — every newcomer picks a different subject, so two runs never collide.\n\n## How to find your way in\n\n1. Read `art/README.md` and `docs/recipes/pixel-art-pipeline.md`, plus `modules/art-pipeline/template/style_guide.md`, `modules/art-pipeline/template/rubric.json`, and `modules/art-pipeline/template/palette.json` — that's the look you must hit: 16-bit pixel art, early-Pokémon (FireRed) touchstone, top-down, 32×32, locked 64-colour Mediterranean palette (olive greens, warm tans, turquoise sea, weathered marble — NOT spring green, NOT navy, NOT pine).\n2. **Pick ONE subject.** Keep it to a SINGLE 32×32 tile/sprite — not a whole spritesheet or family (that's a bigger task).\n3. **Check the budget first**: `python3 modules/art-pipeline/pipeline/cost_ledger.py status` (the pipeline hard-stops at $80; one asset is ~$0.05–0.45).\n4. **Generate via the pipeline — do not hand-draw or freelance.** Use the `/otb-tile-generate` skill: it appends your new entry to `modules/art-pipeline/template/vocabulary.json`, runs the generate → post-process-to-32×32 → score-against-rubric → retry orchestrator, saves the final PNG to `modules/art-pipeline/generated/tiles/<id>.png`, and logs cost. (This needs the art pipeline's `GOOGLE_AI_STUDIO_KEY`, which resolves from `~/.config/otb/env`; most builder setups have it. If yours doesn't, claim the engineer or ideator newcomer chore instead, or ask an Archon to enable it.)\n5. **Confirm it PASSED** the rubric (`modules/art-pipeline/iterations/run_summary.json` shows `status: \"pass\"`). If it only reached best-effort, regenerate with the fix_hint — never paper over a fail.\n6. **See it in-game**: `node scripts/gds/art-stage.js` rebuilds the atlas and surfaces it on your sandbox preview (`sandbox-<login>.<instance-apex>`).\n7. Ship via `/builder-ship`; the ship notes name your asset and its rubric score.\n\n## Stay clear of (READ-ONLY)\n\nNever modify the locked palette (`modules/art-pipeline/template/palette.json`) or the rubric (`modules/art-pipeline/template/rubric.json`) — those are theme-defining and changing them needs an ADR (out of scope for a first ship). Touch only art assets/vocabulary under `art/` (and, only if your asset is a genuinely new base-tile TYPE, the `public/game/world/tilePalette.js` mapping). Do not touch any permission/pipeline-core/migration/infra file.",
53
53
  "done_when": [
54
54
  "Exactly one NEW in-world asset (your chosen subject) was generated via the pixel-art pipeline (`/otb-tile-generate` / orchestrator.py); the final PNG exists at modules/art-pipeline/generated/tiles/<id>.png.",
55
55
  "It PASSED the rubric (modules/art-pipeline/iterations/run_summary.json status 'pass', or /otb-design-review numbers reported at/above the bar) and every pixel is on the locked palette (modules/art-pipeline/template/palette.json).",
@@ -70,7 +70,7 @@
70
70
  "est_minutes": 45,
71
71
  "credits_reward": 12,
72
72
  "title": "Idea-seed pass: file 5-8 fresh in-world ideas (names, lore, or builder-UX) into the idea inbox",
73
- "description": "## What you'll do\n\nThe seed of almost everything built here is the **idea_inbox** — a queue anyone can drop ideas into, which trusted builders later triage into real tasks. Your first ship as an ideator is simple and concrete: **add a small batch of fresh ideas to that inbox.** You write no code, touch no game, change no system — you think, write a few short well-shaped ideas, and file them with one command each.\n\nIt is identical for every newcomer and **always valuable**: the inbox is never \"done\" — every triage pass consumes ideas (promote/discard/merge), so it is permanently hungry for fresh input, and a world the size of Amazonprimea always wants more names, more lore, more texture. Your batch is brand-new content, so it can never duplicate \"the work\" — the supply IS the work.\n\n## Pick ONE branch\n\n- **Branch A — PRODUCT flavour (default).** Ideas that grow the world players see: **names + one line of lore** for a character, structure, landmark, district, ship, or festival; or a world-background snippet (a custom, a superstition, a line a named NPC says). Tone: mythic, mysterious, a \"discovered secret\"; Mediterranean ancient-Greek coast (just south of Athens); NOT modern, NOT tongue-in-cheek-modern. Ground it in CLAUDE.md §3/§6/§7.\n- **Branch B — INTERNAL / builder-UX.** Small user-experience improvement ideas for the GDS / builders' hall (the surfaces builders use, not the game): a clearer label, a confusing onboarding message, a quality-of-life tweak to a status page or a slash-command's output. See `docs/TRACKS.md` for product vs internal.\n\n## How to find your way in\n\n1. (Branch A) Read CLAUDE.md §3/§6/§7 for tone. (Branch B) Skim `docs/TRACKS.md` and click around https://amazonprimea.com/builders for the rough edge you'll write up.\n2. See what's already filed so you don't repeat it: `node scripts/gds/api.js GET /api/gds/inbox`.\n3. File each idea with `node scripts/gds/capture.js \"<title>\" --body-file <path>` (filing an idea is the open-to-all path — a Xenos/Thetes session is allowed to do it; you just can't triage them). Put one line of detail + a `track: product` or `track: internal` line in each body file.",
73
+ "description": "## What you'll do\n\nThe seed of almost everything built here is the **idea_inbox** — a queue anyone can drop ideas into, which trusted builders later triage into real tasks. Your first ship as an ideator is simple and concrete: **add a small batch of fresh ideas to that inbox.** You write no code, touch no game, change no system — you think, write a few short well-shaped ideas, and file them with one command each.\n\nIt is identical for every newcomer and **always valuable**: the inbox is never \"done\" — every triage pass consumes ideas (promote/discard/merge), so it is permanently hungry for fresh input, and a growing world always wants more names, more lore, more texture. Your batch is brand-new content, so it can never duplicate \"the work\" — the supply IS the work.\n\n## Pick ONE branch\n\n- **Branch A — PRODUCT flavour (default).** Ideas that grow the world players see: **names + one line of lore** for a character, structure, landmark, district, ship, or festival; or a world-background snippet (a custom, a superstition, a line a named NPC says). Tone: mythic, mysterious, a \"discovered secret\"; Mediterranean ancient-Greek coast (just south of Athens); NOT modern, NOT tongue-in-cheek-modern. Ground it in CLAUDE.md §3/§6/§7.\n- **Branch B — INTERNAL / builder-UX.** Small user-experience improvement ideas for the GDS / builders' hall (the surfaces builders use, not the game): a clearer label, a confusing onboarding message, a quality-of-life tweak to a status page or a slash-command's output. See `docs/TRACKS.md` for product vs internal.\n\n## How to find your way in\n\n1. (Branch A) Read CLAUDE.md §3/§6/§7 for tone. (Branch B) Skim `docs/TRACKS.md` and click around your instance's /builders hall for the rough edge you'll write up.\n2. See what's already filed so you don't repeat it: `node scripts/gds/api.js GET /api/gds/inbox`.\n3. File each idea with `node scripts/gds/capture.js \"<title>\" --body-file <path>` (filing an idea is the open-to-all path — a Xenos/Thetes session is allowed to do it; you just can't triage them). Put one line of detail + a `track: product` or `track: internal` line in each body file.",
74
74
  "done_when": [
75
75
  "You filed 5–8 fresh ideas into the idea_inbox via `node scripts/gds/capture.js` (each prints a `Captured idea #N` confirmation), all in ONE branch (A product, or B internal/UX).",
76
76
  "Each idea has a clear title and a body with at least one sentence of detail — for Branch A, one line of in-world lore (who/what it is); for Branch B, what the rough edge is and what 'better' looks like.",
@@ -169,6 +169,10 @@ const CONFIG = {
169
169
  sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
170
170
  };
171
171
 
172
+ // task 1002757 — the provisioner's git identity resolves from the instance's own
173
+ // apex (env → bot@<apex> → neutral), never the hub's address hardcoded.
174
+ const { provisionerBotEmail } = require('./instance-apex');
175
+
172
176
  const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
173
177
 
174
178
  // Manifest-placement verification budget (task 1002720 / QA-walk F23): a child
@@ -709,7 +713,7 @@ async function scaffoldStandaloneRepo(inst, deps) {
709
713
  writeFile(giPath, INSTANCE_GITIGNORE);
710
714
  }
711
715
  exec(`git -C ${root} config user.name "Cloud Bongos"`);
712
- exec(`git -C ${root} config user.email "bot@cloudbongos.com"`);
716
+ exec(`git -C ${root} config user.email "${provisionerBotEmail()}"`);
713
717
  exec(`git -C ${root} add -A`);
714
718
  const commitMsg = mode === 'adopt'
715
719
  ? `chore: adopt Cloud Bongos onto ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0121)`
@@ -939,7 +943,7 @@ function buildCorePinRefreshCommit({ root, fromVersion, toVersion, repo, ownerTo
939
943
  return {
940
944
  config: [
941
945
  `git -C ${root} config user.name "Cloud Bongos"`,
942
- `git -C ${root} config user.email "bot@cloudbongos.com"`,
946
+ `git -C ${root} config user.email "${provisionerBotEmail()}"`,
943
947
  ],
944
948
  add: `git -C ${root} add -A`,
945
949
  commit: `git -C ${root} commit -m "${msg}"`,
@@ -2477,7 +2481,7 @@ async function main() {
2477
2481
  if (require.main === module) main();
2478
2482
 
2479
2483
  module.exports = {
2480
- CONFIG, MAX_INTENT_ATTEMPTS,
2484
+ CONFIG, MAX_INTENT_ATTEMPTS, provisionerBotEmail,
2481
2485
  // pure step generators (tested directly)
2482
2486
  dbName, dbCreateCmd, migrateCmd, seedFirstVersionCmd, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath,
2483
2487
  backupUnitName, backupServicePath, backupTimerPath, backupScriptPath, backupService, backupTimer,
@@ -0,0 +1,388 @@
1
+ // scripts/gds/ship-deploy-target.js — deploy-mode + deploy-target resolution, the gh helpers and push-path selection.
2
+ //
3
+ // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original ship.js lines 639-989 unchanged. ship.js remains the facade that
6
+ // re-exports every name and owns the CLI entry point, so the ship contract and
7
+ // every flag are untouched, and the other scripts that import from it are too.
8
+
9
+ 'use strict';
10
+
11
+ const { spawnSync } = require('node:child_process');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const { loadSessionSync } = require('./cli-lib');
15
+ const { argvOk, argvOut } = require('./ship-git.js');
16
+ const { REPO_ROOT } = require('./ship-state.js');
17
+
18
+ // ---------- CI deploy mode (#679, ADR 0042) ----------
19
+ //
20
+ // Two ways the confirmed→shipped land can run, chosen by a SINGLE committed
21
+ // switch (config/deploy.json `mode`, env OTB_DEPLOY_MODE overrides):
22
+ //
23
+ // 'laptop' (default) — the operator's machine merges to main locally and, IFF a
24
+ // deploy target is configured (config/deploy.json "sshTarget" or
25
+ // <PREFIX>_DEPLOY_SSH_TARGET, resolveDeployTarget()), SSHes `~/deploy.sh` on it.
26
+ // The core carries NO target of its own, so by default this is a merge-to-main
27
+ // with no SSH deploy — a change reaches a live instance via package-core +
28
+ // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). When a target IS
29
+ // set it requires that host's SSH key, so only the operator can land.
30
+ //
31
+ // 'ci' — NO builder box holds the prod key. ship pushes the branch, opens a
32
+ // PR, and enables GitHub auto-merge; once required checks pass, GitHub
33
+ // merges to main and .github/workflows/deploy-prod.yml deploys from
34
+ // Actions. ship then CONFIRMS the deploy landed (PR merged + the
35
+ // deploy-prod workflow run succeeded for the merge commit + public
36
+ // healthz 200) so the synchronous "deploy-confirmed" UX is preserved
37
+ // without the key. This is what lets a Windows/Chromebook builder with no
38
+ // main checkout and no droplet key land their own work.
39
+ //
40
+ // parseDeployMode is pure (env string + raw config file contents → mode) and
41
+ // exported so the test can assert the precedence + the safe default without
42
+ // touching fs/env. The default is 'laptop' precisely so this code is INERT
43
+ // until the deliberate, atomic cutover (config flip + GitHub arming together).
44
+ function parseDeployMode(envVal, configRaw) {
45
+ const fromEnv = String(envVal || '').trim().toLowerCase();
46
+ if (fromEnv === 'ci' || fromEnv === 'laptop') return fromEnv;
47
+ if (configRaw) {
48
+ try {
49
+ const m = String(JSON.parse(configRaw).mode || '').trim().toLowerCase();
50
+ if (m === 'ci' || m === 'laptop') return m;
51
+ } catch (_) {
52
+ /* malformed config → fall through to the safe default */
53
+ }
54
+ }
55
+ return 'laptop';
56
+ }
57
+
58
+ // effectiveDeployMode — credential- and protection-aware adjustment of the parsed
59
+ // mode (pure; exported for the regression test). The 'laptop' land path pushes
60
+ // main + SSHes the droplet FROM THIS MACHINE, so it requires a local GitHub push
61
+ // credential. A machine without one — a dev box, or a fresh/stale local clone that
62
+ // can't even `git pull` (token-less origin, ADR 0053) — physically cannot run it;
63
+ // it must ship via the 'ci' server-mediated publish path instead
64
+ // (pushVia()==='server', ADR 0055), which lets the GDS server push with its own
65
+ // credential. So when the parsed mode is 'laptop' but the machine can't push
66
+ // locally AND the mode was NOT pinned by an explicit OTB_DEPLOY_MODE, fall back to
67
+ // 'ci' rather than strand every ship at merge. This is the scootermack 2026-06-22
68
+ // case: a checkout left at the pre-cutover 'laptop' default that can't pull
69
+ // forward to 'ci', so it kept attempting a push it had no credential for. An
70
+ // explicit env override always wins (tests/staging may force 'laptop'). Mirrors
71
+ // pushVia()'s existing credential-awareness — the asymmetry (pushVia smart,
72
+ // resolveDeployMode blind) WAS the bug. (task 1449)
73
+ //
74
+ // Second input, same shape (task 1002629): a machine WITH push credentials but a
75
+ // branch-PROTECTED main also cannot run the laptop land — GitHub refuses the
76
+ // direct `git push origin main` no matter whose credential it is. When task
77
+ // 1002619 armed protection (required `unit` check), every ship resolved 'laptop'
78
+ // and pushed into the wall, stranding graded-PASS tasks at confirmed (tasks
79
+ // 1002608/1002609; learning 1000177). mainProtected must be STRICTLY true to
80
+ // trigger — null/undefined means the probe couldn't answer (no gh, offline, no
81
+ // permission) and keeps today's behavior rather than rerouting on a guess.
82
+ function effectiveDeployMode(mode, { canPushLocally, envForced, mainProtected } = {}) {
83
+ if (mode === 'laptop' && !envForced && !canPushLocally) return 'ci';
84
+ if (mode === 'laptop' && !envForced && mainProtected === true) return 'ci';
85
+ return mode;
86
+ }
87
+
88
+ // mainIsProtected — true/false when GitHub answers, null when it can't. The
89
+ // `gh api` resolves {owner}/{repo} from the checkout's origin; any failure
90
+ // (gh missing, offline, non-200) is null = unknown, which effectiveDeployMode
91
+ // treats as "no change". Same spawn discipline as ghAvailableAndAuthed().
92
+ function mainIsProtected() {
93
+ try {
94
+ const r = spawnSync('gh', ['api', 'repos/{owner}/{repo}/branches/main', '--jq', '.protected'], {
95
+ encoding: 'utf8',
96
+ shell: process.platform === 'win32',
97
+ windowsHide: true,
98
+ timeout: 15000,
99
+ });
100
+ if (!r || r.error || r.status !== 0) return null;
101
+ const out = String(r.stdout || '').trim();
102
+ if (out === 'true') return true;
103
+ if (out === 'false') return false;
104
+ return null;
105
+ } catch (_) {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function resolveDeployMode() {
111
+ const configRaw = readDeployConfigRaw();
112
+ const mode = parseDeployMode(process.env.OTB_DEPLOY_MODE, configRaw);
113
+ const envForced = ['ci', 'laptop'].includes(
114
+ String(process.env.OTB_DEPLOY_MODE || '').trim().toLowerCase()
115
+ );
116
+ // Probe `gh` ONLY when it can change the answer (resolved 'laptop', no env
117
+ // override) — the happy 'ci' path and an env-pinned mode spawn no extra gh,
118
+ // keeping the documented spawn discipline (the line-1019 note). The protection
119
+ // probe runs only when the credential check kept us on 'laptop' (a
120
+ // credential-less machine already falls back without it).
121
+ if (mode === 'laptop' && !envForced) {
122
+ const canPushLocally = ghAvailableAndAuthed();
123
+ const mainProtected = canPushLocally ? mainIsProtected() : null;
124
+ const effective = effectiveDeployMode(mode, { canPushLocally, envForced, mainProtected });
125
+ if (effective === 'ci' && mainProtected === true) {
126
+ console.log(' deploy mode: main is branch-protected (direct pushes refused) — landing via the ci PR path');
127
+ }
128
+ return effective;
129
+ }
130
+ return mode;
131
+ }
132
+
133
+ // readDeployConfigRaw — raw contents of config/deploy.json (or null if absent /
134
+ // unreadable). One reader shared by resolveDeployMode (the mode switch) and
135
+ // resolveDeployTarget (the SSH target) so they never diverge on where the file is.
136
+ function readDeployConfigRaw() {
137
+ try {
138
+ return fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
139
+ } catch (_) {
140
+ return null; // no config file → callers apply their safe default
141
+ }
142
+ }
143
+
144
+ // safeBranding — the instance brand pack, or null if it can't be read. Guarded the
145
+ // same way ship.js guards the sound player (line ~91): a deploy must never crash
146
+ // because the brand file is missing on a fresh / re-seeded checkout.
147
+ function safeBranding() {
148
+ try {
149
+ return require('../../src/branding').branding();
150
+ } catch (_) {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ // readSessionApiBase — the signed-in session's persisted api_base (the instance the
156
+ // CLI is actually pointed at), or null when not signed in / unreadable. This is the
157
+ // session's OWN value only — deliberately NOT readSessionToken().apiBase, which
158
+ // already folds in the env override + branding default; resolveInstanceBase orders
159
+ // that precedence itself so it stays pure over its injected inputs.
160
+ function readSessionApiBase() {
161
+ try {
162
+ const s = loadSessionSync();
163
+ return (s && typeof s.api_base === 'string' && s.api_base.trim()) ? s.api_base.trim() : null;
164
+ } catch (_) {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ // resolveInstanceBase — the ORIGIN of the instance this ship is landing on, used by
170
+ // the ci-land deployed-version probe (/version), the /healthz re-probe, the ci
171
+ // PR-body deploy note, and the manual-confirm curl guidance. Precedence mirrors the
172
+ // CLI's own base resolution: <PREFIX>_API_BASE env → the signed-in session's
173
+ // api_base → branding publicOrigin → null (unknowable). There is NO hardcoded
174
+ // founder host — 5 sites here baked in `https://amazonprimea.com`, so a ci-land /
175
+ // manual-confirm on a migrated instance pointed the builder at the WRONG box; the
176
+ // ci-land/guidance sibling of resolveDeployTarget()'s laptop-mode fix (task 1002232
177
+ // ⊃ task 1002202). Pure over its injected inputs (env / sessionApiBase / branding)
178
+ // so the precedence is unit-tested without fs/env/network; the defaults read the
179
+ // real session file + brand pack.
180
+ function resolveInstanceBase({
181
+ env = process.env,
182
+ sessionApiBase = readSessionApiBase(),
183
+ branding = safeBranding(),
184
+ } = {}) {
185
+ const ic = require('../../src/instance-config');
186
+ const trim = (s) => (typeof s === 'string' && s.trim() ? s.trim().replace(/\/+$/, '') : null);
187
+ const envBase = trim(ic.resolveEnv('API_BASE', { env }));
188
+ if (envBase) return envBase;
189
+ const sess = trim(sessionApiBase);
190
+ if (sess) return sess;
191
+ const origin = branding && branding.domains && branding.domains.publicOrigin;
192
+ return trim(origin);
193
+ }
194
+
195
+ // deployedVersionUrl — the instance's /version endpoint, or null when no instance
196
+ // origin is resolvable (then fetchDeployedVersion is a no-op). Derived from
197
+ // resolveInstanceBase — no hardcoded host (task 1002232).
198
+ function deployedVersionUrl() {
199
+ const base = resolveInstanceBase();
200
+ return base ? `${base}/version` : null;
201
+ }
202
+
203
+ // resolveDeployTarget — WHERE a laptop-mode ship SSHes `~/deploy.sh`, and which
204
+ // /healthz it re-probes when deploy.sh's own health check races the restart window.
205
+ // Pure (env + raw config + branding → target) and exported so the precedence and the
206
+ // safe "no target" default are unit-tested without touching fs/env/network.
207
+ //
208
+ // There is deliberately NO hardcoded default. The founder instance's old game-box
209
+ // SSH target was baked in here, so a laptop-mode ship from a migrated
210
+ // checkout SSHed the WRONG server (task 1002202). The core carries no deploy target
211
+ // of its own — under the standalone model a core change reaches a live instance via
212
+ // package-core + `bongos upgrade` (pin-bump), not an SSH deploy (ADR 0108). An
213
+ // instance whose OWN checkout is its live server opts back into laptop-SSH deploy by
214
+ // setting the target:
215
+ // config/deploy.json → { "sshTarget": "user@host", "healthzUrl": "https://…/healthz" }
216
+ // env → <PREFIX>_DEPLOY_SSH_TARGET, <PREFIX>_DEPLOY_HEALTHZ_URL
217
+ // healthzUrl, when unset, falls back to the instance's own publicOrigin + /healthz
218
+ // (never a hardcoded host); null when even that is unknown (re-probe then skipped).
219
+ function resolveDeployTarget({ env = process.env, configRaw = null, branding = null } = {}) {
220
+ const ic = require('../../src/instance-config');
221
+ let cfg = {};
222
+ if (configRaw) {
223
+ try { cfg = JSON.parse(configRaw) || {}; } catch (_) { cfg = {}; }
224
+ }
225
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
226
+
227
+ const sshTarget = ic.resolveEnv('DEPLOY_SSH_TARGET', { env }) || str(cfg.sshTarget) || null;
228
+
229
+ let healthzUrl = ic.resolveEnv('DEPLOY_HEALTHZ_URL', { env }) || str(cfg.healthzUrl) || null;
230
+ const publicOrigin = branding && branding.domains && branding.domains.publicOrigin;
231
+ if (!healthzUrl && str(publicOrigin)) {
232
+ try { healthzUrl = new URL('/healthz', publicOrigin).toString(); }
233
+ catch (_) { /* malformed origin → leave healthzUrl null */ }
234
+ }
235
+ return { sshTarget, healthzUrl };
236
+ }
237
+
238
+ // gh runners — mirror gitArgvOk/gitOk but for the GitHub CLI. No shell, ever:
239
+ // branch names + DB-sourced value_summary reach `gh` as literal argv elements
240
+ // (same injection discipline as #681). ghArgvOk = boolean side-effect; ghJson =
241
+ // parse `gh ... --json` stdout to an object/array (null on any failure).
242
+ function ghArgvOk(args, opts = {}) {
243
+ return argvOk('gh', args, opts);
244
+ }
245
+
246
+ function ghJson(args, opts = {}) {
247
+ const out = argvOut('gh', args, opts);
248
+ if (out == null) return null;
249
+ try { return JSON.parse(out); } catch (_) { return null; }
250
+ }
251
+
252
+ // Pure argv builders for the PR land path — exported + tested so the untrusted
253
+ // value_summary is proven to ride as ONE literal argv element (never a shell
254
+ // string), exactly like buildMergeArgv.
255
+ function buildPrCreateArgv(branch, title, body) {
256
+ return ['pr', 'create', '--head', branch, '--base', 'main', '--title', title, '--body', body];
257
+ }
258
+
259
+ // --merge keeps the project's --no-ff merge-commit convention (architecture.md
260
+ // branch-cleanup note); --auto arms GitHub to merge the instant required checks
261
+ // pass + branch protection is satisfied; --delete-branch mirrors the laptop
262
+ // path's origin-branch cleanup.
263
+ function buildPrMergeArgv(branch) {
264
+ return ['pr', 'merge', '--auto', '--merge', '--delete-branch', branch];
265
+ }
266
+
267
+ // Pure: did the prod-deploy workflow finish successfully for the merge commit?
268
+ // `runs` is the parsed `gh run list --json headSha,status,conclusion` array.
269
+ function deployRunSucceeded(runs, mergeSha) {
270
+ if (!mergeSha) return false;
271
+ return (runs || []).some(
272
+ (r) => r && r.headSha === mergeSha && r.status === 'completed' && r.conclusion === 'success'
273
+ );
274
+ }
275
+
276
+ // Pure: does the prod /version endpoint (#858) report it's running the commit we
277
+ // just merged? 'match' = commit-pinned confirmation; 'mismatch' = prod is on a
278
+ // different commit (e.g. a later deploy already superseded ours — still fine, our
279
+ // run succeeded); 'unknown' = /version absent or unparseable (older prod / blip)
280
+ // → caller falls back to the deploy-run + healthz signal. Compares on the shorter
281
+ // length so a short vs full sha still matches. Exported + tested.
282
+ function deployedCommitStatus(versionBody, mergeSha) {
283
+ if (!versionBody || !mergeSha) return 'unknown';
284
+ const c = String(versionBody.commit || '');
285
+ if (!/^[0-9a-f]{7,40}$/i.test(c)) return 'unknown';
286
+ const n = Math.min(c.length, String(mergeSha).length);
287
+ return c.slice(0, n).toLowerCase() === String(mergeSha).slice(0, n).toLowerCase()
288
+ ? 'match'
289
+ : 'mismatch';
290
+ }
291
+
292
+ // Fetch + parse the prod /version JSON (#858). null on any failure — never throws.
293
+ // Default target is the resolved instance origin (no hardcoded host — task 1002232);
294
+ // null when none is resolvable, in which case the probe is skipped.
295
+ function fetchDeployedVersion(url = deployedVersionUrl()) {
296
+ if (!url) return null; // no resolvable instance origin → nothing to probe
297
+ // stdio override DISCARDS stderr (a curl progress/error line must not pollute
298
+ // the JSON parse) — the documented per-caller difference from ghJson.
299
+ const out = argvOut('curl', ['-fsS', '--max-time', '10', url], { stdio: ['ignore', 'pipe', 'ignore'] });
300
+ if (out == null) return null;
301
+ try { return JSON.parse(out); } catch (_) { return null; }
302
+ }
303
+
304
+ // ciLand — the 'ci' deploy-mode analogue of the laptop merge+SSH block. Pushes
305
+ // the branch, ensures a PR exists, enables auto-merge, then confirms the deploy
306
+ // actually landed. Returns true ONLY when prod is confirmed live on the merge
307
+ // commit — so the caller flips the task to shipped exactly as the laptop path
308
+ // does. Any earlier exit leaves the task at confirmed with a clear next step.
309
+ // pushVia — which publish path ship uses in 'ci' mode (ADR 0055 / task 1025):
310
+ // 'local' — this machine pushes the branch + opens the PR with git + gh
311
+ // (laptop: gh present + authenticated). Today's behaviour.
312
+ // 'server' — this machine has NO GitHub push credential (a dev box: no gh, a
313
+ // credential-less origin). ship uploads a thin branch bundle to the
314
+ // GDS, which pushes + opens the PR + auto-merges with the SERVER's
315
+ // credential. The box holds nothing (#919 model preserved).
316
+ // Override with OTB_PUSH_VIA_SERVER=1 (force server) / =0 (force local) for tests.
317
+ function ghAvailableAndAuthed() {
318
+ try {
319
+ const a = spawnSync('gh', ['auth', 'status'], {
320
+ stdio: 'ignore',
321
+ shell: process.platform === 'win32',
322
+ windowsHide: true,
323
+ timeout: 15000,
324
+ });
325
+ return !!a && !a.error && a.status === 0;
326
+ } catch (_) {
327
+ return false;
328
+ }
329
+ }
330
+
331
+ function pushVia() {
332
+ const o = String(process.env.OTB_PUSH_VIA_SERVER || '').trim();
333
+ if (o === '1') return 'server';
334
+ if (o === '0') return 'local';
335
+ // No explicit override: a machine that can run an authenticated gh (laptop)
336
+ // takes the local path; everything else (a dev box) goes server-mediated.
337
+ return ghAvailableAndAuthed() ? 'local' : 'server';
338
+ }
339
+
340
+ // broadcastHandledByServer — task 1045: on the server-mediated publish path the
341
+ // SERVER posts the Discord ship-broadcast after it confirms the deploy (it holds
342
+ // the webhook secrets; the box does NOT — ADR 0053). So ship.js must NOT also post
343
+ // locally there, or the box would either double-post (impossible — no secret) or,
344
+ // as before, fail silently. True only when the publish actually goes server-side:
345
+ // ci deploy mode AND this machine has no GitHub push credential (pushVia 'server').
346
+ // The laptop path (pushVia 'local') keeps posting locally, exactly as today. Pure
347
+ // w.r.t. its inputs (env + committed deploy.json) + exported so the gate is tested.
348
+ function broadcastHandledByServer() {
349
+ return resolveDeployMode() === 'ci' && pushVia() === 'server';
350
+ }
351
+
352
+ // shouldBailOnFetchFailure — autoMerge's decision when `git fetch origin main`
353
+ // fails. A credential-less dev box (private origin, scrubbed clone token —
354
+ // ADR 0053) CANNOT fetch origin, and that is FINE for the server-mediated
355
+ // publish path (ADR 0055): ciLandServer bundles against the box's LOCAL
356
+ // origin/main and the SERVER tops up main with its own credential. So a fetch
357
+ // failure is fatal ONLY when we are NOT taking that path — otherwise the box can
358
+ // never reach ciLand and strands every ship at confirmed (the bug task 1027
359
+ // surfaced: ADR 0055 shipped the server path but left this fetch guard in front
360
+ // of it). Pure + exported for the regression test (no git, no network).
361
+ // NOTE: callers must still guard the pushVia() gh-probe behind `!fetchedMain` so
362
+ // the happy path (fetch ok) spawns no extra gh and laptop behavior is unchanged.
363
+ function shouldBailOnFetchFailure(fetchedMain, deployMode, via) {
364
+ if (fetchedMain) return false;
365
+ const serverPublish = deployMode === 'ci' && via === 'server';
366
+ return !serverPublish;
367
+ }
368
+
369
+ module.exports = {
370
+ broadcastHandledByServer,
371
+ buildPrCreateArgv,
372
+ buildPrMergeArgv,
373
+ deployRunSucceeded,
374
+ deployedCommitStatus,
375
+ effectiveDeployMode,
376
+ fetchDeployedVersion,
377
+ ghArgvOk,
378
+ ghJson,
379
+ mainIsProtected,
380
+ parseDeployMode,
381
+ pushVia,
382
+ readDeployConfigRaw,
383
+ resolveDeployMode,
384
+ resolveDeployTarget,
385
+ resolveInstanceBase,
386
+ safeBranding,
387
+ shouldBailOnFetchFailure,
388
+ };