@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.
Files changed (76) hide show
  1. package/dist/check.d.ts.map +1 -1
  2. package/dist/check.js +27 -7
  3. package/dist/check.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +40 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/command-catalog.d.ts +6 -0
  8. package/dist/command-catalog.d.ts.map +1 -1
  9. package/dist/command-catalog.js +20 -2
  10. package/dist/command-catalog.js.map +1 -1
  11. package/dist/doctor.d.ts +42 -0
  12. package/dist/doctor.d.ts.map +1 -1
  13. package/dist/doctor.js +64 -7
  14. package/dist/doctor.js.map +1 -1
  15. package/dist/env-file.d.ts +17 -0
  16. package/dist/env-file.d.ts.map +1 -0
  17. package/dist/env-file.js +113 -0
  18. package/dist/env-file.js.map +1 -0
  19. package/dist/fix-recipes.d.ts.map +1 -1
  20. package/dist/fix-recipes.js +91 -15
  21. package/dist/fix-recipes.js.map +1 -1
  22. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  23. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  24. package/dist/internal/reserved-segment-codemod.js +134 -0
  25. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  26. package/dist/internal/typescript-import.d.ts +1 -1
  27. package/dist/internal/typescript-import.d.ts.map +1 -1
  28. package/dist/internal/typescript-import.js +62 -11
  29. package/dist/internal/typescript-import.js.map +1 -1
  30. package/dist/introspect.d.ts +26 -1
  31. package/dist/introspect.d.ts.map +1 -1
  32. package/dist/introspect.js +74 -24
  33. package/dist/introspect.js.map +1 -1
  34. package/dist/mcp-http.js +1 -1
  35. package/dist/mcp-http.js.map +1 -1
  36. package/dist/mcp.d.ts +8 -0
  37. package/dist/mcp.d.ts.map +1 -1
  38. package/dist/mcp.js +68 -6
  39. package/dist/mcp.js.map +1 -1
  40. package/dist/recipes/3.0.0.d.ts +10 -0
  41. package/dist/recipes/3.0.0.d.ts.map +1 -0
  42. package/dist/recipes/3.0.0.js +23 -0
  43. package/dist/recipes/3.0.0.js.map +1 -0
  44. package/dist/recipes/index.d.ts.map +1 -1
  45. package/dist/recipes/index.js +2 -0
  46. package/dist/recipes/index.js.map +1 -1
  47. package/dist/release-verification.d.ts.map +1 -1
  48. package/dist/release-verification.js +15 -0
  49. package/dist/release-verification.js.map +1 -1
  50. package/dist/rules/routes.d.ts.map +1 -1
  51. package/dist/rules/routes.js +17 -30
  52. package/dist/rules/routes.js.map +1 -1
  53. package/dist/workspace-link.d.ts +37 -0
  54. package/dist/workspace-link.d.ts.map +1 -0
  55. package/dist/workspace-link.js +72 -0
  56. package/dist/workspace-link.js.map +1 -0
  57. package/docs/examples.json +1 -1
  58. package/docs/llms-full.txt +362 -20
  59. package/docs/types.json +933 -46
  60. package/package.json +9 -9
  61. package/src/check.ts +28 -7
  62. package/src/cli.ts +41 -2
  63. package/src/command-catalog.ts +22 -2
  64. package/src/doctor.ts +99 -8
  65. package/src/env-file.ts +121 -0
  66. package/src/fix-recipes.ts +101 -15
  67. package/src/internal/reserved-segment-codemod.ts +150 -0
  68. package/src/internal/typescript-import.ts +61 -11
  69. package/src/introspect.ts +82 -21
  70. package/src/mcp-http.ts +1 -1
  71. package/src/mcp.ts +81 -6
  72. package/src/recipes/3.0.0.ts +24 -0
  73. package/src/recipes/index.ts +2 -0
  74. package/src/release-verification.ts +17 -0
  75. package/src/rules/routes.ts +17 -30
  76. 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
- const abort = (): void => proc.kill()
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
- proc.kill()
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
- new Response(proc.stdout).text(),
200
- new Response(proc.stderr).text(),
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
+ }
@@ -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> => {
@@ -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): the seven HTTP verbs
11
- * (case-insensitively), `subscribe`, `ws`, `index`, and `then`. A route whose path contains a
12
- * static segment spelling one of these cannot be reached by DOT ACCESS - `api.delete.post` resolves
13
- * the DELETE verb, not the path. The typed spelling is a call on the parent node
14
- * (`api.api("delete").post()`, treaty.ts `CollisionEscape`), and the client type rejects the dot
15
- * access with the same guidance - so the collision is a warning that teaches the escape spelling,
16
- * not a blocking error: the route IS reachable, just not by the spelling its name suggests.
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 = reservedCollision(segment)
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: ${RESERVED_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`,
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
+ }