@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.21

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 (149) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +26 -0
  3. package/package.json +28 -7
  4. package/src/app.ts +86 -2
  5. package/src/clack.ts +22 -0
  6. package/src/cli.ts +330 -11
  7. package/src/completions.ts +240 -0
  8. package/src/lifecycle-source-io.ts +33 -0
  9. package/src/load-app-mirror.ts +202 -0
  10. package/src/local-state-io.ts +153 -0
  11. package/src/mcp-app.ts +30 -0
  12. package/src/mcp-options.ts +77 -0
  13. package/src/mcp.ts +8 -0
  14. package/src/project-writes.ts +377 -0
  15. package/src/release/bindings.ts +39 -0
  16. package/src/release/check.ts +818 -0
  17. package/src/release/config.ts +63 -0
  18. package/src/release/contract-facts.ts +425 -0
  19. package/src/release/index.ts +85 -0
  20. package/src/release/native-bun-publish.ts +651 -0
  21. package/src/release/native-bun-registry.ts +350 -0
  22. package/src/release/packed-artifacts-smoke.ts +236 -0
  23. package/src/release/smoke.ts +46 -0
  24. package/src/release/wayfinder-dogfood-smoke.ts +226 -0
  25. package/src/retired-topo-command.ts +36 -0
  26. package/src/run-adapter-check.ts +76 -0
  27. package/src/run-collision.ts +126 -0
  28. package/src/run-completions-install.ts +179 -0
  29. package/src/run-example.ts +149 -0
  30. package/src/run-examples.ts +148 -0
  31. package/src/run-quiet.ts +75 -0
  32. package/src/run-release-check.ts +74 -0
  33. package/src/run-trace.ts +273 -0
  34. package/src/run-warden.ts +39 -0
  35. package/src/run-watch.ts +432 -0
  36. package/src/scaffold-version-sync.ts +183 -0
  37. package/src/scaffold-versions.generated.ts +12 -0
  38. package/src/trails/adapter-check.ts +244 -0
  39. package/src/trails/add-surface.ts +93 -40
  40. package/src/trails/add-trail.ts +79 -41
  41. package/src/trails/add-verify.ts +94 -25
  42. package/src/trails/compile.ts +67 -0
  43. package/src/trails/completions-complete.ts +165 -0
  44. package/src/trails/completions.ts +47 -0
  45. package/src/trails/create-adapter.ts +1084 -0
  46. package/src/trails/create-scaffold.ts +354 -74
  47. package/src/trails/create-versions.ts +62 -0
  48. package/src/trails/create.ts +185 -71
  49. package/src/trails/deprecate.ts +59 -0
  50. package/src/trails/dev-clean.ts +82 -0
  51. package/src/trails/dev-reset.ts +50 -0
  52. package/src/trails/dev-stats.ts +72 -0
  53. package/src/trails/dev-support.ts +340 -0
  54. package/src/trails/doctor.ts +56 -0
  55. package/src/trails/draft-promote.ts +949 -0
  56. package/src/trails/guide.ts +74 -68
  57. package/src/trails/load-app.ts +1143 -15
  58. package/src/trails/project.ts +17 -3
  59. package/src/trails/release-check.ts +104 -0
  60. package/src/trails/release-smoke.ts +48 -0
  61. package/src/trails/revise.ts +53 -0
  62. package/src/trails/root-dir.ts +21 -0
  63. package/src/trails/run-example.ts +491 -0
  64. package/src/trails/run-examples.ts +145 -0
  65. package/src/trails/run.ts +410 -0
  66. package/src/trails/survey.ts +881 -226
  67. package/src/trails/topo-activation.ts +385 -0
  68. package/src/trails/topo-constants.ts +2 -0
  69. package/src/trails/topo-history.ts +47 -0
  70. package/src/trails/topo-output-schemas.ts +248 -0
  71. package/src/trails/topo-pin.ts +52 -0
  72. package/src/trails/topo-read-support.ts +313 -0
  73. package/src/trails/topo-reports.ts +807 -0
  74. package/src/trails/topo-store-support.ts +174 -0
  75. package/src/trails/topo-support.ts +220 -0
  76. package/src/trails/topo-unpin.ts +61 -0
  77. package/src/trails/topo.ts +106 -0
  78. package/src/trails/validate.ts +38 -0
  79. package/src/trails/version-lifecycle-support.ts +945 -0
  80. package/src/trails/warden-guide.ts +129 -0
  81. package/src/trails/warden.ts +165 -58
  82. package/src/versions.ts +31 -0
  83. package/.turbo/turbo-build.log +0 -1
  84. package/.turbo/turbo-lint.log +0 -3
  85. package/.turbo/turbo-typecheck.log +0 -1
  86. package/__tests__/examples.test.ts +0 -6
  87. package/dist/bin/trails.d.ts +0 -3
  88. package/dist/bin/trails.d.ts.map +0 -1
  89. package/dist/bin/trails.js +0 -4
  90. package/dist/bin/trails.js.map +0 -1
  91. package/dist/src/app.d.ts +0 -2
  92. package/dist/src/app.d.ts.map +0 -1
  93. package/dist/src/app.js +0 -11
  94. package/dist/src/app.js.map +0 -1
  95. package/dist/src/clack.d.ts +0 -9
  96. package/dist/src/clack.d.ts.map +0 -1
  97. package/dist/src/clack.js +0 -62
  98. package/dist/src/clack.js.map +0 -1
  99. package/dist/src/cli.d.ts +0 -2
  100. package/dist/src/cli.d.ts.map +0 -1
  101. package/dist/src/cli.js +0 -13
  102. package/dist/src/cli.js.map +0 -1
  103. package/dist/src/trails/add-surface.d.ts +0 -13
  104. package/dist/src/trails/add-surface.d.ts.map +0 -1
  105. package/dist/src/trails/add-surface.js +0 -88
  106. package/dist/src/trails/add-surface.js.map +0 -1
  107. package/dist/src/trails/add-trail.d.ts +0 -11
  108. package/dist/src/trails/add-trail.d.ts.map +0 -1
  109. package/dist/src/trails/add-trail.js +0 -85
  110. package/dist/src/trails/add-trail.js.map +0 -1
  111. package/dist/src/trails/add-verify.d.ts +0 -10
  112. package/dist/src/trails/add-verify.d.ts.map +0 -1
  113. package/dist/src/trails/add-verify.js +0 -67
  114. package/dist/src/trails/add-verify.js.map +0 -1
  115. package/dist/src/trails/create-scaffold.d.ts +0 -15
  116. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  117. package/dist/src/trails/create-scaffold.js +0 -288
  118. package/dist/src/trails/create-scaffold.js.map +0 -1
  119. package/dist/src/trails/create.d.ts +0 -22
  120. package/dist/src/trails/create.d.ts.map +0 -1
  121. package/dist/src/trails/create.js +0 -121
  122. package/dist/src/trails/create.js.map +0 -1
  123. package/dist/src/trails/guide.d.ts +0 -11
  124. package/dist/src/trails/guide.d.ts.map +0 -1
  125. package/dist/src/trails/guide.js +0 -80
  126. package/dist/src/trails/guide.js.map +0 -1
  127. package/dist/src/trails/load-app.d.ts +0 -4
  128. package/dist/src/trails/load-app.d.ts.map +0 -1
  129. package/dist/src/trails/load-app.js +0 -24
  130. package/dist/src/trails/load-app.js.map +0 -1
  131. package/dist/src/trails/project.d.ts +0 -8
  132. package/dist/src/trails/project.d.ts.map +0 -1
  133. package/dist/src/trails/project.js +0 -43
  134. package/dist/src/trails/project.js.map +0 -1
  135. package/dist/src/trails/survey.d.ts +0 -33
  136. package/dist/src/trails/survey.d.ts.map +0 -1
  137. package/dist/src/trails/survey.js +0 -225
  138. package/dist/src/trails/survey.js.map +0 -1
  139. package/dist/src/trails/warden.d.ts +0 -19
  140. package/dist/src/trails/warden.d.ts.map +0 -1
  141. package/dist/src/trails/warden.js +0 -88
  142. package/dist/src/trails/warden.js.map +0 -1
  143. package/dist/tsconfig.tsbuildinfo +0 -1
  144. package/src/__tests__/create.test.ts +0 -349
  145. package/src/__tests__/guide.test.ts +0 -91
  146. package/src/__tests__/load-app.test.ts +0 -15
  147. package/src/__tests__/survey.test.ts +0 -161
  148. package/src/__tests__/warden.test.ts +0 -74
  149. package/tsconfig.json +0 -9
