diff --git a/.llm/runs/beta5-impl--supervisor/slices/347-deploy/context-pack.md b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/context-pack.md new file mode 100644 index 000000000..51ff00e96 --- /dev/null +++ b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/context-pack.md @@ -0,0 +1,36 @@ +# Context Pack + +Issue #347 S11 scope is CI/CD template generation plus Aspire deployment state/secret hardening. The branch is `feat/347-deploy-s11` at baseline `37e6818c4f7871d7d6f051c90e9734ec45fb4566`. + +Key facts: + +- Workflow templates do not exist in the CLI asset manifest yet. +- Scaffold root generation in `packages/cli/src/kernel/application/scaffold/plan-init.ts` is the planned emission point. +- Aspire CLI `deploy` supports `--clear-cache`; Aspire docs say cache files under `~/.aspire/deployments/{AppHostSha}/{environment}.json` may contain plaintext secrets and `--clear-cache` does not save prompted values. +- Current shipped deploy targets in issue scope are Deno Deploy, compose/docker via Aspire, and bare-metal compile/service. + +Planned implementation: + +1. Add three `.github/workflows/*.yml` templates as CLI assets and emit them during init. Done: + default Aspire-backed init emits Compose/GHCR, Deno Deploy, and bare-metal workflows; + `--no-aspire` omits the Aspire-dependent Compose/GHCR workflow. +2. Adjust Aspire compose/docker deploy behavior so generated CI can use `--clear-cache`, + `--non-interactive`, and explicit `--environment`. Done: router flags are generic and + the Aspire adapter forwards supported args. +3. Update generated README/site docs with CI workflow usage and dev -> staging -> prod promotion. + Done, including the plaintext `~/.aspire/deployments` warning and CI non-persistence guidance. +4. Run focused tests, scoped wrappers, `deno task arch:check`, full `deno task check`, and full + `deno task test`. Done. `deno task e2e:cli` was not run in this implementation slice; supervisor + merge-readiness owns `deno task e2e:cli run scaffold.runtime --cleanup --format pretty`. + +Plan-Gate status: first PLAN-EVAL returned `FAIL_PLAN`; plan gate set updated; second PLAN-EVAL returned `PASS`. Implementation may proceed. + +Final validation: + +- Focused deploy/scaffold/generator tests: 30 passed. +- `rtk proxy deno task --cwd packages/cli test`: 311 passed, 0 failed. +- `run-deno-doc-lint --root packages/cli`: 0 errors. +- `rtk proxy deno task publish:dry-run`: passed. +- `rtk proxy deno task check`: 2102 selected files, 18 batches, 0 occurrences. +- `rtk proxy deno task test`: 1527 passed, 0 failed, 12 ignored. +- `rtk proxy deno task arch:check`: exit 0; existing warnings only. diff --git a/.llm/runs/beta5-impl--supervisor/slices/347-deploy/drift.md b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/drift.md new file mode 100644 index 000000000..f86b34898 --- /dev/null +++ b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/drift.md @@ -0,0 +1,3 @@ +# Drift + +No drift recorded yet. diff --git a/.llm/runs/beta5-impl--supervisor/slices/347-deploy/notes.md b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/notes.md new file mode 100644 index 000000000..2d23bc7e2 --- /dev/null +++ b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/notes.md @@ -0,0 +1,9 @@ +# Notes + +- If a planned item conflicts with the actual checkout, stop that item and record the conflict here. +- No file-level collision with sibling slices #346 or #348 observed during initial research. +- PR label note: requested `type:feature` does not exist in `.github/labels.yml` or GitHub; draft PR #490 uses existing taxonomy label `type:feat`. +- `deno task e2e:cli` intentionally not run by this implementation slice; supervisor owns the + `scaffold.runtime --cleanup --format pretty` merge-readiness smoke per handoff. +- `rtk proxy deno task arch:check` exited 0 but emitted existing WARN/INFO items in unrelated + package/plugin doctrine readiness checks; no S11 item stopped on those warnings. diff --git a/.llm/runs/beta5-impl--supervisor/slices/347-deploy/plan-eval.md b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/plan-eval.md new file mode 100644 index 000000000..0ca552379 --- /dev/null +++ b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/plan-eval.md @@ -0,0 +1,40 @@ +# PLAN-EVAL - beta5-impl--supervisor / 347-deploy + +- Plan evaluator session: Codex second PLAN-EVAL, 2026-07-06 +- Run: `beta5-impl--supervisor` +- Surface / archetype: Issue #347 S11, Archetype 7 Deployment Target Adapter composed with Archetype 6 CLI/tooling and Archetype 2 Aspire integration concerns +- Scope overlays: Docs overlay; Aspire cache/deploy behavior +- Prior PLAN-EVAL: first pass returned `FAIL_PLAN`; this pass evaluates the corrected artifacts only. + +## Checklist results + +| Plan-Gate item | Result | Evidence / location | +| --- | --- | --- | +| Research present and current | PASS | `research.md` exists and has `## Re-Baseline Findings` R1-R10 against current tree/docs/CLI behavior. Spot-check: asset manifest, root scaffold asset loader, and root scaffold planning paths match R3-R5 and plan D1-D2. | +| Decisions locked | PASS | `plan.md` `## Locked Decisions` D1-D6 locks asset location, scaffold write point, workflow set, Aspire cache behavior, promotion model, and deploy-router thinness. | +| Open-decision sweep | PASS | `plan.md` marks the new CI-generation command, encrypted Aspire cache, and live cloud deployment validation safe to defer. Evaluator sweep found no additional rework-forcing open decision. | +| Commit slices (< 30, gate + files each) | PASS | `plan.md` lists four ordered slices S11-A through S11-D; each names what it proves, files touched, and gates. | +| Risk register | PASS | `plan.md` lists YAML drift, generated barrel churn, Aspire non-interactive prompting, and docs overclaiming risks with mitigations. | +| Gate set selected | PASS | `plan.md` now selects static gates, universal fitness evidence, A6 F-CLI manual/PENDING_SCRIPT evidence backed by `deno task arch:check`, A7 F-DEPLOY reviewed evidence, consumer scaffold-output validation, and conditional public-surface doc-lint/publish dry-run. The required `scaffold.runtime` e2e gate is assigned to supervisor merge-readiness while preserving the explicit instruction not to run `deno task e2e:cli` in this implementation slice. | +| Deferred scope explicit | PASS | `plan.md` and `worklog.md` defer the new CI command, encrypted cache/state provider, live deployments, and sibling #346/#348 scope. | +| jsr-audit surface scan (pkg/plugin) | PASS | `research.md` states the slice changes CLI internals/scaffold output, not exports or `mod.ts`; `plan.md` requires doc-lint/publish dry-run if implementation unexpectedly changes exports or public symbols. | + +## Open-decision sweep (evaluator-run) + +None beyond the plan's listed deferrals. The corrected e2e placement is not an open decision: the user explicitly forbade `deno task e2e:cli` in the implementation slice, and the plan assigns the required scaffold runtime gate to the supervisor merge-readiness pass. + +## Verdict + +`PASS` + +Implementation may proceed. + +### If FAIL_PLAN - required fixes + +N/A. + +## Notes + +- No implementation gates were run. +- No product code was edited. +- This verdict replaces the first `FAIL_PLAN` after confirming its required fixes were addressed. diff --git a/.llm/runs/beta5-impl--supervisor/slices/347-deploy/plan.md b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/plan.md new file mode 100644 index 000000000..24fe971ad --- /dev/null +++ b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/plan.md @@ -0,0 +1,77 @@ +# Plan + +## Archetype + +- **Selected:** Archetype 7 — Deployment Target Adapter. +- **Composed surfaces:** Archetype 6 CLI/tooling (`packages/cli` scaffold generation and deploy adapter), with Archetype 2 integration concerns for the Aspire target adapter. +- **Scope overlays:** Docs overlay for generated README/site docs; Aspire skill applies to `aspire deploy` cache behavior. +- **Current doctrine verdict:** `@netscript/cli` is still a restructure package in doctrine file 10; new work must not deepen existing AP-1/AP-21 debt. + +## Locked Decisions + +| ID | Decision | Rationale | +| --- | --- | --- | +| D1 | Add workflow templates as checked-in CLI template assets under `packages/cli/src/kernel/assets/workspace/github/workflows/`. | Keeps generation in the existing asset registry instead of embedding long YAML strings in application code. | +| D2 | Extend `loadRootScaffoldTemplateAssets()` and `scaffoldRoot()` to write `.github/workflows/deploy-*.yml`. | Root scaffold planning already owns root files and directories; workflows are generated project root files. | +| D3 | Generate three workflows: compose/GHCR, Deno Deploy push, and bare-metal compile artifact. | Matches the issue's shipped-target template list exactly. | +| D4 | Do not cache `~/.aspire/deployments` in generated CI; use `aspire deploy --clear-cache --non-interactive --environment ` where Aspire deployment is needed. | Aspire docs state cache files can contain plaintext secrets and `--clear-cache` does not save prompted values. | +| D5 | Document environment promotion as explicit environment names and protected GitHub environments: development -> staging -> production. | Aspire already keys deployment state by `--environment`; GitHub environments provide approval/secret boundaries. | +| D6 | Keep target-specific business logic out of the deploy router. | S11 templates and adapter options belong in assets/adapters, not `deploy-group.ts`. | + +## Open-Decision Sweep + +| Decision | Status | Why Safe | +| --- | --- | --- | +| Whether to add a new `netscript generate ci` command | Safe to defer | Acceptance does not require a command; init-time scaffold templates satisfy generated templates without expanding CLI surface. | +| Whether to encrypt Aspire deployment cache | Safe to defer | Aspire's documented safe CI path for this slice is not persisting the cache. Encryption would require a separate state-store contract beyond S11. | +| Whether live cloud deployment is validated | Safe to defer | Credentials are unavailable in this slice; unit tests assert argv and generated templates. Supervisor owns expensive/runtime E2E. | + +## Commit Slices + +| Slice | Proves | Files | Gates | +| --- | --- | --- | --- | +| S11-A | Workflow templates are registered and emitted by scaffold root generation. | `packages/cli/src/kernel/assets/manifest.ts`, new `packages/cli/src/kernel/assets/workspace/github/workflows/*.yml.template`, `packages/cli/src/kernel/assets/embedded.generated.ts`, `packages/cli/src/kernel/adapters/templates/scaffold-template-assets.ts`, `packages/cli/src/kernel/application/scaffold/plan-init.ts`, focused scaffold/template tests. | Focused Deno tests; scoped check/lint/fmt for `packages/cli/src/kernel/{assets,adapters/templates,application/scaffold,templates/workspace}`; consumer check that scaffold root output contains all three workflow files. | +| S11-B | Aspire deploy CI path avoids plaintext cache persistence and records environment promotion behavior. | `packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target.ts`, `packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target_test.ts`, docs. | Focused adapter test; scoped check/lint/fmt on adapter root; F-DEPLOY-2 reviewed evidence that router remains thin and cache policy stays in adapter/template docs. | +| S11-C | Generated and site docs describe workflow usage, cache hardening, and dev -> staging -> prod promotion. | `packages/cli/src/kernel/templates/workspace/generate-readme.ts`, `packages/cli/src/kernel/templates/workspace/generators_test.ts`, `docs/site/how-to/deploy.md` or focused deployment doc. | Focused generator tests; docs validation when available; reviewed evidence that docs do not overclaim live cloud deployment. | +| S11-D | Final validation and PR trail. | Run artifacts under this slice dir. | Touched-root wrappers, focused tests, full `deno task check`, full `deno task test`, `deno task arch:check` for universal/F-CLI/F-DEPLOY reviewed evidence, consumer scaffold/import validation. Supervisor merge-readiness triggers `deno task e2e:cli run scaffold.runtime --cleanup --format pretty`; this implementation slice does not run it per briefing. | + +## Risk Register + +| Risk | Mitigation | +| --- | --- | +| YAML templates drift from generated project commands. | Keep workflows conservative and reference existing CLI commands; test emitted files include required trigger, secret, and command lines. | +| Generated asset barrel churn is broad. | Use `deno task gen:assets-barrel` and inspect diff; do not hand-edit generated barrel. | +| Aspire `--clear-cache` still prompts on missing values in CI. | Generated workflow supplies required values through GitHub environment secrets and uses `--non-interactive`; docs warn not to cache plaintext state. | +| Docs claim more target maturity than code supports. | State exact current target behavior: Deno Deploy command, Aspire compose/docker adapter, bare-metal compile workflow. | + +## Gate Set + +- Static gates: + - scoped check/lint/fmt wrappers on touched TypeScript roots with `--ext ts,tsx`; + - focused Deno tests for template generation, scaffold emission, generated README text, and Aspire adapter argv; + - full `deno task check`; + - full `deno task test`. +- Universal fitness evidence: + - `deno task arch:check` as the mechanical doctrine gate backing F-1, F-3, F-5, F-10, F-11, F-12, F-15, F-16, F-17, F-18, and related CLI package checks; + - manual reviewed evidence for F-2/F-4/F-8/F-9/F-14/F-19 where scripts do not directly cover this slice or the gate is not touched. +- Archetype 6 evidence: + - F-CLI-1 through F-CLI-31 recorded as `PENDING_SCRIPT` with manual/structural evidence, backed by `deno task arch:check`; + - explicit F-CLI-21/F-CLI-22/F-CLI-24 evidence for workflow templates under `kernel/assets/**` and manifest/embedded asset consistency; + - explicit F-CLI-27/F-DEPLOY-2 evidence that no target-specific business logic is added to the deploy command router. +- Archetype 7 evidence: + - F-DEPLOY-1 reviewed evidence that first-party target registrations still advertise the supported uniform-operation subset; + - F-DEPLOY-2 reviewed evidence that CI/cache/secrets conventions live in assets/adapters/docs, not in router logic. +- Consumer validation: + - generated scaffold root emits `.github/workflows/deploy-compose-ghcr.yml`, `.github/workflows/deploy-deno-deploy.yml`, and `.github/workflows/deploy-bare-metal.yml`; + - generated workflow command lines reference existing public CLI/deploy commands and avoid caching `~/.aspire/deployments`. +- Release/merge-readiness gate: + - because this slice changes scaffold output, the supervisor merge-readiness pass must trigger the one-pass `deno task e2e:cli run scaffold.runtime --cleanup --format pretty`; + - this implementation slice must not run `deno task e2e:cli` during implementation, per briefing. +- Public surface doc-lint/publish dry-run only if implementation changes package exports or public symbols. + +## Deferred Scope + +- New `netscript generate ci` command. +- Encrypted Aspire deployment-state cache or custom state-store provider. +- Live GHCR, Deno Deploy, Azure, or remote host deployment. +- Sibling issue #346/#348 scope. diff --git a/.llm/runs/beta5-impl--supervisor/slices/347-deploy/research.md b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/research.md new file mode 100644 index 000000000..7cc5a8fbb --- /dev/null +++ b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/research.md @@ -0,0 +1,35 @@ +# Research + +## Issue Scope + +Issue #347 requires exactly: + +- generated GitHub Actions workflow templates per shipped deploy target; +- Aspire deployment-state caching no longer persists plaintext secrets in CI, with documented handling; +- a documented promotion path between Aspire environments `dev -> staging -> prod`. + +Dependencies #342 and #343 are closed, so the slice is unblocked. Parent epic is #327. + +## Re-Baseline Findings + +| ID | Finding | Evidence | +| --- | --- | --- | +| R1 | The current deploy surface already has the canonical 7-op target contract. | `deno doc packages/cli/src/kernel/domain/deploy/deploy-target-port.ts` shows `plan`, `emit`, `up`, `down`, `status`, `logs`, `rollback`, `secrets`, plus legacy aliases. | +| R2 | Shipped deploy targets in scope are `deno-deploy`, `compose`, and bare-metal compile/OS-service. | `packages/config/src/domain/schemas/deploy-schema.ts` documents `deno-deploy`, `docker`/`compose`, `linux`, and `windows`; issue scope names compose -> GHCR, Deno Deploy push, and bare-metal compile. | +| R3 | Workflow templates are absent from the CLI asset manifest today. | `packages/cli/src/kernel/assets/manifest.ts` has workspace, app, Aspire, database, service, plugin, and Windows env templates, but no `.github/workflows/*` keys. | +| R4 | Root scaffold planning is the correct write point for generated workflows. | `packages/cli/src/kernel/application/scaffold/plan-init.ts` writes root-level `.gitignore`, `README.md`, `deno.json`, `netscript.config.ts`, and uses `loadRootScaffoldTemplateAssets()`. | +| R5 | Embedded template content is generated and must not be hand-edited. | `packages/cli/src/kernel/assets/embedded.generated.ts` says to run `deno task gen:assets-barrel`; `.llm/tools/generate-cli-assets-barrel.ts` reads `TEMPLATE_MANIFEST`. | +| R6 | The Aspire compose adapter delegates `plan`/`emit` to `aspire publish --output-path` and docker `up` to `aspire deploy`. | `packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target.ts`. | +| R7 | Aspire CLI `deploy` supports `--clear-cache`, and help states it clears deployment cache and does not save deployment state. | `aspire deploy --help` output on 2026-07-06. | +| R8 | Aspire docs confirm deployment cache files contain plaintext secrets and live at `~/.aspire/deployments/{AppHostSha}/{environment}.json`; `--clear-cache` performs a deployment without persisting new values. | `aspire docs get deployment-state-caching` on 2026-07-06. | +| R9 | Existing deploy secrets convention already centralizes env-file rendering and restricted permissions. | `packages/cli/src/kernel/domain/deploy/secrets-convention.ts` and `packages/cli/src/kernel/adapters/secrets/env-file-secrets-store.ts`. | +| R10 | Current docs under `docs/site/how-to/deploy.md` still describe Docker/Compose/Linux as config-schema only, which conflicts with the now-landed target adapters. | `docs/site/how-to/deploy.md` lines found by `rtk rg` mention no runnable `netscript deploy docker|compose|linux` verb. | + +## Open Questions + +- OQ1: Whether a separate CLI command should generate workflows on demand or workflows should be emitted by `netscript init`. Plan locks to init-time generated templates because the acceptance says "Generated GH Actions workflow templates" and the existing scaffold asset system is the established generation path. +- OQ2: Whether to preserve Aspire deployment cache in CI with encryption. Plan locks to no plaintext cache persistence for generated CI by using `aspire deploy --clear-cache --non-interactive --environment ` and no `actions/cache` over `~/.aspire/deployments`. + +## JSR / Public Surface Scan + +This slice changes `packages/cli` internals and scaffold output, not package exports or `mod.ts`. No new public TypeScript export is planned. Publish-surface risk is limited to generated asset registration and generated docs text; `deno task publish:dry-run` is not required by the issue unless implementation unexpectedly changes export maps or public symbols. diff --git a/.llm/runs/beta5-impl--supervisor/slices/347-deploy/supervisor.md b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/supervisor.md new file mode 100644 index 000000000..28913a4ce --- /dev/null +++ b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/supervisor.md @@ -0,0 +1,17 @@ +# Slice 347 Deploy S11 + +- **Run:** `beta5-impl--supervisor` +- **Slice:** `347-deploy` +- **Issue:** #347 `[Deploy-S11] CI/CD template generation + state/secret hardening` +- **Worktree:** `/home/codex/repos/netscript-347-deploy` +- **Branch:** `feat/347-deploy-s11` +- **Baseline:** `37e6818c4f7871d7d6f051c90e9734ec45fb4566` +- **Generator session:** WSL Codex implementation slice +- **Evaluator separation:** PLAN-EVAL and IMPL-EVAL must be separate sessions. +- **PR policy:** Draft PR to `main`, push only with `git push origin HEAD:refs/heads/feat/347-deploy-s11`. + +## Lane Notes + +- This slice is a Tier-D implementation lane under the supervisor run. +- The slice writes artifacts only under this directory. +- Sibling slices #346 and #348 are out of scope. diff --git a/.llm/runs/beta5-impl--supervisor/slices/347-deploy/worklog.md b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/worklog.md new file mode 100644 index 000000000..b6d2e9236 --- /dev/null +++ b/.llm/runs/beta5-impl--supervisor/slices/347-deploy/worklog.md @@ -0,0 +1,87 @@ +# Worklog + +## Design + +### Public Surface + +- Generated project files: + - `.github/workflows/deploy-compose-ghcr.yml` + - `.github/workflows/deploy-deno-deploy.yml` + - `.github/workflows/deploy-bare-metal.yml` +- Existing CLI deploy target adapter behavior: + - `AspireComposeDeployTarget` shells `aspire publish` / `aspire deploy`. +- No new package exports or command names are planned. + +### Domain Vocabulary + +- **Deploy workflow template:** checked-in `.template` asset emitted into generated projects. +- **Promotion environment:** `development`, `staging`, `production`, mapped to Aspire `--environment` and GitHub Environments. +- **CI-safe Aspire deploy:** deploy invocation that uses `--clear-cache` and does not persist `~/.aspire/deployments`. +- **Shipped targets:** `compose`, `deno-deploy`, bare-metal compile/OS-service. + +### Ports + +- Existing `ProcessPort` remains the Aspire adapter side-effect seam. +- Existing scaffold `context.scaffolder` remains the generated-file write seam. +- No new external port is planned. + +### Constants + +- Workflow asset keys will be added to `TEMPLATE_KEYS`. +- Workflow target filenames: + - `deploy-compose-ghcr.yml` + - `deploy-deno-deploy.yml` + - `deploy-bare-metal.yml` +- Aspire promotion environments: + - `development` + - `staging` + - `production` + +### Commit Slices + +1. S11-A: register and emit generated workflow templates; prove with focused scaffold/template tests. +2. S11-B: harden Aspire adapter CI cache behavior; prove `aspire deploy` argv includes `--clear-cache`/environment controls where needed. +3. S11-C: docs and generated README promotion guidance; prove with generator tests. +4. S11-D: final validation, commit trail, and slice completion. + +### Deferred Scope + +- New CLI generation command. +- Encrypted persisted Aspire cache. +- Live deploy validation. +- Sibling deploy slices. + +### Contributor Path + +To add a future workflow template, add a `.template` file under `packages/cli/src/kernel/assets/workspace/github/workflows/`, register it in `TEMPLATE_KEYS`, expose it through `loadRootScaffoldTemplateAssets()`, write it from `scaffoldRoot()`, regenerate `embedded.generated.ts`, and add a focused scaffold test asserting the emitted path and key lines. + +## Evidence + +| Time | Action | Result | +| --- | --- | --- | +| 2026-07-06 | Loaded required skills and harness docs. | Complete. | +| 2026-07-06 | Fetched issue #347 with `gh issue view 347 --repo rickylabs/netscript --json title,body`. | Scope confirmed. | +| 2026-07-06 | Checked branch/status. | On `feat/347-deploy-s11`, clean worktree before planning. | +| 2026-07-06 | Ran `aspire deploy --help`, `aspire publish --help`, and `aspire docs get deployment-state-caching`. | Confirmed `--clear-cache` and plaintext cache docs. | +| 2026-07-06 | Separate PLAN-EVAL returned `FAIL_PLAN`. | Gate set omitted full A7/A6 evidence and scaffold runtime merge-readiness gate. | +| 2026-07-06 | Updated plan gate set. | Added universal fitness, F-CLI, F-DEPLOY, consumer validation, and supervisor-owned `scaffold.runtime` merge-readiness gate while preserving the implementation-slice instruction not to run `e2e:cli`. | +| 2026-07-06 | Separate PLAN-EVAL second pass returned `PASS`. | `plan-eval.md` updated from evaluator-provided PASS content. | +| 2026-07-06 | Implemented workflow template assets and scaffold emission. | Default Aspire-backed init emits Compose/GHCR, Deno Deploy, and bare-metal GitHub Actions templates; `--no-aspire` omits the Aspire-backed Compose/GHCR workflow. | +| 2026-07-06 | Implemented deploy target CI flags. | `deploy ` accepts `--environment`, `--non-interactive`, and `--clear-cache`; Aspire adapter forwards environment/non-interactive to publish/deploy and clear-cache to `aspire deploy`. | +| 2026-07-06 | Updated docs/readme generation. | Generated README and `docs/site/how-to/deploy.md` document generated workflows, `development -> staging -> production` promotion, and non-persistence of `~/.aspire/deployments` in CI. | +| 2026-07-06 | Ran focused tests. | `deno test --allow-all packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target_test.ts packages/cli/src/public/features/deploy/target/target-deploy-command_test.ts packages/cli/src/kernel/application/scaffold/plan-init_test.ts packages/cli/src/kernel/templates/workspace/generators_test.ts` passed: 30 passed. | +| 2026-07-06 | Ran scoped check/lint/fmt evidence. | 12 touched CLI TS files clean via scoped `run-deno-check`, direct `deno lint --no-config --rules-exclude=no-import-prefix`, and direct `deno fmt --check --no-config --single-quote=true --line-width 100 --indent-width 2`. | +| 2026-07-06 | Ran affected package tests. | `rtk proxy deno task --cwd packages/cli test` passed: 311 passed, 0 failed. | +| 2026-07-06 | Ran public surface gates. | `run-deno-doc-lint --root packages/cli` passed with 0 errors after re-exporting `CacheBackendChoice`; `rtk proxy deno task publish:dry-run` passed. | +| 2026-07-06 | Ran final repo validation. | `rtk proxy deno task check` passed: 2102 files, 18 batches, 0 occurrences. `rtk proxy deno task test` passed: 1527 passed, 0 failed, 12 ignored. | +| 2026-07-06 | Ran architecture gate. | `rtk proxy deno task arch:check` exited 0 with existing WARN/INFO items outside this slice. | +| 2026-07-06 | Fixed adversarial review caveat 1. | Replaced generated/docs `deploy compose emit` with the routed `deploy compose plan` verb in the source templates, embedded asset mirror, and deploy how-to. | +| 2026-07-06 | Fixed adversarial review caveat 2. | Replaced generated bare-metal `deploy build --deploy-dir` with the real `--output-dir` flag in the source template and embedded asset mirror. | +| 2026-07-06 | Closed scaffold test-honesty gap. | Added a parser-backed scaffold test that extracts every emitted `netscript deploy ...` workflow invocation and parses it through `createDeployCommand()` with no-op deploy adapters. | +| 2026-07-06 | Ran caveat-fix focused validation. | Focused scaffold test passed: 3 passed. Affected CLI test set passed: 39 passed. Scoped check on 4 changed TS files passed. Stale-string grep for `deploy compose emit` and `--deploy-dir` returned no matches. | +| 2026-07-06 | Ran caveat-fix package/root/public validation. | `rtk proxy deno task --cwd packages/cli check` passed. `rtk proxy deno task --cwd packages/cli test` passed: 312 passed, 0 failed. `rtk proxy deno task check` passed: 2102 files, 18 batches, 0 occurrences. `rtk proxy deno task test` passed: 1528 passed, 0 failed, 12 ignored. `deno doc --lint mod.ts scaffolding.ts testing.ts` from `packages/cli` passed. `rtk proxy deno task publish:dry-run` passed with existing dynamic-import warnings. | + +## Reconcile Notes + +- Pre-implementation reconcile: no product code changed; issue #347 scope is unblocked by closed #342/#343 per issue body. +- Implementation reconcile: no file-level sibling collision observed; no `deno.lock` churn; `deno task e2e:cli` intentionally not run per slice instructions. diff --git a/docs/site/how-to/deploy.md b/docs/site/how-to/deploy.md index d9bc1155a..0ae0cc4c4 100644 --- a/docs/site/how-to/deploy.md +++ b/docs/site/how-to/deploy.md @@ -12,26 +12,49 @@ next: { label: "Deploy to Deno Deploy", href: "/how-to/deploy-deno-deploy/" } other than your laptop — a container host, a VM, or a managed platform — with clear expectations about what the scaffold wires for you and what you still own. -This is a task recipe, not a one-click button. NetScript gives you a single declarative description -of every process (`appsettings.json`), runnable Deno entrypoints with explicit permissions, and the -Aspire AppHost that can publish deployment artifacts for targets you configure in that AppHost. The -CLI has thin deploy routers for Deno Deploy, Docker/Compose, Kubernetes, Azure, and Cloud Run, but -target-specific infrastructure still lives in Aspire and your cloud account. +This is a task recipe, not a one-click button. NetScript is in alpha, and the scaffold is +deliberately minimal about deployment: it gives you a single declarative description of every +process (`appsettings.json`), runnable Deno entrypoints with explicit permissions, the Aspire +AppHost that orchestrates them locally and can publish deployment artifacts for targets you +configure in it, and starter GitHub Actions workflows for the shipped deployment targets. The CLI +has thin deploy routers for Deno Deploy, Docker/Compose, Kubernetes, Azure, and Cloud Run, but it +does **not** generate a `Dockerfile`, a `docker-compose.yml`, or a finished cloud infrastructure +stack for you — target-specific infrastructure still lives in Aspire and your cloud account, +assembled from the verified facts below. {{ comp callout { type: "important", title: "What is wired vs. what is manual" } }} Wired: a declarative resource graph in appsettings.json (ports, entrypoints, permissions, env vars, dependencies), runnable per-process Deno entrypoints, and the Aspire AppHost (aspire/apphost.mts) for local orchestration. +Generated CI starters: .github/workflows/deploy-compose-ghcr.yml, +.github/workflows/deploy-deno-deploy.yml, and +.github/workflows/deploy-bare-metal.yml. Delegated: Docker/Compose, Kubernetes, and Azure target commands call aspire publish, aspire deploy, and aspire destroy against the generated AppHost code. Aspire emits the manifests and provisions supported resources; your cloud account, cluster credentials, registry access, and RBAC remain yours. Cloud Run follows the Docker-image provider lane: Docker builds and pushes the image, then gcloud run deploy applies it. +Manual: there is no generated container image, compose file, or finished +cloud infrastructure stack. netscript.config.ts ships an empty deploy: {} +block unless you configure a target. You assemble the production target yourself from the primitives +below — every one of which is a verified fact you can copy verbatim.
Migration (#337): Windows deploy settings now live under deploy.targets.windows (previously deploy.windows). {{ /comp }} +{{ comp callout { type: "tip", title: "Generated CI follows the shipped targets" } }} +The scaffolded workflows cover the deploy surfaces that ship today: +Deno Deploy (netscript deploy deno-deploy up), +Compose/GHCR (netscript deploy compose plan + +netscript deploy docker up), and bare metal +(netscript deploy build). They are intentionally starter pipelines: fill in repository +secrets, GitHub environment protection, host credentials, and target-specific configuration before +you treat them as production release jobs. See +Deploy to Deno Deploy for the managed-platform command +reference. +{{ /comp }} + {{ comp callout { type: "tip", title: "Managed deployment commands" } }} There is now a first-class, runnable managed-platform path: netscript deploy deno-deploy <op> (plan/up/ @@ -92,7 +115,7 @@ first-party plugins installed, the graph looks like this: {{ comp.apiTable({ caption: "Resources declared in appsettings.json (verified from a scaffolded workspace)", rows: [ { name: "users", type: "service · :3000 (the scaffold default; the exact port is OS-allocated from the SERVICE range starting at 3000)", desc: "Example oRPC service. Entrypoint src/main.ts, runtime deno. RPC mounts under /api/rpc/*." }, - { name: "streams", type: "plugin · :4437", desc: "durable-streams runtime service. RequiresDb=false, RequiresKv=false. Real producer runtime — see Step 5." }, + { name: "streams", type: "plugin · :4437", desc: "durable-streams runtime service. RequiresDb=false, RequiresKv=false. Real producer runtime — see Step 6." }, { name: "workers-api", type: "plugin · :8091", desc: "Workers API. Requires DB + KV. References streams." }, { name: "sagas-api", type: "plugin · :8092", desc: "Sagas API. Requires DB + KV. References workers-api, streams." }, { name: "triggers-api", type: "plugin · :8093", desc: "Triggers API. Typed v1 oRPC contract for trigger/event introspection + management; the webhook ingress endpoint POST /api/v1/webhooks/:triggerId stays a raw HMAC-verifying route by design. Requires DB + KV. References workers-api, streams." }, @@ -288,7 +311,33 @@ export default defineConfig({ / .`, `docker push /`, then `gcloud run deploy --image / --quiet`. -## Step 5 — Run a process by hand (the bare-metal primitive) +## Step 5 — Wire generated CI and promotion + +New Aspire-backed workspaces include three GitHub Actions starter workflows under +`.github/workflows/`: + +{{ comp.apiTable({ caption: "Generated deployment workflows", rows: [ + { name: "deploy-compose-ghcr.yml", type: "Compose + GHCR", desc: "Restores the Aspire AppHost, emits Compose output with netscript deploy compose plan, builds/pushes images to GHCR, then runs netscript deploy docker up with --clear-cache." }, + { name: "deploy-deno-deploy.yml", type: "Deno Deploy", desc: "Runs workspace checks, then calls netscript deploy deno-deploy up using GitHub secrets and variables for the Deno Deploy token, organization, and app." }, + { name: "deploy-bare-metal.yml", type: "Bare metal", desc: "Compiles service artifacts with netscript deploy build on Linux and Windows runners, then uploads the output as workflow artifacts for host-specific installation." } +] }) }} + +Treat the workflow `environment` input as the promotion ladder: +`development` first, then `staging`, then `production`. In GitHub, map those names to protected +environments and keep secrets environment-scoped so a staging run cannot accidentally read +production credentials. Promote the same reviewed commit through each environment; do not rebuild +from a different branch between staging and production. + +{{ comp callout { type: "warning", title: "Do not persist Aspire deployment state in CI" } }} +Aspire deployment state is cached under ~/.aspire/deployments/{AppHostSha}/{environment}.json +and may contain plaintext values entered during deployment prompts. The generated Compose/GHCR +workflow does not cache ~/.aspire/deployments and passes --clear-cache +to netscript deploy docker up, which forwards it to aspire deploy --clear-cache. +Keep that behavior in CI. If you need durable values, store them in GitHub environment secrets or +your platform's secret manager, not in the Aspire deployment cache. +{{ /comp }} + +## Step 6 — Run a process by hand (the bare-metal primitive) Under every option above, the atomic unit is the same: one Deno process started from an entrypoint with the exact permission set from `appsettings.json`. This is what a container @@ -321,8 +370,9 @@ deployment. To containerize, each process becomes one image whose `CMD` is the m {{ comp callout { type: "warning", title: "Limits of the alpha scaffold" } }}
    -
  • Docker/Compose, Kubernetes, and Azure artifacts are generated by Aspire from the AppHost code you configure; NetScript does not hand-author those manifests in the CLI.
  • +
  • The CLI scaffold does not hand-author a Dockerfile, docker-compose.yml, or Kubernetes manifest. Docker/Compose, Kubernetes, and Azure artifacts are generated by Aspire from the AppHost code you configure; you write anything Aspire does not emit from the appsettings.json facts above.
  • Cloud Run uses the Docker-image provider lane (docker build, docker push, gcloud run deploy) and requires registry plus imageName in config.
  • +
  • netscript.config.ts ships an empty deploy: {} block unless you configure a target. Generated workflows are starter CI definitions; you still provide registry, host, cloud, and secret-manager configuration before they are production release jobs.
  • Cluster/cloud auth, registry access, RBAC, subscriptions, regions, and provider quotas are operator prerequisites. The CLI shells to Aspire, Docker, or gcloud; it does not create credentials.
  • The streams service runs a real producer runtime (durable-streams over @netscript/plugin-streams-core, served on :4437) — deploy it as a first-class service. What is not production-ready is the manifest-helper layer: @netscript/plugin-streams's defineStreamProducer/defineStreamConsumer throw StreamUnsupportedOperationError by design (use @netscript/plugin-streams-core directly), and there is no in-process consumer subscribe() — consumption is HTTP/SSE.
  • The scaffold worker createJobTools(ctx) handler helpers (trace.addEvent, withChildSpan, progress) are still no-op stubs (tracked debt, fix planned). Job dispatch/execution traces still appear in Aspire automatically; for custom handler spans call @netscript/telemetry helpers directly.
  • @@ -333,7 +383,7 @@ deployment. To containerize, each process becomes one image whose `CMD` is the m {{ /comp }} -## Step 6 — Verify the deployment +## Step 7 — Verify the deployment Once your processes are up against real backing services, hit the health endpoints to confirm the graph is wired. These are the exact routes the local runtime exposes (substitute your diff --git a/packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target.ts b/packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target.ts index 3e993809f..5463cb82e 100644 --- a/packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target.ts +++ b/packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target.ts @@ -82,7 +82,8 @@ export class AspireComposeDeployTarget implements DeployTargetPort { this.#process = options.process; this.#aspireBin = options.aspireBin ?? 'aspire'; this.#dockerBin = options.dockerBin ?? 'docker'; - this.#defaultOutputDir = options.defaultOutputDir ?? join('.deploy', 'compose'); + this.#defaultOutputDir = options.defaultOutputDir ?? + join('.deploy', 'compose'); this.#composeFileName = options.composeFileName ?? 'docker-compose.yaml'; } @@ -103,8 +104,10 @@ export class AspireComposeDeployTarget implements DeployTargetPort { */ async up(request: DeployTargetRequest): Promise { if (this.key === 'docker') { - const result = await this.#exec('up', request, this.#aspireBin, ['deploy']); - return this.#result('up', 'aspire deploy', result); + const args = ['deploy', ...this.#aspireCommonArgs(request)]; + if (request.clearCache) args.push('--clear-cache'); + const result = await this.#exec('up', request, this.#aspireBin, args); + return this.#result('up', args.join(' '), result); } const outputDir = this.#outputDir(request); const result = await this.#exec('up', request, this.#dockerBin, [ @@ -138,7 +141,11 @@ export class AspireComposeDeployTarget implements DeployTargetPort { this.#composeFile(outputDir), 'ps', ]); - return this.#result('status', result.stdout.trim() || 'docker compose ps', result); + return this.#result( + 'status', + result.stdout.trim() || 'docker compose ps', + result, + ); } /** Tail deployment logs via `docker compose logs` on the emitted project. */ @@ -151,7 +158,11 @@ export class AspireComposeDeployTarget implements DeployTargetPort { 'logs', '--no-color', ]); - return this.#result('logs', result.stdout.trim() || 'docker compose logs', result); + return this.#result( + 'logs', + result.stdout.trim() || 'docker compose logs', + result, + ); } async #publish( @@ -159,12 +170,21 @@ export class AspireComposeDeployTarget implements DeployTargetPort { request: DeployTargetRequest, ): Promise { const outputDir = this.#outputDir(request); - const result = await this.#exec(operation, request, this.#aspireBin, [ + const args = [ 'publish', '--output-path', outputDir, - ]); - return this.#result(operation, `aspire publish --output-path ${outputDir}`, result); + ...this.#aspireCommonArgs(request), + ]; + const result = await this.#exec(operation, request, this.#aspireBin, args); + return this.#result(operation, args.join(' '), result); + } + + #aspireCommonArgs(request: DeployTargetRequest): string[] { + const args: string[] = []; + if (request.environment) args.push('--environment', request.environment); + if (request.nonInteractive) args.push('--non-interactive'); + return args; } #outputDir(request: DeployTargetRequest): string { @@ -181,7 +201,9 @@ export class AspireComposeDeployTarget implements DeployTargetPort { command: string, args: readonly string[], ): Promise { - const result = await this.#process.exec(command, args, { cwd: request.projectRoot }); + const result = await this.#process.exec(command, args, { + cwd: request.projectRoot, + }); if (result.code !== 0) { const detail = (result.stderr || result.stdout).trim(); throw new Error( diff --git a/packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target_test.ts b/packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target_test.ts index d8df97e53..b59f5ff9a 100644 --- a/packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target_test.ts +++ b/packages/cli/src/kernel/adapters/aspire/aspire-compose-deploy-target_test.ts @@ -32,10 +32,20 @@ const request = { projectRoot: '/proj', outputDir: '.deploy/compose' } as const; Deno.test('compose adapter declares the supported subset and omits rollback/secrets', () => { const process = new FakeProcess(); - const adapter: DeployTargetPort = new AspireComposeDeployTarget({ key: 'compose', process }); + const adapter: DeployTargetPort = new AspireComposeDeployTarget({ + key: 'compose', + process, + }); assertEquals(adapter.key, 'compose'); - assertEquals(adapter.operations, ['plan', 'emit', 'up', 'down', 'status', 'logs']); + assertEquals(adapter.operations, [ + 'plan', + 'emit', + 'up', + 'down', + 'status', + 'logs', + ]); assertEquals(adapter.rollback, undefined); assertEquals(adapter.secrets, undefined); }); @@ -48,9 +58,37 @@ Deno.test('plan/emit delegate to `aspire publish --output-path` (authors no YAML await adapter.emit(request); assertEquals(process.calls[0].command, 'aspire'); - assertEquals(process.calls[0].args, ['publish', '--output-path', '.deploy/compose']); + assertEquals(process.calls[0].args, [ + 'publish', + '--output-path', + '.deploy/compose', + ]); assertEquals(process.calls[0].cwd, '/proj'); - assertEquals(process.calls[1].args, ['publish', '--output-path', '.deploy/compose']); + assertEquals(process.calls[1].args, [ + 'publish', + '--output-path', + '.deploy/compose', + ]); +}); + +Deno.test('plan forwards Aspire environment and non-interactive flags', async () => { + const process = new FakeProcess(); + const adapter = new AspireComposeDeployTarget({ key: 'compose', process }); + + await adapter.plan({ + ...request, + environment: 'staging', + nonInteractive: true, + }); + + assertEquals(process.calls[0].args, [ + 'publish', + '--output-path', + '.deploy/compose', + '--environment', + 'staging', + '--non-interactive', + ]); }); Deno.test('compose `up` self-hosts via `docker compose up -d` on the emitted file', async () => { @@ -79,6 +117,26 @@ Deno.test('docker `up` delegates the apply to `aspire deploy`', async () => { assertEquals(process.calls[0].args, ['deploy']); }); +Deno.test('docker `up` can clear Aspire deployment cache for CI', async () => { + const process = new FakeProcess(); + const adapter = new AspireComposeDeployTarget({ key: 'docker', process }); + + await adapter.up({ + ...request, + environment: 'production', + clearCache: true, + nonInteractive: true, + }); + + assertEquals(process.calls[0].args, [ + 'deploy', + '--environment', + 'production', + '--non-interactive', + '--clear-cache', + ]); +}); + Deno.test('down/status/logs shell `docker compose` against the emitted project', async () => { const process = new FakeProcess(); const adapter = new AspireComposeDeployTarget({ key: 'compose', process }); @@ -87,8 +145,18 @@ Deno.test('down/status/logs shell `docker compose` against the emitted project', await adapter.status(request); await adapter.logs(request); - assertEquals(process.calls[0].args, ['compose', '-f', '.deploy/compose/docker-compose.yaml', 'down']); - assertEquals(process.calls[1].args, ['compose', '-f', '.deploy/compose/docker-compose.yaml', 'ps']); + assertEquals(process.calls[0].args, [ + 'compose', + '-f', + '.deploy/compose/docker-compose.yaml', + 'down', + ]); + assertEquals(process.calls[1].args, [ + 'compose', + '-f', + '.deploy/compose/docker-compose.yaml', + 'ps', + ]); assertEquals(process.calls[2].args, [ 'compose', '-f', diff --git a/packages/cli/src/kernel/adapters/templates/scaffold-template-assets.ts b/packages/cli/src/kernel/adapters/templates/scaffold-template-assets.ts index 12e44be30..b41d7d44e 100644 --- a/packages/cli/src/kernel/adapters/templates/scaffold-template-assets.ts +++ b/packages/cli/src/kernel/adapters/templates/scaffold-template-assets.ts @@ -71,6 +71,9 @@ const LEGACY_ASPIRE_TEMPLATE_URLS: TemplateUrlMap = { } as const; const ROOT_TEMPLATE_URLS: TemplateUrlMap = { + bareMetalDeployWorkflowTemplate: TEMPLATE_KEYS.workspaceGithubWorkflowsDeployBareMetal, + composeGhcrDeployWorkflowTemplate: TEMPLATE_KEYS.workspaceGithubWorkflowsDeployComposeGhcr, + denoDeployWorkflowTemplate: TEMPLATE_KEYS.workspaceGithubWorkflowsDeployDenoDeploy, gitignoreTemplate: TEMPLATE_KEYS.workspaceGitignore, } as const; @@ -88,20 +91,28 @@ export type AppScaffoldTemplateAssets = TemplateMap; export type ExampleServiceAppTemplateAssets = TemplateMap< typeof EXAMPLE_SERVICE_APP_TEMPLATE_URLS >; -export type LegacyAspireTemplateAssets = TemplateMap; +export type LegacyAspireTemplateAssets = TemplateMap< + typeof LEGACY_ASPIRE_TEMPLATE_URLS +>; export type RootScaffoldTemplateAssets = TemplateMap; -export type AspireHelperTemplateAssets = TemplateMap; +export type AspireHelperTemplateAssets = TemplateMap< + typeof ASPIRE_HELPER_TEMPLATE_URLS +>; async function loadTemplateMap>( urls: T, ): Promise> { const entries = await Promise.all( - Object.entries(urls).map(async ([name, url]) => [name, await readTemplateAsset(url)]), + Object.entries(urls).map(async ( + [name, url], + ) => [name, await readTemplateAsset(url)]), ); return Object.fromEntries(entries) as TemplateMap; } -export async function loadAppScaffoldTemplateAssets(): Promise { +export async function loadAppScaffoldTemplateAssets(): Promise< + AppScaffoldTemplateAssets +> { return await loadTemplateMap(APP_TEMPLATE_URLS); } @@ -111,14 +122,20 @@ export async function loadExampleServiceAppTemplateAssets(): Promise< return await loadTemplateMap(EXAMPLE_SERVICE_APP_TEMPLATE_URLS); } -export async function loadLegacyAspireTemplateAssets(): Promise { +export async function loadLegacyAspireTemplateAssets(): Promise< + LegacyAspireTemplateAssets +> { return await loadTemplateMap(LEGACY_ASPIRE_TEMPLATE_URLS); } -export async function loadRootScaffoldTemplateAssets(): Promise { +export async function loadRootScaffoldTemplateAssets(): Promise< + RootScaffoldTemplateAssets +> { return await loadTemplateMap(ROOT_TEMPLATE_URLS); } -export async function loadAspireHelperTemplateAssets(): Promise { +export async function loadAspireHelperTemplateAssets(): Promise< + AspireHelperTemplateAssets +> { return await loadTemplateMap(ASPIRE_HELPER_TEMPLATE_URLS); } diff --git a/packages/cli/src/kernel/application/scaffold/plan-init.ts b/packages/cli/src/kernel/application/scaffold/plan-init.ts index 2baa33578..e04206025 100644 --- a/packages/cli/src/kernel/application/scaffold/plan-init.ts +++ b/packages/cli/src/kernel/application/scaffold/plan-init.ts @@ -14,7 +14,11 @@ import type { InitPipelineContext } from './context.ts'; import { createScaffoldPlan } from '../../domain/scaffold/scaffold-plan.ts'; function generateAppStubDenoJson(name: string, appName: string): string { - const config = { name: `@${name}/${appName}`, version: '0.0.0', exports: './main.ts' }; + const config = { + name: `@${name}/${appName}`, + version: '0.0.0', + exports: './main.ts', + }; return JSON.stringify(config, null, 2) + '\n'; } @@ -35,7 +39,12 @@ export async function scaffoldRoot( const plan = createScaffoldPlan(options, { useWorkspacePackages: context.packagesAsWorkspaceMembers(options), }); - const { gitignoreTemplate } = await loadRootScaffoldTemplateAssets(); + const { + bareMetalDeployWorkflowTemplate, + composeGhcrDeployWorkflowTemplate, + denoDeployWorkflowTemplate, + gitignoreTemplate, + } = await loadRootScaffoldTemplateAssets(); // Create root directory await context.scaffolder.createDir(targetPath); @@ -78,7 +87,13 @@ export async function scaffoldRoot( dbEngines: [...plan.dbEngines], }); const denoJsonPath = join(targetPath, SCAFFOLD_FILES.DENO_JSON); - if (await context.scaffolder.writeFile(denoJsonPath, denoJsonContent, options.force)) { + if ( + await context.scaffolder.writeFile( + denoJsonPath, + denoJsonContent, + options.force, + ) + ) { filesCreated.push(denoJsonPath); } else { filesSkipped.push(denoJsonPath); @@ -91,7 +106,9 @@ export async function scaffoldRoot( localBase: options.localBase, }); const configPath = join(targetPath, SCAFFOLD_FILES.NETSCRIPT_CONFIG); - if (await context.scaffolder.writeFile(configPath, configContent, options.force)) { + if ( + await context.scaffolder.writeFile(configPath, configContent, options.force) + ) { filesCreated.push(configPath); } else { filesSkipped.push(configPath); @@ -99,13 +116,39 @@ export async function scaffoldRoot( // 3. .gitignore (Tier 2 — static, no variables) const gitignorePath = join(targetPath, SCAFFOLD_FILES.GITIGNORE); - if (await context.scaffolder.writeFile(gitignorePath, gitignoreTemplate, options.force)) { + if ( + await context.scaffolder.writeFile( + gitignorePath, + gitignoreTemplate, + options.force, + ) + ) { filesCreated.push(gitignorePath); } else { filesSkipped.push(gitignorePath); } - // 4. Optional editor config files (Tier 1 — programmatic, answer-driven) + // 4. CI/CD workflow templates (Tier 2 — static, target-specific) + const workflowFiles = [ + ['deploy-deno-deploy.yml', denoDeployWorkflowTemplate], + ['deploy-bare-metal.yml', bareMetalDeployWorkflowTemplate], + ] as const; + const aspireWorkflowFiles = options.noAspire ? workflowFiles : [ + ['deploy-compose-ghcr.yml', composeGhcrDeployWorkflowTemplate], + ...workflowFiles, + ] as const; + for (const [filename, content] of aspireWorkflowFiles) { + const workflowPath = join(targetPath, '.github', 'workflows', filename); + if ( + await context.scaffolder.writeFile(workflowPath, content, options.force) + ) { + filesCreated.push(workflowPath); + } else { + filesSkipped.push(workflowPath); + } + } + + // 5. Optional editor config files (Tier 1 — programmatic, answer-driven) for (const file of generateEditorConfigFiles(options.editor)) { const filePath = join(targetPath, file.path); const directory = dirname(filePath); @@ -113,14 +156,16 @@ export async function scaffoldRoot( await context.scaffolder.createDir(directory); directoriesCreated.push(directory); } - if (await context.scaffolder.writeFile(filePath, file.content, options.force)) { + if ( + await context.scaffolder.writeFile(filePath, file.content, options.force) + ) { filesCreated.push(filePath); } else { filesSkipped.push(filePath); } } - // 5. README.md (Tier 1 — programmatic, answer-driven) + // 6. README.md (Tier 1 — programmatic, answer-driven) // Shows TS vs legacy AppHost commands, includes services/ // and database sections when applicable. const readmeContent = generateReadme({ @@ -132,13 +177,15 @@ export async function scaffoldRoot( dbEngine: options.dbEngine, }); const readmePath = join(targetPath, SCAFFOLD_FILES.README); - if (await context.scaffolder.writeFile(readmePath, readmeContent, options.force)) { + if ( + await context.scaffolder.writeFile(readmePath, readmeContent, options.force) + ) { filesCreated.push(readmePath); } else { filesSkipped.push(readmePath); } - // 6. appsettings.json (Tier 1 — NetScript infrastructure config) + // 7. appsettings.json (Tier 1 — NetScript infrastructure config) // Always generated at project root — consumed by helpers generator // regardless of C# vs TS AppHost mode. // TS AppHost uses port 8010 so the Aspire proxy port (8010) does not @@ -157,7 +204,13 @@ export async function scaffoldRoot( service: plan.service, }); const appsettingsPath = join(targetPath, SCAFFOLD_FILES.APPSETTINGS); - if (await context.scaffolder.writeFile(appsettingsPath, appsettingsContent, options.force)) { + if ( + await context.scaffolder.writeFile( + appsettingsPath, + appsettingsContent, + options.force, + ) + ) { filesCreated.push(appsettingsPath); } else { filesSkipped.push(appsettingsPath); @@ -171,7 +224,11 @@ export async function scaffoldRoot( } const smokeTestPath = join(testsDir, 'scaffold_test.ts'); if ( - await context.scaffolder.writeFile(smokeTestPath, generateScaffoldSmokeTest(), options.force) + await context.scaffolder.writeFile( + smokeTestPath, + generateScaffoldSmokeTest(), + options.force, + ) ) { filesCreated.push(smokeTestPath); } else { diff --git a/packages/cli/src/kernel/application/scaffold/plan-init_test.ts b/packages/cli/src/kernel/application/scaffold/plan-init_test.ts new file mode 100644 index 000000000..b6eb599b5 --- /dev/null +++ b/packages/cli/src/kernel/application/scaffold/plan-init_test.ts @@ -0,0 +1,262 @@ +import { assert, assertEquals, assertStringIncludes } from 'jsr:@std/assert@^1'; +import { join } from '@std/path'; + +import { InMemoryScaffolder } from '../testing/in-memory-scaffolder.ts'; +import { DeployTargetRegistry } from '../registries/deploy-target-registry.ts'; +import type { + DeployTargetOperation, + DeployTargetPort, + DeployTargetRequest, + DeployTargetResult, +} from '../../domain/deploy/deploy-target-port.ts'; +import type { DenoDeployTargetDefaults } from '../../domain/deploy/deno-deploy-target.ts'; +import type { ValidatedInitOptions } from '../../domain/scaffold/scaffold-options.ts'; +import type { BuildResult } from '../../domain/deploy/compile-target.ts'; +import type { ResolvedConfig } from '../../domain/resolved-config.ts'; +import type { PublicCommandDependencies } from '../../../public/features/root/public-command-dependencies.ts'; +import { createDeployCommand } from '../../../public/features/deploy/deploy-group.ts'; +import type { InitPipelineContext } from './context.ts'; +import { scaffoldRoot } from './plan-init.ts'; + +function options( + overrides: Partial = {}, +): ValidatedInitOptions { + return { + name: 'deploy-app', + appName: 'dashboard', + targetPath: '/workspace/deploy-app', + importMode: 'jsr', + editor: 'none', + force: false, + ci: true, + dryRun: false, + noGit: true, + noAspire: false, + legacyAspire: false, + dbEngine: 'none', + cache: true, + cacheBackend: 'redis', + includeExampleService: false, + modelName: 'User', + ...overrides, + }; +} + +function context(scaffolder: InMemoryScaffolder): InitPipelineContext { + return { + scaffolder, + packagesAsWorkspaceMembers: () => false, + } as unknown as InitPipelineContext; +} + +function fakeDeployTarget(key: string): DeployTargetPort { + const handler = + (operation: DeployTargetOperation) => + (_request: DeployTargetRequest): Promise => + Promise.resolve({ target: key, operation, message: `${key} ${operation} ok` }); + return { + key, + label: key, + operations: ['plan', 'up', 'down', 'status', 'logs'], + plan: handler('plan'), + up: handler('up'), + down: handler('down'), + status: handler('status'), + logs: handler('logs'), + }; +} + +function fakeDeployDependencies(): PublicCommandDependencies { + const buildResult: BuildResult = { + success: true, + outputDir: '.deploy', + compilations: [], + durationMs: 0, + errors: [], + }; + return { + deployBuildDependencies: { + loadConfig: () => Promise.resolve({} as ResolvedConfig), + buildWindowsDeployment: () => Promise.resolve(buildResult), + }, + loadConfig: () => Promise.resolve({ deploy: {} }), + resolveProjectRoot: (projectRoot?: string) => Promise.resolve(projectRoot ?? '/workspace'), + manifestPort: { + resolve: () => Promise.reject(new Error('manifest resolution is not used by this test')), + }, + osServices: {}, + deployTargets: new DeployTargetRegistry([ + ['compose', fakeDeployTarget('compose')], + ['docker', fakeDeployTarget('docker')], + ]), + denoDeployTargetFactory: (_defaults: DenoDeployTargetDefaults) => + fakeDeployTarget('deno-deploy'), + } as unknown as PublicCommandDependencies; +} + +function shellWords(input: string): string[] { + const words: string[] = []; + const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g; + for (const match of input.matchAll(pattern)) { + words.push(match[1] ?? match[2] ?? match[3]); + } + return words; +} + +function extractDeployInvocations(workflow: string): string[][] { + const invocations: string[][] = []; + const lines = workflow.split(/\r?\n/); + let shellArgs: string[] | undefined; + const optionalShellArgs: string[] = []; + + for (let index = 0; index < lines.length; index++) { + const trimmed = lines[index].trim(); + if (trimmed === 'args=(') { + shellArgs = []; + for (index++; index < lines.length; index++) { + const argLine = lines[index].trim(); + if (argLine === ')') break; + shellArgs.push(...shellWords(argLine)); + } + continue; + } + + if (trimmed.startsWith('args+=(') && trimmed.endsWith(')')) { + optionalShellArgs.push(...shellWords(trimmed.slice('args+=('.length, -1))); + continue; + } + + if (trimmed === 'deno x -A jsr:@netscript/cli "${args[@]}"') { + if (!shellArgs) throw new Error('Found args invocation before args array declaration.'); + invocations.push(shellArgs); + if (optionalShellArgs.length > 0) { + invocations.push([...shellArgs, ...optionalShellArgs]); + } + continue; + } + + if (!trimmed.startsWith('deno x -A jsr:@netscript/cli ')) continue; + + const commandLines = [trimmed]; + for (let cursor = index + 1; cursor < lines.length; cursor++) { + const continuation = lines[cursor].trim(); + if (continuation === '') break; + commandLines.push(continuation); + index = cursor; + } + invocations.push(shellWords(commandLines.join(' ')).slice(4)); + } + + return invocations.filter((args) => args[0] === 'deploy'); +} + +Deno.test('scaffoldRoot emits CI/CD workflow templates for shipped deploy targets', async () => { + const scaffolder = new InMemoryScaffolder(); + const result = await scaffoldRoot(context(scaffolder), options()); + const workflowsRoot = join('/workspace/deploy-app', '.github', 'workflows'); + const composePath = join(workflowsRoot, 'deploy-compose-ghcr.yml'); + const denoDeployPath = join(workflowsRoot, 'deploy-deno-deploy.yml'); + const bareMetalPath = join(workflowsRoot, 'deploy-bare-metal.yml'); + + assert(result.filesCreated.includes(composePath)); + assert(result.filesCreated.includes(denoDeployPath)); + assert(result.filesCreated.includes(bareMetalPath)); + + const compose = scaffolder.files.get(composePath); + const denoDeploy = scaffolder.files.get(denoDeployPath); + const bareMetal = scaffolder.files.get(bareMetalPath); + + assertStringIncludes(compose ?? '', 'deploy compose plan'); + assertStringIncludes(compose ?? '', '--clear-cache'); + assertStringIncludes(compose ?? '', 'ghcr.io'); + assert(!compose?.includes('~/.aspire/deployments')); + + assertStringIncludes(denoDeploy ?? '', 'deploy deno-deploy up'); + assertStringIncludes(denoDeploy ?? '', 'DENO_DEPLOY_TOKEN'); + + assertStringIncludes(bareMetal ?? '', 'deploy build'); + assertStringIncludes(bareMetal ?? '', '--output-dir'); + assertStringIncludes(bareMetal ?? '', 'actions/upload-artifact'); + assertEquals(scaffolder.directories.has('/workspace/deploy-app'), true); +}); + +Deno.test('scaffoldRoot emits deploy workflow invocations accepted by the real deploy parser', async () => { + const scaffolder = new InMemoryScaffolder(); + await scaffoldRoot(context(scaffolder), options()); + const workflowContents = [...scaffolder.files.entries()] + .filter(([path]) => path.includes('/.github/workflows/')) + .map(([, content]) => content); + const invocations = workflowContents.flatMap(extractDeployInvocations); + + assertEquals(invocations, [ + [ + 'deploy', + 'compose', + 'plan', + '--project-root', + '.', + '--output-dir', + '.deploy/compose', + '--environment', + '$DEPLOY_ENVIRONMENT', + '--non-interactive', + ], + [ + 'deploy', + 'docker', + 'up', + '--project-root', + '.', + '--environment', + '$DEPLOY_ENVIRONMENT', + '--clear-cache', + '--non-interactive', + ], + [ + 'deploy', + 'deno-deploy', + 'up', + '--org', + '$DENO_DEPLOY_ORG', + '--app', + '$DENO_DEPLOY_APP', + ], + [ + 'deploy', + 'deno-deploy', + 'up', + '--org', + '$DENO_DEPLOY_ORG', + '--app', + '$DENO_DEPLOY_APP', + '--prod', + ], + ['deploy', 'build', '--output-dir', '${{ matrix.deploy_dir }}'], + ]); + + for (const args of invocations) { + const command = createDeployCommand(fakeDeployDependencies()).noExit().throwErrors(); + await command.parse(args.slice(1)); + } +}); + +Deno.test('scaffoldRoot omits Aspire-backed compose CI when --no-aspire is used', async () => { + const scaffolder = new InMemoryScaffolder(); + const result = await scaffoldRoot( + context(scaffolder), + options({ noAspire: true }), + ); + const workflowsRoot = join('/workspace/deploy-app', '.github', 'workflows'); + + assert( + !result.filesCreated.includes( + join(workflowsRoot, 'deploy-compose-ghcr.yml'), + ), + ); + assert( + result.filesCreated.includes(join(workflowsRoot, 'deploy-deno-deploy.yml')), + ); + assert( + result.filesCreated.includes(join(workflowsRoot, 'deploy-bare-metal.yml')), + ); +}); diff --git a/packages/cli/src/kernel/assets/embedded.generated.ts b/packages/cli/src/kernel/assets/embedded.generated.ts index b3e42ab4e..fc302898a 100644 --- a/packages/cli/src/kernel/assets/embedded.generated.ts +++ b/packages/cli/src/kernel/assets/embedded.generated.ts @@ -202,8 +202,14 @@ const template_097 = const template_098 = '/**\n * @module contracts/versions/v1\n *\n * Version 1 contract definitions.\n *\n * Populate this module by running `netscript contract add `\n * or by adding a `.contract.ts` file beside this one and\n * re-exporting it in the `v1` object below.\n */\n\nexport const v1 = {};\n'; const template_099 = - '# Dependencies\nnode_modules/\n\n# Build artifacts\n.deploy/\n*.exe\n.data/\n\n# IDE\n.idea/\n\n# Aspire\ndotnet/**/bin/\ndotnet/**/obj/\n.aspire/\n\n# Generated (regenerated by netscript generate)\ndotnet/AppHost/appsettings.json\n\n# OS\n.DS_Store\nThumbs.db\n\n# Environment\n.env\n.env.local\n'; + "name: Build Bare-Metal Deploy Artifacts\n\non:\n workflow_dispatch:\n inputs:\n environment:\n description: Deployment environment represented by the artifact.\n required: true\n default: staging\n type: choice\n options:\n - development\n - staging\n - production\n\npermissions:\n contents: read\n\nconcurrency:\n group: deploy-bare-metal-${{ github.event.inputs.environment || 'staging' }}\n cancel-in-progress: false\n\njobs:\n build:\n strategy:\n fail-fast: false\n matrix:\n include:\n - os: ubuntu-latest\n target: linux\n deploy_dir: .deploy/linux\n - os: windows-latest\n target: windows\n deploy_dir: .deploy/windows\n runs-on: ${{ matrix.os }}\n environment: ${{ github.event.inputs.environment || 'staging' }}\n steps:\n - uses: actions/checkout@v4\n\n - uses: denoland/setup-deno@v2\n with:\n deno-version: v2.x\n\n - name: Type-check workspace\n run: deno task check\n\n - name: Compile deployment artifacts\n run: >\n deno x -A jsr:@netscript/cli deploy build\n --output-dir \"${{ matrix.deploy_dir }}\"\n\n - uses: actions/upload-artifact@v4\n with:\n name: netscript-${{ matrix.target }}-${{ github.event.inputs.environment || 'staging' }}\n path: ${{ matrix.deploy_dir }}\n if-no-files-found: error\n"; const template_100 = + "name: Deploy Compose to GHCR\n\non:\n workflow_dispatch:\n inputs:\n environment:\n description: GitHub/Aspire environment to deploy.\n required: true\n default: staging\n type: choice\n options:\n - development\n - staging\n - production\n push:\n branches:\n - main\n\npermissions:\n contents: read\n packages: write\n\nconcurrency:\n group: deploy-compose-${{ github.event.inputs.environment || 'staging' }}\n cancel-in-progress: false\n\njobs:\n deploy:\n runs-on: ubuntu-latest\n environment: ${{ github.event.inputs.environment || 'staging' }}\n env:\n DEPLOY_ENVIRONMENT: ${{ github.event.inputs.environment || 'staging' }}\n REGISTRY: ghcr.io\n IMAGE_OWNER: ${{ github.repository_owner }}\n steps:\n - uses: actions/checkout@v4\n\n - uses: denoland/setup-deno@v2\n with:\n deno-version: v2.x\n\n - uses: docker/setup-buildx-action@v3\n\n - uses: docker/login-action@v3\n with:\n registry: ghcr.io\n username: ${{ github.actor }}\n password: ${{ secrets.GITHUB_TOKEN }}\n\n - name: Restore Aspire SDK\n run: cd aspire && aspire restore\n\n - name: Emit compose artifacts\n run: >\n deno x -A jsr:@netscript/cli deploy compose plan\n --project-root .\n --output-dir .deploy/compose\n --environment \"$DEPLOY_ENVIRONMENT\"\n --non-interactive\n\n - name: Build and push compose images\n run: |\n docker compose -f .deploy/compose/docker-compose.yaml build\n docker compose -f .deploy/compose/docker-compose.yaml push\n\n - name: Deploy with Aspire without persisting plaintext state\n run: >\n deno x -A jsr:@netscript/cli deploy docker up\n --project-root .\n --environment \"$DEPLOY_ENVIRONMENT\"\n --clear-cache\n --non-interactive\n"; +const template_101 = + 'name: Deploy to Deno Deploy\n\non:\n workflow_dispatch:\n inputs:\n environment:\n description: GitHub environment to deploy.\n required: true\n default: staging\n type: choice\n options:\n - development\n - staging\n - production\n production:\n description: Promote this deployment to production.\n required: true\n default: false\n type: boolean\n\npermissions:\n contents: read\n\nconcurrency:\n group: deploy-deno-deploy-${{ github.event.inputs.environment || \'staging\' }}\n cancel-in-progress: false\n\njobs:\n deploy:\n runs-on: ubuntu-latest\n environment: ${{ github.event.inputs.environment || \'staging\' }}\n env:\n DENO_DEPLOY_TOKEN: ${{ secrets.DENO_DEPLOY_TOKEN }}\n DENO_DEPLOY_ORG: ${{ vars.DENO_DEPLOY_ORG }}\n DENO_DEPLOY_APP: ${{ vars.DENO_DEPLOY_APP }}\n steps:\n - uses: actions/checkout@v4\n\n - uses: denoland/setup-deno@v2\n with:\n deno-version: v2.x\n\n - name: Type-check workspace\n run: deno task check\n\n - name: Push deployment\n run: |\n args=(\n deploy deno-deploy up\n --org "$DENO_DEPLOY_ORG"\n --app "$DENO_DEPLOY_APP"\n )\n if [ "${{ github.event.inputs.production }}" = "true" ]; then\n args+=(--prod)\n fi\n deno x -A jsr:@netscript/cli "${args[@]}"\n'; +const template_102 = + '# Dependencies\nnode_modules/\n\n# Build artifacts\n.deploy/\n*.exe\n.data/\n\n# IDE\n.idea/\n\n# Aspire\ndotnet/**/bin/\ndotnet/**/obj/\n.aspire/\n\n# Generated (regenerated by netscript generate)\ndotnet/AppHost/appsettings.json\n\n# OS\n.DS_Store\nThumbs.db\n\n# Environment\n.env\n.env.local\n'; +const template_103 = '/**\n * @module plugins\n *\n * Plugin system entry point.\n *\n * Plugin configuration is discovered from `netscript.config.ts` and generated\n * registries under `.netscript/generated/`.\n */\n\nexport const plugins = [] as const;\n'; /** Embedded text content for every checked-in CLI template asset. */ @@ -307,6 +313,9 @@ export const EMBEDDED_TEMPLATE_CONTENT: Record = { 'workspace/contracts/mod.ts.template': template_096, 'workspace/contracts/v1-aggregate.ts.template': template_097, 'workspace/contracts/v1-empty.ts.template': template_098, - 'workspace/gitignore.template': template_099, - 'workspace/plugins/mod.ts.template': template_100, + 'workspace/github/workflows/deploy-bare-metal.yml.template': template_099, + 'workspace/github/workflows/deploy-compose-ghcr.yml.template': template_100, + 'workspace/github/workflows/deploy-deno-deploy.yml.template': template_101, + 'workspace/gitignore.template': template_102, + 'workspace/plugins/mod.ts.template': template_103, }; diff --git a/packages/cli/src/kernel/assets/manifest.ts b/packages/cli/src/kernel/assets/manifest.ts index 1ecc4eec2..f5bc69575 100644 --- a/packages/cli/src/kernel/assets/manifest.ts +++ b/packages/cli/src/kernel/assets/manifest.ts @@ -30,8 +30,7 @@ export const TEMPLATE_KEYS = { appRoutesDesignTokensView: 'app/routes/(design)/design/(_components)/tokens-view.tsx.template', appRoutesExamplesComponentsExamplesView: 'app/routes/examples/(_components)/examples-view.tsx.template', - appRoutesExamplesComponentsCrudView: - 'app/routes/examples/(_components)/crud-view.tsx.template', + appRoutesExamplesComponentsCrudView: 'app/routes/examples/(_components)/crud-view.tsx.template', appRoutesExamplesComponentsHero: 'app/routes/examples/(_components)/hero.tsx.template', appRoutesExamplesComponentsLabPanel: 'app/routes/examples/(_components)/lab-panel.tsx.template', appRoutesExamplesComponentsNotesCard: 'app/routes/examples/(_components)/notes-card.tsx.template', @@ -125,6 +124,12 @@ export const TEMPLATE_KEYS = { workspaceContractsMod: 'workspace/contracts/mod.ts.template', workspaceContractsV1Aggregate: 'workspace/contracts/v1-aggregate.ts.template', workspaceContractsV1Empty: 'workspace/contracts/v1-empty.ts.template', + workspaceGithubWorkflowsDeployBareMetal: + 'workspace/github/workflows/deploy-bare-metal.yml.template', + workspaceGithubWorkflowsDeployComposeGhcr: + 'workspace/github/workflows/deploy-compose-ghcr.yml.template', + workspaceGithubWorkflowsDeployDenoDeploy: + 'workspace/github/workflows/deploy-deno-deploy.yml.template', workspaceGitignore: 'workspace/gitignore.template', workspacePluginsMod: 'workspace/plugins/mod.ts.template', } as const; @@ -141,7 +146,9 @@ export const TEMPLATE_MANIFEST = Object.values(TEMPLATE_KEYS).map((path) => ({ path, })) as readonly TemplateManifestItem[]; -const TEMPLATE_KEY_SET: ReadonlySet = new Set(TEMPLATE_MANIFEST.map((item) => item.key)); +const TEMPLATE_KEY_SET: ReadonlySet = new Set( + TEMPLATE_MANIFEST.map((item) => item.key), +); export function isTemplateKey(value: string): value is TemplateKey { return TEMPLATE_KEY_SET.has(value); diff --git a/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-bare-metal.yml.template b/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-bare-metal.yml.template new file mode 100644 index 000000000..ac72b4d9f --- /dev/null +++ b/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-bare-metal.yml.template @@ -0,0 +1,56 @@ +name: Build Bare-Metal Deploy Artifacts + +on: + workflow_dispatch: + inputs: + environment: + description: Deployment environment represented by the artifact. + required: true + default: staging + type: choice + options: + - development + - staging + - production + +permissions: + contents: read + +concurrency: + group: deploy-bare-metal-${{ github.event.inputs.environment || 'staging' }} + cancel-in-progress: false + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: linux + deploy_dir: .deploy/linux + - os: windows-latest + target: windows + deploy_dir: .deploy/windows + runs-on: ${{ matrix.os }} + environment: ${{ github.event.inputs.environment || 'staging' }} + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Type-check workspace + run: deno task check + + - name: Compile deployment artifacts + run: > + deno x -A jsr:@netscript/cli deploy build + --output-dir "${{ matrix.deploy_dir }}" + + - uses: actions/upload-artifact@v4 + with: + name: netscript-${{ matrix.target }}-${{ github.event.inputs.environment || 'staging' }} + path: ${{ matrix.deploy_dir }} + if-no-files-found: error diff --git a/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-compose-ghcr.yml.template b/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-compose-ghcr.yml.template new file mode 100644 index 000000000..c5b9f545a --- /dev/null +++ b/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-compose-ghcr.yml.template @@ -0,0 +1,72 @@ +name: Deploy Compose to GHCR + +on: + workflow_dispatch: + inputs: + environment: + description: GitHub/Aspire environment to deploy. + required: true + default: staging + type: choice + options: + - development + - staging + - production + push: + branches: + - main + +permissions: + contents: read + packages: write + +concurrency: + group: deploy-compose-${{ github.event.inputs.environment || 'staging' }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.environment || 'staging' }} + env: + DEPLOY_ENVIRONMENT: ${{ github.event.inputs.environment || 'staging' }} + REGISTRY: ghcr.io + IMAGE_OWNER: ${{ github.repository_owner }} + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Restore Aspire SDK + run: cd aspire && aspire restore + + - name: Emit compose artifacts + run: > + deno x -A jsr:@netscript/cli deploy compose plan + --project-root . + --output-dir .deploy/compose + --environment "$DEPLOY_ENVIRONMENT" + --non-interactive + + - name: Build and push compose images + run: | + docker compose -f .deploy/compose/docker-compose.yaml build + docker compose -f .deploy/compose/docker-compose.yaml push + + - name: Deploy with Aspire without persisting plaintext state + run: > + deno x -A jsr:@netscript/cli deploy docker up + --project-root . + --environment "$DEPLOY_ENVIRONMENT" + --clear-cache + --non-interactive diff --git a/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-deno-deploy.yml.template b/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-deno-deploy.yml.template new file mode 100644 index 000000000..014ddc792 --- /dev/null +++ b/packages/cli/src/kernel/assets/workspace/github/workflows/deploy-deno-deploy.yml.template @@ -0,0 +1,56 @@ +name: Deploy to Deno Deploy + +on: + workflow_dispatch: + inputs: + environment: + description: GitHub environment to deploy. + required: true + default: staging + type: choice + options: + - development + - staging + - production + production: + description: Promote this deployment to production. + required: true + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: deploy-deno-deploy-${{ github.event.inputs.environment || 'staging' }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.environment || 'staging' }} + env: + DENO_DEPLOY_TOKEN: ${{ secrets.DENO_DEPLOY_TOKEN }} + DENO_DEPLOY_ORG: ${{ vars.DENO_DEPLOY_ORG }} + DENO_DEPLOY_APP: ${{ vars.DENO_DEPLOY_APP }} + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Type-check workspace + run: deno task check + + - name: Push deployment + run: | + args=( + deploy deno-deploy up + --org "$DENO_DEPLOY_ORG" + --app "$DENO_DEPLOY_APP" + ) + if [ "${{ github.event.inputs.production }}" = "true" ]; then + args+=(--prod) + fi + deno x -A jsr:@netscript/cli "${args[@]}" diff --git a/packages/cli/src/kernel/domain/deploy/deploy-target-port.ts b/packages/cli/src/kernel/domain/deploy/deploy-target-port.ts index 85a11f11d..990c43e12 100644 --- a/packages/cli/src/kernel/domain/deploy/deploy-target-port.ts +++ b/packages/cli/src/kernel/domain/deploy/deploy-target-port.ts @@ -55,6 +55,12 @@ export interface DeployTargetRequest { readonly projectRoot: string; /** Optional deployment output directory. */ readonly outputDir?: string; + /** Optional Aspire deployment environment (for example, `staging`). */ + readonly environment?: string; + /** Whether Aspire deploy should clear and avoid saving deployment state. */ + readonly clearCache?: boolean; + /** Whether the underlying deploy tool should run without prompts. */ + readonly nonInteractive?: boolean; /** Target-specific config resolved from `deploy.targets.`. */ readonly targetConfig?: DeployTargetRequestConfig; } diff --git a/packages/cli/src/kernel/templates/workspace/generate-readme.ts b/packages/cli/src/kernel/templates/workspace/generate-readme.ts index a77af7656..db6c0d0a4 100644 --- a/packages/cli/src/kernel/templates/workspace/generate-readme.ts +++ b/packages/cli/src/kernel/templates/workspace/generate-readme.ts @@ -63,7 +63,9 @@ export function generateReadme(options: ReadmeOptions): string { lines.push('# Start the full Aspire orchestration (C# AppHost)'); lines.push('dotnet run --project dotnet/AppHost'); } else { - lines.push('# Restore Aspire SDK modules (once), then start orchestration'); + lines.push( + '# Restore Aspire SDK modules (once), then start orchestration', + ); lines.push( '# (run from the aspire/ subfolder so `aspire` sees apphost.mts + aspire.config.json)', ); @@ -81,25 +83,35 @@ export function generateReadme(options: ReadmeOptions): string { lines.push(''); lines.push('```'); lines.push(`${options.name}/`); - lines.push(`├── apps/${options.appName}/ # Fresh frontend (defineFreshApp)`); + lines.push( + `├── apps/${options.appName}/ # Fresh frontend (defineFreshApp)`, + ); lines.push('├── contracts/ # Shared oRPC contracts (versioned)'); if (options.serviceName) { lines.push(`├── services/${options.serviceName}/ # Example oRPC service`); } lines.push('├── plugins/ # Plugin registry and implementations'); if (!options.noAspire && options.legacyAspire) { - lines.push('├── dotnet/ # Aspire C# orchestration (AppHost + ServiceDefaults)'); + lines.push( + '├── dotnet/ # Aspire C# orchestration (AppHost + ServiceDefaults)', + ); } else if (!options.noAspire) { lines.push( '├── aspire/ # Aspire TypeScript orchestration (isolated Node.js runtime)', ); lines.push('│ ├── apphost.mts # Entry point for `aspire start`'); lines.push('│ ├── .helpers/ # Generated register-*.mts helpers'); - lines.push('│ ├── .aspire/ # Aspire SDK modules (aspire restore output)'); - lines.push('│ └── package.json # tsx + vscode-jsonrpc (isolated from the Deno workspace)'); + lines.push( + '│ ├── .aspire/ # Aspire SDK modules (aspire restore output)', + ); + lines.push( + '│ └── package.json # tsx + vscode-jsonrpc (isolated from the Deno workspace)', + ); } if (!options.noAspire) { - lines.push('├── appsettings.json # NetScript infrastructure config (Services/Databases/…)'); + lines.push( + '├── appsettings.json # NetScript infrastructure config (Services/Databases/…)', + ); } lines.push('├── deno.json # Workspace root configuration'); lines.push('└── netscript.config.ts # NetScript framework configuration'); @@ -124,9 +136,13 @@ export function generateReadme(options: ReadmeOptions): string { } if (!options.noAspire) { if (options.legacyAspire) { - lines.push('| `dotnet run --project dotnet/AppHost` | Start Aspire orchestration (C#) |'); + lines.push( + '| `dotnet run --project dotnet/AppHost` | Start Aspire orchestration (C#) |', + ); } else { - lines.push('| `cd aspire && aspire restore` | Restore Aspire SDK modules (run once) |'); + lines.push( + '| `cd aspire && aspire restore` | Restore Aspire SDK modules (run once) |', + ); lines.push( '| `cd aspire && aspire start` | Start Aspire orchestration (TypeScript AppHost) |', ); @@ -139,9 +155,39 @@ export function generateReadme(options: ReadmeOptions): string { lines.push( '| `deno run -A packages/cli/bin/netscript-dev.ts --help` | Show local contributor CLI commands |', ); - lines.push('| `deno run -A packages/cli/bin/netscript.ts --help` | Show public CLI commands |'); + lines.push( + '| `deno run -A packages/cli/bin/netscript.ts --help` | Show public CLI commands |', + ); lines.push(''); + if (!options.noAspire) { + lines.push('## Deployment CI'); + lines.push(''); + lines.push( + 'The scaffold includes GitHub Actions starter workflows under `.github/workflows`:', + ); + lines.push(''); + lines.push('| Workflow | Purpose |'); + lines.push('| --- | --- |'); + lines.push( + '| `deploy-compose-ghcr.yml` | Build and push Compose-backed container images to GHCR, then run `netscript deploy docker up` with `--clear-cache`. |', + ); + lines.push( + '| `deploy-deno-deploy.yml` | Run checks and promote a Deno Deploy target with `netscript deploy deno-deploy up`. |', + ); + lines.push( + '| `deploy-bare-metal.yml` | Compile bare-metal service artifacts with `netscript deploy build` and upload them as workflow artifacts. |', + ); + lines.push(''); + lines.push( + 'Use each workflow environment input as the promotion path: `development` first, then `staging`, then `production` after the same artifact and configuration have passed review.', + ); + lines.push( + 'The Compose/GHCR workflow intentionally passes `--clear-cache` and does not persist `~/.aspire/deployments`, so Aspire deployment state that may include prompted secrets is not saved in CI caches.', + ); + lines.push(''); + } + if (options.serviceName) { lines.push(`## Example Service — \`${options.serviceName}\``); lines.push(''); @@ -177,13 +223,19 @@ export function generateReadme(options: ReadmeOptions): string { ); } lines.push(''); - lines.push('Use the local contributor CLI for database commands in this workspace:'); + lines.push( + 'Use the local contributor CLI for database commands in this workspace:', + ); if (!options.noAspire) { - lines.push('Run these after the Aspire AppHost has restored and started.'); + lines.push( + 'Run these after the Aspire AppHost has restored and started.', + ); } lines.push(''); lines.push('```bash'); - lines.push('deno run -A packages/cli/bin/netscript-dev.ts db init --name init'); + lines.push( + 'deno run -A packages/cli/bin/netscript-dev.ts db init --name init', + ); lines.push('deno run -A packages/cli/bin/netscript-dev.ts db generate'); lines.push('deno run -A packages/cli/bin/netscript-dev.ts db seed'); lines.push('deno run -A packages/cli/bin/netscript-dev.ts db status'); @@ -208,7 +260,9 @@ export function generateReadme(options: ReadmeOptions): string { lines.push('## Learn More'); lines.push(''); - lines.push('- [NetScript Documentation](https://github.com/rickylabs/netscript)'); + lines.push( + '- [NetScript Documentation](https://github.com/rickylabs/netscript)', + ); lines.push('- [Deno Manual](https://docs.deno.com)'); lines.push('- [Fresh](https://fresh.deno.dev)'); lines.push('- [oRPC](https://orpc.unnoq.com)'); diff --git a/packages/cli/src/kernel/templates/workspace/generators_test.ts b/packages/cli/src/kernel/templates/workspace/generators_test.ts index 18a82dac7..51892ce54 100644 --- a/packages/cli/src/kernel/templates/workspace/generators_test.ts +++ b/packages/cli/src/kernel/templates/workspace/generators_test.ts @@ -43,8 +43,17 @@ Deno.test('generateDenoJson emits the expected root workspace shape in JSR mode' '**/.generated', ]); assertEquals(result.tasks.dev, 'deno run --allow-all apps/dashboard/main.ts'); - assertEquals(result.tasks.check, 'deno check apps/**/*.ts services/**/*.ts contracts/**/*.ts'); - assertEquals(Object.keys(result.tasks), ['dev', 'check', 'lint', 'fmt', 'test']); + assertEquals( + result.tasks.check, + 'deno check apps/**/*.ts services/**/*.ts contracts/**/*.ts', + ); + assertEquals(Object.keys(result.tasks), [ + 'dev', + 'check', + 'lint', + 'fmt', + 'test', + ]); assertEquals(Object.keys(result.fmt), [ 'useTabs', 'lineWidth', @@ -143,7 +152,10 @@ Deno.test('generateDenoJson expands copied workspace packages in stable order', './packages/cron', './packages/database', ]); - assertEquals(result.workspace[result.workspace.length - 1], './packages/prisma-adapter-mysql'); + assertEquals( + result.workspace[result.workspace.length - 1], + './packages/prisma-adapter-mysql', + ); }); Deno.test('generateNetScriptConfig emits the JSR import and stable section order', () => { @@ -152,8 +164,13 @@ Deno.test('generateNetScriptConfig emits the JSR import and stable section order importMode: 'jsr', }); - assert(!result.includes('// TODO: When @netscript packages are published to JSR')); - assertStringIncludes(result, "import { defineConfig } from '@netscript/config';"); + assert( + !result.includes('// TODO: When @netscript packages are published to JSR'), + ); + assertStringIncludes( + result, + "import { defineConfig } from '@netscript/config';", + ); assert( result.indexOf("import { defineConfig } from '@netscript/config';") < result.indexOf('export default defineConfig({'), @@ -176,8 +193,13 @@ Deno.test('generateNetScriptConfig switches to local imports without the JSR TOD localBase: '../..', }); - assertStringIncludes(result, "import { defineConfig } from '@netscript/config';"); - assert(!result.includes('// TODO: When @netscript packages are published to JSR')); + assertStringIncludes( + result, + "import { defineConfig } from '@netscript/config';", + ); + assert( + !result.includes('// TODO: When @netscript packages are published to JSR'), + ); assert( result.indexOf("import { defineConfig } from '@netscript/config';") < result.indexOf('export default defineConfig({'), @@ -200,19 +222,49 @@ Deno.test('generateReadme — TS AppHost with service + postgres', () => { assertStringIncludes(md, 'services/users'); assertStringIncludes(md, 'PostgreSQL'); assertStringIncludes(md, 'appsettings.json'); - assertStringIncludes(md, 'deno run -A packages/cli/bin/netscript-dev.ts --help'); + assertStringIncludes( + md, + 'deno run -A packages/cli/bin/netscript-dev.ts --help', + ); assertStringIncludes(md, 'deno run -A packages/cli/bin/netscript.ts --help'); assertStringIncludes( md, 'Use `packages/cli/bin/netscript-dev.ts` for local contributor workflows', ); - assertStringIncludes(md, 'deno run -A packages/cli/bin/netscript-dev.ts db init --name init'); - assertStringIncludes(md, 'deno run -A packages/cli/bin/netscript-dev.ts db generate'); - assertStringIncludes(md, 'deno run -A packages/cli/bin/netscript-dev.ts db seed'); - assertStringIncludes(md, 'deno run -A packages/cli/bin/netscript-dev.ts db status'); + assertStringIncludes( + md, + 'deno run -A packages/cli/bin/netscript-dev.ts db init --name init', + ); + assertStringIncludes( + md, + 'deno run -A packages/cli/bin/netscript-dev.ts db generate', + ); + assertStringIncludes( + md, + 'deno run -A packages/cli/bin/netscript-dev.ts db seed', + ); + assertStringIncludes( + md, + 'deno run -A packages/cli/bin/netscript-dev.ts db status', + ); + assertStringIncludes(md, 'deploy-compose-ghcr.yml'); + assertStringIncludes(md, 'deploy-deno-deploy.yml'); + assertStringIncludes(md, 'deploy-bare-metal.yml'); + assertStringIncludes( + md, + '`development` first, then `staging`, then `production`', + ); + assertStringIncludes(md, '--clear-cache'); + assertStringIncludes(md, '~/.aspire/deployments'); assertStringIncludes(md, '`users.health.check` via `/api/rpc`'); - assert(!md.includes('dotnet run'), 'TS AppHost README should not mention dotnet run'); - assert(!md.includes('aspire run'), 'TS AppHost README should not mention aspire run'); + assert( + !md.includes('dotnet run'), + 'TS AppHost README should not mention dotnet run', + ); + assert( + !md.includes('aspire run'), + 'TS AppHost README should not mention aspire run', + ); }); Deno.test('generateReadme — legacy C# AppHost', () => { @@ -225,9 +277,18 @@ Deno.test('generateReadme — legacy C# AppHost', () => { }); assertStringIncludes(md, 'dotnet run --project dotnet/AppHost'); assertStringIncludes(md, 'dotnet/'); - assert(!md.includes('aspire run'), 'legacy README should not mention aspire run'); - assert(!md.includes('services/'), 'README should omit services section when no service'); - assert(!md.includes('## Database'), 'README should omit database section when dbEngine=none'); + assert( + !md.includes('aspire run'), + 'legacy README should not mention aspire run', + ); + assert( + !md.includes('services/'), + 'README should omit services section when no service', + ); + assert( + !md.includes('## Database'), + 'README should omit database section when dbEngine=none', + ); }); Deno.test('generateReadme — no aspire points at app dev task', () => { @@ -239,9 +300,22 @@ Deno.test('generateReadme — no aspire points at app dev task', () => { dbEngine: 'none', }); assertStringIncludes(md, 'deno task --cwd apps/dashboard dev'); - assert(!md.includes('aspire run'), 'no-aspire README must not mention aspire run'); - assert(!md.includes('dotnet run'), 'no-aspire README must not mention dotnet run'); - assert(!md.includes('appsettings.json'), 'no-aspire README must not list appsettings.json'); + assert( + !md.includes('aspire run'), + 'no-aspire README must not mention aspire run', + ); + assert( + !md.includes('dotnet run'), + 'no-aspire README must not mention dotnet run', + ); + assert( + !md.includes('appsettings.json'), + 'no-aspire README must not list appsettings.json', + ); + assert( + !md.includes('## Deployment CI'), + 'no-aspire README should not describe Aspire CI', + ); }); Deno.test('generateReadme — no aspire postgres asks for self-provisioning', () => { @@ -256,7 +330,10 @@ Deno.test('generateReadme — no aspire postgres asks for self-provisioning', () assertStringIncludes(md, 'Self-provision the database'); assertStringIncludes(md, '`POSTGRES_URI` or `DATABASE_URL`'); assert(!md.includes('Aspire orchestration layer provisions it')); - assert(!md.includes('appsettings.json'), 'no-aspire README must not mention appsettings.json'); + assert( + !md.includes('appsettings.json'), + 'no-aspire README must not mention appsettings.json', + ); }); Deno.test('generateReadme — sqlite gets non-persistent note', () => { diff --git a/packages/cli/src/public/features/deploy/deno-deploy/deno-deploy-command.ts b/packages/cli/src/public/features/deploy/deno-deploy/deno-deploy-command.ts index 18fdd225b..cf748d4ff 100644 --- a/packages/cli/src/public/features/deploy/deno-deploy/deno-deploy-command.ts +++ b/packages/cli/src/public/features/deploy/deno-deploy/deno-deploy-command.ts @@ -23,13 +23,19 @@ import type { DeployTargetOperation, DeployTargetPort, } from '../../../../kernel/domain/deploy/deploy-target-port.ts'; +import type { DenoDeployTargetDefaults } from '../../../../kernel/domain/deploy/deno-deploy-target.ts'; import type { PublicCommandDependencies } from '../../root/public-command-dependencies.ts'; /** Dependencies the Deno Deploy command surface needs. */ -type DenoDeployCommandDependencies = Pick< - PublicCommandDependencies, - 'loadConfig' | 'resolveProjectRoot' ->; +type DenoDeployCommandDependencies = + & Pick< + PublicCommandDependencies, + 'loadConfig' | 'resolveProjectRoot' + > + & { + /** Compose the target implementation. Defaults to the concrete Deno Deploy adapter. */ + readonly createTarget?: (defaults: DenoDeployTargetDefaults) => DeployTargetPort; + }; /** Flag overrides shared by every Deno Deploy operation. */ interface DenoDeployFlags { @@ -67,7 +73,8 @@ async function dispatch( envFile: flags.envFile, }); - const target: DeployTargetPort = createDenoDeployTarget(resolved); + const target: DeployTargetPort = dependencies.createTarget?.(resolved) ?? + createDenoDeployTarget(resolved); const handler = target[operation]; if (!handler) { outputError(red(`✗ Deno Deploy does not support \`${operation}\`.`)); diff --git a/packages/cli/src/public/features/deploy/deploy-group.ts b/packages/cli/src/public/features/deploy/deploy-group.ts index 8d402243f..b4b3179a0 100644 --- a/packages/cli/src/public/features/deploy/deploy-group.ts +++ b/packages/cli/src/public/features/deploy/deploy-group.ts @@ -31,12 +31,13 @@ export function createDeployCommand( buildDeployDependencies: { loadConfig: dependencies.deployBuildDependencies.loadConfig, buildWindowsDeployment: (config, options) => - buildWindowsDeployment(config, { - ...options, - skipServices: options.skipServices ? [...options.skipServices] : undefined, - includeTasks: options.includeTasks ? [...options.includeTasks] : undefined, - excludeTasks: options.excludeTasks ? [...options.excludeTasks] : undefined, - }), + (dependencies.deployBuildDependencies.buildWindowsDeployment ?? + buildWindowsDeployment)(config, { + ...options, + skipServices: options.skipServices ? [...options.skipServices] : undefined, + includeTasks: options.includeTasks ? [...options.includeTasks] : undefined, + excludeTasks: options.excludeTasks ? [...options.excludeTasks] : undefined, + }), }, }), ) @@ -45,6 +46,7 @@ export function createDeployCommand( createDenoDeployCommand({ loadConfig: dependencies.loadConfig, resolveProjectRoot: dependencies.resolveProjectRoot, + createTarget: dependencies.denoDeployTargetFactory, }), ) .command('package-cli', packageCliCommand) diff --git a/packages/cli/src/public/features/deploy/target/target-deploy-command.ts b/packages/cli/src/public/features/deploy/target/target-deploy-command.ts index 3a2c5683d..7956ff613 100644 --- a/packages/cli/src/public/features/deploy/target/target-deploy-command.ts +++ b/packages/cli/src/public/features/deploy/target/target-deploy-command.ts @@ -63,9 +63,32 @@ export function createTargetDeployCommand( operation, new Command() .description(OPERATION_DESCRIPTIONS[operation]) - .option('--project-root ', 'Project root for the deployment operation') - .option('--output-dir ', 'Directory for emitted deployment artifacts') - .action(async (options: { projectRoot?: string; outputDir?: string }) => { + .option( + '--project-root ', + 'Project root for the deployment operation', + ) + .option( + '--output-dir ', + 'Directory for emitted deployment artifacts', + ) + .option( + '--environment ', + 'Deployment environment passed to the target', + ) + .option( + '--clear-cache', + 'Clear target deployment state and do not persist new values', + ) + .option('--non-interactive', 'Run the target in non-interactive mode') + .action(async ( + options: { + projectRoot?: string; + outputDir?: string; + environment?: string; + clearCache?: boolean; + nonInteractive?: boolean; + }, + ) => { await runTargetOperation(dependencies, key, operation, options); }), ); @@ -78,17 +101,29 @@ async function runTargetOperation( dependencies: PublicCommandDependencies, key: string, operation: DeployOperation, - options: { projectRoot?: string; outputDir?: string }, + options: { + projectRoot?: string; + outputDir?: string; + environment?: string; + clearCache?: boolean; + nonInteractive?: boolean; + }, ): Promise { const target = dependencies.deployTargets.get(key); if (!target || typeof target[operation] !== 'function') { - outputError(red(`✗ Deploy target '${key}' does not support '${operation}'.`)); + outputError( + red(`✗ Deploy target '${key}' does not support '${operation}'.`), + ); failDeployCommand(`Unsupported deploy operation: ${key} ${operation}`); } - const projectRoot = await dependencies.resolveProjectRoot(options.projectRoot); + const projectRoot = await dependencies.resolveProjectRoot( + options.projectRoot, + ); if (!projectRoot) { - outputError(red('✗ Could not resolve a project root. Pass --project-root.')); + outputError( + red('✗ Could not resolve a project root. Pass --project-root.'), + ); failDeployCommand('Deploy command failed: project root not found.'); } @@ -96,10 +131,19 @@ async function runTargetOperation( const outputDir = options.outputDir ?? targetConfig?.outputPath; try { - const result = await target[operation]!({ projectRoot, outputDir, targetConfig }); + const result = await target[operation]!({ + projectRoot, + outputDir, + environment: options.environment, + clearCache: options.clearCache, + nonInteractive: options.nonInteractive, + targetConfig, + }); outputText(green(`✓ ${result.message}`)); } catch (error: unknown) { - outputError(red(`✗ ${error instanceof Error ? error.message : String(error)}`)); + outputError( + red(`✗ ${error instanceof Error ? error.message : String(error)}`), + ); failDeployCommand('Deploy command failed.', { cause: error }); } } diff --git a/packages/cli/src/public/features/deploy/target/target-deploy-command_test.ts b/packages/cli/src/public/features/deploy/target/target-deploy-command_test.ts index 1e05e773e..17dd7d7e1 100644 --- a/packages/cli/src/public/features/deploy/target/target-deploy-command_test.ts +++ b/packages/cli/src/public/features/deploy/target/target-deploy-command_test.ts @@ -39,7 +39,10 @@ function fakeTarget(calls: RecordedOp[]): DeployTargetPort { } function fakeDependencies(calls: RecordedOp[]): PublicCommandDependencies { - const deployTargets = new DeployTargetRegistry([['compose', fakeTarget(calls)]]); + const deployTargets = new DeployTargetRegistry([[ + 'compose', + fakeTarget(calls), + ]]); return { deployTargets, resolveProjectRoot: (projectRoot?: string) => Promise.resolve(projectRoot ?? '/resolved-root'), @@ -58,12 +61,35 @@ Deno.test('router routes a verb straight to the registry-resolved adapter', asyn const calls: RecordedOp[] = []; const command = createTargetDeployCommand('compose', fakeDependencies(calls)); - await command.parse(['plan', '--project-root', '/proj', '--output-dir', '.deploy/compose']); + await command.parse([ + 'plan', + '--project-root', + '/proj', + '--output-dir', + '.deploy/compose', + '--environment', + 'staging', + '--non-interactive', + ]); assertEquals(calls.length, 1); assertEquals(calls[0].operation, 'plan'); assertEquals(calls[0].request.projectRoot, '/proj'); assertEquals(calls[0].request.outputDir, '.deploy/compose'); + assertEquals(calls[0].request.environment, 'staging'); + assertEquals(calls[0].request.nonInteractive, true); +}); + +Deno.test('router forwards cache clearing without target-specific branching', async () => { + const calls: RecordedOp[] = []; + const command = createTargetDeployCommand('compose', fakeDependencies(calls)); + + await command.parse(['up', '--clear-cache']); + + assertEquals(calls.length, 1); + assertEquals(calls[0].operation, 'up'); + assertEquals(calls[0].request.projectRoot, '/resolved-root'); + assertEquals(calls[0].request.clearCache, true); }); Deno.test('router merges target config into the deploy request', async () => { diff --git a/packages/cli/src/public/features/root/public-command-dependencies.ts b/packages/cli/src/public/features/root/public-command-dependencies.ts index 5a0bd2af0..28071f712 100644 --- a/packages/cli/src/public/features/root/public-command-dependencies.ts +++ b/packages/cli/src/public/features/root/public-command-dependencies.ts @@ -9,6 +9,7 @@ import { findProjectRoot as findDeployProjectRoot, loadDeployConfig, } from '../../../kernel/adapters/config/deploy-config.ts'; +import type { buildWindowsDeployment } from '../deploy/build/build-windows-strategy.ts'; import { loadRegisteredPlugins } from '../../../kernel/adapters/config/plugin-registry.ts'; import { createProjectConfigLoader } from '../../../kernel/adapters/config/project-config-loader.ts'; import { createContractScaffolder } from '../../../kernel/adapters/contracts/contract-scaffolder.ts'; @@ -45,6 +46,8 @@ import type { GenerateRuntimeSchemasCommandDependencies } from '../generate/runt import type { InitPipelineContext } from '../../../kernel/application/scaffold/context.ts'; import type { FileSystemPort } from '../../../kernel/ports/file-system-port.ts'; import type { ProcessPort } from '../../../kernel/ports/process-port.ts'; +import type { DenoDeployTargetDefaults } from '../../../kernel/domain/deploy/deno-deploy-target.ts'; +import type { DeployTargetPort } from '../../../kernel/domain/deploy/deploy-target-port.ts'; import { createPluginDispatchPort } from '../plugins/dispatch/dispatch-plugin-verb.ts'; import type { PluginDispatchPort } from '../plugins/dispatch/plugin-dispatch-port.ts'; import type { DoctorPluginCommandDependencies } from '../plugins/doctor/doctor-plugin-command.ts'; @@ -143,7 +146,10 @@ export interface PublicCommandDependencies { /** Dependencies for deploy build. */ readonly deployBuildDependencies: { readonly loadConfig: typeof loadDeployConfig; + readonly buildWindowsDeployment?: typeof buildWindowsDeployment; }; + /** Optional Deno Deploy target factory override, used by parser-level tests. */ + readonly denoDeployTargetFactory?: (defaults: DenoDeployTargetDefaults) => DeployTargetPort; /** Resolve deployment manifests for install/uninstall. */ readonly manifestPort: { readonly resolve: (