create-agent-rig 0.1.0 → 0.3.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 (52) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +112 -30
  3. package/package.json +9 -2
  4. package/packages/cli/dist/commands/create.js +8 -2
  5. package/packages/cli/dist/commands/init.js +72 -0
  6. package/packages/cli/dist/index.js +44 -2
  7. package/packages/cli/dist/lib/copy-tree.js +2 -0
  8. package/packages/cli/dist/lib/summary.js +19 -5
  9. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
  10. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +24 -11
  11. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
  12. package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +1 -0
  13. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +18 -0
  14. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
  15. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +66 -0
  16. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
  17. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +36 -0
  18. package/templates/agent-os/universal/.claude/queue.json +3 -0
  19. package/templates/agent-os/universal/.claude/rules/architecture.md +7 -0
  20. package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
  21. package/templates/agent-os/universal/.claude/rules/invariants.md +169 -0
  22. package/templates/agent-os/universal/.claude/rules/workflow.md +33 -7
  23. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
  24. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
  25. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
  26. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
  27. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
  28. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
  29. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
  30. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
  31. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
  32. package/templates/agent-os/universal/.claude/settings.json +24 -0
  33. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +302 -0
  34. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
  35. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
  36. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  37. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
  38. package/templates/agent-os/universal/CLAUDE.md +64 -4
  39. package/templates/agent-os/universal/PLAN.md +53 -0
  40. package/templates/agent-os/universal/layers.json +40 -0
  41. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
  42. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +57 -0
  43. package/templates/skeleton/aws-serverless/README.md +28 -7
  44. package/templates/skeleton/aws-serverless/gitignore +8 -0
  45. package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
  46. package/templates/skeleton/node-service/.github/workflows/deploy.yml +29 -0
  47. package/templates/skeleton/node-service/README.md +21 -1
  48. package/templates/skeleton/node-service/gitignore +9 -0
  49. package/templates/skeleton/node-service/package.json +4 -2
  50. package/templates/skeleton/node-service/pnpm-lock.yaml +3 -0
  51. package/templates/skeleton/node-service/scripts/build-artifact.mjs +34 -0
  52. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +93 -0
@@ -42,12 +42,72 @@ them all; they are one rulebook.
42
42
 
43
43
  - **TDD, without exception.** The failing test comes first — use the
44
44
  `test-writer` agent for it. See `.claude/rules/workflow.md`.
45
+ - **One task, one branch — and merge via PR.** Every unit of work gets its own
46
+ short-lived branch; the default branch is never committed to directly. Once
47
+ the project has a remote and CI, changes reach it through the PR flow (local
48
+ checks → reviewer fan-out → merge on an explicit criterion). See
49
+ `.claude/rules/workflow.md` ("Branches and commits", "PR flow"). When another
50
+ session may touch this repo at the same time, the branch lives in its own
51
+ worktree — the `worktree-task` skill has the lifecycle and the cleanup.
45
52
  - **Gates.** `code-reviewer` runs before every PR; `security-scanner` runs when
46
53
  a change touches auth, secrets, parsing, or outbound calls. Blocking findings
