@kici-dev/agent 0.1.10 → 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.
@@ -5,19 +5,21 @@ import { register } from "node:module";
5
5
  import { createInterface } from "node:readline";
6
6
  import crypto, { createHash, randomUUID } from "node:crypto";
7
7
  import { existsSync } from "node:fs";
8
- import fsPromises, { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
8
+ import fsPromises, { access, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
9
9
  import os, { tmpdir } from "node:os";
10
- import path, { dirname, join } from "node:path";
10
+ import path, { dirname, isAbsolute, join, relative, resolve } from "node:path";
11
11
  import { $ } from "zx";
12
12
  import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
13
13
  import { ExecutionJobStatus, ExecutionStepStatus } from "@kici-dev/engine";
14
14
  import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
15
- import "node:child_process";
15
+ import { execFile } from "node:child_process";
16
16
  import { Readable, Transform } from "node:stream";
17
17
  import { pipeline } from "node:stream/promises";
18
18
  import { createGunzip } from "node:zlib";
19
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
20
  import { x } from "tar";
21
+ import { promisify } from "node:util";
22
+ import { PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
21
23
  import https from "node:https";
22
24
  import http from "node:http";
23
25
  import.meta.url;
@@ -868,8 +870,8 @@ const SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
868
870
  * We sidestep the race entirely by extracting each attempt into a unique
869
871
  * scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
870
872
  * draining writes are harmless — the next attempt does not touch them. On
871
- * success we `fs.rename` `${scratchDir}/node_modules` into the final location
872
- * (atomic on the same filesystem), then best-effort clean the scratch dir.
873
+ * success `moveScratchIntoRepo` renames the extracted entries into place
874
+ * (atomic on the same filesystem), then best-effort cleans the scratch dir.
873
875
  *
874
876
  * Scratch dirs land inside the customer's cloned working tree, so the clone
875
877
  * phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
@@ -951,13 +953,58 @@ function resolveOrchestratorUrl(url) {
951
953
  }
952
954
  }
953
955
  /**
954
- * Restore dependencies from a cached tarball.
956
+ * Move a fully-extracted scratch tree into the cloned repo. The dep tarball is
957
+ * packed repo-root-relative, so the scratch holds repo-root entries:
958
+ * `.kici/node_modules` for every manager, plus (for pnpm) the root
959
+ * `node_modules/.pnpm` store and in-repo workspace sibling dirs. `.kici/` itself
960
+ * already exists in the work tree (cloned or source-restored), so its children
961
+ * are moved individually; every other top-level entry is moved wholesale.
962
+ *
963
+ * On a cache-hit execution agent the destinations do not pre-exist (source
964
+ * restore excludes node_modules and never carries sibling dirs), so the renames
965
+ * have nothing to race; the defensive `rm` covers re-runs.
966
+ */
967
+ async function moveScratchIntoRepo(scratchDir, workDir) {
968
+ for (const child of await fsPromises.readdir(scratchDir)) if (child === ".kici") {
969
+ const kiciScratch = join(scratchDir, ".kici");
970
+ for (const sub of await fsPromises.readdir(kiciScratch)) await moveInto(join(kiciScratch, sub), join(workDir, ".kici", sub));
971
+ } else await moveInto(join(scratchDir, child), join(workDir, child));
972
+ }
973
+ /** Move `src` to `dest`, creating the parent and clearing any stale dest. */
974
+ async function moveInto(src, dest) {
975
+ await mkdir(dirname(dest), { recursive: true });
976
+ await fsPromises.rm(dest, {
977
+ recursive: true,
978
+ force: true
979
+ });
980
+ await fsPromises.rename(src, dest);
981
+ }
982
+ /** Best-effort cleanup of a settled scratch dir; logs and continues on failure. */
983
+ async function cleanupScratch(scratchDir) {
984
+ try {
985
+ await fsPromises.rm(scratchDir, {
986
+ recursive: true,
987
+ force: true
988
+ });
989
+ } catch (cleanupErr) {
990
+ logger$3.warn("Scratch dir cleanup failed (orphan left behind)", {
991
+ scratchDir,
992
+ error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
993
+ });
994
+ }
995
+ }
996
+ /**
997
+ * Restore dependencies from a cached tarball into the cloned repo.
955
998
  *
956
- * For HTTP/HTTPS URLs: uses a streaming pipeline (response -> hash -> gunzip -> tar)
957
- * with a 5-minute timeout and up to 2 retries. This avoids buffering entire tarballs
958
- * in memory, eliminating memory spikes proportional to tarball size.
999
+ * The tarball is packed repo-root-relative (see `dep-packer.ts`): every manager
1000
+ * carries `.kici/node_modules`; pnpm additionally carries the root
1001
+ * `node_modules/.pnpm` store and the in-repo workspace siblings `.kici` resolves.
1002
+ * Restore extracts into a scratch dir, then moves each entry into place — one
1003
+ * code path for all managers.
959
1004
  *
960
- * For file:// URLs: uses a buffer-based approach (local, no streaming benefit).
1005
+ * For HTTP/HTTPS URLs: a streaming pipeline (response -> hash -> gunzip -> tar)
1006
+ * with a 5-minute timeout and up to 2 retries avoids buffering whole tarballs.
1007
+ * For file:// URLs: a buffer-based approach (local, no streaming benefit).
961
1008
  *
962
1009
  * @param workDir - Root directory of the cloned repository
963
1010
  * @param depsUrl - URL to the dependency tarball (http://, https://, or file://)
@@ -974,11 +1021,14 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
974
1021
  const actualHash = computeHash(data);
975
1022
  if (actualHash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${actualHash}`);
976
1023
  }
977
- await extractTarball(data, kiciDir);
1024
+ const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-file-${Date.now()}`);
1025
+ await extractTarball(data, scratchDir);
1026
+ await moveScratchIntoRepo(scratchDir, workDir);
1027
+ await cleanupScratch(scratchDir);
978
1028
  const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
979
1029
  logger$3.info("Dependencies restored from cache (file)", {
980
1030
  sizeMB,
981
- targetDir: kiciDir
1031
+ targetDir: workDir
982
1032
  });
983
1033
  return;
984
1034
  }
@@ -992,19 +1042,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
992
1042
  try {
993
1043
  const { scratchDir, hash } = await extractIntoScratch(depsUrl, kiciDir, attempt);
994
1044
  if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
995
- await fsPromises.rename(join(scratchDir, "node_modules"), join(kiciDir, "node_modules"));
996
- try {
997
- await fsPromises.rm(scratchDir, {
998
- recursive: true,
999
- force: true
1000
- });
1001
- } catch (cleanupErr) {
1002
- logger$3.warn("Scratch dir cleanup failed (orphan left behind)", {
1003
- scratchDir,
1004
- error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
1005
- });
1006
- }
1007
- logger$3.info("Dependencies restored from cache (stream)", { targetDir: kiciDir });
1045
+ await moveScratchIntoRepo(scratchDir, workDir);
1046
+ await cleanupScratch(scratchDir);
1047
+ logger$3.info("Dependencies restored from cache (stream)", { targetDir: workDir });
1008
1048
  return;
1009
1049
  } catch (err) {
1010
1050
  lastError = err instanceof Error ? err : new Error(String(err));
@@ -1163,48 +1203,190 @@ function redactNpmOutput(input, tokens) {
1163
1203
  }
1164
1204
  return out;
1165
1205
  }
1206
+ const LOCAL_PROTOCOLS = [
1207
+ "workspace:",
1208
+ "file:",
1209
+ "link:",
1210
+ "portal:"
1211
+ ];
1212
+ /** The dependency maps a package manager resolves in `.kici/package.json`. */
1213
+ const DEP_FIELDS = [
1214
+ "dependencies",
1215
+ "devDependencies",
1216
+ "optionalDependencies",
1217
+ "peerDependencies"
1218
+ ];
1219
+ /**
1220
+ * Scan a parsed `.kici/package.json` for dependency specifiers that use a
1221
+ * local protocol (`workspace:`/`file:`/`link:`/`portal:`). Returns one entry
1222
+ * per dependency, in field order. Returns an empty array when there are none.
1223
+ */
1224
+ function findLocalProtocolDeps(pkg) {
1225
+ const found = [];
1226
+ for (const field of DEP_FIELDS) {
1227
+ const deps = pkg[field];
1228
+ if (!deps || typeof deps !== "object") continue;
1229
+ for (const [name, spec] of Object.entries(deps)) {
1230
+ if (typeof spec !== "string") continue;
1231
+ const protocol = LOCAL_PROTOCOLS.find((proto) => spec.startsWith(proto));
1232
+ if (protocol) found.push({
1233
+ name,
1234
+ spec,
1235
+ protocol
1236
+ });
1237
+ }
1238
+ }
1239
+ return found;
1240
+ }
1241
+ /** Parse `.kici/package.json`, returning `null` when it is missing or invalid. */
1242
+ async function readKiciPackageJson(kiciDir) {
1243
+ let raw;
1244
+ try {
1245
+ raw = await readFile(join(kiciDir, "package.json"), "utf-8");
1246
+ } catch {
1247
+ return null;
1248
+ }
1249
+ try {
1250
+ return JSON.parse(raw);
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ }
1255
+ /**
1256
+ * Whether `.kici/package.json` declares any local-protocol dependency. Used to
1257
+ * decide whether the agent must build the in-repo workspace dependency closure
1258
+ * after a pnpm install (so a `workspace:` sibling's build output exists before
1259
+ * the workflow that imports it loads).
1260
+ */
1261
+ async function kiciHasLocalProtocolDeps(kiciDir) {
1262
+ const pkg = await readKiciPackageJson(kiciDir);
1263
+ if (!pkg) return false;
1264
+ return findLocalProtocolDeps(pkg).length > 0;
1265
+ }
1266
+ async function fileExists$1(target) {
1267
+ try {
1268
+ await access(target);
1269
+ return true;
1270
+ } catch {
1271
+ return false;
1272
+ }
1273
+ }
1274
+ /** Resolve a `file:`/`link:`/`portal:` spec to an absolute path under kiciDir. */
1275
+ function resolveLocalPath(kiciDir, dep) {
1276
+ const rawPath = dep.spec.slice(dep.protocol.length);
1277
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(kiciDir, rawPath);
1278
+ }
1279
+ /** Whether `target` is `repoRoot` itself or a path inside it. */
1280
+ function isInsideRepo(repoRoot, target) {
1281
+ const rel = relative(repoRoot, target);
1282
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
1283
+ }
1284
+ /**
1285
+ * Classify each local-protocol dependency for the detected package manager and
1286
+ * return the ones that are unresolvable in the agent's single-clone model.
1287
+ */
1288
+ async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
1289
+ if (packageManager === PackageManager.Npm) return [...deps];
1290
+ const hasWorkspaceFile = await fileExists$1(join(repoRoot, "pnpm-workspace.yaml"));
1291
+ const unresolvable = [];
1292
+ for (const dep of deps) {
1293
+ if (dep.protocol === "workspace:") {
1294
+ if (!hasWorkspaceFile) unresolvable.push(dep);
1295
+ continue;
1296
+ }
1297
+ if (!isInsideRepo(repoRoot, resolveLocalPath(kiciDir, dep))) unresolvable.push(dep);
1298
+ }
1299
+ return unresolvable;
1300
+ }
1301
+ /** Build the actionable error for unresolvable local-protocol dependencies. */
1302
+ function formatUnresolvableDepError(offenders, packageManager) {
1303
+ const list = offenders.map((o) => `${o.name}: ${o.spec}`).join(", ");
1304
+ 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.`;
1305
+ 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.`;
1306
+ }
1307
+ /**
1308
+ * Throw an actionable error when `.kici/package.json` declares a local-protocol
1309
+ * dependency the detected package manager cannot resolve from the single cloned
1310
+ * repository. A missing or unparseable package.json is left for the install to
1311
+ * report.
1312
+ */
1313
+ async function assertResolvableDeps(args) {
1314
+ const pkg = await readKiciPackageJson(args.kiciDir);
1315
+ if (!pkg) return;
1316
+ const localDeps = findLocalProtocolDeps(pkg);
1317
+ if (localDeps.length === 0) return;
1318
+ const offenders = await findUnresolvableDeps(localDeps, args.packageManager, args.kiciDir, args.repoRoot);
1319
+ if (offenders.length === 0) return;
1320
+ throw new Error(formatUnresolvableDepError(offenders, args.packageManager));
1321
+ }
1166
1322
  //#endregion
1167
1323
  //#region src/execution/dep-installer.ts
1168
1324
  /**
1169
1325
  * Inline dependency installation for graceful degradation.
1170
1326
  *
1171
- * When dep cache is unavailable or download fails, the agent falls back
1172
- * to running npm install directly.
1327
+ * When the dep cache is unavailable or a download fails, the agent installs
1328
+ * `.kici/` dependencies directly with the repository's package manager.
1173
1329
  *
1174
- * Only npm is supported — it ships with every Node.js installation.
1175
- * .kici/package.json is required — its presence signals deps should be installed.
1330
+ * The package manager is detected from the cloned repo (npm / pnpm); the
1331
+ * presence of `.kici/package.json` signals that deps should be installed. npm
1332
+ * is the default and ships with every Node.js install; pnpm is used when the
1333
+ * repo is a pnpm workspace so a `.kici/` member can resolve in-repo
1334
+ * `workspace:` siblings. yarn is detected but not yet supported and is
1335
+ * rejected with an actionable error.
1176
1336
  *
1177
- * Security: npm runs with an isolated per-invocation cache directory to prevent
1178
- * cache poisoning across build jobs. A malicious package.json in one repo cannot
1179
- * taint the cache used by subsequent builds. The same pressure rules out letting
1180
- * lifecycle scripts see synthesized auth env vars — npm runs with
1181
- * `--ignore-scripts` whenever a private registry is configured.
1337
+ * Security: the install runs with an isolated per-invocation cache/store
1338
+ * directory to prevent cache poisoning across build jobs — a malicious
1339
+ * package.json in one repo cannot taint the cache used by subsequent builds.
1340
+ * The same pressure rules out letting lifecycle scripts see synthesized auth
1341
+ * env vars — the install runs with `--ignore-scripts` whenever a private
1342
+ * registry is configured.
1182
1343
  */
1183
1344
  const logger$2 = createLogger({ prefix: "dep-installer" });
1345
+ const execFileAsync = promisify(execFile);
1346
+ /** Install subprocess timeout (10 min) and stdout/stderr buffer (128 MiB). */
1347
+ const INSTALL_TIMEOUT_MS = 6e5;
1348
+ const INSTALL_MAX_BUFFER = 128 * 1024 * 1024;
1184
1349
  /**
1185
- * Install dependencies inline using npm.
1186
- *
1187
- * Falls back to this when the dep cache is unavailable or download fails.
1350
+ * Detect the package manager for the cloned repo from its committed manifests.
1351
+ * A pnpm workspace's `packageManager` field + `pnpm-lock.yaml` live at the repo
1352
+ * root, so check there first; fall back to `.kici/` for a standalone
1353
+ * (non-workspace) project that carries its own lockfile; default to npm when
1354
+ * neither carries a signal. Uses the manifests-only detector so the agent's own
1355
+ * launch env (`npm_config_user_agent`) never leaks into the decision.
1356
+ */
1357
+ async function detectKiciPackageManager(repoRoot, kiciDir) {
1358
+ return await detectPackageManagerFromManifests(repoRoot) ?? await detectPackageManagerFromManifests(kiciDir) ?? PackageManager.Npm;
1359
+ }
1360
+ /**
1361
+ * Install `.kici/` dependencies inline with the repo's package manager.
1188
1362
  *
1189
- * npm runs with an isolated cache directory (created in os.tmpdir()) to prevent
1190
- * cache poisoning between build jobs. The cache is removed after installation.
1363
+ * Falls back to this when the dep cache is unavailable or a download fails.
1364
+ * The install runs with an isolated cache/store directory (created in
1365
+ * `os.tmpdir()`) to prevent cache poisoning between build jobs; the directory
1366
+ * is removed after installation.
1191
1367
  *
1192
- * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, the helper
1193
- * synthesizes a job-scoped `.kici/.npmrc` overlay for the install, restores
1194
- * the original file in `finally`, and runs npm with `--ignore-scripts` so
1195
- * lifecycle scripts in committed `package.json` cannot exfiltrate the
1196
- * synthesized token env vars.
1368
+ * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
1369
+ * `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
1370
+ * and the install runs with `--ignore-scripts` so lifecycle scripts in a
1371
+ * committed `package.json` cannot exfiltrate the synthesized token env vars.
1197
1372
  *
1198
- * @param kiciDir - Path to the .kici/ directory containing package.json
1199
- * @param opts - Optional registry / installEnv configuration. When absent,
1200
- * behavior is identical to the pre-private-registry version.
1373
+ * @param kiciDir - Path to the `.kici/` directory containing package.json.
1374
+ * @param opts - Optional registry / installEnv / repoRoot configuration.
1201
1375
  */
1202
1376
  async function installDeps(kiciDir, opts = {}) {
1377
+ const repoRoot = opts.repoRoot ?? dirname(kiciDir);
1378
+ const packageManager = await detectKiciPackageManager(repoRoot, kiciDir);
1203
1379
  logger$2.info("Installing deps inline", {
1204
- packageManager: "npm",
1380
+ packageManager,
1205
1381
  dir: kiciDir
1206
1382
  });
1207
- process.stderr.write(`[dep-installer:trace] starting install: pm=npm, cwd=${kiciDir}\n`);
1383
+ process.stderr.write(`[dep-installer:trace] starting install: pm=${packageManager}, cwd=${kiciDir}\n`);
1384
+ 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.");
1385
+ await assertResolvableDeps({
1386
+ kiciDir,
1387
+ repoRoot,
1388
+ packageManager
1389
+ });
1208
1390
  const startTime = Date.now();
1209
1391
  const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
1210
1392
  const registryConfig = await applyNpmRegistryConfig({
@@ -1214,71 +1396,149 @@ async function installDeps(kiciDir, opts = {}) {
1214
1396
  jobIdShort: opts.jobIdShort ?? "00000000"
1215
1397
  });
1216
1398
  try {
1217
- const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
1218
- const npmCmd = "install";
1219
- const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
1220
- const { NODE_ENV: _, ...restEnv } = process.env;
1221
- const envWithNode = {
1222
- ...restEnv,
1223
- ...registryConfig.extraEnv,
1224
- PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
1225
- };
1226
- const { execFile } = await import("node:child_process");
1227
- const { promisify } = await import("node:util");
1228
- const execFileAsync = promisify(execFile);
1229
- const buildArgs = (...prefix) => {
1230
- const args = [
1231
- ...prefix,
1232
- npmCmd,
1233
- "--cache",
1234
- cacheDir,
1235
- "--no-audit",
1236
- "--no-fund"
1237
- ];
1238
- if (hasPrivateRegistry) args.push("--ignore-scripts");
1239
- return args;
1240
- };
1241
- try {
1242
- if (npmCliPath) {
1243
- process.stderr.write(`[dep-installer:trace] running: ${nodeExe} ${npmCliPath} ${buildArgs().join(" ")}\n`);
1244
- await execFileAsync(nodeExe, buildArgs(npmCliPath), {
1245
- cwd: kiciDir,
1246
- env: envWithNode,
1247
- timeout: 6e5,
1248
- maxBuffer: 128 * 1024 * 1024
1249
- });
1250
- } else {
1251
- process.stderr.write(`[dep-installer:trace] running: npm ${buildArgs().join(" ")}\n`);
1252
- await execFileAsync("npm", buildArgs(), {
1253
- cwd: kiciDir,
1254
- env: envWithNode,
1255
- timeout: 6e5,
1256
- maxBuffer: 128 * 1024 * 1024
1257
- });
1258
- }
1259
- } finally {
1260
- await rm(cacheDir, {
1261
- recursive: true,
1262
- force: true
1263
- }).catch(() => {});
1264
- }
1399
+ if (packageManager === PackageManager.Pnpm) await runPnpmInstall({
1400
+ kiciDir,
1401
+ hasPrivateRegistry,
1402
+ registryConfig
1403
+ });
1404
+ else await runNpmInstall({
1405
+ kiciDir,
1406
+ hasPrivateRegistry,
1407
+ registryConfig
1408
+ });
1265
1409
  } catch (e) {
1266
- const msg = toErrorMessage(e);
1267
1410
  const tokens = registryConfig.tokensForRedaction;
1268
- process.stderr.write(`[dep-installer:trace] INSTALL FAILED: ${redactNpmOutput(msg, tokens)}\n`);
1269
- if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
1270
- if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
1411
+ process.stderr.write(`[dep-installer:trace] INSTALL FAILED: ${redactNpmOutput(toErrorMessage(e), tokens)}\n`);
1412
+ logSubprocessStreams(e, tokens);
1271
1413
  throw e;
1272
1414
  } finally {
1273
1415
  await registryConfig.cleanup();
1274
1416
  }
1417
+ if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
1275
1418
  const durationMs = Date.now() - startTime;
1276
1419
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
1277
1420
  logger$2.info("Deps installed inline", {
1278
- packageManager: "npm",
1421
+ packageManager,
1279
1422
  durationMs
1280
1423
  });
1281
1424
  }
1425
+ /** Build the Node binary directory onto PATH so spawned tools find `node`. */
1426
+ function envWithNodeOnPath(extraEnv, nodeDir) {
1427
+ const { NODE_ENV: _NODE_ENV, ...restEnv } = process.env;
1428
+ return {
1429
+ ...restEnv,
1430
+ ...extraEnv,
1431
+ PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
1432
+ };
1433
+ }
1434
+ /** Run `npm install` in `.kici/` with an isolated cache directory. */
1435
+ async function runNpmInstall(args) {
1436
+ const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
1437
+ const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
1438
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
1439
+ const buildArgs = (...prefix) => {
1440
+ const a = [
1441
+ ...prefix,
1442
+ "install",
1443
+ "--cache",
1444
+ cacheDir,
1445
+ "--no-audit",
1446
+ "--no-fund"
1447
+ ];
1448
+ if (args.hasPrivateRegistry) a.push("--ignore-scripts");
1449
+ return a;
1450
+ };
1451
+ try {
1452
+ const bin = npmCliPath ? nodeExe : "npm";
1453
+ const argv = npmCliPath ? buildArgs(npmCliPath) : buildArgs();
1454
+ process.stderr.write(`[dep-installer:trace] running: ${bin} ${argv.join(" ")}\n`);
1455
+ await execFileAsync(bin, argv, {
1456
+ cwd: args.kiciDir,
1457
+ env,
1458
+ timeout: INSTALL_TIMEOUT_MS,
1459
+ maxBuffer: INSTALL_MAX_BUFFER
1460
+ });
1461
+ } finally {
1462
+ await rm(cacheDir, {
1463
+ recursive: true,
1464
+ force: true
1465
+ }).catch(() => {});
1466
+ }
1467
+ }
1468
+ /**
1469
+ * Run `pnpm install` from `.kici/`. pnpm walks up to the workspace root, so a
1470
+ * `workspace:` sibling in the same cloned repo resolves. Uses an isolated store
1471
+ * (`--config.store-dir`) for cross-job isolation, `package-import-method=copy`
1472
+ * so the on-disk store is a self-contained tree of real files (a later dep
1473
+ * cache tars it), and disables interactive purge prompts + the side-effects
1474
+ * cache for deterministic, non-interactive runs.
1475
+ */
1476
+ async function runPnpmInstall(args) {
1477
+ await assertPnpmAvailable();
1478
+ const { nodeDir } = resolveNpm();
1479
+ const storeDir = await mkdtemp(join(tmpdir(), "kici-pnpm-store-"));
1480
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
1481
+ const argv = [
1482
+ "install",
1483
+ `--config.store-dir=${storeDir}`,
1484
+ "--config.package-import-method=copy",
1485
+ "--config.confirm-modules-purge=false",
1486
+ "--config.side-effects-cache=false"
1487
+ ];
1488
+ if (args.hasPrivateRegistry) argv.push("--ignore-scripts");
1489
+ try {
1490
+ process.stderr.write(`[dep-installer:trace] running: pnpm ${argv.join(" ")}\n`);
1491
+ await execFileAsync("pnpm", argv, {
1492
+ cwd: args.kiciDir,
1493
+ env,
1494
+ timeout: INSTALL_TIMEOUT_MS,
1495
+ maxBuffer: INSTALL_MAX_BUFFER
1496
+ });
1497
+ } finally {
1498
+ await rm(storeDir, {
1499
+ recursive: true,
1500
+ force: true
1501
+ }).catch(() => {});
1502
+ }
1503
+ }
1504
+ /**
1505
+ * Build the in-repo dependency closure of the `.kici/` package so a
1506
+ * `workspace:` sibling's build output exists before the workflow that imports
1507
+ * it loads. `--filter "{.kici}^..."` selects only `.kici`'s dependencies (not
1508
+ * `.kici` itself); `--if-present` skips siblings without a build script. Runs
1509
+ * with a clean env (no synthesized registry tokens).
1510
+ */
1511
+ async function buildWorkspaceClosure(repoRoot) {
1512
+ const { nodeDir } = resolveNpm();
1513
+ const env = envWithNodeOnPath({}, nodeDir);
1514
+ const argv = [
1515
+ "--filter",
1516
+ "{.kici}^...",
1517
+ "run",
1518
+ "build",
1519
+ "--if-present"
1520
+ ];
1521
+ process.stderr.write(`[dep-installer:trace] building workspace closure: pnpm ${argv.join(" ")}\n`);
1522
+ await execFileAsync("pnpm", argv, {
1523
+ cwd: repoRoot,
1524
+ env,
1525
+ timeout: INSTALL_TIMEOUT_MS,
1526
+ maxBuffer: INSTALL_MAX_BUFFER
1527
+ });
1528
+ }
1529
+ /** Throw an actionable error when the repo needs pnpm but it is not installed. */
1530
+ async function assertPnpmAvailable() {
1531
+ try {
1532
+ await execFileAsync("pnpm", ["--version"], { timeout: 3e4 });
1533
+ } catch (e) {
1534
+ 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)})`);
1535
+ }
1536
+ }
1537
+ /** Trace the redacted stdout/stderr of a failed install subprocess. */
1538
+ function logSubprocessStreams(e, tokens) {
1539
+ if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
1540
+ if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
1541
+ }
1282
1542
  //#endregion
1283
1543
  //#region src/execution/workflow-loader.ts
1284
1544
  /**
@@ -1288,7 +1548,7 @@ async function installDeps(kiciDir, opts = {}) {
1288
1548
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
1289
1549
  * Node's normal ESM lookup against `.kici/node_modules/`.
1290
1550
  */
1291
- const AGENT_SDK_VERSION = "0.1.10";
1551
+ const AGENT_SDK_VERSION = "0.1.11";
1292
1552
  const AGENT_SDK_BUNDLE_HASH = "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
1293
1553
  /**
1294
1554
  * Register the shared oxc-transform ESM loader hook so subsequent dynamic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kici-dev/agent",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Customer-deployable agent for the KiCI CI/CD stack. Connects to an orchestrator, clones the workflow repo, executes steps, and streams logs back.",
5
5
  "keywords": [
6
6
  "kici",
@@ -57,9 +57,9 @@
57
57
  "ws": "^8.20.0",
58
58
  "zod": "^4.3.6",
59
59
  "zx": "^8.8.5",
60
- "@kici-dev/engine": "0.1.10",
61
- "@kici-dev/sdk": "0.1.10",
62
- "@kici-dev/shared": "0.1.10"
60
+ "@kici-dev/sdk": "0.1.11",
61
+ "@kici-dev/engine": "0.1.11",
62
+ "@kici-dev/shared": "0.1.11"
63
63
  },
64
64
  "kici": {
65
65
  "metrics": {