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

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 (150) hide show
  1. package/CHANGELOG.md +647 -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 +94 -40
  40. package/src/trails/add-trail.ts +79 -41
  41. package/src/trails/add-verify.ts +95 -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 +399 -104
  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/scaffold-json.ts +58 -0
  67. package/src/trails/survey.ts +881 -226
  68. package/src/trails/topo-activation.ts +385 -0
  69. package/src/trails/topo-constants.ts +2 -0
  70. package/src/trails/topo-history.ts +47 -0
  71. package/src/trails/topo-output-schemas.ts +248 -0
  72. package/src/trails/topo-pin.ts +52 -0
  73. package/src/trails/topo-read-support.ts +313 -0
  74. package/src/trails/topo-reports.ts +807 -0
  75. package/src/trails/topo-store-support.ts +174 -0
  76. package/src/trails/topo-support.ts +220 -0
  77. package/src/trails/topo-unpin.ts +61 -0
  78. package/src/trails/topo.ts +106 -0
  79. package/src/trails/validate.ts +38 -0
  80. package/src/trails/version-lifecycle-support.ts +945 -0
  81. package/src/trails/warden-guide.ts +129 -0
  82. package/src/trails/warden.ts +165 -58
  83. package/src/versions.ts +31 -0
  84. package/.turbo/turbo-build.log +0 -1
  85. package/.turbo/turbo-lint.log +0 -3
  86. package/.turbo/turbo-typecheck.log +0 -1
  87. package/__tests__/examples.test.ts +0 -6
  88. package/dist/bin/trails.d.ts +0 -3
  89. package/dist/bin/trails.d.ts.map +0 -1
  90. package/dist/bin/trails.js +0 -4
  91. package/dist/bin/trails.js.map +0 -1
  92. package/dist/src/app.d.ts +0 -2
  93. package/dist/src/app.d.ts.map +0 -1
  94. package/dist/src/app.js +0 -11
  95. package/dist/src/app.js.map +0 -1
  96. package/dist/src/clack.d.ts +0 -9
  97. package/dist/src/clack.d.ts.map +0 -1
  98. package/dist/src/clack.js +0 -62
  99. package/dist/src/clack.js.map +0 -1
  100. package/dist/src/cli.d.ts +0 -2
  101. package/dist/src/cli.d.ts.map +0 -1
  102. package/dist/src/cli.js +0 -13
  103. package/dist/src/cli.js.map +0 -1
  104. package/dist/src/trails/add-surface.d.ts +0 -13
  105. package/dist/src/trails/add-surface.d.ts.map +0 -1
  106. package/dist/src/trails/add-surface.js +0 -88
  107. package/dist/src/trails/add-surface.js.map +0 -1
  108. package/dist/src/trails/add-trail.d.ts +0 -11
  109. package/dist/src/trails/add-trail.d.ts.map +0 -1
  110. package/dist/src/trails/add-trail.js +0 -85
  111. package/dist/src/trails/add-trail.js.map +0 -1
  112. package/dist/src/trails/add-verify.d.ts +0 -10
  113. package/dist/src/trails/add-verify.d.ts.map +0 -1
  114. package/dist/src/trails/add-verify.js +0 -67
  115. package/dist/src/trails/add-verify.js.map +0 -1
  116. package/dist/src/trails/create-scaffold.d.ts +0 -15
  117. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  118. package/dist/src/trails/create-scaffold.js +0 -288
  119. package/dist/src/trails/create-scaffold.js.map +0 -1
  120. package/dist/src/trails/create.d.ts +0 -22
  121. package/dist/src/trails/create.d.ts.map +0 -1
  122. package/dist/src/trails/create.js +0 -121
  123. package/dist/src/trails/create.js.map +0 -1
  124. package/dist/src/trails/guide.d.ts +0 -11
  125. package/dist/src/trails/guide.d.ts.map +0 -1
  126. package/dist/src/trails/guide.js +0 -80
  127. package/dist/src/trails/guide.js.map +0 -1
  128. package/dist/src/trails/load-app.d.ts +0 -4
  129. package/dist/src/trails/load-app.d.ts.map +0 -1
  130. package/dist/src/trails/load-app.js +0 -24
  131. package/dist/src/trails/load-app.js.map +0 -1
  132. package/dist/src/trails/project.d.ts +0 -8
  133. package/dist/src/trails/project.d.ts.map +0 -1
  134. package/dist/src/trails/project.js +0 -43
  135. package/dist/src/trails/project.js.map +0 -1
  136. package/dist/src/trails/survey.d.ts +0 -33
  137. package/dist/src/trails/survey.d.ts.map +0 -1
  138. package/dist/src/trails/survey.js +0 -225
  139. package/dist/src/trails/survey.js.map +0 -1
  140. package/dist/src/trails/warden.d.ts +0 -19
  141. package/dist/src/trails/warden.d.ts.map +0 -1
  142. package/dist/src/trails/warden.js +0 -88
  143. package/dist/src/trails/warden.js.map +0 -1
  144. package/dist/tsconfig.tsbuildinfo +0 -1
  145. package/src/__tests__/create.test.ts +0 -349
  146. package/src/__tests__/guide.test.ts +0 -91
  147. package/src/__tests__/load-app.test.ts +0 -15
  148. package/src/__tests__/survey.test.ts +0 -161
  149. package/src/__tests__/warden.test.ts +0 -74
  150. 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,67 @@
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';
20
+ import { stringifyScaffoldPackageJson } from './scaffold-json.js';
21
+
22
+ type Surface = 'cli' | 'http' | 'mcp';
14
23
 
