@ontrails/config 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 +366 -0
- package/README.md +262 -0
- package/package.json +37 -0
- package/src/app-config.ts +318 -0
- package/src/collect.ts +117 -0
- package/src/compose.ts +47 -0
- package/src/config-resource.ts +32 -0
- package/src/define-config.ts +126 -0
- package/src/derive/env.ts +108 -0
- package/src/derive/example.ts +222 -0
- package/src/derive/helpers.ts +158 -0
- package/src/derive/index.ts +3 -0
- package/src/derive/json-schema.ts +137 -0
- package/src/derive-fields.ts +252 -0
- package/src/derive-provenance.ts +240 -0
- package/src/doctor.ts +238 -0
- package/src/extensions.ts +51 -0
- package/src/index.ts +74 -0
- package/src/merge.ts +43 -0
- package/src/path-boundary.ts +40 -0
- package/src/ref.ts +38 -0
- package/src/registry.ts +33 -0
- package/src/resolve.ts +196 -0
- package/src/secret-heuristics.ts +13 -0
- package/src/trails/config-check.ts +95 -0
- package/src/trails/config-describe.ts +44 -0
- package/src/trails/config-init.ts +96 -0
- package/src/trails-config-file.ts +136 -0
- package/src/trails-conventions.ts +240 -0
- package/src/workspace-config-collection.ts +371 -0
- package/src/workspace-config-source.ts +532 -0
- package/src/workspace-config.ts +552 -0
- package/src/zod-utils.ts +152 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { ValidationError } from '@ontrails/core';
|
|
2
|
+
import {
|
|
3
|
+
extractStringLiteral,
|
|
4
|
+
extractStringOrTemplateLiteral,
|
|
5
|
+
getNodeExpression,
|
|
6
|
+
identifierName,
|
|
7
|
+
parseWithDiagnostics,
|
|
8
|
+
propertyKeyName,
|
|
9
|
+
} from '@ontrails/source';
|
|
10
|
+
import type { AstNode } from '@ontrails/source';
|
|
11
|
+
import { isAlias, isMap, isScalar, parseDocument } from 'yaml';
|
|
12
|
+
|
|
13
|
+
import { parseTrailsConfigData } from './trails-config-file.js';
|
|
14
|
+
|
|
15
|
+
type StaticIdentityReason =
|
|
16
|
+
| 'dynamic-expression'
|
|
17
|
+
| 'invalid-app'
|
|
18
|
+
| 'invalid-path'
|
|
19
|
+
| 'invalid-shape'
|
|
20
|
+
| 'parse-error';
|
|
21
|
+
|
|
22
|
+
export const staticIdentityError = (
|
|
23
|
+
message: string,
|
|
24
|
+
filePath: string,
|
|
25
|
+
reason: StaticIdentityReason,
|
|
26
|
+
context: Record<string, unknown> = {}
|
|
27
|
+
): ValidationError =>
|
|
28
|
+
new ValidationError(message, {
|
|
29
|
+
context: { ...context, path: filePath, reason, section: 'workspace.apps' },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Unwrap `as const`, `satisfies`, and parenthesized wrappers. */
|
|
33
|
+
const unwrapExpression = (node: AstNode): AstNode => {
|
|
34
|
+
let current = node;
|
|
35
|
+
while (
|
|
36
|
+
current.type === 'ParenthesizedExpression' ||
|
|
37
|
+
current.type === 'TSAsExpression' ||
|
|
38
|
+
current.type === 'TSSatisfiesExpression'
|
|
39
|
+
) {
|
|
40
|
+
const inner = getNodeExpression(current);
|
|
41
|
+
if (inner === undefined) {
|
|
42
|
+
return current;
|
|
43
|
+
}
|
|
44
|
+
current = inner;
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const propertiesOf = (node: AstNode, filePath: string): readonly AstNode[] => {
|
|
50
|
+
if (node.type !== 'ObjectExpression') {
|
|
51
|
+
throw staticIdentityError(
|
|
52
|
+
`workspace.apps must use inline object literals in ${filePath}; move project identity out of variables, calls, spreads, and conditionals.`,
|
|
53
|
+
filePath,
|
|
54
|
+
'dynamic-expression',
|
|
55
|
+
{ expressionType: node.type }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return (node['properties'] as readonly AstNode[] | undefined) ?? [];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const staticPropertyEntries = (
|
|
62
|
+
node: AstNode,
|
|
63
|
+
filePath: string,
|
|
64
|
+
label: string
|
|
65
|
+
): ReadonlyMap<string, AstNode> => {
|
|
66
|
+
const entries = new Map<string, AstNode>();
|
|
67
|
+
for (const property of propertiesOf(unwrapExpression(node), filePath)) {
|
|
68
|
+
if (property.type !== 'Property') {
|
|
69
|
+
throw staticIdentityError(
|
|
70
|
+
`${label} must not use spreads in ${filePath}; author workspace identity as direct literal properties.`,
|
|
71
|
+
filePath,
|
|
72
|
+
'dynamic-expression',
|
|
73
|
+
{ expressionType: property.type }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const key = propertyKeyName(property);
|
|
77
|
+
const value = property['value'] as AstNode | undefined;
|
|
78
|
+
if (key === null || value === undefined) {
|
|
79
|
+
throw staticIdentityError(
|
|
80
|
+
`${label} must not use computed keys or shorthand values in ${filePath}.`,
|
|
81
|
+
filePath,
|
|
82
|
+
'dynamic-expression'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (entries.has(key)) {
|
|
86
|
+
throw staticIdentityError(
|
|
87
|
+
`${label} declares "${key}" more than once in ${filePath}.`,
|
|
88
|
+
filePath,
|
|
89
|
+
'invalid-shape',
|
|
90
|
+
{ key }
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
entries.set(key, unwrapExpression(value));
|
|
94
|
+
}
|
|
95
|
+
return entries;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const directPropertyKey = (property: AstNode): string | null => {
|
|
99
|
+
if (property.type !== 'Property' || property['computed'] === true) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return propertyKeyName(property);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const computedPropertyKey = (property: AstNode): string | null =>
|
|
106
|
+
property.type === 'Property' && property['computed'] === true
|
|
107
|
+
? extractStringOrTemplateLiteral(property['key'] as AstNode | undefined)
|
|
108
|
+
: null;
|
|
109
|
+
|
|
110
|
+
/** Locate the sole explicit workspace property without constraining deployment. */
|
|
111
|
+
const findWorkspaceNode = (
|
|
112
|
+
configObject: AstNode,
|
|
113
|
+
filePath: string
|
|
114
|
+
): AstNode | undefined => {
|
|
115
|
+
const properties = propertiesOf(unwrapExpression(configObject), filePath);
|
|
116
|
+
const workspaceProperties = properties.filter(
|
|
117
|
+
(property) => directPropertyKey(property) === 'workspace'
|
|
118
|
+
);
|
|
119
|
+
if (workspaceProperties.length > 1) {
|
|
120
|
+
throw staticIdentityError(
|
|
121
|
+
`The default config object declares "workspace" more than once in ${filePath}.`,
|
|
122
|
+
filePath,
|
|
123
|
+
'invalid-shape',
|
|
124
|
+
{ key: 'workspace' }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const [workspaceProperty] = workspaceProperties;
|
|
128
|
+
if (workspaceProperty === undefined) {
|
|
129
|
+
const computedWorkspace = properties.find(
|
|
130
|
+
(property) => computedPropertyKey(property) === 'workspace'
|
|
131
|
+
);
|
|
132
|
+
if (computedWorkspace !== undefined) {
|
|
133
|
+
throw staticIdentityError(
|
|
134
|
+
`Static workspace identity in ${filePath} must use a direct workspace property.`,
|
|
135
|
+
filePath,
|
|
136
|
+
'dynamic-expression',
|
|
137
|
+
{ expressionType: computedWorkspace.type }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const possibleWorkspaceProvider = properties.find(
|
|
141
|
+
(property) =>
|
|
142
|
+
property.type !== 'Property' ||
|
|
143
|
+
(property['computed'] === true &&
|
|
144
|
+
computedPropertyKey(property) === null)
|
|
145
|
+
);
|
|
146
|
+
if (possibleWorkspaceProvider !== undefined) {
|
|
147
|
+
throw staticIdentityError(
|
|
148
|
+
`Static workspace identity in ${filePath} cannot be proven absent because spreads or computed properties could supply workspace.`,
|
|
149
|
+
filePath,
|
|
150
|
+
'dynamic-expression',
|
|
151
|
+
{ expressionType: possibleWorkspaceProvider.type }
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
const workspaceIndex = properties.indexOf(workspaceProperty);
|
|
157
|
+
const possibleOverride = properties
|
|
158
|
+
.slice(workspaceIndex + 1)
|
|
159
|
+
.find(
|
|
160
|
+
(property) =>
|
|
161
|
+
property.type !== 'Property' ||
|
|
162
|
+
(property['computed'] === true &&
|
|
163
|
+
(computedPropertyKey(property) === null ||
|
|
164
|
+
computedPropertyKey(property) === 'workspace'))
|
|
165
|
+
);
|
|
166
|
+
if (possibleOverride !== undefined) {
|
|
167
|
+
throw staticIdentityError(
|
|
168
|
+
`Static workspace identity in ${filePath} must follow spreads and computed properties that could override workspace.`,
|
|
169
|
+
filePath,
|
|
170
|
+
'dynamic-expression',
|
|
171
|
+
{ expressionType: possibleOverride.type }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return unwrapExpression(workspaceProperty['value'] as AstNode);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const findConfigHelperNames = (
|
|
178
|
+
body: readonly AstNode[]
|
|
179
|
+
): ReadonlySet<string> => {
|
|
180
|
+
const names = new Set<string>();
|
|
181
|
+
for (const node of body) {
|
|
182
|
+
if (node.type !== 'ImportDeclaration') {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const source = extractStringLiteral(node['source'] as AstNode | undefined);
|
|
186
|
+
if (source !== '@ontrails/config') {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const specifiers =
|
|
190
|
+
(node['specifiers'] as readonly AstNode[] | undefined) ?? [];
|
|
191
|
+
for (const specifier of specifiers) {
|
|
192
|
+
if (specifier.type !== 'ImportSpecifier') {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const imported = identifierName(
|
|
196
|
+
specifier['imported'] as AstNode | undefined
|
|
197
|
+
);
|
|
198
|
+
const local = identifierName(specifier['local'] as AstNode | undefined);
|
|
199
|
+
if (imported === 'defineConfig' && local !== null) {
|
|
200
|
+
names.add(local);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return names;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const findDefaultDeclaration = (
|
|
208
|
+
body: readonly AstNode[],
|
|
209
|
+
filePath: string
|
|
210
|
+
): AstNode => {
|
|
211
|
+
const defaults = body.filter(
|
|
212
|
+
(node) => node.type === 'ExportDefaultDeclaration'
|
|
213
|
+
);
|
|
214
|
+
const declaration = defaults[0]?.['declaration'] as AstNode | undefined;
|
|
215
|
+
if (defaults.length !== 1 || declaration === undefined) {
|
|
216
|
+
throw staticIdentityError(
|
|
217
|
+
`Static workspace identity in ${filePath} requires one export default object or defineConfig({...}) call.`,
|
|
218
|
+
filePath,
|
|
219
|
+
'invalid-shape'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return declaration;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const unwrapConfigObject = (
|
|
226
|
+
declaration: AstNode,
|
|
227
|
+
helperNames: ReadonlySet<string>,
|
|
228
|
+
filePath: string
|
|
229
|
+
): AstNode => {
|
|
230
|
+
const expression = unwrapExpression(declaration);
|
|
231
|
+
if (expression.type !== 'CallExpression') {
|
|
232
|
+
return expression;
|
|
233
|
+
}
|
|
234
|
+
const callee = expression['callee'] as AstNode | undefined;
|
|
235
|
+
const args =
|
|
236
|
+
(expression['arguments'] as readonly AstNode[] | undefined) ?? [];
|
|
237
|
+
const helperName = identifierName(callee);
|
|
238
|
+
if (
|
|
239
|
+
helperName === null ||
|
|
240
|
+
!helperNames.has(helperName) ||
|
|
241
|
+
args.length !== 1
|
|
242
|
+
) {
|
|
243
|
+
throw staticIdentityError(
|
|
244
|
+
`Static workspace identity in ${filePath} may only use defineConfig({...}) imported from @ontrails/config.`,
|
|
245
|
+
filePath,
|
|
246
|
+
'dynamic-expression',
|
|
247
|
+
{ expressionType: expression.type }
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return unwrapExpression(args[0] as AstNode);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const extractLiteralApps = (
|
|
254
|
+
appsNode: AstNode,
|
|
255
|
+
filePath: string
|
|
256
|
+
): Record<string, unknown> => {
|
|
257
|
+
const apps = new Map<string, unknown>();
|
|
258
|
+
const appsEntries = staticPropertyEntries(
|
|
259
|
+
appsNode,
|
|
260
|
+
filePath,
|
|
261
|
+
'workspace.apps'
|
|
262
|
+
);
|
|
263
|
+
for (const [id, appNode] of appsEntries) {
|
|
264
|
+
const appEntries = staticPropertyEntries(
|
|
265
|
+
appNode,
|
|
266
|
+
filePath,
|
|
267
|
+
`workspace.apps.${id}`
|
|
268
|
+
);
|
|
269
|
+
const app = new Map<string, unknown>();
|
|
270
|
+
for (const [key, valueNode] of appEntries) {
|
|
271
|
+
if (key !== 'entry' && key !== 'root') {
|
|
272
|
+
throw staticIdentityError(
|
|
273
|
+
`workspace.apps.${id} contains unsupported field "${key}". Use only root and the optional entry override.`,
|
|
274
|
+
filePath,
|
|
275
|
+
'invalid-app',
|
|
276
|
+
{ appId: id, field: key }
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const literal = extractStringLiteral(valueNode);
|
|
280
|
+
if (literal === null) {
|
|
281
|
+
throw staticIdentityError(
|
|
282
|
+
`workspace.apps.${id}.${key} must be a direct string literal in ${filePath}; environment reads, identifiers, imports, calls, and conditionals are not allowed.`,
|
|
283
|
+
filePath,
|
|
284
|
+
'dynamic-expression',
|
|
285
|
+
{ appId: id, expressionType: valueNode.type, field: key }
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
app.set(key, literal);
|
|
289
|
+
}
|
|
290
|
+
apps.set(id, Object.fromEntries(app));
|
|
291
|
+
}
|
|
292
|
+
return Object.fromEntries(apps);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const extractWorkspaceFromModule = (
|
|
296
|
+
filePath: string,
|
|
297
|
+
sourceCode: string,
|
|
298
|
+
parsePath = filePath
|
|
299
|
+
): unknown => {
|
|
300
|
+
const parsed = parseWithDiagnostics(parsePath, sourceCode);
|
|
301
|
+
if (parsed.ast === null || parsed.diagnostics.length > 0) {
|
|
302
|
+
throw staticIdentityError(
|
|
303
|
+
`Unable to parse static workspace identity from ${filePath}. Fix the TypeScript syntax before running a workspace command.`,
|
|
304
|
+
filePath,
|
|
305
|
+
'parse-error',
|
|
306
|
+
{ diagnostics: parsed.diagnostics }
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const body = (parsed.ast['body'] as readonly AstNode[] | undefined) ?? [];
|
|
311
|
+
const declaration = findDefaultDeclaration(body, filePath);
|
|
312
|
+
const configObject = unwrapConfigObject(
|
|
313
|
+
declaration,
|
|
314
|
+
findConfigHelperNames(body),
|
|
315
|
+
filePath
|
|
316
|
+
);
|
|
317
|
+
const workspaceNode = findWorkspaceNode(configObject, filePath);
|
|
318
|
+
if (workspaceNode === undefined) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
const workspaceEntries = staticPropertyEntries(
|
|
322
|
+
workspaceNode,
|
|
323
|
+
filePath,
|
|
324
|
+
'workspace'
|
|
325
|
+
);
|
|
326
|
+
const appsNode = workspaceEntries.get('apps');
|
|
327
|
+
if (appsNode === undefined || workspaceEntries.size !== 1) {
|
|
328
|
+
throw staticIdentityError(
|
|
329
|
+
`workspace in ${filePath} must contain exactly one literal apps catalog.`,
|
|
330
|
+
filePath,
|
|
331
|
+
'invalid-shape'
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return { apps: extractLiteralApps(appsNode, filePath) };
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const yamlKeyName = (value: unknown): string | null => {
|
|
338
|
+
if (!isScalar(value)) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const scalar = value.value;
|
|
342
|
+
return typeof scalar === 'string' ||
|
|
343
|
+
typeof scalar === 'number' ||
|
|
344
|
+
typeof scalar === 'boolean' ||
|
|
345
|
+
typeof scalar === 'bigint' ||
|
|
346
|
+
scalar === null
|
|
347
|
+
? String(scalar)
|
|
348
|
+
: null;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const yamlUniqueEntry = (
|
|
352
|
+
value: unknown,
|
|
353
|
+
key: string,
|
|
354
|
+
label: string,
|
|
355
|
+
filePath: string
|
|
356
|
+
): unknown => {
|
|
357
|
+
if (!isMap(value)) {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
const matches = value.items.filter((pair) => yamlKeyName(pair.key) === key);
|
|
361
|
+
if (matches.length > 1) {
|
|
362
|
+
throw staticIdentityError(
|
|
363
|
+
`${label} declares "${key}" more than once in ${filePath}.`,
|
|
364
|
+
filePath,
|
|
365
|
+
'invalid-shape',
|
|
366
|
+
{ key }
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return matches[0]?.value;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const YAML_MERGE_KEY = '<<';
|
|
373
|
+
|
|
374
|
+
const isYamlMergeKey = (value: unknown): boolean =>
|
|
375
|
+
yamlKeyName(value) === YAML_MERGE_KEY;
|
|
376
|
+
|
|
377
|
+
const yamlIndirectionError = (
|
|
378
|
+
filePath: string,
|
|
379
|
+
expressionType: string
|
|
380
|
+
): ValidationError =>
|
|
381
|
+
staticIdentityError(
|
|
382
|
+
`Static workspace identity in ${filePath} must use literal, JSON-compatible YAML; move project identity out of anchors, aliases, and merge keys.`,
|
|
383
|
+
filePath,
|
|
384
|
+
'dynamic-expression',
|
|
385
|
+
{ expressionType }
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
/** Reject YAML indirection so identity cannot be merged in behind literal keys. */
|
|
389
|
+
const assertYamlLiteralIdentity = (value: unknown, filePath: string): void => {
|
|
390
|
+
if (isAlias(value)) {
|
|
391
|
+
throw yamlIndirectionError(filePath, 'alias');
|
|
392
|
+
}
|
|
393
|
+
if (!isMap(value)) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
for (const pair of value.items) {
|
|
397
|
+
if (isYamlMergeKey(pair.key)) {
|
|
398
|
+
throw yamlIndirectionError(filePath, 'merge key');
|
|
399
|
+
}
|
|
400
|
+
assertYamlLiteralIdentity(pair.key, filePath);
|
|
401
|
+
assertYamlLiteralIdentity(pair.value, filePath);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** Absent workspace identity is only provable when nothing can merge it in. */
|
|
406
|
+
const assertYamlWorkspaceProvablyAbsent = (
|
|
407
|
+
contents: unknown,
|
|
408
|
+
filePath: string
|
|
409
|
+
): void => {
|
|
410
|
+
const merges =
|
|
411
|
+
isAlias(contents) ||
|
|
412
|
+
(isMap(contents) &&
|
|
413
|
+
contents.items.some((pair) => isYamlMergeKey(pair.key)));
|
|
414
|
+
if (merges) {
|
|
415
|
+
throw staticIdentityError(
|
|
416
|
+
`Static workspace identity in ${filePath} cannot be proven absent because aliases or merge keys could supply workspace.`,
|
|
417
|
+
filePath,
|
|
418
|
+
'dynamic-expression'
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const assertYamlIdentityKeysUnique = (
|
|
424
|
+
filePath: string,
|
|
425
|
+
sourceCode: string
|
|
426
|
+
): void => {
|
|
427
|
+
const document = parseDocument(sourceCode, { uniqueKeys: false });
|
|
428
|
+
const workspaceNode = yamlUniqueEntry(
|
|
429
|
+
document.contents,
|
|
430
|
+
'workspace',
|
|
431
|
+
'The default config object',
|
|
432
|
+
filePath
|
|
433
|
+
);
|
|
434
|
+
if (workspaceNode === undefined) {
|
|
435
|
+
assertYamlWorkspaceProvablyAbsent(document.contents, filePath);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
assertYamlLiteralIdentity(workspaceNode, filePath);
|
|
439
|
+
const appsNode = yamlUniqueEntry(
|
|
440
|
+
workspaceNode,
|
|
441
|
+
'apps',
|
|
442
|
+
'workspace',
|
|
443
|
+
filePath
|
|
444
|
+
);
|
|
445
|
+
if (!isMap(appsNode)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const appIds = new Set<string>();
|
|
449
|
+
for (const pair of appsNode.items) {
|
|
450
|
+
const appId = yamlKeyName(pair.key);
|
|
451
|
+
if (appId === null) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (appIds.has(appId)) {
|
|
455
|
+
throw staticIdentityError(
|
|
456
|
+
`workspace.apps declares "${appId}" more than once in ${filePath}.`,
|
|
457
|
+
filePath,
|
|
458
|
+
'invalid-shape',
|
|
459
|
+
{ key: appId }
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
appIds.add(appId);
|
|
463
|
+
yamlUniqueEntry(pair.value, 'root', `workspace.apps.${appId}`, filePath);
|
|
464
|
+
yamlUniqueEntry(pair.value, 'entry', `workspace.apps.${appId}`, filePath);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const extensionFor = (filePath: string): string | undefined =>
|
|
469
|
+
['.jsonc', '.json', '.toml', '.yaml', '.mts', '.mjs', '.ts', '.js'].find(
|
|
470
|
+
(extension) => filePath.endsWith(extension)
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
export const parseTrailsProjectConfigFile = async (
|
|
474
|
+
filePath: string
|
|
475
|
+
): Promise<unknown> => {
|
|
476
|
+
try {
|
|
477
|
+
const text = await Bun.file(filePath).text();
|
|
478
|
+
switch (extensionFor(filePath)) {
|
|
479
|
+
case '.json': {
|
|
480
|
+
parseTrailsConfigData(filePath, text);
|
|
481
|
+
return {
|
|
482
|
+
workspace: extractWorkspaceFromModule(
|
|
483
|
+
filePath,
|
|
484
|
+
`export default (${text});`,
|
|
485
|
+
`${filePath}.ts`
|
|
486
|
+
),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
case '.jsonc': {
|
|
490
|
+
parseTrailsConfigData(filePath, text);
|
|
491
|
+
return {
|
|
492
|
+
workspace: extractWorkspaceFromModule(
|
|
493
|
+
filePath,
|
|
494
|
+
`export default (${text});`,
|
|
495
|
+
`${filePath}.ts`
|
|
496
|
+
),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
case '.toml': {
|
|
500
|
+
return parseTrailsConfigData(filePath, text);
|
|
501
|
+
}
|
|
502
|
+
case '.yaml': {
|
|
503
|
+
const parsed = parseTrailsConfigData(filePath, text);
|
|
504
|
+
assertYamlIdentityKeysUnique(filePath, text);
|
|
505
|
+
return parsed;
|
|
506
|
+
}
|
|
507
|
+
case '.js':
|
|
508
|
+
case '.mjs':
|
|
509
|
+
case '.mts':
|
|
510
|
+
case '.ts': {
|
|
511
|
+
return { workspace: extractWorkspaceFromModule(filePath, text) };
|
|
512
|
+
}
|
|
513
|
+
default: {
|
|
514
|
+
throw staticIdentityError(
|
|
515
|
+
`Unsupported Trails config file: ${filePath}`,
|
|
516
|
+
filePath,
|
|
517
|
+
'invalid-shape'
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (error instanceof ValidationError) {
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
throw staticIdentityError(
|
|
526
|
+
`Failed to parse static workspace identity from ${filePath}.`,
|
|
527
|
+
filePath,
|
|
528
|
+
'parse-error',
|
|
529
|
+
{ cause: error instanceof Error ? error.message : String(error) }
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
};
|