@ontrails/trails 1.0.0-beta.14 → 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.
Files changed (197) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +27 -0
  3. package/package.json +19 -8
  4. package/src/app.ts +17 -7
  5. package/src/clack.ts +1 -1
  6. package/src/cli.ts +304 -10
  7. package/src/completions.ts +240 -0
  8. package/src/load-app-mirror.ts +160 -0
  9. package/src/local-state-io.ts +153 -0
  10. package/src/project-writes.ts +320 -0
  11. package/src/run-collision.ts +125 -0
  12. package/src/run-completions-install.ts +179 -0
  13. package/src/run-example.ts +149 -0
  14. package/src/run-examples.ts +148 -0
  15. package/src/run-quiet.ts +75 -0
  16. package/src/run-trace.ts +273 -0
  17. package/src/run-warden.ts +39 -0
  18. package/src/run-watch.ts +432 -0
  19. package/src/scaffold-versions.generated.ts +12 -0
  20. package/src/trails/add-surface.ts +172 -0
  21. package/src/trails/add-trail.ts +73 -27
  22. package/src/trails/add-verify.ts +68 -23
  23. package/src/trails/completions-complete.ts +165 -0
  24. package/src/trails/completions.ts +47 -0
  25. package/src/trails/create-scaffold.ts +101 -35
  26. package/src/trails/create.ts +87 -74
  27. package/src/trails/dev-clean.ts +31 -22
  28. package/src/trails/dev-reset.ts +9 -3
  29. package/src/trails/dev-stats.ts +28 -20
  30. package/src/trails/dev-support.ts +109 -95
  31. package/src/trails/draft-promote.ts +351 -107
  32. package/src/trails/guide.ts +55 -38
  33. package/src/trails/load-app.ts +712 -38
  34. package/src/trails/root-dir.ts +21 -0
  35. package/src/trails/run-example.ts +482 -0
  36. package/src/trails/run-examples.ts +141 -0
  37. package/src/trails/run.ts +403 -0
  38. package/src/trails/survey.ts +517 -186
  39. package/src/trails/topo-activation.ts +385 -0
  40. package/src/trails/topo-compile.ts +55 -0
  41. package/src/trails/topo-history.ts +14 -11
  42. package/src/trails/topo-output-schemas.ts +175 -0
  43. package/src/trails/topo-pin.ts +25 -16
  44. package/src/trails/topo-read-support.ts +178 -238
  45. package/src/trails/topo-reports.ts +445 -63
  46. package/src/trails/topo-store-support.ts +67 -35
  47. package/src/trails/topo-support.ts +93 -147
  48. package/src/trails/topo-unpin.ts +17 -7
  49. package/src/trails/topo-verify.ts +19 -10
  50. package/src/trails/topo.ts +64 -31
  51. package/src/trails/warden-guide.ts +121 -0
  52. package/src/trails/warden.ts +137 -47
  53. package/src/versions.ts +28 -0
  54. package/.turbo/turbo-build.log +0 -1
  55. package/.turbo/turbo-lint.log +0 -3
  56. package/.turbo/turbo-typecheck.log +0 -1
  57. package/__tests__/examples.test.ts +0 -20
  58. package/dist/bin/trails.d.ts +0 -3
  59. package/dist/bin/trails.d.ts.map +0 -1
  60. package/dist/bin/trails.js +0 -4
  61. package/dist/bin/trails.js.map +0 -1
  62. package/dist/src/app.d.ts +0 -2
  63. package/dist/src/app.d.ts.map +0 -1
  64. package/dist/src/app.js +0 -22
  65. package/dist/src/app.js.map +0 -1
  66. package/dist/src/clack.d.ts +0 -9
  67. package/dist/src/clack.d.ts.map +0 -1
  68. package/dist/src/clack.js +0 -84
  69. package/dist/src/clack.js.map +0 -1
  70. package/dist/src/cli.d.ts +0 -2
  71. package/dist/src/cli.d.ts.map +0 -1
  72. package/dist/src/cli.js +0 -13
  73. package/dist/src/cli.js.map +0 -1
  74. package/dist/src/trails/add-surface.d.ts +0 -13
  75. package/dist/src/trails/add-surface.d.ts.map +0 -1
  76. package/dist/src/trails/add-surface.js +0 -88
  77. package/dist/src/trails/add-surface.js.map +0 -1
  78. package/dist/src/trails/add-trail.d.ts +0 -10
  79. package/dist/src/trails/add-trail.d.ts.map +0 -1
  80. package/dist/src/trails/add-trail.js +0 -77
  81. package/dist/src/trails/add-trail.js.map +0 -1
  82. package/dist/src/trails/add-trailhead.d.ts +0 -13
  83. package/dist/src/trails/add-trailhead.d.ts.map +0 -1
  84. package/dist/src/trails/add-trailhead.js +0 -88
  85. package/dist/src/trails/add-trailhead.js.map +0 -1
  86. package/dist/src/trails/add-verify.d.ts +0 -10
  87. package/dist/src/trails/add-verify.d.ts.map +0 -1
  88. package/dist/src/trails/add-verify.js +0 -67
  89. package/dist/src/trails/add-verify.js.map +0 -1
  90. package/dist/src/trails/create-scaffold.d.ts +0 -15
  91. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  92. package/dist/src/trails/create-scaffold.js +0 -288
  93. package/dist/src/trails/create-scaffold.js.map +0 -1
  94. package/dist/src/trails/create.d.ts +0 -22
  95. package/dist/src/trails/create.d.ts.map +0 -1
  96. package/dist/src/trails/create.js +0 -121
  97. package/dist/src/trails/create.js.map +0 -1
  98. package/dist/src/trails/dev-clean.d.ts +0 -9
  99. package/dist/src/trails/dev-clean.d.ts.map +0 -1
  100. package/dist/src/trails/dev-clean.js +0 -65
  101. package/dist/src/trails/dev-clean.js.map +0 -1
  102. package/dist/src/trails/dev-reset.d.ts +0 -6
  103. package/dist/src/trails/dev-reset.d.ts.map +0 -1
  104. package/dist/src/trails/dev-reset.js +0 -38
  105. package/dist/src/trails/dev-reset.js.map +0 -1
  106. package/dist/src/trails/dev-stats.d.ts +0 -7
  107. package/dist/src/trails/dev-stats.d.ts.map +0 -1
  108. package/dist/src/trails/dev-stats.js +0 -61
  109. package/dist/src/trails/dev-stats.js.map +0 -1
  110. package/dist/src/trails/dev-support.d.ts +0 -64
  111. package/dist/src/trails/dev-support.d.ts.map +0 -1
  112. package/dist/src/trails/dev-support.js +0 -178
  113. package/dist/src/trails/dev-support.js.map +0 -1
  114. package/dist/src/trails/draft-promote.d.ts +0 -18
  115. package/dist/src/trails/draft-promote.d.ts.map +0 -1
  116. package/dist/src/trails/draft-promote.js +0 -386
  117. package/dist/src/trails/draft-promote.js.map +0 -1
  118. package/dist/src/trails/guide.d.ts +0 -21
  119. package/dist/src/trails/guide.d.ts.map +0 -1
  120. package/dist/src/trails/guide.js +0 -64
  121. package/dist/src/trails/guide.js.map +0 -1
  122. package/dist/src/trails/load-app.d.ts +0 -6
  123. package/dist/src/trails/load-app.d.ts.map +0 -1
  124. package/dist/src/trails/load-app.js +0 -67
  125. package/dist/src/trails/load-app.js.map +0 -1
  126. package/dist/src/trails/project.d.ts +0 -8
  127. package/dist/src/trails/project.d.ts.map +0 -1
  128. package/dist/src/trails/project.js +0 -54
  129. package/dist/src/trails/project.js.map +0 -1
  130. package/dist/src/trails/survey.d.ts +0 -18
  131. package/dist/src/trails/survey.d.ts.map +0 -1
  132. package/dist/src/trails/survey.js +0 -212
  133. package/dist/src/trails/survey.js.map +0 -1
  134. package/dist/src/trails/topo-constants.d.ts +0 -3
  135. package/dist/src/trails/topo-constants.d.ts.map +0 -1
  136. package/dist/src/trails/topo-constants.js +0 -3
  137. package/dist/src/trails/topo-constants.js.map +0 -1
  138. package/dist/src/trails/topo-export.d.ts +0 -18
  139. package/dist/src/trails/topo-export.d.ts.map +0 -1
  140. package/dist/src/trails/topo-export.js +0 -34
  141. package/dist/src/trails/topo-export.js.map +0 -1
  142. package/dist/src/trails/topo-history.d.ts +0 -24
  143. package/dist/src/trails/topo-history.d.ts.map +0 -1
  144. package/dist/src/trails/topo-history.js +0 -33
  145. package/dist/src/trails/topo-history.js.map +0 -1
  146. package/dist/src/trails/topo-pin.d.ts +0 -21
  147. package/dist/src/trails/topo-pin.d.ts.map +0 -1
  148. package/dist/src/trails/topo-pin.js +0 -35
  149. package/dist/src/trails/topo-pin.js.map +0 -1
  150. package/dist/src/trails/topo-read-support.d.ts +0 -54
  151. package/dist/src/trails/topo-read-support.d.ts.map +0 -1
  152. package/dist/src/trails/topo-read-support.js +0 -178
  153. package/dist/src/trails/topo-read-support.js.map +0 -1
  154. package/dist/src/trails/topo-reports.d.ts +0 -50
  155. package/dist/src/trails/topo-reports.d.ts.map +0 -1
  156. package/dist/src/trails/topo-reports.js +0 -122
  157. package/dist/src/trails/topo-reports.js.map +0 -1
  158. package/dist/src/trails/topo-show.d.ts +0 -23
  159. package/dist/src/trails/topo-show.d.ts.map +0 -1
  160. package/dist/src/trails/topo-show.js +0 -53
  161. package/dist/src/trails/topo-show.js.map +0 -1
  162. package/dist/src/trails/topo-store-support.d.ts +0 -13
  163. package/dist/src/trails/topo-store-support.d.ts.map +0 -1
  164. package/dist/src/trails/topo-store-support.js +0 -55
  165. package/dist/src/trails/topo-store-support.js.map +0 -1
  166. package/dist/src/trails/topo-support.d.ts +0 -87
  167. package/dist/src/trails/topo-support.d.ts.map +0 -1
  168. package/dist/src/trails/topo-support.js +0 -165
  169. package/dist/src/trails/topo-support.js.map +0 -1
  170. package/dist/src/trails/topo-unpin.d.ts +0 -15
  171. package/dist/src/trails/topo-unpin.d.ts.map +0 -1
  172. package/dist/src/trails/topo-unpin.js +0 -39
  173. package/dist/src/trails/topo-unpin.js.map +0 -1
  174. package/dist/src/trails/topo-verify.d.ts +0 -5
  175. package/dist/src/trails/topo-verify.d.ts.map +0 -1
  176. package/dist/src/trails/topo-verify.js +0 -28
  177. package/dist/src/trails/topo-verify.js.map +0 -1
  178. package/dist/src/trails/topo.d.ts +0 -5
  179. package/dist/src/trails/topo.d.ts.map +0 -1
  180. package/dist/src/trails/topo.js +0 -67
  181. package/dist/src/trails/topo.js.map +0 -1
  182. package/dist/src/trails/warden.d.ts +0 -19
  183. package/dist/src/trails/warden.d.ts.map +0 -1
  184. package/dist/src/trails/warden.js +0 -89
  185. package/dist/src/trails/warden.js.map +0 -1
  186. package/dist/tsconfig.tsbuildinfo +0 -1
  187. package/src/__tests__/create.test.ts +0 -351
  188. package/src/__tests__/draft-promote.test.ts +0 -144
  189. package/src/__tests__/guide.test.ts +0 -91
  190. package/src/__tests__/load-app.test.ts +0 -58
  191. package/src/__tests__/survey.test.ts +0 -301
  192. package/src/__tests__/topo-dev.test.ts +0 -424
  193. package/src/__tests__/warden.test.ts +0 -74
  194. package/src/trails/add-trailhead.ts +0 -121
  195. package/src/trails/topo-export.ts +0 -39
  196. package/src/trails/topo-show.ts +0 -58
  197. package/tsconfig.json +0 -9
