@ultimat3/cli 24.0.0 → 25.1.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/CLAUDE.md +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +19 -30
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +36 -15
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
|
@@ -1,40 +1,32 @@
|
|
|
1
|
-
// The scaffold's answer to "who is this?",
|
|
1
|
+
// The scaffold's answer to "who is this?": a session cookie, resolved by `@ultimat3/auth` over the
|
|
2
|
+
// framework's own `x_users` / `x_sessions` tables, installed in EVERY environment.
|
|
2
3
|
//
|
|
3
|
-
// `hooks.authenticate` is the ONLY place an actor can come from, and
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// `hooks.authenticate` is the ONLY place an actor can come from, and a fresh `x new` once installed
|
|
5
|
+
// nothing there — `X_CONFIG_INVALID: 7 route(s) declare auth: 'required' and no authenticator is
|
|
6
|
+
// configured` on every start, and a 401 on its own `/dashboard`. The first answer was a viewer
|
|
7
|
+
// named by a cookie, installed in `development` alone, which left every other environment with no
|
|
8
|
+
// authenticator at all and the real path for the app's author to invent. Now the real path is the
|
|
9
|
+
// one installed, and the development viewer is only what a request with NO session is answered as
|
|
10
|
+
// under `x dev` — so `x dev` stays zero-config and nothing has to be swapped out before a deploy.
|
|
9
11
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the next. The scaffold would teach the wrong shape to every app that starts from it.
|
|
12
|
+
// Dropping `policy:` from the scaffolded routes was refused: a dashboard that declares no policy is
|
|
13
|
+
// registered `auth: 'public'`, which also skips `render-ssr`'s gated branch, so the document ships
|
|
14
|
+
// with no `vary: cookie` and a shared cache may hand one visitor's page to the next.
|
|
14
15
|
|
|
15
16
|
import type { GeneratedFile, NameSet } from './naming';
|
|
16
17
|
|
|
17
18
|
const devActor = (
|
|
18
19
|
app: NameSet,
|
|
19
|
-
): string => `// Who a browser is
|
|
20
|
+
): string => `// Who a browser with NO session is under \`x dev\`: a viewer named by a cookie.
|
|
20
21
|
//
|
|
21
|
-
// \`
|
|
22
|
-
//
|
|
23
|
-
//
|
|
22
|
+
// \`authenticator.ts\` is the real path — a session cookie, resolved by \`@ultimat3/auth\` — and it
|
|
23
|
+
// is installed in every environment. A fresh app issues no session until it has a sign-in page, so
|
|
24
|
+
// without this every route declaring a \`policy:\` answered 401 on the first \`x dev\`. This module
|
|
25
|
+
// only answers; \`authenticator.ts\` decides when it is asked, and it asks in \`development\` alone —
|
|
26
|
+
// a viewer that followed this to staging would sign every visitor in as an admin.
|
|
24
27
|
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
// \`tryResolveEnvironment\`'s own default: that default is \`development\`, so a process that named
|
|
28
|
-
// NEITHER \`ULTIMATE_ENV\` nor \`NODE_ENV\` would otherwise read as development too — indistinguishable
|
|
29
|
-
// from the one this file exists to allow. \`x dev\` declares \`ULTIMATE_ENV=development\` for exactly
|
|
30
|
-
// this reason (whenever neither key is already set), so a bare \`x dev\` still installs this viewer;
|
|
31
|
-
// \`bun test\` sets \`NODE_ENV=test\`, so it does not install there either — a fixture mints its own
|
|
32
|
-
// actor, and a second one arriving from a cookie would decide which actor a test is about.
|
|
33
|
-
//
|
|
34
|
-
// REPLACE IT with the real thing: resolve a session cookie to a row, and return that actor.
|
|
35
|
-
// Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
|
|
36
|
-
import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
|
|
37
|
-
import { configureAuthenticator, readCookie } from '@ultimat3/http';
|
|
28
|
+
// Delete it the day sign-in exists, and the fallback line in \`authenticator.ts\` with it.
|
|
29
|
+
import { type Actor, logger, readCookie } from '@ultimat3/core';
|
|
38
30
|
import { DEMO_ORG_ID } from '../../shared/demo-org';
|
|
39
31
|
import { roles } from '../../shared/roles';
|
|
40
32
|
|
|
@@ -82,13 +74,13 @@ export const devActorFor = (role: string): Actor => ({
|
|
|
82
74
|
});
|
|
83
75
|
|
|
84
76
|
/** The one method this reads off a request. */
|
|
85
|
-
interface HeaderRequest {
|
|
77
|
+
export interface HeaderRequest {
|
|
86
78
|
header(name: string): string | null;
|
|
87
79
|
}
|
|
88
80
|
|
|
89
81
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
82
|
+
* The development viewer for a request: its cookie header decides the role. Typed by the one
|
|
83
|
+
* method it reads, so a test calls it with a header and no server.
|
|
92
84
|
*
|
|
93
85
|
* A cookie naming an undeclared role answers \`null\` — ANONYMOUS, the least a request can be —
|
|
94
86
|
* and says so, with the value it read and the roles it could have named.
|
|
@@ -104,35 +96,111 @@ export const devAuthenticate = (request: HeaderRequest): Actor | null => {
|
|
|
104
96
|
});
|
|
105
97
|
return null;
|
|
106
98
|
};
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
const authModule =
|
|
102
|
+
(): string => `// This app's one \`@ultimat3/auth\` instance: sessions, passwords and the lockout, over the
|
|
103
|
+
// framework's own \`x_users\` and \`x_sessions\` tables — \`x dev\` and \`ROLE=migrate\` apply them.
|
|
104
|
+
//
|
|
105
|
+
// Sign-in is one call once the app has a page for it: \`login(appAuth(), { email, password, ip })\`
|
|
106
|
+
// answers \`{ token, cookie }\` — set \`cookie\` on the response and every request after it is that
|
|
107
|
+
// user, through \`authenticator.ts\`. \`register(appAuth(), { email, password, orgId, roles })\`
|
|
108
|
+
// creates one.
|
|
109
|
+
import { type Auth, defineAuth, postgresAuthAdapter } from '@ultimat3/auth';
|
|
110
|
+
|
|
111
|
+
let built: Auth | undefined;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Built on first use, never at import: the boot scan imports this module before the database and
|
|
115
|
+
* the host's shared lockout limiter are installed, and \`defineAuth\` reads both.
|
|
116
|
+
*/
|
|
117
|
+
export const appAuth = (): Auth => {
|
|
118
|
+
built ??= defineAuth({ adapter: postgresAuthAdapter() });
|
|
119
|
+
return built;
|
|
120
|
+
};
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
const authenticatorModule =
|
|
124
|
+
(): string => `// Who a request is: the session its cookie names, resolved by \`@ultimat3/auth\` — the one
|
|
125
|
+
// authenticator this app installs, in every environment. Everything downstream (pages, policies,
|
|
126
|
+
// live subscribers, MCP tools) reads the actor this returns.
|
|
127
|
+
//
|
|
128
|
+
// With NO session cookie the request is anonymous — except under \`x dev\`, where it is the
|
|
129
|
+
// development viewer (\`dev-actor.ts\`), so a fresh app opens its own dashboard with nothing
|
|
130
|
+
// configured. That fallback FAILS CLOSED (\`fallback: 'production'\`): \`tryResolveEnvironment\`'s own
|
|
131
|
+
// default is \`development\`, so a process naming NEITHER \`ULTIMATE_ENV\` nor \`NODE_ENV\` would
|
|
132
|
+
// otherwise read as development too. \`x dev\` declares \`ULTIMATE_ENV=development\` for exactly this
|
|
133
|
+
// reason, and \`bun test\` sets \`NODE_ENV=test\`, where a fixture mints its own actor.
|
|
134
|
+
import { type Auth, authenticate } from '@ultimat3/auth';
|
|
135
|
+
import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
|
|
136
|
+
import { configureAuthenticator } from '@ultimat3/http';
|
|
137
|
+
import { appAuth } from './auth';
|
|
138
|
+
import {
|
|
139
|
+
DEFAULT_DEV_ROLE,
|
|
140
|
+
DEV_ROLE_COOKIE,
|
|
141
|
+
devAuthenticate,
|
|
142
|
+
type HeaderRequest,
|
|
143
|
+
} from './dev-actor';
|
|
144
|
+
|
|
145
|
+
/** The two methods this reads off a request. */
|
|
146
|
+
export interface SessionRequest extends HeaderRequest {
|
|
147
|
+
cookie(name: string): string | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface SessionAuthenticatorOptions {
|
|
151
|
+
/** The app's \`Auth\`, asked for per request so it is built after the boot, not at import. */
|
|
152
|
+
readonly auth: () => Auth;
|
|
153
|
+
/** Whether a request with no session is the development viewer rather than anonymous. */
|
|
154
|
+
readonly development: boolean;
|
|
155
|
+
}
|
|
107
156
|
|
|
108
157
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
158
|
+
* A session cookie is that session's user, or a refusal — never a fallback to anyone else:
|
|
159
|
+
* \`X_SESSION_EXPIRED\` for a session past its absolute or idle expiry, \`X_UNAUTHENTICATED\` for
|
|
160
|
+
* one that is unknown, revoked or forged, or whose user is disabled. No cookie is the development
|
|
161
|
+
* viewer or nobody.
|
|
111
162
|
*/
|
|
112
|
-
export
|
|
163
|
+
export const sessionAuthenticator =
|
|
164
|
+
(options: SessionAuthenticatorOptions) =>
|
|
165
|
+
async (request: SessionRequest): Promise<Actor | null> => {
|
|
166
|
+
const auth = options.auth();
|
|
167
|
+
const token = request.cookie(auth.sessions.policy.cookieName);
|
|
168
|
+
if (token !== null && token.length > 0) return await authenticate(auth, token);
|
|
169
|
+
return options.development ? devAuthenticate(request) : null;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Installs the session authenticator, and returns whether the development fallback is on — so the
|
|
174
|
+
* test can assert both halves. Loud when it is, because a stand-in for a session is the one thing
|
|
175
|
+
* an operator must never find in a log they did not expect it in.
|
|
176
|
+
*/
|
|
177
|
+
export function installAuthenticator(
|
|
113
178
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
179
|
+
auth: () => Auth = appAuth,
|
|
114
180
|
): boolean {
|
|
115
|
-
|
|
116
|
-
configureAuthenticator(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
181
|
+
const development = tryResolveEnvironment({ env, fallback: 'production' }) === 'development';
|
|
182
|
+
configureAuthenticator(sessionAuthenticator({ auth, development }));
|
|
183
|
+
if (development) {
|
|
184
|
+
logger.warn('a request with no session is answered as a development viewer', {
|
|
185
|
+
role: DEFAULT_DEV_ROLE,
|
|
186
|
+
cause:
|
|
187
|
+
'apps/web/app/auth/authenticator.ts falls back to dev-actor.ts in development, because this app has no sign-in page yet',
|
|
188
|
+
fix: \`browse as someone else: document.cookie = '\${DEV_ROLE_COOKIE}=member'\`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return development;
|
|
124
192
|
}
|
|
125
193
|
|
|
126
194
|
// Module scope, which IS the wiring: the boot scan imports every module under \`apps/*\` before a
|
|
127
195
|
// listener binds, and \`x dev\` and the container both read the configured value back at start.
|
|
128
|
-
|
|
196
|
+
installAuthenticator();
|
|
129
197
|
`;
|
|
130
198
|
|
|
131
199
|
const devActorTest =
|
|
132
|
-
(): string => `// The
|
|
133
|
-
//
|
|
200
|
+
(): string => `// The development viewer is safe because of what it refuses: a role nobody declared is never the
|
|
201
|
+
// default viewer, and the actor it mints holds what its role holds and nothing else. WHEN it is
|
|
202
|
+
// asked is \`authenticator.ts\`'s, and \`authenticator.test.ts\` holds that half.
|
|
134
203
|
import { setLogSink } from '@ultimat3/core';
|
|
135
|
-
import { configuredAuthenticator, resetAuthenticator } from '@ultimat3/http';
|
|
136
204
|
import { actorHas } from '@ultimat3/policy';
|
|
137
205
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
138
206
|
import { roles } from '../../shared/roles';
|
|
@@ -143,7 +211,6 @@ import {
|
|
|
143
211
|
devActorFor,
|
|
144
212
|
devAuthenticate,
|
|
145
213
|
devRoleFrom,
|
|
146
|
-
installDevAuthenticator,
|
|
147
214
|
} from './dev-actor';
|
|
148
215
|
|
|
149
216
|
unitTest('the cookie names a DECLARED role; with no cookie it is the default viewer', () => {
|
|
@@ -173,27 +240,7 @@ unitTest('the actor it mints holds what the role map grants it, and nothing else
|
|
|
173
240
|
expect(actorHas(devActorFor('member'), 'admin:read', roles)).toBe(false);
|
|
174
241
|
});
|
|
175
242
|
|
|
176
|
-
unitTest('it
|
|
177
|
-
// This process is \`test\`, so the module-scope call at the bottom of dev-actor.ts installed
|
|
178
|
-
// nothing — which is what keeps a fixture's own actor the only one a test can be about.
|
|
179
|
-
expect(configuredAuthenticator()).toBeUndefined();
|
|
180
|
-
|
|
181
|
-
expect(installDevAuthenticator({ ULTIMATE_ENV: 'production' })).toBe(false);
|
|
182
|
-
expect(installDevAuthenticator({ ULTIMATE_ENV: 'staging' })).toBe(false);
|
|
183
|
-
// FAILS CLOSED: a process naming NEITHER key is production here, never the default-development
|
|
184
|
-
// a bare \`tryResolveEnvironment({ env })\` would answer. \`x dev\` is what makes a real \`x dev\`
|
|
185
|
-
// still install this viewer — it declares \`ULTIMATE_ENV=development\` before this module loads,
|
|
186
|
-
// for exactly the process this call simulates having none of.
|
|
187
|
-
expect(installDevAuthenticator({})).toBe(false);
|
|
188
|
-
expect(configuredAuthenticator()).toBeUndefined();
|
|
189
|
-
|
|
190
|
-
expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
|
|
191
|
-
expect(configuredAuthenticator()).toBe(devAuthenticate);
|
|
192
|
-
// Process-global, so the case that installed one takes it back out.
|
|
193
|
-
resetAuthenticator();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
unitTest('what it installs answers a request from its cookie header, and no other', () => {
|
|
243
|
+
unitTest('it answers a request from its cookie header, and no other', () => {
|
|
197
244
|
const cookie = \`\${DEV_ROLE_COOKIE}=member\`;
|
|
198
245
|
const request = { header: (name: string) => (name === 'cookie' ? cookie : null) };
|
|
199
246
|
expect(devAuthenticate(request)?.roles).toEqual(['member']);
|
|
@@ -217,9 +264,97 @@ unitTest('an undeclared role is answered as ANONYMOUS, and the log names what it
|
|
|
217
264
|
});
|
|
218
265
|
`;
|
|
219
266
|
|
|
220
|
-
|
|
267
|
+
const authenticatorTest =
|
|
268
|
+
(): string => `// The real path and its one fallback: a session cookie is its user in every environment, a bad
|
|
269
|
+
// one is refused rather than replaced, and only development answers a request with no session.
|
|
270
|
+
import { defineAuth, login, memoryAuthAdapter, register } from '@ultimat3/auth';
|
|
271
|
+
import { configuredAuthenticator, resetAuthenticator } from '@ultimat3/http';
|
|
272
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
273
|
+
import { DEMO_ORG_ID } from '../../shared/demo-org';
|
|
274
|
+
import { installAuthenticator, sessionAuthenticator } from './authenticator';
|
|
275
|
+
import { DEFAULT_DEV_ROLE, DEV_ROLE_COOKIE } from './dev-actor';
|
|
276
|
+
|
|
277
|
+
/** In memory: the session half is the package's, and this test is about the wiring around it. */
|
|
278
|
+
const memoryAuth = () => {
|
|
279
|
+
const auth = defineAuth({ adapter: memoryAuthAdapter() });
|
|
280
|
+
return () => auth;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** A request carrying exactly these cookies, through both methods the authenticator reads. */
|
|
284
|
+
const requestWith = (cookies: Readonly<Record<string, string>>) => ({
|
|
285
|
+
header: (name: string) =>
|
|
286
|
+
name === 'cookie' && Object.keys(cookies).length > 0
|
|
287
|
+
? Object.entries(cookies)
|
|
288
|
+
.map(([key, value]) => \`\${key}=\${value}\`)
|
|
289
|
+
.join('; ')
|
|
290
|
+
: null,
|
|
291
|
+
cookie: (name: string) => cookies[name] ?? null,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const PASSWORD = 'a long scaffold test passphrase';
|
|
295
|
+
|
|
296
|
+
unitTest('a session cookie is the user it was issued to, in every environment', async () => {
|
|
297
|
+
const auth = memoryAuth();
|
|
298
|
+
const user = await register(auth(), {
|
|
299
|
+
email: 'ada@example.com',
|
|
300
|
+
password: PASSWORD,
|
|
301
|
+
orgId: DEMO_ORG_ID,
|
|
302
|
+
roles: ['member'],
|
|
303
|
+
});
|
|
304
|
+
const { token } = await login(auth(), { email: 'ada@example.com', password: PASSWORD });
|
|
305
|
+
const request = requestWith({ [auth().sessions.policy.cookieName]: token });
|
|
306
|
+
for (const development of [true, false]) {
|
|
307
|
+
const actor = await sessionAuthenticator({ auth, development })(request);
|
|
308
|
+
expect(actor?.id).toBe(user.id);
|
|
309
|
+
expect(actor?.roles).toEqual(['member']);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
unitTest('a cookie naming no live session is refused, never answered as the viewer', async () => {
|
|
314
|
+
const auth = memoryAuth();
|
|
315
|
+
const request = requestWith({ [auth().sessions.policy.cookieName]: 'forged.token' });
|
|
316
|
+
await expect(sessionAuthenticator({ auth, development: true })(request)).rejects.toMatchObject({
|
|
317
|
+
code: 'X_UNAUTHENTICATED',
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
unitTest('no session: the development viewer under x dev, anonymous anywhere else', async () => {
|
|
322
|
+
const auth = memoryAuth();
|
|
323
|
+
const member = requestWith({ [DEV_ROLE_COOKIE]: 'member' });
|
|
324
|
+
expect((await sessionAuthenticator({ auth, development: true })(member))?.roles).toEqual([
|
|
325
|
+
'member',
|
|
326
|
+
]);
|
|
327
|
+
const none = requestWith({});
|
|
328
|
+
expect((await sessionAuthenticator({ auth, development: true })(none))?.roles).toEqual([
|
|
329
|
+
DEFAULT_DEV_ROLE,
|
|
330
|
+
]);
|
|
331
|
+
expect(await sessionAuthenticator({ auth, development: false })(member)).toBeNull();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
unitTest('it installs in every environment; only development turns the fallback on', () => {
|
|
335
|
+
const auth = memoryAuth();
|
|
336
|
+
try {
|
|
337
|
+
expect(installAuthenticator({ ULTIMATE_ENV: 'production' }, auth)).toBe(false);
|
|
338
|
+
expect(configuredAuthenticator()).toBeDefined();
|
|
339
|
+
expect(installAuthenticator({ ULTIMATE_ENV: 'staging' }, auth)).toBe(false);
|
|
340
|
+
// FAILS CLOSED: a process naming NEITHER key is production here, never the default-development
|
|
341
|
+
// a bare \`tryResolveEnvironment({ env })\` would answer. \`x dev\` declares
|
|
342
|
+
// \`ULTIMATE_ENV=development\` before this module loads, for exactly the process this simulates.
|
|
343
|
+
expect(installAuthenticator({}, auth)).toBe(false);
|
|
344
|
+
expect(installAuthenticator({ ULTIMATE_ENV: 'development' }, auth)).toBe(true);
|
|
345
|
+
} finally {
|
|
346
|
+
// Process-global, so the case that installed one takes it back out.
|
|
347
|
+
resetAuthenticator();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
`;
|
|
351
|
+
|
|
352
|
+
/** The app's authenticator, its \`Auth\`, and the development viewer it falls back to. */
|
|
221
353
|
export function authFiles(app: NameSet): readonly GeneratedFile[] {
|
|
222
354
|
return [
|
|
355
|
+
{ path: 'apps/web/app/auth/auth.ts', contents: authModule() },
|
|
356
|
+
{ path: 'apps/web/app/auth/authenticator.ts', contents: authenticatorModule() },
|
|
357
|
+
{ path: 'apps/web/app/auth/authenticator.test.ts', contents: authenticatorTest() },
|
|
223
358
|
{ path: 'apps/web/app/auth/dev-actor.ts', contents: devActor(app) },
|
|
224
359
|
{ path: 'apps/web/app/auth/dev-actor.test.ts', contents: devActorTest() },
|
|
225
360
|
];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The two scripts a scaffolded app is driven by — `bun run setup` and `bun run check` — written as
|
|
2
|
+
// TypeScript that Bun runs, never as shell. A bash `bin/setup` was the first thing a PowerShell or
|
|
3
|
+
// cmd user hit after `x new`, and it failed there; a `.ts` file run by the Bun the app already
|
|
4
|
+
// requires is the same script on every OS. `bun run dev` needs no file: it is `x dev`.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile } from './naming';
|
|
7
|
+
|
|
8
|
+
/** Where each script lands, and what the root `package.json`'s `scripts` run — named once. */
|
|
9
|
+
export const BIN_SCRIPTS = {
|
|
10
|
+
setup: 'bin/setup.ts',
|
|
11
|
+
check: 'bin/check.ts',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/** The `package.json` script line that runs a bin file: Bun itself, so no shell interprets it. */
|
|
15
|
+
export const binScript = (path: string): string => `bun ${path}`;
|
|
16
|
+
|
|
17
|
+
// Every step is a TOP-LEVEL call statement, one per line — `scripts/setup-commands.ts` reads the
|
|
18
|
+
// documented sequence out of exactly those lines, so a helper's body (indented) or the closing
|
|
19
|
+
// `console.log` (a member call) is never mistaken for a step. `bun x x` is `bunx x` spelled through
|
|
20
|
+
// the running Bun's own path: no PATH lookup, so no shell or PATHEXT decides which `x` runs.
|
|
21
|
+
const binSetup =
|
|
22
|
+
(): string => `// Fresh clone to running. Idempotent: safe to re-run. \`bun run setup\` — Bun runs this file, so
|
|
23
|
+
// it is the same script under PowerShell, cmd or any POSIX shell.
|
|
24
|
+
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const root = join(import.meta.dir, '..');
|
|
28
|
+
|
|
29
|
+
/** One step, from the app root, on this terminal. A red step ends setup with its own exit code. */
|
|
30
|
+
const run = async (argv: readonly string[]): Promise<void> => {
|
|
31
|
+
const code = await Bun.spawn([...argv], { cwd: root, stdio: ['inherit', 'inherit', 'inherit'] })
|
|
32
|
+
.exited;
|
|
33
|
+
if (code !== 0) process.exit(code);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Bun itself, by the path it is running from. */
|
|
37
|
+
const bun = (...args: string[]): Promise<void> => run([process.execPath, ...args]);
|
|
38
|
+
|
|
39
|
+
/** The app's own CLI, as \`bunx x\` resolves it — the install below links it into node_modules. */
|
|
40
|
+
const x = (...args: string[]): Promise<void> => run([process.execPath, 'x', 'x', ...args]);
|
|
41
|
+
|
|
42
|
+
/** Writes a file only when it is absent, so a re-run never clobbers what a box put there. */
|
|
43
|
+
const ensureFile = (path: string, contents: string): void => {
|
|
44
|
+
if (!existsSync(join(root, path))) writeFileSync(join(root, path), contents);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const PER_BOX = '# per-box secrets, gitignored, wins over .env.development\\n';
|
|
48
|
+
|
|
49
|
+
// \`x db gen\` is the ONE writer of packages/db/migrations — the scaffold no longer hand-writes a
|
|
50
|
+
// 0000_initial.sql, because a second writer is how the source and the ledger ended up disagreeing
|
|
51
|
+
// about what "initial" meant. Guarded on the directory rather than on the generator being a no-op:
|
|
52
|
+
// this script is documented idempotent, and the guard is what makes that true here.
|
|
53
|
+
const hasMigration = (): boolean => {
|
|
54
|
+
const dir = join(root, 'packages', 'db', 'migrations');
|
|
55
|
+
return existsSync(dir) && readdirSync(dir).some((name) => name.endsWith('.sql'));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
await bun('install');
|
|
59
|
+
ensureFile('.env.development.local', PER_BOX);
|
|
60
|
+
if (!hasMigration()) await x('db', 'gen', 'initial');
|
|
61
|
+
await x('db', 'migrate', ...process.argv.slice(2));
|
|
62
|
+
// \`x db seed\`, never a plain script: the CLI owns the connection, so this reaches the same
|
|
63
|
+
// embedded PGlite the migration above just wrote to. A plain script goes through \`db()\`, which
|
|
64
|
+
// needs a \`postgres:\` DATABASE_URL and so dies on a clone with no Postgres.
|
|
65
|
+
await x('db', 'seed');
|
|
66
|
+
// The file AGENTS.md tells an agent facts live in, and \`x dev\` prints the path of. It is a
|
|
67
|
+
// projection of the loaded app, so \`x new\` cannot write it — node_modules does not exist yet.
|
|
68
|
+
// \`x verify\`'s manifest step refuses its absence (X_MANIFEST_MISSING).
|
|
69
|
+
await x('manifest');
|
|
70
|
+
console.log('setup complete — next: bun run dev');
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
const binCheck =
|
|
74
|
+
(): string => `// The gate. Same steps as CI, because a check that lives only in CI cannot be run locally.
|
|
75
|
+
// \`bun run check\` — Bun runs this file, so it is the same gate under PowerShell, cmd or bash.
|
|
76
|
+
import { join } from 'node:path';
|
|
77
|
+
|
|
78
|
+
const root = join(import.meta.dir, '..');
|
|
79
|
+
const args = process.argv.slice(2);
|
|
80
|
+
|
|
81
|
+
/** The app's own CLI, as \`bunx x\` resolves it, from the app root; resolves to its exit code. */
|
|
82
|
+
const x = async (...argv: string[]): Promise<number> => {
|
|
83
|
+
const child = Bun.spawn([process.execPath, 'x', 'x', ...argv], {
|
|
84
|
+
cwd: root,
|
|
85
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
86
|
+
});
|
|
87
|
+
// The terminal already hands Ctrl-C to the child; this process only waits for its verdict. A
|
|
88
|
+
// SIGTERM is forwarded, so a CI timeout stops the gate instead of orphaning it mid-step.
|
|
89
|
+
const wait = (): void => {};
|
|
90
|
+
const stop = (): void => {
|
|
91
|
+
child.kill('SIGTERM');
|
|
92
|
+
};
|
|
93
|
+
process.on('SIGINT', wait);
|
|
94
|
+
process.on('SIGTERM', stop);
|
|
95
|
+
const code = await child.exited;
|
|
96
|
+
process.off('SIGINT', wait);
|
|
97
|
+
process.off('SIGTERM', stop);
|
|
98
|
+
return code;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// The build FIRST, and not as a convenience: \`x verify\`'s budgets step compares declared limits
|
|
102
|
+
// against measured bytes in .x/build-stats.json, so with no build it reports X_BUDGET_UNMEASURED
|
|
103
|
+
// and the very first gate anyone runs on a brand-new app is red for a reason that has nothing to
|
|
104
|
+
// do with their code.
|
|
105
|
+
//
|
|
106
|
+
// \`--json\` is forwarded to BOTH, or the contract breaks: the build's human renderer followed by
|
|
107
|
+
// the gate's JSON is neither on stdout. Both commands emit one object; a reader takes the last line.
|
|
108
|
+
// \`--no-preflight\`: the build's own preflight is x verify's first six steps (typecheck, lint,
|
|
109
|
+
// boundaries, filesize, package-shape, errors), and the gate below runs them anyway.
|
|
110
|
+
const json = args.some((arg) => arg === '--json' || arg === '-j') ? ['--json'] : [];
|
|
111
|
+
const built = await x('build', '--target', 'static', '--no-preflight', ...json);
|
|
112
|
+
if (built !== 0) process.exit(built);
|
|
113
|
+
process.exit(await x('verify', ...args));
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
/** The bin scripts, in the order a newcomer runs them. */
|
|
117
|
+
export function binFiles(): readonly GeneratedFile[] {
|
|
118
|
+
return [
|
|
119
|
+
{ path: BIN_SCRIPTS.setup, contents: binSetup() },
|
|
120
|
+
{ path: BIN_SCRIPTS.check, contents: binCheck() },
|
|
121
|
+
];
|
|
122
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
7
|
|
|
8
8
|
const feature = (app: NameSet): string => `---
|
|
9
|
-
description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`
|
|
9
|
+
description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bun run check\`.
|
|
10
10
|
argument-hint: <what you want built or fixed, plain language>
|
|
11
11
|
allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
|
|
12
12
|
---
|
|
@@ -16,7 +16,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
|
|
|
16
16
|
You are a senior engineer on **${app.kebab}**, an Ultimate app. Read \`AGENTS.md\` before designing
|
|
17
17
|
anything — it is the short form of every rule below, and it wins where the two disagree.
|
|
18
18
|
|
|
19
|
-
**Done means \`
|
|
19
|
+
**Done means \`bun run check\` green.** A passing unit test is not done. A working \`x dev\` is not done.
|
|
20
20
|
Report what you actually ran, never what you assume passed.
|
|
21
21
|
|
|
22
22
|
## Request
|
|
@@ -77,15 +77,15 @@ the filename never is. One interactive control on a 0kb page is an island: \`x g
|
|
|
77
77
|
## 4. Gate it
|
|
78
78
|
|
|
79
79
|
\`\`\`sh
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
bun run check # the gate. green = shippable
|
|
81
|
+
bun run check --json # the same, machine-readable
|
|
82
82
|
\`\`\`
|
|
83
83
|
|
|
84
|
-
\`
|
|
84
|
+
\`bun run check\` is \`x build --target static\` and THEN \`x verify\`, and the order is the whole point:
|
|
85
85
|
the \`budgets\` step measures \`.x/build-stats.json\`, which only a build writes, so \`x verify\` on
|
|
86
86
|
its own reports X_BUDGET_UNMEASURED on a tree that is fine. \`--json\` is forwarded to both halves;
|
|
87
87
|
a machine consumer takes the last line. CI runs this same script — \`.github/workflows/ci.yml\` is
|
|
88
|
-
\`
|
|
88
|
+
\`bun run setup\` and then \`bun run check\`, nothing else.
|
|
89
89
|
|
|
90
90
|
Red is instructions, not a verdict: **every finding carries an executable \`fix:\` — run it verbatim
|
|
91
91
|
before improvising**, and \`x errors explain <CODE>\` expands any code it names. Never narrow the gate
|
|
@@ -118,7 +118,7 @@ ISO code, never a float. Bun only.
|
|
|
118
118
|
Primitive: <which of the eight> Slice: <dir>
|
|
119
119
|
Generated: <the x g invocations you ran>
|
|
120
120
|
Changed: <files>
|
|
121
|
-
Gate:
|
|
121
|
+
Gate: bun run check ✓ | ✗ <failing steps>
|
|
122
122
|
Deferred: <what you did not do, and why> [never omit this line]
|
|
123
123
|
\`\`\`
|
|
124
124
|
`;
|
|
@@ -172,7 +172,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
|
|
|
172
172
|
- What to add, next to the source as \`<file>.test.ts\`. Command to run it.
|
|
173
173
|
|
|
174
174
|
## Done when
|
|
175
|
-
- Acceptance criteria, ending in \`
|
|
175
|
+
- Acceptance criteria, ending in \`bun run check\` green.
|
|
176
176
|
|
|
177
177
|
## Risks
|
|
178
178
|
- Anything the executor must decide, and every claim in the ask the code disproves.
|
|
@@ -185,7 +185,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
|
|
|
185
185
|
- No checkboxes. The plan is a map, not a tracker.
|
|
186
186
|
- The plan must obey the app's own rules — one way to do each thing, generators over hand-written
|
|
187
187
|
files, imports that never cross a surface boundary, a stable error code with a runnable \`fix:\` for
|
|
188
|
-
every new failure, and \`
|
|
188
|
+
every new failure, and \`bun run check\` green as the last line of *Done when*.
|
|
189
189
|
|
|
190
190
|
## Output
|
|
191
191
|
|
|
@@ -202,7 +202,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
|
|
202
202
|
|
|
203
203
|
# /verify
|
|
204
204
|
|
|
205
|
-
Run \`
|
|
205
|
+
Run \`bun run check\`.
|
|
206
206
|
|
|
207
207
|
It is \`x build --target static\` and then \`x verify\`, and it is THE gate — the one CI runs
|
|
208
208
|
(\`.github/workflows/ci.yml\`) and the one \`README.md\` names. \`x verify\` alone is half of it:
|
|
@@ -213,7 +213,7 @@ Green: say so and stop.
|
|
|
213
213
|
|
|
214
214
|
Red: fix every finding, then re-run until green. Each finding carries a stable code, a cause and an
|
|
215
215
|
executable \`fix:\` — **run the \`fix:\` verbatim before improvising**, and use \`x errors explain <CODE>\`
|
|
216
|
-
when the cause is not enough. \`
|
|
216
|
+
when the cause is not enough. \`bun run check --json\` gives the same steps machine-readably; \`x doctor\`
|
|
217
217
|
covers the case where the environment, not the code, is what is broken.
|
|
218
218
|
|
|
219
219
|
Do not narrow the gate to make it pass. There is no \`--only\` and no \`--skip\`; disabling a lint rule,
|
|
@@ -10,7 +10,7 @@ import { SECRETS_KEY_FILE } from '@ultimat3/core';
|
|
|
10
10
|
import { PREBUILT_COMMAND } from '../serve-prebuilt-paths';
|
|
11
11
|
import type { GeneratedFile, NameSet } from './naming';
|
|
12
12
|
import { composeProdFile, PROD_ENV_FILE } from './scaffold-container-compose';
|
|
13
|
-
import { helmFiles } from './scaffold-helm';
|
|
13
|
+
import { helmFiles, RUNTIME_UID } from './scaffold-helm';
|
|
14
14
|
|
|
15
15
|
const dockerfile = (
|
|
16
16
|
app: NameSet,
|
|
@@ -32,6 +32,8 @@ const dockerfile = (
|
|
|
32
32
|
# from the file itself, and without it the frozen install cannot find what the lockfile names.
|
|
33
33
|
# The tag names the series for the reader; the digest is what builds — a tag is a pointer its
|
|
34
34
|
# publisher can move. Dependabot's docker ecosystem moves the digest together with the tag.
|
|
35
|
+
# The digest is \`oven/bun:1.4.2-alpine\`'s, the patch the framework that wrote this file was
|
|
36
|
+
# tested on — the same patch as \`engines.bun\` in package.json.
|
|
35
37
|
FROM oven/bun:1.4-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS manifests
|
|
36
38
|
WORKDIR /app
|
|
37
39
|
COPY . .
|
|
@@ -92,9 +94,11 @@ HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
|
|
|
92
94
|
# \`.x/\` is where any binding that is still embedded keeps its state — the local storage disk, and
|
|
93
95
|
# PGlite if DATABASE_URL is unset. Owned by the runtime user, because /app is not: the alternative
|
|
94
96
|
# is a non-root process failing at boot on a directory it is the only one that ever writes.
|
|
95
|
-
|
|
97
|
+
# Numeric, and the same number docker/helm/values.yaml runs every pod as: ${RUNTIME_UID} is the base
|
|
98
|
+
# image's \`bun\` user, and a numeric USER is one a kubelet can check against runAsNonRoot.
|
|
99
|
+
RUN mkdir -p /app/.x && chown -R ${RUNTIME_UID}:${RUNTIME_UID} /app/.x
|
|
96
100
|
|
|
97
|
-
USER
|
|
101
|
+
USER ${RUNTIME_UID}:${RUNTIME_UID}
|
|
98
102
|
# apps/web/server.ts reads ROLE and PORT and nothing else. \`migrate\` applies the migrations and
|
|
99
103
|
# exits; every other role serves until SIGTERM.
|
|
100
104
|
ENTRYPOINT ["bun", "apps/web/server.ts"]
|
|
@@ -190,9 +194,11 @@ x build --target static --out dist/static # one HTML file per \`render: 'stati
|
|
|
190
194
|
x build --target binary --out dist/app # a single executable, no Bun install needed
|
|
191
195
|
\`\`\`
|
|
192
196
|
|
|
193
|
-
The binary bundles the framework
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
The binary bundles neither the framework nor the app: it is a launcher. \`@ultimat3/*\` stays
|
|
198
|
+
external and loads from the app's own \`node_modules\`, the app's \`package.json\` and
|
|
199
|
+
\`tsconfig.json\` are read at run time, and the registries are filled by scanning
|
|
200
|
+
\`apps/*/{site,app,api,shared}\` at boot. So it must be **started from the app root**, with the
|
|
201
|
+
source tree and an installed \`node_modules\` beside it. The image is the self-contained artifact.
|
|
196
202
|
|
|
197
203
|
## A PaaS (Heroku, Render, Fly, Railway, Cloud Run, App Runner…)
|
|
198
204
|
|
|
@@ -224,10 +224,14 @@ unitTest('the tiles are the framework registries, counted', async () => {
|
|
|
224
224
|
unitTest('the table is the route table: path, surface and render mode per route', async () => {
|
|
225
225
|
const t = useT();
|
|
226
226
|
const view = await renderRoute(page, { url });
|
|
227
|
-
expect(view.html.match(/<tr
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
227
|
+
expect(view.html.match(/<tr[^>]*\\sdata-row=/g)).toHaveLength(1);
|
|
228
|
+
// Each cell ends in its value; on a phone a card label rides in front of it, hidden from AT.
|
|
229
|
+
const row = view.html.match(/<tr[^>]*data-row="\\/dashboard">([\\s\\S]*?)<\\/tr>/)?.[1] ?? '';
|
|
230
|
+
expect([...row.matchAll(/([^>]*)<\\/td>/g)].map((cell) => cell[1])).toEqual([
|
|
231
|
+
'/dashboard',
|
|
232
|
+
'app',
|
|
233
|
+
'ssr',
|
|
234
|
+
]);
|
|
231
235
|
expect(view.text).toContain(t('app.dashboard.routesCaption'));
|
|
232
236
|
expect(view.meta.title).toBe(t('app.dashboard.title'));
|
|
233
237
|
// The sidebar knows where it is.
|