@ultimat3/cli 5.0.0 → 6.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.
@@ -0,0 +1,275 @@
1
+ // `x dev`'s preflight: the two ways a second one fails, refused before anything boots.
2
+ //
3
+ // Both were reachable in 5.0.1 and both reported the wrong thing.
4
+ //
5
+ // A PORT already bound surfaced as `X_CLI_UNEXPECTED` wrapping Bun's own text — "Failed to start
6
+ // server. Is port 3000 in use?", a guess phrased as a question — with `fix: x doctor --json`, a
7
+ // command whose output does not mention the port. Port 3000 being taken by another project is the
8
+ // normal case on a developer machine, and `--port` was never named.
9
+ //
10
+ // A second `x dev` on ONE checkout failed later and worse. Embedded PGlite is a single-writer data
11
+ // directory, so the second boot died on `X_DB_UNAVAILABLE` while creating the jobs table, whose
12
+ // `fix:` reads "run `x dev` to use the embedded PGlite" — naming the command that had just failed.
13
+ //
14
+ // The lock file is what makes the second one nameable at all: nothing else in the process can tell
15
+ // "another dev server owns this directory" from "the database is broken".
16
+
17
+ import { unlinkSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { UltimateError } from '@ultimat3/core';
20
+ import { docsFor } from './error-codes';
21
+ import { exec, type Runner } from './exec';
22
+
23
+ /** Where the running dev server records itself, inside the state directory it already owns. */
24
+ export const DEV_LOCK_FILE = 'dev.lock';
25
+
26
+ export interface DevLock {
27
+ readonly pid: number;
28
+ readonly port: number;
29
+ readonly url: string;
30
+ /** ISO 8601. Only ever printed — a stale lock is decided by the pid, never by the clock. */
31
+ readonly startedAt: string;
32
+ }
33
+
34
+ export const lockPath = (stateDir: string): string => join(stateDir, DEV_LOCK_FILE);
35
+
36
+ /**
37
+ * Parse a lock file's contents. Total: a truncated or hand-edited file is a stale lock, not a
38
+ * crash — this runs on the path whose entire job is to make a confusing failure clear.
39
+ */
40
+ export const parseLock = (raw: string): DevLock | null => {
41
+ try {
42
+ const value = JSON.parse(raw) as Partial<DevLock>;
43
+ if (typeof value.pid !== 'number' || typeof value.port !== 'number') return null;
44
+ return {
45
+ pid: value.pid,
46
+ port: value.port,
47
+ url: typeof value.url === 'string' ? value.url : `http://localhost:${value.port}`,
48
+ startedAt: typeof value.startedAt === 'string' ? value.startedAt : '',
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ };
54
+
55
+ /**
56
+ * Is the pid in the lock still running? Signal 0 checks for existence without delivering anything.
57
+ *
58
+ * A hard kill leaves the lock behind, which is the common case and must NOT block the next boot —
59
+ * so a dead pid means the lock is stale and gets cleared. The rare wrong answer is a recycled pid
60
+ * belonging to something else; the cost of that is one unnecessary refusal that `--port` resolves,
61
+ * against the cost of the alternative, which is a silent second writer on a single-writer database.
62
+ */
63
+ export const isProcessAlive = (pid: number): boolean => {
64
+ if (!Number.isInteger(pid) || pid <= 0) return false;
65
+ try {
66
+ process.kill(pid, 0);
67
+ return true;
68
+ } catch (error) {
69
+ // EPERM means it exists and belongs to another user. Alive, and not ours to signal.
70
+ return (error as { code?: string }).code === 'EPERM';
71
+ }
72
+ };
73
+
74
+ /**
75
+ * Refused before boot, so the failure names the process holding the directory.
76
+ *
77
+ * The lock is on the CHECKOUT, not on the database, and the cause says so. `x dev` is one process
78
+ * running every role (`dev-roles.ts`), so a second one is unsupported whatever the services are.
79
+ * The embedded-Postgres sentence is appended only when the database actually IS embedded — with an
80
+ * external `DATABASE_URL` it would name a mechanism that is not in play, which is the same defect
81
+ * as the message this whole module replaced.
82
+ */
83
+ export class DevAlreadyRunningError extends UltimateError {
84
+ constructor(input: {
85
+ readonly lock: DevLock;
86
+ readonly stateDir: string;
87
+ /** True when this checkout's database is the embedded one. */
88
+ readonly embeddedDb?: boolean;
89
+ }) {
90
+ const single =
91
+ input.embeddedDb === true
92
+ ? ' — embedded Postgres is a single-writer data directory, so a second one cannot open it'
93
+ : ' — x dev runs every role in one process, so a second one on this checkout is unsupported';
94
+ super({
95
+ code: 'X_DEV_ALREADY_RUNNING',
96
+ cause: `pid ${input.lock.pid} is already running x dev on ${input.lock.url} and holds ${input.stateDir}${single}`,
97
+ fix: `use the one already running at ${input.lock.url}, or stop it: kill ${input.lock.pid}`,
98
+ docs: docsFor('X_DEV_ALREADY_RUNNING'),
99
+ meta: { pid: input.lock.pid, port: input.lock.port, stateDir: input.stateDir },
100
+ });
101
+ }
102
+ }
103
+
104
+ /** Whatever is listening, as far as the OS will say. Both fields absent when it will not say. */
105
+ export interface PortHolder {
106
+ readonly pid?: number;
107
+ /** The process name, e.g. `bun`. Enough to recognise "that is my other project". */
108
+ readonly command?: string;
109
+ }
110
+
111
+ /**
112
+ * Who holds the port. `ss` first because it is present on every Linux box the framework targets and
113
+ * needs no elevation for your own processes; `lsof` is the macOS answer.
114
+ *
115
+ * Through `exec.ts`, like every other subprocess the CLI runs, so a test injects a fake `Runner`
116
+ * rather than racing a real socket.
117
+ *
118
+ * EVERY PROBE IS CAUGHT SEPARATELY. `exec` refuses a missing program with `X_CLI_UNEXPECTED`, and
119
+ * letting that escape would mean a box without `ss` gets the CLI's catch-all instead of
120
+ * `X_PORT_IN_USE` — the exact substitution this module exists to end. A missing `ss` must fall
121
+ * through to `lsof`, and a box with neither still gets the refusal, just without a pid in it.
122
+ */
123
+ export const portHolder = async (port: number, runner: Runner = exec): Promise<PortHolder> => {
124
+ const probe = async (command: readonly string[]): Promise<string> => {
125
+ try {
126
+ const result = await runner(command, { cwd: process.cwd() });
127
+ return result.stdout;
128
+ } catch {
129
+ return '';
130
+ }
131
+ };
132
+
133
+ const parsed = /users:\(\("([^"]+)",pid=(\d+)/.exec(
134
+ await probe(['ss', '-lptnH', `sport = :${port}`]),
135
+ );
136
+ const name = parsed?.[1];
137
+ if (parsed !== null && name !== undefined) return { command: name, pid: Number(parsed[2]) };
138
+
139
+ const lines = (
140
+ await probe(['lsof', '-nP', `-iTCP:${String(port)}`, '-sTCP:LISTEN', '-Fpc'])
141
+ ).split('\n');
142
+ const pid = lines.find((line) => line.startsWith('p'))?.slice(1);
143
+ const command = lines.find((line) => line.startsWith('c'))?.slice(1);
144
+ if (pid !== undefined && /^\d+$/.test(pid)) {
145
+ return command === undefined ? { pid: Number(pid) } : { command, pid: Number(pid) };
146
+ }
147
+ return {};
148
+ };
149
+
150
+ /**
151
+ * The port is bound by something that is not us. Named separately from the lock case because the
152
+ * remedy is different: a stranger's port is worked around with `--port`, while a second `x dev` on
153
+ * this checkout is not a port problem at all and moving it would still fail on the database.
154
+ *
155
+ * The holder is NAMED when the OS will say. On a developer machine :3000 is usually another
156
+ * project's dev server, and "pid 41234, bun" is the difference between knowing that and guessing —
157
+ * which decides whether the right move is `--port` or stopping the other thing. Both are offered,
158
+ * `--port` first: moving your own server is always safe, and killing someone else's is not.
159
+ */
160
+ export class DevPortInUseError extends UltimateError {
161
+ constructor(input: {
162
+ readonly port: number;
163
+ readonly suggestion: number;
164
+ readonly holder?: PortHolder;
165
+ }) {
166
+ const holder = input.holder ?? {};
167
+ const named =
168
+ holder.pid === undefined
169
+ ? 'another process'
170
+ : `pid ${holder.pid}${holder.command === undefined ? '' : ` (${holder.command})`}`;
171
+ super({
172
+ code: 'X_PORT_IN_USE',
173
+ cause: `:${input.port} is already bound by ${named}, so the web role cannot listen — on a machine running several projects this is usually another one's dev server, not a stale copy of this one`,
174
+ fix:
175
+ holder.pid === undefined
176
+ ? `x dev --port ${input.suggestion}`
177
+ : `x dev --port ${input.suggestion} # or free it, if that pid is yours: kill ${holder.pid}`,
178
+ docs: docsFor('X_PORT_IN_USE'),
179
+ meta: { port: input.port, ...holder },
180
+ });
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The next port to suggest. Never `port + 1` at the top of the range — 65536 is not a port, and a
186
+ * `fix:` that cannot run is the failure this whole module exists to end.
187
+ */
188
+ export const suggestPort = (port: number): number => (port >= 65535 ? port - 1 : port + 1);
189
+
190
+ /**
191
+ * Is anything listening? A successful bind-then-close is the only answer that does not lie.
192
+ *
193
+ * PROBE THE ADDRESS THE SERVER WILL BIND, which is why the hostname is a parameter and why the
194
+ * caller passes `DEV_BINDING.hostname` rather than accepting a default. Probing a wider address
195
+ * than the server uses is not the safe direction: `0.0.0.0` reports "in use" whenever ANY interface
196
+ * holds the port, so a neighbour bound to one LAN address would refuse a boot that would have
197
+ * succeeded on loopback. Probing a narrower one misses a holder the server would collide with.
198
+ * Matching is the only rule that is right in both directions.
199
+ */
200
+ export const isPortBound = (port: number, hostname: string): boolean => {
201
+ try {
202
+ const probe = Bun.listen({ hostname, port, socket: { data() {} } });
203
+ probe.stop(true);
204
+ return false;
205
+ } catch {
206
+ return true;
207
+ }
208
+ };
209
+
210
+ export interface PreflightInput {
211
+ readonly stateDir: string;
212
+ readonly port: number;
213
+ /** The address the web role will bind. Probed exactly, never widened — see `isPortBound`. */
214
+ readonly hostname: string;
215
+ /** True when this checkout's database is the embedded one; only shapes the message. */
216
+ readonly embeddedDb?: boolean;
217
+ /** Injected by the test; `isPortBound` in production. */
218
+ readonly portBound?: (port: number, hostname: string) => boolean;
219
+ readonly alive?: (pid: number) => boolean;
220
+ readonly holder?: (port: number) => Promise<PortHolder>;
221
+ }
222
+
223
+ /**
224
+ * Run before anything boots. Throws the coded refusal, or returns the stale lock it cleared so the
225
+ * caller can say so — a lock left by a hard kill is normal and worth one line, not a failure.
226
+ */
227
+ export const preflight = async (input: PreflightInput): Promise<{ clearedStale: boolean }> => {
228
+ const alive = input.alive ?? isProcessAlive;
229
+ const bound = input.portBound ?? isPortBound;
230
+ const path = lockPath(input.stateDir);
231
+ const file = Bun.file(path);
232
+ let clearedStale = false;
233
+
234
+ if (await file.exists()) {
235
+ const lock = parseLock(await file.text());
236
+ if (lock !== null && alive(lock.pid)) {
237
+ throw new DevAlreadyRunningError({
238
+ lock,
239
+ stateDir: input.stateDir,
240
+ ...(input.embeddedDb === undefined ? {} : { embeddedDb: input.embeddedDb }),
241
+ });
242
+ }
243
+ // Stale: a hard kill, or a file nothing here wrote. Removing it is what makes the next boot
244
+ // work, and `unlinkSync` because a lock that outlives its own cleanup is the bug being fixed.
245
+ try {
246
+ unlinkSync(path);
247
+ } catch {
248
+ // Already gone, or not ours to remove. Either way the boot below is what decides.
249
+ }
250
+ clearedStale = true;
251
+ }
252
+
253
+ if (bound(input.port, input.hostname)) {
254
+ throw new DevPortInUseError({
255
+ port: input.port,
256
+ suggestion: suggestPort(input.port),
257
+ holder: await (input.holder ?? portHolder)(input.port),
258
+ });
259
+ }
260
+ return { clearedStale };
261
+ };
262
+
263
+ /** Record this process. Written after the preflight passes and before the roles start. */
264
+ export const writeLock = async (stateDir: string, lock: DevLock): Promise<void> => {
265
+ await Bun.write(lockPath(stateDir), `${JSON.stringify(lock, null, 2)}\n`);
266
+ };
267
+
268
+ /** Remove it. Safe to call twice — shutdown paths overlap, and a throw here would mask the real one. */
269
+ export const clearLock = (stateDir: string): void => {
270
+ try {
271
+ unlinkSync(lockPath(stateDir));
272
+ } catch {
273
+ // Never written, already removed, or removed by a concurrent shutdown. Nothing to report.
274
+ }
275
+ };
package/src/dev-render.ts CHANGED
@@ -26,13 +26,12 @@ import {
26
26
  hydrateRuntime,
27
27
  isrKey,
28
28
  metaContextFor,
29
+ ROOT_ELEMENT_ID,
29
30
  renderComponent,
30
31
  renderHead,
31
- renderSpa,
32
32
  renderSsr,
33
33
  routeDataFor,
34
34
  routeEntries,
35
- SPA_ROOT_ID,
36
35
  seoRenderers,
37
36
  staticHeaders,
38
37
  streamResult,
@@ -87,9 +86,10 @@ const styleTag = (entry: RouteEntry): string => {
87
86
  };
88
87
 
89
88
  /**
90
- * The route's rendered body, inside the hydration root. A module that exports no component (an
91
- * `api/` route, or a `spa` whose data is all client-side) renders an empty root, which is the
92
- * shell those modes are defined to serve — not a fallback for a component that failed.
89
+ * The route's rendered body, inside the hydration root. Every mode goes through here — an empty
90
+ * root is what a module exporting no component renders, and nothing else: `spa` was the one mode
91
+ * that never reached this function, which is why every `spa` route ever declared served
92
+ * `<div id="x-root"></div>` and painted nothing.
93
93
  */
94
94
  export async function routeBody(
95
95
  entry: RouteEntry,
@@ -97,7 +97,7 @@ export async function routeBody(
97
97
  data: RouteData,
98
98
  islands: IslandCollector,
99
99
  ): Promise<string> {
100
- if (entry.component === undefined) return `<div id="${SPA_ROOT_ID}"></div>`;
100
+ if (entry.component === undefined) return `<div id="${ROOT_ELEMENT_ID}"></div>`;
101
101
  const url = new URL(ctx.url);
102
102
  const html = await renderComponent(
103
103
  entry.component,
@@ -113,7 +113,7 @@ export async function routeBody(
113
113
  entry.file,
114
114
  { islands },
115
115
  );
116
- return `<div id="${SPA_ROOT_ID}">${html}</div>`;
116
+ return `<div id="${ROOT_ELEMENT_ID}">${html}</div>`;
117
117
  }
118
118
 
119
119
  /**
@@ -195,16 +195,6 @@ async function resultFor(
195
195
  );
196
196
  return served.result;
197
197
  }
198
- case 'spa':
199
- // The shell renders no body by definition, but it still carries the surface's CSS: the
200
- // client paints into `#x-root` and a flash of unstyled shell is the mode's own regression.
201
- return renderSpa({
202
- entry,
203
- buildId: options.buildId,
204
- head: (await headFor(entry, request, data)) + styleTag(entry),
205
- chunks: [],
206
- lang: lang(),
207
- });
208
198
  case 'stream': {
209
199
  // The shell IS the component: nothing can yet mark a subtree as a hole. Solid's `Suspense`
210
200
  // is not the missing piece and never will be here — it calls `getContextId()`, which throws
@@ -55,6 +55,7 @@ export const CLI_OWNED_ERROR_CODES = [
55
55
  // its health check with nothing in the log that names the cause.
56
56
  'X_ROLE_UNKNOWN',
57
57
  'X_PORT_INVALID',
58
+ 'X_DEV_ALREADY_RUNNING',
58
59
  // The boot's own consistency check. `startServices` captures the drivers it built, and
59
60
  // `loadApp` runs AFTER it — so an app module calling `setJobDriver(theirs)` moved the ambient
60
61
  // slot and left the captured object alone: every `handle.enqueue()` went to their queue while
@@ -167,6 +168,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
167
168
  X_DEPLOY_FAILED: 'a deploy step failed',
168
169
  X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
169
170
  X_PORT_INVALID: 'PORT is not a TCP port number',
171
+ X_DEV_ALREADY_RUNNING: 'another x dev already owns this checkout',
170
172
  X_RUNTIME_DRIVER_SPLIT: 'the ambient driver is not the one this process serves',
171
173
  X_GENERATE_CONFLICT: 'a generator would overwrite a file',
172
174
  X_PORT_IN_USE: 'the dev port is taken',
@@ -0,0 +1,127 @@
1
+ // Which files each generator kind emits, as a pure function of its options. Split from
2
+ // `cmd-generate.ts` so a generator's output can be asserted on — by the generator tests, the
3
+ // scaffold fixture and `x new` — without a command line, an app root or a filesystem.
4
+
5
+ import { CliNotImplementedError } from './errors';
6
+ import type { Generator } from './generate-kinds';
7
+ import { assertSurfaceSupported, GENERATORS } from './generate-kinds';
8
+ import { dedupe } from './generate-write';
9
+ import type { GeneratedFile, Surface } from './templates';
10
+ import {
11
+ actionFiles,
12
+ adminPageFiles,
13
+ backfillFiles,
14
+ entityFiles,
15
+ guardFiles,
16
+ islandFiles,
17
+ jobFiles,
18
+ kebab,
19
+ policyFiles,
20
+ queryFiles,
21
+ resourceFiles,
22
+ routeFiles,
23
+ taskFiles,
24
+ } from './templates';
25
+
26
+ export interface GenerateOptions {
27
+ readonly kind: Generator;
28
+ readonly name: string;
29
+ readonly feature?: string;
30
+ readonly surface?: Surface;
31
+ readonly live?: boolean;
32
+ /** `resource` only: also emit the per-entity admin override. */
33
+ readonly admin?: boolean;
34
+ /** Every locale a generated i18n catalog entry ships for. Defaults to `['en']`. */
35
+ readonly locales?: readonly string[];
36
+ /**
37
+ * `island` and `admin:page`: the directory the generated files land in. Named rather than
38
+ * derived, because neither destination is derivable — `X_ISLAND_INVALID`'s cause already holds
39
+ * the path a page's `src` resolved to, and an app's admin is wherever its `defineAdmin` is.
40
+ */
41
+ readonly at?: string;
42
+ /** `admin:page` only: the permission the page's own work needs, on top of `admin:read`. */
43
+ readonly permission?: string;
44
+ /**
45
+ * The app's own catalog module, `@<app>/i18n` — what every generated component imports `useT()`
46
+ * from. Supplied by `run` through `resolveCatalogModule`, because a template is a pure string
47
+ * function and the package name lives in a manifest on disk. Absent for an app with no catalog
48
+ * package, and only then does a generated file import `t` from `@ultimat3/i18n` instead.
49
+ */
50
+ readonly catalogModule?: string;
51
+ }
52
+
53
+ const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
54
+ site: 'apps/web/site',
55
+ app: 'apps/web/app',
56
+ };
57
+
58
+ /**
59
+ * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
60
+ * on them, and nothing has to run a filesystem to review what a generator produces.
61
+ */
62
+ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
63
+ const surface: Surface = options.surface ?? 'app';
64
+ assertSurfaceSupported(options.kind, surface, options.name);
65
+ const surfaceDir = DEFAULT_SURFACE_DIR[surface];
66
+ const feature = options.feature ?? options.name;
67
+ const target = { surfaceDir, feature };
68
+ switch (options.kind) {
69
+ case 'resource':
70
+ return dedupe(
71
+ resourceFiles(options.name, {
72
+ ...target,
73
+ admin: options.admin === true,
74
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
75
+ ...(options.catalogModule === undefined ? {} : { catalogModule: options.catalogModule }),
76
+ }),
77
+ );
78
+ case 'action':
79
+ return dedupe(actionFiles(options.name, target));
80
+ case 'mutator':
81
+ return dedupe(actionFiles(options.name, { ...target, mutator: true }));
82
+ case 'backfill':
83
+ return dedupe(backfillFiles(options.name, target));
84
+ case 'entity':
85
+ return dedupe(entityFiles(options.name, target));
86
+ case 'policy':
87
+ return dedupe(policyFiles(options.name, target));
88
+ case 'query':
89
+ return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
90
+ case 'job':
91
+ return dedupe(jobFiles(options.name, target));
92
+ case 'task':
93
+ return dedupe(taskFiles(options.name, target));
94
+ case 'island':
95
+ return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
96
+ // No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
97
+ // guard that lived anywhere else would need an app-side registration to be found.
98
+ case 'guard':
99
+ return dedupe(guardFiles(options.name));
100
+ case 'admin:page':
101
+ // A default permission, never none: an empty list is `X_ADMIN_PAGE_UNGUARDED` on sight.
102
+ return dedupe(
103
+ adminPageFiles(options.name, {
104
+ permission: options.permission ?? `${kebab(options.name)}:read`,
105
+ // The same `--at` `island` takes: an app's admin is wherever its `defineAdmin` is.
106
+ ...(options.at === undefined ? {} : { dir: options.at }),
107
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
108
+ ...(options.catalogModule === undefined ? {} : { catalogModule: options.catalogModule }),
109
+ }),
110
+ );
111
+ case 'route':
112
+ // `--locales` reaches the route generator too: its catalog entry is the route's title and
113
+ // description, and a locale asked for on the command line is a locale that gets a file.
114
+ return dedupe(
115
+ routeFiles(options.name, {
116
+ surface,
117
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
118
+ ...(options.catalogModule === undefined ? {} : { catalogModule: options.catalogModule }),
119
+ }),
120
+ );
121
+ default:
122
+ throw new CliNotImplementedError({
123
+ feature: `generator "${String(options.kind)}"`,
124
+ fix: `x g ${GENERATORS.join('|')}`,
125
+ });
126
+ }
127
+ }