create-daloy 0.34.2 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +62 -6
  2. package/bin/create-daloy.mjs +344 -25
  3. package/package.json +2 -2
  4. package/sbom.cdx.json +13 -9
  5. package/sbom.spdx.json +5 -5
  6. package/templates/_ci/deno/SECURITY.md +31 -2
  7. package/templates/_ci/deno/_github/CODEOWNERS +2 -0
  8. package/templates/_ci/deno/_github/workflows/container-scan.yml +42 -8
  9. package/templates/_ci/deno/_github/workflows/deploy.yml +183 -0
  10. package/templates/_ci/deno/_github/workflows/opengrep.yml +137 -0
  11. package/templates/_ci/deno/_github/workflows/osv-scan.yml +121 -0
  12. package/templates/_ci/deno/_github/workflows/secret-scan.yml +106 -0
  13. package/templates/_ci/node/SECURITY.md +100 -1
  14. package/templates/_ci/node/_github/CODEOWNERS +3 -0
  15. package/templates/_ci/node/_github/workflows/container-scan.yml +46 -10
  16. package/templates/_ci/node/_github/workflows/dast.yml +177 -0
  17. package/templates/_ci/node/_github/workflows/deploy.yml +94 -0
  18. package/templates/_ci/node/_github/workflows/opengrep.yml +169 -0
  19. package/templates/_ci/node/_github/workflows/osv-scan.yml +135 -0
  20. package/templates/_ci/node/_github/workflows/secret-scan.yml +106 -0
  21. package/templates/bun-basic/AGENTS.md +12 -0
  22. package/templates/bun-basic/_gitignore +5 -0
  23. package/templates/bun-basic/package.json +3 -2
  24. package/templates/cloudflare-worker/AGENTS.md +13 -0
  25. package/templates/cloudflare-worker/_gitignore +9 -0
  26. package/templates/cloudflare-worker/_npmrc +2 -1
  27. package/templates/cloudflare-worker/package.json +3 -2
  28. package/templates/deno-basic/AGENTS.md +12 -0
  29. package/templates/deno-basic/_gitignore +5 -0
  30. package/templates/deno-basic/deno.json +2 -2
  31. package/templates/node-basic/AGENTS.md +12 -0
  32. package/templates/node-basic/_gitignore +5 -0
  33. package/templates/node-basic/_npmrc +2 -2
  34. package/templates/node-basic/package.json +1 -1
  35. package/templates/vercel-edge/AGENTS.md +12 -0
  36. package/templates/vercel-edge/_gitignore +5 -0
  37. package/templates/vercel-edge/_npmrc +2 -1
  38. package/templates/vercel-edge/package.json +3 -2
package/README.md CHANGED
@@ -53,10 +53,11 @@ pnpm create daloy@latest my-api \
53
53
  | `--template <name>` | `node-basic` (default), `vercel-edge`, `cloudflare-worker`, `bun-basic`, or `deno-basic`. |
54
54
  | `--package-manager <pm>` | `pnpm` (default), `npm`, `yarn`, or `bun`. Ignored for `deno-basic`. |
55
55
  | `--list-templates` | Print available templates with descriptions. |
