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
package/README.md CHANGED
@@ -88,8 +88,12 @@ flowchart LR
88
88
  | `ci` | Run CI simulation (lint + compile + test + security + ghagga) |
89
89
  | `ci init` | Install git hooks in `.git/hooks/` (recommended for existing repos) |
90
90
  | `tdd init` | Install TDD-enforcing pre-commit hook (auto-detects stack) |
91
+ | `tdd pipeline` | Install full TDD pipeline (red → green → refactor enforcement) |
91
92
  | `analyze` | Run repoforge skills analysis on current project |
92
93
  | `doctor` | Show comprehensive health report |
94
+ | `workflow show` | Print the active workflow definition |
95
+ | `workflow validate` | Lint the workflow YAML against the schema |
96
+ | `workflow list` | List bundled workflow templates |
93
97
  | `plugin add` | Install a plugin from GitHub (`org/repo`) |
94
98
  | `plugin remove` | Remove an installed plugin |
95
99
  | `plugin list` | List installed plugins |
@@ -98,8 +102,11 @@ flowchart LR
98
102
  | `plugin sync` | Auto-detect and wire installed plugins into manifest |
99
103
  | `plugin export` | Export plugin to Agent Skills spec (`skills.json`) |
100
104
  | `plugin export --codex` | Export plugin to Codex-compatible TOML subagent files |
105
+ | `plugin export-skills` | Aggregate all installed plugin skills into a single bundle |
101
106
  | `plugin import` | Import an Agent Skills spec package as a javi-forge plugin |
102
107
  | `skills doctor` | Show skills health report (conflict + duplicate detection) |
108
+ | `skills auto` | Scan project lockfiles and auto-install matching skills from the registry |
109
+ | `skill publish` | Publish a local skill to the registry (validates manifest, signs the artifact) |
103
110
  | `skills budget` | Show token cost of loaded skills |
104
111
  | `skills score` | Score a skill on quality dimensions (0-100) |
105
112
  | `skills benchmark` | Benchmark a skill with structural quality checks |
@@ -126,6 +133,43 @@ npx javi-forge security check
126
133
  npx javi-forge llms-txt
127
134
  ```
128
135
 
136
+ ### Mixed-stack CI (hybrid repositories)
137
+
138
+ By default `ci` auto-detects a **single** stack from marker files — zero
139
+ config, unchanged for single-stack repos. Hybrid repositories (e.g. Node
140
+ frontend + Python backend) declare every toolchain in a versioned config,
141
+ `.javi-forge/ci.yaml`:
142
+
143
+ ```yaml
144
+ version: 1
145
+ runners:
146
+ - name: backend
147
+ stack: python
148
+ directory: backend
149
+ setup: pip install -r requirements.txt
150
+ lint: ruff check .
151
+ test: pytest
152
+ requires: [python3, ruff, pytest]
153
+ - name: frontend
154
+ stack: node
155
+ lint: pnpm run lint
156
+ test: pnpm run test
157
+ requires: [node, pnpm]
158
+ ```
159
+
160
+ Runners execute in order, each in its own directory and image, and every
161
+ `requires` tool is verified fail-closed before any phase runs — a missing
162
+ tool aborts with an explicit error instead of a skipped check. Custom
163
+ images, digest pinning (`image: name@sha256:…`) and custom
164
+ `build-context` Dockerfiles are supported.
165
+
166
+ - `javi-forge ci --config <path>` — load explicit runners
167
+ - `javi-forge ci --stack python` — force a single stack (single-stack
168
+ repos only; insufficient for hybrid repos)
169
+
170
+ Full guide: [docs/ci-runners.md](docs/ci-runners.md) (config reference,
171
+ migration from auto-detection, troubleshooting, trust boundary).
172
+
129
173
  ### CLI Flags
130
174
 
131
175
  | Flag | Type | Default | Description |
@@ -330,7 +374,7 @@ npx javi-forge doctor
330
374
 
331
375
  ## Requirements
332
376
 
333
- - **Node.js** >= 18
377
+ - **Node.js** >= 22 (required by ink 7; previous versions ran on >= 18)
334
378
 
335
379
  ## Ecosystem
336
380
 
@@ -15,6 +15,16 @@ Reproduce tu CI/CD localmente antes de push. Si pasa local → pasa en GitHub Ac
15
15
 
16
16
  ## Instalación (30 segundos)
17
17
 
18
+ ### Prerequisito obligatorio
19
+
20
+ Los hooks invocan la CLI `javi-forge`. Instalala global ANTES de correr `install.sh`:
21
+
22
+ ```bash
23
+ npm install -g javi-forge
24
+ ```
25
+
26
+ Si estás desarrollando javi-forge mismo, usá `pnpm link --global` desde la raíz del workspace.
27
+
18
28
  ### Copiar a un proyecto nuevo
19
29
 
20
30
  ```bash