47
- are resolved, not argued with.
48
- - **Enforcement is mechanical.** `guard-core-purity` refuses impure edits to
49
- the core; `block-no-verify` refuses pre-commit bypasses. If a hook blocks
50
- you, fix the cause; never route around a hook.
54
+ are resolved, not argued with. The `pr-ship` skill drives the gate.
55
+ - **Enforcement is mechanical.** `guard-core-purity` catches an impure edit to
56
+ the core the moment it lands; `guard-web-boundary` keeps the frontend off the
57
+ backend; `block-no-verify` refuses pre-commit bypasses; `guard-bash` refuses
58
+ the "Never" tier — force-pushing a shared branch, a production deploy, a
59
+ filesystem wipe — and carries the kill switch; `gate-stop-dod` refuses to end
60
+ the session while a Definition-of-Done check fails. If a hook blocks you, fix
61
+ the cause; never route around a hook.
62
+ - **Enforcement is a pattern you can apply again.** Each of those hooks is one
63
+ stated invariant + one mechanical check + one test — the pattern is written down
64
+ in `.claude/rules/invariants.md`, and the `new-invariant` skill walks you
65
+ through adding one. The hooks that ship here are **examples, not laws**: if the
66
+ invariant they guard is not load-bearing in this project, delete it and spend
67
+ the slot on one that is.
68
+ - **There is a brake, and it is a real file.** `touch
69
+ ~/.claude/__PROJECT_NAME__-loop-STOP` and `guard-bash` denies every merge
70
+ until it is removed. Everything short of the merge stays allowed on purpose:
71
+ finish the task, push the branch, open the PR, write the journal, stop.
72
+ Stopping cleanly never means losing the work.
73
+ - **Work comes from the queue, through an adapter.** The `loop` skill selects via
74
+ `.claude/scripts/queue/index.mjs`, which reads whichever queue
75
+ `.claude/queue.json` names — the Agent queue in `PLAN.md` by default, issues in
76
+ this repository once it has a remote. An empty queue **ends the session**; it is
77
+ never a cue to invent work, and the agent never files its own work items.
78
+
79
+ ## The elevated paths of this project
80
+
81
+ Tier 2 in `.claude/rules/autonomy.md` names *kinds* of change. This block names
82
+ the **paths** in this repository where those kinds live, and
83
+ `.claude/scripts/detect-missed-gate.mjs` reads it — so a path that is not declared
84
+ is a path the gate sweep cannot see.
85
+
86
+ ```elevated-paths
87
+ packages/db/src/
88
+ .claude/
89
+ .github/workflows/
90
+ ```
91
+
92
+ `.claude/` and `.github/workflows/` are there because they are what *disarms* the
93
+ rest: a merge that rewrites the Never tier, unwires a hook or edits what CI runs
94
+ should never pass unreviewed. `packages/db/src/` is the one the generated shape
95
+ has.
96
+
97
+ **They are a seed, not a law — the list is yours to extend.** It is what every
98
+ generated shape has; a real project accumulates more (auth handlers, billing, a
99
+ credentials module, a migration directory). Add a path the same day you add the
100
+ code, because the gap between the two is exactly the window in which a change
101
+ slips through unreviewed.
102
+
103
+ The declaration is **composed, not centralised**: the sweep unions this block with
104
+ every `elevated-paths` block in `.claude/rules/`, so a stack layer declares the
105
+ paths that only exist in its shape. A gate declared over a directory this project
106
+ does not have would report "clean" while looking nowhere.
107
+
108
+ Nothing about this list is retroactive. Installing the sweep into a repo with
109
+ history means passing `--epoch <the day you installed it>` once, or the first run
110
+ reports every merge that predates the gate.
51
111
 
52
112
  ## Foot-guns
53
113
 