@@ -0,0 +1,153 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, isAbsolute, join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import {
7
+ deriveSafePath,
8
+ InternalError,
9
+ Result,
10
+ ValidationError,
11
+ } from '@ontrails/core';
12
+ import type { Result as TrailsResult } from '@ontrails/core';
13
+
14
+ const EXAMPLE_ROOT_PARENT = join(tmpdir(), 'ontrails-trails-examples');
15
+ const EXAMPLE_ROOT_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
16
+
17
+ const asError = (error: unknown): Error =>
18
+ error instanceof Error ? error : new Error(String(error));
19
+
20
+ const resolveExampleRoot = (name: string): TrailsResult<string, Error> => {
21
+ if (!EXAMPLE_ROOT_NAME_PATTERN.test(name)) {
22
+ return Result.err(
23
+ new ValidationError(
24
+ 'Example root name must be lowercase and contain only letters, digits, ".", "_", or "-".',
25
+ { context: { name } }
26
+ )
27
+ );
28
+ }
29
+
30
+ return deriveSafePath(EXAMPLE_ROOT_PARENT, name);
31
+ };
32
+
33
+ export const createIsolatedExampleRoot = (name: string): string => {
34
+ const root = resolveExampleRoot(name);
35
+ if (root.isErr()) {
36
+ throw root.error;
37
+ }
38
+
39
+ try {
40
+ rmSync(root.value, { force: true, recursive: true });
41
+ mkdirSync(root.value, { recursive: true });
42
+ return root.value;
43
+ } catch (error) {
44
+ throw new InternalError(`Failed to recreate example root "${name}"`, {
45
+ cause: asError(error),
46
+ context: { name, rootDir: root.value },
47
+ });
48
+ }
49
+ };
50
+
51
+ export const writeIsolatedExampleAppModule = (
52
+ rootDir: string,
53
+ sourceModulePath: string
54
+ ): string => {
55
+ if (!isAbsolute(sourceModulePath)) {
56
+ throw new ValidationError(
57
+ 'Example app source module path must be absolute.',
58
+ {
59
+ context: { rootDir, sourceModulePath },
60
+ }
61
+ );
62
+ }
63
+
64
+ const modulePath = './src/app.ts';
65
+ const target = deriveSafePath(rootDir, modulePath);
66
+ if (target.isErr()) {
67
+ throw target.error;
68
+ }
69
+
70
+ try {
71
+ mkdirSync(dirname(target.value), { recursive: true });
72
+ writeFileSync(
73
+ target.value,
74
+ `export { app } from ${JSON.stringify(pathToFileURL(sourceModulePath).href)};\n`
75
+ );
76
+ return modulePath;
77
+ } catch (error) {
78
+ throw new InternalError('Failed to write isolated example app module', {
79
+ cause: asError(error),
80
+ context: { rootDir, sourceModulePath, targetPath: target.value },
81
+ });
82
+ }
83
+ };
84
+
85
+ export const writeIsolatedExampleJsonFile = (
86
+ rootDir: string,
87
+ relativePath: string,
88
+ value: unknown
89
+ ): string => {
90
+ const target = deriveSafePath(rootDir, relativePath);
91
+ if (target.isErr()) {
92
+ throw target.error;
93
+ }
94
+
95
+ try {
96
+ mkdirSync(dirname(target.value), { recursive: true });
97
+ writeFileSync(target.value, `${JSON.stringify(value, null, 2)}\n`);
98
+ return relativePath;
99
+ } catch (error) {
100
+ throw new InternalError('Failed to write isolated example JSON file', {
101
+ cause: asError(error),
102
+ context: { relativePath, rootDir, targetPath: target.value },
103
+ });
104
+ }
105
+ };
106
+
107
+ export const writeIsolatedExampleTextFile = (
108
+ rootDir: string,
109
+ relativePath: string,
110
+ contents: string
111
+ ): string => {
112
+ const target = deriveSafePath(rootDir, relativePath);
113
+ if (target.isErr()) {
114
+ throw target.error;
115
+ }
116
+
117
+ try {
118
+ mkdirSync(dirname(target.value), { recursive: true });
119
+ writeFileSync(target.value, contents);
120
+ return relativePath;
121
+ } catch (error) {
122
+ throw new InternalError('Failed to write isolated example text file', {
123
+ cause: asError(error),
124
+ context: { relativePath, rootDir, targetPath: target.value },
125
+ });
126
+ }
127
+ };
128
+
129
+ export const removeRootRelativeFileIfPresent = (
130
+ rootDir: string,
131
+ relativePath: string
132
+ ): TrailsResult<boolean, Error> => {
133
+ const target = deriveSafePath(rootDir, relativePath);
134
+ if (target.isErr()) {
135
+ return target;
136
+ }
137
+
138
+ if (!existsSync(target.value)) {
139
+ return Result.ok(false);
140
+ }
141
+
142
+ try {
143
+ rmSync(target.value, { force: true });
144
+ return Result.ok(true);
145
+ } catch (error) {
146
+ return Result.err(
147
+ new InternalError(`Failed to remove local state file "${relativePath}"`, {
148
+ cause: asError(error),
149
+ context: { relativePath, rootDir, targetPath: target.value },
150
+ })
151
+ );
152
+ }
153
+ };
@@ -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
+ };