javi-forge 1.20.0 → 1.21.1

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,7 +8,7 @@ import fs from "fs-extra";
8
8
  import { HOOK_ASSETS_DIR } from "../constants.js";
9
9
  import { CI_STACKS, findCIConfig, GATE_MODE, GATE_SCOPE, loadCIConfig, } from "../lib/ci-config.js";
10
10
  import { refreshContextDir } from "../lib/context.js";
11
- import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
11
+ import { CONTAINER_WORKDIR, ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
12
12
  import { execFileAsync } from "../lib/exec.js";
13
13
  import { changedFiles, resolveBaseRef } from "../lib/git-diff.js";
14
14
  // =============================================================================
@@ -880,9 +880,32 @@ const GATE_TIMEOUT_EXIT_CODE = 124;
880
880
  * would corrupt line-based parsing on the gate side. This is a low-likelihood
881
881
  * edge — repo paths with embedded newlines are pathological — and is accepted as
882
882
  * a documented caveat rather than switched to NUL-joining, which would force
883
- * every gate consumer to change its parser.
883
+ * every gate consumer to change its parser. A NUL-joined variant is also
884
+ * infeasible for a deeper reason (see {@link CHANGED_FILES_ABS_ENV}): a NUL byte
885
+ * cannot be carried in an environment variable at all.
884
886
  */
885
887
  const CHANGED_FILES_ENV = "JAVI_FORGE_CHANGED_FILES";
888
+ /**
889
+ * Env var carrying the SAME changed-file set as {@link CHANGED_FILES_ENV}, but as
890
+ * ABSOLUTE paths, newline-joined, in the SAME order.
891
+ *
892
+ * WHY absolute and not "cwd-relative": a literal cwd-relative variant is
893
+ * impossible for the engine to produce. A gate chooses its own working directory
894
+ * at runtime (its script may `cd` anywhere), while at injection time the engine's
895
+ * cwd is always the repo root — so a "cwd-relative" var would just equal the
896
+ * repo-root-relative {@link CHANGED_FILES_ENV}. The cwd-INDEPENDENT form that lets
897
+ * a gate resolve changed files from ANY working directory is absolute paths.
898
+ *
899
+ * CONTEXT-DEPENDENT base (JDA-001): this is the ONE injected var whose VALUE must
900
+ * differ between execution modes, so it is NOT placed in the shared `injected`
901
+ * map. A NATIVE gate runs at the host repo root, so its base is the HOST
902
+ * `<projectDir>/<relpath>`. A CONTAINER gate runs with the repo bind-mounted at
903
+ * `CONTAINER_WORKDIR` (docker.ts mount target + WORKDIR), so its base is
904
+ * `<CONTAINER_WORKDIR>/<relpath>` — the HOST projectDir does not exist inside the
905
+ * container. Same var NAME in both; each gate sees the value valid for its own
906
+ * execution context.
907
+ */
908
+ const CHANGED_FILES_ABS_ENV = "JAVI_FORGE_CHANGED_FILES_ABS";
886
909
  /** Env var carrying a gate's optional baseline artifact path. */
887
910
  const BASELINE_ENV = "JAVI_FORGE_BASELINE";
888
911
  /**
@@ -942,7 +965,7 @@ async function runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv) {
942
965
  projectDir,
943
966
  image: gate.image,
944
967
  // Gates run at the mount root (native gates run at the repo root).
945
- command: `cd /home/runner/work && ${cmd}`,
968
+ command: `cd ${CONTAINER_WORKDIR} && ${cmd}`,
946
969
  timeout: gate.timeout, // undefined ⇒ unbounded (docker.ts gate 7)
947
970
  env: containerEnv,
948
971
  stream: true,
@@ -1063,19 +1086,58 @@ docker, onOutcome) {
1063
1086
  }
1064
1087
  gateChangedFiles = scope.files;
1065
1088
  injected[CHANGED_FILES_ENV] = scope.files.join("\n");
1089
+ // ABSOLUTE-path variant (GATE-4 / JDA-001) is DELIBERATELY NOT put in the
1090
+ // shared `injected` map: it is the ONE var whose value MUST differ between
1091
+ // native and container execution. A native gate runs at the host repo root
1092
+ // (cwd = projectDir), so its base is the HOST projectDir. A containerized
1093
+ // gate runs with the repo bind-mounted at CONTAINER_WORKDIR (docker.ts mount
1094
+ // target + WORKDIR), so `<projectDir>/<relpath>` would point at a
1095
+ // non-existent HOST path inside the container. Instead it is computed twice
1096
+ // below — once per execution context — and added to nativeEnv / containerEnv
1097
+ // separately. See the nativeEnv / containerEnv construction.
1098
+ // NUL-joined variant (GATE-5) is DELIBERATELY NOT INJECTED. A `git -z`
1099
+ // style NUL separator is unambiguous for paths containing a literal
1100
+ // newline, BUT a NUL byte cannot live in an environment variable: execve's
1101
+ // `environ` is an array of NUL-terminated C strings, so a NUL inside a
1102
+ // value truncates it. Node's child_process refuses it outright — it throws
1103
+ // ERR_INVALID_ARG_VALUE ("must be a string without null bytes") for a NUL
1104
+ // in BOTH an argv element (container `-e KEY=VALUE`) AND a spawn env-map
1105
+ // value (native gates). Empirically verified against Node + `docker run`.
1106
+ // Injecting JAVI_FORGE_CHANGED_FILES_Z would therefore CRASH every
1107
+ // scope:changed gate at spawn time (native and containerized alike), so it
1108
+ // is omitted entirely rather than shipped broken. The documented caveat on
1109
+ // CHANGED_FILES_ENV (paths with embedded newlines corrupt line parsing)
1110
+ // stands; the cwd-independent alternative that IS deliverable is
1111
+ // CHANGED_FILES_ABS_ENV above.
1066
1112
  }
1067
1113
  if (gate.baseline !== undefined) {
1068
1114
  injected[BASELINE_ENV] = gate.baseline;
1069
1115
  }
1070
1116
  const gateOverrides = gate.env ?? {};
1117
+ // Context-dependent ABSOLUTE-path variant (JDA-001): the SAME env var NAME,
1118
+ // but a DIFFERENT base per execution mode. Native gates resolve against the
1119
+ // HOST projectDir (cwd = repo root); container gates resolve against
1120
+ // CONTAINER_WORKDIR — the exact mount target from docker.ts (single source of
1121
+ // truth), so the "absolute" path is valid INSIDE the container. Both use the
1122
+ // SAME relpaths in the SAME order, newline-joined, only for scope:changed
1123
+ // (gateChangedFiles defined). path.join for native normalizes a trailing
1124
+ // slash; the container form is a POSIX join under a known-absolute root.
1125
+ const nativeAbs = gateChangedFiles &&
1126
+ gateChangedFiles.map((rel) => path.join(projectDir, rel)).join("\n");
1127
+ const containerAbs = gateChangedFiles &&
1128
+ gateChangedFiles.map((rel) => `${CONTAINER_WORKDIR}/${rel}`).join("\n");
1071
1129
  const nativeEnv = {
1072
1130
  ...baseEnv,
1073
1131
  ...injected,
1132
+ ...(nativeAbs !== undefined && { [CHANGED_FILES_ABS_ENV]: nativeAbs }),
1074
1133
  ...gateOverrides,
1075
1134
  };
1076
1135
  const containerEnv = {
1077
1136
  CI: "true",
1078
1137
  ...injected,
1138
+ ...(containerAbs !== undefined && {
1139
+ [CHANGED_FILES_ABS_ENV]: containerAbs,
1140
+ }),
1079
1141
  ...gateOverrides,
1080
1142
  };
1081
1143
  let exitCode = 0;
@@ -77,6 +77,35 @@ const RUNNER_FIELDS = new Set([
77
77
  function isRecord(value) {
78
78
  return typeof value === "object" && value !== null && !Array.isArray(value);
79
79
  }
80
+ /**
81
+ * Validate an optional container `image` ref shared by the runner and gate
82
+ * paths (IMG-1). Rejects non-string / empty-after-trim and leading-dash refs
83
+ * (an image like "--privileged" or "-v /:/host" would be parsed by `docker run`
84
+ * as a FLAG, not an image, if it reached argv — JDB-004) with a named error at
85
+ * `fieldPath`. Returns the TRIMMED value to store, so a whitespace-padded ref
86
+ * fails cleanly at validation instead of reaching docker argv untrimmed.
87
+ * Returns undefined when the field is absent or invalid.
88
+ */
89
+ function validateImageRef(value, fieldPath, errors) {
90
+ if (value === undefined)
91
+ return undefined;
92
+ if (typeof value !== "string" || !value.trim()) {
93
+ errors.push({
94
+ path: fieldPath,
95
+ message: "image must be a non-empty string",
96
+ });
97
+ return undefined;
98
+ }
99
+ const trimmed = value.trim();
100
+ if (trimmed.startsWith("-")) {
101
+ errors.push({
102
+ path: fieldPath,
103
+ message: "image must not start with '-' (would be parsed as a docker flag)",
104
+ });
105
+ return undefined;
106
+ }
107
+ return trimmed;
108
+ }
80
109
  function normalizeCommands(value, fieldPath, errors) {
81
110
  if (value === undefined)
82
111
  return [];
@@ -159,18 +188,7 @@ function validateRunner(raw, index, errors) {
159
188
  }
160
189
  }
161
190
  }
