@ontrails/trails 1.0.0-beta.15 → 1.0.0-beta.16
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 +197 -2
- package/README.md +27 -0
- package/package.json +19 -8
- package/src/app.ts +15 -5
- package/src/cli.ts +303 -10
- package/src/completions.ts +240 -0
- package/src/load-app-mirror.ts +160 -0
- package/src/local-state-io.ts +153 -0
- package/src/project-writes.ts +320 -0
- package/src/run-collision.ts +125 -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-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch.ts +432 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/add-surface.ts +45 -23
- package/src/trails/add-trail.ts +27 -17
- package/src/trails/add-verify.ts +57 -17
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-scaffold.ts +86 -33
- package/src/trails/create.ts +11 -3
- package/src/trails/dev-clean.ts +6 -1
- package/src/trails/dev-reset.ts +6 -1
- package/src/trails/dev-stats.ts +6 -1
- package/src/trails/dev-support.ts +29 -17
- package/src/trails/draft-promote.ts +289 -80
- package/src/trails/guide.ts +54 -34
- package/src/trails/load-app.ts +251 -56
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +482 -0
- package/src/trails/run-examples.ts +141 -0
- package/src/trails/run.ts +403 -0
- package/src/trails/survey.ts +506 -200
- package/src/trails/topo-activation.ts +385 -0
- package/src/trails/topo-compile.ts +55 -0
- package/src/trails/topo-history.ts +6 -1
- package/src/trails/topo-output-schemas.ts +175 -0
- package/src/trails/topo-pin.ts +19 -6
- package/src/trails/topo-read-support.ts +171 -228
- package/src/trails/topo-reports.ts +400 -25
- package/src/trails/topo-store-support.ts +43 -19
- package/src/trails/topo-support.ts +18 -28
- package/src/trails/topo-unpin.ts +6 -1
- package/src/trails/topo-verify.ts +18 -5
- package/src/trails/topo.ts +60 -23
- package/src/trails/warden-guide.ts +121 -0
- package/src/trails/warden.ts +137 -56
- package/src/versions.ts +3 -18
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/__tests__/examples.test.ts +0 -45
- package/dist/bin/trails.d.ts +0 -3
- package/dist/bin/trails.d.ts.map +0 -1
- package/dist/bin/trails.js +0 -4
- package/dist/bin/trails.js.map +0 -1
- package/dist/src/app.d.ts +0 -2
- package/dist/src/app.d.ts.map +0 -1
- package/dist/src/app.js +0 -22
- package/dist/src/app.js.map +0 -1
- package/dist/src/clack.d.ts +0 -9
- package/dist/src/clack.d.ts.map +0 -1
- package/dist/src/clack.js +0 -84
- package/dist/src/clack.js.map +0 -1
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -14
- package/dist/src/cli.js.map +0 -1
- package/dist/src/trails/add-surface.d.ts +0 -13
- package/dist/src/trails/add-surface.d.ts.map +0 -1
- package/dist/src/trails/add-surface.js +0 -110
- package/dist/src/trails/add-surface.js.map +0 -1
- package/dist/src/trails/add-trail.d.ts +0 -12
- package/dist/src/trails/add-trail.d.ts.map +0 -1
- package/dist/src/trails/add-trail.js +0 -104
- package/dist/src/trails/add-trail.js.map +0 -1
- package/dist/src/trails/add-trailhead.d.ts +0 -13
- package/dist/src/trails/add-trailhead.d.ts.map +0 -1
- package/dist/src/trails/add-trailhead.js +0 -88
- package/dist/src/trails/add-trailhead.js.map +0 -1
- package/dist/src/trails/add-verify.d.ts +0 -10
- package/dist/src/trails/add-verify.d.ts.map +0 -1
- package/dist/src/trails/add-verify.js +0 -68
- package/dist/src/trails/add-verify.js.map +0 -1
- package/dist/src/trails/create-scaffold.d.ts +0 -15
- package/dist/src/trails/create-scaffold.d.ts.map +0 -1
- package/dist/src/trails/create-scaffold.js +0 -295
- package/dist/src/trails/create-scaffold.js.map +0 -1
- package/dist/src/trails/create.d.ts +0 -18
- package/dist/src/trails/create.d.ts.map +0 -1
- package/dist/src/trails/create.js +0 -126
- package/dist/src/trails/create.js.map +0 -1
- package/dist/src/trails/dev-clean.d.ts +0 -9
- package/dist/src/trails/dev-clean.d.ts.map +0 -1
- package/dist/src/trails/dev-clean.js +0 -66
- package/dist/src/trails/dev-clean.js.map +0 -1
- package/dist/src/trails/dev-reset.d.ts +0 -6
- package/dist/src/trails/dev-reset.d.ts.map +0 -1
- package/dist/src/trails/dev-reset.js +0 -39
- package/dist/src/trails/dev-reset.js.map +0 -1
- package/dist/src/trails/dev-stats.d.ts +0 -7
- package/dist/src/trails/dev-stats.d.ts.map +0 -1
- package/dist/src/trails/dev-stats.js +0 -61
- package/dist/src/trails/dev-stats.js.map +0 -1
- package/dist/src/trails/dev-support.d.ts +0 -64
- package/dist/src/trails/dev-support.d.ts.map +0 -1
- package/dist/src/trails/dev-support.js +0 -181
- package/dist/src/trails/dev-support.js.map +0 -1
- package/dist/src/trails/draft-promote.d.ts +0 -18
- package/dist/src/trails/draft-promote.d.ts.map +0 -1
- package/dist/src/trails/draft-promote.js +0 -400
- package/dist/src/trails/draft-promote.js.map +0 -1
- package/dist/src/trails/guide.d.ts +0 -21
- package/dist/src/trails/guide.d.ts.map +0 -1
- package/dist/src/trails/guide.js +0 -61
- package/dist/src/trails/guide.js.map +0 -1
- package/dist/src/trails/load-app.d.ts +0 -12
- package/dist/src/trails/load-app.d.ts.map +0 -1
- package/dist/src/trails/load-app.js +0 -415
- package/dist/src/trails/load-app.js.map +0 -1
- package/dist/src/trails/project.d.ts +0 -8
- package/dist/src/trails/project.d.ts.map +0 -1
- package/dist/src/trails/project.js +0 -54
- package/dist/src/trails/project.js.map +0 -1
- package/dist/src/trails/survey.d.ts +0 -18
- package/dist/src/trails/survey.d.ts.map +0 -1
- package/dist/src/trails/survey.js +0 -234
- package/dist/src/trails/survey.js.map +0 -1
- package/dist/src/trails/topo-constants.d.ts +0 -3
- package/dist/src/trails/topo-constants.d.ts.map +0 -1
- package/dist/src/trails/topo-constants.js +0 -3
- package/dist/src/trails/topo-constants.js.map +0 -1
- package/dist/src/trails/topo-export.d.ts +0 -19
- package/dist/src/trails/topo-export.d.ts.map +0 -1
- package/dist/src/trails/topo-export.js +0 -31
- package/dist/src/trails/topo-export.js.map +0 -1
- package/dist/src/trails/topo-history.d.ts +0 -20
- package/dist/src/trails/topo-history.d.ts.map +0 -1
- package/dist/src/trails/topo-history.js +0 -32
- package/dist/src/trails/topo-history.js.map +0 -1
- package/dist/src/trails/topo-pin.d.ts +0 -17
- package/dist/src/trails/topo-pin.d.ts.map +0 -1
- package/dist/src/trails/topo-pin.js +0 -31
- package/dist/src/trails/topo-pin.js.map +0 -1
- package/dist/src/trails/topo-read-support.d.ts +0 -58
- package/dist/src/trails/topo-read-support.d.ts.map +0 -1
- package/dist/src/trails/topo-read-support.js +0 -167
- package/dist/src/trails/topo-read-support.js.map +0 -1
- package/dist/src/trails/topo-reports.d.ts +0 -54
- package/dist/src/trails/topo-reports.d.ts.map +0 -1
- package/dist/src/trails/topo-reports.js +0 -128
- package/dist/src/trails/topo-reports.js.map +0 -1
- package/dist/src/trails/topo-show.d.ts +0 -23
- package/dist/src/trails/topo-show.d.ts.map +0 -1
- package/dist/src/trails/topo-show.js +0 -49
- package/dist/src/trails/topo-show.js.map +0 -1
- package/dist/src/trails/topo-store-support.d.ts +0 -13
- package/dist/src/trails/topo-store-support.d.ts.map +0 -1
- package/dist/src/trails/topo-store-support.js +0 -55
- package/dist/src/trails/topo-store-support.js.map +0 -1
- package/dist/src/trails/topo-support.d.ts +0 -76
- package/dist/src/trails/topo-support.d.ts.map +0 -1
- package/dist/src/trails/topo-support.js +0 -132
- package/dist/src/trails/topo-support.js.map +0 -1
- package/dist/src/trails/topo-unpin.d.ts +0 -20
- package/dist/src/trails/topo-unpin.d.ts.map +0 -1
- package/dist/src/trails/topo-unpin.js +0 -44
- package/dist/src/trails/topo-unpin.js.map +0 -1
- package/dist/src/trails/topo-verify.d.ts +0 -5
- package/dist/src/trails/topo-verify.d.ts.map +0 -1
- package/dist/src/trails/topo-verify.js +0 -24
- package/dist/src/trails/topo-verify.js.map +0 -1
- package/dist/src/trails/topo.d.ts +0 -5
- package/dist/src/trails/topo.d.ts.map +0 -1
- package/dist/src/trails/topo.js +0 -63
- package/dist/src/trails/topo.js.map +0 -1
- package/dist/src/trails/warden.d.ts +0 -20
- package/dist/src/trails/warden.d.ts.map +0 -1
- package/dist/src/trails/warden.js +0 -98
- package/dist/src/trails/warden.js.map +0 -1
- package/dist/src/versions.d.ts +0 -12
- package/dist/src/versions.d.ts.map +0 -1
- package/dist/src/versions.js +0 -23
- package/dist/src/versions.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/__tests__/add-trail.test.ts +0 -97
- package/src/__tests__/create.test.ts +0 -415
- package/src/__tests__/draft-promote.test.ts +0 -144
- package/src/__tests__/guide.test.ts +0 -96
- package/src/__tests__/load-app.test.ts +0 -419
- package/src/__tests__/survey.test.ts +0 -377
- package/src/__tests__/topo-dev.test.ts +0 -426
- package/src/__tests__/warden.test.ts +0 -74
- package/src/trails/topo-export.ts +0 -35
- package/src/trails/topo-show.ts +0 -54
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
package/src/trails/guide.ts
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
import { NotFoundError, Result, trail } from '@ontrails/core';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { tryLoadFreshAppLease } from './load-app.js';
|
|
11
|
+
import { trailDetailOutput } from './topo-output-schemas.js';
|
|
11
12
|
import {
|
|
12
13
|
buildCurrentGuideEntries,
|
|
13
14
|
buildCurrentTopoDetail,
|
|
15
|
+
readSurfaceLayerNamesFromContext,
|
|
14
16
|
} from './topo-read-support.js';
|
|
17
|
+
import { createIsolatedExampleInput } from './topo-support.js';
|
|
18
|
+
import { resolveTrailRootDir } from './root-dir.js';
|
|
15
19
|
|
|
16
20
|
// ---------------------------------------------------------------------------
|
|
17
21
|
// Types
|
|
@@ -21,7 +25,7 @@ interface GuideEntry {
|
|
|
21
25
|
readonly description: string;
|
|
22
26
|
readonly exampleCount: number;
|
|
23
27
|
readonly id: string;
|
|
24
|
-
readonly kind:
|
|
28
|
+
readonly kind: 'trail';
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
// ---------------------------------------------------------------------------
|
|
@@ -30,57 +34,73 @@ interface GuideEntry {
|
|
|
30
34
|
|
|
31
35
|
export const guideTrail = trail('guide', {
|
|
32
36
|
blaze: async (input, ctx) => {
|
|
33
|
-
const
|
|
34
|
-
|
|
37
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
38
|
+
if (rootDirResult.isErr()) {
|
|
39
|
+
return Result.err(rootDirResult.error);
|
|
40
|
+
}
|
|
41
|
+
const rootDir = rootDirResult.value;
|
|
42
|
+
const leaseResult = await tryLoadFreshAppLease(input.module, rootDir);
|
|
43
|
+
if (leaseResult.isErr()) {
|
|
44
|
+
return Result.err(leaseResult.error);
|
|
45
|
+
}
|
|
46
|
+
const lease = leaseResult.value;
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
48
|
+
try {
|
|
49
|
+
if (input.trailId) {
|
|
50
|
+
const detail = buildCurrentTopoDetail(lease.app, input.trailId, {
|
|
51
|
+
rootDir,
|
|
52
|
+
surfaceLayerNames: readSurfaceLayerNamesFromContext(ctx),
|
|
53
|
+
});
|
|
54
|
+
if (detail === undefined || detail.kind !== 'trail') {
|
|
55
|
+
return Result.err(
|
|
56
|
+
new NotFoundError(`Trail not found: ${input.trailId}`)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return Result.ok({
|
|
60
|
+
detail,
|
|
61
|
+
mode: 'detail' as const,
|
|
62
|
+
});
|
|
42
63
|
}
|
|
64
|
+
|
|
43
65
|
return Result.ok({
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
kind: detail.kind,
|
|
66
|
+
entries: buildCurrentGuideEntries(lease.app, {
|
|
67
|
+
rootDir,
|
|
68
|
+
}) as GuideEntry[],
|
|
69
|
+
mode: 'list' as const,
|
|
49
70
|
});
|
|
71
|
+
} finally {
|
|
72
|
+
lease.release();
|
|
50
73
|
}
|
|
51
|
-
|
|
52
|
-
return Result.ok(
|
|
53
|
-
buildCurrentGuideEntries(app, { rootDir }) as GuideEntry[]
|
|
54
|
-
);
|
|
55
74
|
},
|
|
56
75
|
description: 'Runtime guidance for trails',
|
|
57
76
|
examples: [
|
|
58
77
|
{
|
|
59
78
|
description: 'Lists all trails with descriptions and example counts',
|
|
60
|
-
input:
|
|
79
|
+
input: createIsolatedExampleInput('guide-list'),
|
|
61
80
|
name: 'List trail guidance',
|
|
62
81
|
},
|
|
63
82
|
],
|
|
64
83
|
input: z.object({
|
|
65
84
|
module: z.string().optional().describe('Path to the app module'),
|
|
85
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
66
86
|
trailId: z.string().optional().describe('Trail ID for detailed guidance'),
|
|
67
87
|
}),
|
|
68
88
|
intent: 'read',
|
|
69
|
-
output: z.
|
|
70
|
-
z.
|
|
71
|
-
z.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
output: z.discriminatedUnion('mode', [
|
|
90
|
+
z.object({
|
|
91
|
+
entries: z.array(
|
|
92
|
+
z.object({
|
|
93
|
+
description: z.string(),
|
|
94
|
+
exampleCount: z.number(),
|
|
95
|
+
id: z.string(),
|
|
96
|
+
kind: z.literal('trail'),
|
|
97
|
+
})
|
|
98
|
+
),
|
|
99
|
+
mode: z.literal('list'),
|
|
100
|
+
}),
|
|
78
101
|
z.object({
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
examples: z.array(z.unknown()),
|
|
82
|
-
id: z.string(),
|
|
83
|
-
kind: z.string(),
|
|
102
|
+
detail: trailDetailOutput,
|
|
103
|
+
mode: z.literal('detail'),
|
|
84
104
|
}),
|
|
85
105
|
]),
|
|
86
106
|
});
|
package/src/trails/load-app.ts
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from 'node:fs';
|
|
2
8
|
import {
|
|
3
9
|
dirname,
|
|
4
10
|
extname,
|
|
5
11
|
isAbsolute,
|
|
6
12
|
join,
|
|
7
|
-
parse as parsePath,
|
|
8
13
|
relative,
|
|
9
14
|
resolve,
|
|
10
15
|
} from 'node:path';
|
|
11
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
17
|
|
|
18
|
+
import {
|
|
19
|
+
deriveSafePath,
|
|
20
|
+
InternalError,
|
|
21
|
+
isTrailsError,
|
|
22
|
+
PermissionError,
|
|
23
|
+
Result,
|
|
24
|
+
ValidationError,
|
|
25
|
+
} from '@ontrails/core';
|
|
13
26
|
import type { Topo } from '@ontrails/core';
|
|
14
27
|
import { findAppModule } from '@ontrails/cli';
|
|
15
28
|
|
|
29
|
+
import {
|
|
30
|
+
createLoadAppMirrorRootPath,
|
|
31
|
+
LOAD_APP_MIRROR_ENTRY_PREFIX,
|
|
32
|
+
LOAD_APP_MIRROR_PARENT_DIRNAME,
|
|
33
|
+
removeLoadAppMirrorRootQuietly,
|
|
34
|
+
resolveLoadAppMirrorFilePath,
|
|
35
|
+
writeLoadAppMirrorFile,
|
|
36
|
+
} from '../load-app-mirror.js';
|
|
37
|
+
|
|
16
38
|
const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
|
|
17
39
|
|
|
18
40
|
type TranspilerLoader = 'ts' | 'tsx' | 'js' | 'jsx';
|
|
@@ -58,7 +80,7 @@ const RETAINED_MIRROR_ROOTS = new Set<string>();
|
|
|
58
80
|
|
|
59
81
|
const cleanupAllMirrorRoots = (): void => {
|
|
60
82
|
for (const root of [...ACTIVE_MIRROR_ROOTS, ...RETAINED_MIRROR_ROOTS]) {
|
|
61
|
-
|
|
83
|
+
removeLoadAppMirrorRootQuietly(root);
|
|
62
84
|
}
|
|
63
85
|
ACTIVE_MIRROR_ROOTS.clear();
|
|
64
86
|
RETAINED_MIRROR_ROOTS.clear();
|
|
@@ -110,7 +132,7 @@ const acquireMirrorLease = (mirrorRoot: string): (() => void) => {
|
|
|
110
132
|
|
|
111
133
|
released = true;
|
|
112
134
|
ACTIVE_MIRROR_ROOTS.delete(mirrorRoot);
|
|
113
|
-
|
|
135
|
+
removeLoadAppMirrorRootQuietly(mirrorRoot);
|
|
114
136
|
};
|
|
115
137
|
};
|
|
116
138
|
|
|
@@ -134,30 +156,161 @@ const resolveFilesystemModulePath = (
|
|
|
134
156
|
return existsSync(tsPath) ? tsPath : absolutePath;
|
|
135
157
|
};
|
|
136
158
|
|
|
137
|
-
|
|
138
|
-
|
|
159
|
+
const trustBoundaryError = (reason: string): PermissionError =>
|
|
160
|
+
new PermissionError(
|
|
161
|
+
`Refusing to load an app module outside the workspace trust boundary (${reason}). Use a workspace-relative module path, or pass trustedModulePath: true from trusted code.`
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const toLoadAppError = (error: unknown): Error =>
|
|
165
|
+
isTrailsError(error)
|
|
166
|
+
? error
|
|
167
|
+
: new InternalError('Failed to load app module', {
|
|
168
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const isPathInside = (root: string, target: string): boolean => {
|
|
172
|
+
const candidate = relative(root, target);
|
|
173
|
+
return (
|
|
174
|
+
candidate === '' || (!candidate.startsWith('..') && !isAbsolute(candidate))
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const realpathIfPresent = (path: string): string | undefined => {
|
|
179
|
+
try {
|
|
180
|
+
return realpathSync(path);
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const ensureRealPathInsideCwd = (
|
|
187
|
+
resolvedModulePath: string,
|
|
188
|
+
cwd: string
|
|
189
|
+
): string => {
|
|
190
|
+
const realRoot = realpathIfPresent(cwd);
|
|
191
|
+
const realModule = realpathIfPresent(resolvedModulePath);
|
|
192
|
+
if (
|
|
193
|
+
realRoot !== undefined &&
|
|
194
|
+
realModule !== undefined &&
|
|
195
|
+
!isPathInside(realRoot, realModule)
|
|
196
|
+
) {
|
|
197
|
+
throw trustBoundaryError('symlink_escape');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return resolvedModulePath;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Resolve a caller-trusted module path using the legacy escape hatch policy. */
|
|
204
|
+
const resolveTrustedModulePath = (modulePath: string, cwd: string): string =>
|
|
139
205
|
URL_SCHEME.test(modulePath)
|
|
140
206
|
? resolveUrlModulePath(modulePath)
|
|
141
207
|
: resolveFilesystemModulePath(modulePath, cwd);
|
|
142
208
|
|
|
143
|
-
const MIRROR_PARENT_DIRNAME = '.trails-tmp';
|
|
144
|
-
|
|
145
|
-
const MIRROR_ENTRY_PREFIX = 'load-app-fresh-';
|
|
146
|
-
|
|
147
209
|
/**
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
210
|
+
* Resolve the default app module path inside cwd.
|
|
211
|
+
*
|
|
212
|
+
* @remarks
|
|
213
|
+
* CLI and trail callers accept user-supplied module specifiers, so the default
|
|
214
|
+
* policy is deliberately narrower than `import()` itself: no URL schemes, no
|
|
215
|
+
* absolute paths, and no parent traversal. Internal callers that intentionally
|
|
216
|
+
* load a path outside cwd must opt into `trustedModulePath`.
|
|
152
217
|
*/
|
|
153
|
-
const
|
|
154
|
-
|
|
218
|
+
const resolveContainedModulePath = (
|
|
219
|
+
modulePath: string,
|
|
220
|
+
cwd: string
|
|
221
|
+
): string => {
|
|
222
|
+
if (URL_SCHEME.test(modulePath)) {
|
|
223
|
+
throw trustBoundaryError('url_scheme');
|
|
224
|
+
}
|
|
225
|
+
if (isAbsolute(modulePath)) {
|
|
226
|
+
throw trustBoundaryError('absolute_path');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const safePath = deriveSafePath(cwd, modulePath);
|
|
230
|
+
if (safePath.isErr()) {
|
|
231
|
+
throw trustBoundaryError('parent_escape');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return ensureRealPathInsideCwd(
|
|
235
|
+
resolveFilesystemModulePath(safePath.value, cwd),
|
|
236
|
+
cwd
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
interface LoadAppTrustOptions {
|
|
241
|
+
readonly trustedModulePath?: boolean | undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const resolveLoadAppModulePath = (
|
|
245
|
+
modulePath: string,
|
|
246
|
+
cwd: string,
|
|
247
|
+
options: LoadAppTrustOptions = {}
|
|
248
|
+
): string =>
|
|
249
|
+
options.trustedModulePath === true
|
|
250
|
+
? resolveTrustedModulePath(modulePath, cwd)
|
|
251
|
+
: resolveContainedModulePath(modulePath, cwd);
|
|
252
|
+
|
|
253
|
+
const findWorkspaceRelativeAppModule = (cwd: string): string => {
|
|
254
|
+
const discovered = findAppModule(cwd);
|
|
255
|
+
return isAbsolute(discovered) ? relative(cwd, discovered) : discovered;
|
|
256
|
+
};
|
|
155
257
|
|
|
156
258
|
const isLocalFilesystemImport = (importPath: string): boolean =>
|
|
157
259
|
importPath.startsWith('.') ||
|
|
158
260
|
importPath.startsWith('/') ||
|
|
159
261
|
importPath.startsWith('file:');
|
|
160
262
|
|
|
263
|
+
const readPackageName = (packagePath: string): string | undefined => {
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as
|
|
266
|
+
| { readonly name?: unknown }
|
|
267
|
+
| undefined;
|
|
268
|
+
return typeof parsed?.name === 'string' && parsed.name.length > 0
|
|
269
|
+
? parsed.name
|
|
270
|
+
: undefined;
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const findNearestPackageName = (directoryPath: string): string | undefined => {
|
|
277
|
+
let current = directoryPath;
|
|
278
|
+
while (true) {
|
|
279
|
+
const name = readPackageName(join(current, 'package.json'));
|
|
280
|
+
if (name !== undefined) {
|
|
281
|
+
return name;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const parent = dirname(current);
|
|
285
|
+
if (parent === current) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
current = parent;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const isPackageLocalImport = (
|
|
293
|
+
importerPath: string,
|
|
294
|
+
importPath: string
|
|
295
|
+
): boolean => {
|
|
296
|
+
if (importPath.startsWith('#')) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const packageName = findNearestPackageName(dirname(importerPath));
|
|
301
|
+
return (
|
|
302
|
+
packageName !== undefined &&
|
|
303
|
+
(importPath === packageName || importPath.startsWith(`${packageName}/`))
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const shouldMirrorImportSpecifier = (
|
|
308
|
+
importerPath: string,
|
|
309
|
+
importPath: string
|
|
310
|
+
): boolean =>
|
|
311
|
+
isLocalFilesystemImport(importPath) ||
|
|
312
|
+
isPackageLocalImport(importerPath, importPath);
|
|
313
|
+
|
|
161
314
|
const isScannableModule = (modulePath: string): boolean =>
|
|
162
315
|
SCANNABLE_EXTENSIONS.has(extname(modulePath));
|
|
163
316
|
|
|
@@ -188,19 +341,10 @@ const collectImportedModulePaths = (
|
|
|
188
341
|
return getImportScanner(loader)
|
|
189
342
|
.scanImports(source)
|
|
190
343
|
.map((entry) => entry.path)
|
|
191
|
-
.filter(
|
|
344
|
+
.filter((importPath) => shouldMirrorImportSpecifier(modulePath, importPath))
|
|
192
345
|
.map((importPath) => resolveImportedModulePath(modulePath, importPath));
|
|
193
346
|
};
|
|
194
347
|
|
|
195
|
-
/**
|
|
196
|
-
* Copy a single file into the mirror by raw bytes.
|
|
197
|
-
*
|
|
198
|
-
* @remarks
|
|
199
|
-
* Reading via `.bytes()` rather than `.text()` preserves binary payloads
|
|
200
|
-
* (`.wasm`, `.node`, compiled assets) that may sit alongside source files in
|
|
201
|
-
* the app's graph. Text decoding would corrupt them on the way through the
|
|
202
|
-
* mirror.
|
|
203
|
-
*/
|
|
204
348
|
const copyFileToMirror = async (
|
|
205
349
|
sourcePath: string,
|
|
206
350
|
mirrorRoot: string,
|
|
@@ -211,10 +355,10 @@ const copyFileToMirror = async (
|
|
|
211
355
|
}
|
|
212
356
|
copied.add(sourcePath);
|
|
213
357
|
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
358
|
+
const written = await writeLoadAppMirrorFile(sourcePath, mirrorRoot);
|
|
359
|
+
if (written.isErr()) {
|
|
360
|
+
throw written.error;
|
|
361
|
+
}
|
|
218
362
|
};
|
|
219
363
|
|
|
220
364
|
/**
|
|
@@ -301,14 +445,11 @@ const isStaleMirrorEntry = (entryPath: string, now: number): boolean => {
|
|
|
301
445
|
};
|
|
302
446
|
|
|
303
447
|
const removeStaleMirrorEntry = (entryPath: string): void => {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
* will retry.
|
|
310
|
-
*/
|
|
311
|
-
}
|
|
448
|
+
/*
|
|
449
|
+
* Another concurrent load may own it. Safe to ignore — the next sweep
|
|
450
|
+
* will retry.
|
|
451
|
+
*/
|
|
452
|
+
removeLoadAppMirrorRootQuietly(entryPath);
|
|
312
453
|
};
|
|
313
454
|
|
|
314
455
|
/**
|
|
@@ -322,7 +463,7 @@ const cleanupStaleMirrorRoots = (mirrorParent: string): void => {
|
|
|
322
463
|
}
|
|
323
464
|
const now = Date.now();
|
|
324
465
|
for (const entry of entries) {
|
|
325
|
-
if (!entry.startsWith(
|
|
466
|
+
if (!entry.startsWith(LOAD_APP_MIRROR_ENTRY_PREFIX)) {
|
|
326
467
|
continue;
|
|
327
468
|
}
|
|
328
469
|
const entryPath = join(mirrorParent, entry);
|
|
@@ -333,12 +474,9 @@ const cleanupStaleMirrorRoots = (mirrorParent: string): void => {
|
|
|
333
474
|
};
|
|
334
475
|
|
|
335
476
|
const freshMirrorRootPath = (cwd: string): string => {
|
|
336
|
-
const mirrorParent = join(cwd,
|
|
477
|
+
const mirrorParent = join(cwd, LOAD_APP_MIRROR_PARENT_DIRNAME);
|
|
337
478
|
cleanupStaleMirrorRoots(mirrorParent);
|
|
338
|
-
return
|
|
339
|
-
mirrorParent,
|
|
340
|
-
`${MIRROR_ENTRY_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
341
|
-
);
|
|
479
|
+
return createLoadAppMirrorRootPath(cwd);
|
|
342
480
|
};
|
|
343
481
|
|
|
344
482
|
interface MirrorWalkContext {
|
|
@@ -383,11 +521,33 @@ const copyDirectoryTreeToMirror = async (
|
|
|
383
521
|
}
|
|
384
522
|
};
|
|
385
523
|
|
|
524
|
+
const copyNearestPackageJsonToMirror = async (
|
|
525
|
+
directoryPath: string,
|
|
526
|
+
context: MirrorWalkContext
|
|
527
|
+
): Promise<void> => {
|
|
528
|
+
let current = directoryPath;
|
|
529
|
+
while (true) {
|
|
530
|
+
const packagePath = join(current, 'package.json');
|
|
531
|
+
const packageStat = safeStat(packagePath);
|
|
532
|
+
if (packageStat?.isFile()) {
|
|
533
|
+
await copyFileToMirror(packagePath, context.mirrorRoot, context.copied);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const parent = dirname(current);
|
|
538
|
+
if (parent === current) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
current = parent;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
386
545
|
const mirrorImportedModule = async (
|
|
387
546
|
modulePath: string,
|
|
388
547
|
context: MirrorWalkContext
|
|
389
548
|
): Promise<void> => {
|
|
390
549
|
const moduleDirectory = dirname(modulePath);
|
|
550
|
+
await copyNearestPackageJsonToMirror(moduleDirectory, context);
|
|
391
551
|
if (context.visitedDirectories.has(moduleDirectory)) {
|
|
392
552
|
await copyFileToMirror(modulePath, context.mirrorRoot, context.copied);
|
|
393
553
|
return;
|
|
@@ -429,7 +589,11 @@ const mirrorFreshImportGraph = async (
|
|
|
429
589
|
};
|
|
430
590
|
|
|
431
591
|
await visit(entryPath);
|
|
432
|
-
|
|
592
|
+
const freshPath = resolveLoadAppMirrorFilePath(entryPath, mirrorRoot);
|
|
593
|
+
if (freshPath.isErr()) {
|
|
594
|
+
throw freshPath.error;
|
|
595
|
+
}
|
|
596
|
+
return freshPath.value;
|
|
433
597
|
};
|
|
434
598
|
|
|
435
599
|
/**
|
|
@@ -461,16 +625,16 @@ const prepareMirror = async (
|
|
|
461
625
|
const freshPath = await mirrorFreshImportGraph(absolutePath, mirrorRoot);
|
|
462
626
|
return { freshPath, mirrorRoot };
|
|
463
627
|
} catch (error) {
|
|
464
|
-
|
|
628
|
+
removeLoadAppMirrorRootQuietly(mirrorRoot);
|
|
465
629
|
throw error;
|
|
466
630
|
}
|
|
467
631
|
};
|
|
468
632
|
|
|
469
633
|
const importFreshModule = async (
|
|
470
|
-
|
|
634
|
+
resolvedModulePath: string,
|
|
471
635
|
cwd: string
|
|
472
636
|
): Promise<Record<string, unknown>> => {
|
|
473
|
-
const absolutePath =
|
|
637
|
+
const absolutePath = resolvedModulePath;
|
|
474
638
|
if (URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')) {
|
|
475
639
|
return await importWithCacheBust(absolutePath);
|
|
476
640
|
}
|
|
@@ -491,7 +655,7 @@ const resolveLoadedTopo = (
|
|
|
491
655
|
| Topo
|
|
492
656
|
| undefined;
|
|
493
657
|
if (!app?.trails) {
|
|
494
|
-
throw new
|
|
658
|
+
throw new ValidationError(
|
|
495
659
|
`Could not find a Topo export in "${effectivePath}". ` +
|
|
496
660
|
"Expected a default, 'graph', or 'app' named export created with topo()."
|
|
497
661
|
);
|
|
@@ -505,6 +669,12 @@ export interface FreshAppLease {
|
|
|
505
669
|
readonly release: () => void;
|
|
506
670
|
}
|
|
507
671
|
|
|
672
|
+
export type LoadAppLeaseOptions = LoadAppTrustOptions;
|
|
673
|
+
|
|
674
|
+
export interface LoadAppOptions extends LoadAppTrustOptions {
|
|
675
|
+
readonly fresh?: boolean | undefined;
|
|
676
|
+
}
|
|
677
|
+
|
|
508
678
|
const noopRelease = (): void => undefined;
|
|
509
679
|
|
|
510
680
|
const createUrlSchemeLease = async (
|
|
@@ -546,26 +716,51 @@ const createFilesystemLease = async (
|
|
|
546
716
|
|
|
547
717
|
export const loadFreshAppLease = async (
|
|
548
718
|
modulePath: string | undefined,
|
|
549
|
-
cwd: string
|
|
719
|
+
cwd: string,
|
|
720
|
+
options: LoadAppLeaseOptions = {}
|
|
550
721
|
): Promise<FreshAppLease> => {
|
|
551
722
|
const effectivePath =
|
|
552
|
-
modulePath === undefined ?
|
|
553
|
-
const absolutePath =
|
|
723
|
+
modulePath === undefined ? findWorkspaceRelativeAppModule(cwd) : modulePath;
|
|
724
|
+
const absolutePath = resolveLoadAppModulePath(effectivePath, cwd, options);
|
|
554
725
|
|
|
555
726
|
return URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')
|
|
556
727
|
? await createUrlSchemeLease(absolutePath, effectivePath)
|
|
557
728
|
: await createFilesystemLease(absolutePath, cwd, effectivePath);
|
|
558
729
|
};
|
|
559
730
|
|
|
560
|
-
|
|
731
|
+
export const tryLoadFreshAppLease = async (
|
|
732
|
+
modulePath: string | undefined,
|
|
733
|
+
cwd: string,
|
|
734
|
+
options: LoadAppLeaseOptions = {}
|
|
735
|
+
): Promise<Result<FreshAppLease, Error>> => {
|
|
736
|
+
try {
|
|
737
|
+
return Result.ok(await loadFreshAppLease(modulePath, cwd, options));
|
|
738
|
+
} catch (error) {
|
|
739
|
+
return Result.err(toLoadAppError(error));
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Load a Topo export from a module path relative to cwd.
|
|
745
|
+
*
|
|
746
|
+
* @remarks
|
|
747
|
+
* By default, `modulePath` must be workspace-relative and stay under `cwd`.
|
|
748
|
+
* URL-shaped, absolute, and parent-escape paths are rejected. Trusted internal
|
|
749
|
+
* callers can pass `trustedModulePath: true` to deliberately use the broader
|
|
750
|
+
* dynamic-import escape hatch.
|
|
751
|
+
*/
|
|
561
752
|
export const loadApp = async (
|
|
562
753
|
modulePath: string | undefined,
|
|
563
754
|
cwd: string,
|
|
564
|
-
options:
|
|
755
|
+
options: LoadAppOptions = {}
|
|
565
756
|
): Promise<Topo> => {
|
|
566
757
|
const effectivePath =
|
|
567
|
-
modulePath === undefined ?
|
|
568
|
-
const resolvedModulePath =
|
|
758
|
+
modulePath === undefined ? findWorkspaceRelativeAppModule(cwd) : modulePath;
|
|
759
|
+
const resolvedModulePath = resolveLoadAppModulePath(
|
|
760
|
+
effectivePath,
|
|
761
|
+
cwd,
|
|
762
|
+
options
|
|
763
|
+
);
|
|
569
764
|
const mod =
|
|
570
765
|
options.fresh === true
|
|
571
766
|
? await importFreshModule(resolvedModulePath, cwd)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Result, ValidationError } from '@ontrails/core';
|
|
2
|
+
|
|
3
|
+
const ROOT_DIR_MESSAGE =
|
|
4
|
+
'Trail execution requires rootDir input or ctx.cwd from the runtime context.';
|
|
5
|
+
|
|
6
|
+
export const resolveTrailRootDir = (
|
|
7
|
+
rootDir: string | undefined,
|
|
8
|
+
cwd: string | undefined
|
|
9
|
+
): Result<string, ValidationError> => {
|
|
10
|
+
const resolved = rootDir ?? cwd;
|
|
11
|
+
return resolved === undefined
|
|
12
|
+
? Result.err(new ValidationError(ROOT_DIR_MESSAGE))
|
|
13
|
+
: Result.ok(resolved);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const requireTrailRootDir = (rootDir: string | undefined): string => {
|
|
17
|
+
if (rootDir === undefined) {
|
|
18
|
+
throw new ValidationError(ROOT_DIR_MESSAGE);
|
|
19
|
+
}
|
|
20
|
+
return rootDir;
|
|
21
|
+
};
|