@bongos/core 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +5727 -0
- package/.claude/skills/blocker-review/SKILL.md +91 -0
- package/.claude/skills/blocker-solve/SKILL.md +96 -0
- package/.claude/skills/builder-backup/SKILL.md +54 -0
- package/.claude/skills/builder-box/SKILL.md +45 -0
- package/.claude/skills/builder-claim/SKILL.md +76 -0
- package/.claude/skills/builder-connect/SKILL.md +59 -0
- package/.claude/skills/builder-cost/SKILL.md +47 -0
- package/.claude/skills/builder-end/SKILL.md +81 -0
- package/.claude/skills/builder-exit/SKILL.md +88 -0
- package/.claude/skills/builder-key/SKILL.md +40 -0
- package/.claude/skills/builder-reauth/SKILL.md +50 -0
- package/.claude/skills/builder-redteam/SKILL.md +109 -0
- package/.claude/skills/builder-release/SKILL.md +41 -0
- package/.claude/skills/builder-setup/SKILL.md +62 -0
- package/.claude/skills/builder-ship/SKILL.md +79 -0
- package/.claude/skills/builder-stage/SKILL.md +51 -0
- package/.claude/skills/builder-start/SKILL.md +46 -0
- package/.claude/skills/close-box/SKILL.md +43 -0
- package/.claude/skills/design-sync/SKILL.md +69 -0
- package/.claude/skills/dev/SKILL.md +49 -0
- package/.claude/skills/feedback/SKILL.md +32 -0
- package/.claude/skills/figma-design-sync/SKILL.md +74 -0
- package/.claude/skills/goal-review/SKILL.md +89 -0
- package/.claude/skills/idea-triage/SKILL.md +144 -0
- package/.claude/skills/ideate/SKILL.md +82 -0
- package/.claude/skills/merge-mode/SKILL.md +169 -0
- package/.claude/skills/new-project/SKILL.md +108 -0
- package/.claude/skills/otb-character-review/SKILL.md +71 -0
- package/.claude/skills/otb-design-review/SKILL.md +47 -0
- package/.claude/skills/otb-feedback-capture/SKILL.md +45 -0
- package/.claude/skills/otb-figma-sync/SKILL.md +54 -0
- package/.claude/skills/otb-tile-generate/SKILL.md +45 -0
- package/.claude/skills/paint/SKILL.md +70 -0
- package/.claude/skills/planning-session/SKILL.md +299 -0
- package/.claude/skills/priority-session/SKILL.md +134 -0
- package/.claude/skills/read-session-export/SKILL.md +58 -0
- package/.claude/skills/recall/SKILL.md +52 -0
- package/.claude/skills/session-handoff/SKILL.md +46 -0
- package/.claude/skills/status/SKILL.md +33 -0
- package/.devcontainer/Dockerfile +69 -0
- package/.devcontainer/README.md +73 -0
- package/.devcontainer/devcontainer-lock.json +19 -0
- package/.devcontainer/devcontainer.json +93 -0
- package/.devcontainer/healthcheck.sh +84 -0
- package/.devcontainer/init-firewall.sh +198 -0
- package/.devcontainer/post-create.sh +153 -0
- package/.dockerignore +36 -0
- package/.env.local.example +32 -0
- package/.gitattributes +83 -0
- package/.gitignore +91 -0
- package/CLAUDE.md +242 -0
- package/CODE_OF_CONDUCT.md +46 -0
- package/CONTRIBUTING.md +51 -0
- package/Dockerfile +46 -0
- package/GOVERNANCE.md +44 -0
- package/LICENSE +661 -0
- package/README.md +88 -0
- package/bin/bongos.js +222 -0
- package/clients/bongos-client/README.md +48 -0
- package/clients/bongos-client/bongos-client.global.js +617 -0
- package/clients/bongos-client/examples/hello-world.mjs +62 -0
- package/clients/bongos-client/index.cjs +619 -0
- package/clients/bongos-client/index.d.ts +793 -0
- package/clients/bongos-client/index.mjs +614 -0
- package/clients/bongos-client/package.json +32 -0
- package/config/branding.neutral.json +60 -0
- package/config/design-tokens.neutral.json +51 -0
- package/config/design-tokens.schema.json +96 -0
- package/config/modules.neutral.json +9 -0
- package/docker-compose.yml +52 -0
- package/docker-entrypoint.sh +30 -0
- package/docs/adr/0001-stack-choice-single-vps.md +45 -0
- package/docs/adr/0002-digitalocean-over-hetzner.md +27 -0
- package/docs/adr/0003-cloudflare-origin-cert-over-letsencrypt.md +35 -0
- package/docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md +45 -0
- package/docs/adr/0005-invisible-other-players-in-v1.md +37 -0
- package/docs/adr/0006-singleton-worldroom-plus-queueroom.md +30 -0
- package/docs/adr/0007-persistent-identity-via-localstorage-uuid.md +31 -0
- package/docs/adr/0008-family-based-tile-generation.md +86 -0
- package/docs/adr/0008-google-chat-oauth-user-auth.md +37 -0
- package/docs/adr/0009-autotile-architecture.md +181 -0
- package/docs/adr/0010-pms-architecture.md +70 -0
- package/docs/adr/0011-terrain-id-rendering-model.md +174 -0
- package/docs/adr/0012-ground-object-cell-split.md +208 -0
- package/docs/adr/0013-paths-as-object-overlays-with-autotile-dispatch.md +158 -0
- package/docs/adr/0014-families-canonical-over-vocabulary.md +103 -0
- package/docs/adr/0015-task-dependencies-and-auto-promotion.md +125 -0
- package/docs/adr/0016-trust-boundary-server-enforced-permissions.md +132 -0
- package/docs/adr/0017-code-simplification-and-repo-cleanliness.md +74 -0
- package/docs/adr/0018-three-rank-model-goes-live.md +97 -0
- package/docs/adr/0019-red-team-bounty-payout-table.md +52 -0
- package/docs/adr/0020-security-model-public-stub.md +70 -0
- package/docs/adr/0021-per-builder-skill-model-preferences.md +99 -0
- package/docs/adr/0022-secrets-policy.md +124 -0
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +118 -0
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +277 -0
- package/docs/adr/0024-multi-agent-system-architecture.md +125 -0
- package/docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md +59 -0
- package/docs/adr/0025-structured-criterion-task-link.md +44 -0
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +117 -0
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +170 -0
- package/docs/adr/0028-honest-llm-spend-accounting.md +109 -0
- package/docs/adr/0029-status-page-outage-resilience.md +59 -0
- package/docs/adr/0030-builders-hall-live-updates.md +59 -0
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +222 -0
- package/docs/adr/0032-discord-mirror-of-gds.md +50 -0
- package/docs/adr/0033-discord-bot-service-principal.md +82 -0
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +53 -0
- package/docs/adr/0035-builder-onboarding-three-paths.md +128 -0
- package/docs/adr/0036-builders-subdomain-and-ui-reorg.md +40 -0
- package/docs/adr/0037-discord-channel-management.md +51 -0
- package/docs/adr/0038-chromebook-ttyd-cloudflare-tunnel.md +111 -0
- package/docs/adr/0039-setup-first-onboarding-ux.md +49 -0
- package/docs/adr/0040-remote-control-default-browser-onramp.md +47 -0
- package/docs/adr/0041-temporary-grader-bypass-killswitch.md +40 -0
- package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +82 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +74 -0
- package/docs/adr/0044-mediterranean-palette-replacement.md +73 -0
- package/docs/adr/0044-per-box-live-game-preview.md +57 -0
- package/docs/adr/0045-devbox-desktop-app.md +133 -0
- package/docs/adr/0046-sandbox-first-review-gate.md +72 -0
- package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +36 -0
- package/docs/adr/0048-works-in-progress-ia-split.md +32 -0
- package/docs/adr/0049-split-parallel-safety-contract.md +46 -0
- package/docs/adr/0050-device-flow-admission-invite-gated-by-default.md +43 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +57 -0
- package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +70 -0
- package/docs/adr/0053-scoped-dev-box-session.md +55 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +63 -0
- package/docs/adr/0055-server-mediated-branch-publish.md +47 -0
- package/docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md +62 -0
- package/docs/adr/0057-container-cost-ledger.md +28 -0
- package/docs/adr/0057-discord-archon-approval-channels.md +64 -0
- package/docs/adr/0058-automatic-gate-surface-gatekeeper.md +64 -0
- package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +37 -0
- package/docs/adr/0060-gds-retrieval-layer.md +97 -0
- package/docs/adr/0061-context-layer-decomposition.md +61 -0
- package/docs/adr/0062-medusa-instance-model-decoupling.md +107 -0
- package/docs/adr/0063-generated-repo-map-symbol-skeleton.md +44 -0
- package/docs/adr/0064-rename-medusa-to-cloud-bongos.md +49 -0
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +69 -0
- package/docs/adr/0066-generated-file-map-sections.md +51 -0
- package/docs/adr/0067-session-start-fast-forward-pull.md +43 -0
- package/docs/adr/0068-shared-art-key-for-newcomers.md +38 -0
- package/docs/adr/0069-hall-live-channel-sse-decouple.md +35 -0
- package/docs/adr/0070-deterministic-session-card-delivery.md +44 -0
- package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +59 -0
- package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +35 -0
- package/docs/adr/0072-dev-box-code-staleness-visibility.md +39 -0
- package/docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md +64 -0
- package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +37 -0
- package/docs/adr/0074-memory-map-stays-light.md +32 -0
- package/docs/adr/0075-per-system-cost-attribution.md +37 -0
- package/docs/adr/0076-in-session-deterministic-compaction.md +58 -0
- package/docs/adr/0077-content-addressed-llm-cache.md +32 -0
- package/docs/adr/0078-deterministic-first-cron.md +48 -0
- package/docs/adr/0079-value-gated-autonomous-engagement.md +39 -0
- package/docs/adr/0080-llm-cache-api-transport-and-trust-model.md +46 -0
- package/docs/adr/0081-tool-agnostic-design-layer.md +62 -0
- package/docs/adr/0082-productized-key-storage-provisioning.md +32 -0
- package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +55 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +143 -0
- package/docs/adr/0084-auto-derived-per-task-rank-floor.md +86 -0
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +39 -0
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +123 -0
- package/docs/adr/0087-bongos-app-architecture-and-handoff-contract.md +49 -0
- package/docs/adr/0087-compete-on-governance-not-tooling-cursor.md +65 -0
- package/docs/adr/0088-consistent-character-generative-animation.md +169 -0
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +179 -0
- package/docs/adr/0090-metic-task-authoring.md +73 -0
- package/docs/adr/0091-bounding-the-kernel-and-db-carve.md +106 -0
- package/docs/adr/0092-hosting-module-and-on-prem-target.md +117 -0
- package/docs/adr/0093-tranche-2-core-carve-sequence.md +72 -0
- package/docs/adr/0094-repo-atlas-self-drawing-codebase-map.md +49 -0
- package/docs/adr/0095-borrowed-memory-and-retrieval-concepts.md +65 -0
- package/docs/adr/0095-cross-agent-context-management.md +58 -0
- package/docs/adr/0096-require-reward-before-workable.md +209 -0
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +46 -0
- package/docs/adr/0097-retroactive-reward-backfill.md +124 -0
- package/docs/adr/0098-public-mirror-publish-manifest.md +92 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +55 -0
- package/docs/adr/0100-two-repo-instance-model.md +60 -0
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +90 -0
- package/docs/adr/0102-finish-bounding-the-kernel.md +36 -0
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +70 -0
- package/docs/adr/0103-gdsv4-bongos-consolidation.md +28 -0
- package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +42 -0
- package/docs/adr/0105-instance-seed-migrations-out-of-core.md +47 -0
- package/docs/adr/0106-goal-owner-manager-authority.md +84 -0
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +83 -0
- package/docs/adr/0108-instance-composition-configurable-root.md +87 -0
- package/docs/adr/0109-self-describing-openapi-and-hosted-docs.md +44 -0
- package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +49 -0
- package/docs/adr/0111-instance-hosting-provisioning-module.md +135 -0
- package/docs/adr/0112-private-goals-invitations-join-requests.md +72 -0
- package/docs/adr/0113-sandbox-studio-descope-superseded-by-0044-0052.md +41 -0
- package/docs/adr/0114-instance-docs-discovery-contract.md +32 -0
- package/docs/adr/0115-scheduled-routines-portability.md +31 -0
- package/docs/adr/0116-api-error-envelope-and-code-taxonomy.md +62 -0
- package/docs/adr/0117-api-versioning-and-deprecation-policy.md +50 -0
- package/docs/adr/0118-api-contract-source-of-truth-code-first.md +47 -0
- package/docs/adr/0119-api-pagination-contract.md +34 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +108 -0
- package/docs/adr/0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md +97 -0
- package/docs/adr/0122-onboarding-diagram-renders-deploy-rendered-off-git.md +43 -0
- package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +36 -0
- package/docs/adr/0124-typed-api-response-bodies.md +37 -0
- package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +36 -0
- package/docs/adr/0126-dedicated-cloudbongos-control-plane-droplet.md +30 -0
- package/docs/adr/0127-cross-box-fleet-observability.md +64 -0
- package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +46 -0
- package/docs/adr/0129-cloudbongos-control-plane-pull-deploy.md +34 -0
- package/docs/adr/0130-provision-remote-exec-over-ssh.md +61 -0
- package/docs/adr/0131-rank-scoped-skill-visibility.md +47 -0
- package/docs/adr/0132-cohost-fleet-control-plane-migration.md +54 -0
- package/docs/adr/0133-github-app-manifest-onboarding-signin.md +42 -0
- package/docs/adr/0134-private-first-npm-distribution.md +38 -0
- package/docs/adr/README.md +208 -0
- package/docs/api/openapi.json +13720 -0
- package/docs/api-reference.md +437 -0
- package/docs/architecture.md +244 -0
- package/docs/branding-contract.md +66 -0
- package/docs/branding-fill-prompt.md +25 -0
- package/docs/canonical-permissions.md +241 -0
- package/docs/design/container-exterior-tiles.md +49 -0
- package/docs/design/container-exterior.md +81 -0
- package/docs/design/container-interior.md +77 -0
- package/docs/design/modular-architecture/00-research-report.md +107 -0
- package/docs/design/modular-architecture/01-architecture.md +125 -0
- package/docs/design/modular-architecture/02-module-map.md +116 -0
- package/docs/design/modular-architecture/03-builder-flows.md +104 -0
- package/docs/design/modular-architecture/04-module-lifecycle.md +62 -0
- package/docs/design/modular-architecture/README.md +27 -0
- package/docs/design/vanilla-hall-ui-redesign-scope.md +284 -0
- package/docs/design/world-map-scroll.md +41 -0
- package/docs/design-contract.md +320 -0
- package/docs/file-map.md +418 -0
- package/docs/handoff-template.md +92 -0
- package/docs/modules-contract.md +293 -0
- package/docs/onboarding/browser-terminal-guide.md +73 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +28 -0
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +54 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +37 -0
- package/docs/onboarding/diagrams/04-architecture.mmd +51 -0
- package/docs/onboarding/diagrams/05-versioning.mmd +29 -0
- package/docs/onboarding/diagrams/README.md +94 -0
- package/docs/onboarding/diagrams/assertions.json +170 -0
- package/docs/onboarding/diagrams/puppeteer-config.json +1 -0
- package/docs/onboarding/diagrams/render-config.json +9 -0
- package/docs/onboarding/drachmae-and-karma.md +178 -0
- package/docs/onboarding/primer.md +202 -0
- package/docs/project-context.template.md +67 -0
- package/docs/recipes/README.md +35 -0
- package/docs/recipes/audit-replay.md +100 -0
- package/docs/recipes/bongos-cli-release.md +66 -0
- package/docs/recipes/builder-box-lifecycle.md +152 -0
- package/docs/recipes/claude-design-loop.md +143 -0
- package/docs/recipes/claude-gemini-bongos-doors.md +155 -0
- package/docs/recipes/cloudbongos-staging-gate.md +139 -0
- package/docs/recipes/cloudbongos-standup.md +111 -0
- package/docs/recipes/devbox-app-release.md +93 -0
- package/docs/recipes/devbox-app-windows-test.md +128 -0
- package/docs/recipes/failed-ship-recovery-proof.md +102 -0
- package/docs/recipes/figma-design-loop.md +129 -0
- package/docs/recipes/gds-db-backup.md +255 -0
- package/docs/recipes/instance-deploy.md +37 -0
- package/docs/recipes/managed-settings-remote-control.md +416 -0
- package/docs/recipes/memory-backup.md +71 -0
- package/docs/recipes/memory-sync-conflicts.md +98 -0
- package/docs/recipes/multi-builder-merge.md +78 -0
- package/docs/recipes/ops-gotchas.md +212 -0
- package/docs/recipes/overnight-builder.md +53 -0
- package/docs/recipes/packaging-the-core.md +49 -0
- package/docs/recipes/parallel-load-results-2026-05-30.md +63 -0
- package/docs/recipes/parallel-load-test.md +32 -0
- package/docs/recipes/pixel-art-pipeline.md +411 -0
- package/docs/recipes/private-npm-distribution.md +79 -0
- package/docs/recipes/search-index-ingestion.md +67 -0
- package/docs/recipes/self-host.md +60 -0
- package/docs/recipes/ship-and-regrade.md +72 -0
- package/docs/recipes/staging-data.md +50 -0
- package/docs/recipes/staging-environment.md +215 -0
- package/docs/recipes/upgrading-the-core.md +58 -0
- package/docs/research/cursor-competitive-analysis.md +255 -0
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +227 -0
- package/migrations/003_pms.sql +284 -0
- package/migrations/004_pms_cost_idempotency.sql +13 -0
- package/migrations/005_pms_smoke_filter.sql +44 -0
- package/migrations/006_pms_v2.sql +378 -0
- package/migrations/007_pms_v2_hierarchy_trigger.sql +59 -0
- package/migrations/008_pms_v2_migration_allocator.sql +27 -0
- package/migrations/009_pms_v2_blockers_table.sql +68 -0
- package/migrations/010_uptime.sql +29 -0
- package/migrations/011_backfill_v2_v3_done_when.sql +20 -0
- package/migrations/012_pms_v2_task_lifecycle_states.sql +82 -0
- package/migrations/013_claim_locking.sql +22 -0
- package/migrations/014_pms_task_discipline.sql +86 -0
- package/migrations/015_pms_estimation_calibration.sql +140 -0
- package/migrations/016_pms_task_grades.sql +60 -0
- package/migrations/017_version_progress_exclude_abandoned.sql +49 -0
- package/migrations/019_rename_pms_v3_to_gds_v3.sql +65 -0
- package/migrations/020_task_dependencies.sql +99 -0
- package/migrations/021_v3_renumber_and_deps.sql +224 -0
- package/migrations/022_v3_reconcile_dep_gated_status.sql +41 -0
- package/migrations/023_pms_rank.sql +30 -0
- package/migrations/025_builder_exit_status.sql +15 -0
- package/migrations/027_builders_preferred_disciplines.sql +46 -0
- package/migrations/028_tasks_parallel_safe.sql +71 -0
- package/migrations/029_rank_three_live.sql +32 -0
- package/migrations/030_vulnerability_reports.sql +61 -0
- package/migrations/031_reward_simplification.sql +57 -0
- package/migrations/032_repo_health_snapshots.sql +56 -0
- package/migrations/033_audit_log.sql +64 -0
- package/migrations/034_lifecycle_breakdown_leaf_only.sql +62 -0
- package/migrations/035_auth_token_hygiene.sql +66 -0
- package/migrations/037_builder_skill_model_prefs.sql +44 -0
- package/migrations/038_task_override_requests.sql +51 -0
- package/migrations/039_newcomer_friendly_column.sql +124 -0
- package/migrations/040_builder_karma.sql +58 -0
- package/migrations/041_kind_multipliers_and_idea_bonus.sql +42 -0
- package/migrations/042_criterion_proposal_workflow.sql +78 -0
- package/migrations/043_builder_onboarding_observability.sql +66 -0
- package/migrations/044_tasks_requires_rank.sql +97 -0
- package/migrations/045_builder_onboarding_state.sql +147 -0
- package/migrations/046_cost_log_builder_attribution.sql +42 -0
- package/migrations/054_builder_memory.sql +108 -0
- package/migrations/055_task_criteria.sql +90 -0
- package/migrations/058_peer_votes.sql +41 -0
- package/migrations/059_onboarding_pick_disciplines.sql +59 -0
- package/migrations/060_access_requests.sql +46 -0
- package/migrations/061_backfill_override_ship_attribution.sql +29 -0
- package/migrations/062_session_records.sql +34 -0
- package/migrations/063_session_records_tool_errors_idx.sql +11 -0
- package/migrations/064_grade_attempts.sql +18 -0
- package/migrations/065_bfg_principal.sql +58 -0
- package/migrations/066_live_notify_triggers.sql +69 -0
- package/migrations/067_builder_boxes.sql +117 -0
- package/migrations/067_discord_bot_foundation.sql +69 -0
- package/migrations/068_thetes_requires_rank.sql +27 -0
- package/migrations/069_builder_ssh_keys.sql +66 -0
- package/migrations/070_box_intents.sql +101 -0
- package/migrations/071_box_first_provision_approval.sql +46 -0
- package/migrations/072_onboarding_ship_three_remove_primer.sql +80 -0
- package/migrations/073_box_terminal_access.sql +46 -0
- package/migrations/075_builder_sound_prefs.sql +28 -0
- package/migrations/078_box_claude_active.sql +22 -0
- package/migrations/079_box_first_connected_at.sql +20 -0
- package/migrations/081_tasks_needs_migration.sql +34 -0
- package/migrations/084_tasks_security_sensitive.sql +24 -0
- package/migrations/085_security_engagement_docs.sql +34 -0
- package/migrations/086_rename_disciplines.sql +125 -0
- package/migrations/088_builders_anthropic_link.sql +28 -0
- package/migrations/089_builders_monthly_budget.sql +19 -0
- package/migrations/090_box_scoped_sessions.sql +38 -0
- package/migrations/091_session_token_reward.sql +44 -0
- package/migrations/093_tasks_sandbox_review.sql +22 -0
- package/migrations/102_remove_box_first_provision_approval.sql +58 -0
- package/migrations/103_search_chunks.sql +90 -0
- package/migrations/104_builder_box_blocked.sql +34 -0
- package/migrations/105_box_host_keys.sql +35 -0
- package/migrations/111_medusa_customer0_framing.sql +35 -0
- package/migrations/112_drop_track_check.sql +25 -0
- package/migrations/113_merge_lease.sql +16 -0
- package/migrations/114_cloudbongos_rename_live_strings.sql +32 -0
- package/migrations/118_currency_label_decoupling.sql +23 -0
- package/migrations/119_builder_behavior_prefs.sql +31 -0
- package/migrations/121_box_code_version.sql +37 -0
- package/migrations/122_builder_gemini_key.sql +30 -0
- package/migrations/123_cost_log_tiers.sql +33 -0
- package/migrations/124_cost_baseline.sql +45 -0
- package/migrations/125_llm_cache.sql +54 -0
- package/migrations/126_autonomy_runs.sql +33 -0
- package/migrations/143_cost_calibration.sql +63 -0
- package/migrations/145_backfill_task_requires_rank.sql +84 -0
- package/migrations/160_goal_tier.sql +88 -0
- package/migrations/163_polymorphic_dependencies.sql +187 -0
- package/migrations/164_event_sound_prefs.sql +20 -0
- package/migrations/165_task_module_key.sql +15 -0
- package/migrations/171_claims_published_head_sha.sql +24 -0
- package/migrations/174_close_orphaned_claims_on_terminal_tasks.sql +30 -0
- package/migrations/175_task_grades_diff_hash.sql +9 -0
- package/migrations/176_promotion_respects_open_blockers.sql +58 -0
- package/migrations/176_ui_discipline.sql +40 -0
- package/migrations/177_require_reward_before_workable.sql +76 -0
- package/migrations/178_reward_gate_auto_assign_revert_autopromote.sql +61 -0
- package/migrations/179_search_chunk_usefulness.sql +33 -0
- package/migrations/180_one_active_claim_per_session.sql +54 -0
- package/migrations/181_cost_log_skill_name.sql +33 -0
- package/migrations/184_claim_release_requested.sql +25 -0
- package/migrations/185_module_upstreaming_goal_wiring.sql +59 -0
- package/migrations/187_goal_sort_order.sql +30 -0
- package/migrations/187_tasks_kind_check_add_verify.sql +21 -0
- package/migrations/README.md +88 -0
- package/migrations/core_188_goal_visibility_and_membership_requests.sql +71 -0
- package/migrations/core_189_core_upgrades_ledger.sql +37 -0
- package/migrations/core_189_credit_log_task_id_index.sql +19 -0
- package/migrations/core_190_rebuild_claimable_task_views.sql +71 -0
- package/migrations/instance/018_pms_v3_planning_seed.sql +131 -0
- package/migrations/instance/080_gds_v4_planning_seed.sql +75 -0
- package/migrations/instance/110_medusa_planning_seed.sql +70 -0
- package/migrations/instance/120_bongos_v1_planning_seed.sql +69 -0
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +26 -0
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +29 -0
- package/migrations/instance/172_v2_world_planning_seed.sql +163 -0
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +33 -0
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +132 -0
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +116 -0
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +69 -0
- package/modules/autonomy/CLAUDE.md +49 -0
- package/modules/autonomy/db.js +50 -0
- package/modules/autonomy/module.json +13 -0
- package/modules/autonomy/pollers/routine-timers.js +146 -0
- package/modules/autonomy/routes/autonomy.js +37 -0
- package/modules/builder-settings/CLAUDE.md +58 -0
- package/modules/builder-settings/builder-needs.js +115 -0
- package/modules/builder-settings/db.js +168 -0
- package/modules/builder-settings/display-name.js +84 -0
- package/modules/builder-settings/event-sound-prefs.js +169 -0
- package/modules/builder-settings/interaction-prefs.js +201 -0
- package/modules/builder-settings/model-alloc-prefs.js +147 -0
- package/modules/builder-settings/module.json +10 -0
- package/modules/builder-settings/render-prefs.js +103 -0
- package/modules/builder-settings/routes/settings.js +417 -0
- package/modules/builder-settings/settings.js +52 -0
- package/modules/builder-settings/skill-prefs.js +196 -0
- package/modules/builder-settings/sound-prefs.js +178 -0
- package/modules/builder-settings/wandering-prefs.js +282 -0
- package/modules/dev-box/CLAUDE.md +38 -0
- package/modules/dev-box/box-access.js +275 -0
- package/modules/dev-box/box-credential.js +94 -0
- package/modules/dev-box/box-onboard.js +486 -0
- package/modules/dev-box/boxes.js +842 -0
- package/modules/dev-box/db.js +39 -0
- package/modules/dev-box/module.json +14 -0
- package/modules/dev-box/routes/box.js +977 -0
- package/modules/discord/CLAUDE.md +50 -0
- package/modules/discord/auth.js +54 -0
- package/modules/discord/bug-attachments.js +210 -0
- package/modules/discord/db.js +96 -0
- package/modules/discord/discord-approvals.js +145 -0
- package/modules/discord/discord-audit.js +29 -0
- package/modules/discord/discord-bot.js +545 -0
- package/modules/discord/discord-bugs.js +257 -0
- package/modules/discord/discord-channels.js +412 -0
- package/modules/discord/discord-inbound.js +147 -0
- package/modules/discord/discord-message-buffer.js +54 -0
- package/modules/discord/discord-reaction.js +186 -0
- package/modules/discord/discord-role-sync.js +318 -0
- package/modules/discord/module.json +14 -0
- package/modules/discord/pollers/discord-bot.js +19 -0
- package/modules/discord/routes/bug-attachments.js +44 -0
- package/modules/discord/routes/discord.js +102 -0
- package/modules/discord/ship-broadcast.js +78 -0
- package/modules/discord/webhook.js +74 -0
- package/modules/economy/CLAUDE.md +48 -0
- package/modules/economy/achievements.js +594 -0
- package/modules/economy/cost-classify.js +331 -0
- package/modules/economy/cost-summary-shape.js +90 -0
- package/modules/economy/cost.js +438 -0
- package/modules/economy/credits.js +430 -0
- package/modules/economy/migrations/economy_001_fix_gds_shipper_description.sql +91 -0
- package/modules/economy/module.json +10 -0
- package/modules/economy/reward.js +61 -0
- package/modules/economy/routes/cost.js +97 -0
- package/modules/economy/routes/leaderboard.js +26 -0
- package/modules/economy/routes/reward.js +42 -0
- package/modules/economy/streak.js +285 -0
- package/modules/grading/CLAUDE.md +42 -0
- package/modules/grading/db.js +39 -0
- package/modules/grading/grader-rubric.json +151 -0
- package/modules/grading/grader-workers/efficiency.js +213 -0
- package/modules/grading/grader-workers/extract-json.js +80 -0
- package/modules/grading/grader-workers/fence.js +100 -0
- package/modules/grading/grader-workers/hacker.js +239 -0
- package/modules/grading/grader-workers/index.js +183 -0
- package/modules/grading/grader-workers/narc.js +207 -0
- package/modules/grading/grader-workers/quality.js +198 -0
- package/modules/grading/grader-workers/rubric.js +20 -0
- package/modules/grading/grader-workers/worker-prompt.js +89 -0
- package/modules/grading/grader-workers/worker-verdict.js +50 -0
- package/modules/grading/grader.js +1790 -0
- package/modules/grading/module.json +12 -0
- package/modules/grading/routes/grade.js +51 -0
- package/modules/hall-ui/CLAUDE.md +61 -0
- package/modules/hall-ui/module.json +14 -0
- package/modules/hall-ui/public/atlas.html +180 -0
- package/modules/hall-ui/public/atlas.js +336 -0
- package/modules/hall-ui/public/box-panel.js +423 -0
- package/modules/hall-ui/public/brand-apply.js +117 -0
- package/modules/hall-ui/public/builders.js +3028 -0
- package/modules/hall-ui/public/chime.wav +0 -0
- package/modules/hall-ui/public/copy-on-click.js +174 -0
- package/modules/hall-ui/public/cursors/arrow.png +0 -0
- package/modules/hall-ui/public/cursors/hand-pointer.png +0 -0
- package/modules/hall-ui/public/cursors/magnifier-out.png +0 -0
- package/modules/hall-ui/public/cursors/magnifier.png +0 -0
- package/modules/hall-ui/public/diagram-viewer.css +125 -0
- package/modules/hall-ui/public/diagram-viewer.js +330 -0
- package/modules/hall-ui/public/diagrams.html +113 -0
- package/modules/hall-ui/public/dom-utils.js +212 -0
- package/modules/hall-ui/public/drachmae.html +68 -0
- package/modules/hall-ui/public/drachmae.js +136 -0
- package/modules/hall-ui/public/gate.html +143 -0
- package/modules/hall-ui/public/gate.js +228 -0
- package/modules/hall-ui/public/goals-page.js +1297 -0
- package/modules/hall-ui/public/goals.html +376 -0
- package/modules/hall-ui/public/goals.js +119 -0
- package/modules/hall-ui/public/hall-widgets.js +189 -0
- package/modules/hall-ui/public/harbor.html +167 -0
- package/modules/hall-ui/public/harbor.js +335 -0
- package/modules/hall-ui/public/index.html +116 -0
- package/modules/hall-ui/public/instances-panel.js +172 -0
- package/modules/hall-ui/public/md-reader.js +234 -0
- package/modules/hall-ui/public/modules.html +119 -0
- package/modules/hall-ui/public/modules.js +154 -0
- package/modules/hall-ui/public/new-project-wizard.js +186 -0
- package/modules/hall-ui/public/not-ready.html +48 -0
- package/modules/hall-ui/public/pair.html +119 -0
- package/modules/hall-ui/public/pair.js +132 -0
- package/modules/hall-ui/public/primer.css +404 -0
- package/modules/hall-ui/public/primer.html +85 -0
- package/modules/hall-ui/public/primer.js +641 -0
- package/modules/hall-ui/public/profile.html +85 -0
- package/modules/hall-ui/public/profile.js +236 -0
- package/modules/hall-ui/public/ranks.html +69 -0
- package/modules/hall-ui/public/ranks.js +206 -0
- package/modules/hall-ui/public/roadmap.html +209 -0
- package/modules/hall-ui/public/roadmap.js +375 -0
- package/modules/hall-ui/public/sessions.html +92 -0
- package/modules/hall-ui/public/sessions.js +222 -0
- package/modules/hall-ui/public/settings.html +640 -0
- package/modules/hall-ui/public/settings.js +1219 -0
- package/modules/hall-ui/public/shell.js +495 -0
- package/modules/hall-ui/public/style.css +1748 -0
- package/modules/hall-ui/public/task-detail.js +253 -0
- package/modules/hall-ui/public/task.html +83 -0
- package/modules/hall-ui/public/task.js +326 -0
- package/modules/hall-ui/public/watch.html +333 -0
- package/modules/hall-ui/public/watch.js +859 -0
- package/modules/hall-ui/public/work.html +469 -0
- package/modules/hall-ui/public/work.js +1292 -0
- package/modules/ideas/CLAUDE.md +37 -0
- package/modules/ideas/blockers.js +100 -0
- package/modules/ideas/inbox.js +459 -0
- package/modules/ideas/module.json +10 -0
- package/modules/ideas/routes/blockers.js +120 -0
- package/modules/ideas/routes/inbox.js +230 -0
- package/modules/lifecycle/CLAUDE.md +63 -0
- package/modules/lifecycle/conflict-resolve.js +436 -0
- package/modules/lifecycle/db.js +4970 -0
- package/modules/lifecycle/done-when.js +355 -0
- package/modules/lifecycle/github-push.js +969 -0
- package/modules/lifecycle/goal-conflicts.js +133 -0
- package/modules/lifecycle/hierarchy.js +104 -0
- package/modules/lifecycle/lifecycle.js +58 -0
- package/modules/lifecycle/merge-lock.js +261 -0
- package/modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql +28 -0
- package/modules/lifecycle/migrations/lifecycle_002_backfill_orphan_task_goals.sql +36 -0
- package/modules/lifecycle/module.json +13 -0
- package/modules/lifecycle/pollers/publish-reconciler.js +20 -0
- package/modules/lifecycle/publish-reconciler.js +418 -0
- package/modules/lifecycle/routes/analytics.js +63 -0
- package/modules/lifecycle/routes/claims.js +506 -0
- package/modules/lifecycle/routes/dependencies.js +82 -0
- package/modules/lifecycle/routes/done-when.js +136 -0
- package/modules/lifecycle/routes/gate-approvals.js +197 -0
- package/modules/lifecycle/routes/goals.js +1419 -0
- package/modules/lifecycle/routes/lifecycle.js +43 -0
- package/modules/lifecycle/routes/tasks.js +1703 -0
- package/modules/lifecycle/routes/versions.js +128 -0
- package/modules/lifecycle/ship-card.js +505 -0
- package/modules/lifecycle/ship-preflight.js +225 -0
- package/modules/lifecycle/task-classifier.js +512 -0
- package/modules/lifecycle/version-close.js +106 -0
- package/modules/memory/CLAUDE.md +40 -0
- package/modules/memory/learnings.js +275 -0
- package/modules/memory/memory.js +572 -0
- package/modules/memory/module.json +12 -0
- package/modules/memory/routes/learnings.js +90 -0
- package/modules/memory/routes/memory.js +916 -0
- package/modules/memory/routes/search.js +188 -0
- package/modules/memory/search-chunker.js +252 -0
- package/modules/memory/search.js +320 -0
- package/modules/onboarding/CLAUDE.md +55 -0
- package/modules/onboarding/approval-broadcast.js +59 -0
- package/modules/onboarding/db.js +54 -0
- package/modules/onboarding/module.json +10 -0
- package/modules/onboarding/newcomer-restock.js +212 -0
- package/modules/onboarding/onboarding-state.js +532 -0
- package/modules/onboarding/onboarding.js +508 -0
- package/modules/onboarding/port.js +63 -0
- package/modules/onboarding/routes/access-requests.js +208 -0
- package/modules/onboarding/routes/onboarding.js +54 -0
- package/modules/security/CLAUDE.md +44 -0
- package/modules/security/module.json +10 -0
- package/modules/security/routes/reports.js +251 -0
- package/modules/security/secret-scrub.js +201 -0
- package/modules/security/security.js +294 -0
- package/modules/sessions/CLAUDE.md +39 -0
- package/modules/sessions/bfg.js +357 -0
- package/modules/sessions/db.js +326 -0
- package/modules/sessions/module.json +10 -0
- package/modules/sessions/pulse.js +149 -0
- package/modules/sessions/routes/sessions.js +331 -0
- package/modules/status-ui/CLAUDE.md +47 -0
- package/modules/status-ui/module.json +14 -0
- package/modules/status-ui/public/README.md +29 -0
- package/modules/status-ui/public/cursors/arrow.png +0 -0
- package/modules/status-ui/public/cursors/hand-pointer.png +0 -0
- package/modules/status-ui/public/index.html +363 -0
- package/modules/status-ui/public/snapshots/.gitkeep +0 -0
- package/modules/status-ui/public/status.js +1375 -0
- package/modules/status-ui/public/style.css +1342 -0
- package/package-lock.json +975 -0
- package/package.json +28 -0
- package/public-docs/index.html +35 -0
- package/public-docs/redoc.standalone.js +1838 -0
- package/public-docs/redoc.standalone.js.LICENSE.txt +102 -0
- package/public-landing/buddha.png +0 -0
- package/public-landing/create.html +868 -0
- package/public-landing/index.html +168 -0
- package/scripts/bongos-mcp.js +28 -0
- package/scripts/cron/style_guide_audit.sh +134 -0
- package/scripts/discord/README.md +52 -0
- package/scripts/discord/lib.js +80 -0
- package/scripts/discord/post.js +51 -0
- package/scripts/gds/CLAUDE.md +144 -0
- package/scripts/gds/abandon.js +58 -0
- package/scripts/gds/adopt-detect.js +123 -0
- package/scripts/gds/adopt-import.js +111 -0
- package/scripts/gds/adopt-preflight.js +121 -0
- package/scripts/gds/api.js +78 -0
- package/scripts/gds/architect-audit.js +288 -0
- package/scripts/gds/art-stage.js +152 -0
- package/scripts/gds/audit-authorship.sh +243 -0
- package/scripts/gds/audit-deps.js +121 -0
- package/scripts/gds/audit-exports.js +128 -0
- package/scripts/gds/audit-replay.sh +161 -0
- package/scripts/gds/audit-ships.js +251 -0
- package/scripts/gds/autonomy-gate.js +135 -0
- package/scripts/gds/backfill-cost.js +165 -0
- package/scripts/gds/backfill-sessions.js +351 -0
- package/scripts/gds/backfill-task-rewards.js +408 -0
- package/scripts/gds/backup.js +94 -0
- package/scripts/gds/bfg-principal-token.js +65 -0
- package/scripts/gds/box-auth-check.js +160 -0
- package/scripts/gds/box-connect-lib.js +453 -0
- package/scripts/gds/box.js +1258 -0
- package/scripts/gds/build-bongos.js +94 -0
- package/scripts/gds/capture.js +123 -0
- package/scripts/gds/cf-tunnel.js +419 -0
- package/scripts/gds/ci-grade.js +82 -0
- package/scripts/gds/claim-recall.js +112 -0
- package/scripts/gds/claim.js +333 -0
- package/scripts/gds/classify-discipline.js +153 -0
- package/scripts/gds/claude-materialize.js +176 -0
- package/scripts/gds/cleanup-orphans.js +74 -0
- package/scripts/gds/cli-lib.js +518 -0
- package/scripts/gds/code.js +101 -0
- package/scripts/gds/codemod-rename-src-gds.js +145 -0
- package/scripts/gds/conductor-dispatch.js +426 -0
- package/scripts/gds/context-pack.js +228 -0
- package/scripts/gds/coreB-carve-proof.sh +716 -0
- package/scripts/gds/cost.js +47 -0
- package/scripts/gds/db-backup-nightly.sh +160 -0
- package/scripts/gds/db-backup-offsite.sh +152 -0
- package/scripts/gds/demote.js +40 -0
- package/scripts/gds/design-sync.js +62 -0
- package/scripts/gds/diagnose-achievements.js +118 -0
- package/scripts/gds/diagram-facts.js +319 -0
- package/scripts/gds/discipline-modes.json +25 -0
- package/scripts/gds/discord-channels.js +66 -0
- package/scripts/gds/do-api.js +176 -0
- package/scripts/gds/docs-entropy.js +403 -0
- package/scripts/gds/doctor.js +389 -0
- package/scripts/gds/feedback-latest.js +94 -0
- package/scripts/gds/fetch-art-key.js +106 -0
- package/scripts/gds/fetch-sound-prefs.js +88 -0
- package/scripts/gds/figma-design-sync.js +61 -0
- package/scripts/gds/fitness.js +1446 -0
- package/scripts/gds/gate-review.js +322 -0
- package/scripts/gds/gating.js +325 -0
- package/scripts/gds/gds-db-backup.service +24 -0
- package/scripts/gds/gds-db-backup.timer +20 -0
- package/scripts/gds/gds-db-offsite.service +29 -0
- package/scripts/gds/gds-db-offsite.timer +18 -0
- package/scripts/gds/gen-api-client.js +508 -0
- package/scripts/gds/gen-api-docs.js +1037 -0
- package/scripts/gds/gen-atlas.js +366 -0
- package/scripts/gds/gen-diagrams.js +608 -0
- package/scripts/gds/gen-file-map.js +196 -0
- package/scripts/gds/gen-repo-map.js +519 -0
- package/scripts/gds/gen-session-index.js +210 -0
- package/scripts/gds/git-merge-regen.js +147 -0
- package/scripts/gds/grade-smoke.js +205 -0
- package/scripts/gds/hooks/pre-push.js +169 -0
- package/scripts/gds/http-api-client.js +88 -0
- package/scripts/gds/init.js +903 -0
- package/scripts/gds/install-git-hooks.js +114 -0
- package/scripts/gds/key-card.js +213 -0
- package/scripts/gds/key-status.js +126 -0
- package/scripts/gds/land-watch.js +168 -0
- package/scripts/gds/learning-capture.js +145 -0
- package/scripts/gds/lib.sh +44 -0
- package/scripts/gds/linkify-refs.js +473 -0
- package/scripts/gds/local-preview-lib.js +159 -0
- package/scripts/gds/local-preview.js +95 -0
- package/scripts/gds/login.js +225 -0
- package/scripts/gds/main-audit.js +279 -0
- package/scripts/gds/me.js +88 -0
- package/scripts/gds/memory-map.js +174 -0
- package/scripts/gds/merge-lock.js +71 -0
- package/scripts/gds/migration-namespace.js +200 -0
- package/scripts/gds/mirror-lineage-check.js +319 -0
- package/scripts/gds/mirror-redact.js +305 -0
- package/scripts/gds/module.js +482 -0
- package/scripts/gds/newcomer-floor.config.json +13 -0
- package/scripts/gds/newcomer-floor.js +415 -0
- package/scripts/gds/newcomer-floor.templates.json +86 -0
- package/scripts/gds/newcomer-floor.test.js +235 -0
- package/scripts/gds/next-number.js +163 -0
- package/scripts/gds/oauth-secret.js +197 -0
- package/scripts/gds/onboard.js +217 -0
- package/scripts/gds/onboarding-analyze.js +226 -0
- package/scripts/gds/onboarding-config.js +178 -0
- package/scripts/gds/onboarding-ship.js +92 -0
- package/scripts/gds/package-core.js +684 -0
- package/scripts/gds/paste-token.js +131 -0
- package/scripts/gds/portability-smoke-art.sh +85 -0
- package/scripts/gds/portability-smoke-memory.sh +147 -0
- package/scripts/gds/provision.js +1324 -0
- package/scripts/gds/publish-manifest.js +307 -0
- package/scripts/gds/pulse.js +309 -0
- package/scripts/gds/push-main.js +45 -0
- package/scripts/gds/read-session-export.js +359 -0
- package/scripts/gds/reauth.js +149 -0
- package/scripts/gds/recall.js +135 -0
- package/scripts/gds/release.js +71 -0
- package/scripts/gds/render-ideas.js +173 -0
- package/scripts/gds/repo-metrics.js +363 -0
- package/scripts/gds/resolve-skill-model.js +57 -0
- package/scripts/gds/run-routine.js +197 -0
- package/scripts/gds/run-unit-tests.js +178 -0
- package/scripts/gds/sandbox-stage.js +653 -0
- package/scripts/gds/search-ingest-db.js +209 -0
- package/scripts/gds/search-ingest-md.js +225 -0
- package/scripts/gds/security-baseline-check.sh +180 -0
- package/scripts/gds/seed-adopt-tasks.js +75 -0
- package/scripts/gds/seed-api-contract-goal-tasks.js +141 -0
- package/scripts/gds/seed-bongos-app-tasks.js +294 -0
- package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +81 -0
- package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +100 -0
- package/scripts/gds/seed-bongos-goal-hierarchy-tasks.js +307 -0
- package/scripts/gds/seed-bongos-module-system-tasks.js +346 -0
- package/scripts/gds/seed-bongos-tasks.js +214 -0
- package/scripts/gds/seed-gds-v4-tasks.js +624 -0
- package/scripts/gds/seed-governance-tasks.js +340 -0
- package/scripts/gds/seed-medusa-tasks.js +440 -0
- package/scripts/gds/seed-module-upstreaming.js +219 -0
- package/scripts/gds/seed-productization-tasks.js +235 -0
- package/scripts/gds/seed-provisioning-tasks.js +83 -0
- package/scripts/gds/seed-staging.sh +104 -0
- package/scripts/gds/seed-v2-world-tasks.js +283 -0
- package/scripts/gds/seed-v3-tasks.js +1981 -0
- package/scripts/gds/session-cost.js +393 -0
- package/scripts/gds/session-digest.js +179 -0
- package/scripts/gds/session-eval.js +576 -0
- package/scripts/gds/session.js +404 -0
- package/scripts/gds/setup.js +504 -0
- package/scripts/gds/shell.js +100 -0
- package/scripts/gds/ship.js +3618 -0
- package/scripts/gds/smoke-batch-claim.sh +213 -0
- package/scripts/gds/smoke-batch-release.sh +296 -0
- package/scripts/gds/smoke-box.sh +150 -0
- package/scripts/gds/smoke-claim-consistency.sh +212 -0
- package/scripts/gds/smoke-dependencies.sh +84 -0
- package/scripts/gds/smoke-gds.js +125 -0
- package/scripts/gds/smoke-write.sh +168 -0
- package/scripts/gds/start.js +782 -0
- package/scripts/gds/status.js +365 -0
- package/scripts/gds/triage.js +366 -0
- package/scripts/gds/upgrade.js +421 -0
- package/scripts/gds/validate-design.js +254 -0
- package/scripts/gds/version-close.js +549 -0
- package/scripts/migrate.sh +183 -0
- package/scripts/migrate_cost_ledger.sh +178 -0
- package/scripts/public-mirror-export.js +291 -0
- package/scripts/render-diagrams.sh +88 -0
- package/scripts/security/dep-audit.js +276 -0
- package/scripts/setup-builder.js +274 -0
- package/scripts/setup-builder.sh +28 -0
- package/scripts/snapshots/backfill-history.mjs +292 -0
- package/scripts/snapshots/maybe-render-on-deploy.sh +66 -0
- package/scripts/snapshots/render-pre-art-eras.mjs +329 -0
- package/scripts/snapshots/render-world.mjs +322 -0
- package/scripts/status-mirror-sync.js +192 -0
- package/scripts/status-probe.mjs +185 -0
- package/server.js +212 -0
- package/src/asset-version.js +71 -0
- package/src/bongos/CLAUDE.md +64 -0
- package/src/bongos/api-errors.js +281 -0
- package/src/bongos/api-path-404.js +67 -0
- package/src/bongos/api-prefix.js +38 -0
- package/src/bongos/app-pair.js +172 -0
- package/src/bongos/auth.js +979 -0
- package/src/bongos/cascade-dispatch.js +123 -0
- package/src/bongos/db-kernel.js +252 -0
- package/src/bongos/db.js +911 -0
- package/src/bongos/goal-scope-check.js +120 -0
- package/src/bongos/hierarchy-config.js +189 -0
- package/src/bongos/live-channel.js +145 -0
- package/src/bongos/llm-cache.js +260 -0
- package/src/bongos/llm-pricing.js +220 -0
- package/src/bongos/metrics.js +286 -0
- package/src/bongos/middleware/audit.js +274 -0
- package/src/bongos/middleware/error-envelope.js +93 -0
- package/src/bongos/middleware/rate-limit.js +195 -0
- package/src/bongos/module-scope-map.js +271 -0
- package/src/bongos/optimizer.js +166 -0
- package/src/bongos/path-match.js +47 -0
- package/src/bongos/permission-path-check.js +248 -0
- package/src/bongos/pool.js +55 -0
- package/src/bongos/repo-info.js +91 -0
- package/src/bongos/route-rank-check.js +420 -0
- package/src/bongos/routes/CLAUDE.md +43 -0
- package/src/bongos/routes/_helpers.js +369 -0
- package/src/bongos/routes/audit-log.js +59 -0
- package/src/bongos/routes/auth.js +755 -0
- package/src/bongos/routes/backup.js +104 -0
- package/src/bongos/routes/builders.js +526 -0
- package/src/bongos/routes/healthz.js +16 -0
- package/src/bongos/routes/instance.js +129 -0
- package/src/bongos/routes/live.js +125 -0
- package/src/bongos/routes/llm-cache.js +119 -0
- package/src/bongos/routes/me.js +380 -0
- package/src/bongos/routes/modules.js +131 -0
- package/src/bongos/routes/optimizer.js +54 -0
- package/src/bongos/routes/public.js +476 -0
- package/src/bongos/routes/security.js +207 -0
- package/src/bongos/routes.js +166 -0
- package/src/bongos/secret-box.js +177 -0
- package/src/bongos/sensitive-surfaces.js +30 -0
- package/src/bongos/serve-internal.js +889 -0
- package/src/bongos/uptime-poller.js +90 -0
- package/src/bongos-downloads.js +70 -0
- package/src/branding.js +182 -0
- package/src/build-info.js +77 -0
- package/src/devbox-downloads.js +67 -0
- package/src/instance-config.js +200 -0
- package/src/module-api.js +366 -0
- package/src/module-loader/loader.js +268 -0
- package/src/module-loader/manifest-schema.js +140 -0
- package/src/module-loader/provenance.js +28 -0
- package/src/module-loader/semver.js +100 -0
- package/src/module-seams.js +291 -0
- package/src/modules.js +254 -0
- package/src/platform-server.js +135 -0
- package/src/ui/adapters/claude-design/index.js +333 -0
- package/src/ui/adapters/figma/index.js +328 -0
- package/tests/abandon_task.mjs +138 -0
- package/tests/achievements_reconcile.mjs +167 -0
- package/tests/adopt_detect.mjs +141 -0
- package/tests/adopt_import.mjs +132 -0
- package/tests/adopt_preflight.mjs +105 -0
- package/tests/anti_grid.mjs +89 -0
- package/tests/api_client.mjs +231 -0
- package/tests/api_docs.mjs +283 -0
- package/tests/api_error_envelope_buildcheck.mjs +56 -0
- package/tests/api_error_envelope_middleware.mjs +99 -0
- package/tests/api_error_registry.mjs +108 -0
- package/tests/api_pagination_contract.mjs +51 -0
- package/tests/api_path_404.mjs +96 -0
- package/tests/app_pair.mjs +168 -0
- package/tests/architect.mjs +140 -0
- package/tests/art_key.mjs +121 -0
- package/tests/art_stage.mjs +121 -0
- package/tests/asset_version.mjs +94 -0
- package/tests/atlas.mjs +69 -0
- package/tests/audit_deps.mjs +74 -0
- package/tests/audit_middleware.mjs +385 -0
- package/tests/auth_admission_gate.mjs +164 -0
- package/tests/auth_anthropic_identity.mjs +95 -0
- package/tests/auth_duplicate_cookie.mjs +56 -0
- package/tests/auth_error_brand.mjs +77 -0
- package/tests/auth_flow_slice1.mjs +223 -0
- package/tests/auth_inactive_builder.mjs +94 -0
- package/tests/auth_page_gate.mjs +92 -0
- package/tests/auth_session_ttl.mjs +155 -0
- package/tests/autonomous_fit_protected_paths.mjs +115 -0
- package/tests/autonomy_gate.mjs +85 -0
- package/tests/autotile_variants.mjs +256 -0
- package/tests/autotile_xverify.mjs +133 -0
- package/tests/backfill_sessions.mjs +101 -0
- package/tests/backfill_task_rewards.mjs +152 -0
- package/tests/backup_cli.mjs +104 -0
- package/tests/bfg.mjs +327 -0
- package/tests/bfg_write.mjs +423 -0
- package/tests/bongos_box_connect.mjs +89 -0
- package/tests/bongos_dispatch.mjs +74 -0
- package/tests/bongos_downloads.mjs +118 -0
- package/tests/bongos_login.mjs +34 -0
- package/tests/box_access.mjs +186 -0
- package/tests/box_auth_check.mjs +114 -0
- package/tests/box_code_staleness.mjs +116 -0
- package/tests/box_connect_e2e.mjs +196 -0
- package/tests/box_cost.mjs +70 -0
- package/tests/box_dns_repoint.mjs +151 -0
- package/tests/box_host_keys.mjs +102 -0
- package/tests/box_onboard.mjs +317 -0
- package/tests/box_scope_session.mjs +115 -0
- package/tests/box_ship_permissions.mjs +71 -0
- package/tests/box_terminal_ssrf.mjs +90 -0
- package/tests/boxes.mjs +1255 -0
- package/tests/branding.mjs +88 -0
- package/tests/bug_attachments.mjs +189 -0
- package/tests/build_info.mjs +88 -0
- package/tests/builder_needs.mjs +99 -0
- package/tests/cap_newcomer_tasks.mjs +114 -0
- package/tests/cascade-dispatch.mjs +95 -0
- package/tests/cf_tunnel.mjs +459 -0
- package/tests/claim_recall.mjs +150 -0
- package/tests/claims_bind_session.mjs +47 -0
- package/tests/claude_materialize.mjs +148 -0
- package/tests/cli_box_scope.mjs +52 -0
- package/tests/cli_lib_formatters.mjs +87 -0
- package/tests/cli_token_reissue.mjs +147 -0
- package/tests/client_bundle_route.mjs +47 -0
- package/tests/cloud_bongos_pack.mjs +85 -0
- package/tests/conductor.mjs +496 -0
- package/tests/confirm_attribution.mjs +156 -0
- package/tests/conflict_resolve.mjs +462 -0
- package/tests/context_pack.mjs +240 -0
- package/tests/cost.mjs +94 -0
- package/tests/cost_audit.mjs +122 -0
- package/tests/cost_budget_status.mjs +78 -0
- package/tests/cost_reconcile.mjs +102 -0
- package/tests/cost_summary_shape.mjs +94 -0
- package/tests/credit_grant.mjs +376 -0
- package/tests/criterion_progress.mjs +220 -0
- package/tests/criterion_proposal.mjs +109 -0
- package/tests/criterion_ref_parse.mjs +53 -0
- package/tests/currency_label.mjs +119 -0
- package/tests/day_cycle.mjs +197 -0
- package/tests/dependencies_migration.mjs +77 -0
- package/tests/dependencies_polymorphic.mjs +212 -0
- package/tests/deploy_instance.mjs +78 -0
- package/tests/devbox_downloads.mjs +122 -0
- package/tests/diff_derived_signals.mjs +115 -0
- package/tests/discord_approvals.mjs +174 -0
- package/tests/discord_autojoin.mjs +158 -0
- package/tests/discord_bot.mjs +94 -0
- package/tests/discord_bugs.mjs +297 -0
- package/tests/discord_channels.mjs +337 -0
- package/tests/discord_foundation.mjs +76 -0
- package/tests/discord_inbound.mjs +190 -0
- package/tests/discord_message_buffer.mjs +87 -0
- package/tests/discord_oauth.mjs +55 -0
- package/tests/discord_reaction.mjs +170 -0
- package/tests/discord_role_sync.mjs +151 -0
- package/tests/display_name.mjs +123 -0
- package/tests/dns_preflight.mjs +74 -0
- package/tests/event_sound_prefs.mjs +194 -0
- package/tests/eviction_policy.mjs +279 -0
- package/tests/figma_adapter.mjs +199 -0
- package/tests/file_map.mjs +91 -0
- package/tests/first_admin_bootstrap.mjs +40 -0
- package/tests/fitness.mjs +435 -0
- package/tests/fitness_two_root.mjs +104 -0
- package/tests/fixtures/session-506-digest.json +34 -0
- package/tests/gate_approvals.mjs +451 -0
- package/tests/gate_review.mjs +152 -0
- package/tests/git_merge_regen.mjs +151 -0
- package/tests/github_push_ancestry.mjs +119 -0
- package/tests/github_push_land_proof.mjs +127 -0
- package/tests/github_push_merge_driver.mjs +175 -0
- package/tests/goal_abuse_matrix.mjs +187 -0
- package/tests/goal_achievement.mjs +146 -0
- package/tests/goal_conflicts.mjs +305 -0
- package/tests/goal_dependencies_read.mjs +73 -0
- package/tests/goal_member_roles.mjs +420 -0
- package/tests/goal_membership_requests.mjs +332 -0
- package/tests/goal_routes.mjs +203 -0
- package/tests/goal_scope_check.mjs +167 -0
- package/tests/goal_tier.mjs +312 -0
- package/tests/goal_wall_proof.mjs +112 -0
- package/tests/grade_attempts.mjs +73 -0
- package/tests/grade_attribution.mjs +117 -0
- package/tests/grade_diff_hash.mjs +191 -0
- package/tests/grade_server_authoritative.mjs +305 -0
- package/tests/grade_unavailable_display.mjs +66 -0
- package/tests/grader.mjs +1960 -0
- package/tests/grader_bypass.mjs +57 -0
- package/tests/grader_panel.mjs +252 -0
- package/tests/grader_prompt_fencing.mjs +192 -0
- package/tests/helpers.mjs +52 -0
- package/tests/hierarchy-config.mjs +81 -0
- package/tests/init.mjs +463 -0
- package/tests/instance_config.mjs +76 -0
- package/tests/instance_manifest.mjs +110 -0
- package/tests/interaction_prefs.mjs +72 -0
- package/tests/key-card.mjs +110 -0
- package/tests/key-status.mjs +45 -0
- package/tests/land_watch.mjs +115 -0
- package/tests/lifecycle-card.mjs +145 -0
- package/tests/lifecycle_breakdown_view.mjs +236 -0
- package/tests/linkify_refs.mjs +264 -0
- package/tests/live_channel.mjs +128 -0
- package/tests/live_sse.mjs +122 -0
- package/tests/llm-cache-route.mjs +110 -0
- package/tests/llm-cache.mjs +140 -0
- package/tests/llm-pricing.mjs +152 -0
- package/tests/local_preview.mjs +136 -0
- package/tests/main_audit.mjs +137 -0
- package/tests/memory_isolation.mjs +431 -0
- package/tests/memory_map.mjs +107 -0
- package/tests/memory_pull.mjs +429 -0
- package/tests/merge_lease.mjs +100 -0
- package/tests/merge_lock.mjs +312 -0
- package/tests/metrics.mjs +147 -0
- package/tests/migration_allocation.mjs +146 -0
- package/tests/migration_namespace.mjs +185 -0
- package/tests/migrations_fresh_replay_guard.mjs +38 -0
- package/tests/mirror_lineage_fulltree.mjs +45 -0
- package/tests/mirror_lineage_proof.mjs +72 -0
- package/tests/mirror_redact.mjs +193 -0
- package/tests/model_alloc_prefs.mjs +63 -0
- package/tests/module-scope-map.mjs +88 -0
- package/tests/module_api.mjs +110 -0
- package/tests/module_cli.mjs +292 -0
- package/tests/module_contributions.mjs +148 -0
- package/tests/module_loader.mjs +286 -0
- package/tests/module_manifest.mjs +194 -0
- package/tests/module_owned_tests.mjs +105 -0
- package/tests/module_provenance.mjs +34 -0
- package/tests/module_route_rank.mjs +147 -0
- package/tests/module_seams.mjs +68 -0
- package/tests/modules.mjs +156 -0
- package/tests/modules_route.mjs +38 -0
- package/tests/newcomer_floor.mjs +17 -0
- package/tests/newcomer_restock.mjs +80 -0
- package/tests/next_number.mjs +154 -0
- package/tests/oauth_secret.mjs +60 -0
- package/tests/onboard.mjs +70 -0
- package/tests/onboarding_gate.mjs +267 -0
- package/tests/onboarding_observability.mjs +344 -0
- package/tests/onboarding_route_signals.mjs +175 -0
- package/tests/onboarding_state.mjs +346 -0
- package/tests/one_active_claim_per_session.mjs +111 -0
- package/tests/orphaned_claim_ship.mjs +135 -0
- package/tests/override_attribution.mjs +142 -0
- package/tests/override_request_ownership.mjs +235 -0
- package/tests/package_core.mjs +435 -0
- package/tests/parallel-load.mjs +402 -0
- package/tests/path_match.mjs +62 -0
- package/tests/permission_path.mjs +151 -0
- package/tests/platform_boot.mjs +167 -0
- package/tests/pre_compact.mjs +60 -0
- package/tests/preview_server.mjs +127 -0
- package/tests/profile_route.mjs +197 -0
- package/tests/provision.mjs +805 -0
- package/tests/provisioning_github_app_routes.mjs +98 -0
- package/tests/public_mirror_export.mjs +124 -0
- package/tests/public_task_resolver.mjs +112 -0
- package/tests/publish_branch_route.mjs +422 -0
- package/tests/publish_manifest.mjs +184 -0
- package/tests/publish_reconciler.mjs +728 -0
- package/tests/rank_gate.mjs +463 -0
- package/tests/rate_limit_public.mjs +217 -0
- package/tests/read_session_export.mjs +89 -0
- package/tests/recall_cli.mjs +82 -0
- package/tests/recall_hints.mjs +119 -0
- package/tests/recent_shipped_window.mjs +80 -0
- package/tests/regrade_eligibility.mjs +202 -0
- package/tests/render_prefs.mjs +122 -0
- package/tests/repo_map.mjs +92 -0
- package/tests/reward_gate.mjs +284 -0
- package/tests/route_rank_check.mjs +243 -0
- package/tests/route_validation.mjs +490 -0
- package/tests/routine_timers.mjs +72 -0
- package/tests/rubric_fixtures.mjs +140 -0
- package/tests/run_routine.mjs +69 -0
- package/tests/sandbox_stage.mjs +582 -0
- package/tests/scrubber_corpus.mjs +257 -0
- package/tests/search_chunker.mjs +209 -0
- package/tests/search_ingest.mjs +96 -0
- package/tests/search_ingest_db.mjs +88 -0
- package/tests/search_isolation.mjs +204 -0
- package/tests/search_route.mjs +95 -0
- package/tests/secret_box.mjs +189 -0
- package/tests/security_docs.mjs +186 -0
- package/tests/security_route.mjs +108 -0
- package/tests/served_paths_shipped.mjs +118 -0
- package/tests/session-cards.mjs +142 -0
- package/tests/session_506_regression.mjs +69 -0
- package/tests/session_caps.mjs +238 -0
- package/tests/session_cost.mjs +133 -0
- package/tests/session_digest.mjs +73 -0
- package/tests/session_eval.mjs +386 -0
- package/tests/session_index.mjs +105 -0
- package/tests/session_records.mjs +369 -0
- package/tests/session_search.mjs +78 -0
- package/tests/session_start_freshness.mjs +123 -0
- package/tests/setup_smoke.mjs +154 -0
- package/tests/ship_autolinkify.mjs +60 -0
- package/tests/ship_automerge_injection.mjs +174 -0
- package/tests/ship_broadcast_routing.mjs +125 -0
- package/tests/ship_broadcast_server.mjs +175 -0
- package/tests/ship_card.mjs +280 -0
- package/tests/ship_ci_deploy.mjs +374 -0
- package/tests/ship_preflight.mjs +368 -0
- package/tests/ship_premerge.mjs +75 -0
- package/tests/ship_resume.mjs +129 -0
- package/tests/ship_upload_skip.mjs +89 -0
- package/tests/ship_zero_commit.mjs +60 -0
- package/tests/single_host_hall.mjs +98 -0
- package/tests/skill_prefs.mjs +150 -0
- package/tests/smoke_preflight.mjs +66 -0
- package/tests/snapshot_picker.mjs +233 -0
- package/tests/sound_prefs.mjs +198 -0
- package/tests/sprite_facing.mjs +35 -0
- package/tests/status-probe.mjs +89 -0
- package/tests/status_backfill.mjs +94 -0
- package/tests/stop_refcheck.mjs +127 -0
- package/tests/streak.mjs +175 -0
- package/tests/subdomain_auth.mjs +58 -0
- package/tests/task_classifier_vocab.mjs +41 -0
- package/tests/task_create_idempotency.mjs +96 -0
- package/tests/task_goal_link.mjs +93 -0
- package/tests/task_ownership_gate.mjs +125 -0
- package/tests/terrain_picker.mjs +298 -0
- package/tests/touches_overlap.mjs +75 -0
- package/tests/triage_development.mjs +140 -0
- package/tests/turn_in_place.mjs +280 -0
- package/tests/upgrade.mjs +357 -0
- package/tests/verify_task_guard.mjs +64 -0
- package/tests/version_close.mjs +76 -0
- package/tests/versions_route.mjs +93 -0
- package/tests/walkability.mjs +188 -0
- package/tests/wandering_prefs.mjs +176 -0
- package/tests/worktree_disambiguation.mjs +105 -0
- package/tests/worktree_guard.mjs +90 -0
|
@@ -0,0 +1,1324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/provision.js — the INSTANCE-provisioning control-plane runner
|
|
5
|
+
// (ADR 0111 §2/§3, BONGOS-V1 goal 32, task #1944). A sibling of box.js.
|
|
6
|
+
//
|
|
7
|
+
// WHAT IT IS. The trust-boundary-safe executor for the `provisioning` module.
|
|
8
|
+
// The internet-facing web tier can only ENQUEUE a provision/teardown intent into
|
|
9
|
+
// provisioning_intents (modules/provisioning/routes/provisioning.js) — it holds
|
|
10
|
+
// NO cloud tokens. THIS runner, driven by a systemd timer
|
|
11
|
+
// (provision-intent-runner.timer → `provision.js run-intents --apply`), is the
|
|
12
|
+
// SOLE holder of the DigitalOcean / Cloudflare tokens (from a chmod-600
|
|
13
|
+
// /etc/<inst>/box.env via systemd EnvironmentFile=). It drains the queue and
|
|
14
|
+
// composes the docs/recipes/cloudbongos-standup.md runbook into ONE idempotent,
|
|
15
|
+
// dry-run-default, drift-reconciling flow (ADR 0016 / ADR 0031 §9.4 trust boundary):
|
|
16
|
+
//
|
|
17
|
+
// server (co-tenant process OR dedicated droplet)
|
|
18
|
+
// → createdb + GDS-only migrate (MEDUSA_GDS_ONLY=1)
|
|
19
|
+
// → write config + per-instance /etc/<inst>/web.env
|
|
20
|
+
// → install + enable a per-instance systemd unit (+ allocate a port)
|
|
21
|
+
// → Caddy site block + reload
|
|
22
|
+
// → /healthz
|
|
23
|
+
//
|
|
24
|
+
// DESIGN — inherited from box.js / do-api.js:
|
|
25
|
+
// • DRY-RUN by DEFAULT. Without --apply, every step only PRINTS the command it
|
|
26
|
+
// WOULD run and mutates nothing (no DB writes, no shell, no cloud call). The
|
|
27
|
+
// LIVE leg is an explicit operator/timer `--apply`.
|
|
28
|
+
// • PRIVILEGE model (task 2066, ADR 0128). The intent runner runs as a NON-root
|
|
29
|
+
// user (User=lars) with passwordless sudo, which cannot itself write under /etc,
|
|
30
|
+
// reload systemd/Caddy, or peer-auth to Postgres as a superuser. PROVISION_SUDO=1
|
|
31
|
+
// escalates exactly those steps (sudo install / sudo systemctl / sudo -u postgres);
|
|
32
|
+
// default OFF = bare commands (root / test / co-tenant-legacy contexts unchanged).
|
|
33
|
+
// • Injectable executor. `makeExec` returns a `run(cmd)` that is a pure logger
|
|
34
|
+
// in dry-run and child_process in --apply; tests pass a recording fake, so the
|
|
35
|
+
// whole runbook composition is unit-tested with no droplet / DB / network.
|
|
36
|
+
// • Idempotent. Re-running a half-done provision is safe (createdb tolerates
|
|
37
|
+
// "already exists"; unit/Caddy writes are overwrites; healthz is a poll).
|
|
38
|
+
// • The step-command generators are PURE functions (exported) → the SAME command
|
|
39
|
+
// strings drive local exec (co-tenant) AND the dedicated droplet's cloud-init.
|
|
40
|
+
//
|
|
41
|
+
// This is control-plane ops tooling (scripts/), so — like box.js — it may require
|
|
42
|
+
// the core + a module's data slice directly (a module may not, but a CLI may). It
|
|
43
|
+
// reuses modules/provisioning/provisioning.js (the intent queue + state) and
|
|
44
|
+
// scripts/gds/do-api.js (the shared DO client extracted in task #1943).
|
|
45
|
+
|
|
46
|
+
const fs = require('node:fs');
|
|
47
|
+
const os = require('node:os');
|
|
48
|
+
const path = require('node:path');
|
|
49
|
+
const cp = require('node:child_process');
|
|
50
|
+
|
|
51
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
52
|
+
|
|
53
|
+
// The verified OAuth-cred placement helpers (task 1979) — reused by the GitHub App
|
|
54
|
+
// Manifest flow (task 2080) to VALIDATE the creds GitHub returns before writing them
|
|
55
|
+
// and to PARSE the post-restart /healthz auth_configured probe. A sibling script; a
|
|
56
|
+
// plain require (its require.main guard means importing it runs no CLI).
|
|
57
|
+
const oauthSecret = require('./oauth-secret');
|
|
58
|
+
|
|
59
|
+
// The HTTP User-Agent the manifest conversion stamps its GitHub call with (GitHub
|
|
60
|
+
// requires a UA). Overridable so a fork can identify itself; not secret.
|
|
61
|
+
const MANIFEST_UA = process.env.PROVISION_GITHUB_UA || 'cloudbongos-provisioner';
|
|
62
|
+
|
|
63
|
+
// The running core version (no caret) — for naming the vendored tarball the standalone
|
|
64
|
+
// scaffold pins. Fail-soft (a partial checkout returns null; the scaffold then errors
|
|
65
|
+
// with guidance rather than pinning a `…-null.tgz`). Mirrors init.js coreVersionSafe().
|
|
66
|
+
function coreVersionSafe() {
|
|
67
|
+
try { return require(path.join(REPO_ROOT, 'src/module-api')).CORE_VERSION; } catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function numEnv(name, dflt) {
|
|
71
|
+
const v = process.env[name];
|
|
72
|
+
if (v == null || v === '') return dflt;
|
|
73
|
+
const n = Number(v);
|
|
74
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
75
|
+
}
|
|
76
|
+
// A boolean env flag: '1'/'true'/'yes'/'on' (case-insensitive) → true, else false.
|
|
77
|
+
function boolEnv(name) {
|
|
78
|
+
return /^(1|true|yes|on)$/i.test(String(process.env[name] || '').trim());
|
|
79
|
+
}
|
|
80
|
+
function hasFlag(args, name) { return args.includes(name); }
|
|
81
|
+
|
|
82
|
+
const CONFIG = {
|
|
83
|
+
// The port range a CO-TENANT instance process binds on the shared box. 3000 (OTB)
|
|
84
|
+
// and 3002 (cloudbongos.com) are taken; new co-tenant instances start at 3003.
|
|
85
|
+
portBase: numEnv('PROVISION_PORT_BASE', 3003),
|
|
86
|
+
reservedPorts: (process.env.PROVISION_RESERVED_PORTS || '3000,3001,3002')
|
|
87
|
+
.split(',').map((s) => Number(s.trim())).filter(Number.isFinite),
|
|
88
|
+
// Where a per-instance secrets file lives: /etc/<slug>/web.env (the standup
|
|
89
|
+
// runbook's /etc/cloudbongos/web.env shape). chmod 600, OUTSIDE the repo.
|
|
90
|
+
etcBase: process.env.PROVISION_ETC_BASE || '/etc',
|
|
91
|
+
// The dir holding per-instance Caddy site snippets. Each instance gets its OWN
|
|
92
|
+
// <slug>.caddy file (idempotent overwrite, no fragile append to a shared file);
|
|
93
|
+
// the main Caddyfile carries a one-time `import <dir>/*.caddy` (operator setup).
|
|
94
|
+
caddySnippetDir: process.env.PROVISION_CADDY_SNIPPET_DIR || '/etc/caddy/sites',
|
|
95
|
+
// Dedicated-droplet defaults (only used for hosting_shape='dedicated').
|
|
96
|
+
region: process.env.PROVISION_REGION || 'nyc3',
|
|
97
|
+
baseImage: process.env.PROVISION_BASE_IMAGE || 'ubuntu-24-04-x64',
|
|
98
|
+
tag: process.env.PROVISION_TAG || 'cloudbongos-instance',
|
|
99
|
+
doToken: process.env.DO_API_TOKEN || null,
|
|
100
|
+
sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
101
|
+
// The Cloudflare DNS hook (the SAME infra/box-dns-cloudflare.sh box.js uses).
|
|
102
|
+
// P4 (#1945) formalizes the UPSERT: point the instance's domain at the target
|
|
103
|
+
// public IP (below), in the domain's OWN Cloudflare zone, proxied=false (see
|
|
104
|
+
// dnsUpsertEnv). No hook configured → DNS is skipped (a co-tenant instance is
|
|
105
|
+
// still reachable by IP; TLS then can't be issued until DNS lands).
|
|
106
|
+
dnsHook: process.env.BOX_DNS_HOOK || null,
|
|
107
|
+
// This box's PUBLIC IP — the A-record target for a CO-TENANT instance's domain.
|
|
108
|
+
// Set in the control-plane /etc/<inst>/box.env (never the web tier). A dedicated
|
|
109
|
+
// instance instead points its A record at the freshly-created droplet's IP. Under
|
|
110
|
+
// remote-exec (PROVISION_REMOTE_HOST) this is the CO-HOSTING box's IP — where the
|
|
111
|
+
// co-tenant actually runs (ADR 0130).
|
|
112
|
+
publicIp: process.env.PROVISION_PUBLIC_IP || process.env.BOX_PUBLIC_IP || null,
|
|
113
|
+
// The CONTROL PLANE's OWN public IP — the A-record target for a STANDALONE instance
|
|
114
|
+
// (task 2074). A standalone runs control-plane-LOCAL (its scaffolded repo dir lives
|
|
115
|
+
// here, not on the remote co-hosting box), so its domain must point at THIS box, not
|
|
116
|
+
// at publicIp (which, under remote-exec, is the co-hosting box). Only needed when
|
|
117
|
+
// remote-exec splits the two boxes; all-local (no remoteHost) falls back to publicIp
|
|
118
|
+
// since the control plane and the run box are then the same machine.
|
|
119
|
+
controlPlaneIp: process.env.PROVISION_CONTROL_PLANE_IP || null,
|
|
120
|
+
// Parent dir for a STANDALONE instance's OWN repo checkout (hosting_shape='standalone',
|
|
121
|
+
// ADR 0108): each lives at <standaloneBase>/<slug> with @cloudbongos/core installed as
|
|
122
|
+
// an npm dep. Distinct from co-tenant (which shares THIS control-plane checkout).
|
|
123
|
+
standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
|
|
124
|
+
// ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
|
|
125
|
+
// The intent runner runs as a NON-root user (provision-intent-runner.service:
|
|
126
|
+
// User=lars) that holds passwordless sudo. That user cannot itself write under
|
|
127
|
+
// /etc, reload systemd/Caddy, or peer-auth to Postgres as a superuser — so the
|
|
128
|
+
// runbook steps below FAIL as bare commands (the goal-35 blocker). With
|
|
129
|
+
// PROVISION_SUDO=1 (alias PROVISION_PRIVILEGED=1) the privileged steps escalate:
|
|
130
|
+
// • /etc writes → temp file (lars-writable) + `sudo install -D -m <mode>`
|
|
131
|
+
// • systemctl / rm /etc → `sudo …`
|
|
132
|
+
// • createdb / migrate → `sudo -u postgres …` (postgres peer-auths as the PG
|
|
133
|
+
// superuser — the Ubuntu default — so it never needs `lars` to be a PG role)
|
|
134
|
+
// OFF (default) preserves the run-as-root / test / co-tenant-legacy behavior
|
|
135
|
+
// (bare commands, direct fs writes), so nothing outside a sudo context changes.
|
|
136
|
+
// Dry-run ALWAYS logs the exact command either way. NB: the runner unit must NOT
|
|
137
|
+
// set NoNewPrivileges=true — it blocks sudo (see infra/provision-intent-runner.service).
|
|
138
|
+
privileged: boolEnv('PROVISION_SUDO') || boolEnv('PROVISION_PRIVILEGED'),
|
|
139
|
+
// ── Remote orchestration over SSH (task 2065, ADR 0130) ──────────────────
|
|
140
|
+
// The token-separation endgame (ADR 0126): run this runner ON the token-holding
|
|
141
|
+
// control-plane box, and have it drive co-tenant workloads that still LIVE on the
|
|
142
|
+
// co-hosting box over SSH — so the co-hosting box can hold ZERO cloud tokens.
|
|
143
|
+
// When PROVISION_REMOTE_HOST is set, the BOX-MUTATING steps (createdb, migrate,
|
|
144
|
+
// /etc writes, systemctl, Caddy, healthz) run on that host over `ssh`; the
|
|
145
|
+
// TOKEN step (the Cloudflare DNS hook) + the standalone repo scaffold STAY local
|
|
146
|
+
// on the control plane, where the token + the packaging tools live. UNSET
|
|
147
|
+
// (default) = every step runs locally, byte-identical to today.
|
|
148
|
+
remoteHost: process.env.PROVISION_REMOTE_HOST || null,
|
|
149
|
+
// The co-hosting box's checkout dir — the cwd for the remote migrate (its relative
|
|
150
|
+
// ./scripts/migrate.sh). Defaults to that box's shared checkout.
|
|
151
|
+
remoteCwd: process.env.PROVISION_REMOTE_CHECKOUT || '/home/lars/amazonprimea',
|
|
152
|
+
// ssh options for the control-plane → co-hosting hop. BatchMode so a missing key
|
|
153
|
+
// fails fast (never hangs on a prompt); accept-new pins the host key on first
|
|
154
|
+
// connect (the operator can pre-seed known_hosts for strict pinning).
|
|
155
|
+
sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Lazy dep load (so --help / arg errors need no Postgres) — the box.js pattern.
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
let _deps;
|
|
164
|
+
function loadDeps() {
|
|
165
|
+
if (_deps) return _deps;
|
|
166
|
+
// eslint-disable-next-line global-require
|
|
167
|
+
const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
|
|
168
|
+
// eslint-disable-next-line global-require
|
|
169
|
+
const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning'));
|
|
170
|
+
// eslint-disable-next-line global-require
|
|
171
|
+
const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
|
|
172
|
+
_deps = { pool, provisioning, DoApi };
|
|
173
|
+
return _deps;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// PURE step-command generators (exported for tests). Each returns the exact shell
|
|
178
|
+
// command a runbook step runs. Kept pure so local exec (co-tenant) and the
|
|
179
|
+
// dedicated droplet's cloud-init both build from ONE source of truth.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
// A slug is validated DNS/db-safe upstream (provisioning.isValidSlug), so it is
|
|
183
|
+
// already shell-safe (only [a-z0-9-]). We still never interpolate untrusted text.
|
|
184
|
+
function dbName(inst) { return inst.db_name || inst.slug; }
|
|
185
|
+
|
|
186
|
+
// Escalate a DB command to run as the postgres OS user (→ PG superuser via peer auth,
|
|
187
|
+
// the Ubuntu default) when privileged — the DB-auth fix (task 2066). The runner's
|
|
188
|
+
// `lars` is not a usable PG role, so bare peer-auth-as-lars fails; `sudo -u postgres`
|
|
189
|
+
// sidesteps it without granting lars a role. PURE given the flag.
|
|
190
|
+
function asPostgres(cmd, privileged) { return privileged ? `sudo -u postgres ${cmd}` : cmd; }
|
|
191
|
+
|
|
192
|
+
function dbCreateCmd(inst, { privileged = false } = {}) {
|
|
193
|
+
// Idempotent: the caller runs it allowFail — "database already exists" is success.
|
|
194
|
+
// Non-privileged = bare `createdb` (peer auth as the running user, same as the
|
|
195
|
+
// standup runbook / OTB); privileged = `sudo -u postgres createdb` (task 2066).
|
|
196
|
+
return asPostgres(`createdb ${dbName(inst)}`, privileged);
|
|
197
|
+
}
|
|
198
|
+
function migrateCmd(inst, { privileged = false } = {}) {
|
|
199
|
+
// GDS-only schema — skips migrations/game/*.sql (no game tables on a vanilla
|
|
200
|
+
// instance), per the standup runbook (ADR 0105 instance seeds off). Privileged
|
|
201
|
+
// runs migrate as postgres (task 2066): `sudo` resets env, so the PGDATABASE/
|
|
202
|
+
// MEDUSA_GDS_ONLY vars ride `env …` and the script runs via `bash …` (a bare
|
|
203
|
+
// `VAR=val ./script` would be parsed BY sudo as a command). Precondition: the
|
|
204
|
+
// control-plane checkout must be readable by the postgres user (ADR 0128).
|
|
205
|
+
return privileged
|
|
206
|
+
? `sudo -u postgres env PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ./scripts/migrate.sh`
|
|
207
|
+
: `PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 ./scripts/migrate.sh`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── STANDALONE shape (hosting_shape='standalone', ADR 0108, task 1978) ────────
|
|
211
|
+
// A standalone instance runs from its OWN repo checkout (host content: config/,
|
|
212
|
+
// migrations/instance/, the core PIN in package.json) with @cloudbongos/core installed
|
|
213
|
+
// as an npm DEPENDENCY — NOT the shared control-plane checkout the co-tenant shape uses.
|
|
214
|
+
// It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
|
|
215
|
+
// scripts/deploy/deploy-instance.sh runbook API-driven instead of by hand.
|
|
216
|
+
const CORE_PKG_DIR = 'node_modules/@cloudbongos/core';
|
|
217
|
+
function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
|
|
218
|
+
// Sync the instance's own repo (host content + the core pin). Mirrors deploy-instance.sh
|
|
219
|
+
// step 1 — assumes the repo is already cloned at standaloneRoot (initial clone is a
|
|
220
|
+
// precondition, like the runbook's `git pull`). Run with cwd = standaloneRoot.
|
|
221
|
+
function standalonePullCmd() { return `git pull --ff-only origin main`; }
|
|
222
|
+
// Install the pinned core + instance deps — replaces co-tenant's shared checkout
|
|
223
|
+
// (deploy-instance.sh step 2). Run with cwd = standaloneRoot.
|
|
224
|
+
function standaloneInstallCmd() { return `npm ci --omit=dev`; }
|
|
225
|
+
// Migrate core + instance from the INSTANCE root's copy of the three-root migrate.sh
|
|
226
|
+
// (the installed core's, falling back to an in-tree copy — deploy-instance.sh step 3).
|
|
227
|
+
// Run with cwd = standaloneRoot.
|
|
228
|
+
function standaloneMigrateCmd(inst, { privileged = false } = {}) {
|
|
229
|
+
// Privileged: run as postgres (task 2066), passing env via `env …` so it survives
|
|
230
|
+
// sudo's env reset (the instance root must be postgres-readable, ADR 0128).
|
|
231
|
+
return privileged
|
|
232
|
+
? `sudo -u postgres env PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ${CORE_PKG_DIR}/scripts/migrate.sh`
|
|
233
|
+
: `PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ${CORE_PKG_DIR}/scripts/migrate.sh`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── STANDALONE scaffold (ADR 0108 / 0125, task 2055) ──────────────────────────
|
|
237
|
+
// The MISSING first leg of the greenfield "one guided flow": provision.js used to
|
|
238
|
+
// THROW if the instance repo wasn't already cloned. These generate + run the scaffold
|
|
239
|
+
// that clones an EMPTY target_ref and turns it into an installable, bootable instance.
|
|
240
|
+
|
|
241
|
+
// Parse an 'owner/repo' (or a github SSH/HTTPS URL) target_ref → { owner, name }.
|
|
242
|
+
// Null when unparseable, so the scaffold can fail with actionable guidance.
|
|
243
|
+
function parseTargetRef(ref) {
|
|
244
|
+
const m = String(ref || '').trim()
|
|
245
|
+
.replace(/^git@[^:]+:/, '')
|
|
246
|
+
.replace(/^https?:\/\/[^/]+\//, '')
|
|
247
|
+
.match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/);
|
|
248
|
+
return m ? { owner: m[1], name: m[2] } : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The git remote for an instance's OWN repo. Reuses the SAME SSH deploy alias the
|
|
252
|
+
// control-plane checkout uses (git@<alias>:<owner>/<repo>.git) — NO secret in the
|
|
253
|
+
// command (SSH-key auth, nothing to leak into journald), consistent with how the
|
|
254
|
+
// platform repo is wired. Standing up a new instance repo therefore needs only that
|
|
255
|
+
// the control plane's deploy key has WRITE access to that repo (ADR 0125). Alias is
|
|
256
|
+
// overridable via PROVISION_GIT_SSH_ALIAS.
|
|
257
|
+
function instanceRepoRemote(repo, { sshAlias = process.env.PROVISION_GIT_SSH_ALIAS || 'github-deploy' } = {}) {
|
|
258
|
+
return `git@${sshAlias}:${repo.owner}/${repo.name}.git`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A minimal, VALID `bongos init` spec (init.validateSpec greenfield) from the
|
|
262
|
+
// provisioning row + the owner's github login. The scaffold produces a bootable,
|
|
263
|
+
// NEUTRAL instance; the owner brands + scopes it later via init's kickoff tasks.
|
|
264
|
+
function instanceInitSpec(inst, { ownerLogin } = {}) {
|
|
265
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
266
|
+
return {
|
|
267
|
+
identity: { productName: inst.slug },
|
|
268
|
+
repo: repo || { owner: ownerLogin || 'owner', name: inst.slug },
|
|
269
|
+
firstAdmin: ownerLogin || (repo && repo.owner) || 'owner',
|
|
270
|
+
currency: { label: 'credits' },
|
|
271
|
+
domains: { publicOrigin: inst.domain ? `https://${inst.domain}` : `http://localhost:${inst.port || 3000}` },
|
|
272
|
+
modules: [],
|
|
273
|
+
version: { id: 'V1', name: 'V1', done_when: 'A task goes claim → grade → ship.' },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The .gitignore a scaffolded instance repo carries: keep node_modules + the local
|
|
278
|
+
// dist/ + secrets OUT of the commit, while the vendored core tarball (vendor/*.tgz)
|
|
279
|
+
// and package-lock.json stay IN — `npm ci` on deploy resolves the pinned core from
|
|
280
|
+
// the committed tarball (ADR 0108), so they MUST be tracked.
|
|
281
|
+
const INSTANCE_GITIGNORE = ['node_modules/', 'dist/', '.env', '.env.local', '*.log', ''].join('\n');
|
|
282
|
+
|
|
283
|
+
// Clone the (empty) target_ref → standaloneRoot → materialize config/ + .claude/ + a
|
|
284
|
+
// file:-pinned @cloudbongos/core + lockfile (via `bongos init --vendor-core`, task 2053)
|
|
285
|
+
// → commit + push. Idempotent (skips once the repo carries a package.json) and dry-run
|
|
286
|
+
// safe (every mutation goes through the injected exec/writeFile). ADR 0108 / 0125.
|
|
287
|
+
async function scaffoldStandaloneRepo(inst, deps) {
|
|
288
|
+
// The scaffold BUILDS + pushes the instance's repo (git clone, package-core,
|
|
289
|
+
// `bongos init`, git push) — control-plane work that needs the packaging tools +
|
|
290
|
+
// the GitHub deploy key, so it runs LOCALLY even when box-mutating steps go
|
|
291
|
+
// remote (task 2065). Falls back to exec/writeFile when a caller passes no
|
|
292
|
+
// control executor (then all are the same local executor — unchanged behavior).
|
|
293
|
+
const { db, log } = deps;
|
|
294
|
+
const exec = deps.controlExec || deps.exec;
|
|
295
|
+
const writeFile = deps.controlWriteFile || deps.writeFile;
|
|
296
|
+
const root = standaloneRoot(inst);
|
|
297
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
298
|
+
if (!repo) {
|
|
299
|
+
throw new Error(`standalone instance ${inst.slug}: target_ref "${inst.target_ref || '(unset)'}" is not an owner/repo — set it (e.g. <owner>/${inst.slug}) so the scaffold knows which repo to clone + push.`);
|
|
300
|
+
}
|
|
301
|
+
// Idempotent: a completed scaffold leaves package.json (the file:-pinned core). Skip.
|
|
302
|
+
if (fs.existsSync(path.join(root, 'package.json'))) {
|
|
303
|
+
log(` [scaffold] ${root} already scaffolded (package.json present) — skipping`);
|
|
304
|
+
return { already: true };
|
|
305
|
+
}
|
|
306
|
+
const coreV = coreVersionSafe();
|
|
307
|
+
if (!coreV) {
|
|
308
|
+
throw new Error('cannot resolve the core version (src/module-api CORE_VERSION) — run the scaffold from the control-plane checkout.');
|
|
309
|
+
}
|
|
310
|
+
const remote = instanceRepoRemote(repo);
|
|
311
|
+
log(` [scaffold] ${repo.owner}/${repo.name} → ${root} (clone → materialize → pin core ${coreV} → commit → push)`);
|
|
312
|
+
|
|
313
|
+
// Owner login = the founding firstAdmin. Best-effort; the spec falls back to the repo owner.
|
|
314
|
+
let ownerLogin = null;
|
|
315
|
+
if (inst.owner_builder_id && db) {
|
|
316
|
+
try {
|
|
317
|
+
const r = await db.query('SELECT github_login FROM builders WHERE id = $1', [inst.owner_builder_id]);
|
|
318
|
+
ownerLogin = r && r.rows && r.rows[0] ? r.rows[0].github_login : null;
|
|
319
|
+
} catch { /* fall back to the repo owner */ }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 1. clone the (possibly empty) repo, then force the initial branch to `main` (an empty
|
|
323
|
+
// clone leaves an unborn HEAD). A re-run after a partial clone reuses the existing dir.
|
|
324
|
+
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
325
|
+
exec(`mkdir -p ${path.posix.dirname(root)}`);
|
|
326
|
+
exec(`git clone ${remote} ${root}`);
|
|
327
|
+
}
|
|
328
|
+
exec(`git -C ${root} checkout -B main`);
|
|
329
|
+
|
|
330
|
+
// 2. build the installable core tarball from THIS control-plane checkout (the core source).
|
|
331
|
+
exec(`node scripts/gds/package-core.js`); // cwd = REPO_ROOT → dist/cloudbongos-core-<v>.tgz
|
|
332
|
+
const tgz = path.join(REPO_ROOT, 'dist', `cloudbongos-core-${coreV}.tgz`);
|
|
333
|
+
|
|
334
|
+
// 3. materialize config/ + .claude/ + a file:-pinned package.json + lockfile via `bongos init`
|
|
335
|
+
// (--vendor-core, task 2053). --no-seed: the instance's own DB is seeded by migrate, not here.
|
|
336
|
+
const spec = instanceInitSpec(inst, { ownerLogin });
|
|
337
|
+
const specPath = path.join(os.tmpdir(), `bongos-init-${inst.slug}.json`);
|
|
338
|
+
writeFile(specPath, JSON.stringify(spec, null, 2));
|
|
339
|
+
exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed --force --vendor-core ${tgz}`);
|
|
340
|
+
|
|
341
|
+
// 4. commit + push the scaffold to the instance's own main (keep node_modules out).
|
|
342
|
+
writeFile(path.join(root, '.gitignore'), INSTANCE_GITIGNORE);
|
|
343
|
+
exec(`git -C ${root} config user.name "Cloud Bongos"`);
|
|
344
|
+
exec(`git -C ${root} config user.email "bot@cloudbongos.com"`);
|
|
345
|
+
exec(`git -C ${root} add -A`);
|
|
346
|
+
exec(`git -C ${root} commit -m "chore: scaffold ${inst.slug} on a pinned @cloudbongos/core ${coreV} (provision.js, ADR 0108)"`, { allowFail: true });
|
|
347
|
+
exec(`git -C ${root} push -u origin main`);
|
|
348
|
+
log(` [scaffold] pushed initial scaffold to ${repo.owner}/${repo.name}@main`);
|
|
349
|
+
return { ok: true, scaffolded: true };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The per-instance systemd unit (the infra/cloudbongos.service shape): one Node process
|
|
353
|
+
// bound to the instance's port + DB + brand, secrets from its own env file. SHAPE-AWARE:
|
|
354
|
+
// a STANDALONE instance runs the CORE PACKAGE's platform-server from its OWN repo dir
|
|
355
|
+
// (WorkingDirectory=standaloneRoot → resolveInstanceRoot() resolves to the instance via
|
|
356
|
+
// cwd, deploy-instance.sh §4); co-tenant/dedicated run this control-plane checkout's src/.
|
|
357
|
+
function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
358
|
+
const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
|
|
359
|
+
const standalone = inst.hosting_shape === 'standalone';
|
|
360
|
+
const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
|
|
361
|
+
const execStart = standalone
|
|
362
|
+
? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
|
|
363
|
+
: `/usr/bin/node src/platform-server.js`;
|
|
364
|
+
return `[Unit]
|
|
365
|
+
Description=Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0111)
|
|
366
|
+
After=network-online.target postgresql.service
|
|
367
|
+
Wants=network-online.target
|
|
368
|
+
Requires=postgresql.service
|
|
369
|
+
|
|
370
|
+
[Service]
|
|
371
|
+
Type=simple
|
|
372
|
+
User=lars
|
|
373
|
+
Group=lars
|
|
374
|
+
WorkingDirectory=${workDir}
|
|
375
|
+
EnvironmentFile=${envFile}
|
|
376
|
+
Environment=PGDATABASE=${dbName(inst)}
|
|
377
|
+
Environment=PORT=${inst.port}
|
|
378
|
+
ExecStart=${execStart}
|
|
379
|
+
Restart=always
|
|
380
|
+
RestartSec=3
|
|
381
|
+
StandardOutput=journal
|
|
382
|
+
StandardError=journal
|
|
383
|
+
NoNewPrivileges=true
|
|
384
|
+
|
|
385
|
+
[Install]
|
|
386
|
+
WantedBy=multi-user.target
|
|
387
|
+
`;
|
|
388
|
+
}
|
|
389
|
+
function serviceUnitPath(inst) { return `/etc/systemd/system/${inst.slug}.service`; }
|
|
390
|
+
|
|
391
|
+
// The branding pack a provisioned CO-TENANT instance loads: the shared, committed
|
|
392
|
+
// neutral starter (the co-tenant model shares ONE checkout, differentiated only by
|
|
393
|
+
// env — src/branding.js's GDS_BRANDING_FILE knob). Per-instance identity/domains
|
|
394
|
+
// therefore ride ENV OVERRIDES, not a divergent pack. Kept as a constant so the
|
|
395
|
+
// GDS_BRANDING_FILE line below and the env-var PREFIX (which must equal this pack's
|
|
396
|
+
// envPrefix, or src/branding.js's ENV_OVERRIDES never fire) can't drift apart.
|
|
397
|
+
const PROVISIONED_BRANDING_FILE = 'config/branding.neutral.json';
|
|
398
|
+
|
|
399
|
+
// The env-var prefix a provisioned instance actually RESOLVES — i.e. the envPrefix
|
|
400
|
+
// of the pack it loads (PROVISIONED_BRANDING_FILE). src/instance-config.resolveEnv
|
|
401
|
+
// reads <envPrefix>_<SUFFIX>, and src/branding.resolveBranding applies
|
|
402
|
+
// <envPrefix>_{PUBLIC,BUILDERS,…}_ORIGIN overrides — so a web.env var only takes
|
|
403
|
+
// effect under THIS prefix. Read from the pack (fail-soft to the vanilla default)
|
|
404
|
+
// so it stays in lockstep instead of a magic string. (The prior slug-based prefix
|
|
405
|
+
// was a latent bug: it never matched the neutral pack's CLOUDBONGOS envPrefix, so
|
|
406
|
+
// the OAuth creds were silently unreadable — task 1972.)
|
|
407
|
+
function provisionedEnvPrefix() {
|
|
408
|
+
try {
|
|
409
|
+
const pack = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, PROVISIONED_BRANDING_FILE), 'utf8'));
|
|
410
|
+
return pack.envPrefix || 'CLOUDBONGOS';
|
|
411
|
+
} catch { return 'CLOUDBONGOS'; }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// The per-instance secrets file body (the standup runbook's /etc/<inst>/web.env).
|
|
415
|
+
// The OAuth client id/secret are GUIDED/manual inputs (no GitHub create-API) — the
|
|
416
|
+
// runner writes placeholders the owner fills, unless provided via the intent later.
|
|
417
|
+
// A domain (bring-your-own or provisioned) drives the single-host origin overrides:
|
|
418
|
+
// with them set, src/branding.js resolves the instance's REAL buildersOrigin, so the
|
|
419
|
+
// core's single-host router (isBuildersHost === buildersOrigin host, task 1971) serves
|
|
420
|
+
// the hall on this host + OAuth uses the real callback origin — without them the pack
|
|
421
|
+
// stays at neutral localhost and the hall never loads (task 1972). No domain yet
|
|
422
|
+
// (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
|
|
423
|
+
function webEnvBody(inst, { clientId = '', clientSecret = '' } = {}) {
|
|
424
|
+
const prefix = provisionedEnvPrefix();
|
|
425
|
+
const origin = inst.domain ? `https://${inst.domain}` : '';
|
|
426
|
+
const originLines = origin
|
|
427
|
+
? `# Single-host origins (task 1972): the hall serves on THIS host + OAuth/redirects
|
|
428
|
+
# use the real address. Resolved by src/branding.js ENV_OVERRIDES under the pack's
|
|
429
|
+
# ${prefix} envPrefix. A single-host instance has no dedicated builders./status.
|
|
430
|
+
# subdomain, so all four point at the one host.
|
|
431
|
+
${prefix}_PUBLIC_ORIGIN=${origin}
|
|
432
|
+
${prefix}_BUILDERS_ORIGIN=${origin}
|
|
433
|
+
${prefix}_STATUS_ORIGIN=${origin}
|
|
434
|
+
${prefix}_OAUTH_ORIGIN=${origin}
|
|
435
|
+
`
|
|
436
|
+
: `# No domain yet (bring-your-own-domain pending, ADR 0111 §4) — origins stay
|
|
437
|
+
# neutral (localhost); add ${prefix}_{PUBLIC,BUILDERS,OAUTH}_ORIGIN once a domain
|
|
438
|
+
# is set, or the hall won't load on the instance's host.
|
|
439
|
+
`;
|
|
440
|
+
// The OAuth app creds are filled by ONE of two paths (task 2080): the GitHub App
|
|
441
|
+
// Manifest flow (placeManifestCreds fetches them control-plane-side + re-writes this
|
|
442
|
+
// file with clientId/clientSecret set), or the manual oauth-secret.js placement. On
|
|
443
|
+
// the first provision write they are blank placeholders the flow fills later.
|
|
444
|
+
const credComment = clientId || clientSecret
|
|
445
|
+
? `# Provisioned by scripts/gds/provision.js (ADR 0111). OAuth app creds placed via the
|
|
446
|
+
# GitHub App Manifest flow (ADR 0133) — the owner clicked one link; no secret was pasted.`
|
|
447
|
+
: `# Provisioned by scripts/gds/provision.js (ADR 0111). Fill the OAuth app creds via the
|
|
448
|
+
# one-click GitHub App Manifest flow (ADR 0133), or manually (oauth-secret.js place):`;
|
|
449
|
+
return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
|
|
450
|
+
${credComment}
|
|
451
|
+
${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
|
|
452
|
+
${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
|
|
453
|
+
GDS_BRANDING_FILE=${PROVISIONED_BRANDING_FILE}
|
|
454
|
+
${originLines}PORT=${inst.port}
|
|
455
|
+
PGDATABASE=${dbName(inst)}
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
459
|
+
return path.posix.join(etcBase, inst.slug, 'web.env');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// The Cloudflare zone a hostname's A record lives in. Cloudflare zones are
|
|
463
|
+
// registrable domains, which for the common case is the last two DNS labels
|
|
464
|
+
// (mercury.example.com → example.com; apex example.com → example.com). Multi-part
|
|
465
|
+
// public suffixes (foo.co.uk) are NOT special-cased here — set CLOUDFLARE_ZONE in
|
|
466
|
+
// the control-plane box.env to override (box-dns-cloudflare.sh honours it). PURE.
|
|
467
|
+
function zoneForDomain(domain) {
|
|
468
|
+
const d = String(domain || '').trim().toLowerCase().replace(/\.$/, '');
|
|
469
|
+
if (!d) return '';
|
|
470
|
+
const labels = d.split('.').filter(Boolean);
|
|
471
|
+
return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// The env a DNS UPSERT invocation of infra/box-dns-cloudflare.sh needs for a
|
|
475
|
+
// PROVISIONED host: the hostname, the target public IP (the A-record content), the
|
|
476
|
+
// zone the record lives in, and whether Cloudflare proxies it.
|
|
477
|
+
// • proxied=false (default) is LOAD-BEARING for a CO-TENANT/dedicated host: it uses
|
|
478
|
+
// Caddy on-demand ACME (Let's Encrypt), whose challenge must reach Caddy directly —
|
|
479
|
+
// Cloudflare proxy ON would intercept :80/:443 at the edge and the challenge would
|
|
480
|
+
// silently fail (ADR 0003 / docs/recipes/ops-gotchas.md). Do NOT proxy those.
|
|
481
|
+
// • proxied=true for a STANDALONE host (task 2077): it runs control-plane-local under
|
|
482
|
+
// the *.cloudbongos.com wildcard Cloudflare Origin cert, so on-demand ACME never
|
|
483
|
+
// fires (a matching cert already exists) and proxied=false would serve that origin
|
|
484
|
+
// cert — untrusted to direct clients. Proxying lets the CF edge present a public cert
|
|
485
|
+
// (origin cert valid behind Full-strict) AND hides the control-plane IP.
|
|
486
|
+
// An explicit `zone` wins over the derived one (for a multi-part-suffix domain). PURE.
|
|
487
|
+
function dnsUpsertEnv(inst, { publicIp, zone, proxied = false } = {}) {
|
|
488
|
+
return {
|
|
489
|
+
BOX_HOSTNAME: inst.domain,
|
|
490
|
+
BOX_IP: publicIp,
|
|
491
|
+
CLOUDFLARE_ZONE: zone || zoneForDomain(inst.domain),
|
|
492
|
+
BOX_PROXIED: proxied ? '1' : '0',
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── DNS token-scope PREFLIGHT (task 1980) ────────────────────────────────────
|
|
497
|
+
// Onboarding a NEW project on a domain the control-plane's Cloudflare token does NOT
|
|
498
|
+
// cover fails LATE: the DNS UPSERT (step 5) errors mid-provision (the Emersonian
|
|
499
|
+
// standup — the token only covered the platform's original zone, so the new project's
|
|
500
|
+
// record couldn't be created until it was widened MID-FLOW). This preflight detects
|
|
501
|
+
// "the configured token can't reach the project's zone" UP FRONT, before the
|
|
502
|
+
// connect-address step, and GUIDES the fix — instead of a half-done provision. It is
|
|
503
|
+
// READ-ONLY (a CF `GET /zones?name=<zone>`); it never mutates DNS.
|
|
504
|
+
const CF_API_BASE = process.env.CLOUDFLARE_API_BASE || 'https://api.cloudflare.com/client/v4';
|
|
505
|
+
|
|
506
|
+
// PURE: given the outcome of `GET /zones?name=<zone>` (HTTP status + parsed JSON) and
|
|
507
|
+
// the target zone, decide whether the token can MANAGE that zone, with actionable
|
|
508
|
+
// guidance when it can't. Exported + unit-tested with no network.
|
|
509
|
+
function classifyZoneScope({ status, body, zone }) {
|
|
510
|
+
const z = String(zone || '');
|
|
511
|
+
if (status === 401 || status === 403) {
|
|
512
|
+
return { reachable: false, reason: `Cloudflare rejected the token (HTTP ${status})`,
|
|
513
|
+
guidance: `The DNS token is invalid or lacks Zone:Read. Set CLOUDFLARE_API_TOKEN in the control-plane box.env to a token scoped to include ${z} (Zone:DNS:Edit + Zone:Read).` };
|
|
514
|
+
}
|
|
515
|
+
if (status !== 200 || !body || body.success !== true) {
|
|
516
|
+
const errs = body && Array.isArray(body.errors) ? body.errors.map((e) => e && e.message).filter(Boolean).join('; ') : '';
|
|
517
|
+
return { reachable: false, reason: `Cloudflare zone lookup failed (HTTP ${status}${errs ? `: ${errs}` : ''})`,
|
|
518
|
+
guidance: `Check CLOUDFLARE_API_TOKEN + network from the control plane, then retry.` };
|
|
519
|
+
}
|
|
520
|
+
const result = Array.isArray(body.result) ? body.result : [];
|
|
521
|
+
const match = result.find((r) => r && r.name === z);
|
|
522
|
+
if (!match) {
|
|
523
|
+
return { reachable: false, reason: `the token authenticated, but zone "${z}" is not in its scope`,
|
|
524
|
+
guidance: `Widen the Cloudflare API token to include ${z}: CF dashboard → My Profile → API Tokens → edit the token → Zone Resources → Include → ${z} (or use an account-scoped token). If ${z} is a multi-part public suffix (e.g. foo.co.uk), set CLOUDFLARE_ZONE explicitly in the control-plane box.env.` };
|
|
525
|
+
}
|
|
526
|
+
return { reachable: true, reason: `token can manage zone "${z}" (id ${match.id})`, zoneId: match.id };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Query Cloudflare for the domain's zone with the configured token. Injectable
|
|
530
|
+
// `token`/`fetchImpl` for tests. Returns { zone, reachable, reason, guidance[, zoneId] }.
|
|
531
|
+
// A missing token/fetch/domain is reported as not-reachable-with-guidance — it never
|
|
532
|
+
// throws for a config gap (the caller decides whether to hard-fail).
|
|
533
|
+
async function checkDnsTokenScope(domain, { token = process.env.CLOUDFLARE_API_TOKEN, zoneOverride = process.env.CLOUDFLARE_ZONE, fetchImpl } = {}) {
|
|
534
|
+
const zone = zoneOverride || zoneForDomain(domain);
|
|
535
|
+
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
|
536
|
+
if (!zone) return { zone, reachable: false, reason: 'no domain given', guidance: 'provide a domain before the address step' };
|
|
537
|
+
if (!token) return { zone, reachable: false, reason: 'no CLOUDFLARE_API_TOKEN configured', guidance: `Set CLOUDFLARE_API_TOKEN (Zone:DNS:Edit on ${zone}) in the control-plane box.env before provisioning DNS for ${domain}.` };
|
|
538
|
+
if (!doFetch) return { zone, reachable: false, reason: 'no fetch available', guidance: 'run on a Node with global fetch, or inject fetchImpl' };
|
|
539
|
+
let status = 0; let bodyJson = null;
|
|
540
|
+
try {
|
|
541
|
+
const res = await doFetch(`${CF_API_BASE}/zones?name=${encodeURIComponent(zone)}`, {
|
|
542
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
543
|
+
});
|
|
544
|
+
status = res.status;
|
|
545
|
+
bodyJson = await res.json().catch(() => null);
|
|
546
|
+
} catch (e) {
|
|
547
|
+
return { zone, reachable: false, reason: `Cloudflare request failed: ${e.message}`, guidance: 'check control-plane network + token, then retry' };
|
|
548
|
+
}
|
|
549
|
+
return { zone, ...classifyZoneScope({ status, body: bodyJson, zone }) };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// A Caddy reverse-proxy site block for the instance's domain → its local port.
|
|
553
|
+
// TLS = on-demand ACME (P4, ADR 0111 §4): Caddy fetches a Let's Encrypt cert for
|
|
554
|
+
// this host LAZILY, on the first TLS handshake — no manual Cloudflare Origin-Cert
|
|
555
|
+
// console step (the least-automated leg). It is GATED by the global
|
|
556
|
+
// `on_demand_tls { ask … }` allow-list in infra/Caddyfile (→ /api/bongos/
|
|
557
|
+
// provisioning/tls-check), so Caddy only mints a cert for a host this module
|
|
558
|
+
// actually provisioned — never an arbitrary SNI. The A record is proxied=false
|
|
559
|
+
// (dnsUpsertEnv) so the challenge reaches Caddy. No domain → a comment
|
|
560
|
+
// (bring-your-own-domain is deferred per-instance, ADR 0111 §4).
|
|
561
|
+
function caddyBlock(inst) {
|
|
562
|
+
if (!inst.domain) {
|
|
563
|
+
return `# ${inst.slug}: no domain yet (bring-your-own-domain pending) — reachable on 127.0.0.1:${inst.port}\n`;
|
|
564
|
+
}
|
|
565
|
+
return `${inst.domain} {
|
|
566
|
+
tls {
|
|
567
|
+
on_demand
|
|
568
|
+
}
|
|
569
|
+
reverse_proxy 127.0.0.1:${inst.port}
|
|
570
|
+
}
|
|
571
|
+
`;
|
|
572
|
+
}
|
|
573
|
+
function caddySnippetPath(inst, { snippetDir = CONFIG.caddySnippetDir } = {}) {
|
|
574
|
+
return path.posix.join(snippetDir, `${inst.slug}.caddy`);
|
|
575
|
+
}
|
|
576
|
+
function healthzCmd(inst) {
|
|
577
|
+
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// The injectable executor. dry-run → log-only; --apply → child_process.
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
|
|
584
|
+
return function run(cmd, { allowFail = false, cwd, env } = {}) {
|
|
585
|
+
// Extra env is passed to the child (not interpolated into the command), so
|
|
586
|
+
// values like a hostname/IP never need shell quoting. It's shown in the dry-run
|
|
587
|
+
// plan for transparency — DNS env (BOX_HOSTNAME/BOX_IP/CLOUDFLARE_ZONE) is not
|
|
588
|
+
// secret (the token stays in the hook's own EnvironmentFile, out of `env`).
|
|
589
|
+
const envPrefix = env ? `${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')} ` : '';
|
|
590
|
+
if (remoteHost) {
|
|
591
|
+
// Remote step (task 2065): run it on the co-hosting box over SSH. The cwd +
|
|
592
|
+
// env + command are composed into a small script fed to the remote shell via
|
|
593
|
+
// STDIN (`ssh … bash -s`), so nothing needs shell-quoting on the way over
|
|
594
|
+
// (the #1 remote-exec footgun). cwd defaults to the remote checkout so a
|
|
595
|
+
// relative `./scripts/migrate.sh` resolves there. env goes INTO the script
|
|
596
|
+
// (a local child env wouldn't cross the ssh hop).
|
|
597
|
+
const wd = cwd || remoteCwd;
|
|
598
|
+
const script = `${wd ? `cd ${wd} && ` : ''}${envPrefix}${cmd}`;
|
|
599
|
+
const sshCmd = `ssh ${sshOpts} ${remoteHost} bash -s`;
|
|
600
|
+
const shown = `[ssh ${remoteHost}] ${script}`;
|
|
601
|
+
if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
|
|
602
|
+
try {
|
|
603
|
+
const stdout = cp.execSync(sshCmd, { input: script, stdio: ['pipe', 'pipe', 'pipe'], env: process.env }).toString();
|
|
604
|
+
log(` ✓ ${shown}`);
|
|
605
|
+
return { ok: true, stdout };
|
|
606
|
+
} catch (e) {
|
|
607
|
+
if (allowFail) { log(` ~ ${shown} (non-fatal: ${(e.stderr || e.message || '').toString().trim().slice(0, 120)})`); return { ok: true, stdout: '', softFailed: true }; }
|
|
608
|
+
log(` ✖ ${shown}`);
|
|
609
|
+
throw e;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Local step (control plane / today's behavior). cwd defaults to REPO_ROOT.
|
|
613
|
+
const shown = `${envPrefix}${cmd}`;
|
|
614
|
+
if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
|
|
615
|
+
try {
|
|
616
|
+
const stdout = cp.execSync(cmd, { cwd: cwd || REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: env ? { ...process.env, ...env } : process.env }).toString();
|
|
617
|
+
log(` ✓ ${cmd}`);
|
|
618
|
+
return { ok: true, stdout };
|
|
619
|
+
} catch (e) {
|
|
620
|
+
if (allowFail) { log(` ~ ${cmd} (non-fatal: ${(e.stderr || e.message || '').toString().trim().slice(0, 120)})`); return { ok: true, stdout: '', softFailed: true }; }
|
|
621
|
+
log(` ✖ ${cmd}`);
|
|
622
|
+
throw e;
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// A file write that respects dry-run (log-only) and applies chmod when asked.
|
|
627
|
+
// When privileged AND the call opts in with { sudo: true } (the /etc destinations
|
|
628
|
+
// the runner's non-root user can't write directly), the body is staged in a temp
|
|
629
|
+
// file (lars-writable) then atomically placed with `sudo install -D -m <mode>`
|
|
630
|
+
// (`-D` creates the parent dir, e.g. /etc/<slug>/). Otherwise a direct fs write
|
|
631
|
+
// (unchanged). `exec` is injected so the install goes through the SAME dry-run/apply
|
|
632
|
+
// + logging path as every other command (task 2066).
|
|
633
|
+
function makeWriteFile({ apply, privileged = false, exec, log = console.log, remoteHost = null }) {
|
|
634
|
+
return function writeFile(p, body, { mode, sudo = false } = {}) {
|
|
635
|
+
if (remoteHost) {
|
|
636
|
+
// Remote file placement (task 2065): base64 the body, decode it ON the
|
|
637
|
+
// co-hosting box, and `tee` it into place — sudo-escalated when the
|
|
638
|
+
// destination (/etc/<slug>/…) needs root and we're privileged. base64 keeps
|
|
639
|
+
// arbitrary bytes/newlines out of the shell. It rides the box `exec` (which
|
|
640
|
+
// ssh's it), so it shares the same dry-run + logging + ssh path.
|
|
641
|
+
const dir = path.posix.dirname(p);
|
|
642
|
+
const sp = (sudo && privileged) ? 'sudo ' : '';
|
|
643
|
+
const b64 = Buffer.from(body, 'utf8').toString('base64');
|
|
644
|
+
const steps = [
|
|
645
|
+
`${sp}mkdir -p ${dir}`,
|
|
646
|
+
`printf %s '${b64}' | base64 -d | ${sp}tee ${p} >/dev/null`,
|
|
647
|
+
];
|
|
648
|
+
if (mode) steps.push(`${sp}chmod ${mode.toString(8)} ${p}`);
|
|
649
|
+
const cmd = steps.join(' && ');
|
|
650
|
+
if (!apply) {
|
|
651
|
+
log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''}) on ${remoteHost}`);
|
|
652
|
+
if (exec) exec(cmd);
|
|
653
|
+
return { dryRun: true };
|
|
654
|
+
}
|
|
655
|
+
exec(cmd);
|
|
656
|
+
log(` ✓ wrote ${p} (on ${remoteHost})`);
|
|
657
|
+
return { ok: true };
|
|
658
|
+
}
|
|
659
|
+
if (sudo && privileged) {
|
|
660
|
+
// lars can't write under /etc; stage in a temp file then place it as root.
|
|
661
|
+
const tmp = path.join(os.tmpdir(), `prov-${path.basename(p)}-${process.pid}`);
|
|
662
|
+
const installCmd = `sudo install -D ${mode ? `-m ${mode.toString(8)} ` : ''}${tmp} ${p}`;
|
|
663
|
+
if (!apply) {
|
|
664
|
+
log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''}) via temp + sudo install`);
|
|
665
|
+
if (exec) exec(installCmd);
|
|
666
|
+
return { dryRun: true };
|
|
667
|
+
}
|
|
668
|
+
fs.writeFileSync(tmp, body);
|
|
669
|
+
try { exec(installCmd); } finally { fs.rmSync(tmp, { force: true }); }
|
|
670
|
+
log(` ✓ wrote ${p} (via sudo install)`);
|
|
671
|
+
return { ok: true };
|
|
672
|
+
}
|
|
673
|
+
if (!apply) { log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''})`); return { dryRun: true }; }
|
|
674
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
675
|
+
fs.writeFileSync(p, body, mode ? { mode } : undefined);
|
|
676
|
+
if (mode) fs.chmodSync(p, mode);
|
|
677
|
+
log(` ✓ wrote ${p}`);
|
|
678
|
+
return { ok: true };
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
// Port allocation for a co-tenant instance: the lowest free port ≥ portBase not
|
|
684
|
+
// already taken by another instance row or a reserved port. PURE given the taken set.
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
function nextFreePort(takenPorts, { portBase = CONFIG.portBase, reserved = CONFIG.reservedPorts } = {}) {
|
|
687
|
+
const taken = new Set([...(takenPorts || []), ...reserved].map(Number));
|
|
688
|
+
let p = portBase;
|
|
689
|
+
while (taken.has(p)) p += 1;
|
|
690
|
+
return p;
|
|
691
|
+
}
|
|
692
|
+
// Ports held by a LISTENING socket on the box the instance will bind on. A
|
|
693
|
+
// co-tenant/standalone instance binds 127.0.0.1:<port>, so ANY process already
|
|
694
|
+
// listening there — not just another provisioned instance or a CONFIG reserved
|
|
695
|
+
// port — causes an EADDRINUSE crash-loop. Snapshot `ss` and return every listening
|
|
696
|
+
// port so allocatePort can exclude them all → robust to any co-located service
|
|
697
|
+
// (e.g. Grafana on the dedicated control plane, task 2070). `allowFail` degrades to
|
|
698
|
+
// the prior DB-only behavior if `ss` is unavailable rather than blocking provisioning.
|
|
699
|
+
function boundLocalPorts(exec) {
|
|
700
|
+
if (typeof exec !== 'function') return [];
|
|
701
|
+
let res;
|
|
702
|
+
try { res = exec('ss -ltnH 2>/dev/null || ss -ltn 2>/dev/null', { allowFail: true }); }
|
|
703
|
+
catch { return []; }
|
|
704
|
+
const ports = [];
|
|
705
|
+
for (const line of String((res && res.stdout) || '').split('\n')) {
|
|
706
|
+
const cols = line.trim().split(/\s+/);
|
|
707
|
+
if (cols.length < 4) continue; // skip header / blank lines
|
|
708
|
+
const m = /:(\d+)$/.exec(cols[3]); // Local Address:Port → 127.0.0.1:3003, *:80, [::1]:3003
|
|
709
|
+
if (m) ports.push(Number(m[1]));
|
|
710
|
+
}
|
|
711
|
+
return ports;
|
|
712
|
+
}
|
|
713
|
+
// Impure: reads the instance table AND (via the injected exec) the box's live
|
|
714
|
+
// listening ports, so a port squatted by a NON-instance co-tenant is never handed
|
|
715
|
+
// out. `exec` is the same executor provisionInstance uses, so for a remote co-tenant
|
|
716
|
+
// it snapshots the remote box (task 2065) — where the instance actually binds.
|
|
717
|
+
async function allocatePort(db, provisioning, { exec } = {}) {
|
|
718
|
+
const rows = await db.query(`SELECT port FROM provisioning_instances WHERE port IS NOT NULL`);
|
|
719
|
+
const dbPorts = rows.rows.map((r) => r.port);
|
|
720
|
+
return nextFreePort([...dbPorts, ...boundLocalPorts(exec)]);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
// Compose + run the standup runbook for ONE instance (idempotent, dry-run-safe).
|
|
725
|
+
// deps: { db, provisioning, doApi, exec, writeFile, log, apply, privileged }
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
async function provisionInstance(inst, deps) {
|
|
728
|
+
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
729
|
+
// The token step (Cloudflare DNS hook) runs on the control plane where the token
|
|
730
|
+
// lives — NEVER on the remote box (task 2065). Falls back to `exec` when a caller
|
|
731
|
+
// (e.g. a test) passes no controlExec — then both are the same local executor.
|
|
732
|
+
const controlExec = deps.controlExec || exec;
|
|
733
|
+
// Standalone instances run on the CONTROL PLANE (task 2074): scaffoldStandaloneRepo
|
|
734
|
+
// created their OWN repo dir here via controlExec, so the box-mutating steps below
|
|
735
|
+
// (code sync, migrate, /etc writes, systemd, Caddy, healthz) must run on the SAME
|
|
736
|
+
// box — NOT the remote co-hosting box that PROVISION_REMOTE_HOST routes co-tenant
|
|
737
|
+
// steps to (that box never had the dir; ADR 0130's remote-exec was co-tenant-only).
|
|
738
|
+
// So a standalone uses the control-plane executors; co-tenant + dedicated are
|
|
739
|
+
// unchanged (boxExec === exec, boxWriteFile === writeFile). When no remoteHost is
|
|
740
|
+
// set, controlExec === exec anyway, so this is a no-op for the all-local default.
|
|
741
|
+
const standalone = inst.hosting_shape === 'standalone';
|
|
742
|
+
const boxExec = standalone ? controlExec : exec;
|
|
743
|
+
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
744
|
+
const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
|
|
745
|
+
log(`provision ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
|
|
746
|
+
|
|
747
|
+
// Idempotency: an already-active instance is a no-op success (re-drained intent).
|
|
748
|
+
if (inst.status === 'active') { log(' already active — nothing to do'); return { ok: true, already: true }; }
|
|
749
|
+
|
|
750
|
+
// ── preflight: DNS token scope (task 1980) ─────────────────────────────────
|
|
751
|
+
// BEFORE committing to the address (allocating a port / creating a droplet / the
|
|
752
|
+
// step-5 DNS UPSERT), verify the control-plane Cloudflare token can actually reach
|
|
753
|
+
// the domain's zone. Turns the Emersonian standup's confusing mid-flow step-5 DNS
|
|
754
|
+
// failure into an up-front, actionable stop with no half-done provision. Read-only.
|
|
755
|
+
if (inst.domain && CONFIG.dnsHook) {
|
|
756
|
+
if (apply) {
|
|
757
|
+
// Injectable so the composition stays hermetic in tests (deps.checkDnsTokenScope
|
|
758
|
+
// stubs the CF call); production passes the real checker (network read-only).
|
|
759
|
+
const scope = await (deps.checkDnsTokenScope || checkDnsTokenScope)(inst.domain);
|
|
760
|
+
if (!scope.reachable) {
|
|
761
|
+
throw new Error(`DNS preflight failed for ${inst.domain}: ${scope.reason}. ${scope.guidance}`);
|
|
762
|
+
}
|
|
763
|
+
log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
|
|
764
|
+
} else {
|
|
765
|
+
log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
|
|
770
|
+
const patch = {};
|
|
771
|
+
|
|
772
|
+
// ── step 1: server ───────────────────────────────────────────────────────
|
|
773
|
+
// The A-record target for step 5's DNS UPSERT:
|
|
774
|
+
// • co-tenant → PROVISION_PUBLIC_IP (the box it runs on — under remote-exec that
|
|
775
|
+
// is the co-hosting box, ADR 0130).
|
|
776
|
+
// • standalone → the CONTROL PLANE's own IP (task 2074): it runs control-plane-
|
|
777
|
+
// local, so its domain must point HERE, not at the co-hosting box.
|
|
778
|
+
// PROVISION_CONTROL_PLANE_IP when remote-exec splits the two boxes;
|
|
779
|
+
// else PROVISION_PUBLIC_IP (all-local: one and the same machine).
|
|
780
|
+
// Remote-exec set but no control-plane IP configured → null, which
|
|
781
|
+
// makes step 5 fail loud rather than point the domain at the wrong box.
|
|
782
|
+
// • dedicated → the droplet's own IP (captured once it settles, below).
|
|
783
|
+
let recordIp;
|
|
784
|
+
if (inst.hosting_shape === 'dedicated') recordIp = null;
|
|
785
|
+
else if (standalone) recordIp = CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp);
|
|
786
|
+
else recordIp = CONFIG.publicIp;
|
|
787
|
+
if (inst.hosting_shape === 'dedicated') {
|
|
788
|
+
// Dedicated droplet: create it + settle its public IP via the shared DO client
|
|
789
|
+
// (do-api, dry-run-default). The on-droplet app standup rides cloud-init
|
|
790
|
+
// user-data built from the SAME step commands below (mirrors box.js). For the
|
|
791
|
+
// dry-run plan we surface the intent; --apply creates the droplet.
|
|
792
|
+
const doApi = deps.doApi;
|
|
793
|
+
log(' [server] dedicated droplet');
|
|
794
|
+
if (apply && doApi) {
|
|
795
|
+
const { droplet } = await doApi.createDroplet({
|
|
796
|
+
name: `cb-${inst.slug}`, region: CONFIG.region, size: inst.tier || 's-2vcpu-4gb',
|
|
797
|
+
image: CONFIG.baseImage, tags: [CONFIG.tag], sshKeys: CONFIG.sshKeyIds,
|
|
798
|
+
userData: dedicatedUserData(inst),
|
|
799
|
+
});
|
|
800
|
+
const settled = await doApi.settleDroplet(droplet.id);
|
|
801
|
+
patch.error_note = null;
|
|
802
|
+
recordIp = settled.ip || null; // the droplet's own IP is this instance's A-record target
|
|
803
|
+
log(` droplet ${droplet.id} @ ${settled.ip || '(pending)'} — app standup rides cloud-init`);
|
|
804
|
+
} else {
|
|
805
|
+
log(' would createDroplet + settle IP, baking cloud-init that runs the standup steps');
|
|
806
|
+
}
|
|
807
|
+
} else {
|
|
808
|
+
// Co-tenant OR standalone: a process bound to an allocated port on the box it runs
|
|
809
|
+
// on. Co-tenant shares that box's checkout ($0, cloudbongos.com model — under
|
|
810
|
+
// remote-exec, the co-hosting box); standalone (ADR 0108) runs from its OWN repo
|
|
811
|
+
// dir with a pinned @cloudbongos/core (synced in step 2) on the CONTROL PLANE.
|
|
812
|
+
// Allocate against the ports of whichever box it runs on (boxExec: remote for a
|
|
813
|
+
// co-tenant, local for a standalone — task 2074), then run the rest there.
|
|
814
|
+
if (!inst.port) {
|
|
815
|
+
const port = await allocatePort(db, provisioning, { exec: boxExec });
|
|
816
|
+
patch.port = port;
|
|
817
|
+
inst = { ...inst, port };
|
|
818
|
+
log(` [server] ${inst.hosting_shape} — allocated port ${port}`);
|
|
819
|
+
} else {
|
|
820
|
+
log(` [server] ${inst.hosting_shape} — port ${inst.port}`);
|
|
821
|
+
}
|
|
822
|
+
// Standalone: SCAFFOLD its own repo if absent — clone the (empty) target_ref →
|
|
823
|
+
// materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
|
|
824
|
+
// (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
|
|
825
|
+
// throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
|
|
826
|
+
if (inst.hosting_shape === 'standalone') {
|
|
827
|
+
await scaffoldStandaloneRepo(inst, deps);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (!inst.db_name) { patch.db_name = inst.slug; inst = { ...inst, db_name: inst.slug }; }
|
|
831
|
+
|
|
832
|
+
// The remaining steps run on the instance's box for co-tenant + standalone (boxExec/
|
|
833
|
+
// boxWriteFile — remote for co-tenant under remote-exec, control-plane-local for
|
|
834
|
+
// standalone; task 2074); for dedicated they are baked into cloud-init (above) and
|
|
835
|
+
// re-asserted here as idempotent no-ops on the box.
|
|
836
|
+
const local = inst.hosting_shape !== 'dedicated';
|
|
837
|
+
|
|
838
|
+
// ── step 2: code sync (standalone) + database ─────────────────────────────
|
|
839
|
+
if (local && standalone) {
|
|
840
|
+
// Standalone (ADR 0108 / deploy-instance.sh steps 1-2): sync the instance's own
|
|
841
|
+
// repo + install the pinned @cloudbongos/core, so the migrate + service below run
|
|
842
|
+
// from the INSTANCE root, not this control-plane checkout. cwd = standaloneRoot.
|
|
843
|
+
// boxExec = the control plane (where scaffoldStandaloneRepo just created the dir).
|
|
844
|
+
const root = standaloneRoot(inst);
|
|
845
|
+
log(' [code] standalone — pull instance repo + npm ci (pinned @cloudbongos/core)');
|
|
846
|
+
boxExec(standalonePullCmd(), { cwd: root });
|
|
847
|
+
boxExec(standaloneInstallCmd(), { cwd: root });
|
|
848
|
+
}
|
|
849
|
+
log(' [db] create + GDS-only migrate');
|
|
850
|
+
if (local) {
|
|
851
|
+
boxExec(dbCreateCmd(inst, { privileged }), { allowFail: true }); // "already exists" is fine
|
|
852
|
+
if (standalone) boxExec(standaloneMigrateCmd(inst, { privileged }), { cwd: standaloneRoot(inst) }); // instance-root migrate
|
|
853
|
+
else boxExec(migrateCmd(inst, { privileged }));
|
|
854
|
+
} else { log(' (runs via cloud-init on the droplet)'); }
|
|
855
|
+
|
|
856
|
+
// ── step 3: config + per-instance secrets ─────────────────────────────────
|
|
857
|
+
log(' [config] per-instance web.env');
|
|
858
|
+
if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst), { mode: 0o600, sudo: true });
|
|
859
|
+
else log(' (baked into cloud-init)');
|
|
860
|
+
|
|
861
|
+
// ── step 4: systemd unit + enable ─────────────────────────────────────────
|
|
862
|
+
log(' [service] install + enable systemd unit');
|
|
863
|
+
if (local) {
|
|
864
|
+
boxWriteFile(serviceUnitPath(inst), serviceUnit(inst), { mode: 0o644, sudo: true });
|
|
865
|
+
boxExec(`${sudoP}systemctl daemon-reload`);
|
|
866
|
+
boxExec(`${sudoP}systemctl enable --now ${inst.slug}`);
|
|
867
|
+
} else { log(' (unit baked into cloud-init)'); }
|
|
868
|
+
|
|
869
|
+
// ── step 5: DNS — Cloudflare A-record UPSERT for the instance's domain (P4) ──
|
|
870
|
+
// Formalized in P4 (#1945): the shared box-dns-cloudflare.sh hook UPSERTs an A
|
|
871
|
+
// record pointing the domain at its target IP (recordIp), in the domain's own
|
|
872
|
+
// zone, proxied=false so Caddy's on-demand ACME challenge (step 6) reaches the
|
|
873
|
+
// origin (dnsUpsertEnv). It runs on the CONTROL PLANE for BOTH shapes: the hook
|
|
874
|
+
// needs the Cloudflare token, which never leaves the control plane, so a dedicated
|
|
875
|
+
// instance's DNS is upserted from here (not baked into the droplet's cloud-init).
|
|
876
|
+
// Unlike P3's best-effort allowFail, a DNS FAILURE now SURFACES (the task-1180
|
|
877
|
+
// lesson: never report provision success while the A record silently didn't land).
|
|
878
|
+
if (inst.domain && CONFIG.dnsHook) {
|
|
879
|
+
if (!recordIp) {
|
|
880
|
+
const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
|
|
881
|
+
if (apply) throw new Error(`cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env, or the droplet must settle an IP (dedicated)`);
|
|
882
|
+
log(` [dns] would upsert A ${inst.domain} once the target IP is known (set ${ipEnvHint})`);
|
|
883
|
+
} else {
|
|
884
|
+
// Standalone runs control-plane-local under the *.cloudbongos.com wildcard origin
|
|
885
|
+
// cert (task 2074/2077) → proxied=true (CF edge fronts it, IP hidden); co-tenant +
|
|
886
|
+
// dedicated stay proxied=false so their on-demand ACME challenge reaches the origin.
|
|
887
|
+
const dnsEnv = dnsUpsertEnv(inst, { publicIp: recordIp, proxied: standalone });
|
|
888
|
+
log(` [dns] upsert A ${inst.domain} -> ${recordIp} (zone ${dnsEnv.CLOUDFLARE_ZONE}, proxied=${dnsEnv.BOX_PROXIED === '1'}) via ${CONFIG.dnsHook}`);
|
|
889
|
+
// controlExec, NOT exec: the DNS hook needs the Cloudflare token, which lives
|
|
890
|
+
// only on the control plane — it must never run on the (token-free) co-hosting
|
|
891
|
+
// box even when the box-mutating steps above went there over SSH (task 2065).
|
|
892
|
+
controlExec(CONFIG.dnsHook, { env: dnsEnv });
|
|
893
|
+
}
|
|
894
|
+
} else {
|
|
895
|
+
log(' [dns] skipped (no domain, or BOX_DNS_HOOK unset)');
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ── step 6: Caddy per-instance site snippet + reload ──────────────────────
|
|
899
|
+
// Write the instance's OWN <slug>.caddy snippet (idempotent overwrite via the
|
|
900
|
+
// injected writeFile — no read-modify-append to a shared Caddyfile), then reload.
|
|
901
|
+
// The main Caddyfile carries a one-time `import <snippetDir>/*.caddy` (operator
|
|
902
|
+
// setup). The on-demand-ACME TLS hardening of this block is task P4 (#1945).
|
|
903
|
+
log(' [caddy] write site snippet + reload');
|
|
904
|
+
if (local) {
|
|
905
|
+
boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
|
|
906
|
+
boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
|
|
907
|
+
} else { log(' (Caddy on the droplet via cloud-init)'); }
|
|
908
|
+
|
|
909
|
+
// ── step 7: healthz ───────────────────────────────────────────────────────
|
|
910
|
+
log(' [healthz] verify the instance answers');
|
|
911
|
+
if (local) boxExec(healthzCmd(inst), { allowFail: true });
|
|
912
|
+
else log(' (verified post-boot)');
|
|
913
|
+
|
|
914
|
+
// Mark active.
|
|
915
|
+
if (apply) {
|
|
916
|
+
await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, provisioned_at: new Date().toISOString() });
|
|
917
|
+
await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active', detail: `provisioned (${inst.hosting_shape}, port ${inst.port || '-'})`, actor: 'runner' });
|
|
918
|
+
}
|
|
919
|
+
log(` ✓ ${inst.slug} provisioned`);
|
|
920
|
+
return { ok: true, port: inst.port };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// The cloud-init user-data for a dedicated droplet: run the co-tenant standup
|
|
924
|
+
// steps on first boot. Kept small (well under DO's 64 KiB cap). Built from the
|
|
925
|
+
// SAME pure command generators so local + dedicated never diverge.
|
|
926
|
+
function dedicatedUserData(inst) {
|
|
927
|
+
const i = { ...inst, db_name: inst.db_name || inst.slug };
|
|
928
|
+
return `#cloud-config
|
|
929
|
+
runcmd:
|
|
930
|
+
- [ bash, -lc, "${dbCreateCmd(i)} || true" ]
|
|
931
|
+
- [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
|
|
932
|
+
- [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug}" ]
|
|
933
|
+
`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// Teardown one instance (reverse the runbook; idempotent).
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
async function teardownInstance(inst, deps) {
|
|
940
|
+
const { db, provisioning, exec, log, apply, privileged } = deps;
|
|
941
|
+
// Tear down on the box the instance actually RAN on: a standalone ran control-plane-
|
|
942
|
+
// local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
|
|
943
|
+
// box, where it was never installed (that would allowFail-noop and leave the real
|
|
944
|
+
// control-plane unit running). Co-tenant + dedicated are unchanged.
|
|
945
|
+
const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
|
|
946
|
+
const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
|
|
947
|
+
log(`teardown ${inst.slug} (status=${inst.status})`);
|
|
948
|
+
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
|
|
949
|
+
if (inst.hosting_shape !== 'dedicated') {
|
|
950
|
+
boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
|
|
951
|
+
boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
|
|
952
|
+
boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
|
|
953
|
+
// DB + Caddy block are left for an explicit operator (data is precious; a
|
|
954
|
+
// teardown should not silently dropdb). We log the manual follow-ups.
|
|
955
|
+
log(` (left for the operator: dropdb ${dbName(inst)}, remove the Caddy block for ${inst.slug})`);
|
|
956
|
+
} else if (apply && deps.doApi && inst.droplet_id) {
|
|
957
|
+
await deps.doApi.deleteDroplet(inst.droplet_id);
|
|
958
|
+
}
|
|
959
|
+
if (apply) {
|
|
960
|
+
await provisioning.setInstanceStatus(db, inst.id, 'torn_down', { torn_down_at: new Date().toISOString() });
|
|
961
|
+
await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'torn_down', detail: 'torn down by runner', actor: 'runner' });
|
|
962
|
+
}
|
|
963
|
+
log(` ✓ ${inst.slug} torn down`);
|
|
964
|
+
return { ok: true };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ---------------------------------------------------------------------------
|
|
968
|
+
// GitHub App Manifest conversion + cred placement (task 2080, ADR 0133).
|
|
969
|
+
// The web tier stored GitHub's temporary `code` on a provisioning_oauth_manifests
|
|
970
|
+
// row (status 'code_received'); THIS runner — the sole token-holder — exchanges it
|
|
971
|
+
// for the client creds and places them in the instance's web.env, then verifies
|
|
972
|
+
// /healthz. The secret is fetched here and written straight to disk: it never
|
|
973
|
+
// touches the web tier or the browser (ADR 0111 §2 / ADR 0016 trust boundary).
|
|
974
|
+
// ---------------------------------------------------------------------------
|
|
975
|
+
|
|
976
|
+
// Exchange GitHub's manifest `code` for the App's client creds. UNAUTHENTICATED by
|
|
977
|
+
// design — the single-use code IS the auth (GitHub docs). `fetchImpl` injectable for
|
|
978
|
+
// tests. Throws on a non-2xx or a response missing client_id/client_secret.
|
|
979
|
+
async function convertManifestCode(code, { fetchImpl = globalThis.fetch, userAgent = MANIFEST_UA } = {}) {
|
|
980
|
+
if (!code) throw new Error('no manifest code to convert');
|
|
981
|
+
const res = await fetchImpl(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
|
|
982
|
+
method: 'POST',
|
|
983
|
+
headers: {
|
|
984
|
+
Accept: 'application/vnd.github+json',
|
|
985
|
+
'User-Agent': userAgent,
|
|
986
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
const text = await res.text();
|
|
990
|
+
let data; try { data = JSON.parse(text); } catch { data = null; }
|
|
991
|
+
if (!res.ok) {
|
|
992
|
+
const msg = (data && (data.message || data.error)) || `HTTP ${res.status}`;
|
|
993
|
+
throw new Error(`GitHub manifest conversion failed: ${msg}`);
|
|
994
|
+
}
|
|
995
|
+
if (!data || !data.client_id || !data.client_secret) {
|
|
996
|
+
throw new Error('GitHub manifest conversion returned no client_id/client_secret');
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
clientId: String(data.client_id),
|
|
1000
|
+
clientSecret: String(data.client_secret),
|
|
1001
|
+
appId: data.id != null ? Number(data.id) : null,
|
|
1002
|
+
appSlug: data.slug || null,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Convert one manifest row's code → creds and PLACE them (rewrite web.env + restart +
|
|
1007
|
+
// verify /healthz). Uses the SAME executors provisionInstance does, so co-tenant (local
|
|
1008
|
+
// or remote-cohost) and standalone (control-plane-local) are handled identically. A
|
|
1009
|
+
// dedicated droplet bakes web.env into cloud-init and has no post-hoc exec path here, so
|
|
1010
|
+
// it is refused with guidance. Returns { ok, appId, appSlug } | { ok:false, error }.
|
|
1011
|
+
async function placeManifestCreds(inst, mrow, deps) {
|
|
1012
|
+
const { log, apply } = deps;
|
|
1013
|
+
if (inst.hosting_shape === 'dedicated') {
|
|
1014
|
+
return { ok: false, error: 'GitHub App Manifest cred placement is not supported for a dedicated droplet yet — run oauth-secret.js on the droplet instead' };
|
|
1015
|
+
}
|
|
1016
|
+
if (!mrow.github_code) return { ok: false, error: 'manifest row carries no github_code to convert' };
|
|
1017
|
+
const standalone = inst.hosting_shape === 'standalone';
|
|
1018
|
+
const boxExec = standalone ? (deps.controlExec || deps.exec) : deps.exec;
|
|
1019
|
+
const boxWriteFile = standalone ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
|
|
1020
|
+
const sudoP = deps.privileged ? 'sudo ' : '';
|
|
1021
|
+
|
|
1022
|
+
// 1) exchange the code with GitHub (control-plane-side; the secret lands here only).
|
|
1023
|
+
let creds;
|
|
1024
|
+
if (apply) {
|
|
1025
|
+
creds = await (deps.convertManifestCode || convertManifestCode)(mrow.github_code, {});
|
|
1026
|
+
} else {
|
|
1027
|
+
log(` [oauth-manifest] would POST api.github.com/app-manifests/<code>/conversions for ${inst.slug}`);
|
|
1028
|
+
creds = { clientId: '0'.repeat(20), clientSecret: '0'.repeat(40), appId: null, appSlug: null };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// 2) validate the returned creds BEFORE writing (catch a malformed GitHub response —
|
|
1032
|
+
// the same length/charset guard oauth-secret.js uses against silent truncation).
|
|
1033
|
+
const idV = oauthSecret.validateClientId(creds.clientId);
|
|
1034
|
+
const secV = oauthSecret.validateClientSecret(creds.clientSecret);
|
|
1035
|
+
if (!idV.ok || !secV.ok) {
|
|
1036
|
+
const why = [!idV.ok ? `id: ${idV.reason}` : null, !secV.ok ? `secret: ${secV.reason}` : null].filter(Boolean).join('; ');
|
|
1037
|
+
return { ok: false, error: `GitHub returned unusable creds (${why})` };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
|
|
1041
|
+
// writeFile logs only a byte count; the value is base64/temp-file placed).
|
|
1042
|
+
log(` [oauth-manifest] place creds in ${webEnvPath(inst)} + restart ${inst.slug}`);
|
|
1043
|
+
boxWriteFile(webEnvPath(inst), webEnvBody(inst, { clientId: creds.clientId, clientSecret: creds.clientSecret }), { mode: 0o600, sudo: true });
|
|
1044
|
+
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
1045
|
+
|
|
1046
|
+
// 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
|
|
1047
|
+
if (apply) {
|
|
1048
|
+
let authConfigured = false;
|
|
1049
|
+
for (let i = 0; i < 10; i++) {
|
|
1050
|
+
const out = boxExec(healthzCmd(inst), { allowFail: true });
|
|
1051
|
+
if (oauthSecret.parseHealthzAuth(out && out.stdout).authConfigured) { authConfigured = true; break; }
|
|
1052
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1053
|
+
}
|
|
1054
|
+
if (!authConfigured) {
|
|
1055
|
+
return { ok: false, error: 'placed creds but /healthz still reports auth_configured=false — sign-in not live yet' };
|
|
1056
|
+
}
|
|
1057
|
+
log(' [oauth-manifest] ✓ /healthz auth_configured=true — sign-in is live');
|
|
1058
|
+
} else {
|
|
1059
|
+
log(' [oauth-manifest] would probe /healthz for auth_configured after restart');
|
|
1060
|
+
}
|
|
1061
|
+
return { ok: true, appId: creds.appId, appSlug: creds.appSlug };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Drain provisioning_oauth_manifests rows awaiting conversion (status 'code_received').
|
|
1065
|
+
// Runs on the SAME timer tick as the intent queue (called from cmdRunIntents). Each row
|
|
1066
|
+
// is claimed atomically (status → 'converting', SKIP LOCKED) so overlapping ticks are
|
|
1067
|
+
// safe; a retryable failure resets it to 'code_received' for a future tick until the
|
|
1068
|
+
// attempt cap, then 'error'. A `seen` set stops a hot re-claim loop within one tick.
|
|
1069
|
+
async function drainOAuthManifests(deps) {
|
|
1070
|
+
const { db, provisioning, log, apply } = deps;
|
|
1071
|
+
if (!apply) {
|
|
1072
|
+
const { rows } = await db.query(
|
|
1073
|
+
`SELECT m.id, i.slug FROM provisioning_oauth_manifests m
|
|
1074
|
+
JOIN provisioning_instances i ON i.id = m.instance_id
|
|
1075
|
+
WHERE m.status = 'code_received' ORDER BY m.created_at`);
|
|
1076
|
+
if (rows.length) {
|
|
1077
|
+
log(`oauth-manifest runner (dry-run): ${rows.length} awaiting conversion`);
|
|
1078
|
+
for (const r of rows) log(` would convert + place creds for ${r.slug} (manifest #${r.id})`);
|
|
1079
|
+
}
|
|
1080
|
+
return { converted: 0, errors: 0 };
|
|
1081
|
+
}
|
|
1082
|
+
let converted = 0, errors = 0;
|
|
1083
|
+
const seen = new Set();
|
|
1084
|
+
for (;;) {
|
|
1085
|
+
const mrow = await provisioning.claimNextOAuthManifest(db);
|
|
1086
|
+
if (!mrow) break;
|
|
1087
|
+
if (seen.has(mrow.id)) {
|
|
1088
|
+
// Already tried this row this tick (a retryable failure reset it). Put it back and
|
|
1089
|
+
// stop, so a persistent failure retries on the NEXT tick instead of hot-looping.
|
|
1090
|
+
await provisioning.resolveOAuthManifest(db, mrow.id, 'code_received', {}).catch(() => {});
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
seen.add(mrow.id);
|
|
1094
|
+
if (mrow.attempts > MAX_INTENT_ATTEMPTS) {
|
|
1095
|
+
await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: `exceeded ${MAX_INTENT_ATTEMPTS} attempts` });
|
|
1096
|
+
errors++; log(` ✖ oauth-manifest #${mrow.id} retired (too many attempts)`); continue;
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
const inst = await provisioning.getInstanceById(db, mrow.instance_id);
|
|
1100
|
+
if (!inst) { await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: 'instance row vanished' }); errors++; continue; }
|
|
1101
|
+
const result = await placeManifestCreds(inst, mrow, deps);
|
|
1102
|
+
if (result && result.ok === false) {
|
|
1103
|
+
if (mrow.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error });
|
|
1104
|
+
else await provisioning.resolveOAuthManifest(db, mrow.id, 'code_received', { last_error: result.error });
|
|
1105
|
+
errors++; log(` ✖ oauth-manifest #${mrow.id}: ${result.error}`);
|
|
1106
|
+
} else {
|
|
1107
|
+
await provisioning.resolveOAuthManifest(db, mrow.id, 'done', { app_id: result.appId, app_slug: result.appSlug });
|
|
1108
|
+
await provisioning.recordEvent(db, {
|
|
1109
|
+
instanceId: inst.id, ownerBuilderId: mrow.requested_by, event: 'oauth-manifest-done',
|
|
1110
|
+
detail: `sign-in wired via GitHub App Manifest (app ${result.appSlug || result.appId || '?'})`, actor: 'runner',
|
|
1111
|
+
}).catch(() => {});
|
|
1112
|
+
converted++;
|
|
1113
|
+
}
|
|
1114
|
+
} catch (e) {
|
|
1115
|
+
await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: e.message }).catch(() => {});
|
|
1116
|
+
errors++; log(` ✖ oauth-manifest #${mrow.id}: ${e.message}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (converted || errors) log(`oauth-manifest — converted=${converted} errors=${errors}`);
|
|
1120
|
+
return { converted, errors };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
// run-intents — drain provisioning_intents (the systemd-timer entry point). The
|
|
1125
|
+
// box.js cmdRunIntents shape: dry-run lists the pending queue; --apply claims +
|
|
1126
|
+
// executes each, with a poison-attempt cap. FOR UPDATE SKIP LOCKED (in
|
|
1127
|
+
// provisioning.claimNextIntent) makes overlapping ticks safe. It ALSO drains the
|
|
1128
|
+
// GitHub-App-Manifest conversion queue on the same tick (task 2080).
|
|
1129
|
+
// ---------------------------------------------------------------------------
|
|
1130
|
+
async function cmdRunIntents(deps) {
|
|
1131
|
+
const { db, provisioning, log, apply } = deps;
|
|
1132
|
+
if (!apply) {
|
|
1133
|
+
const { rows } = await db.query(
|
|
1134
|
+
`SELECT pi.*, i.slug FROM provisioning_intents pi
|
|
1135
|
+
JOIN provisioning_instances i ON i.id = pi.instance_id
|
|
1136
|
+
WHERE pi.state = 'pending' ORDER BY pi.created_at`);
|
|
1137
|
+
log(`provision intent runner (dry-run): ${rows.length} pending intent(s)`);
|
|
1138
|
+
for (const r of rows) log(` would ${r.action} ${r.slug} (intent #${r.id}, attempts=${r.attempts})`);
|
|
1139
|
+
if (rows.length === 0) log(' queue empty — exit clean');
|
|
1140
|
+
await drainOAuthManifests(deps);
|
|
1141
|
+
return { ran: 0 };
|
|
1142
|
+
}
|
|
1143
|
+
let ran = 0, ok = 0, errors = 0;
|
|
1144
|
+
for (;;) {
|
|
1145
|
+
const intent = await provisioning.claimNextIntent(db);
|
|
1146
|
+
if (!intent) break;
|
|
1147
|
+
ran++;
|
|
1148
|
+
if (intent.attempts > MAX_INTENT_ATTEMPTS) {
|
|
1149
|
+
await provisioning.resolveIntent(db, intent.id, 'error', `exceeded ${MAX_INTENT_ATTEMPTS} attempts`);
|
|
1150
|
+
errors++; log(` ✖ intent #${intent.id} retired (too many attempts)`); continue;
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
const inst = await provisioning.getInstanceById(db, intent.instance_id);
|
|
1154
|
+
if (!inst) { await provisioning.resolveIntent(db, intent.id, 'error', 'instance row vanished'); errors++; continue; }
|
|
1155
|
+
const result = intent.action === 'provision'
|
|
1156
|
+
? await provisionInstance(inst, deps)
|
|
1157
|
+
: await teardownInstance(inst, deps);
|
|
1158
|
+
if (result && result.ok === false) {
|
|
1159
|
+
if (intent.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
|
|
1160
|
+
else await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
|
|
1161
|
+
errors++;
|
|
1162
|
+
} else { await provisioning.resolveIntent(db, intent.id, 'done', null); ok++; }
|
|
1163
|
+
} catch (e) {
|
|
1164
|
+
await provisioning.resolveIntent(db, intent.id, 'error', e.message).catch(() => {});
|
|
1165
|
+
// Surface the failure on the instance too, so a stuck instance is visible.
|
|
1166
|
+
await provisioning.setInstanceStatus(db, intent.instance_id, 'error', { error_note: e.message }).catch(() => {});
|
|
1167
|
+
errors++; log(` ✖ intent #${intent.id}: ${e.message}`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
// Same tick: convert + place any GitHub-App-Manifest creds awaiting the control plane.
|
|
1171
|
+
const m = await drainOAuthManifests(deps);
|
|
1172
|
+
log(`done — ran=${ran} ok=${ok} errors=${errors}; oauth-manifest converted=${m.converted} errors=${m.errors}`);
|
|
1173
|
+
return { ran, ok, errors, manifests: m };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// A light drift reconcile: surface instances stuck mid-flight (provisioning /
|
|
1177
|
+
// tearing_down) with no open intent — a runner died mid-op. Reports; --apply
|
|
1178
|
+
// re-enqueues a fresh intent so the next tick retries. (Full droplet-vs-DB drift,
|
|
1179
|
+
// the box.js reconcile-drift analogue, is a later hardening.)
|
|
1180
|
+
async function cmdReconcile(deps) {
|
|
1181
|
+
const { db, provisioning, log, apply } = deps;
|
|
1182
|
+
const { rows } = await db.query(
|
|
1183
|
+
`SELECT i.* FROM provisioning_instances i
|
|
1184
|
+
WHERE i.status IN ('provisioning','tearing_down')
|
|
1185
|
+
AND NOT EXISTS (SELECT 1 FROM provisioning_intents pi
|
|
1186
|
+
WHERE pi.instance_id = i.id AND pi.state IN ('pending','running'))`);
|
|
1187
|
+
log(`reconcile: ${rows.length} instance(s) stuck mid-flight with no open intent`);
|
|
1188
|
+
for (const inst of rows) {
|
|
1189
|
+
const action = inst.status === 'tearing_down' ? 'teardown' : 'provision';
|
|
1190
|
+
log(` ${inst.slug} [${inst.status}] → re-enqueue ${action}`);
|
|
1191
|
+
if (apply) await provisioning.enqueueIntent(db, inst.id, action, 'runner:reconcile');
|
|
1192
|
+
}
|
|
1193
|
+
return { stuck: rows.length };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function cmdStatus(deps) {
|
|
1197
|
+
const { db, log } = deps;
|
|
1198
|
+
const { rows } = await db.query(
|
|
1199
|
+
`SELECT i.slug, i.status, i.hosting_shape, i.port, i.domain, i.cost_estimate_usd, b.github_login
|
|
1200
|
+
FROM provisioning_instances i JOIN builders b ON b.id = i.owner_builder_id
|
|
1201
|
+
ORDER BY i.updated_at DESC LIMIT 200`);
|
|
1202
|
+
if (rows.length === 0) { log('no provisioned instances yet.'); return { count: 0 }; }
|
|
1203
|
+
log('provisioned instances:');
|
|
1204
|
+
let monthly = 0;
|
|
1205
|
+
for (const r of rows) {
|
|
1206
|
+
monthly += Number(r.cost_estimate_usd || 0);
|
|
1207
|
+
log(` ${String(r.slug).padEnd(20)} ${String(r.status).padEnd(13)} ${r.hosting_shape.padEnd(10)} port=${r.port || '-'} @${r.github_login} $${Number(r.cost_estimate_usd || 0).toFixed(2)}/mo ${r.domain || ''}`);
|
|
1208
|
+
}
|
|
1209
|
+
log(`\n estimated run-rate: ~$${monthly.toFixed(2)}/mo`);
|
|
1210
|
+
return { count: rows.length };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// ---------------------------------------------------------------------------
|
|
1214
|
+
// CLI
|
|
1215
|
+
// ---------------------------------------------------------------------------
|
|
1216
|
+
function usage() {
|
|
1217
|
+
console.log(`provision.js — instance-provisioning control-plane runner (ADR 0111, #1944)
|
|
1218
|
+
|
|
1219
|
+
Usage:
|
|
1220
|
+
node scripts/gds/provision.js <command> [slug] [--apply]
|
|
1221
|
+
|
|
1222
|
+
Commands:
|
|
1223
|
+
run-intents drain provisioning_intents (the systemd-timer entry point):
|
|
1224
|
+
execute each pending provision/teardown intent
|
|
1225
|
+
provision <slug> provision one instance by slug (composes the standup runbook)
|
|
1226
|
+
teardown <slug> tear down one instance by slug
|
|
1227
|
+
reconcile re-enqueue instances stuck mid-flight with no open intent
|
|
1228
|
+
status list provisioned instances + estimated run-rate
|
|
1229
|
+
dns-check <domain> PREFLIGHT: verify the control-plane Cloudflare token can reach
|
|
1230
|
+
the domain's zone BEFORE the address step (network-only, no DB)
|
|
1231
|
+
|
|
1232
|
+
DRY-RUN by default — nothing mutates without --apply. The LIVE leg (real createdb /
|
|
1233
|
+
migrate / systemctl / caddy / DigitalOcean) needs the DO/Cloudflare tokens from
|
|
1234
|
+
/etc/<inst>/box.env (systemd EnvironmentFile=) and runs on the control plane only.
|
|
1235
|
+
|
|
1236
|
+
Set PROVISION_SUDO=1 (the control-plane runner unit does) to escalate the privileged
|
|
1237
|
+
steps via sudo — required when the runner runs as a non-root user (ADR 0128).
|
|
1238
|
+
|
|
1239
|
+
Set PROVISION_REMOTE_HOST=<user@host> (+ PROVISION_REMOTE_CHECKOUT, PROVISION_PUBLIC_IP)
|
|
1240
|
+
to run the box-mutating steps on a REMOTE co-hosting box over ssh, so the control
|
|
1241
|
+
plane can orchestrate co-tenant workloads that live there without that box holding
|
|
1242
|
+
any cloud token (ADR 0130). The Cloudflare DNS hook always stays on the control plane.
|
|
1243
|
+
|
|
1244
|
+
STANDALONE instances (their own repo dir, ADR 0108) run control-plane-LOCAL even under
|
|
1245
|
+
PROVISION_REMOTE_HOST — their scaffolded dir lives on the control plane, not the remote
|
|
1246
|
+
box (task 2074). Set PROVISION_CONTROL_PLANE_IP to this box's own public IP so a
|
|
1247
|
+
standalone instance's A record points HERE, not at the co-hosting box's PROVISION_PUBLIC_IP.`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function main() {
|
|
1251
|
+
const args = process.argv.slice(2);
|
|
1252
|
+
const cmd = args[0];
|
|
1253
|
+
const apply = hasFlag(args, '--apply');
|
|
1254
|
+
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { usage(); process.exit(0); }
|
|
1255
|
+
|
|
1256
|
+
// dns-check <domain> — the DNS token-scope PREFLIGHT (task 1980). Network-only + no
|
|
1257
|
+
// DB, so onboarding can run it BEFORE the connect-address step (and before any
|
|
1258
|
+
// provision). Exits 0 if the token can reach the domain's zone, 1 with guidance if not.
|
|
1259
|
+
if (cmd === 'dns-check') {
|
|
1260
|
+
const domain = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1261
|
+
if (!domain) { console.error('dns-check needs a <domain>'); process.exit(2); }
|
|
1262
|
+
const scope = await checkDnsTokenScope(domain);
|
|
1263
|
+
if (scope.reachable) { console.log(`✓ DNS token can reach zone ${scope.zone} for ${domain} — ${scope.reason}`); process.exit(0); }
|
|
1264
|
+
console.error(`✖ DNS token cannot reach zone ${scope.zone} for ${domain}: ${scope.reason}`);
|
|
1265
|
+
console.error(` → ${scope.guidance}`);
|
|
1266
|
+
process.exit(1);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const known = ['run-intents', 'provision', 'teardown', 'reconcile', 'status'];
|
|
1270
|
+
if (!known.includes(cmd)) { console.error(`unknown command: ${cmd}\n`); usage(); process.exit(2); }
|
|
1271
|
+
const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1272
|
+
if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
|
|
1273
|
+
|
|
1274
|
+
let pool, provisioning, DoApi;
|
|
1275
|
+
try { ({ pool, provisioning, DoApi } = loadDeps()); }
|
|
1276
|
+
catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
|
|
1277
|
+
|
|
1278
|
+
// Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
|
|
1279
|
+
// local) for the box-mutating steps; controlExec is ALWAYS local — the token
|
|
1280
|
+
// step (Cloudflare DNS hook) + the standalone repo scaffold run on the control
|
|
1281
|
+
// plane where the token + packaging tools live (task 2065, ADR 0130).
|
|
1282
|
+
const exec = makeExec({ apply, remoteHost: CONFIG.remoteHost, remoteCwd: CONFIG.remoteCwd, sshOpts: CONFIG.sshOpts });
|
|
1283
|
+
const controlExec = makeExec({ apply });
|
|
1284
|
+
const deps = {
|
|
1285
|
+
db: pool, provisioning, apply, privileged: CONFIG.privileged, log: console.log,
|
|
1286
|
+
exec, controlExec,
|
|
1287
|
+
writeFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec, remoteHost: CONFIG.remoteHost }),
|
|
1288
|
+
controlWriteFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec: controlExec }),
|
|
1289
|
+
doApi: DoApi ? new DoApi({ dryRun: !apply, token: CONFIG.doToken, log: (m) => console.log(' ' + m) }) : null,
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
let result;
|
|
1293
|
+
try {
|
|
1294
|
+
if (cmd === 'run-intents') result = await cmdRunIntents(deps);
|
|
1295
|
+
else if (cmd === 'provision') { const inst = await provisioning.getInstanceBySlug(pool, slug); if (!inst) { console.error(`no instance with slug '${slug}'`); await pool.end().catch(() => {}); process.exit(1); } result = await provisionInstance(inst, deps); }
|
|
1296
|
+
else if (cmd === 'teardown') { const inst = await provisioning.getInstanceBySlug(pool, slug); if (!inst) { console.error(`no instance with slug '${slug}'`); await pool.end().catch(() => {}); process.exit(1); } result = await teardownInstance(inst, deps); }
|
|
1297
|
+
else if (cmd === 'reconcile') result = await cmdReconcile(deps);
|
|
1298
|
+
else if (cmd === 'status') result = await cmdStatus(deps);
|
|
1299
|
+
} catch (e) {
|
|
1300
|
+
console.error('fatal:', e && e.message ? e.message : e);
|
|
1301
|
+
await pool.end().catch(() => {});
|
|
1302
|
+
process.exit(1);
|
|
1303
|
+
}
|
|
1304
|
+
await pool.end().catch(() => {});
|
|
1305
|
+
process.exit(result && result.ok === false ? 1 : 0);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if (require.main === module) main();
|
|
1309
|
+
|
|
1310
|
+
module.exports = {
|
|
1311
|
+
CONFIG, MAX_INTENT_ATTEMPTS,
|
|
1312
|
+
// pure step generators (tested directly)
|
|
1313
|
+
dbName, dbCreateCmd, migrateCmd, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath,
|
|
1314
|
+
standaloneRoot, standalonePullCmd, standaloneInstallCmd, standaloneMigrateCmd,
|
|
1315
|
+
// standalone scaffold (ADR 0108 / 0125, task 2055)
|
|
1316
|
+
parseTargetRef, instanceRepoRemote, instanceInitSpec, scaffoldStandaloneRepo, coreVersionSafe,
|
|
1317
|
+
zoneForDomain, dnsUpsertEnv, classifyZoneScope, checkDnsTokenScope,
|
|
1318
|
+
caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
|
|
1319
|
+
// executors + orchestration (injectable deps for tests)
|
|
1320
|
+
makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
|
|
1321
|
+
cmdRunIntents, cmdReconcile, cmdStatus,
|
|
1322
|
+
// GitHub App Manifest flow (task 2080, ADR 0133)
|
|
1323
|
+
convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
|
|
1324
|
+
};
|