@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- 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
|
+
}
|