@ultimat3/cli 1.0.0 → 1.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/package.json +23 -23
- package/src/app-load.ts +10 -0
- package/src/cmd-build.ts +28 -4
- package/src/dev-render.ts +8 -4
- package/src/dev-roles.ts +19 -2
- package/src/dev-services.ts +6 -1
- package/src/errors.ts +57 -0
- package/src/index.ts +27 -4
- package/src/mcp-errors.ts +6 -0
- package/src/migrations.ts +45 -0
- package/src/prerender.ts +71 -0
- package/src/serve.ts +176 -0
- package/src/templates/index.ts +1 -0
- package/src/templates/scaffold-app.ts +55 -0
- package/src/templates/scaffold-container.ts +264 -0
- package/src/templates/scaffold-docs.ts +5 -27
- package/src/templates/scaffold-repo.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,27 +34,27 @@
|
|
|
34
34
|
"dev": "bun run src/bin.ts dev"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/action": "1.
|
|
38
|
-
"@ultimat3/admin": "1.
|
|
39
|
-
"@ultimat3/ai": "1.
|
|
40
|
-
"@ultimat3/cache": "1.
|
|
41
|
-
"@ultimat3/core": "1.
|
|
42
|
-
"@ultimat3/db": "1.
|
|
43
|
-
"@ultimat3/entity": "1.
|
|
44
|
-
"@ultimat3/http": "1.
|
|
45
|
-
"@ultimat3/i18n": "1.
|
|
46
|
-
"@ultimat3/jobs": "1.
|
|
47
|
-
"@ultimat3/mail": "1.
|
|
48
|
-
"@ultimat3/manifest": "1.
|
|
49
|
-
"@ultimat3/mcp": "1.
|
|
50
|
-
"@ultimat3/policy": "1.
|
|
51
|
-
"@ultimat3/pwa": "1.
|
|
52
|
-
"@ultimat3/query": "1.
|
|
53
|
-
"@ultimat3/realtime": "1.
|
|
54
|
-
"@ultimat3/render": "1.
|
|
55
|
-
"@ultimat3/seo": "1.
|
|
56
|
-
"@ultimat3/storage": "1.
|
|
57
|
-
"@ultimat3/testing": "1.
|
|
58
|
-
"@ultimat3/time": "1.
|
|
37
|
+
"@ultimat3/action": "1.1.0",
|
|
38
|
+
"@ultimat3/admin": "1.1.0",
|
|
39
|
+
"@ultimat3/ai": "1.1.0",
|
|
40
|
+
"@ultimat3/cache": "1.1.0",
|
|
41
|
+
"@ultimat3/core": "1.1.0",
|
|
42
|
+
"@ultimat3/db": "1.1.0",
|
|
43
|
+
"@ultimat3/entity": "1.1.0",
|
|
44
|
+
"@ultimat3/http": "1.1.0",
|
|
45
|
+
"@ultimat3/i18n": "1.1.0",
|
|
46
|
+
"@ultimat3/jobs": "1.1.0",
|
|
47
|
+
"@ultimat3/mail": "1.1.0",
|
|
48
|
+
"@ultimat3/manifest": "1.1.0",
|
|
49
|
+
"@ultimat3/mcp": "1.1.0",
|
|
50
|
+
"@ultimat3/policy": "1.1.0",
|
|
51
|
+
"@ultimat3/pwa": "1.1.0",
|
|
52
|
+
"@ultimat3/query": "1.1.0",
|
|
53
|
+
"@ultimat3/realtime": "1.1.0",
|
|
54
|
+
"@ultimat3/render": "1.1.0",
|
|
55
|
+
"@ultimat3/seo": "1.1.0",
|
|
56
|
+
"@ultimat3/storage": "1.1.0",
|
|
57
|
+
"@ultimat3/testing": "1.1.0",
|
|
58
|
+
"@ultimat3/time": "1.1.0"
|
|
59
59
|
}
|
|
60
60
|
}
|
package/src/app-load.ts
CHANGED
|
@@ -22,6 +22,15 @@ const APP_GLOBS = [
|
|
|
22
22
|
'packages/*/src/**/*.ts',
|
|
23
23
|
] as const;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The two files that are *entry points*, not app modules: `apps/web/server.ts` starts the process
|
|
27
|
+
* and `apps/web/prerender.ts` runs the build. Importing either registers nothing — and importing
|
|
28
|
+
* `server.ts` deadlocks, because that module's own top-level `await runRole()` is what called this
|
|
29
|
+
* scan, so the dynamic import waits on a module that is waiting on the import. Anchored to the
|
|
30
|
+
* surface root: `apps/web/app/server.ts` is app code and stays in the scan.
|
|
31
|
+
*/
|
|
32
|
+
const ENTRY_POINT = /^apps\/[^/]+\/(?:server|prerender)\.tsx?$/;
|
|
33
|
+
|
|
25
34
|
export interface LoadedApp {
|
|
26
35
|
readonly root: string;
|
|
27
36
|
/** App-root-relative POSIX paths of every module that imported, sorted. */
|
|
@@ -64,6 +73,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
64
73
|
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
65
74
|
if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
|
|
66
75
|
const file = relative(root, absolute).split(sep).join('/');
|
|
76
|
+
if (ENTRY_POINT.test(file)) continue;
|
|
67
77
|
let module: Record<string, unknown>;
|
|
68
78
|
try {
|
|
69
79
|
module = (await import(absolute)) as Record<string, unknown>;
|
package/src/cmd-build.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// `x build --target docker|binary|static` — three targets, no platform primitives. Deploy anywhere
|
|
2
2
|
// means "anywhere that runs a container or a binary"; nothing here knows the name of a cloud.
|
|
3
3
|
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { requireAppRoot } from './app-root';
|
|
6
7
|
import { runVerify } from './cmd-verify';
|
|
7
8
|
import type { CliCommand, CommandContext } from './command';
|
|
8
|
-
import { UnknownCommandError } from './errors';
|
|
9
|
+
import { BuildEntryMissingError, UnknownCommandError } from './errors';
|
|
9
10
|
import { execOutput } from './exec';
|
|
10
11
|
import { msg } from './messages';
|
|
11
12
|
import type { CommandResult, Finding } from './output';
|
|
@@ -26,9 +27,28 @@ export function readTarget(raw: string | undefined): BuildTarget {
|
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The one file each target builds from, app-root-relative and POSIX. One table, because `x build`
|
|
32
|
+
* has to refuse a missing entry by name before it spawns anything, and the spawned command has to
|
|
33
|
+
* name the same file — a second copy is how `binary` came to compile a path `x new` never wrote.
|
|
34
|
+
*/
|
|
35
|
+
export const BUILD_ENTRY: Readonly<Record<BuildTarget, string>> = {
|
|
36
|
+
docker: 'docker/Dockerfile',
|
|
37
|
+
binary: 'apps/web/server.ts',
|
|
38
|
+
static: 'apps/web/prerender.ts',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Absolute path of the target's entry, or the error that names the file and what writes it. */
|
|
42
|
+
export function requireEntry(root: string, target: BuildTarget): string {
|
|
43
|
+
const entry = BUILD_ENTRY[target];
|
|
44
|
+
const absolute = join(root, entry);
|
|
45
|
+
if (!existsSync(absolute)) throw new BuildEntryMissingError({ target, entry });
|
|
46
|
+
return absolute;
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
/** One image for every role; ROLE selects behaviour at start, so there is one artifact to promote. */
|
|
30
50
|
export function dockerArgs(root: string, tag: string): readonly string[] {
|
|
31
|
-
return ['docker', 'build', '-f', join(root,
|
|
51
|
+
return ['docker', 'build', '-f', join(root, BUILD_ENTRY.docker), '-t', tag, root];
|
|
32
52
|
}
|
|
33
53
|
|
|
34
54
|
export function binaryArgs(root: string, out: string): readonly string[] {
|
|
@@ -37,14 +57,14 @@ export function binaryArgs(root: string, out: string): readonly string[] {
|
|
|
37
57
|
'build',
|
|
38
58
|
'--compile',
|
|
39
59
|
'--minify',
|
|
40
|
-
join(root,
|
|
60
|
+
join(root, BUILD_ENTRY.binary),
|
|
41
61
|
'--outfile',
|
|
42
62
|
out,
|
|
43
63
|
];
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
export function staticArgs(root: string, out: string): readonly string[] {
|
|
47
|
-
return ['bun', 'run', join(root,
|
|
67
|
+
return ['bun', 'run', join(root, BUILD_ENTRY.static), '--out', out];
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
export function argsFor(
|
|
@@ -71,6 +91,10 @@ export const buildCommand: CliCommand = {
|
|
|
71
91
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
72
92
|
const root = requireAppRoot('build', ctx.cwd).dir;
|
|
73
93
|
const target = readTarget(flagString(ctx.args, 'target'));
|
|
94
|
+
// Before the gate, not after: an entry the app does not have cannot be produced by a green
|
|
95
|
+
// typecheck, and eight seconds of `tsc` ahead of "that file does not exist" is eight seconds
|
|
96
|
+
// an agent spends on the wrong question.
|
|
97
|
+
requireEntry(root, target);
|
|
74
98
|
|
|
75
99
|
// Run static verify steps before building.
|
|
76
100
|
const staticSteps = ['typecheck', 'lint', 'boundaries', 'filesize', 'package-shape', 'errors'];
|
package/src/dev-render.ts
CHANGED
|
@@ -43,7 +43,11 @@ const headFor = async (entry: RouteEntry, data: DevRouteData): Promise<string> =
|
|
|
43
43
|
headFromMeta(await entry.config.meta(data), seoRenderers({ path: new URL(data.url).pathname })),
|
|
44
44
|
);
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Head + shell for one route render. Exported because the build's prerenderer must emit the same
|
|
48
|
+
* document `x dev` serves — two document builders is how a page that works in dev ships broken.
|
|
49
|
+
*/
|
|
50
|
+
export async function routeDocument(entry: RouteEntry, data: DevRouteData): Promise<string> {
|
|
47
51
|
return shellFor(await headFor(entry, data));
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -63,11 +67,11 @@ async function resultFor(
|
|
|
63
67
|
case 'static': {
|
|
64
68
|
// Not `renderStatic`: that enumerates every prerendered path for the build. A request
|
|
65
69
|
// names exactly one, and it earns the same content-hashed headers.
|
|
66
|
-
const body = await
|
|
70
|
+
const body = await routeDocument(entry, data);
|
|
67
71
|
return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
68
72
|
}
|
|
69
73
|
case 'isr': {
|
|
70
|
-
const served = await isr.serve(url.pathname, () =>
|
|
74
|
+
const served = await isr.serve(url.pathname, () => routeDocument(entry, data));
|
|
71
75
|
return served.result;
|
|
72
76
|
}
|
|
73
77
|
case 'spa':
|
|
@@ -90,7 +94,7 @@ async function resultFor(
|
|
|
90
94
|
);
|
|
91
95
|
}
|
|
92
96
|
default:
|
|
93
|
-
return renderSsr({ entry, params: data.params, url, ctx }, () =>
|
|
97
|
+
return renderSsr({ entry, params: data.params, url, ctx }, () => routeDocument(entry, data), {
|
|
94
98
|
buildId: options.buildId,
|
|
95
99
|
});
|
|
96
100
|
}
|
package/src/dev-roles.ts
CHANGED
|
@@ -49,8 +49,24 @@ export interface StartRolesOptions {
|
|
|
49
49
|
readonly routes: readonly Route[];
|
|
50
50
|
/** The process environment, for the roles that resolve a driver from it. */
|
|
51
51
|
readonly env: Env;
|
|
52
|
+
/**
|
|
53
|
+
* How the web role binds and what it admits about itself. `x dev` keeps the default —
|
|
54
|
+
* loopback, `dev: true`, so a laptop on a café network is not serving the app to the café. A
|
|
55
|
+
* container passes `{ dev: false, hostname: '0.0.0.0' }`: a process bound to `localhost` inside
|
|
56
|
+
* a container is unreachable from the port mapping, the load balancer and every PaaS health
|
|
57
|
+
* probe, which is the same failure in four costumes.
|
|
58
|
+
*/
|
|
59
|
+
readonly http?: WebBinding;
|
|
52
60
|
}
|
|
53
61
|
|
|
62
|
+
export interface WebBinding {
|
|
63
|
+
readonly dev: boolean;
|
|
64
|
+
readonly hostname: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Loopback and dev-mode. What `x dev` means, and what a container must override. */
|
|
68
|
+
export const DEV_BINDING: WebBinding = { dev: true, hostname: 'localhost' };
|
|
69
|
+
|
|
54
70
|
export interface RunningRoles {
|
|
55
71
|
readonly roles: readonly Role[];
|
|
56
72
|
/** `http://…` once the web role is up; null when it was not selected. */
|
|
@@ -99,15 +115,16 @@ export function selectRoles(flag: string | undefined): readonly Role[] {
|
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
function startWeb(options: StartRolesOptions): ServerHandle {
|
|
118
|
+
const binding = options.http ?? DEV_BINDING;
|
|
102
119
|
return createServer({
|
|
103
120
|
routes: options.routes,
|
|
104
121
|
role: 'web',
|
|
105
122
|
hooks: devHooks(),
|
|
106
123
|
config: defineHttpConfig({
|
|
107
124
|
port: options.port,
|
|
108
|
-
dev:
|
|
125
|
+
dev: binding.dev,
|
|
109
126
|
buildId: options.buildId,
|
|
110
|
-
hostname:
|
|
127
|
+
hostname: binding.hostname,
|
|
111
128
|
}),
|
|
112
129
|
}).start();
|
|
113
130
|
}
|
package/src/dev-services.ts
CHANGED
|
@@ -33,10 +33,15 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
33
33
|
*/
|
|
34
34
|
export function resolveServices(root: string, env: Env): DevServices {
|
|
35
35
|
const stateDir = join(root, '.x');
|
|
36
|
-
mkdirSync(stateDir, { recursive: true });
|
|
37
36
|
const databaseUrl = nonEmpty(env['DATABASE_URL']);
|
|
38
37
|
const natsUrl = nonEmpty(env['NATS_URL']);
|
|
39
38
|
const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
|
|
39
|
+
// Created only when something will actually live in it. A container whose bindings are all
|
|
40
|
+
// external runs non-root over a read-only app directory, and an unconditional mkdir there is an
|
|
41
|
+
// EACCES at boot for a directory that would have stayed empty.
|
|
42
|
+
if (databaseUrl === undefined || natsUrl === undefined || s3Endpoint === undefined) {
|
|
43
|
+
mkdirSync(stateDir, { recursive: true });
|
|
44
|
+
}
|
|
40
45
|
return {
|
|
41
46
|
stateDir,
|
|
42
47
|
db:
|
package/src/errors.ts
CHANGED
|
@@ -36,7 +36,14 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
36
36
|
'X_MANIFEST_STALE',
|
|
37
37
|
'X_BUDGET_UNMEASURED',
|
|
38
38
|
'X_BUILD_FAILED',
|
|
39
|
+
'X_BUILD_ENTRY_MISSING',
|
|
39
40
|
'X_DEPLOY_FAILED',
|
|
41
|
+
// The two the container's own environment can get wrong. A PaaS injects `PORT` and a supervisor
|
|
42
|
+
// injects `ROLE`; both arrive as strings from outside the app, so both are validated at boot
|
|
43
|
+
// rather than defaulted — a web role that quietly bound 3000 when the platform said 8080 fails
|
|
44
|
+
// its health check with nothing in the log that names the cause.
|
|
45
|
+
'X_ROLE_UNKNOWN',
|
|
46
|
+
'X_PORT_INVALID',
|
|
40
47
|
'X_GENERATE_CONFLICT',
|
|
41
48
|
'X_PORT_IN_USE',
|
|
42
49
|
'X_DB_GEN_FAILED',
|
|
@@ -100,7 +107,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
100
107
|
X_MANIFEST_STALE: 'openapi.json is stale',
|
|
101
108
|
X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
|
|
102
109
|
X_BUILD_FAILED: 'x build failed',
|
|
110
|
+
X_BUILD_ENTRY_MISSING: "the build target's entry file is not in the app",
|
|
103
111
|
X_DEPLOY_FAILED: 'a deploy step failed',
|
|
112
|
+
X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
|
|
113
|
+
X_PORT_INVALID: 'PORT is not a TCP port number',
|
|
104
114
|
X_GENERATE_CONFLICT: 'a generator would overwrite a file',
|
|
105
115
|
X_PORT_IN_USE: 'the dev port is taken',
|
|
106
116
|
X_DB_GEN_FAILED: 'x db gen failed',
|
|
@@ -354,6 +364,53 @@ export class FixTargetUnknownError extends UltimateError {
|
|
|
354
364
|
}
|
|
355
365
|
}
|
|
356
366
|
|
|
367
|
+
/**
|
|
368
|
+
* A build target names an entry file the app does not have. `x build` refuses before it spawns the
|
|
369
|
+
* builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
|
|
370
|
+
* supposed to own, and `docker build`'s says nothing about which target wanted it.
|
|
371
|
+
*/
|
|
372
|
+
export class BuildEntryMissingError extends UltimateError {
|
|
373
|
+
constructor(input: { target: string; entry: string }) {
|
|
374
|
+
super({
|
|
375
|
+
code: 'X_BUILD_ENTRY_MISSING',
|
|
376
|
+
cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
|
|
377
|
+
fix: `x new <name> writes ${input.entry} — copy it from a fresh scaffold into this app`,
|
|
378
|
+
docs: docsFor('X_BUILD_ENTRY_MISSING'),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
|
|
385
|
+
* otherwise start, serve nothing and report healthy — the one failure a rolling deploy cannot see.
|
|
386
|
+
*/
|
|
387
|
+
export class RoleUnknownError extends UltimateError {
|
|
388
|
+
constructor(input: { role: string; known: readonly string[] }) {
|
|
389
|
+
super({
|
|
390
|
+
code: 'X_ROLE_UNKNOWN',
|
|
391
|
+
cause: `ROLE="${input.role}" is not a role (known: ${input.known.join(', ')})`,
|
|
392
|
+
fix: `docker run -e ROLE=web <image> # one of: ${input.known.join(', ')}`,
|
|
393
|
+
docs: docsFor('X_ROLE_UNKNOWN'),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Every PaaS injects `PORT` and expects the process to bind exactly it. Defaulting past a value
|
|
400
|
+
* that will not parse is how a deploy comes up on 3000, fails the platform's health probe, and
|
|
401
|
+
* reports nothing an operator can act on.
|
|
402
|
+
*/
|
|
403
|
+
export class PortInvalidError extends UltimateError {
|
|
404
|
+
constructor(input: { value: string }) {
|
|
405
|
+
super({
|
|
406
|
+
code: 'X_PORT_INVALID',
|
|
407
|
+
cause: `PORT="${input.value}" is not a TCP port number between 0 and 65535`,
|
|
408
|
+
fix: 'docker run -e PORT=3000 <image>',
|
|
409
|
+
docs: docsFor('X_PORT_INVALID'),
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
357
414
|
/** An interface-complete command path whose remote/native half is not written yet. */
|
|
358
415
|
export class CliNotImplementedError extends UltimateError {
|
|
359
416
|
constructor(input: { feature: string; fix: string }) {
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,14 @@ export { planBoundaryCuts } from './boundary-cuts';
|
|
|
24
24
|
export type { BuildStats, RouteStats } from './budgets';
|
|
25
25
|
export { BUILD_STATS_FILE, checkBudgets, readBuildStats } from './budgets';
|
|
26
26
|
export type { BuildTarget } from './cmd-build';
|
|
27
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
argsFor,
|
|
29
|
+
BUILD_ENTRY,
|
|
30
|
+
BUILD_TARGETS,
|
|
31
|
+
buildCommand,
|
|
32
|
+
readTarget,
|
|
33
|
+
requireEntry,
|
|
34
|
+
} from './cmd-build';
|
|
28
35
|
export { branchDatabaseName, branchSql, dbCommand, previewUrl } from './cmd-db';
|
|
29
36
|
export type { DeployPlan } from './cmd-deploy';
|
|
30
37
|
export { deployCommand, planDeploy } from './cmd-deploy';
|
|
@@ -64,9 +71,9 @@ export { devHooks } from './dev-hooks';
|
|
|
64
71
|
export type { DevDbClient, RunningQueue } from './dev-queue';
|
|
65
72
|
export { startQueue } from './dev-queue';
|
|
66
73
|
export type { DevRenderOptions, DevRouteData } from './dev-render';
|
|
67
|
-
export { appRoutes } from './dev-render';
|
|
68
|
-
export type { RunningRoles, StartRolesOptions } from './dev-roles';
|
|
69
|
-
export { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
|
|
74
|
+
export { appRoutes, routeDocument } from './dev-render';
|
|
75
|
+
export type { RunningRoles, StartRolesOptions, WebBinding } from './dev-roles';
|
|
76
|
+
export { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
|
|
70
77
|
export type { RunningServices } from './dev-runtime';
|
|
71
78
|
export { startServices } from './dev-runtime';
|
|
72
79
|
export type { DevServices, ServiceBinding } from './dev-services';
|
|
@@ -98,6 +105,7 @@ export {
|
|
|
98
105
|
export type { CliErrorCode } from './errors';
|
|
99
106
|
export {
|
|
100
107
|
BadFlagError,
|
|
108
|
+
BuildEntryMissingError,
|
|
101
109
|
BunVersionError,
|
|
102
110
|
CatalogExistsError,
|
|
103
111
|
CLI_ERROR_CODES,
|
|
@@ -109,6 +117,8 @@ export {
|
|
|
109
117
|
JobUnknownError,
|
|
110
118
|
NoTestFilesError,
|
|
111
119
|
NotInAppError,
|
|
120
|
+
PortInvalidError,
|
|
121
|
+
RoleUnknownError,
|
|
112
122
|
UnknownCommandError,
|
|
113
123
|
VerifyFailedError,
|
|
114
124
|
} from './errors';
|
|
@@ -122,6 +132,7 @@ export { renderJobTable } from './jobs-table';
|
|
|
122
132
|
export type { CliMcpServer, DevHostInput } from './mcp-host';
|
|
123
133
|
export { createDevMcpServer, DEV_TOOL_SCOPES, localCaller } from './mcp-host';
|
|
124
134
|
export { messageKeys, msg } from './messages';
|
|
135
|
+
export { MIGRATIONS_DIR, migrationName, parseMigrationSql, readMigrations } from './migrations';
|
|
125
136
|
export type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
126
137
|
export {
|
|
127
138
|
exitCodeFor,
|
|
@@ -135,7 +146,19 @@ export {
|
|
|
135
146
|
} from './output';
|
|
136
147
|
export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
|
|
137
148
|
export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
|
|
149
|
+
export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
|
|
150
|
+
export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
|
|
138
151
|
export { CLI_VERSION, COMMANDS, commandFor, SPECS } from './registry';
|
|
152
|
+
export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
|
|
153
|
+
export {
|
|
154
|
+
CONTAINER_BINDING,
|
|
155
|
+
DEFAULT_PORT,
|
|
156
|
+
portFromEnv,
|
|
157
|
+
roleFromEnv,
|
|
158
|
+
runMigrations,
|
|
159
|
+
runRole,
|
|
160
|
+
serveApp,
|
|
161
|
+
} from './serve';
|
|
139
162
|
export {
|
|
140
163
|
eachSourceFile,
|
|
141
164
|
isGenerated,
|
package/src/mcp-errors.ts
CHANGED
|
@@ -47,7 +47,13 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
47
47
|
X_MANIFEST_STALE: 'x manifest --json',
|
|
48
48
|
X_BUDGET_UNMEASURED: 'x build --json && x verify --json',
|
|
49
49
|
X_BUILD_FAILED: 'x build --json # the finding names the failing step',
|
|
50
|
+
X_BUILD_ENTRY_MISSING:
|
|
51
|
+
'x new <name> --dry-run --json # the file list names every entry a build needs',
|
|
50
52
|
X_DEPLOY_FAILED: 'x deploy --json # the finding carries the command to re-run directly',
|
|
53
|
+
// The container's own environment, so the answer is the run that sets it — never an `x` command,
|
|
54
|
+
// which is not what is running when a `ROLE=wroker` pod refuses to boot.
|
|
55
|
+
X_ROLE_UNKNOWN: 'docker run -e ROLE=web <image>',
|
|
56
|
+
X_PORT_INVALID: 'docker run -e PORT=3000 <image>',
|
|
51
57
|
X_GENERATE_CONFLICT: 'x g <kind> <name> --force --json',
|
|
52
58
|
X_PORT_IN_USE: 'x dev --port 3001 --json',
|
|
53
59
|
// Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The app's SQL migrations, read off disk into `@ultimat3/db`'s own `Migration` shape. One reader,
|
|
2
|
+
// because the release phase and the developer must apply the identical list through the identical
|
|
3
|
+
// ledger — a deploy that migrated by some other route is a schema nobody can reconstruct.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import type { Migration } from '@ultimat3/db';
|
|
8
|
+
|
|
9
|
+
/** Where `x new` writes them and where `x db gen` adds to. App-root-relative, POSIX. */
|
|
10
|
+
export const MIGRATIONS_DIR = 'packages/db/migrations';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `-- down` alone on a line splits a migration file. Anchored to the whole line so a comment that
|
|
14
|
+
* merely mentions the word — `-- down migrations are required` — is not mistaken for the marker.
|
|
15
|
+
*/
|
|
16
|
+
const DOWN_MARKER = /^[ \t]*--[ \t]*down[ \t]*$/im;
|
|
17
|
+
|
|
18
|
+
/** `0000_initial` → the ledger id; `initial` → the name a conflict message prints. */
|
|
19
|
+
export const migrationName = (id: string): string => id.replace(/^\d+_/, '');
|
|
20
|
+
|
|
21
|
+
export function parseMigrationSql(id: string, sql: string): Migration {
|
|
22
|
+
const marker = DOWN_MARKER.exec(sql);
|
|
23
|
+
const up = (marker === null ? sql : sql.slice(0, marker.index)).trim();
|
|
24
|
+
const down = marker === null ? '' : sql.slice(marker.index + marker[0].length).trim();
|
|
25
|
+
return { id, name: migrationName(id), up, down };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sorted by id, because that is the apply order and `pendingMigrations` re-sorts on the same key.
|
|
30
|
+
* A missing directory is an empty list rather than a throw: an app can legitimately declare no
|
|
31
|
+
* entity yet, and the count is reported so "nothing was applied" is never silent.
|
|
32
|
+
*/
|
|
33
|
+
export async function readMigrations(root: string): Promise<readonly Migration[]> {
|
|
34
|
+
const dir = join(root, MIGRATIONS_DIR);
|
|
35
|
+
if (!existsSync(dir)) return [];
|
|
36
|
+
const files: string[] = [];
|
|
37
|
+
for await (const file of new Bun.Glob('*.sql').scan({ cwd: dir })) files.push(file);
|
|
38
|
+
files.sort();
|
|
39
|
+
const migrations: Migration[] = [];
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
const text = await Bun.file(join(dir, file)).text();
|
|
42
|
+
migrations.push(parseMigrationSql(file.replace(/\.sql$/, ''), text));
|
|
43
|
+
}
|
|
44
|
+
return migrations;
|
|
45
|
+
}
|
package/src/prerender.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `x build --target static` — the `site/` surface, rendered once and written as files a CDN or an
|
|
2
|
+
// object store can serve with no process behind it. Enumeration, hashing and the output path are
|
|
3
|
+
// `@ultimat3/render`'s `renderStatic`; the document is the one `x dev` serves. This file decides
|
|
4
|
+
// only which routes qualify and where the bytes land.
|
|
5
|
+
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import type { RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import { renderStatic, routeEntries } from '@ultimat3/render';
|
|
9
|
+
import { loadApp } from './app-load';
|
|
10
|
+
import { appManifest } from './app-manifest';
|
|
11
|
+
import { routeDocument } from './dev-render';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `static` only. `isr` revalidates and `ssr`/`stream`/`spa` need a process, so writing any of them
|
|
15
|
+
* to disk would publish a page whose staleness nothing can correct — and the route already
|
|
16
|
+
* declared which of the five it is.
|
|
17
|
+
*/
|
|
18
|
+
export const isPrerenderable = (entry: RouteEntry): boolean => entry.config.render === 'static';
|
|
19
|
+
|
|
20
|
+
export interface PrerenderOptions {
|
|
21
|
+
readonly root: string;
|
|
22
|
+
readonly out: string;
|
|
23
|
+
/** Origin the rendered `<head>` builds canonical and og:url from. */
|
|
24
|
+
readonly origin?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PrerenderedPage {
|
|
28
|
+
readonly path: string;
|
|
29
|
+
/** Relative to `out`, POSIX, as `renderStatic` computed it. */
|
|
30
|
+
readonly file: string;
|
|
31
|
+
readonly hash: string;
|
|
32
|
+
readonly bytes: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PrerenderReport {
|
|
36
|
+
readonly out: string;
|
|
37
|
+
readonly buildId: string;
|
|
38
|
+
readonly pages: readonly PrerenderedPage[];
|
|
39
|
+
/** Routes that exist and are not static. Reported, so "only 2 pages" is never a mystery. */
|
|
40
|
+
readonly skipped: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_ORIGIN = 'https://localhost';
|
|
44
|
+
|
|
45
|
+
export async function prerenderSite(options: PrerenderOptions): Promise<PrerenderReport> {
|
|
46
|
+
// The same load `x dev` and `x manifest` perform: importing the app's modules IS what fills the
|
|
47
|
+
// route registry, so there is no route table to prerender before this runs.
|
|
48
|
+
await loadApp(options.root);
|
|
49
|
+
const buildId = (await appManifest(options.root)).manifest.buildId;
|
|
50
|
+
const origin = options.origin ?? DEFAULT_ORIGIN;
|
|
51
|
+
const pages: PrerenderedPage[] = [];
|
|
52
|
+
const skipped: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const entry of routeEntries()) {
|
|
55
|
+
if (!isPrerenderable(entry)) {
|
|
56
|
+
skipped.push(entry.path);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const artifacts = await renderStatic(
|
|
60
|
+
entry,
|
|
61
|
+
({ path, params }) => routeDocument(entry, { url: new URL(path, origin).href, params }),
|
|
62
|
+
{ buildId },
|
|
63
|
+
);
|
|
64
|
+
for (const artifact of artifacts) {
|
|
65
|
+
const file = join(options.out, artifact.outputPath);
|
|
66
|
+
const bytes = await Bun.write(file, artifact.html);
|
|
67
|
+
pages.push({ path: artifact.path, file: artifact.outputPath, hash: artifact.hash, bytes });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { out: options.out, buildId, pages, skipped };
|
|
71
|
+
}
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// What a container starts. `apps/web/server.ts` is three lines that call `runRole`, so the boot a
|
|
2
|
+
// production process performs is framework code with tests rather than app code the author has to
|
|
3
|
+
// get right — and it is the SAME code `x dev` runs, minus the watcher, minus `/_x`, minus
|
|
4
|
+
// `dev: true`. The only production-shaped decisions live here: which role, which port, and the
|
|
5
|
+
// fact that a container must bind every interface.
|
|
6
|
+
|
|
7
|
+
import { listActions, toRoute } from '@ultimat3/action';
|
|
8
|
+
import type { Role } from '@ultimat3/core';
|
|
9
|
+
import { isRole, logger, ROLES } from '@ultimat3/core';
|
|
10
|
+
import { type MigrationReport, migrate } from '@ultimat3/db';
|
|
11
|
+
import type { Route } from '@ultimat3/http';
|
|
12
|
+
import { loadApp } from './app-load';
|
|
13
|
+
import { appManifest } from './app-manifest';
|
|
14
|
+
import { assetRoutes } from './dev-assets';
|
|
15
|
+
import { startQueue } from './dev-queue';
|
|
16
|
+
import { appRoutes } from './dev-render';
|
|
17
|
+
import type { RunningRoles, WebBinding } from './dev-roles';
|
|
18
|
+
import { startRoles } from './dev-roles';
|
|
19
|
+
import type { RunningServices } from './dev-runtime';
|
|
20
|
+
import { startServices } from './dev-runtime';
|
|
21
|
+
import type { Env } from './dev-services';
|
|
22
|
+
import { resolveServices } from './dev-services';
|
|
23
|
+
import { PortInvalidError, RoleUnknownError } from './errors';
|
|
24
|
+
import { holdUntilShutdown } from './hold';
|
|
25
|
+
import { readMigrations } from './migrations';
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_PORT = 3000;
|
|
28
|
+
|
|
29
|
+
/** Every interface. A container bound to loopback is unreachable through its own port mapping. */
|
|
30
|
+
export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
|
|
34
|
+
* to `web` would start a process that serves nothing the operator asked for and reports healthy.
|
|
35
|
+
*/
|
|
36
|
+
export function roleFromEnv(env: Env): Role {
|
|
37
|
+
const raw = env['ROLE'] ?? 'web';
|
|
38
|
+
if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every PaaS injects `PORT` and routes traffic to exactly it. `Number.parseInt` would read `80abc`
|
|
44
|
+
* as 80, so the whole string has to be a port — a partially-parsed port is a deploy that binds
|
|
45
|
+
* somewhere nobody asked for.
|
|
46
|
+
*/
|
|
47
|
+
export function portFromEnv(env: Env): number {
|
|
48
|
+
const raw = env['PORT'];
|
|
49
|
+
if (raw === undefined || raw.trim().length === 0) return DEFAULT_PORT;
|
|
50
|
+
const port = Number(raw.trim());
|
|
51
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
52
|
+
throw new PortInvalidError({ value: raw });
|
|
53
|
+
return port;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ServeOptions {
|
|
57
|
+
readonly root: string;
|
|
58
|
+
readonly env: Env;
|
|
59
|
+
/** Overrides `ROLE`; `runRole` reads the environment when this is absent. */
|
|
60
|
+
readonly role?: Role;
|
|
61
|
+
/** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
|
|
62
|
+
readonly port?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ServedApp {
|
|
66
|
+
readonly kind: 'served';
|
|
67
|
+
readonly role: Role;
|
|
68
|
+
/** `http://…` for the web role; null for the roles that open no HTTP socket. */
|
|
69
|
+
readonly url: string | null;
|
|
70
|
+
readonly buildId: string;
|
|
71
|
+
readonly running: RunningRoles;
|
|
72
|
+
readonly runtime: RunningServices;
|
|
73
|
+
stop(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MigratedApp {
|
|
77
|
+
readonly kind: 'migrated';
|
|
78
|
+
readonly role: 'migrate';
|
|
79
|
+
readonly report: MigrationReport;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type StartedApp = ServedApp | MigratedApp;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The release phase, as a role. `migrate` is not a server: it applies the app's own migrations
|
|
86
|
+
* through `@ultimat3/db`'s ledger — advisory lock, per-migration checksum, app-version fence — and
|
|
87
|
+
* exits, so a platform that runs one container to completion before the rest start (Heroku's
|
|
88
|
+
* release phase, a compose `service_completed_successfully`, a Kubernetes Job) has exactly one
|
|
89
|
+
* thing to run and no framework-specific flag to learn.
|
|
90
|
+
*
|
|
91
|
+
* It boots the queue, not the whole runtime: this role touches the database and nothing else, and
|
|
92
|
+
* `startQueue` is what installs `db()` for `migrate()` to find.
|
|
93
|
+
*/
|
|
94
|
+
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
95
|
+
const queue = await startQueue(resolveServices(options.root, options.env));
|
|
96
|
+
try {
|
|
97
|
+
const migrations = await readMigrations(options.root);
|
|
98
|
+
const report = await migrate({
|
|
99
|
+
migrations,
|
|
100
|
+
...(options.env['APP_VERSION'] === undefined
|
|
101
|
+
? {}
|
|
102
|
+
: { appVersion: options.env['APP_VERSION'] }),
|
|
103
|
+
});
|
|
104
|
+
logger.info('ultimate migrate applied', {
|
|
105
|
+
applied: report.applied.length,
|
|
106
|
+
available: migrations.length,
|
|
107
|
+
appVersion: report.appVersion,
|
|
108
|
+
});
|
|
109
|
+
return { kind: 'migrated', role: 'migrate', report };
|
|
110
|
+
} finally {
|
|
111
|
+
await queue.stop();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Boot order is `x dev`'s, for the reason `x dev` gives: services, then the app's own modules
|
|
117
|
+
* (importing them IS the registration), then the role that serves what they registered. The route
|
|
118
|
+
* table is the same three contributions minus the dashboard — a `/_x` in production would expose
|
|
119
|
+
* the app's policy matrix, its outbox and its spans to the internet.
|
|
120
|
+
*/
|
|
121
|
+
export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
122
|
+
const role = options.role ?? roleFromEnv(options.env);
|
|
123
|
+
const runtime = await startServices(resolveServices(options.root, options.env), options.env);
|
|
124
|
+
// Importing the app's modules IS the registration: every route, action and job below is
|
|
125
|
+
// whatever this call put in the registries.
|
|
126
|
+
await loadApp(options.root);
|
|
127
|
+
// The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
|
|
128
|
+
// answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only
|
|
129
|
+
// when unstamped, because a stamped image already paid for it at build time and a replica's boot
|
|
130
|
+
// should not repeat it — the load above is the part every boot needs either way.
|
|
131
|
+
const stamped = options.env['BUILD_ID'];
|
|
132
|
+
const buildId =
|
|
133
|
+
stamped !== undefined && stamped.length > 0
|
|
134
|
+
? stamped
|
|
135
|
+
: (await appManifest(options.root)).manifest.buildId;
|
|
136
|
+
const routes: readonly Route[] = [
|
|
137
|
+
...listActions().map(toRoute),
|
|
138
|
+
...assetRoutes({ root: options.root, storage: runtime.storage }),
|
|
139
|
+
...appRoutes({ buildId }),
|
|
140
|
+
];
|
|
141
|
+
const running = await startRoles({
|
|
142
|
+
roles: [role],
|
|
143
|
+
port: options.port ?? portFromEnv(options.env),
|
|
144
|
+
buildId,
|
|
145
|
+
runtime,
|
|
146
|
+
routes,
|
|
147
|
+
env: options.env,
|
|
148
|
+
http: CONTAINER_BINDING,
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
kind: 'served',
|
|
152
|
+
role,
|
|
153
|
+
url: running.url,
|
|
154
|
+
buildId,
|
|
155
|
+
running,
|
|
156
|
+
runtime,
|
|
157
|
+
async stop() {
|
|
158
|
+
await running.stop();
|
|
159
|
+
await runtime.stop();
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* What `apps/web/server.ts` calls. Returns for `migrate` — the process is meant to exit — and
|
|
166
|
+
* holds for every other role until core's drain completes, so SIGTERM from a rolling restart takes
|
|
167
|
+
* the three-phase path (stop accepting, finish in-flight, close) instead of killing a query.
|
|
168
|
+
*/
|
|
169
|
+
export async function runRole(options: ServeOptions): Promise<StartedApp> {
|
|
170
|
+
const role = options.role ?? roleFromEnv(options.env);
|
|
171
|
+
if (role === 'migrate') return runMigrations({ ...options, role });
|
|
172
|
+
const app = await serveApp({ ...options, role });
|
|
173
|
+
logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
|
|
174
|
+
await holdUntilShutdown('serve', () => app.stop())();
|
|
175
|
+
return app;
|
|
176
|
+
}
|
package/src/templates/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export { resourceFiles } from './resource';
|
|
|
18
18
|
export type { RouteOptions, Surface } from './route';
|
|
19
19
|
export { routeFiles } from './route';
|
|
20
20
|
export { appFiles } from './scaffold-app';
|
|
21
|
+
export { containerFiles } from './scaffold-container';
|
|
21
22
|
export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
|
|
22
23
|
export { i18nIndex } from './scaffold-i18n';
|
|
23
24
|
export { repoFiles } from './scaffold-repo';
|
|
@@ -280,6 +280,59 @@ export function AdminHome() {
|
|
|
280
280
|
}
|
|
281
281
|
`;
|
|
282
282
|
|
|
283
|
+
// The two entry files a deploy needs. Both are deliberately thin: which role a container is, which
|
|
284
|
+
// port it binds, how it drains and what a static build enumerates are the framework's answers, so
|
|
285
|
+
// an upgrade moves them without a codemod in every app that ever shipped.
|
|
286
|
+
|
|
287
|
+
const server =
|
|
288
|
+
(): string => `// The production entry. \`docker/Dockerfile\` starts this, and \`x build --target binary\` compiles it.
|
|
289
|
+
// ROLE selects what this process is — web, sync, worker, scheduler, replicator, or migrate, which
|
|
290
|
+
// applies the migrations and exits. PORT is bound on every interface, because a container bound to
|
|
291
|
+
// localhost is unreachable through its own port mapping.
|
|
292
|
+
|
|
293
|
+
import { join } from 'node:path';
|
|
294
|
+
import { runRole } from '@ultimat3/cli';
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
|
|
298
|
+
* A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
|
|
299
|
+
* holds this module's bundled imports and none of the app's source, and the framework's registries
|
|
300
|
+
* are filled by scanning that source at boot. So a binary reads its root from the directory it is
|
|
301
|
+
* started in — it is a launcher for an app tree, not a self-contained copy of one.
|
|
302
|
+
*/
|
|
303
|
+
const root = import.meta.dir.startsWith('/$bunfs')
|
|
304
|
+
? process.cwd()
|
|
305
|
+
: join(import.meta.dir, '..', '..');
|
|
306
|
+
|
|
307
|
+
// Guarded, because the framework's module scan imports every file under apps/*/ to fill its
|
|
308
|
+
// registries — an unguarded boot would start a server inside \`x verify\`.
|
|
309
|
+
if (import.meta.main) {
|
|
310
|
+
await runRole({ root, env: Bun.env });
|
|
311
|
+
}
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
const prerender =
|
|
315
|
+
(): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
|
|
316
|
+
// file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
|
|
317
|
+
// behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
|
|
318
|
+
|
|
319
|
+
import { join } from 'node:path';
|
|
320
|
+
import { prerenderSite } from '@ultimat3/cli';
|
|
321
|
+
|
|
322
|
+
const root = join(import.meta.dir, '..', '..');
|
|
323
|
+
const flag = Bun.argv.indexOf('--out');
|
|
324
|
+
const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
|
|
325
|
+
// SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
|
|
326
|
+
const origin = Bun.env['SITE_ORIGIN'];
|
|
327
|
+
|
|
328
|
+
if (import.meta.main) {
|
|
329
|
+
const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
|
|
330
|
+
await Bun.stdout.write(
|
|
331
|
+
\`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped })}\\n\`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
`;
|
|
335
|
+
|
|
283
336
|
const placeholder = (surface: string, app: NameSet): string => `# ${surface}
|
|
284
337
|
|
|
285
338
|
Placeholder. The monorepo shape exists now so adding ${surface} later is a new directory, not a
|
|
@@ -297,6 +350,8 @@ export function appFiles(app: NameSet): readonly GeneratedFile[] {
|
|
|
297
350
|
return [
|
|
298
351
|
{ path: 'apps/web/package.json', contents: webPackage(app) },
|
|
299
352
|
{ path: 'apps/web/tsconfig.json', contents: tsconfig() },
|
|
353
|
+
{ path: 'apps/web/server.ts', contents: server() },
|
|
354
|
+
{ path: 'apps/web/prerender.ts', contents: prerender() },
|
|
300
355
|
{ path: 'apps/web/site/icon.png', contents: icon() },
|
|
301
356
|
{ path: 'apps/web/site/page.tsx', contents: sitePage(app) },
|
|
302
357
|
{ path: 'apps/web/site/page.module.scss', contents: siteStyle() },
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The container half of what `x new` writes: the image, what to ignore when building it, the
|
|
2
|
+
// production topology, and the one page that explains how a platform runs the release phase.
|
|
3
|
+
// Split from scaffold-docs.ts because these four are one subject and that file is another.
|
|
4
|
+
//
|
|
5
|
+
// Axiom 7 in file form. Nothing here names a cloud: `$PORT`, `0.0.0.0`, `/readyz` and a
|
|
6
|
+
// run-to-completion migrate step are conventions every container platform shares — a Heroku
|
|
7
|
+
// buildpack, a Render blueprint or a fly.toml would be the primitive that never ships.
|
|
8
|
+
|
|
9
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
10
|
+
|
|
11
|
+
const dockerfile = (
|
|
12
|
+
app: NameSet,
|
|
13
|
+
): string => `# One image, every role. ROLE selects behaviour at start, so there is exactly one artifact to
|
|
14
|
+
# promote — the image that passed staging is the image production runs.
|
|
15
|
+
#
|
|
16
|
+
# x build --target docker --tag ${app.kebab}:$(git rev-parse --short HEAD)
|
|
17
|
+
# docker run --rm -e ROLE=migrate -e DATABASE_URL=... ${app.kebab}:... # release phase
|
|
18
|
+
# docker run -e ROLE=web -e PORT=8080 -p 8080:8080 -e DATABASE_URL=... ${app.kebab}:...
|
|
19
|
+
#
|
|
20
|
+
# syntax=docker/dockerfile:1
|
|
21
|
+
|
|
22
|
+
# ---------- deps: runtime dependencies only, cached on the workspace manifests ----------
|
|
23
|
+
FROM oven/bun:1.3-alpine AS deps
|
|
24
|
+
WORKDIR /app
|
|
25
|
+
COPY package.json bun.lock ./
|
|
26
|
+
# The workspace members' manifests are what \`bun install\` resolves against; their sources are not.
|
|
27
|
+
COPY apps ./apps
|
|
28
|
+
COPY packages ./packages
|
|
29
|
+
RUN bun install --frozen-lockfile --production
|
|
30
|
+
|
|
31
|
+
# ---------- runtime ----------
|
|
32
|
+
# No build stage and no second gate: \`x verify\` is the gate and \`x build\` runs the static steps
|
|
33
|
+
# before it ever calls \`docker build\`. Re-running typecheck and lint here would need the
|
|
34
|
+
# devDependencies the \`--production\` install above deliberately leaves out — which is exactly how
|
|
35
|
+
# a build stage came to run \`tsc\` and \`biome\` against a tree that had neither.
|
|
36
|
+
FROM oven/bun:1.3-alpine AS runtime
|
|
37
|
+
WORKDIR /app
|
|
38
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
39
|
+
COPY . .
|
|
40
|
+
|
|
41
|
+
# The immutable content hash this image serves. Stamped by CI (\`--build-arg BUILD_ID=$(git rev-parse HEAD)\`);
|
|
42
|
+
# without one the server computes the same hash from the manifest at boot. Never \`latest\`.
|
|
43
|
+
ARG BUILD_ID=
|
|
44
|
+
ENV NODE_ENV=production \\
|
|
45
|
+
ROLE=web \\
|
|
46
|
+
PORT=3000 \\
|
|
47
|
+
BUILD_ID=\${BUILD_ID}
|
|
48
|
+
|
|
49
|
+
# Documentation only — the platform decides the real port and injects it as PORT. The server binds
|
|
50
|
+
# whatever arrives, on 0.0.0.0, because a container bound to localhost is unreachable through its
|
|
51
|
+
# own port mapping, its load balancer and every health probe alike.
|
|
52
|
+
EXPOSE 3000
|
|
53
|
+
|
|
54
|
+
# Every role serves /healthz and /readyz. /readyz flips to 503 on SIGTERM *before* the socket
|
|
55
|
+
# closes, so a rolling restart drains in-flight work instead of dropping it.
|
|
56
|
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
|
|
57
|
+
bun --eval "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
|
|
58
|
+
|
|
59
|
+
# \`.x/\` is where any binding that is still embedded keeps its state — the local storage disk, and
|
|
60
|
+
# PGlite if DATABASE_URL is unset. Owned by the runtime user, because /app is not: the alternative
|
|
61
|
+
# is a non-root process failing at boot on a directory it is the only one that ever writes.
|
|
62
|
+
RUN mkdir -p /app/.x && chown -R bun:bun /app/.x
|
|
63
|
+
|
|
64
|
+
USER bun
|
|
65
|
+
# apps/web/server.ts reads ROLE and PORT and nothing else. \`migrate\` applies the migrations and
|
|
66
|
+
# exits; every other role serves until SIGTERM.
|
|
67
|
+
ENTRYPOINT ["bun", "apps/web/server.ts"]
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* BuildKit prefers `<dockerfile>.dockerignore` over the context root's, so this sits beside the
|
|
72
|
+
* Dockerfile. Without it `COPY . .` ships `node_modules` and `.x/` — a stale host `node_modules`
|
|
73
|
+
* would shadow the `--production` install the deps stage just made.
|
|
74
|
+
*/
|
|
75
|
+
const dockerignore = (): string => `node_modules
|
|
76
|
+
**/node_modules
|
|
77
|
+
**/.x
|
|
78
|
+
**/dist
|
|
79
|
+
**/*.tsbuildinfo
|
|
80
|
+
.git
|
|
81
|
+
.env
|
|
82
|
+
.env.*.local
|
|
83
|
+
coverage
|
|
84
|
+
**/test-results
|
|
85
|
+
**/playwright-report
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const composeProd = (
|
|
89
|
+
app: NameSet,
|
|
90
|
+
): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
|
|
91
|
+
# What \`x deploy --method compose\` runs. migrate runs to completion before anything serves.
|
|
92
|
+
#
|
|
93
|
+
# IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
|
|
94
|
+
name: ${app.kebab}
|
|
95
|
+
|
|
96
|
+
x-image: &image
|
|
97
|
+
image: \${IMAGE:-${app.kebab}:dev}
|
|
98
|
+
env_file: [../.env.production]
|
|
99
|
+
restart: unless-stopped
|
|
100
|
+
stop_grace_period: 30s # SIGTERM → drain in-flight requests, jobs and sockets
|
|
101
|
+
depends_on:
|
|
102
|
+
db: { condition: service_healthy }
|
|
103
|
+
|
|
104
|
+
services:
|
|
105
|
+
db:
|
|
106
|
+
image: postgres:17-alpine
|
|
107
|
+
environment:
|
|
108
|
+
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
109
|
+
POSTGRES_DB: ${app.kebab}
|
|
110
|
+
volumes: ['pgdata:/var/lib/postgresql/data']
|
|
111
|
+
healthcheck:
|
|
112
|
+
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
|
113
|
+
interval: 5s
|
|
114
|
+
restart: unless-stopped
|
|
115
|
+
|
|
116
|
+
# The release phase. Applies pending migrations under an advisory lock and exits; every serving
|
|
117
|
+
# role waits for it to complete, so no replica ever serves against a schema it does not ship.
|
|
118
|
+
migrate:
|
|
119
|
+
<<: *image
|
|
120
|
+
environment: [ROLE=migrate]
|
|
121
|
+
restart: 'no'
|
|
122
|
+
|
|
123
|
+
web:
|
|
124
|
+
<<: *image
|
|
125
|
+
environment: [ROLE=web]
|
|
126
|
+
depends_on:
|
|
127
|
+
db: { condition: service_healthy }
|
|
128
|
+
migrate: { condition: service_completed_successfully }
|
|
129
|
+
deploy: { replicas: 2 } # stateless: scales on RPS
|
|
130
|
+
ports: ['3000:3000']
|
|
131
|
+
|
|
132
|
+
sync:
|
|
133
|
+
<<: *image
|
|
134
|
+
environment: [ROLE=sync]
|
|
135
|
+
depends_on:
|
|
136
|
+
db: { condition: service_healthy }
|
|
137
|
+
migrate: { condition: service_completed_successfully }
|
|
138
|
+
deploy: { replicas: 1 } # scales on concurrent websockets; no sticky sessions
|
|
139
|
+
ports: ['3001:3001']
|
|
140
|
+
|
|
141
|
+
worker:
|
|
142
|
+
<<: *image
|
|
143
|
+
environment: [ROLE=worker]
|
|
144
|
+
depends_on:
|
|
145
|
+
db: { condition: service_healthy }
|
|
146
|
+
migrate: { condition: service_completed_successfully }
|
|
147
|
+
deploy: { replicas: 1 } # scales on queue depth
|
|
148
|
+
|
|
149
|
+
scheduler:
|
|
150
|
+
<<: *image
|
|
151
|
+
environment: [ROLE=scheduler]
|
|
152
|
+
depends_on:
|
|
153
|
+
db: { condition: service_healthy }
|
|
154
|
+
migrate: { condition: service_completed_successfully }
|
|
155
|
+
deploy: { replicas: 1 } # fixed 1; leadership is a Postgres advisory lock
|
|
156
|
+
|
|
157
|
+
volumes:
|
|
158
|
+
pgdata:
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
const readme = (app: NameSet): string => `# docker
|
|
162
|
+
|
|
163
|
+
One image, every role. \`ROLE\` selects behaviour at start, so there is one artifact to promote and
|
|
164
|
+
nothing to rebuild between staging and production.
|
|
165
|
+
|
|
166
|
+
| Role | Does | Listens |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| \`web\` | HTTP: pages, actions, assets | \`$PORT\` (default 3000) |
|
|
169
|
+
| \`sync\` | websockets for live queries | \`$PORT + 1\` |
|
|
170
|
+
| \`worker\` | the job queue | — |
|
|
171
|
+
| \`scheduler\` | cron tasks; leadership is a Postgres advisory lock | — |
|
|
172
|
+
| \`replicator\` | the logical replication slot, exactly one per database | — |
|
|
173
|
+
| \`migrate\` | applies pending migrations and **exits** | — |
|
|
174
|
+
|
|
175
|
+
Every serving role answers \`/healthz\` and \`/readyz\`. \`/readyz\` flips to 503 on \`SIGTERM\` before the
|
|
176
|
+
socket closes, which is what makes a rolling restart drain instead of drop.
|
|
177
|
+
|
|
178
|
+
## Build and run
|
|
179
|
+
|
|
180
|
+
\`\`\`sh
|
|
181
|
+
x build --target docker --tag ${app.kebab}:dev
|
|
182
|
+
docker run --rm -e ROLE=migrate -e DATABASE_URL=postgres://... ${app.kebab}:dev
|
|
183
|
+
docker run -p 3000:3000 -e DATABASE_URL=postgres://... ${app.kebab}:dev
|
|
184
|
+
\`\`\`
|
|
185
|
+
|
|
186
|
+
## One box, every role
|
|
187
|
+
|
|
188
|
+
\`\`\`sh
|
|
189
|
+
docker compose -f docker/docker-compose.prod.yml up -d # db → migrate → the rest
|
|
190
|
+
x deploy --image ${app.kebab}:dev --dry-run --json # the same plan, printed
|
|
191
|
+
\`\`\`
|
|
192
|
+
|
|
193
|
+
## The other two build targets
|
|
194
|
+
|
|
195
|
+
\`\`\`sh
|
|
196
|
+
x build --target static --out dist/static # one HTML file per \`render: 'static'\` route
|
|
197
|
+
x build --target binary --out dist/app # a single executable, no Bun install needed
|
|
198
|
+
\`\`\`
|
|
199
|
+
|
|
200
|
+
The binary bundles the framework, not the app: the registries are filled by scanning
|
|
201
|
+
\`apps/*/{site,app,api,shared}\` at boot, so it is a launcher that must be **started from the app
|
|
202
|
+
root**, with the source tree beside it. The image is the self-contained artifact.
|
|
203
|
+
|
|
204
|
+
## A PaaS (Heroku, Render, Fly, Railway, Cloud Run, App Runner…)
|
|
205
|
+
|
|
206
|
+
The framework ships **no** platform primitives — no buildpack, no \`app.json\`, no \`fly.toml\`, no
|
|
207
|
+
adapter. It does not need to: every one of these platforms builds a Dockerfile and every one of
|
|
208
|
+
them expects the same three things, which this image already does.
|
|
209
|
+
|
|
210
|
+
| The platform does | The image does |
|
|
211
|
+
|---|---|
|
|
212
|
+
| injects \`PORT\` and routes traffic to it | \`apps/web/server.ts\` binds exactly \`$PORT\`, refusing a value that is not a port (\`X_PORT_INVALID\`) rather than defaulting past it |
|
|
213
|
+
| requires the process to bind \`0.0.0.0\` | it binds every interface; loopback would be unreachable from outside the container |
|
|
214
|
+
| polls a health path | \`/readyz\` for "may I have traffic", \`/healthz\` for "am I alive" |
|
|
215
|
+
| sends \`SIGTERM\`, then \`SIGKILL\` after a grace period | drains in three phases: stop accepting, finish in-flight, close |
|
|
216
|
+
|
|
217
|
+
Set \`DATABASE_URL\` and deploy the Dockerfile. That is the whole integration.
|
|
218
|
+
|
|
219
|
+
### Release-phase migrations — the one way
|
|
220
|
+
|
|
221
|
+
Run **the same image** with \`ROLE=migrate\` before the new release serves traffic. It applies every
|
|
222
|
+
pending migration in \`packages/db/migrations\` under a Postgres advisory lock, records each in the
|
|
223
|
+
\`x_migrations\` ledger with its checksum, and exits 0. Concurrent migrators serialise; a checksum
|
|
224
|
+
that no longer matches an applied migration stops the release instead of corrupting it.
|
|
225
|
+
|
|
226
|
+
| Platform | Where the command goes |
|
|
227
|
+
|---|---|
|
|
228
|
+
| Heroku | \`release: bun apps/web/server.ts\` in \`Procfile\`, with \`ROLE=migrate\` on the release dyno |
|
|
229
|
+
| Render | \`preDeployCommand: ROLE=migrate bun apps/web/server.ts\` |
|
|
230
|
+
| Fly.io | \`[deploy] release_command = "bun apps/web/server.ts"\` with \`ROLE=migrate\` |
|
|
231
|
+
| Railway | a pre-deploy command running the same |
|
|
232
|
+
| Kubernetes | an \`initContainer\` or a \`Job\` on the same image with \`ROLE=migrate\` |
|
|
233
|
+
| Compose | the \`migrate\` service; every other role waits on \`service_completed_successfully\` |
|
|
234
|
+
|
|
235
|
+
There is no \`x db migrate\` in that list on purpose: it is the developer's command and it needs the
|
|
236
|
+
toolchain, while the release phase runs the shipped image and nothing else.
|
|
237
|
+
|
|
238
|
+
## Environment
|
|
239
|
+
|
|
240
|
+
| Key | Meaning | Unset means |
|
|
241
|
+
|---|---|---|
|
|
242
|
+
| \`ROLE\` | which process this is | \`web\` |
|
|
243
|
+
| \`PORT\` | the port the web role binds | 3000 |
|
|
244
|
+
| \`DATABASE_URL\` | Postgres | embedded PGlite — never in production |
|
|
245
|
+
| \`BUILD_ID\` | the immutable build hash clients are served against | computed from the manifest at boot |
|
|
246
|
+
| \`NATS_URL\` | multi-node realtime transport | in-process fanout, single node only |
|
|
247
|
+
| \`S3_ENDPOINT\` | object storage | a local directory |
|
|
248
|
+
|
|
249
|
+
## Kubernetes
|
|
250
|
+
|
|
251
|
+
\`x deploy --method helm\` expects a chart at \`docker/helm\`. \`x new\` does not write one — a chart is
|
|
252
|
+
a topology decision, not a scaffold default. Copy \`docker/helm\` from the framework repository, or
|
|
253
|
+
stay on \`--method compose\`.
|
|
254
|
+
`;
|
|
255
|
+
|
|
256
|
+
/** The container files for a new app, in the order a reader meets them. */
|
|
257
|
+
export function containerFiles(app: NameSet): readonly GeneratedFile[] {
|
|
258
|
+
return [
|
|
259
|
+
{ path: 'docker/Dockerfile', contents: dockerfile(app) },
|
|
260
|
+
{ path: 'docker/Dockerfile.dockerignore', contents: dockerignore() },
|
|
261
|
+
{ path: 'docker/docker-compose.prod.yml', contents: composeProd(app) },
|
|
262
|
+
{ path: 'docker/README.md', contents: readme(app) },
|
|
263
|
+
];
|
|
264
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// The human-authored half of what `x new` writes: the READMEs, the agent-facing convention files,
|
|
2
|
-
// the bin/ shims and the
|
|
3
|
-
// be scrolled to find the other — one file, one job applies to templates too.
|
|
2
|
+
// the bin/ shims and the optional dev compose. Separated from the config half so neither file has
|
|
3
|
+
// to be scrolled to find the other — one file, one job applies to templates too. The image, its
|
|
4
|
+
// ignore file, the production topology and the deploy page are `scaffold-container.ts`.
|
|
4
5
|
|
|
5
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { containerFiles } from './scaffold-container';
|
|
6
8
|
|
|
7
9
|
const agents = (app: NameSet): string => `# AGENTS.md
|
|
8
10
|
|
|
@@ -114,30 +116,6 @@ services:
|
|
|
114
116
|
ports: ['9000:9000']
|
|
115
117
|
`;
|
|
116
118
|
|
|
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
119
|
/** Docs, shims and container files for a new app, in the order a reader meets them. */
|
|
142
120
|
export function docsFiles(app: NameSet): readonly GeneratedFile[] {
|
|
143
121
|
return [
|
|
@@ -147,8 +125,8 @@ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
|
|
|
147
125
|
{ path: 'bin/setup', contents: binSetup() },
|
|
148
126
|
{ path: 'bin/dev', contents: binDev() },
|
|
149
127
|
{ path: 'bin/check', contents: binCheck() },
|
|
150
|
-
{ path: 'docker/Dockerfile', contents: dockerfile(app) },
|
|
151
128
|
{ path: 'docker/docker-compose.dev.yml', contents: composeDev(app) },
|
|
129
|
+
...containerFiles(app),
|
|
152
130
|
];
|
|
153
131
|
}
|
|
154
132
|
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// The config half of what `x new` writes: the one config file, the tooling configs and the
|
|
2
2
|
// workspace packages. Committed defaults only — a fresh clone boots with `x dev` and no env
|
|
3
|
-
// scavenger hunt. The docs
|
|
3
|
+
// scavenger hunt. The docs and shims live in scaffold-docs.ts, the container files in
|
|
4
|
+
// scaffold-container.ts.
|
|
4
5
|
|
|
5
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
7
|
import { docsFiles } from './scaffold-docs';
|
|
7
8
|
import { i18nFiles } from './scaffold-i18n';
|
|
8
9
|
import { packageShapeFiles } from './scaffold-package-shape';
|
|
9
10
|
|
|
11
|
+
// `version` is not decoration: the manifest's app version IS the contract's compatibility gate,
|
|
12
|
+
// and the manifest never fabricates one — so an app scaffolded without it failed `x manifest`,
|
|
13
|
+
// the `manifest` verify step and every production boot with X_APP_PACKAGE_INVALID.
|
|
10
14
|
const rootPackage = (app: NameSet, version: string): string => `{
|
|
11
15
|
"name": "${app.kebab}",
|
|
16
|
+
"version": "0.1.0",
|
|
12
17
|
"private": true,
|
|
13
18
|
"type": "module",
|
|
14
19
|
"workspaces": [
|
|
@@ -64,6 +69,8 @@ const rootTsconfig = (app: NameSet): string => `{
|
|
|
64
69
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
65
70
|
"types": ["bun"],
|
|
66
71
|
"paths": {
|
|
72
|
+
"@${app.kebab}/web/*": ["./apps/web/*"],
|
|
73
|
+
"@${app.kebab}/admin/*": ["./apps/admin/*"],
|
|
67
74
|
"@${app.kebab}/*": ["./packages/*/src"]
|
|
68
75
|
},
|
|
69
76
|
"strict": true,
|
|
@@ -105,10 +112,13 @@ export const config = defineConfig({
|
|
|
105
112
|
});
|
|
106
113
|
`;
|
|
107
114
|
|
|
115
|
+
// `biome.json` is strict JSON — Biome's own parser rejects a `//` comment in it, which made every
|
|
116
|
+
// scaffolded app fail its first `x verify` on the config rather than on the code. The note that
|
|
117
|
+
// used to be a comment lives here, where it is read by the person who would have changed the line:
|
|
118
|
+
// x.manifest.json and openapi.json are emitted byte-for-byte by `x manifest`, so a formatter
|
|
119
|
+
// rewriting them puts `x manifest` and `x verify` in a loop neither can win.
|
|
108
120
|
const biome = (): string => `{
|
|
109
121
|
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
|
110
|
-
// x.manifest.json and openapi.json are emitted byte-for-byte by \`x manifest\`; a formatter
|
|
111
|
-
// rewriting them puts \`x manifest\` and \`x verify\` in a loop neither can win.
|
|
112
122
|
"files": { "includes": ["**", "!x.manifest.json", "!openapi.json"] },
|
|
113
123
|
"formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
|
|
114
124
|
"linter": {
|