@kici-dev/agent 0.1.17 → 0.1.18

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.
@@ -8,10 +8,13 @@
8
8
  * presence of `.kici/package.json` signals that deps should be installed. npm
9
9
  * is the default and ships with every Node.js install; pnpm is used when the
10
10
  * repo is a pnpm workspace so a `.kici/` member can resolve in-repo
11
- * `workspace:` siblings. yarn classic (v1) is supported for registry
12
- * dependencies and version-range workspace siblings (which it links but does
13
- * not build, so the agent builds the in-repo closure after install). yarn
14
- * berry (v2+) is not yet supported.
11
+ * `workspace:` siblings. yarn is supported in both flavors: classic (v1) reads
12
+ * `.kici/.npmrc` for registry auth and links version-range workspace siblings;
13
+ * berry (v2+) reads a synthesized `.kici/.yarnrc.yml` for auth, runs with a
14
+ * forced `nodeLinker: node-modules` (so the resulting tree matches classic/npm
15
+ * and the runner's plain node resolution holds), and resolves
16
+ * `workspace:`/`portal:` siblings. Either flavor links the sibling but does not
17
+ * build it, so the agent builds the in-repo closure after install.
15
18
  *
16
19
  * Security: the install runs with an isolated per-invocation cache/store
17
20
  * directory to prevent cache poisoning across build jobs — a malicious
@@ -53,4 +56,6 @@ export interface InstallDepsOptions {
53
56
  export declare function installDeps(kiciDir: string, opts?: InstallDepsOptions): Promise<void>;
54
57
  /** Pure: argv for `yarn install` with an isolated cache folder. */
55
58
  export declare function buildYarnInstallArgs(cacheDir: string, hasPrivateRegistry: boolean): string[];
59
+ /** Pure: argv for a berry `yarn install`. Cache + linker live in .yarnrc.yml. */
60
+ export declare function buildYarnBerryInstallArgs(): string[];
56
61
  //# sourceMappingURL=dep-installer.d.ts.map
@@ -13,11 +13,14 @@
13
13
  * symlinks into the root store and into sibling dirs that live outside
14
14
  * `.kici/`, so packing `.kici/node_modules` alone would capture dangling
15
15
  * links — the store and siblings must travel together.
16
- * - yarn classic: the resolved node_modules root (standalone `.kici`
17
- * `.kici/node_modules`; hoisted workspace member → the repo-root
18
- * `node_modules`) plus the in-repo version-range sibling package directories
19
- * `.kici` depends on (with their built output), whose symlinks would dangle
20
- * otherwise.
16
+ * - yarn (classic + berry): the resolved node_modules root (standalone `.kici`
17
+ * `.kici/node_modules`; hoisted workspace member → the repo-root
18
+ * `node_modules`) plus the in-repo sibling package directories `.kici` depends
19
+ * on (with their built output), whose symlinks would dangle otherwise. Berry
20
+ * runs with a forced `nodeLinker: node-modules`, so its tree has the same
21
+ * node_modules shape as classic and is packed identically (the flavor only
22
+ * changes how siblings are referenced — version range vs `workspace:`/`portal:`
23
+ * — not the packed layout).
21
24
  *
22
25
  * Uses tar.gz (Node.js built-in zlib, no external binary) in portable mode to
23
26
  * strip user/group info for cross-machine consistency; symlinks are preserved
@@ -379,6 +379,21 @@ export interface JobExecutionRequest {
379
379
  * to steps as `ctx.matrix`. Absent for non-matrix jobs.
380
380
  */
381
381
  matrixValues?: Record<string, unknown>;
382
+ /**
383
+ * For a `runsOnAll` host-fanout child: the hostname this child runs on,
384
+ * exposed to steps as `ctx.host`. Absent for non-host jobs.
385
+ */
386
+ host?: string;
387
+ /**
388
+ * For a `runsOnAll` host-fanout child: the resolved agent facts, exposed to
389
+ * steps as `ctx.agent`. Absent for non-host jobs.
390
+ */
391
+ agent?: {
392
+ host: string;
393
+ labels: string[];
394
+ platform?: string;
395
+ arch?: string;
396
+ };
382
397
  /** Secrets to merge into step environment (highest precedence). */
383
398
  secrets?: Record<string, string>;
384
399
  /** Namespaced secrets by context name for ctx.secrets['context-name'].KEY access. */
@@ -468,6 +483,14 @@ export interface JobExecutionRequest {
468
483
  event: Record<string, unknown>;
469
484
  /** Expected job names from the original eval (for determinism validation). */
470
485
  expectedJobNames?: string[];
486
+ /**
487
+ * Frozen upstream-output snapshot for a result-aware generator. When present
488
+ * the re-eval rebuilds `ctx.needs` from this snapshot (never a live read),
489
+ * so the generator sees the same upstream data as the original eval.
490
+ */
491
+ upstreamSnapshot?: import('@kici-dev/engine').UpstreamSnapshot;
492
+ /** Declared upstream needs (normalized lock edges) used to shape ctx.needs. */
493
+ declaredNeeds?: readonly unknown[];
471
494
  };
472
495
  }
473
496
  export {};
@@ -17,13 +17,15 @@
17
17
  * - yarn classic (v1) has no `workspace:` protocol and no `portal:` — it links
18
18
  * in-repo siblings by version range, not by a local specifier — so both are
19
19
  * rejected with guidance; `file:`/`link:` are allowed when the path stays
20
- * inside the clone, rejected when it escapes. (yarn berry is not yet
21
- * supported.)
20
+ * inside the clone, rejected when it escapes.
21
+ * - yarn berry (v2+) resolves `workspace:` against the repo-root package.json
22
+ * `workspaces` field and `portal:`/`file:`/`link:` against inside-repo paths,
23
+ * so those are allowed when present/inside the clone and rejected otherwise.
22
24
  *
23
25
  * This module performs that classification so unresolvable specifiers fail
24
26
  * fast with guidance rather than a cryptic install error.
25
27
  */
26
- import { PackageManager } from '@kici-dev/shared/package-manager';
28
+ import { PackageManager, YarnFlavor } from '@kici-dev/shared/package-manager';
27
29
  /** Local-protocol specifier prefixes that resolve against the filesystem. */
28
30
  export declare enum LocalDepProtocol {
29
31
  Workspace = "workspace:",
@@ -54,7 +56,7 @@ export declare function findLocalProtocolDeps(pkg: PackageJsonShape): LocalProto
54
56
  */
55
57
  export declare function kiciHasLocalProtocolDeps(kiciDir: string): Promise<boolean>;
56
58
  /** Build the actionable error for unresolvable local-protocol dependencies. */
57
- export declare function formatUnresolvableDepError(offenders: readonly LocalProtocolDep[], packageManager: PackageManager): string;
59
+ export declare function formatUnresolvableDepError(offenders: readonly LocalProtocolDep[], packageManager: PackageManager, yarnFlavor: YarnFlavor): string;
58
60
  /**
59
61
  * Throw an actionable error when `.kici/package.json` declares a local-protocol
60
62
  * dependency the detected package manager cannot resolve from the single cloned
@@ -65,6 +67,7 @@ export declare function assertResolvableDeps(args: {
65
67
  kiciDir: string;
66
68
  repoRoot: string;
67
69
  packageManager: PackageManager;
70
+ yarnFlavor?: YarnFlavor;
68
71
  }): Promise<void>;
69
72
  export {};
70
73
  //# sourceMappingURL=validate-kici-deps.d.ts.map
@@ -64,7 +64,11 @@ export declare function extractSteps(workflow: Workflow, jobName: string): reado
64
64
  * A sibling mismatch logs a warning; a missing target job throws a clear
65
65
  * determinism error.
66
66
  */
67
- export declare function extractStepsFromDynamicJob(workflow: Workflow, dynamicIndex: number, jobName: string, event: Record<string, unknown>, env: Record<string, string | undefined>, apiTransport?: (method: string, params?: Record<string, unknown>) => Promise<unknown>, expectedJobNames?: string[]): Promise<{
67
+ export declare function extractStepsFromDynamicJob(workflow: Workflow, dynamicIndex: number, jobName: string, event: Record<string, unknown>, env: Record<string, string | undefined>, apiTransport?: (method: string, params?: Record<string, unknown>) => Promise<unknown>, expectedJobNames?: string[],
68
+ /** Frozen upstream snapshot for a result-aware generator (rebuilds ctx.needs). */
69
+ upstreamSnapshot?: import('@kici-dev/engine').UpstreamSnapshot,
70
+ /** Declared upstream needs that shape ctx.needs. */
71
+ declaredNeeds?: readonly unknown[]): Promise<{
68
72
  steps: readonly StepInput[];
69
73
  droppedJobs: string[];
70
74
  }>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Apply yarn-berry registry auth + a forced `nodeLinker: node-modules` to a
3
+ * workflow's `.kici/.yarnrc.yml` for the lifetime of one `yarn install`, then
4
+ * restore the file on cleanup. The berry analog of `npm-registry-config.ts`:
5
+ * berry reads `.yarnrc.yml` (not `.npmrc`), so the auth block uses berry's
6
+ * `npmRegistryServer` / `npmScopes` / `npmAuthToken` keys with `${VAR}`
7
+ * env-var interpolation. Token bytes never reach disk — each registry token is
8
+ * exposed as a job-scoped env var and the on-disk value is the `${VAR}`
9
+ * reference.
10
+ *
11
+ * `nodeLinker: node-modules` makes berry lay down a real `node_modules` tree
12
+ * (no PnP `.pnp.cjs`), so the agent's packer / restore / sibling-walk /
13
+ * workflow-loader work unchanged. `enableScripts: false` (when a private
14
+ * registry is configured) keeps dependency lifecycle scripts from seeing the
15
+ * synthesized token env vars — the same security model as npm/pnpm/classic
16
+ * `--ignore-scripts`.
17
+ *
18
+ * Reuses the same `ApplyNpmRegistryConfigArgs` / `ApplyNpmRegistryConfigResult`
19
+ * shapes as the npm overlay so `dep-installer` can pick either by flavor.
20
+ */
21
+ import type { ApplyNpmRegistryConfigArgs, ApplyNpmRegistryConfigResult } from './npm-registry-config.js';
22
+ export declare function applyYarnrcBerryConfig(args: ApplyNpmRegistryConfigArgs): Promise<ApplyNpmRegistryConfigResult>;
23
+ //# sourceMappingURL=yarnrc-berry-config.d.ts.map
package/dist/index.js CHANGED
@@ -9,8 +9,9 @@ import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename,
9
9
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { promisify } from "node:util";
11
11
  import { createLogger, sha256, toErrorMessage } from "@kici-dev/shared";
12
- import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
12
+ import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
13
13
  import { existsSync } from "node:fs";
14
+ import { parse, stringify } from "yaml";
14
15
  import { Readable, Transform } from "node:stream";
15
16
  import { pipeline } from "node:stream/promises";
16
17
  import { createGunzip } from "node:zlib";
@@ -214,7 +215,7 @@ function noopResult() {
214
215
  };
215
216
  }
216
217
  /** Build the synthesized env-var name for registry index `i`. */
217
- function tokenEnvName(jobIdShort, index) {
218
+ function tokenEnvName$1(jobIdShort, index) {
218
219
  return `KICI_NPM_TOKEN_${jobIdShort}_${index}`;
219
220
  }
220
221
  /** Render the agent-managed block of `.npmrc` lines. */
@@ -223,7 +224,7 @@ function renderAgentLines(registries, jobIdShort) {
223
224
  const lines = [];
224
225
  for (let i = 0; i < registries.length; i++) {
225
226
  const reg = registries[i];
226
- const envVar = tokenEnvName(jobIdShort, i);
227
+ const envVar = tokenEnvName$1(jobIdShort, i);
227
228
  const authKey = reg.url.replace(/^https?:/, "");
228
229
  if (reg.scope) lines.push(`${reg.scope}:registry=${reg.url}`);
229
230
  else lines.push(`registry=${reg.url}`);
@@ -255,7 +256,7 @@ async function applyNpmRegistryConfig(args) {
255
256
  const tokenEnv = {};
256
257
  const tokensForRedaction = [];
257
258
  for (let i = 0; i < registries.length; i++) {
258
- tokenEnv[tokenEnvName(args.jobIdShort, i)] = registries[i].token;
259
+ tokenEnv[tokenEnvName$1(args.jobIdShort, i)] = registries[i].token;
259
260
  tokensForRedaction.push(registries[i].token);
260
261
  }
261
262
  for (const value of Object.values(installEnvSecrets)) if (value) tokensForRedaction.push(value);
@@ -292,6 +293,112 @@ function redactNpmOutput(input, tokens) {
292
293
  return out;
293
294
  }
294
295
  //#endregion
296
+ //#region src/execution/yarnrc-berry-config.ts
297
+ /**
298
+ * Apply yarn-berry registry auth + a forced `nodeLinker: node-modules` to a
299
+ * workflow's `.kici/.yarnrc.yml` for the lifetime of one `yarn install`, then
300
+ * restore the file on cleanup. The berry analog of `npm-registry-config.ts`:
301
+ * berry reads `.yarnrc.yml` (not `.npmrc`), so the auth block uses berry's
302
+ * `npmRegistryServer` / `npmScopes` / `npmAuthToken` keys with `${VAR}`
303
+ * env-var interpolation. Token bytes never reach disk — each registry token is
304
+ * exposed as a job-scoped env var and the on-disk value is the `${VAR}`
305
+ * reference.
306
+ *
307
+ * `nodeLinker: node-modules` makes berry lay down a real `node_modules` tree
308
+ * (no PnP `.pnp.cjs`), so the agent's packer / restore / sibling-walk /
309
+ * workflow-loader work unchanged. `enableScripts: false` (when a private
310
+ * registry is configured) keeps dependency lifecycle scripts from seeing the
311
+ * synthesized token env vars — the same security model as npm/pnpm/classic
312
+ * `--ignore-scripts`.
313
+ *
314
+ * Reuses the same `ApplyNpmRegistryConfigArgs` / `ApplyNpmRegistryConfigResult`
315
+ * shapes as the npm overlay so `dep-installer` can pick either by flavor.
316
+ */
317
+ /** Build the synthesized env-var name for registry index `i`. */
318
+ function tokenEnvName(jobIdShort, index) {
319
+ return `KICI_NPM_TOKEN_${jobIdShort}_${index}`;
320
+ }
321
+ /** Read + parse an existing `.yarnrc.yml`, or `{}` when absent/empty. */
322
+ async function readOriginalYarnrc(path) {
323
+ try {
324
+ const raw = await readFile(path, "utf8");
325
+ return {
326
+ raw,
327
+ doc: parse(raw) ?? {}
328
+ };
329
+ } catch (err) {
330
+ if (err.code === "ENOENT") return {
331
+ raw: null,
332
+ doc: {}
333
+ };
334
+ throw err;
335
+ }
336
+ }
337
+ function buildRegistryBlock(envVar, url, alwaysAuth) {
338
+ return {
339
+ npmRegistryServer: url,
340
+ npmAuthToken: `\${${envVar}}`,
341
+ ...alwaysAuth ? { npmAlwaysAuth: true } : {}
342
+ };
343
+ }
344
+ async function applyYarnrcBerryConfig(args) {
345
+ const registries = args.npmRegistries ?? [];
346
+ const installEnvSecrets = args.installEnvSecrets ?? {};
347
+ const hasPrivateRegistry = registries.length > 0 || Object.keys(installEnvSecrets).length > 0;
348
+ const yarnrcPath = join(args.kiciDir, ".yarnrc.yml");
349
+ const { raw: original, doc } = await readOriginalYarnrc(yarnrcPath);
350
+ const cacheFolder = await mkdtemp(join(tmpdir(), "kici-yarn-berry-cache-"));
351
+ const merged = {
352
+ ...doc,
353
+ nodeLinker: "node-modules",
354
+ enableGlobalCache: false,
355
+ cacheFolder
356
+ };
357
+ const tokenEnv = {};
358
+ const tokensForRedaction = [];
359
+ if (hasPrivateRegistry) {
360
+ merged.enableScripts = false;
361
+ const npmScopes = { ...doc.npmScopes ?? {} };
362
+ for (let i = 0; i < registries.length; i++) {
363
+ const reg = registries[i];
364
+ const envVar = tokenEnvName(args.jobIdShort, i);
365
+ tokenEnv[envVar] = reg.token;
366
+ tokensForRedaction.push(reg.token);
367
+ const block = buildRegistryBlock(envVar, reg.url, reg.alwaysAuth);
368
+ if (reg.scope) npmScopes[reg.scope] = block;
369
+ else {
370
+ merged.npmRegistryServer = reg.url;
371
+ merged.npmAuthToken = block.npmAuthToken;
372
+ if (reg.alwaysAuth) merged.npmAlwaysAuth = true;
373
+ }
374
+ }
375
+ if (Object.keys(npmScopes).length > 0) merged.npmScopes = npmScopes;
376
+ for (const value of Object.values(installEnvSecrets)) if (value) tokensForRedaction.push(value);
377
+ }
378
+ await writeFile(yarnrcPath, stringify(merged), {
379
+ encoding: "utf8",
380
+ mode: 384
381
+ });
382
+ const cleanup = async () => {
383
+ try {
384
+ if (original === null) await unlink(yarnrcPath).catch(() => {});
385
+ else await writeFile(yarnrcPath, original, { encoding: "utf8" });
386
+ } catch {}
387
+ await rm(cacheFolder, {
388
+ recursive: true,
389
+ force: true
390
+ }).catch(() => {});
391
+ };
392
+ return {
393
+ extraEnv: {
394
+ ...installEnvSecrets,
395
+ ...tokenEnv
396
+ },
397
+ tokensForRedaction,
398
+ cleanup
399
+ };
400
+ }
401
+ //#endregion
295
402
  //#region src/execution/validate-kici-deps.ts
296
403
  /**
297
404
  * Pre-install validation for `.kici/` dependency specifiers.
@@ -312,8 +419,10 @@ function redactNpmOutput(input, tokens) {
312
419
  * - yarn classic (v1) has no `workspace:` protocol and no `portal:` — it links
313
420
  * in-repo siblings by version range, not by a local specifier — so both are
314
421
  * rejected with guidance; `file:`/`link:` are allowed when the path stays
315
- * inside the clone, rejected when it escapes. (yarn berry is not yet
316
- * supported.)
422
+ * inside the clone, rejected when it escapes.
423
+ * - yarn berry (v2+) resolves `workspace:` against the repo-root package.json
424
+ * `workspaces` field and `portal:`/`file:`/`link:` against inside-repo paths,
425
+ * so those are allowed when present/inside the clone and rejected otherwise.
317
426
  *
318
427
  * This module performs that classification so unresolvable specifiers fail
319
428
  * fast with guidance rather than a cryptic install error.
@@ -394,6 +503,17 @@ async function fileExists(target) {
394
503
  return false;
395
504
  }
396
505
  }
506
+ /** Whether the repo-root package.json declares a non-empty `workspaces` array. */
507
+ async function rootHasWorkspaces(repoRoot) {
508
+ try {
509
+ const ws = JSON.parse(await readFile(join(repoRoot, "package.json"), "utf-8")).workspaces;
510
+ if (Array.isArray(ws)) return ws.length > 0;
511
+ if (ws && typeof ws === "object" && Array.isArray(ws.packages)) return ws.packages.length > 0;
512
+ return false;
513
+ } catch {
514
+ return false;
515
+ }
516
+ }
397
517
  /** Resolve a `file:`/`link:`/`portal:` spec to an absolute path under kiciDir. */
398
518
  function resolveLocalPath(kiciDir, dep) {
399
519
  const rawPath = dep.spec.slice(dep.protocol.length);
@@ -408,8 +528,20 @@ function isInsideRepo(repoRoot, target) {
408
528
  * Classify each local-protocol dependency for the detected package manager and
409
529
  * return the ones that are unresolvable in the agent's single-clone model.
410
530
  */
411
- async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
531
+ async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot, yarnFlavor) {
412
532
  if (packageManager === PackageManager.Npm) return [...deps];
533
+ if (packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry) {
534
+ const hasWorkspaces = await rootHasWorkspaces(repoRoot);
535
+ const unresolvable = [];
536
+ for (const dep of deps) {
537
+ if (dep.protocol === "workspace:") {
538
+ if (!hasWorkspaces) unresolvable.push(dep);
539
+ continue;
540
+ }
541
+ if (!isInsideRepo(repoRoot, resolveLocalPath(kiciDir, dep))) unresolvable.push(dep);
542
+ }
543
+ return unresolvable;
544
+ }
413
545
  if (packageManager === PackageManager.Yarn) {
414
546
  const unresolvable = [];
415
547
  for (const dep of deps) {
@@ -433,10 +565,11 @@ async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
433
565
  return unresolvable;
434
566
  }
435
567
  /** Build the actionable error for unresolvable local-protocol dependencies. */
436
- function formatUnresolvableDepError(offenders, packageManager) {
568
+ function formatUnresolvableDepError(offenders, packageManager, yarnFlavor) {
437
569
  const list = offenders.map((o) => `${o.name}: ${o.spec}`).join(", ");
438
570
  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.`;
439
- if (packageManager === PackageManager.Yarn) return `These .kici/ dependencies use specifiers yarn classic cannot resolve: ${list}. yarn classic has no workspace: or portal: protocol reference an in-repo sibling by a version range (yarn links matching workspace members), use pnpm, or keep file:/link: paths inside this repository. (yarn berry support is planned.)`;
571
+ if (packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry) return `These .kici/ dependencies cannot be resolved by yarn berry from the cloned repository: ${list}. A workspace: dependency requires a "workspaces" array in the repo-root package.json, and file:/link:/portal: paths must stay inside this repository.`;
572
+ if (packageManager === PackageManager.Yarn) return `These .kici/ dependencies use specifiers yarn classic cannot resolve: ${list}. yarn classic has no workspace: or portal: protocol — reference an in-repo sibling by a version range (yarn links matching workspace members), use pnpm, or keep file:/link: paths inside this repository. (yarn berry support requires a yarn@2+ packageManager field or a .yarnrc.yml.)`;
440
573
  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.`;
441
574
  }
442
575
  /**
@@ -450,9 +583,10 @@ async function assertResolvableDeps(args) {
450
583
  if (!pkg) return;
451
584
  const localDeps = findLocalProtocolDeps(pkg);
452
585
  if (localDeps.length === 0) return;
453
- const offenders = await findUnresolvableDeps(localDeps, args.packageManager, args.kiciDir, args.repoRoot);
586
+ const flavor = args.yarnFlavor ?? YarnFlavor.Classic;
587
+ const offenders = await findUnresolvableDeps(localDeps, args.packageManager, args.kiciDir, args.repoRoot, flavor);
454
588
  if (offenders.length === 0) return;
455
- throw new Error(formatUnresolvableDepError(offenders, args.packageManager));
589
+ throw new Error(formatUnresolvableDepError(offenders, args.packageManager, flavor));
456
590
  }
457
591
  //#endregion
458
592
  //#region src/execution/workspace-siblings.ts
@@ -561,10 +695,13 @@ function isAbsoluteRel(rel) {
561
695
  * presence of `.kici/package.json` signals that deps should be installed. npm
562
696
  * is the default and ships with every Node.js install; pnpm is used when the
563
697
  * repo is a pnpm workspace so a `.kici/` member can resolve in-repo
564
- * `workspace:` siblings. yarn classic (v1) is supported for registry
565
- * dependencies and version-range workspace siblings (which it links but does
566
- * not build, so the agent builds the in-repo closure after install). yarn
567
- * berry (v2+) is not yet supported.
698
+ * `workspace:` siblings. yarn is supported in both flavors: classic (v1) reads
699
+ * `.kici/.npmrc` for registry auth and links version-range workspace siblings;
700
+ * berry (v2+) reads a synthesized `.kici/.yarnrc.yml` for auth, runs with a
701
+ * forced `nodeLinker: node-modules` (so the resulting tree matches classic/npm
702
+ * and the runner's plain node resolution holds), and resolves
703
+ * `workspace:`/`portal:` siblings. Either flavor links the sibling but does not
704
+ * build it, so the agent builds the in-repo closure after install.
568
705
  *
569
706
  * Security: the install runs with an isolated per-invocation cache/store
570
707
  * directory to prevent cache poisoning across build jobs — a malicious
@@ -590,6 +727,15 @@ async function detectKiciPackageManager(repoRoot, kiciDir) {
590
727
  return await detectPackageManagerFromManifests(repoRoot) ?? await detectPackageManagerFromManifests(kiciDir) ?? PackageManager.Npm;
591
728
  }
592
729
  /**
730
+ * Detect the yarn flavor (classic vs berry) for the cloned repo. Mirrors
731
+ * `detectKiciPackageManager`: probe the repo root first, then `.kici/` for a
732
+ * standalone project. Only called when the detected manager is `Yarn`.
733
+ */
734
+ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
735
+ if (await detectYarnFlavor(repoRoot) === YarnFlavor.Berry) return YarnFlavor.Berry;
736
+ return detectYarnFlavor(kiciDir);
737
+ }
738
+ /**
593
739
  * Install `.kici/` dependencies inline with the repo's package manager.
594
740
  *
595
741
  * Falls back to this when the dep cache is unavailable or a download fails.
@@ -608,19 +754,28 @@ async function detectKiciPackageManager(repoRoot, kiciDir) {
608
754
  async function installDeps(kiciDir, opts = {}) {
609
755
  const repoRoot = opts.repoRoot ?? dirname(kiciDir);
610
756
  const packageManager = await detectKiciPackageManager(repoRoot, kiciDir);
757
+ const yarnFlavor = packageManager === PackageManager.Yarn ? await detectKiciYarnFlavor(repoRoot, kiciDir) : YarnFlavor.Classic;
611
758
  logger$2.info("Installing deps inline", {
612
759
  packageManager,
760
+ yarnFlavor,
613
761
  dir: kiciDir
614
762
  });
615
- process.stderr.write(`[dep-installer:trace] starting install: pm=${packageManager}, cwd=${kiciDir}\n`);
763
+ process.stderr.write(`[dep-installer:trace] starting install: pm=${packageManager}, flavor=${yarnFlavor}, cwd=${kiciDir}\n`);
616
764
  await assertResolvableDeps({
617
765
  kiciDir,
618
766
  repoRoot,
619
- packageManager
767
+ packageManager,
768
+ yarnFlavor
620
769
  });
621
770
  const startTime = Date.now();
622
771
  const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
623
- const registryConfig = await applyNpmRegistryConfig({
772
+ const isBerry = packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry;
773
+ const registryConfig = isBerry ? await applyYarnrcBerryConfig({
774
+ kiciDir,
775
+ npmRegistries: opts.npmRegistries,
776
+ installEnvSecrets: opts.installEnvSecrets,
777
+ jobIdShort: opts.jobIdShort ?? "00000000"
778
+ }) : await applyNpmRegistryConfig({
624
779
  kiciDir,
625
780
  npmRegistries: opts.npmRegistries,
626
781
  installEnvSecrets: opts.installEnvSecrets,
@@ -632,6 +787,10 @@ async function installDeps(kiciDir, opts = {}) {
632
787
  hasPrivateRegistry,
633
788
  registryConfig
634
789
  });
790
+ else if (isBerry) await runYarnBerryInstall({
791
+ kiciDir,
792
+ registryConfig
793
+ });
635
794
  else if (packageManager === PackageManager.Yarn) await runYarnInstall({
636
795
  kiciDir,
637
796
  hasPrivateRegistry,
@@ -651,7 +810,7 @@ async function installDeps(kiciDir, opts = {}) {
651
810
  await registryConfig.cleanup();
652
811
  }
653
812
  if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
654
- if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir);
813
+ if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor);
655
814
  const durationMs = Date.now() - startTime;
656
815
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
657
816
  logger$2.info("Deps installed inline", {
@@ -780,6 +939,35 @@ async function runYarnInstall(args) {
780
939
  }).catch(() => {});
781
940
  }
782
941
  }
942
+ /** Pure: argv for a berry `yarn install`. Cache + linker live in .yarnrc.yml. */
943
+ function buildYarnBerryInstallArgs() {
944
+ return ["install"];
945
+ }
946
+ /**
947
+ * Run a berry `yarn install` from `.kici/`. The synthesized `.kici/.yarnrc.yml`
948
+ * (applied by `applyYarnrcBerryConfig`) forces `nodeLinker: node-modules`, an
949
+ * isolated `cacheFolder`, and — when a private registry is configured —
950
+ * `enableScripts: false` + `npmScopes`/`npmRegistryServer` auth. corepack
951
+ * provisions the repo-pinned berry version; `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`
952
+ * makes that non-interactive. Not `--immutable` (resolved URLs in the lockfile
953
+ * may point at a different registry than the synthesized config).
954
+ */
955
+ async function runYarnBerryInstall(args) {
956
+ await assertYarnAvailable();
957
+ const { nodeDir } = resolveNpm();
958
+ const env = {
959
+ ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir),
960
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
961
+ };
962
+ const argv = buildYarnBerryInstallArgs();
963
+ process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")} (berry)\n`);
964
+ await execFileAsync("yarn", argv, {
965
+ cwd: args.kiciDir,
966
+ env,
967
+ timeout: INSTALL_TIMEOUT_MS,
968
+ maxBuffer: INSTALL_MAX_BUFFER
969
+ });
970
+ }
783
971
  /** Throw an actionable error when the repo needs yarn but it is not installed. */
784
972
  async function assertYarnAvailable() {
785
973
  try {
@@ -799,7 +987,7 @@ async function assertYarnAvailable() {
799
987
  * Deep cross-sibling build chains may build out of strict topological order —
800
988
  * real `.kici` closures are shallow.
801
989
  */
802
- async function buildYarnWorkspaceClosure(repoRoot, kiciDir) {
990
+ async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor) {
803
991
  const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
804
992
  if (siblings.length === 0) return;
805
993
  const { nodeDir } = resolveNpm();
@@ -807,15 +995,16 @@ async function buildYarnWorkspaceClosure(repoRoot, kiciDir) {
807
995
  for (const rel of [...siblings].reverse()) {
808
996
  const sibDir = join(repoRoot, rel);
809
997
  if (!await siblingHasBuildScript(sibDir)) continue;
810
- process.stderr.write(`[dep-installer:trace] building yarn sibling: yarn --cwd ${sibDir} run build\n`);
998
+ const [argv, cwd] = yarnFlavor === YarnFlavor.Berry ? [["run", "build"], sibDir] : [[
999
+ "--cwd",
1000
+ sibDir,
1001
+ "run",
1002
+ "build"
1003
+ ], repoRoot];
1004
+ process.stderr.write(`[dep-installer:trace] building yarn sibling (${yarnFlavor}): yarn ${argv.join(" ")} @ ${cwd}\n`);
811
1005
  try {
812
- await execFileAsync("yarn", [
813
- "--cwd",
814
- sibDir,
815
- "run",
816
- "build"
817
- ], {
818
- cwd: repoRoot,
1006
+ await execFileAsync("yarn", argv, {
1007
+ cwd,
819
1008
  env,
820
1009
  timeout: INSTALL_TIMEOUT_MS,
821
1010
  maxBuffer: INSTALL_MAX_BUFFER