@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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DRAFT_ID_PREFIX,
|
|
6
|
+
deriveSafePath,
|
|
7
|
+
InternalError,
|
|
8
|
+
Result,
|
|
9
|
+
ValidationError,
|
|
10
|
+
} from '@ontrails/core';
|
|
11
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
12
|
+
|
|
13
|
+
export const PROJECT_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
14
|
+
export const PROJECT_NAME_MESSAGE =
|
|
15
|
+
'Project name must start with a lowercase letter or digit and contain only lowercase letters, digits, ".", "_", or "-".';
|
|
16
|
+
|
|
17
|
+
export const TRAIL_ID_PATTERN =
|
|
18
|
+
/^(?:_draft\.)?[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/u;
|
|
19
|
+
export const TRAIL_ID_MESSAGE =
|
|
20
|
+
'Trail ID must be lowercase dotted segments, optionally prefixed with "_draft.", with each non-draft segment starting with a letter and containing only letters or digits.';
|
|
21
|
+
|
|
22
|
+
const asError = (error: unknown): Error =>
|
|
23
|
+
error instanceof Error ? error : new Error(String(error));
|
|
24
|
+
|
|
25
|
+
export type PlannedProjectOperation =
|
|
26
|
+
| { readonly kind: 'mkdir'; readonly path: string }
|
|
27
|
+
| { readonly kind: 'rename'; readonly from: string; readonly to: string }
|
|
28
|
+
| { readonly kind: 'write'; readonly path: string };
|
|
29
|
+
|
|
30
|
+
export type ProjectWriteOperation =
|
|
31
|
+
| { readonly kind: 'mkdir'; readonly path: string }
|
|
32
|
+
| { readonly kind: 'rename'; readonly from: string; readonly to: string }
|
|
33
|
+
| {
|
|
34
|
+
readonly content: string | Uint8Array;
|
|
35
|
+
readonly kind: 'write';
|
|
36
|
+
readonly path: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const validateProjectName = (
|
|
40
|
+
name: string
|
|
41
|
+
): TrailsResult<string, ValidationError> =>
|
|
42
|
+
PROJECT_NAME_PATTERN.test(name)
|
|
43
|
+
? Result.ok(name)
|
|
44
|
+
: Result.err(new ValidationError(PROJECT_NAME_MESSAGE));
|
|
45
|
+
|
|
46
|
+
export const validateTrailId = (
|
|
47
|
+
trailId: string
|
|
48
|
+
): TrailsResult<string, ValidationError> =>
|
|
49
|
+
TRAIL_ID_PATTERN.test(trailId)
|
|
50
|
+
? Result.ok(trailId)
|
|
51
|
+
: Result.err(new ValidationError(TRAIL_ID_MESSAGE));
|
|
52
|
+
|
|
53
|
+
export const trailIdToModuleName = (trailId: string): string =>
|
|
54
|
+
trailId.startsWith(DRAFT_ID_PREFIX)
|
|
55
|
+
? `${DRAFT_ID_PREFIX}${trailId.slice(DRAFT_ID_PREFIX.length).replaceAll('.', '-')}`
|
|
56
|
+
: trailId.replaceAll('.', '-');
|
|
57
|
+
|
|
58
|
+
export const trailIdToExportName = (trailId: string): string =>
|
|
59
|
+
trailId.replaceAll('.', '_');
|
|
60
|
+
|
|
61
|
+
export const resolveProjectDir = (
|
|
62
|
+
parentDir: string,
|
|
63
|
+
projectName: string
|
|
64
|
+
): TrailsResult<string, Error> => {
|
|
65
|
+
const validated = validateProjectName(projectName);
|
|
66
|
+
if (validated.isErr()) {
|
|
67
|
+
return validated;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return deriveSafePath(resolve(parentDir), validated.value);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const resolveProjectPath = (
|
|
74
|
+
projectDir: string,
|
|
75
|
+
relativePath: string
|
|
76
|
+
): TrailsResult<string, Error> => deriveSafePath(projectDir, relativePath);
|
|
77
|
+
|
|
78
|
+
export const projectPathExists = (
|
|
79
|
+
projectDir: string,
|
|
80
|
+
pathWithinProject: string
|
|
81
|
+
): TrailsResult<boolean, Error> => {
|
|
82
|
+
const target = resolveProjectPath(projectDir, pathWithinProject);
|
|
83
|
+
if (target.isErr()) {
|
|
84
|
+
return target;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return Result.ok(existsSync(target.value));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Write a generated project-relative file and return the relative path. */
|
|
91
|
+
export const writeProjectFile = async (
|
|
92
|
+
projectDir: string,
|
|
93
|
+
relativePath: string,
|
|
94
|
+
content: string | Uint8Array
|
|
95
|
+
): Promise<TrailsResult<string, Error>> => {
|
|
96
|
+
const target = resolveProjectPath(projectDir, relativePath);
|
|
97
|
+
if (target.isErr()) {
|
|
98
|
+
return target;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(dirname(target.value), { recursive: true });
|
|
103
|
+
await Bun.write(target.value, content);
|
|
104
|
+
return Result.ok(relativePath);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return Result.err(
|
|
107
|
+
new InternalError(`Failed to write project file "${relativePath}"`, {
|
|
108
|
+
cause: asError(error),
|
|
109
|
+
context: { projectDir, relativePath },
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Write an already-derived path that must stay contained under the project. */
|
|
116
|
+
export const writeContainedProjectPath = async (
|
|
117
|
+
projectDir: string,
|
|
118
|
+
pathWithinProject: string,
|
|
119
|
+
content: string | Uint8Array
|
|
120
|
+
): Promise<TrailsResult<string, Error>> => {
|
|
121
|
+
const target = resolveProjectPath(projectDir, pathWithinProject);
|
|
122
|
+
if (target.isErr()) {
|
|
123
|
+
return target;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(dirname(target.value), { recursive: true });
|
|
128
|
+
await Bun.write(target.value, content);
|
|
129
|
+
return Result.ok(target.value);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return Result.err(
|
|
132
|
+
new InternalError(
|
|
133
|
+
`Failed to write contained project path "${pathWithinProject}"`,
|
|
134
|
+
{
|
|
135
|
+
cause: asError(error),
|
|
136
|
+
context: { pathWithinProject, projectDir },
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const renameContainedProjectPath = (
|
|
144
|
+
projectDir: string,
|
|
145
|
+
fromPath: string,
|
|
146
|
+
toPath: string
|
|
147
|
+
): TrailsResult<void, Error> => {
|
|
148
|
+
const from = resolveProjectPath(projectDir, fromPath);
|
|
149
|
+
if (from.isErr()) {
|
|
150
|
+
return Result.err(from.error);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const to = resolveProjectPath(projectDir, toPath);
|
|
154
|
+
if (to.isErr()) {
|
|
155
|
+
return Result.err(to.error);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
renameSync(from.value, to.value);
|
|
160
|
+
return Result.ok();
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return Result.err(
|
|
163
|
+
new InternalError(
|
|
164
|
+
`Failed to rename contained project path "${fromPath}"`,
|
|
165
|
+
{
|
|
166
|
+
cause: asError(error),
|
|
167
|
+
context: { fromPath, projectDir, toPath },
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const toProjectRelativePath = (
|
|
175
|
+
projectDir: string,
|
|
176
|
+
pathWithinProject: string
|
|
177
|
+
): TrailsResult<string, Error> => {
|
|
178
|
+
const target = resolveProjectPath(projectDir, pathWithinProject);
|
|
179
|
+
if (target.isErr()) {
|
|
180
|
+
return target;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return Result.ok(
|
|
184
|
+
relative(resolve(projectDir), target.value).replaceAll('\\', '/')
|
|
185
|
+
);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const planProjectOperation = (
|
|
189
|
+
projectDir: string,
|
|
190
|
+
operation: ProjectWriteOperation
|
|
191
|
+
): TrailsResult<PlannedProjectOperation, Error> => {
|
|
192
|
+
switch (operation.kind) {
|
|
193
|
+
case 'mkdir': {
|
|
194
|
+
const path = toProjectRelativePath(projectDir, operation.path);
|
|
195
|
+
return path.isErr()
|
|
196
|
+
? path
|
|
197
|
+
: Result.ok({ kind: 'mkdir', path: path.value });
|
|
198
|
+
}
|
|
199
|
+
case 'rename': {
|
|
200
|
+
const from = toProjectRelativePath(projectDir, operation.from);
|
|
201
|
+
if (from.isErr()) {
|
|
202
|
+
return from;
|
|
203
|
+
}
|
|
204
|
+
const to = toProjectRelativePath(projectDir, operation.to);
|
|
205
|
+
return to.isErr()
|
|
206
|
+
? to
|
|
207
|
+
: Result.ok({ from: from.value, kind: 'rename', to: to.value });
|
|
208
|
+
}
|
|
209
|
+
case 'write': {
|
|
210
|
+
const path = toProjectRelativePath(projectDir, operation.path);
|
|
211
|
+
return path.isErr()
|
|
212
|
+
? path
|
|
213
|
+
: Result.ok({ kind: 'write', path: path.value });
|
|
214
|
+
}
|
|
215
|
+
default: {
|
|
216
|
+
return Result.err(
|
|
217
|
+
new InternalError('Unknown project operation kind', {
|
|
218
|
+
context: { operation },
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const planProjectOperations = (
|
|
226
|
+
projectDir: string,
|
|
227
|
+
operations: readonly ProjectWriteOperation[]
|
|
228
|
+
): TrailsResult<PlannedProjectOperation[], Error> => {
|
|
229
|
+
const planned: PlannedProjectOperation[] = [];
|
|
230
|
+
for (const operation of operations) {
|
|
231
|
+
const result = planProjectOperation(projectDir, operation);
|
|
232
|
+
if (result.isErr()) {
|
|
233
|
+
return Result.err(result.error);
|
|
234
|
+
}
|
|
235
|
+
planned.push(result.value);
|
|
236
|
+
}
|
|
237
|
+
return Result.ok(planned);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const applyProjectOperation = async (
|
|
241
|
+
projectDir: string,
|
|
242
|
+
operation: ProjectWriteOperation
|
|
243
|
+
): Promise<TrailsResult<void, Error>> => {
|
|
244
|
+
switch (operation.kind) {
|
|
245
|
+
case 'mkdir': {
|
|
246
|
+
const target = resolveProjectPath(projectDir, operation.path);
|
|
247
|
+
if (target.isErr()) {
|
|
248
|
+
return Result.err(target.error);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
mkdirSync(target.value, { recursive: true });
|
|
252
|
+
return Result.ok();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return Result.err(
|
|
255
|
+
new InternalError(
|
|
256
|
+
`Failed to create project directory "${operation.path}"`,
|
|
257
|
+
{
|
|
258
|
+
cause: asError(error),
|
|
259
|
+
context: { projectDir, relativePath: operation.path },
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
case 'rename': {
|
|
266
|
+
return renameContainedProjectPath(
|
|
267
|
+
projectDir,
|
|
268
|
+
operation.from,
|
|
269
|
+
operation.to
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
case 'write': {
|
|
273
|
+
const target = resolveProjectPath(projectDir, operation.path);
|
|
274
|
+
if (target.isErr()) {
|
|
275
|
+
return Result.err(target.error);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
mkdirSync(dirname(target.value), { recursive: true });
|
|
279
|
+
await Bun.write(target.value, operation.content);
|
|
280
|
+
return Result.ok();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return Result.err(
|
|
283
|
+
new InternalError(
|
|
284
|
+
`Failed to write project file "${operation.path}"`,
|
|
285
|
+
{
|
|
286
|
+
cause: asError(error),
|
|
287
|
+
context: { projectDir, relativePath: operation.path },
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
default: {
|
|
294
|
+
return Result.err(
|
|
295
|
+
new InternalError('Unknown project operation kind', {
|
|
296
|
+
context: { operation },
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export const applyProjectOperations = async (
|
|
304
|
+
projectDir: string,
|
|
305
|
+
operations: readonly ProjectWriteOperation[]
|
|
306
|
+
): Promise<TrailsResult<PlannedProjectOperation[], Error>> => {
|
|
307
|
+
const planned = planProjectOperations(projectDir, operations);
|
|
308
|
+
if (planned.isErr()) {
|
|
309
|
+
return Result.err(planned.error);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const operation of operations) {
|
|
313
|
+
const applied = await applyProjectOperation(projectDir, operation);
|
|
314
|
+
if (applied.isErr()) {
|
|
315
|
+
return Result.err(applied.error);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return planned;
|
|
320
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-surface bridge for the `run` trail's collision UX.
|
|
3
|
+
*
|
|
4
|
+
* The `run` trail is surface-agnostic: when a trail id collides across two or
|
|
5
|
+
* more workspace apps and no `--app` override is provided, the trail returns
|
|
6
|
+
* `Result.err(AmbiguousError)` with the candidate app names in `error.context`.
|
|
7
|
+
*
|
|
8
|
+
* The CLI surface decides whether to prompt the user (TTY) or surface the
|
|
9
|
+
* error verbatim (non-TTY). This module owns that surface decision so the
|
|
10
|
+
* trail itself never reads `process.stdin.isTTY` or imports a prompt library.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ActionResultContext } from '@ontrails/cli';
|
|
14
|
+
import { AmbiguousError, executeTrail, isPlainObject } from '@ontrails/core';
|
|
15
|
+
import type { Result, Topo } from '@ontrails/core';
|
|
16
|
+
import * as clack from '@clack/prompts';
|
|
17
|
+
|
|
18
|
+
/** Runtime dependencies the wrapper resolves through; injectable for tests. */
|
|
19
|
+
export interface RunCollisionDeps {
|
|
20
|
+
readonly graph: Topo;
|
|
21
|
+
readonly isTTY?: () => boolean;
|
|
22
|
+
readonly promptForApp?: (
|
|
23
|
+
candidates: readonly string[],
|
|
24
|
+
trailId: string
|
|
25
|
+
) => Promise<string | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaultIsTTY = (): boolean => process.stdin.isTTY === true;
|
|
29
|
+
|
|
30
|
+
const defaultPromptForApp = async (
|
|
31
|
+
candidates: readonly string[],
|
|
32
|
+
trailId: string
|
|
33
|
+
): Promise<string | undefined> => {
|
|
34
|
+
const choice = await clack.select({
|
|
35
|
+
message: `Trail ID '${trailId}' is exposed by multiple apps. Choose one:`,
|
|
36
|
+
options: candidates.map((appName) => ({
|
|
37
|
+
label: appName,
|
|
38
|
+
value: appName,
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
return clack.isCancel(choice) ? undefined : (choice as string);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const isAmbiguousCollision = (
|
|
45
|
+
ctx: ActionResultContext
|
|
46
|
+
): ctx is ActionResultContext & {
|
|
47
|
+
readonly result: { readonly error: AmbiguousError };
|
|
48
|
+
} =>
|
|
49
|
+
ctx.trail.id === 'run' &&
|
|
50
|
+
ctx.result.isErr() &&
|
|
51
|
+
ctx.result.error instanceof AmbiguousError;
|
|
52
|
+
|
|
53
|
+
const readCandidates = (error: AmbiguousError): readonly string[] => {
|
|
54
|
+
const ctx = error.context;
|
|
55
|
+
if (!isPlainObject(ctx)) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const raw = ctx['candidates'];
|
|
59
|
+
if (!Array.isArray(raw)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
return raw.filter((entry): entry is string => typeof entry === 'string');
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const readTrailId = (error: AmbiguousError): string | undefined => {
|
|
66
|
+
const ctx = error.context;
|
|
67
|
+
if (!isPlainObject(ctx)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const raw = ctx['trailId'];
|
|
71
|
+
return typeof raw === 'string' ? raw : undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const hasAppOverride = (input: unknown): boolean =>
|
|
75
|
+
isPlainObject(input) && typeof input['app'] === 'string';
|
|
76
|
+
|
|
77
|
+
const mergeAppOverride = (
|
|
78
|
+
input: unknown,
|
|
79
|
+
app: string
|
|
80
|
+
): Record<string, unknown> => ({
|
|
81
|
+
...(isPlainObject(input) ? input : {}),
|
|
82
|
+
app,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Try to recover from an ambiguous-trail-id collision on the run trail.
|
|
87
|
+
*
|
|
88
|
+
* Returns the re-execution result when a TTY prompt yielded a chosen app, or
|
|
89
|
+
* `undefined` when there is nothing to recover (non-TTY, non-collision, or the
|
|
90
|
+
* user cancelled). The caller forwards `undefined` to the default result
|
|
91
|
+
* handler, which surfaces the error verbatim and maps it to exit code 1.
|
|
92
|
+
*/
|
|
93
|
+
export const tryRecoverFromRunCollision = async (
|
|
94
|
+
ctx: ActionResultContext,
|
|
95
|
+
deps: RunCollisionDeps
|
|
96
|
+
): Promise<Result<unknown, Error> | undefined> => {
|
|
97
|
+
if (!isAmbiguousCollision(ctx)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (hasAppOverride(ctx.input)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isTTY = deps.isTTY ?? defaultIsTTY;
|
|
105
|
+
if (!isTTY()) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { error } = ctx.result;
|
|
110
|
+
const candidates = readCandidates(error);
|
|
111
|
+
const trailId = readTrailId(error);
|
|
112
|
+
if (candidates.length === 0 || trailId === undefined) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const promptForApp = deps.promptForApp ?? defaultPromptForApp;
|
|
117
|
+
const chosen = await promptForApp(candidates, trailId);
|
|
118
|
+
if (chosen === undefined) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return await executeTrail(ctx.trail, mergeAppOverride(ctx.input, chosen), {
|
|
123
|
+
topo: deps.graph,
|
|
124
|
+
});
|
|
125
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI bridge for installing shell completion scripts.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally not a trail: it resolves CLI-local defaults such as
|
|
5
|
+
* `$SHELL` and the user's home directory, then writes to the user's completion
|
|
6
|
+
* directory. The surface-agnostic trail remains `completions`, which renders a
|
|
7
|
+
* script string for any caller.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
projectPublicSurfaceError,
|
|
16
|
+
Result,
|
|
17
|
+
ValidationError,
|
|
18
|
+
} from '@ontrails/core';
|
|
19
|
+
import type { Command } from 'commander';
|
|
20
|
+
|
|
21
|
+
import { renderCompletionScript } from './completions.js';
|
|
22
|
+
import type { CompletionShell } from './completions.js';
|
|
23
|
+
|
|
24
|
+
export const COMPLETIONS_BIN_NAME = 'trails';
|
|
25
|
+
|
|
26
|
+
const SHELLS = new Set<CompletionShell>(['bash', 'fish', 'zsh']);
|
|
27
|
+
|
|
28
|
+
const INSTALL_PATH_BY_SHELL: Readonly<Record<CompletionShell, string>> = {
|
|
29
|
+
bash: '.local/share/bash-completion/completions/trails',
|
|
30
|
+
fish: '.config/fish/completions/trails.fish',
|
|
31
|
+
zsh: '.local/share/zsh/site-functions/_trails',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface CompletionsInstallOptions {
|
|
35
|
+
readonly binName?: string | undefined;
|
|
36
|
+
readonly homeDir?: string | undefined;
|
|
37
|
+
readonly shell?: string | undefined;
|
|
38
|
+
readonly shellEnv?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CompletionsInstallResult {
|
|
42
|
+
readonly created: boolean;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
readonly path: string;
|
|
45
|
+
readonly shell: CompletionShell;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface StdoutLike {
|
|
49
|
+
write(chunk: string): unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AttachCompletionsInstallOptions {
|
|
53
|
+
readonly binName?: string | undefined;
|
|
54
|
+
readonly homeDir?: string | undefined;
|
|
55
|
+
readonly shellEnv?: string | undefined;
|
|
56
|
+
readonly stdout?: StdoutLike | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isCompletionShell = (value: string): value is CompletionShell =>
|
|
60
|
+
SHELLS.has(value as CompletionShell);
|
|
61
|
+
|
|
62
|
+
const detectShellFromEnv = (shellEnv: string): CompletionShell | null => {
|
|
63
|
+
if (shellEnv.length === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const slashIndex = shellEnv.lastIndexOf('/');
|
|
67
|
+
const base = slashIndex === -1 ? shellEnv : shellEnv.slice(slashIndex + 1);
|
|
68
|
+
return isCompletionShell(base) ? base : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const unsupportedShellMessage =
|
|
72
|
+
'Could not detect shell from $SHELL. Pass --shell with one of: bash, zsh, fish.';
|
|
73
|
+
|
|
74
|
+
const resolveTargetShell = (input: {
|
|
75
|
+
readonly shell?: string | undefined;
|
|
76
|
+
readonly shellEnv?: string | undefined;
|
|
77
|
+
}): Result<CompletionShell, ValidationError> => {
|
|
78
|
+
if (input.shell !== undefined) {
|
|
79
|
+
if (isCompletionShell(input.shell)) {
|
|
80
|
+
return Result.ok(input.shell);
|
|
81
|
+
}
|
|
82
|
+
return Result.err(
|
|
83
|
+
new ValidationError(
|
|
84
|
+
`Unsupported shell "${input.shell}". Pass one of: bash, zsh, fish.`
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const envValue = input.shellEnv ?? process.env['SHELL'] ?? '';
|
|
89
|
+
const detected = detectShellFromEnv(envValue);
|
|
90
|
+
return detected === null
|
|
91
|
+
? Result.err(new ValidationError(unsupportedShellMessage))
|
|
92
|
+
: Result.ok(detected);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const fileExists = async (path: string): Promise<boolean> =>
|
|
96
|
+
await Bun.file(path).exists();
|
|
97
|
+
|
|
98
|
+
export const runCompletionsInstall = async (
|
|
99
|
+
options: CompletionsInstallOptions = {}
|
|
100
|
+
): Promise<Result<CompletionsInstallResult, Error>> => {
|
|
101
|
+
const shellResult = resolveTargetShell(options);
|
|
102
|
+
if (shellResult.isErr()) {
|
|
103
|
+
return Result.err(shellResult.error);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const shell = shellResult.value;
|
|
107
|
+
const home = options.homeDir ?? homedir();
|
|
108
|
+
const path = join(home, INSTALL_PATH_BY_SHELL[shell]);
|
|
109
|
+
const scriptResult = renderCompletionScript(
|
|
110
|
+
shell,
|
|
111
|
+
options.binName ?? COMPLETIONS_BIN_NAME
|
|
112
|
+
);
|
|
113
|
+
if (scriptResult.isErr()) {
|
|
114
|
+
return Result.err(scriptResult.error);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let existed: boolean;
|
|
118
|
+
try {
|
|
119
|
+
existed = await fileExists(path);
|
|
120
|
+
await mkdir(dirname(path), { recursive: true });
|
|
121
|
+
await Bun.write(path, scriptResult.value);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return Result.err(
|
|
124
|
+
error instanceof Error ? error : new Error(String(error))
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const created = !existed;
|
|
128
|
+
|
|
129
|
+
return Result.ok({
|
|
130
|
+
created,
|
|
131
|
+
message: created
|
|
132
|
+
? `Installed ${shell} completions to ${path}. Run \`exec $SHELL\` or restart your shell to activate.`
|
|
133
|
+
: `Updated ${shell} completions at ${path}.`,
|
|
134
|
+
path,
|
|
135
|
+
shell,
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleCliError = (error: unknown): void => {
|
|
140
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
141
|
+
const projection = projectPublicSurfaceError('cli', err);
|
|
142
|
+
process.stderr.write(`Error: ${projection.message}\n`);
|
|
143
|
+
process.exit(projection.code);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const findCompletionsCommand = (program: Command): Command | undefined =>
|
|
147
|
+
program.commands.find((command) => command.name() === 'completions');
|
|
148
|
+
|
|
149
|
+
export const attachCompletionsInstallCommand = (
|
|
150
|
+
program: Command,
|
|
151
|
+
options: AttachCompletionsInstallOptions = {}
|
|
152
|
+
): void => {
|
|
153
|
+
const completionsCommand =
|
|
154
|
+
findCompletionsCommand(program) ??
|
|
155
|
+
program
|
|
156
|
+
.command('completions')
|
|
157
|
+
.description('Render and install shell completion scripts');
|
|
158
|
+
|
|
159
|
+
completionsCommand
|
|
160
|
+
.command('install')
|
|
161
|
+
.description('Install a shell completion script for the trails CLI')
|
|
162
|
+
.option(
|
|
163
|
+
'-s, --shell <shell>',
|
|
164
|
+
'Target shell; auto-detected from $SHELL when omitted.'
|
|
165
|
+
)
|
|
166
|
+
.action(async (flags: { readonly shell?: string | undefined }) => {
|
|
167
|
+
const result = await runCompletionsInstall({
|
|
168
|
+
binName: options.binName,
|
|
169
|
+
homeDir: options.homeDir,
|
|
170
|
+
shell: flags.shell,
|
|
171
|
+
shellEnv: options.shellEnv,
|
|
172
|
+
});
|
|
173
|
+
if (result.isErr()) {
|
|
174
|
+
handleCliError(result.error);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
(options.stdout ?? process.stdout).write(`${result.value.message}\n`);
|
|
178
|
+
});
|
|
179
|
+
};
|