diff --git a/.llm/harness/debt/arch-debt.md b/.llm/harness/debt/arch-debt.md index 80fa239b5..e84d545d6 100644 --- a/.llm/harness/debt/arch-debt.md +++ b/.llm/harness/debt/arch-debt.md @@ -1313,12 +1313,18 @@ match the merged exemplars). IMPL-EVAL must not FAIL a slice for retaining eithe command. - **Owner:** Deployment tooling follow-up. - **Created:** 2026-06-22. -- **Status:** partially resolved (#339 + #340), narrowed to container/orchestrator artifacts. +- **Status:** partially resolved (#339 + #340, #342), narrowed to container/orchestrator artifacts. - **Update (2026-07-03, #339/#340):** `netscript deploy` now IS a first-class command that emits bare-metal deployment artifacts: a self-contained single binary per service (`deno compile`, OS-generic triple) managed as an OS service through the OS-agnostic `OsServicePort` (Servy on Windows, systemd on Linux). Dockerfiles / Compose / Kubernetes manifests are still not generated — the container/orchestrator lane is tracked by the Aspire-compose deploy adapter (#343). +- **Update (2026-07-03, #342 Deploy-S6):** a first-class cloud deploy target now exists — + `netscript deploy deno-deploy` (`plan`/`up`/`down`/`status`/`logs`) via the `DenoDeployTarget` + adapter behind the canonical `DeployTargetPort`. `rollback`/`secrets` remain declared-unsupported + pending the deploy-core convention seam (#341/#364) — the adapter delegates to the seam by design + and omits the ops until it lands, rather than forking. The unstable-API preflight guard is + best-effort (`deno.json` + entrypoint, not the full transitive module graph). - **Gate:** Close when `netscript deploy` (or scaffold generation) also emits the container / orchestrator artifacts, or the deployment docs are rewritten to remove generated-artifact expectations for those targets. diff --git a/.llm/tmp/run/deploy-s6-deno-deploy/commits.md b/.llm/tmp/run/deploy-s6-deno-deploy/commits.md new file mode 100644 index 000000000..6a6e5c5eb --- /dev/null +++ b/.llm/tmp/run/deploy-s6-deno-deploy/commits.md @@ -0,0 +1,8 @@ +# Commits — deploy-s6-deno-deploy + +- bf0beeef: docs(deploy): [Deploy-S6] research + plan for Deno Deploy tier-1 adapter (#342) — rev 1 (FAIL_PLAN B1) +- 0c760b3c: docs(deploy): [Deploy-S6] revise research+plan to Archetype-7 7-op contract (#342) — rev 2 (post-FAIL_PLAN correction) +- dc115f3a: feat(deploy): [Deploy-S6] add deno-deploy config member (S1) — config type+schema+exports+tests; check/test/lint/fmt/publish-dry-run green +- 8bea12ac: feat(deploy): [Deploy-S6] add unstable-API preflight guard (S2) — pure domain scanner + 6 tests; check/test/fmt/lint green +- 06f595ff: feat(deploy): add Deno Deploy target adapter (7-op subset) (S3) — DenoDeployTarget (domain) + DenoDeployCliAdapter/DenoDeployPreflightReader (kernel/adapters, ProcessPort-behind-port) + registry wiring; 14 tests; check/test/fmt/lint green; pushed 34013a72..06f595ff +- 2c93ce8d: feat(deploy): wire Deno Deploy target into CLI router + config resolver (S4) — resolveDenoDeployTarget + createDenoDeployTarget composition factory + `netscript deploy deno-deploy` thin router (plan/up/down/status/logs) + README permissions block + arch-debt advance; +4 resolver tests (18 total); check(72 files)/test/fmt green diff --git a/.llm/tmp/run/deploy-s6-deno-deploy/drift.md b/.llm/tmp/run/deploy-s6-deno-deploy/drift.md new file mode 100644 index 000000000..8c0d1fc63 --- /dev/null +++ b/.llm/tmp/run/deploy-s6-deno-deploy/drift.md @@ -0,0 +1,58 @@ +# Drift — deploy-s6-deno-deploy (Implement) + +Append-only. Records divergence from plan/doctrine discovered during implementation. + +## D-IMPL-1 — `secrets`/`rollback` core convention seam is ABSENT (BLOCKING for those two ops) + +- **Severity:** significant (cross-slice dependency). +- **Plan expectation:** Locked D4 sets the adapter's `operations` to the full + `['plan','up','down','status','logs','rollback','secrets']` and routes `secrets`/`rollback` + through the deploy **core** conventions (R-DEPLOY-3), owned by #339/#340/#341 (PR #364/#341). +- **Reality on branch (95576c44 S0 merge + main):** only the 7-op `DeployTargetPort` type exists. + There is **no core secrets/rollback convention primitive** anywhere in `packages/cli/src/kernel` + (grep for `rollback|secrets convention|SecretsConvention|RollbackConvention` returns only the port + type + its test + unrelated asset templates). The port's own docstring states rollback/secrets + bodies "land with the deployment hardening slice (#341); until then adapters may declare them + unsupported (omit the method) rather than provide a silent no-op." +- **Decision (per task guardrail + port doctrine):** do NOT fork the convention adapter-locally + (that is the R-DEPLOY-3 violation / B1-class error). The DenoDeployTarget declares the subset it + can genuinely support today — `operations = ['plan','up','down','status','logs']` — and OMITS the + `rollback`/`secrets` methods, exactly as the port docstring and the S0 `linux-service` test + precedent prescribe. `rollback`/`secrets` are added when the #341/#364 core convention seam lands; + the adapter is structured so they slot in by delegation, not re-implementation. +- **Surface:** flagged as a NEEDS-USER / cross-slice blocker in the implementer checkpoint. Does not + block the marquee `up` path (plan/up/down/status/logs), which is the beta driver (D5). + +## D-IMPL-2 — `packages/cli` is excluded from workspace `deno fmt`/`deno lint` + +- **Severity:** minor (evidence-path clarification, not a code change). +- Root `deno.json` `fmt.exclude` and `lint.exclude` both list `packages/cli/`; the repo `fmt:check` + task also excludes it via `^(packages/(cli)|...)`. The scoped wrappers run from the worktree root + therefore report "No target files found" for cli paths — this is the repo policy, not a failure. +- **Evidence path used instead:** `deno check` (authoritative) + `deno test` for cli, plus a direct + `deno fmt --check --no-config --line-width=100 --indent-width=2 --options-single-quote` and + `deno lint --no-config` on the new files to prove default-style conformance. `packages/config` IS + fmt/lint-gated and uses the wrappers normally. +- **S4 addendum (`no-explicit-any`):** `deno lint --no-config` on the new `deno-deploy-command.ts` + reports `no-explicit-any` for the cliffy `Command` generics. These are the repo-standard, + explicitly-accepted "top-level router any" (see MEMORY: only 2 accepted casts + the router `any`); + every command in `packages/cli/src/public/features/**` uses the identical signature. cli is + lint-excluded, so this is not a new soundness debt and not a gate failure. Business logic uses no + `any` (flag marshalling narrows from `unknown`). + +## D-IMPL-3 — unstable-API preflight guard is best-effort (not full graph walk) + +- **Severity:** minor (documented bound, recorded in arch-debt). +- The `DenoDeployPreflightReader` feeds the pure guard with `deno.json` + the entrypoint (and + `src/`), not the full transitive module graph. A violation reached only through a deep + import is not detected. This is an intentional cost/scope bound for S6; recorded under + `cli-deploy-artifacts-missing` in arch-debt as remaining work. The guard is advisory on preview + pushes and blocking only on `up --prod`. + +## D-IMPL-4 — `deno deploy delete/show` subcommand surface assumption + +- **Severity:** minor (upstream-CLI-shape assumption, isolated). +- The argv builders map `down`→`deno deploy delete` and `status`→`deno deploy show`. The non-`deploy` + subcommand surface of the native `deno deploy` CLI is still stabilizing; the builders are isolated + (`buildDeleteArgs`/`buildStatusArgs`) so a rename is a one-file edit. Verified argv shape by unit + test, not against a live platform account (no credentials in this environment — D5 NEEDS-USER). diff --git a/.llm/tmp/run/deploy-s6-deno-deploy/plan.md b/.llm/tmp/run/deploy-s6-deno-deploy/plan.md new file mode 100644 index 000000000..e0f8a13ff --- /dev/null +++ b/.llm/tmp/run/deploy-s6-deno-deploy/plan.md @@ -0,0 +1,306 @@ +# Plan: Deno Deploy tier-1 adapter (#342 [Deploy-S6], MARQUEE) + +> **Revision 2 (post-FAIL_PLAN).** Rev 1 was returned `FAIL_PLAN` (blocking finding B1): it +> misattributed the shipped 3-op `DeployTargetPort` to #338, demoted the ratified **7-op Archetype 7** +> contract to "stale corpus," and premised "#337 not merged." This revision: (a) selects +> **Archetype 7 (composite)** as primary; (b) conforms to the canonical **7-op** deploy contract; +> (c) records that the 3-op→7-op **port expansion is OWNED by #339/#340** and #342 **consumes** it +> (does not redefine it); (d) re-baselines against **merged `origin/main`** (#352 config + +> `3137e455` seed port already landed); (e) corrects the dispatch lane to **Opus 4.8 sub-agents**. +> Still **PLAN-ONLY** — no adapter/CLI/schema code is written; Implement waits for PLAN-EVAL. + +## Run Metadata + +| Field | Value | +| -------------- | --------------------------------------------------------------- | +| Run ID | `deploy-s6-deno-deploy` | +| Branch | `feat/deploy-s6-deno-deploy` (forked off `cf1ac47b`; re-baselined against merged `origin/main` @ `37108172`) | +| Phase | `plan` (PLAN-ONLY; Implement blocked on merge order below + PLAN-EVAL PASS) | +| Target | `packages/cli` (adapter + wiring) + `packages/config` (schema) | +| Archetype | **7 — Deploy Target Adapter (composite: Arch 2 Integration + Arch 6 CLI/Tooling)** | +| Scope overlays | `none` (no Fresh/UI; no new long-running service; Aspire-optional at runtime gate) | + +## Archetype + +**Primary: ARCHETYPE-7 — Deployment Target Adapter (composite).** Ratified by **#338** (impl #357, +IMPL-EVAL PASS); doctrine at `.llm/harness/archetypes/ARCHETYPE-7-deploy-target-adapter.md`. It is a +**composite** that composes two archetypes and folds **neither**: + +- **Composed Archetype 2 (Integration — port/adapter core).** The substance is wrapping an external + system — the Deno Deploy platform via the `deno deploy` CLI — behind a **package-owned port** + (the 7-op deploy port) registered in a **closed-on-key** `deploy-target-registry`. The deno-deploy + adapter is a new **adapter on that existing port axis**: it shells the external CLI through a + `ProcessPort` (mirroring `ServyCliAdapter`), never ad-hoc `Deno.*` in features/presentation + (F-CLI-16, AP-11). +- **Composed Archetype 6 (CLI/Tooling — thin router).** The user reaches the target through a **thin** + `netscript deploy ` router that carries **no target-specific business logic** + (R-DEPLOY-2) — it dispatches to the registry. + +**Config member (small-contract sub-concern).** The `deploy.targets['deno-deploy']` schema member is +a types+schema addition to the published `@netscript/config` package, following the **exact +spread-composition + `z.ZodType` pattern** merged for `windows` via **#352** (Findings 1–2). Per +R-DEPLOY-4 the member **spreads** `deployTargetBaseShape` — no per-target base-class hierarchy. + +**Why Archetype 7 and not "Arch-6 folding Arch-2":** #338 established that deployment targets are a +first-class composite archetype with its own rule family (R-DEPLOY-1..5) and gates (F-DEPLOY-1/2). +Rev 1's "Arch-6 primary folding Arch-2" framing is superseded; this slice is governed by Archetype 7 +and must satisfy its rules, not just the generic CLI family. + +## Canonical contract (7-op) — the correction + +The deploy port exposes the **uniform 7-op contract** (Archetype 7): **`plan`/`emit` · `up` · `down` +· `status` · `logs` · `rollback` · `secrets`**. Each adapter implements the **subset it supports** +(R-DEPLOY-1). The 3-op `build|install|uninstall` port currently on `main` is a **seed stub from +`3137e455`** (an unrelated command-registry slice), scheduled for expansion to 7-op by **#339/#340**. + +**#342 CONSUMES the expanded 7-op port; it does NOT define or redefine it** (research Finding 6; +`port-ownership.md`). The deno-deploy adapter implements this subset: + +| Canonical op | Deno Deploy mapping | Support | +| -------------- | ------------------- | ------- | +| `plan`/`emit` | Preflight: validate `deno.json` Deploy build settings + run the **unstable-API guard**; compute the plan, no platform mutation. | Supported (local) | +| `up` | **`deno deploy`** (preview) / **`deno deploy --prod`** (production) — the marquee one-click push. | Supported (marquee) | +| `down` | Delete the Deno Deploy app/deployment (platform-native; CLI verb TBD). | Supported (platform-native) | +| `status` | Deployment/app state via platform API. | Supported | +| `logs` | **`deno deploy logs`**. | Supported | +| `rollback` | Platform-native rollback (repoint to prior good deployment), routed via the **core rollback convention** (R-DEPLOY-3) — not a no-op. | Supported | +| `secrets` | **`deno deploy env add [--secret]` / `env load`**, via the **core secrets convention** (R-DEPLOY-3). | Supported | +| (host install/uninstall) | N/A — hosted platform has no OS-service install. | **Explicitly unsupported** (declared subset) | + +Legacy CLI verbs (`build/install/uninstall`) remain **user-facing aliases** onto canonical ops per +the #339/#340 verb-lock (`build`→`plan`, `install`→`up`, `uninstall`→`down`); the adapter's declared +`operations` set uses the **canonical** names. + +## Current Doctrine Verdict + +N/A as a fresh verdict for this consumer-of-doctrine slice. Governing doctrine: **Archetype 7** +(#338/#357) + `07-composition-and-extension.md` (Deploy target axis; `registerDeployTarget` factory +pattern; no cross-package inheritance; every `Registry` in `extension-points.ts`). The adapter +conforms to that axis; it does not change doctrine. **R-DEPLOY-3** requires the shared conventions +(health, OTEL, **secrets, rollback**) to live in the deploy **core**, not in this adapter. + +## Axioms in Play + +| Axiom | Why it matters | +| ----- | -------------- | +| A2 (public surface minimal) | Adapter is internal; only the config type + schema is new public surface. | +| A5 / A6 (ports & adapters, no cross-boundary inheritance) | Adapter implements the port; consumer supplies a factory, not a subclass (R-COMP, R-DEPLOY-4). | +| A11 (edge-only side effects) | `deno deploy` shelling goes through a `ProcessPort` in `adapters/`; no `Deno.Command` in features/presentation (F-CLI-16, AP-11). | +| A8 (extension via typed registry) | New target registered in the closed-on-key `deploy-target-registry`, exported from `extension-points.ts` (F-CLI-31, AP-24). | +| A13/A14 (declarative composition, documented permissions) | Composition declarative (F-CLI-27); README declares `--allow-run` for `deno deploy` + network (AP-19). | + +## Goal + +Add a tier-1 **Deno Deploy** deployment target to NetScript: a `deploy.targets['deno-deploy']` +config member (spreads #352's `deployTargetBaseShape`) + a resolver + a `DenoDeployTarget` adapter +implementing the **subset of the 7-op deploy port** (consumed from #339/#340) that Deno Deploy +supports, shelling the native `deno deploy` CLI (source build, no Dockerfile) to push a scaffolded +NetScript project to the new Deno Deploy platform — reachable via the thin router as +`netscript deploy deno-deploy `. Includes the **unstable-API preflight guard** (Finding 9) in +`plan`, and routes `secrets`/`rollback` through the **core conventions** (R-DEPLOY-3). + +## Scope + +- `packages/config`: `DenoDeployTarget` interface + `DenoDeployTargetSchema` (`z.ZodType<…>`, + spread-composed on `deployTargetBaseShape`); add `deno-deploy` key to `DeployConfigSchema.targets` + and `DeployConfig.targets`. +- `packages/cli` kernel: `DenoDeployTarget` adapter implementing the **7-op port** subset + (`key='deno-deploy'`, `operations = ['plan','up','down','status','logs','rollback','secrets']`); a + `deno deploy` process-wrapper adapter (mirrors `ServyCliAdapter`); an unstable-API preflight + checker (used by `plan`); a `resolveDenoDeployTarget` config resolver. +- `packages/cli` router reach: register the target so the thin `netscript deploy deno-deploy ` + router dispatches its ops (R-DEPLOY-2 — no target logic in the router), with `--org`/`--app`/ + `--prod`/`--env-file`/`--dry-run` mapped onto `deno deploy` flags. +- **Core conventions (consume, do not fork):** `secrets` and `rollback` route through the deploy + **core** convention primitives (R-DEPLOY-3) owned by #339/#340's core, not re-implemented here. +- Tests: schema round-trip/defaults; resolver defaults; adapter unit tests with a fake `ProcessPort` + (asserting exact `deno deploy` argv, exit-code mapping, unstable-guard refusal, declared-subset + reporting for unsupported ops); registry membership test. +- Docs: README permissions block (`--allow-run`, network) + a short how-to note; drift/worklog. + +## Non-Scope + +- **Owning or redefining the deploy port / op vocabulary** — owned by **#339/#340**; #342 consumes + the expanded 7-op port and rebases onto its port-contract commit. +- Building the `OsServicePort` + the full multi-target router — #339/#340 core work; #342 adds the + deno-deploy adapter behind it + its registry entry. +- Merging #357 / #339 / #340 (external prerequisites; this slice rebases onto them — merge order + below). +- Managed Postgres/KV wiring (`deno deploy database`), HA/multi-region, GitHub-push auto-build + provisioning, Deploy Classic/deployctl — deferred (research Non-goals). +- Authoring the shared `secrets`/`rollback`/health/OTEL **core conventions** — those are R-DEPLOY-3 + core primitives (#339/#340); #342 consumes them for the deno-deploy mapping. + +## Hidden Scope + +- **Port timing (Finding 3/6).** `main` currently carries only the 3-op seed stub. #342 Implement + **must not** begin against the 3-op stub; it rebases onto #339/#340's expanded 7-op port commit + first (merge order below). Designing the adapter against the 7-op contract now is safe because the + contract is ratified doctrine (#338). +- **`isolatedDeclarations` on config** — the new interface needs explicit field types; the schema + must keep the `z.ZodType` annotation or fast-types/`deno doc`/JSR slow-types break (Finding 2). +- **Unstable-API guard is not optional** — without it the marquee "one-click" silently ships an app + that dies on-platform (KV `--unstable-kv`). First-class slice, not a nice-to-have (Finding 9). +- **`secrets`/`rollback` must not be re-implemented per-target** — R-DEPLOY-3 requires the core + convention; the adapter only maps the convention onto `deno deploy env`/platform rollback. +- **Windows key precedent** — keep config member key, CLI token, and registry key all `deno-deploy` + (Finding 6 / windows precedent) to avoid the `windows` vs `windows-service` split. + +## Locked Decisions + +| ID | Decision | Rationale | +| -- | -------- | --------- | +| D1 | **Config base is MERGED (#352).** Implement adds the `deno-deploy` member by **spreading** `deployTargetBaseShape` (R-DEPLOY-4). No "rebase onto unmerged #337" gate — that rev-1 premise is dropped. | #352 (`44d7945b`) landed `DeployTargetBaseSchema`/`DeployConfigSchema` on `main` (Findings 1–2). | +| D2 | **Conform to Archetype 7 (#338/#357); cite it by name.** Adapter satisfies R-DEPLOY-1..5 + F-DEPLOY-1/2. | #338 ratified the composite archetype + 7-op contract (Findings 4–5); PLAN-EVAL checks conformance. | +| D3 | **CONSUME the 7-op port from #339/#340 — do NOT redefine it.** The adapter's `operations` uses the **canonical** op names; `build/install/uninstall` are user-facing aliases only. Implement rebases onto #339/#340's expanded-port commit. | Port expansion is **owned** by #339/#340 (Finding 6; `port-ownership.md`). Redefining it = the B1-class error. | +| D4 | **Op mapping = the 7-op→Deno-Deploy table above.** Deno Deploy implements the declared subset; host install/uninstall is explicitly unsupported. `secrets`/`rollback` route via the **core conventions** (R-DEPLOY-3), not adapter-local logic. | Uniform contract + R-DEPLOY-1 (subset) + R-DEPLOY-3 (conventions in core). | +| D5 | **CLI push is the beta driver** (`deno deploy` from project root → `up`), not GitHub-push auto-build. GitHub-push deferred. | Shortest deterministic one-click marquee. *(Product-facing default — flagged NEEDS USER for confirmation; reversible.)* | +| D6 | **Shell `deno deploy` through a `ProcessPort` adapter** (`DenoDeployCliAdapter`), mirroring `ServyCliAdapter`; capture stdout/stderr, map exit code → deploy result. No `Deno.Command` outside `kernel/adapters/**`. | Arch-2 named-adapter-behind-port + F-CLI-16; testable with a fake port. | +| D7 | **Thin router, register-first (R-DEPLOY-2).** Register the target in the closed-on-key `deploy-target-registry`; the `netscript deploy deno-deploy ` router only dispatches — **no target-specific logic in the command surface**. The generalized router is #339/#340 work; #342 supplies the adapter + registry entry. | R-DEPLOY-2; keeps the slice bounded. | +| D8 | **Registry key = CLI token = config member key = `deno-deploy`.** | Avoid the `windows`/`windows-service` split (Finding 6). | +| D9 | **Unstable-API preflight guard is a required slice**, invoked by `plan`. Scans entrypoint + `deno.json` imports for `--unstable-*`-requiring APIs (KV et al.) and refuses (or hard-warns) before `up`. | Deno Deploy rejects `--unstable-*`; NetScript KV plugins use `--unstable-kv` (Finding 9). | +| D10 | **Dispatch lane = Opus 4.8 sub-agents (this epic).** Implementers = Opus 4.8 sub-agents; evaluators = separate Opus session (or Codex GPT-5.5). **WSL Codex / OpenHands is NOT the impl lane for the deployment epic.** | Epic dispatch-lane override (Finding 14; `port-ownership.md`). | + +## Merge order (Implement gate) + +``` +#357 (Archetype-7 doctrine) → #339/#340 expanded 7-op port commit → rebase #342 onto both (+ current main, which already carries #352 + 3137e455) +``` + +Implement does NOT start until #339/#340's port-contract commit exists and #342 is rebased onto it. +Designing now against the ratified 7-op contract is safe and unblocks PLAN-EVAL. + +## Open-Decision Sweep + +| Decision | Status | Notes | +| -------- | ------ | ----- | +| **CLI-push vs GitHub-push default** | **NEEDS USER** (proceeding on D5 = CLI-push) | Product-facing marquee default. Reversible: if GitHub-push-first is wanted, the `up` driver changes but the port/schema/guard/conventions do not. | +| **Non-interactive CI auth for new `deno deploy`** (env var / org token) | **NEEDS USER** + freshness re-check at Implement | Docs confirm keyring + interactive prompt + user/org tokens but not a documented non-interactive env var (Finding 11). Blocks CI/e2e automation of a *real* push, not the adapter/unit design. | +| Unstable-guard = **refuse** vs **warn-and-continue** | safe to defer (default: refuse on `--prod`, warn on preview) | Reversible flag on the guard; does not affect its structure. | +| Exact `down`/`status`/`rollback` CLI-vs-API surface | safe to defer (confirm at Implement vs then-current platform API) | Each op isolated behind the port; platform-native mechanism confirmed at Implement freshness re-check. | + +> No open decision forces rework if deferred: the two `NEEDS USER` items affect the *driver default* +> and *CI auth wiring*, not port conformance, schema shape, adapter structure, the guard, or the +> core-convention routing — all locked. Recorded so PLAN-EVAL can confirm the deferral is safe. + +## Change Map + +| Area | File (relative to repo root) | Change | Slice | +| ---- | ---------------------------- | ------ | ----- | +| Config type | `packages/config/src/domain/config-section-types.ts` | Add `DenoDeployTarget extends DeployTargetBase`; add `deno-deploy?` to `DeployConfig.targets`. | S1 | +| Config schema | `packages/config/src/domain/schemas/deploy-schema.ts` | Add `DenoDeployTargetSchema: z.ZodType` (spread `deployTargetBaseShape` + Deno-Deploy fields); add `'deno-deploy'` key to `DeployConfigSchema.targets`. | S1 | +| Config test | `packages/config/**/deploy-schema*_test.ts` (co-located) | Round-trip + defaults + optionality for the new member. | S1 | +| Unstable guard | `packages/cli/src/kernel/domain/deploy/unstable-api-guard.ts` (or `adapters/…` if it reads FS) | Scan entrypoint + `deno.json` for `--unstable-*`-requiring APIs; return a violation report. | S2 | +| Adapter (7-op subset) | `packages/cli/src/kernel/domain/deploy/deno-deploy-target.ts` | `class DenoDeployTarget implements <7-op DeployPort>` (`key='deno-deploy'`, `operations = ['plan','up','down','status','logs','rollback','secrets']`, D4 mapping; `secrets`/`rollback` delegate to core conventions). | S3 | +| Process wrapper | `packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-cli.ts` | `DenoDeployCliAdapter` shelling `deno deploy` via `ProcessPort` (mirrors `ServyCliAdapter`); argv builders for deploy/env/logs/create. | S3 | +| Registry wiring | `packages/cli/src/kernel/application/registries/deploy-target-registry.ts` | Add `DENO_DEPLOY_TARGET` to `DEFAULT_DEPLOY_TARGETS` (closed-on-key registry). | S3 | +| Extension-points | `packages/cli/src/kernel/extension-points.ts` | Confirm registry re-export completeness (F-CLI-31); no new export expected. | S3 | +| Config resolver | `packages/cli/src/kernel/adapters/config/deploy-config-resolvers.ts` | Add `resolveDenoDeployTarget(userDeploy?.targets?['deno-deploy'])` → `ResolvedDenoDeployConfig` + defaults. | S4 | +| Router reach | `packages/cli/src/public/features/deploy/**` (thin, R-DEPLOY-2) | Dispatch `deno-deploy` ops through the router + `--org/--app/--prod/--env-file/--dry-run` → `deno deploy` flags; **no target logic in the surface**. | S4 | +| Adapter tests | co-located `*_test.ts` under the new folders | Fake `ProcessPort`: exact argv, exit-code→result, guard refusal, declared-subset for unsupported ops, registry membership. | S2–S4 | +| README/permissions | `packages/cli/README.md` (permissions block) | Declare `--allow-run` (deno deploy) + network; note unstable-API + auth caveats. | S4 | +| Debt note | `.llm/harness/debt/arch-debt.md` §`cli-deploy-artifacts-missing` | Update: cloud target added; debt advanced, not closed. | S4 | + +## Commit Slices + +Ordered per Archetype 7: kernel port-conformant domain + guard first; registry + adapters next; +router reach + resolver + docs last. Each is independently committed, pushed, PR-commented. All +slices are gated behind the **merge order** (rebase onto #357 + #339/#340 port commit) at Implement. + +| # | Slice | Proves | Gate | Files | +| - | ----- | ------ | ---- | ----- | +| S1 | **Config member** — `DenoDeployTarget` type + `DenoDeployTargetSchema` + `targets['deno-deploy']`. | Published config surface accepts a valid deno-deploy target and rejects bad shapes; fast-types intact; spreads base (R-DEPLOY-4). | `run-deno-check.ts --root packages/config --ext ts` + config `_test.ts` + `deno publish --dry-run` (config). | config type + schema + test. | +| S2 | **Unstable-API guard** (pure/kernel) + tests. | Guard flags `--unstable-*`-requiring imports (KV) in a fixture project and passes a clean one; consumed by `plan`. | `run-deno-check.ts --root packages/cli` + guard `_test.ts` (fixtures) + F-CLI-1/16 manual. | `unstable-api-guard.ts` + test. | +| S3 | **Adapter (7-op subset) + process wrapper + registry** — `DenoDeployTarget` implements the 7-op port subset; `DenoDeployCliAdapter` (ProcessPort); `DEFAULT_DEPLOY_TARGETS` entry. | Adapter declares the canonical op subset, emits exact `deno deploy` argv, maps exit codes, refuses on guard violation, delegates `secrets`/`rollback` to core conventions; registry lists `deno-deploy`. | adapter/registry `_test.ts` (fake `ProcessPort`) + `run-deno-check.ts` + F-DEPLOY-1 + F-CLI-16/17/19/28/31 + F-4 manual. | adapter, cli-wrapper, registry, extension-points confirm. | +| S4 | **Resolver + thin router reach + docs/debt** — `resolveDenoDeployTarget`, thin router dispatch, README permissions, arch-debt update. | `netscript deploy deno-deploy plan` runs the guard/preflight against a scaffold without a real push; router carries no target logic (R-DEPLOY-2); options map to `deno deploy` flags; README declares perms. | resolver `_test.ts` + CLI help/name check + `run-deno-lint.ts` + `run-deno-fmt.ts` + F-DEPLOY-2 + F-CLI-26/27 + AP-19 manual. | resolver, deploy-surface reach, README, arch-debt. | + +Slice count: **4** (< 30). If S3/S4 reveals the #339/#340 router/port is not yet mergeable or its +shape differs from the ratified 7-op contract, that is a **blocking dependency**, not a #342 rescope +— surface it, do not redefine the port. + +## Risk Register + +| Risk | Mitigation | +| ---- | ---------- | +| #339/#340 port-expansion commit not merged when #342 Implement starts. | Merge order hard-blocks Implement; PLAN designs against the ratified 7-op contract; rebase onto the port commit first. | +| Temptation to redefine the port inside #342 to "unblock." | **Forbidden** (D3): the port is owned by #339/#340. If blocked, escalate to the epic supervisor; do not fork the contract. | +| 7-op port shape lands slightly different from doctrine. | Design against ratified doctrine; drift-watch the port commit; re-verify at Implement start; reconcile with #339/#340 owner, not by local edits. | +| Deno Deploy CLI surface/auth changes (fast-moving platform). | Freshness re-check (`deno deploy --help`) at Implement; auth flagged `NEEDS USER`; adapter isolates argv construction in one wrapper. | +| Unstable-API guard false-negatives (misses a transitive KV import). | Scan entrypoint + `deno.json` import map + best-effort `deno info` graph; document bounds; default refuse on `--prod`. | +| Real e2e push needs live Deno Deploy creds/org. | Unit-test with fake `ProcessPort`; a real push is manual verification, gated on the auth `NEEDS USER`. | +| `secrets`/`rollback` re-implemented per-target (R-DEPLOY-3 violation). | Route through core conventions; adapter only maps convention → `deno deploy env`/platform rollback. | + +## Anti-Patterns to Resolve or Avoid + +| AP | Status | Plan | +| -- | ------ | ---- | +| AP-11 (side effects outside adapters) | risk | `deno deploy` shell only in `kernel/adapters/deno-deploy/**` via `ProcessPort` (D6). | +| AP-24 (switch-over-union for variants) | avoid | Register `deno-deploy` in the closed-on-key registry; no `if target === …` in the router (R-DEPLOY-2). | +| AP-3 (port with every backend op) | avoid | Implement the **declared subset** of the 7-op port; do not add adapter-only ops or bloat the shared port (D3/D4). | +| AP-19 (undocumented permissions) | new | README permissions block: `--allow-run` + network + unstable/auth caveats (S4). | +| AP-1 (command/pipeline monolith) | avoid | Adapter + wrapper + guard + resolver are separate files, each ≤ per-layer LOC caps (F-CLI-1/2). | +| AP-18 (string-snapshot tests) | avoid | Assert semantic argv arrays + exit-code mapping, not stdout snapshots. | +| (R-DEPLOY-3) convention duplication | avoid | `secrets`/`rollback` consume core conventions, not adapter-local re-implementations. | + +## Fitness Gates + +| Gate | Required | Expected evidence | +| ---- | -------- | ----------------- | +| **F-DEPLOY-1** (adapter implements 7-op contract or a **declared** subset) | yes | Adapter `operations` = canonical op names; unsupported host ops explicitly declared; subset test. | +| **F-DEPLOY-2** (thin router, conventions in core) | yes | Router dispatches only (R-DEPLOY-2); `secrets`/`rollback` delegate to core (R-DEPLOY-3). | +| Static (check/lint/fmt via scoped wrappers) | yes | `run-deno-check.ts`/`run-deno-lint.ts`/`run-deno-fmt.ts` on `packages/config` + `packages/cli`, `--ext ts`. | +| F-1/F-CLI-1,2 (file size) | yes | Adapter ≤ 350, guard/domain ≤ 250, command ≤ 150 LOC — manual/PENDING_SCRIPT. | +| F-4/F-CLI-4 (kernel ≠ surface imports) | yes | Import-graph manual: kernel adapter imports no `public/**`. | +| F-CLI-16 (`Deno.Command` only in adapters) | yes | Shelling only in `kernel/adapters/deno-deploy/**`. | +| F-CLI-31 (registry in extension-points) | yes | `deploy-target-registry` exported; confirm. | +| F-5 (public surface audit) | yes | Only `DenoDeployTarget`/schema newly public (config); CLI adapter internal. | +| F-6 / publish dry-run (config) | yes | `deno publish --dry-run` on `@netscript/config`. | +| F-9 / AP-19 (permissions declared) | yes | README permissions block updated. | +| F-10 (test-shape) | yes | Fake-`ProcessPort` semantic tests, guard fixtures, schema round-trip, declared-subset test. | +| Runtime/Aspire (optional) | at evaluator | `scaffold.runtime` e2e is the merge-readiness authority — evaluator, not per-slice. | + +> **F-DEPLOY-1/2 are currently seeded `reviewed`** (packages don't yet exist). This slice — the first +> cloud adapter — is where they promote toward `gated`; PLAN-EVAL confirms the promotion path. + +## Arch-Debt Implications + +| Entry | Action | Notes | +| ----- | ------ | ----- | +| `cli-deploy-artifacts-missing` (`arch-debt.md`) | update | Cloud (Deno Deploy) target added; debt **advanced not closed** (windows + deno-deploy only). | +| `Archetype-7 core-centralization + F-DEPLOY seed` | reference | This slice exercises the R-DEPLOY-3 core conventions for the first cloud adapter; note progress. | +| (new, if guard bounds are partial) | create-if-needed | If the unstable-API guard is best-effort, record the bound as explicit debt rather than claiming full coverage. | + +## Validation Plan + +| Order | Gate | Command or check | Expected result | +| ----- | ---- | ---------------- | --------------- | +| 1 | Config check | `deno run -A .llm/tools/run-deno-check.ts --root packages/config --ext ts` | 0 errors | +| 2 | Config tests | `deno test` on config deploy-schema `_test.ts` | green | +| 3 | Config publish surface | `deno task publish:dry-run` (config member) | dry-run success (modulo declared JSR_DEPS_PENDING) | +| 4 | CLI check | `deno run -A .llm/tools/run-deno-check.ts --root packages/cli --ext ts` (+`--unstable-kv` if KV-touching) | 0 errors | +| 5 | CLI tests | `deno test` on new adapter/guard/resolver `_test.ts` (fake `ProcessPort`) | green | +| 6 | Lint + fmt | `run-deno-lint.ts` + `run-deno-fmt.ts` on both roots, `--ext ts` | clean | +| 7 | Fitness (Arch-7) | `deno task arch:check` (F-DEPLOY-1/2 + F-CLI-*) or PENDING_SCRIPT with manual evidence | pass / documented | +| 8 | Merge-readiness (evaluator) | `deno task e2e:cli run scaffold.runtime --cleanup --format pretty` | evaluator pass; NOT a per-slice gate (expensive) | +| 9 | Real push (manual, gated on auth `NEEDS USER`) | `netscript deploy deno-deploy plan` (preflight, no push) then interactive `up` | preflight runs guard; push deferred pending auth confirmation | + +## Dependencies + +- **#352** (`[Deploy-S1]` config contract) — **MERGED** (`44d7945b`); provides `deployTargetBaseShape` + the member spreads. +- **#357** (#338 Archetype-7 doctrine) — IMPL-EVAL PASS; merged before Implement. +- **#339/#340** (`[Deploy-S3]` bare-metal) — **owns the 3-op→7-op port-expansion commit**; #342 + rebases onto it and consumes the expanded port (does NOT redefine it). +- External: Deno 2.9 toolchain (native `deno deploy`), a Deno Deploy org + token for real push + (manual verification only). + +## Drift Watch + +- The 7-op deploy port shape as landed by #339/#340 (op set, request/result fields) — re-verify at + Implement; reconcile with the owner, never by local port edits. +- `deploy.targets.*` schema shape on `main` (base fields, `z.ZodType` annotation) — already merged + (#352); re-confirm at Implement. +- `deno deploy` CLI flag surface + non-interactive auth mechanism (docs freshness). +- `ARCHETYPE-7-deploy-target-adapter.md` stale slice-number parenthetical (`#340 deno-deploy` / + `#342 docker`) — #338's doc to correct; #342 does not edit it. Logged so PLAN-EVAL is not misled. +- Dispatch lane: Opus 4.8 sub-agents (not WSL Codex) for this epic. diff --git a/.llm/tmp/run/deploy-s6-deno-deploy/research.md b/.llm/tmp/run/deploy-s6-deno-deploy/research.md new file mode 100644 index 000000000..dd4092fe9 --- /dev/null +++ b/.llm/tmp/run/deploy-s6-deno-deploy/research.md @@ -0,0 +1,148 @@ +# Research — deploy-s6-deno-deploy + +Slice **#342 [Deploy-S6]** — "Deno Deploy tier-1 adapter (MARQUEE)". Phase 2 of the deployment +epic **#327**, decision **D2**. **PLAN-ONLY** run: research + plan + draft PR, then a +separate-session PLAN-EVAL. No adapter/CLI/schema code is written here. + +> **Revision 2 (post-FAIL_PLAN).** The first plan was returned `FAIL_PLAN` (blocking finding B1) +> because it misattributed the shipped 3-op deploy port to #338 and demoted the ratified 7-op +> doctrine to "stale corpus." This revision corrects the misattribution, conforms to the **canonical +> 7-op Archetype 7 contract**, re-baselines against **merged** `main`, and records that #342 +> **consumes** (does not own) the port expansion owned by **#339/#340**. Authoritative sources: +> `.claude/worktrees/deploy-s2/.llm/tmp/run/deploy-s2-doctrine/contract-reconciliation.md` and +> `.claude/worktrees/deploy-s3/.llm/tmp/run/deploy-s3-baremetal/port-ownership.md` (epic #327 +> supervisor decisions — internal architecture decisions, not user product decisions). + +## Re-baseline + +- **Worktree base is STALE.** Branch `feat/deploy-s6-deno-deploy` forked from `cf1ac47b`, which + **predates** the deployment merges. Re-derived against **merged `origin/main` @ `37108172`** + (verified 2026-07-03). +- **What is already merged to `main` (verified firsthand):** + - **`#352`** `44d7945b [Deploy-S1] deploy.targets.* config contract (clean break)` — the + `deploy.targets.*` schema + `DeployTargetBaseSchema` now live at + `packages/config/src/domain/schemas/deploy-schema.ts` on `main`. The clean-break merge + **preserved** the spread + `z.ZodType` pattern (`deployTargetBaseShape`, + `WindowsDeployTargetSchema` spreads `...deployTargetBaseShape`, `DeployConfigSchema.targets.windows`). + → **The "#337 not merged" premise from rev 1 is dropped.** + - **`3137e455`** `Slice 2: command registry deploy port E2E green` — landed the **3-op** + `DeployTargetPort` (`build|install|uninstall`, all optional) + + `WindowsServiceDeployTarget` stub + `DeployTargetRegistry` at + `packages/cli/src/kernel/domain/deploy/**`. This is an **unrelated command-registry slice**, NOT + #338, and NOT part of the deployment epic design. → **Rev 1's misattribution corrected.** +- **What is in-flight / inbound (Implement-phase dependencies):** + - **`#357` (#338 impl)** — ratifies **Archetype 7** deploy-target-adapter doctrine (the canonical + **7-op** contract). IMPL-EVAL **PASS** at the merge gate. Doctrine file: + `.llm/harness/archetypes/ARCHETYPE-7-deploy-target-adapter.md`. + - **`#339/#340` (Deploy-S3 bare-metal)** — **OWNS** expanding the seed 3-op `DeployTargetPort` → + the canonical **7-op** `OsServicePort`/cloud-adapter contract, and performs the verb-vocabulary + lock. It front-loads this as an **early independently-mergeable port-contract commit**. +- **Merge order for Implement:** `#357` (doctrine) → `#339/#340` port-expansion commit → **rebase + #342** onto both (plus current `main`, which already carries #352 + `3137e455`). + +## Findings + +| # | Finding (fact the plan depends on) | How to verify | +| -- | ---------------------------------- | ------------- | +| 1 | **Config base is merged.** `origin/main:packages/config/src/domain/schemas/deploy-schema.ts` defines `deployTargetBaseShape` (const), `DeployTargetBaseSchema: z.ZodType`, `WindowsDeployTargetSchema` spreading `...deployTargetBaseShape`, and `DeployConfigSchema.targets.windows`. New members are sibling keys added by spread (R-DEPLOY-4: no per-target base-class hierarchy). | `git show origin/main:packages/config/src/domain/schemas/deploy-schema.ts` | +| 2 | **Config types merged.** `config-section-types.ts` on `main` declares `DeployTargetBase`, `WindowsDeployTarget extends DeployTargetBase`, `DeployConfig.targets`. A `deno-deploy` member needs a `DenoDeployTarget` interface + `DenoDeployTargetSchema: z.ZodType` (explicit types — `isolatedDeclarations` + JSR slow-type). | `git show origin/main:packages/config/src/domain/config-section-types.ts` | +| 3 | **The shipped 3-op port is a SEED STUB from `3137e455`, not #338.** `deploy-target-port.ts` `DeployTargetPort { key; label; operations: ('build'\|'install'\|'uninstall')[]; build?/install?/uninstall? }`; `WindowsServiceDeployTarget` returns canned messages, no real work. It is a placeholder to be **expanded** to 7-op by #339/#340 — not the canonical contract. | `git show origin/main:packages/cli/src/kernel/domain/deploy/deploy-target-port.ts`; `git log --oneline -1 3137e455` | +| 4 | **Canonical contract = Archetype 7's uniform 7-op set** (#357, IMPL-EVAL PASS): `plan`/`emit` · `up` · `down` · `status` · `logs` · `rollback` · `secrets`. Each adapter implements the **subset it supports**; Aspire adapters delegate `up`/`plan` to Aspire; deno-deploy wraps `deno deploy`. | `.llm/harness/archetypes/ARCHETYPE-7-deploy-target-adapter.md` §"Uniform Adapter Contract" | +| 5 | **Archetype 7 is a COMPOSITE.** It composes **Archetype 2** (Integration — package-owned `OsServicePort` + cloud adapters, closed-on-key `deploy-target-registry`) and **Archetype 6** (thin `netscript deploy ` router), folding neither. Router carries **no target-specific business logic** (R-DEPLOY-2); convention-bearing primitives (health, OTEL, **secrets, rollback**) live in the **core**, shared across targets (R-DEPLOY-3). | ARCHETYPE-7 §"Composition", §"Rules" | +| 6 | **Port ownership: #339/#340 owns the 3-op→7-op expansion; #342 CONSUMES.** #342/#343 implement adapters against the expanded 7-op port and **MUST NOT redefine the port or op contract**. Verb-lock resolved: canonical = the 7 doctrine op names; legacy CLI verbs (`build/install/uninstall`) remain user-facing aliases. | `.../deploy-s3-baremetal/port-ownership.md` §Decision 1–4 | +| 7 | **External-CLI shell pattern (unchanged).** `ServyCliAdapter implements WindowsServicePort` shells `servy-cli.exe` via a `ProcessPort`, mapping exit code → result. Mirror this for shelling `deno deploy` (a `ProcessPort`-driven adapter, not ad-hoc `Deno.Command`). NOTE: `main` also has a **real** `WindowsServicePort`+`ServyCliAdapter` seam (start/stop/status bypass the port via `runServy()` today); #339/#340 unifies both seams behind the port. | `packages/cli/src/public/adapters/servy-cli.ts`; port-ownership.md §Facts | +| 8 | **Deno Deploy = NEW platform, source-driven, no Dockerfile.** Target is *Deno Deploy* (`console.deno.com`, `deno deploy` CLI), NOT deprecated Deploy Classic / Subhosting (deployctl, sunset 2026-07-20). Serverless microVM; remote 5-stage build (Queue→Prepare→Install→Build→Deploy) from source; build config from `deno.json`/`deno.jsonc` + dashboard. | https://docs.deno.com/runtime/reference/cli/deploy/ | +| 9 | **Runtime constraint (critical guard).** Deno Deploy runtime is pinned, runs `--allow-all`, and **rejects `--unstable-*` flags**. NetScript scaffolds use `--unstable-kv`. A deployed app importing unstable APIs (Deno KV) fails on-platform → an **unstable-API preflight guard** is required in `plan`/`up`. | https://docs.deno.com/... ; decision-gap-tracker.md "Blockers" | +| 10 | **`deno deploy` CLI surface (live, 2026).** `deno deploy` (deploys cwd; `--prod`); `deno deploy create --org --app --source github\|local --runtime-mode dynamic\|static --entrypoint --framework-preset --install-command --build-command --static-dir`; `deno deploy env add [--secret]` / `env load `; `deno deploy switch --org --app`; `deno deploy logs`; `deno deploy logout`; `deno deploy database`. | https://docs.deno.com/runtime/reference/cli/deploy/ | +| 11 | **Auth model (CI open question).** New `deno deploy` uses OS-keyring token-based auth, auto-prompting interactively; supports user + organization tokens. The docs do **not** confirm a non-interactive env var for the new CLI (classic `deployctl` used `DENO_DEPLOY_TOKEN`). → `NEEDS USER`. | https://docs.deno.com/... ; https://docs.deno.com/deploy/classic/deployctl/ | +| 12 | **Existing debt this advances.** `arch-debt.md` carries the deployment `Archetype-7 core-centralization + F-DEPLOY seed` entry and the `cli-deploy-artifacts-missing` / AP-1 command-registry-deploy-seams entries. This slice advances the cloud-adapter portion; it does not close them. | `.llm/harness/debt/arch-debt.md` | +| 13 | **F-DEPLOY gates are seeded `reviewed`.** `F-DEPLOY-1` (each adapter implements the 7-op contract or a declared subset) and `F-DEPLOY-2` (thin router, conventions in core) are seeded `reviewed` until the deployment packages exist, then promoted `gated`. | ARCHETYPE-7 §"Fitness Gates"; gate matrix | +| 14 | **Dispatch-lane override (this epic).** Implementers = **Opus 4.8 sub-agents only**; evaluators = separate Opus session (or Codex GPT-5.5 when reachable). **WSL Codex is dropped for the deployment epic.** Rev 1's generic "WSL Codex impl / OpenHands PLAN-EVAL" next-step is corrected. | port-ownership.md §"Dispatch-lane correction" | + +> **Slice-number note (drift):** `ARCHETYPE-7-deploy-target-adapter.md` line ~13 still carries an +> earlier parenthetical mapping (`#340 deno-deploy, #342 docker/compose`). The **current +> authoritative** mapping (this task + both rev-2 decision docs) is **#342 = Deno Deploy**. The stale +> parenthetical is #338's doc to correct (its contract-reconciliation already schedules an additive +> follow-up); #342 does not edit it. Logged so PLAN-EVAL is not misled. + +## Deno Deploy deployment model (target being wrapped) + +- **Platform:** the NEW **Deno Deploy** (dashboard `console.deno.com`, built-in `deno deploy` + subcommand). Deploy **Classic** (deployctl / Subhosting v1, sunset 2026-07-20) is a **non-target**. +- **Deployment unit:** an **organization → app** (app = deployable; deployments = its revisions). + `deno deploy switch --org --app` sets default context; otherwise `--org`/`--app` per invocation. +- **Two push modes (product default — see plan open decision):** (1) **CLI push** — `deno deploy` + from project root uploads source + triggers a remote build (deterministic, one-shot, the natural + marquee one-click); (2) **GitHub-push auto-build** — the Deno Deploy GitHub app builds on push + (NetScript would *configure*, not *drive*). +- **Build model:** remote, source-driven, **no Dockerfile**; 5 stages; build settings from + `deno.json`/`deno.jsonc` (app dir, framework preset, install/build/pre-deploy commands, runtime + mode) + dashboard. NetScript's job: ensure valid Deploy build settings + invoke/verify — **wrap, + do not reinvent** the remote builder. +- **Runtime constraints:** pinned Deno, `--allow-all`, **no `--unstable-*`** — the single most + important caveat for NetScript's KV-backed plugins → preflight guard (Finding 9). +- **Auth/token:** OS-keyring token, interactive auto-prompt, user or org tokens; non-interactive CI + path unconfirmed for the new CLI (Finding 11) → `NEEDS USER`. +- **Secrets:** `deno deploy env add [--secret]` / `env load`, per environment — mapped through the + **core secrets convention** (R-DEPLOY-3), not per-target ad-hoc code. + +## 7-op contract → Deno Deploy adapter mapping (the corrected op contract) + +Deno Deploy is a **hosted platform**: it has no OS-host install/uninstall concept, so it implements +the **declared subset** of the canonical 7-op contract it supports (R-DEPLOY-1). **`#342` does not +define these ops — it consumes the 7-op port from #339/#340 and implements this subset.** + +| Canonical op | Deno Deploy mapping | Support | +| -------------- | ------------------- | ------- | +| `plan`/`emit` | Preflight: validate `deno.json` Deploy build settings + run the **unstable-API guard**; compute the deploy plan without mutating the platform. | **Supported** (local, no remote call) | +| `up` | **`deno deploy`** (preview) / **`deno deploy --prod`** (production) — the marquee one-click push. | **Supported** (marquee) | +| `down` | Tear down = delete the Deno Deploy **app/deployment** (platform-native, via API/dashboard; CLI verb TBD). Distinct from an OS-host uninstall. | **Supported** (platform-native; CLI-surface TBD) | +| `status` | Report deployment/app state via the platform API. | **Supported** (platform API) | +| `logs` | **`deno deploy logs`** (platform API). | **Supported** | +| `rollback` | Platform-**native** rollback (repoint to a prior good deployment) — routed through the **core rollback convention**, NOT a silent no-op (R-DEPLOY-3). | **Supported** (platform mechanism) | +| `secrets` | **`deno deploy env add [--secret]` / `env load`**, via the **core secrets convention** (R-DEPLOY-3). | **Supported** | +| (host install/uninstall) | N/A — no OS-service install for a hosted platform. | **Explicitly unsupported** (declared subset) | + +## #352 / #357 (#338) / #339-#340 dependency status + +- **`#352` (Deploy-S1 config lineage) — MERGED to `main`.** `deploy.targets.*` + + `DeployTargetBaseSchema` landed (Findings 1–2). #342's config member **spreads** + `DeployTargetBaseSchema` (composition, not a new base — R-DEPLOY-4). +- **`#357` (#338 doctrine) — IMPL-EVAL PASS, at merge gate.** Ratifies Archetype 7 (7-op canonical). + #342 conforms; cite Archetype 7 by name (S-1). Implement rebases after #357 merges. +- **`#339/#340` (bare-metal) — OWNS the 3-op→7-op port-contract commit (front-loaded).** #342 + **consumes** the expanded 7-op port and rebases onto that commit; #342 does **not** redefine the + port or op vocabulary (Finding 6). +- **Merge order:** `#357` → `#339/#340` port-expansion commit → **rebase #342**. Stated so Implement + does not begin against the 3-op seed or a missing 7-op port. + +## jsr-audit surface scan (package/plugin waves) + +- **Surface scanned (planned):** `packages/config` new public `DenoDeployTarget` type + + `DenoDeployTargetSchema`; `packages/cli` new adapter is **internal** (registered via the registry, + no new public *type* export). +- **Slow-type / surface risks:** `packages/config` under `isolatedDeclarations` — annotate + `DenoDeployTargetSchema` as `z.ZodType` exactly like `WindowsDeployTargetSchema`; + `DenoDeployTarget` carries explicit field types. `packages/config` is **published** → + `deno publish --dry-run` is a required S1 gate. +- **Verdict:** low surface risk if the merged spread + `z.ZodType` pattern is followed; PLAN-EVAL + confirms the annotation before slicing. + +## Non-goals (deferred to `stable` per D-tiers; explicit) + +- HA / multi-region / autoscaling policy (Deno Deploy manages it). +- Managed Postgres/KV wiring (`deno deploy database` → NetScript DB layer) — deferred follow-up. +- GitHub-push auto-build orchestration beyond emitting/validating build settings — CLI-push is the + beta default (pending open decision). +- **Owning or redefining the deploy port / op vocabulary** — owned by #339/#340; #342 consumes. +- **The `OsServicePort` + full multi-target router build** — that is #339/#340 core work; #342 adds + the deno-deploy cloud adapter behind it + the target's registry entry. +- Deploy Classic / deployctl / Subhosting — deprecated, not targeted. + +## Open questions (closed in `plan.md`) + +1. CLI-push vs GitHub-push default for the marquee? → `NEEDS USER` (proceed CLI-push, reversible). +2. Non-interactive CI auth for the new `deno deploy`? → `NEEDS USER` + Implement-time freshness + re-check; design behind a fake `ProcessPort` so unit tests need no real auth. +3. Exact `down`/`rollback`/`status` CLI-vs-API surface (platform-native mechanisms) → confirm at + Implement against the then-current platform API; adapter isolates each op behind the port. diff --git a/.llm/tmp/run/deploy-s6-deno-deploy/worklog.md b/.llm/tmp/run/deploy-s6-deno-deploy/worklog.md new file mode 100644 index 000000000..2c5cca793 --- /dev/null +++ b/.llm/tmp/run/deploy-s6-deno-deploy/worklog.md @@ -0,0 +1,85 @@ +# Worklog — deploy-s6-deno-deploy (Implement) + +Implementer session (Opus 4.8 sub-agent, per plan D10). Separate evaluator (IMPL-EVAL) gates after. +Branch `feat/deploy-s6-deno-deploy` (PR #359, DRAFT). S0 7-op port merge (95576c44) verified present. + +## S1 — Config member (`deploy.targets['deno-deploy']`) + +- Files: `packages/config/src/domain/config-section-types.ts` (+`DenoDeployTarget`, `+'deno-deploy'` + target key), `packages/config/src/domain/schemas/deploy-schema.ts` (`DenoDeployTargetSchema: + z.ZodType` spread on `deployTargetBaseShape`; `'deno-deploy'` in + `DeployConfigSchema.targets`), `packages/config/src/public/mod.ts` (export type + schema), + `packages/config/tests/schema/netscript_config_test.ts` (2 new tests). +- Validation: check 0 errors; `deno test` 8/8 green; wrapper lint 0 findings; wrapper fmt 0 findings; + `deno publish --dry-run` on `@netscript/config` clean (only the pre-existing loader.ts + unanalyzable-dynamic-import warning; no slow-type errors on the new surface). +- Commit: dc115f3a. + +## S2 — Unstable-API preflight guard (pure domain) + +- Files: `packages/cli/src/kernel/domain/deploy/unstable-api-guard.ts` (pure `scanUnstableApis`), + `.../unstable-api-guard_test.ts` (6 tests). +- Design: pure — caller supplies already-read `deno.json` + source contents; FS access is deferred to + an adapter (kept out of domain per A11/F-CLI-16). Detects declared `deno.json#unstable` features and + direct unstable-API tokens (`Deno.openKv`→`--unstable-kv`, `Deno.cron`, `BroadcastChannel`, + `Temporal`). Best-effort (no transitive graph walk) — bound recorded for arch-debt in S4. +- Validation: check 0 errors; `deno test` 6/6 green; direct `deno fmt --check` + `deno lint` + conformant (packages/cli is workspace-excluded from the fmt/lint gates — see drift D-IMPL-2). +- Commit: (S2, below). + +## S3 — Adapter (7-op subset) + process wrapper + registry + +- Files (domain): `packages/cli/src/kernel/domain/deploy/deno-deploy-cli-port.ts` (ports: + `DenoDeployCliPort` deploy/logs/remove/status + `DenoDeployPreflightPort.readGuardInputs`), + `.../deno-deploy-target.ts` (`DenoDeployTarget implements DeployTargetPort`; + key `deno-deploy`, operations `plan/up/down/status/logs`), `.../deno-deploy-target_test.ts` (9 tests). +- Files (kernel/adapters): `packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-cli.ts` + (`DenoDeployCliAdapter implements DenoDeployCliPort`, shells `deno deploy` through injected + `ProcessPort` — F-CLI-16; exported `buildDeployArgs`/`buildLogsArgs`/`buildDeleteArgs`/`buildStatusArgs`), + `.../deno-deploy-preflight.ts` (`DenoDeployPreflightReader`, FS read of `deno.json`+entrypoint feeding + the pure guard), `.../deno-deploy-cli_test.ts` (5 tests, exact argv + exit-code mapping). +- Files (application): `packages/cli/src/kernel/application/registries/deploy-target-registry.ts` + (`DENO_DEPLOY_TARGET` wired with concrete adapters at the composition layer; added to + `DEFAULT_DEPLOY_TARGETS`). Domain target imports neither `Deno.Command` nor FS. +- Behaviour: `plan` runs the guard, never mutates; `up` runs the guard then `deno deploy [--prod]` — + **refuses** production pushes with unstable-API violations (throw), warns-but-proceeds on preview; + `down`/`status`/`logs` delegate to the CLI port; non-zero exit → throw with stderr. rollback/secrets + omitted (D-IMPL-1). +- Validation: scoped `run-deno-check` 0 errors across domain/deploy + adapters/deno-deploy + + application/registries (22 files); `deno test` 14/14 green; direct `deno fmt --check` + `deno lint` + conformant (packages/cli workspace-excluded from the fmt/lint gates — drift D-IMPL-2). +- Commit: 06f595ff; pushed 34013a72..06f595ff. + +## S4 — Config resolver + thin CLI router reach + README/debt + +- Files (domain): `packages/cli/src/kernel/domain/resolved-config.ts` (+`ResolvedDenoDeployConfig`). +- Files (kernel/adapters): `.../adapters/config/deploy-config-resolvers.ts` (+`resolveDenoDeployTarget`: + merge `deploy.targets['deno-deploy']` config with CLI flag overrides, flags win, `prod` default + false), `.../adapters/config/deploy-config-resolvers.test.ts` (4 tests), + `.../adapters/deno-deploy/create-deno-deploy-target.ts` (single composition factory wiring CLI + adapter + preflight reader; registry refactored to use it). +- Files (surface): `.../public/features/deploy/deno-deploy/deno-deploy-command.ts` + (`createDenoDeployCommand` — thin `deno-deploy plan|up|down|status|logs` group mapping + `--org/--app/--prod/--entrypoint/--env-file/--dry-run` onto the resolved config; `up --dry-run` + diverts to `plan`; no target logic in the surface, no `Deno.Command`), wired into + `deploy-group.ts`. +- Docs/debt: `packages/cli/README.md` (Deno Deploy target section + permissions table + `--allow-run/read/net/env` + unstable-API/auth caveats), `.llm/harness/debt/arch-debt.md` + (`cli-deploy-artifacts-missing` advanced, not closed). +- Validation: scoped `run-deno-check` 0 errors across 72 files (adapters/deno-deploy + adapters/config + + domain + public/features/deploy + application/registries); `deno test` 18/18 green (4 resolver + + 5 adapter + 9 target); direct `deno fmt --check` conformant on all 7 S4 TS files. +- Note (lint): the surface's cliffy `Command` generics are the repo-standard, explicitly + accepted "top-level router any" (packages/cli is excluded from the lint gate); `deno lint + --no-config` flags them, but they match the established pattern in every `features/**` command and + are not new type-soundness debt. No `any` in business logic (flag marshalling uses `unknown` + + narrow casts). +- Commit: 2c93ce8d. + +## Blocker / NEEDS-USER (see drift.md) + +- D-IMPL-1: no core `secrets`/`rollback` convention seam on branch → adapter declares the + `plan/up/down/status/logs` subset and omits rollback/secrets (per port docstring + S0 precedent); + do not fork. Surfaced, not worked around. +- D5 (CLI-push default) and non-interactive CI auth for `deno deploy` remain NEEDS-USER; proceeding on + the plan's locked CLI-push default; a real push is manual/deferred. diff --git a/packages/cli/README.md b/packages/cli/README.md index d84a2c712..7421729e1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -71,6 +71,38 @@ The bare-metal targets implement the canonical `plan` / `emit` / `up` / `down` / --- +## ☁️ Deno Deploy target + +`netscript deploy deno-deploy ` deploys a workspace to +[Deno Deploy](https://deno.com/deploy). Supported operations: `plan` (preflight +only), `up` (`deno deploy [--prod]`), `down`, `status`, `logs`. Configure defaults +under `deploy.targets['deno-deploy']` in `netscript.config.ts` +(`org`/`app`/`prod`/`entrypoint`/`envFile`) or pass +`--org`/`--app`/`--prod`/`--entrypoint`/`--env-file`; flags override config. `up +--dry-run` runs `plan` without pushing. + +`plan`/`up` first run an unstable-API guard: `up --prod` **refuses** to push when +the project uses APIs that require `--unstable-*` flags (e.g. `Deno.openKv`, +`Deno.cron`, `BroadcastChannel`, `Temporal`), which Deno Deploy rejects; preview +pushes warn but proceed. + +### Permissions + +The deploy subcommands shell out to the `deno deploy` CLI and read project files, +so a host binary embedding this surface must grant: + +| Permission | Why | +| -------------- | ------------------------------------------------------------- | +| `--allow-run` | Spawn the `deno deploy` CLI (`plan`/`up`/`down`/`status`/`logs`). | +| `--allow-read` | Read `deno.json` + the entrypoint for the unstable-API guard. | +| `--allow-net` | The `deno deploy` CLI uploads to and queries the platform. | +| `--allow-env` | The `deno deploy` CLI reads auth/token environment variables. | + +Authentication is delegated to the `deno deploy` CLI (its token/login flow); this +surface issues no credentials of its own. + +--- + ## 📖 Documentation - **Reference**: [rickylabs.github.io/netscript/reference/cli/](https://rickylabs.github.io/netscript/reference/cli/) diff --git a/packages/cli/src/kernel/adapters/config/deploy-config-resolvers.test.ts b/packages/cli/src/kernel/adapters/config/deploy-config-resolvers.test.ts new file mode 100644 index 000000000..061cee251 --- /dev/null +++ b/packages/cli/src/kernel/adapters/config/deploy-config-resolvers.test.ts @@ -0,0 +1,68 @@ +import { assertEquals } from 'jsr:@std/assert@^1'; +import type { DeployConfig } from '@netscript/config'; + +import { resolveDenoDeployTarget } from './deploy-config-resolvers.ts'; + +Deno.test('resolveDenoDeployTarget: empty config defaults prod to false with no fields', () => { + assertEquals(resolveDenoDeployTarget(undefined), { + org: undefined, + app: undefined, + prod: false, + entrypoint: undefined, + envFile: undefined, + }); +}); + +Deno.test('resolveDenoDeployTarget: reads deploy.targets[deno-deploy] from config', () => { + const deploy = { + targets: { + 'deno-deploy': { + org: 'acme', + app: 'orders', + prod: true, + entrypoint: 'main.ts', + envFile: '.env.production', + }, + }, + } as unknown as DeployConfig; + + assertEquals(resolveDenoDeployTarget(deploy), { + org: 'acme', + app: 'orders', + prod: true, + entrypoint: 'main.ts', + envFile: '.env.production', + }); +}); + +Deno.test('resolveDenoDeployTarget: CLI flag overrides win over config', () => { + const deploy = { + targets: { + 'deno-deploy': { org: 'acme', app: 'orders', prod: false }, + }, + } as unknown as DeployConfig; + + assertEquals( + resolveDenoDeployTarget(deploy, { app: 'orders-staging', prod: true }), + { + org: 'acme', + app: 'orders-staging', + prod: true, + entrypoint: undefined, + envFile: undefined, + }, + ); +}); + +Deno.test('resolveDenoDeployTarget: flags alone work with no config section', () => { + assertEquals( + resolveDenoDeployTarget(undefined, { org: 'acme', app: 'orders' }), + { + org: 'acme', + app: 'orders', + prod: false, + entrypoint: undefined, + envFile: undefined, + }, + ); +}); diff --git a/packages/cli/src/kernel/adapters/config/deploy-config-resolvers.ts b/packages/cli/src/kernel/adapters/config/deploy-config-resolvers.ts index fafb16a05..cd0b306a4 100644 --- a/packages/cli/src/kernel/adapters/config/deploy-config-resolvers.ts +++ b/packages/cli/src/kernel/adapters/config/deploy-config-resolvers.ts @@ -24,6 +24,7 @@ import type { RegisteredPluginConfig, ResolvedAppConfig, ResolvedDefaultsConfig, + ResolvedDenoDeployConfig, ResolvedDeployBaseConfig, ResolvedLinuxDeployConfig, ResolvedPluginConfig, @@ -351,6 +352,25 @@ export function resolveLinuxDeploy(userDeploy?: DeployConfig): ResolvedLinuxDepl }; } +/** + * Resolve the Deno Deploy target config by merging netscript.config.ts + * `deploy.targets['deno-deploy']` with CLI flag overrides. Overrides (flags) + * take precedence over config; `prod` defaults to `false`. + */ +export function resolveDenoDeployTarget( + userDeploy?: DeployConfig, + overrides?: Partial, +): ResolvedDenoDeployConfig { + const target = userDeploy?.targets?.['deno-deploy']; + return { + org: overrides?.org ?? target?.org, + app: overrides?.app ?? target?.app, + prod: overrides?.prod ?? target?.prod ?? false, + entrypoint: overrides?.entrypoint ?? target?.entrypoint, + envFile: overrides?.envFile ?? target?.envFile, + }; +} + // ============================================================================ // PROJECT ROOT FINDER // ============================================================================ diff --git a/packages/cli/src/kernel/adapters/deno-deploy/create-deno-deploy-target.ts b/packages/cli/src/kernel/adapters/deno-deploy/create-deno-deploy-target.ts new file mode 100644 index 000000000..e5ee1df7f --- /dev/null +++ b/packages/cli/src/kernel/adapters/deno-deploy/create-deno-deploy-target.ts @@ -0,0 +1,25 @@ +import { + DenoDeployTarget, + type DenoDeployTargetDefaults, +} from '../../domain/deploy/deno-deploy-target.ts'; +import { DenoDeployCliAdapter } from './deno-deploy-cli.ts'; +import { DenoDeployPreflightReader } from './deno-deploy-preflight.ts'; +import { DenoProcess } from '../runtime/process/deno-process.ts'; + +/** + * Compose a {@link DenoDeployTarget} wired with the concrete `deno deploy` CLI + * adapter (ProcessPort-backed) and the filesystem preflight reader. + * + * This is the single composition point for the Deno Deploy target: the default + * registry uses it with no baked defaults, and the CLI surface uses it with the + * config-resolved defaults (org/app/prod/entrypoint/envFile). Keeping the wiring + * here means neither the registry nor the command surface reaches for + * `Deno.Command` (F-CLI-16); they consume a ready target. + */ +export function createDenoDeployTarget(defaults?: DenoDeployTargetDefaults): DenoDeployTarget { + return new DenoDeployTarget({ + cli: new DenoDeployCliAdapter(new DenoProcess()), + preflight: new DenoDeployPreflightReader(), + defaults, + }); +} diff --git a/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-cli.ts b/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-cli.ts new file mode 100644 index 000000000..916b62ae5 --- /dev/null +++ b/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-cli.ts @@ -0,0 +1,78 @@ +import type { ProcessPort } from '../../ports/process-port.ts'; +import type { + DenoDeployCliPort, + DenoDeployCliResult, + DenoDeployInvocation, +} from '../../domain/deploy/deno-deploy-cli-port.ts'; + +/** + * `deno deploy` CLI process wrapper (Arch-2 named-adapter-behind-port; mirrors + * the Servy CLI adapter). All `Deno.Command` shelling for Deno Deploy is + * confined here via an injected {@link ProcessPort} (F-CLI-16 / A11), so the + * domain target stays pure and the argv is unit-testable with a fake port. + * + * Note: the `delete`/`show` subcommand surface of `deno deploy` is still + * stabilizing; the argv builders below isolate that so a platform change is a + * one-file edit (see run drift). + */ +export class DenoDeployCliAdapter implements DenoDeployCliPort { + readonly #process: ProcessPort; + + constructor(process: ProcessPort) { + this.#process = process; + } + + deploy(invocation: DenoDeployInvocation): Promise { + return this.#run(buildDeployArgs(invocation), invocation.projectRoot); + } + + logs(invocation: DenoDeployInvocation): Promise { + return this.#run(buildLogsArgs(invocation), invocation.projectRoot); + } + + remove(invocation: DenoDeployInvocation): Promise { + return this.#run(buildDeleteArgs(invocation), invocation.projectRoot); + } + + status(invocation: DenoDeployInvocation): Promise { + return this.#run(buildStatusArgs(invocation), invocation.projectRoot); + } + + async #run(args: readonly string[], cwd: string): Promise { + const result = await this.#process.exec('deno', args, { cwd }); + return { code: result.code, stdout: result.stdout, stderr: result.stderr }; + } +} + +/** Shared `--org`/`--app` flag suffix used by every subcommand. */ +function targetFlags(invocation: DenoDeployInvocation): string[] { + const args: string[] = []; + if (invocation.org) args.push('--org', invocation.org); + if (invocation.app) args.push('--app', invocation.app); + return args; +} + +/** Build argv for `deno deploy [--prod] [--org] [--app] [--env-file] [entrypoint]`. */ +export function buildDeployArgs(invocation: DenoDeployInvocation): string[] { + const args = ['deploy']; + if (invocation.prod) args.push('--prod'); + args.push(...targetFlags(invocation)); + if (invocation.envFile) args.push('--env-file', invocation.envFile); + if (invocation.entrypoint) args.push(invocation.entrypoint); + return args; +} + +/** Build argv for `deno deploy logs [--org] [--app]`. */ +export function buildLogsArgs(invocation: DenoDeployInvocation): string[] { + return ['deploy', 'logs', ...targetFlags(invocation)]; +} + +/** Build argv for `deno deploy delete [--org] [--app]`. */ +export function buildDeleteArgs(invocation: DenoDeployInvocation): string[] { + return ['deploy', 'delete', ...targetFlags(invocation)]; +} + +/** Build argv for `deno deploy show [--org] [--app]`. */ +export function buildStatusArgs(invocation: DenoDeployInvocation): string[] { + return ['deploy', 'show', ...targetFlags(invocation)]; +} diff --git a/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-cli_test.ts b/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-cli_test.ts new file mode 100644 index 000000000..6e8bd2b66 --- /dev/null +++ b/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-cli_test.ts @@ -0,0 +1,92 @@ +import { assertEquals } from 'jsr:@std/assert@^1'; + +import type { ProcessPort, ProcessResult } from '../../ports/process-port.ts'; +import { + buildDeleteArgs, + buildDeployArgs, + buildLogsArgs, + buildStatusArgs, + DenoDeployCliAdapter, +} from './deno-deploy-cli.ts'; + +interface RecordedCall { + readonly command: string; + readonly args: readonly string[]; + readonly cwd?: string; +} + +class FakeProcess implements ProcessPort { + readonly calls: RecordedCall[] = []; + constructor(private readonly result: ProcessResult = { code: 0, stdout: '', stderr: '' }) {} + exec( + command: string, + args: readonly string[], + options?: { readonly cwd?: string }, + ): Promise { + this.calls.push({ command, args: [...args], cwd: options?.cwd }); + return Promise.resolve(this.result); + } +} + +Deno.test('buildDeployArgs: preview push maps only provided flags', () => { + assertEquals(buildDeployArgs({ projectRoot: '/p', app: 'orders' }), [ + 'deploy', + '--app', + 'orders', + ]); +}); + +Deno.test('buildDeployArgs: production push maps --prod/--org/--app/--env-file/entrypoint in order', () => { + assertEquals( + buildDeployArgs({ + projectRoot: '/p', + prod: true, + org: 'acme', + app: 'orders', + envFile: '.env.production', + entrypoint: 'main.ts', + }), + [ + 'deploy', + '--prod', + '--org', + 'acme', + '--app', + 'orders', + '--env-file', + '.env.production', + 'main.ts', + ], + ); +}); + +Deno.test('buildLogsArgs/buildDeleteArgs/buildStatusArgs: map subcommand + target flags', () => { + const inv = { projectRoot: '/p', org: 'acme', app: 'orders' }; + assertEquals(buildLogsArgs(inv), ['deploy', 'logs', '--org', 'acme', '--app', 'orders']); + assertEquals(buildDeleteArgs(inv), ['deploy', 'delete', '--org', 'acme', '--app', 'orders']); + assertEquals(buildStatusArgs(inv), ['deploy', 'show', '--org', 'acme', '--app', 'orders']); +}); + +Deno.test('DenoDeployCliAdapter: shells `deno` with deploy argv from the project root', async () => { + const process = new FakeProcess({ code: 0, stdout: 'ok', stderr: '' }); + const adapter = new DenoDeployCliAdapter(process); + + const result = await adapter.deploy({ projectRoot: '/proj', prod: true, app: 'orders' }); + + assertEquals(process.calls.length, 1); + assertEquals(process.calls[0]?.command, 'deno'); + assertEquals(process.calls[0]?.args, ['deploy', '--prod', '--app', 'orders']); + assertEquals(process.calls[0]?.cwd, '/proj'); + assertEquals(result, { code: 0, stdout: 'ok', stderr: '' }); +}); + +Deno.test('DenoDeployCliAdapter: propagates a non-zero exit code', async () => { + const process = new FakeProcess({ code: 1, stdout: '', stderr: 'auth required' }); + const adapter = new DenoDeployCliAdapter(process); + + const result = await adapter.remove({ projectRoot: '/proj', app: 'orders' }); + + assertEquals(process.calls[0]?.args, ['deploy', 'delete', '--app', 'orders']); + assertEquals(result.code, 1); + assertEquals(result.stderr, 'auth required'); +}); diff --git a/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-preflight.ts b/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-preflight.ts new file mode 100644 index 000000000..6e81186e6 --- /dev/null +++ b/packages/cli/src/kernel/adapters/deno-deploy/deno-deploy-preflight.ts @@ -0,0 +1,59 @@ +import { join } from '@std/path'; + +import type { DenoDeployPreflightPort } from '../../domain/deploy/deno-deploy-cli-port.ts'; +import type { + UnstableApiScanInput, + UnstableApiSource, +} from '../../domain/deploy/unstable-api-guard.ts'; + +/** Default entrypoint scanned when a target declares none. */ +const DEFAULT_ENTRYPOINT = 'main.ts'; + +/** + * Filesystem reader that feeds the pure unstable-API guard (F-CLI-16 / A11). + * + * Reads `deno.json` and the deploy entrypoint from the project root, tolerating + * missing files, and hands the parsed contents to `scanUnstableApis`. The scan + * itself stays pure in the domain; this adapter owns the side effects. The read + * is best-effort (entrypoint + config, not the full transitive graph) — the + * bound is recorded in arch-debt. + */ +export class DenoDeployPreflightReader implements DenoDeployPreflightPort { + async readGuardInputs( + projectRoot: string, + entrypoint = DEFAULT_ENTRYPOINT, + ): Promise { + const denoJson = await this.#readJson(join(projectRoot, 'deno.json')) ?? + await this.#readJson(join(projectRoot, 'deno.jsonc')); + const sources = await this.#readSources(projectRoot, entrypoint); + return { denoJson, sources }; + } + + async #readJson(path: string): Promise { + const text = await this.#readText(path); + if (text === undefined) return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } + } + + async #readSources(projectRoot: string, entrypoint: string): Promise { + const sources: UnstableApiSource[] = []; + for (const candidate of [entrypoint, join('src', entrypoint)]) { + const path = join(projectRoot, candidate); + const content = await this.#readText(path); + if (content !== undefined) sources.push({ path: candidate, content }); + } + return sources; + } + + async #readText(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch { + return undefined; + } + } +} diff --git a/packages/cli/src/kernel/application/registries/deploy-target-registry.ts b/packages/cli/src/kernel/application/registries/deploy-target-registry.ts index 3ffc324a4..5999d63a1 100644 --- a/packages/cli/src/kernel/application/registries/deploy-target-registry.ts +++ b/packages/cli/src/kernel/application/registries/deploy-target-registry.ts @@ -3,6 +3,7 @@ import type { DeployTargetPort } from '../../domain/deploy/deploy-target-port.ts import type { DeployTargetRegistryPort } from '../../domain/deploy/deploy-target-registry-port.ts'; import { WindowsServiceDeployTarget } from '../../domain/deploy/windows-service-deploy-target.ts'; import { LinuxServiceDeployTarget } from '../../domain/deploy/linux-service-deploy-target.ts'; +import { createDenoDeployTarget } from '../../adapters/deno-deploy/create-deno-deploy-target.ts'; /** Metadata and operations for a deploy target exposed by the CLI. */ export type DeployTarget = DeployTargetPort; @@ -13,11 +14,20 @@ export const WINDOWS_SERVICE_DEPLOY_TARGET: DeployTarget = new WindowsServiceDep /** Linux (systemd) service deploy target descriptor. */ export const LINUX_SERVICE_DEPLOY_TARGET: DeployTarget = new LinuxServiceDeployTarget(); +/** + * Deno Deploy cloud target descriptor. Composed with the concrete `deno deploy` + * CLI adapter (ProcessPort-backed) and the FS preflight reader; the registry + * instance carries no baked defaults (the CLI surface builds a config-resolved + * instance per invocation). + */ +export const DENO_DEPLOY_TARGET: DeployTarget = createDenoDeployTarget(); + /** Ordered default deployment targets. */ export const DEFAULT_DEPLOY_TARGETS: readonly (readonly [string, DeployTarget])[] = Object .freeze([ ['windows-service', WINDOWS_SERVICE_DEPLOY_TARGET], ['linux-service', LINUX_SERVICE_DEPLOY_TARGET], + ['deno-deploy', DENO_DEPLOY_TARGET], ]); /** Registry for supported deployment targets. */ diff --git a/packages/cli/src/kernel/domain/deploy/deno-deploy-cli-port.ts b/packages/cli/src/kernel/domain/deploy/deno-deploy-cli-port.ts new file mode 100644 index 000000000..83a98c03f --- /dev/null +++ b/packages/cli/src/kernel/domain/deploy/deno-deploy-cli-port.ts @@ -0,0 +1,62 @@ +import type { UnstableApiScanInput } from './unstable-api-guard.ts'; + +/** + * Domain-facing ports for the Deno Deploy adapter (Archetype 7 / Arch-2). + * + * The `DenoDeployTarget` domain adapter depends only on these interfaces; the + * concrete implementations shell the native `deno deploy` CLI through a + * `ProcessPort` and read the filesystem — both of which live in + * `kernel/adapters/deno-deploy/**` so side effects stay at the edge + * (A11 / F-CLI-16). This mirrors how the Windows target keeps Servy shelling in + * an adapter. + */ + +/** Resolved Deno-Deploy invocation parameters mapped onto `deno deploy` flags. */ +export interface DenoDeployInvocation { + /** Project root the deploy runs from (`cwd`). */ + readonly projectRoot: string; + /** Deno Deploy organization slug (`--org`). */ + readonly org?: string; + /** Deno Deploy application/project name (`--app`). */ + readonly app?: string; + /** Whether the push targets production (`--prod`). */ + readonly prod?: boolean; + /** Entrypoint module argument passed to `deno deploy`. */ + readonly entrypoint?: string; + /** Path to an env file loaded via `deno deploy env load`. */ + readonly envFile?: string; +} + +/** Normalized result of a `deno deploy` invocation. */ +export interface DenoDeployCliResult { + /** Process exit code (0 = success). */ + readonly code: number; + /** Captured standard output. */ + readonly stdout: string; + /** Captured standard error. */ + readonly stderr: string; +} + +/** + * Port that shells the native `deno deploy` CLI. One method per deploy op the + * adapter supports; argv construction and exit-code capture live in the adapter. + */ +export interface DenoDeployCliPort { + /** `deno deploy [--prod] [--org] [--app] [--env-file] [entrypoint]`. */ + deploy(invocation: DenoDeployInvocation): Promise; + /** `deno deploy logs [--org] [--app]`. */ + logs(invocation: DenoDeployInvocation): Promise; + /** `deno deploy delete [--org] [--app]` (bring the deployment down). */ + remove(invocation: DenoDeployInvocation): Promise; + /** `deno deploy show [--org] [--app]` (report deployment status). */ + status(invocation: DenoDeployInvocation): Promise; +} + +/** + * Port that reads already-parsed project sources for the unstable-API guard, + * keeping filesystem access out of the domain. + */ +export interface DenoDeployPreflightPort { + /** Read `deno.json` + entrypoint sources for {@link UnstableApiScanInput}. */ + readGuardInputs(projectRoot: string, entrypoint?: string): Promise; +} diff --git a/packages/cli/src/kernel/domain/deploy/deno-deploy-target.ts b/packages/cli/src/kernel/domain/deploy/deno-deploy-target.ts new file mode 100644 index 000000000..1dbe015c5 --- /dev/null +++ b/packages/cli/src/kernel/domain/deploy/deno-deploy-target.ts @@ -0,0 +1,174 @@ +import type { + DeployTargetOperation, + DeployTargetPort, + DeployTargetRequest, + DeployTargetResult, +} from './deploy-target-port.ts'; +import type { + DenoDeployCliPort, + DenoDeployInvocation, + DenoDeployPreflightPort, +} from './deno-deploy-cli-port.ts'; +import { scanUnstableApis, type UnstableApiViolation } from './unstable-api-guard.ts'; + +/** Default deploy options baked into a {@link DenoDeployTarget} instance. */ +export interface DenoDeployTargetDefaults { + /** Deno Deploy organization slug. */ + readonly org?: string; + /** Deno Deploy application/project name. */ + readonly app?: string; + /** Whether pushes target production by default. */ + readonly prod?: boolean; + /** Entrypoint module passed to `deno deploy`. */ + readonly entrypoint?: string; + /** Path to an env file loaded via `deno deploy env load`. */ + readonly envFile?: string; +} + +/** Dependencies injected into a {@link DenoDeployTarget}. */ +export interface DenoDeployTargetDeps { + /** CLI port that shells `deno deploy`. */ + readonly cli: DenoDeployCliPort; + /** Preflight port that reads project sources for the unstable-API guard. */ + readonly preflight: DenoDeployPreflightPort; + /** Optional resolved defaults (org/app/prod/entrypoint/envFile). */ + readonly defaults?: DenoDeployTargetDefaults; +} + +/** + * Deno Deploy cloud deploy target (Archetype 7 — the tier-1 MARQUEE adapter). + * + * Implements the subset of the canonical 7-op {@link DeployTargetPort} that the + * hosted platform supports today: `plan` (preflight + unstable-API guard, no + * platform mutation), `up` (`deno deploy [--prod]` — the one-click push), + * `down` (delete the deployment), `status`, and `logs`. Host `install`/ + * `uninstall` are N/A for a hosted platform. + * + * `rollback` and `secrets` are intentionally omitted: per R-DEPLOY-3 they route + * through the deploy **core** conventions, which are not yet on `main` (owned by + * the #341/#364 hardening slice). Per the port docstring, an adapter declares + * such ops unsupported by omission rather than shipping a silent no-op or a + * forked per-target implementation — they slot in by delegation when the core + * seam lands. See run drift D-IMPL-1. + */ +export class DenoDeployTarget implements DeployTargetPort { + readonly key = 'deno-deploy'; + readonly label = 'Deno Deploy'; + readonly operations: readonly DeployTargetOperation[] = [ + 'plan', + 'up', + 'down', + 'status', + 'logs', + ]; + + readonly #cli: DenoDeployCliPort; + readonly #preflight: DenoDeployPreflightPort; + readonly #defaults: DenoDeployTargetDefaults; + + constructor(deps: DenoDeployTargetDeps) { + this.#cli = deps.cli; + this.#preflight = deps.preflight; + this.#defaults = deps.defaults ?? {}; + } + + /** + * Preflight: run the unstable-API guard against the project. Never mutates the + * platform. Reports whether the project is Deploy-safe. + */ + plan = async (request: DeployTargetRequest): Promise => { + const violations = await this.#scan(request.projectRoot); + const message = violations.length === 0 + ? `${this.label} plan: no unstable-API violations; project is Deploy-ready.` + : `${this.label} plan: ${violations.length} unstable-API violation(s) — ${ + this.#describe(violations) + }. Deno Deploy rejects these flags; resolve before \`up --prod\`.`; + return this.#result('plan', message); + }; + + /** + * The marquee one-click push: `deno deploy` (preview) / `deno deploy --prod`. + * Runs the unstable-API guard first: refuses on production pushes with + * violations, warns (but proceeds) on preview pushes. + */ + up = async (request: DeployTargetRequest): Promise => { + const invocation = this.#invocation(request); + const violations = await this.#scan(request.projectRoot); + + if (violations.length > 0 && invocation.prod) { + throw new Error( + `${this.label} up refused: production push blocked by ${violations.length} unstable-API ` + + `violation(s) — ${this.#describe(violations)}. Deno Deploy rejects --unstable-* flags.`, + ); + } + + const warning = violations.length > 0 + ? ` (warning: ${violations.length} unstable-API violation(s) — ${this.#describe(violations)})` + : ''; + const result = await this.#cli.deploy(invocation); + this.#assertOk('up', result.code, result.stderr); + return this.#result( + 'up', + `${this.label} up: ${invocation.prod ? 'production' : 'preview'} ` + + `deployment pushed for ${request.projectRoot}${warning}.`, + ); + }; + + /** Bring the deployment down (platform-native delete). */ + down = async (request: DeployTargetRequest): Promise => { + const result = await this.#cli.remove(this.#invocation(request)); + this.#assertOk('down', result.code, result.stderr); + return this.#result( + 'down', + `${this.label} down: deployment deleted for ${request.projectRoot}.`, + ); + }; + + /** Report deployment status. */ + status = async (request: DeployTargetRequest): Promise => { + const result = await this.#cli.status(this.#invocation(request)); + this.#assertOk('status', result.code, result.stderr); + return this.#result('status', result.stdout.trim() || `${this.label} status: no deployments.`); + }; + + /** Tail deployment logs. */ + logs = async (request: DeployTargetRequest): Promise => { + const result = await this.#cli.logs(this.#invocation(request)); + this.#assertOk('logs', result.code, result.stderr); + return this.#result('logs', result.stdout.trim() || `${this.label} logs: no output.`); + }; + + #invocation(request: DeployTargetRequest): DenoDeployInvocation { + return { + projectRoot: request.projectRoot, + org: this.#defaults.org, + app: this.#defaults.app, + prod: this.#defaults.prod, + entrypoint: this.#defaults.entrypoint, + envFile: this.#defaults.envFile, + }; + } + + async #scan(projectRoot: string): Promise { + const input = await this.#preflight.readGuardInputs(projectRoot, this.#defaults.entrypoint); + return scanUnstableApis(input).violations; + } + + #describe(violations: readonly UnstableApiViolation[]): string { + return violations.map((v) => `${v.api} (${v.requiresFlag})`).join(', '); + } + + #assertOk(operation: DeployTargetOperation, code: number, stderr: string): void { + if (code !== 0) { + throw new Error( + `${this.label} ${operation} failed (deno deploy exit ${code})${ + stderr.trim() ? `: ${stderr.trim()}` : '' + }`, + ); + } + } + + #result(operation: DeployTargetOperation, message: string): DeployTargetResult { + return { target: this.key, operation, message }; + } +} diff --git a/packages/cli/src/kernel/domain/deploy/deno-deploy-target_test.ts b/packages/cli/src/kernel/domain/deploy/deno-deploy-target_test.ts new file mode 100644 index 000000000..1e2a4e4b5 --- /dev/null +++ b/packages/cli/src/kernel/domain/deploy/deno-deploy-target_test.ts @@ -0,0 +1,138 @@ +import { assertEquals, assertRejects } from 'jsr:@std/assert@^1'; + +import { DenoDeployTarget, type DenoDeployTargetDefaults } from './deno-deploy-target.ts'; +import type { + DenoDeployCliPort, + DenoDeployCliResult, + DenoDeployInvocation, + DenoDeployPreflightPort, +} from './deno-deploy-cli-port.ts'; +import type { UnstableApiScanInput } from './unstable-api-guard.ts'; +import { DEFAULT_DEPLOY_TARGETS } from '../../application/registries/deploy-target-registry.ts'; + +class FakeCli implements DenoDeployCliPort { + readonly calls: { op: string; invocation: DenoDeployInvocation }[] = []; + constructor(private readonly result: DenoDeployCliResult = { code: 0, stdout: '', stderr: '' }) {} + #record(op: string, invocation: DenoDeployInvocation): Promise { + this.calls.push({ op, invocation }); + return Promise.resolve(this.result); + } + deploy(i: DenoDeployInvocation) { + return this.#record('deploy', i); + } + logs(i: DenoDeployInvocation) { + return this.#record('logs', i); + } + remove(i: DenoDeployInvocation) { + return this.#record('remove', i); + } + status(i: DenoDeployInvocation) { + return this.#record('status', i); + } +} + +class FakePreflight implements DenoDeployPreflightPort { + constructor(private readonly input: UnstableApiScanInput) {} + readGuardInputs(): Promise { + return Promise.resolve(this.input); + } +} + +function makeTarget( + input: UnstableApiScanInput, + defaults?: DenoDeployTargetDefaults, + result?: DenoDeployCliResult, +): { target: DenoDeployTarget; cli: FakeCli } { + const cli = new FakeCli(result); + const target = new DenoDeployTarget({ + cli, + preflight: new FakePreflight(input), + defaults, + }); + return { target, cli }; +} + +const CLEAN: UnstableApiScanInput = { denoJson: {}, sources: [] }; +const KV: UnstableApiScanInput = { + denoJson: {}, + sources: [{ path: 'main.ts', content: 'await Deno.openKv();' }], +}; + +Deno.test('DenoDeployTarget: declares the supported canonical op subset (no rollback/secrets)', () => { + const { target } = makeTarget(CLEAN); + assertEquals(target.key, 'deno-deploy'); + assertEquals(target.operations, ['plan', 'up', 'down', 'status', 'logs']); + assertEquals((target as { rollback?: unknown }).rollback, undefined); + assertEquals((target as { secrets?: unknown }).secrets, undefined); +}); + +Deno.test('DenoDeployTarget: plan reports guard violations without shelling', async () => { + const { target, cli } = makeTarget(KV); + const result = await target.plan({ projectRoot: '/p' }); + assertEquals(result.operation, 'plan'); + assertEquals(result.message.includes('--unstable-kv'), true); + assertEquals(cli.calls.length, 0); +}); + +Deno.test('DenoDeployTarget: plan on a clean project reports Deploy-ready', async () => { + const { target } = makeTarget(CLEAN); + const result = await target.plan({ projectRoot: '/p' }); + assertEquals(result.message.includes('Deploy-ready'), true); +}); + +Deno.test('DenoDeployTarget: up refuses a production push with unstable-API violations', async () => { + const { target, cli } = makeTarget(KV, { prod: true, app: 'orders' }); + await assertRejects(() => target.up({ projectRoot: '/p' }), Error, 'production push blocked'); + assertEquals(cli.calls.length, 0); +}); + +Deno.test('DenoDeployTarget: up proceeds on a preview push despite violations (warns)', async () => { + const { target, cli } = makeTarget(KV, { app: 'orders' }); + const result = await target.up({ projectRoot: '/p' }); + assertEquals(cli.calls[0]?.op, 'deploy'); + assertEquals(cli.calls[0]?.invocation.app, 'orders'); + assertEquals(result.message.includes('warning'), true); +}); + +Deno.test('DenoDeployTarget: up pushes cleanly and forwards resolved defaults', async () => { + const { target, cli } = makeTarget(CLEAN, { prod: true, org: 'acme', app: 'orders' }); + const result = await target.up({ projectRoot: '/p' }); + assertEquals(cli.calls[0]?.invocation, { + projectRoot: '/p', + org: 'acme', + app: 'orders', + prod: true, + entrypoint: undefined, + envFile: undefined, + }); + assertEquals(result.message.includes('production'), true); +}); + +Deno.test('DenoDeployTarget: down/status/logs delegate to the CLI port', async () => { + const { target, cli } = makeTarget(CLEAN, undefined, { + code: 0, + stdout: 'deployment-x', + stderr: '', + }); + await target.down({ projectRoot: '/p' }); + await target.status({ projectRoot: '/p' }); + await target.logs({ projectRoot: '/p' }); + assertEquals(cli.calls.map((c) => c.op), ['remove', 'status', 'logs']); +}); + +Deno.test('DenoDeployTarget: a non-zero CLI exit code throws', async () => { + const { target } = makeTarget(CLEAN, { app: 'orders' }, { + code: 1, + stdout: '', + stderr: 'boom', + }); + await assertRejects(() => target.up({ projectRoot: '/p' }), Error, 'exit 1'); +}); + +Deno.test('DEFAULT_DEPLOY_TARGETS: registers the deno-deploy target', () => { + const keys = DEFAULT_DEPLOY_TARGETS.map(([key]) => key); + assertEquals(keys.includes('deno-deploy'), true); + const entry = DEFAULT_DEPLOY_TARGETS.find(([key]) => key === 'deno-deploy'); + assertEquals(entry?.[1].key, 'deno-deploy'); + assertEquals(entry?.[1].operations, ['plan', 'up', 'down', 'status', 'logs']); +}); diff --git a/packages/cli/src/kernel/domain/deploy/deploy-target-port_test.ts b/packages/cli/src/kernel/domain/deploy/deploy-target-port_test.ts index d3fb9575a..6c7937905 100644 --- a/packages/cli/src/kernel/domain/deploy/deploy-target-port_test.ts +++ b/packages/cli/src/kernel/domain/deploy/deploy-target-port_test.ts @@ -96,10 +96,16 @@ Deno.test('a bare-metal service operation resolves a target-scoped descriptor re assertEquals(result.message.includes('/srv/app'), true); }); -Deno.test('the default deploy target registry seeds both bare-metal OS targets', () => { +Deno.test('the default deploy target registry seeds the bare-metal OS targets plus deno-deploy', () => { const registry = new DeployTargetRegistry(); - assertEquals(registry.entries().map(([key]) => key), ['linux-service', 'windows-service']); + // entries() sorts keys with localeCompare for deterministic ordering. + assertEquals(registry.entries().map(([key]) => key), [ + 'deno-deploy', + 'linux-service', + 'windows-service', + ]); assertEquals(registry.get('linux-service')?.label, 'Linux service'); assertEquals(registry.get('windows-service')?.label, 'Windows service'); + assertEquals(registry.get('deno-deploy')?.label, 'Deno Deploy'); }); diff --git a/packages/cli/src/kernel/domain/deploy/unstable-api-guard.ts b/packages/cli/src/kernel/domain/deploy/unstable-api-guard.ts new file mode 100644 index 000000000..410e621b7 --- /dev/null +++ b/packages/cli/src/kernel/domain/deploy/unstable-api-guard.ts @@ -0,0 +1,110 @@ +/** + * Unstable-API preflight guard (Archetype 7 deploy, D9 / research Finding 9). + * + * Deno Deploy rejects `--unstable-*` runtime flags, but NetScript projects that + * use KV (and other unstable APIs) require them locally. Before a `deno deploy` + * push, this guard scans the project entrypoint sources plus the `deno.json` + * `unstable` list for APIs that only work behind an `--unstable-*` flag, so the + * marquee one-click `up` can refuse (on `--prod`) or warn (on preview) rather + * than silently ship an app that dies on-platform. + * + * Pure by design: callers supply already-read file contents; filesystem access + * lives in an adapter (`kernel/adapters/deno-deploy/**`). The scan is + * best-effort — it inspects the declared `unstable` list and direct entrypoint + * source, not the full transitive module graph (see arch-debt). + */ + +/** A single detected unstable-API usage that Deno Deploy would reject. */ +export interface UnstableApiViolation { + /** Human-readable API or feature name (e.g. `Deno.openKv`). */ + readonly api: string; + /** The `--unstable-*` flag the API would require locally. */ + readonly requiresFlag: string; + /** Where the usage was found (`deno.json#unstable` or a source path). */ + readonly source: string; +} + +/** Input to {@link scanUnstableApis}: already-read project sources. */ +export interface UnstableApiScanInput { + /** Parsed `deno.json` (unknown shape; only the `unstable` array is read). */ + readonly denoJson?: unknown; + /** Entrypoint (and reachable) source files to scan. */ + readonly sources?: readonly UnstableApiSource[]; +} + +/** A single source file provided to the guard. */ +export interface UnstableApiSource { + /** Path used for reporting (project-relative is fine). */ + readonly path: string; + /** Full text content of the file. */ + readonly content: string; +} + +/** Result of an unstable-API scan. */ +export interface UnstableApiScanResult { + /** `true` when no unstable-API usage was detected. */ + readonly ok: boolean; + /** All detected violations, deduped by `api` + `source`. */ + readonly violations: readonly UnstableApiViolation[]; +} + +/** Source-token signatures for unstable APIs Deno Deploy rejects. */ +const UNSTABLE_API_SIGNATURES: readonly { + readonly pattern: RegExp; + readonly api: string; + readonly flag: string; +}[] = [ + { pattern: /\bDeno\.openKv\b/, api: 'Deno.openKv', flag: '--unstable-kv' }, + { pattern: /\bDeno\.cron\b/, api: 'Deno.cron', flag: '--unstable-cron' }, + { + pattern: /\bnew\s+BroadcastChannel\b/, + api: 'BroadcastChannel', + flag: '--unstable-broadcast-channel', + }, + { pattern: /\bTemporal\./, api: 'Temporal', flag: '--unstable-temporal' }, +]; + +function readUnstableList(denoJson: unknown): readonly string[] { + if (denoJson === null || typeof denoJson !== 'object') return []; + const value = (denoJson as { readonly unstable?: unknown }).unstable; + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +/** + * Scan already-read project sources for unstable APIs that Deno Deploy rejects. + * + * Detects (a) every feature declared in `deno.json`'s `unstable` array and + * (b) direct usage of known unstable-API tokens in the supplied source files. + * Returns a deduped, ordered violation report; the caller decides whether a + * violation is fatal (`refuse` on `--prod`) or a warning (preview push). + */ +export function scanUnstableApis(input: UnstableApiScanInput): UnstableApiScanResult { + const violations: UnstableApiViolation[] = []; + const seen = new Set(); + + const push = (violation: UnstableApiViolation): void => { + const dedupeKey = `${violation.api} ${violation.source}`; + if (seen.has(dedupeKey)) return; + seen.add(dedupeKey); + violations.push(violation); + }; + + for (const feature of readUnstableList(input.denoJson)) { + push({ + api: feature, + requiresFlag: `--unstable-${feature}`, + source: 'deno.json#unstable', + }); + } + + for (const file of input.sources ?? []) { + for (const signature of UNSTABLE_API_SIGNATURES) { + if (signature.pattern.test(file.content)) { + push({ api: signature.api, requiresFlag: signature.flag, source: file.path }); + } + } + } + + return { ok: violations.length === 0, violations }; +} diff --git a/packages/cli/src/kernel/domain/deploy/unstable-api-guard_test.ts b/packages/cli/src/kernel/domain/deploy/unstable-api-guard_test.ts new file mode 100644 index 000000000..8e003aebb --- /dev/null +++ b/packages/cli/src/kernel/domain/deploy/unstable-api-guard_test.ts @@ -0,0 +1,77 @@ +import { assertEquals } from 'jsr:@std/assert@^1'; + +import { scanUnstableApis } from './unstable-api-guard.ts'; + +Deno.test('scanUnstableApis: clean project reports ok with no violations', () => { + const result = scanUnstableApis({ + denoJson: { tasks: {}, imports: {} }, + sources: [{ path: 'main.ts', content: 'Deno.serve(() => new Response("ok"));' }], + }); + + assertEquals(result.ok, true); + assertEquals(result.violations, []); +}); + +Deno.test('scanUnstableApis: flags Deno.openKv usage in an entrypoint', () => { + const result = scanUnstableApis({ + denoJson: {}, + sources: [{ path: 'src/main.ts', content: 'const kv = await Deno.openKv();' }], + }); + + assertEquals(result.ok, false); + assertEquals(result.violations.length, 1); + assertEquals(result.violations[0]?.api, 'Deno.openKv'); + assertEquals(result.violations[0]?.requiresFlag, '--unstable-kv'); + assertEquals(result.violations[0]?.source, 'src/main.ts'); +}); + +Deno.test('scanUnstableApis: flags features declared in deno.json unstable list', () => { + const result = scanUnstableApis({ + denoJson: { unstable: ['kv', 'cron'] }, + sources: [], + }); + + assertEquals(result.ok, false); + assertEquals(result.violations.map((v) => v.requiresFlag), [ + '--unstable-kv', + '--unstable-cron', + ]); + assertEquals(result.violations.every((v) => v.source === 'deno.json#unstable'), true); +}); + +Deno.test('scanUnstableApis: dedupes the same API across the declared list and sources', () => { + const result = scanUnstableApis({ + denoJson: { unstable: ['kv'] }, + sources: [ + { path: 'a.ts', content: 'await Deno.openKv();' }, + { path: 'a.ts', content: 'await Deno.openKv();' }, + ], + }); + + // deno.json 'kv' + one src violation for a.ts (the duplicate a.ts entry dedupes). + assertEquals(result.violations.length, 2); + assertEquals(result.ok, false); +}); + +Deno.test('scanUnstableApis: detects Temporal, cron, and BroadcastChannel tokens', () => { + const result = scanUnstableApis({ + sources: [ + { path: 'cron.ts', content: 'Deno.cron("nightly", "0 0 * * *", () => {});' }, + { path: 'time.ts', content: 'const now = Temporal.Now.instant();' }, + { path: 'bus.ts', content: 'const ch = new BroadcastChannel("events");' }, + ], + }); + + assertEquals(result.ok, false); + assertEquals(result.violations.map((v) => v.api).sort(), [ + 'BroadcastChannel', + 'Deno.cron', + 'Temporal', + ]); +}); + +Deno.test('scanUnstableApis: tolerates missing/invalid deno.json and sources', () => { + assertEquals(scanUnstableApis({}).ok, true); + assertEquals(scanUnstableApis({ denoJson: null }).ok, true); + assertEquals(scanUnstableApis({ denoJson: { unstable: 'kv' } }).ok, true); +}); diff --git a/packages/cli/src/kernel/domain/resolved-config.ts b/packages/cli/src/kernel/domain/resolved-config.ts index efc8657a0..a2e7bd5a5 100644 --- a/packages/cli/src/kernel/domain/resolved-config.ts +++ b/packages/cli/src/kernel/domain/resolved-config.ts @@ -260,6 +260,25 @@ export interface ResolvedLinuxDeployConfig extends ResolvedDeployBaseConfig { runtimeDir: string; } +/** + * Resolved Deno Deploy target configuration. + * Merges `deploy.targets['deno-deploy']` from netscript.config.ts with CLI flag + * overrides (flags win). Consumed by the composition layer to construct a + * defaults-baked `DenoDeployTarget`. + */ +export interface ResolvedDenoDeployConfig { + /** Deno Deploy organization slug (`--org`). */ + org?: string; + /** Deno Deploy application/project name (`--app`). */ + app?: string; + /** Whether pushes target production by default (`--prod`). */ + prod: boolean; + /** Entrypoint module passed to `deno deploy`. */ + entrypoint?: string; + /** Path to an env file loaded via `--env-file`. */ + envFile?: string; +} + /** * Fully-resolved deployment configuration — the single source of truth * passed to all adapters, generators, and command handlers. 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 new file mode 100644 index 000000000..18fdd225b --- /dev/null +++ b/packages/cli/src/public/features/deploy/deno-deploy/deno-deploy-command.ts @@ -0,0 +1,148 @@ +/** + * @module commands/deploy/deno-deploy + * + * `netscript deploy deno-deploy ` — dispatch the Deno Deploy cloud target's + * operations (`plan`/`up`/`down`/`status`/`logs`). + * + * This surface is deliberately thin (R-DEPLOY-2): it maps CLI flags + * (`--org`/`--app`/`--prod`/`--entrypoint`/`--env-file`/`--dry-run`) onto the + * resolved target config and delegates to the domain target. All target logic — + * the unstable-API guard, `deno deploy` argv, production-push refusal — lives in + * the domain/adapter layers. No `Deno.Command` is issued here (F-CLI-16); the + * target is composed via `createDenoDeployTarget`. + */ + +import { Command } from '@cliffy/command'; +import { red } from '@std/fmt/colors'; + +import { failDeployCommand } from '../../../../kernel/adapters/deploy/deploy-exit.ts'; +import { outputError, outputText } from '../../../../kernel/presentation/output/default-output.ts'; +import { createDenoDeployTarget } from '../../../../kernel/adapters/deno-deploy/create-deno-deploy-target.ts'; +import { resolveDenoDeployTarget } from '../../../../kernel/adapters/config/deploy-config-resolvers.ts'; +import type { + DeployTargetOperation, + DeployTargetPort, +} from '../../../../kernel/domain/deploy/deploy-target-port.ts'; +import type { PublicCommandDependencies } from '../../root/public-command-dependencies.ts'; + +/** Dependencies the Deno Deploy command surface needs. */ +type DenoDeployCommandDependencies = Pick< + PublicCommandDependencies, + 'loadConfig' | 'resolveProjectRoot' +>; + +/** Flag overrides shared by every Deno Deploy operation. */ +interface DenoDeployFlags { + readonly org?: string; + readonly app?: string; + readonly prod?: boolean; + readonly entrypoint?: string; + readonly envFile?: string; + readonly projectRoot?: string; +} + +/** Resolve the project root, best-effort config, and dispatch a single op. */ +async function dispatch( + operation: DeployTargetOperation, + dependencies: DenoDeployCommandDependencies, + flags: DenoDeployFlags, +): Promise { + const projectRoot = (await dependencies.resolveProjectRoot(flags.projectRoot)) ?? Deno.cwd(); + + // Merge netscript.config.ts `deploy.targets['deno-deploy']` with CLI flags + // (flags win). Config is optional — flags alone are sufficient. + let deploySection; + try { + const config = await dependencies.loadConfig({ cwd: projectRoot }); + deploySection = config.deploy; + } catch { + deploySection = undefined; + } + + const resolved = resolveDenoDeployTarget(deploySection, { + org: flags.org, + app: flags.app, + prod: flags.prod, + entrypoint: flags.entrypoint, + envFile: flags.envFile, + }); + + const target: DeployTargetPort = createDenoDeployTarget(resolved); + const handler = target[operation]; + if (!handler) { + outputError(red(`✗ Deno Deploy does not support \`${operation}\`.`)); + failDeployCommand('Deploy command failed.'); + } + + try { + const result = await handler({ projectRoot }); + outputText(result.message); + } catch (error: unknown) { + outputError(red(`✗ ${error instanceof Error ? error.message : String(error)}`)); + failDeployCommand('Deploy command failed.'); + } +} + +/** Attach the flags shared by every Deno Deploy subcommand. */ +function withDenoDeployFlags( + command: Command, +): Command { + return command + .option('--org ', 'Deno Deploy organization slug') + .option('--app ', 'Deno Deploy application/project name') + .option('--entrypoint ', 'Entrypoint module passed to `deno deploy`') + .option('--env-file ', 'Env file loaded via --env-file') + .option('--project-root ', 'Project root (defaults to discovered root or cwd)'); +} + +/** Create the `netscript deploy deno-deploy` command group. */ +export function createDenoDeployCommand( + dependencies: DenoDeployCommandDependencies, +): Command { + const toFlags = (options: Record): DenoDeployFlags => ({ + org: options.org as string | undefined, + app: options.app as string | undefined, + prod: options.prod as boolean | undefined, + entrypoint: options.entrypoint as string | undefined, + envFile: options.envFile as string | undefined, + projectRoot: options.projectRoot as string | undefined, + }); + + const plan = withDenoDeployFlags( + new Command() + .name('plan') + .description('Preflight the project for Deno Deploy (unstable-API guard; no push)'), + ).action((options) => dispatch('plan', dependencies, toFlags(options))); + + const up = withDenoDeployFlags( + new Command() + .name('up') + .description('Push a deployment (`deno deploy [--prod]`)') + .option('--prod', 'Push to production') + .option('--dry-run', 'Preflight only (equivalent to `plan`); do not push'), + ).action((options) => dispatch(options.dryRun ? 'plan' : 'up', dependencies, toFlags(options))); + + const down = withDenoDeployFlags( + new Command().name('down').description('Delete the deployment'), + ).action((options) => dispatch('down', dependencies, toFlags(options))); + + const status = withDenoDeployFlags( + new Command().name('status').description('Show deployment status'), + ).action((options) => dispatch('status', dependencies, toFlags(options))); + + const logs = withDenoDeployFlags( + new Command().name('logs').description('Show deployment logs'), + ).action((options) => dispatch('logs', dependencies, toFlags(options))); + + return new Command() + .name('deno-deploy') + .description('Deploy to Deno Deploy (cloud target)') + .action(function () { + this.showHelp(); + }) + .command('plan', plan) + .command('up', up) + .command('down', down) + .command('status', status) + .command('logs', logs); +} diff --git a/packages/cli/src/public/features/deploy/deploy-group.ts b/packages/cli/src/public/features/deploy/deploy-group.ts index 96f68c9f9..c76ff1383 100644 --- a/packages/cli/src/public/features/deploy/deploy-group.ts +++ b/packages/cli/src/public/features/deploy/deploy-group.ts @@ -2,6 +2,7 @@ import { Command } from '@cliffy/command'; import { buildWindowsDeployment } from './build/build-windows-strategy.ts'; import { createDeployBuildCommand } from './build/build-deploy-command.ts'; +import { createDenoDeployCommand } from './deno-deploy/deno-deploy-command.ts'; import { copyCommand } from './copy/copy-deploy-command.ts'; import { createDeployInstallCommand } from './install/install-deploy-command.ts'; import { logsCommand } from './logs/logs-deploy-command.ts'; @@ -39,6 +40,13 @@ export function createDeployCommand( }, }), ) + .command( + 'deno-deploy', + createDenoDeployCommand({ + loadConfig: dependencies.loadConfig, + resolveProjectRoot: dependencies.resolveProjectRoot, + }), + ) .command('package-cli', packageCliCommand) .command('copy', copyCommand) .command( diff --git a/packages/config/src/domain/config-section-types.ts b/packages/config/src/domain/config-section-types.ts index 9eac56ba4..2a8d81a6d 100644 --- a/packages/config/src/domain/config-section-types.ts +++ b/packages/config/src/domain/config-section-types.ts @@ -461,6 +461,27 @@ export interface LinuxDeployTarget extends DeployTargetBase { runtimeDir?: string; } +/** + * Deno Deploy cloud deployment target (`deploy.targets['deno-deploy']`). + * + * Pushes a scaffolded NetScript project to the Deno Deploy platform via the + * native `deno deploy` CLI (source build, no Dockerfile). The shared compile / + * bundle fields of {@link DeployTargetBase} are inherited but generally unused + * by the hosted platform; the Deno-Deploy-specific fields below drive the push. + */ +export interface DenoDeployTarget extends DeployTargetBase { + /** Deno Deploy organization slug (`deno deploy --org`). */ + org?: string; + /** Deno Deploy application/project name (`deno deploy --app`). */ + app?: string; + /** Entrypoint module passed to `deno deploy`. Default: `main.ts`. */ + entrypoint?: string; + /** Whether pushes target production (`deno deploy --prod`) by default. */ + prod?: boolean; + /** Path to an env file loaded via `deno deploy env load`. */ + envFile?: string; +} + /** Top-level deployment configuration type. */ export interface DeployConfig { /** Deployment targets keyed by name. */ @@ -473,6 +494,8 @@ export interface DeployConfig { compose?: DockerComposeDeployTarget; /** Linux systemd deployment settings. */ linux?: LinuxDeployTarget; + /** Deno Deploy cloud deployment settings. */ + 'deno-deploy'?: DenoDeployTarget; }; } diff --git a/packages/config/src/domain/schemas/deploy-schema.ts b/packages/config/src/domain/schemas/deploy-schema.ts index ed8523a71..dbcf10fe1 100644 --- a/packages/config/src/domain/schemas/deploy-schema.ts +++ b/packages/config/src/domain/schemas/deploy-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import type { + DenoDeployTarget, DeployConfig, DeployTargetBase, DockerComposeDeployTarget, @@ -174,12 +175,32 @@ export const LinuxDeployTargetSchema: z.ZodType = z.object({ runtimeDir: z.string().optional(), }); +/** + * Deno Deploy cloud deployment target (`deploy.targets['deno-deploy']`). + * Extends the shared base with Deno-Deploy-specific push fields. Consumed by + * `netscript deploy deno-deploy ` via the native `deno deploy` CLI. + */ +export const DenoDeployTargetSchema: z.ZodType = z.object({ + ...deployTargetBaseShape, + + // ── Deno Deploy platform ──────────────────────────────────────────────── + /** Deno Deploy organization slug (`deno deploy --org`). */ + org: z.string().optional(), + /** Deno Deploy application/project name (`deno deploy --app`). */ + app: z.string().optional(), + /** Entrypoint module passed to `deno deploy`. Default: 'main.ts' */ + entrypoint: z.string().optional(), + /** Whether pushes target production (`deno deploy --prod`) by default. */ + prod: z.boolean().optional(), + /** Path to an env file loaded via `deno deploy env load`. */ + envFile: z.string().optional(), +}); + /** * Top-level deploy configuration section. * `targets` is a name-keyed map of deployment targets: `windows` (Servy), - * `docker` (single-image build/push), `compose` (emit + self-host) and `linux` - * (systemd bare-metal). Future targets (deno-deploy) are added as sibling keys - * by #342–#343. + * `docker` (single-image build/push), `compose` (emit + self-host), `linux` + * (systemd bare-metal) and `deno-deploy` (Deno Deploy cloud). */ export const DeployConfigSchema: z.ZodType = z .object({ @@ -194,6 +215,8 @@ export const DeployConfigSchema: z.ZodType = z compose: DockerComposeDeployTargetSchema.optional(), /** Linux systemd deployment. */ linux: LinuxDeployTargetSchema.optional(), + /** Deno Deploy cloud deployment via the native `deno deploy` CLI. */ + 'deno-deploy': DenoDeployTargetSchema.optional(), }) .optional(), }) diff --git a/packages/config/src/public/mod.ts b/packages/config/src/public/mod.ts index a7effaafa..f841b5ce7 100644 --- a/packages/config/src/public/mod.ts +++ b/packages/config/src/public/mod.ts @@ -22,6 +22,7 @@ export { export { AppConfigSchema, DatabaseConfigSchema, + DenoDeployTargetSchema, DeployConfigSchema, DeployTargetBaseSchema, DockerComposeDeployTargetSchema, @@ -45,6 +46,7 @@ export type { DatabaseConfig, DatabaseProvider, DatabasesConfig, + DenoDeployTarget, DeployConfig, DeployTargetBase, DockerComposeDeployTarget, diff --git a/packages/config/tests/schema/netscript_config_test.ts b/packages/config/tests/schema/netscript_config_test.ts index d0f084c24..2e8565087 100644 --- a/packages/config/tests/schema/netscript_config_test.ts +++ b/packages/config/tests/schema/netscript_config_test.ts @@ -55,6 +55,44 @@ Deno.test('defineConfig: accepts a deploy.targets.windows target', () => { assertEquals(config.deploy?.targets?.windows?.servicePrefix, 'Acme'); }); +Deno.test('defineConfig: accepts a deploy.targets[deno-deploy] target', () => { + const config = defineConfig({ + name: 'orders', + databases: { config: [] }, + deploy: { + targets: { + 'deno-deploy': { + org: 'acme', + app: 'orders-api', + entrypoint: 'main.ts', + prod: true, + envFile: '.env.production', + }, + }, + }, + }); + + assertEquals(config.deploy?.targets?.['deno-deploy']?.org, 'acme'); + assertEquals(config.deploy?.targets?.['deno-deploy']?.app, 'orders-api'); + assertEquals(config.deploy?.targets?.['deno-deploy']?.prod, true); +}); + +Deno.test('defineConfig: accepts windows and deno-deploy targets side by side', () => { + const config = defineConfig({ + name: 'orders', + databases: { config: [] }, + deploy: { + targets: { + windows: { servicePrefix: 'Acme' }, + 'deno-deploy': { app: 'orders-api' }, + }, + }, + }); + + assertEquals(config.deploy?.targets?.windows?.servicePrefix, 'Acme'); + assertEquals(config.deploy?.targets?.['deno-deploy']?.app, 'orders-api'); +}); + Deno.test('defineConfig: does not honor the legacy deploy.windows shape (clean break)', () => { const config = defineConfig( { @@ -77,9 +115,11 @@ Deno.test('defineConfig: drops unknown deploy.targets keys', () => { deploy: { targets: { windows: { mode: 'compile' }, - // `linux` is now a valid member of the target map and is retained. + // `linux` is a valid member of the target map and is retained. linux: { mode: 'compile' }, - // `solaris` is not a member of this slice's target map and is dropped. + // `deno-deploy` is a valid cloud member of the target map and is retained. + 'deno-deploy': { org: 'acme', app: 'orders' }, + // `solaris` is not a member of the target map and is dropped. solaris: { mode: 'compile' }, }, }, @@ -88,7 +128,9 @@ Deno.test('defineConfig: drops unknown deploy.targets keys', () => { assertEquals(config.deploy?.targets?.windows?.mode, 'compile'); assertEquals(config.deploy?.targets?.linux?.mode, 'compile'); - assertEquals(Object.keys(config.deploy?.targets ?? {}), ['windows', 'linux']); + assertEquals(config.deploy?.targets?.['deno-deploy']?.org, 'acme'); + assertEquals(config.deploy?.targets?.['deno-deploy']?.app, 'orders'); + assertEquals(Object.keys(config.deploy?.targets ?? {}), ['windows', 'linux', 'deno-deploy']); }); Deno.test('defineConfig: rejects unrelated saga and trigger section shapes', () => {