@@ -0,0 +1,53 @@
1
+ # __PROJECT_NAME__ — plan and work queues
2
+
3
+ Work in this project has a stated origin: one of the two queues below. An
4
+ agent session picks from the **Agent queue** (see the `loop` skill); anything
5
+ that needs a human decision waits in the **Operator queue**. An empty Agent
6
+ queue means the session ends — it is never an invitation to improvise.
7
+
8
+ Keep entries one line each, most valuable first. Delete done items — the
9
+ journal records history; the queues state only what is next.
10
+
11
+ ## Agent queue
12
+
13
+ <!-- Tasks an agent may pick up autonomously (Tier 0/1 — see
14
+ .claude/rules/autonomy.md). One line each, e.g.:
15
+ - add a GET /notes/:id route through every layer (TDD)
16
+ -->
17
+
18
+ ## Operator queue
19
+
20
+ <!-- Decisions and Tier-2 work waiting on a human. State what is needed, e.g.:
21
+ - decide: retention policy before real data (RemovalPolicy flip)
22
+ -->
23
+
24
+ ## Journal
25
+
26
+ Newest first, date-free — order carries the sequence. Prune freely: this is
27
+ operational memory, not an archive. An unattended run writes an entry at every
28
+ stop **and** at checkpoints along the way, because a run that dies unexpectedly
29
+ must not take its history with it.
30
+
31
+ The fields exist so an entry can be visibly **incomplete**. A journal with no
32
+ stated shape decays into a diary that reads fine and proves nothing.
33
+
34
+ <!-- Template — copy the block, drop the fields that do not apply:
35
+
36
+ ### <one-line summary of the session>
37
+
38
+ - **done** — what landed, one line each, with the PR reference
39
+ - **escalated** — what stopped, and the diagnosis: what failed, what was tried,
40
+ the current hypothesis, and the one question whose answer unblocks it
41
+ - **reviewed** — changes that went through a reviewer gate, and what it returned
42
+ - **stopped at** — which stop condition ended the session (or "checkpoint,
43
+ still running")
44
+ - **queue hygiene** — queue items fixed in passing: stale state, a dependency
45
+ that was already satisfied, an item that describes work already done
46
+ - **cost** — the counts the session actually observed: reviewer subagents run,
47
+ CI runs consumed (re-runs included — the cheapest signal that a task fought
48
+ its tests), deploys triggered
49
+
50
+ A field the session cannot observe stays **visibly empty — never estimated**.
51
+ A plausible number will be believed, by the next reader and by the next run
52
+ reasoning about its own budget. Leave the gap; it is information.
53
+ -->
@@ -0,0 +1,40 @@
1
+ {
2
+ "process": [
3
+ ".claude/rules/workflow.md",
4
+ ".claude/rules/autonomy.md",
5
+ ".claude/rules/invariants.md",
6
+ ".claude/skills/new-invariant/SKILL.md",
7
+ ".claude/skills/new-invariant/guard-invariant.example.mjs",
8
+ ".claude/skills/new-invariant/guard-invariant.example.test.mjs",
9
+ ".claude/agents/test-writer.md",
10
+ ".claude/agents/code-reviewer.md",
11
+ ".claude/agents/security-scanner.md",
12
+ ".claude/hooks/block-no-verify.mjs",
13
+ ".claude/hooks/guard-bash.mjs",
14
+ ".claude/hooks/gate-stop-dod.mjs",
15
+ ".claude/hooks/inject-rules.mjs",
16
+ ".claude/skills/pr-ship/SKILL.md",
17
+ ".claude/skills/loop/SKILL.md",
18
+ ".claude/skills/worktree-task/SKILL.md",
19
+ ".claude/scripts/detect-missed-gate.mjs",
20
+ ".claude/scripts/reconcile-external-prs.mjs",
21
+ ".claude/scripts/stop-flag.mjs",
22
+ ".claude/scripts/preflight.mjs",
23
+ ".claude/scripts/queue/core.mjs",
24
+ ".claude/scripts/queue/plan-md.mjs",
25
+ ".claude/scripts/queue/github-issues.mjs",
26
+ ".claude/scripts/queue/jira.mjs",
27
+ ".claude/scripts/queue/index.mjs",
28
+ ".claude/queue.json",
29
+ "PLAN.md"
30
+ ],
31
+ "architecture": [
32
+ ".claude/rules/architecture.md",
33
+ ".claude/hooks/guard-core-purity.mjs",
34
+ ".claude/hooks/guard-web-boundary.mjs"
35
+ ],
36
+ "meta": [
37
+ ".claude/settings.json",
38
+ "CLAUDE.md"
39
+ ]
40
+ }
@@ -1,5 +1,10 @@
1
1
  name: CI
2
2
 
3
+ # The default token is read-only for this job: CI needs nothing else, and a
4
+ # workflow that can write is a workflow a compromised dependency can write with.
5
+ permissions:
6
+ contents: read
7
+
3
8
  on:
4
9
  push:
5
10
  pull_request:
@@ -16,7 +21,7 @@ jobs:
16
21
  with:
17
22
  node-version: 22
18
23
  cache: pnpm
19
- - run: pnpm install --no-frozen-lockfile
24
+ - run: pnpm install --frozen-lockfile
20
25
  - run: pnpm lint
21
26
  - run: pnpm typecheck
22
27
  - run: pnpm test
@@ -0,0 +1,57 @@
1
+ name: Deploy (dev)
2
+
3
+ # DEV deployment only. Production is a HUMAN step (see README) — the Never tier
4
+ # (.claude/rules/autonomy.md) forbids an agent from triggering a production
5
+ # deploy, so no production path exists in this workflow at all.
6
+ on:
7
+ push:
8
+ branches: [main, master]
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ # OIDC: assume a short-lived federated role. No long-lived keys in secrets —
13
+ # a starter multiplies whatever it ships, so it ships zero static credentials.
14
+ id-token: write
15
+ contents: read
16
+
17
+ jobs:
18
+ deploy:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ # Degrade cleanly: with no configured role this SKIPS with a message and
24
+ # the job stays green — never a red X on a fresh project with no cloud
25
+ # account. Enabling deployment is "add the AWS_DEPLOY_ROLE_ARN secret",
26
+ # not "edit this file".
27
+ - id: guard
28
+ env:
29
+ ROLE: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
30
+ run: |
31
+ if [ -z "$ROLE" ]; then
32
+ echo "deploy skipped: no credentials configured — see README (Deploy)."
33
+ echo "skip=true" >> "$GITHUB_OUTPUT"
34
+ fi
35
+
36
+ - if: steps.guard.outputs.skip != 'true'
37
+ uses: pnpm/action-setup@v4
38
+ - if: steps.guard.outputs.skip != 'true'
39
+ uses: actions/setup-node@v4
40
+ with:
41
+ node-version: 22
42
+ cache: pnpm
43
+ - if: steps.guard.outputs.skip != 'true'
44
+ run: pnpm install --frozen-lockfile
45
+ - if: steps.guard.outputs.skip != 'true'
46
+ run: pnpm build:web
47
+ env:
48
+ NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
49
+
50
+ - if: steps.guard.outputs.skip != 'true'
51
+ uses: aws-actions/configure-aws-credentials@v4
52
+ with:
53
+ role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
54
+ aws-region: ${{ vars.AWS_REGION || '__REGION__' }}
55
+
56
+ - if: steps.guard.outputs.skip != 'true'
57
+ run: pnpm --filter @app/infra exec cdk deploy AppStack WebStack --require-approval never
@@ -36,20 +36,41 @@ pnpm check # all of the above
36
36
 
37
37
  ## Deploy it
38
38
 
39
+ ### Dev — automated, on push (OIDC, no static keys)
40
+
41
+ `.github/workflows/deploy.yml` runs a **dev** deploy on every push to the
42
+ default branch. It ships with **no credentials**, so on a fresh repo it skips
43
+ cleanly with a message — never a red X. To enable it, you *add a secret*, you
44
+ do not edit the workflow:
45
+
46
+ 1. In AWS, create an IAM role your repo can assume via GitHub's OIDC provider
47
+ (`token.actions.githubusercontent.com`) — a short-lived federated role, no
48
+ long-lived access keys anywhere.
49
+ 2. Add its ARN as the repository secret **`AWS_DEPLOY_ROLE_ARN`** (and,
50
+ optionally, repo variables `AWS_REGION` and `API_URL`).
51
+
52
+ The workflow then assumes the role, builds the web bundle, and runs
53
+ `cdk deploy AppStack WebStack`. The web bundle is served from S3 + CloudFront
54
+ (the `WebUrl` output).
55
+
56
+ ### Local / manual
57
+
39
58
  ```sh
40
59
  # needs AWS credentials; region comes from your profile (generator default: __REGION__)
41
60
  cd infra
42
61
  npx cdk bootstrap # first time per account/region
43
- npx cdk deploy AppStack
44
-
45
- # the web bundle: build against the deployed API, then sync to the web stack
46
- cd ..
47
- NEXT_PUBLIC_API_URL=<ApiUrl output> pnpm build:web
48
- cd infra && npx cdk deploy WebStack
62
+ npx cdk deploy AppStack WebStack
49
63
  aws s3 sync ../apps/web/out "s3://<WebBucketName output>"
50
- # the site is at the WebUrl output
51
64
  ```
52
65
 
66
+ ### Production — a human step, on purpose
67
+
68
+ There is **no production deploy in this repo**, by design: the agent operating
69
+ system's Never tier forbids an agent from triggering a production deploy, so
70
+ shipping an automated prod path would contradict the rules on day one.
71
+ Promote to production yourself — a separate account/role, a reviewed change,
72
+ your own approval — reusing the dev workflow's OIDC pattern.
73
+
53
74
  ## Verify runtime health (CI-green ≠ runtime-healthy)
54
75
 
55
76
  After every deploy:
@@ -7,3 +7,11 @@ cdk.out/
7
7
  .next/
8
8
  out/
9
9
  next-env.d.ts
10
+ .claude/worktrees/
11
+
12
+ # secrets — never commit these
13
+ .env
14
+ .env.*
15
+ !.env.example
16
+ *.pem
17
+ *.key
@@ -1,5 +1,10 @@
1
1
  name: CI
2
2
 
3
+ # The default token is read-only for this job: CI needs nothing else, and a
4
+ # workflow that can write is a workflow a compromised dependency can write with.
5
+ permissions:
6
+ contents: read
7
+
3
8
  on:
4
9
  push:
5
10
  pull_request:
@@ -16,7 +21,7 @@ jobs:
16
21
  with:
17
22
  node-version: 22
18
23
  cache: pnpm
19
- - run: pnpm install --no-frozen-lockfile
24
+ - run: pnpm install --frozen-lockfile
20
25
  - run: pnpm lint
21
26
  - run: pnpm typecheck
22
27
  - run: pnpm test
@@ -0,0 +1,29 @@
1
+ name: Build artifact
2
+
3
+ # node-service ships NO deploy destination — a container registry, a VPS, a
4
+ # PaaS is the project owner's choice, not the starter's. This builds a runnable
5
+ # artifact (dist/) and stops short of delivery: it produces something real,
6
+ # then leaves shipping to you. Production is a HUMAN step (see README).
7
+ on:
8
+ push:
9
+ branches: [main, master]
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ build-artifact:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: pnpm/action-setup@v4
18
+ - uses: actions/setup-node@v4
19
+ with:
20
+ node-version: 22
21
+ cache: pnpm
22
+ - run: pnpm install --frozen-lockfile
23
+ - run: pnpm build:web
24
+ - run: pnpm build:artifact
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: server-bundle
28
+ path: dist/
29
+ - run: echo "Artifact built (dist/). Deploy destination is your choice — see README (Deploy)."
@@ -45,7 +45,27 @@ Open http://localhost:3000/ — the form validates with the same core schema
45
45
  the server applies again on POST.
46
46
 
47
47
  Configuration is environment-first (`PORT`, `DATA_DIR`, `QUEUE_DIR`, `DLQ_DIR`,
48
- `POLL_INTERVAL_MS`) with working defaults — see each service's `main.ts`.
48
+ `POLL_INTERVAL_MS`, `STATIC_DIR`) with working defaults — see each service's
49
+ `main.ts`.
50
+
51
+ ## Deploy it
52
+
53
+ This target ships **no deploy destination** — a container registry, a VPS, a
54
+ PaaS is your choice, not the starter's. What it ships is a **build that
55
+ produces a runnable artifact** and stops there:
56
+
57
+ ```sh
58
+ pnpm build:artifact # → dist/server.mjs + dist/public/
59
+ STATIC_DIR=dist/public node dist/server.mjs # run the artifact anywhere Node runs
60
+ ```
61
+
62
+ `.github/workflows/deploy.yml` runs this on every push and uploads `dist/` as a
63
+ build artifact — it produces something real and delivers it nowhere. Point it
64
+ at your host of choice (add a `docker build`, an `scp`, a PaaS deploy step).
65
+
66
+ **Production is a human step, on purpose.** The agent operating system's Never
67
+ tier forbids an agent from triggering a production deploy, so no automated prod
68
+ path ships here — promoting the artifact to production is your reviewed action.
49
69
 
