javi-forge 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,14 +33,26 @@ export async function handleCi(cli, ctx) {
33
33
  }
34
34
  }
35
35
  else if (cli.flags.json) {
36
- console.log(JSON.stringify({ ok: true, runners: result.runners }, null, 2));
36
+ const gates = result.gates ?? [];
37
+ console.log(JSON.stringify({
38
+ ok: true,
39
+ runners: result.runners,
40
+ ...(gates.length > 0 ? { gates } : {}),
41
+ }, null, 2));
37
42
  }
38
43
  else {
44
+ const gates = result.gates ?? [];
39
45
  console.log(`✓ CI config valid: ${result.configPath}`);
40
46
  console.log(` ${result.runners.length} runner(s):`);
41
47
  for (const runner of result.runners) {
42
48
  console.log(` - ${runner.name} (${runner.stack})`);
43
49
  }
50
+ if (gates.length > 0) {
51
+ console.log(` ${gates.length} gate(s):`);
52
+ for (const gate of gates) {
53
+ console.log(` - ${gate.id} (${gate.mode}, scope: ${gate.scope})`);
54
+ }
55
+ }
44
56
  }
45
57
  process.exit(0);
46
58
  }
@@ -12,6 +12,12 @@ export interface CIValidateRunnerSummary {
12
12
  name: string;
13
13
  stack: string;
14
14
  }
15
+ /** One validated gate, reduced to what the report shows. */
16
+ export interface CIValidateGateSummary {
17
+ id: string;
18
+ mode: string;
19
+ scope: string;
20
+ }
15
21
  export interface CIValidateOk {
16
22
  ok: true;
17
23
  /**
@@ -24,6 +30,8 @@ export interface CIValidateOk {
24
30
  /** Resolved config path, or null in auto-detect mode. */
25
31
  configPath: string | null;
26
32
  runners: CIValidateRunnerSummary[];
33
+ /** Declared gates (version 2). Empty when none are declared. */
34
+ gates: CIValidateGateSummary[];
27
35
  }
28
36
  export interface CIValidateErr {
29
37
  ok: false;
@@ -35,7 +35,13 @@ export async function validateCIConfig(projectDir, config) {
35
35
  else {
36
36
  const discovered = await findCIConfig(projectDir);
37
37
  if (!discovered) {
38
- return { ok: true, mode: "auto-detect", configPath: null, runners: [] };
38
+ return {
39
+ ok: true,
40
+ mode: "auto-detect",
41
+ configPath: null,
42
+ runners: [],
43
+ gates: [],
44
+ };
39
45
  }
40
46
  configPath = discovered;
41
47
  }
@@ -49,6 +55,11 @@ export async function validateCIConfig(projectDir, config) {
49
55
  name: r.name,
50
56
  stack: r.stack,
51
57
  })),
58
+ gates: (ciConfig.gates ?? []).map((g) => ({
59
+ id: g.id,
60
+ mode: g.mode,
61
+ scope: g.scope,
62
+ })),
52
63
  };
53
64
  }