15
24
  const generateCliEntry = (appImportPath: string): string =>
16
- `import { blaze } from '@ontrails/cli/commander';
25
+ `import { devPermitPreset, permitPreset } from '@ontrails/cli';
26
+ import { surface } from '@ontrails/commander';
17
27
 
18
28
  import { app } from '${appImportPath}';
19
29
 
20
- blaze(app);
30
+ await surface(app, {
31
+ presets: [permitPreset(), devPermitPreset()],
32
+ });
21
33
  `;
22
34
 
23
35
  const generateMcpEntry = (appImportPath: string): string =>
24
- `import { blaze } from '@ontrails/mcp';
36
+ `import { surface } from '@ontrails/mcp';
37
+
38
+ import { app } from '${appImportPath}';
39
+
40
+ await surface(app);
41
+ `;
42
+
43
+ const generateHttpEntry = (appImportPath: string): string =>
44
+ `import { surface } from '@ontrails/hono';
25
45
 
26
46
  import { app } from '${appImportPath}';
27
47
 
28
- await blaze(app);
48
+ await surface(app, { port: 3000 });
29
49
  `;
30
50
 
51
+ const surfaceEntryFiles = {
52
+ cli: 'src/cli.ts',
53
+ http: 'src/http.ts',
54
+ mcp: 'src/mcp.ts',
55
+ } satisfies Record<Surface, string>;
56
+
57
+ const surfaceDependencies = {
58
+ cli: ['@ontrails/cli', '@ontrails/commander'],
59
+ http: ['@ontrails/hono', '@ontrails/http'],
60
+ mcp: ['@ontrails/mcp'],
61
+ } satisfies Record<Surface, readonly string[]>;
62
+
31
63
  /** Resolve the entry file for a surface. */
32
- const getEntryFile = (surface: 'cli' | 'mcp'): string =>
33
- surface === 'cli' ? 'src/cli.ts' : 'src/mcp.ts';
64
+ const getEntryFile = (surface: Surface): string => surfaceEntryFiles[surface];
34
65
 
35
66
  // ---------------------------------------------------------------------------
36
67
  // Trail definition
@@ -39,14 +70,15 @@ const getEntryFile = (surface: 'cli' | 'mcp'): string =>
39
70
  /** Patch deps and optionally bin in a parsed package.json. */