@@ -0,0 +1,244 @@
1
+ /**
2
+ * `adapter.check` trail -- Local adapter authoring readiness checks.
3
+ */
4
+
5
+ import { adapterTargetPlacements, checkAdapters } from '@ontrails/adapter-kit';
6
+ import type { AdapterCheckReport } from '@ontrails/adapter-kit';
7
+ import { isPlainObject, Result, trail, ValidationError } from '@ontrails/core';
8
+ import { existsSync, readFileSync, statSync } from 'node:fs';
9
+ import { join, relative } from 'node:path';
10
+ import { z } from 'zod';
11
+
12
+ import { resolveTrailRootDir } from './root-dir.js';
13
+
14
+ const adapterCheckInputSchema = z.object({
15
+ rootDir: z.string().optional().describe('Root directory to scan'),
16
+ });
17
+
18
+ const adapterPlacementSchema = z.enum(adapterTargetPlacements);
19
+
20
+ const adapterCheckDiagnosticSchema = z.object({
21
+ code: z.string().describe('Stable adapter diagnostic code'),
22
+ message: z.string(),
23
+ packageJsonPath: z.string(),
24
+ packageName: z.string().optional(),
25
+ placement: adapterPlacementSchema.optional(),
26
+ severity: z.enum(['error', 'warn']),
27
+ target: z.string().optional(),
28
+ });
29
+
30
+ const adapterCheckSubjectSchema = z.object({
31
+ conformanceTestPaths: z.array(z.string()).readonly(),
32
+ key: z.string(),
33
+ ownerPackage: z.string(),
34
+ packageJsonPath: z.string(),
35
+ packageName: z.string(),
36
+ packageRoot: z.string(),
37
+ placement: adapterPlacementSchema,
38
+ target: z.string(),
39
+ targetKey: z.string(),
40
+ testingImport: z.string().optional(),
41
+ });
42
+
43
+ const adapterTargetSchema = z.object({
44
+ conformance: z
45
+ .object({
46
+ adapterType: z.string(),
47
+ casesFactory: z.string(),
48
+ runner: z.string(),
49
+ })
50
+ .optional(),
51
+ key: z.string(),
52
+ ownerPackage: z.string(),
53
+ packageJsonPath: z.string(),
54
+ packageRoot: z.string(),
55
+ placements: z.array(adapterPlacementSchema).readonly(),
56
+ supportExportTarget: z.string().optional(),
57
+ supportImport: z.string().optional(),
58
+ target: z.string(),
59
+ testingExportTarget: z.string().optional(),
60
+ testingImport: z.string().optional(),
61
+ });
62
+
63
+ const adapterCheckOutputSchema = z.object({
64
+ diagnostics: z.array(adapterCheckDiagnosticSchema).readonly(),
65
+ formatted: z.string(),
66
+ passed: z.boolean(),
67
+ subjects: z.array(adapterCheckSubjectSchema).readonly(),
68
+ targets: z.array(adapterTargetSchema).readonly(),
69
+ });
70
+
71
+ const relativeToRoot = (rootDir: string, path: string): string => {
72
+ const normalized = relative(rootDir, path).replaceAll('\\', '/');
73
+ return normalized.length === 0 || normalized.startsWith('..')
74
+ ? path
75
+ : normalized;
76
+ };
77
+
78
+ const workspacePatternsFromManifest = (
79
+ manifest: Readonly<Record<string, unknown>>
80
+ ): readonly string[] => {
81
+ const { workspaces } = manifest;
82
+ if (Array.isArray(workspaces)) {
83
+ return workspaces.filter(
84
+ (pattern): pattern is string => typeof pattern === 'string'
85
+ );
86
+ }
87
+
88
+ const packages = isPlainObject(workspaces)
89
+ ? workspaces['packages']
90
+ : undefined;
91
+ return Array.isArray(packages)
92
+ ? packages.filter(
93
+ (pattern): pattern is string => typeof pattern === 'string'
94
+ )
95
+ : [];
96
+ };
97
+
98
+ const readWorkspaceManifest = (
99
+ packageJsonPath: string
100
+ ): Result<Readonly<Record<string, unknown>>, ValidationError> => {
101
+ try {
102
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
103
+ return isPlainObject(parsed)
104
+ ? Result.ok(parsed)
105
+ : Result.err(
106
+ new ValidationError(
107
+ `adapter.check root package.json must contain a JSON object: "${packageJsonPath}"`
108
+ )
109
+ );
110
+ } catch (error) {
111
+ return Result.err(
112
+ new ValidationError(
113
+ `adapter.check could not read root package.json: "${packageJsonPath}"`,
114
+ error instanceof Error ? { cause: error } : undefined
115
+ )
116
+ );
117
+ }
118
+ };
119
+
120
+ const validateAdapterCheckRoot = (
121
+ rootDir: string
122
+ ): Result<void, ValidationError> => {
123
+ if (!existsSync(rootDir)) {
124
+ return Result.err(
125
+ new ValidationError(`adapter.check rootDir does not exist: "${rootDir}"`)
126
+ );
127
+ }
128
+
129
+ if (!statSync(rootDir).isDirectory()) {
130
+ return Result.err(
131
+ new ValidationError(
132
+ `adapter.check rootDir must be a directory: "${rootDir}"`
133
+ )
134
+ );
135
+ }
136
+
137
+ const packageJsonPath = join(rootDir, 'package.json');
138
+ if (!existsSync(packageJsonPath)) {
139
+ return Result.err(
140
+ new ValidationError(
141
+ `adapter.check rootDir must contain a package.json workspace manifest: "${packageJsonPath}"`
142
+ )
143
+ );
144
+ }
145
+
146
+ const manifest = readWorkspaceManifest(packageJsonPath);
147
+ if (manifest.isErr()) {
148
+ return Result.err(manifest.error);
149
+ }
150
+
151
+ if (workspacePatternsFromManifest(manifest.value).length === 0) {
152
+ return Result.err(
153
+ new ValidationError(
154
+ `adapter.check root package.json must declare workspace packages: "${packageJsonPath}"`
155
+ )
156
+ );
157
+ }
158
+
159
+ return Result.ok();
160
+ };
161
+
162
+ export const formatAdapterCheckReport = (
163
+ report: AdapterCheckReport,
164
+ rootDir: string
165
+ ): string => {
166
+ const passed = report.diagnostics.length === 0;
167
+ const lines = [
168
+ '## Adapter Check Report',
169
+ '',
170
+ `Result: ${passed ? 'PASS' : 'FAIL'}`,
171
+ `Targets: ${report.targets.length}`,
172
+ `Adapters: ${report.subjects.length}`,
173
+ `Diagnostics: ${report.diagnostics.length}`,
174
+ ];
175
+
176
+ if (report.targets.length > 0) {
177
+ lines.push('', '### Targets');
178
+ for (const target of report.targets) {
179
+ lines.push(
180
+ `- ${target.key} (${target.placements.join(', ')}) from ${relativeToRoot(rootDir, target.packageJsonPath)}`
181
+ );
182
+ }
183
+ }
184
+
185
+ if (report.subjects.length > 0) {
186
+ lines.push('', '### Adapters');
187
+ for (const subject of report.subjects) {
188
+ const conformance =
189
+ subject.conformanceTestPaths.length === 0
190
+ ? 'no conformance tests'
191
+ : `${subject.conformanceTestPaths.length} conformance test(s)`;
192
+ lines.push(
193
+ `- ${subject.packageName} -> ${subject.targetKey} (${subject.placement}, ${conformance})`
194
+ );
195
+ }
196
+ }
197
+
198
+ if (report.diagnostics.length > 0) {
199
+ lines.push('', '### Diagnostics');
200
+ for (const diagnostic of report.diagnostics) {
201
+ lines.push(
202
+ `- ${diagnostic.severity.toUpperCase()} ${diagnostic.code} ${relativeToRoot(rootDir, diagnostic.packageJsonPath)}: ${diagnostic.message}`
203
+ );
204
+ }
205
+ }
206
+
207
+ return lines.join('\n');
208
+ };
209
+
210
+ export const adapterCheckTrail = trail('adapter.check', {
211
+ blaze: (input, ctx) => {
212
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
213
+ if (rootDirResult.isErr()) {
214
+ return rootDirResult;
215
+ }
216
+
217
+ const rootDir = rootDirResult.value;
218
+ const validRoot = validateAdapterCheckRoot(rootDir);
219
+ if (validRoot.isErr()) {
220
+ return validRoot;
221
+ }
222
+
223
+ const report = checkAdapters(rootDir);
224
+
225
+ return Result.ok({
226
+ diagnostics: [...report.diagnostics],
227
+ formatted: formatAdapterCheckReport(report, rootDir),
228
+ passed: report.diagnostics.length === 0,
229
+ subjects: [...report.subjects],
230
+ targets: [...report.targets],
231
+ });
232
+ },
233
+ description: 'Check adapter authoring readiness',
234
+ examples: [
235
+ {
236
+ input: {},
237
+ name: 'Check adapters in the current workspace',
238
+ },
239
+ ],
240
+ input: adapterCheckInputSchema,
241
+ intent: 'read',
242
+ output: adapterCheckOutputSchema,
243
+ permit: 'public',
244
+ });
@@ -1,36 +1,66 @@
1
1
  /**
2
2
  * `add.surface` trail -- Add a surface to an existing project.
3
3
  *
4
- * Generates the CLI or MCP entry point and updates package.json dependencies.
4
+ * Generates surface entry points and updates package.json dependencies.
5
5
  */
6
6
 
7
- import { existsSync, mkdirSync } from 'node:fs';
8
- import { basename, dirname, join, resolve } from 'node:path';
7
+ import { existsSync } from 'node:fs';
8
+ import { basename, resolve } from 'node:path';
9
9
 
10
10
  import { Result, trail } from '@ontrails/core';
11
11
  import { z } from 'zod';
12
12
 
13
+ import {
14
+ projectPathExists,
15
+ resolveProjectPath,
16
+ writeProjectFile,
17
+ } from '../project-writes.js';
18
+ import { ontrailsPackageRange } from '../versions.js';
13
19
  import { findTopoPath } from './project.js';
14
20
 
21
+ type Surface = 'cli' | 'http' | 'mcp';
22
+
15
23
  const generateCliEntry = (appImportPath: string): string =>
16
- `import { blaze } from '@ontrails/cli/commander';
24
+ `import { devPermitPreset, permitPreset } from '@ontrails/cli';
25
+ import { surface } from '@ontrails/commander';
17
26
 
18
27
  import { app } from '${appImportPath}';
19
28
 
20
- blaze(app);
29
+ await surface(app, {
30
+ presets: [permitPreset(), devPermitPreset()],
31
+ });
21
32
  `;
22
33
 
23
34
  const generateMcpEntry = (appImportPath: string): string =>
24
- `import { blaze } from '@ontrails/mcp';
35
+ `import { surface } from '@ontrails/mcp';
36
+
37
+ import { app } from '${appImportPath}';
38
+
39
+ await surface(app);
40
+ `;
41
+
42
+ const generateHttpEntry = (appImportPath: string): string =>
43
+ `import { surface } from '@ontrails/hono';
25
44
 
26
45
  import { app } from '${appImportPath}';
27
46
 
28
- await blaze(app);
47
+ await surface(app, { port: 3000 });
29
48
  `;
30
49
 
50
+ const surfaceEntryFiles = {
51
+ cli: 'src/cli.ts',
52
+ http: 'src/http.ts',
53
+ mcp: 'src/mcp.ts',
54
+ } satisfies Record<Surface, string>;
55
+
56
+ const surfaceDependencies = {
57
+ cli: ['@ontrails/cli', '@ontrails/commander'],
58
+ http: ['@ontrails/hono', '@ontrails/http'],
59
+ mcp: ['@ontrails/mcp'],
60
+ } satisfies Record<Surface, readonly string[]>;
61
+
31
62
  /** Resolve the entry file for a surface. */
32
- const getEntryFile = (surface: 'cli' | 'mcp'): string =>
33
- surface === 'cli' ? 'src/cli.ts' : 'src/mcp.ts';
63
+ const getEntryFile = (surface: Surface): string => surfaceEntryFiles[surface];
34
64
 
35
65
  // ---------------------------------------------------------------------------
36
66
  // Trail definition
@@ -39,14 +69,15 @@ const getEntryFile = (surface: 'cli' | 'mcp'): string =>
39
69
  /** Patch deps and optionally bin in a parsed package.json. */
40
70
  const patchPkgDeps = (
41
71
  pkg: Record<string, unknown>,
42
- surface: 'cli' | 'mcp',
72
+ surface: Surface,
43
73
  cwd: string
44
74
  ): string => {
45
- const depName = surface === 'cli' ? '@ontrails/cli' : '@ontrails/mcp';
75
+ const [depName = ''] = surfaceDependencies[surface];
46
76
  const deps = (pkg['dependencies'] ?? {}) as Record<string, string>;
47
- deps[depName] = 'workspace:*';
77
+ for (const dependency of surfaceDependencies[surface]) {
78
+ deps[dependency] = ontrailsPackageRange;
79
+ }
48
80
  if (surface === 'cli') {
49
- deps['commander'] = '^14.0.0';
50
81
  pkg['bin'] = {
51
82
  [(pkg['name'] as string | undefined) ?? basename(cwd)]: './src/cli.ts',
52
83
  };
@@ -60,60 +91,82 @@ const patchPkgDeps = (
60
91
  /** Update package.json with surface dependency and CLI bin if needed. */
61
92
  const updatePkgJsonForSurface = async (
62
93
  cwd: string,
63
- surface: 'cli' | 'mcp'
64
- ): Promise<string> => {
65
- const pkgPath = join(cwd, 'package.json');
94
+ surface: Surface
95
+ ): Promise<Result<string, Error>> => {
96
+ const pkgPathResult = resolveProjectPath(cwd, 'package.json');
97
+ if (pkgPathResult.isErr()) {
98
+ return Result.err(pkgPathResult.error);
99
+ }
100
+
101
+ const pkgPath = pkgPathResult.value;
66
102
  if (!existsSync(pkgPath)) {
67
- return surface === 'cli' ? '@ontrails/cli' : '@ontrails/mcp';
103
+ return Result.ok(surfaceDependencies[surface][0] ?? '');
68
104
  }
69
105
  const pkg = (await Bun.file(pkgPath).json()) as Record<string, unknown>;
70
106
  const depName = patchPkgDeps(pkg, surface, cwd);
71
- await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
72
- return depName;
107
+ const written = await writeProjectFile(
108
+ cwd,
109
+ 'package.json',
110
+ `${JSON.stringify(pkg, null, 2)}\n`
111
+ );
112
+ return written.isErr() ? Result.err(written.error) : Result.ok(depName);
73
113
  };
74
114
 
75
115
  /** Create the entry file for a surface and return the relative path. */
76
116
  const writeSurfaceEntry = async (
77
117
  cwd: string,
78
- surface: 'cli' | 'mcp'
79
- ): Promise<string> => {
118
+ surface: Surface
119
+ ): Promise<Result<string, Error>> => {
80
120
  const entryFile = getEntryFile(surface);
81
- const fullEntryPath = join(cwd, entryFile);
82
121
  const appImport = (await findTopoPath(cwd)) ?? './app.js';
83
- const content =
84
- surface === 'cli'
85
- ? generateCliEntry(appImport)
86
- : generateMcpEntry(appImport);
87
-
88
- mkdirSync(dirname(fullEntryPath), { recursive: true });
89
- await Bun.write(fullEntryPath, content);
90
- return entryFile;
122
+ const generators = {
123
+ cli: generateCliEntry,
124
+ http: generateHttpEntry,
125
+ mcp: generateMcpEntry,
126
+ } satisfies Record<Surface, (appImportPath: string) => string>;
127
+ const content = generators[surface](appImport);
128
+
129
+ const written = await writeProjectFile(cwd, entryFile, content);
130
+ return written.isErr() ? Result.err(written.error) : Result.ok(entryFile);
91
131
  };
92
132
 
93
133
  export const addSurface = trail('add.surface', {
94
- description: 'Add a surface to an existing project',
95
- implementation: async (input) => {
134
+ blaze: async (input) => {
96
135
  const cwd = resolve(input.dir ?? '.');
97
136
  const { surface } = input;
98
137
  const entryFile = getEntryFile(surface);
138
+ const entryExists = projectPathExists(cwd, entryFile);
139
+ if (entryExists.isErr()) {
140
+ return entryExists;
141
+ }
142
+
143
+ let created: string | null = null;
144
+ if (!entryExists.value) {
145
+ const written = await writeSurfaceEntry(cwd, surface);
146
+ if (written.isErr()) {
147
+ return written;
148
+ }
149
+ created = entryFile;
150
+ }
99
151
 
100
- if (existsSync(join(cwd, entryFile))) {
101
- return Result.err(
102
- new Error(`${surface.toUpperCase()} is already blazed. Nothing to do.`)
103
- );
152
+ const dependency = await updatePkgJsonForSurface(cwd, surface);
153
+ if (dependency.isErr()) {
154
+ return dependency;
104
155
  }
105
156
 
106
157
  return Result.ok({
107
- created: await writeSurfaceEntry(cwd, surface),
108
- dependency: await updatePkgJsonForSurface(cwd, surface),
158
+ created,
159
+ dependency: dependency.value,
109
160
  });
110
161
  },
162
+ description: 'Add a surface to an existing project',
111
163
  input: z.object({
112
164
  dir: z.string().optional().describe('Project directory'),
113
- surface: z.enum(['cli', 'mcp']).describe('Surface to add'),
165
+ surface: z.enum(['cli', 'http', 'mcp']).describe('Surface to add'),
114
166
  }),
115
167
  output: z.object({
116
- created: z.string(),
168
+ created: z.string().nullable(),
117
169
  dependency: z.string(),
118
170
  }),
171
+ permit: { scopes: ['project:write'] },
119
172
  });
@@ -2,58 +2,73 @@
2
2
  * `add.trail` trail -- Scaffold a new trail file with tests.
3
3
  */
4
4
 
5
- import { mkdirSync } from 'node:fs';
6
- import { dirname, join, resolve } from 'node:path';
5
+ import { resolve } from 'node:path';
7
6
 
8
7
  import { Result, trail } from '@ontrails/core';
9
8
  import { z } from 'zod';
10
9
 
10
+ import {
11
+ trailIdToExportName,
12
+ trailIdToModuleName,
13
+ TRAIL_ID_MESSAGE,
14
+ TRAIL_ID_PATTERN,
15
+ validateTrailId,
16
+ writeProjectFile,
17
+ } from '../project-writes.js';
18
+
11
19
  // ---------------------------------------------------------------------------
12
20
  // Helpers
13
21
  // ---------------------------------------------------------------------------
14
22
 
23
+ const literal = (value: string): string => JSON.stringify(value);
24
+
25
+ const deriveExampleMessage = (id: string): string => `${id} completed`;
26
+
15
27
  const generateTrailFile = (
16
28
  id: string,
17
- readOnly: boolean,
18
- destructive: boolean
29
+ description: string,
30
+ exampleName: string,
31
+ intent: 'read' | 'write' | 'destroy'
19
32
  ): string => {
20
- const markers: string[] = [];
21
- if (readOnly) {
22
- markers.push(' readOnly: true,');
23
- }
24
- if (destructive) {
25
- markers.push(' destructive: true,');
26
- }
27
- const markerBlock = markers.length > 0 ? `\n${markers.join('\n')}` : '';
33
+ const intentLine =
34
+ intent === 'write' ? '' : `\n intent: ${literal(intent)},`;
35
+ const exampleMessage = deriveExampleMessage(id);
36
+ const trailName = trailIdToExportName(id);
28
37
 
29
38
  return `import { Result, trail } from '@ontrails/core';
30
39
  import { z } from 'zod';
31
40
 
32
- export const ${id.replaceAll('.', '_')} = trail('${id}', {
33
- description: 'TODO: describe this trail',
41
+ export const ${trailName} = trail(${literal(id)}, {
42
+ blaze: async () => {
43
+ return Result.ok({ message: ${literal(exampleMessage)} });
44
+ },
45
+ description: ${literal(description)},
34
46
  examples: [
35
47
  {
48
+ expected: { message: ${literal(exampleMessage)} },
36
49
  input: {},
37
- name: 'TODO: add example',
50
+ name: ${literal(exampleName)},
38
51
  },
39
52
  ],
40
- implementation: async (input) => {
41
- return Result.ok({ message: 'TODO' });
42
- },
43
- input: z.object({}),${markerBlock}
53
+ input: z.object({}),${intentLine}
44
54
  output: z.object({ message: z.string() }),
45
55
  });
46
56
  `;
47
57
  };
48
58
 
49
- const generateTestFile = (id: string): string => {
50
- const moduleName = id.replaceAll('.', '-');
51
- const trailName = id.replaceAll('.', '_');
59
+ const generateTestFile = (id: string, exampleName: string): string => {
60
+ const moduleName = trailIdToModuleName(id);
61
+ const trailName = trailIdToExportName(id);
62
+ const exampleMessage = deriveExampleMessage(id);
52
63
  return `import { testTrail } from '@ontrails/testing';
53
64
  import { ${trailName} } from '../src/trails/${moduleName}.js';
54
65
 
55
66
  testTrail(${trailName}, [
56
- { description: 'basic test', input: {}, expectOk: true },
67
+ {
68
+ description: ${literal(exampleName)},
69
+ expectValue: { message: ${literal(exampleMessage)} },
70
+ input: {},
71
+ },
57
72
  ]);
58
73
  `;
59
74
  };
@@ -62,42 +77,65 @@ testTrail(${trailName}, [
62
77
  // Trail definition
63
78
  // ---------------------------------------------------------------------------
64
79
 
65
- /** Write a file, creating parent directories as needed. */
66
- const writeWithDirs = async (
67
- filePath: string,
68
- content: string
69
- ): Promise<void> => {
70
- mkdirSync(dirname(filePath), { recursive: true });
71
- await Bun.write(filePath, content);
72
- };
73
-
74
80
  export const addTrail = trail('add.trail', {
75
- description: 'Scaffold a new trail with tests and examples',
76
- implementation: async (input, ctx) => {
81
+ args: ['id'],
82
+ blaze: async (input, ctx) => {
77
83
  const { id } = input;
78
- const moduleName = id.replaceAll('.', '-');
84
+ const validated = validateTrailId(id);
85
+ if (validated.isErr()) {
86
+ return validated;
87
+ }
88
+
89
+ const moduleName = trailIdToModuleName(validated.value);
79
90
  const cwd = resolve(ctx.cwd ?? '.');
80
91
 
81
92
  const files = new Map<string, string>([
82
93
  [
83
94
  `src/trails/${moduleName}.ts`,
84
- generateTrailFile(id, input.readOnly, input.destructive),
95
+ generateTrailFile(
96
+ id,
97
+ input.description,
98
+ input.exampleName,
99
+ input.intent
100
+ ),
101
+ ],
102
+ [
103
+ `__tests__/${moduleName}.test.ts`,
104
+ generateTestFile(id, input.exampleName),
85
105
  ],
86
- [`__tests__/${moduleName}.test.ts`, generateTestFile(id)],
87
106
  ]);
88
107
 
89
108
  for (const [relativePath, content] of files) {
90
- await writeWithDirs(join(cwd, relativePath), content);
109
+ const written = await writeProjectFile(cwd, relativePath, content);
110
+ if (written.isErr()) {
111
+ return written;
112
+ }
91
113
  }
92
114
 
93
115
  return Result.ok({ created: [...files.keys()] });
94
116
  },
117
+ description: 'Scaffold a new trail with tests and examples',
95
118
  input: z.object({
96
- destructive: z.boolean().default(false).describe('Destructive trail'),
97
- id: z.string().describe('Trail ID (e.g., entity.update)'),
98
- readOnly: z.boolean().default(false).describe('Read-only trail'),
119
+ description: z
120
+ .string()
121
+ .min(1, 'Trail description is required')
122
+ .describe('Trail description'),
123
+ exampleName: z
124
+ .string()
125
+ .min(1, 'Starter example name is required')
126
+ .describe('Starter example name'),
127
+ id: z
128
+ .string()
129
+ .min(1, 'Trail ID is required')
130
+ .regex(TRAIL_ID_PATTERN, TRAIL_ID_MESSAGE)
131
+ .describe('Trail ID (e.g., entity.update)'),
132
+ intent: z
133
+ .enum(['read', 'write', 'destroy'])
134
+ .default('write')
135
+ .describe('Trail intent'),
99
136
  }),
100
137
  output: z.object({
101
138
  created: z.array(z.string()),
102
139
  }),
140
+ permit: { scopes: ['project:write'] },
103
141
  });