@ultimat3/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
package/src/hold.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Staying up. A command whose server is still listening when `run` resolves is a command whose
|
|
2
|
+
// process `bin.ts` exits out from under — so it hands back a hold, and `dispatch` awaits that
|
|
3
|
+
// before the exit code. Ctrl-C then takes core's own three-phase drain (stop accepting, finish
|
|
4
|
+
// in-flight, close) instead of killing a query mid-round-trip.
|
|
5
|
+
|
|
6
|
+
import { drain, installSignalHandlers, onShutdown } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wait for a shutdown, then release what core's lifecycle does not own.
|
|
10
|
+
*
|
|
11
|
+
* The wait is on the drain's first phase, never on a signal table of our own: core already owns
|
|
12
|
+
* which signals mean stop, and a second list here would be a second answer to that question. It
|
|
13
|
+
* also means anything else that calls `drain()` — a test, a supervisor, a later role — releases
|
|
14
|
+
* this command too.
|
|
15
|
+
*
|
|
16
|
+
* `release` runs after the drain completes, so in-flight requests still see the database the
|
|
17
|
+
* handler opened them against. It is the resources core never learned about: the embedded
|
|
18
|
+
* Postgres, the worker, the file watcher.
|
|
19
|
+
*/
|
|
20
|
+
export function holdUntilShutdown(name: string, release: () => Promise<void>): () => Promise<void> {
|
|
21
|
+
const uninstall = installSignalHandlers({ exit: false });
|
|
22
|
+
let unregister = (): void => {};
|
|
23
|
+
const shuttingDown = new Promise<void>((resolve) => {
|
|
24
|
+
unregister = onShutdown(
|
|
25
|
+
`cli:${name}:hold`,
|
|
26
|
+
() => {
|
|
27
|
+
resolve();
|
|
28
|
+
},
|
|
29
|
+
{ phase: 'accept' },
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let held: Promise<void> | undefined;
|
|
34
|
+
return () => {
|
|
35
|
+
// Memoised: awaiting a hold twice must not release twice, and `dispatch` is not the only
|
|
36
|
+
// caller a test can be.
|
|
37
|
+
held ??= (async () => {
|
|
38
|
+
await shuttingDown;
|
|
39
|
+
// Idempotent in core: this joins the drain already in flight and resolves when its last
|
|
40
|
+
// phase is done. Calling it is what makes `release` the step after the drain, not beside it.
|
|
41
|
+
await drain();
|
|
42
|
+
unregister();
|
|
43
|
+
uninstall();
|
|
44
|
+
await release();
|
|
45
|
+
})();
|
|
46
|
+
return held;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Pure facts behind `x i18n`: the source scan, the catalogs on disk, the audit, and the seed/sync
|
|
2
|
+
// key sets. No CLI shapes and no msg() — an app root in, plain data out, so every path here is
|
|
3
|
+
// testable without a ParsedArgs or a rendered message.
|
|
4
|
+
|
|
5
|
+
// `node:` and not Bun: Bun exposes no existence check (`existsSync`, which is how a missing
|
|
6
|
+
// `catalogs/` directory reads as "nothing shipped" instead of a throw) and no path API at all —
|
|
7
|
+
// `join` builds the absolute path `Bun.file` reads, and `relative`/`sep` turn it back into the
|
|
8
|
+
// root-relative POSIX shape every CLI-reported path is keyed by.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join, relative, sep } from 'node:path';
|
|
11
|
+
import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
|
|
12
|
+
import {
|
|
13
|
+
auditCatalogs,
|
|
14
|
+
catalogInvalid,
|
|
15
|
+
catalogKeys,
|
|
16
|
+
DEFAULT_LOCALE,
|
|
17
|
+
extractFromFiles,
|
|
18
|
+
loadCatalog,
|
|
19
|
+
missingFrom,
|
|
20
|
+
nestCatalog,
|
|
21
|
+
} from '@ultimat3/i18n';
|
|
22
|
+
import { eachSourceFile, isTest } from './source-files';
|
|
23
|
+
import { CATALOG_ROOT, catalogPath } from './templates/locales';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every `t()` call the app's own source makes. `source-files.ts` (`eachSourceFile`) is the one
|
|
27
|
+
* glob set every text-scanning gate step already shares (`errors`, `filesize`) — a second glob
|
|
28
|
+
* here would mean this command and `x verify` disagree on what "the app's source" is. Test files
|
|
29
|
+
* are excluded: a fixture's `t('fixture.key')` is not a gap the shipped catalogs owe an answer to.
|
|
30
|
+
* `extractFromFiles` reads by absolute path, so its `file` label is rewritten back to the same
|
|
31
|
+
* root-relative POSIX shape `app-load.ts` uses for every other CLI-reported path.
|
|
32
|
+
*/
|
|
33
|
+
export async function scanSource(root: string): Promise<Extraction> {
|
|
34
|
+
const files: string[] = [];
|
|
35
|
+
for await (const file of eachSourceFile(root)) {
|
|
36
|
+
if (!isTest(file)) files.push(file);
|
|
37
|
+
}
|
|
38
|
+
const extraction = await extractFromFiles(files.map((file) => join(root, file)));
|
|
39
|
+
const toRelative = (file: string): string => relative(root, file).split(sep).join('/');
|
|
40
|
+
return {
|
|
41
|
+
usages: extraction.usages.map((usage) => ({ ...usage, file: toRelative(usage.file) })),
|
|
42
|
+
dynamic: extraction.dynamic.map((entry) => ({ ...entry, file: toRelative(entry.file) })),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A `JSON.parse` failure is still a catalog problem, not a bare-Error crash through this command —
|
|
48
|
+
* reported through the same factory a structural violation (`loadCatalog` below) uses.
|
|
49
|
+
*/
|
|
50
|
+
function parseCatalogJson(path: string, raw: string): unknown {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(raw);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw catalogInvalid(path, error instanceof Error ? error.message : String(error));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every `packages/i18n/catalogs/*.json` on disk, parsed and flattened through `loadCatalog` —
|
|
60
|
+
* never a bare `JSON.parse`: a nested `{ one, other }` plural authored by hand is a known past
|
|
61
|
+
* bug, and `loadCatalog` is the seam that fails it loud (`X_CATALOG_INVALID`) instead of auditing
|
|
62
|
+
* it as an empty catalog. No `catalogs/` directory yet (a fresh app, or `packages/i18n` never
|
|
63
|
+
* scaffolded) yields `{}` — every caller here treats that as "nothing shipped", not an error.
|
|
64
|
+
*/
|
|
65
|
+
export async function loadCatalogs(root: string): Promise<Readonly<Record<Locale, Catalog>>> {
|
|
66
|
+
const dir = join(root, CATALOG_ROOT);
|
|
67
|
+
if (!existsSync(dir)) return {};
|
|
68
|
+
const catalogs: Record<string, Catalog> = {};
|
|
69
|
+
for await (const entry of new Bun.Glob('*.json').scan({ cwd: dir, absolute: false })) {
|
|
70
|
+
const locale = entry.replace(/\.json$/, '');
|
|
71
|
+
const path = catalogPath(locale);
|
|
72
|
+
const raw = await Bun.file(join(root, path)).text();
|
|
73
|
+
catalogs[locale] = loadCatalog(parseCatalogJson(path, raw));
|
|
74
|
+
}
|
|
75
|
+
return catalogs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AuditFacts {
|
|
79
|
+
readonly report: ExtractReport;
|
|
80
|
+
readonly catalogs: Readonly<Record<Locale, Catalog>>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The static head of a template literal, up to its first interpolation. */
|
|
84
|
+
const TEMPLATE_HEAD = /^`([^`$]*)\$\{/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `unused` is the half of the audit an agent acts on destructively — it reads as "safe to delete".
|
|
88
|
+
* A key only ever reached through `t(`plans.${plan}.name`)` is used, and reporting it unused is
|
|
89
|
+
* how a live key gets deleted. `AuditInput.ignoreUnused` exists for exactly this, so every dynamic
|
|
90
|
+
* call contributes its own static head as a `prefix*` pattern. An expression that is not a template
|
|
91
|
+
* literal (a ternary over string literals, a bare variable) contributes nothing: guessing a prefix
|
|
92
|
+
* from it would suppress real findings, and the `dynamic` list already names it for a human.
|
|
93
|
+
*/
|
|
94
|
+
export function runtimeKeyPatterns(extraction: Extraction): readonly string[] {
|
|
95
|
+
const patterns = new Set<string>();
|
|
96
|
+
for (const entry of extraction.dynamic) {
|
|
97
|
+
const head = TEMPLATE_HEAD.exec(entry.expression)?.[1];
|
|
98
|
+
if (head !== undefined && head.length > 0) patterns.add(`${head}*`);
|
|
99
|
+
}
|
|
100
|
+
return [...patterns].sort();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The source scan and the catalogs on disk, audited together — the one fact `x i18n check` reports. */
|
|
104
|
+
export async function auditApp(root: string): Promise<AuditFacts> {
|
|
105
|
+
const [extraction, catalogs] = await Promise.all([scanSource(root), loadCatalogs(root)]);
|
|
106
|
+
const ignoreUnused = runtimeKeyPatterns(extraction);
|
|
107
|
+
return { report: auditCatalogs({ extraction, catalogs, ignoreUnused }), catalogs };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Which locale is the source of truth for seeding (`x i18n add`) and syncing (`x i18n sync`).
|
|
112
|
+
* `declared` is the app's own `defineCatalogs({ default })`, projected by `app-load.ts` from
|
|
113
|
+
* `@ultimat3/i18n`'s `localeConfig()` — the framework's answer to its own question, never a parse
|
|
114
|
+
* of the app's source. Three rules, in order:
|
|
115
|
+
* 1. `declared`, trusted only when a catalog for it actually exists on disk.
|
|
116
|
+
* 2. `en`, when a catalog for it exists — every app `x new` scaffolds has one, so this covers
|
|
117
|
+
* every real app even when the i18n module would not import.
|
|
118
|
+
* 3. The sole catalog on disk, when there is exactly one.
|
|
119
|
+
* `undefined` when none of the three resolve (no catalogs yet, or several with no `en` and nothing
|
|
120
|
+
* on disk answering `declared`) — callers seed/sync from an empty source rather than guess at one.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveDefaultLocale(
|
|
123
|
+
declared: string | undefined,
|
|
124
|
+
catalogs: Readonly<Record<Locale, Catalog>>,
|
|
125
|
+
): Locale | undefined {
|
|
126
|
+
if (declared !== undefined && Object.hasOwn(catalogs, declared)) return declared;
|
|
127
|
+
if (Object.hasOwn(catalogs, DEFAULT_LOCALE)) return DEFAULT_LOCALE;
|
|
128
|
+
const locales = Object.keys(catalogs);
|
|
129
|
+
return locales.length === 1 ? locales[0] : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A sorted copy of `catalog`. Every write below goes through this, so a later `sync` (or a second
|
|
134
|
+
* `add`) diffs only the keys that actually changed, never a reshuffle.
|
|
135
|
+
*/
|
|
136
|
+
function sortCatalog(catalog: Catalog): Catalog {
|
|
137
|
+
const out: Record<string, string> = {};
|
|
138
|
+
for (const key of catalogKeys(catalog)) {
|
|
139
|
+
const value = catalog[key];
|
|
140
|
+
if (value !== undefined) out[key] = value;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `x i18n add`'s seed: every key the default locale defines, values copied verbatim — an
|
|
147
|
+
* untranslated string that renders is strictly better than a missing key that renders `⟦key⟧`.
|
|
148
|
+
*/
|
|
149
|
+
export function seedCatalog(source: Catalog): Catalog {
|
|
150
|
+
return sortCatalog(source);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface SyncResult {
|
|
154
|
+
readonly merged: Catalog;
|
|
155
|
+
readonly added: readonly string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `x i18n sync`: every key `source` has that `target` does not, added; every key `target` already
|
|
160
|
+
* has stays exactly as written, translated or not.
|
|
161
|
+
*/
|
|
162
|
+
export function syncCatalog(target: Catalog, source: Catalog): SyncResult {
|
|
163
|
+
const added = missingFrom(source, target);
|
|
164
|
+
if (added.length === 0) return { merged: target, added };
|
|
165
|
+
const merged: Record<string, string> = { ...target };
|
|
166
|
+
for (const key of added) {
|
|
167
|
+
const value = source[key];
|
|
168
|
+
if (value !== undefined) merged[key] = value;
|
|
169
|
+
}
|
|
170
|
+
return { merged: sortCatalog(merged), added };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `Bun.write`'s contents for any catalog this command writes: **nested**, sorted, 2-space indent,
|
|
175
|
+
* a trailing newline — the shape a hand-authored catalog already has, so the next `sync` or edit
|
|
176
|
+
* produces a clean diff. Nested is load-bearing, not cosmetic: `Catalog` is the flat dot-key form
|
|
177
|
+
* the translator reads, and a file written in it is one `loadCatalog` refuses on the very next
|
|
178
|
+
* read (`X_CATALOG_INVALID` — a dot is not a key segment). `nestCatalog` is `@ultimat3/i18n`'s own
|
|
179
|
+
* inverse of the flatten every read does, so a catalog this command writes round-trips through it.
|
|
180
|
+
*/
|
|
181
|
+
export function serializeCatalog(catalog: Catalog): string {
|
|
182
|
+
return `${JSON.stringify(nestCatalog(sortCatalog(catalog)), null, 2)}\n`;
|
|
183
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Public API of @ultimat3/cli. Explicit re-exports only: create-ultimate and the test suite build
|
|
2
|
+
// on these, and a barrel that re-exports everything would make every internal a compatibility
|
|
3
|
+
// promise.
|
|
4
|
+
|
|
5
|
+
export type { BoundaryCode, SourceFile } from './app-boundaries';
|
|
6
|
+
export {
|
|
7
|
+
appImportGraph,
|
|
8
|
+
BOUNDARY_CODES,
|
|
9
|
+
checkAppBoundaries,
|
|
10
|
+
checkImportRules,
|
|
11
|
+
readAppSources,
|
|
12
|
+
resolveSpecifier,
|
|
13
|
+
scanRuntimeImports,
|
|
14
|
+
} from './app-boundaries';
|
|
15
|
+
export type { LoadedApp } from './app-load';
|
|
16
|
+
export { loadApp, resetAppLoad } from './app-load';
|
|
17
|
+
export type { AppManifest } from './app-manifest';
|
|
18
|
+
export { appManifest, policyFacts, readAppManifest, writeAppManifest } from './app-manifest';
|
|
19
|
+
export { OPENAPI_FILE, openApiJson } from './app-openapi';
|
|
20
|
+
export type { AppRoot } from './app-root';
|
|
21
|
+
export { findAppRoot, requireAppRoot, requireBunVersion, versionAtLeast } from './app-root';
|
|
22
|
+
export type { BoundaryCut, BoundarySplit } from './boundary-cuts';
|
|
23
|
+
export { planBoundaryCuts } from './boundary-cuts';
|
|
24
|
+
export type { BuildStats, RouteStats } from './budgets';
|
|
25
|
+
export { BUILD_STATS_FILE, checkBudgets, readBuildStats } from './budgets';
|
|
26
|
+
export type { BuildTarget } from './cmd-build';
|
|
27
|
+
export { argsFor, BUILD_TARGETS, buildCommand, readTarget } from './cmd-build';
|
|
28
|
+
export { branchDatabaseName, branchSql, dbCommand, previewUrl } from './cmd-db';
|
|
29
|
+
export type { DeployPlan } from './cmd-deploy';
|
|
30
|
+
export { deployCommand, planDeploy } from './cmd-deploy';
|
|
31
|
+
export type { DevServer, StartDevOptions } from './cmd-dev';
|
|
32
|
+
export { devCommand, startDev } from './cmd-dev';
|
|
33
|
+
export type { DoctorProbe } from './cmd-doctor';
|
|
34
|
+
export { doctorCommand, OFFLINE_FALLBACK, probeFor, runDoctor } from './cmd-doctor';
|
|
35
|
+
export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors';
|
|
36
|
+
export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix';
|
|
37
|
+
export type { GenerateOptions, Generator } from './cmd-generate';
|
|
38
|
+
export { GENERATORS, generate, generateCommand, writeFiles } from './cmd-generate';
|
|
39
|
+
export { createHelpCommand, createVersionCommand, renderHelp } from './cmd-help';
|
|
40
|
+
export { buildDrainTarget, JOBS_SUBCOMMANDS, jobsCommand } from './cmd-jobs';
|
|
41
|
+
export { manifestCommand } from './cmd-manifest';
|
|
42
|
+
export type { McpHttpServer } from './cmd-mcp';
|
|
43
|
+
export { mcpCommand, startMcpHttp } from './cmd-mcp';
|
|
44
|
+
export type { NewAppOptions, WrittenApp } from './cmd-new';
|
|
45
|
+
export { newCommand, planNewApp, writeNewApp } from './cmd-new';
|
|
46
|
+
export type { PlannedCommand } from './cmd-planned';
|
|
47
|
+
export { PLANNED_COMMANDS, plannedCommands } from './cmd-planned';
|
|
48
|
+
export { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
|
|
49
|
+
export { renderRouteTable, routesCommand } from './cmd-routes';
|
|
50
|
+
export { availableCpus, testCommand } from './cmd-test';
|
|
51
|
+
export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify';
|
|
52
|
+
export type { CliCommand, CommandContext } from './command';
|
|
53
|
+
export { failed, ok } from './command';
|
|
54
|
+
export type { AssetRoutesOptions } from './dev-assets';
|
|
55
|
+
export {
|
|
56
|
+
assetRoutes,
|
|
57
|
+
ICON_BASE_PATH,
|
|
58
|
+
ICON_SOURCE,
|
|
59
|
+
MEDIA_BASE_PATH,
|
|
60
|
+
} from './dev-assets';
|
|
61
|
+
export type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
62
|
+
export { devDashboardRoutes, devPanels, devSources } from './dev-dashboard';
|
|
63
|
+
export { devHooks } from './dev-hooks';
|
|
64
|
+
export type { DevDbClient, RunningQueue } from './dev-queue';
|
|
65
|
+
export { startQueue } from './dev-queue';
|
|
66
|
+
export type { DevRenderOptions, DevRouteData } from './dev-render';
|
|
67
|
+
export { appRoutes } from './dev-render';
|
|
68
|
+
export type { RunningRoles, StartRolesOptions } from './dev-roles';
|
|
69
|
+
export { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
|
|
70
|
+
export type { RunningServices } from './dev-runtime';
|
|
71
|
+
export { startServices } from './dev-runtime';
|
|
72
|
+
export type { DevServices, ServiceBinding } from './dev-services';
|
|
73
|
+
export { describeServices, resolveServices } from './dev-services';
|
|
74
|
+
export type { DispatchOptions } from './dispatch';
|
|
75
|
+
export { dispatch } from './dispatch';
|
|
76
|
+
export { checkDrift, recordedHashes, schemaHash, writeSchemaHash } from './drift';
|
|
77
|
+
export type { ErrorCatalog } from './error-catalog';
|
|
78
|
+
export {
|
|
79
|
+
buildErrorCatalog,
|
|
80
|
+
CATALOG_PACKAGES,
|
|
81
|
+
loadErrorCatalog,
|
|
82
|
+
registeredErrorCodes,
|
|
83
|
+
resetErrorCatalog,
|
|
84
|
+
} from './error-catalog';
|
|
85
|
+
export {
|
|
86
|
+
BANNED_PHRASES,
|
|
87
|
+
COMMAND_TOKENS,
|
|
88
|
+
checkErrorCodeDocs,
|
|
89
|
+
checkErrorCodeRegistry,
|
|
90
|
+
checkErrorFixes,
|
|
91
|
+
collectDeclaredCodes,
|
|
92
|
+
documentedCodes,
|
|
93
|
+
fixProblem,
|
|
94
|
+
liveCodes,
|
|
95
|
+
RESERVED_HEADING,
|
|
96
|
+
staticFix,
|
|
97
|
+
} from './error-contract';
|
|
98
|
+
export type { CliErrorCode } from './errors';
|
|
99
|
+
export {
|
|
100
|
+
BadFlagError,
|
|
101
|
+
BunVersionError,
|
|
102
|
+
CatalogExistsError,
|
|
103
|
+
CLI_ERROR_CODES,
|
|
104
|
+
CLI_ERROR_TITLES,
|
|
105
|
+
CliNotImplementedError,
|
|
106
|
+
DeclarationUnknownError,
|
|
107
|
+
ErrorCodeUnknownError,
|
|
108
|
+
FixTargetUnknownError,
|
|
109
|
+
JobUnknownError,
|
|
110
|
+
NoTestFilesError,
|
|
111
|
+
NotInAppError,
|
|
112
|
+
UnknownCommandError,
|
|
113
|
+
VerifyFailedError,
|
|
114
|
+
} from './errors';
|
|
115
|
+
export type { ExecOptions, ExecResult, Runner } from './exec';
|
|
116
|
+
export { exec, execOutput } from './exec';
|
|
117
|
+
export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
|
|
118
|
+
export { drainJobs } from './jobs-drain';
|
|
119
|
+
export type { JobsListFilter, JobsListResult } from './jobs-report';
|
|
120
|
+
export { JOB_STATES, listJobs, retryJob, showJob } from './jobs-report';
|
|
121
|
+
export { renderJobTable } from './jobs-table';
|
|
122
|
+
export type { CliMcpServer, DevHostInput } from './mcp-host';
|
|
123
|
+
export { createDevMcpServer, DEV_TOOL_SCOPES, localCaller } from './mcp-host';
|
|
124
|
+
export { messageKeys, msg } from './messages';
|
|
125
|
+
export type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
126
|
+
export {
|
|
127
|
+
exitCodeFor,
|
|
128
|
+
findingFrom,
|
|
129
|
+
isUltimateErrorShape,
|
|
130
|
+
render,
|
|
131
|
+
renderFinding,
|
|
132
|
+
renderHuman,
|
|
133
|
+
renderJson,
|
|
134
|
+
renderUltimateError,
|
|
135
|
+
} from './output';
|
|
136
|
+
export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
|
|
137
|
+
export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
|
|
138
|
+
export { CLI_VERSION, COMMANDS, commandFor, SPECS } from './registry';
|
|
139
|
+
export {
|
|
140
|
+
eachSourceFile,
|
|
141
|
+
isGenerated,
|
|
142
|
+
isTest,
|
|
143
|
+
isVendored,
|
|
144
|
+
SOURCE_GLOBS,
|
|
145
|
+
} from './source-files';
|
|
146
|
+
export type { TestFile } from './test-select';
|
|
147
|
+
export { belongsToType, discoverTests, sampleFiles } from './test-select';
|
|
148
|
+
export type { ReproduceOptions, RunShardsOptions, Shard } from './test-shards';
|
|
149
|
+
export { planShards, quoteArg, reproduceFor, runShards, shardArgs } from './test-shards';
|
|
150
|
+
export type { CodeSite, FixSite, SourceSite } from './ts-scan';
|
|
151
|
+
export {
|
|
152
|
+
isCodeRegistry,
|
|
153
|
+
maskLiterals,
|
|
154
|
+
scanBorrowedCodes,
|
|
155
|
+
scanCodes,
|
|
156
|
+
scanFixes,
|
|
157
|
+
stripComments,
|
|
158
|
+
} from './ts-scan';
|
|
159
|
+
export type {
|
|
160
|
+
HostCheck,
|
|
161
|
+
StepOutcome,
|
|
162
|
+
VerifyContext,
|
|
163
|
+
VerifyStep,
|
|
164
|
+
VerifyStepName,
|
|
165
|
+
} from './verify-step';
|
|
166
|
+
export { VERIFY_STEP_NAMES } from './verify-step';
|
|
167
|
+
export type { TestType } from './verify-tests';
|
|
168
|
+
export { TEST_STEPS, TEST_TYPES, testStepCommand, typeFilterOf } from './verify-tests';
|
|
169
|
+
export type { ManifestFacts } from './workspace-checks';
|
|
170
|
+
export {
|
|
171
|
+
checkFileSizes,
|
|
172
|
+
checkLockstep,
|
|
173
|
+
checkPackageShape,
|
|
174
|
+
frameworkDepsOf,
|
|
175
|
+
hasWorkspacePackages,
|
|
176
|
+
LINE_CEILING,
|
|
177
|
+
PACKAGE_FILES,
|
|
178
|
+
workspacePackages,
|
|
179
|
+
} from './workspace-checks';
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// `x jobs drain`: moving in-flight work from one queue driver onto another. Its own file because
|
|
2
|
+
// it is the only command here that WRITES to two drivers at once, and the ordering rule that makes
|
|
3
|
+
// that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
|
|
4
|
+
|
|
5
|
+
import { uuid } from '@ultimat3/core';
|
|
6
|
+
import type { JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
|
|
7
|
+
import { inspectJobList } from '@ultimat3/jobs';
|
|
8
|
+
import type { Finding } from './output';
|
|
9
|
+
import { findingFrom } from './output';
|
|
10
|
+
|
|
11
|
+
/** `running` is deliberately excluded: a job a worker is mid-execution on is not "pending". */
|
|
12
|
+
const PENDING_STATES: readonly JobState[] = ['ready', 'delayed', 'suspended'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The lease has to outlive the WHOLE transfer loop, not one record: the batch is claimed up
|
|
16
|
+
* front, and a lease expiring mid-drain would hand a half-transferred job back to a source
|
|
17
|
+
* worker. The snapshot is bounded by the drivers' own 100-row-per-state list cap, so this is a
|
|
18
|
+
* ceiling over a few hundred sequential enqueues rather than a guess about one.
|
|
19
|
+
*/
|
|
20
|
+
const DRAIN_LEASE_MS = 300_000;
|
|
21
|
+
|
|
22
|
+
const NO_LEASE = 'no lease could be taken — not yet due, or a worker is already running it';
|
|
23
|
+
|
|
24
|
+
export interface DrainFailure {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly finding: Finding;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A candidate the drain deliberately left alone, because it could not prove it owned the job. */
|
|
31
|
+
export interface DrainSkip {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly queue: string;
|
|
35
|
+
readonly state: JobState;
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DrainOutcome {
|
|
40
|
+
readonly from: string;
|
|
41
|
+
readonly to: string;
|
|
42
|
+
readonly dryRun: boolean;
|
|
43
|
+
readonly candidates: readonly JobRecord[];
|
|
44
|
+
readonly moved: readonly JobRecord[];
|
|
45
|
+
readonly skipped: readonly DrainSkip[];
|
|
46
|
+
readonly failures: readonly DrainFailure[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The lease IS the proof of ownership. `ack()` takes only a job id, so a drain that ack'd rows
|
|
51
|
+
* off a snapshot could acknowledge a job a source worker claimed and is executing right now —
|
|
52
|
+
* the target would then run a duplicate of a job still in flight. One batch claim over the
|
|
53
|
+
* candidates' own queues; whatever comes back is the drain's to move, and everything else is
|
|
54
|
+
* left alone. `queues` is always explicit because the pg driver reads an empty list as
|
|
55
|
+
* `['default']` rather than as "every queue".
|
|
56
|
+
*/
|
|
57
|
+
function leaseCandidates(
|
|
58
|
+
source: JobDriver,
|
|
59
|
+
candidates: readonly JobRecord[],
|
|
60
|
+
): Promise<readonly JobRecord[]> {
|
|
61
|
+
if (candidates.length === 0) return Promise.resolve([]);
|
|
62
|
+
return source.claim({
|
|
63
|
+
queues: [...new Set(candidates.map((record) => record.queue))],
|
|
64
|
+
limit: candidates.length,
|
|
65
|
+
visibilityTimeoutMs: DRAIN_LEASE_MS,
|
|
66
|
+
workerId: `x-jobs-drain:${uuid()}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Steps carry their own `runId`, so a copy lands under the same key the target job resumes on. */
|
|
71
|
+
async function copySteps(source: JobDriver, target: JobDriver, runId: string): Promise<void> {
|
|
72
|
+
for (const record of await source.steps.list(runId)) await target.steps.put(record);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hand a leased job back exactly as the drain found it. `countsAsAttempt: false` is the point:
|
|
77
|
+
* a transfer that failed is not a failed attempt, and burning one per `x jobs drain` retry would
|
|
78
|
+
* dead-letter a job nobody ever ran. It parks as `suspended`, which every driver claims.
|
|
79
|
+
*/
|
|
80
|
+
async function releaseLease(source: JobDriver, id: string): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
await source.nack(id, { delayMs: 0, countsAsAttempt: false });
|
|
83
|
+
} catch {
|
|
84
|
+
// The lease expires on its own. Masking the transfer's real error with this one helps nobody.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const toSkip = (record: JobRecord): DrainSkip => ({
|
|
89
|
+
id: record.id,
|
|
90
|
+
name: record.name,
|
|
91
|
+
queue: record.queue,
|
|
92
|
+
state: record.state,
|
|
93
|
+
reason: NO_LEASE,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Move every pending job the drain can take a lease on from `source` onto `target`. `--dry-run`
|
|
98
|
+
* reports the candidate list, takes no lease and enqueues nothing. Per-record try/catch, not a
|
|
99
|
+
* batch operation: one record that cannot enqueue on the target (a redis/nats stub, a transient
|
|
100
|
+
* error) must not stop the rest from moving, and the caller reports each failure on its own.
|
|
101
|
+
*/
|
|
102
|
+
export async function drainJobs(
|
|
103
|
+
source: JobDriver,
|
|
104
|
+
target: JobDriver,
|
|
105
|
+
dryRun: boolean,
|
|
106
|
+
): Promise<DrainOutcome> {
|
|
107
|
+
const lists = await Promise.all(PENDING_STATES.map((state) => inspectJobList(source, { state })));
|
|
108
|
+
const candidates = lists.flat();
|
|
109
|
+
const base = { from: source.name, to: target.name, candidates };
|
|
110
|
+
if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
|
|
111
|
+
|
|
112
|
+
const leased = await leaseCandidates(source, candidates);
|
|
113
|
+
const held = new Set(leased.map((record) => record.id));
|
|
114
|
+
const found = new Map(candidates.map((record) => [record.id, record]));
|
|
115
|
+
const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
|
|
116
|
+
|
|
117
|
+
const moved: JobRecord[] = [];
|
|
118
|
+
const failures: DrainFailure[] = [];
|
|
119
|
+
for (const record of leased) {
|
|
120
|
+
let enqueued = false;
|
|
121
|
+
try {
|
|
122
|
+
// Steps BEFORE the job: a worker on the target must never be able to claim a run whose
|
|
123
|
+
// checkpoint has not landed, or it repeats completed steps or loses them outright.
|
|
124
|
+
await copySteps(source, target, record.runId);
|
|
125
|
+
await target.enqueue({
|
|
126
|
+
name: record.name,
|
|
127
|
+
queue: record.queue,
|
|
128
|
+
input: record.input,
|
|
129
|
+
idempotencyKey: record.idempotencyKey,
|
|
130
|
+
runId: record.runId,
|
|
131
|
+
maxAttempts: record.maxAttempts,
|
|
132
|
+
runAt: record.runAt,
|
|
133
|
+
...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
|
|
134
|
+
});
|
|
135
|
+
enqueued = true;
|
|
136
|
+
// Only now is the ack the drain's to make: the lease proves no source worker holds this
|
|
137
|
+
// job, and the target already has both the row and its steps. A crash between the two
|
|
138
|
+
// leaves the job live on both drivers, where `idempotencyKey` dedupes it.
|
|
139
|
+
await source.ack(record.id);
|
|
140
|
+
// Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
|
|
141
|
+
// attempt higher), a state nothing on either driver is in once this returns.
|
|
142
|
+
moved.push(found.get(record.id) ?? record);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
|
|
145
|
+
// not: the job is already live there, and releasing it would race the target's worker.
|
|
146
|
+
if (!enqueued) await releaseLease(source, record.id);
|
|
147
|
+
failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { ...base, dryRun: false, moved, skipped, failures };
|
|
151
|
+
}
|