@ultimat3/cli 20.2.1 → 22.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 +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +56 -13
- package/src/build-errors.ts +54 -0
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +119 -0
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +19 -15
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/page-sync.ts +54 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +23 -11
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-render.ts → runtime-render.ts} +119 -20
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -338
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +25 -145
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A registered code nothing constructs. The registry and the reference keep a shipped code alive
|
|
2
|
+
// forever — which is right — and that is exactly why a code can outlive its last thrower with no
|
|
3
|
+
// gate noticing: `X_RPC_FAILED` sat registered and documented as live after the transport change
|
|
4
|
+
// took its only throw site away. The reference row has to SAY so, or this reports it.
|
|
5
|
+
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core';
|
|
8
|
+
import { RESERVED_HEADING } from './error-contract';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
11
|
+
import { isCodeRegistry } from './ts-scan';
|
|
12
|
+
|
|
13
|
+
const CODE_LITERAL = /(['"`])(X_[A-Z0-9_]+)\1/g;
|
|
14
|
+
const TITLE_KEY = /^[\t ]*(X_[A-Z0-9_]+)\s*:/gm;
|
|
15
|
+
/** A registry's own code LIST line — `'X_FOO',` — declares a code and throws nothing. */
|
|
16
|
+
const LIST_LINE = /^[\t ]*(['"`])X_[A-Z0-9_]+\1\s*,?\s*$/;
|
|
17
|
+
/** `metaMissing: 'X_SEO_META_MISSING'` — the table `@ultimat3/seo` and `@ultimat3/ui` raise from. */
|
|
18
|
+
const TABLE_ENTRY = /\b([a-z][A-Za-z0-9]*)\s*:\s*(['"`])(X_[A-Z0-9_]+)\2/g;
|
|
19
|
+
const MEMBER_READ = /\.([A-Za-z_$][\w$]*)/g;
|
|
20
|
+
|
|
21
|
+
/** Phrases a reference row uses to say, in words, that nothing throws the code any more. */
|
|
22
|
+
const DECLARED_UNTHROWN = /thrown by nothing|not thrown/i;
|
|
23
|
+
|
|
24
|
+
export interface CodeUse {
|
|
25
|
+
/** Every code a package registry names. */
|
|
26
|
+
readonly registered: ReadonlySet<string>;
|
|
27
|
+
/** Every code shipped source constructs, compares or raises outside a registry's declaration. */
|
|
28
|
+
readonly used: ReadonlySet<string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One file's contribution. A registry file's code LIST and title KEYS declare; any other literal in
|
|
33
|
+
* it is a use (a class's `code:`, a `?? 'X_…'` fallback, a comparison). A table entry counts only
|
|
34
|
+
* once something reads that member — which is how two packages raise every code they own.
|
|
35
|
+
*/
|
|
36
|
+
export function codeUseOf(source: string): {
|
|
37
|
+
readonly registered: readonly string[];
|
|
38
|
+
readonly used: readonly string[];
|
|
39
|
+
readonly table: ReadonlyMap<string, string>;
|
|
40
|
+
readonly members: readonly string[];
|
|
41
|
+
} {
|
|
42
|
+
const text = stripComments(source);
|
|
43
|
+
const members = [...maskLiterals(source).matchAll(MEMBER_READ)].map((m) => m[1] ?? '');
|
|
44
|
+
if (!isCodeRegistry(text)) {
|
|
45
|
+
return {
|
|
46
|
+
registered: [],
|
|
47
|
+
used: [...text.matchAll(CODE_LITERAL)].map((m) => m[2] ?? ''),
|
|
48
|
+
table: new Map(),
|
|
49
|
+
members,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const registered = [
|
|
53
|
+
...[...text.matchAll(TITLE_KEY)].map((m) => m[1] ?? ''),
|
|
54
|
+
...[...text.matchAll(CODE_LITERAL)].map((m) => m[2] ?? ''),
|
|
55
|
+
];
|
|
56
|
+
const table = new Map<string, string>();
|
|
57
|
+
for (const m of text.matchAll(TABLE_ENTRY)) {
|
|
58
|
+
// `code: 'X_…'` is a class or a factory constructing the code, never a table entry.
|
|
59
|
+
if (m[1] !== 'code') table.set(m[1] ?? '', m[3] ?? '');
|
|
60
|
+
}
|
|
61
|
+
const tableCodes = new Set(table.values());
|
|
62
|
+
const used: string[] = [];
|
|
63
|
+
for (const line of text.split('\n')) {
|
|
64
|
+
if (LIST_LINE.test(line)) continue;
|
|
65
|
+
for (const m of line.matchAll(CODE_LITERAL)) {
|
|
66
|
+
const code = m[2] ?? '';
|
|
67
|
+
if (!tableCodes.has(code)) used.push(code);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { registered, used, table, members };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Shipped package source only: `scripts/` never ships, so a code only a gate script names is unthrown. */
|
|
74
|
+
export async function collectCodeUse(root: string): Promise<CodeUse> {
|
|
75
|
+
const registered = new Set<string>();
|
|
76
|
+
const used = new Set<string>();
|
|
77
|
+
const table = new Map<string, string>();
|
|
78
|
+
const members = new Set<string>();
|
|
79
|
+
for await (const source of eachSourceFile(root)) {
|
|
80
|
+
if (!/^packages\/[^/]+\/src\//.test(source) || isTest(source) || isGenerated(source)) continue;
|
|
81
|
+
const use = codeUseOf(await Bun.file(join(root, source)).text());
|
|
82
|
+
for (const code of use.registered) registered.add(code);
|
|
83
|
+
for (const code of use.used) used.add(code);
|
|
84
|
+
for (const [member, code] of use.table) table.set(member, code);
|
|
85
|
+
for (const member of use.members) members.add(member);
|
|
86
|
+
}
|
|
87
|
+
for (const [member, code] of table) if (members.has(member)) used.add(code);
|
|
88
|
+
return { registered, used };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Codes whose reference row says nothing throws them, or that sit under the reserved heading. */
|
|
92
|
+
export function declaredUnthrown(markdown: string): ReadonlySet<string> {
|
|
93
|
+
const out = new Set<string>();
|
|
94
|
+
let reserved = false;
|
|
95
|
+
for (const line of markdown.split('\n')) {
|
|
96
|
+
if (line.trim() === RESERVED_HEADING) reserved = true;
|
|
97
|
+
const row = /^\|\s*`(X_[A-Z0-9_]+)`\s*\|/.exec(line);
|
|
98
|
+
if (row !== null && (reserved || DECLARED_UNTHROWN.test(line))) out.add(row[1] ?? '');
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const unthrownFinding = (code: string, page: string): Finding => ({
|
|
104
|
+
code: 'X_ERROR_CODE_UNTHROWN',
|
|
105
|
+
cause: `${code} is registered and ${page} presents it as live, but no shipped source constructs it — a reader matching on it waits for an error that cannot arrive`,
|
|
106
|
+
// Never "delete the registration": a shipped code is stable forever, and an old log line must
|
|
107
|
+
// still explain. The row is what has to change.
|
|
108
|
+
fix: `write "registered, thrown by nothing since <version>" into ${code}'s row in ${page}, naming the code that replaced it — or throw it again where it belongs`,
|
|
109
|
+
docs: ERROR_DOCS_URL,
|
|
110
|
+
at: page,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Registered, used by nothing, and not declared unthrown on the reference. A host check — the page
|
|
115
|
+
* is the host repo's to name, and only a monorepo's walk sees every package's source; in a
|
|
116
|
+
* generated app every framework code would read as unthrown.
|
|
117
|
+
*/
|
|
118
|
+
export async function checkErrorCodesThrown(
|
|
119
|
+
root: string,
|
|
120
|
+
page: string,
|
|
121
|
+
): Promise<readonly Finding[]> {
|
|
122
|
+
const reference = Bun.file(join(root, page));
|
|
123
|
+
if (!(await reference.exists())) return [];
|
|
124
|
+
const exempt = declaredUnthrown(await reference.text());
|
|
125
|
+
const { registered, used } = await collectCodeUse(root);
|
|
126
|
+
return [...registered]
|
|
127
|
+
.filter((code) => !used.has(code) && !exempt.has(code))
|
|
128
|
+
.sort()
|
|
129
|
+
.map((code) => unthrownFinding(code, page));
|
|
130
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -72,6 +72,20 @@ export class AppNameIsPathError extends UltimateError {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* `x new '!!!'`: a name that slugifies to nothing names no directory, and the scaffold landed in
|
|
77
|
+
* the cwd itself — where `--force` then committed every file the user already had there.
|
|
78
|
+
*/
|
|
79
|
+
export class AppNameEmptyError extends UltimateError {
|
|
80
|
+
constructor(input: { name: string; invocation: string; flags: readonly string[] }) {
|
|
81
|
+
super({
|
|
82
|
+
code: 'X_APP_NAME_EMPTY',
|
|
83
|
+
cause: `"${input.name}" has no letters or digits, so it names no directory`,
|
|
84
|
+
fix: [input.invocation, '<name-with-letters>', ...input.flags].join(' '),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
/**
|
|
76
90
|
* A command that declares subcommands, invoked with none and declaring no `defaultSubcommand`.
|
|
77
91
|
*
|
|
@@ -287,35 +301,14 @@ export class FixTargetUnknownError extends UltimateError {
|
|
|
287
301
|
}
|
|
288
302
|
}
|
|
289
303
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
code: 'X_BUILD_ENTRY_MISSING',
|
|
299
|
-
cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
|
|
300
|
-
fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
|
|
307
|
-
* entry point like any other, and the target's own logs are what says which line. The fix builds
|
|
308
|
-
* exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
|
|
309
|
-
*/
|
|
310
|
-
export class IslandBuildFailedError extends UltimateError {
|
|
311
|
-
constructor(input: { file: string; logs: string }) {
|
|
312
|
-
super({
|
|
313
|
-
code: 'X_BUILD_FAILED',
|
|
314
|
-
cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
|
|
315
|
-
fix: `bun build --target browser ${input.file}`,
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
}
|
|
304
|
+
// The build and bundle refusals live in `build-errors.ts` (split at the 500-line ceiling); re-exported
|
|
305
|
+
// here so every existing `from './errors'` import keeps resolving.
|
|
306
|
+
export type { FrameworkScriptKind } from './build-errors';
|
|
307
|
+
export {
|
|
308
|
+
BuildEntryMissingError,
|
|
309
|
+
FrameworkScriptBuildFailedError,
|
|
310
|
+
IslandBuildFailedError,
|
|
311
|
+
} from './build-errors';
|
|
319
312
|
|
|
320
313
|
/**
|
|
321
314
|
* `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
|
package/src/favicon.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
// why: Bun exposes no path-join primitive, and `FAVICON_SOURCE` is app-root-relative, so resolving
|
|
7
7
|
// it against the root is string work no `Bun.file` overload does — the same necessity
|
|
8
|
-
// `
|
|
8
|
+
// `runtime-assets.ts` records for `ICON_SOURCE`.
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { createRaster, encodeImage } from '@ultimat3/core';
|
|
11
11
|
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
@@ -69,7 +69,7 @@ const builtinBytes = (): Uint8Array => {
|
|
|
69
69
|
const iconResponse = (bytes: Uint8Array, contentType: string): Response =>
|
|
70
70
|
applyCacheHeaders(
|
|
71
71
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
72
|
-
// `SharedArrayBuffer`, which `Response` does not accept — `
|
|
72
|
+
// `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
|
|
73
73
|
new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
|
|
74
74
|
FAVICON_CACHE,
|
|
75
75
|
);
|
package/src/fix-imports.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
// `dirname`/`join` are `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
|
+
import { endOfLiteral, maskLiterals } from '@ultimat3/core';
|
|
7
8
|
import type { FixHelper } from './fix-scan';
|
|
8
9
|
import { scanFixHelpers } from './fix-scan';
|
|
9
|
-
import { endOfLiteral, maskLiterals } from './ts-scan';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* A named import, matched over the MASKED source and anchored at the start of a line, so an
|
package/src/fix-scan.ts
CHANGED
|
@@ -3,16 +3,9 @@
|
|
|
3
3
|
// class's constructor. Split out of `ts-scan.ts` when the third shape and cross-file resolution
|
|
4
4
|
// (`fix-imports.ts`) took the file past the 500-line ceiling; the masking primitives stay there.
|
|
5
5
|
|
|
6
|
+
import { endOfLiteral, maskLiterals, QUOTES } from '@ultimat3/core';
|
|
6
7
|
import type { FixSite } from './ts-scan';
|
|
7
|
-
import {
|
|
8
|
-
CLOSERS,
|
|
9
|
-
endOfLiteral,
|
|
10
|
-
lineIndex,
|
|
11
|
-
maskLiterals,
|
|
12
|
-
OPENERS,
|
|
13
|
-
QUOTES,
|
|
14
|
-
valueLiterals,
|
|
15
|
-
} from './ts-scan';
|
|
8
|
+
import { CLOSERS, lineIndex, OPENERS, valueLiterals } from './ts-scan';
|
|
16
9
|
|
|
17
10
|
/** The lookbehind rejects member access: `cond ? e.fix : ''` is a ternary, not a declaration. */
|
|
18
11
|
const FIX_KEY = /(?<![.\w$])fix\s*:\s*/g;
|
package/src/flag-reads.ts
CHANGED
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
// `join`/`relative` are `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
11
11
|
import { join, relative } from 'node:path';
|
|
12
|
-
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
12
|
+
import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
|
|
13
13
|
import type { Finding } from './output';
|
|
14
14
|
import type { CommandSpec, FlagSpec } from './parse';
|
|
15
15
|
import { GLOBAL_FLAGS } from './parse';
|
|
16
|
-
import { stripComments } from './ts-scan';
|
|
17
16
|
|
|
18
17
|
/** A flag as declared, with the command that declares it. */
|
|
19
18
|
export interface DeclaredFlag {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The one fence for foreign text an agent reads: a PR review body (`x pr review`) and a CI log
|
|
2
|
+
// (`x ci`) are both written by anyone who can comment or push, and rendered bare they arrive in the
|
|
3
|
+
// agent's context indistinguishable from this CLI's own output. A leaf, so both commands share it.
|
|
4
|
+
|
|
5
|
+
const BLOCK_OPEN = '<comment id=';
|
|
6
|
+
const BLOCK_CLOSE = '</comment>';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The fence, as a READER would parse it rather than as this file spells it.
|
|
10
|
+
*
|
|
11
|
+
* Two literal `replaceAll`s were the whole neutralisation, and markup is not spelled one way:
|
|
12
|
+
* `</comment >`, `</COMMENT>` and `< comment id=` all end or open a block for anything reading
|
|
13
|
+
* tags, and none of the three matched. One pattern over `<`, an optional `/`, and whitespace
|
|
14
|
+
* around a case-insensitive `comment` covers every spelling of the delimiter; the escape goes on
|
|
15
|
+
* the `<`, so what the reviewer wrote after it survives byte for byte.
|
|
16
|
+
*/
|
|
17
|
+
const BLOCK_DELIMITER = /<(\s*\/?\s*comment\b)/gi;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One comment body, fenced and labelled with the thread id it came from — `@ultimat3/ai`'s
|
|
21
|
+
* `documentBlock` (`rag.ts`), applied to the other place foreign text enters an agent's context.
|
|
22
|
+
* `x pr review` exists because an agent cannot read the GitHub web UI, and a review body is
|
|
23
|
+
* written by anyone who can comment on the pull request: rendered as bare indented text it arrived
|
|
24
|
+
* in that agent's context indistinguishable from the command's own output, which is prompt
|
|
25
|
+
* injection with a shell attached.
|
|
26
|
+
*
|
|
27
|
+
* The fence is neutralised INSIDE the payload rather than deleted, so every word the reviewer
|
|
28
|
+
* wrote still reads, and the label is stripped of the three characters that would end the
|
|
29
|
+
* attribute. Influence only, and deliberately not sold as more: a fence tells a reader this text
|
|
30
|
+
* is data, and it can never stop one that decides otherwise.
|
|
31
|
+
*/
|
|
32
|
+
export function commentBlock(id: string, lines: readonly string[]): readonly string[] {
|
|
33
|
+
const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
|
|
34
|
+
const body = lines.map((line) => line.replace(BLOCK_DELIMITER, '<\\$1'));
|
|
35
|
+
return [`${BLOCK_OPEN}"${label}">`, ...body, BLOCK_CLOSE];
|
|
36
|
+
}
|
package/src/framework-schema.ts
CHANGED
|
@@ -78,12 +78,11 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
|
|
|
78
78
|
/**
|
|
79
79
|
* The five tables `BuiltinAdapter` reads, and the oldest hole in this list.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* rows already follow.
|
|
81
|
+
* Until this row nothing applied them, in dev or in production: they are not `entity()`
|
|
82
|
+
* declarations, so `x db gen` never saw them, and an app was left to paste per-table exports
|
|
83
|
+
* into a migration nobody wrote — nobody could hold a session in the reference app. Those
|
|
84
|
+
* exports are gone; `AUTH_TABLES` is the whole DDL, the 1.3 `x_users` upgrade included, applied
|
|
85
|
+
* here on exactly the rule the rate-limit and audit rows already follow.
|
|
87
86
|
*
|
|
88
87
|
* `AUTH_TABLE_NAMES` rather than five literals: @ultimat3/auth already publishes the list, and a
|
|
89
88
|
* second copy is a second thing to keep right when a table is added.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// `x g <kind> --feature <f>` writes INTO a slice, and a slice that does not exist is refused rather
|
|
2
|
+
// than invented. It was invented: `x g task nightly --feature reports` laid down an
|
|
3
|
+
// `entity('reports', { title, price })` nobody asked for, and the `drift` step then demanded a
|
|
4
|
+
// migration for the table. Only `x g entity` and `x g resource` create a feature's data.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs'; // why: a synchronous directory probe; Bun.file answers files, not directories.
|
|
7
|
+
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import type { Generator } from './generate-kinds';
|
|
9
|
+
import { containedPath } from './generate-write';
|
|
10
|
+
import { quoteArg } from './shell-quote';
|
|
11
|
+
|
|
12
|
+
/** The generators whose `--feature` names an existing slice to write into. */
|
|
13
|
+
const WRITES_INTO_SLICE: ReadonlySet<Generator> = new Set<Generator>([
|
|
14
|
+
'action',
|
|
15
|
+
'mutator',
|
|
16
|
+
'query',
|
|
17
|
+
'job',
|
|
18
|
+
'task',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** Declared beside its one thrower: `errors.ts` sits at its 500-line ceiling. */
|
|
22
|
+
export class FeatureUnknownError extends UltimateError {
|
|
23
|
+
constructor(input: { readonly feature: string; readonly slice: string }) {
|
|
24
|
+
super({
|
|
25
|
+
code: 'X_FEATURE_UNKNOWN',
|
|
26
|
+
cause: `--feature ${quoteArg(input.feature)} names ${input.slice}, which does not exist — a generator writing into a slice never invents one, or its entity table`,
|
|
27
|
+
fix: `x g resource ${quoteArg(input.feature)}`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Refuses a `--feature` whose slice directory is absent, for the kinds that write into one. */
|
|
33
|
+
export function assertFeatureExists(
|
|
34
|
+
root: string,
|
|
35
|
+
kind: Generator,
|
|
36
|
+
feature: string | undefined,
|
|
37
|
+
slice: string,
|
|
38
|
+
): void {
|
|
39
|
+
if (feature === undefined || !WRITES_INTO_SLICE.has(kind)) return;
|
|
40
|
+
if (existsSync(containedPath(root, slice))) return;
|
|
41
|
+
throw new FeatureUnknownError({ feature, slice });
|
|
42
|
+
}
|
package/src/generate-files.ts
CHANGED
|
@@ -56,8 +56,8 @@ export interface GenerateOptions {
|
|
|
56
56
|
*/
|
|
57
57
|
readonly sliceErrors?: string;
|
|
58
58
|
/**
|
|
59
|
-
* `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
|
|
60
|
-
* entity yet. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
|
|
59
|
+
* `action`, `mutator`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
|
|
60
|
+
* the feature has no entity yet — and then none is written. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
|
|
61
61
|
* tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
|
|
62
62
|
* `repo.byId`/`repo.listByOrg` calls into a feature whose entity names no tenant column. Read at
|
|
63
63
|
* `sliceDir(surface, feature)/entity.ts`.
|
|
@@ -84,7 +84,9 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
84
84
|
const surface: Surface = options.surface ?? 'app';
|
|
85
85
|
assertSurfaceSupported(options.kind, surface, options.name);
|
|
86
86
|
const surfaceDir = DEFAULT_SURFACE_DIR[surface];
|
|
87
|
-
|
|
87
|
+
// Kebab, always: `x g entity BlogPost` wrote `app/BlogPost/` while `resource` wrote
|
|
88
|
+
// `app/blog-post/`, so one feature grew two slice directories depending on the generator.
|
|
89
|
+
const feature = kebab(options.feature ?? options.name);
|
|
88
90
|
const target = { surfaceDir, feature };
|
|
89
91
|
switch (options.kind) {
|
|
90
92
|
case 'resource':
|
|
@@ -101,6 +103,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
101
103
|
actionFiles(options.name, {
|
|
102
104
|
...target,
|
|
103
105
|
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
106
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
104
107
|
}),
|
|
105
108
|
);
|
|
106
109
|
case 'mutator':
|
|
@@ -109,6 +112,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
109
112
|
...target,
|
|
110
113
|
mutator: true,
|
|
111
114
|
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
115
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
112
116
|
}),
|
|
113
117
|
);
|
|
114
118
|
case 'backfill':
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `x g policy` / `x g resource` grant what they declare. A generated `policy.ts` declares
|
|
2
|
+
// `<feature>:read` and `<feature>:write`; nothing granted them, so every generated endpoint
|
|
3
|
+
// answered 403 to every actor a role mints. The edit lands in the scaffold's one role map,
|
|
4
|
+
// `apps/web/shared/roles.ts`: `:read` to `member`, `:write` to `admin` (which inherits member).
|
|
5
|
+
|
|
6
|
+
import { containedPath } from './generate-write';
|
|
7
|
+
import { ROLES_FILE } from './permission-grants';
|
|
8
|
+
import { wrapList } from './templates/wrap';
|
|
9
|
+
|
|
10
|
+
/** One permission to add to one role's `grants`. */
|
|
11
|
+
export interface RoleGrant {
|
|
12
|
+
readonly role: string;
|
|
13
|
+
readonly permission: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A generated policy file: `<surface>/<feature>/policy.ts`, never a test beside it. */
|
|
17
|
+
const POLICY_PATH = /^apps\/[^/]+\/[^/]+\/(?<feature>[a-z0-9-]+)\/policy\.ts$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The grants a written file set implies: the lowest role that makes sense for each verb. `member`
|
|
21
|
+
* reads, `admin` writes — the scaffold's dev actor is `admin`, so both reach it through
|
|
22
|
+
* `inherits`, and a `member` cookie still cannot write.
|
|
23
|
+
*/
|
|
24
|
+
export function grantsForWritten(written: readonly string[]): readonly RoleGrant[] {
|
|
25
|
+
return written.flatMap((path) => {
|
|
26
|
+
const feature = POLICY_PATH.exec(path)?.groups?.['feature'];
|
|
27
|
+
return feature === undefined
|
|
28
|
+
? []
|
|
29
|
+
: [
|
|
30
|
+
{ role: 'member', permission: `${feature}:read` },
|
|
31
|
+
{ role: 'admin', permission: `${feature}:write` },
|
|
32
|
+
];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `source` with each grant added to its role's `grants: [...]`, re-wrapped the way Biome prints
|
|
40
|
+
* it. A role or a `grants` array the scaffold's shape does not have is left alone and returned in
|
|
41
|
+
* `skipped` — the `policy` step's X_PERMISSION_UNGRANTED names the edit, so a guess here would be
|
|
42
|
+
* a second, worse answer.
|
|
43
|
+
*/
|
|
44
|
+
export function insertGrants(
|
|
45
|
+
source: string,
|
|
46
|
+
grants: readonly RoleGrant[],
|
|
47
|
+
): { readonly source: string; readonly skipped: readonly RoleGrant[] } {
|
|
48
|
+
let next = source;
|
|
49
|
+
const skipped: RoleGrant[] = [];
|
|
50
|
+
for (const grant of grants) {
|
|
51
|
+
const role = new RegExp(`\\n ${escapeRegExp(grant.role)}: \\{`).exec(next);
|
|
52
|
+
const open = role === null ? -1 : next.indexOf('grants: [', role.index);
|
|
53
|
+
const close = open === -1 ? -1 : next.indexOf(']', open);
|
|
54
|
+
const blockEnd = role === null ? -1 : next.indexOf('\n },', role.index);
|
|
55
|
+
if (role === null || open === -1 || close === -1 || (blockEnd !== -1 && open > blockEnd)) {
|
|
56
|
+
skipped.push(grant);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const current = [...next.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? '');
|
|
60
|
+
if (current.includes(grant.permission)) continue;
|
|
61
|
+
const entries = [...current, grant.permission].map((permission) => `'${permission}'`);
|
|
62
|
+
const lineStart = next.lastIndexOf('\n', open) + 1;
|
|
63
|
+
const indent = next.slice(lineStart, open);
|
|
64
|
+
const rewritten = wrapList(indent, 'grants: [', entries, ']').slice(indent.length);
|
|
65
|
+
next = `${next.slice(0, open)}${rewritten}${next.slice(close + 1)}`;
|
|
66
|
+
}
|
|
67
|
+
return { source: next, skipped };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Performs `insertGrants` on the app's role map. Answers the paths it rewrote. */
|
|
71
|
+
export async function grantGeneratedPermissions(
|
|
72
|
+
root: string,
|
|
73
|
+
written: readonly string[],
|
|
74
|
+
): Promise<readonly string[]> {
|
|
75
|
+
const grants = grantsForWritten(written);
|
|
76
|
+
const file = containedPath(root, ROLES_FILE);
|
|
77
|
+
if (grants.length === 0 || !(await Bun.file(file).exists())) return [];
|
|
78
|
+
const before = await Bun.file(file).text();
|
|
79
|
+
const { source } = insertGrants(before, grants);
|
|
80
|
+
if (source === before) return [];
|
|
81
|
+
await Bun.write(file, source);
|
|
82
|
+
return [ROLES_FILE];
|
|
83
|
+
}
|
package/src/generate-kinds.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
UnknownCommandError,
|
|
11
11
|
} from './errors';
|
|
12
12
|
import type { Surface } from './templates';
|
|
13
|
+
import { camel, kebab } from './templates/naming';
|
|
13
14
|
|
|
14
15
|
export const GENERATORS = [
|
|
15
16
|
'resource',
|
|
@@ -106,11 +107,62 @@ export function readSurface(raw: string | undefined, kind: Generator, name: stri
|
|
|
106
107
|
* is a command that runs.
|
|
107
108
|
*/
|
|
108
109
|
export function readName(raw: string | undefined, kind: Generator): string {
|
|
109
|
-
if (raw
|
|
110
|
-
|
|
110
|
+
if (raw === undefined) {
|
|
111
|
+
throw new MissingPositionalError({
|
|
112
|
+
command: `g ${kind}`,
|
|
113
|
+
positional: 'name',
|
|
114
|
+
example: exampleFor(kind),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
refusePath(raw, 'name', kind);
|
|
118
|
+
refuseBadIdentifier(raw, kind);
|
|
119
|
+
return raw;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `--feature`, refused where it is a path: it is a DIRECTORY under the surface, one segment. */
|
|
123
|
+
export function readFeature(raw: string | undefined, kind: Generator): string | undefined {
|
|
124
|
+
if (raw !== undefined) refusePath(raw, 'feature', kind);
|
|
125
|
+
return raw;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A name is one directory segment: `x g action ../../../evil` wrote outside `apps/`. */
|
|
129
|
+
function refusePath(raw: string, flag: string, kind: Generator): void {
|
|
130
|
+
if (!/[\\/]/.test(raw) && raw.trim() !== '..' && raw.trim() !== '.') return;
|
|
131
|
+
throw new BadFlagError({
|
|
132
|
+
flag,
|
|
133
|
+
command: `g ${kind}`,
|
|
134
|
+
reason: `"${raw}" is a path, and a generator ${flag} is one directory segment under the surface`,
|
|
135
|
+
fix: exampleFor(kind),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Strict-mode reserved words: `export const delete = …` does not parse. */
|
|
140
|
+
const RESERVED = new Set(
|
|
141
|
+
(
|
|
142
|
+
'break case catch class const continue debugger default delete do else enum export extends ' +
|
|
143
|
+
'false finally for function if import in instanceof new null return super switch this throw ' +
|
|
144
|
+
'true try typeof var void while with yield let static implements interface package private ' +
|
|
145
|
+
'protected public await arguments eval'
|
|
146
|
+
).split(' '),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The name becomes `camel(name)` in emitted source, so it has to be an identifier there: not a
|
|
151
|
+
* reserved word, not starting with a digit. The fix is the same run with a name that is one — the
|
|
152
|
+
* kind appended to a reserved word, prepended to a leading digit.
|
|
153
|
+
*/
|
|
154
|
+
function refuseBadIdentifier(raw: string, kind: Generator): void {
|
|
155
|
+
const identifier = camel(raw);
|
|
156
|
+
const digit = /^[0-9]/.test(identifier);
|
|
157
|
+
if (identifier !== '' && !digit && !RESERVED.has(identifier)) return;
|
|
158
|
+
const noun = kind.split(':').at(-1) ?? kind;
|
|
159
|
+
const suggestion = digit ? `${noun}-${kebab(raw)}` : `${kebab(raw)}-${noun}`;
|
|
160
|
+
throw new BadFlagError({
|
|
161
|
+
flag: 'name',
|
|
111
162
|
command: `g ${kind}`,
|
|
112
|
-
|
|
113
|
-
|
|
163
|
+
reason: `"${raw}" becomes the identifier "${identifier}" in generated source, which ${digit ? 'starts with a digit' : identifier === '' ? 'is empty' : 'is a reserved word'}`,
|
|
164
|
+
// Joined, never spliced: the suggestion is kebab output, and the rule reads the shape.
|
|
165
|
+
fix: ['x g', kind, suggestion].join(' '),
|
|
114
166
|
});
|
|
115
167
|
}
|
|
116
168
|
|
package/src/i18n-index.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// whether to write at all, before any await the caller could interleave with.
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { containedPath } from './generate-write';
|
|
13
|
-
import {
|
|
13
|
+
import type { Finding } from './output';
|
|
14
|
+
import { CATALOG_ROOT, i18nIndex, localeEntry, localeImport } from './templates';
|
|
14
15
|
|
|
15
16
|
export const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
|
|
16
17
|
|
|
@@ -25,16 +26,64 @@ export async function catalogLocales(root: string): Promise<readonly string[]> {
|
|
|
25
26
|
return locales.sort();
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/** What a sync did: whether every catalog on disk is now selectable, and why not when it is not. */
|
|
30
|
+
export interface IndexSync {
|
|
31
|
+
/** False for an app with no i18n package, or an index this writer refused to edit. */
|
|
32
|
+
readonly registered: boolean;
|
|
33
|
+
readonly findings: readonly Finding[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CATALOG_IMPORT = /^import (\w+) from '\.\.\/catalogs\/([^']+)\.json';$/gm;
|
|
37
|
+
const LOCALES_OBJECT = /(locales:\s*\{)([^{}]*)(\})/;
|
|
38
|
+
|
|
39
|
+
/** The tags the index imports a catalog for, in file order. */
|
|
40
|
+
const importedLocales = (source: string): readonly string[] =>
|
|
41
|
+
[...source.matchAll(CATALOG_IMPORT)].map((match) => match[2] ?? '');
|
|
42
|
+
|
|
28
43
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
44
|
+
* Makes every catalog on disk selectable, and touches the index no more than that takes.
|
|
45
|
+
*
|
|
46
|
+
* An index that IS the template's output (for the locales it imports) is the framework's file and
|
|
47
|
+
* is re-derived whole. Anything else is the author's: each missing locale gets its import and its
|
|
48
|
+
* `locales: { … }` entry and nothing else moves — the declared `default`, and any code beside it,
|
|
49
|
+
* stay. A shape with no `locales: { … }` object to add to is refused with the edit named, never
|
|
50
|
+
* overwritten: it was, on every `x g`, with a template hard-coding `default: 'en'`.
|
|
34
51
|
*/
|
|
35
|
-
export async function syncI18nIndex(root: string): Promise<
|
|
52
|
+
export async function syncI18nIndex(root: string): Promise<IndexSync> {
|
|
36
53
|
const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
|
|
37
|
-
if (!existsSync(indexAbsolute)) return false;
|
|
38
|
-
await Bun.
|
|
39
|
-
|
|
54
|
+
if (!existsSync(indexAbsolute)) return { registered: false, findings: [] };
|
|
55
|
+
const current = await Bun.file(indexAbsolute).text();
|
|
56
|
+
const onDisk = await catalogLocales(root);
|
|
57
|
+
const imported = importedLocales(current);
|
|
58
|
+
if (current === i18nIndex(imported)) {
|
|
59
|
+
await Bun.write(indexAbsolute, i18nIndex(onDisk));
|
|
60
|
+
return { registered: true, findings: [] };
|
|
61
|
+
}
|
|
62
|
+
const missing = onDisk.filter((locale) => !imported.includes(locale));
|
|
63
|
+
if (missing.length === 0) return { registered: true, findings: [] };
|
|
64
|
+
const edited = withLocales(current, missing);
|
|
65
|
+
if (edited === undefined) return { registered: false, findings: [refusal(missing)] };
|
|
66
|
+
await Bun.write(indexAbsolute, edited);
|
|
67
|
+
return { registered: true, findings: [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Each import after the last catalog import (or at the top), each entry at the object's end. */
|
|
71
|
+
function withLocales(source: string, locales: readonly string[]): string | undefined {
|
|
72
|
+
const object = LOCALES_OBJECT.exec(source);
|
|
73
|
+
if (object === null) return undefined;
|
|
74
|
+
const body = (object[2] ?? '').trim().replace(/,$/, '');
|
|
75
|
+
const entries = [body, ...locales.map(localeEntry)].filter((part) => part !== '').join(', ');
|
|
76
|
+
const withEntries = source.replace(LOCALES_OBJECT, `$1 ${entries} $3`);
|
|
77
|
+
const imports = locales.map(localeImport).join('\n');
|
|
78
|
+
const lastImport = [...withEntries.matchAll(CATALOG_IMPORT)].at(-1);
|
|
79
|
+
if (lastImport === undefined) return `${imports}\n${withEntries}`;
|
|
80
|
+
const at = lastImport.index + lastImport[0].length;
|
|
81
|
+
return `${withEntries.slice(0, at)}\n${imports}${withEntries.slice(at)}`;
|
|
40
82
|
}
|
|
83
|
+
|
|
84
|
+
const refusal = (locales: readonly string[]): Finding => ({
|
|
85
|
+
code: 'X_CATALOG_UNREGISTERED',
|
|
86
|
+
cause: `${I18N_INDEX_PATH} is hand-written in a shape this writer cannot add ${locales.join(', ')} to, so those catalogs are on disk and not selectable`,
|
|
87
|
+
fix: `edit ${I18N_INDEX_PATH} — add ${locales.map(localeImport).join(' ')} and ${locales.map(localeEntry).join(', ')} to the locales passed to defineCatalogs`,
|
|
88
|
+
at: I18N_INDEX_PATH,
|
|
89
|
+
});
|
package/src/icon-assets.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single responsibility: the one source image every generated icon derives from, where the matrix
|
|
2
2
|
// is served, and the renderer that turns one into the other. Its own module so the two things that
|
|
3
|
-
// need it — `
|
|
3
|
+
// need it — `runtime-assets.ts`, which SERVES the matrix, and `pwa-artifacts.ts`, which NAMES it in the
|
|
4
4
|
// web manifest — can share it without importing each other.
|
|
5
5
|
|
|
6
6
|
// why: Bun exposes no path-join primitive, and `ICON_SOURCE` is app-root-relative, so resolving it
|