54
65
  catch (e) {
@@ -83,13 +83,13 @@ async function buildCICommands(stack, buildTool, projectDir) {
83
83
  case "java-gradle":
84
84
  return {
85
85
  lintCmd: "./gradlew spotlessCheck --no-daemon",
86
- compileCmd: "./gradlew clean classes testClasses --no-daemon && chown -R runner:runner build/ .gradle/ 2>/dev/null || true",
86
+ compileCmd: "./gradlew clean classes testClasses --no-daemon",
87
87
  testCmd: "./gradlew test --no-daemon",
88
88
  };
89
89
  case "java-maven":
90
90
  return {
91
91
  lintCmd: "./mvnw spotless:check",
92
- compileCmd: "./mvnw clean compile test-compile && chown -R runner:runner target/ .mvn/ 2>/dev/null || true",
92
+ compileCmd: "./mvnw clean compile test-compile",
93
93
  testCmd: "./mvnw test",
94
94
  };
95
95
  case "node": {
@@ -101,14 +101,14 @@ async function buildCICommands(stack, buildTool, projectDir) {
101
101
  catch {
102
102
  /* no package.json */
103
103
  }
104
- // Clean dist/ before build and chown after so tests (as runner) can access output.
105
- // Runs as root inside the container to handle host-owned output dirs.
104
+ // Clean dist/ before build so a stale directory never masks a broken
105
+ // build. The container runs as the host uid (see runInContainer,
106
+ // ENV-1), so output lands host-owned — no chown needed.
106
107
  const buildPrefix = "rm -rf dist/ && ";
107
- const buildSuffix = " && chown -R runner:runner dist/ 2>/dev/null || true";
108
108
  return {
109
109
  lintCmd: pkgContent.includes('"lint"') ? `${buildTool} run lint` : null,
110
110
  compileCmd: pkgContent.includes('"build"')
111
- ? `${buildPrefix}${buildTool} run build${buildSuffix}`
111
+ ? `${buildPrefix}${buildTool} run build`
112
112
  : null,
113
113
  testCmd: pkgContent.includes('"test"')
114
114
  ? `${buildTool} ${buildTool === "npm" ? "test" : "run test"}`
@@ -124,13 +124,13 @@ async function buildCICommands(stack, buildTool, projectDir) {
124
124
  case "go":
125
125
  return {
126
126
  lintCmd: "golangci-lint run",
127
- compileCmd: "go clean -cache && go build ./... && chown -R runner:runner . 2>/dev/null || true",
127
+ compileCmd: "go clean -cache && go build ./...",
128
128
  testCmd: "go test ./...",
129
129
  };
130
130
  case "rust":
131
131
  return {
132
132
  lintCmd: "cargo clippy -- -D warnings",
133
- compileCmd: "cargo clean && cargo build && chown -R runner:runner target/ 2>/dev/null || true",
133
+ compileCmd: "cargo clean && cargo build",
134
134
  testCmd: "cargo test",
135
135
  };
136
136
  default:
@@ -349,10 +349,26 @@ export async function runCI(options, onStep) {
349
349
  report(onStep, "docker-image", "Building Docker image", "running");
350
350
  let shellImage;
351
351
  try {
352
- shellImage = await ensureImage({
353
- stack: stackInfo.stackType,
354
- javaVersion: stackInfo.javaVersion,
355
- });
352
+ // B2: honor a per-runner pinned image / build context the same way the
353
+ // runner loop does, instead of always deriving the stack default. An
354
+ // explicit image passes through verbatim; a build context is built with
355
+ // the deterministic per-runner tag; otherwise fall back to the stack image.
356
+ if (primary.image) {
357
+ shellImage = primary.image;
358
+ }
359
+ else if (primary.buildContext) {
360
+ shellImage = await ensureImage({
361
+ stack: primary.stack,
362
+ buildContext: path.resolve(projectDir, primary.buildContext),
363
+ imageTag: `javi-forge-ci-${primary.name}`,
364
+ });
365
+ }
366
+ else {
367
+ shellImage = await ensureImage({
368
+ stack: stackInfo.stackType,
369
+ javaVersion: stackInfo.javaVersion,
370
+ });
371
+ }
356
372
  report(onStep, "docker-image", "Docker image ready", "done");
357
373
  }
358
374
  catch (e) {
@@ -411,10 +427,16 @@ export async function runCI(options, onStep) {
411
427
  report(onStep, stepContext, "Refresh .context/ directory", "error", String(e));
412
428
  }
413
429
  // ── Runner execution ─────────────────────────────────────────────────────────
414
- // ONE executor for every resolution source. Naming is a function of
415
- // `resolved.source` alone — never of `resolved.runners.length`, which would
416
- // silently rename a single-runner CONFIG.
417
- const naming = resolved.source === "auto" ? NAMING_MODE.BARE : NAMING_MODE.SUFFIXED;
430
+ // ONE executor for every resolution source. Naming is a function of whether
431
+ // the runner NAME is IMPLICIT or EXPLICIT — never of `resolved.runners.length`,
432
+ // which would silently rename a single-runner CONFIG (B1):
433
+ // - IMPLICIT name BARE ids: `auto` (zero-config) OR `stack-override`
434
+ // (`--stack`, the user never named the runner).
435
+ // - EXPLICIT name → SUFFIXED ids: `config` (a runner named in ci.yaml).
436
+ const implicitName = resolved.source === "auto" || resolved.source === "stack-override";
437
+ const naming = implicitName
438
+ ? NAMING_MODE.BARE
439
+ : NAMING_MODE.SUFFIXED;
418
440
  for (const runner of resolved.runners) {
419
441
  await runRunner(runner, {
420
442
  projectDir,
@@ -499,7 +521,10 @@ async function runRunner(runner, ctx) {
499
521
  imageName = ctx.preresolvedImage;
500
522
  }
501
523
  else {
502
- const stepImage = `docker-image:${runner.name}`;
524
+ // Under an IMPLICIT name (bare) the image step id is unsuffixed too, so a
525
+ // `--stack` run reads identically to zero-config auto. A CONFIG runner
526
+ // (suffixed) keeps `docker-image:<name>` (the R3 guard).
527
+ const stepImage = bare ? "docker-image" : `docker-image:${runner.name}`;
503
528
  try {
504
529
  if (runner.buildContext) {
505
530
  report(onStep, stepImage, `Building image for ${runner.name} from ${runner.buildContext}`, "running");
@@ -565,7 +590,6 @@ async function runRunner(runner, ctx) {
565
590
  id: "compile",
566
591
  label: "Compile",
567
592
  cmds: runner.compileCmds,
568
- user: "root",
569
593
  skip: false,
570
594
  },
571
595
  {
@@ -10,6 +10,20 @@
10
10
  */
11
11
  import type { Stack } from "../types/index.js";
12
12
  export declare const CI_CONFIG_VERSION = 1;
13
+ /** Config schema versions this loader accepts. v2 is additive (unlocks gates). */
14
+ export declare const CI_CONFIG_VERSIONS: readonly number[];
15
+ /** Gate outcome mode: a blocking gate fails the build, informative degrades. */
16
+ export declare const GATE_MODE: {
17
+ readonly BLOCKING: "blocking";
18
+ readonly INFORMATIVE: "informative";
19
+ };
20
+ export type GateMode = (typeof GATE_MODE)[keyof typeof GATE_MODE];
21
+ /** Gate file scope: all files, or only the changed set (slice 4 wiring). */
22
+ export declare const GATE_SCOPE: {
23
+ readonly ALL: "all";
24
+ readonly CHANGED: "changed";
25
+ };
26
+ export type GateScope = (typeof GATE_SCOPE)[keyof typeof GATE_SCOPE];
13
27
  /** Default config locations, in discovery order, relative to the project root. */
14
28
  export declare const CI_CONFIG_CANDIDATES: readonly [".javi-forge/ci.yaml", ".javi-forge/ci.yml"];
15
29
  export interface CIRunnerConfig {
@@ -32,9 +46,26 @@ export interface CIRunnerConfig {
32
46
  /** Tools that must exist in the runner environment (validated fail-closed) */
33
47
  requires: string[];
34
48
  }
49
+ /** A declarable named quality gate (version 2 only). Runs host-native. */
50
+ export interface CIGateConfig {
51
+ /** Unique, tag-safe identifier (reuses RUNNER_NAME_RE). */
52
+ id: string;
53
+ /** Command(s) to run — string or list, normalized to a list. */
54
+ run: string[];
55
+ /** blocking (default) fails the build; informative degrades to a warning. */
56
+ mode: GateMode;
57
+ /** all (default) or changed — the file scope the gate cares about. */
58
+ scope: GateScope;
59
+ /** Optional baseline artifact path (slice 4). */
60
+ baseline?: string;
61
+ /** Optional env injected via the child-process env map (slice 4). */
62
+ env?: Record<string, string>;
63
+ }
35
64
  export interface CIConfig {
36
65
  version: number;
37
66
  runners: CIRunnerConfig[];
67
+ /** Present only under version 2 when `gates:` is declared. */
68
+ gates?: CIGateConfig[];
38
69
  }
39
70
  export interface CIConfigValidationError {
40
71
  path: string;
@@ -46,6 +77,12 @@ export declare class CIConfigError extends Error {
46
77
  }
47
78
  /** Stacks accepted by the CI config schema and the --stack override. */
48
79
  export declare const CI_STACKS: readonly string[];
80
+ /**
81
+ * Validate the `gates:` block (version 2 only). Every schema error names the
82
+ * offending field; a duplicate id is reported once. Returns the parsed gates
83
+ * (the caller discards them all if `errors` is non-empty — fail closed).
84
+ */
85
+ export declare function validateGates(raw: unknown, errors: CIConfigValidationError[]): CIGateConfig[];
49
86
  /**
50
87
  * Parse and validate CI config YAML text. Throws CIConfigError listing every
51
88
  * validation problem; never returns a partially valid config.
@@ -15,6 +15,18 @@ import YAML from "yaml";
15
15
  // Types
16
16
  // =============================================================================
17
17
  export const CI_CONFIG_VERSION = 1;
18
+ /** Config schema versions this loader accepts. v2 is additive (unlocks gates). */
19
+ export const CI_CONFIG_VERSIONS = [1, 2];
20
+ /** Gate outcome mode: a blocking gate fails the build, informative degrades. */
21
+ export const GATE_MODE = {
22
+ BLOCKING: "blocking",
23
+ INFORMATIVE: "informative",
24
+ };
25
+ /** Gate file scope: all files, or only the changed set (slice 4 wiring). */
26
+ export const GATE_SCOPE = {
27
+ ALL: "all",
28
+ CHANGED: "changed",
29
+ };
18
30
  /** Default config locations, in discovery order, relative to the project root. */
19
31
  export const CI_CONFIG_CANDIDATES = [
20
32
  ".javi-forge/ci.yaml",
@@ -202,6 +214,144 @@ function validateRunner(raw, index, errors) {
202
214
  requires,
203
215
  };
204
216
  }
217
+ const GATE_FIELDS = new Set(["id", "run", "mode", "scope", "baseline", "env"]);
218
+ function validateGate(raw, index, errors) {
219
+ const base = `gates[${index}]`;
220
+ if (!isRecord(raw)) {
221
+ errors.push({ path: base, message: "gate must be an object" });
222
+ return null;
223
+ }
224
+ for (const key of Object.keys(raw)) {
225
+ if (!GATE_FIELDS.has(key)) {
226
+ errors.push({
227
+ path: `${base}.${key}`,
228
+ message: `unknown field "${key}"`,
229
+ });
230
+ }
231
+ }
232
+ const id = raw.id;
233
+ if (typeof id !== "string" || !id.trim()) {
234
+ errors.push({
235
+ path: `${base}.id`,
236
+ message: "id is required and must be a non-empty string",
237
+ });
238
+ }
239
+ else if (!RUNNER_NAME_RE.test(id.trim())) {
240
+ errors.push({
241
+ path: `${base}.id`,
242
+ message: "id must start with a letter, digit or underscore and contain only [a-zA-Z0-9._-] (it is used as a tag)",
243
+ });
244
+ }
245
+ const run = normalizeCommands(raw.run, `${base}.run`, errors);
246
+ if (raw.run === undefined) {
247
+ errors.push({
248
+ path: `${base}.run`,
249
+ message: "run is required and must be a non-empty string or a list of non-empty strings",
250
+ });
251
+ }
252
+ else if (run.length === 0 &&
253
+ !errors.some((e) => e.path === `${base}.run`)) {
254
+ errors.push({
255
+ path: `${base}.run`,
256
+ message: "run must be a non-empty string or a list of non-empty strings",
257
+ });
258
+ }
259
+ let mode = GATE_MODE.BLOCKING;
260
+ if (raw.mode !== undefined) {
261
+ if (raw.mode === GATE_MODE.BLOCKING || raw.mode === GATE_MODE.INFORMATIVE) {
262
+ mode = raw.mode;
263
+ }
264
+ else {
265
+ errors.push({
266
+ path: `${base}.mode`,
267
+ message: `mode must be one of: ${GATE_MODE.BLOCKING}, ${GATE_MODE.INFORMATIVE} (got "${String(raw.mode)}")`,
268
+ });
269
+ }
270
+ }
271
+ let scope = GATE_SCOPE.ALL;
272
+ if (raw.scope !== undefined) {
273
+ if (raw.scope === GATE_SCOPE.ALL || raw.scope === GATE_SCOPE.CHANGED) {
274
+ scope = raw.scope;
275
+ }
276
+ else {
277
+ errors.push({
278
+ path: `${base}.scope`,
279
+ message: `scope must be one of: ${GATE_SCOPE.ALL}, ${GATE_SCOPE.CHANGED} (got "${String(raw.scope)}")`,
280
+ });
281
+ }
282
+ }
283
+ let baseline;
284
+ if (raw.baseline !== undefined) {
285
+ if (typeof raw.baseline !== "string" || !raw.baseline.trim()) {
286
+ errors.push({
287
+ path: `${base}.baseline`,
288
+ message: "baseline must be a non-empty string (path)",
289
+ });
290
+ }
291
+ else {
292
+ baseline = raw.baseline;
293
+ }
294
+ }
295
+ let env;
296
+ if (raw.env !== undefined) {
297
+ if (!isRecord(raw.env)) {
298
+ errors.push({
299
+ path: `${base}.env`,
300
+ message: "env must be a mapping of string keys to string values",
301
+ });
302
+ }
303
+ else if (Object.values(raw.env).some((v) => typeof v !== "string")) {
304
+ errors.push({
305
+ path: `${base}.env`,
306
+ message: "env values must all be strings",
307
+ });
308
+ }
309
+ else {
310
+ env = raw.env;
311
+ }
312
+ }
313
+ return {
314
+ id: typeof id === "string" ? id.trim() : "",
315
+ run,
316
+ mode,
317
+ scope,
318
+ baseline,
319
+ env,
320
+ };
321
+ }
322
+ /**
323
+ * Validate the `gates:` block (version 2 only). Every schema error names the
324
+ * offending field; a duplicate id is reported once. Returns the parsed gates
325
+ * (the caller discards them all if `errors` is non-empty — fail closed).
326
+ */
327
+ export function validateGates(raw, errors) {
328
+ if (!Array.isArray(raw)) {
329
+ errors.push({ path: "gates", message: "gates must be a non-empty list" });
330
+ return [];
331
+ }
332
+ if (raw.length === 0) {
333
+ errors.push({ path: "gates", message: "gates must be a non-empty list" });
334
+ return [];
335
+ }
336
+ const gates = [];
337
+ const seen = new Set();
338
+ raw.forEach((item, index) => {
339
+ const gate = validateGate(item, index, errors);
340
+ if (!gate)
341
+ return;
342
+ if (gate.id) {
343
+ if (seen.has(gate.id)) {
344
+ errors.push({
345
+ path: `gates[${index}].id`,
346
+ message: `duplicate gate id "${gate.id}"`,
347
+ });
348
+ }
349
+ seen.add(gate.id);
350
+ }
351
+ gates.push(gate);
352
+ });
353
+ return gates;
354
+ }
205
355
  // =============================================================================
206
356
  // Public API
207
357
  // =============================================================================
@@ -226,23 +376,54 @@ export function parseCIConfig(rawYaml, source) {
226
376
  if (!isRecord(doc)) {
227
377
  throw new CIConfigError([{ path: "<document>", message: "config must be a YAML object/mapping" }], source);
228
378
  }
229
- for (const key of Object.keys(doc)) {
230
- if (!TOP_LEVEL_FIELDS.has(key)) {
231
- errors.push({ path: key, message: `unknown field "${key}"` });
232
- }
233
- }
234
- if (doc.version !== CI_CONFIG_VERSION) {
379
+ // Read the version FIRST: the allowed-key set is a function of the accepted
380
+ // version, so `gates` under v1 reports the named "gates require version: 2"
381
+ // error, never the generic unknown-field error (JDA-006).
382
+ const version = typeof doc.version === "number" && CI_CONFIG_VERSIONS.includes(doc.version)
383
+ ? doc.version
384
+ : undefined;
385
+ if (version === undefined) {
235
386
  errors.push({
236
387
  path: "version",
237
- message: `version is required and must be the number ${CI_CONFIG_VERSION}`,
388
+ message: `version is required and must be one of: ${CI_CONFIG_VERSIONS.join(", ")}`,
238
389
  });
239
390
  }
240
- if (!Array.isArray(doc.runners) || doc.runners.length === 0) {
391
+ const isV2 = version === 2;
392
+ const hasGates = doc.gates !== undefined;
393
+ for (const key of Object.keys(doc)) {
394
+ if (TOP_LEVEL_FIELDS.has(key))
395
+ continue;
396
+ if (key === "gates") {
397
+ // `gates` is only a known key under v2; under any other version it is a
398
+ // named schema error that takes precedence over the generic path.
399
+ if (!isV2) {
400
+ errors.push({ path: "gates", message: "gates require version: 2" });
401
+ }
402
+ continue;
403
+ }
404
+ errors.push({ path: key, message: `unknown field "${key}"` });
405
+ }
406
+ if (isV2) {
407
+ // v2: runners OPTIONAL when gates present; NEITHER runners nor gates fails
408
+ // closed (nothing to run).
409
+ if (!hasGates &&
410
+ (!Array.isArray(doc.runners) || doc.runners.length === 0)) {
411
+ errors.push({
412
+ path: "runners",
413
+ message: "a version 2 config must declare runners or gates (nothing to run otherwise)",
414
+ });
415
+ }
416
+ }
417
+ else if (!Array.isArray(doc.runners) || doc.runners.length === 0) {
418
+ // v1 (and invalid-version) still hard-require a non-empty runners list.
241
419
  errors.push({
242
420
  path: "runners",
243
421
  message: "runners is required and must be a non-empty list",
244
422
  });
245
423
  }
424
+ // Gates are validated ONLY under v2 — a v1+gates config is already rejected
425
+ // above and must not surface a second, confusing wave of gate-field errors.
426
+ const gates = isV2 && hasGates ? validateGates(doc.gates, errors) : [];
246
427
  const runners = [];
247
428
  if (Array.isArray(doc.runners)) {
248
429
  doc.runners.forEach((raw, index) => {
@@ -266,7 +447,12 @@ export function parseCIConfig(rawYaml, source) {
266
447
  if (errors.length > 0) {
267
448
  throw new CIConfigError(errors, source);
268
449
  }
269
- return { version: CI_CONFIG_VERSION, runners };
450
+ // `version` is defined here (an undefined version pushed an error above and
451
+ // would have thrown). A v1 config carries NO gates key — byte-identical shape.
452
+ const config = { version: version ?? CI_CONFIG_VERSION, runners };
453
+ if (gates.length > 0)
454
+ config.gates = gates;
455
+ return config;
270
456
  }
271
457
  /**
272
458
  * Load and validate a CI config file. Fails closed: a missing file or any
@@ -189,6 +189,20 @@ export async function runInContainer(options) {
189
189
  // detection happens on this path — ever.
190
190
  const imageName = image;
191
191
  const isInteractive = process.stdin.isTTY && stream;
192
+ // ENV-1: match the container process to the HOST user. The images bake a
193
+ // `runner` user whose uid depends on the base (1001 on node:22-slim, where
194
+ // uid 1000 is already `node`). When that uid differs from the host uid,
195
+ // everything the container writes to the bind-mounted workspace (dist/,
196
+ // node_modules/.vite-temp, build output) lands owned by the wrong user,
197
+ // and the host's local vitest then fails with EACCES. Running as the host
198
+ // uid:gid makes artifacts host-owned — no chown dance, ever. An explicit
199
+ // `user` override still wins (e.g. a caller that needs root). getuid/getgid
200
+ // are undefined on non-POSIX platforms (Windows); there we omit the flag
201
+ // and keep the image default.
202
+ const uid = process.getuid?.();
203
+ const gid = process.getgid?.();
204
+ const runAsUser = user ??
205
+ (uid !== undefined && gid !== undefined ? `${uid}:${gid}` : undefined);
192
206
  // Use --mount instead of -v: the -v form parses the value as a single
193
207
  // "src:dst[:opt]" colon-separated string, which breaks (and could be
194
208
  // hijacked) when projectDir itself contains a colon. --mount takes
@@ -201,7 +215,7 @@ export async function runInContainer(options) {
201
215
  "30",
202
216
  "--entrypoint",
203
217
  "",
204
- ...(user ? ["--user", user] : []),
218
+ ...(runAsUser ? ["--user", runAsUser] : []),
205
219
  "--mount",
206
220
  `type=bind,source=${projectDir},target=/home/runner/work`,
207
221
  "-e",
@@ -237,6 +251,11 @@ export async function runInContainer(options) {
237
251
  */
238
252
  export async function openShell(projectDir, image) {
239
253
  const imageName = image;
254
+ // ENV-1: run the interactive shell as the host user too, so anything
255
+ // written from the debug shell stays host-owned. See runInContainer.
256
+ const uid = process.getuid?.();
257
+ const gid = process.getgid?.();
258
+ const runAsUser = uid !== undefined && gid !== undefined ? `${uid}:${gid}` : undefined;
240
259
  await new Promise((resolve, reject) => {
241
260
  const proc = spawn("docker", [
242
261
  "run",
@@ -244,6 +263,7 @@ export async function openShell(projectDir, image) {
244
263
  "-it",
245
264
  "--entrypoint",
246
265
  "",
266
+ ...(runAsUser ? ["--user", runAsUser] : []),
247
267
  // --mount is colon-safe; see runInContainer for the rationale.
248
268
  "--mount",
249
269
  `type=bind,source=${projectDir},target=/home/runner/work`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {