javi-forge 1.6.1 → 1.7.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.
Files changed (159) hide show
  1. package/README.md +45 -1
  2. package/ci-local/README.md +56 -11
  3. package/ci-local/ci-local.ps1 +445 -0
  4. package/ci-local/ci-local.sh +52 -9
  5. package/ci-local/docker/python.Dockerfile +7 -0
  6. package/ci-local/hooks/commit-msg +120 -44
  7. package/ci-local/hooks/commit-msg.test.sh +286 -0
  8. package/ci-local/hooks/pre-commit +9 -1
  9. package/ci-local/hooks/pre-push +13 -2
  10. package/ci-local/install.ps1 +367 -0
  11. package/ci-local/install.sh +151 -5
  12. package/ci-local/uninstall.ps1 +85 -0
  13. package/ci-local/uninstall.sh +95 -0
  14. package/dist/cli/dispatch/ci.d.ts +12 -0
  15. package/dist/cli/dispatch/ci.js +38 -0
  16. package/dist/cli/dispatch/security.d.ts +11 -0
  17. package/dist/cli/dispatch/security.js +68 -0
  18. package/dist/cli/dispatch/simple-renderers.d.ts +16 -0
  19. package/dist/cli/dispatch/simple-renderers.js +65 -0
  20. package/dist/cli/dispatch/skill-publish.d.ts +15 -0
  21. package/dist/cli/dispatch/skill-publish.js +44 -0
  22. package/dist/cli/dispatch/skills-cmd.d.ts +13 -0
  23. package/dist/cli/dispatch/skills-cmd.js +93 -0
  24. package/dist/cli/dispatch/tdd.d.ts +11 -0
  25. package/dist/cli/dispatch/tdd.js +45 -0
  26. package/dist/cli/dispatch/types.d.ts +29 -0
  27. package/dist/cli/dispatch/types.js +2 -0
  28. package/dist/cli/dispatch/workflow.d.ts +11 -0
  29. package/dist/cli/dispatch/workflow.js +67 -0
  30. package/dist/cli/help.d.ts +127 -0
  31. package/dist/cli/help.js +138 -0
  32. package/dist/cli/runtime.d.ts +16 -0
  33. package/dist/cli/runtime.js +38 -0
  34. package/dist/cli/validators.d.ts +17 -0
  35. package/dist/cli/validators.js +35 -0
  36. package/dist/commands/analyze.js +1 -3
  37. package/dist/commands/ci.d.ts +47 -0
  38. package/dist/commands/ci.js +380 -63
  39. package/dist/commands/doctor.js +2 -4
  40. package/dist/commands/init/report.d.ts +13 -0
  41. package/dist/commands/init/report.js +13 -0
  42. package/dist/commands/init/steps/agent-skills.d.ts +13 -0
  43. package/dist/commands/init/steps/agent-skills.js +45 -0
  44. package/dist/commands/init/steps/ai-sync.d.ts +15 -0
  45. package/dist/commands/init/steps/ai-sync.js +62 -0
  46. package/dist/commands/init/steps/ci.d.ts +24 -0
  47. package/dist/commands/init/steps/ci.js +74 -0
  48. package/dist/commands/init/steps/claude-md.d.ts +14 -0
  49. package/dist/commands/init/steps/claude-md.js +54 -0
  50. package/dist/commands/init/steps/code-graph.d.ts +16 -0
  51. package/dist/commands/init/steps/code-graph.js +68 -0
  52. package/dist/commands/init/steps/context-dir.d.ts +13 -0
  53. package/dist/commands/init/steps/context-dir.js +47 -0
  54. package/dist/commands/init/steps/docker-deploy.d.ts +16 -0
  55. package/dist/commands/init/steps/docker-deploy.js +60 -0
  56. package/dist/commands/init/steps/ghagga.d.ts +15 -0
  57. package/dist/commands/init/steps/ghagga.js +58 -0
  58. package/dist/commands/init/steps/git.d.ts +25 -0
  59. package/dist/commands/init/steps/git.js +81 -0
  60. package/dist/commands/init/steps/gitignore.d.ts +13 -0
  61. package/dist/commands/init/steps/gitignore.js +39 -0
  62. package/dist/commands/init/steps/local-ai.d.ts +14 -0
  63. package/dist/commands/init/steps/local-ai.js +53 -0
  64. package/dist/commands/init/steps/manifest.d.ts +13 -0
  65. package/dist/commands/init/steps/manifest.js +58 -0
  66. package/dist/commands/init/steps/memory.d.ts +15 -0
  67. package/dist/commands/init/steps/memory.js +60 -0
  68. package/dist/commands/init/steps/mock.d.ts +13 -0
  69. package/dist/commands/init/steps/mock.js +64 -0
  70. package/dist/commands/init/steps/sdd.d.ts +12 -0
  71. package/dist/commands/init/steps/sdd.js +40 -0
  72. package/dist/commands/init/steps/security.d.ts +28 -0
  73. package/dist/commands/init/steps/security.js +103 -0
  74. package/dist/commands/init/types.d.ts +30 -0
  75. package/dist/commands/init/types.js +2 -0
  76. package/dist/commands/init.d.ts +5 -4
  77. package/dist/commands/init.js +42 -630
  78. package/dist/commands/llmstxt.js +1 -1
  79. package/dist/commands/plugin.js +1 -1
  80. package/dist/commands/security.js +23 -4
  81. package/dist/commands/skills/analysis.d.ts +19 -0
  82. package/dist/commands/skills/analysis.js +211 -0
  83. package/dist/commands/skills/benchmark.d.ts +6 -0
  84. package/dist/commands/skills/benchmark.js +82 -0
  85. package/dist/commands/skills/constants.d.ts +10 -0
  86. package/dist/commands/skills/constants.js +27 -0
  87. package/dist/commands/skills/directives.d.ts +21 -0
  88. package/dist/commands/skills/directives.js +101 -0
  89. package/dist/commands/skills/parsing.d.ts +16 -0
  90. package/dist/commands/skills/parsing.js +108 -0
  91. package/dist/commands/skills/rules.d.ts +5 -0
  92. package/dist/commands/skills/rules.js +32 -0
  93. package/dist/commands/skills/scoring.d.ts +47 -0
  94. package/dist/commands/skills/scoring.js +306 -0
  95. package/dist/commands/skills.d.ts +7 -125
  96. package/dist/commands/skills.js +8 -874
  97. package/dist/commands/tdd-pipeline.js +1 -1
  98. package/dist/commands/tdd.js +1 -1
  99. package/dist/commands/workflow.js +2 -0
  100. package/dist/constants.js +2 -2
  101. package/dist/index.js +26 -473
  102. package/dist/lib/agent-skills.js +1 -1
  103. package/dist/{commands → lib}/atlassian-mcp.js +2 -2
  104. package/dist/lib/auto-skill-install.js +2 -3
  105. package/dist/lib/auto-wire.js +7 -7
  106. package/dist/lib/ci-config.d.ts +65 -0
  107. package/dist/lib/ci-config.js +297 -0
  108. package/dist/lib/claudemd.js +1 -1
  109. package/dist/lib/codex-export.js +4 -8
  110. package/dist/lib/common.js +2 -2
  111. package/dist/lib/context.js +2 -2
  112. package/dist/{commands → lib}/crash-recovery.js +4 -4
  113. package/dist/lib/docker.d.ts +22 -1
  114. package/dist/lib/docker.js +57 -49
  115. package/dist/lib/exec.d.ts +10 -0
  116. package/dist/lib/exec.js +11 -0
  117. package/dist/lib/frontmatter.js +2 -2
  118. package/dist/{commands → lib}/harness-audit.js +1 -1
  119. package/dist/{commands → lib}/parallel-batch.js +1 -3
  120. package/dist/lib/plugin.js +2 -4
  121. package/dist/{commands → lib}/secret-scanner.js +3 -1
  122. package/dist/{commands → lib}/security-analysis.js +4 -2
  123. package/dist/lib/skill-publish.js +23 -5
  124. package/dist/{commands → lib}/skill-scanner.d.ts +1 -1
  125. package/dist/{commands → lib}/skill-scanner.js +3 -3
  126. package/dist/lib/stack-detector.js +3 -3
  127. package/dist/{commands → lib}/supply-chain.js +1 -1
  128. package/dist/lib/template.js +2 -2
  129. package/dist/lib/workflow/discovery.d.ts +8 -0
  130. package/dist/lib/workflow/discovery.js +18 -1
  131. package/dist/lib/workflow/parser.js +15 -15
  132. package/dist/lib/workflow/validator.js +4 -4
  133. package/dist/tasks/index.d.ts +3 -3
  134. package/dist/tasks/index.js +3 -3
  135. package/dist/tasks/scaffold-tasks.d.ts +1 -1
  136. package/dist/tasks/scaffold-tasks.js +10 -10
  137. package/dist/tasks/task-id.d.ts +1 -1
  138. package/dist/tasks/task-id.js +9 -5
  139. package/dist/tasks/task-tracker.d.ts +1 -1
  140. package/dist/tasks/task-tracker.js +7 -7
  141. package/dist/ui/App.js +1 -1
  142. package/dist/ui/CIContext.js +1 -2
  143. package/dist/ui/Header.js +4 -4
  144. package/dist/ui/NameInput.js +1 -1
  145. package/dist/ui/Progress.d.ts +1 -1
  146. package/dist/ui/Progress.js +1 -1
  147. package/dist/ui/StackSelector.js +1 -1
  148. package/lib/common.psm1 +167 -0
  149. package/lib/common.sh +30 -0
  150. package/package.json +38 -19
  151. /package/dist/{commands → lib}/atlassian-mcp.d.ts +0 -0
  152. /package/dist/{commands → lib}/crash-recovery.d.ts +0 -0
  153. /package/dist/{commands → lib}/harness-audit.d.ts +0 -0
  154. /package/dist/{commands → lib}/parallel-batch.d.ts +0 -0
  155. /package/dist/{commands → lib}/secret-scanner.d.ts +0 -0
  156. /package/dist/{commands → lib}/security-analysis.d.ts +0 -0
  157. /package/dist/{commands → lib}/supply-chain.d.ts +0 -0
  158. /package/dist/{commands → lib}/team-presets.d.ts +0 -0
  159. /package/dist/{commands → lib}/team-presets.js +0 -0
