@ontrails/trails 1.0.0-beta.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.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +12 -0
- package/__tests__/examples.test.ts +6 -0
- package/bin/trails.ts +3 -0
- package/dist/bin/trails.d.ts +3 -0
- package/dist/bin/trails.d.ts.map +1 -0
- package/dist/bin/trails.js +4 -0
- package/dist/bin/trails.js.map +1 -0
- package/dist/src/app.d.ts +2 -0
- package/dist/src/app.d.ts.map +1 -0
- package/dist/src/app.js +11 -0
- package/dist/src/app.js.map +1 -0
- package/dist/src/clack.d.ts +9 -0
- package/dist/src/clack.d.ts.map +1 -0
- package/dist/src/clack.js +62 -0
- package/dist/src/clack.js.map +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +13 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/trails/add-surface.d.ts +13 -0
- package/dist/src/trails/add-surface.d.ts.map +1 -0
- package/dist/src/trails/add-surface.js +88 -0
- package/dist/src/trails/add-surface.js.map +1 -0
- package/dist/src/trails/add-trail.d.ts +11 -0
- package/dist/src/trails/add-trail.d.ts.map +1 -0
- package/dist/src/trails/add-trail.js +85 -0
- package/dist/src/trails/add-trail.js.map +1 -0
- package/dist/src/trails/add-verify.d.ts +10 -0
- package/dist/src/trails/add-verify.d.ts.map +1 -0
- package/dist/src/trails/add-verify.js +67 -0
- package/dist/src/trails/add-verify.js.map +1 -0
- package/dist/src/trails/create-scaffold.d.ts +15 -0
- package/dist/src/trails/create-scaffold.d.ts.map +1 -0
- package/dist/src/trails/create-scaffold.js +288 -0
- package/dist/src/trails/create-scaffold.js.map +1 -0
- package/dist/src/trails/create.d.ts +22 -0
- package/dist/src/trails/create.d.ts.map +1 -0
- package/dist/src/trails/create.js +121 -0
- package/dist/src/trails/create.js.map +1 -0
- package/dist/src/trails/guide.d.ts +11 -0
- package/dist/src/trails/guide.d.ts.map +1 -0
- package/dist/src/trails/guide.js +80 -0
- package/dist/src/trails/guide.js.map +1 -0
- package/dist/src/trails/load-app.d.ts +4 -0
- package/dist/src/trails/load-app.d.ts.map +1 -0
- package/dist/src/trails/load-app.js +24 -0
- package/dist/src/trails/load-app.js.map +1 -0
- package/dist/src/trails/project.d.ts +8 -0
- package/dist/src/trails/project.d.ts.map +1 -0
- package/dist/src/trails/project.js +43 -0
- package/dist/src/trails/project.js.map +1 -0
- package/dist/src/trails/survey.d.ts +33 -0
- package/dist/src/trails/survey.d.ts.map +1 -0
- package/dist/src/trails/survey.js +225 -0
- package/dist/src/trails/survey.js.map +1 -0
- package/dist/src/trails/warden.d.ts +19 -0
- package/dist/src/trails/warden.d.ts.map +1 -0
- package/dist/src/trails/warden.js +88 -0
- package/dist/src/trails/warden.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +28 -0
- package/src/__tests__/create.test.ts +349 -0
- package/src/__tests__/guide.test.ts +91 -0
- package/src/__tests__/load-app.test.ts +15 -0
- package/src/__tests__/survey.test.ts +161 -0
- package/src/__tests__/warden.test.ts +74 -0
- package/src/app.ts +22 -0
- package/src/clack.ts +89 -0
- package/src/cli.ts +14 -0
- package/src/trails/add-surface.ts +119 -0
- package/src/trails/add-trail.ts +103 -0
- package/src/trails/add-verify.ts +87 -0
- package/src/trails/create-scaffold.ts +352 -0
- package/src/trails/create.ts +203 -0
- package/src/trails/guide.ts +104 -0
- package/src/trails/load-app.ts +37 -0
- package/src/trails/project.ts +51 -0
- package/src/trails/survey.ts +307 -0
- package/src/trails/warden.ts +104 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create.scaffold` trail -- Creates base project structure.
|
|
3
|
+
*
|
|
4
|
+
* Generates package.json, tsconfig, app.ts, starter trails, and .trails/ directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { Result, trail } from '@ontrails/core';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
type Starter = 'empty' | 'entity' | 'hello';
|
|
18
|
+
|
|
19
|
+
interface ScaffoldResult {
|
|
20
|
+
readonly created: string[];
|
|
21
|
+
readonly dir: string;
|
|
22
|
+
readonly name: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Content generators
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const generatePackageJson = (name: string): string => {
|
|
30
|
+
const deps: Record<string, string> = {
|
|
31
|
+
'@ontrails/core': 'workspace:*',
|
|
32
|
+
zod: '^4.0.0',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const pkg: Record<string, unknown> = {
|
|
36
|
+
dependencies: Object.fromEntries(
|
|
37
|
+
Object.entries(deps).toSorted(([a], [b]) => a.localeCompare(b))
|
|
38
|
+
),
|
|
39
|
+
name,
|
|
40
|
+
scripts: {
|
|
41
|
+
build: 'tsc -b',
|
|
42
|
+
lint: 'oxlint ./src',
|
|
43
|
+
test: 'bun test',
|
|
44
|
+
typecheck: 'tsc --noEmit',
|
|
45
|
+
},
|
|
46
|
+
type: 'module',
|
|
47
|
+
version: '0.1.0',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return JSON.stringify(pkg, null, 2);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const TSCONFIG_CONTENT = JSON.stringify(
|
|
54
|
+
{
|
|
55
|
+
compilerOptions: {
|
|
56
|
+
declaration: true,
|
|
57
|
+
module: 'ESNext',
|
|
58
|
+
moduleResolution: 'bundler',
|
|
59
|
+
noUncheckedIndexedAccess: true,
|
|
60
|
+
outDir: 'dist',
|
|
61
|
+
rootDir: 'src',
|
|
62
|
+
skipLibCheck: true,
|
|
63
|
+
strict: true,
|
|
64
|
+
target: 'ESNext',
|
|
65
|
+
verbatimModuleSyntax: true,
|
|
66
|
+
},
|
|
67
|
+
include: ['src'],
|
|
68
|
+
},
|
|
69
|
+
null,
|
|
70
|
+
2
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const GITIGNORE_CONTENT = `node_modules/
|
|
74
|
+
dist/
|
|
75
|
+
*.tsbuildinfo
|
|
76
|
+
.trails/_surface.json
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const OXLINTRC_CONTENT = JSON.stringify(
|
|
80
|
+
{
|
|
81
|
+
extends: ['ultracite'],
|
|
82
|
+
},
|
|
83
|
+
null,
|
|
84
|
+
2
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const OXFMTRC_CONTENT = `{
|
|
88
|
+
// ultracite defaults
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const generateHelloTrail = (): string =>
|
|
93
|
+
`import { Result, trail } from '@ontrails/core';
|
|
94
|
+
import { z } from 'zod';
|
|
95
|
+
|
|
96
|
+
export const hello = trail('hello', {
|
|
97
|
+
description: 'Say hello',
|
|
98
|
+
examples: [
|
|
99
|
+
{
|
|
100
|
+
expected: { message: 'Hello, world!' },
|
|
101
|
+
input: {},
|
|
102
|
+
name: 'Default greeting',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
expected: { message: 'Hello, Trails!' },
|
|
106
|
+
input: { name: 'Trails' },
|
|
107
|
+
name: 'Named greeting',
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
implementation: (input) => {
|
|
111
|
+
const name = input.name ?? 'world';
|
|
112
|
+
return Result.ok({ message: \`Hello, \${name}!\` });
|
|
113
|
+
},
|
|
114
|
+
input: z.object({
|
|
115
|
+
name: z.string().optional(),
|
|
116
|
+
}),
|
|
117
|
+
output: z.object({
|
|
118
|
+
message: z.string(),
|
|
119
|
+
}),
|
|
120
|
+
readOnly: true,
|
|
121
|
+
});
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
const generateEntityTrails = (): string =>
|
|
125
|
+
`import { Result, trail } from '@ontrails/core';
|
|
126
|
+
import { z } from 'zod';
|
|
127
|
+
|
|
128
|
+
const entitySchema = z.object({
|
|
129
|
+
id: z.string(),
|
|
130
|
+
name: z.string(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export const show = trail('entity.show', {
|
|
134
|
+
description: 'Show an entity by ID',
|
|
135
|
+
examples: [
|
|
136
|
+
{
|
|
137
|
+
expected: { id: '1', name: 'Example' },
|
|
138
|
+
input: { id: '1' },
|
|
139
|
+
name: 'Show entity',
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
implementation: (input) => {
|
|
143
|
+
return Result.ok({ id: input.id, name: 'Example' });
|
|
144
|
+
},
|
|
145
|
+
input: z.object({ id: z.string() }),
|
|
146
|
+
output: entitySchema,
|
|
147
|
+
readOnly: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export const add = trail('entity.add', {
|
|
151
|
+
description: 'Add a new entity',
|
|
152
|
+
examples: [
|
|
153
|
+
{
|
|
154
|
+
expected: { id: '1', name: 'New' },
|
|
155
|
+
input: { name: 'New' },
|
|
156
|
+
name: 'Add entity',
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
implementation: (input) => {
|
|
160
|
+
return Result.ok({ id: '1', name: input.name });
|
|
161
|
+
},
|
|
162
|
+
input: z.object({ name: z.string() }),
|
|
163
|
+
output: entitySchema,
|
|
164
|
+
});
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
const generateSearchTrail = (): string =>
|
|
168
|
+
`import { Result, trail } from '@ontrails/core';
|
|
169
|
+
import { z } from 'zod';
|
|
170
|
+
|
|
171
|
+
export const search = trail('search', {
|
|
172
|
+
description: 'Search entities by query',
|
|
173
|
+
examples: [
|
|
174
|
+
{
|
|
175
|
+
expected: { results: [] },
|
|
176
|
+
input: { query: 'test' },
|
|
177
|
+
name: 'Search entities',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
implementation: () => {
|
|
181
|
+
return Result.ok({ results: [] });
|
|
182
|
+
},
|
|
183
|
+
input: z.object({ query: z.string() }),
|
|
184
|
+
output: z.object({
|
|
185
|
+
results: z.array(z.object({ id: z.string(), name: z.string() })),
|
|
186
|
+
}),
|
|
187
|
+
readOnly: true,
|
|
188
|
+
});
|
|
189
|
+
`;
|
|
190
|
+
|
|
191
|
+
const generateOnboardHike = (): string =>
|
|
192
|
+
`import { Result, hike } from '@ontrails/core';
|
|
193
|
+
import { z } from 'zod';
|
|
194
|
+
|
|
195
|
+
export const onboard = hike('entity.onboard', {
|
|
196
|
+
description: 'Onboard a new entity end-to-end',
|
|
197
|
+
follows: ['entity.add'],
|
|
198
|
+
implementation: async (input, ctx) => {
|
|
199
|
+
const result = await ctx.follow('entity.add', { name: input.name });
|
|
200
|
+
if (result.isErr()) {
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
return Result.ok({ onboarded: true });
|
|
204
|
+
},
|
|
205
|
+
input: z.object({ name: z.string() }),
|
|
206
|
+
output: z.object({ onboarded: z.boolean() }),
|
|
207
|
+
});
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
const generateEntityEvents = (): string =>
|
|
211
|
+
`import { event } from '@ontrails/core';
|
|
212
|
+
import { z } from 'zod';
|
|
213
|
+
|
|
214
|
+
export const entityUpdated = event('entity.updated', {
|
|
215
|
+
description: 'Fired when an entity is updated',
|
|
216
|
+
payload: z.object({
|
|
217
|
+
entityId: z.string(),
|
|
218
|
+
updatedAt: z.string(),
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
`;
|
|
222
|
+
|
|
223
|
+
const generateStore = (): string =>
|
|
224
|
+
`/** In-memory store for entities. */
|
|
225
|
+
|
|
226
|
+
interface Entity {
|
|
227
|
+
readonly id: string;
|
|
228
|
+
readonly name: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const store = new Map<string, Entity>();
|
|
232
|
+
|
|
233
|
+
export const getEntity = (id: string): Entity | undefined => store.get(id);
|
|
234
|
+
export const addEntity = (entity: Entity): void => {
|
|
235
|
+
store.set(entity.id, entity);
|
|
236
|
+
};
|
|
237
|
+
export const deleteEntity = (id: string): boolean => store.delete(id);
|
|
238
|
+
export const listEntities = (): Entity[] => Array.from(store.values());
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
const starterImports: Record<
|
|
242
|
+
Starter,
|
|
243
|
+
{ imports: string[]; modules: string[] }
|
|
244
|
+
> = {
|
|
245
|
+
empty: { imports: [], modules: [] },
|
|
246
|
+
entity: {
|
|
247
|
+
imports: [
|
|
248
|
+
"import * as entity from './trails/entity.js';",
|
|
249
|
+
"import * as search from './trails/search.js';",
|
|
250
|
+
"import * as onboard from './trails/onboard.js';",
|
|
251
|
+
"import * as entityEvents from './events/entity-events.js';",
|
|
252
|
+
],
|
|
253
|
+
modules: ['entity', 'search', 'onboard', 'entityEvents'],
|
|
254
|
+
},
|
|
255
|
+
hello: {
|
|
256
|
+
imports: ["import * as hello from './trails/hello.js';"],
|
|
257
|
+
modules: ['hello'],
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const generateAppTs = (name: string, starter: Starter): string => {
|
|
262
|
+
const { imports, modules } = starterImports[starter];
|
|
263
|
+
const topoArgs =
|
|
264
|
+
modules.length > 0 ? `'${name}', ${modules.join(', ')}` : `'${name}'`;
|
|
265
|
+
|
|
266
|
+
return [
|
|
267
|
+
"import { topo } from '@ontrails/core';",
|
|
268
|
+
...imports,
|
|
269
|
+
'',
|
|
270
|
+
`export const app = topo(${topoArgs});`,
|
|
271
|
+
'',
|
|
272
|
+
].join('\n');
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// File collection and writing
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
const starterFileGenerators: Record<Starter, () => [string, string][]> = {
|
|
280
|
+
empty: () => [['src/trails/.gitkeep', '']],
|
|
281
|
+
entity: () => [
|
|
282
|
+
['src/trails/entity.ts', generateEntityTrails()],
|
|
283
|
+
['src/trails/search.ts', generateSearchTrail()],
|
|
284
|
+
['src/trails/onboard.ts', generateOnboardHike()],
|
|
285
|
+
['src/events/entity-events.ts', generateEntityEvents()],
|
|
286
|
+
['src/store.ts', generateStore()],
|
|
287
|
+
],
|
|
288
|
+
hello: () => [['src/trails/hello.ts', generateHelloTrail()]],
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const collectScaffoldFiles = (
|
|
292
|
+
name: string,
|
|
293
|
+
starter: Starter
|
|
294
|
+
): Map<string, string> =>
|
|
295
|
+
new Map([
|
|
296
|
+
['package.json', generatePackageJson(name)],
|
|
297
|
+
['tsconfig.json', TSCONFIG_CONTENT],
|
|
298
|
+
['.gitignore', GITIGNORE_CONTENT],
|
|
299
|
+
['.oxlintrc.json', OXLINTRC_CONTENT],
|
|
300
|
+
['.oxfmtrc.jsonc', OXFMTRC_CONTENT],
|
|
301
|
+
['src/app.ts', generateAppTs(name, starter)],
|
|
302
|
+
...starterFileGenerators[starter](),
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
const writeScaffoldFiles = async (
|
|
306
|
+
projectDir: string,
|
|
307
|
+
fileMap: Map<string, string>
|
|
308
|
+
): Promise<string[]> => {
|
|
309
|
+
const files: string[] = [];
|
|
310
|
+
for (const [relativePath, content] of fileMap) {
|
|
311
|
+
const fullPath = join(projectDir, relativePath);
|
|
312
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
313
|
+
await Bun.write(fullPath, content);
|
|
314
|
+
files.push(relativePath);
|
|
315
|
+
}
|
|
316
|
+
return files;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Trail definition
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
export const createScaffold = trail('create.scaffold', {
|
|
324
|
+
description: 'Scaffold a new Trails project',
|
|
325
|
+
implementation: async (input) => {
|
|
326
|
+
const projectDir = resolve(input.dir ?? '.', input.name);
|
|
327
|
+
const starter = (input.starter ?? 'hello') as Starter;
|
|
328
|
+
const fileMap = collectScaffoldFiles(input.name, starter);
|
|
329
|
+
const files = await writeScaffoldFiles(projectDir, fileMap);
|
|
330
|
+
mkdirSync(join(projectDir, '.trails'), { recursive: true });
|
|
331
|
+
|
|
332
|
+
return Result.ok({
|
|
333
|
+
created: files,
|
|
334
|
+
dir: projectDir,
|
|
335
|
+
name: input.name,
|
|
336
|
+
} satisfies ScaffoldResult);
|
|
337
|
+
},
|
|
338
|
+
input: z.object({
|
|
339
|
+
dir: z.string().optional().describe('Parent directory'),
|
|
340
|
+
name: z.string().describe('Project name'),
|
|
341
|
+
starter: z
|
|
342
|
+
.enum(['hello', 'entity', 'empty'])
|
|
343
|
+
.default('hello')
|
|
344
|
+
.describe('Starter trail'),
|
|
345
|
+
}),
|
|
346
|
+
markers: { internal: true },
|
|
347
|
+
output: z.object({
|
|
348
|
+
created: z.array(z.string()),
|
|
349
|
+
dir: z.string(),
|
|
350
|
+
name: z.string(),
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create` route -- Create a new Trails project.
|
|
3
|
+
*
|
|
4
|
+
* Composes create.scaffold, add.surface, and add.verify sub-trails
|
|
5
|
+
* via ctx.follow().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FollowFn } from '@ontrails/core';
|
|
9
|
+
import { Result, hike } from '@ontrails/core';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
type Starter = 'empty' | 'entity' | 'hello';
|
|
17
|
+
type Surface = 'cli' | 'mcp';
|
|
18
|
+
|
|
19
|
+
interface BlazeInput {
|
|
20
|
+
readonly dir?: string | undefined;
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly starter: Starter;
|
|
23
|
+
readonly surfaces: readonly Surface[];
|
|
24
|
+
readonly verify: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ScaffoldRequest {
|
|
28
|
+
readonly dir?: string | undefined;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly starter: Starter;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface VerifyRequest {
|
|
34
|
+
readonly dir?: string | undefined;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly verify: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ScaffoldedProject {
|
|
40
|
+
readonly created: string[];
|
|
41
|
+
readonly dir: string;
|
|
42
|
+
readonly name: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const buildScaffoldInput = (input: ScaffoldRequest) => ({
|
|
46
|
+
...(input.dir === undefined ? {} : { dir: input.dir }),
|
|
47
|
+
name: input.name,
|
|
48
|
+
starter: input.starter,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const buildSurfaceInput = (dir: string, surface: string) => ({
|
|
52
|
+
dir,
|
|
53
|
+
surface,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const buildVerifyInput = (input: VerifyRequest) => ({
|
|
57
|
+
...(input.dir === undefined ? {} : { dir: input.dir }),
|
|
58
|
+
name: input.name,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const scaffoldProject = (
|
|
62
|
+
follow: FollowFn,
|
|
63
|
+
input: ScaffoldRequest
|
|
64
|
+
): Promise<Result<ScaffoldedProject, Error>> =>
|
|
65
|
+
follow('create.scaffold', buildScaffoldInput(input));
|
|
66
|
+
|
|
67
|
+
const addSurfaceFiles = async (
|
|
68
|
+
follow: FollowFn,
|
|
69
|
+
dir: string,
|
|
70
|
+
surfaces: readonly string[]
|
|
71
|
+
): Promise<Result<string[], Error>> => {
|
|
72
|
+
const created: string[] = [];
|
|
73
|
+
|
|
74
|
+
for (const surface of surfaces) {
|
|
75
|
+
const result = await follow<{ created: string; dependency: string }>(
|
|
76
|
+
'add.surface',
|
|
77
|
+
buildSurfaceInput(dir, surface)
|
|
78
|
+
);
|
|
79
|
+
if (result.isErr()) {
|
|
80
|
+
return Result.err(result.error);
|
|
81
|
+
}
|
|
82
|
+
created.push(result.value.created);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Result.ok(created);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const collectVerifyFiles = async (
|
|
89
|
+
follow: FollowFn,
|
|
90
|
+
input: VerifyRequest
|
|
91
|
+
): Promise<Result<string[], Error>> => {
|
|
92
|
+
if (!input.verify) {
|
|
93
|
+
return Result.ok([]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result = await follow<{ created: string[] }>(
|
|
97
|
+
'add.verify',
|
|
98
|
+
buildVerifyInput(input)
|
|
99
|
+
);
|
|
100
|
+
return result.isErr()
|
|
101
|
+
? Result.err(result.error)
|
|
102
|
+
: Result.ok(result.value.created);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const collectCreatedFiles = (
|
|
106
|
+
scaffolded: readonly string[],
|
|
107
|
+
surfaces: readonly string[],
|
|
108
|
+
verify: readonly string[]
|
|
109
|
+
): string[] => [...scaffolded, ...surfaces, ...verify];
|
|
110
|
+
|
|
111
|
+
const runCreate = async (
|
|
112
|
+
follow: FollowFn,
|
|
113
|
+
input: BlazeInput
|
|
114
|
+
): Promise<Result<{ created: string[]; dir: string; name: string }, Error>> => {
|
|
115
|
+
const scaffolded = await scaffoldProject(follow, input);
|
|
116
|
+
if (scaffolded.isErr()) {
|
|
117
|
+
return Result.err(scaffolded.error);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const surfaceResults = await addSurfaceFiles(
|
|
121
|
+
follow,
|
|
122
|
+
scaffolded.value.dir,
|
|
123
|
+
input.surfaces
|
|
124
|
+
);
|
|
125
|
+
if (surfaceResults.isErr()) {
|
|
126
|
+
return Result.err(surfaceResults.error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const verifyFiles = await collectVerifyFiles(follow, input);
|
|
130
|
+
if (verifyFiles.isErr()) {
|
|
131
|
+
return Result.err(verifyFiles.error);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return Result.ok({
|
|
135
|
+
created: collectCreatedFiles(
|
|
136
|
+
scaffolded.value.created,
|
|
137
|
+
surfaceResults.value,
|
|
138
|
+
verifyFiles.value
|
|
139
|
+
),
|
|
140
|
+
dir: scaffolded.value.dir,
|
|
141
|
+
name: input.name,
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Route definition
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export const createRoute = hike('create', {
|
|
150
|
+
description: 'Create a new Trails project',
|
|
151
|
+
fields: {
|
|
152
|
+
starter: {
|
|
153
|
+
options: [
|
|
154
|
+
{
|
|
155
|
+
hint: 'One trail, one example',
|
|
156
|
+
label: 'Hello world',
|
|
157
|
+
value: 'hello',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
hint: '4 trails, hike, event, store',
|
|
161
|
+
label: 'Entity CRUD',
|
|
162
|
+
value: 'entity',
|
|
163
|
+
},
|
|
164
|
+
{ hint: 'Just the structure', label: 'Empty', value: 'empty' },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
surfaces: {
|
|
168
|
+
options: [
|
|
169
|
+
{ hint: 'Commander-based command line', label: 'CLI', value: 'cli' },
|
|
170
|
+
{
|
|
171
|
+
hint: 'Model Context Protocol for agents',
|
|
172
|
+
label: 'MCP',
|
|
173
|
+
value: 'mcp',
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
follows: ['create.scaffold', 'add.surface', 'add.verify'],
|
|
179
|
+
implementation: async (input: BlazeInput, ctx) => {
|
|
180
|
+
if (!ctx.follow) {
|
|
181
|
+
return Result.err(new Error('create route requires ctx.follow'));
|
|
182
|
+
}
|
|
183
|
+
return await runCreate(ctx.follow, input);
|
|
184
|
+
},
|
|
185
|
+
input: z.object({
|
|
186
|
+
dir: z.string().optional().describe('Parent directory'),
|
|
187
|
+
name: z.string().describe('Project name'),
|
|
188
|
+
starter: z
|
|
189
|
+
.enum(['hello', 'entity', 'empty'])
|
|
190
|
+
.default('hello')
|
|
191
|
+
.describe('Starter trail'),
|
|
192
|
+
surfaces: z
|
|
193
|
+
.array(z.enum(['cli', 'mcp']))
|
|
194
|
+
.default(['cli'])
|
|
195
|
+
.describe('Surfaces'),
|
|
196
|
+
verify: z.boolean().default(true).describe('Include testing + warden'),
|
|
197
|
+
}),
|
|
198
|
+
output: z.object({
|
|
199
|
+
created: z.array(z.string()),
|
|
200
|
+
dir: z.string(),
|
|
201
|
+
name: z.string(),
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `guide` trail -- Runtime guidance.
|
|
3
|
+
*
|
|
4
|
+
* Lists trails with descriptions and examples. Detailed guidance is planned for post-v1.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Topo, Trail } from '@ontrails/core';
|
|
8
|
+
import { Result, trail } from '@ontrails/core';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import { loadApp } from './load-app.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
interface GuideEntry {
|
|
18
|
+
readonly description: string;
|
|
19
|
+
readonly exampleCount: number;
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly kind: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const toGuideEntries = (app: Topo): GuideEntry[] => {
|
|
29
|
+
const entries: GuideEntry[] = [];
|
|
30
|
+
|
|
31
|
+
for (const item of app.list()) {
|
|
32
|
+
const raw = item as unknown as Record<string, unknown>;
|
|
33
|
+
entries.push({
|
|
34
|
+
description:
|
|
35
|
+
typeof raw['description'] === 'string'
|
|
36
|
+
? raw['description']
|
|
37
|
+
: '(no description)',
|
|
38
|
+
exampleCount: Array.isArray(raw['examples'])
|
|
39
|
+
? (raw['examples'] as unknown[]).length
|
|
40
|
+
: 0,
|
|
41
|
+
id: item.id,
|
|
42
|
+
kind: item.kind,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return entries;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const toGuideDetail = (item: Trail<unknown, unknown>): object => ({
|
|
50
|
+
description: item.description ?? null,
|
|
51
|
+
detours: item.detours ?? null,
|
|
52
|
+
examples: item.examples ?? [],
|
|
53
|
+
id: item.id,
|
|
54
|
+
kind: item.kind,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const guideTrail = trail('guide', {
|
|
58
|
+
description: 'Runtime guidance for trails',
|
|
59
|
+
examples: [
|
|
60
|
+
{
|
|
61
|
+
description: 'Lists all trails with descriptions and example counts',
|
|
62
|
+
input: {},
|
|
63
|
+
name: 'List trail guidance',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
implementation: async (input, ctx) => {
|
|
67
|
+
const app = await loadApp(input.module, ctx.cwd ?? '.');
|
|
68
|
+
|
|
69
|
+
if (input.trailId) {
|
|
70
|
+
const item = app.get(input.trailId);
|
|
71
|
+
if (!item) {
|
|
72
|
+
return Result.err(new Error(`Trail not found: ${input.trailId}`));
|
|
73
|
+
}
|
|
74
|
+
return Result.ok(toGuideDetail(item as Trail<unknown, unknown>));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return Result.ok(toGuideEntries(app));
|
|
78
|
+
},
|
|
79
|
+
input: z.object({
|
|
80
|
+
module: z
|
|
81
|
+
.string()
|
|
82
|
+
.default('./src/app.ts')
|
|
83
|
+
.describe('Path to the app module'),
|
|
84
|
+
trailId: z.string().optional().describe('Trail ID for detailed guidance'),
|
|
85
|
+
}),
|
|
86
|
+
output: z.union([
|
|
87
|
+
z.array(
|
|
88
|
+
z.object({
|
|
89
|
+
description: z.string(),
|
|
90
|
+
exampleCount: z.number(),
|
|
91
|
+
id: z.string(),
|
|
92
|
+
kind: z.string(),
|
|
93
|
+
})
|
|
94
|
+
),
|
|
95
|
+
z.object({
|
|
96
|
+
description: z.string().nullable(),
|
|
97
|
+
detours: z.unknown().nullable(),
|
|
98
|
+
examples: z.array(z.unknown()),
|
|
99
|
+
id: z.string(),
|
|
100
|
+
kind: z.string(),
|
|
101
|
+
}),
|
|
102
|
+
]),
|
|
103
|
+
readOnly: true,
|
|
104
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import type { Topo } from '@ontrails/core';
|
|
5
|
+
|
|
6
|
+
const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
|
|
7
|
+
|
|
8
|
+
/** Resolve a module path from cwd so CLI defaults behave like shell paths. */
|
|
9
|
+
const resolveModuleSpecifier = (modulePath: string, cwd: string): string => {
|
|
10
|
+
if (URL_SCHEME.test(modulePath)) {
|
|
11
|
+
return modulePath;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const absolutePath = isAbsolute(modulePath)
|
|
15
|
+
? modulePath
|
|
16
|
+
: resolve(cwd, modulePath);
|
|
17
|
+
return pathToFileURL(absolutePath).href;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Load a Topo export from a module path relative to cwd. */
|
|
21
|
+
export const loadApp = async (
|
|
22
|
+
modulePath: string,
|
|
23
|
+
cwd: string
|
|
24
|
+
): Promise<Topo> => {
|
|
25
|
+
const mod = (await import(resolveModuleSpecifier(modulePath, cwd))) as Record<
|
|
26
|
+
string,
|
|
27
|
+
unknown
|
|
28
|
+
>;
|
|
29
|
+
const app = (mod['default'] ?? mod['app']) as Topo | undefined;
|
|
30
|
+
if (!app?.trails) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Could not find a Topo export in "${modulePath}". ` +
|
|
33
|
+
"Expected a default or named 'app' export created with topo()."
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return app;
|
|
37
|
+
};
|