@arki/dot 0.1.3 → 0.2.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 (42) hide show
  1. package/README.md +427 -18
  2. package/dist/cli/index.d.ts +5 -0
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +21 -1
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/render-graph.d.ts +36 -0
  7. package/dist/cli/render-graph.d.ts.map +1 -0
  8. package/dist/cli/render-graph.js +70 -0
  9. package/dist/cli/render-graph.js.map +1 -0
  10. package/dist/define-app.d.ts +10 -0
  11. package/dist/define-app.d.ts.map +1 -1
  12. package/dist/define-app.js +2 -0
  13. package/dist/define-app.js.map +1 -1
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/kernel/app-instance.d.ts +2 -0
  19. package/dist/kernel/app-instance.d.ts.map +1 -1
  20. package/dist/kernel/app-instance.js +67 -17
  21. package/dist/kernel/app-instance.js.map +1 -1
  22. package/dist/lifecycle.d.ts +2 -0
  23. package/dist/lifecycle.d.ts.map +1 -1
  24. package/dist/lifecycle.js +2 -0
  25. package/dist/lifecycle.js.map +1 -1
  26. package/dist/signals.d.ts +65 -0
  27. package/dist/signals.d.ts.map +1 -0
  28. package/dist/signals.js +97 -0
  29. package/dist/signals.js.map +1 -0
  30. package/dist/test-harness.d.ts +75 -1
  31. package/dist/test-harness.d.ts.map +1 -1
  32. package/dist/test-harness.js +71 -0
  33. package/dist/test-harness.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/index.ts +36 -2
  36. package/src/cli/render-graph.ts +88 -0
  37. package/src/define-app.ts +13 -0
  38. package/src/index.ts +5 -2
  39. package/src/kernel/app-instance.ts +113 -51
  40. package/src/lifecycle.ts +2 -0
  41. package/src/signals.ts +134 -0
  42. package/src/test-harness.ts +140 -1
package/README.md CHANGED
@@ -3,26 +3,31 @@
3
3
  > TypeScript-first application composition framework for the ARKI package family.
4
4
 
5
5
  `@arki/dot` is the kernel that wires **pips**, lifecycle hooks, dependency
6
- graphs, and diagnostics into a deterministic application boot. It gives library
7
- authors a stable contract for declaring how their package participates in an
8
- app, and gives app developers a single place to wire those packages together.
6
+ injection, and diagnostics into a deterministic application boot. It gives
7
+ library authors a stable contract for declaring how their package
8
+ participates in an app, and gives app developers a single place to wire
9
+ those packages together — with the type checker verifying the wiring before
10
+ anything runs.
9
11
 
10
12
  ## What is a pip?
11
13
 
12
14
  A **pip** is the unit a DOT app is built from — one self-describing,
13
15
  lifecycle-aware piece of an application. Each pip:
14
16
 
15
- - declares a **name**, **version**, and the kinds of **services it provides**;
17
+ - declares a **name**, **version**, and the services it **needs** as a shape
18
+ of type witnesses;
19
+ - **provides** services by returning them from its `boot` hook — provides
20
+ are *inferred from the return type*, never declared separately;
16
21
  - runs a **5-hook lifecycle** — `configure` → `boot` → `start` → `stop` → `dispose`;
17
- - **publishes typed services** to a shared, type-safe registry that later pips
18
- can read from;
19
- - **composes deterministically** — pips boot in declaration order and dispose
20
- in reverse.
22
+ - **publishes typed services** to a shared, type-safe registry that later
23
+ pips can read from;
24
+ - **composes deterministically** — pips boot in declaration order and
25
+ dispose in reverse.
21
26
 
22
27
  The name comes from the small dots on dice, dominoes, and music notation:
23
- each pip is one small mark, and the *combination* of pips is what gives the app
24
- its value. Two pips on a die make a value of two; six pips make six. The pips
25
- **are** the app — not optional add-ons to a hidden core.
28
+ each pip is one small mark, and the *combination* of pips is what gives the
29
+ app its value. Two pips on a die make a value of two; six pips make six. The
30
+ pips **are** the app — not optional add-ons to a hidden core.
26
31
 
