@arki/dot 0.1.1 → 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.
@@ -0,0 +1,539 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `dot` CLI entry point.
5
+ *
6
+ * Two commands are wired today (more land in v1.1):
7
+ * - `dot explain` — print the static app graph (manifest)
8
+ * - `dot doctor` — boot the app and print runtime diagnostics
9
+ *
10
+ * Common options:
11
+ * --json Emit JSON envelope instead of human-readable text
12
+ * --app <p> Path to the app file (overrides auto-discovery)
13
+ * --cwd <p> Working directory (default: process.cwd())
14
+ * --help Show help
15
+ * --version Print version
16
+ *
17
+ * Exit codes: `0` on success/warning envelopes (doctor warnings still mean the
18
+ * app booted), `1` on `failure` envelopes and on every structured CLI error.
19
+ */
20
+
21
+ import { parseArgs as nodeParseArgs } from 'node:util';
22
+
23
+ import { createDebugLogger } from '@arki/log/debug';
24
+
25
+ import type { DotApp, DotAppBuilder, DotAppConfigured } from '../define-app.js';
26
+ import type { DiagnosticIssue } from '../diagnostics.js';
27
+ import type { DiscoveredApp } from './discover.js';
28
+ import type { DotCliEnvelope, DotCliEnvelopeStatus } from './render-explain.js';
29
+ import { DotLifecycleError } from '../lifecycle.js';
30
+ import { discoverApp, guards } from './discover.js';
31
+ import { DotCliError, DotCliErrorCode, dotCliDocsUrl } from './error-codes.js';
32
+ import { runNew } from './new.js';
33
+ import { probeObservability } from './observability-probe.js';
34
+ import { renderDoctor } from './render-doctor.js';
35
+ import { renderExplain } from './render-explain.js';
36
+
37
+ const debugCli = createDebugLogger('arki:dot:cli');
38
+
39
+ const VERSION = '0.1.0';
40
+
41
+ const HELP_TEXT = `dot — CLI for inspecting and scaffolding DOT apps
42
+
43
+ Usage:
44
+ dot <command> [options]
45
+
46
+ Commands:
47
+ explain Print the static app graph (manifest)
48
+ doctor Boot the app and print runtime diagnostics
49
+ new <app-name> Scaffold a minimal DOT app
50
+
51
+ Common options:
52
+ --json Emit a JSON envelope to stdout (default: text)
53
+ --help Show this help and exit
54
+ --version Print version and exit
55
+
56
+ \`explain\` / \`doctor\` options:
57
+ --app <path> Path to the app entry file (default: discovers
58
+ ./dot.config.ts, ./src/app.ts, or ./app.ts)
59
+ --cwd <dir> Working directory (default: current)
60
+
61
+ \`doctor\` options:
62
+ --observability Also probe whether an OpenTelemetry SDK is
63
+ registered. Surfaces a warning issue when not,
64
+ pointing at the docs to wire one in.
65
+
66
+ \`new\` options:
67
+ --target <dir> Directory to create the app in (default: <app-name>)
68
+ --pm <npm|pnpm|bun> Package manager hint for the README (default: bun)
69
+ --dry-run Print planned file operations without writing
70
+ --force Overwrite existing files in the target directory
71
+ `;
72
+
73
+ export type CliCommand = 'explain' | 'doctor' | 'new' | 'help' | 'version';
74
+
75
+ export type CliArgs = {
76
+ command: CliCommand;
77
+ json: boolean;
78
+ appPath?: string;
79
+ cwd?: string;
80
+ /** Positional after the command (used by `new` for the app name). */
81
+ positional?: string;
82
+ /** `--target` (only honored by `new`). */
83
+ target?: string;
84
+ /** `--pm` (only honored by `new`). */
85
+ pm?: 'npm' | 'pnpm' | 'bun';
86
+ /** `--dry-run` (only honored by `new`). */
87
+ dryRun?: boolean;
88
+ /** `--force` (only honored by `new`). */
89
+ force?: boolean;
90
+ /** `--observability` (only honored by `doctor`). */
91
+ observability?: boolean;
92
+ };
93
+
94
+ /**
95
+ * Parse argv into a typed shape. Exported so tests can exercise it without
96
+ * spawning the binary.
97
+ */
98
+ export function parseArgs(argv: readonly string[]): CliArgs {
99
+ // Extract the (optional) leading positional command; node:util parseArgs
100
+ // wants every positional to come *after* flags by default, but we want
101
+ // `dot explain --json` to parse correctly.
102
+ const rest = [...argv];
103
+ let command: CliArgs['command'] | null = null;
104
+
105
+ // Pull off the first positional that doesn't look like a flag.
106
+ for (let i = 0; i < rest.length; i++) {
107
+ const tok = rest[i]!;
108
+ if (!tok.startsWith('-')) {
109
+ const cmd = tok;
110
+ if (cmd === 'explain' || cmd === 'doctor' || cmd === 'new' || cmd === 'help' || cmd === 'version') {
111
+ command = cmd;
112
+ rest.splice(i, 1);
113
+ break;
114
+ }
115
+ throw new DotCliError({
116
+ code: DotCliErrorCode.UnknownCommand,
117
+ message: `Unknown command: ${cmd}`,
118
+ remediation: 'Use one of: explain, doctor, new. Run `dot --help` for usage.',
119
+ metadata: { received: cmd },
120
+ });
121
+ }
122
+ }
123
+
124
+ // `new` takes a positional <app-name> after the command. Pull the next
125
+ // non-flag token out so node:util parseArgs doesn't see it.
126
+ let positional: string | undefined;
127
+ if (command === 'new') {
128
+ for (let i = 0; i < rest.length; i++) {
129
+ const tok = rest[i]!;
130
+ if (!tok.startsWith('-')) {
131
+ positional = tok;
132
+ rest.splice(i, 1);
133
+ break;
134
+ }
135
+ }
136
+ }
137
+
138
+ let parsed: ReturnType<typeof nodeParseArgs>;
139
+ try {
140
+ parsed = nodeParseArgs({
141
+ args: rest,
142
+ strict: true,
143
+ allowPositionals: false,
144
+ options: {
145
+ json: { type: 'boolean', default: false },
146
+ app: { type: 'string' },
147
+ cwd: { type: 'string' },
148
+ help: { type: 'boolean', default: false },
149
+ version: { type: 'boolean', default: false },
150
+ target: { type: 'string' },
151
+ pm: { type: 'string' },
152
+ 'dry-run': { type: 'boolean', default: false },
153
+ force: { type: 'boolean', default: false },
154
+ observability: { type: 'boolean', default: false },
155
+ },
156
+ });
157
+ } catch (err) {
158
+ throw new DotCliError({
159
+ code: DotCliErrorCode.InvalidArgs,
160
+ message: `Invalid CLI arguments: ${err instanceof Error ? err.message : String(err)}`,
161
+ remediation: 'Run `dot --help` to see supported options.',
162
+ cause: err,
163
+ });
164
+ }
165
+
166
+ const values = parsed.values as {
167
+ json?: boolean;
168
+ app?: string;
169
+ cwd?: string;
170
+ help?: boolean;
171
+ version?: boolean;
172
+ target?: string;
173
+ pm?: string;
174
+ 'dry-run'?: boolean;
175
+ force?: boolean;
176
+ observability?: boolean;
177
+ };
178
+
179
+ if (values.help) command = 'help';
180
+ if (values.version) command = 'version';
181
+
182
+ if (!command) command = 'help';
183
+
184
+ let pm: CliArgs['pm'];
185
+ if (values.pm !== undefined) {
186
+ if (values.pm !== 'npm' && values.pm !== 'pnpm' && values.pm !== 'bun') {
187
+ throw new DotCliError({
188
+ code: DotCliErrorCode.InvalidArgs,
189
+ message: `Invalid --pm value: ${values.pm}`,
190
+ remediation: 'Use one of: npm, pnpm, bun.',
191
+ metadata: { received: values.pm },
192
+ });
193
+ }
194
+ pm = values.pm;
195
+ }
196
+
197
+ return {
198
+ command,
199
+ json: values.json ?? false,
200
+ appPath: values.app,
201
+ cwd: values.cwd,
202
+ positional,
203
+ target: values.target,
204
+ pm,
205
+ dryRun: values['dry-run'] ?? false,
206
+ force: values.force ?? false,
207
+ observability: values.observability ?? false,
208
+ };
209
+ }
210
+
211
+ /** Discovery wrapper — returns `null` for help/version commands. */
212
+ async function loadApp(args: CliArgs): Promise<DiscoveredApp> {
213
+ const { app } = await discoverApp({ appPath: args.appPath, cwd: args.cwd });
214
+ return app;
215
+ }
216
+
217
+ /**
218
+ * Run `explain` on a discovered app.
219
+ * Pure dependency on a `DiscoveredApp` so tests can pass synthetic values.
220
+ */
221
+ export async function runExplain(
222
+ discovered: DiscoveredApp,
223
+ opts: { json: boolean; out?: (line: string) => void; now?: () => Date },
224
+ ): Promise<DotCliEnvelope<unknown>> {
225
+ let configured: DotApp<Record<string, unknown>> | DotAppConfigured<Record<string, unknown>>;
226
+ try {
227
+ if (guards.isDotAppBuilder(discovered)) {
228
+ // explain never boots — just configure.
229
+ configured = (discovered as DotAppBuilder<Record<string, unknown>>).configure();
230
+ } else {
231
+ configured = discovered as
232
+ | DotApp<Record<string, unknown>>
233
+ | DotAppConfigured<Record<string, unknown>>;
234
+ }
235
+ } catch (err) {
236
+ throw wrapLifecycleError(err, 'configure');
237
+ }
238
+
239
+ return renderExplain({ manifest: configured.manifest }, { json: opts.json, out: opts.out, now: opts.now });
240
+ }
241
+
242
+ type DoctorRunOptions = {
243
+ json: boolean;
244
+ out?: (line: string) => void;
245
+ now?: () => Date;
246
+ /**
247
+ * When `true`, probes for a registered OpenTelemetry SDK and injects
248
+ * a warning-severity issue into the diagnostics envelope when none is
249
+ * present. Default `false`.
250
+ */
251
+ observability?: boolean;
252
+ };
253
+
254
+ /**
255
+ * Run `doctor` on a discovered app. The CLI owns boot+dispose only when it
256
+ * receives a builder. If the caller passed an already-booted app, we leave
257
+ * lifecycle to them.
258
+ *
259
+ * If `boot()` throws, doctor's job is still to surface diagnostics — so we
260
+ * pre-configure the builder, then re-read the configured seam's diagnostics
261
+ * even after a boot throw. This is the whole point of `doctor`: failure
262
+ * should be observable, not opaque.
263
+ */
264
+ export async function runDoctor(
265
+ discovered: DiscoveredApp,
266
+ opts: DoctorRunOptions,
267
+ ): Promise<DotCliEnvelope<unknown>> {
268
+ // Already-booted app: just read diagnostics, don't touch lifecycle.
269
+ if (!guards.isDotAppBuilder(discovered) && !guards.isDotAppConfigured(discovered)) {
270
+ const app = discovered as DotApp<Record<string, unknown>>;
271
+ const diagnostics = applyObservabilityProbe(app.diagnostics, opts.observability ?? false);
272
+ return renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
273
+ }
274
+
275
+ // Builder or configured seam: drive lifecycle ourselves. Boot failures fall
276
+ // back to the configured seam's diagnostics so per-pip issues stay
277
+ // visible.
278
+ let configured: DotAppConfigured<Record<string, unknown>>;
279
+ try {
280
+ configured = guards.isDotAppBuilder(discovered)
281
+ ? (discovered as DotAppBuilder<Record<string, unknown>>).configure()
282
+ : (discovered as DotAppConfigured<Record<string, unknown>>);
283
+ } catch (err) {
284
+ throw wrapLifecycleError(err, 'configure');
285
+ }
286
+
287
+ let bootedApp: DotApp<Record<string, unknown>> | null = null;
288
+ let bootThrew = false;
289
+ try {
290
+ bootedApp = await configured.boot();
291
+ } catch (err) {
292
+ // Boot failed — surface diagnostics from the configured seam below.
293
+ // We deliberately swallow the throw so the user sees the per-pip
294
+ // issues instead of an opaque wrapper error.
295
+ bootThrew = true;
296
+ debugCli('boot threw, falling back to configured diagnostics: %O', err);
297
+ }
298
+
299
+ try {
300
+ const rawDiagnostics = bootedApp ? bootedApp.diagnostics : configured.diagnostics;
301
+ const diagnostics = applyObservabilityProbe(rawDiagnostics, opts.observability ?? false);
302
+ const envelope = renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
303
+ // Sanity: if boot threw, the envelope SHOULD be failure already (kernel
304
+ // populates issues on boot failure). If somehow it isn't, downgrade
305
+ // status to surface the failure.
306
+ if (bootThrew && envelope.status === 'success') {
307
+ return { ...envelope, status: 'failure' };
308
+ }
309
+ return envelope;
310
+ } finally {
311
+ if (bootedApp) {
312
+ try {
313
+ await bootedApp.dispose();
314
+ } catch (err) {
315
+ debugCli('dispose threw: %O', err);
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Run the observability probe and fold its issue (if any) into the
323
+ * top-level `issues` array. Returns a new snapshot — never mutates the
324
+ * input.
325
+ */
326
+ function applyObservabilityProbe<T extends { issues: readonly { code: string }[] }>(
327
+ diagnostics: T,
328
+ enabled: boolean,
329
+ ): T {
330
+ if (!enabled) return diagnostics;
331
+ const probeIssue = probeObservability();
332
+ if (!probeIssue) return diagnostics;
333
+ return { ...diagnostics, issues: [...diagnostics.issues, probeIssue] };
334
+ }
335
+
336
+ function wrapLifecycleError(err: unknown, phase: 'configure' | 'boot'): DotCliError {
337
+ if (err instanceof DotCliError) return err;
338
+
339
+ const isLifecycleError = err instanceof DotLifecycleError;
340
+ const message = err instanceof Error ? err.message : String(err);
341
+ const code = isLifecycleError ? err.code : 'UNKNOWN';
342
+
343
+ return new DotCliError({
344
+ code: DotCliErrorCode.AppLifecycleFailed,
345
+ message: `App ${phase} failed: ${message}`,
346
+ remediation:
347
+ phase === 'configure'
348
+ ? 'Check the pips registered in your app for a synchronous `configure` hook that throws or returns a Promise. Run `dot doctor` for per-pip diagnostics.'
349
+ : 'Run `dot doctor` to see per-pip diagnostics. The boot hook for one of your pips failed.',
350
+ metadata: { phase, underlyingCode: code },
351
+ cause: err,
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Convert a `DotCliError` into a synthetic envelope so JSON consumers see a
357
+ * uniform shape regardless of failure mode.
358
+ */
359
+ function errorEnvelope(err: DotCliError, command: string, now: () => Date): DotCliEnvelope<null> {
360
+ const issue: DiagnosticIssue = {
361
+ code: err.code,
362
+ severity: 'error',
363
+ message: err.message,
364
+ remediation: err.remediation,
365
+ docsUrl: err.docsUrl,
366
+ metadata: err.metadata,
367
+ };
368
+ return {
369
+ status: 'failure',
370
+ command,
371
+ generatedAt: now().toISOString(),
372
+ data: null,
373
+ errors: [issue],
374
+ };
375
+ }
376
+
377
+ type MainOptions = {
378
+ argv: readonly string[];
379
+ stdout?: (line: string) => void;
380
+ stderr?: (line: string) => void;
381
+ now?: () => Date;
382
+ };
383
+
384
+ /**
385
+ * Run the CLI. Returns the exit code so tests can assert without calling
386
+ * `process.exit`. Real entry point at the bottom of the file calls `exit()`.
387
+ */
388
+ export async function main(options: MainOptions): Promise<number> {
389
+ const stdout = options.stdout ?? ((line: string) => process.stdout.write(line));
390
+ const stderr = options.stderr ?? ((line: string) => process.stderr.write(line));
391
+ const nowFactory = options.now ?? (() => new Date());
392
+
393
+ let args: CliArgs;
394
+ try {
395
+ args = parseArgs(options.argv);
396
+ } catch (err) {
397
+ if (err instanceof DotCliError) {
398
+ const envelope = errorEnvelope(err, 'unknown', nowFactory);
399
+ stderr(`${JSON.stringify(envelope, null, 2)}\n`);
400
+ return 1;
401
+ }
402
+ throw err;
403
+ }
404
+
405
+ if (args.command === 'help') {
406
+ stdout(HELP_TEXT);
407
+ return 0;
408
+ }
409
+ if (args.command === 'version') {
410
+ stdout(`${VERSION}\n`);
411
+ return 0;
412
+ }
413
+
414
+ if (args.command === 'new') {
415
+ if (!args.positional) {
416
+ const err = new DotCliError({
417
+ code: DotCliErrorCode.InvalidArgs,
418
+ message: 'Missing required <app-name> positional for `dot new`.',
419
+ remediation: 'Run `dot new <app-name>`. Example: `dot new my-app`.',
420
+ });
421
+ const envelope = errorEnvelope(err, 'new', nowFactory);
422
+ if (args.json) stdout(`${JSON.stringify(envelope, null, 2)}\n`);
423
+ else stderr(formatErrorText(err));
424
+ return 1;
425
+ }
426
+ try {
427
+ const envelope = await runNew({
428
+ name: args.positional,
429
+ cwd: args.cwd,
430
+ target: args.target,
431
+ pm: args.pm,
432
+ dryRun: args.dryRun,
433
+ json: args.json,
434
+ force: args.force,
435
+ out: stdout,
436
+ err: stderr,
437
+ now: nowFactory,
438
+ });
439
+ return envelope.status === 'failure' ? 1 : 0;
440
+ } catch (err) {
441
+ if (err instanceof DotCliError) {
442
+ const envelope = errorEnvelope(err, 'new', nowFactory);
443
+ if (args.json) stdout(`${JSON.stringify(envelope, null, 2)}\n`);
444
+ else stderr(formatErrorText(err));
445
+ return 1;
446
+ }
447
+ throw err;
448
+ }
449
+ }
450
+
451
+ try {
452
+ const discovered = await loadApp(args);
453
+ const opts = { json: args.json, out: stdout, now: nowFactory };
454
+ let envelope: DotCliEnvelope<unknown>;
455
+
456
+ if (args.command === 'explain') {
457
+ envelope = await runExplain(discovered, opts);
458
+ } else {
459
+ envelope = await runDoctor(discovered, { ...opts, observability: args.observability });
460
+ }
461
+
462
+ return envelopeExitCode(envelope.status);
463
+ } catch (err) {
464
+ if (err instanceof DotCliError) {
465
+ const envelope = errorEnvelope(err, args.command, nowFactory);
466
+ if (args.json) {
467
+ stdout(`${JSON.stringify(envelope, null, 2)}\n`);
468
+ } else {
469
+ stderr(formatErrorText(err));
470
+ }
471
+ return 1;
472
+ }
473
+
474
+ // Last-resort path: an exception escaped without being wrapped. Surface
475
+ // it as a structured envelope rather than dumping a stack trace.
476
+ const fallback = new DotCliError({
477
+ code: DotCliErrorCode.AppLifecycleFailed,
478
+ message: err instanceof Error ? err.message : String(err),
479
+ remediation: 'See debug logs (DEBUG=arki:dot:cli) for context. Re-run with --json for machine-readable output.',
480
+ cause: err,
481
+ });
482
+ const envelope = errorEnvelope(fallback, args.command, nowFactory);
483
+ if (args.json) {
484
+ stdout(`${JSON.stringify(envelope, null, 2)}\n`);
485
+ } else {
486
+ stderr(formatErrorText(fallback));
487
+ }
488
+ return 1;
489
+ }
490
+ }
491
+
492
+ function envelopeExitCode(status: DotCliEnvelopeStatus): number {
493
+ return status === 'failure' ? 1 : 0;
494
+ }
495
+
496
+ function formatErrorText(err: DotCliError): string {
497
+ return [
498
+ `dot: ${err.code} ${err.message}`,
499
+ ` remediation: ${err.remediation}`,
500
+ ` docs: ${err.docsUrl}`,
501
+ '',
502
+ ].join('\n');
503
+ }
504
+
505
+ /**
506
+ * Re-exports for test consumers and adapter packages.
507
+ */
508
+ export { DotCliError, DotCliErrorCode, dotCliDocsUrl };
509
+ export type { DotCliEnvelope, DotCliEnvelopeStatus } from './render-explain.js';
510
+ export { runNew } from './new.js';
511
+ export type { DotNewEnvelope, DotNewOperation } from './json.js';
512
+
513
+ // Direct-execution guard: only spin up the CLI when this module is the entry
514
+ // point, not when it's imported (e.g. by tests).
515
+ const isMainModule = await (async () => {
516
+ try {
517
+ // Bun sets `import.meta.main` when running as the entry script.
518
+ if (typeof (import.meta as { main?: boolean }).main === 'boolean') {
519
+ return (import.meta as { main: boolean }).main;
520
+ }
521
+ } catch {
522
+ /* ignore */
523
+ }
524
+ // Node fallback: compare argv[1]'s resolved file URL with import.meta.url.
525
+ try {
526
+ const entry = process.argv[1];
527
+ if (typeof entry !== 'string') return false;
528
+ const { pathToFileURL } = await import('node:url');
529
+ return pathToFileURL(entry).href === import.meta.url;
530
+ } catch {
531
+ return false;
532
+ }
533
+ })();
534
+
535
+ if (isMainModule) {
536
+ void main({ argv: process.argv.slice(2) }).then(code => {
537
+ process.exit(code);
538
+ });
539
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * JSON envelope shapes for the `dot new` command.
3
+ *
4
+ * The envelope mirrors `DotCliEnvelope` in spirit but exposes the
5
+ * scaffold-specific `app` + `operations` payload directly at the top level
6
+ * so consumers don't have to dig under a generic `data` field.
7
+ */
8
+
9
+ import type { DiagnosticIssue } from '../diagnostics.js';
10
+ import type { FileOperation } from './files.js';
11
+
12
+ /** Subset of {@link FileOperation} emitted in the JSON envelope. */
13
+ export type DotNewOperation = {
14
+ readonly path: string;
15
+ readonly action: FileOperation['action'];
16
+ readonly contentHash: string;
17
+ readonly contentBytes: number;
18
+ readonly reason: string;
19
+ };
20
+
21
+ /**
22
+ * Envelope returned by `dot new`. Both dry-run and real runs share the
23
+ * same shape, distinguished only by `errors` and per-operation `action`.
24
+ *
25
+ * `errors` carries the same {@link DiagnosticIssue} shape used everywhere
26
+ * else in the CLI — agents already parse it.
27
+ */
28
+ export type DotNewEnvelope = {
29
+ readonly status: 'success' | 'failure';
30
+ readonly command: 'new';
31
+ readonly generatedAt: string;
32
+ readonly app: {
33
+ readonly name: string;
34
+ readonly target: string;
35
+ };
36
+ readonly operations: readonly DotNewOperation[];
37
+ readonly errors: readonly DiagnosticIssue[];
38
+ };
39
+
40
+ /** Strip the in-memory `content` field before serialising. */
41
+ export function toPublicOperation(op: FileOperation): DotNewOperation {
42
+ return {
43
+ path: op.path,
44
+ action: op.action,
45
+ contentHash: op.contentHash,
46
+ contentBytes: op.contentBytes,
47
+ reason: op.reason,
48
+ };
49
+ }