@ontrails/core 1.0.0-beta.32 → 1.0.0-beta.39
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 +45 -0
- package/package.json +1 -1
- package/src/context.ts +18 -4
- package/src/derive.ts +21 -0
- package/src/index.ts +48 -3
- package/src/path-security.ts +10 -2
- package/src/resource-config.ts +16 -4
- package/src/runtime-builtins.ts +69 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +4 -4
- package/src/surface-overlay.ts +694 -0
- package/src/trail.ts +78 -0
- package/src/trails-db.ts +28 -22
- package/src/validate-topo.ts +181 -40
- package/src/webhook.ts +194 -11
- package/src/workspace.ts +39 -33
package/src/webhook.ts
CHANGED
|
@@ -4,7 +4,9 @@ import type {
|
|
|
4
4
|
ActivationSourceParse,
|
|
5
5
|
} from './activation-source.js';
|
|
6
6
|
import { InternalError, ValidationError } from './errors.js';
|
|
7
|
+
import type { AnyResource } from './resource.js';
|
|
7
8
|
import { Result } from './result.js';
|
|
9
|
+
import type { TrailContext } from './types.js';
|
|
8
10
|
|
|
9
11
|
export const webhookMethods = Object.freeze([
|
|
10
12
|
'DELETE',
|
|
@@ -28,16 +30,46 @@ export interface WebhookVerifyRequest {
|
|
|
28
30
|
readonly path: string;
|
|
29
31
|
}
|
|
30
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
|
+
*/
|
|
31
41
|
export type WebhookVerify = (
|
|
32
|
-
request: WebhookVerifyRequest
|
|
42
|
+
request: WebhookVerifyRequest,
|
|
43
|
+
ctx?: TrailContext
|
|
33
44
|
) => Promise<Result<void, Error>> | Result<void, Error>;
|
|
34
45
|
|
|
35
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;
|
|
36
54
|
readonly meta?: ActivationSourceMeta | undefined;
|
|
37
55
|
readonly method?: WebhookMethodInput | undefined;
|
|
38
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
|
+
*/
|
|
39
62
|
readonly path: string;
|
|
40
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;
|
|
41
73
|
readonly verify?: WebhookVerify | undefined;
|
|
42
74
|
/** Reserved for future webhook-specific design; trail versioning is trail-only. */
|
|
43
75
|
readonly version?: never;
|
|
@@ -45,11 +77,16 @@ export interface WebhookSpec<TOutput = unknown> {
|
|
|
45
77
|
|
|
46
78
|
export interface WebhookSource<TOutput = unknown> extends ActivationSource {
|
|
47
79
|
readonly kind: 'webhook';
|
|
80
|
+
readonly headers?: readonly string[] | undefined;
|
|
48
81
|
readonly meta?: ActivationSourceMeta | undefined;
|
|
49
82
|
readonly method: WebhookMethod;
|
|
50
83
|
readonly parse: ActivationSourceParse<TOutput>;
|
|
51
84
|
readonly path: string;
|
|
85
|
+
/** Dynamic segment names parsed from `path`, in order. Empty for static paths. */
|
|
86
|
+
readonly pathParams: readonly string[];
|
|
52
87
|
readonly payload?: ActivationSource['payload'] | undefined;
|
|
88
|
+
readonly rawBody?: boolean | undefined;
|
|
89
|
+
readonly resources?: readonly AnyResource[] | undefined;
|
|
53
90
|
readonly verify?: WebhookVerify | undefined;
|
|
54
91
|
}
|
|
55
92
|
|
|
@@ -66,6 +103,111 @@ const normalizeMethod = (
|
|
|
66
103
|
|
|
67
104
|
const normalizePath = (path: string): string => path.trim();
|
|
68
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
|
+
|
|
69
211
|
const isWebhookMethod = (method: string): method is WebhookMethod =>
|
|
70
212
|
(webhookMethods as readonly string[]).includes(method);
|
|
71
213
|
|
|
@@ -100,14 +242,42 @@ const validatePath = (path: unknown): WebhookValidationIssue[] => {
|
|
|
100
242
|
}
|
|
101
243
|
|
|
102
244
|
const normalized = normalizePath(path);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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;
|
|
111
281
|
};
|
|
112
282
|
|
|
113
283
|
const validateRequiredParse = (parse: unknown): WebhookValidationIssue[] =>
|
|
@@ -229,14 +399,15 @@ export const getWebhookHeader = (
|
|
|
229
399
|
|
|
230
400
|
export const verifyWebhookRequest = async (
|
|
231
401
|
source: Pick<WebhookSource, 'id' | 'verify'>,
|
|
232
|
-
request: WebhookVerifyRequest
|
|
402
|
+
request: WebhookVerifyRequest,
|
|
403
|
+
ctx?: TrailContext
|
|
233
404
|
): Promise<Result<void, Error>> => {
|
|
234
405
|
if (source.verify === undefined) {
|
|
235
406
|
return Result.ok();
|
|
236
407
|
}
|
|
237
408
|
|
|
238
409
|
try {
|
|
239
|
-
return await source.verify(request);
|
|
410
|
+
return await source.verify(request, ctx);
|
|
240
411
|
} catch (error) {
|
|
241
412
|
const cause = errorFromUnknown(error);
|
|
242
413
|
return Result.err(
|
|
@@ -269,10 +440,22 @@ export function webhook<TOutput>(
|
|
|
269
440
|
method: normalized.method,
|
|
270
441
|
parse: spec.parse,
|
|
271
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
|
+
}),
|
|
272
451
|
...(spec.meta === undefined
|
|
273
452
|
? {}
|
|
274
453
|
: { meta: Object.freeze({ ...spec.meta }) }),
|
|
275
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]) }),
|
|
276
459
|
...(spec.verify === undefined ? {} : { verify: spec.verify }),
|
|
277
460
|
});
|
|
278
461
|
}
|
package/src/workspace.ts
CHANGED
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
* helpers for working with paths relative to a workspace.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
9
|
-
import { resolve, relative, dirname, join, isAbsolute } from 'node:path';
|
|
10
|
-
|
|
11
8
|
import { NotFoundError } from './errors.js';
|
|
12
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');
|
|
13
17
|
|
|
14
18
|
export interface WorkspaceRootManifest {
|
|
15
19
|
readonly workspaces?: unknown;
|
|
@@ -31,33 +35,24 @@ const normalizePath = (path: string): string => path.replaceAll('\\', '/');
|
|
|
31
35
|
|
|
32
36
|
const normalizeRealPath = (path: string): string => {
|
|
33
37
|
try {
|
|
34
|
-
return normalizePath(realpathSync(path));
|
|
38
|
+
return normalizePath(fs().realpathSync(path));
|
|
35
39
|
} catch {
|
|
36
|
-
return normalizePath(resolve(path));
|
|
40
|
+
return normalizePath(nodePath().resolve(path));
|
|
37
41
|
}
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
const readJsonSync = <T>(path: string): T | undefined => {
|
|
41
45
|
try {
|
|
42
|
-
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
46
|
+
return JSON.parse(fs().readFileSync(path, 'utf8')) as T;
|
|
43
47
|
} catch {
|
|
44
48
|
return undefined;
|
|
45
49
|
}
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
/** Check if a directory has a package.json with a `workspaces` field. */
|
|
49
|
-
const hasWorkspacesField =
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
if (!(await file.exists())) {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
try {
|
|
56
|
-
const pkg: unknown = await file.json();
|
|
57
|
-
return typeof pkg === 'object' && pkg !== null && 'workspaces' in pkg;
|
|
58
|
-
} catch {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
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;
|
|
61
56
|
};
|
|
62
57
|
|
|
63
58
|
/**
|
|
@@ -93,19 +88,24 @@ const workspaceDirsForPattern = (
|
|
|
93
88
|
pattern: string
|
|
94
89
|
): readonly string[] => {
|
|
95
90
|
if (!pattern.endsWith('/*')) {
|
|
96
|
-
const workspaceDir = join(rootDir, pattern);
|
|
97
|
-
return existsSync(join(workspaceDir, 'package.json'))
|
|
91
|
+
const workspaceDir = nodePath().join(rootDir, pattern);
|
|
92
|
+
return fs().existsSync(nodePath().join(workspaceDir, 'package.json'))
|
|
93
|
+
? [workspaceDir]
|
|
94
|
+
: [];
|
|
98
95
|
}
|
|
99
96
|
|
|
100
|
-
const groupDir = join(rootDir, pattern.slice(0, -2));
|
|
101
|
-
if (!existsSync(groupDir)) {
|
|
97
|
+
const groupDir = nodePath().join(rootDir, pattern.slice(0, -2));
|
|
98
|
+
if (!fs().existsSync(groupDir)) {
|
|
102
99
|
return [];
|
|
103
100
|
}
|
|
104
101
|
|
|
105
|
-
return
|
|
102
|
+
return fs()
|
|
103
|
+
.readdirSync(groupDir, { withFileTypes: true })
|
|
106
104
|
.filter((entry) => entry.isDirectory())
|
|
107
|
-
.map((entry) => join(groupDir, entry.name))
|
|
108
|
-
.filter((workspaceDir) =>
|
|
105
|
+
.map((entry) => nodePath().join(groupDir, entry.name))
|
|
106
|
+
.filter((workspaceDir) =>
|
|
107
|
+
fs().existsSync(nodePath().join(workspaceDir, 'package.json'))
|
|
108
|
+
)
|
|
109
109
|
.toSorted();
|
|
110
110
|
};
|
|
111
111
|
|
|
@@ -140,7 +140,7 @@ export const listWorkspacePackages = <
|
|
|
140
140
|
): readonly WorkspacePackage<Manifest>[] => {
|
|
141
141
|
const normalizedRoot = normalizeRealPath(rootDir);
|
|
142
142
|
const rootManifest = readJsonSync<WorkspaceRootManifest>(
|
|
143
|
-
join(normalizedRoot, 'package.json')
|
|
143
|
+
nodePath().join(normalizedRoot, 'package.json')
|
|
144
144
|
);
|
|
145
145
|
const packages: WorkspacePackage<Manifest>[] = [];
|
|
146
146
|
|
|
@@ -148,18 +148,20 @@ export const listWorkspacePackages = <
|
|
|
148
148
|
normalizedRoot,
|
|
149
149
|
listWorkspacePatterns(rootManifest)
|
|
150
150
|
)) {
|
|
151
|
-
const packageJsonPath = join(workspaceDir, 'package.json');
|
|
151
|
+
const packageJsonPath = nodePath().join(workspaceDir, 'package.json');
|
|
152
152
|
const manifest = readJsonSync<Manifest>(packageJsonPath);
|
|
153
153
|
if (!manifest || typeof manifest.name !== 'string') {
|
|
154
154
|
continue;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
const packageRoot = normalizeRealPath(dirname(packageJsonPath));
|
|
157
|
+
const packageRoot = normalizeRealPath(nodePath().dirname(packageJsonPath));
|
|
158
158
|
packages.push({
|
|
159
159
|
manifest,
|
|
160
160
|
packageJsonPath: normalizeRealPath(packageJsonPath),
|
|
161
161
|
packageRoot,
|
|
162
|
-
workspacePath: normalizePath(
|
|
162
|
+
workspacePath: normalizePath(
|
|
163
|
+
nodePath().relative(normalizedRoot, packageRoot)
|
|
164
|
+
),
|
|
163
165
|
});
|
|
164
166
|
}
|
|
165
167
|
|
|
@@ -196,15 +198,15 @@ export const findWorkspacePackage = <
|
|
|
196
198
|
export const findWorkspaceRoot = async (
|
|
197
199
|
startDir?: string
|
|
198
200
|
): Promise<Result<string, NotFoundError>> => {
|
|
199
|
-
let current = resolve(startDir ?? process.cwd());
|
|
201
|
+
let current = nodePath().resolve(startDir ?? process.cwd());
|
|
200
202
|
|
|
201
203
|
// eslint-disable-next-line no-constant-condition
|
|
202
204
|
while (true) {
|
|
203
|
-
if (
|
|
205
|
+
if (hasWorkspacesField(current)) {
|
|
204
206
|
return Result.ok(current);
|
|
205
207
|
}
|
|
206
208
|
|
|
207
|
-
const parent = dirname(current);
|
|
209
|
+
const parent = nodePath().dirname(current);
|
|
208
210
|
|
|
209
211
|
if (parent === current) {
|
|
210
212
|
return Result.err(
|
|
@@ -225,6 +227,7 @@ export const isInsideWorkspace = (
|
|
|
225
227
|
filePath: string,
|
|
226
228
|
workspaceRoot: string
|
|
227
229
|
): boolean => {
|
|
230
|
+
const { isAbsolute, relative, resolve } = nodePath();
|
|
228
231
|
const rel = relative(resolve(workspaceRoot), resolve(filePath));
|
|
229
232
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
230
233
|
};
|
|
@@ -235,4 +238,7 @@ export const isInsideWorkspace = (
|
|
|
235
238
|
export const deriveRelativePath = (
|
|
236
239
|
filePath: string,
|
|
237
240
|
workspaceRoot: string
|
|
238
|
-
): string =>
|
|
241
|
+
): string => {
|
|
242
|
+
const { relative, resolve } = nodePath();
|
|
243
|
+
return relative(resolve(workspaceRoot), resolve(filePath));
|
|
244
|
+
};
|