@@ -1,10 +1,10 @@
1
- import { execFile, spawn } from "child_process";
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
2
3
  import fs from "fs-extra";
3
- import path from "path";
4
- import { promisify } from "util";
4
+ import { CI_STACKS, findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
5
5
  import { refreshContextDir } from "../lib/context.js";
6
- import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
7
- const execFileAsync = promisify(execFile);
6
+ import { ensureImage, getImageName, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
7
+ import { execFileAsync } from "../lib/exec.js";
8
8
  // =============================================================================
9
9
  // Stack detection
10
10
  // =============================================================================
@@ -133,6 +133,136 @@ async function buildCICommands(stack, buildTool, projectDir) {
133
133
  return { lintCmd: null, compileCmd: null, testCmd: null };
134
134
  }
135
135
  }
136
+ function freezeRunner(runner) {
137
+ return Object.freeze({
138
+ ...runner,
139
+ setupCmds: Object.freeze([...runner.setupCmds]),
140
+ lintCmds: Object.freeze([...runner.lintCmds]),
141
+ compileCmds: Object.freeze([...runner.compileCmds]),
142
+ testCmds: Object.freeze([...runner.testCmds]),
143
+ securityCmds: Object.freeze([...runner.securityCmds]),
144
+ requiredTools: Object.freeze([...runner.requiredTools]),
145
+ });
146
+ }
147
+ function freezeRunners(source, runners) {
148
+ return Object.freeze({ source, runners: Object.freeze(runners) });
149
+ }
150
+ /** Build-tool heuristic for a known stack in a given directory. */
151
+ async function detectBuildTool(stack, dir) {
152
+ switch (stack) {
153
+ case "node":
154
+ if (await fs.pathExists(path.join(dir, "pnpm-lock.yaml")))
155
+ return "pnpm";
156
+ if (await fs.pathExists(path.join(dir, "yarn.lock")))
157
+ return "yarn";
158
+ return "npm";
159
+ case "python":
160
+ if (await fs.pathExists(path.join(dir, "uv.lock")))
161
+ return "uv";
162
+ if (await fs.pathExists(path.join(dir, "poetry.lock")))
163
+ return "poetry";
164
+ return "pip";
165
+ case "java-gradle":
166
+ return "gradle";
167
+ case "java-maven":
168
+ return "mvn";
169
+ case "go":
170
+ return "go";
171
+ case "rust":
172
+ return "cargo";
173
+ default:
174
+ return "";
175
+ }
176
+ }
177
+ function toList(command) {
178
+ return command ? [command] : [];
179
+ }
180
+ async function resolveConfiguredRunner(projectDir, config) {
181
+ const runnerDir = path.join(projectDir, config.directory);
182
+ const buildTool = await detectBuildTool(config.stack, runnerDir);
183
+ const defaults = await buildCICommands(config.stack, buildTool, runnerDir);
184
+ return freezeRunner({
185
+ name: config.name,
186
+ stack: config.stack,
187
+ buildTool,
188
+ javaVersion: "21",
189
+ directory: config.directory,
190
+ image: config.image,
191
+ buildContext: config.buildContext,
192
+ setupCmds: config.setup,
193
+ lintCmds: config.lint.length > 0 ? config.lint : toList(defaults.lintCmd),
194
+ compileCmds: config.build.length > 0 ? config.build : toList(defaults.compileCmd),
195
+ testCmds: config.test.length > 0 ? config.test : toList(defaults.testCmd),
196
+ securityCmds: config.security,
197
+ requiredTools: config.requires,
198
+ });
199
+ }
200
+ async function resolveExplicitStackRunner(projectDir, stack) {
201
+ const buildTool = await detectBuildTool(stack, projectDir);
202
+ const defaults = await buildCICommands(stack, buildTool, projectDir);
203
+ return freezeRunner({
204
+ name: stack,
205
+ stack,
206
+ buildTool,
207
+ javaVersion: "21",
208
+ directory: ".",
209
+ setupCmds: [],
210
+ lintCmds: toList(defaults.lintCmd),
211
+ compileCmds: toList(defaults.compileCmd),
212
+ testCmds: toList(defaults.testCmd),
213
+ securityCmds: [],
214
+ requiredTools: [],
215
+ });
216
+ }
217
+ /**
218
+ * Resolve the ordered runner list exactly once per CI run.
219
+ *
220
+ * Precedence (fail closed on ambiguity):
221
+ * 1. --config <path> (or discovered .javi-forge/ci.yaml) → configured runners
222
+ * 2. --stack <stack> → single explicit runner (single-stack repos only)
223
+ * 3. otherwise → single auto-detected runner (zero-config default)
224
+ */
225
+ export async function resolveCIRunners(projectDir, options = {}) {
226
+ const { config, stack } = options;
227
+ if (config && stack) {
228
+ throw new Error("Ambiguous CI options: --config and --stack cannot be used together. " +
229
+ "Use --config for hybrid repositories.");
230
+ }
231
+ if (stack) {
232
+ if (!CI_STACKS.includes(stack)) {
233
+ throw new Error(`Unknown stack "${stack}". Valid stacks: ${CI_STACKS.join(", ")}`);
234
+ }
235
+ return freezeRunners("stack-override", [
236
+ await resolveExplicitStackRunner(projectDir, stack),
237
+ ]);
238
+ }
239
+ const configPath = config ?? (await findCIConfig(projectDir));
240
+ if (configPath) {
241
+ const ciConfig = await loadCIConfig(configPath);
242
+ const runners = [];
243
+ for (const runnerConfig of ciConfig.runners) {
244
+ runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
245
+ }
246
+ return freezeRunners("config", runners);
247
+ }
248
+ // Zero-config default: single auto-detected runner (unchanged behavior).
249
+ const info = await detectCIStack(projectDir);
250
+ return freezeRunners("auto", [
251
+ freezeRunner({
252
+ name: info.stackType,
253
+ stack: info.stackType,
254
+ buildTool: info.buildTool,
255
+ javaVersion: info.javaVersion,
256
+ directory: ".",
257
+ setupCmds: [],
258
+ lintCmds: toList(info.lintCmd),
259
+ compileCmds: toList(info.compileCmd),
260
+ testCmds: toList(info.testCmd),
261
+ securityCmds: [],
262
+ requiredTools: [],
263
+ }),
264
+ ]);
265
+ }
136
266
  // =============================================================================
137
267
  // GHAGGA check
138
268
  // =============================================================================
@@ -151,20 +281,48 @@ async function isGhaggaAvailable() {
151
281
  function report(onStep, id, label, status, detail) {
152
282
  onStep({ id, label, status, detail });
153
283
  }
284
+ /** Detect-step label: legacy format for auto, explicit otherwise. */
285
+ function describeRunners(resolved) {
286
+ const first = resolved.runners[0];
287
+ if (resolved.source === "auto" && first) {
288
+ return `Stack: ${first.stack} (${first.buildTool})`;
289
+ }
290
+ if (resolved.source === "stack-override" && first) {
291
+ return `Stack: ${first.stack} (${first.buildTool}, --stack override)`;
292
+ }
293
+ const summary = resolved.runners
294
+ .map((r) => `${r.name} (${r.stack})`)
295
+ .join(", ");
296
+ return `Config: ${resolved.runners.length} runner(s) — ${summary}`;
297
+ }
154
298
  export async function runCI(options, onStep) {
155
299
  const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
156
- // ── Detect stack ────────────────────────────────────────────────────────────
300
+ // ── Resolve runners (once — nothing downstream re-detects) ─────────────────
157
301
  const stepDetect = "detect";
158
302
  report(onStep, stepDetect, "Detecting stack", "running");
159
- let stackInfo;
303
+ let resolved;
160
304
  try {
161
- stackInfo = await detectCIStack(projectDir);
162
- report(onStep, stepDetect, `Stack: ${stackInfo.stackType} (${stackInfo.buildTool})`, "done");
305
+ resolved = await resolveCIRunners(projectDir, {
306
+ config: options.config,
307
+ stack: options.stack,
308
+ });
309
+ report(onStep, stepDetect, describeRunners(resolved), "done");
163
310
  }
164
311
  catch (e) {
165
312
  report(onStep, stepDetect, "Detecting stack", "error", String(e));
166
313
  throw e;
167
314
  }
315
+ // Legacy single-runner view for the zero-config auto path. Keeping this
316
+ // shape guarantees single-stack repositories behave exactly as before.
317
+ const primary = resolved.runners[0];
318
+ const stackInfo = {
319
+ stackType: primary?.stack ?? "node",
320
+ buildTool: primary?.buildTool ?? "npm",
321
+ javaVersion: primary?.javaVersion ?? "21",
322
+ lintCmd: primary?.lintCmds[0] ?? null,
323
+ compileCmd: primary?.compileCmds[0] ?? null,
324
+ testCmd: primary?.testCmds[0] ?? null,
325
+ };
168
326
  // ── Detect mode ─────────────────────────────────────────────────────────────
169
327
  if (mode === "detect")
170
328
  return;
@@ -175,8 +333,9 @@ export async function runCI(options, onStep) {
175
333
  throw new Error("--shell requires Docker");
176
334
  }
177
335
  report(onStep, "docker-image", "Building Docker image", "running");
336
+ let shellImage;
178
337
  try {
179
- await ensureImage({
338
+ shellImage = await ensureImage({
180
339
  stack: stackInfo.stackType,
181
340
  javaVersion: stackInfo.javaVersion,
182
341
  });
@@ -186,7 +345,7 @@ export async function runCI(options, onStep) {
186
345
  report(onStep, "docker-image", "Building Docker image", "error", String(e));
187
346
  throw e;
188
347
  }
189
- await openShell(projectDir);
348
+ await openShell(projectDir, shellImage);
190
349
  return;
191
350
  }
192
351
  // ── Check Docker ─────────────────────────────────────────────────────────────
@@ -199,19 +358,22 @@ export async function runCI(options, onStep) {
199
358
  throw new Error("Docker is not available");
200
359
  }
201
360
  report(onStep, stepDocker, "Docker available", "done");
202
- // Build image
203
- const stepImage = "docker-image";
204
- report(onStep, stepImage, `Building image for ${stackInfo.stackType}`, "running");
205
- try {
206
- await ensureImage({
207
- stack: stackInfo.stackType,
208
- javaVersion: stackInfo.javaVersion,
209
- });
210
- report(onStep, stepImage, "Docker image ready", "done");
211
- }
212
- catch (e) {
213
- report(onStep, stepImage, "Building Docker image", "error", String(e));
214
- throw e;
361
+ // Build image (auto path only — configured runners ensure their own
362
+ // image inside the runner loop)
363
+ if (resolved.source === "auto") {
364
+ const stepImage = "docker-image";
365
+ report(onStep, stepImage, `Building image for ${stackInfo.stackType}`, "running");
366
+ try {
367
+ await ensureImage({
368
+ stack: stackInfo.stackType,
369
+ javaVersion: stackInfo.javaVersion,
370
+ });
371
+ report(onStep, stepImage, "Docker image ready", "done");
372
+ }
373
+ catch (e) {
374
+ report(onStep, stepImage, "Building Docker image", "error", String(e));
375
+ throw e;
376
+ }
215
377
  }
216
378
  }
217
379
  // ── Refresh .context/ ────────────────────────────────────────────────────────
@@ -230,12 +392,78 @@ export async function runCI(options, onStep) {
230
392
  // Non-fatal: context refresh failure should not block CI
231
393
  report(onStep, stepContext, "Refresh .context/ directory", "error", String(e));
232
394
  }
233
- // ── Lint ─────────────────────────────────────────────────────────────────────
395
+ // ── Runner execution ─────────────────────────────────────────────────────────
396
+ if (resolved.source === "auto" && primary) {
397
+ // Legacy zero-config path — unchanged single-stack behavior.
398
+ await runLegacySteps(stackInfo, primary, {
399
+ projectDir,
400
+ mode,
401
+ noDocker,
402
+ timeout,
403
+ onStep,
404
+ });
405
+ }
406
+ else {
407
+ // Configured (or --stack override) runners execute in declared order,
408
+ // each in its own working directory.
409
+ for (const runner of resolved.runners) {
410
+ await runConfiguredRunner(runner, {
411
+ projectDir,
412
+ mode,
413
+ noDocker,
414
+ noSecurity,
415
+ timeout,
416
+ onStep,
417
+ });
418
+ }
419
+ }
420
+ // ── Security scan (full mode only) ──────────────────────────────────────────
421
+ if (mode === "full" && !noSecurity) {
422
+ const stepSecurity = "security";
423
+ const semgrepAvailable = await isSemgrepAvailable();
424
+ if (semgrepAvailable) {
425
+ report(onStep, stepSecurity, "Security scan (Semgrep)", "running");
426
+ try {
427
+ await runSemgrep(projectDir);
428
+ report(onStep, stepSecurity, "Security scan passed", "done");
429
+ }
430
+ catch (e) {
431
+ report(onStep, stepSecurity, "Security scan failed", "error", String(e));
432
+ throw e;
433
+ }
434
+ }
435
+ else {
436
+ report(onStep, stepSecurity, "Security scan", "skipped", "Semgrep not available — install semgrep or Docker");
437
+ }
438
+ }
439
+ // ── GHAGGA review (full mode only) ──────────────────────────────────────────
440
+ if (mode === "full" && !noGhagga) {
441
+ const stepGhagga = "ghagga";
442
+ const ghagga = await isGhaggaAvailable();
443
+ if (ghagga) {
444
+ report(onStep, stepGhagga, "GHAGGA review", "running");
445
+ try {
446
+ await runGhagga(projectDir);
447
+ report(onStep, stepGhagga, "GHAGGA review passed", "done");
448
+ }
449
+ catch (e) {
450
+ report(onStep, stepGhagga, "GHAGGA review failed", "error", String(e));
451
+ throw e;
452
+ }
453
+ }
454
+ else {
455
+ report(onStep, stepGhagga, "GHAGGA review", "skipped", "ghagga not installed");
456
+ }
457
+ }
458
+ }
459
+ /** Legacy zero-config execution — identical to the pre-mixed-stack behavior. */
460
+ async function runLegacySteps(stackInfo, runner, ctx) {
461
+ const { projectDir, mode, noDocker, timeout, onStep } = ctx;
234
462
  if (stackInfo.lintCmd) {
235
463
  const stepLint = "lint";
236
464
  report(onStep, stepLint, `Lint: ${stackInfo.lintCmd}`, "running");
237
465
  try {
238
- await runStep(stackInfo.lintCmd, projectDir, noDocker, timeout);
466
+ await runStep(stackInfo.lintCmd, projectDir, noDocker, timeout, runner);
239
467
  report(onStep, stepLint, "Lint passed", "done");
240
468
  }
241
469
  catch (e) {
@@ -243,14 +471,13 @@ export async function runCI(options, onStep) {
243
471
  throw e;
244
472
  }
245
473
  }
246
- // ── Compile ──────────────────────────────────────────────────────────────────
247
474
  if (stackInfo.compileCmd) {
248
475
  const stepCompile = "compile";
249
476
  report(onStep, stepCompile, `Compile: ${stackInfo.compileCmd}`, "running");
250
477
  try {
251
478
  // Run as root inside the container to rm/build output dirs owned by any host user,
252
479
  // then chown back to runner so subsequent test steps can read the output.
253
- await runStep(stackInfo.compileCmd, projectDir, noDocker, timeout, "root");
480
+ await runStep(stackInfo.compileCmd, projectDir, noDocker, timeout, runner, "root");
254
481
  report(onStep, stepCompile, "Compile passed", "done");
255
482
  }
256
483
  catch (e) {
@@ -258,12 +485,11 @@ export async function runCI(options, onStep) {
258
485
  throw e;
259
486
  }
260
487
  }
261
- // ── Test (full mode only) ────────────────────────────────────────────────────
262
488
  if (mode === "full" && stackInfo.testCmd) {
263
489
  const stepTest = "test";
264
490
  report(onStep, stepTest, `Test: ${stackInfo.testCmd}`, "running");
265
491
  try {
266
- await runStep(stackInfo.testCmd, projectDir, noDocker, timeout);
492
+ await runStep(stackInfo.testCmd, projectDir, noDocker, timeout, runner);
267
493
  report(onStep, stepTest, "Tests passed", "done");
268
494
  }
269
495
  catch (e) {
@@ -271,54 +497,117 @@ export async function runCI(options, onStep) {
271
497
  throw e;
272
498
  }
273
499
  }
274
- // ── Security scan (full mode only) ──────────────────────────────────────────
275
- if (mode === "full" && !noSecurity) {
276
- const stepSecurity = "security";
277
- const semgrepAvailable = await isSemgrepAvailable();
278
- if (semgrepAvailable) {
279
- report(onStep, stepSecurity, "Security scan (Semgrep)", "running");
280
- try {
281
- await runSemgrep(projectDir);
282
- report(onStep, stepSecurity, "Security scan passed", "done");
500
+ }
501
+ /**
502
+ * Execute one configured runner: resolve the image (default stack image,
503
+ * explicit/digest-pinned image, or deterministic build-context build),
504
+ * verify required tools fail-closed, then run setup/lint/compile/test/
505
+ * security commands in order, inside the runner's working directory.
506
+ * Any failure aborts the whole run — nothing is ever skipped silently.
507
+ */
508
+ async function runConfiguredRunner(runner, ctx) {
509
+ const { projectDir, mode, noDocker, noSecurity, timeout, onStep } = ctx;
510
+ // ── Image resolution (Docker mode only) ────────────────────────────────────
511
+ let imageName;
512
+ if (!noDocker) {
513
+ const stepImage = `docker-image:${runner.name}`;
514
+ try {
515
+ if (runner.buildContext) {
516
+ report(onStep, stepImage, `Building image for ${runner.name} from ${runner.buildContext}`, "running");
517
+ imageName = await ensureImage({
518
+ stack: runner.stack,
519
+ buildContext: path.resolve(projectDir, runner.buildContext),
520
+ imageTag: `javi-forge-ci-${runner.name}`,
521
+ });
283
522
  }
284
- catch (e) {
285
- report(onStep, stepSecurity, "Security scan failed", "error", String(e));
286
- throw e;
523
+ else if (runner.image) {
524
+ // Explicit image (digest pins pass through verbatim).
525
+ imageName = runner.image;
526
+ report(onStep, stepImage, `Using configured image ${runner.image}`, "done");
527
+ }
528
+ else {
529
+ report(onStep, stepImage, `Building image for ${runner.name} (${runner.stack})`, "running");
530
+ imageName = await ensureImage({
531
+ stack: runner.stack,
532
+ javaVersion: runner.javaVersion,
533
+ });
534
+ }
535
+ if (!runner.image) {
536
+ report(onStep, stepImage, `Docker image ready (${imageName})`, "done");
287
537
  }
288
538
  }
289
- else {
290
- report(onStep, stepSecurity, "Security scan", "skipped", "Semgrep not available — install semgrep or Docker");
539
+ catch (e) {
540
+ report(onStep, stepImage, "Building Docker image", "error", String(e));
541
+ throw e;
291
542
  }
292
543
  }
293
- // ── GHAGGA review (full mode only) ──────────────────────────────────────────
294
- if (mode === "full" && !noGhagga) {
295
- const stepGhagga = "ghagga";
296
- const ghagga = await isGhaggaAvailable();
297
- if (ghagga) {
298
- report(onStep, stepGhagga, "GHAGGA review", "running");
544
+ // ── Required tools (fail closed, before any phase) ─────────────────────────
545
+ if (runner.requiredTools.length > 0) {
546
+ const stepTools = `tools:${runner.name}`;
547
+ const envDesc = noDocker
548
+ ? `native environment (directory "${runner.directory}")`
549
+ : `image ${imageName}`;
550
+ report(onStep, stepTools, `Checking required tools [${runner.name}]: ${runner.requiredTools.join(", ")}`, "running");
551
+ for (const tool of runner.requiredTools) {
299
552
  try {
300
- await runGhagga(projectDir);
301
- report(onStep, stepGhagga, "GHAGGA review passed", "done");
553
+ await runStep(`command -v ${tool}`, projectDir, noDocker, timeout, runner, undefined, imageName);
554
+ }
555
+ catch {
556
+ const message = `runner "${runner.name}": required tool "${tool}" not found in ${envDesc} — ` +
557
+ "install it in the runner image/environment or remove it from requires";
558
+ report(onStep, stepTools, `Missing required tool [${runner.name}]: ${tool}`, "error", message);
559
+ throw new Error(message);
560
+ }
561
+ }
562
+ report(onStep, stepTools, `Required tools OK [${runner.name}]`, "done");
563
+ }
564
+ const phases = [
565
+ { id: "setup", label: "Setup", cmds: runner.setupCmds, skip: false },
566
+ { id: "lint", label: "Lint", cmds: runner.lintCmds, skip: false },
567
+ {
568
+ id: "compile",
569
+ label: "Compile",
570
+ cmds: runner.compileCmds,
571
+ user: "root",
572
+ skip: false,
573
+ },
574
+ {
575
+ id: "test",
576
+ label: "Test",
577
+ cmds: runner.testCmds,
578
+ skip: mode !== "full",
579
+ },
580
+ {
581
+ id: "security",
582
+ label: "Security",
583
+ cmds: runner.securityCmds,
584
+ skip: mode !== "full" || noSecurity,
585
+ },
586
+ ];
587
+ for (const phase of phases) {
588
+ if (phase.skip)
589
+ continue;
590
+ const stepId = `${phase.id}:${runner.name}`;
591
+ for (const cmd of phase.cmds) {
592
+ report(onStep, stepId, `${phase.label} [${runner.name}]: ${cmd}`, "running");
593
+ try {
594
+ await runStep(cmd, projectDir, noDocker, timeout, runner, phase.user, imageName);
595
+ report(onStep, stepId, `${phase.label} [${runner.name}] passed`, "done");
302
596
  }
303
597
  catch (e) {
304
- report(onStep, stepGhagga, "GHAGGA review failed", "error", String(e));
598
+ report(onStep, stepId, `${phase.label} [${runner.name}] failed`, "error", String(e));
305
599
  throw e;
306
600
  }
307
601
  }
308
- else {
309
- report(onStep, stepGhagga, "GHAGGA review", "skipped", "ghagga not installed");
310
- }
311
602
  }
312
603
  }
313
- // =============================================================================
314
- // Step runners
315
- // =============================================================================
316
- async function runStep(command, projectDir, noDocker, timeout, user) {
604
+ async function runStep(command, projectDir, noDocker, timeout, runner, user, imageOverride) {
317
605
  if (noDocker) {
318
- // Run natively
606
+ // Run natively, in the runner's working directory.
607
+ const cwd = path.join(projectDir, runner.directory);
319
608
  await new Promise((resolve, reject) => {
320
609
  const proc = spawn("bash", ["-c", command], {
321
- cwd: projectDir,
610
+ cwd,
322
611
  stdio: "inherit",
323
612
  env: { ...process.env, CI: "true" },
324
613
  });
@@ -329,9 +618,17 @@ async function runStep(command, projectDir, noDocker, timeout, user) {
329
618
  });
330
619
  }
331
620
  else {
621
+ // The image is always resolved upstream (per-runner ensureImage,
622
+ // explicit config image, or the default per-stack image). Docker
623
+ // receives it verbatim and never re-detects anything.
624
+ const image = imageOverride ?? runner.image ?? getImageName(runner.stack);
625
+ const workdir = runner.directory !== "."
626
+ ? `/home/runner/work/${runner.directory}`
627
+ : "/home/runner/work";
332
628
  const result = await runInContainer({
333
629
  projectDir,
334
- command: `cd /home/runner/work && ${command}`,
630
+ image,
631
+ command: `cd ${workdir} && ${command}`,
335
632
  timeout,
336
633
  stream: true,
337
634
  user,
@@ -469,6 +766,26 @@ export async function installCIHooks(projectDir) {
469
766
  for (const hook of hooks) {
470
767
  const hookPath = path.join(hooksDir, hook.name);
471
768
  try {
769
+ // Refuse to follow symlinks. .git/hooks/<name> as a symlink to a
770
+ // sensitive file (e.g., ~/.ssh/authorized_keys) would otherwise be
771
+ // clobbered by writeFile. lstat reports the link, not the target.
772
+ let isSymlink = false;
773
+ try {
774
+ const stat = await fs.lstat(hookPath);
775
+ isSymlink = stat.isSymbolicLink();
776
+ }
777
+ catch (statErr) {
778
+ const code = statErr && typeof statErr === "object" && "code" in statErr
779
+ ? statErr.code
780
+ : "";
781
+ if (code !== "ENOENT") {
782
+ throw statErr;
783
+ }
784
+ // ENOENT is expected if the hook file doesn't exist yet.
785
+ }
786
+ if (isSymlink) {
787
+ throw new Error(`refusing to write through a symlink at ${hookPath}`);
788
+ }
472
789
  await fs.writeFile(hookPath, hook.content, { mode: 0o755 });
473
790
  installed.push(hook.name);
474
791
  }
@@ -1,12 +1,10 @@
1
- import { execFile } from "child_process";
1
+ import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import path from "path";
4
- import { promisify } from "util";
5
3
  import { FORGE_ROOT, MODULES_DIR, PLUGINS_DIR, TEMPLATES_DIR, } from "../constants.js";
6
4
  import { detectStack } from "../lib/common.js";
7
5
  import { refreshContextDir } from "../lib/context.js";
6
+ import { execFileAsync } from "../lib/exec.js";
8
7
  import { listInstalledPlugins } from "../lib/plugin.js";
9
- const execFileAsync = promisify(execFile);
10
8
  /** Resolve a binary name to its full path, returns null if not found */
11
9
  async function which(bin) {
12
10
  try {
@@ -0,0 +1,13 @@
1
+ import type { InitStep } from "../../types/index.js";
2
+ import type { StepCallback } from "./types.js";
3
+ /**
4
+ * Emit a step progress event via the callback.
5
+ *
6
+ * Centralized helper extracted during the init.ts split. Functionally
7
+ * identical to the original inline report function, with an explicit
8
+ * `void` return type added for type clarity. Behavior MUST stay
9
+ * compatible so tests asserting on emitted steps[] (and the ordering
10
+ * contract test) keep passing through the PR 1-6 extraction.
11
+ */
12
+ export declare function report(onStep: StepCallback, id: string, label: string, status: InitStep["status"], detail?: string): void;
13
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Emit a step progress event via the callback.
3
+ *
4
+ * Centralized helper extracted during the init.ts split. Functionally
5
+ * identical to the original inline report function, with an explicit
6
+ * `void` return type added for type clarity. Behavior MUST stay
7
+ * compatible so tests asserting on emitted steps[] (and the ordering
8
+ * contract test) keep passing through the PR 1-6 extraction.
9
+ */
10
+ export function report(onStep, id, label, status, detail) {
11
+ onStep({ id, label, status, detail });
12
+ }
13
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1,13 @@
1
+ import type { StepFn } from "../types.js";
2
+ /**
3
+ * Step 17: Generate Agent Skills manifest (skills.json).
4
+ *
5
+ * - In dry-run mode, reports what would be created without touching disk.
6
+ * - Otherwise writes <projectDir>/<AGENT_SKILLS_MANIFEST_FILE> with an empty
7
+ * skills array unless the file already exists (idempotent).
8
+ * - Errors are swallowed and reported as status:"error" — never thrown.
9
+ *
10
+ * Extracted VERBATIM from src/commands/init.ts (PR 6 of 6).
11
+ */
12
+ export declare const stepAgentSkills: StepFn;
13
+ //# sourceMappingURL=agent-skills.d.ts.map