27
32
  ```ts
28
33
  import { defineApp } from '@arki/dot';
@@ -61,6 +66,19 @@ bun add @arki/dot
61
66
  ```ts
62
67
  import { defineApp, pip, service } from '@arki/dot';
63
68
 
69
+ type Db = { query(sql: string): Promise<unknown[]> };
70
+
71
+ const dbPip = pip({
72
+ name: 'database',
73
+ async boot() {
74
+ const db = await openDb(process.env.DATABASE_URL);
75
+ return { db }; // ← this IS the provides declaration
76
+ },
77
+ async dispose({ db }) {
78
+ await db.close();
79
+ },
80
+ });
81
+
64
82
  const billingPip = pip({
65
83
  name: 'billing',
66
84
  version: '1.0.0',
@@ -94,6 +112,371 @@ await app.stop();
94
112
  await app.dispose();
95
113
  ```
96
114
 
115
+ There is no registry to configure, no decorators, no reflection. The `needs`
116
+ shape is the injection contract; the `boot` return type is the provision
117
+ contract; the builder's type-level guard connects the two.
118
+
119
+ ## Dependency injection
120
+
121
+ ### Needs are witnesses, provides are inferred
122
+
123
+ `service<T>()` creates a phantom **type witness** — a runtime no-op that
124
+ carries `T` at the type level. The property name you give it is your local
125
+ alias *and* (for anonymous witnesses) the wire key:
126
+
127
+ ```ts
128
+ const search = pip({
129
+ name: 'search',
130
+ needs: {
131
+ db: service<Db>(), // wire key 'db', local alias 'db'
132
+ log: service<Logger>(), // wire key 'log'
133
+ },
134
+ async boot({ db, log, $app }) { // $app/$pip/$config: kernel context
135
+ log.info(`indexing for ${$app}`);
136
+ return { search: await buildIndex(db) };
137
+ },
138
+ });
139
+ ```
140
+
141
+ `start`, `stop`, and `dispose` additionally receive the pip's **own
142
+ provides** — and because teardown runs in reverse declaration order, a pip's
143
+ needs are still alive in its `dispose`:
144
+
145
+ ```ts
146
+ async dispose({ search, db }) { // own provide + still-live need
147
+ await search.flush(db);
148
+ },
149
+ ```
150
+
151
+ ### Wrong wiring does not compile
152
+
153
+ Declaration order IS boot order. The `.use()` guard makes unsatisfied needs,
154
+ type mismatches, and key collisions **compile errors at the call site**:
155
+
156
+ ```ts
157
+ defineApp('shop')
158
+ .use(billing) // ❌ "Expected 2 arguments, but got 1."
159
+ .use(database); // billing's `db` need has no earlier provider
160
+
161
+ defineApp('shop')
162
+ .use(database)
163
+ .use(database2); // ❌ collision: two providers of wire key 'db'
164
+ ```
165
+
166
+ Reordering the first example — or `rename`-ing one provider in the second —
167
+ makes both compile. The kernel re-validates at runtime with coded errors
168
+ (`E012` unsatisfied need, `E013` collision, `E014` reserved key) for
169
+ erased/dynamic composition, with full rollback of already-booted pips.
170
+
171
+ There is no dependency graph to debug and **cycles are unrepresentable**:
172
+ services flow strictly forward through the `.use()` chain. If two pips need
173
+ each other's services, that's a design smell the type system surfaces
174
+ immediately — merge them, or extract the shared piece into a third pip both
175
+ consume.
176
+
177
+ ### Tokens — cross-package service contracts
178
+
179
+ An anonymous `service<T>()` couples consumer and provider by property name.
180
+ When a contract spans packages, a **token** owns the wire key and the local
181
+ alias becomes free choice:
182
+
183
+ ```ts
184
+ // The package that owns the contract exports the token:
185
+ export const Db = token<NodePgDatabase<Schema>>()('arki.db');
186
+
187
+ // A provider publishes under the token's key:
188
+ const database = pip({
189
+ name: 'database',
190
+ async boot() {
191
+ return provide(Db, await connect()); // → { 'arki.db': NodePgDatabase }
192
+ },
193
+ });
194
+
195
+ // Any consumer, any package, any local alias:
196
+ const reports = pip({
197
+ name: 'reports',
198
+ needs: { warehouse: Db }, // wire key 'arki.db', alias yours
199
+ async boot({ warehouse }) { /* ... */ },
200
+ });
201
+ ```
202
+
203
+ ### Multi-instance with `rename`
204
+
205
+ Mounting an adapter twice would collide on its wire keys — loudly, at
206
+ compile time. `rename` is the multi-instance primitive, and
207
+ `Token.instance()` derives the matching contract:
208
+
209
+ ```ts
210
+ const ReportsDb = Db.instance('reports'); // token for key 'arki.db#reports'
211
+
212
+ const app = await defineApp('shop')
213
+ .use(database({ url: PRIMARY_URL }))
214
+ .use(rename(
215
+ database({ url: REPLICA_URL }),
216
+ { 'arki.db': ReportsDb.key }, // republish under the derived key
217
+ 'reports-db', // distinct pip name
218
+ ))
219
+ .use(pip({
220
+ name: 'analytics',
221
+ needs: { primary: Db, replica: ReportsDb },
222
+ async boot({ primary, replica }) { /* two live instances, both typed */ },
223
+ }))
224
+ .boot();
225
+ ```
226
+
227
+ Renames compose left-to-right and are applied at publish time — collision
228
+ checks see the final keys.
229
+
230
+ ### Lazy services
231
+
232
+ `lazy(init, { dispose })` publishes a **handle** instead of an open
233
+ resource. Initialization runs on first `get()` (memoized, single-flight;
234
+ failed attempts retry). Never-touched handles never initialize — and the
235
+ kernel auto-disposes initialized ones during teardown, *after* the
236
+ publishing pip's own `dispose` hook, even if that pip has none:
237
+
238
+ ```ts
239
+ const search = pip({
240
+ name: 'search',
241
+ async boot() {
242
+ return {
243
+ search: lazy(() => connectToElastic(), {
244
+ dispose: client => client.close(),
245
+ }),
246
+ };
247
+ },
248
+ });
249
+ ```
250
+
251
+ Consumers that shouldn't care whether a provider is eager or lazy declare a
252
+ **lifting witness** — `service.lazy<T>()` always delivers a `Lazy<T>`
253
+ handle, wrapping eager provides and passing lazy ones through by identity:
254
+
255
+ ```ts
256
+ const suggestions = pip({
257
+ name: 'suggestions',
258
+ needs: { search: service.lazy<SearchClient>() },
259
+ async start({ search }) {
260
+ const client = await search.get(); // first access initializes
261
+ /* ... */
262
+ },
263
+ });
264
+ ```
265
+
266
+ Swapping the search provider between eager and lazy is invisible to every
267
+ consumer — the wiring guard accepts both shapes against the same witness.
268
+
269
+ ### Kernel context — the reserved `$` namespace
270
+
271
+ Every service-carrying hook context includes `$app` (app name), `$pip` (pip
272
+ name), and `$config` (the `defineApp(name, { config })` bag). The `$` prefix
273
+ is enforced as reserved: `pip()` rejects `$`-prefixed needs aliases and
274
+ publish keys at compile time, and the kernel re-validates at runtime
275
+ (`DOT_LIFECYCLE_E014`) — kernel keys can never be shadowed.
276
+
277
+ ## A complex setup
278
+
279
+ A distilled commerce platform showing everything above working together.
280
+ (The package's stress-test suite boots a 28-pip version of this and asserts
281
+ exact boot/teardown ordering.)
282
+
283
+ ```ts
284
+ import { defineApp, lazy, pip, provide, rename, service, token } from '@arki/dot';
285
+
286
+ // ── contracts.ts — tokens owned by their respective packages ─────────
287
+ export const Env = token<AppEnv>()('shop.env');
288
+ export const Db = token<DbHandle>()('shop.db');
289
+ export const ReportsDb = Db.instance('reports');
290
+ export const Cache = token<CacheHandle>()('shop.cache');
291
+ export const Bus = token<MessageBus>()('shop.bus');
292
+
293
+ // ── infrastructure pips ───────────────────────────────────────────────
294
+ const env = pip({
295
+ name: 'env',
296
+ boot: ({ $config }) => provide(Env, parseEnv($config)),
297
+ });
298
+
299
+ const telemetry = pip({
300
+ name: 'telemetry',
301
+ needs: { env: Env },
302
+ boot: ({ env }) => ({ metrics: makeMetrics(env.OTEL_ENDPOINT) }),
303
+ dispose: ({ metrics }) => metrics.flush(),
304
+ });
305
+
306
+ const database = (url: string) =>
307
+ pip({
308
+ name: 'database',
309
+ needs: { metrics: service<Metrics>() },
310
+ async boot({ metrics }) {
311
+ return provide(Db, await connectPg(url, { metrics }));
312
+ },
313
+ async dispose(ctx) {
314
+ await ctx[Db.key].end(); // own provide, published under the token key
315
+ },
316
+ });
317
+
318
+ const cache = pip({
319
+ name: 'cache',
320
+ needs: { env: Env },
321
+ boot: ({ env }) => provide(Cache, connectRedis(env.REDIS_URL)),
322
+ dispose: async ctx => ctx[Cache.key].quit(),
323
+ });
324
+
325
+ const searchCluster = pip({
326
+ name: 'search-cluster',
327
+ needs: { env: Env },
328
+ boot: ({ env }) => ({
329
+ // Expensive external cluster — only opens if something get()s it.
330
+ search: lazy(() => connectElastic(env.ELASTIC_URL), {
331
+ dispose: client => client.close(),
332
+ }),
333
+ }),
334
+ });
335
+
336
+ const bus = pip({
337
+ name: 'bus',
338
+ boot: () => provide(Bus, makeInMemoryBus()),
339
+ });
340
+
341
+ // ── domain pips ───────────────────────────────────────────────────────
342
+ const catalog = pip({
343
+ name: 'catalog',
344
+ needs: {
345
+ db: Db,
346
+ cache: Cache,
347
+ search: service.lazy<SearchClient>(), // eager or lazy provider — same code
348
+ },
349
+ boot: ({ db, cache, search }) => ({
350
+ catalog: makeCatalog({ db, cache, search }),
351
+ }),
352
+ });
353
+
354
+ const checkout = pip({
355
+ name: 'checkout',
356
+ needs: { db: Db, bus: Bus, catalog: service<Catalog>() },
357
+ boot: ({ db, bus, catalog }) => ({
358
+ checkout: makeCheckout({ db, bus, catalog }),
359
+ }),
360
+ });
361
+
362
+ const reporting = pip({
363
+ name: 'reporting',
364
+ needs: { replica: ReportsDb, catalog: service<Catalog>() },
365
+ boot: ({ replica, catalog }) => ({ reporting: makeReporting(replica, catalog) }),
366
+ });
367
+
368
+ // ── process pips — real work happens in start/stop ───────────────────
369
+ const outboxWorker = pip({
370
+ name: 'outbox-worker',
371
+ needs: { db: Db, bus: Bus },
372
+ boot: ({ db, bus }) => ({ outbox: makeOutbox(db, bus) }),
373
+ start: ({ outbox }) => outbox.startPolling(),
374
+ stop: ({ outbox }) => outbox.drain(), // stop processing, keep resources
375
+ });
376
+
377
+ const http = pip({
378
+ name: 'http',
379
+ needs: {
380
+ env: Env,
381
+ catalog: service<Catalog>(),
382
+ checkout: service<Checkout>(),
383
+ reporting: service<Reporting>(),
384
+ },
385
+ async boot({ env, catalog, checkout, reporting }) {
386
+ return { server: buildServer({ port: env.PORT, catalog, checkout, reporting }) };
387
+ },
388
+ start: ({ server }) => server.listen(),
389
+ stop: ({ server }) => server.close(),
390
+ });
391
+
392
+ // ── composition — the order IS the architecture ──────────────────────
393
+ const app = await defineApp('shop', { config: process.env })
394
+ .use(env)
395
+ .use(telemetry)
396
+ .use(database(PRIMARY_URL))
397
+ .use(rename(database(REPLICA_URL), { 'shop.db': ReportsDb.key }, 'reports-db'))
398
+ .use(cache)
399
+ .use(searchCluster)
400
+ .use(bus)
401
+ .use(catalog)
402
+ .use(checkout)
403
+ .use(reporting)
404
+ .use(outboxWorker)
405
+ .use(http)
406
+ .start();
407
+
408
+ // SIGTERM → dispose() cascades stop() first, tears down in exact reverse:
409
+ // http, outbox (drained), reporting, checkout, catalog, bus,
410
+ // search cluster (only if it ever initialized), cache, reports-db,
411
+ // primary db, telemetry, env.
412
+ process.on('SIGTERM', () => void app.dispose());
413
+ ```
414
+
415
+ What the type checker is holding for you in that chain:
416
+
417
+ - move `.use(catalog)` above `.use(cache)` → compile error at that line;
418
+ - delete `.use(bus)` → compile errors at `checkout` and `outboxWorker`;
419
+ - mount the second `database(...)` without `rename` → collision error;
420
+ - change `makeCatalog` to return something that isn't a `Catalog` →
421
+ every consumer's `.use()` flags the mismatch.
422
+
423
+ Concurrent lifecycle calls are safe: transitions are serialized on one
424
+ queue, same-phase calls coalesce onto one in-flight promise, and a
425
+ `dispose()` racing a slow `start()` always runs after it — the app ends
426
+ `disposed`, never resurrected.
427
+
428
+ ## Testing pips
429
+
430
+ `testPip` is the typed unit-test builder: satisfy a pip's needs directly
431
+ with fakes — no real providers, no dependency chain — and the compiler
432
+ holds the same line it holds in production. A missing fake means `boot()`
433
+ does not compile; a fake of the wrong shape fails at the `.provide()`
434
+ call site:
435
+
436
+ ```ts
437
+ import { testPip } from '@arki/dot/test-harness';
438
+
439
+ const app = await testPip(catalog)
440
+ .provide(Db, fakeDb) // token need
441
+ .provide('cache', fakeKv) // anonymous need — wire key is the alias
442
+ .boot();
443
+
444
+ expect(app.services.catalog.list()).toEqual([]);
445
+ await app.dispose();
446
+ ```
447
+
448
+ The fakes are published by a synthetic first pip, so lifecycle semantics
449
+ are the real kernel's — reverse-order teardown, lazy auto-dispose, and
450
+ `$config` all behave exactly as in production. `service.lazy<T>()` needs
451
+ accept a plain `T` fake (lifted automatically) or `lazyOf(value)`.
452
+
453
+ For integration tests across several real pips, `testApp([...pips])`
454
+ builds an app from an erased pip array (runtime validation only), and
455
+ `bootTestApp` is the one-line boot-and-return variant.
456
+
457
+ ## Operations
458
+
459
+ Two kernel helpers close the gap between "boots on my machine" and "runs
460
+ under a process manager":
461
+
462
+ ```ts
463
+ const app = await defineApp('shop', { hookTimeoutMs: 30_000 })
464
+ .use(/* ... */)
465
+ .start();
466
+
467
+ hookSignals(app); // SIGTERM/SIGINT → stop() + dispose() → re-raise
468
+ ```
469
+
470
+ - **`hookSignals(app, { timeoutMs? })`** — graceful shutdown wiring. The
471
+ first signal drains the app (bounded by `timeoutMs`, default 10 s) and
472
+ re-raises itself so the exit status keeps standard signal semantics; a
473
+ second signal falls through to the runtime default (immediate kill).
474
+ Returns an unhook function.
475
+ - **`hookTimeoutMs`** — a per-hook watchdog. Any `boot`/`start`/`stop`/
476
+ `dispose` hook exceeding the budget fails with `DOT_LIFECYCLE_E015`
477
+ naming the pip and hook, and the kernel applies its normal rollback or
478
+ aggregation rules. Your app cannot hang silently at boot.
479
+
97
480
  ## Pip authoring
98
481
 
99
482
  `pip(config)` accepts a `needs` shape plus five lifecycle hooks. Hook
@@ -106,7 +489,7 @@ contexts carry the needed services (typed, under your local aliases) and
106
489
  | `boot` | Open connections; the returned record is what the pip provides. |
107
490
  | `start` | Begin processing (workers, subscribers, schedulers). |
108
491
  | `stop` | Stop processing in reverse declaration order. |
109
- | `dispose` | Free resources after `stop`. |
492
+ | `dispose` | Free resources after `stop`; lazy handles auto-clean afterwards. |
110
493
 
111
494
  Wiring is compile-time checked: `.use(pip)` fails to typecheck when the
112
495
  pip's needs aren't satisfied by earlier `.use()` calls, or when its
@@ -122,7 +505,7 @@ order is boot order — same input, same order, every time.
122
505
  `defineApp(name)` returns a builder. Calling `.use(pip)` accumulates
123
506
  pips. The lifecycle then flows:
124
507
 
125
- ```
508
+ ```text
126
509
  defined ──configure()──▶ configured ──boot()──▶ booted ──start()──▶ started
127
510
 
128
511
  disposed ◀──dispose()── stopped ◀──stop()───────┘
@@ -131,7 +514,20 @@ defined ──configure()──▶ configured ──boot()──▶ booted ─
131
514
  `boot()` runs `configure()` implicitly if you skipped it. `start()` runs
132
515
  `boot()` implicitly. `stop()` and `dispose()` always run in reverse
133
516
  declaration order, even when an earlier hook failed — failure isolation is
134
- part of the contract.
517
+ part of the contract. A boot failure rolls back every already-booted pip
518
+ before throwing.
519
+
520
+ Runtime failures carry stable codes (`DotLifecycleError.code`):
521
+
522
+ | Code | Meaning |
523
+ | -------------------- | ---------------------------------------------------- |
524
+ | `DOT_LIFECYCLE_E011` | Pip registered twice. |
525
+ | `DOT_LIFECYCLE_E012` | A need has no provider among earlier pips. |
526
+ | `DOT_LIFECYCLE_E013` | A published wire key collides with an earlier one. |
527
+ | `DOT_LIFECYCLE_E014` | A service key uses the reserved `$` (kernel) prefix. |
528
+ | `DOT_LIFECYCLE_E015` | A hook exceeded the `hookTimeoutMs` watchdog. |
529
+
530
+ (Full table including hook-failure codes: [docs/lifecycle.md](./docs/lifecycle.md).)
135
531
 
136
532
  ## CLI
137
533
 
@@ -151,12 +547,18 @@ dot explain --app ./my-app.ts
151
547
  # Run boot-time diagnostics; non-zero exit if any check fails.
152
548
  dot doctor --app ./my-app.ts
153
549
 
550
+ # Render the pip graph as Mermaid flowchart source. explain shows
551
+ # declaration (= boot) order; doctor boots and shows the OBSERVED wiring.
552
+ dot doctor --app ./my-app.ts --graph
553
+
154
554
  # Every command supports --json for agent-friendly output.
155
555
  dot explain --app ./my-app.ts --json | jq '.data.pips'
156
556
  ```
157
557
 
158
558
  The CLI emits the same envelope shape as the in-process diagnostics snapshot
159
- (`app.diagnostics`), so the same downstream tools can consume either.
559
+ (`app.diagnostics`), so the same downstream tools can consume either. The
560
+ manifest's dependency edges are **observed, not declared** — the kernel
561
+ records which pip's published service satisfied which need during boot.
160
562
 
161
563
  ### `dot new <app-name>`
162
564
 
@@ -177,7 +579,14 @@ ship with the published tarball.
177
579
  `@arki/dot` is intentionally small: it defines the contracts (pip shape,
178
580
  lifecycle hooks, manifest schema, diagnostics envelope) and runs them. Adapters
179
581
  that bridge databases, queues, auth providers, and HTTP routers live in their
180
- own packages and consume `@arki/dot` as a peer dependency.
582
+ own packages and consume `@arki/dot` as a peer dependency:
583
+
584
+ ```ts
585
+ import { env } from '@arki/env/dot';
586
+ import { db } from '@arki/db/dot';
587
+ import { kv } from '@arki/kv/dot';
588
+ import { eventSourcing } from '@arki/event-sourcing/dot';
589
+ ```
181
590
 
182
591
  This keeps the kernel free of optional dependencies and lets each adapter
183
592
  ship at its own cadence.
@@ -189,8 +598,8 @@ The full docs live in [`docs/`](./docs):
189
598
  - [Principles](./docs/principles.md) — **read first.** The five rules every
190
599
  API, error, and PR is measured against. Slightly playful, very precise.
191
600
  - [Quickstart](./docs/quickstart.md) — boot your first app in five minutes.
192
- - [Pip authoring](./docs/pip-authoring.md) — write your own `DotPip`.
193
- - [Lifecycle](./docs/lifecycle.md) — the 5-hook contract.
601
+ - [Pip authoring](./docs/pip-authoring.md) — write your own pip.
602
+ - [Lifecycle](./docs/lifecycle.md) — the 5-hook contract, idempotency, error codes.
194
603
  - [Diagnostics](./docs/diagnostics.md) — `app.manifest`, `app.diagnostics`,
195
604
  `dot explain`, `dot doctor`.
196
605
  - [Adapter authoring](./docs/adapter-authoring.md) — expose your package as
@@ -37,6 +37,8 @@ export type CliArgs = {
37
37
  force?: boolean;
38
38
  /** `--observability` (only honored by `doctor`). */
39
39
  observability?: boolean;
40
+ /** `--graph` (honored by `explain` and `doctor`). */
41
+ graph?: boolean;
40
42
  };
41
43
  /**
42
44
  * Parse argv into a typed shape. Exported so tests can exercise it without
@@ -49,6 +51,7 @@ export declare function parseArgs(argv: readonly string[]): CliArgs;
49
51
  */
50
52
  export declare function runExplain(discovered: DiscoveredApp, opts: {
51
53
  json: boolean;
54
+ graph?: boolean;
52
55
  out?: (line: string) => void;
53
56
  now?: () => Date;
54
57
  }): Promise<DotCliEnvelope<unknown>>;
@@ -62,6 +65,8 @@ type DoctorRunOptions = {
62
65
  * present. Default `false`.
63
66
  */
64
67
  observability?: boolean;
68
+ /** When `true`, emit the pip graph (Mermaid) instead of diagnostics. */
69
+ graph?: boolean;
65
70
  };
66
71
  /**
67
72
  * Run `doctor` on a discovered app. The CLI owns boot+dispose only when it
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAwB,MAAM,qBAAqB,CAAC;AAGhF,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AA0C/E,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,GAAG,SAAS,CAAC;AAE3E,MAAM,MAAM,OAAO,GAAG;IACpB,OAAO,EAAE,UAAU,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,EAAE,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;IAC5B,2CAA2C;IAC3C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oDAAoD;IACpD,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA+G1D;AAQD;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,UAAU,EAAE,aAAa,EACzB,IAAI,EAAE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,GACtE,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAgBlC;AAED,KAAK,gBAAgB,GAAG;IACtB,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAsB,SAAS,CAC7B,UAAU,EAAE,aAAa,EACzB,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAoDlC;AA0DD,KAAK,WAAW,GAAG;IACjB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC;AAEF;;;GAGG;AACH,wBAAsB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAsGhE;AAeD;;GAEG;AACH,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChF,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAwB,MAAM,qBAAqB,CAAC;AAGhF,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AA+C/E,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,GAAG,SAAS,CAAC;AAE3E,MAAM,MAAM,OAAO,GAAG;IACpB,OAAO,EAAE,UAAU,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,EAAE,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;IAC5B,2CAA2C;IAC3C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oDAAoD;IACpD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qDAAqD;IACrD,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAkH1D;AAQD;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,UAAU,EAAE,aAAa,EACzB,IAAI,EAAE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,GACvF,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAsBlC;AAED,KAAK,gBAAgB,GAAG;IACtB,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAsB,SAAS,CAC7B,UAAU,EAAE,aAAa,EACzB,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAoElC;AA0DD,KAAK,WAAW,GAAG;IACjB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC;AAEF;;;GAGG;AACH,wBAAsB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAsGhE;AAeD;;GAEG;AACH,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChF,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC"}
package/dist/cli/index.js CHANGED
@@ -25,6 +25,7 @@ import { runNew } from './new.js';
25
25
  import { probeObservability } from './observability-probe.js';
26
26
  import { renderDoctor } from './render-doctor.js';
27
27
  import { renderExplain } from './render-explain.js';
28
+ import { renderGraph } from './render-graph.js';
28
29
  const debugCli = createDebugLogger('arki:dot:cli');
29
30
  const VERSION = '0.1.0';
30
31
  const HELP_TEXT = `dot — CLI for inspecting and scaffolding DOT apps
@@ -46,6 +47,10 @@ Common options:
46
47
  --app <path> Path to the app entry file (default: discovers
47
48
  ./dot.config.ts, ./src/app.ts, or ./app.ts)
48
49
  --cwd <dir> Working directory (default: current)
50
+ --graph Emit the pip graph as Mermaid flowchart source
51
+ instead of the standard output. explain shows
52
+ declaration (= boot) order; doctor shows the
53
+ wiring observed during boot. Composes with --json.
49
54
 
50
55
  \`doctor\` options:
51
56
  --observability Also probe whether an OpenTelemetry SDK is
@@ -116,6 +121,7 @@ export function parseArgs(argv) {
116
121
  'dry-run': { type: 'boolean', default: false },
117
122
  force: { type: 'boolean', default: false },
118
123
  observability: { type: 'boolean', default: false },
124
+ graph: { type: 'boolean', default: false },
119
125
  },
120
126
  });
121
127
  }
@@ -157,6 +163,7 @@ export function parseArgs(argv) {
157
163
  dryRun: values['dry-run'] ?? false,
158
164
  force: values.force ?? false,
159
165
  observability: values.observability ?? false,
166
+ graph: values.graph ?? false,
160
167
  };
161
168
  }
162
169
  /** Discovery wrapper — returns `null` for help/version commands. */
@@ -182,6 +189,9 @@ export async function runExplain(discovered, opts) {
182
189
  catch (err) {
183
190
  throw wrapLifecycleError(err, 'configure');
184
191
  }
192
+ if (opts.graph === true) {
193
+ return renderGraph({ manifest: configured.manifest, command: 'explain' }, { json: opts.json, out: opts.out, now: opts.now });
194
+ }
185
195
  return renderExplain({ manifest: configured.manifest }, { json: opts.json, out: opts.out, now: opts.now });
186
196
  }
187
197
  /**
@@ -198,6 +208,9 @@ export async function runDoctor(discovered, opts) {
198
208
  // Already-booted app: just read diagnostics, don't touch lifecycle.
199
209
  if (!guards.isDotAppBuilder(discovered) && !guards.isDotAppConfigured(discovered)) {
200
210
  const app = discovered;
211
+ if (opts.graph === true) {
212
+ return renderGraph({ manifest: app.manifest, command: 'doctor' }, { json: opts.json, out: opts.out, now: opts.now });
213
+ }
201
214
  const diagnostics = applyObservabilityProbe(app.diagnostics, opts.observability ?? false);
202
215
  return renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
203
216
  }
@@ -226,6 +239,13 @@ export async function runDoctor(discovered, opts) {
226
239
  debugCli('boot threw, falling back to configured diagnostics: %O', err);
227
240
  }
228
241
  try {
242
+ if (opts.graph === true) {
243
+ // Post-boot manifest carries the observed wiring edges; after a boot
244
+ // failure it carries the edges recorded up to the failing pip.
245
+ const manifest = bootedApp ? bootedApp.manifest : configured.manifest;
246
+ const graphEnvelope = renderGraph({ manifest, command: 'doctor' }, { json: opts.json, out: opts.out, now: opts.now });
247
+ return bootThrew ? { ...graphEnvelope, status: 'failure' } : graphEnvelope;
248
+ }
229
249
  const rawDiagnostics = bootedApp ? bootedApp.diagnostics : configured.diagnostics;
230
250
  const diagnostics = applyObservabilityProbe(rawDiagnostics, opts.observability ?? false);
231
251
  const envelope = renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
@@ -369,7 +389,7 @@ export async function main(options) {
369
389
  }
370
390
  try {
371
391
  const discovered = await loadApp(args);
372
- const opts = { json: args.json, out: stdout, now: nowFactory };
392
+ const opts = { json: args.json, graph: args.graph, out: stdout, now: nowFactory };
373
393
  let envelope;
374
394
  if (args.command === 'explain') {
375
395
  envelope = await runExplain(discovered, opts);