@ultimat3/cli 5.0.1 → 7.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/CLAUDE.md +75 -6
- package/README.md +2 -2
- package/package.json +28 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- package/types/babel-modules.d.ts +31 -0
package/src/dev-lock.ts
ADDED
|
@@ -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.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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="${
|
|
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="${
|
|
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
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
|
@@ -95,6 +96,14 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
95
96
|
// CLI's problem alone, and core would have no `fix:` to offer for one.
|
|
96
97
|
'X_SECRETS_EDITOR_MISSING',
|
|
97
98
|
'X_SECRETS_EDIT_FAILED',
|
|
99
|
+
'X_WORKSPACE_DEP_UNDECLARED',
|
|
100
|
+
'X_SHOT_BROWSER_MISSING',
|
|
101
|
+
'X_GH_UNAVAILABLE',
|
|
102
|
+
'X_GH_NOT_AUTHENTICATED',
|
|
103
|
+
'X_GH_COMMAND_FAILED',
|
|
104
|
+
'X_GH_RESPONSE_INVALID',
|
|
105
|
+
'X_PR_NOT_FOUND',
|
|
106
|
+
'X_CI_RUN_NOT_FOUND',
|
|
98
107
|
] as const;
|
|
99
108
|
|
|
100
109
|
/**
|
|
@@ -167,6 +176,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
167
176
|
X_DEPLOY_FAILED: 'a deploy step failed',
|
|
168
177
|
X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
|
|
169
178
|
X_PORT_INVALID: 'PORT is not a TCP port number',
|
|
179
|
+
X_DEV_ALREADY_RUNNING: 'another x dev already owns this checkout',
|
|
170
180
|
X_RUNTIME_DRIVER_SPLIT: 'the ambient driver is not the one this process serves',
|
|
171
181
|
X_GENERATE_CONFLICT: 'a generator would overwrite a file',
|
|
172
182
|
X_PORT_IN_USE: 'the dev port is taken',
|
|
@@ -186,6 +196,14 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
186
196
|
X_CLI_FLAG_UNREAD: 'a command declares a flag no code reads',
|
|
187
197
|
X_SECRETS_EDITOR_MISSING: 'no $EDITOR to open the decrypted secrets in',
|
|
188
198
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
199
|
+
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
200
|
+
X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
|
|
201
|
+
X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
|
|
202
|
+
X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
|
|
203
|
+
X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
|
|
204
|
+
X_GH_RESPONSE_INVALID: "gh's output is not the shape the command reads",
|
|
205
|
+
X_PR_NOT_FOUND: 'no pull request for this checkout',
|
|
206
|
+
X_CI_RUN_NOT_FOUND: 'no workflow run for this branch',
|
|
189
207
|
};
|
|
190
208
|
|
|
191
209
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
|
@@ -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
|
+
}
|