@ontrails/trails 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,1215 @@
1
+ /**
2
+ * `create.scaffold` trail -- Creates base project structure.
3
+ *
4
+ * Generates package.json, tsconfig, app.ts, starter trails, and scaffold provenance.
5
+ */
6
+
7
+ import { existsSync, realpathSync } from 'node:fs';
8
+ import { dirname, join, relative, resolve } from 'node:path';
9
+
10
+ import { readTrailsProjectIdentity } from '@ontrails/config';
11
+ import { Result, trail, ValidationError } from '@ontrails/core';
12
+ import type { Result as TrailsResult } from '@ontrails/core';
13
+ import { z } from 'zod';
14
+
15
+ import {
16
+ applyProjectOperations,
17
+ planProjectOperations,
18
+ PROJECT_NAME_MESSAGE,
19
+ PROJECT_NAME_PATTERN,
20
+ resolveProjectDir,
21
+ } from '../project-writes.js';
22
+ import type {
23
+ PlannedProjectOperation,
24
+ ProjectWriteOperation,
25
+ } from '../project-writes.js';
26
+ import { parseSemver } from '../release/semver.js';
27
+ import {
28
+ ontrailsPackageRange,
29
+ scaffoldDependencyVersions,
30
+ trailsPackageVersion,
31
+ } from '../versions.js';
32
+ import { isCanonicalLintCommand } from './add-surface.js';
33
+ import {
34
+ assertConfiguredAppBinding,
35
+ resolveOperatorCollectionBoundary,
36
+ } from './project-context.js';
37
+ import { stringifyScaffoldPackageJson } from './scaffold-json.js';
38
+ import { deriveStaticSelectedTopoId } from './scaffold-topo-identity.js';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Types
42
+ // ---------------------------------------------------------------------------
43
+
44
+ type Starter = 'empty' | 'entity' | 'hello';
45
+ type ScaffoldLayout = 'standalone' | 'workspace';
46
+
47
+ interface ScaffoldResult {
48
+ readonly appDir: string;
49
+ readonly appRoot: string;
50
+ readonly created: string[];
51
+ readonly dir: string;
52
+ readonly dryRun: boolean;
53
+ readonly layout: ScaffoldLayout;
54
+ readonly name: string;
55
+ readonly plannedOperations: PlannedProjectOperation[];
56
+ }
57
+
58
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
59
+ typeof value === 'object' && value !== null && !Array.isArray(value);
60
+
61
+ const frameworkCommandScripts = {
62
+ add: 'trails add',
63
+ compile: 'trails compile',
64
+ completions: 'trails completions',
65
+ deprecate: 'trails deprecate',
66
+ diff: 'trails diff',
67
+ doctor: 'trails doctor',
68
+ guide: 'trails guide',
69
+ revise: 'trails revise',
70
+ run: 'trails run',
71
+ survey: 'trails survey',
72
+ topo: 'trails topo',
73
+ validate: 'trails validate',
74
+ warden: 'trails warden',
75
+ } as const satisfies Record<string, string>;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Content generators
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const generateAppPackageJson = (name: string): string => {
82
+ const deps: Record<string, string> = {
83
+ '@ontrails/core': ontrailsPackageRange,
84
+ zod: scaffoldDependencyVersions.zod,
85
+ };
86
+
87
+ const pkg: Record<string, unknown> = {
88
+ dependencies: Object.fromEntries(
89
+ Object.entries(deps).toSorted(([a], [b]) => a.localeCompare(b))
90
+ ),
91
+ devDependencies: Object.fromEntries(
92
+ Object.entries({
93
+ '@ontrails/trails': ontrailsPackageRange,
94
+ '@types/bun': scaffoldDependencyVersions.bunTypes,
95
+ oxfmt: scaffoldDependencyVersions.oxfmt,
96
+ oxlint: scaffoldDependencyVersions.oxlint,
97
+ typescript: scaffoldDependencyVersions.typescript,
98
+ ultracite: scaffoldDependencyVersions.ultracite,
99
+ }).toSorted(([a], [b]) => a.localeCompare(b))
100
+ ),
101
+ name,
102
+ scripts: Object.fromEntries(
103
+ Object.entries({
104
+ build: 'tsc -b',
105
+ 'format:check': 'bunx ultracite check .',
106
+ 'format:fix': 'bunx ultracite fix .',
107
+ lint: 'oxlint ./src ./bin',
108
+ test: 'bun test',
109
+ typecheck: 'tsc --noEmit',
110
+ ...frameworkCommandScripts,
111
+ }).toSorted(([a], [b]) => a.localeCompare(b))
112
+ ),
113
+ type: 'module',
114
+ version: '0.1.0',
115
+ };
116
+
117
+ return stringifyScaffoldPackageJson(pkg);
118
+ };
119
+
120
+ const mergeScaffoldOwnedMap = (
121
+ existing: unknown,
122
+ required: Record<string, unknown>,
123
+ field: string,
124
+ contract = 'generated app contract'
125
+ ): Record<string, unknown> => {
126
+ if (existing !== undefined && !isPlainRecord(existing)) {
127
+ throw new TypeError(`${field} must be an object when present`);
128
+ }
129
+
130
+ const entries = existing ?? {};
131
+ for (const [key, value] of Object.entries(required)) {
132
+ const existingValue = entries[key];
133
+ if (existingValue !== undefined && existingValue !== value) {
134
+ throw new TypeError(
135
+ `${field}.${key} must remain ${JSON.stringify(value)} for the ${contract}`
136
+ );
137
+ }
138
+ }
139
+
140
+ return { ...required, ...entries };
141
+ };
142
+
143
+ /**
144
+ * Earlier scaffold generations pinned narrower lint entry scopes. Treat those
145
+ * generated commands as reconcilable so a rerun preserves the established
146
+ * layout instead of hard-conflicting. Surface entry roots stay owned by the
147
+ * surface resolver, which still rejects a requested surface the preserved
148
+ * scope excludes.
149
+ */
150
+ const reconcileGeneratedScripts = (
151
+ existing: unknown,
152
+ generated: Record<string, unknown>
153
+ ): Record<string, unknown> => {
154
+ if (!isPlainRecord(existing)) {
155
+ return generated;
156
+ }
157
+
158
+ const existingLint = existing['lint'];
159
+ if (
160
+ typeof existingLint !== 'string' ||
161
+ existingLint === generated['lint'] ||
162
+ !isCanonicalLintCommand(existingLint)
163
+ ) {
164
+ return generated;
165
+ }
166
+
167
+ return { ...generated, lint: existingLint };
168
+ };
169
+
170
+ /**
171
+ * Scaffold-owned `@ontrails/*` entries pin the exact operator version that
172
+ * generated them, so every release makes an earlier project's pin differ from
173
+ * the current one. Treat a plain semver value on those keys as a recognized
174
+ * prior generated pin so a rerun reconciles instead of hard-conflicting.
175
+ * Anything else -- `workspace:*`, a caret range, a dist-tag, a URL -- is user
176
+ * customization and still fails closed.
177
+ *
178
+ * Unlike `reconcileGeneratedScripts`, which preserves the established command,
179
+ * this reconciler upgrades the pin to the current range: the rerun installs
180
+ * current-version scaffold files beside it, and surface addition already
181
+ * rewrites its own `@ontrails/*` dependencies the same way, so a preserved
182
+ * stale pin would be the incoherent outcome. The general per-key reconciler
183
+ * policy is tracked as TRL-1323.
184
+ */
185
+ const upgradePriorOntrailsPins = (
186
+ existing: unknown,
187
+ required: unknown
188
+ ): unknown => {
189
+ if (!(isPlainRecord(existing) && isPlainRecord(required))) {
190
+ return existing;
191
+ }
192
+
193
+ const upgrades = Object.entries(required).filter(([key, value]) => {
194
+ const existingValue = existing[key];
195
+ return (
196
+ key.startsWith('@ontrails/') &&
197
+ value === ontrailsPackageRange &&
198
+ typeof existingValue === 'string' &&
199
+ parseSemver(existingValue) !== undefined
200
+ );
201
+ });
202
+
203
+ return upgrades.length === 0
204
+ ? existing
205
+ : { ...existing, ...Object.fromEntries(upgrades) };
206
+ };
207
+
208
+ const mergeAppPackageJson = (
209
+ source: string,
210
+ name: string
211
+ ): Record<string, unknown> => {
212
+ const parsed: unknown = JSON.parse(source);
213
+ if (!isPlainRecord(parsed)) {
214
+ throw new TypeError('the app manifest root value must be an object');
215
+ }
216
+
217
+ const generated = JSON.parse(generateAppPackageJson(name)) as Record<
218
+ string,
219
+ unknown
220
+ >;
221
+ const existingType = parsed['type'];
222
+ if (existingType !== undefined && existingType !== generated['type']) {
223
+ throw new TypeError(
224
+ `type must remain ${JSON.stringify(generated['type'])} for the generated app contract`
225
+ );
226
+ }
227
+
228
+ return {
229
+ ...generated,
230
+ ...parsed,
231
+ dependencies: mergeScaffoldOwnedMap(
232
+ upgradePriorOntrailsPins(
233
+ parsed['dependencies'],
234
+ generated['dependencies']
235
+ ),
236
+ generated['dependencies'] as Record<string, unknown>,
237
+ 'dependencies'
238
+ ),
239
+ devDependencies: mergeScaffoldOwnedMap(
240
+ upgradePriorOntrailsPins(
241
+ parsed['devDependencies'],
242
+ generated['devDependencies']
243
+ ),
244
+ generated['devDependencies'] as Record<string, unknown>,
245
+ 'devDependencies'
246
+ ),
247
+ scripts: mergeScaffoldOwnedMap(
248
+ parsed['scripts'],
249
+ reconcileGeneratedScripts(
250
+ parsed['scripts'],
251
+ generated['scripts'] as Record<string, unknown>
252
+ ),
253
+ 'scripts'
254
+ ),
255
+ type: generated['type'],
256
+ };
257
+ };
258
+
259
+ const requiredWorkspaceScripts = {
260
+ build: 'bun run --filter "*" build',
261
+ test: 'bun run --filter "*" test',
262
+ typecheck: 'bun run --filter "*" typecheck',
263
+ } as const;
264
+
265
+ const requiredWorkspaceDevDependencies = {
266
+ '@ontrails/trails': ontrailsPackageRange,
267
+ } as const;
268
+
269
+ const generateWorkspacePackageJson = (name: string): string =>
270
+ stringifyScaffoldPackageJson({
271
+ devDependencies: requiredWorkspaceDevDependencies,
272
+ name,
273
+ private: true,
274
+ scripts: requiredWorkspaceScripts,
275
+ workspaces: ['apps/*', 'packages/*'],
276
+ });
277
+
278
+ const generateWorkspaceConfig = (name: string): string =>
279
+ `export default {
280
+ workspace: {
281
+ apps: {
282
+ ${/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(name) ? name : `'${name}'`}: {
283
+ root: 'apps/${name}',
284
+ },
285
+ },
286
+ },
287
+ };
288
+ `;
289
+
290
+ const validateRequiredWorkspaceScripts = (
291
+ existingScripts: Record<string, unknown> | undefined
292
+ ): void => {
293
+ for (const [script, command] of Object.entries(requiredWorkspaceScripts)) {
294
+ const existingCommand = existingScripts?.[script];
295
+ if (existingCommand !== undefined && existingCommand !== command) {
296
+ throw new TypeError(
297
+ `scripts.${script} must remain "${command}" so the root command reaches every generated app`
298
+ );
299
+ }
300
+ }
301
+ };
302
+
303
+ interface PreparedScaffoldFiles {
304
+ readonly files: Map<string, string>;
305
+ readonly overwritePaths: ReadonlySet<string>;
306
+ }
307
+
308
+ const nearestExistingDirectory = (path: string): string => {
309
+ let current = resolve(path);
310
+ while (!existsSync(current)) {
311
+ const parent = dirname(current);
312
+ if (parent === current) {
313
+ return current;
314
+ }
315
+ current = parent;
316
+ }
317
+ return current;
318
+ };
319
+
320
+ const canonicalScaffoldPath = (path: string): string => {
321
+ const resolvedPath = resolve(path);
322
+ const existingDirectory = nearestExistingDirectory(resolvedPath);
323
+ return resolve(
324
+ realpathSync.native(existingDirectory),
325
+ relative(existingDirectory, resolvedPath)
326
+ );
327
+ };
328
+
329
+ const mergeWorkspacePackageJson = (source: string): Record<string, unknown> => {
330
+ const parsed: unknown = JSON.parse(source);
331
+ if (!isPlainRecord(parsed)) {
332
+ throw new TypeError('the root value must be an object');
333
+ }
334
+ const existingScripts = parsed['scripts'];
335
+ if (existingScripts !== undefined && !isPlainRecord(existingScripts)) {
336
+ throw new TypeError('scripts must be an object when present');
337
+ }
338
+ const existingWorkspaces = parsed['workspaces'];
339
+ if (
340
+ existingWorkspaces !== undefined &&
341
+ (!Array.isArray(existingWorkspaces) ||
342
+ existingWorkspaces.some((value) => typeof value !== 'string'))
343
+ ) {
344
+ throw new TypeError('workspaces must be an array of strings when present');
345
+ }
346
+ validateRequiredWorkspaceScripts(existingScripts);
347
+ return {
348
+ ...parsed,
349
+ devDependencies: mergeScaffoldOwnedMap(
350
+ upgradePriorOntrailsPins(
351
+ parsed['devDependencies'],
352
+ requiredWorkspaceDevDependencies
353
+ ),
354
+ requiredWorkspaceDevDependencies,
355
+ 'devDependencies',
356
+ 'generated workspace contract'
357
+ ),
358
+ private: true,
359
+ scripts: { ...requiredWorkspaceScripts, ...existingScripts },
360
+ workspaces: [
361
+ ...new Set([
362
+ ...((existingWorkspaces as string[] | undefined) ?? []),
363
+ 'apps/*',
364
+ 'packages/*',
365
+ ]),
366
+ ],
367
+ };
368
+ };
369
+
370
+ interface ExistingManifestReconciliation {
371
+ readonly files: Map<string, string>;
372
+ readonly merge: (source: string) => Record<string, unknown>;
373
+ readonly message: string;
374
+ readonly overwritePaths: Set<string>;
375
+ readonly path: string;
376
+ readonly projectDir: string;
377
+ readonly reason: string;
378
+ }
379
+
380
+ const reconcileExistingManifest = async ({
381
+ files,
382
+ merge,
383
+ message,
384
+ overwritePaths,
385
+ path,
386
+ projectDir,
387
+ reason,
388
+ }: ExistingManifestReconciliation): Promise<TrailsResult<void, Error>> => {
389
+ const manifestPath = join(projectDir, path);
390
+ const manifestFile = Bun.file(manifestPath);
391
+ if (!(await manifestFile.exists())) {
392
+ return Result.ok();
393
+ }
394
+
395
+ try {
396
+ const source = await manifestFile.text();
397
+ const merged = stringifyScaffoldPackageJson(merge(source));
398
+ if (merged === source) {
399
+ files.delete(path);
400
+ } else {
401
+ files.set(path, merged);
402
+ overwritePaths.add(path);
403
+ }
404
+ return Result.ok();
405
+ } catch (error) {
406
+ return Result.err(
407
+ new ValidationError(`${message} at ${manifestPath}.`, {
408
+ ...(error instanceof Error ? { cause: error } : {}),
409
+ context: { path: manifestPath, reason },
410
+ })
411
+ );
412
+ }
413
+ };
414
+
415
+ const prepareWorkspaceScaffoldFiles = async (
416
+ projectDir: string,
417
+ name: string,
418
+ sourceFiles: Map<string, string>,
419
+ standaloneTsconfig: string
420
+ ): Promise<TrailsResult<PreparedScaffoldFiles, Error>> => {
421
+ const files = new Map(sourceFiles);
422
+ const overwritePaths = new Set<string>();
423
+
424
+ try {
425
+ const discoveryStart = nearestExistingDirectory(projectDir);
426
+ const identity = await readTrailsProjectIdentity({
427
+ boundaryDir: await resolveOperatorCollectionBoundary(discoveryStart),
428
+ startDir: discoveryStart,
429
+ });
430
+ const identityMatchesTarget =
431
+ canonicalScaffoldPath(identity.rootDir) ===
432
+ canonicalScaffoldPath(projectDir);
433
+ if (identity.workspace !== undefined && !identityMatchesTarget) {
434
+ return Result.err(
435
+ new ValidationError(
436
+ `Cannot create a nested Trails workspace at "${projectDir}" inside the workspace owned by "${identity.rootDir}". Add the app to the existing workspace or choose a target outside it.`,
437
+ {
438
+ context: {
439
+ configPath: identity.configPath,
440
+ existingWorkspaceRoot: identity.rootDir,
441
+ reason: 'nested-workspace-target',
442
+ targetRoot: projectDir,
443
+ },
444
+ }
445
+ )
446
+ );
447
+ }
448
+
449
+ if (identity.configPath !== undefined && identityMatchesTarget) {
450
+ const expectedRoot = `apps/${name}`;
451
+ const expectedEntry = 'src/app.ts';
452
+ const app = identity.apps.find((candidate) => candidate.id === name);
453
+ if (app?.root !== expectedRoot || app.entry !== expectedEntry) {
454
+ return Result.err(
455
+ new ValidationError(
456
+ `Cannot reconcile existing Trails Config with workspace app "${name}" at root "${expectedRoot}" and entry "${expectedEntry}". Update workspace.apps first or choose a different target directory.`,
457
+ {
458
+ context: {
459
+ configuredAppIds: identity.apps.map(
460
+ (candidate) => candidate.id
461
+ ),
462
+ configuredEntry: app?.entry,
463
+ configuredRoot: app?.root,
464
+ expectedAppId: name,
465
+ expectedEntry,
466
+ expectedRoot,
467
+ paths: [identity.configPath],
468
+ reason: 'incompatible-workspace-config',
469
+ },
470
+ }
471
+ )
472
+ );
473
+ }
474
+ const entryFile = Bun.file(app.entryPath);
475
+ if (await entryFile.exists()) {
476
+ const authoredTopoId = deriveStaticSelectedTopoId(
477
+ app.entryPath,
478
+ await entryFile.text()
479
+ );
480
+ if (authoredTopoId !== undefined) {
481
+ const binding = assertConfiguredAppBinding(
482
+ {
483
+ app: { id: app.id, modulePath: app.modulePath },
484
+ projectRoot: identity.rootDir,
485
+ },
486
+ authoredTopoId
487
+ );
488
+ if (binding.isErr()) {
489
+ return binding;
490
+ }
491
+ }
492
+ }
493
+ files.delete('trails.config.ts');
494
+ }
495
+ } catch (error) {
496
+ return Result.err(
497
+ error instanceof Error
498
+ ? error
499
+ : new ValidationError('Unable to read existing Trails Config.')
500
+ );
501
+ }
502
+
503
+ const workspaceManifest = await reconcileExistingManifest({
504
+ files,
505
+ merge: mergeWorkspacePackageJson,
506
+ message: 'Cannot reconcile the existing workspace package.json',
507
+ overwritePaths,
508
+ path: 'package.json',
509
+ projectDir,
510
+ reason: 'invalid-workspace-manifest',
511
+ });
512
+ if (workspaceManifest.isErr()) {
513
+ return workspaceManifest;
514
+ }
515
+
516
+ const appManifestRelativePath = `apps/${name}/package.json`;
517
+ const appManifest = await reconcileExistingManifest({
518
+ files,
519
+ merge: (source) => mergeAppPackageJson(source, name),
520
+ message: 'Cannot reconcile the existing app package.json',
521
+ overwritePaths,
522
+ path: appManifestRelativePath,
523
+ projectDir,
524
+ reason: 'invalid-app-manifest',
525
+ });
526
+ if (appManifest.isErr()) {
527
+ return appManifest;
528
+ }
529
+
530
+ if (await Bun.file(join(projectDir, 'tsconfig.base.json')).exists()) {
531
+ files.set(`apps/${name}/tsconfig.json`, standaloneTsconfig);
532
+ }
533
+
534
+ return Result.ok({ files, overwritePaths });
535
+ };
536
+
537
+ const prepareStandaloneScaffoldFiles = async (
538
+ projectDir: string,
539
+ name: string,
540
+ sourceFiles: Map<string, string>
541
+ ): Promise<TrailsResult<PreparedScaffoldFiles, Error>> => {
542
+ const files = new Map(sourceFiles);
543
+ const overwritePaths = new Set<string>();
544
+ const appManifest = await reconcileExistingManifest({
545
+ files,
546
+ merge: (source) => mergeAppPackageJson(source, name),
547
+ message: 'Cannot reconcile the existing app package.json',
548
+ overwritePaths,
549
+ path: 'package.json',
550
+ projectDir,
551
+ reason: 'invalid-app-manifest',
552
+ });
553
+ return appManifest.isErr()
554
+ ? appManifest
555
+ : Result.ok({ files, overwritePaths });
556
+ };
557
+
558
+ const TSCONFIG_CONTENT = `{
559
+ "compilerOptions": {
560
+ "declaration": true,
561
+ "module": "ESNext",
562
+ "moduleResolution": "bundler",
563
+ "noUncheckedIndexedAccess": true,
564
+ "outDir": "dist",
565
+ "rootDir": ".",
566
+ "skipLibCheck": true,
567
+ "strict": true,
568
+ "target": "ESNext",
569
+ "verbatimModuleSyntax": true
570
+ },
571
+ "include": ["bin", "src"]
572
+ }
573
+ `;
574
+
575
+ const TSCONFIG_BASE_CONTENT = `{
576
+ "compilerOptions": {
577
+ "declaration": true,
578
+ "module": "ESNext",
579
+ "moduleResolution": "bundler",
580
+ "noUncheckedIndexedAccess": true,
581
+ "skipLibCheck": true,
582
+ "strict": true,
583
+ "target": "ESNext",
584
+ "verbatimModuleSyntax": true
585
+ }
586
+ }
587
+ `;
588
+
589
+ const TSCONFIG_WORKSPACE_APP_CONTENT = `{
590
+ "compilerOptions": {
591
+ "outDir": "dist",
592
+ "rootDir": "."
593
+ },
594
+ "extends": "../../tsconfig.base.json",
595
+ "include": ["bin", "src"]
596
+ }
597
+ `;
598
+
599
+ const TSCONFIG_TESTS_CONTENT = `{
600
+ "compilerOptions": {
601
+ "noEmit": true,
602
+ "rootDir": ".",
603
+ "types": ["bun"]
604
+ },
605
+ "exclude": [],
606
+ "extends": "./tsconfig.json",
607
+ "include": ["src", "__tests__"]
608
+ }
609
+ `;
610
+
611
+ const AGENTS_CONTENT = `# AGENTS.md
612
+
613
+ This is a Trails project. Trails is an agent-native, contract-first TypeScript framework: author a trail once with typed input, Result output, examples, intent, and meta; surface it through CLI, MCP, HTTP, or future WebSocket without rewriting the contract.
614
+
615
+ ## Commands
616
+
617
+ Use the project scripts first:
618
+
619
+ \`\`\`bash
620
+ bun install
621
+ bun run build
622
+ bun test
623
+ bun run typecheck
624
+ bun run lint
625
+ bun run format:check
626
+ bun run warden
627
+ bun run survey
628
+ bun run guide
629
+ \`\`\`
630
+
631
+ ## Lexicon
632
+
633
+ - \`trail\`, not action or handler
634
+ - \`implementation\`, not handler or impl
635
+ - \`topo\`, not registry or collection
636
+ - \`compose\`, not follow
637
+ - \`surface\`, not transport
638
+ - \`resource\`, not service or dependency
639
+ - \`layer\`, for cross-cutting trail wrapping
640
+
641
+ ## Trail Rules
642
+
643
+ - Implementations return \`Result\`; never throw from trail logic.
644
+ - Use \`Result.ok()\` and \`Result.err()\`; branch with \`isOk()\`, \`isErr()\`, or \`match()\`.
645
+ - Keep trail logic surface-agnostic. Do not import CLI, MCP, HTTP, request, or response types into implementations.
646
+ - Public MCP or HTTP trails declare an \`output\` schema.
647
+ - Trails that compose other trails declare \`composes: [...]\` and invoke them with \`ctx.compose(...)\`.
648
+ - Trails that use infrastructure declare \`resources: [...]\` and access them through the resource helpers.
649
+ - Use \`detours\` for recovery strategies instead of inline retry logic.
650
+ - Prefer examples for happy-path coverage, and add focused tests for edge cases.
651
+ `;
652
+
653
+ const CLAUDE_CONTENT = `# CLAUDE.md
654
+
655
+ ## Compatibility Shim
656
+
657
+ Keep shared project guidance in \`./AGENTS.md\`. Only Claude-specific bootstrap notes belong here.
658
+
659
+ ## Agent Instructions
660
+
661
+ @AGENTS.md
662
+ `;
663
+
664
+ const GITIGNORE_CONTENT = `node_modules/
665
+ dist/
666
+ *.tsbuildinfo
667
+ trails.config.local.*
668
+ `;
669
+
670
+ const OXLINT_CONFIG_CONTENT = `import { defineConfig } from 'oxlint';
671
+ import ultracite from 'ultracite/oxlint/core';
672
+
673
+ export default defineConfig({
674
+ extends: [ultracite],
675
+ rules: {
676
+ 'no-warning-comments': [
677
+ 'error',
678
+ {
679
+ location: 'start',
680
+ terms: ['todo:', 'fixme', 'xxx'],
681
+ },
682
+ ],
683
+ },
684
+ });
685
+ `;
686
+
687
+ const OXFMTRC_CONTENT = `{
688
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
689
+ "tabWidth": 2,
690
+ "useTabs": false,
691
+ "semi": true,
692
+ "singleQuote": true,
693
+ "trailingComma": "es5",
694
+ "bracketSpacing": true,
695
+ "arrowParens": "always",
696
+ "proseWrap": "never",
697
+ "printWidth": 80,
698
+ "ignorePatterns": ["**/.trails/regrade/history/**"],
699
+ }
700
+ `;
701
+
702
+ const generateHelloTrail = (): string =>
703
+ `import { Result, trail } from '@ontrails/core';
704
+ import { z } from 'zod';
705
+
706
+ export const hello = trail('hello', {
707
+ description: 'Say hello',
708
+ examples: [
709
+ {
710
+ expected: { message: 'Hello, world!' },
711
+ input: {},
712
+ name: 'Default greeting',
713
+ },
714
+ {
715
+ expected: { message: 'Hello, Trails!' },
716
+ input: { name: 'Trails' },
717
+ name: 'Named greeting',
718
+ },
719
+ ],
720
+ implementation: (input) => {
721
+ const name = input.name ?? 'world';
722
+ return Result.ok({ message: \`Hello, \${name}!\` });
723
+ },
724
+ input: z
725
+ .object({
726
+ name: z.string().optional(),
727
+ })
728
+ .default({}),
729
+ intent: 'read',
730
+ output: z.object({
731
+ message: z.string(),
732
+ }),
733
+ });
734
+ `;
735
+
736
+ const generateEntityTrails = (): string =>
737
+ `import { randomUUID } from 'node:crypto';
738
+
739
+ import { NotFoundError, Result, trail } from '@ontrails/core';
740
+ import { z } from 'zod';
741
+
742
+ import { entityStore } from '../store.js';
743
+
744
+ const entitySchema = z.object({
745
+ id: z.string(),
746
+ name: z.string(),
747
+ });
748
+
749
+ export const show = trail('entity.show', {
750
+ description: 'Show an entity by ID',
751
+ examples: [
752
+ {
753
+ expected: { id: '1', name: 'Example' },
754
+ input: { id: '1' },
755
+ name: 'Show entity',
756
+ },
757
+ ],
758
+ implementation: (input, ctx) => {
759
+ const store = entityStore.from(ctx);
760
+ const entity = store.get(input.id);
761
+ if (!entity) {
762
+ return Result.err(new NotFoundError(\`Entity "\${input.id}" not found\`));
763
+ }
764
+ return Result.ok(entity);
765
+ },
766
+ input: z.object({ id: z.string() }),
767
+ intent: 'read',
768
+ output: entitySchema,
769
+ resources: [entityStore],
770
+ });
771
+
772
+ export const add = trail('entity.add', {
773
+ description: 'Add a new entity',
774
+ examples: [
775
+ {
776
+ expectedMatch: { name: 'New' },
777
+ input: { name: 'New' },
778
+ name: 'Add entity',
779
+ },
780
+ ],
781
+ implementation: (input, ctx) => {
782
+ const store = entityStore.from(ctx);
783
+ const entity = { id: randomUUID(), name: input.name };
784
+ store.add(entity);
785
+ return Result.ok(entity);
786
+ },
787
+ input: z.object({ name: z.string() }),
788
+ intent: 'write',
789
+ output: entitySchema,
790
+ permit: { scopes: ['entity:write'] },
791
+ resources: [entityStore],
792
+ });
793
+
794
+ export const list = trail('entity.list', {
795
+ description: 'List entities',
796
+ examples: [
797
+ {
798
+ expected: { entities: [{ id: '1', name: 'Example' }] },
799
+ input: {},
800
+ name: 'List entities',
801
+ },
802
+ ],
803
+ implementation: (_input, ctx) => {
804
+ const store = entityStore.from(ctx);
805
+ return Result.ok({ entities: store.list() });
806
+ },
807
+ input: z.object({}).default({}),
808
+ intent: 'read',
809
+ output: z.object({
810
+ entities: z.array(entitySchema),
811
+ }),
812
+ resources: [entityStore],
813
+ });
814
+
815
+ export const remove = trail('entity.delete', {
816
+ description: 'Delete an entity by ID',
817
+ examples: [
818
+ {
819
+ expected: { deleted: true, id: '1' },
820
+ input: { id: '1' },
821
+ name: 'Delete entity',
822
+ },
823
+ ],
824
+ implementation: (input, ctx) => {
825
+ const store = entityStore.from(ctx);
826
+ const deleted = store.delete(input.id);
827
+ return Result.ok({ deleted, id: input.id });
828
+ },
829
+ input: z.object({ id: z.string() }),
830
+ intent: 'destroy',
831
+ output: z.object({
832
+ deleted: z.boolean(),
833
+ id: z.string(),
834
+ }),
835
+ permit: { scopes: ['entity:write'] },
836
+ resources: [entityStore],
837
+ });
838
+ `;
839
+
840
+ const generateSearchTrail = (): string =>
841
+ `import { Result, trail } from '@ontrails/core';
842
+ import { z } from 'zod';
843
+
844
+ export const search = trail('search', {
845
+ description: 'Search entities by query',
846
+ examples: [
847
+ {
848
+ expected: { results: [] },
849
+ input: { query: 'test' },
850
+ name: 'Search entities',
851
+ },
852
+ ],
853
+ implementation: () => Result.ok({ results: [] }),
854
+ input: z.object({ query: z.string() }),
855
+ intent: 'read',
856
+ output: z.object({
857
+ results: z.array(z.object({ id: z.string(), name: z.string() })),
858
+ }),
859
+ });
860
+ `;
861
+
862
+ const generateOnboardTrail = (): string =>
863
+ `import { Result, trail } from '@ontrails/core';
864
+ import { z } from 'zod';
865
+
866
+ export const onboard = trail('entity.onboard', {
867
+ composes: ['entity.add'],
868
+ description: 'Onboard a new entity end-to-end',
869
+ implementation: async (input, ctx) => {
870
+ const result = await ctx.compose('entity.add', { name: input.name });
871
+ if (result.isErr()) {
872
+ return result;
873
+ }
874
+ return Result.ok({ onboarded: true });
875
+ },
876
+ input: z.object({ name: z.string() }),
877
+ intent: 'write',
878
+ output: z.object({ onboarded: z.boolean() }),
879
+ permit: { scopes: ['entity:write'] },
880
+ });
881
+ `;
882
+
883
+ const generateEntitySignals = (): string =>
884
+ `import { signal } from '@ontrails/core';
885
+ import { z } from 'zod';
886
+
887
+ export const entityUpdated = signal('entity.updated', {
888
+ description: 'Fired when an entity is updated',
889
+ payload: z.object({
890
+ entityId: z.string(),
891
+ updatedAt: z.string(),
892
+ }),
893
+ });
894
+ `;
895
+
896
+ const generateStore = (): string =>
897
+ `import { Result, resource } from '@ontrails/core';
898
+
899
+ /** In-memory store for entities. */
900
+
901
+ export interface Entity {
902
+ readonly id: string;
903
+ readonly name: string;
904
+ }
905
+
906
+ export interface EntityStore {
907
+ add(entity: Entity): void;
908
+ delete(id: string): boolean;
909
+ get(id: string): Entity | undefined;
910
+ list(): Entity[];
911
+ }
912
+
913
+ const defaultEntities: readonly Entity[] = [{ id: '1', name: 'Example' }];
914
+
915
+ export const createEntityStore = (
916
+ seed: readonly Entity[] = defaultEntities
917
+ ): EntityStore => {
918
+ const store = new Map(seed.map((entity) => [entity.id, entity] as const));
919
+ return {
920
+ add(entity) {
921
+ store.set(entity.id, entity);
922
+ },
923
+ delete(id) {
924
+ return store.delete(id);
925
+ },
926
+ get(id) {
927
+ return store.get(id);
928
+ },
929
+ list() {
930
+ return [...store.values()];
931
+ },
932
+ };
933
+ };
934
+
935
+ export const entityStore = resource('entity.store', {
936
+ create: () => Result.ok(createEntityStore()),
937
+ description: 'In-memory entity store for the entity starter.',
938
+ mock: createEntityStore,
939
+ });
940
+ `;
941
+
942
+ const starterImports: Record<
943
+ Starter,
944
+ { imports: string[]; modules: string[] }
945
+ > = {
946
+ empty: { imports: [], modules: [] },
947
+ entity: {
948
+ imports: [
949
+ "import * as entity from './trails/entity.js';",
950
+ "import * as search from './trails/search.js';",
951
+ "import * as onboard from './trails/onboard.js';",
952
+ "import * as entitySignals from './signals/entity-signals.js';",
953
+ "import * as store from './store.js';",
954
+ ],
955
+ modules: ['entity', 'search', 'onboard', 'entitySignals', 'store'],
956
+ },
957
+ hello: {
958
+ imports: ["import * as hello from './trails/hello.js';"],
959
+ modules: ['hello'],
960
+ },
961
+ };
962
+
963
+ const renderTopoExpression = (
964
+ appNameLiteral: string,
965
+ modules: readonly string[]
966
+ ): string => {
967
+ if (modules.length === 0) {
968
+ return `topo(${appNameLiteral})`;
969
+ }
970
+
971
+ if (modules.length === 1) {
972
+ return `topo(${appNameLiteral}, ${modules[0]})`;
973
+ }
974
+
975
+ return `topo(\n ${[appNameLiteral, ...modules].join(',\n ')}\n)`;
976
+ };
977
+
978
+ const generateAppTs = (name: string, starter: Starter): string => {
979
+ const { imports, modules } = starterImports[starter];
980
+ const appNameLiteral = `'${name}'`;
981
+ const topoExpression = renderTopoExpression(appNameLiteral, modules);
982
+
983
+ return [
984
+ "import { topo } from '@ontrails/core';",
985
+ "import { z } from 'zod';",
986
+ ...imports,
987
+ '',
988
+ `export const app = ${topoExpression};`,
989
+ '',
990
+ 'export const trailsOverlays = [',
991
+ ' {',
992
+ ' derive: () => ({',
993
+ ` scaffoldVersion: '${trailsPackageVersion}',`,
994
+ ' schemaVersion: 1,',
995
+ ` template: '${starter}',`,
996
+ ' }),',
997
+ " namespace: 'scaffold',",
998
+ ' schema: z.object({',
999
+ ' scaffoldVersion: z.string(),',
1000
+ ' schemaVersion: z.literal(1),',
1001
+ " template: z.enum(['empty', 'entity', 'hello']),",
1002
+ ' }),',
1003
+ ' },',
1004
+ '];',
1005
+ '',
1006
+ ].join('\n');
1007
+ };
1008
+
1009
+ // ---------------------------------------------------------------------------
1010
+ // File collection and writing
1011
+ // ---------------------------------------------------------------------------
1012
+
1013
+ const starterFileGenerators: Record<Starter, () => [string, string][]> = {
1014
+ empty: () => [['src/trails/.gitkeep', '']],
1015
+ entity: () => [
1016
+ ['src/trails/entity.ts', generateEntityTrails()],
1017
+ ['src/trails/search.ts', generateSearchTrail()],
1018
+ ['src/trails/onboard.ts', generateOnboardTrail()],
1019
+ ['src/signals/entity-signals.ts', generateEntitySignals()],
1020
+ ['src/store.ts', generateStore()],
1021
+ ],
1022
+ hello: () => [['src/trails/hello.ts', generateHelloTrail()]],
1023
+ };
1024
+
1025
+ const collectScaffoldFiles = (
1026
+ name: string,
1027
+ starter: Starter,
1028
+ layout: ScaffoldLayout
1029
+ ): Map<string, string> => {
1030
+ const appRoot = layout === 'workspace' ? `apps/${name}` : '.';
1031
+ const appPath = (path: string): string =>
1032
+ appRoot === '.' ? path : `${appRoot}/${path}`;
1033
+ const appFiles: [string, string][] = [
1034
+ [appPath('package.json'), generateAppPackageJson(name)],
1035
+ [appPath('AGENTS.md'), AGENTS_CONTENT],
1036
+ [appPath('CLAUDE.md'), CLAUDE_CONTENT],
1037
+ [
1038
+ appPath('tsconfig.json'),
1039
+ layout === 'workspace'
1040
+ ? TSCONFIG_WORKSPACE_APP_CONTENT
1041
+ : TSCONFIG_CONTENT,
1042
+ ],
1043
+ [appPath('tsconfig.tests.json'), TSCONFIG_TESTS_CONTENT],
1044
+ [appPath('oxlint.config.ts'), OXLINT_CONFIG_CONTENT],
1045
+ [appPath('.oxfmtrc.jsonc'), OXFMTRC_CONTENT],
1046
+ [appPath('src/app.ts'), generateAppTs(name, starter)],
1047
+ ...starterFileGenerators[starter]().map(
1048
+ ([path, content]) => [appPath(path), content] as [string, string]
1049
+ ),
1050
+ ];
1051
+
1052
+ return new Map(
1053
+ layout === 'workspace'
1054
+ ? [
1055
+ ['package.json', generateWorkspacePackageJson(name)],
1056
+ ['trails.config.ts', generateWorkspaceConfig(name)],
1057
+ ['.gitignore', GITIGNORE_CONTENT],
1058
+ ['tsconfig.base.json', TSCONFIG_BASE_CONTENT],
1059
+ ...appFiles,
1060
+ ]
1061
+ : [['.gitignore', GITIGNORE_CONTENT], ...appFiles]
1062
+ );
1063
+ };
1064
+
1065
+ const collectScaffoldOperations = (
1066
+ fileMap: Map<string, string>
1067
+ ): ProjectWriteOperation[] =>
1068
+ [...fileMap].map(([path, content]) => ({
1069
+ content,
1070
+ kind: 'write' as const,
1071
+ path,
1072
+ }));
1073
+
1074
+ // ---------------------------------------------------------------------------
1075
+ // Trail definition
1076
+ // ---------------------------------------------------------------------------
1077
+
1078
+ export const createScaffold = trail('create.scaffold', {
1079
+ description: 'Scaffold a new Trails project',
1080
+ implementation: async (input) => {
1081
+ const projectDirResult = resolveProjectDir(input.dir ?? '.', input.name);
1082
+ if (projectDirResult.isErr()) {
1083
+ return projectDirResult;
1084
+ }
1085
+
1086
+ const projectDir = projectDirResult.value;
1087
+ const starter = (input.starter ?? 'hello') as Starter;
1088
+ const dryRun = input.dryRun === true;
1089
+ const layout: ScaffoldLayout = input.workspace ? 'workspace' : 'standalone';
1090
+ const appRoot = layout === 'workspace' ? `apps/${input.name}` : '.';
1091
+ const sourceFiles = collectScaffoldFiles(input.name, starter, layout);
1092
+ const prepared =
1093
+ layout === 'workspace'
1094
+ ? await prepareWorkspaceScaffoldFiles(
1095
+ projectDir,
1096
+ input.name,
1097
+ sourceFiles,
1098
+ TSCONFIG_CONTENT
1099
+ )
1100
+ : await prepareStandaloneScaffoldFiles(
1101
+ projectDir,
1102
+ input.name,
1103
+ sourceFiles
1104
+ );
1105
+ if (prepared.isErr()) {
1106
+ return prepared;
1107
+ }
1108
+ const operations = collectScaffoldOperations(prepared.value.files);
1109
+ const overwriteOperations = operations.filter(
1110
+ (operation) =>
1111
+ operation.kind === 'write' &&
1112
+ prepared.value.overwritePaths.has(operation.path)
1113
+ );
1114
+ const preserveOperations = operations.filter(
1115
+ (operation) =>
1116
+ operation.kind !== 'write' ||
1117
+ !prepared.value.overwritePaths.has(operation.path)
1118
+ );
1119
+ const overwritePlan = planProjectOperations(
1120
+ projectDir,
1121
+ overwriteOperations
1122
+ );
1123
+ if (overwritePlan.isErr()) {
1124
+ return overwritePlan;
1125
+ }
1126
+ const preservePlan = planProjectOperations(projectDir, preserveOperations, {
1127
+ existing: 'preserve',
1128
+ });
1129
+ if (preservePlan.isErr()) {
1130
+ return preservePlan;
1131
+ }
1132
+ if (!dryRun) {
1133
+ const overwritten = await applyProjectOperations(
1134
+ projectDir,
1135
+ overwriteOperations
1136
+ );
1137
+ if (overwritten.isErr()) {
1138
+ return overwritten;
1139
+ }
1140
+ const preserved = await applyProjectOperations(
1141
+ projectDir,
1142
+ preserveOperations,
1143
+ { existing: 'preserve' }
1144
+ );
1145
+ if (preserved.isErr()) {
1146
+ return preserved;
1147
+ }
1148
+ }
1149
+ const plannedOperations: PlannedProjectOperation[] = [
1150
+ ...overwritePlan.value,
1151
+ ...preservePlan.value,
1152
+ ];
1153
+
1154
+ const created = dryRun
1155
+ ? []
1156
+ : plannedOperations
1157
+ .filter((operation) => operation.kind === 'write')
1158
+ .map((operation) => operation.path);
1159
+
1160
+ return Result.ok({
1161
+ appDir: resolve(projectDir, appRoot),
1162
+ appRoot,
1163
+ created,
1164
+ dir: resolve(projectDir),
1165
+ dryRun,
1166
+ layout,
1167
+ name: input.name,
1168
+ plannedOperations,
1169
+ } satisfies ScaffoldResult);
1170
+ },
1171
+ input: z.object({
1172
+ dir: z.string().optional().describe('Parent directory'),
1173
+ dryRun: z
1174
+ .boolean()
1175
+ .default(false)
1176
+ .describe('Plan scaffold writes without touching the project directory'),
1177
+ name: z
1178
+ .string()
1179
+ .regex(PROJECT_NAME_PATTERN, PROJECT_NAME_MESSAGE)
1180
+ .describe('Project name'),
1181
+ starter: z
1182
+ .enum(['hello', 'entity', 'empty'])
1183
+ .default('hello')
1184
+ .describe('Starter trail'),
1185
+ workspace: z
1186
+ .boolean()
1187
+ .default(false)
1188
+ .describe('Create a configured workspace with one app'),
1189
+ }),
1190
+ intent: 'write',
1191
+ output: z.object({
1192
+ appDir: z.string(),
1193
+ appRoot: z.string(),
1194
+ created: z
1195
+ .array(z.string())
1196
+ .describe('Project-relative paths of files written (empty in dry-run)'),
1197
+ dir: z.string(),
1198
+ dryRun: z.boolean(),
1199
+ layout: z.enum(['standalone', 'workspace']),
1200
+ name: z.string(),
1201
+ plannedOperations: z.array(
1202
+ z.discriminatedUnion('kind', [
1203
+ z.object({ kind: z.literal('mkdir'), path: z.string() }),
1204
+ z.object({
1205
+ from: z.string(),
1206
+ kind: z.literal('rename'),
1207
+ to: z.string(),
1208
+ }),
1209
+ z.object({ kind: z.literal('write'), path: z.string() }),
1210
+ ])
1211
+ ),
1212
+ }),
1213
+ permit: { scopes: ['project:write'] },
1214
+ visibility: 'internal',
1215
+ });