@ontrails/trails 1.0.0-beta.13 → 1.0.0-beta.15

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 (165) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/__tests__/examples.test.ts +39 -0
  4. package/dist/src/app.d.ts.map +1 -1
  5. package/dist/src/app.js +12 -1
  6. package/dist/src/app.js.map +1 -1
  7. package/dist/src/cli.js +4 -3
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/trails/add-surface.d.ts +3 -3
  10. package/dist/src/trails/add-surface.d.ts.map +1 -1
  11. package/dist/src/trails/add-surface.js +46 -24
  12. package/dist/src/trails/add-surface.js.map +1 -1
  13. package/dist/src/trails/add-trail.d.ts +3 -1
  14. package/dist/src/trails/add-trail.d.ts.map +1 -1
  15. package/dist/src/trails/add-trail.js +49 -22
  16. package/dist/src/trails/add-trail.js.map +1 -1
  17. package/dist/src/trails/add-trailhead.d.ts +13 -0
  18. package/dist/src/trails/add-trailhead.d.ts.map +1 -0
  19. package/dist/src/trails/add-trailhead.js +88 -0
  20. package/dist/src/trails/add-trailhead.js.map +1 -0
  21. package/dist/src/trails/add-verify.d.ts +1 -1
  22. package/dist/src/trails/add-verify.d.ts.map +1 -1
  23. package/dist/src/trails/add-verify.js +17 -16
  24. package/dist/src/trails/add-verify.js.map +1 -1
  25. package/dist/src/trails/create-scaffold.d.ts +1 -1
  26. package/dist/src/trails/create-scaffold.d.ts.map +1 -1
  27. package/dist/src/trails/create-scaffold.js +34 -27
  28. package/dist/src/trails/create-scaffold.js.map +1 -1
  29. package/dist/src/trails/create.d.ts +9 -13
  30. package/dist/src/trails/create.d.ts.map +1 -1
  31. package/dist/src/trails/create.js +40 -35
  32. package/dist/src/trails/create.js.map +1 -1
  33. package/dist/src/trails/dev-clean.d.ts +9 -0
  34. package/dist/src/trails/dev-clean.d.ts.map +1 -0
  35. package/dist/src/trails/dev-clean.js +66 -0
  36. package/dist/src/trails/dev-clean.js.map +1 -0
  37. package/dist/src/trails/dev-reset.d.ts +6 -0
  38. package/dist/src/trails/dev-reset.d.ts.map +1 -0
  39. package/dist/src/trails/dev-reset.js +39 -0
  40. package/dist/src/trails/dev-reset.js.map +1 -0
  41. package/dist/src/trails/dev-stats.d.ts +7 -0
  42. package/dist/src/trails/dev-stats.d.ts.map +1 -0
  43. package/dist/src/trails/dev-stats.js +61 -0
  44. package/dist/src/trails/dev-stats.js.map +1 -0
  45. package/dist/src/trails/dev-support.d.ts +64 -0
  46. package/dist/src/trails/dev-support.d.ts.map +1 -0
  47. package/dist/src/trails/dev-support.js +181 -0
  48. package/dist/src/trails/dev-support.js.map +1 -0
  49. package/dist/src/trails/draft-promote.d.ts +18 -0
  50. package/dist/src/trails/draft-promote.d.ts.map +1 -0
  51. package/dist/src/trails/draft-promote.js +400 -0
  52. package/dist/src/trails/draft-promote.js.map +1 -0
  53. package/dist/src/trails/guide.d.ts +14 -4
  54. package/dist/src/trails/guide.d.ts.map +1 -1
  55. package/dist/src/trails/guide.js +22 -41
  56. package/dist/src/trails/guide.js.map +1 -1
  57. package/dist/src/trails/load-app.d.ts +9 -1
  58. package/dist/src/trails/load-app.d.ts.map +1 -1
  59. package/dist/src/trails/load-app.js +404 -13
  60. package/dist/src/trails/load-app.js.map +1 -1
  61. package/dist/src/trails/project.d.ts.map +1 -1
  62. package/dist/src/trails/project.js +14 -3
  63. package/dist/src/trails/project.js.map +1 -1
  64. package/dist/src/trails/survey.d.ts +6 -60
  65. package/dist/src/trails/survey.d.ts.map +1 -1
  66. package/dist/src/trails/survey.js +83 -182
  67. package/dist/src/trails/survey.js.map +1 -1
  68. package/dist/src/trails/topo-constants.d.ts +3 -0
  69. package/dist/src/trails/topo-constants.d.ts.map +1 -0
  70. package/dist/src/trails/topo-constants.js +3 -0
  71. package/dist/src/trails/topo-constants.js.map +1 -0
  72. package/dist/src/trails/topo-export.d.ts +19 -0
  73. package/dist/src/trails/topo-export.d.ts.map +1 -0
  74. package/dist/src/trails/topo-export.js +31 -0
  75. package/dist/src/trails/topo-export.js.map +1 -0
  76. package/dist/src/trails/topo-history.d.ts +20 -0
  77. package/dist/src/trails/topo-history.d.ts.map +1 -0
  78. package/dist/src/trails/topo-history.js +32 -0
  79. package/dist/src/trails/topo-history.js.map +1 -0
  80. package/dist/src/trails/topo-pin.d.ts +17 -0
  81. package/dist/src/trails/topo-pin.d.ts.map +1 -0
  82. package/dist/src/trails/topo-pin.js +31 -0
  83. package/dist/src/trails/topo-pin.js.map +1 -0
  84. package/dist/src/trails/topo-read-support.d.ts +58 -0
  85. package/dist/src/trails/topo-read-support.d.ts.map +1 -0
  86. package/dist/src/trails/topo-read-support.js +167 -0
  87. package/dist/src/trails/topo-read-support.js.map +1 -0
  88. package/dist/src/trails/topo-reports.d.ts +54 -0
  89. package/dist/src/trails/topo-reports.d.ts.map +1 -0
  90. package/dist/src/trails/topo-reports.js +128 -0
  91. package/dist/src/trails/topo-reports.js.map +1 -0
  92. package/dist/src/trails/topo-show.d.ts +23 -0
  93. package/dist/src/trails/topo-show.d.ts.map +1 -0
  94. package/dist/src/trails/topo-show.js +49 -0
  95. package/dist/src/trails/topo-show.js.map +1 -0
  96. package/dist/src/trails/topo-store-support.d.ts +13 -0
  97. package/dist/src/trails/topo-store-support.d.ts.map +1 -0
  98. package/dist/src/trails/topo-store-support.js +55 -0
  99. package/dist/src/trails/topo-store-support.js.map +1 -0
  100. package/dist/src/trails/topo-support.d.ts +76 -0
  101. package/dist/src/trails/topo-support.d.ts.map +1 -0
  102. package/dist/src/trails/topo-support.js +132 -0
  103. package/dist/src/trails/topo-support.js.map +1 -0
  104. package/dist/src/trails/topo-unpin.d.ts +20 -0
  105. package/dist/src/trails/topo-unpin.d.ts.map +1 -0
  106. package/dist/src/trails/topo-unpin.js +44 -0
  107. package/dist/src/trails/topo-unpin.js.map +1 -0
  108. package/dist/src/trails/topo-verify.d.ts +5 -0
  109. package/dist/src/trails/topo-verify.d.ts.map +1 -0
  110. package/dist/src/trails/topo-verify.js +24 -0
  111. package/dist/src/trails/topo-verify.js.map +1 -0
  112. package/dist/src/trails/topo.d.ts +5 -0
  113. package/dist/src/trails/topo.d.ts.map +1 -0
  114. package/dist/src/trails/topo.js +63 -0
  115. package/dist/src/trails/topo.js.map +1 -0
  116. package/dist/src/trails/warden.d.ts +3 -2
  117. package/dist/src/trails/warden.d.ts.map +1 -1
  118. package/dist/src/trails/warden.js +37 -27
  119. package/dist/src/trails/warden.js.map +1 -1
  120. package/dist/src/versions.d.ts +12 -0
  121. package/dist/src/versions.d.ts.map +1 -0
  122. package/dist/src/versions.js +23 -0
  123. package/dist/src/versions.js.map +1 -0
  124. package/dist/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +8 -7
  126. package/src/__tests__/add-trail.test.ts +97 -0
  127. package/src/__tests__/create.test.ts +91 -27
  128. package/src/__tests__/draft-promote.test.ts +144 -0
  129. package/src/__tests__/guide.test.ts +10 -5
  130. package/src/__tests__/load-app.test.ts +406 -2
  131. package/src/__tests__/survey.test.ts +221 -60
  132. package/src/__tests__/topo-dev.test.ts +426 -0
  133. package/src/app.ts +24 -2
  134. package/src/clack.ts +1 -1
  135. package/src/cli.ts +4 -3
  136. package/src/trails/add-surface.ts +150 -0
  137. package/src/trails/add-trail.ts +46 -10
  138. package/src/trails/add-verify.ts +11 -6
  139. package/src/trails/create-scaffold.ts +16 -3
  140. package/src/trails/create.ts +76 -71
  141. package/src/trails/dev-clean.ts +77 -0
  142. package/src/trails/dev-reset.ts +45 -0
  143. package/src/trails/dev-stats.ts +67 -0
  144. package/src/trails/dev-support.ts +328 -0
  145. package/src/trails/draft-promote.ts +739 -0
  146. package/src/trails/guide.ts +23 -41
  147. package/src/trails/load-app.ts +556 -14
  148. package/src/trails/project.ts +17 -3
  149. package/src/trails/survey.ts +110 -285
  150. package/src/trails/topo-constants.ts +2 -0
  151. package/src/trails/topo-export.ts +35 -0
  152. package/src/trails/topo-history.ts +38 -0
  153. package/src/trails/topo-pin.ts +38 -0
  154. package/src/trails/topo-read-support.ts +329 -0
  155. package/src/trails/topo-reports.ts +228 -0
  156. package/src/trails/topo-show.ts +54 -0
  157. package/src/trails/topo-store-support.ts +104 -0
  158. package/src/trails/topo-support.ts +230 -0
  159. package/src/trails/topo-unpin.ts +56 -0
  160. package/src/trails/topo-verify.ts +25 -0
  161. package/src/trails/topo.ts +69 -0
  162. package/src/trails/warden.ts +13 -3
  163. package/src/versions.ts +43 -0
  164. package/tsconfig.tests.json +10 -0
  165. package/src/trails/add-trailhead.ts +0 -121
