@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,613 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
basename,
|
|
4
|
+
dirname,
|
|
5
|
+
isAbsolute,
|
|
6
|
+
join,
|
|
7
|
+
relative,
|
|
8
|
+
resolve,
|
|
9
|
+
} from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { findAppModule } from '@ontrails/cli';
|
|
12
|
+
import {
|
|
13
|
+
findTrailsConfigPaths,
|
|
14
|
+
readTrailsProjectIdentity,
|
|
15
|
+
resolveTrailsProjectRoot,
|
|
16
|
+
trailsAppEntryRelativePath,
|
|
17
|
+
trailsLockFileName,
|
|
18
|
+
trailsSourceRootCandidates,
|
|
19
|
+
} from '@ontrails/config';
|
|
20
|
+
import type {
|
|
21
|
+
ReadTrailsProjectIdentityResult,
|
|
22
|
+
ResolvedTrailsWorkspaceApp,
|
|
23
|
+
TrailsProjectRootResolution,
|
|
24
|
+
} from '@ontrails/config';
|
|
25
|
+
import { deriveSafePath, Result, ValidationError } from '@ontrails/core';
|
|
26
|
+
import { deriveWorkspaceView } from '@ontrails/topography';
|
|
27
|
+
|
|
28
|
+
export type OperatorModuleSource = 'config' | 'convention' | 'module';
|
|
29
|
+
export type OperatorSelectionProvenance = 'app' | 'cwd' | 'root-dir';
|
|
30
|
+
|
|
31
|
+
export interface OperatorProjectApp {
|
|
32
|
+
readonly configured: boolean;
|
|
33
|
+
readonly id?: string | undefined;
|
|
34
|
+
readonly lockPath: string;
|
|
35
|
+
readonly modulePath: string;
|
|
36
|
+
readonly moduleSource: OperatorModuleSource;
|
|
37
|
+
readonly root: string;
|
|
38
|
+
readonly rootDir: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface OperatorProjectContextBase {
|
|
42
|
+
readonly boundaryDir: string;
|
|
43
|
+
readonly identity: ReadTrailsProjectIdentityResult;
|
|
44
|
+
readonly projectRoot: string;
|
|
45
|
+
readonly selectionProvenance: OperatorSelectionProvenance;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface OperatorAppProjectContext extends OperatorProjectContextBase {
|
|
49
|
+
readonly app: OperatorProjectApp;
|
|
50
|
+
readonly selectedExtent: 'configured-app' | 'standalone-app';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface OperatorWorkspaceProjectContext extends OperatorProjectContextBase {
|
|
54
|
+
readonly apps: readonly OperatorProjectApp[];
|
|
55
|
+
readonly selectedExtent: 'workspace';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type OperatorProjectContext =
|
|
59
|
+
| OperatorAppProjectContext
|
|
60
|
+
| OperatorWorkspaceProjectContext;
|
|
61
|
+
|
|
62
|
+
export interface OperatorProjectContextInput {
|
|
63
|
+
readonly app?: string | undefined;
|
|
64
|
+
readonly module?: string | undefined;
|
|
65
|
+
readonly rootDir?: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface OperatorProjectContextRuntime {
|
|
69
|
+
readonly cwd?: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
|
|
73
|
+
|
|
74
|
+
const isInside = (root: string, target: string): boolean => {
|
|
75
|
+
const path = relative(root, target);
|
|
76
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const canonicalPath = (path: string): string => {
|
|
80
|
+
try {
|
|
81
|
+
return realpathSync(path);
|
|
82
|
+
} catch {
|
|
83
|
+
return resolve(path);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isPreviouslyOwnedSourceMarker = (
|
|
88
|
+
current: string,
|
|
89
|
+
candidate: string,
|
|
90
|
+
markerPath: string,
|
|
91
|
+
resolvedMarkerPath: string | undefined
|
|
92
|
+
): boolean =>
|
|
93
|
+
(candidate === 'trails' && basename(current) === 'src') ||
|
|
94
|
+
canonicalPath(markerPath) === resolvedMarkerPath;
|
|
95
|
+
|
|
96
|
+
const projectContextError = (
|
|
97
|
+
message: string,
|
|
98
|
+
reason:
|
|
99
|
+
| 'compile-needs-app'
|
|
100
|
+
| 'invalid-binding'
|
|
101
|
+
| 'outside-project'
|
|
102
|
+
| 'unknown-app',
|
|
103
|
+
context: Record<string, unknown>
|
|
104
|
+
): ValidationError =>
|
|
105
|
+
new ValidationError(message, { context: { ...context, reason } });
|
|
106
|
+
|
|
107
|
+
const readGitWorkingTreeBoundary = (cwd: string): string | undefined => {
|
|
108
|
+
try {
|
|
109
|
+
const result = Bun.spawnSync({
|
|
110
|
+
cmd: ['git', '-C', cwd, 'rev-parse', '--show-toplevel'],
|
|
111
|
+
env: process.env,
|
|
112
|
+
stderr: 'ignore',
|
|
113
|
+
stdout: 'pipe',
|
|
114
|
+
});
|
|
115
|
+
if (!result.success) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const root = Buffer.from(result.stdout).toString('utf8').trim();
|
|
119
|
+
return root === '' ? undefined : resolve(root);
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Widen the boundary far enough for overlapping workspace declarations to fail
|
|
127
|
+
* closed, mirroring the Git working-tree boundary the Git-available path uses.
|
|
128
|
+
*/
|
|
129
|
+
const resolveNonGitCollectionBoundary = async (
|
|
130
|
+
cwd: string
|
|
131
|
+
): Promise<string> => {
|
|
132
|
+
let current = cwd;
|
|
133
|
+
let nearestConfigRoot: string | undefined;
|
|
134
|
+
let outermostWorkspaceRoot: string | undefined;
|
|
135
|
+
while (true) {
|
|
136
|
+
if (findTrailsConfigPaths(current).length > 0) {
|
|
137
|
+
nearestConfigRoot ??= current;
|
|
138
|
+
const identity = await readTrailsProjectIdentity({
|
|
139
|
+
boundaryDir: current,
|
|
140
|
+
startDir: current,
|
|
141
|
+
});
|
|
142
|
+
if (identity.workspace !== undefined) {
|
|
143
|
+
outermostWorkspaceRoot = identity.rootDir;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (existsSync(join(current, '.git'))) {
|
|
147
|
+
return current;
|
|
148
|
+
}
|
|
149
|
+
const parent = dirname(current);
|
|
150
|
+
if (parent === current) {
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
current = parent;
|
|
154
|
+
}
|
|
155
|
+
return (
|
|
156
|
+
outermostWorkspaceRoot ??
|
|
157
|
+
nearestConfigRoot ??
|
|
158
|
+
resolveTrailsProjectRoot({ startDir: cwd }).rootDir
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the first-class filesystem collection observed by operator commands.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* const boundaryDir = await resolveOperatorCollectionBoundary(process.cwd());
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export const resolveOperatorCollectionBoundary = async (
|
|
171
|
+
startDir: string
|
|
172
|
+
): Promise<string> =>
|
|
173
|
+
readGitWorkingTreeBoundary(startDir) ??
|
|
174
|
+
(await resolveNonGitCollectionBoundary(startDir));
|
|
175
|
+
|
|
176
|
+
const configuredApp = (
|
|
177
|
+
app: ResolvedTrailsWorkspaceApp,
|
|
178
|
+
moduleOverride?: string | undefined
|
|
179
|
+
): OperatorProjectApp => {
|
|
180
|
+
let moduleSource: OperatorModuleSource = 'module';
|
|
181
|
+
if (moduleOverride === undefined) {
|
|
182
|
+
moduleSource = app.entrySource === 'explicit' ? 'config' : 'convention';
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
configured: true,
|
|
186
|
+
id: app.id,
|
|
187
|
+
lockPath: join(app.rootDir, trailsLockFileName),
|
|
188
|
+
modulePath: moduleOverride ?? app.entry,
|
|
189
|
+
moduleSource,
|
|
190
|
+
root: app.root,
|
|
191
|
+
rootDir: app.rootDir,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const standaloneApp = (
|
|
196
|
+
projectRoot: string,
|
|
197
|
+
moduleOverride: string | undefined
|
|
198
|
+
): OperatorProjectApp => ({
|
|
199
|
+
configured: false,
|
|
200
|
+
lockPath: join(projectRoot, trailsLockFileName),
|
|
201
|
+
modulePath: moduleOverride ?? trailsAppEntryRelativePath,
|
|
202
|
+
moduleSource: moduleOverride === undefined ? 'convention' : 'module',
|
|
203
|
+
root: '.',
|
|
204
|
+
rootDir: projectRoot,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/** Apply canonical module discovery only when a standalone app will be loaded. */
|
|
208
|
+
export const resolveOperatorAppModuleContext = (
|
|
209
|
+
context: OperatorAppProjectContext
|
|
210
|
+
): Result<OperatorAppProjectContext, Error> => {
|
|
211
|
+
if (context.app.configured || context.app.moduleSource !== 'convention') {
|
|
212
|
+
return Result.ok(context);
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
return Result.ok({
|
|
216
|
+
...context,
|
|
217
|
+
app: {
|
|
218
|
+
...context.app,
|
|
219
|
+
modulePath: findAppModule(context.app.rootDir),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return Result.err(
|
|
224
|
+
error instanceof Error
|
|
225
|
+
? error
|
|
226
|
+
: new ValidationError('Unable to discover a Trails app module.', {
|
|
227
|
+
context: { detail: String(error) },
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const unknownAppError = (
|
|
234
|
+
requestedAppId: string,
|
|
235
|
+
configuredAppIds: readonly string[],
|
|
236
|
+
projectRoot: string
|
|
237
|
+
): ValidationError =>
|
|
238
|
+
projectContextError(
|
|
239
|
+
configuredAppIds.length === 0
|
|
240
|
+
? `App "${requestedAppId}" is not configured. This project has no workspace.apps catalog; omit --app or add the app to workspace.apps.`
|
|
241
|
+
: `Unknown app "${requestedAppId}". Configured apps: ${configuredAppIds.join(', ')}. Use --app <id> with one of those IDs.`,
|
|
242
|
+
'unknown-app',
|
|
243
|
+
{ configuredAppIds, projectRoot, requestedAppId }
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const configuredSelection = (
|
|
247
|
+
input: OperatorProjectContextInput,
|
|
248
|
+
cwd: string,
|
|
249
|
+
boundaryDir: string,
|
|
250
|
+
identity: ReadTrailsProjectIdentityResult,
|
|
251
|
+
explicitRoot: boolean
|
|
252
|
+
): OperatorProjectContext => {
|
|
253
|
+
const configuredAppIds = identity.apps.map((app) => app.id);
|
|
254
|
+
if (input.app !== undefined) {
|
|
255
|
+
const selected = identity.apps.find((app) => app.id === input.app);
|
|
256
|
+
if (selected === undefined) {
|
|
257
|
+
throw unknownAppError(input.app, configuredAppIds, identity.rootDir);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
app: configuredApp(selected, input.module),
|
|
261
|
+
boundaryDir,
|
|
262
|
+
identity,
|
|
263
|
+
projectRoot: identity.rootDir,
|
|
264
|
+
selectedExtent: 'configured-app',
|
|
265
|
+
selectionProvenance: 'app',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const canonicalBoundary = canonicalPath(boundaryDir);
|
|
270
|
+
const canonicalCwd = canonicalPath(cwd);
|
|
271
|
+
const selectedByCwd =
|
|
272
|
+
canonicalCwd !== canonicalBoundary &&
|
|
273
|
+
isInside(canonicalBoundary, canonicalCwd);
|
|
274
|
+
const selectionPoint = selectedByCwd ? cwd : boundaryDir;
|
|
275
|
+
let selectionProvenance: OperatorSelectionProvenance = explicitRoot
|
|
276
|
+
? 'root-dir'
|
|
277
|
+
: 'cwd';
|
|
278
|
+
if (selectedByCwd) {
|
|
279
|
+
selectionProvenance = 'cwd';
|
|
280
|
+
}
|
|
281
|
+
if (canonicalPath(selectionPoint) === canonicalPath(identity.rootDir)) {
|
|
282
|
+
if (input.module !== undefined) {
|
|
283
|
+
throw projectContextError(
|
|
284
|
+
'--module cannot select one app from a workspace extent. Add --app <id> before refining its module.',
|
|
285
|
+
'invalid-binding',
|
|
286
|
+
{
|
|
287
|
+
configuredAppIds,
|
|
288
|
+
module: input.module,
|
|
289
|
+
projectRoot: identity.rootDir,
|
|
290
|
+
}
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
apps: identity.apps.map((app) => configuredApp(app)),
|
|
295
|
+
boundaryDir,
|
|
296
|
+
identity,
|
|
297
|
+
projectRoot: identity.rootDir,
|
|
298
|
+
selectedExtent: 'workspace',
|
|
299
|
+
selectionProvenance,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const containingApps = identity.apps.filter((app) =>
|
|
304
|
+
isInside(canonicalPath(app.rootDir), canonicalPath(selectionPoint))
|
|
305
|
+
);
|
|
306
|
+
if (containingApps.length !== 1) {
|
|
307
|
+
throw projectContextError(
|
|
308
|
+
containingApps.length === 0
|
|
309
|
+
? `Current directory ${selectionPoint} is inside workspace ${identity.rootDir} but not inside a configured app root. Run from the workspace root for workspace extent or pass --app <id>.`
|
|
310
|
+
: `Current directory ${selectionPoint} is inside overlapping configured app roots: ${containingApps.map((app) => app.id).join(', ')}. Make workspace.apps roots non-overlapping.`,
|
|
311
|
+
containingApps.length === 0 ? 'outside-project' : 'invalid-binding',
|
|
312
|
+
{
|
|
313
|
+
configuredAppIds,
|
|
314
|
+
cwd: selectionPoint,
|
|
315
|
+
matchingAppIds: containingApps.map((app) => app.id),
|
|
316
|
+
projectRoot: identity.rootDir,
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
app: configuredApp(
|
|
323
|
+
containingApps[0] as ResolvedTrailsWorkspaceApp,
|
|
324
|
+
input.module
|
|
325
|
+
),
|
|
326
|
+
boundaryDir,
|
|
327
|
+
identity,
|
|
328
|
+
projectRoot: identity.rootDir,
|
|
329
|
+
selectedExtent: 'configured-app',
|
|
330
|
+
selectionProvenance,
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const resolveStandaloneRoot = (
|
|
335
|
+
startDir: string,
|
|
336
|
+
boundaryDir: string,
|
|
337
|
+
explicitRoot: boolean,
|
|
338
|
+
modulePath: string | undefined
|
|
339
|
+
): TrailsProjectRootResolution => {
|
|
340
|
+
const resolved = resolveTrailsProjectRoot({
|
|
341
|
+
boundaryDir,
|
|
342
|
+
...(explicitRoot ? { explicitRootDir: boundaryDir } : {}),
|
|
343
|
+
startDir,
|
|
344
|
+
});
|
|
345
|
+
if (explicitRoot) {
|
|
346
|
+
return resolved;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let current = resolve(startDir);
|
|
350
|
+
const discoveredRoot = resolve(resolved.rootDir);
|
|
351
|
+
const resolvedMarkerPath =
|
|
352
|
+
resolved.markerPath === undefined
|
|
353
|
+
? undefined
|
|
354
|
+
: canonicalPath(resolved.markerPath);
|
|
355
|
+
while (true) {
|
|
356
|
+
if (
|
|
357
|
+
modulePath !== undefined &&
|
|
358
|
+
!isAbsolute(modulePath) &&
|
|
359
|
+
!URL_SCHEME.test(modulePath)
|
|
360
|
+
) {
|
|
361
|
+
const safeModulePath = deriveSafePath(current, modulePath);
|
|
362
|
+
if (safeModulePath.isOk()) {
|
|
363
|
+
try {
|
|
364
|
+
if (statSync(safeModulePath.value).isFile()) {
|
|
365
|
+
return {
|
|
366
|
+
marker: 'source',
|
|
367
|
+
markerPath: safeModulePath.value,
|
|
368
|
+
rootDir: current,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// A missing custom entry is ordinary during the bounded walk.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const appEntryPath = join(current, trailsAppEntryRelativePath);
|
|
377
|
+
try {
|
|
378
|
+
if (statSync(appEntryPath).isFile()) {
|
|
379
|
+
return { marker: 'source', markerPath: appEntryPath, rootDir: current };
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
// A missing conventional entry is ordinary during the bounded walk.
|
|
383
|
+
}
|
|
384
|
+
for (const candidate of trailsSourceRootCandidates) {
|
|
385
|
+
const markerPath = join(current, candidate);
|
|
386
|
+
try {
|
|
387
|
+
if (
|
|
388
|
+
statSync(markerPath).isDirectory() &&
|
|
389
|
+
!isPreviouslyOwnedSourceMarker(
|
|
390
|
+
current,
|
|
391
|
+
candidate,
|
|
392
|
+
markerPath,
|
|
393
|
+
resolvedMarkerPath
|
|
394
|
+
)
|
|
395
|
+
) {
|
|
396
|
+
return { marker: 'source', markerPath, rootDir: current };
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
// Missing source conventions are ordinary during the bounded walk.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (current === discoveredRoot) {
|
|
403
|
+
return resolved;
|
|
404
|
+
}
|
|
405
|
+
const parent = dirname(current);
|
|
406
|
+
if (parent === current) {
|
|
407
|
+
return resolved;
|
|
408
|
+
}
|
|
409
|
+
current = parent;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export const resolveOperatorProjectContext = async (
|
|
414
|
+
input: OperatorProjectContextInput,
|
|
415
|
+
runtime: OperatorProjectContextRuntime
|
|
416
|
+
): Promise<Result<OperatorProjectContext, Error>> => {
|
|
417
|
+
try {
|
|
418
|
+
const explicitRoot = input.rootDir !== undefined;
|
|
419
|
+
const invocationCwd = resolve(runtime.cwd ?? process.cwd());
|
|
420
|
+
const cwd = explicitRoot ? invocationCwd : canonicalPath(invocationCwd);
|
|
421
|
+
const boundaryDir = explicitRoot
|
|
422
|
+
? resolve(cwd, input.rootDir)
|
|
423
|
+
: await resolveOperatorCollectionBoundary(cwd);
|
|
424
|
+
if (!existsSync(boundaryDir)) {
|
|
425
|
+
throw projectContextError(
|
|
426
|
+
`Project root ${boundaryDir} does not exist.`,
|
|
427
|
+
'outside-project',
|
|
428
|
+
{ boundaryDir, cwd }
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
const startDir = explicitRoot ? boundaryDir : cwd;
|
|
432
|
+
const identity = await readTrailsProjectIdentity({
|
|
433
|
+
boundaryDir,
|
|
434
|
+
startDir,
|
|
435
|
+
});
|
|
436
|
+
if (identity.workspace !== undefined) {
|
|
437
|
+
return Result.ok(
|
|
438
|
+
configuredSelection(input, cwd, boundaryDir, identity, explicitRoot)
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (input.app !== undefined) {
|
|
442
|
+
throw unknownAppError(input.app, [], identity.rootDir);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const root = resolveStandaloneRoot(
|
|
446
|
+
startDir,
|
|
447
|
+
boundaryDir,
|
|
448
|
+
explicitRoot,
|
|
449
|
+
input.module
|
|
450
|
+
);
|
|
451
|
+
return Result.ok({
|
|
452
|
+
app: standaloneApp(root.rootDir, input.module),
|
|
453
|
+
boundaryDir,
|
|
454
|
+
identity: { ...identity, rootDir: root.rootDir },
|
|
455
|
+
projectRoot: root.rootDir,
|
|
456
|
+
selectedExtent: 'standalone-app',
|
|
457
|
+
selectionProvenance: explicitRoot ? 'root-dir' : 'cwd',
|
|
458
|
+
});
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return Result.err(
|
|
461
|
+
error instanceof Error
|
|
462
|
+
? error
|
|
463
|
+
: new ValidationError('Unable to resolve Trails project context.', {
|
|
464
|
+
context: { detail: String(error) },
|
|
465
|
+
})
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
export const compileNeedsAppError = (
|
|
471
|
+
context: OperatorWorkspaceProjectContext
|
|
472
|
+
): ValidationError => {
|
|
473
|
+
const configuredAppIds = context.apps.map((app) => app.id as string);
|
|
474
|
+
return projectContextError(
|
|
475
|
+
`Workspace compile requires one app and never fans out. Choose: ${configuredAppIds.map((id) => `--app ${id}`).join(', ')}.`,
|
|
476
|
+
'compile-needs-app',
|
|
477
|
+
{ configuredAppIds, projectRoot: context.projectRoot }
|
|
478
|
+
);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
interface ConfiguredAppBindingExpectation {
|
|
482
|
+
readonly app: {
|
|
483
|
+
readonly id?: string | undefined;
|
|
484
|
+
readonly modulePath: string;
|
|
485
|
+
};
|
|
486
|
+
readonly projectRoot: string;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export const assertConfiguredAppBinding = (
|
|
490
|
+
context: ConfiguredAppBindingExpectation,
|
|
491
|
+
actualAppId: string
|
|
492
|
+
): Result<void, ValidationError> => {
|
|
493
|
+
const expectedAppId = context.app.id;
|
|
494
|
+
if (expectedAppId === undefined || expectedAppId === actualAppId) {
|
|
495
|
+
return Result.ok();
|
|
496
|
+
}
|
|
497
|
+
return Result.err(
|
|
498
|
+
projectContextError(
|
|
499
|
+
`Configured app "${expectedAppId}" loaded topo "${actualAppId}". Align workspace.apps, the app topo name, and ${context.app.modulePath}.`,
|
|
500
|
+
'invalid-binding',
|
|
501
|
+
{
|
|
502
|
+
actualAppId,
|
|
503
|
+
expectedAppId,
|
|
504
|
+
modulePath: context.app.modulePath,
|
|
505
|
+
projectRoot: context.projectRoot,
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
export const assertObservableProjectApps = async (
|
|
512
|
+
context: OperatorProjectContext
|
|
513
|
+
): Promise<Result<void, ValidationError>> => {
|
|
514
|
+
if (context.identity.workspace === undefined) {
|
|
515
|
+
return Result.ok();
|
|
516
|
+
}
|
|
517
|
+
const selectedAppIds =
|
|
518
|
+
context.selectedExtent === 'workspace'
|
|
519
|
+
? context.apps.map((app) => app.id as string)
|
|
520
|
+
: [context.app.id as string];
|
|
521
|
+
let view: Awaited<ReturnType<typeof deriveWorkspaceView>>;
|
|
522
|
+
try {
|
|
523
|
+
view = await deriveWorkspaceView({
|
|
524
|
+
identity: context.identity,
|
|
525
|
+
selectedAppIds,
|
|
526
|
+
});
|
|
527
|
+
} catch (error) {
|
|
528
|
+
return Result.err(
|
|
529
|
+
projectContextError(
|
|
530
|
+
`Unable to observe configured app collection boundaries: ${error instanceof Error ? error.message : String(error)}.`,
|
|
531
|
+
'invalid-binding',
|
|
532
|
+
{
|
|
533
|
+
appIds: selectedAppIds,
|
|
534
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
535
|
+
projectRoot: context.projectRoot,
|
|
536
|
+
}
|
|
537
|
+
)
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const unavailable = view.evidence.apps.filter(
|
|
541
|
+
(app) => selectedAppIds.includes(app.id) && app.status === 'unavailable'
|
|
542
|
+
);
|
|
543
|
+
if (unavailable.length === 0) {
|
|
544
|
+
return Result.ok();
|
|
545
|
+
}
|
|
546
|
+
return Result.err(
|
|
547
|
+
projectContextError(
|
|
548
|
+
`Configured app collection boundary is unavailable: ${unavailable.map((app) => `${app.id} (${app.detail ?? app.status})`).join(', ')}.`,
|
|
549
|
+
'invalid-binding',
|
|
550
|
+
{
|
|
551
|
+
appIds: selectedAppIds,
|
|
552
|
+
collectionSkips: view.evidence.collectionSkips,
|
|
553
|
+
projectRoot: context.projectRoot,
|
|
554
|
+
unavailable,
|
|
555
|
+
}
|
|
556
|
+
)
|
|
557
|
+
);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Require a configured app's saved artifact to belong to that Config ID.
|
|
562
|
+
*
|
|
563
|
+
* @remarks Collection observability and artifact identity are separate facts:
|
|
564
|
+
* a lock can be readable while naming a different app. Saved app-scoped
|
|
565
|
+
* consumers must fail closed on that mismatch instead of presenting the
|
|
566
|
+
* foreign graph under the requested `--app` identity.
|
|
567
|
+
*/
|
|
568
|
+
export const assertSelectedArtifactBinding = async (
|
|
569
|
+
context: OperatorAppProjectContext
|
|
570
|
+
): Promise<Result<void, ValidationError>> => {
|
|
571
|
+
if (!context.app.configured || context.app.id === undefined) {
|
|
572
|
+
return Result.ok();
|
|
573
|
+
}
|
|
574
|
+
const view = await deriveWorkspaceView({
|
|
575
|
+
identity: context.identity,
|
|
576
|
+
selectedAppIds: [context.app.id],
|
|
577
|
+
});
|
|
578
|
+
const observation = view.evidence.apps.find(
|
|
579
|
+
(candidate) => candidate.id === context.app.id
|
|
580
|
+
);
|
|
581
|
+
if (
|
|
582
|
+
observation !== undefined &&
|
|
583
|
+
observation.binding !== 'mismatched' &&
|
|
584
|
+
observation.status !== 'invalid'
|
|
585
|
+
) {
|
|
586
|
+
return Result.ok();
|
|
587
|
+
}
|
|
588
|
+
if (observation === undefined) {
|
|
589
|
+
return Result.err(
|
|
590
|
+
projectContextError(
|
|
591
|
+
`Unable to inspect saved artifact binding for configured app "${context.app.id}".`,
|
|
592
|
+
'invalid-binding',
|
|
593
|
+
{
|
|
594
|
+
appId: context.app.id,
|
|
595
|
+
artifactPath: context.app.lockPath,
|
|
596
|
+
projectRoot: context.projectRoot,
|
|
597
|
+
}
|
|
598
|
+
)
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
return Result.err(
|
|
602
|
+
projectContextError(
|
|
603
|
+
`Saved artifact for configured app "${context.app.id}" is invalid or does not match its Config-owned identity.`,
|
|
604
|
+
'invalid-binding',
|
|
605
|
+
{
|
|
606
|
+
appId: context.app.id,
|
|
607
|
+
artifactPath: context.app.lockPath,
|
|
608
|
+
evidence: observation,
|
|
609
|
+
projectRoot: context.projectRoot,
|
|
610
|
+
}
|
|
611
|
+
)
|
|
612
|
+
);
|
|
613
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Trails-project detection helpers for scaffold trails.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { isDraftMarkedFile } from '@ontrails/warden';
|
|
9
|
+
|
|
10
|
+
/** Return all TypeScript entries in a project's src directory. */
|
|
11
|
+
const sourceEntryPriority = (entry: string): number => {
|
|
12
|
+
if (entry === 'app.ts') {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
return isDraftMarkedFile(entry) ? 2 : 1;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const scanSourceEntries = (srcDir: string): string[] =>
|
|
19
|
+
[...new Bun.Glob('*.ts').scanSync({ cwd: srcDir })].toSorted((a, b) => {
|
|
20
|
+
const priority = sourceEntryPriority(a) - sourceEntryPriority(b);
|
|
21
|
+
if (priority === 0) {
|
|
22
|
+
return a.localeCompare(b);
|
|
23
|
+
}
|
|
24
|
+
return priority;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Resolve an entry to an app import if it contains topo(). */
|
|
28
|
+
const toTopoImport = async (
|
|
29
|
+
srcDir: string,
|
|
30
|
+
entry: string
|
|
31
|
+
): Promise<string | null> => {
|
|
32
|
+
const content = await Bun.file(join(srcDir, entry)).text();
|
|
33
|
+
return content.includes('topo(')
|
|
34
|
+
? `./${entry.replace(/\.ts$/, '.js')}`
|
|
35
|
+
: null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Find the app module that defines a topo inside `src/`. */
|
|
39
|
+
export const findTopoPath = async (cwd: string): Promise<string | null> => {
|
|
40
|
+
const srcDir = join(cwd, 'src');
|
|
41
|
+
if (!existsSync(srcDir)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
for (const entry of scanSourceEntries(srcDir)) {
|
|
47
|
+
const appImport = await toTopoImport(srcDir, entry);
|
|
48
|
+
if (appImport) {
|
|
49
|
+
return appImport;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Detect whether the directory already looks like a Trails project. */
|
|
60
|
+
export const isInsideProject = async (cwd: string): Promise<boolean> => {
|
|
61
|
+
if (existsSync(join(cwd, '.trails'))) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return (await findTopoPath(cwd)) !== null;
|
|
65
|
+
};
|