@kici-dev/agent 0.1.9 → 0.1.11

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/index.js CHANGED
@@ -2,11 +2,18 @@ import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
2
2
  import { dirname as __cjs_dirname } from "node:path";
3
3
  __cjs_dirname(__cjs_fileURLToPath(import.meta.url));
4
4
  import "node:module";
5
- import { hostname } from "node:os";
5
+ import { hostname, tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { z } from "zod";
8
8
  import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
9
9
  import { KNOWN_ROLES, validateNoReservedLabels } from "@kici-dev/engine";
10
+ import { execFile } from "node:child_process";
11
+ import { access, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
12
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
13
+ import { promisify } from "node:util";
14
+ import { createLogger, toErrorMessage } from "@kici-dev/shared";
15
+ import { PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
16
+ import { existsSync } from "node:fs";
10
17
  import.meta.url;
11
18
  //#endregion
12
19
  //#region src/config.ts
@@ -123,6 +130,519 @@ function loadConfig() {
123
130
  };
124
131
  }
125
132
  //#endregion
126
- export { loadConfig };
133
+ //#region src/execution/npm-resolver.ts
134
+ /**
135
+ * Resolve the npm CLI path relative to the running Node.js binary.
136
+ *
137
+ * Used both at startup (builder role readiness check) and at install time
138
+ * (dep-installer). Centralizes the resolution logic so it stays consistent.
139
+ *
140
+ * Resolution strategy:
141
+ * 1. Check standard Node.js layout paths relative to process.execPath
142
+ * 2. Fall back to bare 'npm' on PATH (development environments)
143
+ */
144
+ /**
145
+ * Resolve the npm CLI path from the current Node.js binary.
146
+ *
147
+ * Checks standard Node.js distribution layout paths:
148
+ * - {nodeDir}/../lib/node_modules/npm/bin/npm-cli.js (Linux/macOS installed)
149
+ * - {nodeDir}/node_modules/npm/bin/npm-cli.js (Windows / some layouts)
150
+ *
151
+ * Returns undefined npmCliPath if neither is found (caller can fall back to PATH).
152
+ */
153
+ function resolveNpm() {
154
+ const nodeExe = process.execPath;
155
+ const nodeDir = dirname(nodeExe);
156
+ return {
157
+ npmCliPath: [join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js")].find((p) => existsSync(p)),
158
+ nodeExe,
159
+ nodeDir
160
+ };
161
+ }
162
+ //#endregion
163
+ //#region src/execution/npm-registry-config.ts
164
+ /**
165
+ * Apply private-npm-registry auth to a workflow's `.kici/.npmrc` for the
166
+ * lifetime of one `npm install` invocation, then restore the file on cleanup.
167
+ *
168
+ * Why a closure-cleanup pattern (mirrors `setupSshAuth.cleanup()` in
169
+ * `packages/agent/src/checkout/git-clone.ts`): the customer's committed
170
+ * `.kici/.npmrc` may carry literal `${VAR}` placeholders or an unrelated
171
+ * scope mapping. We must NOT clobber it permanently — we just want to
172
+ * append the agent-managed registry/auth lines for this single install,
173
+ * then revert.
174
+ *
175
+ * Token bytes never end up in the on-disk `.npmrc`. Each registry's token
176
+ * is exposed as a job-scoped env var (`KICI_NPM_TOKEN_${jobIdShort}_<i>`)
177
+ * and the on-disk auth line carries the env var reference (`${VAR}`). npm
178
+ * substitutes at read time. The job-scoped nonce makes the env var name
179
+ * unguessable from outside the install subprocess.
180
+ *
181
+ * Merge order: customer-committed `.npmrc` lines come FIRST, agent-generated
182
+ * lines come LAST. npm's last-wins semantics make the agent's line shadow
183
+ * any literal `_authToken=...` the customer accidentally committed for a
184
+ * registry KiCI manages — refuses to let a committed secret beat a managed
185
+ * one.
186
+ *
187
+ * `installEnvSecrets` is a separate channel for customers who prefer the
188
+ * "commit a `.kici/.npmrc` with `${MY_TOKEN}` and supply MY_TOKEN as a
189
+ * scoped secret" pattern (Option C in the design doc). Each entry becomes
190
+ * an env var on the install subprocess; the customer's existing `.npmrc`
191
+ * uses it as `${MY_TOKEN}`.
192
+ */
193
+ /** No-op result returned when nothing needs to be applied. */
194
+ function noopResult() {
195
+ return {
196
+ extraEnv: {},
197
+ tokensForRedaction: [],
198
+ cleanup: async () => {}
199
+ };
200
+ }
201
+ /** Build the synthesized env-var name for registry index `i`. */
202
+ function tokenEnvName(jobIdShort, index) {
203
+ return `KICI_NPM_TOKEN_${jobIdShort}_${index}`;
204
+ }
205
+ /** Render the agent-managed block of `.npmrc` lines. */
206
+ function renderAgentLines(registries, jobIdShort) {
207
+ if (registries.length === 0) return "";
208
+ const lines = [];
209
+ for (let i = 0; i < registries.length; i++) {
210
+ const reg = registries[i];
211
+ const envVar = tokenEnvName(jobIdShort, i);
212
+ const authKey = reg.url.replace(/^https?:/, "");
213
+ if (reg.scope) lines.push(`${reg.scope}:registry=${reg.url}`);
214
+ else lines.push(`registry=${reg.url}`);
215
+ lines.push(`${authKey}:_authToken=\${${envVar}}`);
216
+ if (reg.alwaysAuth) lines.push(`${authKey}:always-auth=true`);
217
+ }
218
+ return `# kici-managed: applied for one npm install only\n${lines.join("\n")}\n`;
219
+ }
220
+ /** Read original `.npmrc` bytes; null if the file does not exist. */
221
+ async function readOriginalNpmrc(npmrcPath) {
222
+ try {
223
+ return await readFile(npmrcPath, "utf8");
224
+ } catch (err) {
225
+ if (err.code === "ENOENT") return null;
226
+ throw err;
227
+ }
228
+ }
229
+ /**
230
+ * Apply the merged `.npmrc` and return env + redaction + cleanup. Caller
231
+ * runs the npm install with `extraEnv` merged in, then awaits cleanup()
232
+ * inside the install's `finally`.
233
+ */
234
+ async function applyNpmRegistryConfig(args) {
235
+ const registries = args.npmRegistries ?? [];
236
+ const installEnvSecrets = args.installEnvSecrets ?? {};
237
+ if (registries.length === 0 && Object.keys(installEnvSecrets).length === 0) return noopResult();
238
+ const npmrcPath = join(args.kiciDir, ".npmrc");
239
+ const original = await readOriginalNpmrc(npmrcPath);
240
+ const tokenEnv = {};
241
+ const tokensForRedaction = [];
242
+ for (let i = 0; i < registries.length; i++) {
243
+ tokenEnv[tokenEnvName(args.jobIdShort, i)] = registries[i].token;
244
+ tokensForRedaction.push(registries[i].token);
245
+ }
246
+ for (const value of Object.values(installEnvSecrets)) if (value) tokensForRedaction.push(value);
247
+ const agentBlock = renderAgentLines(registries, args.jobIdShort);
248
+ const merged = `${original ?? ""}${original && !original.endsWith("\n") ? "\n" : ""}${agentBlock}`;
249
+ if (agentBlock.length > 0) await writeFile(npmrcPath, merged, {
250
+ encoding: "utf8",
251
+ mode: 384
252
+ });
253
+ const cleanup = async () => {
254
+ if (agentBlock.length === 0) return;
255
+ try {
256
+ if (original === null) await unlink(npmrcPath).catch(() => {});
257
+ else await writeFile(npmrcPath, original, { encoding: "utf8" });
258
+ } catch {}
259
+ };
260
+ return {
261
+ extraEnv: {
262
+ ...installEnvSecrets,
263
+ ...tokenEnv
264
+ },
265
+ tokensForRedaction,
266
+ cleanup
267
+ };
268
+ }
269
+ /** Mask every token in `tokensForRedaction` out of `input` before logging. */
270
+ function redactNpmOutput(input, tokens) {
271
+ if (!input) return input;
272
+ let out = input;
273
+ for (const token of tokens) {
274
+ if (!token) continue;
275
+ out = out.split(token).join("***REDACTED***");
276
+ }
277
+ return out;
278
+ }
279
+ //#endregion
280
+ //#region src/execution/validate-kici-deps.ts
281
+ /**
282
+ * Pre-install validation for `.kici/` dependency specifiers.
283
+ *
284
+ * The agent clones a single source repository and installs its `.kici/`
285
+ * dependencies with the repo's package manager. Local-protocol specifiers —
286
+ * `workspace:`, `file:`, `link:`, `portal:` — resolve a dependency against
287
+ * another package on the same filesystem rather than a registry. Whether they
288
+ * are resolvable depends on the manager and the layout:
289
+ *
290
+ * - npm has no `workspace:` protocol and cannot resolve any of these from a
291
+ * registry, so they are rejected up front with an actionable message
292
+ * instead of the raw `EUNSUPPORTEDPROTOCOL` npm would emit.
293
+ * - pnpm resolves `workspace:` against the repo's pnpm workspace (the agent
294
+ * clones the whole repo, so an in-repo sibling is present), and resolves
295
+ * `file:`/`link:`/`portal:` against a path — allowed when that path stays
296
+ * inside the cloned repo, rejected when it escapes the clone.
297
+ *
298
+ * This module performs that classification so unresolvable specifiers fail
299
+ * fast with guidance rather than a cryptic install error.
300
+ */
301
+ /** Local-protocol specifier prefixes that resolve against the filesystem. */
302
+ let LocalDepProtocol = /* @__PURE__ */ function(LocalDepProtocol) {
303
+ LocalDepProtocol["Workspace"] = "workspace:";
304
+ LocalDepProtocol["File"] = "file:";
305
+ LocalDepProtocol["Link"] = "link:";
306
+ LocalDepProtocol["Portal"] = "portal:";
307
+ return LocalDepProtocol;
308
+ }({});
309
+ const LOCAL_PROTOCOLS = [
310
+ "workspace:",
311
+ "file:",
312
+ "link:",
313
+ "portal:"
314
+ ];
315
+ /** The dependency maps a package manager resolves in `.kici/package.json`. */
316
+ const DEP_FIELDS = [
317
+ "dependencies",
318
+ "devDependencies",
319
+ "optionalDependencies",
320
+ "peerDependencies"
321
+ ];
322
+ /**
323
+ * Scan a parsed `.kici/package.json` for dependency specifiers that use a
324
+ * local protocol (`workspace:`/`file:`/`link:`/`portal:`). Returns one entry
325
+ * per dependency, in field order. Returns an empty array when there are none.
326
+ */
327
+ function findLocalProtocolDeps(pkg) {
328
+ const found = [];
329
+ for (const field of DEP_FIELDS) {
330
+ const deps = pkg[field];
331
+ if (!deps || typeof deps !== "object") continue;
332
+ for (const [name, spec] of Object.entries(deps)) {
333
+ if (typeof spec !== "string") continue;
334
+ const protocol = LOCAL_PROTOCOLS.find((proto) => spec.startsWith(proto));
335
+ if (protocol) found.push({
336
+ name,
337
+ spec,
338
+ protocol
339
+ });
340
+ }
341
+ }
342
+ return found;
343
+ }
344
+ /** Parse `.kici/package.json`, returning `null` when it is missing or invalid. */
345
+ async function readKiciPackageJson(kiciDir) {
346
+ let raw;
347
+ try {
348
+ raw = await readFile(join(kiciDir, "package.json"), "utf-8");
349
+ } catch {
350
+ return null;
351
+ }
352
+ try {
353
+ return JSON.parse(raw);
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+ /**
359
+ * Whether `.kici/package.json` declares any local-protocol dependency. Used to
360
+ * decide whether the agent must build the in-repo workspace dependency closure
361
+ * after a pnpm install (so a `workspace:` sibling's build output exists before
362
+ * the workflow that imports it loads).
363
+ */
364
+ async function kiciHasLocalProtocolDeps(kiciDir) {
365
+ const pkg = await readKiciPackageJson(kiciDir);
366
+ if (!pkg) return false;
367
+ return findLocalProtocolDeps(pkg).length > 0;
368
+ }
369
+ async function fileExists(target) {
370
+ try {
371
+ await access(target);
372
+ return true;
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+ /** Resolve a `file:`/`link:`/`portal:` spec to an absolute path under kiciDir. */
378
+ function resolveLocalPath(kiciDir, dep) {
379
+ const rawPath = dep.spec.slice(dep.protocol.length);
380
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(kiciDir, rawPath);
381
+ }
382
+ /** Whether `target` is `repoRoot` itself or a path inside it. */
383
+ function isInsideRepo(repoRoot, target) {
384
+ const rel = relative(repoRoot, target);
385
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
386
+ }
387
+ /**
388
+ * Classify each local-protocol dependency for the detected package manager and
389
+ * return the ones that are unresolvable in the agent's single-clone model.
390
+ */
391
+ async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
392
+ if (packageManager === PackageManager.Npm) return [...deps];
393
+ const hasWorkspaceFile = await fileExists(join(repoRoot, "pnpm-workspace.yaml"));
394
+ const unresolvable = [];
395
+ for (const dep of deps) {
396
+ if (dep.protocol === "workspace:") {
397
+ if (!hasWorkspaceFile) unresolvable.push(dep);
398
+ continue;
399
+ }
400
+ if (!isInsideRepo(repoRoot, resolveLocalPath(kiciDir, dep))) unresolvable.push(dep);
401
+ }
402
+ return unresolvable;
403
+ }
404
+ /** Build the actionable error for unresolvable local-protocol dependencies. */
405
+ function formatUnresolvableDepError(offenders, packageManager) {
406
+ const list = offenders.map((o) => `${o.name}: ${o.spec}`).join(", ");
407
+ if (packageManager === PackageManager.Npm) return `These .kici/ dependencies use local-protocol specifiers npm cannot resolve from a registry: ${list}. npm has no workspace protocol — pin a published version, publish the package to your registry, or use pnpm so an in-repo workspace sibling can be resolved.`;
408
+ return `These .kici/ dependencies point outside the cloned repository, which the agent never has: ${list}. A workspace: dependency requires a pnpm-workspace.yaml at the repo root, and file:/link:/portal: paths must stay inside this repository.`;
409
+ }
410
+ /**
411
+ * Throw an actionable error when `.kici/package.json` declares a local-protocol
412
+ * dependency the detected package manager cannot resolve from the single cloned
413
+ * repository. A missing or unparseable package.json is left for the install to
414
+ * report.
415
+ */
416
+ async function assertResolvableDeps(args) {
417
+ const pkg = await readKiciPackageJson(args.kiciDir);
418
+ if (!pkg) return;
419
+ const localDeps = findLocalProtocolDeps(pkg);
420
+ if (localDeps.length === 0) return;
421
+ const offenders = await findUnresolvableDeps(localDeps, args.packageManager, args.kiciDir, args.repoRoot);
422
+ if (offenders.length === 0) return;
423
+ throw new Error(formatUnresolvableDepError(offenders, args.packageManager));
424
+ }
425
+ //#endregion
426
+ //#region src/execution/dep-installer.ts
427
+ /**
428
+ * Inline dependency installation for graceful degradation.
429
+ *
430
+ * When the dep cache is unavailable or a download fails, the agent installs
431
+ * `.kici/` dependencies directly with the repository's package manager.
432
+ *
433
+ * The package manager is detected from the cloned repo (npm / pnpm); the
434
+ * presence of `.kici/package.json` signals that deps should be installed. npm
435
+ * is the default and ships with every Node.js install; pnpm is used when the
436
+ * repo is a pnpm workspace so a `.kici/` member can resolve in-repo
437
+ * `workspace:` siblings. yarn is detected but not yet supported and is
438
+ * rejected with an actionable error.
439
+ *
440
+ * Security: the install runs with an isolated per-invocation cache/store
441
+ * directory to prevent cache poisoning across build jobs — a malicious
442
+ * package.json in one repo cannot taint the cache used by subsequent builds.
443
+ * The same pressure rules out letting lifecycle scripts see synthesized auth
444
+ * env vars — the install runs with `--ignore-scripts` whenever a private
445
+ * registry is configured.
446
+ */
447
+ const logger = createLogger({ prefix: "dep-installer" });
448
+ const execFileAsync = promisify(execFile);
449
+ /** Install subprocess timeout (10 min) and stdout/stderr buffer (128 MiB). */
450
+ const INSTALL_TIMEOUT_MS = 6e5;
451
+ const INSTALL_MAX_BUFFER = 128 * 1024 * 1024;
452
+ /**
453
+ * Detect the package manager for the cloned repo from its committed manifests.
454
+ * A pnpm workspace's `packageManager` field + `pnpm-lock.yaml` live at the repo
455
+ * root, so check there first; fall back to `.kici/` for a standalone
456
+ * (non-workspace) project that carries its own lockfile; default to npm when
457
+ * neither carries a signal. Uses the manifests-only detector so the agent's own
458
+ * launch env (`npm_config_user_agent`) never leaks into the decision.
459
+ */
460
+ async function detectKiciPackageManager(repoRoot, kiciDir) {
461
+ return await detectPackageManagerFromManifests(repoRoot) ?? await detectPackageManagerFromManifests(kiciDir) ?? PackageManager.Npm;
462
+ }
463
+ /**
464
+ * Install `.kici/` dependencies inline with the repo's package manager.
465
+ *
466
+ * Falls back to this when the dep cache is unavailable or a download fails.
467
+ * The install runs with an isolated cache/store directory (created in
468
+ * `os.tmpdir()`) to prevent cache poisoning between build jobs; the directory
469
+ * is removed after installation.
470
+ *
471
+ * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
472
+ * `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
473
+ * and the install runs with `--ignore-scripts` so lifecycle scripts in a
474
+ * committed `package.json` cannot exfiltrate the synthesized token env vars.
475
+ *
476
+ * @param kiciDir - Path to the `.kici/` directory containing package.json.
477
+ * @param opts - Optional registry / installEnv / repoRoot configuration.
478
+ */
479
+ async function installDeps(kiciDir, opts = {}) {
480
+ const repoRoot = opts.repoRoot ?? dirname(kiciDir);
481
+ const packageManager = await detectKiciPackageManager(repoRoot, kiciDir);
482
+ logger.info("Installing deps inline", {
483
+ packageManager,
484
+ dir: kiciDir
485
+ });
486
+ process.stderr.write(`[dep-installer:trace] starting install: pm=${packageManager}, cwd=${kiciDir}\n`);
487
+ if (packageManager === PackageManager.Yarn) throw new Error("This repository uses yarn, which the KiCI agent does not yet support for .kici/ dependency installation. Use npm or pnpm for the .kici/ project, or open a feature request for yarn support.");
488
+ await assertResolvableDeps({
489
+ kiciDir,
490
+ repoRoot,
491
+ packageManager
492
+ });
493
+ const startTime = Date.now();
494
+ const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
495
+ const registryConfig = await applyNpmRegistryConfig({
496
+ kiciDir,
497
+ npmRegistries: opts.npmRegistries,
498
+ installEnvSecrets: opts.installEnvSecrets,
499
+ jobIdShort: opts.jobIdShort ?? "00000000"
500
+ });
501
+ try {
502
+ if (packageManager === PackageManager.Pnpm) await runPnpmInstall({
503
+ kiciDir,
504
+ hasPrivateRegistry,
505
+ registryConfig
506
+ });
507
+ else await runNpmInstall({
508
+ kiciDir,
509
+ hasPrivateRegistry,
510
+ registryConfig
511
+ });
512
+ } catch (e) {
513
+ const tokens = registryConfig.tokensForRedaction;
514
+ process.stderr.write(`[dep-installer:trace] INSTALL FAILED: ${redactNpmOutput(toErrorMessage(e), tokens)}\n`);
515
+ logSubprocessStreams(e, tokens);
516
+ throw e;
517
+ } finally {
518
+ await registryConfig.cleanup();
519
+ }
520
+ if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
521
+ const durationMs = Date.now() - startTime;
522
+ process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
523
+ logger.info("Deps installed inline", {
524
+ packageManager,
525
+ durationMs
526
+ });
527
+ }
528
+ /** Build the Node binary directory onto PATH so spawned tools find `node`. */
529
+ function envWithNodeOnPath(extraEnv, nodeDir) {
530
+ const { NODE_ENV: _NODE_ENV, ...restEnv } = process.env;
531
+ return {
532
+ ...restEnv,
533
+ ...extraEnv,
534
+ PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
535
+ };
536
+ }
537
+ /** Run `npm install` in `.kici/` with an isolated cache directory. */
538
+ async function runNpmInstall(args) {
539
+ const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
540
+ const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
541
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
542
+ const buildArgs = (...prefix) => {
543
+ const a = [
544
+ ...prefix,
545
+ "install",
546
+ "--cache",
547
+ cacheDir,
548
+ "--no-audit",
549
+ "--no-fund"
550
+ ];
551
+ if (args.hasPrivateRegistry) a.push("--ignore-scripts");
552
+ return a;
553
+ };
554
+ try {
555
+ const bin = npmCliPath ? nodeExe : "npm";
556
+ const argv = npmCliPath ? buildArgs(npmCliPath) : buildArgs();
557
+ process.stderr.write(`[dep-installer:trace] running: ${bin} ${argv.join(" ")}\n`);
558
+ await execFileAsync(bin, argv, {
559
+ cwd: args.kiciDir,
560
+ env,
561
+ timeout: INSTALL_TIMEOUT_MS,
562
+ maxBuffer: INSTALL_MAX_BUFFER
563
+ });
564
+ } finally {
565
+ await rm(cacheDir, {
566
+ recursive: true,
567
+ force: true
568
+ }).catch(() => {});
569
+ }
570
+ }
571
+ /**
572
+ * Run `pnpm install` from `.kici/`. pnpm walks up to the workspace root, so a
573
+ * `workspace:` sibling in the same cloned repo resolves. Uses an isolated store
574
+ * (`--config.store-dir`) for cross-job isolation, `package-import-method=copy`
575
+ * so the on-disk store is a self-contained tree of real files (a later dep
576
+ * cache tars it), and disables interactive purge prompts + the side-effects
577
+ * cache for deterministic, non-interactive runs.
578
+ */
579
+ async function runPnpmInstall(args) {
580
+ await assertPnpmAvailable();
581
+ const { nodeDir } = resolveNpm();
582
+ const storeDir = await mkdtemp(join(tmpdir(), "kici-pnpm-store-"));
583
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
584
+ const argv = [
585
+ "install",
586
+ `--config.store-dir=${storeDir}`,
587
+ "--config.package-import-method=copy",
588
+ "--config.confirm-modules-purge=false",
589
+ "--config.side-effects-cache=false"
590
+ ];
591
+ if (args.hasPrivateRegistry) argv.push("--ignore-scripts");
592
+ try {
593
+ process.stderr.write(`[dep-installer:trace] running: pnpm ${argv.join(" ")}\n`);
594
+ await execFileAsync("pnpm", argv, {
595
+ cwd: args.kiciDir,
596
+ env,
597
+ timeout: INSTALL_TIMEOUT_MS,
598
+ maxBuffer: INSTALL_MAX_BUFFER
599
+ });
600
+ } finally {
601
+ await rm(storeDir, {
602
+ recursive: true,
603
+ force: true
604
+ }).catch(() => {});
605
+ }
606
+ }
607
+ /**
608
+ * Build the in-repo dependency closure of the `.kici/` package so a
609
+ * `workspace:` sibling's build output exists before the workflow that imports
610
+ * it loads. `--filter "{.kici}^..."` selects only `.kici`'s dependencies (not
611
+ * `.kici` itself); `--if-present` skips siblings without a build script. Runs
612
+ * with a clean env (no synthesized registry tokens).
613
+ */
614
+ async function buildWorkspaceClosure(repoRoot) {
615
+ const { nodeDir } = resolveNpm();
616
+ const env = envWithNodeOnPath({}, nodeDir);
617
+ const argv = [
618
+ "--filter",
619
+ "{.kici}^...",
620
+ "run",
621
+ "build",
622
+ "--if-present"
623
+ ];
624
+ process.stderr.write(`[dep-installer:trace] building workspace closure: pnpm ${argv.join(" ")}\n`);
625
+ await execFileAsync("pnpm", argv, {
626
+ cwd: repoRoot,
627
+ env,
628
+ timeout: INSTALL_TIMEOUT_MS,
629
+ maxBuffer: INSTALL_MAX_BUFFER
630
+ });
631
+ }
632
+ /** Throw an actionable error when the repo needs pnpm but it is not installed. */
633
+ async function assertPnpmAvailable() {
634
+ try {
635
+ await execFileAsync("pnpm", ["--version"], { timeout: 3e4 });
636
+ } catch (e) {
637
+ throw new Error(`This repository is a pnpm workspace, but pnpm is not available on this agent. Install pnpm (e.g. \`corepack enable\`) or run on a container/ Firecracker agent that bundles it. (${toErrorMessage(e)})`);
638
+ }
639
+ }
640
+ /** Trace the redacted stdout/stderr of a failed install subprocess. */
641
+ function logSubprocessStreams(e, tokens) {
642
+ if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
643
+ if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
644
+ }
645
+ //#endregion
646
+ export { LocalDepProtocol, assertResolvableDeps, findLocalProtocolDeps, formatUnresolvableDepError, installDeps, kiciHasLocalProtocolDeps, loadConfig };
127
647
 
128
648
  //# sourceMappingURL=index.js.map