@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.
Files changed (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -0,0 +1,16 @@
1
+ // `x build`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-build.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const buildSpec: CommandSpec = {
7
+ name: 'build',
8
+ summary: 'build a container image, a single binary, or a prerendered static site',
9
+ usage: 'x build --target docker|binary|static [--tag name] [--out path] [--json]',
10
+ requiresApp: true,
11
+ flags: [
12
+ { name: 'target', type: 'string', summary: 'docker | binary | static', default: 'docker' },
13
+ { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
14
+ { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
15
+ ],
16
+ };
package/src/cmd-build.ts CHANGED
@@ -2,15 +2,17 @@
2
2
  // means "anywhere that runs a container or a binary"; nothing here knows the name of a cloud.
3
3
 
4
4
  import { existsSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { join, resolve } from 'node:path';
6
6
  import { ERROR_DOCS_URL, frameworkVersion, VERSION_DEFINE } from '@ultimat3/core';
7
7
  import { requireAppRoot } from './app-root';
8
+ import { buildSpec } from './cmd-build-spec';
8
9
  import { runVerify } from './cmd-verify';
9
10
  import type { CliCommand, CommandContext } from './command';
10
11
  import { externalArgs } from './compile-externals';
11
12
  import { BuildEntryMissingError, UnknownCommandError } from './errors';
12
13
  import type { ExecResult } from './exec';
13
14
  import { execOutput } from './exec';
15
+ import { prepareImage } from './image-prepare';
14
16
  import { msg } from './messages';
15
17
  import type { CommandResult } from './output';
16
18
  import { flagString } from './parse';
@@ -56,9 +58,24 @@ export function requireEntry(root: string, target: BuildTarget): string {
56
58
  return absolute;
57
59
  }
58
60
 
59
- /** One image for every role; ROLE selects behaviour at start, so there is one artifact to promote. */
60
- export function dockerArgs(root: string, tag: string): readonly string[] {
61
- return ['docker', 'build', '-f', join(root, BUILD_ENTRY.docker), '-t', tag, root];
61
+ /**
62
+ * One image for every role; ROLE selects behaviour at start, so there is one artifact to promote.
63
+ * `BUILD_ID` is the manifest's own build id, passed as the build arg the scaffolded Dockerfile
64
+ * declares: without it the image's `BUILD_ID` was empty and every role computed one at boot, so
65
+ * two replicas of one image could disagree about the id their clients are served against.
66
+ */
67
+ export function dockerArgs(root: string, tag: string, buildId: string): readonly string[] {
68
+ return [
69
+ 'docker',
70
+ 'build',
71
+ '-f',
72
+ join(root, BUILD_ENTRY.docker),
73
+ '--build-arg',
74
+ `BUILD_ID=${buildId}`,
75
+ '-t',
76
+ tag,
77
+ root,
78
+ ];
62
79
  }
63
80
 
64
81
  /**
@@ -93,9 +110,15 @@ export function staticArgs(root: string, out: string): readonly string[] {
93
110
 
94
111
  export function argsFor(
95
112
  target: BuildTarget,
96
- paths: { readonly root: string; readonly tag: string; readonly out: string },
113
+ paths: {
114
+ readonly root: string;
115
+ readonly tag: string;
116
+ readonly out: string;
117
+ /** The docker target's `BUILD_ID` build arg — `appManifest(root)`'s own. */
118
+ readonly buildId?: string;
119
+ },
97
120
  ): readonly string[] {
98
- if (target === 'docker') return dockerArgs(paths.root, paths.tag);
121
+ if (target === 'docker') return dockerArgs(paths.root, paths.tag, paths.buildId ?? 'dev');
99
122
  if (target === 'binary') return binaryArgs(paths.root, paths.out);
100
123
  return staticArgs(paths.root, paths.out);
101
124
  }
@@ -162,17 +185,7 @@ export function buildResult(input: {
162
185
  }
163
186
 
164
187
  export const buildCommand: CliCommand = {
165
- spec: {
166
- name: 'build',
167
- summary: 'build a container image, a single binary, or a prerendered static site',
168
- usage: 'x build --target docker|binary|static [--tag name] [--out path] [--json]',
169
- requiresApp: true,
170
- flags: [
171
- { name: 'target', type: 'string', summary: 'docker | binary | static', default: 'docker' },
172
- { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
173
- { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
174
- ],
175
- },
188
+ spec: buildSpec,
176
189
  async run(ctx: CommandContext): Promise<CommandResult> {
177
190
  const root = requireAppRoot('build', ctx.cwd).dir;
178
191
  const target = readTarget(flagString(ctx.args, 'target'));
@@ -186,15 +199,28 @@ export const buildCommand: CliCommand = {
186
199
  const verifySteps = (await import('./cmd-verify')).VERIFY_STEPS.filter((step) =>
187
200
  staticSteps.includes(step.name),
188
201
  );
189
- const verifyResult = await runVerify(verifySteps, { root, runner: ctx.runner });
202
+ const verifyResult = await runVerify(verifySteps, { root, runner: ctx.runner, env: ctx.env });
190
203
  if (!verifyResult.ok) {
191
204
  return preflightResult(verifyResult);
192
205
  }
193
206
 
207
+ // A relative `--out` is a path the caller typed from where they stand, so it resolves against
208
+ // the cwd — against the root it landed somewhere else whenever `x build` ran from `apps/web`.
209
+ const outFlag = flagString(ctx.args, 'out');
194
210
  const out =
195
- flagString(ctx.args, 'out') ?? join(root, '.x', target === 'static' ? 'static' : 'app');
211
+ outFlag === undefined
212
+ ? join(root, '.x', target === 'static' ? 'static' : 'app')
213
+ : resolve(ctx.cwd, outFlag);
196
214
  const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
197
- const command = argsFor(target, { root, tag, out });
215
+ // The docker target stamps the manifest's build id into the image, and writes the island
216
+ // chunks the image serves, so a container boot neither re-derives the one nor rebuilds the other.
217
+ const buildId = target === 'docker' ? await prepareImage(root) : undefined;
218
+ const command = argsFor(target, {
219
+ root,
220
+ tag,
221
+ out,
222
+ ...(buildId === undefined ? {} : { buildId }),
223
+ });
198
224
  // Removed BEFORE the builder runs, so a build that writes no inventory can never be reported
199
225
  // with the last one's: a stale emitted list is worse than none, because it reads as this run's.
200
226
  if (target === 'static') await removeStaticReport(root);
@@ -0,0 +1,24 @@
1
+ // `x ci`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-ci.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ /** Log lines kept per failed job. Enough to hold a findings block, short enough to read. */
7
+ export const TAIL_LINES = 40;
8
+
9
+ export const ciSpec: CommandSpec = {
10
+ name: 'ci',
11
+ summary: 'the workflow runs for this branch, and the findings inside the failed steps log',
12
+ usage: 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--json]',
13
+ flags: [
14
+ { name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
15
+ { name: 'branch', type: 'string', summary: 'branch to read runs for; this one by default' },
16
+ { name: 'run', type: 'string', summary: 'one run id, instead of this branch latest' },
17
+ {
18
+ name: 'tail',
19
+ type: 'string',
20
+ summary: `log lines kept per failed job (default ${TAIL_LINES})`,
21
+ },
22
+ { name: 'full', type: 'boolean', summary: 'the whole failed-step log, not the tail' },
23
+ ],
24
+ };
package/src/cmd-ci.ts CHANGED
@@ -9,14 +9,18 @@ import type { CiLogLine } from './ci-log';
9
9
  import { findingsFrom, jobsInLog, parseLogLines, tailOf } from './ci-log';
10
10
  import type { CiJob, CiRun } from './ci-runs';
11
11
  import { failedLog, isFailed, isRunning, latestPerWorkflow, listRuns, viewRun } from './ci-runs';
12
+ import { ciSpec, TAIL_LINES } from './cmd-ci-spec';
12
13
  import type { CliCommand, CommandContext } from './command';
13
14
  import { parseIntFlag } from './flag-number';
15
+ import { commentBlock } from './foreign-text';
14
16
  import type { GhRepo } from './gh-target';
15
17
  import { currentBranch, resolveRepo } from './gh-target';
16
18
  import { msg } from './messages';
17
19
  import type { CommandResult, Finding, JsonValue } from './output';
18
20
  import { flagBool, flagString } from './parse';
19
21
 
22
+ export { TAIL_LINES } from './cmd-ci-spec';
23
+
20
24
  /**
21
25
  * Every catalog key this command renders, declared — `msg()` answers `⟦key⟧` for a key nobody
22
26
  * added, which is loud in a terminal and SILENT to a build. `cmd-ci.test.ts` holds this list
@@ -34,9 +38,6 @@ export const CI_MESSAGE_KEYS = [
34
38
  'cli.ci.logs.empty',
35
39
  ] as const;
36
40
 
37
- /** Log lines kept per failed job. Enough to hold a findings block, short enough to read. */
38
- export const TAIL_LINES = 40;
39
-
40
41
  /** How far back a branch's run history is read before "the latest run of each workflow". */
41
42
  export const RUN_LOOKBACK = 20;
42
43
 
@@ -55,22 +56,7 @@ const RUN_FLAG = { name: 'run', command: 'ci', min: 1, example: 'x ci --run 3248
55
56
  const TAIL_FLAG = { name: 'tail', command: 'ci', min: 1, example: 'x ci --tail 80 --json' };
56
57
 
57
58
  export const ciCommand: CliCommand = {
58
- spec: {
59
- name: 'ci',
60
- summary: 'the workflow runs for this branch, and the findings inside the failed steps log',
61
- usage: 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--json]',
62
- flags: [
63
- { name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
64
- { name: 'branch', type: 'string', summary: 'branch to read runs for; this one by default' },
65
- { name: 'run', type: 'string', summary: 'one run id, instead of this branch latest' },
66
- {
67
- name: 'tail',
68
- type: 'string',
69
- summary: `log lines kept per failed job (default ${TAIL_LINES})`,
70
- },
71
- { name: 'full', type: 'boolean', summary: 'the whole failed-step log, not the tail' },
72
- ],
73
- },
59
+ spec: ciSpec,
74
60
  async run(ctx: CommandContext): Promise<CommandResult> {
75
61
  const repo = await resolveRepo(ctx, 'ci', flagString(ctx.args, 'repo'));
76
62
  const rawRun = flagString(ctx.args, 'run');
@@ -142,7 +128,14 @@ async function inspect(
142
128
  const pool: readonly CiLogLine[] = own.length > 0 ? own : lines;
143
129
  const job = failed.find((candidate) => candidate.name === name);
144
130
  for (const finding of findingsFrom(pool)) {
145
- findings.push(finding.at === undefined ? { ...finding, at: name } : finding);
131
+ findings.push({
132
+ ...finding,
133
+ at: finding.at ?? name,
134
+ // Fenced and tagged: the log is foreign text, and a `fix:` reconstructed from it is the
135
+ // log author's instruction, not this CLI's. `commentBlock` is `x pr review`'s fence.
136
+ fix: commentBlock(`ci-log ${name}`, [finding.fix]).join('\n'),
137
+ source: 'ci-log',
138
+ });
146
139
  }
147
140
  failures.push({
148
141
  job: name,
@@ -229,7 +222,8 @@ function runLines(entry: RunReport): readonly string[] {
229
222
  continue;
230
223
  }
231
224
  out.push(msg('cli.ci.tail', { job: failure.job }));
232
- for (const line of failure.tail) out.push(` | ${line}`);
225
+ for (const line of commentBlock(`ci-log ${failure.job}`, failure.tail))
226
+ out.push(` | ${line}`);
233
227
  }
234
228
  const jobs = entry.jobs;
235
229
  if (jobs !== undefined && jobs.length > failures.length) {
@@ -266,6 +260,8 @@ function runJson(entry: RunReport): JsonValue {
266
260
  job: failure.job,
267
261
  url: failure.url,
268
262
  failedSteps: [...failure.failedSteps],
263
+ // Raw lines for a machine, labelled as foreign: the log's author wrote every one.
264
+ tailSource: 'ci-log',
269
265
  tail: [...failure.tail],
270
266
  })),
271
267
  }),
@@ -23,12 +23,12 @@ import {
23
23
  previewUrl,
24
24
  } from './db-branch';
25
25
  import { stepFinding } from './db-finding';
26
- import type { DevServices } from './dev-services';
27
- import { resolveServices } from './dev-services';
28
26
  import { MissingPositionalError, UnknownCommandError } from './errors';
29
27
  import { msg } from './messages';
30
28
  import type { CommandResult, Finding } from './output';
31
29
  import { flagString } from './parse';
30
+ import type { DevServices } from './runtime-bindings';
31
+ import { resolveServices } from './runtime-bindings';
32
32
  import { portFromEnv } from './serve';
33
33
  import { renderTable } from './table';
34
34
 
@@ -0,0 +1,99 @@
1
+ // `x db`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-db.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import { BRANCH_SUBCOMMANDS } from './db-branch';
5
+ import type { CommandSpec } from './parse';
6
+
7
+ export const DB_SUBCOMMANDS = [
8
+ 'gen',
9
+ 'migrate',
10
+ 'reset',
11
+ 'seed',
12
+ 'studio',
13
+ 'branch',
14
+ 'backfill',
15
+ ] as const;
16
+
17
+ export const dbSpec: CommandSpec = {
18
+ name: 'db',
19
+ summary: 'gen, migrate, reset, seed, studio, branch, backfill',
20
+ usage:
21
+ 'x db gen "add publish_at" | migrate | reset | seed [<name>] [--tier reference|dev] [--dry-run] | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
22
+ requiresApp: true,
23
+ subcommands: DB_SUBCOMMANDS,
24
+ // Declared from the constant `runBranchCommand` validates against, never a second literal: it
25
+ // is what lets the `errors` step resolve `x db branch ls` — a fix line three shipped errors
26
+ // hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
27
+ subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
28
+ // Each flag whose summary begins `<subcommand>:` declares that scope, and the parser refuses
29
+ // it anywhere else: `x db gen --dry-run` used to parse, reach `runGen` and WRITE the
30
+ // migration. `cmd-db.test.ts` pins summary and scope to the same fact.
31
+ flags: [
32
+ {
33
+ name: 'name',
34
+ type: 'string',
35
+ summary: 'migration, branch or seed name, or backfill to filter',
36
+ },
37
+ {
38
+ name: 'tier',
39
+ type: 'string',
40
+ summary: 'seed: which tier to run — reference or dev; also ULTIMATE_SEED_TIER',
41
+ subcommands: ['seed'],
42
+ },
43
+ {
44
+ name: 'dry-run',
45
+ type: 'boolean',
46
+ summary: 'seed: report what each seed would write, and write nothing',
47
+ subcommands: ['seed'],
48
+ },
49
+ {
50
+ name: 'list',
51
+ type: 'boolean',
52
+ summary: 'backfill: print the x_backfills ledger',
53
+ subcommands: ['backfill'],
54
+ },
55
+ {
56
+ name: 'pending',
57
+ type: 'boolean',
58
+ summary: 'backfill: declared minus completed; non-zero exit when anything is unswept',
59
+ subcommands: ['backfill'],
60
+ },
61
+ {
62
+ name: 'all',
63
+ type: 'boolean',
64
+ summary: 'backfill: every pending sweep, isolated per name',
65
+ subcommands: ['backfill'],
66
+ },
67
+ {
68
+ name: 'write',
69
+ type: 'boolean',
70
+ summary: 'backfill: enqueue the pass; dry run without it',
71
+ subcommands: ['backfill'],
72
+ },
73
+ {
74
+ name: 'force',
75
+ type: 'boolean',
76
+ summary: 'backfill: sweep a name the ledger records as completed, as a NEW ledger row',
77
+ subcommands: ['backfill'],
78
+ },
79
+ {
80
+ name: 'status',
81
+ type: 'string',
82
+ summary: 'backfill: filter by running, completed or failed',
83
+ subcommands: ['backfill'],
84
+ },
85
+ {
86
+ name: 'limit',
87
+ type: 'string',
88
+ summary: 'backfill: max ledger rows to return',
89
+ subcommands: ['backfill'],
90
+ },
91
+ // Declared because `X_MIGRATION_IRREVERSIBLE`'s own fix line names it. A `fix:` is copied
92
+ // and run verbatim, so a flag the parser refuses would make the error unfollowable.
93
+ {
94
+ name: 'allow-destructive',
95
+ type: 'boolean',
96
+ summary: 'let x db gen emit a drop whose down cannot restore the rows',
97
+ },
98
+ ],
99
+ };
package/src/cmd-db.ts CHANGED
@@ -17,9 +17,9 @@ import { postgresDriver } from '@ultimat3/entity';
17
17
  import { requireAppRoot } from './app-root';
18
18
  import { runBackfillCommand } from './cmd-db-backfill';
19
19
  import { runBranchCommand } from './cmd-db-branch';
20
+ import { DB_SUBCOMMANDS, dbSpec } from './cmd-db-spec';
20
21
  import { plannedSubcommand } from './cmd-planned';
21
22
  import type { CliCommand, CommandContext } from './command';
22
- import { BRANCH_SUBCOMMANDS } from './db-branch';
23
23
  import { stepFinding } from './db-finding';
24
24
  import { generateAppMigration, unrenderedJson, unrenderedLines } from './db-generate';
25
25
  import type { SeedPassRow } from './db-seed';
@@ -32,109 +32,20 @@ import {
32
32
  seedTotals,
33
33
  selectSeeds,
34
34
  } from './db-seed';
35
- import { resolveServices } from './dev-services';
35
+ import { DevAlreadyRunningError, liveDevLock } from './dev-lock';
36
36
  import { CliNotImplementedError, MissingSubcommandError, UnknownCommandError } from './errors';
37
37
  import { withJobDriver } from './jobs-driver';
38
38
  import { msg } from './messages';
39
39
  import type { CommandResult, Finding } from './output';
40
40
  import { findingFrom } from './output';
41
41
  import { flagBool, flagString } from './parse';
42
+ import { resolveServices } from './runtime-bindings';
42
43
  import { runMigrations } from './serve';
43
44
 
44
- export const DB_SUBCOMMANDS = [
45
- 'gen',
46
- 'migrate',
47
- 'reset',
48
- 'seed',
49
- 'studio',
50
- 'branch',
51
- 'backfill',
52
- ] as const;
45
+ export { DB_SUBCOMMANDS } from './cmd-db-spec';
53
46
 
54
47
  export const dbCommand: CliCommand = {
55
- spec: {
56
- name: 'db',
57
- summary: 'gen, migrate, reset, seed, studio, branch, backfill',
58
- usage:
59
- 'x db gen "add publish_at" | migrate | reset | seed [<name>] [--tier reference|dev] [--dry-run] | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
60
- requiresApp: true,
61
- subcommands: DB_SUBCOMMANDS,
62
- // Declared from the constant `runBranchCommand` validates against, never a second literal: it
63
- // is what lets the `errors` step resolve `x db branch ls` — a fix line three shipped errors
64
- // hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
65
- subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
66
- // Each flag whose summary begins `<subcommand>:` declares that scope, and the parser refuses
67
- // it anywhere else: `x db gen --dry-run` used to parse, reach `runGen` and WRITE the
68
- // migration. `cmd-db.test.ts` pins summary and scope to the same fact.
69
- flags: [
70
- {
71
- name: 'name',
72
- type: 'string',
73
- summary: 'migration, branch or seed name, or backfill to filter',
74
- },
75
- {
76
- name: 'tier',
77
- type: 'string',
78
- summary: 'seed: which tier to run — reference or dev; also ULTIMATE_SEED_TIER',
79
- subcommands: ['seed'],
80
- },
81
- {
82
- name: 'dry-run',
83
- type: 'boolean',
84
- summary: 'seed: report what each seed would write, and write nothing',
85
- subcommands: ['seed'],
86
- },
87
- {
88
- name: 'list',
89
- type: 'boolean',
90
- summary: 'backfill: print the x_backfills ledger',
91
- subcommands: ['backfill'],
92
- },
93
- {
94
- name: 'pending',
95
- type: 'boolean',
96
- summary: 'backfill: declared minus completed; non-zero exit when anything is unswept',
97
- subcommands: ['backfill'],
98
- },
99
- {
100
- name: 'all',
101
- type: 'boolean',
102
- summary: 'backfill: every pending sweep, isolated per name',
103
- subcommands: ['backfill'],
104
- },
105
- {
106
- name: 'write',
107
- type: 'boolean',
108
- summary: 'backfill: enqueue the pass; dry run without it',
109
- subcommands: ['backfill'],
110
- },
111
- {
112
- name: 'force',
113
- type: 'boolean',
114
- summary: 'backfill: sweep a name the ledger records as completed, as a NEW ledger row',
115
- subcommands: ['backfill'],
116
- },
117
- {
118
- name: 'status',
119
- type: 'string',
120
- summary: 'backfill: filter by running, completed or failed',
121
- subcommands: ['backfill'],
122
- },
123
- {
124
- name: 'limit',
125
- type: 'string',
126
- summary: 'backfill: max ledger rows to return',
127
- subcommands: ['backfill'],
128
- },
129
- // Declared because `X_MIGRATION_IRREVERSIBLE`'s own fix line names it. A `fix:` is copied
130
- // and run verbatim, so a flag the parser refuses would make the error unfollowable.
131
- {
132
- name: 'allow-destructive',
133
- type: 'boolean',
134
- summary: 'let x db gen emit a drop whose down cannot restore the rows',
135
- },
136
- ],
137
- },
48
+ spec: dbSpec,
138
49
  async run(ctx: CommandContext): Promise<CommandResult> {
139
50
  const root = requireAppRoot('db', ctx.cwd).dir;
140
51
  // No default, and no `?? 'migrate'` here either: `gen` writes a migration file and `reset`
@@ -299,6 +210,16 @@ async function runReset(ctx: CommandContext, root: string): Promise<CommandResul
299
210
  fix: 'drop and recreate the database yourself, then run: x db migrate',
300
211
  });
301
212
  }
213
+ // A running `x dev` has this directory open: deleting it under a live embedded Postgres is data
214
+ // loss mid-write, not a reset. The dev lock is the one fact that says someone holds it.
215
+ const holder = liveDevLock(services.stateDir);
216
+ if (holder !== undefined) {
217
+ throw new DevAlreadyRunningError({
218
+ lock: holder,
219
+ stateDir: services.stateDir,
220
+ embeddedDb: true,
221
+ });
222
+ }
302
223
  await rm(join(services.stateDir, 'pgdata'), { recursive: true, force: true });
303
224
  return runMigrate(ctx, root, msg('cli.db.reset.done'));
304
225
  }
@@ -0,0 +1,136 @@
1
+ // The helm half of `x deploy`: which release, in which namespace, waited on for how long, and what
2
+ // the rollout reported. Split from cmd-deploy.ts, which owns the plan and the compose method.
3
+ //
4
+ // `helm upgrade --install` with no `--wait` exits 0 the moment the API server ACCEPTS the objects,
5
+ // so a deploy whose pods never became ready reported success; and helm's default 5m timeout failed
6
+ // an upgrade whose migrate hook was still applying a long migration, leaving the Job running.
7
+
8
+ // why: Bun exposes no path-join primitive; the chart path is handed to helm as one joined string.
9
+ import { join } from 'node:path';
10
+ import { UltimateError } from '@ultimat3/core';
11
+ import { APP_CONFIG_EXPORT } from './app-auth';
12
+ import { APP_CONFIG_FILE } from './app-root';
13
+ import { BadFlagError } from './errors';
14
+
15
+ /** Long enough for a migration a pre-upgrade hook must finish; helm's own 5m default is not. */
16
+ export const HELM_DEFAULT_TIMEOUT = '15m';
17
+
18
+ /** What a helm deploy is aimed at, resolved once from the flags and `app.config.ts`. */
19
+ export interface HelmTarget {
20
+ readonly release: string;
21
+ readonly namespace: string | undefined;
22
+ readonly timeout: string;
23
+ }
24
+
25
+ /** Helm's own `time.ParseDuration` grammar, positive units only: `15m`, `900s`, `1h30m`. */
26
+ const DURATION = /^(?:\d+(?:h|m|s|ms))+$/;
27
+ /** A namespace and a release are both DNS-1123 labels; helm caps a release at 53 characters. */
28
+ const LABEL = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
29
+ const RELEASE_MAX = 53;
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
32
+ typeof value === 'object' && value !== null;
33
+
34
+ /** `--timeout`, screened: a value helm would reject is refused before anything is spawned. */
35
+ export function readHelmTimeout(raw: string | undefined): string {
36
+ if (raw === undefined) return HELM_DEFAULT_TIMEOUT;
37
+ if (DURATION.test(raw) && /[1-9]/.test(raw)) return raw;
38
+ throw new BadFlagError({
39
+ flag: 'timeout',
40
+ command: 'deploy',
41
+ reason: 'it is not a positive helm duration such as 15m, 900s or 1h30m',
42
+ fix: 'x deploy --method helm --timeout 15m --json',
43
+ });
44
+ }
45
+
46
+ /** `--namespace` and `--release`, each a DNS-1123 label or a refusal naming the flag. */
47
+ export function readLabel(flag: 'namespace' | 'release', raw: string): string {
48
+ const max = flag === 'release' ? RELEASE_MAX : 63;
49
+ if (raw.length <= max && LABEL.test(raw)) return raw;
50
+ throw new BadFlagError({
51
+ flag,
52
+ command: 'deploy',
53
+ reason: `it must be a lowercase DNS-1123 label of at most ${max} characters`,
54
+ fix:
55
+ flag === 'release'
56
+ ? 'x deploy --method helm --release my-app --json'
57
+ : 'x deploy --method helm --namespace my-namespace --json',
58
+ });
59
+ }
60
+
61
+ /**
62
+ * The release name: `--release`, else `app.config.ts`'s `name`. It was the literal `app` for every
63
+ * app, so two apps deployed to one namespace were one release and the second upgrade replaced the
64
+ * first. `--release` exists for a release that already carries another name — `app`, for every
65
+ * cluster an earlier version deployed to — because a release name is cluster state the framework
66
+ * does not own and cannot rename.
67
+ */
68
+ export async function readReleaseName(root: string, flag: string | undefined): Promise<string> {
69
+ if (flag !== undefined) return readLabel('release', flag);
70
+ const module = (await import(join(root, APP_CONFIG_FILE))) as Record<string, unknown>;
71
+ const config = module[APP_CONFIG_EXPORT];
72
+ const name = isRecord(config) ? config['name'] : undefined;
73
+ if (typeof name === 'string' && name.length <= RELEASE_MAX && LABEL.test(name)) return name;
74
+ throw new UltimateError({
75
+ code: 'X_CONFIG_INVALID',
76
+ cause:
77
+ typeof name === 'string'
78
+ ? `app.config.ts names the app "${name.slice(0, 80)}", which is not a helm release name (a DNS-1123 label of at most ${RELEASE_MAX} characters)`
79
+ : 'app.config.ts exports no config.name, and x deploy --method helm names the release after it',
80
+ fix: 'name the release explicitly: x deploy --method helm --release my-app --json',
81
+ });
82
+ }
83
+
84
+ /** The one `helm upgrade`, waited on: `--wait` for the rollout, `--output json` for the verdict. */
85
+ export function helmUpgradeArgs(
86
+ root: string,
87
+ target: HelmTarget,
88
+ imageOverrides: readonly string[],
89
+ ): readonly string[] {
90
+ return [
91
+ 'helm',
92
+ 'upgrade',
93
+ '--install',
94
+ target.release,
95
+ join(root, 'docker', 'helm'),
96
+ ...(target.namespace === undefined ? [] : ['--namespace', target.namespace]),
97
+ '--wait',
98
+ '--timeout',
99
+ target.timeout,
100
+ '--output',
101
+ 'json',
102
+ ...imageOverrides,
103
+ ];
104
+ }
105
+
106
+ /** What `--json` reports about the rollout helm waited on. */
107
+ export interface Rollout {
108
+ readonly release: string;
109
+ readonly namespace: string | null;
110
+ readonly revision: number | null;
111
+ readonly status: string;
112
+ }
113
+
114
+ /**
115
+ * helm's `--output json` release record, read structurally. `status` is helm's own word —
116
+ * `deployed`, `failed`, `pending-upgrade` — and `unknown` only when helm printed no record at all,
117
+ * never a guess dressed as one.
118
+ */
119
+ export function readRollout(target: HelmTarget, stdout: string): Rollout {
120
+ let parsed: unknown;
121
+ try {
122
+ parsed = JSON.parse(stdout);
123
+ } catch {
124
+ parsed = undefined;
125
+ }
126
+ const info = isRecord(parsed) ? parsed['info'] : undefined;
127
+ const status = isRecord(info) ? info['status'] : undefined;
128
+ const revision = isRecord(parsed) ? parsed['version'] : undefined;
129
+ const namespace = isRecord(parsed) ? parsed['namespace'] : undefined;
130
+ return {
131
+ release: target.release,
132
+ namespace: typeof namespace === 'string' ? namespace : (target.namespace ?? null),
133
+ revision: typeof revision === 'number' ? revision : null,
134
+ status: typeof status === 'string' ? status : 'unknown',
135
+ };
136
+ }