@@ -24,12 +34,25 @@ cp -r lib /path/to/new-project/
24
34
 
25
35
  # En el nuevo proyecto
26
36
  cd /path/to/new-project
27
- ./.ci-local/install.sh # Linux/Mac
37
+ ./.ci-local/install.sh # Linux/Mac/WSL
28
38
  # o
29
- .\.ci-local\install.ps1 # Windows
39
+ .\.ci-local\install.ps1 # Windows (PowerShell 7+)
30
40
  ```
31
41
 
32
- > **Importante:** CI-Local depende de `lib/common.sh` (funciones compartidas). Copiar siempre ambos directorios.
42
+ > **Importante:** CI-Local depende de `lib/common.sh` (Linux/Mac) y/o `lib/common.psm1` (Windows). Copiá ambos junto con el directorio `ci-local/`.
43
+
44
+ El installer (ambas variantes) falla con mensaje claro si `javi-forge` no está en el PATH.
45
+
46
+ ### Soporte cross-platform
47
+
48
+ | Plataforma | Installer | Runner | Hooks | Requisitos |
49
+ |---|---|---|---|---|
50
+ | Linux | `install.sh` | `ci-local.sh` | bash | bash, perl, docker (opcional) |
51
+ | macOS | `install.sh` | `ci-local.sh` | bash | bash, perl, docker (opcional) |
52
+ | WSL | `install.sh` | `ci-local.sh` | bash | bash, perl, docker (opcional) |
53
+ | Windows nativo | `install.ps1` | `ci-local.ps1` | bash | PowerShell 7+, Git for Windows (MSYS2 bash), docker (opcional) |
54
+
55
+ Los git hooks (`pre-commit`, `commit-msg`, `pre-push`) son bash en todas las plataformas. En Windows nativo corren via el MSYS2 bash que viene con Git for Windows — no se necesita una versión PowerShell de los hooks.
33
56
 
34
57
  ### Dependencias opcionales
35
58
 
@@ -53,15 +76,37 @@ git commit -m "..." # → pre-commit: AI check + lint + security (~30s)
53
76
  git push # → pre-push: CI completo en Docker (~3min)
54
77
  ```
55
78
 
56
- ### AI Attribution Blocker
79
+ ### AI Attribution Blocker (best-effort)
57
80
 
58
- Los hooks bloquean automáticamente:
59
- - `Co-authored-by: Claude`, `Co-authored-by: GPT`, etc.
60
- - `Made by Claude`, `Generated by AI`, `Written by GPT`
61
- - Referencias a `@anthropic.com`, `@openai.com`
62
- - Nombres de modelos: `claude opus`, `gpt-4`, `chatgpt`, etc.
81
+ El hook `commit-msg` bloquea atribución accidental a herramientas de IA.
82
+ **Es best-effort, no es un control de seguridad adversarial.**
83
+
84
+ #### Qué bloquea
63
85
 
