@ontrails/core 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.
Files changed (86) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +190 -0
  3. package/package.json +36 -0
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-derivation.ts +227 -0
  7. package/src/activation-source.ts +93 -0
  8. package/src/blob-ref.ts +90 -0
  9. package/src/branded.ts +135 -0
  10. package/src/collections.ts +99 -0
  11. package/src/compose-batch.ts +69 -0
  12. package/src/compose-schema.ts +36 -0
  13. package/src/context.ts +66 -0
  14. package/src/derive.ts +485 -0
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/entity.ts +346 -0
  19. package/src/error-rendering.ts +87 -0
  20. package/src/errors.ts +483 -0
  21. package/src/execute.ts +1577 -0
  22. package/src/fetch.ts +138 -0
  23. package/src/fire.ts +1172 -0
  24. package/src/glob.ts +81 -0
  25. package/src/guards.ts +37 -0
  26. package/src/index.ts +704 -0
  27. package/src/internal/fork-ctx.ts +69 -0
  28. package/src/layer-field-rendering.ts +193 -0
  29. package/src/layer.ts +81 -0
  30. package/src/observe.ts +361 -0
  31. package/src/path-scope.ts +66 -0
  32. package/src/path-security.ts +98 -0
  33. package/src/patterns/bulk.ts +16 -0
  34. package/src/patterns/change.ts +12 -0
  35. package/src/patterns/date-range.ts +12 -0
  36. package/src/patterns/index.ts +8 -0
  37. package/src/patterns/pagination.ts +22 -0
  38. package/src/patterns/progress.ts +13 -0
  39. package/src/patterns/sorting.ts +14 -0
  40. package/src/patterns/status.ts +11 -0
  41. package/src/patterns/timestamps.ts +12 -0
  42. package/src/permits.ts +12 -0
  43. package/src/queue.ts +163 -0
  44. package/src/redaction/index.ts +3 -0
  45. package/src/redaction/patterns.ts +50 -0
  46. package/src/redaction/redactor.ts +178 -0
  47. package/src/resilience.ts +234 -0
  48. package/src/resource-config.ts +804 -0
  49. package/src/resource.ts +194 -0
  50. package/src/result.ts +212 -0
  51. package/src/run.ts +76 -0
  52. package/src/runtime-builtins.ts +69 -0
  53. package/src/schedule-runtime.ts +689 -0
  54. package/src/schedule.ts +326 -0
  55. package/src/serialization.ts +265 -0
  56. package/src/sha256.ts +136 -0
  57. package/src/signal-diagnostics.ts +633 -0
  58. package/src/signal-ref.ts +111 -0
  59. package/src/signal.ts +104 -0
  60. package/src/store/accessor-protocol.ts +56 -0
  61. package/src/store/index.ts +4 -0
  62. package/src/structured-examples.ts +248 -0
  63. package/src/surface-derivation.ts +91 -0
  64. package/src/surface-filter.ts +101 -0
  65. package/src/surface-overlay.ts +694 -0
  66. package/src/surface-versioning.ts +42 -0
  67. package/src/topo.ts +835 -0
  68. package/src/tracing.ts +346 -0
  69. package/src/trail-id-glob.ts +15 -0
  70. package/src/trail.ts +1351 -0
  71. package/src/trails/derive-trail.ts +835 -0
  72. package/src/trails/index.ts +9 -0
  73. package/src/trails/ingest.ts +152 -0
  74. package/src/trails-db.ts +212 -0
  75. package/src/transport-error-map.ts +163 -0
  76. package/src/type-utils.ts +87 -0
  77. package/src/types.ts +300 -0
  78. package/src/validate-established-topo.ts +73 -0
  79. package/src/validate-topo.ts +725 -0
  80. package/src/validation.ts +330 -0
  81. package/src/version-marker.ts +716 -0
  82. package/src/version-resolution.ts +308 -0
  83. package/src/version-runtime.ts +120 -0
  84. package/src/webhook.ts +461 -0
  85. package/src/workspace.ts +244 -0
  86. package/src/zod-wrappers.ts +72 -0
