@kici-dev/agent 0.1.10 → 0.1.12
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/execution/dep-installer.d.ts +30 -22
- package/dist/execution/dep-packer.d.ts +20 -13
- package/dist/execution/dep-restore.d.ts +9 -5
- package/dist/execution/validate-kici-deps.d.ts +65 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +537 -2
- package/dist/server.js +526 -138
- package/dist/workflow-runner.js +378 -103
- package/package.json +4 -4
- package/sbom.spdx.json +48 -48
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,534 @@ function loadConfig() {
|
|
|
123
130
|
};
|
|
124
131
|
}
|
|
125
132
|
//#endregion
|
|
126
|
-
|
|
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); `run --if-present build` skips siblings without a build
|
|
612
|
+
* script. The `--if-present` flag MUST precede the script name: pnpm forwards
|
|
613
|
+
* everything after the script name to the script itself, so `run build
|
|
614
|
+
* --if-present` would pass `--if-present` to the build command (e.g. `tsc`)
|
|
615
|
+
* and fail. Runs with a clean env (no synthesized registry tokens). On failure
|
|
616
|
+
* the subprocess stderr/stdout is folded into the thrown error so the job's
|
|
617
|
+
* failure message names the real cause instead of a bare "Command failed".
|
|
618
|
+
*/
|
|
619
|
+
async function buildWorkspaceClosure(repoRoot) {
|
|
620
|
+
const { nodeDir } = resolveNpm();
|
|
621
|
+
const env = envWithNodeOnPath({}, nodeDir);
|
|
622
|
+
const argv = [
|
|
623
|
+
"--filter",
|
|
624
|
+
"{.kici}^...",
|
|
625
|
+
"run",
|
|
626
|
+
"--if-present",
|
|
627
|
+
"build"
|
|
628
|
+
];
|
|
629
|
+
process.stderr.write(`[dep-installer:trace] building workspace closure: pnpm ${argv.join(" ")}\n`);
|
|
630
|
+
try {
|
|
631
|
+
await execFileAsync("pnpm", argv, {
|
|
632
|
+
cwd: repoRoot,
|
|
633
|
+
env,
|
|
634
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
635
|
+
maxBuffer: INSTALL_MAX_BUFFER
|
|
636
|
+
});
|
|
637
|
+
} catch (e) {
|
|
638
|
+
logSubprocessStreams(e, []);
|
|
639
|
+
throw new Error(`Failed to build .kici workspace dependency closure: ${describeExecError(e)}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/** Build a single-line cause from an exec error's stderr/stdout, falling back to its message. */
|
|
643
|
+
function describeExecError(e) {
|
|
644
|
+
const pick = (k) => e && typeof e === "object" && k in e ? String(e[k]).trim() : "";
|
|
645
|
+
return (pick("stderr") || pick("stdout") || toErrorMessage(e)).slice(0, 2e3);
|
|
646
|
+
}
|
|
647
|
+
/** Throw an actionable error when the repo needs pnpm but it is not installed. */
|
|
648
|
+
async function assertPnpmAvailable() {
|
|
649
|
+
try {
|
|
650
|
+
await execFileAsync("pnpm", ["--version"], { timeout: 3e4 });
|
|
651
|
+
} catch (e) {
|
|
652
|
+
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)})`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/** Trace the redacted stdout/stderr of a failed install subprocess. */
|
|
656
|
+
function logSubprocessStreams(e, tokens) {
|
|
657
|
+
if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
|
|
658
|
+
if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
|
|
659
|
+
}
|
|
660
|
+
//#endregion
|
|
661
|
+
export { LocalDepProtocol, assertResolvableDeps, findLocalProtocolDeps, formatUnresolvableDepError, installDeps, kiciHasLocalProtocolDeps, loadConfig };
|
|
127
662
|
|
|
128
663
|
//# sourceMappingURL=index.js.map
|