56
- | `--install` / `--no-install` | Install dependencies after scaffolding. Defaults to **Y** for npm/yarn/bun and **N** for pnpm (so first-time runs are not blocked by the 24h `minimumReleaseAge` embargo or the `pnpm.onlyBuiltDependencies` allowlist required by the hardened `.npmrc`). |
56
+ | `--install` / `--no-install` | Install dependencies after scaffolding. Defaults to **Y** for npm/yarn/bun and **N** for pnpm (so first-time runs are not blocked by the 24h `minimumReleaseAge` embargo and so you can review the scaffold's hardened `.npmrc` and `pnpm-workspace.yaml` before the first install). |
57
57
  | `--git` / `--no-git` | Initialize a git repository. Defaults to interactive. |
58
58
  | `--minimal` | Strip the bookstore demo route and the built-in `/docs` + `/openapi.json` routes so only the framework bootstrap and `/healthz` ship. |
59
59
  | `--with-ci` / `--no-ci` | Add the hardened GitHub Actions, Dependabot, CODEOWNERS, SECURITY.md, and lockfile-source verification bundle. **Defaults to Y** so scaffolded projects are secure by default. |
60
+ | `--with-deploy` / `--no-deploy` | Add the starter `.github/workflows/deploy.yml`. Defaults to the same value as `--with-ci`, so you can keep CI but opt out of deploy scaffolding with `--no-deploy`. |
60
61
  | `--code-owner <owner>` | Replace the CODEOWNERS placeholder when `--with-ci` is used, for example `@acme/security`. |
61
62
  | `--force` | Overwrite an existing non-empty directory. |
62
63
  | `--yes` | Accept all defaults; never prompt. |
@@ -85,8 +86,7 @@ A minimal Cloudflare Worker bootstrap using `@daloyjs/core/cloudflare` with:
85
86
 
86
87
  - `wrangler.toml` ready to deploy.
87
88
  - `secureHeaders` and `requestId` enabled by default, with smaller edge-friendly body and timeout limits.
88
- - Zod-validated route exposed as `fetch`.
89
- - A sample test that exercises `app.request(...)`.
89
+ - A Zod-validated `/healthz` route and contract-first `/books/:id` route exposed via `toFetchHandler(app)`.
90
90
 
91
91
  ### `vercel-edge`
92
92
 
@@ -153,11 +153,51 @@ For Node-style templates, the bundle adds:
153
153
  - `.github/workflows/ci.yml` with top-level `permissions: {}`, pinned actions,
154
154
  `harden-runner`, `persist-credentials: false`, no package-manager cache, and
155
155
  install scripts disabled.
156
+ - `.github/workflows/deploy.yml` as a manual-only deployment starter. Container
157
+ templates publish a Docker image to GHCR with the repo-scoped `GITHUB_TOKEN`,
158
+ while Vercel and Cloudflare templates ship concrete CLI deploy steps that
159
+ read their platform credentials from GitHub Actions secrets/variables. The
160
+ deploy job is gated to `main` or a tag by default, and Node-style templates
161
+ re-run `verify:lockfile` before shipping.
156
162
  - `.github/workflows/vuln-scan.yml` — a daily scheduled SCA cron that runs the
157
163
  package manager's audit against the committed lockfile. Catches CVEs disclosed
158
164
  *after* the last PR or push and provides SOC 2 CC7.1
159
165
  ([continuous vulnerability management](https://www.aikido.dev/blog/a-guide-to-automating-technical-vulnerability-management-for-soc-2))
160
166
  evidence even when developers are not touching the repo.
167
+ - `.github/workflows/osv-scan.yml` — a SECOND, independent SCA source.
168
+ `vuln-scan.yml` queries the package manager's audit feed (GHSA); this one
169
+ runs Google's OSV-Scanner against the committed lockfile and cross-references
170
+ the OpenSSF
171
+ [malicious-packages](https://github.com/ossf/malicious-packages) corpus, so
172
+ a malware advisory that lands in OSV.dev before it propagates to GHSA still
173
+ fails the build. The binary is downloaded from a pinned official release and
174
+ verified by SHA-256 before execution — no third-party action is added to the
175
+ supply chain just for this scan. This is the missing layer the Aikido
176
+ [SAST vs SCA](https://www.aikido.dev/blog/sast-vs-sca) and
177
+ [npm-audit-guide](https://www.aikido.dev/blog/npm-audit-guide) write-ups
178
+ warn about, and the Deno scaffold gets it too (Deno has no `audit` built
179
+ in, so without OSV-Scanner a Deno scaffold would have no scheduled SCA at
180
+ all).
181
+ - `.github/workflows/secret-scan.yml` — runs [gitleaks](https://github.com/gitleaks/gitleaks)
182
+ on every PR / push (working tree) and on a daily schedule across the **full
183
+ git history**, so a credential leaked anywhere in any commit, branch, or tag
184
+ is surfaced even if GitHub-native push protection missed it. The gitleaks
185
+ binary is downloaded from a pinned official release and verified by SHA-256
186
+ before execution — no third-party action is added to the supply chain just
187
+ for this scan. See Aikido's
188
+ [Secrets Detection guide](https://www.aikido.dev/blog/secret-detection-application-security)
189
+ for why history-aware scanning is the floor and not the ceiling.
190
+ - `.github/workflows/opengrep.yml` — a second SAST source alongside CodeQL,
191
+ using [Opengrep](https://github.com/opengrep/opengrep) (an open-source
192
+ Semgrep fork) with the same pinned-binary + SHA-256-verified pattern as the
193
+ OSV and gitleaks scans.
194
+ - `.github/workflows/container-scan.yml` — runs Trivy against the image
195
+ produced by the template's `_Dockerfile` (filesystem scan on PR, full image
196
+ scan on push to `main`) so a base-image CVE or a vulnerable layer is
197
+ surfaced before deploy.
198
+ - `.github/workflows/dast.yml` — a manual-only dynamic-analysis workflow that
199
+ boots the scaffolded API and runs an OWASP ZAP baseline scan against it,
200
+ for teams that want a black-box check before promoting a release.
161
201
  - CodeQL, OpenSSF Scorecard, zizmor, Dependabot, CODEOWNERS, and `SECURITY.md`.
162
202
  - `scripts/verify-lockfile-sources.mjs` plus a `verify:lockfile` package script
163
203
  that rejects git dependencies and non-registry tarball URLs in text lockfiles.
@@ -166,20 +206,36 @@ The bundle deliberately does **not** generate an npm publish workflow.
166
206
  `create-daloy` scaffolds REST API services, not libraries; if you later carve
167
207
  out a reusable package, opt into npm trusted publishing yourself.
168
208
 
169
- For `deno-basic`, `--with-ci` generates a Deno-native CI workflow plus CodeQL,
170
- Scorecard, zizmor, Dependabot for GitHub Actions, CODEOWNERS, and `SECURITY.md`.
209
+ For `deno-basic`, `--with-ci` generates a Deno-native CI workflow, a manual-only
210
+ container publish starter for GHCR that is guarded to `main` or a tag by
211
+ default, plus CodeQL, Opengrep, **OSV-Scanner** (the only scheduled SCA layer
212
+ a Deno scaffold has, since Deno ships no `audit`), Scorecard, zizmor,
213
+ Dependabot for GitHub Actions, CODEOWNERS, and `SECURITY.md`.
214
+
215
+ If you want the governance bundle but not the deployment starter, pass
216
+ `--with-ci --no-deploy`. If you only want a deployment starter, pass
217
+ `--with-deploy --no-ci`.
171
218
 
172
219
  If you omit `--code-owner`, the generated CODEOWNERS file uses
173
220
  `@your-org/security-team` as a placeholder. Replace it before relying on branch
174
221
  protection. You should also enable GitHub secret scanning, push protection, and
175
222
  required status checks in the repository settings.
176
223
 
224
+ ## Container-first scaffolds
225
+
226
+ Every template (Node, Bun, Vercel Edge, Cloudflare Worker, and Deno) ships a
227
+ production-oriented `Dockerfile` and `.dockerignore` with the secure-by-default
228
+ posture from `@daloyjs/core` `0.24.0`: a non-root user, `STOPSIGNAL SIGTERM`,
229
+ `tini` as PID 1, and a `HEALTHCHECK` pointed at `/readyz`. Node-style templates
230
+ also ship an `.env.example`. None of this is required — delete or replace
231
+ whatever you do not need.
232
+
177
233
  ## What the CLI guarantees
178
234
 
179
235
  - Zero runtime dependencies (uses only Node built-ins) for a clean supply-chain footprint.
180
236
  - A modern terminal experience with Unicode/color capability detection and ASCII fallbacks.
181
237
  - Templates are copied verbatim from this package's `templates/` directory.
182
- - Files and folders prefixed with `_` are renamed on copy (`_gitignore` → `.gitignore`, `_npmrc` → `.npmrc`, `_github/` → `.github`, `_agents/` → `.agents/`) to survive npm packing.
238
+ - Files and folders prefixed with `_` are renamed on copy (`_gitignore` → `.gitignore`, `_npmrc` → `.npmrc`, `_github/` → `.github`, `_agents/` → `.agents/`, `_Dockerfile` → `Dockerfile`, `_dockerignore` → `.dockerignore`, `_env.example` → `.env.example`) to survive npm packing.
183
239
  - pnpm-specific `.npmrc` hardening is kept only when you choose `pnpm`; other package managers get a clean project without unsupported config warnings.
184
240
  - pnpm projects ship with `ignore-scripts=true`, `minimum-release-age=1440`, `verify-store-integrity=true`, `prefer-frozen-lockfile=true`, and `strict-peer-dependencies=true` by default.
185
241
  - `--with-ci` projects ship with pinned GitHub Actions workflows, CODEOWNERS, Dependabot, SECURITY.md, and lockfile-source verification.
@@ -329,10 +329,11 @@ ${heading("Options")}
329
329
  ${color(COLORS.green, "--template <name>")} ${TEMPLATES.join(" | ")} ${color(COLORS.dim, "(default: node-basic)")}
330
330
  ${color(COLORS.green, "--package-manager <pm>")} ${PACKAGE_MANAGERS.join(" | ")} ${color(COLORS.dim, "(default: pnpm)")}
331
331
  ${color(COLORS.green, "--list-templates")} Print available templates and exit.
332
- ${color(COLORS.green, "--install / --no-install")} Install dependencies after scaffolding. ${color(COLORS.dim, "(default: Y, except pnpm \u2014 N to respect minimumReleaseAge + onlyBuiltDependencies)")}
332
+ ${color(COLORS.green, "--install / --no-install")} Install dependencies after scaffolding. ${color(COLORS.dim, "(default: Y, except pnpm \u2014 N to respect minimumReleaseAge + ignore-scripts review)")}
333
333
  ${color(COLORS.green, "--git / --no-git")} Initialize a git repository.
334
334
  ${color(COLORS.green, "--minimal")} Strip the bookstore + OpenAPI docs demo routes.
335
335
  ${color(COLORS.green, "--with-ci / --no-ci")} Add hardened GitHub Actions + governance files. ${color(COLORS.dim, "(default: Y)")}
336
+ ${color(COLORS.green, "--with-deploy / --no-deploy")} Add starter deploy.yml workflow(s). ${color(COLORS.dim, "(default: inherits --with-ci)")}
336
337
  ${color(COLORS.green, "--code-owner <owner>")} CODEOWNERS owner for --with-ci, e.g. @acme/security.
337
338
  ${color(COLORS.green, "--force")} Overwrite an existing non-empty directory.
338
339
  ${color(COLORS.green, "--yes, -y")} Accept all defaults; never prompt.
@@ -380,6 +381,7 @@ function parseArgs(argv) {
380
381
  listTemplates: false,
381
382
  minimal: false,
382
383
  ci: undefined,
384
+ deploy: undefined,
383
385
  codeOwner: undefined,
384
386
  };
385
387
  const args = [...argv];
@@ -393,6 +395,8 @@ function parseArgs(argv) {
393
395
  else if (a === "--minimal") out.minimal = true;
394
396
  else if (a === "--with-ci") out.ci = true;
395
397
  else if (a === "--no-ci") out.ci = false;
398
+ else if (a === "--with-deploy") out.deploy = true;
399
+ else if (a === "--no-deploy") out.deploy = false;
396
400
  else if (a === "--code-owner") out.codeOwner = args.shift();
397
401
  else if (a?.startsWith("--code-owner=")) out.codeOwner = a.slice("--code-owner=".length);
398
402
  else if (a === "--install") out.install = true;
@@ -686,6 +690,15 @@ function runScriptCommand(packageManager, scriptName) {
686
690
  return `${packageManager} run ${scriptName}`;
687
691
  }
688
692
 
693
+ function execCommand(packageManager, binary, args = "") {
694
+ const suffix = args ? ` ${args}` : "";
695
+ if (packageManager === "pnpm") return `pnpm exec ${binary}${suffix}`;
696
+ if (packageManager === "npm") return `npx ${binary}${suffix}`;
697
+ if (packageManager === "yarn") return `yarn exec ${binary}${suffix}`;
698
+ if (packageManager === "bun") return `bunx ${binary}${suffix}`;
699
+ return `${binary}${suffix}`;
700
+ }
701
+
689
702
  function installCommand(packageManager) {
690
703
  if (packageManager === "pnpm") return "pnpm install --frozen-lockfile --ignore-scripts";
691
704
  if (packageManager === "npm") return "npm ci --ignore-scripts";
@@ -740,6 +753,227 @@ function setupBunStep() {
740
753
  bun-version: latest`;
741
754
  }
742
755
 
756
+ function setupNodeStep() {
757
+ return ` - name: Set up Node.js
758
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
759
+ with:
760
+ node-version: 24
761
+ # Package-manager caching is intentionally disabled. Shared caches
762
+ # can bridge fork PRs into trusted branches when mis-keyed.`;
763
+ }
764
+
765
+ function installDependenciesStep(command) {
766
+ return ` - name: Install dependencies
767
+ run: ${command}
768
+ env:
769
+ npm_config_ignore_scripts: "true"`;
770
+ }
771
+
772
+ function joinWorkflowBlocks(blocks) {
773
+ return blocks.filter(Boolean).join("\n\n");
774
+ }
775
+
776
+ // Deploy job ref guard. `workflow_dispatch` accepts any branch by default, so
777
+ // a junior could trigger a production deploy from an unreviewed feature
778
+ // branch by accident. This `if:` limits the deploy job to `main` or a tag
779
+ // push while still allowing maintainers to flip the guard off if they need
780
+ // to deploy from a release branch.
781
+ function deployRefGuard() {
782
+ return ` # Lock production deploys to main or a tag push. \`workflow_dispatch\`
783
+ # accepts any branch, so this guard stops an accidental dispatch from a
784
+ # feature branch from shipping unreviewed code.
785
+ if: github.ref == 'refs/heads/main' || github.ref_type == 'tag'`;
786
+ }
787
+
788
+ function containerRegistryHeader() {
789
+ return `# Default target: GitHub Container Registry (GHCR).
790
+ # No extra secret is required; the workflow uses the repo-scoped GITHUB_TOKEN.
791
+ # After the image is published, point your platform at GHCR or replace the
792
+ # final push steps with your provider's deploy command.
793
+ #
794
+ # Every pushed image is also signed and attested with Sigstore Cosign
795
+ # (keyless / OIDC) and an SPDX SBOM, so consumers can verify provenance
796
+ # with \`cosign verify\` and \`cosign verify-attestation --type spdxjson\`
797
+ # instead of trusting the registry alone. See Aikido's "Container
798
+ # Security Best Practices" (https://www.aikido.dev/blog/container-security-best-practices)
799
+ # for why signed images + SBOM attestation are the supply-chain floor.
800
+ #
801
+ # Optional container-host handoff examples:
802
+ # - Fly.io: install flyctl in a later step and run
803
+ # flyctl deploy --image "$IMAGE_REPO@\${IMAGE_DIGEST}" --remote-only
804
+ # - Render: create an image-backed service that tracks
805
+ # ghcr.io/<owner>/<repo>@\${IMAGE_DIGEST}`;
806
+ }
807
+
808
+ function containerPublishSteps() {
809
+ return ` - name: Log in to GHCR
810
+ env:
811
+ GH_TOKEN: \${{ github.token }}
812
+ run: |
813
+ set -eu
814
+ echo "$GH_TOKEN" | docker login ghcr.io -u "\${{ github.actor }}" --password-stdin
815
+
816
+ - name: Derive image name
817
+ run: |
818
+ set -eu
819
+ owner="\${GITHUB_REPOSITORY_OWNER,,}"
820
+ repo="\${GITHUB_REPOSITORY#*/}"
821
+ repo="\${repo,,}"
822
+ echo "IMAGE_REPO=ghcr.io/\${owner}/\${repo}" >> "$GITHUB_ENV"
823
+
824
+ - name: Build image
825
+ env:
826
+ DOCKER_BUILDKIT: "1"
827
+ run: |
828
+ set -eu
829
+ docker build \
830
+ --tag "$IMAGE_REPO:sha-\${GITHUB_SHA}" \
831
+ --tag "$IMAGE_REPO:latest" \
832
+ .
833
+
834
+ - name: Push image
835
+ run: |
836
+ set -eu
837
+ docker push "$IMAGE_REPO:sha-\${GITHUB_SHA}"
838
+ docker push "$IMAGE_REPO:latest"
839
+
840
+ - name: Resolve pushed image digest
841
+ # Resolve and pin the immutable digest the rest of the workflow
842
+ # signs and attests against. Signing a mutable tag would let any
843
+ # later push silently re-point a "verified" tag at attacker
844
+ # content; binding cosign + the SBOM attestation to
845
+ # \${IMAGE_REPO}@sha256:<digest> closes that race.
846
+ run: |
847
+ set -eu
848
+ digest="$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_REPO:sha-\${GITHUB_SHA}" | awk -F@ '{print $2}')"
849
+ if [ -z "$digest" ]; then
850
+ echo "::error::Failed to resolve image digest for $IMAGE_REPO:sha-\${GITHUB_SHA}" >&2
851
+ exit 1
852
+ fi
853
+ echo "IMAGE_DIGEST=$digest" >> "$GITHUB_ENV"
854
+ echo "IMAGE_REF=$IMAGE_REPO@$digest" >> "$GITHUB_ENV"
855
+ echo "::notice::Signed image reference: $IMAGE_REPO@$digest"
856
+
857
+ - name: Install Cosign
858
+ # SHA-pinned per the project's actions-pinning policy. v4.1.2,
859
+ # commit 6f9f17788090df1f26f669e9d70d6ae9567deba6.
860
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
861
+
862
+ - name: Sign image with Cosign (keyless / OIDC)
863
+ # Keyless signing uses the workflow's \`id-token\` OIDC identity
864
+ # — no long-lived signing key to leak. Verifiers can pin to
865
+ # \`--certificate-identity\` matching this workflow URL.
866
+ env:
867
+ COSIGN_EXPERIMENTAL: "1"
868
+ run: |
869
+ set -eu
870
+ cosign sign --yes "$IMAGE_REF"
871
+
872
+ - name: Generate SPDX SBOM for image
873
+ # SHA-pinned per the project's actions-pinning policy. v0.24.0,
874
+ # commit e22c389904149dbc22b58101806040fa8d37a610.
875
+ uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
876
+ with:
877
+ image: \${{ env.IMAGE_REF }}
878
+ format: spdx-json
879
+ output-file: sbom.spdx.json
880
+ upload-artifact: false
881
+ upload-release-assets: false
882
+
883
+ - name: Attest SBOM with Cosign
884
+ env:
885
+ COSIGN_EXPERIMENTAL: "1"
886
+ run: |
887
+ set -eu
888
+ cosign attest --yes \
889
+ --predicate sbom.spdx.json \
890
+ --type spdxjson \
891
+ "$IMAGE_REF"`;
892
+ }
893
+
894
+ function vercelDeployHeader() {
895
+ return `# Configure before the first run:
896
+ # - GitHub Actions secret: VERCEL_TOKEN
897
+ # - GitHub Actions variables or environment variables: VERCEL_ORG_ID and VERCEL_PROJECT_ID
898
+ # This workflow stays manual-only until you wire those in and decide whether
899
+ # you want automatic deploys from main.`;
900
+ }
901
+
902
+ function cloudflareDeployHeader() {
903
+ return `# Configure before the first run:
904
+ # - GitHub Actions secret: CLOUDFLARE_API_TOKEN
905
+ # - GitHub Actions variable or environment variable: CLOUDFLARE_ACCOUNT_ID
906
+ # Keep this manual-only until the worker is linked to the right account and
907
+ # the production environment has approval rules.`;
908
+ }
909
+
910
+ function providerDeploySetup({ packageManager, needsBunRuntime }) {
911
+ return joinWorkflowBlocks([
912
+ setupPackageManagerStep(packageManager),
913
+ setupNodeStep(),
914
+ needsBunRuntime ? setupBunStep() : "",
915
+ installDependenciesStep(installCommand(packageManager)),
916
+ ]);
917
+ }
918
+
919
+ function vercelDeploySteps(packageManager) {
920
+ return ` - name: Deploy to Vercel
921
+ env:
922
+ VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }}
923
+ VERCEL_ORG_ID: \${{ vars.VERCEL_ORG_ID }}
924
+ VERCEL_PROJECT_ID: \${{ vars.VERCEL_PROJECT_ID }}
925
+ run: |
926
+ set -eu
927
+ : "\${VERCEL_TOKEN:?Set the VERCEL_TOKEN Actions secret before running this workflow.}"
928
+ : "\${VERCEL_ORG_ID:?Set the VERCEL_ORG_ID Actions variable before running this workflow.}"
929
+ : "\${VERCEL_PROJECT_ID:?Set the VERCEL_PROJECT_ID Actions variable before running this workflow.}"
930
+ ${execCommand(packageManager, "vercel", "deploy --prod --yes --token \"$VERCEL_TOKEN\"")}`;
931
+ }
932
+
933
+ function cloudflareDeploySteps(packageManager) {
934
+ return ` - name: Deploy to Cloudflare Workers
935
+ env:
936
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
937
+ CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
938
+ run: |
939
+ set -eu
940
+ : "\${CLOUDFLARE_API_TOKEN:?Set the CLOUDFLARE_API_TOKEN Actions secret before running this workflow.}"
941
+ : "\${CLOUDFLARE_ACCOUNT_ID:?Set the CLOUDFLARE_ACCOUNT_ID Actions variable before running this workflow.}"
942
+ ${execCommand(packageManager, "wrangler", "deploy")}`;
943
+ }
944
+
945
+ function renderDeployConfig({ template, packageManager, needsBunRuntime }) {
946
+ if (template === "vercel-edge") {
947
+ return {
948
+ header: vercelDeployHeader(),
949
+ jobName: "Deploy to Vercel",
950
+ jobPermissions: "",
951
+ setupSteps: providerDeploySetup({ packageManager, needsBunRuntime }),
952
+ steps: vercelDeploySteps(packageManager),
953
+ };
954
+ }
955
+ if (template === "cloudflare-worker") {
956
+ return {
957
+ header: cloudflareDeployHeader(),
958
+ jobName: "Deploy to Cloudflare Workers",
959
+ jobPermissions: "",
960
+ setupSteps: providerDeploySetup({ packageManager, needsBunRuntime }),
961
+ steps: cloudflareDeploySteps(packageManager),
962
+ };
963
+ }
964
+ return {
965
+ header: containerRegistryHeader(),
966
+ jobName: "Publish container image",
967
+ // `packages: write` lets us push to GHCR; `id-token: write` lets
968
+ // Cosign mint a short-lived OIDC identity for keyless image signing
969
+ // + SBOM attestation. Both are scoped to this single job — the
970
+ // top-level workflow still has `permissions: {}`.
971
+ jobPermissions: " packages: write\n id-token: write",
972
+ setupSteps: "",
973
+ steps: containerPublishSteps(),
974
+ };
975
+ }
976
+
743
977
  function workflowStep(name, command) {
744
978
  return ` - name: ${name}
745
979
  run: ${command}`;
@@ -763,13 +997,21 @@ async function addLockfileVerifyScript(dir) {
763
997
  await writePackageJson(dir, packageJson);
764
998
  }
765
999
 
766
- function renderCiReplacements({ packageManager, template, packageJson, codeOwner }) {
1000
+ function renderCiReplacements({ packageManager, template, packageJson, codeOwner, includeSecurityBundle = true }) {
767
1001
  const setupPm = setupPackageManagerStep(packageManager);
768
1002
  const needsBunRuntime = template === "bun-basic" && packageManager !== "bun";
769
1003
  const audit = auditCommand(packageManager);
770
1004
  const auditFull = auditFullCommand(packageManager);
771
1005
  const buildStep = hasPackageScript(packageJson, "build") ? workflowStep("Build", runScriptCommand(packageManager, "build")) : "";
772
1006
  const auditStep = audit ? workflowStep(auditStepName(packageManager), audit) : "";
1007
+ const deploy = renderDeployConfig({ template, packageManager, needsBunRuntime });
1008
+ // The verify:lockfile script is only scaffolded when the security bundle
1009
+ // ships. In a deploy-only scaffold the script does not exist on disk, so
1010
+ // the deploy workflow must omit the step instead of failing fast on a
1011
+ // missing file.
1012
+ const deployVerifyLockfileStep = includeSecurityBundle
1013
+ ? workflowStep("Verify lockfile sources", runScriptCommand(packageManager, "verify:lockfile"))
1014
+ : "";
773
1015
  // vuln-scan.yml: production-tree audit is blocking, full-tree audit is
774
1016
  // advisory (continue-on-error) so a low-severity dev-tool advisory does
775
1017
  // not page the on-call on a daily cron.
@@ -781,6 +1023,13 @@ function renderCiReplacements({ packageManager, template, packageJson, codeOwner
781
1023
  : "";
782
1024
 
783
1025
  return new Map([
1026
+ ["__DEPLOY_HEADER__", deploy.header],
1027
+ ["__DEPLOY_JOB_NAME__", deploy.jobName],
1028
+ ["__DEPLOY_JOB_PERMISSIONS__", deploy.jobPermissions],
1029
+ ["__DEPLOY_SETUP_STEPS__", deploy.setupSteps],
1030
+ ["__DEPLOY_STEPS__", deploy.steps],
1031
+ ["__DEPLOY_VERIFY_LOCKFILE_STEP__", deployVerifyLockfileStep],
1032
+ ["__DEPLOY_REF_GUARD__", deployRefGuard()],
784
1033
  ["__CODE_OWNER__", codeOwner],
785
1034
  ["__SETUP_PACKAGE_MANAGER_STEP__", setupPm],
786
1035
  ["__SETUP_BUN_RUNTIME_STEP__", needsBunRuntime ? setupBunStep() : ""],
@@ -817,7 +1066,35 @@ async function replacePlaceholdersInTree(dir, replacements) {
817
1066
  }
818
1067
  }
819
1068
 
820
- async function copyCiBundle(targetDir, template, packageManager, skipPackageManager, codeOwner) {
1069
+ async function pruneCiBundle(targetDir, flavor, { includeSecurityBundle, includeDeployWorkflow }) {
1070
+ if (!includeSecurityBundle) {
1071
+ const workflowFiles = flavor === "deno"
1072
+ ? ["ci.yml", "codeql.yml", "container-scan.yml", "dast.yml", "opengrep.yml", "osv-scan.yml", "scorecard.yml", "secret-scan.yml", "zizmor.yml"]
1073
+ : ["ci.yml", "codeql.yml", "container-scan.yml", "dast.yml", "opengrep.yml", "osv-scan.yml", "scorecard.yml", "secret-scan.yml", "vuln-scan.yml", "zizmor.yml"];
1074
+ for (const file of workflowFiles) {
1075
+ await rm(path.join(targetDir, ".github", "workflows", file), { force: true });
1076
+ }
1077
+ await rm(path.join(targetDir, ".github", "dependabot.yml"), { force: true });
1078
+ await rm(path.join(targetDir, ".github", "CODEOWNERS"), { force: true });
1079
+ await rm(path.join(targetDir, "SECURITY.md"), { force: true });
1080
+ if (flavor === "node") {
1081
+ await rm(path.join(targetDir, "scripts", "verify-lockfile-sources.mjs"), { force: true });
1082
+ }
1083
+ }
1084
+
1085
+ if (!includeDeployWorkflow) {
1086
+ await rm(path.join(targetDir, ".github", "workflows", "deploy.yml"), { force: true });
1087
+ }
1088
+ }
1089
+
1090
+ async function copyCiBundle(
1091
+ targetDir,
1092
+ template,
1093
+ packageManager,
1094
+ skipPackageManager,
1095
+ codeOwner,
1096
+ { includeSecurityBundle = true, includeDeployWorkflow = true } = {},
1097
+ ) {
821
1098
  const flavor = skipPackageManager ? "deno" : "node";
822
1099
  const sourceDir = path.join(CI_TEMPLATES_DIR, flavor);
823
1100
  if (!existsSync(sourceDir)) {
@@ -825,23 +1102,42 @@ async function copyCiBundle(targetDir, template, packageManager, skipPackageMana
825
1102
  }
826
1103
  await copyTemplate(sourceDir, targetDir);
827
1104
 
828
- const candidate = codeOwner?.trim() ?? "";
829
- if (candidate && !VALID_CODE_OWNER.test(candidate)) {
830
- throw new Error(
831
- `Invalid --code-owner "${candidate}". Use a GitHub handle (@user), a team (@org/team), or an email address.`,
832
- );
1105
+ await pruneCiBundle(targetDir, flavor, { includeSecurityBundle, includeDeployWorkflow });
1106
+
1107
+ if (!includeSecurityBundle && !includeDeployWorkflow) {
1108
+ return;
833
1109
  }
1110
+
1111
+ const candidate = codeOwner?.trim() ?? "";
834
1112
  const owner = candidate || "@your-org/security-team";
835
1113
  if (skipPackageManager) {
1114
+ if (includeSecurityBundle && candidate && !VALID_CODE_OWNER.test(candidate)) {
1115
+ throw new Error(
1116
+ `Invalid --code-owner "${candidate}". Use a GitHub handle (@user), a team (@org/team), or an email address.`,
1117
+ );
1118
+ }
836
1119
  await replacePlaceholdersInTree(targetDir, new Map([["__CODE_OWNER__", owner]]));
837
1120
  return;
838
1121
  }
839
1122
 
840
- await addLockfileVerifyScript(targetDir);
1123
+ if (includeSecurityBundle) {
1124
+ if (candidate && !VALID_CODE_OWNER.test(candidate)) {
1125
+ throw new Error(
1126
+ `Invalid --code-owner "${candidate}". Use a GitHub handle (@user), a team (@org/team), or an email address.`,
1127
+ );
1128
+ }
1129
+ await addLockfileVerifyScript(targetDir);
1130
+ }
841
1131
  const packageJson = await readPackageJsonIfPresent(targetDir);
842
1132
  await replacePlaceholdersInTree(
843
1133
  targetDir,
844
- renderCiReplacements({ packageManager, template, packageJson, codeOwner: owner }),
1134
+ renderCiReplacements({
1135
+ packageManager,
1136
+ template,
1137
+ packageJson,
1138
+ codeOwner: owner,
1139
+ includeSecurityBundle,
1140
+ }),
845
1141
  );
846
1142
  }
847
1143
 
@@ -1213,7 +1509,7 @@ function createSpinner(initialMessage) {
1213
1509
  };
1214
1510
  }
1215
1511
 
1216
- function printSummary({ projectName, template, packageManager, installDeps, skipPackageManager, withCi }) {
1512
+ function printSummary({ projectName, template, packageManager, installDeps, skipPackageManager, withCi, withDeploy }) {
1217
1513
  const templateMeta = TEMPLATE_OPTIONS.find((option) => option.value === template);
1218
1514
  const templateLabel = templateMeta ? `${templateMeta.title} ${color(COLORS.dim, `(${template})`)}` : template;
1219
1515
  const summaryLines = [
@@ -1230,6 +1526,9 @@ function printSummary({ projectName, template, packageManager, installDeps, skip
1230
1526
  if (withCi) {
1231
1527
  summaryLines.push(`${color(COLORS.gray, "Security ")} ${color(COLORS.cyan, "GitHub CI bundle")}`);
1232
1528
  }
1529
+ if (withDeploy) {
1530
+ summaryLines.push(`${color(COLORS.gray, "Deploy ")} ${color(COLORS.cyan, "Starter workflow")}`);
1531
+ }
1233
1532
  console.log("");
1234
1533
  console.log(renderBox(summaryLines, { accent: COLORS.green }));
1235
1534
  console.log("");
@@ -1254,10 +1553,13 @@ function printSummary({ projectName, template, packageManager, installDeps, skip
1254
1553
  ` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "(including a just-released @daloyjs/core) are embargoed for 24 h.")}`,
1255
1554
  );
1256
1555
  console.log(
1257
- ` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "Lifecycle scripts are blocked by default; allowlist trusted builds in")}`,
1556
+ ` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "Lifecycle scripts stay blocked by default (ignore-scripts=true);")}`,
1557
+ );
1558
+ console.log(
1559
+ ` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "if you later tighten build-script policy, keep exceptions in")}`,
1258
1560
  );
1259
1561
  console.log(
1260
- ` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "package.json under pnpm.onlyBuiltDependencies if install complains.")}`,
1562
+ ` ${color(COLORS.gray, SYMBOLS.pointer)} ${color(COLORS.dim, "pnpm-workspace.yaml under allowBuilds instead of weakening .npmrc.")}`,
1261
1563
  );
1262
1564
  }
1263
1565
 
@@ -1378,18 +1680,17 @@ async function main() {
1378
1680
  } else if (packageManager === "pnpm") {
1379
1681
  // Deny-by-default for pnpm: the scaffolded `pnpm-workspace.yaml` ships
1380
1682
  // with `minimumReleaseAge: 1440` (24 h embargo on newly-published
1381
- // versions) and the `.npmrc` blocks lifecycle scripts unless they're
1382
- // allowlisted in `package.json` under `pnpm.onlyBuiltDependencies`.
1383
- // Both are security best practices, but they mean a fresh
1384
- // `pnpm install` can fail until the user (a) waits 24 h for newly
1385
- // published `@daloyjs/core` versions to clear the embargo, or (b)
1386
- // allowlists any dep that needs a build script. Defaulting to N
1387
- // makes that explicit instead of failing the install silently.
1683
+ // versions), and the scaffolded `.npmrc` keeps `ignore-scripts=true`.
1684
+ // Both are security best practices, but they mean the first install
1685
+ // should be a deliberate user choice rather than something the CLI
1686
+ // does implicitly. Defaulting to N makes that explicit, and it also
1687
+ // avoids surprising users when a fresh `@daloyjs/core` release is
1688
+ // still inside the 24 h embargo window.
1388
1689
  if (rl) {
1389
1690
  console.log(
1390
1691
  color(
1391
1692
  COLORS.gray,
1392
- " (pnpm install may fail until you set pnpm.onlyBuiltDependencies in package.json and wait 24h for fresh @daloyjs/core releases \u2014 see pnpm-workspace.yaml)",
1693
+ " (pnpm install may wait out a fresh-release embargo and keeps lifecycle scripts blocked by default \u2014 see .npmrc + pnpm-workspace.yaml)",
1393
1694
  ),
1394
1695
  );
1395
1696
  installDeps = await askYesNo(rl, `Install dependencies with ${packageManager}?`, false);
@@ -1413,6 +1714,15 @@ async function main() {
1413
1714
  withCi = rl ? await askYesNo(rl, "Add hardened GitHub Actions and security files?", true) : true;
1414
1715
  }
1415
1716
 
1717
+ let withDeploy = opts.deploy;
1718
+ if (withDeploy === undefined) {
1719
+ if (withCi) {
1720
+ withDeploy = true;
1721
+ } else {
1722
+ withDeploy = rl ? await askYesNo(rl, "Add a starter deployment workflow?", false) : false;
1723
+ }
1724
+ }
1725
+
1416
1726
  rl?.close();
1417
1727
 
1418
1728
  if (interactive) {
@@ -1443,9 +1753,18 @@ async function main() {
1443
1753
  }
1444
1754
  }
1445
1755
 
1446
- if (withCi) {
1447
- await copyCiBundle(targetDir, template, packageManager, skipPackageManager, opts.codeOwner);
1448
- logStep("GitHub security bundle added", skipPackageManager ? "deno" : packageManager);
1756
+ if (withCi || withDeploy) {
1757
+ await copyCiBundle(targetDir, template, packageManager, skipPackageManager, opts.codeOwner, {
1758
+ includeSecurityBundle: withCi,
1759
+ includeDeployWorkflow: withDeploy,
1760
+ });
1761
+ if (withCi && withDeploy) {
1762
+ logStep("GitHub automation added", `${skipPackageManager ? "deno" : packageManager} + deploy`);
1763
+ } else if (withCi) {
1764
+ logStep("GitHub security bundle added", skipPackageManager ? "deno" : packageManager);
1765
+ } else if (withDeploy) {
1766
+ logStep("Deploy starter added", template);
1767
+ }
1449
1768
  }
1450
1769
 
1451
1770
  if (initGit) {
@@ -1474,7 +1793,7 @@ async function main() {
1474
1793
  }
1475
1794
  }
1476
1795
 
1477
- printSummary({ projectName, template, packageManager, installDeps, skipPackageManager, withCi });
1796
+ printSummary({ projectName, template, packageManager, installDeps, skipPackageManager, withCi, withDeploy });
1478
1797
  } catch (err) {
1479
1798
  rl?.close();
1480
1799
  if (err && err.message === "Cancelled") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-daloy",
3
- "version": "0.34.2",
3
+ "version": "0.35.0",
4
4
  "description": "Scaffold a new DaloyJS project. Run with `pnpm create daloy`, `npm create daloy@latest`, `yarn create daloy`, or `bun create daloy`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,4 +45,4 @@
45
45
  "scripts": {
46
46
  "test": "node --test test/**/*.test.mjs"
47
47
  }
48
- }
48
+ }