javi-forge 1.9.0 → 1.9.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.
- package/dist/commands/ci.js +71 -20
- package/package.json +1 -1
package/dist/commands/ci.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import fs from "fs-extra";
|
|
6
7
|
import { HOOK_ASSETS_DIR } from "../constants.js";
|
|
@@ -284,13 +285,21 @@ async function isGhaggaAvailable() {
|
|
|
284
285
|
function report(onStep, id, label, status, detail) {
|
|
285
286
|
onStep({ id, label, status, detail });
|
|
286
287
|
}
|
|
287
|
-
/**
|
|
288
|
+
/**
|
|
289
|
+
* Detect-step label: legacy format for auto, explicit otherwise.
|
|
290
|
+
*
|
|
291
|
+
* INVARIANT (holds for every `ResolvedRunners` value): `runners` is never
|
|
292
|
+
* empty. The config path rejects an empty list before resolving
|
|
293
|
+
* (`src/lib/ci-config.ts` — "runners is required and must be a non-empty
|
|
294
|
+
* list"), and the `auto` and `stack-override` paths each yield exactly one
|
|
295
|
+
* runner. `runners[0]` therefore needs no fallback.
|
|
296
|
+
*/
|
|
288
297
|
function describeRunners(resolved) {
|
|
289
298
|
const first = resolved.runners[0];
|
|
290
|
-
if (resolved.source === "auto"
|
|
299
|
+
if (resolved.source === "auto") {
|
|
291
300
|
return `Stack: ${first.stack} (${first.buildTool})`;
|
|
292
301
|
}
|
|
293
|
-
if (resolved.source === "stack-override"
|
|
302
|
+
if (resolved.source === "stack-override") {
|
|
294
303
|
return `Stack: ${first.stack} (${first.buildTool}, --stack override)`;
|
|
295
304
|
}
|
|
296
305
|
const summary = resolved.runners
|
|
@@ -317,14 +326,16 @@ export async function runCI(options, onStep) {
|
|
|
317
326
|
}
|
|
318
327
|
// Legacy single-runner view for the zero-config auto path. Keeping this
|
|
319
328
|
// shape guarantees single-stack repositories behave exactly as before.
|
|
329
|
+
// `runners[0]` is always present — see the invariant on `describeRunners`.
|
|
330
|
+
// The command lists CAN be empty, so those keep their `?? null`.
|
|
320
331
|
const primary = resolved.runners[0];
|
|
321
332
|
const stackInfo = {
|
|
322
|
-
stackType: primary
|
|
323
|
-
buildTool: primary
|
|
324
|
-
javaVersion: primary
|
|
325
|
-
lintCmd: primary
|
|
326
|
-
compileCmd: primary
|
|
327
|
-
testCmd: primary
|
|
333
|
+
stackType: primary.stack,
|
|
334
|
+
buildTool: primary.buildTool,
|
|
335
|
+
javaVersion: primary.javaVersion,
|
|
336
|
+
lintCmd: primary.lintCmds[0] ?? null,
|
|
337
|
+
compileCmd: primary.compileCmds[0] ?? null,
|
|
338
|
+
testCmd: primary.testCmds[0] ?? null,
|
|
328
339
|
};
|
|
329
340
|
// ── Detect mode ─────────────────────────────────────────────────────────────
|
|
330
341
|
if (mode === "detect")
|
|
@@ -876,7 +887,16 @@ async function backupHook(hookPath) {
|
|
|
876
887
|
}
|
|
877
888
|
throw new Error(`could not write the backup ${candidate} (${errorCode(copyErr) || "unknown error"}): ${copyErr instanceof Error ? copyErr.message : String(copyErr)}. The hook was left unchanged.`);
|
|
878
889
|
}
|
|
879
|
-
|
|
890
|
+
// The mode restore addresses the FD of the file THIS call just created,
|
|
891
|
+
// not the path: a symlink planted at `candidate` after the
|
|
892
|
+
// `COPYFILE_EXCL` copy cannot capture the mode change (SEC-1).
|
|
893
|
+
const handle = await fsp.open(candidate, constants.O_RDONLY | O_NOFOLLOW);
|
|
894
|
+
try {
|
|
895
|
+
await handle.chmod(original.mode);
|
|
896
|
+
}
|
|
897
|
+
finally {
|
|
898
|
+
await handle.close();
|
|
899
|
+
}
|
|
880
900
|
return candidate;
|
|
881
901
|
}
|
|
882
902
|
throw new Error(`could not back up ${hookPath}: every candidate backup path is taken. The hook was left unchanged.`);
|
|
@@ -900,13 +920,51 @@ function assertHookManifestEntry(entry, hookName) {
|
|
|
900
920
|
? `has no "${hookName}" entry`
|
|
901
921
|
: typeof entry.sha256 !== "string" ||
|
|
902
922
|
typeof entry.version !== "number" ||
|
|
903
|
-
!Array.isArray(entry.historical)
|
|
904
|
-
|
|
923
|
+
!Array.isArray(entry.historical) ||
|
|
924
|
+
!entry.historical.every((h) => typeof h?.sha256 === "string")
|
|
925
|
+
? `has a malformed "${hookName}" entry (expected version:number, sha256:string, historical:array of {sha256:string})`
|
|
905
926
|
: "";
|
|
906
927
|
if (problem !== "") {
|
|
907
928
|
throw new Error(`${MANIFEST_PATH} ${problem}. The javi-forge install is incomplete; ${REINSTALL_REMEDY}.`);
|
|
908
929
|
}
|
|
909
930
|
}
|
|
931
|
+
/**
|
|
932
|
+
* `O_NOFOLLOW` on the platforms that have it, a no-op flag elsewhere. Windows
|
|
933
|
+
* has no such flag AND no hook-symlink threat worth the crash of an
|
|
934
|
+
* `undefined` in a bitmask.
|
|
935
|
+
*/
|
|
936
|
+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
937
|
+
/**
|
|
938
|
+
* Write the hook through a FILE DESCRIPTOR, never through the path (SEC-1).
|
|
939
|
+
*
|
|
940
|
+
* `classifyHookPath` established moments earlier that `hookPath` is a regular
|
|
941
|
+
* file, but a local attacker with write access to `.git/hooks` could swap a
|
|
942
|
+
* symlink in during that window and turn the write into an arbitrary-write
|
|
943
|
+
* primitive. `O_NOFOLLOW` closes it: if the path IS a symlink when the write
|
|
944
|
+
* finally happens, `open` fails with `ELOOP` and the hook surfaces as a named
|
|
945
|
+
* per-hook error instead of clobbering the link target. Everything after the
|
|
946
|
+
* open then addresses the FD — `fchmod`, not a second path lookup — so the
|
|
947
|
+
* bytes and the mode provably land on the same inode.
|
|
948
|
+
*
|
|
949
|
+
* The mode argument applies ONLY when the file is CREATED: overwriting an
|
|
950
|
+
* existing 0644 hook would leave it non-executable and git would silently skip
|
|
951
|
+
* it. The `fchmod` is therefore unconditional, on every write path, which also
|
|
952
|
+
* makes the final mode independent of the umask.
|
|
953
|
+
*
|
|
954
|
+
* NOT covered (deferred by decision in SEC-1): a hardlink to a victim file
|
|
955
|
+
* survives `O_NOFOLLOW`. On modern Linux `fs.protected_hardlinks=1` blocks the
|
|
956
|
+
* cross-owner case; an `nlink > 1` refusal is parked in the backlog.
|
|
957
|
+
*/
|
|
958
|
+
async function writeHookFile(hookPath, content) {
|
|
959
|
+
const handle = await fsp.open(hookPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW, HOOK_MODE);
|
|
960
|
+
try {
|
|
961
|
+
await handle.writeFile(content, "utf8");
|
|
962
|
+
await handle.chmod(HOOK_MODE);
|
|
963
|
+
}
|
|
964
|
+
finally {
|
|
965
|
+
await handle.close();
|
|
966
|
+
}
|
|
967
|
+
}
|
|
910
968
|
/**
|
|
911
969
|
* Bring an already-current hook back to mode 0755 WITHOUT writing bytes.
|
|
912
970
|
*
|
|
@@ -997,14 +1055,7 @@ export async function installCIHooks(projectDir, options = {}) {
|
|
|
997
1055
|
backups.push(await backupHook(hookPath));
|
|
998
1056
|
}
|
|
999
1057
|
const body = await readHookBody(name);
|
|
1000
|
-
|
|
1001
|
-
// an existing 0644 hook would leave it non-executable and git would
|
|
1002
|
-
// silently skip it. The chmod is therefore unconditional, on every
|
|
1003
|
-
// write path, which also makes the mode independent of the umask.
|
|
1004
|
-
await fs.writeFile(hookPath, renderHook(body, name, entry), {
|
|
1005
|
-
mode: HOOK_MODE,
|
|
1006
|
-
});
|
|
1007
|
-
await fs.chmod(hookPath, HOOK_MODE);
|
|
1058
|
+
await writeHookFile(hookPath, renderHook(body, name, entry));
|
|
1008
1059
|
// `upgraded` means "javi-forge content was replaced by newer
|
|
1009
1060
|
// javi-forge content". A forced overwrite of someone else's file is a
|
|
1010
1061
|
// fresh install, not an upgrade.
|