@ultimat3/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
@@ -0,0 +1,320 @@
1
+ // The `apps/*` half of what `x new` writes: the three surfaces of apps/web, the admin app that
2
+ // already speaks MCP, and the mobile/desktop placeholders that exist so adding them later is not
3
+ // a restructure. Every file here is real, typed and covered — no placeholder that fails to boot.
4
+
5
+ import type { GeneratedFile, NameSet } from './naming';
6
+ import { icon } from './scaffold-icon';
7
+
8
+ const webPackage = (app: NameSet): string => `{
9
+ "name": "@${app.kebab}/web",
10
+ "version": "0.0.0",
11
+ "private": true,
12
+ "type": "module",
13
+ "exports": {
14
+ "./*": "./*.ts",
15
+ "./*.tsx": "./*.tsx"
16
+ },
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit -p tsconfig.json"
19
+ }
20
+ }
21
+ `;
22
+
23
+ // The ambient \`*.module.scss\` declaration is not reachable through an import, so a program that
24
+ // only sees this app's files would report TS2307 on every stylesheet. Naming it in \`include\`
25
+ // is what makes \`tsc -p apps/web\` agree with \`tsc -p .\`.
26
+ const tsconfig = (): string => `{
27
+ "extends": "../../tsconfig.json",
28
+ "include": ["**/*.ts", "**/*.tsx", "../../types/scss.d.ts"]
29
+ }
30
+ `;
31
+
32
+ const sitePage = (
33
+ app: NameSet,
34
+ ): string => `// The landing page. site/ is 0kb JS: static render, hydrate never, no framework script tag.
35
+ import { t } from '@ultimat3/i18n';
36
+ import { defineRoute } from '@ultimat3/render';
37
+ import styles from './page.module.scss';
38
+
39
+ export const config = defineRoute({
40
+ render: 'static',
41
+ hydrate: 'never',
42
+ offline: 'precache',
43
+ budget: { js: '0kb', lcp: 1500 },
44
+ meta: () => ({
45
+ title: t('site.home.title'),
46
+ description: t('site.home.description'),
47
+ }),
48
+ });
49
+
50
+ export function HomePage() {
51
+ return (
52
+ <main class={styles.hero}>
53
+ <h1>{t('site.home.title')}</h1>
54
+ <p>{t('site.home.description')}</p>
55
+ <a class={styles.cta} href="/dashboard">
56
+ {t('site.home.cta')}
57
+ </a>
58
+ </main>
59
+ );
60
+ }
61
+
62
+ export const appName = '${app.kebab}';
63
+ `;
64
+
65
+ const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
66
+
67
+ .hero {
68
+ display: grid;
69
+ gap: tokens.$space-4;
70
+ padding: tokens.$space-8;
71
+ background: tokens.$surface-base;
72
+ color: tokens.$text-primary;
73
+ }
74
+
75
+ .cta {
76
+ justify-self: start;
77
+ padding: tokens.$space-2 tokens.$space-4;
78
+ border-radius: tokens.$radius-md;
79
+ background: tokens.$accent-solid;
80
+ color: tokens.$accent-on-solid;
81
+ }
82
+ `;
83
+
84
+ const sitePageTest = (): string => `import { expect, unitTest } from '@ultimat3/testing';
85
+ import { config } from './page';
86
+
87
+ unitTest('the landing page ships zero JS and declares metadata', async () => {
88
+ expect(config.render).toBe('static');
89
+ expect(config.hydrate).toBe('never');
90
+ expect(config.budget.js).toBe('0kb');
91
+ const meta = await config.meta({});
92
+ expect(meta.title ?? '').not.toBe('');
93
+ });
94
+ `;
95
+
96
+ const dashboardPage =
97
+ (): string => `// The authed dashboard. app/ streams: a static shell is flushed instantly and the holes arrive
98
+ // as their data resolves.
99
+
100
+ import { t } from '@ultimat3/i18n';
101
+ import { defineRoute } from '@ultimat3/render';
102
+ import styles from './page.module.scss';
103
+
104
+ export const config = defineRoute({
105
+ render: 'stream',
106
+ hydrate: 'visible',
107
+ offline: 'runtime',
108
+ // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
109
+ policy: { permission: 'dashboard:read' },
110
+ budget: { js: '60kb', lcp: 2500 },
111
+ meta: () => ({ title: t('app.dashboard.title'), description: t('app.dashboard.description') }),
112
+ });
113
+
114
+ export function DashboardPage() {
115
+ return (
116
+ <section class={styles.panel}>
117
+ <h1>{t('app.dashboard.title')}</h1>
118
+ </section>
119
+ );
120
+ }
121
+ `;
122
+
123
+ const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
124
+
125
+ .panel {
126
+ padding: tokens.$space-6;
127
+ background: tokens.$surface-raised;
128
+ color: tokens.$text-primary;
129
+ }
130
+ `;
131
+
132
+ const dashboardTest = (): string => `import { expect, unitTest } from '@ultimat3/testing';
133
+ import { config } from './page';
134
+
135
+ unitTest('the dashboard streams, requires a permission and has an offline strategy', () => {
136
+ expect(config.render).toBe('stream');
137
+ expect(config.policy?.permission).toBe('dashboard:read');
138
+ expect(config.offline).toBe('runtime');
139
+ });
140
+ `;
141
+
142
+ const offlineFallback =
143
+ (): string => `// The offline fallback. Every app/ route with offline: 'runtime' falls back here, so a train
144
+ // tunnel shows the product's own shell instead of the browser's error page.
145
+
146
+ import { t } from '@ultimat3/i18n';
147
+ import styles from './offline.module.scss';
148
+
149
+ export function OfflineFallback() {
150
+ return (
151
+ <main class={styles.offline}>
152
+ <h1>{t('app.offline.title')}</h1>
153
+ <p>{t('app.offline.description')}</p>
154
+ </main>
155
+ );
156
+ }
157
+ `;
158
+
159
+ const offlineStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
160
+
161
+ .offline {
162
+ display: grid;
163
+ gap: tokens.$space-3;
164
+ padding: tokens.$space-8;
165
+ background: tokens.$surface-base;
166
+ color: tokens.$text-secondary;
167
+ }
168
+ `;
169
+
170
+ const apiAction =
171
+ (): string => `// api/ holds actions only: no rendering, no components. This one is the readiness probe every
172
+ // role exposes, declared as an action so it appears in OpenAPI and MCP like everything else.
173
+
174
+ import { action, t } from '@ultimat3/action';
175
+ import { allow } from '@ultimat3/policy';
176
+
177
+ export const health = action({
178
+ input: t.object({}),
179
+ output: t.object({ ok: t.boolean, role: t.string }),
180
+ // Public, said out loud. \`can('x:y')\` is the other branch; a missing policy is a build error,
181
+ // so "anyone may call this" has to be a declaration too.
182
+ policy: allow('public'),
183
+ mcp: { expose: true, description: 'Readiness of this process' },
184
+ async handle({ ctx }) {
185
+ return { ok: true, role: ctx.role };
186
+ },
187
+ });
188
+ `;
189
+
190
+ const apiTest = (): string => `import { contractTest, expect } from '@ultimat3/testing';
191
+ import { health } from './health';
192
+
193
+ // Named here because every projection needs a stable name and this file does not boot the app.
194
+ // At boot \`registerActions\` stamps the same name onto the same object.
195
+ const target = health.named('health');
196
+
197
+ contractTest('health is an action exposed over MCP', () => {
198
+ expect(target.kind).toBe('action');
199
+ expect(target.mcp?.expose).toBe(true);
200
+ });
201
+
202
+ contractTest('health projects one MCP tool and one OpenAPI operation', () => {
203
+ // Same policy object on both surfaces — a public action says so once, not once per surface.
204
+ expect(target.tool().policy).toBe(target.policy);
205
+ expect(target.openapi().operationId).toBe('health');
206
+ });
207
+ `;
208
+
209
+ const sharedTokens =
210
+ (): string => `// Semantic tokens for this app, layered on @ultimat3/ui. Components reference these names; a raw
211
+ // hex anywhere in the app is a lint failure, because dark theme is not a later project.
212
+ @use '@ultimat3/ui/tokens' as base;
213
+
214
+ $surface-base: base.$surface-base;
215
+ $surface-raised: base.$surface-raised;
216
+ $text-primary: base.$text-primary;
217
+ $text-secondary: base.$text-secondary;
218
+ $accent-solid: base.$accent-solid;
219
+ $accent-on-solid: base.$accent-on-solid;
220
+ `;
221
+
222
+ const sharedActor =
223
+ (): string => `// The actor type both surfaces agree on. Policies read this and nothing else, so authz cannot
224
+ // disagree between HTTP, live queries, jobs and MCP.
225
+ export interface Actor {
226
+ readonly id: string;
227
+ readonly orgId: string;
228
+ readonly roles: readonly string[];
229
+ }
230
+
231
+ export const isMember = (actor: Actor | null): boolean =>
232
+ actor !== null && (actor.roles.includes('member') || actor.roles.includes('owner'));
233
+ `;
234
+
235
+ const sharedActorTest = (): string => `import { expect } from 'bun:test';
236
+ import { unitTest } from '@ultimat3/testing';
237
+ import { isMember } from './actor';
238
+
239
+ unitTest('isMember rejects anonymous and viewer actors', () => {
240
+ expect(isMember(null)).toBe(false);
241
+ expect(isMember({ id: 'a', orgId: 'o', roles: ['viewer'] })).toBe(false);
242
+ expect(isMember({ id: 'a', orgId: 'o', roles: ['owner'] })).toBe(true);
243
+ });
244
+ `;
245
+
246
+ const adminPackage = (app: NameSet): string => `{
247
+ "name": "@${app.kebab}/admin",
248
+ "version": "0.0.0",
249
+ "private": true,
250
+ "type": "module",
251
+ "exports": {
252
+ "./*": "./*.ts",
253
+ "./*.tsx": "./*.tsx"
254
+ },
255
+ "scripts": {
256
+ "typecheck": "tsc --noEmit -p tsconfig.json"
257
+ }
258
+ }
259
+ `;
260
+
261
+ const adminPage =
262
+ (): string => `// The generated admin dashboard. It ships an MCP surface over the app's own actions, so the
263
+ // user's agents can drive the user's product with the user's permissions.
264
+
265
+ import { t } from '@ultimat3/i18n';
266
+ import { defineRoute } from '@ultimat3/render';
267
+
268
+ export const config = defineRoute({
269
+ render: 'spa',
270
+ hydrate: 'idle',
271
+ offline: 'network-only',
272
+ // A spa renders no data, so the shell itself must be gated — @ultimat3/render requires it.
273
+ policy: { permission: 'admin:read' },
274
+ budget: { js: '120kb', lcp: 3000 },
275
+ meta: () => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
276
+ });
277
+
278
+ export function AdminHome() {
279
+ return <h1>{t('admin.home.title')}</h1>;
280
+ }
281
+ `;
282
+
283
+ const placeholder = (surface: string, app: NameSet): string => `# ${surface}
284
+
285
+ Placeholder. The monorepo shape exists now so adding ${surface} later is a new directory, not a
286
+ restructure.
287
+
288
+ | Question | Answer |
289
+ |---|---|
290
+ | Stack | ${surface === 'mobile' ? 'native Swift / Kotlin against the generated typed client' : 'Tauri shell around the app/ surface'} |
291
+ | API | the same actions as \`apps/web/api\` — one authz system, one contract |
292
+ | Contract | \`openapi.json\` at the repo root, regenerated by \`x manifest\` |
293
+ | Start | \`x new ${app.kebab}-${surface}\` inside this directory, or wire it by hand |
294
+ `;
295
+
296
+ export function appFiles(app: NameSet): readonly GeneratedFile[] {
297
+ return [
298
+ { path: 'apps/web/package.json', contents: webPackage(app) },
299
+ { path: 'apps/web/tsconfig.json', contents: tsconfig() },
300
+ { path: 'apps/web/site/icon.png', contents: icon() },
301
+ { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
302
+ { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
303
+ { path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
304
+ { path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage() },
305
+ { path: 'apps/web/app/dashboard/page.module.scss', contents: dashboardStyle() },
306
+ { path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
307
+ { path: 'apps/web/app/offline.tsx', contents: offlineFallback() },
308
+ { path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
309
+ { path: 'apps/web/api/health.ts', contents: apiAction() },
310
+ { path: 'apps/web/api/health.test.ts', contents: apiTest() },
311
+ { path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
312
+ { path: 'apps/web/shared/actor.ts', contents: sharedActor() },
313
+ { path: 'apps/web/shared/actor.test.ts', contents: sharedActorTest() },
314
+ { path: 'apps/admin/package.json', contents: adminPackage(app) },
315
+ { path: 'apps/admin/tsconfig.json', contents: tsconfig() },
316
+ { path: 'apps/admin/app/page.tsx', contents: adminPage() },
317
+ { path: 'apps/mobile/README.md', contents: placeholder('mobile', app) },
318
+ { path: 'apps/desktop/README.md', contents: placeholder('desktop', app) },
319
+ ];
320
+ }
@@ -0,0 +1,156 @@
1
+ // The human-authored half of what `x new` writes: the READMEs, the agent-facing convention files,
2
+ // the bin/ shims and the docker directory. Separated from the config half so neither file has to
3
+ // be scrolled to find the other — one file, one job applies to templates too.
4
+
5
+ import type { GeneratedFile, NameSet } from './naming';
6
+
7
+ const agents = (app: NameSet): string => `# AGENTS.md
8
+
9
+ Human-authored, short, stable. Facts live in \`x.manifest.json\`; this file holds only what an
10
+ agent cannot infer from the code.
11
+
12
+ | Rule | Detail |
13
+ |---|---|
14
+ | One gate | \`x verify\` — green means shippable. Never merge red. |
15
+ | One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` |
16
+ | Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf |
17
+ | Data | routes call actions and queries; only \`repo.ts\` touches the database |
18
+ | Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix |
19
+ | Money | integer minor units + ISO code, never a float |
20
+ | Time | store UTC, format with an explicit IANA time zone |
21
+ | Strings | every user-facing string goes through \`t()\` |
22
+ | Colour | semantic tokens only, never a raw hex |
23
+
24
+ Commands: \`x dev\`, \`x verify\`, \`x g <primitive>\`, \`x db branch <name>\`, \`x doctor\`.
25
+
26
+ Project notes for ${app.kebab}: replace this line with the conventions a newcomer could not guess.
27
+ `;
28
+
29
+ const claude = (app: NameSet): string => `# CLAUDE.md
30
+
31
+ ${app.kebab} — Ultimate app. Read AGENTS.md first; it is the same content in the same order.
32
+
33
+ - Gate: \`x verify\` (add \`--json\` for machine output).
34
+ - Scaffold, do not hand-write: \`x g resource|action|job|route|policy|entity|query|task\`.
35
+ - Destructive DB work goes in a branch: \`x db branch <name>\`, never the shared dev DB.
36
+ - \`x doctor\` explains a broken environment and prints the fix command for every finding.
37
+ `;
38
+
39
+ const readme = (app: NameSet): string => `# ${app.pascal}
40
+
41
+ Built with [Ultimate](https://ultimate.dev). Bun-only, Postgres, SolidJS.
42
+
43
+ ## 🚀 Start
44
+
45
+ \`\`\`sh
46
+ bin/setup # prerequisites, deps, env, migrate, seed
47
+ x dev # all roles in one process, embedded Postgres, /_x mounted
48
+ x verify # the gate: typecheck, lint, boundaries, tests, drift, budgets
49
+ \`\`\`
50
+
51
+ ## 🗺 Layout
52
+
53
+ | Path | Holds |
54
+ |---|---|
55
+ | \`apps/web/site\` | static/isr, 0kb JS, SEO-critical |
56
+ | \`apps/web/app\` | authed, streaming, realtime |
57
+ | \`apps/web/api\` | actions only |
58
+ | \`apps/web/shared\` | tokens, primitives, actor type — a leaf |
59
+ | \`apps/admin\` | generated admin dashboard, MCP on |
60
+ | \`packages/*\` | domain, db, i18n, ui, mcp |
61
+ | \`app.config.ts\` | the one config file |
62
+ | \`x.manifest.json\` | generated facts: routes, actions, jobs, policies |
63
+ `;
64
+
65
+ const binSetup = (): string => `#!/usr/bin/env bash
66
+ # Fresh clone to running. Idempotent: safe to re-run.
67
+ set -euo pipefail
68
+ cd "$(dirname "$0")/.."
69
+ command -v bun >/dev/null || { echo "X_BUN_MISSING: install bun — https://bun.sh"; exit 1; }
70
+ bun install
71
+ [ -f .env.development.local ] || printf '# per-box secrets, gitignored, wins over .env.development\\n' > .env.development.local
72
+ bunx x db migrate "$@"
73
+ bun run db:seed
74
+ echo "setup complete — next: x dev"
75
+ `;
76
+
77
+ const binDev = (): string => `#!/usr/bin/env bash
78
+ # Every role in one process: embedded Postgres, in-process NATS, S3 to a local dir.
79
+ set -euo pipefail
80
+ cd "$(dirname "$0")/.."
81
+ exec bunx x dev "$@"
82
+ `;
83
+
84
+ const binCheck = (): string => `#!/usr/bin/env bash
85
+ # The gate. Same steps as CI, because a check that lives only in CI cannot be run locally.
86
+ set -euo pipefail
87
+ cd "$(dirname "$0")/.."
88
+ exec bunx x verify "$@"
89
+ `;
90
+
91
+ const composeDev = (
92
+ app: NameSet,
93
+ ): string => `# Optional: x dev needs none of this. Use it when you want the real Postgres/NATS/MinIO locally.
94
+ services:
95
+ db:
96
+ image: postgres:17-alpine
97
+ environment:
98
+ POSTGRES_PASSWORD: ${app.kebab}
99
+ POSTGRES_DB: ${app.kebab}
100
+ ports: ['5432:5432']
101
+ healthcheck:
102
+ test: ['CMD-SHELL', 'pg_isready -U postgres']
103
+ interval: 5s
104
+ nats:
105
+ image: nats:2-alpine
106
+ command: ['-js']
107
+ ports: ['4222:4222']
108
+ s3:
109
+ image: minio/minio
110
+ command: ['server', '/data']
111
+ environment:
112
+ MINIO_ROOT_USER: ${app.kebab}
113
+ MINIO_ROOT_PASSWORD: ${app.kebab}-dev
114
+ ports: ['9000:9000']
115
+ `;
116
+
117
+ const dockerfile = (
118
+ app: NameSet,
119
+ ): string => `# One image, all roles. ROLE selects behaviour at start; nothing else differs between processes.
120
+ FROM oven/bun:1.3-alpine AS deps
121
+ WORKDIR /src
122
+ COPY package.json bun.lock ./
123
+ COPY apps ./apps
124
+ COPY packages ./packages
125
+ RUN bun install --frozen-lockfile --production
126
+
127
+ FROM oven/bun:1.3-alpine AS build
128
+ WORKDIR /src
129
+ COPY --from=deps /src/node_modules ./node_modules
130
+ COPY . .
131
+ RUN bunx x build --target binary --out /out/${app.kebab}
132
+
133
+ FROM gcr.io/distroless/base-debian12 AS runtime
134
+ COPY --from=build /out/${app.kebab} /app/${app.kebab}
135
+ ENV ROLE=web PORT=3000
136
+ EXPOSE 3000
137
+ USER 65532:65532
138
+ ENTRYPOINT ["/app/${app.kebab}"]
139
+ `;
140
+
141
+ /** Docs, shims and container files for a new app, in the order a reader meets them. */
142
+ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
143
+ return [
144
+ { path: 'README.md', contents: readme(app) },
145
+ { path: 'AGENTS.md', contents: agents(app) },
146
+ { path: 'CLAUDE.md', contents: claude(app) },
147
+ { path: 'bin/setup', contents: binSetup() },
148
+ { path: 'bin/dev', contents: binDev() },
149
+ { path: 'bin/check', contents: binCheck() },
150
+ { path: 'docker/Dockerfile', contents: dockerfile(app) },
151
+ { path: 'docker/docker-compose.dev.yml', contents: composeDev(app) },
152
+ ];
153
+ }
154
+
155
+ /** Files that must be executable after `x new` writes them. */
156
+ export const EXECUTABLE_FILES: readonly string[] = ['bin/setup', 'bin/dev', 'bin/check'];
@@ -0,0 +1,149 @@
1
+ // The generated app's `packages/i18n`: one catalog file per locale, and the framework's one
2
+ // blessed typed-catalog shape (modelled on examples/dummy/packages/i18n/src/index.ts) — split out
3
+ // of scaffold-repo.ts to stay under the file-size ceiling.
4
+
5
+ import { catalogJson } from './catalog-json';
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+ import { camel } from './naming';
8
+ import { packageShapeFiles } from './scaffold-package-shape';
9
+
10
+ /** The only `packages/*` manifest that names a dependency: every other one only re-exports a
11
+ * framework package's types, but this one statically imports `@ultimat3/i18n` at runtime. */
12
+ const i18nPackage = (app: NameSet, version: string): string => `{
13
+ "name": "@${app.kebab}/i18n",
14
+ "version": "0.0.0",
15
+ "private": true,
16
+ "type": "module",
17
+ "description": "Flat catalogs with loud misses",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "scripts": {
22
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
23
+ },
24
+ "dependencies": {
25
+ "@ultimat3/i18n": "^${version}"
26
+ }
27
+ }
28
+ `;
29
+
30
+ /**
31
+ * `en` first, then every other locale alphabetically — a stable order so a diff shows only the
32
+ * locale a run actually added, never a reshuffle. `en` is always included: `default: 'en'` below
33
+ * requires it to be a registered locale, and every real catalog set already has one from `x new`
34
+ * scaffold time.
35
+ */
36
+ const orderedLocales = (locales: readonly string[]): readonly string[] => {
37
+ const rest = new Set(locales);
38
+ rest.delete('en');
39
+ return ['en', ...[...rest].sort()];
40
+ };
41
+
42
+ /** A locale tag is not always a valid JS binding (`zh-hant`) — `camel()` is the one identifier
43
+ * derivation every generated file already uses for names, so the import agrees with the rest of
44
+ * the app's own naming instead of inventing a second casing rule. */
45
+ const localeImport = (locale: string): string =>
46
+ `import ${camel(locale)} from '../catalogs/${locale}.json';`;
47
+
48
+ /** The object-literal entry for one locale: shorthand when the binding IS the tag (`en`, `es`, …),
49
+ * `'tag': binding` when `camel()` had to reshape it (`zh-hant` → `zhHant`) — `defineCatalogs` reads
50
+ * the locale from the key, never the identifier, so the quoted form is what keeps it addressable. */
51
+ const localeEntry = (locale: string): string => {
52
+ const binding = camel(locale);
53
+ return binding === locale ? binding : `'${locale}': ${binding}`;
54
+ };
55
+
56
+ /**
57
+ * The app's one catalog-registration module — regenerated, never hand-edited, to the full current
58
+ * locale set every time `x g ... --locales` lands a new catalog file (`syncI18nIndex` in
59
+ * `cmd-generate.ts`). `i18nFiles` below calls this with `['en']` for the shape `x new` has always
60
+ * scaffolded; a later run passes whatever `packages/i18n/catalogs/` actually holds.
61
+ */
62
+ export function i18nIndex(locales: readonly string[]): string {
63
+ const ordered = orderedLocales(locales);
64
+ const imports = ordered.map(localeImport).join('\n');
65
+ const entries = ordered.map(localeEntry).join(', ');
66
+ return `// The app's catalog, registered once and typed against English. Every surface resolves strings
67
+ // through this module, and an unknown key is a compile error via useT() — never a runtime miss
68
+ // nobody notices until production.
69
+
70
+ import {
71
+ defineCatalogs,
72
+ type TranslationKey as KeyOf,
73
+ type Translator,
74
+ useI18n,
75
+ } from '@ultimat3/i18n';
76
+ ${imports}
77
+
78
+ export const catalogs = defineCatalogs({ default: 'en', locales: { ${entries} } });
79
+
80
+ /**
81
+ * English is the source of truth for the key space — a second locale must match it exactly, or
82
+ * \`x verify\` fails.
83
+ */
84
+ export type AppCatalog = typeof en;
85
+
86
+ /** Every key this app's catalog defines — dot-paths, plus the stem of each plural family. */
87
+ export type TranslationKey = KeyOf<AppCatalog>;
88
+
89
+ /**
90
+ * Use this, never \`useI18n()\` directly — the type parameter is what makes an unknown key a
91
+ * compile error instead of a \`⟦key⟧\` someone notices in production.
92
+ */
93
+ export const useT = (): Translator<AppCatalog> => useI18n<AppCatalog>();
94
+ `;
95
+ }
96
+
97
+ // `app.post.*` is not listed here: under `--example`, `x g resource post` merges its own keys
98
+ // into this same file (`merge: 'json'`, resolved by `dedupe()`); under `--no-example` that
99
+ // generator never runs, so those keys are simply absent, never a dangling reference.
100
+ const i18nCatalog = (app: NameSet): string =>
101
+ catalogJson({
102
+ 'site.home.title': app.pascal,
103
+ 'site.home.description': 'Everything you need, one command from shippable.',
104
+ 'site.home.cta': 'Open the dashboard',
105
+ 'app.dashboard.title': 'Dashboard',
106
+ 'app.dashboard.description': 'Your workspace.',
107
+ 'app.offline.title': 'You are offline',
108
+ 'app.offline.description': 'This page will refresh itself when the connection returns.',
109
+ 'admin.home.title': 'Admin',
110
+ 'admin.home.description': `Operations for ${app.pascal}.`,
111
+ });
112
+
113
+ const i18nTest = (): string => `import { expect } from 'bun:test';
114
+ import { unitTest } from '@ultimat3/testing';
115
+ import { catalogs } from './index';
116
+
117
+ unitTest('every locale has the same keys as the default one', () => {
118
+ const base = Object.keys(catalogs.catalogs[catalogs.default]).sort();
119
+ for (const locale of catalogs.locales) {
120
+ expect(Object.keys(catalogs.catalogs[locale]).sort()).toEqual(base);
121
+ }
122
+ });
123
+
124
+ unitTest('no catalog value is empty', () => {
125
+ for (const catalog of Object.values(catalogs.catalogs)) {
126
+ for (const value of Object.values(catalog)) expect(value.length).toBeGreaterThan(0);
127
+ }
128
+ });
129
+ `;
130
+
131
+ /**
132
+ * Everything `packages/i18n` ships: the manifest (the one package that declares a dependency),
133
+ * the shared shape files with the extra `catalogs/**` include the JSON needs, the typed index,
134
+ * its test, and the `en` catalog. `merge: 'json'` on the catalog is what lets `x g resource
135
+ * post` (the example slice, under `--example`) land its own keys in this same file instead of
136
+ * `dedupe()` dropping one contributor's — see the comment on `i18nCatalog` above.
137
+ */
138
+ export function i18nFiles(app: NameSet, version: string): readonly GeneratedFile[] {
139
+ return [
140
+ { path: 'packages/i18n/package.json', contents: i18nPackage(app, version) },
141
+ ...packageShapeFiles(app, 'i18n', 'Flat catalogs with loud misses', [
142
+ '**/*.ts',
143
+ 'catalogs/**/*',
144
+ ]),
145
+ { path: 'packages/i18n/src/index.ts', contents: i18nIndex(['en']) },
146
+ { path: 'packages/i18n/src/index.test.ts', contents: i18nTest() },
147
+ { path: 'packages/i18n/catalogs/en.json', contents: i18nCatalog(app), merge: 'json' },
148
+ ];
149
+ }
@@ -0,0 +1,54 @@
1
+ // The one source icon `x new` scaffolds. @ultimat3/core's image pipeline decodes PNG and JPEG
2
+ // only (`DECODABLE_FORMATS`, packages/core/src/image/pipeline.ts) — an SVG source, which is what
3
+ // this used to emit, can never be decoded, so `@ultimat3/pwa`'s `BuiltinImagePipeline` could
4
+ // never turn it into the fourteen `ICON_MATRIX` PNGs the generated web manifest declares.
5
+
6
+ import type { Raster } from '@ultimat3/core';
7
+ import { createRaster, encodeImage } from '@ultimat3/core';
8
+
9
+ /** `@ultimat3/pwa`'s own contract (`requireSourceIcon`'s fix line): square, 1024 or larger. */
10
+ const ICON_SIZE = 1024;
11
+
12
+ /**
13
+ * Mirrors `MASKABLE_PADDING` (`packages/pwa/src/icons.ts`): a maskable icon is cropped to the
14
+ * middle ~80% of the edge, so the mark has to stay inside that fraction or an installed Android
15
+ * icon clips it. Inlined rather than imported — `@ultimat3/pwa` is not a dependency of this
16
+ * package, and a placeholder icon does not need the rest of it.
17
+ */
18
+ const MASKABLE_PADDING = 0.1;
19
+
20
+ /**
21
+ * Not a colour: one mid-grey LEVEL, written to all three channels, so this file recreates no
22
+ * palette value that could drift from one. A token cannot supply it either — `@ultimat3/ui` owns
23
+ * the colour roles and is tier 5 like this package, so `cli -> ui` is a boundary error and
24
+ * `cli -> admin -> ui` does not transit. A placeholder must claim no brand colour to begin with.
25
+ */
26
+ const MARK_LEVEL = 128;
27
+
28
+ /** Opaque over the transparent canvas — the mark is what `probeImage` and a human both see. */
29
+ const MARK_ALPHA = 255;
30
+
31
+ /** Fills the maskable-safe inner square, transparent canvas left untouched around it. */
32
+ function paintMark(raster: Raster, inset: number): void {
33
+ const { width, height, pixels } = raster;
34
+ for (let y = inset; y < height - inset; y += 1) {
35
+ for (let x = inset; x < width - inset; x += 1) {
36
+ const i = (y * width + x) * 4;
37
+ pixels[i] = MARK_LEVEL;
38
+ pixels[i + 1] = MARK_LEVEL;
39
+ pixels[i + 2] = MARK_LEVEL;
40
+ pixels[i + 3] = MARK_ALPHA;
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * A 1024x1024 PNG: a solid square mark inside the maskable safe zone, transparent elsewhere —
47
+ * exactly what a placeholder needs to be, not art. Deterministic: no `Date.now()`, no randomness,
48
+ * so `x new` output never depends on run order or the clock.
49
+ */
50
+ export function icon(): Uint8Array {
51
+ const raster = createRaster(ICON_SIZE, ICON_SIZE, 'scaffold-icon');
52
+ paintMark(raster, Math.round(ICON_SIZE * MASKABLE_PADDING));
53
+ return encodeImage(raster, 'png');
54
+ }