@@ -0,0 +1,426 @@
1
+ /* oxlint-disable max-statements */
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+
13
+ import type { Result } from '@ontrails/core';
14
+ import { openReadTrailsDb } from '@ontrails/core/internal/trails-db';
15
+ import { createDevStore } from '@ontrails/tracing';
16
+
17
+ import { devCleanTrail } from '../trails/dev-clean.js';
18
+ import { devResetTrail } from '../trails/dev-reset.js';
19
+ import { devStatsTrail } from '../trails/dev-stats.js';
20
+ import { guideTrail } from '../trails/guide.js';
21
+ import { surveyTrail } from '../trails/survey.js';
22
+ import { topoExportTrail } from '../trails/topo-export.js';
23
+ import { topoHistoryTrail } from '../trails/topo-history.js';
24
+ import { topoPinTrail } from '../trails/topo-pin.js';
25
+ import { topoShowTrail } from '../trails/topo-show.js';
26
+ import { topoTrail } from '../trails/topo.js';
27
+ import { topoUnpinTrail } from '../trails/topo-unpin.js';
28
+ import { topoVerifyTrail } from '../trails/topo-verify.js';
29
+
30
+ const repoTempDir = (): string =>
31
+ join(
32
+ resolve(import.meta.dir, '../..'),
33
+ '.tmp-tests',
34
+ `topo-dev-${Date.now()}-${Math.random().toString(36).slice(2)}`
35
+ );
36
+
37
+ const expectOk = <T>(result: Result<T, Error>): T => {
38
+ if (result.isErr()) {
39
+ throw result.error;
40
+ }
41
+ return result.value;
42
+ };
43
+
44
+ const expectErr = <E extends Error>(result: Result<unknown, E>): E => {
45
+ if (result.isOk()) {
46
+ throw new Error('expected result to be an error');
47
+ }
48
+ return result.error;
49
+ };
50
+
51
+ const moduleInput = { module: './src/app.ts' } as const;
52
+
53
+ const writeAppFixture = (dir: string): void => {
54
+ mkdirSync(join(dir, 'src'), { recursive: true });
55
+ writeFileSync(
56
+ join(dir, 'src', 'app.ts'),
57
+ `import { Result, resource, topo, trail } from '@ontrails/core';
58
+ import { z } from 'zod';
59
+
60
+ const hello = trail('hello', {
61
+ blaze: async (input) => Result.ok({ message: \`Hello, \${input.name ?? 'world'}!\` }),
62
+ crosses: ['goodbye'],
63
+ examples: [{ input: {}, name: 'Default greeting' }],
64
+ input: z.object({ name: z.string().optional() }),
65
+ intent: 'read',
66
+ output: z.object({ message: z.string() }),
67
+ resources: [
68
+ resource('db.main', {
69
+ create: () => Result.ok({ source: 'factory' }),
70
+ }),
71
+ ],
72
+ });
73
+
74
+ const goodbye = trail('goodbye', {
75
+ blaze: async () => Result.ok({ ok: true }),
76
+ input: z.object({}),
77
+ intent: 'write',
78
+ output: z.object({ ok: z.boolean() }),
79
+ });
80
+
81
+ const [dbMain] = hello.resources;
82
+ if (!dbMain) {
83
+ throw new Error('expected hello to declare db.main');
84
+ }
85
+
86
+ export const app = topo('fixture-app', { dbMain, goodbye, hello });
87
+ `
88
+ );
89
+ };
90
+
91
+ describe('topo and dev trails', () => {
92
+ test('topo surfaces current summary, detail, and export/verify flow', async () => {
93
+ const dir = repoTempDir();
94
+
95
+ try {
96
+ writeAppFixture(dir);
97
+
98
+ const summary = expectOk(
99
+ await topoTrail.blaze(moduleInput, { cwd: dir } as never)
100
+ );
101
+ expect(summary.app.name).toBe('fixture-app');
102
+ expect(summary.list.count).toBe(2);
103
+ expect(summary.list.resourceCount).toBe(1);
104
+ expect(summary.lockExists).toBe(false);
105
+
106
+ const detail = expectOk(
107
+ await topoShowTrail.blaze({ ...moduleInput, id: 'hello' }, {
108
+ cwd: dir,
109
+ } as never)
110
+ );
111
+ expect(detail.id).toBe('hello');
112
+ expect(detail.resources).toEqual(['db.main']);
113
+
114
+ const exportResult = expectOk(
115
+ await topoExportTrail.blaze(moduleInput, { cwd: dir } as never)
116
+ );
117
+ expect(exportResult.hash).toHaveLength(64);
118
+ expect(existsSync(join(dir, '.trails', '_surface.json'))).toBe(true);
119
+ expect(existsSync(join(dir, '.trails', 'trails.lock'))).toBe(true);
120
+ expect(
121
+ JSON.parse(readFileSync(join(dir, '.trails', 'trails.lock'), 'utf8'))
122
+ ).toMatchObject({
123
+ hash: exportResult.hash,
124
+ version: 1,
125
+ });
126
+
127
+ const summaryAfterExport = expectOk(
128
+ await topoTrail.blaze(moduleInput, { cwd: dir } as never)
129
+ );
130
+ expect(summaryAfterExport.lockExists).toBe(true);
131
+
132
+ const verifyResult = expectOk(
133
+ await topoVerifyTrail.blaze(moduleInput, { cwd: dir } as never)
134
+ );
135
+ expect(verifyResult.stale).toBe(false);
136
+
137
+ writeFileSync(join(dir, '.trails', 'trails.lock'), 'stale\n');
138
+ const verifyError = expectErr(
139
+ await topoVerifyTrail.blaze(moduleInput, { cwd: dir } as never)
140
+ );
141
+ expect(verifyError.message).toContain('trails.lock is stale');
142
+ } finally {
143
+ rmSync(dir, { force: true, recursive: true });
144
+ }
145
+ });
146
+
147
+ test('survey and guide read current topo state through the shared topo store', async () => {
148
+ const dir = repoTempDir();
149
+
150
+ try {
151
+ writeAppFixture(dir);
152
+
153
+ const surveyList = expectOk(
154
+ await surveyTrail.blaze({ module: './src/app.ts' }, {
155
+ cwd: dir,
156
+ } as never)
157
+ );
158
+ expect(surveyList).toMatchObject({
159
+ count: 2,
160
+ resourceCount: 1,
161
+ });
162
+
163
+ const surveyBrief = expectOk(
164
+ await surveyTrail.blaze({ brief: true, module: './src/app.ts' }, {
165
+ cwd: dir,
166
+ } as never)
167
+ );
168
+ expect(surveyBrief).toMatchObject({
169
+ features: {
170
+ examples: true,
171
+ outputSchemas: true,
172
+ resources: true,
173
+ },
174
+ name: 'fixture-app',
175
+ trails: 2,
176
+ });
177
+
178
+ const surveyDetail = expectOk(
179
+ await surveyTrail.blaze({ module: './src/app.ts', trailId: 'hello' }, {
180
+ cwd: dir,
181
+ } as never)
182
+ );
183
+ expect(surveyDetail).toMatchObject({
184
+ id: 'hello',
185
+ resources: ['db.main'],
186
+ });
187
+
188
+ const guideList = expectOk(
189
+ await guideTrail.blaze({ module: './src/app.ts' }, {
190
+ cwd: dir,
191
+ } as never)
192
+ );
193
+ expect(guideList).toEqual([
194
+ {
195
+ description: '(no description)',
196
+ exampleCount: 0,
197
+ id: 'goodbye',
198
+ kind: 'trail',
199
+ },
200
+ {
201
+ description: '(no description)',
202
+ exampleCount: 1,
203
+ id: 'hello',
204
+ kind: 'trail',
205
+ },
206
+ ]);
207
+
208
+ const guideDetail = expectOk(
209
+ await guideTrail.blaze({ module: './src/app.ts', trailId: 'hello' }, {
210
+ cwd: dir,
211
+ } as never)
212
+ );
213
+ expect(guideDetail).toMatchObject({
214
+ description: null,
215
+ examples: [
216
+ {
217
+ input: {},
218
+ name: 'Default greeting',
219
+ },
220
+ ],
221
+ id: 'hello',
222
+ kind: 'trail',
223
+ });
224
+ } finally {
225
+ rmSync(dir, { force: true, recursive: true });
226
+ }
227
+ });
228
+
229
+ test('pinning, history, unpinning, and dev maintenance work against shared trails.db', async () => {
230
+ const dir = repoTempDir();
231
+
232
+ try {
233
+ writeAppFixture(dir);
234
+
235
+ const firstPin = expectOk(
236
+ await topoPinTrail.blaze({ ...moduleInput, name: 'before-auth' }, {
237
+ cwd: dir,
238
+ } as never)
239
+ );
240
+ expect(firstPin.snapshot.pinnedAs).toBe('before-auth');
241
+
242
+ const firstExport = expectOk(
243
+ await topoExportTrail.blaze(moduleInput, { cwd: dir } as never)
244
+ );
245
+ const secondExport = expectOk(
246
+ await topoExportTrail.blaze(moduleInput, { cwd: dir } as never)
247
+ );
248
+ expect(firstExport.hash).toBe(secondExport.hash);
249
+ expect(
250
+ JSON.parse(readFileSync(join(dir, '.trails', 'trails.lock'), 'utf8'))
251
+ ).toMatchObject({
252
+ hash: secondExport.hash,
253
+ version: 1,
254
+ });
255
+
256
+ const projectionDb = openReadTrailsDb({ rootDir: dir });
257
+ try {
258
+ const pinnedRows = projectionDb
259
+ .query<{ count: number }, [string]>(
260
+ 'SELECT COUNT(*) as count FROM topo_trails WHERE snapshot_id = ?'
261
+ )
262
+ .get(firstPin.snapshot.id);
263
+ const exportedRows = projectionDb
264
+ .query<{ count: number }, [string]>(
265
+ 'SELECT COUNT(*) as count FROM topo_trails WHERE snapshot_id = ?'
266
+ )
267
+ .get(firstExport.snapshot.id);
268
+ const projectedSaves = projectionDb
269
+ .query<{ count: number }, []>(
270
+ 'SELECT COUNT(DISTINCT snapshot_id) as count FROM topo_trails'
271
+ )
272
+ .get();
273
+ const cachedSchemas = projectionDb
274
+ .query<{ count: number }, []>(
275
+ 'SELECT COUNT(*) as count FROM topo_schemas'
276
+ )
277
+ .get();
278
+
279
+ expect(pinnedRows?.count).toBe(2);
280
+ expect(exportedRows?.count).toBe(2);
281
+ expect(projectedSaves?.count).toBe(3);
282
+ expect(cachedSchemas?.count).toBeGreaterThanOrEqual(9);
283
+ } finally {
284
+ projectionDb.close();
285
+ }
286
+
287
+ const store = createDevStore({ rootDir: dir });
288
+ try {
289
+ store.write({
290
+ attrs: {},
291
+ endedAt: Date.now() - 1000,
292
+ id: 'track-1',
293
+ kind: 'trail',
294
+ name: 'hello',
295
+ rootId: 'track-1',
296
+ startedAt: Date.now() - 10_000,
297
+ status: 'ok',
298
+ traceId: 'trace-1',
299
+ trailId: 'hello',
300
+ trailhead: 'cli',
301
+ });
302
+ store.write({
303
+ attrs: {},
304
+ endedAt: Date.now() - 500,
305
+ id: 'track-2',
306
+ kind: 'trail',
307
+ name: 'goodbye',
308
+ rootId: 'track-2',
309
+ startedAt: Date.now() - 20_000,
310
+ status: 'err',
311
+ traceId: 'trace-2',
312
+ trailId: 'goodbye',
313
+ trailhead: 'cli',
314
+ });
315
+ } finally {
316
+ store.close();
317
+ }
318
+
319
+ const history = expectOk(
320
+ await topoHistoryTrail.blaze({}, { cwd: dir } as never)
321
+ );
322
+ expect(history.pinnedCount).toBe(1);
323
+ expect(history.snapshotCount).toBeGreaterThanOrEqual(3);
324
+ expect(
325
+ history.snapshots.some(
326
+ (snapshot) => snapshot.id === firstPin.snapshot.id
327
+ )
328
+ ).toBe(true);
329
+ expect(
330
+ history.snapshots.some(
331
+ (snapshot) => snapshot.id === secondExport.snapshot.id
332
+ )
333
+ ).toBe(true);
334
+
335
+ const stats = expectOk(
336
+ await devStatsTrail.blaze({}, { cwd: dir } as never)
337
+ );
338
+ expect(stats.topo.pinnedCount).toBe(1);
339
+ expect(stats.tracing.recordCount).toBe(2);
340
+
341
+ const cleanPreview = expectOk(
342
+ await devCleanTrail.blaze(
343
+ { dryRun: true, snapshots: 0, traceAgeMs: 0 },
344
+ {
345
+ cwd: dir,
346
+ } as never
347
+ )
348
+ );
349
+ expect(cleanPreview.dryRun).toBe(true);
350
+ expect(cleanPreview.removed.topoSnapshots).toBeGreaterThanOrEqual(2);
351
+ expect(cleanPreview.removed.traceRecords).toBe(2);
352
+
353
+ const cleanResult = expectOk(
354
+ await devCleanTrail.blaze(
355
+ { dryRun: false, snapshots: 0, traceAgeMs: 0, yes: true },
356
+ { cwd: dir } as never
357
+ )
358
+ );
359
+ expect(cleanResult.removed.traceRecords).toBe(2);
360
+ expect(cleanResult.remaining.pinnedCount).toBe(1);
361
+
362
+ const unpinPreview = expectOk(
363
+ await topoUnpinTrail.blaze({ dryRun: true, name: 'before-auth' }, {
364
+ cwd: dir,
365
+ } as never)
366
+ );
367
+ expect(unpinPreview.dryRun).toBe(true);
368
+ expect(unpinPreview.snapshot?.pinnedAs).toBe('before-auth');
369
+
370
+ const unpinResult = expectOk(
371
+ await topoUnpinTrail.blaze(
372
+ { dryRun: false, name: 'before-auth', yes: true },
373
+ { cwd: dir } as never
374
+ )
375
+ );
376
+ expect(unpinResult.removed).toBe(true);
377
+
378
+ const resetPreview = expectOk(
379
+ await devResetTrail.blaze({ dryRun: true }, { cwd: dir } as never)
380
+ );
381
+ expect(resetPreview.dryRun).toBe(true);
382
+ expect(resetPreview.removedFiles).toContain('.trails/trails.db');
383
+
384
+ const resetResult = expectOk(
385
+ await devResetTrail.blaze({ dryRun: false, yes: true }, {
386
+ cwd: dir,
387
+ } as never)
388
+ );
389
+ expect(resetResult.removedFiles).toContain('.trails/trails.db');
390
+ expect(existsSync(join(dir, '.trails', 'trails.db'))).toBe(false);
391
+ expect(
392
+ readFileSync(join(dir, '.trails', 'trails.lock'), 'utf8').length
393
+ ).toBeGreaterThan(0);
394
+ } finally {
395
+ rmSync(dir, { force: true, recursive: true });
396
+ }
397
+ });
398
+
399
+ test('dev clean stays side-effect free when no local state exists', async () => {
400
+ const dir = repoTempDir();
401
+
402
+ try {
403
+ mkdirSync(dir, { recursive: true });
404
+
405
+ const preview = expectOk(
406
+ await devCleanTrail.blaze({ dryRun: true }, { cwd: dir } as never)
407
+ );
408
+ expect(preview.dryRun).toBe(true);
409
+ expect(preview.removed.topoSnapshots).toBe(0);
410
+ expect(preview.removed.traceRecords).toBe(0);
411
+ expect(existsSync(join(dir, '.trails', 'trails.db'))).toBe(false);
412
+
413
+ const applied = expectOk(
414
+ await devCleanTrail.blaze({ dryRun: false, yes: true }, {
415
+ cwd: dir,
416
+ } as never)
417
+ );
418
+ expect(applied.dryRun).toBe(false);
419
+ expect(applied.removed.topoSnapshots).toBe(0);
420
+ expect(applied.removed.traceRecords).toBe(0);
421
+ expect(existsSync(join(dir, '.trails', 'trails.db'))).toBe(false);
422
+ } finally {
423
+ rmSync(dir, { force: true, recursive: true });
424
+ }
425
+ });
426
+ });
package/src/app.ts CHANGED
@@ -1,22 +1,44 @@
1
1
  import { topo } from '@ontrails/core';