64
- **Tú eres el único autor de tu código.**
86
+ - `Co-authored-by: Claude`, `Co-authored-by: GPT`, etc.
87
+ - `Made/generated/written/built/produced by/with X` (con proveedores conocidos)
88
+ - `X-assisted` (anthropic-assisted, openai-assisted, etc.)
89
+ - Referencias a `@anthropic.com`, `@openai.com`, etc.
90
+ - Nombres de modelos: `claude opus`, `gpt-4`, `chatgpt`, `claude-sonnet`, `Claude3.5`
91
+ - AI IDEs: `Cursor`, `Windsurf`, `Codeium`, `Cody`, `Aider`
92
+ - Variantes con whitespace doble, markdown (`**Claude**`), puntuación entre tokens,
93
+ emoji separadores, full-width latin (`Claude`), combining diacritics (`Ćlaude`),
94
+ y Unicode invisibles (ZWSP/RLM/NBSP).
95
+
96
+ #### Qué NO bloquea (limitaciones documentadas)
97
+
98
+ - **Homoglifos cross-script**: `Сlaude` (Cyrillic), `Ϲlaude` (Greek), `Ꮯlaude` (Cherokee).
99
+ Requieren transliteración via módulos perl no-core.
100
+ - **Tag characters / variation selectors**: `Made by\u{E0063}laude`. Rangos Unicode
101
+ específicos no estripeados.
102
+ - **Bare provider name sin verbo prepositional**: "asked Claude to refactor",
103
+ "I used Claude for this", "claude helped me debug". El riesgo de false positive
104
+ en nombres personales o conversaciones legítimas es alto.
105
+
106
+ Si tu modelo de amenaza requiere defensa contra adversarios motivados, usá
107
+ **signed commits** + revisor humano. Un regex no es cripto.
108
+
109
+ **Tú sos el único autor de tu código.**
65
110
 
66
111
  ### Manual
67
112
 
@@ -133,7 +178,7 @@ Developer workflow:
133
178
 
134
179
  lib/ # Dependencia requerida (copiar junto con .ci-local/)
135
180
  ├── common.sh # Funciones compartidas (Bash)