50
70
  ## Verify runtime health (CI-green ≠ runtime-healthy)
51
71
 
@@ -7,3 +7,12 @@ var/
7
7
  .next/
8
8
  out/
9
9
  next-env.d.ts
10
+ dist/
11
+ .claude/worktrees/
12
+
13
+ # secrets — never commit these
14
+ .env
15
+ .env.*
16
+ !.env.example
17
+ *.pem
18
+ *.key
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app/root",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,13 +11,15 @@
11
11
  "lint": "eslint .",
12
12
  "typecheck": "tsc -p tsconfig.json && tsc -p apps/web/tsconfig.json",
13
13
  "build:web": "pnpm --filter @app/web build",
14
+ "build:artifact": "pnpm build:web && node scripts/build-artifact.mjs",
14
15
  "start:api": "pnpm --filter @app/api start",
15
16
  "start:worker": "pnpm --filter @app/worker start",
16
- "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build:web"
17
+ "check": "pnpm lint && pnpm typecheck && pnpm build:artifact && pnpm test"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@eslint/js": "^10.0.1",
20
21
  "@types/node": "^26.1.1",
22
+ "esbuild": "^0.28.1",
21
23
  "eslint": "^10.7.0",
22
24
  "globals": "^17.7.0",
23
25
  "tsx": "^4.23.1",