2
2
 
3
- import * as addTrailhead from './trails/add-trailhead.js';
3
+ import * as addSurface from './trails/add-surface.js';
4
4
  import * as addTrail from './trails/add-trail.js';
5
5
  import * as addVerify from './trails/add-verify.js';
6
6
  import * as create from './trails/create.js';
7
7
  import * as createScaffold from './trails/create-scaffold.js';
8
+ import * as devClean from './trails/dev-clean.js';
9
+ import * as devReset from './trails/dev-reset.js';
10
+ import * as devStats from './trails/dev-stats.js';
11
+ import * as draftPromote from './trails/draft-promote.js';
8
12
  import * as guide from './trails/guide.js';
9
13
  import * as survey from './trails/survey.js';
14
+ import * as topoExport from './trails/topo-export.js';
15
+ import * as topoHistory from './trails/topo-history.js';
16
+ import * as topoPin from './trails/topo-pin.js';
17
+ import * as topoShow from './trails/topo-show.js';
18
+ import * as topoCommand from './trails/topo.js';
19
+ import * as topoUnpin from './trails/topo-unpin.js';
20
+ import * as topoVerify from './trails/topo-verify.js';
10
21
  import * as warden from './trails/warden.js';
11
22
 
12
23
  export const app = topo(
13
24
  'trails',
14
25
  survey,
26
+ topoCommand,
27
+ topoShow,
28
+ topoHistory,
29
+ topoPin,
30
+ topoUnpin,
31
+ topoExport,
32
+ topoVerify,
33
+ devStats,
34
+ devClean,
35
+ devReset,
15
36
  guide,
37
+ draftPromote,
16
38
  warden,
17
39
  create,
18
40
  createScaffold,
19
- addTrailhead,
41
+ addSurface,
20
42
  addVerify,
21
43
  addTrail
22
44
  );