package/src/webhook.ts ADDED
@@ -0,0 +1,461 @@
1
+ import type {
2
+ ActivationSource,
3
+ ActivationSourceMeta,
4
+ ActivationSourceParse,
5
+ } from './activation-source.js';
6
+ import { InternalError, ValidationError } from './errors.js';
7
+ import type { AnyResource } from './resource.js';
8
+ import { Result } from './result.js';
9
+ import type { TrailContext } from './types.js';
10
+
11
+ export const webhookMethods = Object.freeze([
12
+ 'DELETE',
13
+ 'GET',
14
+ 'PATCH',
15
+ 'POST',
16
+ 'PUT',
17
+ ] as const);
18
+
19
+ export type WebhookMethod = (typeof webhookMethods)[number];
20
+ export type WebhookMethodInput = WebhookMethod | Lowercase<WebhookMethod>;
21
+
22
+ export type WebhookVerifyHeaders = Readonly<
23
+ Record<string, readonly string[] | string | undefined>
24
+ >;
25
+
26
+ export interface WebhookVerifyRequest {
27
+ readonly body: ArrayBuffer | Uint8Array | string;
28
+ readonly headers: WebhookVerifyHeaders;
29
+ readonly method: string;
30
+ readonly path: string;
31
+ }
32
+
33
+ /**
34
+ * Verification callback for inbound webhook requests.
35
+ *
36
+ * When the source declares `resources`, surfaces call `verify` with a
37
+ * resource-capable context so signature checks can reach declared
38
+ * resources (e.g. a store holding per-endpoint secrets). Verifiers that
39
+ * only need the request may ignore the second parameter.
40
+ */
41
+ export type WebhookVerify = (
42
+ request: WebhookVerifyRequest,
43
+ ctx?: TrailContext
44
+ ) => Promise<Result<void, Error>> | Result<void, Error>;
45
+
46
+ export interface WebhookSpec<TOutput = unknown> {
47
+ /**
48
+ * Allowlist of header names delivered to the consumer trail. When set,
49
+ * the webhook envelope includes a `headers` map with the matching
50
+ * headers, lowercased. Headers outside the allowlist never reach the
51
+ * trail boundary.
52
+ */
53
+ readonly headers?: readonly string[] | undefined;
54
+ readonly meta?: ActivationSourceMeta | undefined;
55
+ readonly method?: WebhookMethodInput | undefined;
56
+ readonly parse: ActivationSourceParse<TOutput>;
57
+ /**
58
+ * Absolute path, optionally with dynamic segments (`/hooks/:endpoint`).
59
+ * Segment values are delivered as fields of the webhook envelope under
60
+ * their segment names.
61
+ */
62
+ readonly path: string;
63
+ readonly payload?: ActivationSource['payload'] | undefined;
64
+ /**
65
+ * Deliver the raw request body text to the consumer trail as a
66
+ * `rawBody` envelope field. With `rawBody`, a non-JSON body no longer
67
+ * fails at the surface — the trail owns payload interpretation (e.g.
68
+ * HMAC verification over exact bytes).
69
+ */
70
+ readonly rawBody?: boolean | undefined;
71
+ /** Resources the `verify` callback may access through its context. */
72
+ readonly resources?: readonly AnyResource[] | undefined;
73
+ readonly verify?: WebhookVerify | undefined;
74
+ /** Reserved for future webhook-specific design; trail versioning is trail-only. */
75
+ readonly version?: never;
76
+ }
77
+
78
+ export interface WebhookSource<TOutput = unknown> extends ActivationSource {
79
+ readonly kind: 'webhook';
80
+ readonly headers?: readonly string[] | undefined;
81
+ readonly meta?: ActivationSourceMeta | undefined;
82
+ readonly method: WebhookMethod;
83
+ readonly parse: ActivationSourceParse<TOutput>;
84
+ readonly path: string;
85
+ /** Dynamic segment names parsed from `path`, in order. Empty for static paths. */
86
+ readonly pathParams: readonly string[];
87
+ readonly payload?: ActivationSource['payload'] | undefined;
88
+ readonly rawBody?: boolean | undefined;
89
+ readonly resources?: readonly AnyResource[] | undefined;
90
+ readonly verify?: WebhookVerify | undefined;
91
+ }
92
+
93
+ export interface WebhookValidationIssue {
94
+ readonly field: 'method' | 'parse' | 'path' | 'verify';
95
+ readonly message: string;
96
+ }
97
+
98
+ const DEFAULT_WEBHOOK_METHOD = 'POST' as const;
99
+
100
+ const normalizeMethod = (
101
+ method: WebhookMethodInput | string | undefined
102
+ ): string => (method ?? DEFAULT_WEBHOOK_METHOD).trim().toUpperCase();
103
+
104
+ const normalizePath = (path: string): string => path.trim();
105
+
106
+ const WEBHOOK_PATH_PARAM_PATTERN = /^:[A-Za-z_][A-Za-z0-9_]*$/;
107
+
108
+ /** Envelope field names a path segment must not shadow. */
109
+ const RESERVED_ENVELOPE_FIELDS = new Set(['body', 'headers', 'rawBody']);
110
+
111
+ const pathSegments = (path: string): readonly string[] =>
112
+ normalizePath(path).split('/').slice(1);
113
+
114
+ const isParamSegment = (segment: string): boolean => segment.startsWith(':');
115
+
116
+ /** Decode a path segment, keeping the raw text when the encoding is malformed. */
117
+ const decodeSegment = (segment: string): string => {
118
+ try {
119
+ return decodeURIComponent(segment);
120
+ } catch {
121
+ return segment;
122
+ }
123
+ };
124
+
125
+ /**
126
+ * Parse the dynamic segment names out of a webhook path pattern.
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * parseWebhookPathParams('/hooks/:endpoint'); // ['endpoint']
131
+ * parseWebhookPathParams('/webhooks/payment'); // []
132
+ * ```
133
+ */
134
+ export const parseWebhookPathParams = (path: string): readonly string[] =>
135
+ pathSegments(path)
136
+ .filter((segment) => isParamSegment(segment))
137
+ .map((segment) => segment.slice(1));
138
+
139
+ /**
140
+ * Match a concrete request path against a webhook path pattern.
141
+ *
142
+ * Returns the captured segment values keyed by segment name, or
143
+ * `undefined` when the path does not match. Static patterns match only
144
+ * themselves and capture nothing.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * matchWebhookPath('/hooks/:endpoint', '/hooks/github');
149
+ * // { endpoint: 'github' }
150
+ * ```
151
+ */
152
+ export const matchWebhookPath = (
153
+ pattern: string,
154
+ path: string
155
+ ): Readonly<Record<string, string>> | undefined => {
156
+ const patternSegments = pathSegments(pattern);
157
+ const actualSegments = path.split('/').slice(1);
158
+ if (patternSegments.length !== actualSegments.length) {
159
+ return undefined;
160
+ }
161
+
162
+ const params: Record<string, string> = {};
163
+ for (const [index, patternSegment] of patternSegments.entries()) {
164
+ const actual = actualSegments[index] ?? '';
165
+ if (isParamSegment(patternSegment)) {
166
+ if (actual.length === 0) {
167
+ return undefined;
168
+ }
169
+ params[patternSegment.slice(1)] = decodeSegment(actual);
170
+ continue;
171
+ }
172
+ if (patternSegment !== actual) {
173
+ return undefined;
174
+ }
175
+ }
176
+ return params;
177
+ };
178
+
179
+ /**
180
+ * True when two webhook path patterns can both match one concrete path.
181
+ *
182
+ * Segment-wise: two literals overlap only when equal; a dynamic segment
183
+ * overlaps anything. Used by governance to extend route-collision
184
+ * detection past exact-path equality.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * webhookPathPatternsOverlap('/hooks/:a', '/hooks/github'); // true
189
+ * webhookPathPatternsOverlap('/hooks/:a', '/api/:b'); // false
190
+ * ```
191
+ */
192
+ export const webhookPathPatternsOverlap = (
193
+ left: string,
194
+ right: string
195
+ ): boolean => {
196
+ const leftSegments = pathSegments(left);
197
+ const rightSegments = pathSegments(right);
198
+ if (leftSegments.length !== rightSegments.length) {
199
+ return false;
200
+ }
201
+ return leftSegments.every((leftSegment, index) => {
202
+ const rightSegment = rightSegments[index] ?? '';
203
+ return (
204
+ isParamSegment(leftSegment) ||
205
+ isParamSegment(rightSegment) ||
206
+ leftSegment === rightSegment
207
+ );
208
+ });
209
+ };
210
+
211
+ const isWebhookMethod = (method: string): method is WebhookMethod =>
212
+ (webhookMethods as readonly string[]).includes(method);
213
+
214
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
215
+ typeof value === 'object' && value !== null && !Array.isArray(value);
216
+
217
+ const isZodSchema = (value: unknown): boolean =>
218
+ isObjectRecord(value) && typeof value['safeParse'] === 'function';
219
+
220
+ const validateMethod = (
221
+ method: WebhookMethodInput | string | undefined
222
+ ): WebhookValidationIssue[] => {
223
+ const normalized = normalizeMethod(method);
224
+ return isWebhookMethod(normalized)
225
+ ? []
226
+ : [
227
+ {
228
+ field: 'method',
229
+ message: `Webhook method must be one of ${webhookMethods.join(', ')}`,
230
+ },
231
+ ];
232
+ };
233
+
234
+ const validatePath = (path: unknown): WebhookValidationIssue[] => {
235
+ if (typeof path !== 'string' || path.trim().length === 0) {
236
+ return [
237
+ {
238
+ field: 'path',
239
+ message: 'Webhook path must be a non-empty absolute path',
240
+ },
241
+ ];
242
+ }
243
+
244
+ const normalized = normalizePath(path);
245
+ if (!normalized.startsWith('/')) {
246
+ return [
247
+ {
248
+ field: 'path',
249
+ message: 'Webhook path must start with "/"',
250
+ },
251
+ ];
252
+ }
253
+
254
+ const issues: WebhookValidationIssue[] = [];
255
+ for (const segment of pathSegments(normalized)) {
256
+ if (isParamSegment(segment) && !WEBHOOK_PATH_PARAM_PATTERN.test(segment)) {
257
+ issues.push({
258
+ field: 'path',
259
+ message: `Webhook path segment "${segment}" must match :name with a letter or underscore first`,
260
+ });
261
+ }
262
+ }
263
+
264
+ const params = parseWebhookPathParams(normalized);
265
+ if (new Set(params).size !== params.length) {
266
+ issues.push({
267
+ field: 'path',
268
+ message: 'Webhook path segments must use unique names',
269
+ });
270
+ }
271
+ for (const param of params) {
272
+ if (RESERVED_ENVELOPE_FIELDS.has(param)) {
273
+ issues.push({
274
+ field: 'path',
275
+ message: `Webhook path segment ":${param}" collides with the reserved envelope field "${param}"`,
276
+ });
277
+ }
278
+ }
279
+
280
+ return issues;
281
+ };
282
+
283
+ const validateRequiredParse = (parse: unknown): WebhookValidationIssue[] =>
284
+ parse === undefined
285
+ ? [
286
+ {
287
+ field: 'parse',
288
+ message: 'Webhook sources must define parse',
289
+ },
290
+ ]
291
+ : [];
292
+
293
+ const validateParseShape = (parse: unknown): WebhookValidationIssue[] => {
294
+ if (parse === undefined || isZodSchema(parse)) {
295
+ return [];
296
+ }
297
+ if (isObjectRecord(parse) && isZodSchema(parse['output'])) {
298
+ return [];
299
+ }
300
+ return [
301
+ {
302
+ field: 'parse',
303
+ message: 'Webhook parse must be a Zod schema or define parse.output',
304
+ },
305
+ ];
306
+ };
307
+
308
+ const validateVerify = (verify: unknown): WebhookValidationIssue[] =>
309
+ verify === undefined || typeof verify === 'function'
310
+ ? []
311
+ : [
312
+ {
313
+ field: 'verify',
314
+ message: 'Webhook verify must be a function when provided',
315
+ },
316
+ ];
317
+
318
+ const webhookIssuesMessage = (
319
+ id: string,
320
+ issues: readonly WebhookValidationIssue[]
321
+ ): string =>
322
+ `webhook("${id}") is invalid: ${issues.map((issue) => `${issue.field}: ${issue.message}`).join('; ')}`;
323
+
324
+ const assertWebhookSpec = <TOutput>(
325
+ id: string,
326
+ spec: WebhookSpec<TOutput>
327
+ ): {
328
+ readonly method: WebhookMethod;
329
+ readonly path: string;
330
+ } => {
331
+ const issues = [
332
+ ...validateMethod(spec.method),
333
+ ...validatePath(spec.path),
334
+ ...validateRequiredParse(spec.parse),
335
+ ...validateParseShape(spec.parse),
336
+ ...validateVerify(spec.verify),
337
+ ];
338
+
339
+ if (issues.length > 0) {
340
+ throw new ValidationError(webhookIssuesMessage(id, issues), {
341
+ context: { issues },
342
+ });
343
+ }
344
+
345
+ return {
346
+ method: normalizeMethod(spec.method) as WebhookMethod,
347
+ path: normalizePath(spec.path),
348
+ };
349
+ };
350
+
351
+ export const validateWebhookSource = (
352
+ source: ActivationSource
353
+ ): readonly WebhookValidationIssue[] => {
354
+ if (source.kind !== 'webhook') {
355
+ return [];
356
+ }
357
+
358
+ return [
359
+ ...validateMethod(source.method),
360
+ ...validatePath(source.path),
361
+ ...validateRequiredParse(source.parse),
362
+ ...validateParseShape(source.parse),
363
+ ...validateVerify(source.verify),
364
+ ];
365
+ };
366
+
367
+ const errorFromUnknown = (error: unknown): Error =>
368
+ error instanceof Error ? error : new Error(String(error));
369
+
370
+ export const getWebhookHeaders = (
371
+ request: Pick<WebhookVerifyRequest, 'headers'>,
372
+ name: string
373
+ ): readonly string[] => {
374
+ const normalized = name.toLowerCase();
375
+ const matches: string[] = [];
376
+ for (const [headerName, value] of Object.entries(request.headers)) {
377
+ if (headerName.toLowerCase() !== normalized) {
378
+ continue;
379
+ }
380
+ if (value === undefined) {
381
+ continue;
382
+ }
383
+ if (typeof value === 'string') {
384
+ matches.push(value);
385
+ } else {
386
+ matches.push(...value);
387
+ }
388
+ }
389
+ return matches;
390
+ };
391
+
392
+ export const getWebhookHeader = (
393
+ request: Pick<WebhookVerifyRequest, 'headers'>,
394
+ name: string
395
+ ): string | undefined => {
396
+ const [first] = getWebhookHeaders(request, name);
397
+ return first;
398
+ };
399
+
400
+ export const verifyWebhookRequest = async (
401
+ source: Pick<WebhookSource, 'id' | 'verify'>,
402
+ request: WebhookVerifyRequest,
403
+ ctx?: TrailContext
404
+ ): Promise<Result<void, Error>> => {
405
+ if (source.verify === undefined) {
406
+ return Result.ok();
407
+ }
408
+
409
+ try {
410
+ return await source.verify(request, ctx);
411
+ } catch (error) {
412
+ const cause = errorFromUnknown(error);
413
+ return Result.err(
414
+ new InternalError(`Webhook source "${source.id}" verification threw`, {
415
+ cause,
416
+ })
417
+ );
418
+ }
419
+ };
420
+
421
+ export function webhook<TOutput>(
422
+ id: string,
423
+ spec: WebhookSpec<TOutput>
424
+ ): WebhookSource<TOutput>;
425
+ export function webhook<TOutput>(
426
+ spec: WebhookSpec<TOutput> & { readonly id: string }
427
+ ): WebhookSource<TOutput>;
428
+ export function webhook<TOutput>(
429
+ idOrSpec: string | (WebhookSpec<TOutput> & { readonly id: string }),
430
+ maybeSpec?: WebhookSpec<TOutput>
431
+ ): WebhookSource<TOutput> {
432
+ const id = typeof idOrSpec === 'string' ? idOrSpec : idOrSpec.id;
433
+ // oxlint-disable-next-line no-non-null-assertion -- overload guarantees maybeSpec when idOrSpec is string
434
+ const spec = typeof idOrSpec === 'string' ? maybeSpec! : idOrSpec;
435
+ const normalized = assertWebhookSpec(id, spec);
436
+
437
+ return Object.freeze({
438
+ id,
439
+ kind: 'webhook' as const,
440
+ method: normalized.method,
441
+ parse: spec.parse,
442
+ path: normalized.path,
443
+ pathParams: Object.freeze([...parseWebhookPathParams(normalized.path)]),
444
+ ...(spec.headers === undefined
445
+ ? {}
446
+ : {
447
+ headers: Object.freeze(
448
+ spec.headers.map((name) => name.toLowerCase())
449
+ ),
450
+ }),
451
+ ...(spec.meta === undefined
452
+ ? {}
453
+ : { meta: Object.freeze({ ...spec.meta }) }),
454
+ ...(spec.payload === undefined ? {} : { payload: spec.payload }),
455
+ ...(spec.rawBody === undefined ? {} : { rawBody: spec.rawBody }),
456
+ ...(spec.resources === undefined
457
+ ? {}
458
+ : { resources: Object.freeze([...spec.resources]) }),
459
+ ...(spec.verify === undefined ? {} : { verify: spec.verify }),
460
+ });
461
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Workspace detection utilities.
3
+ *
4
+ * Walks the filesystem to find monorepo workspace roots and provides
5
+ * helpers for working with paths relative to a workspace.
6
+ */
7
+
8
+ import { NotFoundError } from './errors.js';
9
+ import { Result } from './result.js';
10
+ // Workspace discovery is a tooling path: the node builtins load lazily at
11
+ // first use so the core barrel's module graph stays execution-portable on
12
+ // runtimes without node: builtins (TRL-1198).
13
+ import { loadRuntimeBuiltin } from './runtime-builtins.js';
14
+
15
+ const fs = () => loadRuntimeBuiltin('node:fs');
16
+ const nodePath = () => loadRuntimeBuiltin('node:path');
17
+
18
+ export interface WorkspaceRootManifest {
19
+ readonly workspaces?: unknown;
20
+ }
21
+
22
+ export interface WorkspacePackage<
23
+ Manifest extends object = Record<string, unknown>,
24
+ > {
25
+ readonly manifest: Manifest;
26
+ readonly packageJsonPath: string;
27
+ readonly packageRoot: string;
28
+ readonly workspacePath: string;
29
+ }
30
+
31
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
32
+ typeof value === 'object' && value !== null && !Array.isArray(value);
33
+
34
+ const normalizePath = (path: string): string => path.replaceAll('\\', '/');
35
+
36
+ const normalizeRealPath = (path: string): string => {
37
+ try {
38
+ return normalizePath(fs().realpathSync(path));
39
+ } catch {
40
+ return normalizePath(nodePath().resolve(path));
41
+ }
42
+ };
43
+
44
+ const readJsonSync = <T>(path: string): T | undefined => {
45
+ try {
46
+ return JSON.parse(fs().readFileSync(path, 'utf8')) as T;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ };
51
+
52
+ /** Check if a directory has a package.json with a `workspaces` field. */
53
+ const hasWorkspacesField = (dir: string): boolean => {
54
+ const pkg = readJsonSync<unknown>(nodePath().join(dir, 'package.json'));
55
+ return typeof pkg === 'object' && pkg !== null && 'workspaces' in pkg;
56
+ };
57
+
58
+ /**
59
+ * List workspace patterns from a root package manifest.
60
+ *
61
+ * Supports npm/Bun's array form and Yarn-style `{ packages: [] }` form.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * listWorkspacePatterns({ workspaces: ['packages/*'] });
66
+ * ```
67
+ */
68
+ export const listWorkspacePatterns = (
69
+ manifest: WorkspaceRootManifest | undefined
70
+ ): readonly string[] => {
71
+ const { workspaces } = manifest ?? {};
72
+ if (Array.isArray(workspaces)) {
73
+ return workspaces.filter(
74
+ (pattern): pattern is string => typeof pattern === 'string'
75
+ );
76
+ }
77
+
78
+ const packages = isRecord(workspaces) ? workspaces['packages'] : undefined;
79
+ return Array.isArray(packages)
80
+ ? packages.filter(
81
+ (pattern): pattern is string => typeof pattern === 'string'
82
+ )
83
+ : [];
84
+ };
85
+
86
+ const workspaceDirsForPattern = (
87
+ rootDir: string,
88
+ pattern: string
89
+ ): readonly string[] => {
90
+ if (!pattern.endsWith('/*')) {
91
+ const workspaceDir = nodePath().join(rootDir, pattern);
92
+ return fs().existsSync(nodePath().join(workspaceDir, 'package.json'))
93
+ ? [workspaceDir]
94
+ : [];
95
+ }
96
+
97
+ const groupDir = nodePath().join(rootDir, pattern.slice(0, -2));
98
+ if (!fs().existsSync(groupDir)) {
99
+ return [];
100
+ }
101
+
102
+ return fs()
103
+ .readdirSync(groupDir, { withFileTypes: true })
104
+ .filter((entry) => entry.isDirectory())
105
+ .map((entry) => nodePath().join(groupDir, entry.name))
106
+ .filter((workspaceDir) =>
107
+ fs().existsSync(nodePath().join(workspaceDir, 'package.json'))
108
+ )
109
+ .toSorted();
110
+ };
111
+
112
+ /**
113
+ * List package directories matched by workspace patterns.
114
+ *
115
+ * Only directories containing `package.json` are returned.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * listWorkspacePackageDirs('/repo', ['packages/*']);
120
+ * ```
121
+ */
122
+ export const listWorkspacePackageDirs = (
123
+ rootDir: string,
124
+ patterns: readonly string[]
125
+ ): readonly string[] =>
126
+ patterns.flatMap((pattern) => workspaceDirsForPattern(rootDir, pattern));
127
+
128
+ /**
129
+ * List workspace package manifests from a workspace root.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * const packages = listWorkspacePackages('/repo');
134
+ * ```
135
+ */
136
+ export const listWorkspacePackages = <
137
+ Manifest extends { readonly name?: unknown } = { readonly name?: unknown },
138
+ >(
139
+ rootDir: string
140
+ ): readonly WorkspacePackage<Manifest>[] => {
141
+ const normalizedRoot = normalizeRealPath(rootDir);
142
+ const rootManifest = readJsonSync<WorkspaceRootManifest>(
143
+ nodePath().join(normalizedRoot, 'package.json')
144
+ );
145
+ const packages: WorkspacePackage<Manifest>[] = [];
146
+
147
+ for (const workspaceDir of listWorkspacePackageDirs(
148
+ normalizedRoot,
149
+ listWorkspacePatterns(rootManifest)
150
+ )) {
151
+ const packageJsonPath = nodePath().join(workspaceDir, 'package.json');
152
+ const manifest = readJsonSync<Manifest>(packageJsonPath);
153
+ if (!manifest || typeof manifest.name !== 'string') {
154
+ continue;
155
+ }
156
+
157
+ const packageRoot = normalizeRealPath(nodePath().dirname(packageJsonPath));
158
+ packages.push({
159
+ manifest,
160
+ packageJsonPath: normalizeRealPath(packageJsonPath),
161
+ packageRoot,
162
+ workspacePath: normalizePath(
163
+ nodePath().relative(normalizedRoot, packageRoot)
164
+ ),
165
+ });
166
+ }
167
+
168
+ return packages.toSorted((left, right) =>
169
+ left.workspacePath.localeCompare(right.workspacePath)
170
+ );
171
+ };
172
+
173
+ /**
174
+ * Find a workspace package by its package name.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * const workspace = findWorkspacePackage('/repo', '@ontrails/core');
179
+ * ```
180
+ */
181
+ export const findWorkspacePackage = <
182
+ Manifest extends { readonly name?: unknown } = { readonly name?: unknown },
183
+ >(
184
+ rootDir: string,
185
+ packageName: string
186
+ ): WorkspacePackage<Manifest> | undefined =>
187
+ listWorkspacePackages<Manifest>(rootDir).find(
188
+ (workspacePackage) => workspacePackage.manifest.name === packageName
189
+ );
190
+
191
+ /**
192
+ * Walks up from `startDir` (defaults to `process.cwd()`) looking for a
193
+ * `package.json` that contains a `"workspaces"` field.
194
+ *
195
+ * Returns the directory path of the workspace root on success, or a
196
+ * `NotFoundError` if no workspace root is found.
197
+ */
198
+ export const findWorkspaceRoot = async (
199
+ startDir?: string
200
+ ): Promise<Result<string, NotFoundError>> => {
201
+ let current = nodePath().resolve(startDir ?? process.cwd());
202
+
203
+ // eslint-disable-next-line no-constant-condition
204
+ while (true) {
205
+ if (hasWorkspacesField(current)) {
206
+ return Result.ok(current);
207
+ }
208
+
209
+ const parent = nodePath().dirname(current);
210
+
211
+ if (parent === current) {
212
+ return Result.err(
213
+ new NotFoundError(
214
+ `No workspace root found from "${startDir ?? process.cwd()}"`
215
+ )
216
+ );
217
+ }
218
+
219
+ current = parent;
220
+ }
221
+ };
222
+
223
+ /**
224
+ * Returns `true` if `filePath` is inside `workspaceRoot`.
225
+ */
226
+ export const isInsideWorkspace = (
227
+ filePath: string,
228
+ workspaceRoot: string
229
+ ): boolean => {
230
+ const { isAbsolute, relative, resolve } = nodePath();
231
+ const rel = relative(resolve(workspaceRoot), resolve(filePath));
232
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
233
+ };
234
+
235
+ /**
236
+ * Returns the relative path from `workspaceRoot` to `filePath`.
237
+ */
238
+ export const deriveRelativePath = (
239
+ filePath: string,
240
+ workspaceRoot: string
241
+ ): string => {
242
+ const { relative, resolve } = nodePath();
243
+ return relative(resolve(workspaceRoot), resolve(filePath));
244
+ };