@@ -14,6 +14,9 @@ importers:
14
14
  '@types/node':
15
15
  specifier: ^26.1.1
16
16
  version: 26.1.1
17
+ esbuild:
18
+ specifier: ^0.28.1
19
+ version: 0.28.1
17
20
  eslint:
18
21
  specifier: ^10.7.0
19
22
  version: 10.7.0
@@ -0,0 +1,34 @@
1
+ // Builds the deployable artifact: a bundled server plus the web public dir,
2
+ // into dist/. It produces a real, runnable thing — it does not ship it
3
+ // anywhere. Run it: STATIC_DIR=dist/public node dist/server.mjs
4
+ import { build } from 'esbuild';
5
+ import { cp, mkdir, rm } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const dist = path.join(root, 'dist');
11
+
12
+ await rm(dist, { recursive: true, force: true });
13
+ await mkdir(dist, { recursive: true });
14
+
15
+ await build({
16
+ entryPoints: [path.join(root, 'services', 'api', 'src', 'main.ts')],
17
+ outfile: path.join(dist, 'server.mjs'),
18
+ bundle: true,
19
+ platform: 'node',
20
+ format: 'esm',
21
+ target: 'node22',
22
+ // ESM-on-node output may reference require() from a dependency — shim it.
23
+ banner: {
24
+ js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);",
25
+ },
26
+ });
27
+
28
+ // The built web bundle, if present (pnpm build:web). Served via STATIC_DIR.
29
+ await cp(path.join(root, 'apps', 'web', 'out'), path.join(dist, 'public'), {
30
+ recursive: true,
31
+ }).catch(() => {});
32
+
33
+ console.log('artifact built: dist/server.mjs (+ dist/public if the web was built).');
34
+ console.log('run it: STATIC_DIR=dist/public node dist/server.mjs');
@@ -0,0 +1,93 @@
1
+ // CD brief §2: node-service's deployable artifact must actually build and run.
2
+ // This bundles the server, boots the bundle over a real socket, and closes the
3
+ // path — proving the artifact is genuine, not a stub.
4
+ import { execFile, spawn } from 'node:child_process';
5
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import type { AddressInfo } from 'node:net';
8
+ import { createServer } from 'node:net';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { promisify } from 'node:util';
12
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
13
+
14
+ const exec = promisify(execFile);
15
+ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
16
+ const dist = path.join(projectRoot, 'dist');
17
+
18
+ function freePort(): Promise<number> {
19
+ return new Promise((resolve, reject) => {
20
+ const srv = createServer();
21
+ srv.listen(0, () => {
22
+ const { port } = srv.address() as AddressInfo;
23
+ srv.close(() => resolve(port));
24
+ });
25
+ srv.on('error', reject);
26
+ });
27
+ }
28
+
29
+ describe('deployable artifact (dist/)', () => {
30
+ let child: ReturnType<typeof spawn> | undefined;
31
+ let work: string;
32
+
33
+ beforeAll(async () => {
34
+ work = await mkdtemp(path.join(tmpdir(), 'artifact-'));
35
+ // `pnpm check` builds the artifact before tests; build it here only if a
36
+ // bare `pnpm test` run left no dist to boot.
37
+ const built = await stat(path.join(dist, 'server.mjs')).then(
38
+ () => true,
39
+ () => false,
40
+ );
41
+ if (!built) await exec('pnpm', ['build:artifact'], { cwd: projectRoot });
42
+ }, 180_000);
43
+
44
+ afterAll(async () => {
45
+ child?.kill();
46
+ await rm(work, { recursive: true, force: true });
47
+ });
48
+
49
+ it('produces a bundled server and the web public dir', async () => {
50
+ expect((await stat(path.join(dist, 'server.mjs'))).size).toBeGreaterThan(1000);
51
+ await expect(readFile(path.join(dist, 'public', 'index.html'), 'utf8')).resolves.toContain(
52
+ '<',
53
+ );
54
+ });
55
+
56
+ it('the bundle boots and serves the full request path', async () => {
57
+ const port = await freePort();
58
+ child = spawn(process.execPath, [path.join(dist, 'server.mjs')], {
59
+ env: {
60
+ ...process.env,
61
+ PORT: String(port),
62
+ DATA_DIR: path.join(work, 'data'),
63
+ QUEUE_DIR: path.join(work, 'queue'),
64
+ STATIC_DIR: path.join(dist, 'public'),
65
+ },
66
+ stdio: 'ignore',
67
+ });
68
+
69
+ const base = `http://127.0.0.1:${port}`;
70
+ // wait for readiness
71
+ for (let i = 0; i < 50; i++) {
72
+ try {
73
+ await fetch(base + '/notes');
74
+ break;
75
+ } catch {
76
+ await new Promise((r) => setTimeout(r, 100));
77
+ }
78
+ }
79
+
80
+ const created = await fetch(base + '/notes', {
81
+ method: 'POST',
82
+ headers: { 'content-type': 'application/json' },
83
+ body: JSON.stringify({ title: 'artifact proof' }),
84
+ });
85
+ expect(created.status).toBe(201);
86
+ const listed = (await (await fetch(base + '/notes')).json()) as { notes: unknown[] };
87
+ expect(listed.notes).toHaveLength(1);
88
+ // the bundle also serves the web shell
89
+ const root = await fetch(base + '/');
90
+ expect(root.status).toBe(200);
91
+ expect(root.headers.get('content-type')).toContain('text/html');
92
+ }, 30_000);
93
+ });