@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.
- package/CHANGELOG.md +1906 -0
- package/README.md +48 -0
- package/bin/trails.ts +3 -0
- package/package.json +57 -0
- package/src/app.ts +167 -0
- package/src/clack.ts +111 -0
- package/src/cli.ts +308 -0
- package/src/completions.ts +431 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +129 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +92 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/audit.ts +571 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +636 -0
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/live-api-preserve.ts +123 -0
- package/src/regrade/plan-artifact.ts +515 -0
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +446 -0
- package/src/regrade/source-transaction.ts +185 -0
- package/src/release/bindings.ts +58 -0
- package/src/release/changeset-packages.ts +99 -0
- package/src/release/check.ts +1191 -0
- package/src/release/cli-bundle.ts +575 -0
- package/src/release/config.ts +73 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/homebrew.ts +221 -0
- package/src/release/index.ts +180 -0
- package/src/release/lock-roundtrip-smoke.ts +255 -0
- package/src/release/lock-roundtrip-workspace.ts +107 -0
- package/src/release/native-bun-publish.ts +964 -0
- package/src/release/native-bun-registry.ts +848 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1780 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/stable-version-release.ts +80 -0
- package/src/release/wayfinder-dogfood-smoke.ts +762 -0
- package/src/release/zero-line-transition.ts +68 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-argv.ts +133 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-regrade-progress.ts +47 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch-project.ts +52 -0
- package/src/run-watch.ts +381 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +816 -0
- package/src/trails/add-trail.ts +141 -0
- package/src/trails/add-verify.ts +252 -0
- package/src/trails/compile.ts +118 -0
- package/src/trails/completions-complete.ts +236 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/config-explain.ts +43 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +1215 -0
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +652 -0
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +80 -0
- package/src/trails/dev-reset.ts +48 -0
- package/src/trails/dev-stats.ts +71 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +106 -0
- package/src/trails/load-app.ts +1145 -0
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project-context-output.ts +304 -0
- package/src/trails/project-context.ts +613 -0
- package/src/trails/project.ts +65 -0
- package/src/trails/regrade.ts +4951 -0
- package/src/trails/release-check.ts +113 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +592 -0
- package/src/trails/run-examples.ts +149 -0
- package/src/trails/run.ts +496 -0
- package/src/trails/scaffold-json.ts +60 -0
- package/src/trails/scaffold-topo-identity.ts +479 -0
- package/src/trails/survey.ts +990 -0
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +323 -0
- package/src/trails/topo-support.ts +247 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +348 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +598 -0
- package/src/trails/wayfind-diff.ts +716 -0
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1319 -0
- 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
|
+
});
|