@arki/dot 0.1.0 → 0.1.2

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 (52) hide show
  1. package/README.md +36 -31
  2. package/dist/define-app.d.ts +58 -16
  3. package/dist/define-app.d.ts.map +1 -1
  4. package/dist/define-app.js +23 -13
  5. package/dist/define-app.js.map +1 -1
  6. package/dist/index.d.ts +7 -5
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -4
  9. package/dist/index.js.map +1 -1
  10. package/dist/kernel/app-instance.d.ts +3 -3
  11. package/dist/kernel/app-instance.d.ts.map +1 -1
  12. package/dist/kernel/app-instance.js +265 -159
  13. package/dist/kernel/app-instance.js.map +1 -1
  14. package/dist/lifecycle.d.ts +11 -9
  15. package/dist/lifecycle.d.ts.map +1 -1
  16. package/dist/lifecycle.js +15 -9
  17. package/dist/lifecycle.js.map +1 -1
  18. package/dist/pip-contract.d.ts +254 -149
  19. package/dist/pip-contract.d.ts.map +1 -1
  20. package/dist/pip-contract.js +185 -41
  21. package/dist/pip-contract.js.map +1 -1
  22. package/dist/pip.d.ts +9 -12
  23. package/dist/pip.d.ts.map +1 -1
  24. package/dist/pip.js +8 -11
  25. package/dist/pip.js.map +1 -1
  26. package/dist/test-harness.d.ts +13 -10
  27. package/dist/test-harness.d.ts.map +1 -1
  28. package/dist/test-harness.js +12 -12
  29. package/dist/test-harness.js.map +1 -1
  30. package/package.json +10 -4
  31. package/src/cli/discover.ts +223 -0
  32. package/src/cli/error-codes.ts +74 -0
  33. package/src/cli/files.ts +120 -0
  34. package/src/cli/index.ts +539 -0
  35. package/src/cli/json.ts +49 -0
  36. package/src/cli/new.ts +420 -0
  37. package/src/cli/observability-probe.ts +51 -0
  38. package/src/cli/render-doctor.ts +199 -0
  39. package/src/cli/render-explain.ts +161 -0
  40. package/src/define-app.ts +310 -0
  41. package/src/diagnostics.ts +91 -0
  42. package/src/index.ts +89 -0
  43. package/src/kernel/app-instance.ts +1341 -0
  44. package/src/kernel/otel.ts +265 -0
  45. package/src/lifecycle-observer.ts +100 -0
  46. package/src/lifecycle.ts +121 -0
  47. package/src/manifest.ts +94 -0
  48. package/src/pip-contract.ts +477 -0
  49. package/src/pip.ts +84 -0
  50. package/src/test-harness.ts +72 -0
  51. package/src/timeline.ts +137 -0
  52. package/templates/app-minimal/AGENTS.md.tmpl +2 -2