136
- └── Common.psm1 # Funciones compartidas (PowerShell)
181
+ └── common.psm1 # Funciones compartidas (PowerShell)
137
182
  ```
138
183
 
139
184
  ## Personalización
@@ -0,0 +1,445 @@
1
+ # =============================================================================
2
+ # CI-LOCAL: Universal CI Simulation (PowerShell)
3
+ # =============================================================================
4
+ # Mirror of ci-local/ci-local.sh for Windows / PowerShell users.
5
+ #
6
+ # Usage:
7
+ # .\ci-local.ps1 # full CI
8
+ # .\ci-local.ps1 quick # lint + compile only
9
+ # .\ci-local.ps1 shell # interactive shell inside CI container
10
+ # .\ci-local.ps1 detect # print detected stack and exit
11
+ #
12
+ # Behaviour MUST match ci-local.sh. Diff against the bash version on every
13
+ # change.
14
+ # =============================================================================
15
+
16
+ #Requires -Version 7.2
17
+ [CmdletBinding()]
18
+ param(
19
+ [Parameter(Position = 0)]
20
+ [ValidateSet('full', 'quick', 'shell', 'detect')]
21
+ [string]$Mode = 'full'
22
+ )
23
+
24
+ # See install.ps1 for the rationale behind 7.2 minimum (ResolveLinkTarget).
25
+ if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion -lt [Version]'7.2') {
26
+ Write-Host 'ERROR: ci-local.ps1 requires PowerShell 7.2+ (pwsh).' -ForegroundColor Red
27
+ exit 1
28
+ }
29
+
30
+ Set-StrictMode -Version Latest
31
+ $ErrorActionPreference = 'Stop'
32
+
33
+ $ScriptDir = Split-Path -Parent $PSCommandPath
34
+ $ProjectDir = Split-Path -Parent $ScriptDir
35
+
36
+ # ─── Symlink-safe path resolver (inline, runs BEFORE Import-Module) ───
37
+ # Same defense as install.ps1: remove any pre-existing alias/function with
38
+ # this name (Alias > Function in PS lookup order would let a $PROFILE
39
+ # alias shadow our resolver), then verify the binding is the local
40
+ # function defined in THIS script file.
41
+ Remove-Item Alias:Resolve-RealPath -Force -ErrorAction SilentlyContinue
42
+ Remove-Item Function:Resolve-RealPath -Force -ErrorAction SilentlyContinue
43
+ function Resolve-RealPath([string]$P) {
44
+ if ([string]::IsNullOrEmpty($P)) { throw "Resolve-RealPath: empty path" }
45
+ $item = Get-Item -LiteralPath $P -Force -ErrorAction Stop
46
+ $target = $item.ResolveLinkTarget($true)
47
+ if ($target) {
48
+ if (-not $target.Exists) {
49
+ throw "Resolve-RealPath: dangling symlink target $($target.FullName)"
50
+ }
51
+ return $target.FullName
52
+ }
53
+ return $item.FullName
54
+ }
55
+ $_rr = Get-Command Resolve-RealPath -ErrorAction SilentlyContinue
56
+ if (-not $_rr -or $_rr.CommandType -ne 'Function' -or $_rr.ScriptBlock.File -ne $PSCommandPath) {
57
+ Write-Host 'ERROR: Resolve-RealPath is shadowed by an external command' -ForegroundColor Red
58
+ exit 1
59
+ }
60
+ Remove-Variable _rr
61
+
62
+ # ─── Load shared lib ──────────────────────────────────────────────────
63
+ $libCandidates = @(
64
+ (Join-Path $ScriptDir 'lib' 'common.psm1')
65
+ (Join-Path $ScriptDir '..' 'lib' 'common.psm1')
66
+ )
67
+ $libPath = $libCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
68
+ if (-not $libPath) {
69
+ Write-Host 'ERROR: lib/common.psm1 not found' -ForegroundColor Red
70
+ exit 1
71
+ }
72
+
73
+ # SECURITY: validate the module BEFORE importing. Mirror of install.ps1.
74
+ try {
75
+ $libReal = Resolve-RealPath $libPath
76
+ $projectReal = Resolve-RealPath $ProjectDir
77
+ } catch {
78
+ Write-Host "ERROR: could not resolve lib/common.psm1 or project root: $($_.Exception.Message)" -ForegroundColor Red
79
+ exit 1
80
+ }
81
+ $sep = [System.IO.Path]::DirectorySeparatorChar
82
+ if (-not ($libReal.Equals($projectReal, [StringComparison]::OrdinalIgnoreCase) -or
83
+ $libReal.StartsWith($projectReal + $sep, [StringComparison]::OrdinalIgnoreCase))) {
84
+ Write-Host 'ERROR: lib/common.psm1 resolves outside the project root' -ForegroundColor Red
85
+ Write-Host " LIB resolved: $libReal" -ForegroundColor Yellow
86
+ Write-Host " PROJECT resolved: $projectReal" -ForegroundColor Yellow
87
+ exit 1
88
+ }
89
+
90
+ Import-Module $libPath -Force
91
+
92
+ # ─── Stack detection + per-stack CI commands ──────────────────────────
93
+ function Get-CiConfig {
94
+ param([string]$ProjectDir)
95
+
96
+ $stack = Get-Stack -ProjectDir $ProjectDir
97
+
98
+ $cfg = @{
99
+ StackType = $stack.StackType
100
+ BuildTool = $stack.BuildTool
101
+ JavaVersion = $stack.JavaVersion
102
+ Dockerfile = ''
103
+ LintCmd = ''
104
+ CompileCmd = ''
105
+ TestCmd = ''
106
+ }
107
+
108
+ switch ($stack.StackType) {
109
+ 'java-gradle' {
110
+ $cfg.Dockerfile = 'java.Dockerfile'
111
+ $cfg.LintCmd = './gradlew spotlessCheck --no-daemon'
112
+ $cfg.CompileCmd = './gradlew clean classes testClasses --no-daemon && chown -R runner:runner build .gradle 2>/dev/null || true'
113
+ $cfg.TestCmd = './gradlew test --no-daemon'
114
+ }
115
+ 'java-maven' {
116
+ $cfg.Dockerfile = 'java.Dockerfile'
117
+ $cfg.LintCmd = './mvnw spotless:check'
118
+ $cfg.CompileCmd = './mvnw clean compile test-compile && chown -R runner:runner target .mvn 2>/dev/null || true'
119
+ $cfg.TestCmd = './mvnw test'
120
+ }
121
+ 'node' {
122
+ $cfg.Dockerfile = 'node.Dockerfile'
123
+ $pkg = Join-Path $ProjectDir 'package.json'
124
+ $pkgRaw = Get-Content -LiteralPath $pkg -Raw -ErrorAction SilentlyContinue
125
+ if ($pkgRaw -and ($pkgRaw -match '"lint"')) { $cfg.LintCmd = "$($stack.BuildTool) run lint" }
126
+ if ($pkgRaw -and ($pkgRaw -match '"build"')) { $cfg.CompileCmd = "rm -rf dist build && $($stack.BuildTool) run build && chown -R runner:runner dist build 2>/dev/null || true" }
127
+ if ($pkgRaw -and ($pkgRaw -match '"test"')) {
128
+ $cfg.TestCmd = "$($stack.BuildTool) test"
129
+ if ($pkgRaw -match '"test:hooks"') {
130
+ $cfg.TestCmd = "$($cfg.TestCmd) && $($stack.BuildTool) run test:hooks"
131
+ }
132
+ }
133
+ }
134
+ 'python' {
135
+ $cfg.Dockerfile = 'python.Dockerfile'
136
+ $cfg.LintCmd = 'ruff check . && { pylint **/*.py 2>/dev/null || true; }'
137
+ $cfg.TestCmd = 'pytest'
138
+ }
139
+ 'go' {
140
+ $cfg.Dockerfile = 'go.Dockerfile'
141
+ $cfg.LintCmd = 'golangci-lint run'
142
+ $cfg.CompileCmd = 'go clean -cache && go build ./... && chown -R runner:runner . 2>/dev/null || true'
143
+ $cfg.TestCmd = 'go test ./...'
144
+ }
145
+ 'rust' {
146
+ $cfg.Dockerfile = 'rust.Dockerfile'
147
+ $cfg.LintCmd = 'cargo clippy -- -D warnings'
148
+ $cfg.CompileCmd = 'cargo clean && cargo build && chown -R runner:runner target 2>/dev/null || true'
149
+ $cfg.TestCmd = 'cargo test'
150
+ }
151
+ }
152
+
153
+ return $cfg
154
+ }
155
+
156
+ # ─── Docker helpers ───────────────────────────────────────────────────
157
+ function Get-ImageName {
158
+ param([string]$StackType)
159
+ "ci-local-$StackType"
160
+ }
161
+
162
+ function New-Dockerfile {
163
+ param([Parameter(Mandatory)][hashtable]$Cfg)
164
+
165
+ $dockerDir = Join-Path $ScriptDir 'docker'
166
+ New-Item -ItemType Directory -Force -Path $dockerDir | Out-Null
167
+ $target = Join-Path $dockerDir $Cfg.Dockerfile
168
+
169
+ # Heredoc bodies kept byte-identical to ci-local.sh so the image-hash
170
+ # cache aligns across platforms when the same Dockerfile is generated.
171
+ $content = switch ($Cfg.StackType) {
172
+ { $_ -in 'java-gradle','java-maven' } {
173
+ @'
174
+ ARG JAVA_VERSION=21
175
+ FROM eclipse-temurin:${JAVA_VERSION}-jdk-noble
176
+ RUN apt-get update && apt-get install -y git curl unzip && rm -rf /var/lib/apt/lists/*
177
+ RUN useradd -m -s /bin/bash runner
178
+ USER runner
179
+ WORKDIR /home/runner/work
180
+ ENV GRADLE_USER_HOME=/home/runner/.gradle
181
+ ENTRYPOINT ["/bin/bash", "-c"]
182
+ '@
183
+ }
184
+ 'node' {
185
+ @'
186
+ FROM node:22-slim@sha256:689c11043dad91472750cd824c97dd5e2318e9dd6f954e492fe7af0135d33ceb
187
+ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
188
+ RUN npm install -g pnpm
189
+ RUN useradd -m -s /bin/bash runner
190
+ USER runner
191
+ WORKDIR /home/runner/work
192
+ ENTRYPOINT ["/bin/bash", "-c"]
193
+ '@
194
+ }
195
+ 'python' {
196
+ @'
197
+ FROM python:3.12-slim@sha256:401f6e1a67dad31a1bd78e9ad22d0ee0a3b52154e6bd30e90be696bb6a3d7461
198
+ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
199
+ RUN pip install --no-cache-dir pytest ruff pylint poetry
200
+ RUN useradd -m -s /bin/bash runner
201
+ USER runner
202
+ WORKDIR /home/runner/work
203
+ ENTRYPOINT ["/bin/bash", "-c"]
204
+ '@
205
+ }
206
+ 'go' {
207
+ @'
208
+ FROM golang:1.23-bookworm@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db
209
+ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
210
+ RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.0 && \
211
+ mv /root/go/bin/golangci-lint /usr/local/bin/
212
+ RUN useradd -m -s /bin/bash runner
213
+ USER runner
214
+ WORKDIR /home/runner/work
215
+ ENTRYPOINT ["/bin/bash", "-c"]
216
+ '@
217
+ }
218
+ 'rust' {
219
+ @'
220
+ FROM rust:1.83-slim@sha256:540c902e99c384163b688bbd8b5b8520e94e7731b27f7bd0eaa56ae1960627ab
221
+ RUN apt-get update && apt-get install -y git pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
222
+ RUN rustup component add clippy rustfmt
223
+ RUN useradd -m -s /bin/bash runner
224
+ USER runner
225
+ WORKDIR /home/runner/work
226
+ ENTRYPOINT ["/bin/bash", "-c"]
227
+ '@
228
+ }
229
+ default {
230
+ @'
231
+ FROM ubuntu:24.04@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b
232
+ RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*
233
+ RUN useradd -m -s /bin/bash runner
234
+ USER runner
235
+ WORKDIR /home/runner/work
236
+ ENTRYPOINT ["/bin/bash", "-c"]
237
+ '@
238
+ }
239
+ }
240
+
241
+ # Write with LF line endings to match the bash output, so the
242
+ # dockerfile-hash label stays consistent between platforms.
243
+ [System.IO.File]::WriteAllText($target, ($content + "`n"), [System.Text.UTF8Encoding]::new($false))
244
+ Write-Host "Created $($Cfg.Dockerfile)" -ForegroundColor Green
245
+ }
246
+
247
+ function Confirm-DockerImage {
248
+ param([Parameter(Mandatory)][hashtable]$Cfg)
249
+
250
+ $imageName = Get-ImageName -StackType $Cfg.StackType
251
+ $dockerfile = Join-Path $ScriptDir 'docker' $Cfg.Dockerfile
252
+
253
+ if (-not (Test-Path -LiteralPath $dockerfile -PathType Leaf)) {
254
+ Write-Host "Creating Dockerfile for $($Cfg.StackType)..." -ForegroundColor Yellow
255
+ New-Dockerfile -Cfg $Cfg
256
+ }
257
+
258
+ # SHA256 of the Dockerfile content — matches `sha256sum` from the bash version.
259
+ $hash = (Get-FileHash -LiteralPath $dockerfile -Algorithm SHA256).Hash.ToLowerInvariant()
260
+ $imageHash = ''
261
+ try {
262
+ $imageHash = & docker inspect --format='{{index .Config.Labels "dockerfile-hash"}}' $imageName 2>$null
263
+ } catch { }
264
+
265
+ if ($hash -ne $imageHash) {
266
+ Write-Host 'Building CI Docker image...' -ForegroundColor Yellow
267
+ $buildArgs = @('--label', "dockerfile-hash=$hash")
268
+ if ($Cfg.JavaVersion -and $Cfg.StackType -like 'java-*') {
269
+ $buildArgs += @('--build-arg', "JAVA_VERSION=$($Cfg.JavaVersion)")
270
+ }
271
+ & docker build @buildArgs -f $dockerfile -t $imageName (Join-Path $ScriptDir 'docker')
272
+ if ($LASTEXITCODE -ne 0) { throw 'docker build failed' }
273
+ }
274
+ }
275
+
276
+ # Convert a host path to the form expected by docker run -v.
277
+ # Linux/macOS: /path/to/x -> /path/to/x (pass through)
278
+ # Windows: C:\Users\foo\proj -> /c/Users/foo/proj (Docker Desktop format)
279
+ # WSL inside Windows is the bash side and never reaches this code.
280
+ function ConvertTo-DockerHostPath {
281
+ param([Parameter(Mandatory)][string]$Path)
282
+ if (-not $IsWindows) { return $Path }
283
+ # Drive letter + colon. Two forms exist:
284
+ # C:\Users\foo → absolute, valid for docker bind
285
+ # C:Users\foo → drive-relative, NOT a real absolute path
286
+ # Refuse the drive-relative form — it produces /cUsers/foo which is a
287
+ # broken bind mount and worth surfacing immediately.
288
+ if ($Path -match '^([A-Za-z]):(.*)$') {
289
+ $drive = $Matches[1].ToLowerInvariant()
290
+ $rest = $Matches[2]
291
+ if (-not ($rest.StartsWith('/') -or $rest.StartsWith('\'))) {
292
+ throw "ConvertTo-DockerHostPath: drive-relative path not supported ($Path). Use an absolute path."
293
+ }
294
+ return "/$drive" + ($rest -replace '\\', '/')
295
+ }
296
+ return ($Path -replace '\\', '/')
297
+ }
298
+
299
+ function Invoke-InCi {
300
+ param(
301
+ [Parameter(Mandatory)][hashtable]$Cfg,
302
+ [Parameter(Mandatory)][string]$Cmd,
303
+ [string]$RunUser = 'runner'
304
+ )
305
+
306
+ $imageName = Get-ImageName -StackType $Cfg.StackType
307
+
308
+ $timeout = if ($env:CI_LOCAL_TIMEOUT) { $env:CI_LOCAL_TIMEOUT } else { '600' }
309
+ if ($timeout -notmatch '^\d+$') {
310
+ Write-Host "Error: CI_LOCAL_TIMEOUT must be a positive integer, got: $timeout" -ForegroundColor Red
311
+ exit 1
312
+ }
313
+
314
+ $dockerFlags = @('--rm')
315
+ # Allocate a TTY only when stdin is a real terminal. The bash version
316
+ # uses `[ -t 0 ]`; the PowerShell equivalent is IsInputRedirected from
317
+ # System.Console — older UserInteractive + RawUI checks are unreliable
318
+ # in pwsh 7 (flagged by audit round 3).
319
+ if (-not [Console]::IsInputRedirected) { $dockerFlags += '-it' }
320
+
321
+ $hostMount = ConvertTo-DockerHostPath -Path $ProjectDir
322
+ & docker run @dockerFlags `
323
+ --stop-timeout 30 `
324
+ --entrypoint '' `
325
+ --user $RunUser `
326
+ -v "${hostMount}:/home/runner/work" `
327
+ -e CI=true `
328
+ $imageName timeout $timeout bash -c $Cmd
329
+ if ($LASTEXITCODE -ne 0) { throw "step failed with exit $LASTEXITCODE" }
330
+ }
331
+
332
+ # ─── Main ─────────────────────────────────────────────────────────────
333
+ Write-Host ''
334
+ Write-Host '=== CI-LOCAL ===' -ForegroundColor Yellow
335
+
336
+ $cfg = Get-CiConfig -ProjectDir $ProjectDir
337
+
338
+ if ($cfg.StackType -eq 'unknown') {
339
+ Write-Host 'Could not detect project type!' -ForegroundColor Red
340
+ Write-Host 'Supported: Java/Gradle, Java/Maven, Node, Python, Go, Rust' -ForegroundColor Yellow
341
+ exit 1
342
+ }
343
+
344
+ Write-Host "Detected: $($cfg.StackType) ($($cfg.BuildTool))" -ForegroundColor Green
345
+ if ($cfg.StackType -like 'java-*') {
346
+ Write-Host "Java version: $($cfg.JavaVersion)" -ForegroundColor Green
347
+ }
348
+
349
+ $ghaggaAvailable = Test-CommandExists -Name 'ghagga'
350
+
351
+ switch ($Mode) {
352
+ 'detect' {
353
+ Write-Host ''
354
+ Write-Host 'Stack details:' -ForegroundColor Cyan
355
+ Write-Host " Type: $($cfg.StackType)"
356
+ Write-Host " Build tool: $($cfg.BuildTool)"
357
+ Write-Host " Dockerfile: $($cfg.Dockerfile)"
358
+ Write-Host " Lint: $($cfg.LintCmd)"
359
+ Write-Host " Compile: $($cfg.CompileCmd)"
360
+ Write-Host " Test: $($cfg.TestCmd)"
361
+ exit 0
362
+ }
363
+
364
+ 'quick' {
365
+ Confirm-DockerImage -Cfg $cfg
366
+ Write-Host ''
367
+ Write-Host 'Running quick check...' -ForegroundColor Yellow
368
+
369
+ if ($cfg.LintCmd) {
370
+ Write-Host "Lint: $($cfg.LintCmd)" -ForegroundColor Cyan
371
+ Invoke-InCi -Cfg $cfg -Cmd "cd /home/runner/work && $($cfg.LintCmd)"
372
+ }
373
+ if ($cfg.CompileCmd) {
374
+ Write-Host "Compile: $($cfg.CompileCmd)" -ForegroundColor Cyan
375
+ Invoke-InCi -Cfg $cfg -Cmd "cd /home/runner/work && $($cfg.CompileCmd)" -RunUser 'root'
376
+ }
377
+ }
378
+
379
+ 'shell' {
380
+ Confirm-DockerImage -Cfg $cfg
381
+ Write-Host ''
382
+ Write-Host 'Opening shell in CI environment...' -ForegroundColor Yellow
383
+ $imageName = Get-ImageName -StackType $cfg.StackType
384
+ $hostMount = ConvertTo-DockerHostPath -Path $ProjectDir
385
+ & docker run --rm -it `
386
+ -v "${hostMount}:/home/runner/work" `
387
+ -e CI=true `
388
+ $imageName 'cd /home/runner/work && bash'
389
+ }
390
+
391
+ Default {
392
+ # 'full'
393
+ Confirm-DockerImage -Cfg $cfg
394
+ Write-Host ''
395
+ Write-Host 'Running full CI simulation...' -ForegroundColor Yellow
396
+
397
+ $total = 0
398
+ if ($cfg.LintCmd) { $total++ }
399
+ if ($cfg.CompileCmd) { $total++ }
400
+ if ($cfg.TestCmd) { $total++ }
401
+ if ($ghaggaAvailable) { $total++ }
402
+
403
+ $step = 1
404
+
405
+ if ($cfg.LintCmd) {
406
+ Write-Host ''
407
+ Write-Host "Step $step/$total`: Lint" -ForegroundColor Yellow
408
+ Write-Host " $($cfg.LintCmd)" -ForegroundColor Cyan
409
+ Invoke-InCi -Cfg $cfg -Cmd "cd /home/runner/work && $($cfg.LintCmd)"
410
+ $step++
411
+ }
412
+
413
+ if ($cfg.CompileCmd) {
414
+ Write-Host ''
415
+ Write-Host "Step $step/$total`: Compile" -ForegroundColor Yellow
416
+ Write-Host " $($cfg.CompileCmd)" -ForegroundColor Cyan
417
+ Invoke-InCi -Cfg $cfg -Cmd "cd /home/runner/work && $($cfg.CompileCmd)" -RunUser 'root'
418
+ $step++
419
+ }
420
+
421
+ if ($cfg.TestCmd) {
422
+ Write-Host ''
423
+ Write-Host "Step $step/$total`: Test" -ForegroundColor Yellow
424
+ Write-Host " $($cfg.TestCmd)" -ForegroundColor Cyan
425
+ Invoke-InCi -Cfg $cfg -Cmd "cd /home/runner/work && $($cfg.TestCmd)"
426
+ $step++
427
+ }
428
+
429
+ if ($ghaggaAvailable) {
430
+ Write-Host ''
431
+ Write-Host "Step $step/$total`: GHAGGA Review" -ForegroundColor Yellow
432
+ Write-Host ' ghagga review --plain --exit-on-issues' -ForegroundColor Cyan
433
+ & ghagga review --plain --exit-on-issues
434
+ if ($LASTEXITCODE -ne 0) {
435
+ Write-Host 'GHAGGA review found issues!' -ForegroundColor Red
436
+ exit 1
437
+ }
438
+ }
439
+ }
440
+ }
441
+
442
+ Write-Host ''
443
+ Write-Host 'CI Local completed successfully!' -ForegroundColor Green
444
+ Write-Host ' Safe to push - CI should pass.' -ForegroundColor Green
445
+ Write-Host ''