@ontrails/trails 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1906 -0
- package/README.md +48 -0
- package/bin/trails.ts +3 -0
- package/package.json +57 -0
- package/src/app.ts +167 -0
- package/src/clack.ts +111 -0
- package/src/cli.ts +308 -0
- package/src/completions.ts +431 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +129 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +92 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/audit.ts +571 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +636 -0
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/live-api-preserve.ts +123 -0
- package/src/regrade/plan-artifact.ts +515 -0
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +446 -0
- package/src/regrade/source-transaction.ts +185 -0
- package/src/release/bindings.ts +58 -0
- package/src/release/changeset-packages.ts +99 -0
- package/src/release/check.ts +1191 -0
- package/src/release/cli-bundle.ts +575 -0
- package/src/release/config.ts +73 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/homebrew.ts +221 -0
- package/src/release/index.ts +180 -0
- package/src/release/lock-roundtrip-smoke.ts +255 -0
- package/src/release/lock-roundtrip-workspace.ts +107 -0
- package/src/release/native-bun-publish.ts +964 -0
- package/src/release/native-bun-registry.ts +848 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1780 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/stable-version-release.ts +80 -0
- package/src/release/wayfinder-dogfood-smoke.ts +762 -0
- package/src/release/zero-line-transition.ts +68 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-argv.ts +133 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-regrade-progress.ts +47 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch-project.ts +52 -0
- package/src/run-watch.ts +381 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +816 -0
- package/src/trails/add-trail.ts +141 -0
- package/src/trails/add-verify.ts +252 -0
- package/src/trails/compile.ts +118 -0
- package/src/trails/completions-complete.ts +236 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/config-explain.ts +43 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +1215 -0
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +652 -0
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +80 -0
- package/src/trails/dev-reset.ts +48 -0
- package/src/trails/dev-stats.ts +71 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +106 -0
- package/src/trails/load-app.ts +1145 -0
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project-context-output.ts +304 -0
- package/src/trails/project-context.ts +613 -0
- package/src/trails/project.ts +65 -0
- package/src/trails/regrade.ts +4951 -0
- package/src/trails/release-check.ts +113 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +592 -0
- package/src/trails/run-examples.ts +149 -0
- package/src/trails/run.ts +496 -0
- package/src/trails/scaffold-json.ts +60 -0
- package/src/trails/scaffold-topo-identity.ts +479 -0
- package/src/trails/survey.ts +990 -0
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +323 -0
- package/src/trails/topo-support.ts +247 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +348 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +598 -0
- package/src/trails/wayfind-diff.ts +716 -0
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1319 -0
- package/src/versions.ts +31 -0
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `add.surface` trail -- Add a surface to an existing project.
|
|
3
|
+
*
|
|
4
|
+
* Generates surface entry points and updates package.json dependencies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { isPlainObject, Result, trail, ValidationError } from '@ontrails/core';
|
|
11
|
+
import ts from 'typescript';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
projectPathExists,
|
|
16
|
+
resolveProjectPath,
|
|
17
|
+
writeProjectFile,
|
|
18
|
+
} from '../project-writes.js';
|
|
19
|
+
import { ontrailsPackageRange } from '../versions.js';
|
|
20
|
+
import { findTopoPath } from './project.js';
|
|
21
|
+
import { stringifyScaffoldPackageJson } from './scaffold-json.js';
|
|
22
|
+
|
|
23
|
+
type Surface = 'cli' | 'http' | 'mcp';
|
|
24
|
+
type SurfaceEntryRoot = 'bin' | 'src';
|
|
25
|
+
|
|
26
|
+
interface FileSystemEntries {
|
|
27
|
+
readonly directories: string[];
|
|
28
|
+
readonly files: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type MatchTypeScriptFiles = (
|
|
32
|
+
path: string,
|
|
33
|
+
extensions: readonly string[] | undefined,
|
|
34
|
+
excludes: readonly string[] | undefined,
|
|
35
|
+
includes: readonly string[] | undefined,
|
|
36
|
+
useCaseSensitiveFileNames: boolean,
|
|
37
|
+
currentDirectory: string,
|
|
38
|
+
depth: number | undefined,
|
|
39
|
+
getFileSystemEntries: (path: string) => FileSystemEntries,
|
|
40
|
+
realpath: (path: string) => string
|
|
41
|
+
) => string[];
|
|
42
|
+
|
|
43
|
+
const auditedTypeScriptVersion = '5.9.3';
|
|
44
|
+
const auditedMatchFilesArity = 9;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* TypeScript's public config host trusts readDirectory results without matching
|
|
48
|
+
* virtual paths again. Keep this pinned runtime seam private so TypeScript still
|
|
49
|
+
* owns prospective include/exclude/files semantics instead of Trails parsing globs.
|
|
50
|
+
*/
|
|
51
|
+
const matchTypeScriptFiles = (
|
|
52
|
+
ts as typeof ts & { readonly matchFiles?: MatchTypeScriptFiles }
|
|
53
|
+
).matchFiles;
|
|
54
|
+
|
|
55
|
+
const readAuditedTypeScriptMatcher = (): Result<
|
|
56
|
+
MatchTypeScriptFiles,
|
|
57
|
+
Error
|
|
58
|
+
> => {
|
|
59
|
+
if (ts.version !== auditedTypeScriptVersion) {
|
|
60
|
+
return Result.err(
|
|
61
|
+
new ValidationError(
|
|
62
|
+
'TypeScript runtime is not audited for prospective surface matching.',
|
|
63
|
+
{
|
|
64
|
+
context: {
|
|
65
|
+
actualVersion: ts.version,
|
|
66
|
+
expectedVersion: auditedTypeScriptVersion,
|
|
67
|
+
reason: 'typescript-prospective-matcher-version-mismatch',
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (typeof matchTypeScriptFiles !== 'function') {
|
|
74
|
+
return Result.err(
|
|
75
|
+
new ValidationError(
|
|
76
|
+
'Pinned TypeScript runtime cannot match prospective surface entry paths.',
|
|
77
|
+
{
|
|
78
|
+
context: {
|
|
79
|
+
reason: 'typescript-prospective-matcher-unavailable',
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (matchTypeScriptFiles.length !== auditedMatchFilesArity) {
|
|
86
|
+
return Result.err(
|
|
87
|
+
new ValidationError(
|
|
88
|
+
'Pinned TypeScript runtime has an incompatible prospective surface matcher.',
|
|
89
|
+
{
|
|
90
|
+
context: {
|
|
91
|
+
actualArity: matchTypeScriptFiles.length,
|
|
92
|
+
expectedArity: auditedMatchFilesArity,
|
|
93
|
+
reason: 'typescript-prospective-matcher-incompatible',
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return Result.ok(matchTypeScriptFiles);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const generateCliEntry = (appImportPath: string): string =>
|
|
103
|
+
`import { resolveTrailsOverlays } from '@ontrails/adapter-kit';
|
|
104
|
+
import { devPermitPreset, permitPreset } from '@ontrails/cli';
|
|
105
|
+
import { surface } from '@ontrails/commander';
|
|
106
|
+
|
|
107
|
+
import * as appModule from '${appImportPath}';
|
|
108
|
+
|
|
109
|
+
const { app } = appModule;
|
|
110
|
+
const overlays = resolveTrailsOverlays(appModule, '${appImportPath}');
|
|
111
|
+
|
|
112
|
+
await surface(app, {
|
|
113
|
+
overlays,
|
|
114
|
+
presets: [permitPreset(), devPermitPreset()],
|
|
115
|
+
});
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const generateMcpEntry = (appImportPath: string): string =>
|
|
119
|
+
`import { resolveTrailsOverlays } from '@ontrails/adapter-kit';
|
|
120
|
+
import { surface } from '@ontrails/mcp';
|
|
121
|
+
|
|
122
|
+
import * as appModule from '${appImportPath}';
|
|
123
|
+
|
|
124
|
+
const { app } = appModule;
|
|
125
|
+
const overlays = resolveTrailsOverlays(appModule, '${appImportPath}');
|
|
126
|
+
|
|
127
|
+
await surface(app, { overlays });
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const generateHttpEntry = (appImportPath: string): string =>
|
|
131
|
+
`import { surface } from '@ontrails/hono';
|
|
132
|
+
|
|
133
|
+
import { app } from '${appImportPath}';
|
|
134
|
+
|
|
135
|
+
await surface(app, { port: 3000 });
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
const surfaceEntryNames = {
|
|
139
|
+
cli: 'cli.ts',
|
|
140
|
+
http: 'http.ts',
|
|
141
|
+
mcp: 'mcp.ts',
|
|
142
|
+
} satisfies Record<Surface, string>;
|
|
143
|
+
|
|
144
|
+
const surfaceEntryRoots = [
|
|
145
|
+
'bin',
|
|
146
|
+
'src',
|
|
147
|
+
] as const satisfies readonly SurfaceEntryRoot[];
|
|
148
|
+
|
|
149
|
+
const surfaceDependencies = {
|
|
150
|
+
cli: ['@ontrails/cli', '@ontrails/commander', '@ontrails/adapter-kit'],
|
|
151
|
+
http: ['@ontrails/hono', '@ontrails/http'],
|
|
152
|
+
mcp: ['@ontrails/mcp', '@ontrails/adapter-kit'],
|
|
153
|
+
} satisfies Record<Surface, readonly string[]>;
|
|
154
|
+
|
|
155
|
+
/** Resolve the entry file for a surface. */
|
|
156
|
+
const getEntryFile = (surface: Surface, entryRoot: SurfaceEntryRoot): string =>
|
|
157
|
+
`${entryRoot}/${surfaceEntryNames[surface]}`;
|
|
158
|
+
|
|
159
|
+
interface ConfiguredTypeScriptLayout {
|
|
160
|
+
readonly incompatibilities: ReadonlyMap<
|
|
161
|
+
SurfaceEntryRoot,
|
|
162
|
+
readonly TypeScriptSyntaxIncompatibility[]
|
|
163
|
+
>;
|
|
164
|
+
readonly rootDir: string | null;
|
|
165
|
+
readonly supportedEntryRoots: ReadonlySet<SurfaceEntryRoot>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface SurfaceEntryResolutionOptions {
|
|
169
|
+
readonly projectedLocalTsconfig?: boolean;
|
|
170
|
+
readonly projectedPackageTypeModule?: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface TypeScriptSyntaxIncompatibility {
|
|
174
|
+
readonly code: number;
|
|
175
|
+
readonly configPath: string;
|
|
176
|
+
readonly message: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const canonicalLintEntryRoots = new Map<string, ReadonlySet<SurfaceEntryRoot>>([
|
|
180
|
+
['oxlint ./bin', new Set(['bin'])],
|
|
181
|
+
['oxlint ./src', new Set(['src'])],
|
|
182
|
+
['oxlint ./src ./bin', new Set(['bin', 'src'])],
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Recognize a lint command the scaffolder owns, including the narrower entry
|
|
187
|
+
* scopes earlier generations produced.
|
|
188
|
+
*/
|
|
189
|
+
export const isCanonicalLintCommand = (command: string): boolean =>
|
|
190
|
+
canonicalLintEntryRoots.has(command);
|
|
191
|
+
|
|
192
|
+
const normalizeForComparison = (path: string): string =>
|
|
193
|
+
ts.sys.useCaseSensitiveFileNames
|
|
194
|
+
? resolve(path)
|
|
195
|
+
: resolve(path).toLowerCase();
|
|
196
|
+
|
|
197
|
+
const isWithinDirectory = (directory: string, path: string): boolean => {
|
|
198
|
+
const pathFromDirectory = relative(directory, path);
|
|
199
|
+
return (
|
|
200
|
+
pathFromDirectory === '' ||
|
|
201
|
+
(!pathFromDirectory.startsWith('..') && !isAbsolute(pathFromDirectory))
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const createProspectiveFileSystemEntries =
|
|
206
|
+
(candidatePaths: readonly string[]): ((path: string) => FileSystemEntries) =>
|
|
207
|
+
(path) => {
|
|
208
|
+
const directory = resolve(path);
|
|
209
|
+
const directories = new Set<string>();
|
|
210
|
+
const files = new Set<string>();
|
|
211
|
+
|
|
212
|
+
for (const candidatePath of candidatePaths) {
|
|
213
|
+
const pathFromDirectory = relative(directory, candidatePath);
|
|
214
|
+
if (
|
|
215
|
+
pathFromDirectory === '' ||
|
|
216
|
+
pathFromDirectory.startsWith('..') ||
|
|
217
|
+
isAbsolute(pathFromDirectory)
|
|
218
|
+
) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const [entry, ...remaining] = pathFromDirectory.split(/[\\/]/u);
|
|
222
|
+
if (entry === undefined) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (remaining.length === 0) {
|
|
226
|
+
files.add(entry);
|
|
227
|
+
} else {
|
|
228
|
+
directories.add(entry);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { directories: [...directories], files: [...files] };
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
interface TypeScriptCoverageContext {
|
|
236
|
+
readonly candidatePaths: ReadonlyMap<SurfaceEntryRoot, string>;
|
|
237
|
+
readonly cwd: string;
|
|
238
|
+
readonly incompatibilities: Map<
|
|
239
|
+
SurfaceEntryRoot,
|
|
240
|
+
TypeScriptSyntaxIncompatibility[]
|
|
241
|
+
>;
|
|
242
|
+
readonly matcher: MatchTypeScriptFiles;
|
|
243
|
+
readonly prospectiveEntries: (path: string) => FileSystemEntries;
|
|
244
|
+
readonly projectedPackageTypeModule: boolean;
|
|
245
|
+
readonly supportedEntryRoots: Set<SurfaceEntryRoot>;
|
|
246
|
+
readonly visitedConfigPaths: Set<string>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const generatedSurfaceSyntaxDiagnosticCodes = new Set([1309, 1378]);
|
|
250
|
+
|
|
251
|
+
const readGeneratedSurfaceSyntaxDiagnostic = (
|
|
252
|
+
candidatePath: string,
|
|
253
|
+
cwd: string,
|
|
254
|
+
options: ts.CompilerOptions,
|
|
255
|
+
projectedPackageTypeModule: boolean
|
|
256
|
+
): ts.Diagnostic | undefined => {
|
|
257
|
+
const source = 'export {};\nawait 0;\n';
|
|
258
|
+
const normalizedCandidatePath = normalizeForComparison(candidatePath);
|
|
259
|
+
const projectedPackagePath = normalizeForComparison(
|
|
260
|
+
resolve(cwd, 'package.json')
|
|
261
|
+
);
|
|
262
|
+
const host = ts.createCompilerHost({ ...options, noEmit: true });
|
|
263
|
+
const readSourceFile = host.getSourceFile.bind(host);
|
|
264
|
+
const fileExists = host.fileExists.bind(host);
|
|
265
|
+
const readFile = host.readFile.bind(host);
|
|
266
|
+
host.fileExists = (path) =>
|
|
267
|
+
(projectedPackageTypeModule &&
|
|
268
|
+
normalizeForComparison(path) === projectedPackagePath) ||
|
|
269
|
+
normalizeForComparison(path) === normalizedCandidatePath ||
|
|
270
|
+
fileExists(path);
|
|
271
|
+
host.readFile = (path) => {
|
|
272
|
+
const normalizedPath = normalizeForComparison(path);
|
|
273
|
+
if (projectedPackageTypeModule && normalizedPath === projectedPackagePath) {
|
|
274
|
+
return '{"type":"module"}\n';
|
|
275
|
+
}
|
|
276
|
+
return normalizedPath === normalizedCandidatePath ? source : readFile(path);
|
|
277
|
+
};
|
|
278
|
+
host.getSourceFile = (path, languageVersion, onError, shouldCreate) =>
|
|
279
|
+
normalizeForComparison(path) === normalizedCandidatePath
|
|
280
|
+
? ts.createSourceFile(path, source, languageVersion, true)
|
|
281
|
+
: readSourceFile(path, languageVersion, onError, shouldCreate);
|
|
282
|
+
const program = ts.createProgram({
|
|
283
|
+
host,
|
|
284
|
+
options: { ...options, noEmit: true },
|
|
285
|
+
rootNames: [candidatePath],
|
|
286
|
+
});
|
|
287
|
+
return ts
|
|
288
|
+
.getPreEmitDiagnostics(program)
|
|
289
|
+
.find((diagnostic) =>
|
|
290
|
+
generatedSurfaceSyntaxDiagnosticCodes.has(diagnostic.code)
|
|
291
|
+
);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const collectTypeScriptConfigCoverage = (
|
|
295
|
+
configPath: string,
|
|
296
|
+
context: TypeScriptCoverageContext
|
|
297
|
+
): Result<string | null, Error> => {
|
|
298
|
+
const normalizedConfigPath = normalizeForComparison(configPath);
|
|
299
|
+
if (context.visitedConfigPaths.has(normalizedConfigPath)) {
|
|
300
|
+
return Result.ok(null);
|
|
301
|
+
}
|
|
302
|
+
context.visitedConfigPaths.add(normalizedConfigPath);
|
|
303
|
+
|
|
304
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
305
|
+
if (read.error !== undefined) {
|
|
306
|
+
return Result.err(
|
|
307
|
+
new ValidationError(
|
|
308
|
+
`Unable to read TypeScript config before choosing a surface entry root: ${ts.flattenDiagnosticMessageText(read.error.messageText, '\n')}`,
|
|
309
|
+
{ context: { configPath } }
|
|
310
|
+
)
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
314
|
+
read.config,
|
|
315
|
+
{
|
|
316
|
+
...ts.sys,
|
|
317
|
+
readDirectory: (rootDir, extensions, excludes, includes, depth) => [
|
|
318
|
+
...ts.sys.readDirectory(rootDir, extensions, excludes, includes, depth),
|
|
319
|
+
...context.matcher(
|
|
320
|
+
rootDir,
|
|
321
|
+
extensions,
|
|
322
|
+
excludes,
|
|
323
|
+
includes,
|
|
324
|
+
ts.sys.useCaseSensitiveFileNames,
|
|
325
|
+
context.cwd,
|
|
326
|
+
depth,
|
|
327
|
+
context.prospectiveEntries,
|
|
328
|
+
resolve
|
|
329
|
+
),
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
dirname(configPath),
|
|
333
|
+
undefined,
|
|
334
|
+
configPath
|
|
335
|
+
);
|
|
336
|
+
const [error] = parsed.errors;
|
|
337
|
+
if (error !== undefined) {
|
|
338
|
+
return Result.err(
|
|
339
|
+
new ValidationError(
|
|
340
|
+
`Unable to resolve TypeScript config before choosing a surface entry root: ${ts.flattenDiagnosticMessageText(error.messageText, '\n')}`,
|
|
341
|
+
{ context: { configPath } }
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const rootDir = parsed.options.rootDir ?? null;
|
|
347
|
+
const configuredFiles = new Set(parsed.fileNames.map(normalizeForComparison));
|
|
348
|
+
for (const [entryRoot, candidatePath] of context.candidatePaths) {
|
|
349
|
+
if (
|
|
350
|
+
configuredFiles.has(normalizeForComparison(candidatePath)) &&
|
|
351
|
+
(rootDir === null || isWithinDirectory(rootDir, candidatePath))
|
|
352
|
+
) {
|
|
353
|
+
context.supportedEntryRoots.add(entryRoot);
|
|
354
|
+
const diagnostic = readGeneratedSurfaceSyntaxDiagnostic(
|
|
355
|
+
candidatePath,
|
|
356
|
+
context.cwd,
|
|
357
|
+
parsed.options,
|
|
358
|
+
context.projectedPackageTypeModule
|
|
359
|
+
);
|
|
360
|
+
if (diagnostic !== undefined) {
|
|
361
|
+
const incompatibilities =
|
|
362
|
+
context.incompatibilities.get(entryRoot) ?? [];
|
|
363
|
+
incompatibilities.push({
|
|
364
|
+
code: diagnostic.code,
|
|
365
|
+
configPath,
|
|
366
|
+
message: ts.flattenDiagnosticMessageText(
|
|
367
|
+
diagnostic.messageText,
|
|
368
|
+
'\n'
|
|
369
|
+
),
|
|
370
|
+
});
|
|
371
|
+
context.incompatibilities.set(entryRoot, incompatibilities);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const reference of parsed.projectReferences ?? []) {
|
|
377
|
+
const referenced = collectTypeScriptConfigCoverage(
|
|
378
|
+
ts.resolveProjectReferencePath(reference),
|
|
379
|
+
context
|
|
380
|
+
);
|
|
381
|
+
if (referenced.isErr()) {
|
|
382
|
+
return referenced;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return Result.ok(rootDir);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const readConfiguredTypeScriptLayout = (
|
|
390
|
+
cwd: string,
|
|
391
|
+
surface: Surface,
|
|
392
|
+
options: SurfaceEntryResolutionOptions = {}
|
|
393
|
+
): Result<ConfiguredTypeScriptLayout | null, Error> => {
|
|
394
|
+
const configPath = ts.findConfigFile(cwd, ts.sys.fileExists);
|
|
395
|
+
if (configPath === undefined) {
|
|
396
|
+
return Result.ok(null);
|
|
397
|
+
}
|
|
398
|
+
const matcher = readAuditedTypeScriptMatcher();
|
|
399
|
+
if (matcher.isErr()) {
|
|
400
|
+
return matcher;
|
|
401
|
+
}
|
|
402
|
+
const candidatePaths = new Map<SurfaceEntryRoot, string>([
|
|
403
|
+
['bin', resolve(cwd, getEntryFile(surface, 'bin'))],
|
|
404
|
+
['src', resolve(cwd, getEntryFile(surface, 'src'))],
|
|
405
|
+
]);
|
|
406
|
+
const prospectiveEntries = createProspectiveFileSystemEntries([
|
|
407
|
+
...candidatePaths.values(),
|
|
408
|
+
]);
|
|
409
|
+
const supportedEntryRoots = new Set<SurfaceEntryRoot>();
|
|
410
|
+
const incompatibilities = new Map<
|
|
411
|
+
SurfaceEntryRoot,
|
|
412
|
+
TypeScriptSyntaxIncompatibility[]
|
|
413
|
+
>();
|
|
414
|
+
const coverage = collectTypeScriptConfigCoverage(configPath, {
|
|
415
|
+
candidatePaths,
|
|
416
|
+
cwd,
|
|
417
|
+
incompatibilities,
|
|
418
|
+
matcher: matcher.value,
|
|
419
|
+
projectedPackageTypeModule: options.projectedPackageTypeModule === true,
|
|
420
|
+
prospectiveEntries,
|
|
421
|
+
supportedEntryRoots,
|
|
422
|
+
visitedConfigPaths: new Set(),
|
|
423
|
+
});
|
|
424
|
+
if (coverage.isErr()) {
|
|
425
|
+
return coverage;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return Result.ok({
|
|
429
|
+
incompatibilities,
|
|
430
|
+
rootDir: coverage.value,
|
|
431
|
+
supportedEntryRoots,
|
|
432
|
+
});
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const readCanonicalLintEntryRoots = (
|
|
436
|
+
cwd: string
|
|
437
|
+
): Result<ReadonlySet<SurfaceEntryRoot> | null, Error> => {
|
|
438
|
+
const packagePath = resolveProjectPath(cwd, 'package.json');
|
|
439
|
+
if (packagePath.isErr()) {
|
|
440
|
+
return packagePath;
|
|
441
|
+
}
|
|
442
|
+
if (!existsSync(packagePath.value)) {
|
|
443
|
+
return Result.ok(null);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let parsed: unknown;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(readFileSync(packagePath.value, 'utf8'));
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return Result.err(
|
|
451
|
+
new ValidationError(
|
|
452
|
+
'Unable to read package.json before choosing a surface entry root.',
|
|
453
|
+
{
|
|
454
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
455
|
+
context: { path: packagePath.value },
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (!isPlainObject(parsed)) {
|
|
461
|
+
return Result.ok(null);
|
|
462
|
+
}
|
|
463
|
+
const { scripts } = parsed;
|
|
464
|
+
if (!isPlainObject(scripts)) {
|
|
465
|
+
return Result.ok(null);
|
|
466
|
+
}
|
|
467
|
+
const { lint } = scripts;
|
|
468
|
+
return Result.ok(
|
|
469
|
+
typeof lint === 'string'
|
|
470
|
+
? (canonicalLintEntryRoots.get(lint) ?? null)
|
|
471
|
+
: null
|
|
472
|
+
);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const resolveConfiguredSurfaceEntryRoot = (
|
|
476
|
+
cwd: string,
|
|
477
|
+
surface: Surface,
|
|
478
|
+
options: SurfaceEntryResolutionOptions
|
|
479
|
+
): Result<SurfaceEntryRoot, Error> => {
|
|
480
|
+
const lintEntryRoots = readCanonicalLintEntryRoots(cwd);
|
|
481
|
+
if (lintEntryRoots.isErr()) {
|
|
482
|
+
return lintEntryRoots;
|
|
483
|
+
}
|
|
484
|
+
const configuredLayout = readConfiguredTypeScriptLayout(
|
|
485
|
+
cwd,
|
|
486
|
+
surface,
|
|
487
|
+
options
|
|
488
|
+
);
|
|
489
|
+
if (configuredLayout.isErr()) {
|
|
490
|
+
return configuredLayout;
|
|
491
|
+
}
|
|
492
|
+
const supportedEntryRoots = new Set(
|
|
493
|
+
surfaceEntryRoots.filter(
|
|
494
|
+
(entryRoot) =>
|
|
495
|
+
(configuredLayout.value === null ||
|
|
496
|
+
(configuredLayout.value.supportedEntryRoots.has(entryRoot) &&
|
|
497
|
+
!configuredLayout.value.incompatibilities.has(entryRoot))) &&
|
|
498
|
+
(lintEntryRoots.value === null || lintEntryRoots.value.has(entryRoot))
|
|
499
|
+
)
|
|
500
|
+
);
|
|
501
|
+
for (const entryRoot of surfaceEntryRoots) {
|
|
502
|
+
if (supportedEntryRoots.has(entryRoot)) {
|
|
503
|
+
return Result.ok(entryRoot);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const hasLintConstraint = lintEntryRoots.value !== null;
|
|
507
|
+
const hasIncompatibleTypeScriptEntry =
|
|
508
|
+
configuredLayout.value !== null &&
|
|
509
|
+
configuredLayout.value.incompatibilities.size > 0;
|
|
510
|
+
let message = `TypeScript config does not include a supported ${surface} surface entry path.`;
|
|
511
|
+
if (hasLintConstraint) {
|
|
512
|
+
message = `TypeScript and lint configuration do not share a supported ${surface} surface entry path.`;
|
|
513
|
+
}
|
|
514
|
+
if (hasIncompatibleTypeScriptEntry) {
|
|
515
|
+
message = `TypeScript config covering the generated ${surface} surface entry cannot compile top-level await.`;
|
|
516
|
+
}
|
|
517
|
+
return Result.err(
|
|
518
|
+
new ValidationError(message, {
|
|
519
|
+
context: {
|
|
520
|
+
candidates: surfaceEntryRoots.map((entryRoot) =>
|
|
521
|
+
getEntryFile(surface, entryRoot)
|
|
522
|
+
),
|
|
523
|
+
lintEntryRoots:
|
|
524
|
+
lintEntryRoots.value === null ? null : [...lintEntryRoots.value],
|
|
525
|
+
rootDir: configuredLayout.value?.rootDir ?? null,
|
|
526
|
+
...(hasIncompatibleTypeScriptEntry
|
|
527
|
+
? {
|
|
528
|
+
incompatibilities: Object.fromEntries(
|
|
529
|
+
configuredLayout.value.incompatibilities
|
|
530
|
+
),
|
|
531
|
+
reason: 'typescript-surface-syntax-incompatible',
|
|
532
|
+
}
|
|
533
|
+
: {}),
|
|
534
|
+
},
|
|
535
|
+
})
|
|
536
|
+
);
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
/** Preserve an established legacy src layout while fresh scaffolds use bin. */
|
|
540
|
+
const resolveSurfaceEntryRoot = (
|
|
541
|
+
cwd: string,
|
|
542
|
+
surface: Surface,
|
|
543
|
+
options: SurfaceEntryResolutionOptions = {}
|
|
544
|
+
): Result<SurfaceEntryRoot, Error> => {
|
|
545
|
+
for (const entryRoot of surfaceEntryRoots) {
|
|
546
|
+
const targetExists = projectPathExists(
|
|
547
|
+
cwd,
|
|
548
|
+
getEntryFile(surface, entryRoot)
|
|
549
|
+
);
|
|
550
|
+
if (targetExists.isErr()) {
|
|
551
|
+
return targetExists;
|
|
552
|
+
}
|
|
553
|
+
if (targetExists.value) {
|
|
554
|
+
return Result.ok(entryRoot);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let hasBinEntry = false;
|
|
559
|
+
let hasSrcEntry = false;
|
|
560
|
+
for (const candidate of Object.keys(surfaceEntryNames) as Surface[]) {
|
|
561
|
+
for (const entryRoot of surfaceEntryRoots) {
|
|
562
|
+
const exists = projectPathExists(cwd, getEntryFile(candidate, entryRoot));
|
|
563
|
+
if (exists.isErr()) {
|
|
564
|
+
return exists;
|
|
565
|
+
}
|
|
566
|
+
if (exists.value) {
|
|
567
|
+
if (entryRoot === 'bin') {
|
|
568
|
+
hasBinEntry = true;
|
|
569
|
+
} else {
|
|
570
|
+
hasSrcEntry = true;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (hasSrcEntry && !hasBinEntry) {
|
|
576
|
+
return Result.ok('src');
|
|
577
|
+
}
|
|
578
|
+
if (hasBinEntry) {
|
|
579
|
+
return Result.ok('bin');
|
|
580
|
+
}
|
|
581
|
+
if (options.projectedLocalTsconfig === true) {
|
|
582
|
+
const lintEntryRoots = readCanonicalLintEntryRoots(cwd);
|
|
583
|
+
if (lintEntryRoots.isErr()) {
|
|
584
|
+
return lintEntryRoots;
|
|
585
|
+
}
|
|
586
|
+
for (const entryRoot of surfaceEntryRoots) {
|
|
587
|
+
if (
|
|
588
|
+
lintEntryRoots.value === null ||
|
|
589
|
+
lintEntryRoots.value.has(entryRoot)
|
|
590
|
+
) {
|
|
591
|
+
return Result.ok(entryRoot);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return resolveConfiguredSurfaceEntryRoot(cwd, surface, options);
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
/** Resolve the surface entry path without mutating the project. */
|
|
599
|
+
export const resolveSurfaceEntryFile = (
|
|
600
|
+
cwd: string,
|
|
601
|
+
surface: Surface,
|
|
602
|
+
options: SurfaceEntryResolutionOptions = {}
|
|
603
|
+
): Result<string, Error> => {
|
|
604
|
+
const entryRoot = resolveSurfaceEntryRoot(cwd, surface, options);
|
|
605
|
+
if (entryRoot.isErr()) {
|
|
606
|
+
return entryRoot;
|
|
607
|
+
}
|
|
608
|
+
const entryFile = getEntryFile(surface, entryRoot.value);
|
|
609
|
+
const entryExists = projectPathExists(cwd, entryFile);
|
|
610
|
+
if (entryExists.isErr()) {
|
|
611
|
+
return entryExists;
|
|
612
|
+
}
|
|
613
|
+
if (entryExists.value || options.projectedLocalTsconfig === true) {
|
|
614
|
+
return Result.ok(entryFile);
|
|
615
|
+
}
|
|
616
|
+
const lintEntryRoots = readCanonicalLintEntryRoots(cwd);
|
|
617
|
+
if (lintEntryRoots.isErr()) {
|
|
618
|
+
return lintEntryRoots;
|
|
619
|
+
}
|
|
620
|
+
if (
|
|
621
|
+
lintEntryRoots.value !== null &&
|
|
622
|
+
!lintEntryRoots.value.has(entryRoot.value)
|
|
623
|
+
) {
|
|
624
|
+
return Result.err(
|
|
625
|
+
new ValidationError(
|
|
626
|
+
`Lint config does not include the established ${entryRoot.value} surface entry path.`,
|
|
627
|
+
{
|
|
628
|
+
context: {
|
|
629
|
+
entryFile,
|
|
630
|
+
lintEntryRoots: [...lintEntryRoots.value],
|
|
631
|
+
reason: 'lint-established-surface-root-excluded',
|
|
632
|
+
},
|
|
633
|
+
}
|
|
634
|
+
)
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const configuredLayout = readConfiguredTypeScriptLayout(
|
|
638
|
+
cwd,
|
|
639
|
+
surface,
|
|
640
|
+
options
|
|
641
|
+
);
|
|
642
|
+
if (configuredLayout.isErr()) {
|
|
643
|
+
return configuredLayout;
|
|
644
|
+
}
|
|
645
|
+
if (
|
|
646
|
+
configuredLayout.value !== null &&
|
|
647
|
+
!configuredLayout.value.supportedEntryRoots.has(entryRoot.value)
|
|
648
|
+
) {
|
|
649
|
+
return Result.err(
|
|
650
|
+
new ValidationError(
|
|
651
|
+
`TypeScript config does not include the established ${entryRoot.value} surface entry path.`,
|
|
652
|
+
{
|
|
653
|
+
context: {
|
|
654
|
+
entryFile,
|
|
655
|
+
reason: 'typescript-established-surface-root-excluded',
|
|
656
|
+
rootDir: configuredLayout.value.rootDir,
|
|
657
|
+
},
|
|
658
|
+
}
|
|
659
|
+
)
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
if (configuredLayout.value?.incompatibilities.has(entryRoot.value) === true) {
|
|
663
|
+
return Result.err(
|
|
664
|
+
new ValidationError(
|
|
665
|
+
`TypeScript config covering the generated ${surface} surface entry cannot compile top-level await.`,
|
|
666
|
+
{
|
|
667
|
+
context: {
|
|
668
|
+
entryFile,
|
|
669
|
+
incompatibilities: configuredLayout.value.incompatibilities.get(
|
|
670
|
+
entryRoot.value
|
|
671
|
+
),
|
|
672
|
+
reason: 'typescript-surface-syntax-incompatible',
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
)
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
return Result.ok(entryFile);
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// Trail definition
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
const readExistingBinEntries = (
|
|
686
|
+
pkg: Record<string, unknown>,
|
|
687
|
+
packageName: string
|
|
688
|
+
): Record<string, unknown> => {
|
|
689
|
+
const { bin } = pkg;
|
|
690
|
+
if (isPlainObject(bin)) {
|
|
691
|
+
return bin;
|
|
692
|
+
}
|
|
693
|
+
return typeof bin === 'string' ? { [packageName]: bin } : {};
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
/** Patch deps and optionally bin in a parsed package.json. */
|
|
697
|
+
const patchPkgDeps = (
|
|
698
|
+
pkg: Record<string, unknown>,
|
|
699
|
+
surface: Surface,
|
|
700
|
+
cwd: string,
|
|
701
|
+
entryFile: string
|
|
702
|
+
): string => {
|
|
703
|
+
const [depName = ''] = surfaceDependencies[surface];
|
|
704
|
+
const deps = (pkg['dependencies'] ?? {}) as Record<string, string>;
|
|
705
|
+
for (const dependency of surfaceDependencies[surface]) {
|
|
706
|
+
deps[dependency] = ontrailsPackageRange;
|
|
707
|
+
}
|
|
708
|
+
if (surface === 'cli') {
|
|
709
|
+
const packageName =
|
|
710
|
+
typeof pkg['name'] === 'string' ? pkg['name'] : basename(cwd);
|
|
711
|
+
pkg['bin'] = {
|
|
712
|
+
...readExistingBinEntries(pkg, packageName),
|
|
713
|
+
[packageName]: `./${entryFile}`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
pkg['dependencies'] = Object.fromEntries(
|
|
717
|
+
Object.entries(deps).toSorted(([a], [b]) => a.localeCompare(b))
|
|
718
|
+
);
|
|
719
|
+
return depName;
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
/** Update package.json with surface dependency and CLI bin if needed. */
|
|
723
|
+
const updatePkgJsonForSurface = async (
|
|
724
|
+
cwd: string,
|
|
725
|
+
surface: Surface,
|
|
726
|
+
entryFile: string
|
|
727
|
+
): Promise<Result<string, Error>> => {
|
|
728
|
+
const pkgPathResult = resolveProjectPath(cwd, 'package.json');
|
|
729
|
+
if (pkgPathResult.isErr()) {
|
|
730
|
+
return pkgPathResult;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const pkgPath = pkgPathResult.value;
|
|
734
|
+
if (!existsSync(pkgPath)) {
|
|
735
|
+
return Result.ok(surfaceDependencies[surface][0] ?? '');
|
|
736
|
+
}
|
|
737
|
+
const pkg = (await Bun.file(pkgPath).json()) as Record<string, unknown>;
|
|
738
|
+
const depName = patchPkgDeps(pkg, surface, cwd, entryFile);
|
|
739
|
+
const written = await writeProjectFile(
|
|
740
|
+
cwd,
|
|
741
|
+
'package.json',
|
|
742
|
+
stringifyScaffoldPackageJson(pkg)
|
|
743
|
+
);
|
|
744
|
+
return written.isErr() ? Result.err(written.error) : Result.ok(depName);
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
/** Create the entry file for a surface and return the relative path. */
|
|
748
|
+
const writeSurfaceEntry = async (
|
|
749
|
+
cwd: string,
|
|
750
|
+
surface: Surface,
|
|
751
|
+
entryFile: string
|
|
752
|
+
): Promise<Result<string, Error>> => {
|
|
753
|
+
const entryRoot: SurfaceEntryRoot = entryFile.startsWith('src/')
|
|
754
|
+
? 'src'
|
|
755
|
+
: 'bin';
|
|
756
|
+
const sourceImport = (await findTopoPath(cwd)) ?? './app.js';
|
|
757
|
+
const appImport =
|
|
758
|
+
entryRoot === 'src' ? sourceImport : `../src/${sourceImport.slice(2)}`;
|
|
759
|
+
const generators = {
|
|
760
|
+
cli: generateCliEntry,
|
|
761
|
+
http: generateHttpEntry,
|
|
762
|
+
mcp: generateMcpEntry,
|
|
763
|
+
} satisfies Record<Surface, (appImportPath: string) => string>;
|
|
764
|
+
const content = generators[surface](appImport);
|
|
765
|
+
|
|
766
|
+
const written = await writeProjectFile(cwd, entryFile, content);
|
|
767
|
+
return written.isErr() ? Result.err(written.error) : Result.ok(entryFile);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
export const addSurface = trail('add.surface', {
|
|
771
|
+
description: 'Add a surface to an existing project',
|
|
772
|
+
implementation: async (input) => {
|
|
773
|
+
const cwd = resolve(input.dir ?? '.');
|
|
774
|
+
const { surface } = input;
|
|
775
|
+
const entryFile = resolveSurfaceEntryFile(cwd, surface);
|
|
776
|
+
if (entryFile.isErr()) {
|
|
777
|
+
return entryFile;
|
|
778
|
+
}
|
|
779
|
+
const entryExists = projectPathExists(cwd, entryFile.value);
|
|
780
|
+
if (entryExists.isErr()) {
|
|
781
|
+
return entryExists;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
let created: string | null = null;
|
|
785
|
+
if (!entryExists.value) {
|
|
786
|
+
const written = await writeSurfaceEntry(cwd, surface, entryFile.value);
|
|
787
|
+
if (written.isErr()) {
|
|
788
|
+
return written;
|
|
789
|
+
}
|
|
790
|
+
created = entryFile.value;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const dependency = await updatePkgJsonForSurface(
|
|
794
|
+
cwd,
|
|
795
|
+
surface,
|
|
796
|
+
entryFile.value
|
|
797
|
+
);
|
|
798
|
+
if (dependency.isErr()) {
|
|
799
|
+
return dependency;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return Result.ok({
|
|
803
|
+
created,
|
|
804
|
+
dependency: dependency.value,
|
|
805
|
+
});
|
|
806
|
+
},
|
|
807
|
+
input: z.object({
|
|
808
|
+
dir: z.string().optional().describe('Project directory'),
|
|
809
|
+
surface: z.enum(['cli', 'http', 'mcp']).describe('Surface to add'),
|
|
810
|
+
}),
|
|
811
|
+
output: z.object({
|
|
812
|
+
created: z.string().nullable(),
|
|
813
|
+
dependency: z.string(),
|
|
814
|
+
}),
|
|
815
|
+
permit: { scopes: ['project:write'] },
|
|
816
|
+
});
|