create-stitchkit 0.3.3 → 0.4.1
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 +347 -0
- package/README.md +3 -1
- package/UPGRADING.md +342 -0
- package/dist/cli.js +238 -42
- package/examples/repository/_env.example.append +21 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- package/examples/repository/packages/backend/src/surface.ts +1 -1
- package/examples/repository/packages/config/src/features.ts +17 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
- package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
- package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
- package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
- package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
- package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
- package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
- package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
- package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
- package/examples/repository/project.json +189 -0
- package/examples/repository/scripts/runtime-smoke.ts +38 -6
- package/package.json +12 -2
- package/template/AGENTS.md +23 -3
- package/template/README.md +83 -8
- package/template/_env.example +16 -4
- package/template/_gitignore +1 -0
- package/template/biome.json +6 -2
- package/template/bun.lock +115 -98
- package/template/e2e/starter.spec.ts +5 -7
- package/template/ecosystem.config.cjs +42 -19
- package/template/ecosystem.dev.config.cjs +41 -21
- package/template/package.json +12 -10
- package/template/packages/backend/package.json +2 -2
- package/template/packages/backend/src/cleanup.ts +121 -0
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +33 -8
- package/template/packages/backend/src/surface.ts +6 -1
- package/template/packages/backend/src/transport/errors.ts +4 -2
- package/template/packages/config/package.json +6 -2
- package/template/packages/config/src/app-identity.generated.ts +20 -0
- package/template/packages/config/src/declaration.ts +30 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/shutdown.ts +20 -0
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/db/package.json +2 -2
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +13 -13
- package/template/packages/frontend/scripts/serve.ts +70 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
- package/template/packages/frontend/src/app/robots.ts +4 -2
- package/template/packages/frontend/src/app/sitemap.ts +7 -19
- package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
- package/template/packages/frontend/src/env.ts +27 -8
- package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
- package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
- package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
- package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
- package/template/packages/frontend/src/theme/config.ts +1 -1
- package/template/packages/frontend/tsconfig.json +10 -3
- package/template/packages/shared/package.json +1 -1
- package/template/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- package/template/scripts/acceptance-database.test.ts +73 -0
- package/template/scripts/acceptance-database.ts +92 -0
- package/template/scripts/acceptance-local.ts +144 -0
- package/template/scripts/build-inputs.test.ts +69 -0
- package/template/scripts/build-inputs.ts +58 -0
- package/template/scripts/build-stamp.test.ts +151 -0
- package/template/scripts/build-stamp.ts +169 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/client-boundary.test.ts +117 -0
- package/template/scripts/client-boundary.ts +148 -0
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +271 -0
- package/template/scripts/deployment-preflight.ts +41 -0
- package/template/scripts/dev.ts +43 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +9 -3
- package/template/scripts/readiness.ts +92 -0
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +112 -0
- package/template/scripts/release.ts +38 -0
- package/template/scripts/runtime-smoke.test.ts +178 -0
- package/template/scripts/runtime-smoke.ts +21 -5
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/shutdown-budget.fixture.ts +29 -0
- package/template/scripts/shutdown-budget.test.ts +164 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/surface-conformance.ts +8 -1
- package/template/scripts/tooling-env.ts +35 -3
- package/template/scripts/web-surface-smoke.ts +183 -2
- package/template/app.config.json +0 -9
- package/template/packages/config/src/identity.ts +0 -18
package/UPGRADING.md
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# Upgrading a generated project
|
|
2
|
+
|
|
3
|
+
How to move a project generated by `create-stitchkit` from one scaffolder
|
|
4
|
+
version to another. It is a different move from upgrading the framework: your
|
|
5
|
+
project is a **copy** of the template, not a dependency on it, so nothing here
|
|
6
|
+
happens by installing anything. What a new scaffolder version brings is a set of
|
|
7
|
+
edits you apply to a tree you already own, plus — and this is the part that bites
|
|
8
|
+
— **operator steps**: things a machine has to be told before the new shape will
|
|
9
|
+
start at all.
|
|
10
|
+
|
|
11
|
+
The framework's own guide is
|
|
12
|
+
[`docs/guide/upgrading.md`](../../docs/guide/upgrading.md); it covers the
|
|
13
|
+
`stitchkit` dependency. This one covers the generated project.
|
|
14
|
+
|
|
15
|
+
## The one rule that makes this work
|
|
16
|
+
|
|
17
|
+
A scaffolder release that changes the generated project in a way an existing
|
|
18
|
+
project must follow leads its [`CHANGELOG.md`](./CHANGELOG.md) entry with a
|
|
19
|
+
**`### ⚠️ Breaking changes`** section (exact heading), each item carrying a
|
|
20
|
+
**before → after** snippet. A version with **no** such section changes nothing
|
|
21
|
+
you are obliged to adopt.
|
|
22
|
+
|
|
23
|
+
The changelog says *what* changed. This file says what else has to happen —
|
|
24
|
+
including the steps that touch a running machine, which have no place in a
|
|
25
|
+
changelog because the next release overwrites the entry that carried them.
|
|
26
|
+
|
|
27
|
+
## Flow
|
|
28
|
+
|
|
29
|
+
1. **Find your project's origin.** The scaffolder version that generated it is
|
|
30
|
+
not recorded in the tree — check the changelog of the version you scaffolded
|
|
31
|
+
with, or diff your tree against a fresh scaffold of your current target.
|
|
32
|
+
2. **Read every `### ⚠️ Breaking changes` in range** in
|
|
33
|
+
[`CHANGELOG.md`](./CHANGELOG.md), from the version above yours up to your
|
|
34
|
+
target.
|
|
35
|
+
3. **Apply the code edits**, then the **`## Released migration: X.Y.Z`** section
|
|
36
|
+
below for the same versions — that is where the operator steps live.
|
|
37
|
+
|
|
38
|
+
> This channel starts at **0.4.0**. Versions below it have breaking changelog
|
|
39
|
+
> entries and no migration section here, because the file did not exist yet;
|
|
40
|
+
> for those, the changelog entry is all there is.
|
|
41
|
+
4. **Verify** with your project's own gates: `bun run check`, `bun run test`,
|
|
42
|
+
`bun run build`, then a real start.
|
|
43
|
+
|
|
44
|
+
## Where a migration section goes while the version has no number
|
|
45
|
+
|
|
46
|
+
Write it here as **`## Unreleased migration: <short slug>`**. The slug matters:
|
|
47
|
+
several may sit side by side, and each belongs to whoever wrote it. Do **not**
|
|
48
|
+
reuse another author's heading — that is how a migration gets overwritten before
|
|
49
|
+
anyone promotes it.
|
|
50
|
+
|
|
51
|
+
At release, the release commit promotes every `Unreleased migration` heading
|
|
52
|
+
into one `## Released migration: X.Y.Z`, each former heading becoming a `###`
|
|
53
|
+
subsection under it — the same move the changelog makes when `[Unreleased]`
|
|
54
|
+
becomes `## [X.Y.Z]`, in the same commit.
|
|
55
|
+
|
|
56
|
+
A release carrying `### ⚠️ Breaking changes` and no matching
|
|
57
|
+
`## Released migration: X.Y.Z` is refused by `bun scripts/release-plan.ts`, in
|
|
58
|
+
`pre-push` and again in the publishing workflow. The check starts at `0.4.0` —
|
|
59
|
+
the first scaffolder release with a migration channel of its own.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Released migration: 0.4.1
|
|
64
|
+
|
|
65
|
+
### a release refuses a stale artifact, and cleanup is bounded
|
|
66
|
+
|
|
67
|
+
Two changes an existing project should take, both about a shutdown or a start
|
|
68
|
+
that looked safe and was not.
|
|
69
|
+
|
|
70
|
+
1. **Stamp the build.** Copy `scripts/build-stamp.ts` from a fresh scaffold,
|
|
71
|
+
append `&& bun scripts/build-stamp.ts` to your root `build` script, add
|
|
72
|
+
`.build-stamp.json` to `.gitignore`, and call `assertArtifactMatchesSource()`
|
|
73
|
+
at the end of `assertBuildArtifacts()` in `scripts/release-steps.ts`. Until
|
|
74
|
+
you do, `bun run pm2:prod` without a build applies your migrations and starts
|
|
75
|
+
the previous build.
|
|
76
|
+
|
|
77
|
+
2. **Bound the cleanup, and let it end the process.** Copy
|
|
78
|
+
`packages/config/src/shutdown.ts` and `packages/backend/src/cleanup.ts`, add
|
|
79
|
+
the `./shutdown` export to `packages/config/package.json`, and replace the
|
|
80
|
+
bare `await mcp.close(); await prisma.$disconnect();` in your API role's
|
|
81
|
+
`onComplete` with `closeWithinBudget([...])` followed by
|
|
82
|
+
`concludeShutdown(cleanup, result.outcome === 'clean')`. Then have
|
|
83
|
+
`scripts/declaration.ts` import `FORCE_BUDGET_MS` and `CLEANUP_BUDGET_MS`
|
|
84
|
+
from the new module instead of declaring its own copies — the number a
|
|
85
|
+
supervisor is told to allow and the number the role enforces have to be one
|
|
86
|
+
number. `concludeShutdown` is the half that makes the budget real: setting
|
|
87
|
+
`process.exitCode` decides the code a process reports *when it exits*, and a
|
|
88
|
+
step that ran out of time is usually still holding the handle that stops it
|
|
89
|
+
from exiting at all.
|
|
90
|
+
|
|
91
|
+
No operator step: nothing about a running machine changes.
|
|
92
|
+
|
|
93
|
+
### the gate list runs, and never deploys
|
|
94
|
+
|
|
95
|
+
The `Gates` list in the generated guides presented five commands in a row, two
|
|
96
|
+
of which check a **running** deployment — with nothing in the list that starts
|
|
97
|
+
one. The fix is not to add a deploy command: `bun run pm2:prod` applies your
|
|
98
|
+
declared migrations and reloads the deployment you are running, so a list that
|
|
99
|
+
contains it means "run these before handing work off" quietly says "deploy".
|
|
100
|
+
|
|
101
|
+
1. **Take `pm2:prod` out of your gate list**, in `README.md` and `AGENTS.md`
|
|
102
|
+
alike, and delete any advice to run `pm2 delete all` — that empties whichever
|
|
103
|
+
daemon it is pointed at, including applications with nothing to do with this
|
|
104
|
+
project.
|
|
105
|
+
|
|
106
|
+
2. **Add the harness that brings up a deployment of its own.** Copy
|
|
107
|
+
`scripts/acceptance-local.ts` and `scripts/acceptance-database.ts` from a
|
|
108
|
+
fresh scaffold and add `"acceptance:local": "bun scripts/acceptance-local.ts"`
|
|
109
|
+
to your root scripts. It creates and destroys its own deployment — separate
|
|
110
|
+
`PM2_HOME`, ephemeral ports, its own public-host allowlist — and runs
|
|
111
|
+
`runtime:smoke` and `e2e` against that.
|
|
112
|
+
|
|
113
|
+
3. **Give it a database of its own.** Add `ACCEPTANCE_DATABASE_URL` to `.env`
|
|
114
|
+
and `.env.example`, naming a throwaway database — not the one `DATABASE_URL`
|
|
115
|
+
names. The runtime gates WRITE (the repository example's smoke posts a
|
|
116
|
+
refresh, and that upserts), so a harness borrowing `DATABASE_URL` writes rows
|
|
117
|
+
into whatever your `.env` points at. The harness refuses to start when the
|
|
118
|
+
variable is unset or names the same database, and prints the line to add.
|
|
119
|
+
|
|
120
|
+
4. **Wait for readiness before reporting it.** Copy `scripts/readiness.ts` from
|
|
121
|
+
a fresh scaffold, then in `scripts/dev.ts` and `scripts/release.ts` await
|
|
122
|
+
`awaitRolesAnswering(declaredRoleReadiness(appDeclaration, environment))`
|
|
123
|
+
before printing that the roles are running. Until you do, anything you run
|
|
124
|
+
after `bun run dev` or `bun run pm2:prod` races the roles they started.
|
|
125
|
+
|
|
126
|
+
5. **Let the smoke read your allowlist.** In `scripts/web-surface-smoke.ts` the
|
|
127
|
+
portability check named two hosts of its own; take the version that reads
|
|
128
|
+
`PUBLIC_WEB_HOSTS` and pass it from `scripts/runtime-smoke.ts`. If you kept
|
|
129
|
+
the old one, your `.env` must claim `alpha.example` and `beta.example:8443`
|
|
130
|
+
or the check fails on a host your deployment correctly refuses. With the new
|
|
131
|
+
one, `_env.example` drops those example hosts — the harness supplies its own.
|
|
132
|
+
|
|
133
|
+
6. **Keep the declaration out of the browser.**
|
|
134
|
+
`packages/frontend/src/lib/seo/pages.ts` must import `appIdentity` from
|
|
135
|
+
`@app/config/app-identity` rather than `appDeclaration`: a client component
|
|
136
|
+
reaches that module, so the whole declaration was going into your browser
|
|
137
|
+
bundle.
|
|
138
|
+
|
|
139
|
+
No operator step: nothing about a running machine changes.
|
|
140
|
+
|
|
141
|
+
### the declaration schema is imported
|
|
142
|
+
|
|
143
|
+
For a project generated **before** the scaffolder version that drops the schema
|
|
144
|
+
mirror. Nothing in your tree stops working if you skip this — the copy keeps
|
|
145
|
+
parsing. What you lose by skipping is the guarantee: your copy no longer moves
|
|
146
|
+
when the framework's schema does, and nothing tells you.
|
|
147
|
+
|
|
148
|
+
1. **Point the config package at the framework.** In
|
|
149
|
+
`packages/config/src/declaration.ts`:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// before
|
|
153
|
+
import { findProjectRole, parseProjectDeclaration } from './project-declaration.generated';
|
|
154
|
+
// after
|
|
155
|
+
import { findProjectRole, parseProjectDeclaration } from 'stitchkit/declaration';
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Then the same for every `import type { ProjectDeclaration, … }` in
|
|
159
|
+
`scripts/` — they pointed at the same file.
|
|
160
|
+
|
|
161
|
+
2. **Declare the dependency.** `packages/config/package.json` gains
|
|
162
|
+
`"stitchkit": "catalog:"`, and `bun install` refreshes the lockfile.
|
|
163
|
+
|
|
164
|
+
3. **Delete `packages/config/src/project-declaration.generated.ts`.**
|
|
165
|
+
|
|
166
|
+
4. **Check your `stitchkit` range.** The entrypoint ships from **0.60.0**. If
|
|
167
|
+
your catalog targets less than that, raise it first — otherwise the import
|
|
168
|
+
resolves to nothing.
|
|
169
|
+
|
|
170
|
+
5. **Read `.env` without the schema.** If your `scripts/local-env.ts` imports
|
|
171
|
+
`appDeclaration`, switch it to `appIdentity` from
|
|
172
|
+
`packages/config/src/app-identity.generated`. It needs only the slug, and a
|
|
173
|
+
project scaffolded with `--no-install` renders `.env` before anything is
|
|
174
|
+
installed — in that window the framework is not there to import.
|
|
175
|
+
|
|
176
|
+
No operator step: nothing about a running machine changes.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Released migration: 0.4.0
|
|
181
|
+
|
|
182
|
+
### the project declares itself
|
|
183
|
+
|
|
184
|
+
Everything in this section is for a project generated **before** the scaffolder
|
|
185
|
+
version that introduces `project.json`.
|
|
186
|
+
|
|
187
|
+
#### The declaration replaces `app.config.json`
|
|
188
|
+
|
|
189
|
+
`app.config.json` said who the project was. `project.json` says what it *is*:
|
|
190
|
+
identity, the roles it runs, what it builds, what it needs before it starts,
|
|
191
|
+
what must happen once on release, and the **names** of the variables a
|
|
192
|
+
deployment supplies. Identity moved under an `identity` key, and the file gained
|
|
193
|
+
a `schemaVersion` so a reader that does not understand the format refuses the
|
|
194
|
+
project instead of interpreting half of it.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// before
|
|
198
|
+
import { appIdentity } from '@app/config/identity';
|
|
199
|
+
appIdentity.name;
|
|
200
|
+
|
|
201
|
+
// after
|
|
202
|
+
import { appDeclaration } from '@app/config/declaration';
|
|
203
|
+
appDeclaration.identity.name;
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
A **client** component imports the generated identity-only module instead —
|
|
207
|
+
importing the whole declaration from the browser ships role commands, working
|
|
208
|
+
directories, artifact paths and the migration lockfile in the bundle:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// before, in a 'use client' file
|
|
212
|
+
import { appIdentity } from '@app/config/identity';
|
|
213
|
+
|
|
214
|
+
// after
|
|
215
|
+
import { appIdentity } from '@app/config/app-identity';
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
#### Operator step: delete the old supervisor processes FIRST
|
|
219
|
+
|
|
220
|
+
PM2 process names now follow the declared role names, so the new supervision
|
|
221
|
+
files start a **new pair beside the old one**. Under `autorestart` both then
|
|
222
|
+
fight for the same ports, and the symptom is an `EADDRINUSE` loop rather than a
|
|
223
|
+
clear error.
|
|
224
|
+
|
|
225
|
+
Before the first `bun run pm2:prod` on the new files:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
pm2 delete <slug>-backend <slug>-frontend <slug>-backend-dev <slug>-frontend-dev
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`<slug>` is your project's slug — `identity.slug` in `project.json`. Check what
|
|
232
|
+
is actually registered with `pm2 list` first; nothing here is safe to run blind.
|
|
233
|
+
|
|
234
|
+
#### Operator step: the supervisor's patience must cover the whole shutdown
|
|
235
|
+
|
|
236
|
+
The generated `ecosystem.config.cjs` is now rendered from the declaration, and
|
|
237
|
+
its `kill_timeout` is computed from each role's drain floor plus the force
|
|
238
|
+
window plus cleanup. If you kept a hand-edited supervision file, compare its
|
|
239
|
+
`kill_timeout` against `drainFloorMs` in `project.json`: a timeout shorter than
|
|
240
|
+
the full budget means the drain never finishes, every time, and the only visible
|
|
241
|
+
trace is a non-zero exit code.
|
|
242
|
+
|
|
243
|
+
Regenerate rather than hand-edit:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
bun run gen:declaration
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
#### Environment: three variables changed meaning
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
# before
|
|
253
|
+
NEXT_PUBLIC_API_URL=https://api.example
|
|
254
|
+
NEXT_PUBLIC_WEB_URL=https://app.example
|
|
255
|
+
|
|
256
|
+
# after — nothing. Both are gone.
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Anything prefixed `NEXT_PUBLIC_` is substituted at **build** time, so declaring
|
|
260
|
+
one froze an address into the artifact: the built `robots.txt` and `sitemap.xml`
|
|
261
|
+
carried one origin in their bytes and one build could not serve a second
|
|
262
|
+
address. The public origin now comes from the request.
|
|
263
|
+
|
|
264
|
+
A forwarded host must be claimed before it is believed. Set **one** of:
|
|
265
|
+
|
|
266
|
+
```sh
|
|
267
|
+
PUBLIC_WEB_ORIGIN=https://app.example # a single address
|
|
268
|
+
PUBLIC_WEB_HOSTS=app.example,www.app.example # several
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
A host outside them is refused. Setting `PUBLIC_WEB_ORIGIN` also restores static
|
|
272
|
+
rendering for `/robots.txt`, `/sitemap.xml` and the `[locale]` segment, which
|
|
273
|
+
otherwise become request-rendered.
|
|
274
|
+
|
|
275
|
+
The check and e2e addresses were renamed so no build can substitute them:
|
|
276
|
+
|
|
277
|
+
```sh
|
|
278
|
+
# before
|
|
279
|
+
NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
|
|
280
|
+
# after
|
|
281
|
+
SMOKE_API_ORIGIN=http://127.0.0.1:3211
|
|
282
|
+
SMOKE_WEB_ORIGIN=http://127.0.0.1:3210
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`CORS_ORIGIN` is now optional: a frontend that reaches the API through its own
|
|
286
|
+
routing layer makes same-origin requests, and requiring an origin there was
|
|
287
|
+
requiring knowledge of the place.
|
|
288
|
+
|
|
289
|
+
#### The repository example: calls lose their parentheses
|
|
290
|
+
|
|
291
|
+
Only for a project generated with `--example repository`. The browser now talks
|
|
292
|
+
to its own origin, so the API client is a module constant:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
// before
|
|
296
|
+
repositoryApi().read();
|
|
297
|
+
repositoryUrls().read();
|
|
298
|
+
|
|
299
|
+
// after
|
|
300
|
+
repositoryApi.read();
|
|
301
|
+
repositoryUrls.read();
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The web role forwards `/api/…` to the API role, which needs `INTERNAL_API_URL`
|
|
305
|
+
(already required).
|
|
306
|
+
|
|
307
|
+
**The socket's address is now its own variable.** If you use the realtime
|
|
308
|
+
socket and have no routing layer forwarding `/socket.io`, rename it:
|
|
309
|
+
|
|
310
|
+
```sh
|
|
311
|
+
# before — read only by the socket, despite the name
|
|
312
|
+
PUBLIC_API_ORIGIN=https://api.example
|
|
313
|
+
# after
|
|
314
|
+
PUBLIC_REALTIME_ORIGIN=https://api.example
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`PUBLIC_API_ORIGIN` still exists and still means HTTP, but it is inert until you
|
|
318
|
+
switch: the import in `packages/frontend/src/lib/api/queries.ts` decides which
|
|
319
|
+
client the browser uses. Both variables are optional; a deployment behind one
|
|
320
|
+
routing layer sets neither, and `CORS_ORIGIN` with them.
|
|
321
|
+
|
|
322
|
+
The cross-origin form moved to `packages/frontend/src/lib/api/cross-origin.ts`
|
|
323
|
+
(previously `lib/api/origin.ts`); `requirePublicApiOrigin` lives there, beside
|
|
324
|
+
`optionalRealtimeOrigin` and `setPublicOrigins`.
|
|
325
|
+
|
|
326
|
+
#### The SEO helpers are async
|
|
327
|
+
|
|
328
|
+
They read the public origin from the request, so they cannot be constants.
|
|
329
|
+
TypeScript will **not** catch a missing `await` inside an inferred object
|
|
330
|
+
literal — a `Promise` serialises there as `{}`.
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// before
|
|
334
|
+
const url = absoluteSiteUrl('/en');
|
|
335
|
+
createPageMetadata('home', locale);
|
|
336
|
+
|
|
337
|
+
// after — and the component becomes async
|
|
338
|
+
const url = await absoluteSiteUrl('/en');
|
|
339
|
+
await createPageMetadata('home', locale);
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`siteOrigin` is gone.
|
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,207 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
|
-
import {
|
|
5
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
6
|
+
import { basename as basename3, join as join2, resolve as resolve2 } from "path";
|
|
6
7
|
var {spawn } = globalThis.Bun;
|
|
7
8
|
|
|
9
|
+
// src/identity.ts
|
|
10
|
+
import { basename } from "path";
|
|
11
|
+
|
|
12
|
+
// ../core/src/declaration.ts
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var PROJECT_DECLARATION_SCHEMA_VERSION = 1;
|
|
15
|
+
var MACHINE_PATTERNS = [
|
|
16
|
+
[/:\/\//, "an absolute address"],
|
|
17
|
+
[/^\/\//, "a protocol-relative host"],
|
|
18
|
+
[/^[~]/, "a home-relative path"],
|
|
19
|
+
[/^[A-Za-z]:[\\/]/, "a Windows drive path"],
|
|
20
|
+
[/\\/, "a Windows path separator"],
|
|
21
|
+
[/(?:^|[\s=:])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])/, "an IP address"],
|
|
22
|
+
[/(?:^|[\s=])[A-Za-z][\w.-]*:\d{2,5}(?![\w.])/, "a host and port"]
|
|
23
|
+
];
|
|
24
|
+
function namesAMachine(value) {
|
|
25
|
+
for (const [pattern, reason] of MACHINE_PATTERNS) {
|
|
26
|
+
if (pattern.test(value))
|
|
27
|
+
return reason;
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
function refuseMachineNames(label) {
|
|
32
|
+
return (schema) => schema.refine((value) => namesAMachine(value) === undefined, {
|
|
33
|
+
error: (issue) => `${label} names a machine \u2014 ${namesAMachine(String(issue.input)) ?? "a value of the deployment"} is supplied by the deployment, not written in the code`
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
var ProjectSlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and single hyphens (for example: talk-control)");
|
|
37
|
+
var ProjectDescriptionSchema = z.record(refuseMachineNames("A locale tag")(z.string().min(1)), refuseMachineNames("A description")(z.string().trim().min(1))).refine((value) => Object.keys(value).length > 0, "Describe the project in at least one locale");
|
|
38
|
+
var ProjectIdentitySchema = z.object({
|
|
39
|
+
slug: ProjectSlugSchema,
|
|
40
|
+
name: refuseMachineNames("A project name")(z.string().trim().min(1).max(80)),
|
|
41
|
+
version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
|
|
42
|
+
description: ProjectDescriptionSchema
|
|
43
|
+
}).strict();
|
|
44
|
+
var RepositoryPathSchema = z.string().min(1).refine((value) => !value.startsWith("/"), "Use a path relative to the repository root").refine((value) => namesAMachine(value) === undefined, {
|
|
45
|
+
error: (issue) => `A path may not contain ${namesAMachine(String(issue.input)) ?? "a machine name"}`
|
|
46
|
+
}).refine((value) => !value.split("/").includes(".."), "A path may not climb out of the repository");
|
|
47
|
+
var BindingVariableSchema = z.string().regex(/^[A-Z][A-Z0-9_]*$/, "Name an environment variable, for example API_PORT");
|
|
48
|
+
var ProjectListenerSchema = z.object({
|
|
49
|
+
portVariable: BindingVariableSchema,
|
|
50
|
+
bindVariable: BindingVariableSchema,
|
|
51
|
+
readinessPath: refuseMachineNames("A readiness path")(z.string().startsWith("/", "Readiness is a path, for example /health"))
|
|
52
|
+
}).strict();
|
|
53
|
+
var ProjectRunModeSchema = z.enum(["development", "production"]);
|
|
54
|
+
var PROJECT_SCRIPT_LAUNCHERS = [
|
|
55
|
+
[/^(?:bun|npm|pnpm|yarn)$/, /^run$/],
|
|
56
|
+
[/^deno$/, /^task$/],
|
|
57
|
+
[/^(?:npx|bunx|pnpx)$/, null]
|
|
58
|
+
];
|
|
59
|
+
function launchesAScript(executable, firstArgument) {
|
|
60
|
+
for (const [runner, verb] of PROJECT_SCRIPT_LAUNCHERS) {
|
|
61
|
+
if (!runner.test(executable))
|
|
62
|
+
continue;
|
|
63
|
+
if (verb === null)
|
|
64
|
+
return true;
|
|
65
|
+
if (firstArgument !== undefined && verb.test(firstArgument))
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
var PORT_FLAG = /^(?:-p|-{1,2}(?:port|listen))$/i;
|
|
71
|
+
var CommandPartSchema = refuseMachineNames("A command part")(z.string().min(1)).refine((value) => !value.startsWith("/"), "A command part may not be an absolute path \u2014 paths are relative to the source").refine((value) => !/^[^=\s]+=/.test(value), "A command part may not carry an inline value \u2014 write the flag and its value as separate arguments, so the value is checked like every other one");
|
|
72
|
+
var CommandArgumentsSchema = z.array(CommandPartSchema).refine((args) => args.every((value, index) => !(/^\d{1,5}$/.test(value) && PORT_FLAG.test(args[index - 1] ?? ""))), "A number after a port flag is a port \u2014 name the variable that carries it and let the role read it");
|
|
73
|
+
var ProjectCommandSchema = z.object({
|
|
74
|
+
executable: CommandPartSchema,
|
|
75
|
+
args: CommandArgumentsSchema
|
|
76
|
+
}).strict();
|
|
77
|
+
var ProjectRoleCommandSchema = ProjectCommandSchema.refine((value) => !launchesAScript(value.executable, value.args[0]), "Start the role process itself, not a script runner: a launcher between the supervisor and the role duplicates the shutdown signal and forces the drain").refine((value) => !value.args.includes("--filter"), "A workspace filter puts a launcher between the supervisor and the role, and the shutdown signal never reaches it");
|
|
78
|
+
var ProjectRoleSchema = z.object({
|
|
79
|
+
name: ProjectSlugSchema,
|
|
80
|
+
workingDirectory: RepositoryPathSchema.optional(),
|
|
81
|
+
commands: z.record(ProjectRunModeSchema, ProjectRoleCommandSchema),
|
|
82
|
+
listener: ProjectListenerSchema.optional(),
|
|
83
|
+
drainFloorMs: z.number().int().nonnegative()
|
|
84
|
+
}).strict();
|
|
85
|
+
var ProjectBuildInputSchema = z.object({
|
|
86
|
+
name: ProjectSlugSchema,
|
|
87
|
+
path: RepositoryPathSchema,
|
|
88
|
+
digest: z.string().regex(/^sha256:[0-9a-f]{64}$/, 'Use a lowercase sha256 digest, as "sha256:<64 hex>"')
|
|
89
|
+
}).strict();
|
|
90
|
+
var ProjectBuildSchema = z.object({
|
|
91
|
+
command: ProjectCommandSchema,
|
|
92
|
+
artifacts: z.array(RepositoryPathSchema).min(1),
|
|
93
|
+
inputs: z.array(ProjectBuildInputSchema).optional()
|
|
94
|
+
}).strict().refine((build) => {
|
|
95
|
+
const names = (build.inputs ?? []).map((input) => input.name);
|
|
96
|
+
return new Set(names).size === names.length;
|
|
97
|
+
}, "Two build inputs share a name \u2014 a failure could then name either of them");
|
|
98
|
+
var ProjectRequirementPhaseSchema = z.enum(["release", "start"]);
|
|
99
|
+
var ProjectRequirementSchema = z.object({
|
|
100
|
+
name: ProjectSlugSchema,
|
|
101
|
+
phases: z.array(ProjectRequirementPhaseSchema).min(1)
|
|
102
|
+
}).strict();
|
|
103
|
+
var ProjectMigrationsSchema = z.object({
|
|
104
|
+
engine: refuseMachineNames("A migration engine name")(z.string().min(1).max(64)),
|
|
105
|
+
root: RepositoryPathSchema,
|
|
106
|
+
lockfile: RepositoryPathSchema
|
|
107
|
+
}).strict();
|
|
108
|
+
var ProjectReleaseSchema = z.object({ migrations: ProjectMigrationsSchema.optional() }).strict();
|
|
109
|
+
var ProjectEnvShapeSchema = z.enum(["string", "integer", "boolean", "url", "enum"]);
|
|
110
|
+
var ProjectEnvVariableSchema = z.object({
|
|
111
|
+
name: BindingVariableSchema,
|
|
112
|
+
shape: ProjectEnvShapeSchema,
|
|
113
|
+
required: z.boolean(),
|
|
114
|
+
members: z.array(refuseMachineNames("An enum member")(z.string().min(1))).min(1).optional()
|
|
115
|
+
}).strict().refine((value) => value.shape === "enum" === (value.members !== undefined), "An enum variable lists its members; every other shape has none");
|
|
116
|
+
var ProjectDeclarationSchema = z.object({
|
|
117
|
+
schemaVersion: z.literal(PROJECT_DECLARATION_SCHEMA_VERSION),
|
|
118
|
+
kind: z.enum(["library", "application"]),
|
|
119
|
+
identity: ProjectIdentitySchema,
|
|
120
|
+
roles: z.array(ProjectRoleSchema),
|
|
121
|
+
build: ProjectBuildSchema.optional(),
|
|
122
|
+
requires: z.array(ProjectRequirementSchema),
|
|
123
|
+
release: ProjectReleaseSchema,
|
|
124
|
+
env: z.object({ variables: z.array(ProjectEnvVariableSchema) }).strict()
|
|
125
|
+
}).strict().refine((value) => value.kind === "application" ? value.roles.length > 0 : value.roles.length === 0, "An application declares at least one role; a library declares none").refine((value) => new Set(value.roles.map((role) => role.name)).size === value.roles.length, "Role names must be unique").refine((value) => new Set(value.requires.map((entry) => entry.name)).size === value.requires.length, "Name each requirement once and list its phases").refine((value) => value.requires.every((entry) => new Set(entry.phases).size === entry.phases.length), "List each phase of a requirement once").refine((value) => new Set(value.env.variables.map((entry) => entry.name)).size === value.env.variables.length, "Declare each environment variable once").refine((value) => listenerBindingProblem(value) === undefined, {
|
|
126
|
+
error: (issue) => listenerBindingProblem(issue.input) ?? "Listener bindings are inconsistent"
|
|
127
|
+
});
|
|
128
|
+
function isListenerBindingSubject(value) {
|
|
129
|
+
return typeof value === "object" && value !== null && "roles" in value && "env" in value;
|
|
130
|
+
}
|
|
131
|
+
function listenerBindingProblem(value) {
|
|
132
|
+
if (!isListenerBindingSubject(value))
|
|
133
|
+
return;
|
|
134
|
+
const shapes = new Map(value.env.variables.map((entry) => [entry.name, entry.shape]));
|
|
135
|
+
for (const role of value.roles) {
|
|
136
|
+
const listener = role.listener;
|
|
137
|
+
if (!listener)
|
|
138
|
+
continue;
|
|
139
|
+
if (listener.portVariable === listener.bindVariable) {
|
|
140
|
+
return `Role "${role.name}" points its port and its bind address at the same variable "${listener.portVariable}"`;
|
|
141
|
+
}
|
|
142
|
+
const expected = [
|
|
143
|
+
[listener.portVariable, "integer"],
|
|
144
|
+
[listener.bindVariable, "string"]
|
|
145
|
+
];
|
|
146
|
+
for (const [name, shape] of expected) {
|
|
147
|
+
const declared = shapes.get(name);
|
|
148
|
+
if (declared === undefined) {
|
|
149
|
+
return `Role "${role.name}" listens on "${name}", which env.variables does not declare \u2014 a deployment reading this cannot know it has to supply it`;
|
|
150
|
+
}
|
|
151
|
+
if (declared !== shape) {
|
|
152
|
+
return `Role "${role.name}" listens on "${name}", declared as "${declared}" where a ${shape} is needed`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
var VersionProbeSchema = z.object({ schemaVersion: z.unknown() }).loose();
|
|
159
|
+
function parseProjectDeclaration(source) {
|
|
160
|
+
const probe = VersionProbeSchema.safeParse(source);
|
|
161
|
+
const declared = probe.success ? probe.data.schemaVersion : undefined;
|
|
162
|
+
if (declared !== undefined && declared !== PROJECT_DECLARATION_SCHEMA_VERSION) {
|
|
163
|
+
throw new Error(`Project declaration schema version ${JSON.stringify(declared)} is not supported \u2014 ` + `this build understands version ${PROJECT_DECLARATION_SCHEMA_VERSION}.`);
|
|
164
|
+
}
|
|
165
|
+
return ProjectDeclarationSchema.parse(source);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/identity.ts
|
|
169
|
+
var APP_IDENTITY_PATH = "packages/config/src/app-identity.generated.ts";
|
|
170
|
+
function renderAppIdentityModule(identity) {
|
|
171
|
+
return `// GENERATED FILE \u2014 do not edit.
|
|
172
|
+
//
|
|
173
|
+
// Rendered from \`project.json\` by \`scripts/declaration.ts\`.
|
|
174
|
+
//
|
|
175
|
+
// Identity ONLY, inlined rather than imported, because this is the part of the
|
|
176
|
+
// declaration a browser may know. Importing the whole declaration from a client
|
|
177
|
+
// component would put role commands, working directories, build artifact paths,
|
|
178
|
+
// the migration lockfile and every environment variable name into the browser
|
|
179
|
+
// bundle \u2014 the same mistake as publishing internal topology from a status
|
|
180
|
+
// endpoint, made from the other side.
|
|
181
|
+
|
|
182
|
+
export const appIdentity = ${JSON.stringify(identity, undefined, 2)};
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
function displayNameFromSlug(slug) {
|
|
186
|
+
return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
|
|
187
|
+
}
|
|
188
|
+
function createApplicationIdentity(destination, displayName) {
|
|
189
|
+
const slug = ProjectSlugSchema.parse(basename(destination));
|
|
190
|
+
const name = displayName?.trim() || displayNameFromSlug(slug);
|
|
191
|
+
return ProjectIdentitySchema.parse({
|
|
192
|
+
slug,
|
|
193
|
+
name,
|
|
194
|
+
version: "0.1.0",
|
|
195
|
+
description: {
|
|
196
|
+
en: `${name} is a production application built with Stitchkit.`,
|
|
197
|
+
ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function withIdentity(declaration, identity) {
|
|
202
|
+
const copied = parseProjectDeclaration(declaration);
|
|
203
|
+
return parseProjectDeclaration({ ...copied, identity });
|
|
204
|
+
}
|
|
205
|
+
|
|
8
206
|
// src/options.ts
|
|
9
207
|
var HELP = `Create a production-shaped Stitchkit application.
|
|
10
208
|
|
|
@@ -62,38 +260,6 @@ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
|
62
260
|
import { homedir } from "os";
|
|
63
261
|
import { basename as basename2, dirname, extname, join, parse, relative, resolve, sep } from "path";
|
|
64
262
|
import { z as z2 } from "zod";
|
|
65
|
-
|
|
66
|
-
// src/identity.ts
|
|
67
|
-
import { basename } from "path";
|
|
68
|
-
import { z } from "zod";
|
|
69
|
-
var ApplicationSlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and single hyphens (for example: talk-control)");
|
|
70
|
-
var ApplicationIdentitySchema = z.object({
|
|
71
|
-
slug: ApplicationSlugSchema,
|
|
72
|
-
name: z.string().trim().min(1).max(80),
|
|
73
|
-
version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
|
|
74
|
-
description: z.object({
|
|
75
|
-
en: z.string().trim().min(1),
|
|
76
|
-
ru: z.string().trim().min(1)
|
|
77
|
-
})
|
|
78
|
-
});
|
|
79
|
-
function displayNameFromSlug(slug) {
|
|
80
|
-
return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
|
|
81
|
-
}
|
|
82
|
-
function createApplicationIdentity(destination, displayName) {
|
|
83
|
-
const slug = ApplicationSlugSchema.parse(basename(destination));
|
|
84
|
-
const name = displayName?.trim() || displayNameFromSlug(slug);
|
|
85
|
-
return ApplicationIdentitySchema.parse({
|
|
86
|
-
slug,
|
|
87
|
-
name,
|
|
88
|
-
version: "0.1.0",
|
|
89
|
-
description: {
|
|
90
|
-
en: `${name} is a production application built with Stitchkit.`,
|
|
91
|
-
ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// src/scaffold.ts
|
|
97
263
|
var TEXT_EXTENSIONS = new Set([
|
|
98
264
|
".cjs",
|
|
99
265
|
".css",
|
|
@@ -126,6 +292,8 @@ var IGNORED_DIRECTORIES = new Set([
|
|
|
126
292
|
"playwright-report",
|
|
127
293
|
"test-results"
|
|
128
294
|
]);
|
|
295
|
+
var IGNORED_FILE_NAMES = new Set([".env", ".build-stamp.json", "next-env.d.ts"]);
|
|
296
|
+
var IGNORED_FILE_SUFFIXES = [".log", ".tsbuildinfo"];
|
|
129
297
|
function isTemplateSourcePathIncluded(sourcePath) {
|
|
130
298
|
const normalized = sourcePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
131
299
|
if (!normalized)
|
|
@@ -136,7 +304,9 @@ function isTemplateSourcePathIncluded(sourcePath) {
|
|
|
136
304
|
if (normalized === "packages/db/src/generated" || normalized.startsWith("packages/db/src/generated/"))
|
|
137
305
|
return false;
|
|
138
306
|
const name = basename2(normalized);
|
|
139
|
-
|
|
307
|
+
if (IGNORED_FILE_NAMES.has(name))
|
|
308
|
+
return false;
|
|
309
|
+
return !IGNORED_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
140
310
|
}
|
|
141
311
|
function shouldIncludeTemplatePath(templateDirectory, sourcePath) {
|
|
142
312
|
return isTemplateSourcePathIncluded(relative(templateDirectory, sourcePath));
|
|
@@ -231,8 +401,13 @@ async function scaffoldProject(templateDirectory, destination, options = {}) {
|
|
|
231
401
|
if (options.overlayDirectory) {
|
|
232
402
|
await writeMaterialisedFiles(resolvedDestination, await materialiseTemplateFiles(options.overlayDirectory));
|
|
233
403
|
}
|
|
234
|
-
|
|
404
|
+
const declarationPath = join(resolvedDestination, "project.json");
|
|
405
|
+
const declaration = withIdentity(JSON.parse(await readFile(declarationPath, "utf8")), identity);
|
|
406
|
+
await writeFile(declarationPath, `${JSON.stringify(declaration, undefined, 2)}
|
|
235
407
|
`);
|
|
408
|
+
const identityPath = join(resolvedDestination, APP_IDENTITY_PATH);
|
|
409
|
+
await mkdir(dirname(identityPath), { recursive: true });
|
|
410
|
+
await writeFile(identityPath, renderAppIdentityModule(declaration.identity));
|
|
236
411
|
const manifestPath = join(resolvedDestination, "package.json");
|
|
237
412
|
const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
|
|
238
413
|
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: identity.slug }, undefined, 2)}
|
|
@@ -284,14 +459,10 @@ Created ${options.displayName ?? basename3(destination)}${mode}
|
|
|
284
459
|
process.stdout.write(` bun run dev
|
|
285
460
|
|
|
286
461
|
`);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
process.stdout.write(`API: http://localhost:3211
|
|
290
|
-
`);
|
|
291
|
-
process.stdout.write(`MCP: http://localhost:3211/mcp
|
|
292
|
-
`);
|
|
293
|
-
process.stdout.write(`OpenAPI: http://localhost:3211/openapi.json
|
|
462
|
+
for (const line of await roleAddresses(destination)) {
|
|
463
|
+
process.stdout.write(`${line}
|
|
294
464
|
`);
|
|
465
|
+
}
|
|
295
466
|
return 0;
|
|
296
467
|
} catch (error) {
|
|
297
468
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -303,6 +474,31 @@ Created ${options.displayName ?? basename3(destination)}${mode}
|
|
|
303
474
|
if (import.meta.main) {
|
|
304
475
|
process.exitCode = await run(Bun.argv.slice(2));
|
|
305
476
|
}
|
|
477
|
+
async function roleAddresses(destination) {
|
|
478
|
+
try {
|
|
479
|
+
const declaration = parseProjectDeclaration(JSON.parse(await readFile2(join2(destination, "project.json"), "utf8")));
|
|
480
|
+
const environment = readEnvironmentExample(await readFile2(join2(destination, ".env.example"), "utf8"));
|
|
481
|
+
const host = environment.BIND_HOST ?? "127.0.0.1";
|
|
482
|
+
return declaration.roles.flatMap((role) => {
|
|
483
|
+
const port = role.listener && environment[role.listener.portVariable];
|
|
484
|
+
if (!role.listener || !port)
|
|
485
|
+
return [];
|
|
486
|
+
return [`${role.name}: http://${host}:${port}${role.listener.readinessPath}`];
|
|
487
|
+
});
|
|
488
|
+
} catch {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function readEnvironmentExample(source) {
|
|
493
|
+
const values = {};
|
|
494
|
+
for (const line of source.split(`
|
|
495
|
+
`)) {
|
|
496
|
+
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
|
|
497
|
+
if (match?.[1])
|
|
498
|
+
values[match[1]] = match[2] ?? "";
|
|
499
|
+
}
|
|
500
|
+
return values;
|
|
501
|
+
}
|
|
306
502
|
export {
|
|
307
503
|
run
|
|
308
504
|
};
|