162
- let image;
163
- if (raw.image !== undefined) {
164
- if (typeof raw.image !== "string" || !raw.image.trim()) {
165
- errors.push({
166
- path: `${base}.image`,
167
- message: "image must be a non-empty string",
168
- });
169
- }
170
- else {
171
- image = raw.image;
172
- }
173
- }
191
+ const image = validateImageRef(raw.image, `${base}.image`, errors);
174
192
  let buildContext;
175
193
  if (raw["build-context"] !== undefined) {
176
194
  if (typeof raw["build-context"] !== "string" ||
@@ -319,28 +337,7 @@ function validateGate(raw, index, errors) {
319
337
  env = raw.env;
320
338
  }
321
339
  }
322
- let image;
323
- if (raw.image !== undefined) {
324
- if (typeof raw.image !== "string" || !raw.image.trim()) {
325
- errors.push({
326
- path: `${base}.image`,
327
- message: "image must be a non-empty string",
328
- });
329
- }
330
- else if (raw.image.trim().startsWith("-")) {
331
- // Harden against docker-flag injection: an image ref like "--privileged"
332
- // or "-v /:/host" would be parsed by `docker run` as a FLAG, not an
333
- // image argument, if it reached argv. Reject leading-dash refs at
334
- // validation with a named error (JDB-004).
335
- errors.push({
336
- path: `${base}.image`,
337
- message: "image must not start with '-' (would be parsed as a docker flag)",
338
- });
339
- }
340
- else {
341
- image = raw.image;
342
- }
343
- }
340
+ const image = validateImageRef(raw.image, `${base}.image`, errors);
344
341
  let timeout;
345
342
  if (raw.timeout !== undefined) {
346
343
  if (typeof raw.timeout !== "number" ||
@@ -1,4 +1,12 @@
1
1
  import type { Stack } from "../types/index.js";
2
+ /**
3
+ * The in-container path the repo is bind-mounted at (and the WORKDIR gates run
4
+ * from). This is the SINGLE source of truth for the mount target: the `--mount`
5
+ * bind target below and the container-side `JAVI_FORGE_CHANGED_FILES_ABS` base
6
+ * in ci.ts MUST reference this same constant so they can never drift — the
7
+ * container absolute-path invariant is "abs base === mount target".
8
+ */
9
+ export declare const CONTAINER_WORKDIR = "/home/runner/work";
2
10
  export interface DockerRunOptions {
3
11
  /** Absolute path to mount as /home/runner/work */
4
12
  projectDir: string;
@@ -4,6 +4,17 @@ import path from "node:path";
4
4
  import fs from "fs-extra";
5
5
  import { execFileAsync } from "./exec.js";
6
6
  // =============================================================================
7
+ // Constants
8
+ // =============================================================================
9
+ /**
10
+ * The in-container path the repo is bind-mounted at (and the WORKDIR gates run
11
+ * from). This is the SINGLE source of truth for the mount target: the `--mount`
12
+ * bind target below and the container-side `JAVI_FORGE_CHANGED_FILES_ABS` base
13
+ * in ci.ts MUST reference this same constant so they can never drift — the
14
+ * container absolute-path invariant is "abs base === mount target".
15
+ */
16
+ export const CONTAINER_WORKDIR = "/home/runner/work";
17
+ // =============================================================================
7
18
  // Image name
8
19
  // =============================================================================
9
20
  export function getImageName(stack) {
@@ -236,7 +247,7 @@ export async function runInContainer(options) {
236
247
  "",
237
248
  ...(runAsUser ? ["--user", runAsUser] : []),
238
249
  "--mount",
239
- `type=bind,source=${projectDir},target=/home/runner/work`,
250
+ `type=bind,source=${projectDir},target=${CONTAINER_WORKDIR}`,
240
251
  ...envArgs,
241
252
  imageName,
242
253
  "bash",
@@ -328,13 +339,13 @@ export async function openShell(projectDir, image) {
328
339
  ...(runAsUser ? ["--user", runAsUser] : []),
329
340
  // --mount is colon-safe; see runInContainer for the rationale.
330
341
  "--mount",
331
- `type=bind,source=${projectDir},target=/home/runner/work`,
342
+ `type=bind,source=${projectDir},target=${CONTAINER_WORKDIR}`,
332
343
  "-e",
333
344
  "CI=true",
334
345
  imageName,
335
346
  "bash",
336
347
  "-c",
337
- "cd /home/runner/work && exec bash",
348
+ `cd ${CONTAINER_WORKDIR} && exec bash`,
338
349
  ], { stdio: "inherit" });
339
350
  proc.on("close", () => resolve());
340
351
  proc.on("error", reject);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.20.0",
3
+ "version": "1.21.1",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {