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.
- package/CHANGELOG.md +134 -0
- package/README.md +112 -30
- package/package.json +9 -2
- package/packages/cli/dist/commands/create.js +8 -2
- package/packages/cli/dist/commands/init.js +72 -0
- package/packages/cli/dist/index.js +44 -2
- package/packages/cli/dist/lib/copy-tree.js +2 -0
- package/packages/cli/dist/lib/summary.js +19 -5
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +24 -11
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +1 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +18 -0
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +66 -0
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +36 -0
- package/templates/agent-os/universal/.claude/queue.json +3 -0
- package/templates/agent-os/universal/.claude/rules/architecture.md +7 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +169 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +33 -7
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
- package/templates/agent-os/universal/.claude/settings.json +24 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +302 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/CLAUDE.md +64 -4
- package/templates/agent-os/universal/PLAN.md +53 -0
- package/templates/agent-os/universal/layers.json +40 -0
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +57 -0
- package/templates/skeleton/aws-serverless/README.md +28 -7
- package/templates/skeleton/aws-serverless/gitignore +8 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/node-service/.github/workflows/deploy.yml +29 -0
- package/templates/skeleton/node-service/README.md +21 -1
- package/templates/skeleton/node-service/gitignore +9 -0
- package/templates/skeleton/node-service/package.json +4 -2
- package/templates/skeleton/node-service/pnpm-lock.yaml +3 -0
- package/templates/skeleton/node-service/scripts/build-artifact.mjs +34 -0
- 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`
|
|
49
|
-
the core; `
|
|
50
|
-
|
|
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 --
|
|
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:
|
|
@@ -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 --
|
|
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
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app/root",
|
|
3
|
-
"version": "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
|
|
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",
|
|
@@ -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
|
+
});
|