@nifrajs/cli 2.14.1 → 3.1.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/dist/check.d.ts.map +1 -1
- package/dist/check.js +27 -7
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -2
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +6 -0
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +20 -2
- package/dist/command-catalog.js.map +1 -1
- package/dist/doctor.d.ts +42 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +64 -7
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +68 -6
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +15 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +17 -30
- package/dist/rules/routes.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +1 -1
- package/docs/llms-full.txt +362 -20
- package/docs/types.json +933 -46
- package/package.json +9 -9
- package/src/check.ts +28 -7
- package/src/cli.ts +41 -2
- package/src/command-catalog.ts +22 -2
- package/src/doctor.ts +99 -8
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +81 -6
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +17 -0
- package/src/rules/routes.ts +17 -30
- package/src/workspace-link.ts +80 -0
package/src/mcp.ts
CHANGED
|
@@ -104,6 +104,8 @@ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
|
|
|
104
104
|
* within one attention span rather than after minutes of polling.
|
|
105
105
|
*/
|
|
106
106
|
const CHILD_TIMEOUT_MS = 30_000
|
|
107
|
+
/** Maximum stdout or stderr retained from a project subprocess. The process is killed at the limit. */
|
|
108
|
+
export const CHILD_OUTPUT_MAX_BYTES = 1_048_576
|
|
107
109
|
|
|
108
110
|
/** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
|
|
109
111
|
* buffer an unrelated loopback service just because a caller supplied its port. */
|
|
@@ -172,6 +174,47 @@ const timeoutMessage = (label: string, ms: number): string =>
|
|
|
172
174
|
`that keeps the event loop alive (a database pool, a Redis client, an interval) opened during import ` +
|
|
173
175
|
`rather than lazily. Check for top-level connections in the app entry or anything it imports.`
|
|
174
176
|
|
|
177
|
+
export interface BoundedOutput {
|
|
178
|
+
readonly text: string
|
|
179
|
+
readonly truncated: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Read child output incrementally and cancel as soon as the byte budget is crossed. */
|
|
183
|
+
export async function readBoundedStream(
|
|
184
|
+
stream: ReadableStream<Uint8Array>,
|
|
185
|
+
maxBytes = CHILD_OUTPUT_MAX_BYTES,
|
|
186
|
+
onLimit?: () => void,
|
|
187
|
+
): Promise<BoundedOutput> {
|
|
188
|
+
const reader = stream.getReader()
|
|
189
|
+
const decoder = new TextDecoder()
|
|
190
|
+
const chunks: string[] = []
|
|
191
|
+
let totalBytes = 0
|
|
192
|
+
try {
|
|
193
|
+
for (;;) {
|
|
194
|
+
const { done, value } = await reader.read()
|
|
195
|
+
if (done) {
|
|
196
|
+
chunks.push(decoder.decode())
|
|
197
|
+
return { text: chunks.join(""), truncated: false }
|
|
198
|
+
}
|
|
199
|
+
if (value === undefined) continue
|
|
200
|
+
const remaining = maxBytes - totalBytes
|
|
201
|
+
if (remaining <= 0 || value.byteLength > remaining) {
|
|
202
|
+
if (remaining > 0) {
|
|
203
|
+
chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }))
|
|
204
|
+
chunks.push(decoder.decode())
|
|
205
|
+
}
|
|
206
|
+
await reader.cancel()
|
|
207
|
+
onLimit?.()
|
|
208
|
+
return { text: chunks.join(""), truncated: true }
|
|
209
|
+
}
|
|
210
|
+
totalBytes += value.byteLength
|
|
211
|
+
chunks.push(decoder.decode(value, { stream: true }))
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
reader.releaseLock()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
175
218
|
async function spawnChild(
|
|
176
219
|
child: "mcp-run" | "mcp-render" | "mcp-ws",
|
|
177
220
|
cwd: string,
|
|
@@ -185,30 +228,46 @@ async function spawnChild(
|
|
|
185
228
|
stdout: "pipe",
|
|
186
229
|
stderr: "pipe",
|
|
187
230
|
})
|
|
188
|
-
|
|
231
|
+
let killed = false
|
|
232
|
+
const kill = (): void => {
|
|
233
|
+
if (killed) return
|
|
234
|
+
killed = true
|
|
235
|
+
proc.kill()
|
|
236
|
+
}
|
|
237
|
+
const abort = (): void => kill()
|
|
189
238
|
signal?.addEventListener("abort", abort, { once: true })
|
|
190
239
|
let timedOut = false
|
|
191
240
|
const timer = setTimeout(() => {
|
|
192
241
|
timedOut = true
|
|
193
|
-
|
|
242
|
+
kill()
|
|
194
243
|
}, CHILD_TIMEOUT_MS)
|
|
244
|
+
let outputExceeded = false
|
|
195
245
|
try {
|
|
196
246
|
proc.stdin.write(JSON.stringify(input))
|
|
197
247
|
await proc.stdin.end()
|
|
198
248
|
const [out, err] = await Promise.all([
|
|
199
|
-
|
|
200
|
-
|
|
249
|
+
readBoundedStream(proc.stdout, CHILD_OUTPUT_MAX_BYTES, () => {
|
|
250
|
+
outputExceeded = true
|
|
251
|
+
kill()
|
|
252
|
+
}),
|
|
253
|
+
readBoundedStream(proc.stderr, CHILD_OUTPUT_MAX_BYTES, () => {
|
|
254
|
+
outputExceeded = true
|
|
255
|
+
kill()
|
|
256
|
+
}),
|
|
201
257
|
])
|
|
202
258
|
await proc.exited
|
|
203
259
|
if (signal?.aborted) {
|
|
204
260
|
const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
|
|
205
261
|
return `${label} cancelled${reason}.`
|
|
206
262
|
}
|
|
263
|
+
if (outputExceeded) {
|
|
264
|
+
return `${label} output exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes and was terminated.`
|
|
265
|
+
}
|
|
207
266
|
// A killed child may still have flushed a complete result before the timer fired; prefer real
|
|
208
267
|
// output over the timeout message so a slow-but-successful render is not reported as a failure.
|
|
209
|
-
const text = out.trim()
|
|
268
|
+
const text = out.text.trim()
|
|
210
269
|
if (timedOut && text === "") return timeoutMessage(label, CHILD_TIMEOUT_MS)
|
|
211
|
-
return text || `${label} failed:\n${err.trim() || "(no output)"}`
|
|
270
|
+
return text || `${label} failed:\n${err.text.trim() || "(no output)"}`
|
|
212
271
|
} finally {
|
|
213
272
|
clearTimeout(timer)
|
|
214
273
|
signal?.removeEventListener("abort", abort)
|
|
@@ -345,7 +404,23 @@ export class WarmWorker {
|
|
|
345
404
|
for (;;) {
|
|
346
405
|
const { done, value } = await reader.read()
|
|
347
406
|
if (done) break
|
|
407
|
+
if (value !== undefined && value.byteLength > CHILD_OUTPUT_MAX_BYTES) {
|
|
408
|
+
this.stderrBuffer = boundedAppend(
|
|
409
|
+
this.stderrBuffer,
|
|
410
|
+
`warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
|
|
411
|
+
)
|
|
412
|
+
this.stop()
|
|
413
|
+
return
|
|
414
|
+
}
|
|
348
415
|
this.stdoutBuffer += decoder.decode(value, { stream: true })
|
|
416
|
+
if (this.stdoutBuffer.length > CHILD_OUTPUT_MAX_BYTES) {
|
|
417
|
+
this.stderrBuffer = boundedAppend(
|
|
418
|
+
this.stderrBuffer,
|
|
419
|
+
`warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
|
|
420
|
+
)
|
|
421
|
+
this.stop()
|
|
422
|
+
return
|
|
423
|
+
}
|
|
349
424
|
let nl = this.stdoutBuffer.indexOf("\n")
|
|
350
425
|
while (nl !== -1) {
|
|
351
426
|
const line = this.stdoutBuffer.slice(0, nl).trim()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { UpgradeRecipe } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Upgrade recipe for the Nifra 3.0 fixed-group cutover.
|
|
5
|
+
*
|
|
6
|
+
* No package is removed and no import specifier moves this release, so the runner only pins the
|
|
7
|
+
* fixed version group. The two breaking changes are structural - a redirect return value and the
|
|
8
|
+
* reserved typed-client segment types - and stay as explicit notes, one of which points at a codemod.
|
|
9
|
+
*/
|
|
10
|
+
export const recipe: UpgradeRecipe = {
|
|
11
|
+
version: "3.0.0",
|
|
12
|
+
pins: [
|
|
13
|
+
{ match: "@nifrajs/", to: "3.0.0" },
|
|
14
|
+
{ match: "create-nifra", to: "3.0.0" },
|
|
15
|
+
{ match: "nifra", to: "3.0.0" },
|
|
16
|
+
],
|
|
17
|
+
importMoves: [],
|
|
18
|
+
notes: [
|
|
19
|
+
"Read the 2.x → 3.0 migration guide: https://nifra.dev/docs/migrate-3.",
|
|
20
|
+
"`redirect(...)` now returns plain render data, not a `Response`. Read it with `.plain` (`{ status, headers, body }`) or `toResponse()`, add headers with the second argument, and assert on `.plain` in tests. `return redirect()` / `throw redirect()` are unchanged, and cookies still apply.",
|
|
21
|
+
"Reserved typed-client segment keys are a frozen contract: a property or bracket access on a reserved-named route segment no longer type-checks. Run `nifra fix --code NF-C018` to rewrite the affected call sites; `nifra routes` annotates a colliding route with the spelling that reaches it.",
|
|
22
|
+
"On Node, `@nifrajs/proxy` defaults to the undici transport. Pass an explicit `transport` to `createProxy` to override it.",
|
|
23
|
+
],
|
|
24
|
+
}
|
package/src/recipes/index.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { recipe as v1_8_0 } from "./1.8.0.ts"
|
|
8
8
|
import { recipe as v2_0_0 } from "./2.0.0.ts"
|
|
9
|
+
import { recipe as v3_0_0 } from "./3.0.0.ts"
|
|
9
10
|
|
|
10
11
|
/** A pin rule: every dependency whose name starts with `match` is set to version `to`. */
|
|
11
12
|
export interface PinRule {
|
|
@@ -40,6 +41,7 @@ export interface UpgradeRecipe {
|
|
|
40
41
|
const RECIPES: Record<string, UpgradeRecipe> = {
|
|
41
42
|
[v1_8_0.version]: v1_8_0,
|
|
42
43
|
[v2_0_0.version]: v2_0_0,
|
|
44
|
+
[v3_0_0.version]: v3_0_0,
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export function getRecipe(version: string): UpgradeRecipe | undefined {
|
|
@@ -103,6 +103,14 @@ const DEFAULT_GATES: readonly ReleaseGateSpec[] = [
|
|
|
103
103
|
remediation:
|
|
104
104
|
"Run `bun run check:size` and either reduce the bundle or update the reviewed budget.",
|
|
105
105
|
},
|
|
106
|
+
// Appended, not inserted: RELEASE_GATES below composes this list BY INDEX
|
|
107
|
+
// (`slice(0, 3)`, `[3]`), so a gate added in the middle would silently re-point those.
|
|
108
|
+
{
|
|
109
|
+
id: "changesets",
|
|
110
|
+
commands: [["run", "check:changesets"]],
|
|
111
|
+
remediation:
|
|
112
|
+
"Run `bun run changeset` and name every package whose source changed, so the release documents it.",
|
|
113
|
+
},
|
|
106
114
|
]
|
|
107
115
|
|
|
108
116
|
const RELEASE_GATES: readonly ReleaseGateSpec[] = [
|
|
@@ -183,6 +191,15 @@ const RELEASE_GATES: readonly ReleaseGateSpec[] = [
|
|
|
183
191
|
remediation:
|
|
184
192
|
"Run `bun run check:pipeline-parity` and fix the development and production manifest drift.",
|
|
185
193
|
},
|
|
194
|
+
// Last in release mode on purpose: this is the gate on what the release SAYS, and it is the only
|
|
195
|
+
// one whose failure is invisible afterwards - a shipped version cannot grow the changelog line it
|
|
196
|
+
// never had.
|
|
197
|
+
{
|
|
198
|
+
id: "changesets",
|
|
199
|
+
commands: [["run", "check:changesets"]],
|
|
200
|
+
remediation:
|
|
201
|
+
"Run `bun run changeset` and name every package whose source changed, so the release documents it.",
|
|
202
|
+
},
|
|
186
203
|
]
|
|
187
204
|
|
|
188
205
|
const runBunCommand = async (spec: ReleaseCommandSpec): Promise<ReleaseCommandResult> => {
|
package/src/rules/routes.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RESERVED_KEY_READOUT, reservedKeyFor } from "@nifrajs/client"
|
|
1
2
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
2
3
|
import { commentBlockHasMarker } from "./comment-markers.ts"
|
|
3
4
|
import type { CheckRule, RuleContext } from "./index.ts"
|
|
@@ -7,27 +8,18 @@ import type { CheckRule, RuleContext } from "./index.ts"
|
|
|
7
8
|
* once before the registry runs - no rule re-walks source).
|
|
8
9
|
*
|
|
9
10
|
* NF-C018 exists because the typed client's proxy intercepts a fixed set of property names before
|
|
10
|
-
* path resolution (`resolveSegment` + the thenable guard in @nifrajs/client)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* path resolution (`resolveSegment` + the thenable guard in @nifrajs/client). A route whose path
|
|
12
|
+
* contains a static segment spelling one of these cannot be reached by DOT ACCESS -
|
|
13
|
+
* `api.delete.post` resolves the DELETE verb, not the path. The typed spelling is a call on the
|
|
14
|
+
* parent node (`api.api("delete").post()`, treaty.ts `SegmentCall`), and the client type rejects
|
|
15
|
+
* the dot access with the same guidance - so the collision is a warning that teaches the call
|
|
16
|
+
* spelling, not a blocking error: the route IS reachable, just not by the spelling its name
|
|
17
|
+
* suggests.
|
|
18
|
+
*
|
|
19
|
+
* The set itself is NOT copied here. It comes from `@nifrajs/client`'s `reserved.ts`, which is the
|
|
20
|
+
* one place it is written down and the one place the freeze policy lives - a lint that carried its
|
|
21
|
+
* own copy is a lint that can disagree with the compiler it is explaining.
|
|
17
22
|
*/
|
|
18
|
-
const RESERVED_VERB_SEGMENTS: ReadonlySet<string> = new Set([
|
|
19
|
-
"get",
|
|
20
|
-
"post",
|
|
21
|
-
"put",
|
|
22
|
-
"patch",
|
|
23
|
-
"delete",
|
|
24
|
-
"head",
|
|
25
|
-
"options",
|
|
26
|
-
])
|
|
27
|
-
// Intercepted by exact match only (`key === "subscribe"` etc.), unlike the lowercased verbs.
|
|
28
|
-
const RESERVED_EXACT_SEGMENTS: ReadonlySet<string> = new Set(["subscribe", "ws", "index", "then"])
|
|
29
|
-
const RESERVED_READOUT =
|
|
30
|
-
"get/post/put/patch/delete/head/options (any casing), subscribe, ws, index, then"
|
|
31
23
|
|
|
32
24
|
/** Opt-out pragma for a route deliberately served only to NON-typed-client consumers. */
|
|
33
25
|
const RESERVED_SEGMENT_PRAGMA = "nifra-expect reserved-segment"
|
|
@@ -69,14 +61,6 @@ function routeEscapeHint(path: string, colliding: string): string {
|
|
|
69
61
|
return `${["api", ...before].join(".")}("${colliding}")`
|
|
70
62
|
}
|
|
71
63
|
|
|
72
|
-
/** The reserved key a static path segment collides with, or undefined. Params/wildcards never collide. */
|
|
73
|
-
function reservedCollision(segment: string): string | undefined {
|
|
74
|
-
if (segment.startsWith(":") || segment.startsWith("*")) return undefined
|
|
75
|
-
if (RESERVED_EXACT_SEGMENTS.has(segment)) return segment
|
|
76
|
-
const lower = segment.toLowerCase()
|
|
77
|
-
return RESERVED_VERB_SEGMENTS.has(lower) ? lower : undefined
|
|
78
|
-
}
|
|
79
|
-
|
|
80
64
|
export const reservedSegmentRule: CheckRule = {
|
|
81
65
|
code: "NF-C018",
|
|
82
66
|
title: "Route segment collides with a reserved client proxy key",
|
|
@@ -86,7 +70,7 @@ export const reservedSegmentRule: CheckRule = {
|
|
|
86
70
|
for (const route of routeFacts(ctx)) {
|
|
87
71
|
for (const segment of route.path.split("/")) {
|
|
88
72
|
if (segment === "") continue
|
|
89
|
-
const collision =
|
|
73
|
+
const collision = reservedKeyFor(segment)
|
|
90
74
|
if (collision === undefined) continue
|
|
91
75
|
let lines = linesByFile.get(route.file)
|
|
92
76
|
if (lines === undefined) {
|
|
@@ -100,8 +84,11 @@ export const reservedSegmentRule: CheckRule = {
|
|
|
100
84
|
severity: "warn",
|
|
101
85
|
file: route.file,
|
|
102
86
|
line: route.line,
|
|
103
|
-
message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${
|
|
87
|
+
message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${RESERVED_KEY_READOUT}); dot access cannot reach it - call the parent node with the segment instead (\`${routeEscapeHint(route.path, segment)}\`), rename the segment, or mark an intentionally non-typed-client route with \`// ${RESERVED_SEGMENT_PRAGMA}\` above the registration. \`nifra fix --code NF-C018\` rewrites the broken call sites for you`,
|
|
104
88
|
evidence: [`${route.method} ${route.path}`, `segment: ${segment}`],
|
|
89
|
+
// The route is fine; what needs editing is every typed-client call site the collision
|
|
90
|
+
// broke, which the recipe finds from the compiler rather than from this one finding.
|
|
91
|
+
fix: { recipe: "client.reserved-segment", command: "nifra fix --code NF-C018" },
|
|
105
92
|
verify: "nifra check --lints-only",
|
|
106
93
|
}),
|
|
107
94
|
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a WORKSPACE-LINKED dependency back to the directory it really lives in.
|
|
3
|
+
*
|
|
4
|
+
* A workspace link is the one install whose realpath deliberately leaves the project: `link:` and
|
|
5
|
+
* `workspaces` entries point at a source checkout, routinely in a sibling repository. That is exactly
|
|
6
|
+
* what makes such a package able to go stale (its `dist/` is nobody's build step) and exactly what a
|
|
7
|
+
* project-contained path check rejects - so the rebuild has to resolve the escape on purpose, under
|
|
8
|
+
* its own rules, rather than reuse `resolveInsideProject`.
|
|
9
|
+
*
|
|
10
|
+
* The rules that keep it safe:
|
|
11
|
+
* - the path is never taken from a diagnostic; it is re-derived from the package NAME by walking the
|
|
12
|
+
* project's own `node_modules` chain, so only a symlink the package manager already created can be
|
|
13
|
+
* reached, and only a package the project actually declares;
|
|
14
|
+
* - a copy that resolves INSIDE `node_modules` is a registry tarball - immutable, never stale, and
|
|
15
|
+
* never rebuilt;
|
|
16
|
+
* - the package must declare the build script itself. nifra runs the project's own script, never a
|
|
17
|
+
* command of its own composition, and never through a shell.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { sep } from "node:path"
|
|
21
|
+
import { resolvedInstalledCopy } from "@nifrajs/web/internal/parity"
|
|
22
|
+
|
|
23
|
+
/** The one script name a rebuild will run. Guessing among `prepare`/`compile`/`build:*` would risk
|
|
24
|
+
* running something that is not the build at all; a package that names it differently is reported as
|
|
25
|
+
* un-automatable instead. */
|
|
26
|
+
const BUILD_SCRIPT = "build"
|
|
27
|
+
|
|
28
|
+
/** A package name npm would accept, with no path segment that could climb out of `node_modules`. */
|
|
29
|
+
const PACKAGE_NAME = /^@?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?$/
|
|
30
|
+
|
|
31
|
+
export function isPackageName(value: string): boolean {
|
|
32
|
+
return PACKAGE_NAME.test(value) && !value.split("/").some((part) => part === "." || part === "..")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The package's own build script name, or `undefined` when it declares none. */
|
|
36
|
+
export function buildScriptName(meta: Record<string, unknown> | undefined): string | undefined {
|
|
37
|
+
const scripts = meta?.scripts
|
|
38
|
+
if (typeof scripts !== "object" || scripts === null) return undefined
|
|
39
|
+
const script = (scripts as Record<string, unknown>)[BUILD_SCRIPT]
|
|
40
|
+
return typeof script === "string" && script.trim().length > 0 ? BUILD_SCRIPT : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type WorkspaceLinkResolution =
|
|
44
|
+
| { readonly ok: true; readonly dir: string; readonly buildScript: string }
|
|
45
|
+
| { readonly ok: false; readonly reason: string }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve `name` to the workspace-linked checkout the project loads it from, refusing with a stated
|
|
49
|
+
* reason rather than resolving to nothing. The upward walk is unbounded on purpose: it has to reach the
|
|
50
|
+
* same hoisted copy the staleness scan found, which in a monorepo sits above the package being checked.
|
|
51
|
+
* It only ever inspects `node_modules/<name>` directories, so it cannot wander into an arbitrary path.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveWorkspaceLinkedPackage(
|
|
54
|
+
cwd: string,
|
|
55
|
+
name: string,
|
|
56
|
+
): Promise<WorkspaceLinkResolution> {
|
|
57
|
+
if (!isPackageName(name)) return { ok: false, reason: `not a package name: ${name}` }
|
|
58
|
+
// Boundary "" and the `node_modules` refusal below mirror `collectStaleWorkspaceDists` exactly, so
|
|
59
|
+
// the rebuild always lands on the same copy the staleness scan flagged.
|
|
60
|
+
const copy = await resolvedInstalledCopy(cwd, "", name)
|
|
61
|
+
if (copy === undefined) return { ok: false, reason: `${name} is not installed under ${cwd}` }
|
|
62
|
+
if (copy.path.includes(`${sep}node_modules${sep}`)) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: `${name} resolves to a registry install (${copy.path}), which cannot be stale or rebuilt`,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const meta = (await Bun.file(`${copy.path}${sep}package.json`)
|
|
69
|
+
.json()
|
|
70
|
+
.catch(() => undefined)) as Record<string, unknown> | undefined
|
|
71
|
+
if (meta === undefined) return { ok: false, reason: `${copy.path} has no readable package.json` }
|
|
72
|
+
const buildScript = buildScriptName(meta)
|
|
73
|
+
if (buildScript === undefined) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: `${name} declares no "${BUILD_SCRIPT}" script in ${copy.path}/package.json, so its artifact cannot be rebuilt automatically - build it the way that package expects`,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, dir: copy.path, buildScript }
|
|
80
|
+
}
|