package/src/cli/new.ts ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * `dot new <app>` — scaffold a minimal DOT app.
3
+ *
4
+ * Two-phase: we first build an in-memory list of {@link FileOperation}
5
+ * entries describing every file the scaffold WOULD write (template
6
+ * substitution included), then either:
7
+ *
8
+ * - `--dry-run` -> print the JSON envelope, write nothing.
9
+ * - real run -> commit the planned operations to disk.
10
+ *
11
+ * The dry-run envelope is the contract: agents read it to know exactly
12
+ * which files will land, with sha256 hashes so they can verify the
13
+ * post-run filesystem matches the plan.
14
+ *
15
+ * Templates live at `packages/dot/templates/app-minimal/` and use
16
+ * `.tmpl` suffixes + `{{name}}`-style placeholders. The suffix is
17
+ * stripped during scaffolding so `package.json.tmpl` becomes
18
+ * `package.json` on disk.
19
+ *
20
+ * Commands deferred to v1.1 (`dot add <pip>`, `dot dev`,
21
+ * `dot migrate`) are deliberately NOT surfaced here.
22
+ */
23
+
24
+ import { fileURLToPath } from 'node:url';
25
+ import path from 'node:path';
26
+
27
+ import { createDebugLogger } from '@arki/log/debug';
28
+
29
+ import type { DiagnosticIssue } from '../diagnostics.js';
30
+ import type { FileOperation } from './files.js';
31
+ import type { DotNewEnvelope } from './json.js';
32
+ import {
33
+ collectTemplateFiles,
34
+ directoryIsNonEmpty,
35
+ fileExists,
36
+ readTemplate,
37
+ sha256,
38
+ utf8ByteLength,
39
+ writeFileEnsuringDir,
40
+ } from './files.js';
41
+ import { DotCliError, DotCliErrorCode } from './error-codes.js';
42
+ import { toPublicOperation } from './json.js';
43
+
44
+ const debugNew = createDebugLogger('arki:dot:cli:new');
45
+
46
+ /**
47
+ * Package manager the generated README + AGENTS.md will reference.
48
+ * `bun` is the default because the kernel + adapter packages are tested
49
+ * with Bun first.
50
+ */
51
+ export type PackageManager = 'npm' | 'pnpm' | 'bun';
52
+
53
+ /** Options accepted by {@link runNew}. */
54
+ export type RunNewOptions = {
55
+ /** Application name (becomes `package.json#name`). */
56
+ readonly name: string;
57
+ /**
58
+ * Working directory for resolving `--target`. Defaults to
59
+ * `process.cwd()` when omitted.
60
+ */
61
+ readonly cwd?: string;
62
+ /**
63
+ * Target directory; resolved against `cwd`. Defaults to `<name>`
64
+ * under `cwd`. The directory is created if missing.
65
+ */
66
+ readonly target?: string;
67
+ /** Package manager hint surfaced in README/AGENTS.md. Defaults to `bun`. */
68
+ readonly pm?: PackageManager;
69
+ /** When true, only plan operations — write nothing. */
70
+ readonly dryRun?: boolean;
71
+ /** When true, emit JSON to `out`. Otherwise emit progress lines. */
72
+ readonly json?: boolean;
73
+ /** When true, overwrite existing files in the target dir. */
74
+ readonly force?: boolean;
75
+ /** Stdout sink. Defaults to `process.stdout.write`. */
76
+ readonly out?: (line: string) => void;
77
+ /** Stderr sink. Defaults to `process.stderr.write`. */
78
+ readonly err?: (line: string) => void;
79
+ /** Override clock for deterministic envelopes. */
80
+ readonly now?: () => Date;
81
+ /**
82
+ * Override template root. Used by tests; production callers should
83
+ * leave this undefined so the bundled `templates/app-minimal/`
84
+ * directory is used.
85
+ */
86
+ readonly templateRoot?: string;
87
+ /**
88
+ * Version values inlined into the generated `package.json`. The
89
+ * defaults track the current `@arki/dot` + `@arki/env` releases.
90
+ */
91
+ readonly versions?: {
92
+ readonly dot?: string;
93
+ readonly env?: string;
94
+ readonly zod?: string;
95
+ };
96
+ };
97
+
98
+ /** Defaults for inlined dependency versions. Kept in one place. */
99
+ const DEFAULT_VERSIONS = {
100
+ dot: '0.1.0',
101
+ env: '0.1.0',
102
+ zod: '4.3.5',
103
+ } as const;
104
+
105
+ const DEFAULT_PM: PackageManager = 'bun';
106
+
107
+ const APP_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
108
+
109
+ const FILE_REASONS: Record<string, string> = {
110
+ 'package.json': 'Package manifest with @arki/dot + @arki/env dependencies',
111
+ 'tsconfig.json': 'TypeScript config for ESM + NodeNext + strict mode',
112
+ 'README.md': 'Quickstart and inspection commands',
113
+ 'AGENTS.md':
114
+ 'Agent-readable conventions (verification commands, public/private boundaries, DOT artifact locations)',
115
+ '.gitignore': 'Standard ignores for node_modules, dist, env files',
116
+ 'src/app.ts': 'App entrypoint composing defineApp + env adapter',
117
+ 'src/env.ts': 'Env schema (zod) consumed by @arki/env/dot',
118
+ 'tests/boot.test.ts': 'Vitest that boots the app and asserts manifest shape',
119
+ };
120
+
121
+ const TEMPLATE_SUFFIX = '.tmpl';
122
+
123
+ /**
124
+ * Resolve the default template directory, anchored relative to this
125
+ * source file. Works both when running from `src/` under Bun and from
126
+ * `dist/` after `tsc` — both layouts put the templates directory two
127
+ * levels above (`packages/dot/templates/app-minimal`).
128
+ */
129
+ function defaultTemplateRoot(): string {
130
+ const here = path.dirname(fileURLToPath(import.meta.url));
131
+ return path.resolve(here, '..', '..', 'templates', 'app-minimal');
132
+ }
133
+
134
+ /**
135
+ * Resolve the absolute target path for a given app + options bundle.
136
+ * Exported so tests can assert resolution behaviour without running the
137
+ * full scaffold.
138
+ */
139
+ export function resolveTargetDir(opts: { name: string; cwd?: string; target?: string }): string {
140
+ const cwd = opts.cwd ?? process.cwd();
141
+ const target = opts.target ?? opts.name;
142
+ return path.resolve(cwd, target);
143
+ }
144
+
145
+ /**
146
+ * Validate an app name. Rules match `package.json#name` constraints
147
+ * (lower-case, leading alphanumeric, no spaces) with one extra: we
148
+ * disallow scoped names so the scaffold produces a single-segment dir.
149
+ */
150
+ export function validateAppName(name: string): void {
151
+ if (!name) {
152
+ throw new DotCliError({
153
+ code: DotCliErrorCode.InvalidArgs,
154
+ message: 'App name is required.',
155
+ remediation: 'Pass an app name: `dot new <app-name>`.',
156
+ });
157
+ }
158
+ if (!APP_NAME_PATTERN.test(name)) {
159
+ throw new DotCliError({
160
+ code: DotCliErrorCode.InvalidArgs,
161
+ message: `App name "${name}" is invalid.`,
162
+ remediation:
163
+ 'Use lowercase letters, digits, dots, hyphens, or underscores; start with a letter or digit. Scoped names (`@scope/name`) are not supported by `dot new`.',
164
+ metadata: { received: name, pattern: APP_NAME_PATTERN.source },
165
+ });
166
+ }
167
+ }
168
+
169
+ function describeFile(relativePath: string): string {
170
+ return FILE_REASONS[relativePath] ?? `Scaffold file: ${relativePath}`;
171
+ }
172
+
173
+ function substitute(content: string, vars: Record<string, string>): string {
174
+ return content.replaceAll(/\{\{(\w+)\}\}/g, (_match, key: string) => {
175
+ if (Object.prototype.hasOwnProperty.call(vars, key)) {
176
+ return vars[key]!;
177
+ }
178
+ return `{{${key}}}`;
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Plan the file operations for a scaffold run. Pure — does not touch
184
+ * disk other than reading templates and `stat`-ing target paths.
185
+ */
186
+ export async function planOperations(opts: RunNewOptions): Promise<{
187
+ operations: FileOperation[];
188
+ target: string;
189
+ }> {
190
+ validateAppName(opts.name);
191
+
192
+ const target = resolveTargetDir(opts);
193
+ const templateRoot = opts.templateRoot ?? defaultTemplateRoot();
194
+ const pm = opts.pm ?? DEFAULT_PM;
195
+ const versions = {
196
+ dot: opts.versions?.dot ?? DEFAULT_VERSIONS.dot,
197
+ env: opts.versions?.env ?? DEFAULT_VERSIONS.env,
198
+ zod: opts.versions?.zod ?? DEFAULT_VERSIONS.zod,
199
+ };
200
+
201
+ const templateFiles = await collectTemplateFiles(templateRoot);
202
+ if (templateFiles.length === 0) {
203
+ throw new DotCliError({
204
+ code: DotCliErrorCode.AppLifecycleFailed,
205
+ message: `No template files found under ${templateRoot}.`,
206
+ remediation:
207
+ 'The @arki/dot package must ship its `templates/` directory. Reinstall the package or pass --template-root via the programmatic API.',
208
+ metadata: { templateRoot },
209
+ });
210
+ }
211
+
212
+ const vars: Record<string, string> = {
213
+ name: opts.name,
214
+ pkgManager: pm,
215
+ dotVersion: versions.dot,
216
+ envVersion: versions.env,
217
+ zodVersion: versions.zod,
218
+ };
219
+
220
+ const operations: FileOperation[] = [];
221
+ for (const templateRelPath of templateFiles) {
222
+ const absoluteTemplate = path.join(templateRoot, templateRelPath);
223
+ const raw = await readTemplate(absoluteTemplate);
224
+ const rendered = substitute(raw, vars);
225
+
226
+ const stripped = templateRelPath.endsWith(TEMPLATE_SUFFIX)
227
+ ? templateRelPath.slice(0, -TEMPLATE_SUFFIX.length)
228
+ : templateRelPath;
229
+ const targetPath = path.join(target, stripped);
230
+ const exists = await fileExists(targetPath);
231
+
232
+ let action: FileOperation['action'];
233
+ if (!exists) {
234
+ action = 'create';
235
+ } else if (opts.force) {
236
+ action = 'overwrite';
237
+ } else {
238
+ action = 'skip';
239
+ }
240
+
241
+ operations.push({
242
+ path: stripped,
243
+ action,
244
+ contentHash: sha256(rendered),
245
+ contentBytes: utf8ByteLength(rendered),
246
+ reason: describeFile(stripped),
247
+ content: rendered,
248
+ });
249
+ }
250
+
251
+ // Deterministic, sorted output regardless of fs walk order.
252
+ operations.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
253
+ return { operations, target };
254
+ }
255
+
256
+ /** Build the JSON envelope from a plan + status. */
257
+ function buildEnvelope(args: {
258
+ name: string;
259
+ target: string;
260
+ operations: FileOperation[];
261
+ errors: DiagnosticIssue[];
262
+ status: DotNewEnvelope['status'];
263
+ generatedAt: string;
264
+ }): DotNewEnvelope {
265
+ return {
266
+ status: args.status,
267
+ command: 'new',
268
+ generatedAt: args.generatedAt,
269
+ app: { name: args.name, target: args.target },
270
+ operations: args.operations.map(op => toPublicOperation(op)),
271
+ errors: args.errors,
272
+ };
273
+ }
274
+
275
+ function nowIso(opts: RunNewOptions): string {
276
+ const factory = opts.now ?? (() => new Date());
277
+ return factory().toISOString();
278
+ }
279
+
280
+ /**
281
+ * Run the `new` command. Returns the envelope so callers (CLI + tests)
282
+ * can act on it. Side effects: writes files when not in dry-run mode,
283
+ * emits progress to `out`/`err`.
284
+ */
285
+ export async function runNew(opts: RunNewOptions): Promise<DotNewEnvelope> {
286
+ const out = opts.out ?? ((line: string) => process.stdout.write(line));
287
+ const err = opts.err ?? ((line: string) => process.stderr.write(line));
288
+ const generatedAt = nowIso(opts);
289
+
290
+ // Plan first — any error here surfaces as a failure envelope.
291
+ let plan: { operations: FileOperation[]; target: string };
292
+ try {
293
+ plan = await planOperations(opts);
294
+ } catch (error) {
295
+ if (error instanceof DotCliError) {
296
+ const envelope = buildEnvelope({
297
+ name: opts.name,
298
+ target: resolveTargetDir(opts),
299
+ operations: [],
300
+ errors: [toIssue(error)],
301
+ status: 'failure',
302
+ generatedAt,
303
+ });
304
+ if (opts.json) out(`${JSON.stringify(envelope, null, 2)}\n`);
305
+ else err(`dot: ${error.code} ${error.message}\n remediation: ${error.remediation}\n docs: ${error.docsUrl}\n`);
306
+ return envelope;
307
+ }
308
+ throw error;
309
+ }
310
+
311
+ const { operations, target } = plan;
312
+
313
+ // Refuse to overwrite a non-empty target dir unless --force.
314
+ if (!opts.dryRun && !opts.force) {
315
+ const hasSkip = operations.some(op => op.action === 'skip');
316
+ const nonEmpty = await directoryIsNonEmpty(target);
317
+ if (hasSkip || nonEmpty) {
318
+ const issue: DiagnosticIssue = {
319
+ code: DotCliErrorCode.InvalidArgs,
320
+ severity: 'error',
321
+ message: `Target directory "${target}" is not empty.`,
322
+ remediation: 'Choose an empty directory, or re-run with --force to overwrite.',
323
+ docsUrl: 'https://docs.arki.dev/dot/cli#new',
324
+ metadata: { target, conflicting: operations.filter(op => op.action === 'skip').map(op => op.path) },
325
+ };
326
+ const envelope = buildEnvelope({
327
+ name: opts.name,
328
+ target,
329
+ operations,
330
+ errors: [issue],
331
+ status: 'failure',
332
+ generatedAt,
333
+ });
334
+ if (opts.json) out(`${JSON.stringify(envelope, null, 2)}\n`);
335
+ else err(`dot: ${issue.code} ${issue.message}\n remediation: ${issue.remediation}\n`);
336
+ return envelope;
337
+ }
338
+ }
339
+
340
+ // Dry-run: emit envelope, write nothing.
341
+ if (opts.dryRun) {
342
+ const envelope = buildEnvelope({
343
+ name: opts.name,
344
+ target,
345
+ operations,
346
+ errors: [],
347
+ status: 'success',
348
+ generatedAt,
349
+ });
350
+ if (opts.json) {
351
+ out(`${JSON.stringify(envelope, null, 2)}\n`);
352
+ } else {
353
+ out(`Would scaffold ${operations.length} file(s) under ${target}:\n`);
354
+ for (const op of operations) {
355
+ out(` ${op.action.padEnd(9)} ${op.path}\n`);
356
+ }
357
+ }
358
+ return envelope;
359
+ }
360
+
361
+ // Real run: commit files.
362
+ const errors: DiagnosticIssue[] = [];
363
+ const written: FileOperation[] = [];
364
+ for (const op of operations) {
365
+ const dest = path.join(target, op.path);
366
+ try {
367
+ await writeFileEnsuringDir(dest, op.content);
368
+ // Normalise the action to `create` for the envelope; the file was
369
+ // either created or overwritten with the planned content.
370
+ written.push({ ...op, action: op.action === 'skip' ? 'create' : op.action });
371
+ } catch (error) {
372
+ debugNew('write failed for %s: %O', dest, error);
373
+ errors.push({
374
+ code: DotCliErrorCode.AppLifecycleFailed,
375
+ severity: 'error',
376
+ message: `Failed to write ${op.path}: ${error instanceof Error ? error.message : String(error)}`,
377
+ remediation: 'Check filesystem permissions on the target directory.',
378
+ docsUrl: 'https://docs.arki.dev/dot/cli#new',
379
+ metadata: { target, file: op.path },
380
+ });
381
+ }
382
+ }
383
+
384
+ const status: DotNewEnvelope['status'] = errors.length === 0 ? 'success' : 'failure';
385
+ const envelope = buildEnvelope({
386
+ name: opts.name,
387
+ target,
388
+ operations: written,
389
+ errors,
390
+ status,
391
+ generatedAt,
392
+ });
393
+
394
+ if (opts.json) {
395
+ out(`${JSON.stringify(envelope, null, 2)}\n`);
396
+ } else if (status === 'success') {
397
+ out(`Scaffolded ${written.length} file(s) under ${path.relative(opts.cwd ?? process.cwd(), target) || '.'}.\n`);
398
+ out(`Next steps:\n`);
399
+ out(` cd ${path.relative(opts.cwd ?? process.cwd(), target) || '.'}\n`);
400
+ out(` ${opts.pm ?? DEFAULT_PM} install\n`);
401
+ out(` ${opts.pm ?? DEFAULT_PM} run test\n`);
402
+ } else {
403
+ err(`dot: scaffold completed with ${errors.length} error(s).\n`);
404
+ for (const issue of errors) {
405
+ err(` ${issue.code} ${issue.message}\n`);
406
+ }
407
+ }
408
+ return envelope;
409
+ }
410
+
411
+ function toIssue(error: DotCliError): DiagnosticIssue {
412
+ return {
413
+ code: error.code,
414
+ severity: 'error',
415
+ message: error.message,
416
+ remediation: error.remediation,
417
+ docsUrl: error.docsUrl,
418
+ metadata: error.metadata,
419
+ };
420
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `dot doctor --observability` probe.
3
+ *
4
+ * Detects whether an OpenTelemetry SDK is registered in the current
5
+ * process. The check is small and uses only the public OTel API:
6
+ *
7
+ * 1. Start a probe span via the kernel's tracer name.
8
+ * 2. Read the span context's `traceId`.
9
+ * 3. End the span.
10
+ *
11
+ * When no SDK is registered, the OTel API returns a `NonRecordingSpan`
12
+ * whose context has the all-zero trace ID sentinel
13
+ * (`'00000000000000000000000000000000'`) and `traceFlags = 0`. A
14
+ * registered SDK produces a real, non-zero trace ID.
15
+ *
16
+ * If no SDK is detected, the probe returns a `DiagnosticIssue` with
17
+ * severity `warning` — lack of an SDK is a deployment choice, not a
18
+ * bug. The remediation points at the docs section that explains how
19
+ * to wire one in.
20
+ */
21
+
22
+ import { trace } from '@opentelemetry/api';
23
+
24
+ import type { DiagnosticIssue } from '../diagnostics.js';
25
+ import { DotCliErrorCode, dotCliDocsUrl } from './error-codes.js';
26
+
27
+ /** OTel API sentinel returned by `NonRecordingSpan` when no SDK is registered. */
28
+ const ZERO_TRACE_ID = '0'.repeat(32);
29
+
30
+ /**
31
+ * Run the probe.
32
+ *
33
+ * @returns `null` when an SDK is registered; a `DiagnosticIssue` when not.
34
+ */
35
+ export function probeObservability(): DiagnosticIssue | null {
36
+ const probe = trace.getTracer('@arki/dot').startSpan('dot.cli.observability-probe');
37
+ const traceId = probe.spanContext().traceId;
38
+ probe.end();
39
+
40
+ if (traceId !== ZERO_TRACE_ID) return null;
41
+
42
+ return {
43
+ code: DotCliErrorCode.ObservabilityNoSdk,
44
+ severity: 'warning',
45
+ message:
46
+ 'No OpenTelemetry SDK is registered. Lifecycle traces and metrics will not be exported.',
47
+ remediation:
48
+ 'Register an OTel SDK (e.g. @opentelemetry/sdk-node with AsyncLocalStorageContextManager) once at app entry, before any DotApp boots. See the docs for a minimal example.',
49
+ docsUrl: dotCliDocsUrl(DotCliErrorCode.ObservabilityNoSdk),
50
+ };
51
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Renderers for `dot doctor`.
3
+ *
4
+ * Reads a booted (or fully-failed) app's `diagnostics` snapshot and emits
5
+ * either a JSON envelope or a human-readable per-pip status report.
6
+ *
7
+ * Envelope `status` reflects the worst severity present:
8
+ * - `failure` if any issue has severity `error`
9
+ * - `warning` if any issue has severity `warning` (and no errors)
10
+ * - `success` otherwise
11
+ */
12
+
13
+ import type { DiagnosticIssue, DiagnosticStatus, DotDiagnosticsSnapshot } from '../diagnostics.js';
14
+ import type { DotCliEnvelope, DotCliEnvelopeStatus, RenderOptions } from './render-explain.js';
15
+
16
+ export type DoctorSource = {
17
+ diagnostics: DotDiagnosticsSnapshot;
18
+ };
19
+
20
+ const defaultOut = (line: string) => {
21
+ process.stdout.write(line);
22
+ };
23
+
24
+ function nowIso(opts: RenderOptions): string {
25
+ const factory = opts.now ?? (() => new Date());
26
+ return factory().toISOString();
27
+ }
28
+
29
+ /**
30
+ * Walk every issue carried anywhere in the snapshot (top-level + per-pip +
31
+ * per-route + per-service + per-lifecycle entry) and pick the worst severity.
32
+ */
33
+ function worstSeverity(snap: DotDiagnosticsSnapshot): DotCliEnvelopeStatus {
34
+ let hasWarning = false;
35
+
36
+ const collect = (issues: readonly DiagnosticIssue[]): void => {
37
+ for (const issue of issues) {
38
+ if (issue.severity === 'error') {
39
+ // Short-circuit through a thrown sentinel is overkill; track a flag.
40
+ hasError = true;
41
+ } else if (issue.severity === 'warning') {
42
+ hasWarning = true;
43
+ }
44
+ }
45
+ };
46
+
47
+ let hasError = false;
48
+ collect(snap.issues);
49
+ for (const p of snap.pips) collect(p.issues);
50
+ for (const r of snap.routes) collect(r.issues);
51
+ for (const s of snap.services) collect(s.issues);
52
+ for (const l of snap.lifecycle) collect(l.issues);
53
+
54
+ if (hasError) return 'failure';
55
+ if (hasWarning) return 'warning';
56
+ return 'success';
57
+ }
58
+
59
+ /**
60
+ * Compose the JSON envelope. Pure — no IO. Useful for tests and embedding.
61
+ */
62
+ export function buildDoctorEnvelope(
63
+ source: DoctorSource,
64
+ opts: RenderOptions,
65
+ ): DotCliEnvelope<DotDiagnosticsSnapshot> {
66
+ const status = worstSeverity(source.diagnostics);
67
+
68
+ // Top-level `errors` is the flat list of every issue, so agents can fail
69
+ // fast without walking the nested arrays.
70
+ const errors: DiagnosticIssue[] = [];
71
+ const collect = (issues: readonly DiagnosticIssue[]): void => {
72
+ for (const issue of issues) errors.push(issue);
73
+ };
74
+ collect(source.diagnostics.issues);
75
+ for (const p of source.diagnostics.pips) collect(p.issues);
76
+ for (const r of source.diagnostics.routes) collect(r.issues);
77
+ for (const s of source.diagnostics.services) collect(s.issues);
78
+ for (const l of source.diagnostics.lifecycle) collect(l.issues);
79
+
80
+ return {
81
+ status,
82
+ command: 'doctor',
83
+ generatedAt: nowIso(opts),
84
+ data: source.diagnostics,
85
+ errors,
86
+ };
87
+ }
88
+
89
+ function statusIcon(status: DiagnosticStatus): string {
90
+ switch (status) {
91
+ case 'ok':
92
+ return '[OK]';
93
+ case 'degraded':
94
+ return '[WARN]';
95
+ case 'failed':
96
+ return '[FAIL]';
97
+ case 'skipped':
98
+ return '[SKIP]';
99
+ case 'missing':
100
+ return '[MISSING]';
101
+ }
102
+ }
103
+
104
+ function renderIssueLines(issues: readonly DiagnosticIssue[], indent: string): string[] {
105
+ const lines: string[] = [];
106
+ for (const issue of issues) {
107
+ const sev = issue.severity.toUpperCase();
108
+ lines.push(`${indent}- [${sev}] ${issue.code}: ${issue.message}`);
109
+ lines.push(`${indent} remediation: ${issue.remediation}`);
110
+ lines.push(`${indent} docs: ${issue.docsUrl}`);
111
+ }
112
+ return lines;
113
+ }
114
+
115
+ function renderTextDoctor(snap: DotDiagnosticsSnapshot, status: DotCliEnvelopeStatus): string {
116
+ const lines: string[] = [];
117
+ const title = `Doctor: ${snap.app.name} (state=${snap.app.state})`;
118
+ lines.push(title);
119
+ lines.push('='.repeat(title.length));
120
+ lines.push(`generatedAt: ${snap.generatedAt}`);
121
+ lines.push('');
122
+
123
+ // Per-pip status
124
+ lines.push(`Pips (${snap.pips.length})`);
125
+ if (snap.pips.length === 0) {
126
+ lines.push(' (none)');
127
+ } else {
128
+ for (const p of snap.pips) {
129
+ lines.push(` ${statusIcon(p.status)} ${p.pip}`);
130
+ if (p.issues.length > 0) {
131
+ lines.push(...renderIssueLines(p.issues, ' '));
132
+ }
133
+ }
134
+ }
135
+ lines.push('');
136
+
137
+ // Services
138
+ if (snap.services.length > 0) {
139
+ lines.push(`Services (${snap.services.length})`);
140
+ for (const s of snap.services) {
141
+ lines.push(` ${statusIcon(s.status)} ${s.service} (pip: ${s.pip})`);
142
+ if (s.issues.length > 0) {
143
+ lines.push(...renderIssueLines(s.issues, ' '));
144
+ }
145
+ }
146
+ lines.push('');
147
+ }
148
+
149
+ // Routes
150
+ if (snap.routes.length > 0) {
151
+ lines.push(`Routes (${snap.routes.length})`);
152
+ for (const r of snap.routes) {
153
+ lines.push(` ${statusIcon(r.status)} ${r.id} (pip: ${r.pip})`);
154
+ if (r.issues.length > 0) {
155
+ lines.push(...renderIssueLines(r.issues, ' '));
156
+ }
157
+ }
158
+ lines.push('');
159
+ }
160
+
161
+ // Lifecycle issues
162
+ const lifeWithIssues = snap.lifecycle.filter(l => l.issues.length > 0);
163
+ if (lifeWithIssues.length > 0) {
164
+ lines.push(`Lifecycle issues (${lifeWithIssues.length})`);
165
+ for (const l of lifeWithIssues) {
166
+ lines.push(` ${l.pip}:${l.hook} (order=${l.order}, state=${l.state})`);
167
+ lines.push(...renderIssueLines(l.issues, ' '));
168
+ }
169
+ lines.push('');
170
+ }
171
+
172
+ // Top-level issues (kernel-level / cross-cutting)
173
+ if (snap.issues.length > 0) {
174
+ lines.push(`App-level issues (${snap.issues.length})`);
175
+ lines.push(...renderIssueLines(snap.issues, ' '));
176
+ lines.push('');
177
+ }
178
+
179
+ // Summary
180
+ lines.push(`Summary: ${status.toUpperCase()}`);
181
+ return lines.join('\n');
182
+ }
183
+
184
+ /**
185
+ * Render the doctor output. Returns the envelope so callers can act on it
186
+ * (e.g. set the process exit code based on `status`).
187
+ */
188
+ export function renderDoctor(source: DoctorSource, opts: RenderOptions): DotCliEnvelope<DotDiagnosticsSnapshot> {
189
+ const envelope = buildDoctorEnvelope(source, opts);
190
+ const out = opts.out ?? defaultOut;
191
+
192
+ if (opts.json) {
193
+ out(`${JSON.stringify(envelope, null, 2)}\n`);
194
+ } else {
195
+ out(`${renderTextDoctor(source.diagnostics, envelope.status)}\n`);
196
+ }
197
+
198
+ return envelope;
199
+ }