40
71
  const patchPkgDeps = (
41
72
  pkg: Record<string, unknown>,
42
- surface: 'cli' | 'mcp',
73
+ surface: Surface,
43
74
  cwd: string
44
75
  ): string => {
45
- const depName = surface === 'cli' ? '@ontrails/cli' : '@ontrails/mcp';
76
+ const [depName = ''] = surfaceDependencies[surface];
46
77
  const deps = (pkg['dependencies'] ?? {}) as Record<string, string>;
47
- deps[depName] = 'workspace:*';
78
+ for (const dependency of surfaceDependencies[surface]) {
79
+ deps[dependency] = ontrailsPackageRange;
80
+ }
48
81
  if (surface === 'cli') {
49
- deps['commander'] = '^14.0.0';
50
82
  pkg['bin'] = {
51
83
  [(pkg['name'] as string | undefined) ?? basename(cwd)]: './src/cli.ts',
52
84
  };
@@ -60,60 +92,82 @@ const patchPkgDeps = (
60
92
  /** Update package.json with surface dependency and CLI bin if needed. */
61
93
  const updatePkgJsonForSurface = async (
62
94
  cwd: string,
63
- surface: 'cli' | 'mcp'
64
- ): Promise<string> => {
65
- const pkgPath = join(cwd, 'package.json');
95
+ surface: Surface
96
+ ): Promise<Result<string, Error>> => {
97
+ const pkgPathResult = resolveProjectPath(cwd, 'package.json');
98
+ if (pkgPathResult.isErr()) {
99
+ return Result.err(pkgPathResult.error);
100
+ }
101
+
102
+ const pkgPath = pkgPathResult.value;
66
103
  if (!existsSync(pkgPath)) {
67
- return surface === 'cli' ? '@ontrails/cli' : '@ontrails/mcp';
104
+ return Result.ok(surfaceDependencies[surface][0] ?? '');
68
105
  }
69
106
  const pkg = (await Bun.file(pkgPath).json()) as Record<string, unknown>;
70
107
  const depName = patchPkgDeps(pkg, surface, cwd);
71
- await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
72
- return depName;
108
+ const written = await writeProjectFile(
109
+ cwd,
110
+ 'package.json',
111
+ stringifyScaffoldPackageJson(pkg)
112
+ );
113
+ return written.isErr() ? Result.err(written.error) : Result.ok(depName);
73
114
  };
74
115
 
75
116
  /** Create the entry file for a surface and return the relative path. */
76
117
  const writeSurfaceEntry = async (
77
118
  cwd: string,
78
- surface: 'cli' | 'mcp'
79
- ): Promise<string> => {
119
+ surface: Surface
120
+ ): Promise<Result<string, Error>> => {
80
121
  const entryFile = getEntryFile(surface);
81
- const fullEntryPath = join(cwd, entryFile);
82
122
  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;
123
+ const generators = {
124
+ cli: generateCliEntry,
125
+ http: generateHttpEntry,
126
+ mcp: generateMcpEntry,
127
+ } satisfies Record<Surface, (appImportPath: string) => string>;
128
+ const content = generators[surface](appImport);
129
+
130
+ const written = await writeProjectFile(cwd, entryFile, content);
131
+ return written.isErr() ? Result.err(written.error) : Result.ok(entryFile);
91
132
  };
92
133
 
93
134
  export const addSurface = trail('add.surface', {
94
- description: 'Add a surface to an existing project',
95
- implementation: async (input) => {
135
+ blaze: async (input) => {
96
136
  const cwd = resolve(input.dir ?? '.');
97
137
  const { surface } = input;
98
138
  const entryFile = getEntryFile(surface);
139
+ const entryExists = projectPathExists(cwd, entryFile);
140
+ if (entryExists.isErr()) {
141
+ return entryExists;
142
+ }
143
+
144
+ let created: string | null = null;
145
+ if (!entryExists.value) {
146
+ const written = await writeSurfaceEntry(cwd, surface);
147
+ if (written.isErr()) {
148
+ return written;
149
+ }
150
+ created = entryFile;
151
+ }
99
152
 
100
- if (existsSync(join(cwd, entryFile))) {
101
- return Result.err(
102
- new Error(`${surface.toUpperCase()} is already blazed. Nothing to do.`)
103
- );
153
+ const dependency = await updatePkgJsonForSurface(cwd, surface);
154
+ if (dependency.isErr()) {
155
+ return dependency;
104
156
  }
105
157
 
106
158
  return Result.ok({
107
- created: await writeSurfaceEntry(cwd, surface),
108
- dependency: await updatePkgJsonForSurface(cwd, surface),
159
+ created,
160
+ dependency: dependency.value,
109
161
  });
110
162
  },
163
+ description: 'Add a surface to an existing project',
111
164
  input: z.object({
112
165
  dir: z.string().optional().describe('Project directory'),
113
- surface: z.enum(['cli', 'mcp']).describe('Surface to add'),
166
+ surface: z.enum(['cli', 'http', 'mcp']).describe('Surface to add'),
114
167
  }),
115
168
  output: z.object({
116
- created: z.string(),
169
+ created: z.string().nullable(),
117
170
  dependency: z.string(),
118
171
  }),
172
+ permit: { scopes: ['project:write'] },
119
173
  });
@@ -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
  });