package/src/clack.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Clack-backed input resolver for the Trails CLI.
3
3
  *
4
- * This stays at the app gate so @ontrails/cli remains prompt-library agnostic.
4
+ * This stays at the app layer so @ontrails/cli remains prompt-library agnostic.
5
5
  */
6
6
 
7
7
  import type { Field, InputResolver, ResolveInputOptions } from '@ontrails/cli';
package/src/cli.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import { outputModePreset } from '@ontrails/cli';
2
- import { trailhead } from '@ontrails/cli/commander';
2
+ import { surface } from '@ontrails/cli/commander';
3
3
 
4
4
  import { app } from './app.js';
5
5
  import { resolveInputWithClack } from './clack.js';
6
+ import { trailsPackageVersion } from './versions.js';
6
7
 
7
8
  // oxlint-disable-next-line require-hook -- CLI entry point
8
- trailhead(app, {
9
+ await surface(app, {
9
10
  description: 'Agent-native, contract-first TypeScript framework',
10
11
  name: 'trails',
11
12
  presets: [outputModePreset()],
12
13
  resolveInput: resolveInputWithClack,
13
- version: '0.1.0',
14
+ version: trailsPackageVersion,
14
15
  });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `add.surface` trail -- Add a surface to an existing project.
3
+ *
4
+ * Generates surface entry points and updates package.json dependencies.
5
+ */
6
+
7
+ import { existsSync, mkdirSync } from 'node:fs';
8
+ import { basename, dirname, join, resolve } from 'node:path';
9
+
10
+ import { Result, trail } from '@ontrails/core';
11
+ import { z } from 'zod';
12
+
13
+ import {
14
+ ontrailsPackageRange,
15
+ scaffoldDependencyVersions,
16
+ } from '../versions.js';
17
+ import { findTopoPath } from './project.js';
18
+
19
+ type Surface = 'cli' | 'http' | 'mcp';
20
+
21
+ const generateCliEntry = (appImportPath: string): string =>
22
+ `import { surface } from '@ontrails/cli/commander';
23
+
24
+ import { app } from '${appImportPath}';
25
+
26
+ await surface(app);
27
+ `;
28
+
29
+ const generateMcpEntry = (appImportPath: string): string =>
30
+ `import { surface } from '@ontrails/mcp';
31
+
32
+ import { app } from '${appImportPath}';
33
+
34
+ await surface(app);
35
+ `;
36
+
37
+ const generateHttpEntry = (appImportPath: string): string =>
38
+ `import { surface } from '@ontrails/hono';
39
+
40
+ import { app } from '${appImportPath}';
41
+
42
+ await surface(app, { port: 3000 });
43
+ `;
44
+
45
+ const surfaceEntryFiles = {
46
+ cli: 'src/cli.ts',
47
+ http: 'src/http.ts',
48
+ mcp: 'src/mcp.ts',
49
+ } satisfies Record<Surface, string>;
50
+
51
+ const surfaceDependencies = {
52
+ cli: ['@ontrails/cli'],
53
+ http: ['@ontrails/hono', '@ontrails/http'],
54
+ mcp: ['@ontrails/mcp'],
55
+ } satisfies Record<Surface, readonly string[]>;
56
+
57
+ /** Resolve the entry file for a surface. */
58
+ const getEntryFile = (surface: Surface): string => surfaceEntryFiles[surface];
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Trail definition
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** Patch deps and optionally bin in a parsed package.json. */
65
+ const patchPkgDeps = (
66
+ pkg: Record<string, unknown>,
67
+ surface: Surface,
68
+ cwd: string
69
+ ): string => {
70
+ const [depName = ''] = surfaceDependencies[surface];
71
+ const deps = (pkg['dependencies'] ?? {}) as Record<string, string>;
72
+ for (const dependency of surfaceDependencies[surface]) {
73
+ deps[dependency] = ontrailsPackageRange;
74
+ }
75
+ if (surface === 'cli') {
76
+ deps['commander'] = scaffoldDependencyVersions.commander;
77
+ pkg['bin'] = {
78
+ [(pkg['name'] as string | undefined) ?? basename(cwd)]: './src/cli.ts',
79
+ };
80
+ }
81
+ pkg['dependencies'] = Object.fromEntries(
82
+ Object.entries(deps).toSorted(([a], [b]) => a.localeCompare(b))
83
+ );
84
+ return depName;
85
+ };
86
+
87
+ /** Update package.json with surface dependency and CLI bin if needed. */
88
+ const updatePkgJsonForSurface = async (
89
+ cwd: string,
90
+ surface: Surface
91
+ ): Promise<string> => {
92
+ const pkgPath = join(cwd, 'package.json');
93
+ if (!existsSync(pkgPath)) {
94
+ return surfaceDependencies[surface][0] ?? '';
95
+ }
96
+ const pkg = (await Bun.file(pkgPath).json()) as Record<string, unknown>;
97
+ const depName = patchPkgDeps(pkg, surface, cwd);
98
+ await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
99
+ return depName;
100
+ };
101
+
102
+ /** Create the entry file for a surface and return the relative path. */
103
+ const writeSurfaceEntry = async (
104
+ cwd: string,
105
+ surface: Surface
106
+ ): Promise<string> => {
107
+ const entryFile = getEntryFile(surface);
108
+ const fullEntryPath = join(cwd, entryFile);
109
+ const appImport = (await findTopoPath(cwd)) ?? './app.js';
110
+ const generators = {
111
+ cli: generateCliEntry,
112
+ http: generateHttpEntry,
113
+ mcp: generateMcpEntry,
114
+ } satisfies Record<Surface, (appImportPath: string) => string>;
115
+ const content = generators[surface](appImport);
116
+
117
+ mkdirSync(dirname(fullEntryPath), { recursive: true });
118
+ await Bun.write(fullEntryPath, content);
119
+ return entryFile;
120
+ };
121
+
122
+ export const addSurface = trail('add.surface', {
123
+ blaze: async (input) => {
124
+ const cwd = resolve(input.dir ?? '.');
125
+ const { surface } = input;
126
+ const entryFile = getEntryFile(surface);
127
+
128
+ if (existsSync(join(cwd, entryFile))) {
129
+ return Result.err(
130
+ new Error(
131
+ `${surface.toUpperCase()} surface already exists. Nothing to do.`
132
+ )
133
+ );
134
+ }
135
+
136
+ return Result.ok({
137
+ created: await writeSurfaceEntry(cwd, surface),
138
+ dependency: await updatePkgJsonForSurface(cwd, surface),
139
+ });
140
+ },
141
+ description: 'Add a surface to an existing project',
142
+ input: z.object({
143
+ dir: z.string().optional().describe('Project directory'),
144
+ surface: z.enum(['cli', 'http', 'mcp']).describe('Surface to add'),
145
+ }),
146
+ output: z.object({
147
+ created: z.string(),
148
+ dependency: z.string(),
149
+ }),
150
+ });