@endora-commerce/cli 0.100.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/LICENSE +21 -0
- package/README.md +37 -0
- package/dist/bin/endora.d.ts +28 -0
- package/dist/bin/endora.d.ts.map +1 -0
- package/dist/bin/endora.js +926 -0
- package/dist/bin/endora.js.map +1 -0
- package/dist/check/estate.d.ts +189 -0
- package/dist/check/estate.d.ts.map +1 -0
- package/dist/check/estate.js +1037 -0
- package/dist/check/estate.js.map +1 -0
- package/dist/check/hosts.d.ts +25 -0
- package/dist/check/hosts.d.ts.map +1 -0
- package/dist/check/hosts.js +1347 -0
- package/dist/check/hosts.js.map +1 -0
- package/dist/check/index.d.ts +60 -0
- package/dist/check/index.d.ts.map +1 -0
- package/dist/check/index.js +111 -0
- package/dist/check/index.js.map +1 -0
- package/dist/check/layout.d.ts +136 -0
- package/dist/check/layout.d.ts.map +1 -0
- package/dist/check/layout.js +262 -0
- package/dist/check/layout.js.map +1 -0
- package/dist/check/ledger.d.ts +98 -0
- package/dist/check/ledger.d.ts.map +1 -0
- package/dist/check/ledger.js +173 -0
- package/dist/check/ledger.js.map +1 -0
- package/dist/check/peer-owners.d.ts +116 -0
- package/dist/check/peer-owners.d.ts.map +1 -0
- package/dist/check/peer-owners.js +225 -0
- package/dist/check/peer-owners.js.map +1 -0
- package/dist/check/report.d.ts +33 -0
- package/dist/check/report.d.ts.map +1 -0
- package/dist/check/report.js +107 -0
- package/dist/check/report.js.map +1 -0
- package/dist/check/run.d.ts +147 -0
- package/dist/check/run.d.ts.map +1 -0
- package/dist/check/run.js +111 -0
- package/dist/check/run.js.map +1 -0
- package/dist/checks.d.ts +17 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +17 -0
- package/dist/checks.js.map +1 -0
- package/dist/dev/index.d.ts +83 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +298 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/generate/divergence.d.ts +38 -0
- package/dist/generate/divergence.d.ts.map +1 -0
- package/dist/generate/divergence.js +237 -0
- package/dist/generate/divergence.js.map +1 -0
- package/dist/generate/index.d.ts +90 -0
- package/dist/generate/index.d.ts.map +1 -0
- package/dist/generate/index.js +369 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/inputs/declaration.d.ts +18 -0
- package/dist/inputs/declaration.d.ts.map +1 -0
- package/dist/inputs/declaration.js +64 -0
- package/dist/inputs/declaration.js.map +1 -0
- package/dist/inputs/env-file.d.ts +73 -0
- package/dist/inputs/env-file.d.ts.map +1 -0
- package/dist/inputs/env-file.js +134 -0
- package/dist/inputs/env-file.js.map +1 -0
- package/dist/inputs/prompt.d.ts +21 -0
- package/dist/inputs/prompt.d.ts.map +1 -0
- package/dist/inputs/prompt.js +59 -0
- package/dist/inputs/prompt.js.map +1 -0
- package/dist/inputs/resolve.d.ts +163 -0
- package/dist/inputs/resolve.d.ts.map +1 -0
- package/dist/inputs/resolve.js +290 -0
- package/dist/inputs/resolve.js.map +1 -0
- package/dist/install/host.d.ts +27 -0
- package/dist/install/host.d.ts.map +1 -0
- package/dist/install/host.js +90 -0
- package/dist/install/host.js.map +1 -0
- package/dist/install/index.d.ts +173 -0
- package/dist/install/index.d.ts.map +1 -0
- package/dist/install/index.js +793 -0
- package/dist/install/index.js.map +1 -0
- package/dist/install/wizard.d.ts +144 -0
- package/dist/install/wizard.d.ts.map +1 -0
- package/dist/install/wizard.js +362 -0
- package/dist/install/wizard.js.map +1 -0
- package/dist/lib/admin-artefacts.d.ts +70 -0
- package/dist/lib/admin-artefacts.d.ts.map +1 -0
- package/dist/lib/admin-artefacts.js +354 -0
- package/dist/lib/admin-artefacts.js.map +1 -0
- package/dist/lib/admin-surfaces.d.ts +298 -0
- package/dist/lib/admin-surfaces.d.ts.map +1 -0
- package/dist/lib/admin-surfaces.js +669 -0
- package/dist/lib/admin-surfaces.js.map +1 -0
- package/dist/lib/delegated-composer.d.ts +85 -0
- package/dist/lib/delegated-composer.d.ts.map +1 -0
- package/dist/lib/delegated-composer.js +241 -0
- package/dist/lib/delegated-composer.js.map +1 -0
- package/dist/lib/divergence-artefacts.d.ts +305 -0
- package/dist/lib/divergence-artefacts.d.ts.map +1 -0
- package/dist/lib/divergence-artefacts.js +828 -0
- package/dist/lib/divergence-artefacts.js.map +1 -0
- package/dist/lib/divergence.d.ts +337 -0
- package/dist/lib/divergence.d.ts.map +1 -0
- package/dist/lib/divergence.js +1005 -0
- package/dist/lib/divergence.js.map +1 -0
- package/dist/lib/docs-artefacts.d.ts +395 -0
- package/dist/lib/docs-artefacts.d.ts.map +1 -0
- package/dist/lib/docs-artefacts.js +781 -0
- package/dist/lib/docs-artefacts.js.map +1 -0
- package/dist/lib/emitted-exports.d.ts +21 -0
- package/dist/lib/emitted-exports.d.ts.map +1 -0
- package/dist/lib/emitted-exports.js +96 -0
- package/dist/lib/emitted-exports.js.map +1 -0
- package/dist/lib/emitted-freshness.d.ts +112 -0
- package/dist/lib/emitted-freshness.d.ts.map +1 -0
- package/dist/lib/emitted-freshness.js +288 -0
- package/dist/lib/emitted-freshness.js.map +1 -0
- package/dist/lib/entity-index-artefact.d.ts +95 -0
- package/dist/lib/entity-index-artefact.d.ts.map +1 -0
- package/dist/lib/entity-index-artefact.js +210 -0
- package/dist/lib/entity-index-artefact.js.map +1 -0
- package/dist/lib/instance-build-inputs.d.ts +108 -0
- package/dist/lib/instance-build-inputs.d.ts.map +1 -0
- package/dist/lib/instance-build-inputs.js +165 -0
- package/dist/lib/instance-build-inputs.js.map +1 -0
- package/dist/lib/module-docs.d.ts +473 -0
- package/dist/lib/module-docs.d.ts.map +1 -0
- package/dist/lib/module-docs.js +711 -0
- package/dist/lib/module-docs.js.map +1 -0
- package/dist/lib/module-package-subpaths.d.ts +56 -0
- package/dist/lib/module-package-subpaths.d.ts.map +1 -0
- package/dist/lib/module-package-subpaths.js +223 -0
- package/dist/lib/module-package-subpaths.js.map +1 -0
- package/dist/lib/module-packages.d.ts +200 -0
- package/dist/lib/module-packages.d.ts.map +1 -0
- package/dist/lib/module-packages.js +580 -0
- package/dist/lib/module-packages.js.map +1 -0
- package/dist/lib/module-population.d.ts +129 -0
- package/dist/lib/module-population.d.ts.map +1 -0
- package/dist/lib/module-population.js +172 -0
- package/dist/lib/module-population.js.map +1 -0
- package/dist/lib/module-roots.d.ts +337 -0
- package/dist/lib/module-roots.d.ts.map +1 -0
- package/dist/lib/module-roots.js +586 -0
- package/dist/lib/module-roots.js.map +1 -0
- package/dist/lib/nested-checkouts.d.ts +33 -0
- package/dist/lib/nested-checkouts.d.ts.map +1 -0
- package/dist/lib/nested-checkouts.js +160 -0
- package/dist/lib/nested-checkouts.js.map +1 -0
- package/dist/lib/platform-root.d.ts +43 -0
- package/dist/lib/platform-root.d.ts.map +1 -0
- package/dist/lib/platform-root.js +134 -0
- package/dist/lib/platform-root.js.map +1 -0
- package/dist/lib/platform-surface.d.ts +235 -0
- package/dist/lib/platform-surface.d.ts.map +1 -0
- package/dist/lib/platform-surface.js +393 -0
- package/dist/lib/platform-surface.js.map +1 -0
- package/dist/lib/port-registrations.d.ts +223 -0
- package/dist/lib/port-registrations.d.ts.map +1 -0
- package/dist/lib/port-registrations.js +532 -0
- package/dist/lib/port-registrations.js.map +1 -0
- package/dist/lib/read-size.d.ts +154 -0
- package/dist/lib/read-size.d.ts.map +1 -0
- package/dist/lib/read-size.js +182 -0
- package/dist/lib/read-size.js.map +1 -0
- package/dist/lib/registration-owners.d.ts +79 -0
- package/dist/lib/registration-owners.d.ts.map +1 -0
- package/dist/lib/registration-owners.js +77 -0
- package/dist/lib/registration-owners.js.map +1 -0
- package/dist/lib/release-index.d.ts +53 -0
- package/dist/lib/release-index.d.ts.map +1 -0
- package/dist/lib/release-index.js +162 -0
- package/dist/lib/release-index.js.map +1 -0
- package/dist/lib/repeating-timers.d.ts +79 -0
- package/dist/lib/repeating-timers.d.ts.map +1 -0
- package/dist/lib/repeating-timers.js +189 -0
- package/dist/lib/repeating-timers.js.map +1 -0
- package/dist/lib/source-text.d.ts +34 -0
- package/dist/lib/source-text.d.ts.map +1 -0
- package/dist/lib/source-text.js +80 -0
- package/dist/lib/source-text.js.map +1 -0
- package/dist/lib/specifiers.d.ts +20 -0
- package/dist/lib/specifiers.d.ts.map +1 -0
- package/dist/lib/specifiers.js +130 -0
- package/dist/lib/specifiers.js.map +1 -0
- package/dist/lib/sql-tables.d.ts +166 -0
- package/dist/lib/sql-tables.d.ts.map +1 -0
- package/dist/lib/sql-tables.js +464 -0
- package/dist/lib/sql-tables.js.map +1 -0
- package/dist/lib/switchable-modules.d.ts +54 -0
- package/dist/lib/switchable-modules.d.ts.map +1 -0
- package/dist/lib/switchable-modules.js +104 -0
- package/dist/lib/switchable-modules.js.map +1 -0
- package/dist/lib/tailwind-sources.d.ts +136 -0
- package/dist/lib/tailwind-sources.d.ts.map +1 -0
- package/dist/lib/tailwind-sources.js +307 -0
- package/dist/lib/tailwind-sources.js.map +1 -0
- package/dist/lib/ui-layer.d.ts +54 -0
- package/dist/lib/ui-layer.d.ts.map +1 -0
- package/dist/lib/ui-layer.js +57 -0
- package/dist/lib/ui-layer.js.map +1 -0
- package/dist/lib/workspace-packages.d.ts +186 -0
- package/dist/lib/workspace-packages.d.ts.map +1 -0
- package/dist/lib/workspace-packages.js +351 -0
- package/dist/lib/workspace-packages.js.map +1 -0
- package/dist/new-instance/deploy.d.ts +211 -0
- package/dist/new-instance/deploy.d.ts.map +1 -0
- package/dist/new-instance/deploy.js +1381 -0
- package/dist/new-instance/deploy.js.map +1 -0
- package/dist/new-instance/docs-toolchain.d.ts +66 -0
- package/dist/new-instance/docs-toolchain.d.ts.map +1 -0
- package/dist/new-instance/docs-toolchain.js +69 -0
- package/dist/new-instance/docs-toolchain.js.map +1 -0
- package/dist/new-instance/host.d.ts +124 -0
- package/dist/new-instance/host.d.ts.map +1 -0
- package/dist/new-instance/host.js +276 -0
- package/dist/new-instance/host.js.map +1 -0
- package/dist/new-instance/index.d.ts +118 -0
- package/dist/new-instance/index.d.ts.map +1 -0
- package/dist/new-instance/index.js +567 -0
- package/dist/new-instance/index.js.map +1 -0
- package/dist/new-instance/modules.d.ts +188 -0
- package/dist/new-instance/modules.d.ts.map +1 -0
- package/dist/new-instance/modules.js +392 -0
- package/dist/new-instance/modules.js.map +1 -0
- package/dist/new-instance/template.d.ts +505 -0
- package/dist/new-instance/template.d.ts.map +1 -0
- package/dist/new-instance/template.js +1886 -0
- package/dist/new-instance/template.js.map +1 -0
- package/dist/new-module/emit.d.ts +67 -0
- package/dist/new-module/emit.d.ts.map +1 -0
- package/dist/new-module/emit.js +1393 -0
- package/dist/new-module/emit.js.map +1 -0
- package/dist/new-module/host.d.ts +67 -0
- package/dist/new-module/host.d.ts.map +1 -0
- package/dist/new-module/host.js +224 -0
- package/dist/new-module/host.js.map +1 -0
- package/dist/new-module/index.d.ts +32 -0
- package/dist/new-module/index.d.ts.map +1 -0
- package/dist/new-module/index.js +193 -0
- package/dist/new-module/index.js.map +1 -0
- package/dist/new-module/spec.d.ts +188 -0
- package/dist/new-module/spec.d.ts.map +1 -0
- package/dist/new-module/spec.js +404 -0
- package/dist/new-module/spec.js.map +1 -0
- package/dist/new-module/text.d.ts +15 -0
- package/dist/new-module/text.d.ts.map +1 -0
- package/dist/new-module/text.js +22 -0
- package/dist/new-module/text.js.map +1 -0
- package/dist/new-storefront/dockerfile.d.ts +20 -0
- package/dist/new-storefront/dockerfile.d.ts.map +1 -0
- package/dist/new-storefront/dockerfile.js +131 -0
- package/dist/new-storefront/dockerfile.js.map +1 -0
- package/dist/new-storefront/gitignore.d.ts +23 -0
- package/dist/new-storefront/gitignore.d.ts.map +1 -0
- package/dist/new-storefront/gitignore.js +39 -0
- package/dist/new-storefront/gitignore.js.map +1 -0
- package/dist/new-storefront/index.d.ts +93 -0
- package/dist/new-storefront/index.d.ts.map +1 -0
- package/dist/new-storefront/index.js +329 -0
- package/dist/new-storefront/index.js.map +1 -0
- package/dist/new-storefront/npmrc.d.ts +98 -0
- package/dist/new-storefront/npmrc.d.ts.map +1 -0
- package/dist/new-storefront/npmrc.js +217 -0
- package/dist/new-storefront/npmrc.js.map +1 -0
- package/dist/new-storefront/reference.d.ts +189 -0
- package/dist/new-storefront/reference.d.ts.map +1 -0
- package/dist/new-storefront/reference.js +430 -0
- package/dist/new-storefront/reference.js.map +1 -0
- package/dist/new-storefront/rewrite.d.ts +171 -0
- package/dist/new-storefront/rewrite.d.ts.map +1 -0
- package/dist/new-storefront/rewrite.js +701 -0
- package/dist/new-storefront/rewrite.js.map +1 -0
- package/dist/release-index.json +284 -0
- package/dist/rules/action-route-permissions.d.ts +141 -0
- package/dist/rules/action-route-permissions.d.ts.map +1 -0
- package/dist/rules/action-route-permissions.js +556 -0
- package/dist/rules/action-route-permissions.js.map +1 -0
- package/dist/rules/bundle-pairing.d.ts +74 -0
- package/dist/rules/bundle-pairing.d.ts.map +1 -0
- package/dist/rules/bundle-pairing.js +281 -0
- package/dist/rules/bundle-pairing.js.map +1 -0
- package/dist/rules/channel-resolution.d.ts +17 -0
- package/dist/rules/channel-resolution.d.ts.map +1 -0
- package/dist/rules/channel-resolution.js +382 -0
- package/dist/rules/channel-resolution.js.map +1 -0
- package/dist/rules/command-coverage.d.ts +210 -0
- package/dist/rules/command-coverage.d.ts.map +1 -0
- package/dist/rules/command-coverage.js +714 -0
- package/dist/rules/command-coverage.js.map +1 -0
- package/dist/rules/container-imports.d.ts +60 -0
- package/dist/rules/container-imports.d.ts.map +1 -0
- package/dist/rules/container-imports.js +158 -0
- package/dist/rules/container-imports.js.map +1 -0
- package/dist/rules/default-language-prose.d.ts +212 -0
- package/dist/rules/default-language-prose.d.ts.map +1 -0
- package/dist/rules/default-language-prose.js +710 -0
- package/dist/rules/default-language-prose.js.map +1 -0
- package/dist/rules/diacritic-folds.d.ts +238 -0
- package/dist/rules/diacritic-folds.d.ts.map +1 -0
- package/dist/rules/diacritic-folds.js +681 -0
- package/dist/rules/diacritic-folds.js.map +1 -0
- package/dist/rules/entity-tenant-classification.d.ts +171 -0
- package/dist/rules/entity-tenant-classification.d.ts.map +1 -0
- package/dist/rules/entity-tenant-classification.js +323 -0
- package/dist/rules/entity-tenant-classification.js.map +1 -0
- package/dist/rules/entry-presence.d.ts +142 -0
- package/dist/rules/entry-presence.d.ts.map +1 -0
- package/dist/rules/entry-presence.js +339 -0
- package/dist/rules/entry-presence.js.map +1 -0
- package/dist/rules/entry-scope.d.ts +91 -0
- package/dist/rules/entry-scope.d.ts.map +1 -0
- package/dist/rules/entry-scope.js +404 -0
- package/dist/rules/entry-scope.js.map +1 -0
- package/dist/rules/env-inputs.d.ts +222 -0
- package/dist/rules/env-inputs.d.ts.map +1 -0
- package/dist/rules/env-inputs.js +951 -0
- package/dist/rules/env-inputs.js.map +1 -0
- package/dist/rules/kernel-boundary.d.ts +37 -0
- package/dist/rules/kernel-boundary.d.ts.map +1 -0
- package/dist/rules/kernel-boundary.js +195 -0
- package/dist/rules/kernel-boundary.js.map +1 -0
- package/dist/rules/nul-bytes.d.ts +233 -0
- package/dist/rules/nul-bytes.d.ts.map +1 -0
- package/dist/rules/nul-bytes.js +332 -0
- package/dist/rules/nul-bytes.js.map +1 -0
- package/dist/rules/platform-surface.d.ts +479 -0
- package/dist/rules/platform-surface.d.ts.map +1 -0
- package/dist/rules/platform-surface.js +749 -0
- package/dist/rules/platform-surface.js.map +1 -0
- package/dist/rules/port-catches.d.ts +225 -0
- package/dist/rules/port-catches.d.ts.map +1 -0
- package/dist/rules/port-catches.js +1374 -0
- package/dist/rules/port-catches.js.map +1 -0
- package/dist/rules/port-shape.d.ts +213 -0
- package/dist/rules/port-shape.d.ts.map +1 -0
- package/dist/rules/port-shape.js +670 -0
- package/dist/rules/port-shape.js.map +1 -0
- package/dist/rules/queue-names.d.ts +108 -0
- package/dist/rules/queue-names.d.ts.map +1 -0
- package/dist/rules/queue-names.js +395 -0
- package/dist/rules/queue-names.js.map +1 -0
- package/dist/rules/singleton-identity.d.ts +205 -0
- package/dist/rules/singleton-identity.d.ts.map +1 -0
- package/dist/rules/singleton-identity.js +830 -0
- package/dist/rules/singleton-identity.js.map +1 -0
- package/dist/rules/subscribe-seam.d.ts +121 -0
- package/dist/rules/subscribe-seam.d.ts.map +1 -0
- package/dist/rules/subscribe-seam.js +594 -0
- package/dist/rules/subscribe-seam.js.map +1 -0
- package/dist/rules/transaction-context.d.ts +40 -0
- package/dist/rules/transaction-context.d.ts.map +1 -0
- package/dist/rules/transaction-context.js +294 -0
- package/dist/rules/transaction-context.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tree `endora new instance` writes — one plan, three kinds of file, and a
|
|
3
|
+
* rule that refuses a fourth (`contracts/instance-tree.md` §1, §2).
|
|
4
|
+
*
|
|
5
|
+
* ## R1.1 and R1.2 are the whole design
|
|
6
|
+
*
|
|
7
|
+
* Every file here carries its {@link FileKind}, and the kind is what decides
|
|
8
|
+
* whether it may exist at all:
|
|
9
|
+
*
|
|
10
|
+
* * **the client's own** — a value only they can supply, or code they will
|
|
11
|
+
* edit. Written once, never regenerated, never read by us again.
|
|
12
|
+
* * **wiring** — the smallest expression that hands the platform something it
|
|
13
|
+
* cannot derive: a database handle, a root directory, a process's argv.
|
|
14
|
+
* Bounded by R1.4 and counted by {@link wiringLineCount}.
|
|
15
|
+
* * **derived** — rendered from a fact the platform or the module set already
|
|
16
|
+
* holds.
|
|
17
|
+
*
|
|
18
|
+
* **A file that is none of the three may not be written** (R1.2), which is the
|
|
19
|
+
* rule that refuses the fork one file at a time: a composition root is not the
|
|
20
|
+
* client's (it is ours), is not wiring (it is 2 628 lines), and is not derived
|
|
21
|
+
* (nothing generates it). The kind is a field on every entry rather than a
|
|
22
|
+
* comment, so T139's T4 can assert it over the plan the command actually
|
|
23
|
+
* builds.
|
|
24
|
+
*
|
|
25
|
+
* ## Nothing is copied, so nothing is rewritten
|
|
26
|
+
*
|
|
27
|
+
* R1.3 / R5.5 / NFR-002. `endora new storefront` copies a reference tree and
|
|
28
|
+
* rewrites every declaration that names something above it; this command copies
|
|
29
|
+
* nothing, so there is no `rewrite.ts` beside this file and there must never be
|
|
30
|
+
* one — its appearance would be evidence that something was copied that should
|
|
31
|
+
* not have been. Every string below is rendered from the resolved packages, the
|
|
32
|
+
* CLI's own manifest and the operator's own flags.
|
|
33
|
+
*
|
|
34
|
+
* ## No demo artefact, at any tier (D-216)
|
|
35
|
+
*
|
|
36
|
+
* *"A client scaffolding an instance for their own trading receives no demo
|
|
37
|
+
* artefact in a tree they own: no composition, no script, no example and no
|
|
38
|
+
* placeholder. Silence means no."* Nothing here writes one, and the capability
|
|
39
|
+
* is discoverable through the next-steps block rather than reported as an
|
|
40
|
+
* omission — a capability announced as a deficiency is not optional.
|
|
41
|
+
*
|
|
42
|
+
* ## What this build cannot yet write, said here rather than discovered
|
|
43
|
+
*
|
|
44
|
+
* The backend member's wiring names symbols on the platform's declared
|
|
45
|
+
* subpaths — `./composition`, `./db`, `./lifecycle`, `./overlay` and
|
|
46
|
+
* `./packages` today. **How many symbols that is is not written here** (D-100): the
|
|
47
|
+
* reconciliation test derives it from the barrels on every run and prints it,
|
|
48
|
+
* and the count in this sentence was already wrong when the three names below
|
|
49
|
+
* were wrong. `composeApp` is one of them since T118 — the
|
|
50
|
+
* position §2.3 stated (*"`composeApp` is imported, never written (R1.2)"*) is
|
|
51
|
+
* met, and the file below supplies the one argument that composition takes:
|
|
52
|
+
* `deploymentRoot`, the directory holding `apps/`, which no package can derive
|
|
53
|
+
* because in an instance the platform came out of `node_modules`
|
|
54
|
+
* (`contracts/application-root-supplier.md` R1.1). **No contribute callback is
|
|
55
|
+
* supplied and there is nowhere in this tree to write one** — R2.4 — so a
|
|
56
|
+
* client's instance contributes over no name a module defaults.
|
|
57
|
+
*
|
|
58
|
+
* The ORM configuration below is the one place an instance restates its own
|
|
59
|
+
* artefacts, and it has none, so the platform's `*From` factories answer over
|
|
60
|
+
* the packages it installed: `configuredEntitiesFrom`,
|
|
61
|
+
* `discoverConfiguredMigrations` and `mikroOrmConfigFrom` on `./db`, and
|
|
62
|
+
* `resolveManifestEntries` on `./lifecycle` with its three suppliers.
|
|
63
|
+
*
|
|
64
|
+
* **That sentence used to name three other symbols, and nothing held this file
|
|
65
|
+
* to it.** It read *"`configuredMigrations`, `configuredEntities` and
|
|
66
|
+
* `resolvedManifestEntries` are `./db`'s and `./lifecycle`'s **under other
|
|
67
|
+
* names**"* — a doc block describing the repair, beside rendered text that had
|
|
68
|
+
* never taken it, so a scaffolded backend did not compile and the knowledge was
|
|
69
|
+
* present the whole time. The guard is
|
|
70
|
+
* `test/new-instance/template-reconciliation.test.ts`' T1, at **symbol**
|
|
71
|
+
* granularity rather than subpath: `./composition` and `./lifecycle` are both
|
|
72
|
+
* declared subpaths, so a reconciliation of the specifier alone passes over all
|
|
73
|
+
* three errors.
|
|
74
|
+
*
|
|
75
|
+
* §2.3's sixth wiring file, `backend/src/cli.ts`, **is** written since
|
|
76
|
+
* `specs/123-oss-install-experience/` G2, and the omission that stood in its
|
|
77
|
+
* place is deleted rather than reworded. Its reason was *"the demo layer around
|
|
78
|
+
* it is exported under no subpath"*, and what discharged it was not a wider
|
|
79
|
+
* `exports` map: `backend/src/cli/demo-command.ts` moved into
|
|
80
|
+
* `<scope>platform/demo` — where `test/unit/kernel/host-residue-partition.test.ts`
|
|
81
|
+
* had it ledgered as platform-shaped residue all along — and the dispatch around
|
|
82
|
+
* both halves became `<scope>platform/cli`'s `runCli`. So the file this command
|
|
83
|
+
* renders names nothing it had to invent, holds no copy of the 479 lines it
|
|
84
|
+
* calls (D-207), and is five lines.
|
|
85
|
+
*
|
|
86
|
+
* The cost of the omission was measured rather than aesthetic: with no CLI, a
|
|
87
|
+
* scaffolded instance could run no `admin_users create`, so the admin bundle A5
|
|
88
|
+
* and A13 prove is built and styled had nobody to log in as.
|
|
89
|
+
*/
|
|
90
|
+
import { isRequiredGiven, scopeToMembers, } from '@endora-commerce/contracts';
|
|
91
|
+
import { writeEnvFile } from '../inputs/env-file.js';
|
|
92
|
+
import { INSTANCE_BUILD_INPUTS } from '../lib/instance-build-inputs.js';
|
|
93
|
+
import { deployFiles, developmentComposeFile, DEV_COMPOSE_PATH, } from './deploy.js';
|
|
94
|
+
import { InstanceInputError } from './host.js';
|
|
95
|
+
/**
|
|
96
|
+
* The members this template writes, and therefore the whole `--without`
|
|
97
|
+
* vocabulary (`specs/118-instance-member-selection/contracts/instance-members.md`
|
|
98
|
+
* R3.5a, ruled by D-215).
|
|
99
|
+
*
|
|
100
|
+
* **This is the one statement of the member set**, and two consumers read it:
|
|
101
|
+
* the refusal below, and `endora install`'s checklist (125 R6.3a), which
|
|
102
|
+
* renders one row per entry and holds no list of its own. A member the template
|
|
103
|
+
* gains is added here, beside the decision that writes it, and reaches both —
|
|
104
|
+
* `test/new-instance-members.test.ts` fails when a planned member has no entry.
|
|
105
|
+
*/
|
|
106
|
+
export const MEMBER_VOCABULARY = [
|
|
107
|
+
{
|
|
108
|
+
name: 'backend',
|
|
109
|
+
describes: 'the API and the workers — the part every other one talks to',
|
|
110
|
+
fixed: 'an instance is the tree that composes the platform, and the backend is what composes it',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'admin',
|
|
114
|
+
describes: 'the operator interface, built as its own artefact',
|
|
115
|
+
fixed: null,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'docs',
|
|
119
|
+
describes: "a documentation site rendering your modules' own pages",
|
|
120
|
+
fixed: null,
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
/**
|
|
124
|
+
* F10 and F11 (`instance-members.md` §5.6), decided over the names alone — the
|
|
125
|
+
* refusal sentence, or `null` when every name is one this template can decline.
|
|
126
|
+
*
|
|
127
|
+
* Exported because two commands validate the same flag before either writes:
|
|
128
|
+
* `endora new instance`, and `endora install`, which reports it together with
|
|
129
|
+
* every other precondition (125 FR-157) rather than one refusal later.
|
|
130
|
+
*/
|
|
131
|
+
export function memberRefusal(without) {
|
|
132
|
+
const names = without.map((name) => name.trim()).filter((name) => name.length > 0);
|
|
133
|
+
const vocabulary = MEMBER_VOCABULARY.map((entry) => entry.name);
|
|
134
|
+
if (names.includes('backend')) {
|
|
135
|
+
return ('`--without backend` is refused: an instance is the tree that composes the platform, and ' +
|
|
136
|
+
'the backend member is what composes it. If the admin is meant to run on a second host, ' +
|
|
137
|
+
'keep both members and deploy the built admin there — a machine layout is a fact about ' +
|
|
138
|
+
'the deployment, not about the repository (`--topology three-host` writes the examples).');
|
|
139
|
+
}
|
|
140
|
+
const unknown = names.filter((name) => !vocabulary.includes(name));
|
|
141
|
+
if (unknown.length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
const storefront = unknown.includes('storefront')
|
|
144
|
+
? ' The storefront is not a member: it is its own repository, written by ' +
|
|
145
|
+
'`endora new storefront` — and `endora install --no-storefront` is how the one-shot ' +
|
|
146
|
+
'leaves it out.'
|
|
147
|
+
: '';
|
|
148
|
+
return (`\`--without\` names ${unknown.join(', ')}, which ${unknown.length === 1 ? 'is' : 'are'} not ` +
|
|
149
|
+
`a member of an instance. The members are ${vocabulary.join(', ')}, and ` +
|
|
150
|
+
`\`--without\` names the ones not to write.${storefront}`);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A workspace name a client can actually install.
|
|
154
|
+
*
|
|
155
|
+
* npm's own rule, applied to the basename the operator chose, and **refused**
|
|
156
|
+
* rather than sanitised: a command that quietly renamed the directory the
|
|
157
|
+
* operator named would put a name nobody chose into the file that is the
|
|
158
|
+
* module list (R1.2 there).
|
|
159
|
+
*/
|
|
160
|
+
export function assertWorkspaceName(name) {
|
|
161
|
+
if (/^[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
162
|
+
return;
|
|
163
|
+
throw new InstanceInputError('F1', `"${name}" is not a usable npm package name, and it is the name the workspace root takes ` +
|
|
164
|
+
`from the directory you asked for. Use lower-case letters, digits, \`.\`, \`_\` and ` +
|
|
165
|
+
`\`-\`, starting with a letter or a digit — or scaffold into a directory whose basename ` +
|
|
166
|
+
`already is one. Nothing is written.`);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The deployment name, which is `DEPLOYMENT`'s value and nothing else reads it
|
|
170
|
+
* (§2.2).
|
|
171
|
+
*
|
|
172
|
+
* F4. It is a directory name under `apps/`, so the refusal is about what a
|
|
173
|
+
* directory name may be: no separator, no traversal, no leading dot.
|
|
174
|
+
*/
|
|
175
|
+
export function assertDeploymentName(deployment) {
|
|
176
|
+
if (/^[a-z0-9][a-z0-9_-]*$/.test(deployment))
|
|
177
|
+
return;
|
|
178
|
+
throw new InstanceInputError('F4', `\`--deployment ${deployment}\` is not a deployment name. It names the directory under ` +
|
|
179
|
+
`\`apps/\` where this instance's overlay modules and its \`divergence.ts\` live, and it ` +
|
|
180
|
+
`is the value of \`DEPLOYMENT\` — so it is lower-case letters, digits, \`_\` and \`-\`, ` +
|
|
181
|
+
`starting with a letter or a digit. Nothing is written.`);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The derived artefacts an instance generates and commits none of (§2.6,
|
|
185
|
+
* `instance-repository.md` R3.2).
|
|
186
|
+
*
|
|
187
|
+
* **There are three and this list carried two** until T138 wrote the admin
|
|
188
|
+
* member. `admin/src/tailwind.generated.css` arrived with T124 on the same day
|
|
189
|
+
* this command landed, R3.2 already said three, and `new-instance.test.ts`'
|
|
190
|
+
* *"both generated artefacts are git-ignored"* asserted the stale count rather
|
|
191
|
+
* than catching it. A committed stylesheet enumeration is the tree and the
|
|
192
|
+
* install disagreeing about which packages were scanned — which is silent, and
|
|
193
|
+
* is the whole failure `admin-stylesheet-composition.md` exists for.
|
|
194
|
+
*
|
|
195
|
+
* **The fourth is the entity index** (`specs/109-backend-test-kit/` T065), and it
|
|
196
|
+
* is the first entry here that belongs to **no member**: its consumer is a test,
|
|
197
|
+
* and a test is not a member of a client's workspace. It is in this list for the
|
|
198
|
+
* one reason the list exists — `.gitignore` has to cover it — and it is
|
|
199
|
+
* `.gitignore`d for §2.6's own predicate: which modules a host installed is a
|
|
200
|
+
* fact about the install, so a committed index is the tree and the install
|
|
201
|
+
* disagreeing about which tables exist.
|
|
202
|
+
*/
|
|
203
|
+
export const GENERATED_ARTEFACTS = [
|
|
204
|
+
'admin/src/modules.generated.ts',
|
|
205
|
+
'admin/src/tailwind.generated.css',
|
|
206
|
+
'docs/sidebars.modules.generated.js',
|
|
207
|
+
'backend/test/entities.generated.ts',
|
|
208
|
+
];
|
|
209
|
+
/**
|
|
210
|
+
* The generated **trees** — a whole directory the generator owns, rather than a
|
|
211
|
+
* file it writes.
|
|
212
|
+
*
|
|
213
|
+
* They are apart from {@link GENERATED_ARTEFACTS} because the two answer
|
|
214
|
+
* different questions: that list is the files a reconciliation can name and
|
|
215
|
+
* compare, this one is what `.gitignore` has to cover. The documentation half
|
|
216
|
+
* of §2.6 is a *population* rather than a file — one copied page per page a
|
|
217
|
+
* module ships, one reference page per module, and the stamp that lets a run
|
|
218
|
+
* undo the previous one's copies — so a client's `.gitignore` names the
|
|
219
|
+
* directories and git's own "a tracked file is never ignored" keeps a page they
|
|
220
|
+
* write themselves visible with no exception list to maintain.
|
|
221
|
+
*/
|
|
222
|
+
export const GENERATED_TREES = [
|
|
223
|
+
'docs/docs/modules/**',
|
|
224
|
+
'docs/docs/module-reference/**',
|
|
225
|
+
'docs/.module-docs-copies.json',
|
|
226
|
+
];
|
|
227
|
+
/**
|
|
228
|
+
* The named CLI aliases an instance's manifests earn
|
|
229
|
+
* (`specs/123-oss-install-experience/` T2-D).
|
|
230
|
+
*
|
|
231
|
+
* **Derived, never written.** `cli` is the pass-through and covers every
|
|
232
|
+
* command any installed module declares; what a named alias buys on top of it
|
|
233
|
+
* is that an operator reads it in `pnpm run`, and an alias addressing a module
|
|
234
|
+
* this instance did not install would fail with `unknown module` at the one
|
|
235
|
+
* moment a client is least able to tell a missing module from a broken CLI.
|
|
236
|
+
*
|
|
237
|
+
* **There is deliberately no `demo:seed` or `demo:reset` entry**, and
|
|
238
|
+
* `specs/123-oss-install-experience/` T2-D asked for both. D-216 is more
|
|
239
|
+
* specific than the task and is the owner's: *"a client scaffolding an instance
|
|
240
|
+
* for their own trading receives no demo artefact in a tree they own: no
|
|
241
|
+
* composition, **no script**, no example and no placeholder"* — and it names
|
|
242
|
+
* where the capability does belong, which is the next-steps block. `cli` reaches
|
|
243
|
+
* both verbs anyway (`pnpm run cli demo seed`), so nothing is unavailable; what
|
|
244
|
+
* is refused is a line in a client's manifest they did not ask for.
|
|
245
|
+
*/
|
|
246
|
+
export function cliAliasesFor(modules) {
|
|
247
|
+
const installed = new Set(modules.map((module) => module.id));
|
|
248
|
+
const aliases = [];
|
|
249
|
+
for (const [alias, moduleId, command] of MODULE_CLI_ALIASES) {
|
|
250
|
+
if (installed.has(moduleId))
|
|
251
|
+
aliases.push([alias, `node dist/cli.js ${moduleId} ${command}`]);
|
|
252
|
+
}
|
|
253
|
+
return aliases.sort(([a], [b]) => a.localeCompare(b));
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The module-declared commands an operator is given a name for.
|
|
257
|
+
*
|
|
258
|
+
* It is short on purpose and is not a mirror of every `cliCommands` entry in the
|
|
259
|
+
* estate: this command resolves package **manifests**, not their module
|
|
260
|
+
* manifests, so it cannot enumerate declarations — and a generated list of forty
|
|
261
|
+
* aliases would be forty scripts a client scrolls past to find the one that
|
|
262
|
+
* matters. `admin_users create` is the one an install cannot finish without.
|
|
263
|
+
*/
|
|
264
|
+
const MODULE_CLI_ALIASES = [
|
|
265
|
+
['admin:create', 'admin_users', 'create'],
|
|
266
|
+
];
|
|
267
|
+
/**
|
|
268
|
+
* The backend member's scripts, every one of them reading the instance's own
|
|
269
|
+
* `.env` (`specs/123-oss-install-experience/` G3).
|
|
270
|
+
*
|
|
271
|
+
* **`--env-file-if-exists=../.env`, and the `..` is the whole of it.** Every
|
|
272
|
+
* root script is `pnpm -C backend run …`, so these run with `backend/` as the
|
|
273
|
+
* working directory while the `.env` a client is told to fill in sits at the
|
|
274
|
+
* root of the tree beside `.env.example` and beside the `.env` entry in
|
|
275
|
+
* `.gitignore`. Without the prefix the file is inert: a client fills it in, runs
|
|
276
|
+
* `pnpm run migrate`, and the process reads nothing at all — which is the second
|
|
277
|
+
* half of the defect the acceptance criterion papers over by supplying the
|
|
278
|
+
* values through `process.env` instead.
|
|
279
|
+
*
|
|
280
|
+
* `-if-exists` rather than `--env-file`, because a `.env` is not obligatory: a
|
|
281
|
+
* container-hosted instance is configured entirely from its process environment,
|
|
282
|
+
* and a flag that refused to start without the file would break exactly the
|
|
283
|
+
* deployment `deploy/compose.prod.yml` describes. A value already in the
|
|
284
|
+
* environment wins over the file, which is Node's own precedence and the one an
|
|
285
|
+
* operator expects.
|
|
286
|
+
*
|
|
287
|
+
* It is one function rather than a spelling repeated ten times, so a script
|
|
288
|
+
* added later cannot be the one that silently does not read the file.
|
|
289
|
+
*/
|
|
290
|
+
export function backendScripts(input) {
|
|
291
|
+
const node = 'node --env-file-if-exists=../.env';
|
|
292
|
+
return {
|
|
293
|
+
// `tsc` and `node --watch` rather than `tsx`: no manifest this run can
|
|
294
|
+
// read declares a range for `tsx`, and a range this command chose would
|
|
295
|
+
// be a value nobody reviewed (R2.5a).
|
|
296
|
+
dev: `tsc -p tsconfig.json --watch & ${node} --watch dist/index.js`,
|
|
297
|
+
build: 'tsc -p tsconfig.json',
|
|
298
|
+
start: `${node} dist/index.js`,
|
|
299
|
+
worker: `${node} dist/worker.js`,
|
|
300
|
+
migrate: `${node} dist/migrate.js`,
|
|
301
|
+
'module:install': `${node} dist/module-commands/install.js`,
|
|
302
|
+
'module:uninstall': `${node} dist/module-commands/uninstall.js`,
|
|
303
|
+
'module:enable': `${node} dist/module-commands/enable.js`,
|
|
304
|
+
'module:disable': `${node} dist/module-commands/disable.js`,
|
|
305
|
+
'module:status': `${node} dist/module-commands/status.js`,
|
|
306
|
+
// The operator CLI (`specs/123-oss-install-experience/` G2, T2-D).
|
|
307
|
+
// `cli` is the generic pass-through, so a module this instance installed
|
|
308
|
+
// which declares a `cliCommands` entry is addressable with no file in this
|
|
309
|
+
// tree edited; the named entries below are the aliases this repository's own
|
|
310
|
+
// `backend/package.json` carries, **derived** from what is installed rather
|
|
311
|
+
// than written.
|
|
312
|
+
cli: `${node} dist/cli.js`,
|
|
313
|
+
...Object.fromEntries(cliAliasesFor(input.modules).map(([name, command]) => [
|
|
314
|
+
name,
|
|
315
|
+
command.replace(/^node /, `${node} `),
|
|
316
|
+
])),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Lines of wiring in a plan — R1.4's bound, measured rather than intended. */
|
|
320
|
+
export function wiringLineCount(plan) {
|
|
321
|
+
return plan.files
|
|
322
|
+
.filter((file) => file.kind === 'wiring')
|
|
323
|
+
.reduce((total, file) => total + file.content.split('\n').length, 0);
|
|
324
|
+
}
|
|
325
|
+
function json(value) {
|
|
326
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Which members this instance writes, as the environment declaration scopes on.
|
|
330
|
+
*
|
|
331
|
+
* `specs/118-instance-member-selection/`: an input read by no written member is
|
|
332
|
+
* **out of the population** — not declared, not asked for, not counted. The
|
|
333
|
+
* storefront is never here: under D-195 it is its own repository with its own
|
|
334
|
+
* declaration, and `endora new storefront` resolves that one.
|
|
335
|
+
*/
|
|
336
|
+
function writtenMembers(admin) {
|
|
337
|
+
return admin ? ['backend', 'admin'] : ['backend'];
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The environment inputs a client is asked to fill in, in declaration order.
|
|
341
|
+
*
|
|
342
|
+
* Two exclusions, and both are rules rather than names. A **generable secret**
|
|
343
|
+
* is written into the `.env` this command produces and appears in no
|
|
344
|
+
* `.env.example` (R2.5d), so asking for it would be asking for a value the tool
|
|
345
|
+
* has already supplied. And an input **no written member reads** is out of the
|
|
346
|
+
* population (118).
|
|
347
|
+
*/
|
|
348
|
+
export function declaredEnvironmentInputs(declared, admin) {
|
|
349
|
+
return scopeToMembers(declared, writtenMembers(admin)).filter((input) => !(input.generable && input.secret));
|
|
350
|
+
}
|
|
351
|
+
/** The generable secrets this run owes a value for, in declaration order. */
|
|
352
|
+
export function generableEnvironmentInputs(declared, admin) {
|
|
353
|
+
return scopeToMembers(declared, writtenMembers(admin)).filter((input) => input.generable && input.secret);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* One declaration, as an operator reads it: what it decides, then what it costs
|
|
357
|
+
* to leave unset.
|
|
358
|
+
*
|
|
359
|
+
* Both sentences are the **declaration's own**, carried across rather than
|
|
360
|
+
* rewritten. A rewrite here would be a second statement of a fact the author of
|
|
361
|
+
* the input already made, one tree away from where anybody would notice it had
|
|
362
|
+
* drifted — and an `optional` requirement carries *what is lost* precisely so
|
|
363
|
+
* that this line does not have to say the word "optional" and stop there.
|
|
364
|
+
*/
|
|
365
|
+
function declarationComment(input) {
|
|
366
|
+
const lines = [...wrapEnvComment(input.describes.en)];
|
|
367
|
+
switch (input.requirement.kind) {
|
|
368
|
+
case 'required':
|
|
369
|
+
lines.push('# REQUIRED.');
|
|
370
|
+
break;
|
|
371
|
+
case 'requiredWhen':
|
|
372
|
+
lines.push(`# REQUIRED when ${input.requirement.input}=${input.requirement.equals}.`);
|
|
373
|
+
break;
|
|
374
|
+
case 'optional':
|
|
375
|
+
// The cost on a line of its own rather than spliced into a sentence of
|
|
376
|
+
// ours. Declarations disagree about whether `without` opens with a
|
|
377
|
+
// capital, and a sentence built as `without it, <text>` reads wrong for
|
|
378
|
+
// half of them — which would be this file rewriting the author's prose to
|
|
379
|
+
// fit its own grammar, one comma at a time.
|
|
380
|
+
lines.push('# optional — what you lose:', ...wrapEnvComment(input.requirement.without.en));
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
if (input.secret)
|
|
384
|
+
lines.push('# SECRET: never commit this value and never print it.');
|
|
385
|
+
return lines;
|
|
386
|
+
}
|
|
387
|
+
/** One sentence, wrapped to a width a terminal and a diff both show whole. */
|
|
388
|
+
function wrapEnvComment(text) {
|
|
389
|
+
const out = [];
|
|
390
|
+
let line = '';
|
|
391
|
+
for (const word of text.split(' ')) {
|
|
392
|
+
if (line.length > 0 && `${line} ${word}`.length > 84) {
|
|
393
|
+
out.push(`# ${line}`);
|
|
394
|
+
line = word;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
line = line.length === 0 ? word : `${line} ${word}`;
|
|
398
|
+
}
|
|
399
|
+
if (line.length > 0)
|
|
400
|
+
out.push(`# ${line}`);
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* `.env.example` — every input this instance actually reads (FR-010).
|
|
405
|
+
*
|
|
406
|
+
* **Two populations, and keeping them apart is the point.** The build inputs are
|
|
407
|
+
* inlined into an artefact by `docker build`; the declared inputs are read by a
|
|
408
|
+
* process on every start. An operator who conflates them rebuilds an image to
|
|
409
|
+
* change a password. They are two headed sections of one file rather than two
|
|
410
|
+
* files because a client fills in one `.env`, and a second example beside the
|
|
411
|
+
* first is a second thing to forget.
|
|
412
|
+
*
|
|
413
|
+
* Neither section is a list. The first is `INSTANCE_BUILD_INPUTS`; the second is
|
|
414
|
+
* the platform's declaration unioned with the manifests of the modules this run
|
|
415
|
+
* installed, scoped to the members it wrote. The defect it closes is the
|
|
416
|
+
* acceptance criterion's own note — *"its `.env.example` declares none of them,
|
|
417
|
+
* so a client who fills in the file the command wrote has nothing to put them
|
|
418
|
+
* in"* — and the note is deleted in the same merge request, because an
|
|
419
|
+
* instrument that reports a gap must not outlive it.
|
|
420
|
+
*/
|
|
421
|
+
export function envExample(inputs, declared, admin) {
|
|
422
|
+
const lines = [
|
|
423
|
+
'# Everything this instance needs from its environment. Copy to `.env` and fill it in:',
|
|
424
|
+
'# `.env` is git-ignored, so nothing there is a value anybody but you chose.',
|
|
425
|
+
'#',
|
|
426
|
+
'# An entry with no value on the right of the `=` is one the platform has no honest',
|
|
427
|
+
'# default for — the declaration says so, and the refusal belongs to whatever reads it.',
|
|
428
|
+
'',
|
|
429
|
+
'# ---------------------------------------------------------------------------',
|
|
430
|
+
'# The BUILD inputs. Two of them are inlined into a bundle by `docker build`, so',
|
|
431
|
+
'# changing one of these means rebuilding an image rather than restarting a process.',
|
|
432
|
+
'# ---------------------------------------------------------------------------',
|
|
433
|
+
];
|
|
434
|
+
for (const input of inputs) {
|
|
435
|
+
lines.push('', `# ${input.meaning}`, `# example: ${input.example}`);
|
|
436
|
+
lines.push(`${input.name}=${input.default ?? ''}`);
|
|
437
|
+
}
|
|
438
|
+
const runtime = declaredEnvironmentInputs(declared, admin);
|
|
439
|
+
if (runtime.length > 0) {
|
|
440
|
+
lines.push('', '# ---------------------------------------------------------------------------', '# The RUNTIME inputs, read on every start. Each sentence below is the platform\'s or', '# the declaring module\'s own: this file is derived from what you installed, so a', '# different module set is a different file and nothing here was written by hand.', '#', '# The secrets this instance signs and encrypts with are NOT here. `endora new', '# instance` generated them into `.env` beside this file and named them on its own', '# output; a generated secret belongs in no example and in no source file.', '# ---------------------------------------------------------------------------');
|
|
441
|
+
for (const input of runtime) {
|
|
442
|
+
lines.push('', ...declarationComment(input), `${input.name}=`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return `${lines.join('\n')}\n`;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The `.env` this command writes — the generated secrets, and a blank for
|
|
449
|
+
* everything else the instance reads.
|
|
450
|
+
*
|
|
451
|
+
* R2.5d: a generated secret is written **where the operator can read it**,
|
|
452
|
+
* change it and copy it into a secret store. The placeholders are there so that
|
|
453
|
+
* the file a client edits is the file their instance reads: telling them to
|
|
454
|
+
* `cp .env.example .env` after this command has already written one would have
|
|
455
|
+
* them overwrite the generated secrets with empty strings, and the boot that
|
|
456
|
+
* then failed would name none of this.
|
|
457
|
+
*
|
|
458
|
+
* **Every placeholder is commented out, and that is not cosmetic.** A blank
|
|
459
|
+
* assignment is not the same state as no assignment: Node's `--env-file` reads
|
|
460
|
+
* `MEILISEARCH_URL=` as the empty string, and the platform's `??` fallbacks
|
|
461
|
+
* treat an empty string as a value. Measured on the instance acceptance
|
|
462
|
+
* criterion — a `.env` listing every optional input as a blank turned twenty
|
|
463
|
+
* "unset"s into twenty empty strings and the health route answered **503** over
|
|
464
|
+
* a search engine that was running. So the file lists what there is to fill in
|
|
465
|
+
* and changes nothing until a client removes a `#`, and {@link writeEnvFile}
|
|
466
|
+
* fills a placeholder in place rather than appending a second assignment.
|
|
467
|
+
*
|
|
468
|
+
* A `.env` the operator placed here first is **merged into, never rewritten**:
|
|
469
|
+
* their comments, their ordering and their own keys survive, and a value they
|
|
470
|
+
* already supplied is not generated over.
|
|
471
|
+
*/
|
|
472
|
+
export function envFile(input, admin) {
|
|
473
|
+
const required = [];
|
|
474
|
+
const optional = [];
|
|
475
|
+
for (const declaration of declaredEnvironmentInputs(input.declared, admin)) {
|
|
476
|
+
(isRequiredGiven(declaration, {}) ? required : optional).push(declaration.name);
|
|
477
|
+
}
|
|
478
|
+
const seeded = [
|
|
479
|
+
'# This instance\'s own configuration. Git-ignored, and yours.',
|
|
480
|
+
'#',
|
|
481
|
+
'# `endora new instance` wrote it. The values it GENERATED are set, at the bottom; every',
|
|
482
|
+
'# other input this instance reads is listed below, commented out. Remove the `#` and',
|
|
483
|
+
'# fill one in to set it — a commented line and a line reading `NAME=` are NOT the same',
|
|
484
|
+
'# thing to the process that reads this file, and the second is an empty string.',
|
|
485
|
+
'#',
|
|
486
|
+
'# `.env.example` beside this file carries a sentence for each one saying what it',
|
|
487
|
+
'# decides and what leaving it unset costs. Do not copy it over this file.',
|
|
488
|
+
'',
|
|
489
|
+
'# Required — the instance does not start without these.',
|
|
490
|
+
...required.map((name) => `#${name}=`),
|
|
491
|
+
'',
|
|
492
|
+
'# Optional — each has a cost stated in `.env.example`, and no default worth inventing.',
|
|
493
|
+
...optional.map((name) => `#${name}=`),
|
|
494
|
+
'',
|
|
495
|
+
].join('\n');
|
|
496
|
+
// The operator's file wins over the seed entirely: if they placed one, this
|
|
497
|
+
// run adds to it and re-orders nothing.
|
|
498
|
+
const base = input.existingEnv.length > 0 ? input.existingEnv : seeded;
|
|
499
|
+
return writeEnvFile(base, input.generated);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* The plan. Nothing here touches the filesystem, so `--dry-run` reports exactly
|
|
503
|
+
* what a real run writes rather than a second derivation of it (R5.3).
|
|
504
|
+
*/
|
|
505
|
+
export function planInstance(input) {
|
|
506
|
+
const files = [];
|
|
507
|
+
const omitted = [];
|
|
508
|
+
// §2.4 — decided first, because the workspace member list, the root scripts
|
|
509
|
+
// and the `.gitignore` all depend on whether this instance has an operator
|
|
510
|
+
// interface. Deciding it twice is how two files would come to disagree about
|
|
511
|
+
// a member one of them writes.
|
|
512
|
+
const admin = declinable('admin', adminMember(input), input);
|
|
513
|
+
// §2.4a — same reasoning, same three consequences (the member list, the root
|
|
514
|
+
// scripts, the `.gitignore`), decided in the same place.
|
|
515
|
+
const docs = declinable('docs', docsMember(input), input);
|
|
516
|
+
const dependencies = new Map();
|
|
517
|
+
// Each range is `^` over the version **that package** declares about itself,
|
|
518
|
+
// and never over another package's. A release is not uniform — 68 of this
|
|
519
|
+
// repository's packages moved to `0.8.0` on 2026-09-11 and 15 to `0.7.1` —
|
|
520
|
+
// so a module ranged at the platform's version is a range no registry can
|
|
521
|
+
// satisfy, which is a failure a client meets at their first install and
|
|
522
|
+
// nothing in a checkout can see (the tarball acceptance mode overrides every
|
|
523
|
+
// one of these ranges with a `file:` path).
|
|
524
|
+
dependencies.set(`${input.scope}platform`, `^${input.platformVersion}`);
|
|
525
|
+
for (const module of [...input.modules].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
526
|
+
dependencies.set(module.packageName, `^${module.version}`);
|
|
527
|
+
}
|
|
528
|
+
// The packages the installed modules declare **optional** — and they are
|
|
529
|
+
// declared **here**, at the root, rather than in the admin member that needs
|
|
530
|
+
// them rendered (§2.4).
|
|
531
|
+
//
|
|
532
|
+
// pnpm resolves a package's peers from its **dependent's** context, and a
|
|
533
|
+
// module package's dependent in an instance is this manifest: the module set
|
|
534
|
+
// is the root's (R3.6) and the admin member declares none of it. So an
|
|
535
|
+
// optional peer declared in the admin member satisfies nothing — measured,
|
|
536
|
+
// `mod-invoices`' `@endora-commerce/page-builder-admin` stayed unresolved with
|
|
537
|
+
// the package installed and declared one member over, and Vite bound the
|
|
538
|
+
// import to an `__vite-optional-peer-dep:` stub whose every named export is
|
|
539
|
+
// missing. Declaring the module set a second time in the admin member would
|
|
540
|
+
// satisfy them and is exactly what R3.6 forbids; declaring their peers beside
|
|
541
|
+
// them is the same fact in the one place the set already lives.
|
|
542
|
+
if (admin.written) {
|
|
543
|
+
for (const [name, range] of [...input.adminPeers].sort(([a], [b]) => a.localeCompare(b))) {
|
|
544
|
+
if (BUILD_TOOL_PEERS.has(name))
|
|
545
|
+
continue;
|
|
546
|
+
if (!dependencies.has(name))
|
|
547
|
+
dependencies.set(name, range);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (admin.omission !== null)
|
|
551
|
+
omitted.push({ path: 'admin/', reason: admin.omission });
|
|
552
|
+
if (docs.omission !== null)
|
|
553
|
+
omitted.push({ path: 'docs/', reason: docs.omission });
|
|
554
|
+
/**
|
|
555
|
+
* Has this instance anything for `endora generate` to render? (§2.5, §2.6.)
|
|
556
|
+
*
|
|
557
|
+
* One predicate, named once, read by the `generate` script, by `setup`'s term
|
|
558
|
+
* for it and by the `@endora-commerce/cli` devDependency the binary needs —
|
|
559
|
+
* three sites that used to spell `admin.written || docs.written` each, and a
|
|
560
|
+
* fourth artefact family is exactly how three copies of one condition come to
|
|
561
|
+
* disagree.
|
|
562
|
+
*
|
|
563
|
+
* The third term is T065's entity index, which belongs to no member: its
|
|
564
|
+
* population is the module packages this instance installed, so a headless
|
|
565
|
+
* instance with a module has an artefact and a `generate` script where before
|
|
566
|
+
* it had neither. An instance with **no** module installed still gets neither,
|
|
567
|
+
* which is the state `runGenerate` refuses under exit 1.
|
|
568
|
+
*/
|
|
569
|
+
const generatesArtefacts = admin.written || docs.written || input.modules.length > 0;
|
|
570
|
+
// --- the workspace root (§2.1) -------------------------------------------
|
|
571
|
+
//
|
|
572
|
+
// The per-layer builds, and the composite that is their conjunction
|
|
573
|
+
// (`specs/122-layer-deployment-independence/contracts/layer-independence.md`
|
|
574
|
+
// §2 R2.1, under D-230). Three layers are deployed to three hosts on three
|
|
575
|
+
// schedules, so each is built by a command of its own — and a CI job on the
|
|
576
|
+
// admin host cannot cite a command it was never told.
|
|
577
|
+
//
|
|
578
|
+
// One list, so there is one predicate. The composite used to spell the same
|
|
579
|
+
// three terms inline; deriving it from the named entries is what keeps its
|
|
580
|
+
// value byte-identical to the named parts rather than merely similar to them.
|
|
581
|
+
const layerBuilds = [
|
|
582
|
+
['build:backend', 'pnpm -C backend run build'],
|
|
583
|
+
...(admin.written ? [['build:admin', 'pnpm -C admin run build']] : []),
|
|
584
|
+
...(docs.written ? [['build:docs', 'pnpm -C docs run build']] : []),
|
|
585
|
+
];
|
|
586
|
+
// `-C` and never `--filter <name>` — see the block below for what a filter
|
|
587
|
+
// cost the first end-to-end run.
|
|
588
|
+
// The four steps `setup` is the conjunction of (`specs/125-first-mile-install/`
|
|
589
|
+
// FR-109), named as **root scripts** rather than spelled out as commands.
|
|
590
|
+
// That is one level up from `build`'s derivation and buys the same property
|
|
591
|
+
// for a longer sequence: a change to what `migrate` or `module:install` runs
|
|
592
|
+
// reaches the composite with nothing here edited, where an inlined
|
|
593
|
+
// `pnpm -C backend run migrate` would be a second spelling of it.
|
|
594
|
+
//
|
|
595
|
+
// `generate` is conditional on the same predicate the script itself is: an
|
|
596
|
+
// instance with nothing to generate declares none, so the composite must not
|
|
597
|
+
// name one.
|
|
598
|
+
const setupSteps = [
|
|
599
|
+
...(generatesArtefacts ? ['generate'] : []),
|
|
600
|
+
'build',
|
|
601
|
+
'migrate',
|
|
602
|
+
'module:install --all',
|
|
603
|
+
];
|
|
604
|
+
const rootScripts = {
|
|
605
|
+
migrate: 'pnpm -C backend run migrate',
|
|
606
|
+
dev: 'pnpm -C backend run dev',
|
|
607
|
+
...Object.fromEntries(layerBuilds),
|
|
608
|
+
// §2.5's `build` is the whole instance's, and §2.5's `generate` is the
|
|
609
|
+
// three derived artefacts — two of which are the admin member's, so
|
|
610
|
+
// both entries name a member that may not be there. An instance with
|
|
611
|
+
// no operator interface gets neither rather than a script that fails
|
|
612
|
+
// on a directory nobody wrote.
|
|
613
|
+
build: layerBuilds.map(([, command]) => command).join(' && '),
|
|
614
|
+
// One `endora generate` renders every member's artefacts, so the root
|
|
615
|
+
// script is the command itself rather than a member's. An instance with
|
|
616
|
+
// nothing to generate gets no `generate` at all, rather than a script that
|
|
617
|
+
// fails on a directory nobody wrote.
|
|
618
|
+
...(generatesArtefacts ? { generate: 'endora generate' } : {}),
|
|
619
|
+
// The development stack (`specs/125-first-mile-install/` FR-108). `--wait`
|
|
620
|
+
// is not decoration: it blocks until every health check in the rendered
|
|
621
|
+
// document passes, which is what stops `migrate` racing a Postgres that is
|
|
622
|
+
// still initialising — and it is what lets `setup` run straight after this.
|
|
623
|
+
// `down` keeps the volumes, because a development database is not a scratch
|
|
624
|
+
// file and `docker compose down -v` is not a step anybody should be one
|
|
625
|
+
// typo away from.
|
|
626
|
+
'dev:services': `docker compose -f ${DEV_COMPOSE_PATH} up -d --wait`,
|
|
627
|
+
'dev:services:down': `docker compose -f ${DEV_COMPOSE_PATH} down`,
|
|
628
|
+
// FR-109 — four typed lines collapsed into one, and every named step
|
|
629
|
+
// survives beside it for the operator who wants them (§3's User Story 3).
|
|
630
|
+
setup: setupSteps.map((step) => `pnpm run ${step}`).join(' && '),
|
|
631
|
+
start: 'pnpm -C backend run start',
|
|
632
|
+
// FR-110 — the admin bundle, served. Baseline step D1: `admin/package.json`
|
|
633
|
+
// has declared `preview` all along and no root script and no printed step
|
|
634
|
+
// named it, so a client who ran `build:admin` had a bundle and no way to
|
|
635
|
+
// look at it. With the member, like every other admin entry.
|
|
636
|
+
...(admin.written ? { 'preview:admin': 'pnpm -C admin run preview' } : {}),
|
|
637
|
+
// One development command over the layers above (`specs/136-open-source-
|
|
638
|
+
// publication/` GAP-7, FR-060): `endora dev` supervises `start`,
|
|
639
|
+
// `preview:admin` and the sibling storefront's `dev` by **name**, so it
|
|
640
|
+
// changes none of them and none of the per-layer builds (FR-061, D-230).
|
|
641
|
+
// It is the CLI's, so it is declared exactly when the CLI is on the root's
|
|
642
|
+
// path — the predicate `generate` and the devDependency already share.
|
|
643
|
+
...(generatesArtefacts ? { 'dev:all': 'endora dev' } : {}),
|
|
644
|
+
'module:install': 'pnpm -C backend run module:install',
|
|
645
|
+
'module:uninstall': 'pnpm -C backend run module:uninstall',
|
|
646
|
+
'module:enable': 'pnpm -C backend run module:enable',
|
|
647
|
+
'module:disable': 'pnpm -C backend run module:disable',
|
|
648
|
+
'module:status': 'pnpm -C backend run module:status',
|
|
649
|
+
// The operator CLI (`specs/123-oss-install-experience/` G2, T2-D). `cli` is
|
|
650
|
+
// the generic pass-through, so a module this instance installed which
|
|
651
|
+
// declares a `cliCommands` entry is addressable with no file in this tree
|
|
652
|
+
// edited; the named entries beside it are **derived** from what is
|
|
653
|
+
// installed rather than written.
|
|
654
|
+
cli: 'pnpm -C backend run cli',
|
|
655
|
+
...Object.fromEntries(cliAliasesFor(input.modules).map(([name]) => [name, `pnpm -C backend run ${name}`])),
|
|
656
|
+
};
|
|
657
|
+
files.push({
|
|
658
|
+
path: 'package.json',
|
|
659
|
+
kind: 'derived',
|
|
660
|
+
member: 'root',
|
|
661
|
+
content: json({
|
|
662
|
+
name: input.name,
|
|
663
|
+
private: true,
|
|
664
|
+
type: 'module',
|
|
665
|
+
...(input.packageManager === undefined ? {} : { packageManager: input.packageManager }),
|
|
666
|
+
engines: { node: input.enginesNode },
|
|
667
|
+
// `pnpm -C backend`, never `pnpm --filter <name>`
|
|
668
|
+
// (`specs/110-instance-repository/` T141). These read
|
|
669
|
+
// `pnpm --filter backend run …` while the member below is named
|
|
670
|
+
// `<name>-backend`, so pnpm matched no project, printed `No projects
|
|
671
|
+
// matched the filters` and **exited 0** — every root script of a
|
|
672
|
+
// scaffolded instance was a silent no-op, and every step of the next-steps
|
|
673
|
+
// block the command prints was a successful nothing. Measured by the
|
|
674
|
+
// acceptance criterion's first end-to-end run (T140), which found an empty
|
|
675
|
+
// database behind a `migrate` that had exited 0.
|
|
676
|
+
//
|
|
677
|
+
// `-C` is the repair rather than a corrected filter for two reasons. It
|
|
678
|
+
// names the **directory** `pnpm-workspace.yaml` declares, so it cannot
|
|
679
|
+
// drift from a member's name again; and it fails loudly in both directions
|
|
680
|
+
// — a missing directory and a missing script are each exit 1 — where a
|
|
681
|
+
// name filter's whole failure mode is a green nothing. `--fail-if-no-match`
|
|
682
|
+
// would restore the refusal for a filter, and it is pnpm 9.5 and later
|
|
683
|
+
// only; `-C` needs no version this command cannot see.
|
|
684
|
+
scripts: rootScripts,
|
|
685
|
+
dependencies: Object.fromEntries([...dependencies].sort(([a], [b]) => a.localeCompare(b))),
|
|
686
|
+
devDependencies: Object.fromEntries(devDependenciesFor(input, generatesArtefacts)),
|
|
687
|
+
}),
|
|
688
|
+
});
|
|
689
|
+
files.push({
|
|
690
|
+
path: 'pnpm-workspace.yaml',
|
|
691
|
+
kind: 'wiring',
|
|
692
|
+
member: 'root',
|
|
693
|
+
content: [
|
|
694
|
+
'# The members of this workspace. One list, one place.',
|
|
695
|
+
'#',
|
|
696
|
+
'# The module list is NOT here: it is the root `package.json`\'s `dependencies`, and',
|
|
697
|
+
'# the platform discovers those from `node_modules` at runtime. Two spellings of one',
|
|
698
|
+
'# set is the one disagreement nothing in this tree could detect.',
|
|
699
|
+
'packages:',
|
|
700
|
+
' - backend',
|
|
701
|
+
...(admin.written ? [' - admin'] : []),
|
|
702
|
+
...(docs.written ? [' - docs'] : []),
|
|
703
|
+
'',
|
|
704
|
+
].join('\n'),
|
|
705
|
+
});
|
|
706
|
+
files.push({
|
|
707
|
+
path: 'tsconfig.json',
|
|
708
|
+
kind: 'client',
|
|
709
|
+
member: 'root',
|
|
710
|
+
content: json({
|
|
711
|
+
compilerOptions: {
|
|
712
|
+
target: 'ES2023',
|
|
713
|
+
lib: ['ES2023'],
|
|
714
|
+
module: 'NodeNext',
|
|
715
|
+
moduleResolution: 'NodeNext',
|
|
716
|
+
strict: true,
|
|
717
|
+
skipLibCheck: true,
|
|
718
|
+
esModuleInterop: true,
|
|
719
|
+
forceConsistentCasingInFileNames: true,
|
|
720
|
+
},
|
|
721
|
+
}),
|
|
722
|
+
});
|
|
723
|
+
if (input.npmrc !== null) {
|
|
724
|
+
files.push({ path: '.npmrc', kind: 'wiring', member: 'root', content: input.npmrc });
|
|
725
|
+
}
|
|
726
|
+
files.push({
|
|
727
|
+
path: '.env.example',
|
|
728
|
+
kind: 'client',
|
|
729
|
+
member: 'root',
|
|
730
|
+
content: envExample(INSTANCE_BUILD_INPUTS, input.declared, admin.written),
|
|
731
|
+
});
|
|
732
|
+
// The instance's own configuration, holding whatever this run generated
|
|
733
|
+
// (R2.5d) and a blank for everything else it reads. It is `client` for the
|
|
734
|
+
// same reason `.env.example` is — it is the operator's from the moment it is
|
|
735
|
+
// written — and it is git-ignored, so it leaves this tree with nobody but
|
|
736
|
+
// them having seen it.
|
|
737
|
+
files.push({
|
|
738
|
+
path: '.env',
|
|
739
|
+
kind: 'client',
|
|
740
|
+
member: 'root',
|
|
741
|
+
content: envFile(input, admin.written),
|
|
742
|
+
});
|
|
743
|
+
files.push({
|
|
744
|
+
path: '.gitignore',
|
|
745
|
+
kind: 'derived',
|
|
746
|
+
member: 'root',
|
|
747
|
+
content: [
|
|
748
|
+
'# The generated artefacts THAT ARE FACTS ABOUT THE INSTALL. `pnpm run generate`',
|
|
749
|
+
'# writes them and this tree commits none of them: a different module set is a',
|
|
750
|
+
'# different bundle, and committing one makes this tree and the install disagree.',
|
|
751
|
+
'#',
|
|
752
|
+
'# `apps/<deployment>/divergence.generated.{md,json}` is deliberately NOT here. The',
|
|
753
|
+
'# same command writes it and it is a fact about THIS repository — what your own',
|
|
754
|
+
'# overlay modules decorate, intercept and consume — so it is committed and the diff',
|
|
755
|
+
'# is the point: it is where an upgrade that changes behaviour you depended on shows up.',
|
|
756
|
+
...GENERATED_ARTEFACTS,
|
|
757
|
+
'',
|
|
758
|
+
'# The documentation site\'s are a population rather than a file: one copied page per',
|
|
759
|
+
'# page a module ships, one reference page per module, and the stamp that lets a run',
|
|
760
|
+
'# undo the previous one\'s copies. A **tracked** file is never ignored whatever the',
|
|
761
|
+
'# pattern says, so a page you write yourself stays visible and no exception list is',
|
|
762
|
+
'# written down here.',
|
|
763
|
+
...GENERATED_TREES,
|
|
764
|
+
'',
|
|
765
|
+
'node_modules',
|
|
766
|
+
'dist',
|
|
767
|
+
'.next/',
|
|
768
|
+
'*.tsbuildinfo',
|
|
769
|
+
'',
|
|
770
|
+
'# This instance\'s own configuration, with the secrets this command generated.',
|
|
771
|
+
'# `.env.example` is the file to commit.',
|
|
772
|
+
'.env',
|
|
773
|
+
'.env*.local',
|
|
774
|
+
'',
|
|
775
|
+
].join('\n'),
|
|
776
|
+
});
|
|
777
|
+
files.push({
|
|
778
|
+
path: 'README.md',
|
|
779
|
+
kind: 'client',
|
|
780
|
+
member: 'root',
|
|
781
|
+
content: readme(input, dependencies.size, rootScripts, {
|
|
782
|
+
admin: admin.written,
|
|
783
|
+
docs: docs.written,
|
|
784
|
+
}),
|
|
785
|
+
});
|
|
786
|
+
// --- the deployment (§2.2) -----------------------------------------------
|
|
787
|
+
files.push({
|
|
788
|
+
path: `apps/${input.deployment}/divergence.ts`,
|
|
789
|
+
kind: 'client',
|
|
790
|
+
member: 'deployment',
|
|
791
|
+
content: divergenceDeclaration(input.deployment),
|
|
792
|
+
});
|
|
793
|
+
files.push({
|
|
794
|
+
path: `apps/${input.deployment}/modules/.gitkeep`,
|
|
795
|
+
kind: 'client',
|
|
796
|
+
member: 'deployment',
|
|
797
|
+
content: '',
|
|
798
|
+
});
|
|
799
|
+
// --- the backend member (§2.3) -------------------------------------------
|
|
800
|
+
files.push({
|
|
801
|
+
path: 'backend/package.json',
|
|
802
|
+
kind: 'derived',
|
|
803
|
+
member: 'backend',
|
|
804
|
+
content: json({
|
|
805
|
+
name: `${input.name}-backend`,
|
|
806
|
+
private: true,
|
|
807
|
+
type: 'module',
|
|
808
|
+
// No dependency of its own: the module set is the root's (§2.3, R3.6).
|
|
809
|
+
scripts: backendScripts(input),
|
|
810
|
+
}),
|
|
811
|
+
});
|
|
812
|
+
files.push({
|
|
813
|
+
path: 'backend/tsconfig.json',
|
|
814
|
+
kind: 'client',
|
|
815
|
+
member: 'backend',
|
|
816
|
+
content: json({
|
|
817
|
+
compilerOptions: {
|
|
818
|
+
target: 'ES2023',
|
|
819
|
+
lib: ['ES2023'],
|
|
820
|
+
module: 'NodeNext',
|
|
821
|
+
moduleResolution: 'NodeNext',
|
|
822
|
+
outDir: 'dist',
|
|
823
|
+
rootDir: 'src',
|
|
824
|
+
strict: true,
|
|
825
|
+
skipLibCheck: true,
|
|
826
|
+
experimentalDecorators: true,
|
|
827
|
+
emitDecoratorMetadata: true,
|
|
828
|
+
esModuleInterop: true,
|
|
829
|
+
},
|
|
830
|
+
include: ['src'],
|
|
831
|
+
}),
|
|
832
|
+
});
|
|
833
|
+
for (const file of backendWiring(input))
|
|
834
|
+
files.push(file);
|
|
835
|
+
// --- the admin member (§2.4) ---------------------------------------------
|
|
836
|
+
for (const file of admin.files)
|
|
837
|
+
files.push(file);
|
|
838
|
+
// --- the documentation member (§2.4a) ------------------------------------
|
|
839
|
+
for (const file of docs.files)
|
|
840
|
+
files.push(file);
|
|
841
|
+
// --- the deployment examples (§2.7; layer-independence.md §3) ------------
|
|
842
|
+
//
|
|
843
|
+
// Last, because they are derived from the member decisions above and from
|
|
844
|
+
// nothing else but the topology. They belong to no member's directory: an
|
|
845
|
+
// example that deploys the admin is not the admin project's file, and a
|
|
846
|
+
// client editing one is editing the root of their own repository.
|
|
847
|
+
const deployInput = {
|
|
848
|
+
topology: input.topology,
|
|
849
|
+
admin: admin.written,
|
|
850
|
+
docs: docs.written,
|
|
851
|
+
npmrc: input.npmrc !== null,
|
|
852
|
+
enginesNode: input.enginesNode,
|
|
853
|
+
packageManager: input.packageManager,
|
|
854
|
+
// The same declaration the root `.env.example` is derived from. One
|
|
855
|
+
// derivation of what this instance needs, two readers of it.
|
|
856
|
+
declared: input.declared,
|
|
857
|
+
};
|
|
858
|
+
for (const file of deployFiles(deployInput))
|
|
859
|
+
files.push(file);
|
|
860
|
+
// --- the development environment (`specs/125-first-mile-install/` §4.1) ---
|
|
861
|
+
//
|
|
862
|
+
// At the **root** and not under `deploy/`, because everything in there is
|
|
863
|
+
// addressed to a person deploying to a host they own and this file is
|
|
864
|
+
// addressed to a person on a laptop (spec §5.3.1). Written unconditionally
|
|
865
|
+
// (FR-107): it is inert, it is derived from the same catalogue the examples
|
|
866
|
+
// are, and every audience the feature has wants it.
|
|
867
|
+
files.push(developmentComposeFile(deployInput));
|
|
868
|
+
return {
|
|
869
|
+
files,
|
|
870
|
+
omitted,
|
|
871
|
+
members: [
|
|
872
|
+
'root',
|
|
873
|
+
'deployment',
|
|
874
|
+
'backend',
|
|
875
|
+
...(admin.written ? ['admin'] : []),
|
|
876
|
+
...(docs.written ? ['docs'] : []),
|
|
877
|
+
],
|
|
878
|
+
registry: input.registry,
|
|
879
|
+
dependencies,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* The deployment's own declaration, written **out in full with its doc block**
|
|
884
|
+
* (§2.2).
|
|
885
|
+
*
|
|
886
|
+
* `backend/src/apps/example/divergence.ts`'s own stated reason, which is why
|
|
887
|
+
* this is not an empty literal: *"the mechanism is easier to find than to
|
|
888
|
+
* remember … a field an author never sees is a field they never learn they
|
|
889
|
+
* have."*
|
|
890
|
+
*/
|
|
891
|
+
function divergenceDeclaration(deployment) {
|
|
892
|
+
return `/**
|
|
893
|
+
* What this deployment does differently from core.
|
|
894
|
+
*
|
|
895
|
+
* Three fields, and each answers a question a walk of this tree cannot:
|
|
896
|
+
*
|
|
897
|
+
* * \`omittedModules\` — a module this deployment deliberately does not ship.
|
|
898
|
+
* The declaration is two-way: an entry for a module you do ship fails as
|
|
899
|
+
* loudly as an omission you did not declare.
|
|
900
|
+
* * \`decorationOrder\` — where two of your overlay modules decorate one
|
|
901
|
+
* registration, the order they wrap it in. \`beta(acme(core))\` and
|
|
902
|
+
* \`acme(beta(core))\` are different implementations, so the ambiguity is
|
|
903
|
+
* refused at boot rather than resolved by a directory read order.
|
|
904
|
+
* * \`reasons\` — one sentence per derived divergence, keyed as the generated
|
|
905
|
+
* report keys it. A divergence with no sentence is a finding; a sentence
|
|
906
|
+
* describing a divergence that is gone is the same finding walked the other
|
|
907
|
+
* way.
|
|
908
|
+
*
|
|
909
|
+
* It is written out empty on purpose. A field an author never sees is a field
|
|
910
|
+
* they never learn they have.
|
|
911
|
+
*/
|
|
912
|
+
export const divergence = {
|
|
913
|
+
omittedModules: [],
|
|
914
|
+
decorationOrder: {},
|
|
915
|
+
reasons: {},
|
|
916
|
+
} as const;
|
|
917
|
+
|
|
918
|
+
/** The deployment this declaration belongs to. \`DEPLOYMENT=${deployment}\`. */
|
|
919
|
+
export const deployment = '${deployment}';
|
|
920
|
+
`;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* The packages the instance builds with, each range read off a manifest this
|
|
924
|
+
* run resolved (§2.1, R2.5a).
|
|
925
|
+
*
|
|
926
|
+
* `instance-tree.md` §2.1 names the set — *"the platform's four peers plus
|
|
927
|
+
* `@mikro-orm/migrations`, `tsx`, `typescript`"* — and says nothing about where
|
|
928
|
+
* the **ranges** come from, which is the question R2.5a answers: a value with
|
|
929
|
+
* no source is one the command would have to invent. So each is derived:
|
|
930
|
+
*
|
|
931
|
+
* * the four peers, from `@endora-commerce/platform`'s own
|
|
932
|
+
* `peerDependencies`. A platform that widens `zod` to `^5` widens this in
|
|
933
|
+
* the same run, with nothing here to edit.
|
|
934
|
+
* * `@mikro-orm/migrations`, from the range the platform declares for
|
|
935
|
+
* `@mikro-orm/core`. The MikroORM packages version in lockstep and a
|
|
936
|
+
* migrator from another major does not load the driver from this one.
|
|
937
|
+
* * `ioredis`, from the platform's own `dependencies`. It is not in §2.1's
|
|
938
|
+
* list and it is not optional: the five operator commands construct the
|
|
939
|
+
* `OperatorResources` the platform asks them for, and one of its three
|
|
940
|
+
* members is a Redis connection.
|
|
941
|
+
* * `typescript`, from the CLI's own manifest — R2.3's first named source.
|
|
942
|
+
*
|
|
943
|
+
* **`tsx` is deliberately absent.** No manifest this run can read declares a
|
|
944
|
+
* range for it, so writing one would be the invention R2.5a forbids; the
|
|
945
|
+
* member's `dev` script uses `tsc` and `node --watch`, which need nothing that
|
|
946
|
+
* is not already here.
|
|
947
|
+
*/
|
|
948
|
+
export function devDependenciesFor(input,
|
|
949
|
+
/**
|
|
950
|
+
* Does this instance have an artefact to generate at all?
|
|
951
|
+
*
|
|
952
|
+
* The root's `generate` script is `endora generate` — one run renders every
|
|
953
|
+
* member's artefacts — so the binary has to be on the **root's** path. An
|
|
954
|
+
* instance with no artefact family at all has no `generate` script, and
|
|
955
|
+
* declaring the tool that runs it would be a dependency with nothing to do.
|
|
956
|
+
*
|
|
957
|
+
* The caller's predicate is `planInstance`'s `generatesArtefacts`, which since
|
|
958
|
+
* T065 counts the entity index: a headless instance with a module installed
|
|
959
|
+
* does have something to render, so it does get the tool.
|
|
960
|
+
*/
|
|
961
|
+
generates = false) {
|
|
962
|
+
const wanted = [
|
|
963
|
+
['@mikro-orm/core', input.declaredRanges.get('@mikro-orm/core') ?? ''],
|
|
964
|
+
['@mikro-orm/postgresql', input.declaredRanges.get('@mikro-orm/postgresql') ?? ''],
|
|
965
|
+
['@mikro-orm/migrations', input.declaredRanges.get('@mikro-orm/core') ?? ''],
|
|
966
|
+
['fastify', input.declaredRanges.get('fastify') ?? ''],
|
|
967
|
+
['zod', input.declaredRanges.get('zod') ?? ''],
|
|
968
|
+
['ioredis', input.declaredRanges.get('ioredis') ?? ''],
|
|
969
|
+
['typescript', input.declaredRanges.get('typescript') ?? ''],
|
|
970
|
+
...(generates
|
|
971
|
+
? [[`${input.scope}cli`, `^${input.cliVersion}`]]
|
|
972
|
+
: []),
|
|
973
|
+
];
|
|
974
|
+
// A package whose range no resolved manifest declares is **left out**, not
|
|
975
|
+
// guessed at. The client adds it and reviews the range they chose, which is
|
|
976
|
+
// one line of work; a range this command invented would be in their manifest
|
|
977
|
+
// forever with nobody's judgement behind it.
|
|
978
|
+
return wanted
|
|
979
|
+
.filter(([, range]) => range.length > 0)
|
|
980
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* The backend member's wiring — R1.4's whole population.
|
|
984
|
+
*
|
|
985
|
+
* Each file is the smallest expression that hands the platform something it
|
|
986
|
+
* cannot derive: a database handle, a process's argv, a port, **a root
|
|
987
|
+
* directory**. Every symbol named below is on a subpath the platform's
|
|
988
|
+
* `exports` map declares. A symbol that exists nowhere is not written at all:
|
|
989
|
+
* `backend/src/cli.ts` is §2.3's sixth wiring file and is reported as an
|
|
990
|
+
* omission rather than rendered against a name somebody would have had to
|
|
991
|
+
* invent for it.
|
|
992
|
+
*/
|
|
993
|
+
function backendWiring(input) {
|
|
994
|
+
const scope = input.scope;
|
|
995
|
+
const files = [];
|
|
996
|
+
files.push({
|
|
997
|
+
path: 'backend/src/index.ts',
|
|
998
|
+
kind: 'wiring',
|
|
999
|
+
member: 'backend',
|
|
1000
|
+
content: `// The API process. It reads the environment, composes, and listens.
|
|
1001
|
+
import { fileURLToPath } from 'node:url';
|
|
1002
|
+
|
|
1003
|
+
import { buildServer, composeApp } from '${scope}platform/composition';
|
|
1004
|
+
|
|
1005
|
+
const port = Number(process.env['PORT'] ?? 3001);
|
|
1006
|
+
const sessionCookieSecret = process.env['SESSION_COOKIE_SECRET'] ?? '';
|
|
1007
|
+
if (sessionCookieSecret === '') {
|
|
1008
|
+
console.error('SESSION_COOKIE_SECRET must be set.');
|
|
1009
|
+
process.exit(1);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// The directory that holds \`apps/\` — this workspace's root, one level up from
|
|
1013
|
+
// the backend member. It is the one thing the platform cannot derive for
|
|
1014
|
+
// itself, and it is deliberately a required argument rather than a default that
|
|
1015
|
+
// would silently name a directory holding no \`apps/\` at all.
|
|
1016
|
+
const deploymentRoot = fileURLToPath(new URL('../..', import.meta.url));
|
|
1017
|
+
|
|
1018
|
+
const composition = await composeApp({ deploymentRoot });
|
|
1019
|
+
const app = await buildServer({
|
|
1020
|
+
sessionCookieSecret,
|
|
1021
|
+
openApi: { title: '${input.name}', version: '0.0.0', serverUrl: \`http://localhost:\${port}\` },
|
|
1022
|
+
modules: composition.modules,
|
|
1023
|
+
errorEnvelope: composition.errorEnvelope,
|
|
1024
|
+
apiInterceptors: composition.apiInterceptors,
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
const shutdown = async (signal: string): Promise<void> => {
|
|
1028
|
+
app.log.info({ signal }, 'shutting down');
|
|
1029
|
+
await app.close();
|
|
1030
|
+
await composition.dispose();
|
|
1031
|
+
process.exit(0);
|
|
1032
|
+
};
|
|
1033
|
+
process.once('SIGINT', () => void shutdown('SIGINT'));
|
|
1034
|
+
process.once('SIGTERM', () => void shutdown('SIGTERM'));
|
|
1035
|
+
|
|
1036
|
+
await app.listen({ port, host: '0.0.0.0' });
|
|
1037
|
+
`,
|
|
1038
|
+
});
|
|
1039
|
+
files.push({
|
|
1040
|
+
path: 'backend/src/worker.ts',
|
|
1041
|
+
kind: 'wiring',
|
|
1042
|
+
member: 'backend',
|
|
1043
|
+
content: `// The queue-consumer process (Principle X). Same composition, no listen.
|
|
1044
|
+
import { fileURLToPath } from 'node:url';
|
|
1045
|
+
|
|
1046
|
+
import { buildServer, composeApp } from '${scope}platform/composition';
|
|
1047
|
+
|
|
1048
|
+
process.env['BACKEND_ROLE'] = 'worker';
|
|
1049
|
+
const sessionCookieSecret = process.env['SESSION_COOKIE_SECRET'] ?? '';
|
|
1050
|
+
if (sessionCookieSecret === '') {
|
|
1051
|
+
console.error('SESSION_COOKIE_SECRET must be set.');
|
|
1052
|
+
process.exit(1);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const deploymentRoot = fileURLToPath(new URL('../..', import.meta.url));
|
|
1056
|
+
|
|
1057
|
+
const composition = await composeApp({ deploymentRoot });
|
|
1058
|
+
const app = await buildServer({
|
|
1059
|
+
sessionCookieSecret,
|
|
1060
|
+
openApi: { title: '${input.name} worker', version: '0.0.0', serverUrl: 'http://localhost' },
|
|
1061
|
+
modules: composition.modules,
|
|
1062
|
+
errorEnvelope: composition.errorEnvelope,
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
const shutdown = async (signal: string): Promise<void> => {
|
|
1066
|
+
app.log.info({ signal }, 'worker shutting down');
|
|
1067
|
+
await app.close();
|
|
1068
|
+
await composition.dispose();
|
|
1069
|
+
process.exit(0);
|
|
1070
|
+
};
|
|
1071
|
+
process.once('SIGINT', () => void shutdown('SIGINT'));
|
|
1072
|
+
process.once('SIGTERM', () => void shutdown('SIGTERM'));
|
|
1073
|
+
app.log.info('worker started');
|
|
1074
|
+
`,
|
|
1075
|
+
});
|
|
1076
|
+
files.push({
|
|
1077
|
+
path: 'backend/src/mikro-orm.config.ts',
|
|
1078
|
+
kind: 'wiring',
|
|
1079
|
+
member: 'backend',
|
|
1080
|
+
content: `// The ORM configuration. It exists because a bin has none and cannot get one.
|
|
1081
|
+
//
|
|
1082
|
+
// The configuration itself is the platform's: the naming strategy Principle VI
|
|
1083
|
+
// is enforced by, the Migrator extension \`getMigrator()\` needs, and the
|
|
1084
|
+
// migration options an \`allOrNothing\` run takes. A hand-written
|
|
1085
|
+
// \`defineConfig\` here would compile and then create tables the installed
|
|
1086
|
+
// migrations do not name.
|
|
1087
|
+
import {
|
|
1088
|
+
configuredEntitiesFrom,
|
|
1089
|
+
discoverConfiguredMigrations,
|
|
1090
|
+
mikroOrmConfigFrom,
|
|
1091
|
+
} from '${scope}platform/db';
|
|
1092
|
+
|
|
1093
|
+
export default async function config() {
|
|
1094
|
+
const url = process.env['DATABASE_URL'];
|
|
1095
|
+
if (url === undefined || url === '') throw new Error('DATABASE_URL must be set.');
|
|
1096
|
+
// The committed half is empty, which is what an instance is: it ships no
|
|
1097
|
+
// generated manifest index and no committed registry, so its entities and
|
|
1098
|
+
// its migrations are the packages it installed and nothing else.
|
|
1099
|
+
const [entities, migrations] = await Promise.all([
|
|
1100
|
+
configuredEntitiesFrom({ coreEntities: [] }),
|
|
1101
|
+
discoverConfiguredMigrations({ coreEntries: [], manifests: [] }),
|
|
1102
|
+
]);
|
|
1103
|
+
return mikroOrmConfigFrom({ entities, migrations });
|
|
1104
|
+
}
|
|
1105
|
+
`,
|
|
1106
|
+
});
|
|
1107
|
+
files.push({
|
|
1108
|
+
path: 'backend/src/migrate.ts',
|
|
1109
|
+
kind: 'wiring',
|
|
1110
|
+
member: 'backend',
|
|
1111
|
+
content: `// The schema, in the order the installed manifests compute. MikroORM's own
|
|
1112
|
+
// migrator over the configuration beside this file — no second ordering here.
|
|
1113
|
+
import { MikroORM } from '@mikro-orm/postgresql';
|
|
1114
|
+
import config from './mikro-orm.config.js';
|
|
1115
|
+
|
|
1116
|
+
const orm = await MikroORM.init(await config());
|
|
1117
|
+
try {
|
|
1118
|
+
await orm.getMigrator().up();
|
|
1119
|
+
} finally {
|
|
1120
|
+
await orm.close(true);
|
|
1121
|
+
}
|
|
1122
|
+
`,
|
|
1123
|
+
});
|
|
1124
|
+
// §2.3's sixth wiring file — the operator CLI
|
|
1125
|
+
// (`specs/123-oss-install-experience/` G2, T2-C). Five lines, because the
|
|
1126
|
+
// dispatch is `<scope>platform/cli`'s: argv, the demo verbs, the system scope
|
|
1127
|
+
// over the composed container and the exit code. What this file supplies is
|
|
1128
|
+
// the one thing no package can derive — the directory holding `apps/` — and
|
|
1129
|
+
// that is R1.4's definition of wiring.
|
|
1130
|
+
files.push({
|
|
1131
|
+
path: 'backend/src/cli.ts',
|
|
1132
|
+
kind: 'wiring',
|
|
1133
|
+
member: 'backend',
|
|
1134
|
+
content: `// Your modules' operator commands. \`pnpm run cli --list\` shows every one.
|
|
1135
|
+
import { fileURLToPath } from 'node:url';
|
|
1136
|
+
import { runCli } from '${scope}platform/cli';
|
|
1137
|
+
|
|
1138
|
+
await runCli({ deploymentRoot: fileURLToPath(new URL('../..', import.meta.url)) });
|
|
1139
|
+
`,
|
|
1140
|
+
});
|
|
1141
|
+
// The five `module:*` entry points' shared half, and it is fourteen lines
|
|
1142
|
+
// rather than ninety since `fix/instance-wiring-operator-runtime`. The
|
|
1143
|
+
// manifest resolution over three suppliers, the memoised `MikroORM` + `Redis`
|
|
1144
|
+
// open, the system scope and the exit code are
|
|
1145
|
+
// `<scope>platform/lifecycle`'s `runInstanceOperatorCommand`; what this file
|
|
1146
|
+
// supplies is the two values that genuinely name a path in the tree that
|
|
1147
|
+
// installs the platform — the directory holding `apps/`, and this instance's
|
|
1148
|
+
// own ORM configuration — and that is R1.4's definition of wiring. The
|
|
1149
|
+
// ninety-line version was more than a third of the whole 250-line budget and
|
|
1150
|
+
// is what A14 refused in `registry` mode.
|
|
1151
|
+
files.push({
|
|
1152
|
+
path: 'backend/src/module-commands/runtime.ts',
|
|
1153
|
+
kind: 'wiring',
|
|
1154
|
+
member: 'backend',
|
|
1155
|
+
content: `// The one OperatorRuntime the five commands beside this file share.
|
|
1156
|
+
import { fileURLToPath } from 'node:url';
|
|
1157
|
+
|
|
1158
|
+
import { runInstanceOperatorCommand } from '${scope}platform/lifecycle';
|
|
1159
|
+
import type { OperatorRuntime } from '${scope}platform/lifecycle';
|
|
1160
|
+
import config from '../mikro-orm.config.js';
|
|
1161
|
+
|
|
1162
|
+
// The directory that holds \`apps/\` — the same value \`index.ts\` hands
|
|
1163
|
+
// \`composeApp\`, two levels up from the compiled command rather than one.
|
|
1164
|
+
const deploymentRoot = fileURLToPath(new URL('../../..', import.meta.url));
|
|
1165
|
+
|
|
1166
|
+
/** Build the runtime, run one command inside a system scope, close, exit. */
|
|
1167
|
+
export function runOperatorCommand(
|
|
1168
|
+
run: (argv: readonly string[], rt: OperatorRuntime) => Promise<number>,
|
|
1169
|
+
): Promise<never> {
|
|
1170
|
+
return runInstanceOperatorCommand({ deploymentRoot, ormConfig: config, run });
|
|
1171
|
+
}
|
|
1172
|
+
`,
|
|
1173
|
+
});
|
|
1174
|
+
for (const command of ['install', 'uninstall', 'enable', 'disable', 'status']) {
|
|
1175
|
+
const runner = `run${command[0].toUpperCase()}${command.slice(1)}Command`;
|
|
1176
|
+
files.push({
|
|
1177
|
+
path: `backend/src/module-commands/${command}.ts`,
|
|
1178
|
+
kind: 'wiring',
|
|
1179
|
+
member: 'backend',
|
|
1180
|
+
content: `import { ${runner} } from '${scope}platform/lifecycle';
|
|
1181
|
+
import { runOperatorCommand } from './runtime.js';
|
|
1182
|
+
|
|
1183
|
+
await runOperatorCommand(${runner});
|
|
1184
|
+
`,
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
return files;
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* The README's command block — every root script, in the order a client meets
|
|
1191
|
+
* them, each with what it is for (feature 122 T003).
|
|
1192
|
+
*
|
|
1193
|
+
* The **set** is the manifest's, not this list's: a name here that the run did
|
|
1194
|
+
* not declare contributes no line, and a script the run declared with no entry
|
|
1195
|
+
* here is a hole the reconciliation in `test/new-instance.test.ts` reports.
|
|
1196
|
+
* That is the only relationship a second enumeration may have with the first
|
|
1197
|
+
* (D-100) — this one carries the *order* and the *sentence*, and nothing else.
|
|
1198
|
+
*
|
|
1199
|
+
* The three per-layer builds carry one sentence between them and it is the
|
|
1200
|
+
* whole of D-230: the backend, the admin and the storefront are deployed to
|
|
1201
|
+
* hosts of their own, so each is built by a command of its own.
|
|
1202
|
+
*/
|
|
1203
|
+
const README_COMMANDS = [
|
|
1204
|
+
// `specs/125-first-mile-install/` FR-108…FR-110, and the order is the order a
|
|
1205
|
+
// client meets them: the services first, because everything under them needs
|
|
1206
|
+
// one; the composite second, because it is what four of the lines below add
|
|
1207
|
+
// up to; and `preview:admin` beside `start`, because a built bundle nobody
|
|
1208
|
+
// can look at was baseline step D1.
|
|
1209
|
+
['dev:services', '', 'PostgreSQL, Redis, Meilisearch and a mail catcher, from\n`compose.dev.yml` beside this file. It waits for each one to\nreport healthy'],
|
|
1210
|
+
['dev:services:down', '', 'stops them, keeping their data'],
|
|
1211
|
+
['setup', '', 'generate, build, migrate and install every module — the four\nsteps below, in one line. Each still exists on its own'],
|
|
1212
|
+
['generate', '', 'the files the admin and the docs site are built from,\nand your deployment\'s divergence report'],
|
|
1213
|
+
['build', '', 'the entry points, compiled, and every member built'],
|
|
1214
|
+
['build:backend', '', 'one layer at a time. Each of the three is deployed on its own\nhost, on its own schedule, so each is built on its own too'],
|
|
1215
|
+
['build:admin', '', ''],
|
|
1216
|
+
['build:docs', '', ''],
|
|
1217
|
+
['migrate', '', 'the schema, in the order the manifests compute'],
|
|
1218
|
+
['module:install', ' --all', 'every module you declared, in dependency order'],
|
|
1219
|
+
['start', '', 'the API'],
|
|
1220
|
+
['preview:admin', '', 'the admin bundle you just built, served on its own port'],
|
|
1221
|
+
['dev:all', '', 'the API, the admin preview and the storefront beside this\ndirectory, in one terminal. Ctrl-C stops all of them'],
|
|
1222
|
+
['dev', '', 'the API, rebuilt and restarted as you edit your overlay'],
|
|
1223
|
+
['module:status', '', 'what is installed, and what the operator has switched on'],
|
|
1224
|
+
['module:enable', ' <id>', 'the operator\'s switch. A module that is off behaves as\nthough it were never installed'],
|
|
1225
|
+
['module:disable', ' <id>', ''],
|
|
1226
|
+
['module:uninstall', ' <id>', 'the reverse of `module:install`'],
|
|
1227
|
+
// `specs/123-oss-install-experience/` G2. `admin:create` is conditional on
|
|
1228
|
+
// `admin_users` being installed, which the filter below already handles: this
|
|
1229
|
+
// table is annotations, and which of them survive is the manifest's answer.
|
|
1230
|
+
['admin:create', ' -- --email=…', 'the administrator you sign in as. Nothing else creates one'],
|
|
1231
|
+
['cli', ' --list', 'every operator command your installed modules declare.\nRun one as `pnpm run cli <module> <command>`'],
|
|
1232
|
+
];
|
|
1233
|
+
/** The block itself, aligned, over the scripts this run declared. */
|
|
1234
|
+
function commandBlock(scripts) {
|
|
1235
|
+
const rows = README_COMMANDS.filter(([script]) => scripts[script] !== undefined).map(([script, argument, note]) => [`pnpm run ${script}${argument}`, note]);
|
|
1236
|
+
const width = Math.max(...rows.map(([invocation]) => invocation.length)) + 3;
|
|
1237
|
+
return rows
|
|
1238
|
+
.map(([invocation, note]) => {
|
|
1239
|
+
if (note.length === 0)
|
|
1240
|
+
return invocation;
|
|
1241
|
+
const [first, ...rest] = note.split('\n');
|
|
1242
|
+
return [
|
|
1243
|
+
`${invocation.padEnd(width)}# ${first}`,
|
|
1244
|
+
...rest.map((line) => `${''.padEnd(width)}# ${line}`),
|
|
1245
|
+
].join('\n');
|
|
1246
|
+
})
|
|
1247
|
+
.join('\n');
|
|
1248
|
+
}
|
|
1249
|
+
/** The client's own README: what this tree is, and what maintains it. */
|
|
1250
|
+
/**
|
|
1251
|
+
* The tree's own README — the client's, written once and never read by us
|
|
1252
|
+
* again.
|
|
1253
|
+
*
|
|
1254
|
+
* The "what is here" table is built from the members this run actually wrote,
|
|
1255
|
+
* not from a list: which members exist depends on what resolved (§2.4, §2.4a),
|
|
1256
|
+
* and a README naming a directory the command omitted is the first thing a
|
|
1257
|
+
* client would find wrong with their new tree.
|
|
1258
|
+
*
|
|
1259
|
+
* **The command block is the manifest's `scripts`, ordered and annotated** —
|
|
1260
|
+
* feature 122 T003. It used to be six lines of prose naming five of the twelve
|
|
1261
|
+
* scripts the manifest declares, so a client reading it could not learn that
|
|
1262
|
+
* `dev`, `module:status` or the per-layer builds exist. Both sides are now one
|
|
1263
|
+
* run's, reconciled in `test/new-instance.test.ts`: a script with no annotation
|
|
1264
|
+
* below is red rather than a line a client never sees.
|
|
1265
|
+
*/
|
|
1266
|
+
function readme(input, dependencyCount, scripts, members) {
|
|
1267
|
+
return `# ${input.name}
|
|
1268
|
+
|
|
1269
|
+
An Endora Commerce instance. It **composes** the platform; it is not a fork of it and holds a
|
|
1270
|
+
copy of no part of it.
|
|
1271
|
+
|
|
1272
|
+
## What is here
|
|
1273
|
+
|
|
1274
|
+
| | |
|
|
1275
|
+
| --- | --- |
|
|
1276
|
+
| \`package.json\` | the module list. There is no other: the \`dependencies\` are what this instance composes, and the platform discovers them from \`node_modules\` at runtime |
|
|
1277
|
+
| \`apps/${input.deployment}/\` | your deployment — your overlay modules, \`divergence.ts\` (what you declare) and \`divergence.generated.md\` (what is derived from it; commit it and read the diff) |
|
|
1278
|
+
| \`backend/\` | the entry points: a process that listens, a process that consumes queues, an ORM configuration and the operator commands |
|
|
1279
|
+
${members.admin ? '| `admin/` | the operator interface — the admin shell, mounted over the screens your modules ship |\n' : ''}${members.docs ? '| `docs/` | the documentation site — a page per module, written by the module that ships it |\n' : ''}
|
|
1280
|
+
${String(dependencyCount)} packages are declared today. Every one of them is a dependency, so a
|
|
1281
|
+
fix in any of them reaches you through \`pnpm update\` with no file in this tree edited.
|
|
1282
|
+
|
|
1283
|
+
## The commands this tree declares
|
|
1284
|
+
|
|
1285
|
+
\`\`\`
|
|
1286
|
+
pnpm install
|
|
1287
|
+
${commandBlock(scripts)}
|
|
1288
|
+
\`\`\`
|
|
1289
|
+
|
|
1290
|
+
Your modules arrive as installed packages, and a package is installed by \`module:install\` and
|
|
1291
|
+
by **no boot**: that command applies its migrations, reconciles its settings and runs its
|
|
1292
|
+
install hook. Until it has run, \`start\` refuses and names the modules the platform requires.
|
|
1293
|
+
Adding a module later is \`pnpm add\`, then \`pnpm run migrate\` and \`module:install\` again —
|
|
1294
|
+
both are idempotent, so running them over a set that is already installed changes nothing.
|
|
1295
|
+
|
|
1296
|
+
## Changing what the platform does
|
|
1297
|
+
|
|
1298
|
+
Four seams before a fork, in order of cost: the EventBus, an API interceptor, a strategy port,
|
|
1299
|
+
and \`ctx.di.decorate\` from your own overlay module in \`apps/${input.deployment}/modules/\`.
|
|
1300
|
+
Decoration is the only way an instance changes a platform behaviour — there is no file to
|
|
1301
|
+
shadow, because there is no file.
|
|
1302
|
+
|
|
1303
|
+
An overlay module is TypeScript that **nothing in this tree compiles** — Node loads it and
|
|
1304
|
+
strips the types as it goes. So it is written in the subset stripping accepts: an \`enum\`, a
|
|
1305
|
+
\`namespace\` or a constructor parameter property raises
|
|
1306
|
+
\`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX\` at boot, and no type-check you can run here reports it,
|
|
1307
|
+
because all three type-check cleanly. A union of string literals is the \`enum\` you want.
|
|
1308
|
+
|
|
1309
|
+
Whatever you reach for, \`pnpm run generate\` records it in
|
|
1310
|
+
\`apps/${input.deployment}/divergence.generated.md\`: every seam you used, which module owns
|
|
1311
|
+
the thing you changed, what that seam costs on the escalation ladder, and the sentence you
|
|
1312
|
+
wrote about it in \`divergence.ts\`. A divergence with no sentence is reported; so is a
|
|
1313
|
+
sentence describing a divergence that is gone.
|
|
1314
|
+
|
|
1315
|
+
A module you will never publish belongs in that directory. A module you intend to publish or
|
|
1316
|
+
install into a second instance is a package: \`pnpm pack\`, then install the tarball. A
|
|
1317
|
+
\`pnpm link\` is deliberately invisible to the platform's discovery, so it is neither.
|
|
1318
|
+
|
|
1319
|
+
## Next
|
|
1320
|
+
|
|
1321
|
+
\`endora new storefront <dir>\` writes the customer-facing storefront. It is a separate
|
|
1322
|
+
repository on purpose: it shares two \`.env\` values with this tree and nothing else.
|
|
1323
|
+
`;
|
|
1324
|
+
}
|
|
1325
|
+
// ── the admin member (§2.4) ─────────────────────────────────────────────────
|
|
1326
|
+
//
|
|
1327
|
+
// `contracts/instance-tree.md` §2.4 in full: *"`admin/index.html`,
|
|
1328
|
+
// `admin/src/main.tsx`, `admin/vite.config.ts`, `admin/tailwind.config.ts`, the
|
|
1329
|
+
// brand assets, the theme **overrides**, and the **generated** contribution
|
|
1330
|
+
// registry and stylesheet enumeration (§2.6). Nothing else."*
|
|
1331
|
+
//
|
|
1332
|
+
// Two of those nouns no longer describe the tree and are written down here
|
|
1333
|
+
// rather than discovered by the next reader.
|
|
1334
|
+
//
|
|
1335
|
+
// * **`tailwind.config.ts` does not exist**, in this repository or anywhere
|
|
1336
|
+
// else: Tailwind v4 has no configuration file, and its `@theme` and
|
|
1337
|
+
// `@source` are CSS. What the member holds in its place is `src/index.css`
|
|
1338
|
+
// — the two imports and the override slot, which is §2.4's *"theme
|
|
1339
|
+
// overrides"* and `admin-stylesheet-composition.md` R3.1's.
|
|
1340
|
+
// * **`package.json` and `tsconfig.json` are not in §2.4's list** and a
|
|
1341
|
+
// workspace member is neither without them. §2.3 lists both for the backend;
|
|
1342
|
+
// the omission there is the list's rather than the design's.
|
|
1343
|
+
//
|
|
1344
|
+
// **The brand assets are not written**, and that is a decision rather than a
|
|
1345
|
+
// gap: a logo, a favicon and a PWA icon set are exactly the values R2.5a says a
|
|
1346
|
+
// command may not invent, and an instance that shipped ours would be wearing
|
|
1347
|
+
// our name. The reference deployment's `public/` also carries the admin service
|
|
1348
|
+
// worker, so `registerAdminServiceWorker` — which the shell exports and this
|
|
1349
|
+
// entry point does **not** call — would register an asset the tree does not
|
|
1350
|
+
// serve. A client drops their own files in `admin/public/` and links them from
|
|
1351
|
+
// `index.html`, both of which are theirs.
|
|
1352
|
+
/**
|
|
1353
|
+
* What the operator is told a declined member means — one sentence per
|
|
1354
|
+
* declinable member, keyed by the vocabulary's own names.
|
|
1355
|
+
*/
|
|
1356
|
+
const DECLINED_CONSEQUENCE = {
|
|
1357
|
+
admin: 'This instance is a headless API by choice. Its backend still serves /api/v1/admin/*, so ' +
|
|
1358
|
+
'an operator interface built elsewhere reaches it.',
|
|
1359
|
+
docs: 'The pages your module packages ship are still in their tarballs; this instance builds no ' +
|
|
1360
|
+
'site to read them in.',
|
|
1361
|
+
};
|
|
1362
|
+
/**
|
|
1363
|
+
* A member decision, with the operator's `--without` applied on top of it
|
|
1364
|
+
* (118 R3.5c, R4.1).
|
|
1365
|
+
*
|
|
1366
|
+
* **Every reason that holds is printed, never the first.** A member declined
|
|
1367
|
+
* *and* unavailable in this build is two facts with two different remedies —
|
|
1368
|
+
* one stops being true when a package publishes and the other does not — so
|
|
1369
|
+
* the availability sentence the decision already carries is kept beside the
|
|
1370
|
+
* declined one rather than replaced by it.
|
|
1371
|
+
*/
|
|
1372
|
+
function declinable(member, decision, input) {
|
|
1373
|
+
if (input.without?.has(member) !== true)
|
|
1374
|
+
return decision;
|
|
1375
|
+
const declined = `declined: you passed --without ${member}. ${DECLINED_CONSEQUENCE[member]} Scaffolding ` +
|
|
1376
|
+
`again without that flag writes it`;
|
|
1377
|
+
return {
|
|
1378
|
+
written: false,
|
|
1379
|
+
files: [],
|
|
1380
|
+
omission: decision.omission === null ? declined : `${declined}; and unavailable: ${decision.omission}`,
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* The packages the admin member declares, each range read off a manifest this
|
|
1385
|
+
* run resolved (R2.5a) — or the names that had none.
|
|
1386
|
+
*
|
|
1387
|
+
* **It declares no module**, and that is R3.6: *"the set appears exactly once in
|
|
1388
|
+
* the written tree — the root manifest's `dependencies`"*. An instance is a
|
|
1389
|
+
* workspace whose root holds the module packages, so pnpm links them into the
|
|
1390
|
+
* root's `node_modules`, which is where this member's own resolution reaches
|
|
1391
|
+
* them. A second spelling here is the one disagreement nothing in a client's
|
|
1392
|
+
* tree could detect.
|
|
1393
|
+
*
|
|
1394
|
+
* What it does declare is what its **own two source files name**: the shell
|
|
1395
|
+
* `main.tsx` mounts, the design system `index.css` imports, React, and the
|
|
1396
|
+
* build tools. The ranges for those come off the shell's own manifest — its
|
|
1397
|
+
* `peerDependencies` are what a host that mounts it must resolve, and its
|
|
1398
|
+
* optional peers are the shell's statement about what kind of application a
|
|
1399
|
+
* host is.
|
|
1400
|
+
*/
|
|
1401
|
+
function adminMemberPackages(input) {
|
|
1402
|
+
const missing = [];
|
|
1403
|
+
const range = (name) => {
|
|
1404
|
+
const found = input.adminRanges.get(name);
|
|
1405
|
+
if (found === undefined || found.length === 0 || found.startsWith('workspace:')) {
|
|
1406
|
+
missing.push(name);
|
|
1407
|
+
return null;
|
|
1408
|
+
}
|
|
1409
|
+
return found;
|
|
1410
|
+
};
|
|
1411
|
+
const dependencies = new Map([
|
|
1412
|
+
[`${input.scope}admin-kit`, `^${input.adminKitVersion ?? ''}`],
|
|
1413
|
+
[`${input.scope}admin-shell`, `^${input.adminShellVersion ?? ''}`],
|
|
1414
|
+
]);
|
|
1415
|
+
const devDependencies = [
|
|
1416
|
+
[`${input.scope}cli`, `^${input.cliVersion}`],
|
|
1417
|
+
];
|
|
1418
|
+
for (const name of ['react', 'react-dom']) {
|
|
1419
|
+
const declared = range(name);
|
|
1420
|
+
if (declared !== null)
|
|
1421
|
+
dependencies.set(name, declared);
|
|
1422
|
+
}
|
|
1423
|
+
// Nothing else. What the **installed modules** declare optional is the root
|
|
1424
|
+
// manifest's, for the reason written beside it there: pnpm resolves a peer
|
|
1425
|
+
// from the dependent's context, and their dependent is the root.
|
|
1426
|
+
// `typescript` is the CLI's own (R2.3's first named source), exactly as the
|
|
1427
|
+
// backend member's is; the four build tools are the shell's optional peers.
|
|
1428
|
+
const typescript = input.declaredRanges.get('typescript');
|
|
1429
|
+
if (typescript === undefined || typescript.length === 0)
|
|
1430
|
+
missing.push('typescript');
|
|
1431
|
+
else
|
|
1432
|
+
devDependencies.push(['typescript', typescript]);
|
|
1433
|
+
for (const name of ['@tailwindcss/vite', '@vitejs/plugin-react', 'tailwindcss', 'vite']) {
|
|
1434
|
+
const declared = range(name);
|
|
1435
|
+
if (declared !== null)
|
|
1436
|
+
devDependencies.push([name, declared]);
|
|
1437
|
+
}
|
|
1438
|
+
return {
|
|
1439
|
+
dependencies: [...dependencies].sort(([a], [b]) => a.localeCompare(b)),
|
|
1440
|
+
devDependencies: devDependencies.sort(([a], [b]) => a.localeCompare(b)),
|
|
1441
|
+
missing,
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* The four the admin member declares as `devDependencies` rather than as
|
|
1446
|
+
* dependencies, because they build the bundle and are not in it.
|
|
1447
|
+
*
|
|
1448
|
+
* They reach this command as the admin shell's own optional peers — its
|
|
1449
|
+
* statement that a host which mounts it is a Vite application compiled with
|
|
1450
|
+
* Tailwind v4 — and that is one set, whichever block a consumer files each
|
|
1451
|
+
* member under.
|
|
1452
|
+
*/
|
|
1453
|
+
const BUILD_TOOL_PEERS = new Set([
|
|
1454
|
+
'@tailwindcss/vite',
|
|
1455
|
+
'@vitejs/plugin-react',
|
|
1456
|
+
'tailwindcss',
|
|
1457
|
+
'vite',
|
|
1458
|
+
]);
|
|
1459
|
+
/**
|
|
1460
|
+
* §2.4a, decided and rendered — or omitted, in the admin member's own grammar.
|
|
1461
|
+
*
|
|
1462
|
+
* **Why a member at all.** `instance-tree.md` §2.6 lists the documentation
|
|
1463
|
+
* registry among the three artefacts an instance generates and §2 listed no
|
|
1464
|
+
* member that would hold it, so the `.gitignore` this command writes has named
|
|
1465
|
+
* `docs/sidebars.modules.generated.js` since the command landed and nothing
|
|
1466
|
+
* wrote a site for it. A client installs thirty module packages, each shipping
|
|
1467
|
+
* its own `docs/` layer in its tarball, and until this member existed there was
|
|
1468
|
+
* nowhere for a human to read one.
|
|
1469
|
+
*
|
|
1470
|
+
* **Why an omission and not a refusal**, exactly as for the admin member: the
|
|
1471
|
+
* owner's subject is a backend instance, and a command that refused to write one
|
|
1472
|
+
* until Docusaurus had a range would be a command nobody could use. And **why
|
|
1473
|
+
* not silence**: a client who does not know they have no documentation site goes
|
|
1474
|
+
* looking for one.
|
|
1475
|
+
*
|
|
1476
|
+
* **Four files and no `tsconfig.json`.** The configuration and the sidebar are
|
|
1477
|
+
* `.js` rather than `.ts` — Docusaurus reads both — which is what keeps the
|
|
1478
|
+
* member off `@docusaurus/tsconfig`, `@docusaurus/types` and
|
|
1479
|
+
* `@docusaurus/module-type-aliases`, three more ranges this command would have
|
|
1480
|
+
* to find a source for in order to write a file whose whole content is two
|
|
1481
|
+
* objects. `sidebars.js` is also the name `resolveDocsLayout` looks for.
|
|
1482
|
+
*/
|
|
1483
|
+
function docsMember(input) {
|
|
1484
|
+
const missing = ['@docusaurus/core', '@docusaurus/preset-classic'].filter((name) => {
|
|
1485
|
+
const range = input.docsRanges.get(name);
|
|
1486
|
+
return range === undefined || range.length === 0 || range.startsWith('workspace:');
|
|
1487
|
+
});
|
|
1488
|
+
if (missing.length > 0) {
|
|
1489
|
+
return {
|
|
1490
|
+
written: false,
|
|
1491
|
+
files: [],
|
|
1492
|
+
omission: `no manifest this run resolved declares a range for ${missing.join(', ')}, and the ` +
|
|
1493
|
+
`documentation site is built with ${missing.length === 1 ? 'it' : 'them'}. A range ` +
|
|
1494
|
+
`this command chose would be a value nobody reviewed, so none is written and the ` +
|
|
1495
|
+
`pages your module packages ship have no site to be read in`,
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
return { written: true, files: docsFiles(input), omission: null };
|
|
1499
|
+
}
|
|
1500
|
+
/** The four files §2.4a's member is, in the order the plan writes them. */
|
|
1501
|
+
function docsFiles(input) {
|
|
1502
|
+
return [
|
|
1503
|
+
{
|
|
1504
|
+
path: 'docs/package.json',
|
|
1505
|
+
kind: 'derived',
|
|
1506
|
+
member: 'docs',
|
|
1507
|
+
content: json({
|
|
1508
|
+
name: `${input.name}-docs`,
|
|
1509
|
+
private: true,
|
|
1510
|
+
scripts: {
|
|
1511
|
+
// `endora generate` first, for the reason the admin member's `build`
|
|
1512
|
+
// runs it first: Docusaurus is a static build, so a navigation that
|
|
1513
|
+
// is stale or absent is a site with pages missing, and
|
|
1514
|
+
// `onBrokenLinks: 'throw'` turns the *absent* half into a crash
|
|
1515
|
+
// rather than a silence — which is the better of the two failures and
|
|
1516
|
+
// still not one a client should have to meet.
|
|
1517
|
+
generate: 'endora generate',
|
|
1518
|
+
dev: 'endora generate && docusaurus start',
|
|
1519
|
+
build: 'endora generate && docusaurus build',
|
|
1520
|
+
serve: 'docusaurus serve',
|
|
1521
|
+
},
|
|
1522
|
+
dependencies: {
|
|
1523
|
+
'@docusaurus/core': input.docsRanges.get('@docusaurus/core'),
|
|
1524
|
+
'@docusaurus/preset-classic': input.docsRanges.get('@docusaurus/preset-classic'),
|
|
1525
|
+
},
|
|
1526
|
+
devDependencies: { [`${input.scope}cli`]: `^${input.cliVersion}` },
|
|
1527
|
+
}),
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
path: 'docs/docusaurus.config.js',
|
|
1531
|
+
kind: 'client',
|
|
1532
|
+
member: 'docs',
|
|
1533
|
+
content: docsConfig(input),
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
path: 'docs/sidebars.js',
|
|
1537
|
+
kind: 'client',
|
|
1538
|
+
member: 'docs',
|
|
1539
|
+
content: docsSidebar(),
|
|
1540
|
+
},
|
|
1541
|
+
{
|
|
1542
|
+
path: 'docs/docs/intro.md',
|
|
1543
|
+
kind: 'client',
|
|
1544
|
+
member: 'docs',
|
|
1545
|
+
content: docsIntro(input),
|
|
1546
|
+
},
|
|
1547
|
+
];
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* The site's configuration — the client's, in the sense `admin/vite.config.ts`
|
|
1551
|
+
* is theirs: build-tool configuration they will edit.
|
|
1552
|
+
*
|
|
1553
|
+
* Three values are load-bearing rather than decorative. `onBrokenLinks: 'throw'`
|
|
1554
|
+
* is what makes a navigation entry naming a page that is not there fail the
|
|
1555
|
+
* build instead of serving a 404 nobody notices — it is the property feature
|
|
1556
|
+
* 100's own `build:docs` job exists for. The docs plugin declares **no**
|
|
1557
|
+
* `path`, so the content root is Docusaurus's own `docs/`, which is where
|
|
1558
|
+
* `endora generate` puts the pages your modules ship. And `routeBasePath: '/'`
|
|
1559
|
+
* makes the documentation the site rather than a section of one: an instance's
|
|
1560
|
+
* documentation site has nothing else in it.
|
|
1561
|
+
*/
|
|
1562
|
+
function docsConfig(input) {
|
|
1563
|
+
return `// @ts-check
|
|
1564
|
+
// The documentation site of this instance. Yours to brand and to extend — the
|
|
1565
|
+
// title, the URL and the navbar below are values nobody but you can supply.
|
|
1566
|
+
//
|
|
1567
|
+
// What you should not remove:
|
|
1568
|
+
//
|
|
1569
|
+
// * \`onBrokenLinks: 'throw'\` — a navigation entry naming a page that is not
|
|
1570
|
+
// there fails the build instead of serving a 404 nobody notices;
|
|
1571
|
+
// * the docs plugin's absent \`path\` — the content root is Docusaurus's own
|
|
1572
|
+
// \`docs/\`, which is where \`endora generate\` copies the pages your module
|
|
1573
|
+
// packages ship.
|
|
1574
|
+
|
|
1575
|
+
/** @type {import('@docusaurus/types').Config} */
|
|
1576
|
+
const config = {
|
|
1577
|
+
title: '${input.name} documentation',
|
|
1578
|
+
tagline: 'Every module this instance installed, documented by the module that ships it.',
|
|
1579
|
+
url: 'https://example.com',
|
|
1580
|
+
baseUrl: '/',
|
|
1581
|
+
onBrokenLinks: 'throw',
|
|
1582
|
+
onBrokenMarkdownLinks: 'warn',
|
|
1583
|
+
favicon: undefined,
|
|
1584
|
+
presets: [
|
|
1585
|
+
[
|
|
1586
|
+
'classic',
|
|
1587
|
+
{
|
|
1588
|
+
docs: {
|
|
1589
|
+
sidebarPath: './sidebars.js',
|
|
1590
|
+
routeBasePath: '/',
|
|
1591
|
+
},
|
|
1592
|
+
blog: false,
|
|
1593
|
+
},
|
|
1594
|
+
],
|
|
1595
|
+
],
|
|
1596
|
+
themeConfig: {
|
|
1597
|
+
navbar: {
|
|
1598
|
+
title: '${input.name}',
|
|
1599
|
+
items: [{ type: 'docSidebar', sidebarId: 'main', position: 'left', label: 'Documentation' }],
|
|
1600
|
+
},
|
|
1601
|
+
},
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
module.exports = config;
|
|
1605
|
+
`;
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* The navigation — the client's own, with one generated category in it.
|
|
1609
|
+
*
|
|
1610
|
+
* It is `admin/src/index.css`'s shape rather than `admin/src/main.tsx`'s: the
|
|
1611
|
+
* file is yours, and one line of it names a file a generator writes. Everything
|
|
1612
|
+
* you add goes beside \`'intro'\`; nothing you add has to know that the Modules
|
|
1613
|
+
* category is derived.
|
|
1614
|
+
*/
|
|
1615
|
+
function docsSidebar() {
|
|
1616
|
+
return `// @ts-check
|
|
1617
|
+
|
|
1618
|
+
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
|
1619
|
+
const sidebars = {
|
|
1620
|
+
main: [
|
|
1621
|
+
'intro',
|
|
1622
|
+
{
|
|
1623
|
+
type: 'category',
|
|
1624
|
+
label: 'Modules',
|
|
1625
|
+
link: { type: 'generated-index', title: 'Modules' },
|
|
1626
|
+
// Generated by \`endora generate\` over the module packages THIS instance
|
|
1627
|
+
// installed, and not committed: a different module set is a different
|
|
1628
|
+
// navigation. Run \`pnpm run generate\` before the first build.
|
|
1629
|
+
items: require('./sidebars.modules.generated.js'),
|
|
1630
|
+
},
|
|
1631
|
+
],
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
module.exports = sidebars;
|
|
1635
|
+
`;
|
|
1636
|
+
}
|
|
1637
|
+
/** The page the site opens on. Yours from the first word. */
|
|
1638
|
+
function docsIntro(input) {
|
|
1639
|
+
return `---
|
|
1640
|
+
title: ${input.name}
|
|
1641
|
+
sidebar_label: Start here
|
|
1642
|
+
slug: /
|
|
1643
|
+
---
|
|
1644
|
+
|
|
1645
|
+
# ${input.name}
|
|
1646
|
+
|
|
1647
|
+
This is your instance's documentation site. Everything under **Modules** is
|
|
1648
|
+
written by the module that ships it and copied in by \`pnpm run generate\`, so a
|
|
1649
|
+
module you install brings its own pages and a module you remove takes them with
|
|
1650
|
+
it. Nothing under that category is yours to edit — the next \`generate\` undoes
|
|
1651
|
+
it.
|
|
1652
|
+
|
|
1653
|
+
Everything else is yours. Write your own operating notes beside this page and
|
|
1654
|
+
add them to \`sidebars.js\`.
|
|
1655
|
+
`;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* §2.4, decided and rendered — or omitted, in `new storefront`'s own grammar.
|
|
1659
|
+
*
|
|
1660
|
+
* **Why an omission and not a refusal**, unchanged from the build that wrote no
|
|
1661
|
+
* admin at all: *"the owner's subject is a backend instance, and a command that
|
|
1662
|
+
* refused to write one until an unrelated package existed would be a command
|
|
1663
|
+
* nobody could use to find out whether any of this works."* **Why not silence**:
|
|
1664
|
+
* `instance-repository.md` R8.2's reasoning one surface over — a client who does
|
|
1665
|
+
* not know they have no operator interface spends their first hour looking for
|
|
1666
|
+
* one.
|
|
1667
|
+
*
|
|
1668
|
+
* There are now two ways to reach that omission and they are reported apart,
|
|
1669
|
+
* because the remedies are different: a build in which the shell or the design
|
|
1670
|
+
* system does not resolve, and a build in which one of them does and a range its
|
|
1671
|
+
* own manifest should have declared is not there. The second is R2.5a — *"a
|
|
1672
|
+
* value the tool invented is a value nobody reviewed"* — and naming the range
|
|
1673
|
+
* is what makes it actionable rather than mysterious.
|
|
1674
|
+
*/
|
|
1675
|
+
function adminMember(input) {
|
|
1676
|
+
const absent = [
|
|
1677
|
+
...(input.adminShellVersion === null ? [`${input.scope}admin-shell`] : []),
|
|
1678
|
+
...(input.adminKitVersion === null ? [`${input.scope}admin-kit`] : []),
|
|
1679
|
+
];
|
|
1680
|
+
if (absent.length > 0) {
|
|
1681
|
+
return {
|
|
1682
|
+
written: false,
|
|
1683
|
+
files: [],
|
|
1684
|
+
omission: `${absent.join(' and ')} ${absent.length === 1 ? 'does' : 'do'} not resolve at the ` +
|
|
1685
|
+
`version being installed, and the admin member is mounted on ${absent.length === 1 ? 'it' : 'them'}; an instance scaffolded now is a headless API`,
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
const packages = adminMemberPackages(input);
|
|
1689
|
+
if (packages.missing.length > 0) {
|
|
1690
|
+
return {
|
|
1691
|
+
written: false,
|
|
1692
|
+
files: [],
|
|
1693
|
+
omission: `no manifest this run resolved declares a range for ${packages.missing.join(', ')}, ` +
|
|
1694
|
+
`and the admin member cannot be built without ${packages.missing.length === 1 ? 'it' : 'them'}. A range this command chose would be a value nobody reviewed, so none is written ` +
|
|
1695
|
+
`and an instance scaffolded now is a headless API`,
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
return { written: true, files: adminFiles(input, packages), omission: null };
|
|
1699
|
+
}
|
|
1700
|
+
/** The six files §2.4's member is, in the order the plan writes them. */
|
|
1701
|
+
function adminFiles(input, packages) {
|
|
1702
|
+
return [
|
|
1703
|
+
{
|
|
1704
|
+
path: 'admin/package.json',
|
|
1705
|
+
kind: 'derived',
|
|
1706
|
+
member: 'admin',
|
|
1707
|
+
content: json({
|
|
1708
|
+
name: `${input.name}-admin`,
|
|
1709
|
+
private: true,
|
|
1710
|
+
type: 'module',
|
|
1711
|
+
scripts: {
|
|
1712
|
+
// `endora generate` renders §2.6's two artefacts over the packages
|
|
1713
|
+
// this instance installed, and `build` runs it first for the reason
|
|
1714
|
+
// both artefacts exist: Vite and Tailwind are static, so a stale or
|
|
1715
|
+
// absent registry is a bundle with screens missing and a stylesheet
|
|
1716
|
+
// with classes missing, neither of which fails loudly.
|
|
1717
|
+
generate: 'endora generate',
|
|
1718
|
+
dev: 'endora generate && vite',
|
|
1719
|
+
build: 'endora generate && vite build',
|
|
1720
|
+
preview: 'vite preview',
|
|
1721
|
+
typecheck: 'tsc --noEmit',
|
|
1722
|
+
},
|
|
1723
|
+
dependencies: Object.fromEntries(packages.dependencies),
|
|
1724
|
+
devDependencies: Object.fromEntries(packages.devDependencies),
|
|
1725
|
+
}),
|
|
1726
|
+
},
|
|
1727
|
+
{
|
|
1728
|
+
path: 'admin/tsconfig.json',
|
|
1729
|
+
kind: 'client',
|
|
1730
|
+
member: 'admin',
|
|
1731
|
+
content: json({
|
|
1732
|
+
compilerOptions: {
|
|
1733
|
+
target: 'ES2023',
|
|
1734
|
+
lib: ['DOM', 'DOM.Iterable', 'ES2023'],
|
|
1735
|
+
module: 'ESNext',
|
|
1736
|
+
moduleResolution: 'Bundler',
|
|
1737
|
+
jsx: 'react-jsx',
|
|
1738
|
+
strict: true,
|
|
1739
|
+
skipLibCheck: true,
|
|
1740
|
+
noEmit: true,
|
|
1741
|
+
types: ['vite/client'],
|
|
1742
|
+
baseUrl: '.',
|
|
1743
|
+
// The `"@/*"` alias is how `endora generate` finds this project: both
|
|
1744
|
+
// artefacts land in the source root of the workspace member that
|
|
1745
|
+
// declares it, which is the derivation `check:admin-surface` and
|
|
1746
|
+
// `check:admin-zones` already share. Renaming it moves the artefacts;
|
|
1747
|
+
// deleting it leaves the generator with no project to write to.
|
|
1748
|
+
paths: { '@/*': ['./src/*'] },
|
|
1749
|
+
},
|
|
1750
|
+
include: ['src'],
|
|
1751
|
+
}),
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
path: 'admin/index.html',
|
|
1755
|
+
kind: 'client',
|
|
1756
|
+
member: 'admin',
|
|
1757
|
+
content: adminIndexHtml(input),
|
|
1758
|
+
},
|
|
1759
|
+
{
|
|
1760
|
+
path: 'admin/vite.config.ts',
|
|
1761
|
+
kind: 'client',
|
|
1762
|
+
member: 'admin',
|
|
1763
|
+
content: adminViteConfig(),
|
|
1764
|
+
},
|
|
1765
|
+
{
|
|
1766
|
+
path: 'admin/src/main.tsx',
|
|
1767
|
+
kind: 'wiring',
|
|
1768
|
+
member: 'admin',
|
|
1769
|
+
content: `import { createRoot } from 'react-dom/client';
|
|
1770
|
+
import { AdminRoot } from '${input.scope}admin-shell';
|
|
1771
|
+
import { MODULE_ADMIN_CONTRIBUTIONS } from './modules.generated.js';
|
|
1772
|
+
import './index.css';
|
|
1773
|
+
|
|
1774
|
+
const root = document.getElementById('root');
|
|
1775
|
+
if (root === null) throw new Error('index.html has no #root to mount into.');
|
|
1776
|
+
|
|
1777
|
+
createRoot(root).render(<AdminRoot contributions={MODULE_ADMIN_CONTRIBUTIONS} />);
|
|
1778
|
+
`,
|
|
1779
|
+
},
|
|
1780
|
+
{
|
|
1781
|
+
path: 'admin/src/index.css',
|
|
1782
|
+
kind: 'client',
|
|
1783
|
+
member: 'admin',
|
|
1784
|
+
content: adminStylesheet(input),
|
|
1785
|
+
},
|
|
1786
|
+
];
|
|
1787
|
+
}
|
|
1788
|
+
/** The document the bundle mounts into — the client's, and the client's to brand. */
|
|
1789
|
+
function adminIndexHtml(input) {
|
|
1790
|
+
return `<!doctype html>
|
|
1791
|
+
<html lang="en">
|
|
1792
|
+
<head>
|
|
1793
|
+
<meta charset="UTF-8" />
|
|
1794
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1795
|
+
<!-- An operator interface is not a public page. -->
|
|
1796
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
1797
|
+
<title>${input.name} — Admin</title>
|
|
1798
|
+
<!--
|
|
1799
|
+
This file is yours. A favicon, a web app manifest, your own fonts and
|
|
1800
|
+
your own <meta> all go here, and the files they name go in \`public/\`.
|
|
1801
|
+
The scaffold writes none of them: a logo is exactly the value a tool may
|
|
1802
|
+
not invent for you.
|
|
1803
|
+
-->
|
|
1804
|
+
</head>
|
|
1805
|
+
<body>
|
|
1806
|
+
<div id="root"></div>
|
|
1807
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
1808
|
+
</body>
|
|
1809
|
+
</html>
|
|
1810
|
+
`;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* The build, which is Vite's business and therefore the client's file.
|
|
1814
|
+
*
|
|
1815
|
+
* Two plugins and a port. `@tailwindcss/vite` is what compiles `index.css`, and
|
|
1816
|
+
* without it every class in this admin is an unrecognised token — silently,
|
|
1817
|
+
* which is the failure `admin-stylesheet-composition.md` is about.
|
|
1818
|
+
*/
|
|
1819
|
+
function adminViteConfig() {
|
|
1820
|
+
return `import { defineConfig, loadEnv } from 'vite';
|
|
1821
|
+
import react from '@vitejs/plugin-react';
|
|
1822
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
1823
|
+
|
|
1824
|
+
export default defineConfig(({ mode }) => {
|
|
1825
|
+
// \`loadEnv(mode, cwd, '')\` — the empty prefix lets this file read an
|
|
1826
|
+
// unprefixed variable such as \`PORT\`. Only \`VITE_*\` reaches the bundle.
|
|
1827
|
+
const env = loadEnv(mode, process.cwd(), '');
|
|
1828
|
+
const port = Number(env['PORT']) || 3002;
|
|
1829
|
+
return {
|
|
1830
|
+
plugins: [react(), tailwindcss()],
|
|
1831
|
+
server: { port, strictPort: true },
|
|
1832
|
+
preview: { port, strictPort: true },
|
|
1833
|
+
// No source maps in a built admin: \`dist\` is served as static files, and a
|
|
1834
|
+
// \`.map\` beside a chunk is every module's screens, route guards and
|
|
1835
|
+
// permission codes, readable by anyone who can reach the app. A
|
|
1836
|
+
// reproduction runs \`pnpm run dev\`, which has them.
|
|
1837
|
+
build: { outDir: 'dist', sourcemap: false },
|
|
1838
|
+
};
|
|
1839
|
+
});
|
|
1840
|
+
`;
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* The stylesheet — three imports and the override slot, in that order
|
|
1844
|
+
* (`admin-stylesheet-composition.md` R2.3, R3.1, R3.4).
|
|
1845
|
+
*
|
|
1846
|
+
* The order is the whole mechanism: Tailwind first, the design system's tokens
|
|
1847
|
+
* and classes second, the installed packages' `@source` declarations third, and
|
|
1848
|
+
* this deployment's redeclarations **last**, so a later rule wins over the
|
|
1849
|
+
* package's default. Each semantic token is an indirection, so a utility the
|
|
1850
|
+
* design system's own build never saw still resolves against the `:root` below.
|
|
1851
|
+
*/
|
|
1852
|
+
function adminStylesheet(input) {
|
|
1853
|
+
return `@import "tailwindcss";
|
|
1854
|
+
|
|
1855
|
+
/*
|
|
1856
|
+
* The admin's design system — its tokens **and** its class vocabulary. It is a
|
|
1857
|
+
* package's, not this project's: a copy of it here would be a fork frozen on
|
|
1858
|
+
* the day you scaffolded, and a module release adding one class would render
|
|
1859
|
+
* unstyled in this instance with no diagnostic anywhere.
|
|
1860
|
+
*
|
|
1861
|
+
* Change a token by redeclaring it in the slot at the bottom of this file, and
|
|
1862
|
+
* a class by writing a later rule there. Never by editing the package.
|
|
1863
|
+
*/
|
|
1864
|
+
@import "${input.scope}admin-kit/theme.css";
|
|
1865
|
+
|
|
1866
|
+
/*
|
|
1867
|
+
* The packages this admin composes, each declaring its own sources.
|
|
1868
|
+
*
|
|
1869
|
+
* Generated by \`pnpm run generate\` (\`endora generate\`) over the packages this
|
|
1870
|
+
* instance installed, and git-ignored: which packages those are is a fact about
|
|
1871
|
+
* the install rather than about this tree. Tailwind is a static scan and says
|
|
1872
|
+
* nothing about a source that matches nothing, so a package that is not scanned
|
|
1873
|
+
* loses every utility class only it declares — silently. Here the failures are
|
|
1874
|
+
* loud instead: a package that is not installed is \`Can't resolve\`, and one
|
|
1875
|
+
* whose tarball omits the file is \`ERR_PACKAGE_PATH_NOT_EXPORTED\`.
|
|
1876
|
+
*/
|
|
1877
|
+
@import "./tailwind.generated.css";
|
|
1878
|
+
|
|
1879
|
+
/*
|
|
1880
|
+
* Your overrides go here, last. A \`:root\` line for a token, an ordinary rule
|
|
1881
|
+
* for a class. This slot is empty rather than absent: your first edit is a line
|
|
1882
|
+
* below this comment, and nothing above it is yours to change.
|
|
1883
|
+
*/
|
|
1884
|
+
`;
|
|
1885
|
+
}
|
|
1886
|
+
//# sourceMappingURL=template.js.map
|