@ojokesusu/lintasai 1.1.2

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 (86) hide show
  1. package/.github/workflows/publish-npm.yml +40 -0
  2. package/.github/workflows/validate.yml +93 -0
  3. package/AUDIT_POST_SETUP_PROMPT_v1.md +280 -0
  4. package/BOOTSTRAP_PROJECT_DOCS_PROMPT_v1.md +3 -0
  5. package/CHANGELOG.md +313 -0
  6. package/CLAUDE_universal_v1.md +1021 -0
  7. package/CONTRIBUTING.md +101 -0
  8. package/FIRST_SESSION_PROMPT_v1.md +7 -0
  9. package/JALANKAN_KIT.md +188 -0
  10. package/LICENSE +21 -0
  11. package/MULAI_DI_SINI.md +145 -0
  12. package/PROJECT_KICKOFF_PROMPT_v1.md +3 -0
  13. package/PROJECT_LIFECYCLE_PROMPT_v1.md +536 -0
  14. package/PROJECT_MIGRATION_PROMPT_v1.md +3 -0
  15. package/README.md +505 -0
  16. package/SETUP_POLA_B_PROMPT_v1.md +5 -0
  17. package/SPLIT_REPO_MIGRATION_PROMPT_v1.md +485 -0
  18. package/TEAM_ROLLOUT_GUIDE_v1.md +172 -0
  19. package/UPDATE_DOCS_PROMPT_v1.md +3 -0
  20. package/UPDATE_KIT_PROMPT_v1.md +213 -0
  21. package/bin/lintasai.js +81 -0
  22. package/docs/SIGNED_RELEASE.md +162 -0
  23. package/install-windows.ps1 +225 -0
  24. package/kit.ps1 +508 -0
  25. package/lib/agents-md.ps1 +174 -0
  26. package/lib/git-helpers.ps1 +104 -0
  27. package/lib/kit-files.psd1 +133 -0
  28. package/lib/manifest-signing.ps1 +65 -0
  29. package/lib/manifest.ps1 +267 -0
  30. package/lib/rollback.ps1 +241 -0
  31. package/lib/safety.ps1 +193 -0
  32. package/lib/template-deploy.ps1 +242 -0
  33. package/lib/version-detect.ps1 +161 -0
  34. package/package.json +36 -0
  35. package/setup-pola-b.ps1 +687 -0
  36. package/templates/ANALOGI_LIBRARY.md +7 -0
  37. package/templates/CLAUDE_TEAM_GUIDE.md +505 -0
  38. package/templates/CROSS_REPO_TYPES_PIPELINE.md +473 -0
  39. package/templates/DB_SCHEMA_SCAN_PROMPT.md +194 -0
  40. package/templates/DISCORD_BOT_INTEGRATION.md +187 -0
  41. package/templates/GLOSSARY_NON_PROGRAMMER.md +361 -0
  42. package/templates/INDEX.md +157 -0
  43. package/templates/MCP_SETUP.md +1145 -0
  44. package/templates/MIGRATE_TO_SUBFOLDER_PROMPT_v1.md +220 -0
  45. package/templates/ONBOARDING.md +172 -0
  46. package/templates/PROJECT_STARTER_TEMPLATES.md +264 -0
  47. package/templates/PROMPT_LIBRARY.md +790 -0
  48. package/templates/RLS_SETUP_PROMPT.md +167 -0
  49. package/templates/SECURITY_INCIDENT_PLAYBOOK.md +191 -0
  50. package/templates/SPLIT_REPO_AGENTS_TEMPLATES.md +32 -0
  51. package/templates/SPLIT_REPO_NON_PROGRAMMER_PROMPTS.md +604 -0
  52. package/templates/SPLIT_REPO_TOOLS_SETUP.md +388 -0
  53. package/templates/STACK_DETECTION_PATTERN.md +261 -0
  54. package/templates/STACK_GUIDE.md +564 -0
  55. package/templates/STACK_MIGRATION_GUIDE.md +154 -0
  56. package/templates/STACK_VERSIONS.md +31 -0
  57. package/templates/UPDATE_GUIDE.md +246 -0
  58. package/templates/_EXAMPLE.md +110 -0
  59. package/templates/_PATTERNS.md +173 -0
  60. package/templates/architecture.md +180 -0
  61. package/templates/architecture_auto.md +61 -0
  62. package/templates/decisions/README.md +108 -0
  63. package/templates/decisions/_TEMPLATE.md +84 -0
  64. package/templates/feature-flags-advanced.md +171 -0
  65. package/templates/github/CODEOWNERS.template +61 -0
  66. package/templates/github/GENERATE_TYPES_SCRIPT.md +77 -0
  67. package/templates/github/PUBLISH_SHARED_WORKFLOW.yml +52 -0
  68. package/templates/github/RECEIVE_BACKEND_UPDATE.yml +106 -0
  69. package/templates/github/RENOVATE_FRONTEND.json +28 -0
  70. package/templates/github/TRIGGER_FRONTEND_UPDATE.yml +29 -0
  71. package/templates/github/pull_request_template.md +44 -0
  72. package/templates/github/scripts/ai-review.js +153 -0
  73. package/templates/github/workflows/ai-review.yml +61 -0
  74. package/templates/github/workflows/backup-schemas.yml +169 -0
  75. package/templates/glossary.md +110 -0
  76. package/templates/split-agents/BACKEND.md +149 -0
  77. package/templates/split-agents/FRONTEND.md +141 -0
  78. package/templates/split-agents/SHARED.md +82 -0
  79. package/templates/split-agents/TOOLS.md +77 -0
  80. package/tests/Run-Tests.ps1 +19 -0
  81. package/tests/lib-safety.Tests.ps1 +66 -0
  82. package/tests/rollback.Tests.ps1 +66 -0
  83. package/tests/uninstall.Tests.ps1 +265 -0
  84. package/tests/update-kit.Tests.ps1 +78 -0
  85. package/uninstall.ps1 +794 -0
  86. package/update-kit.ps1 +907 -0
@@ -0,0 +1,241 @@
1
+ #Requires -Version 5.1
2
+ Set-StrictMode -Version Latest
3
+ # NOTE: ErrorActionPreference sengaja TIDAK di-set 'Stop' di scope file ini, supaya
4
+ # rollback bisa graceful no-op kalau manifest tidak ada / tidak ada .bak files.
5
+ # Critical calls pakai -ErrorAction Stop per-call, dibungkus try/catch lokal.
6
+
7
+ function Find-ManifestPath {
8
+ param([Parameter(Mandatory=$true)][string]$ProjectRoot)
9
+ # Probe 3 lokasi kandidat — setup-pola-b.ps1 bisa naruh manifest di project-root
10
+ # ATAU di .claude-kit/ tergantung mode setup. Cek semua sebelum nyerah.
11
+ $candidates = @(
12
+ (Join-Path $ProjectRoot '.install-manifest.json'),
13
+ (Join-Path $ProjectRoot '.claude-kit\.install-manifest.json'),
14
+ (Join-Path $ProjectRoot '.claude-kit\install-manifest.json')
15
+ )
16
+ foreach ($c in $candidates) {
17
+ if (Test-Path $c) { return $c }
18
+ }
19
+ return $null
20
+ }
21
+
22
+ function Get-FileSha256 {
23
+ param([Parameter(Mandatory=$true)][string]$Path)
24
+ $hash = Get-FileHash -Path $Path -Algorithm SHA256
25
+ return $hash.Hash.ToLowerInvariant()
26
+ }
27
+
28
+ function Read-ManifestJson {
29
+ param([Parameter(Mandatory=$true)][string]$Path)
30
+ $raw = [System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::UTF8)
31
+ return (ConvertFrom-Json -InputObject $raw)
32
+ }
33
+
34
+ function Write-ManifestJson {
35
+ param(
36
+ [Parameter(Mandatory=$true)][string]$Path,
37
+ [Parameter(Mandatory=$true)]$Manifest
38
+ )
39
+ $json = ConvertTo-Json -InputObject $Manifest -Depth 12
40
+ $tmp = "$Path.tmp"
41
+ [System.IO.File]::WriteAllText($tmp, $json, `
42
+ (New-Object System.Text.UTF8Encoding($false)))
43
+ Move-Item -Path $tmp -Destination $Path -Force
44
+ }
45
+
46
+ function Find-LatestBackup {
47
+ param(
48
+ [Parameter(Mandatory=$true)][string]$OriginalPath
49
+ )
50
+ $dir = Split-Path -Parent $OriginalPath
51
+ $leaf = Split-Path -Leaf $OriginalPath
52
+ if (-not (Test-Path $dir)) { return $null }
53
+ $pattern = "$leaf.bak.*"
54
+ $candidates = Get-ChildItem -Path $dir -Filter $pattern -File `
55
+ -ErrorAction SilentlyContinue
56
+ if (-not $candidates -or $candidates.Count -eq 0) { return $null }
57
+ # Sort by timestamp suffix after ".bak." (descending lexicographic).
58
+ $sorted = $candidates | Sort-Object -Property Name -Descending
59
+ return $sorted[0].FullName
60
+ }
61
+
62
+ function Confirm-Rollback {
63
+ param([switch]$Force)
64
+ if ($Force) { return $true }
65
+ $c = 'n'
66
+ try {
67
+ $c = Read-Host 'Lanjut rollback? (y/N)'
68
+ } catch {
69
+ $c = 'n'
70
+ }
71
+ if ($null -eq $c) { return $false }
72
+ $cn = $c.Trim().ToLowerInvariant()
73
+ return ($cn -eq 'y' -or $cn -eq 'yes')
74
+ }
75
+
76
+ function Test-GitDirty {
77
+ param([Parameter(Mandatory=$true)][string]$Root)
78
+ try {
79
+ $out = & git -C $Root status --porcelain 2>$null
80
+ if ($LASTEXITCODE -ne 0) { return $false }
81
+ if ([string]::IsNullOrWhiteSpace($out)) { return $false }
82
+ return $true
83
+ } catch {
84
+ return $false
85
+ }
86
+ }
87
+
88
+ function Invoke-Rollback {
89
+ [CmdletBinding()]
90
+ param(
91
+ [string]$ProjectRoot = (Get-Location).Path,
92
+ [switch]$Force,
93
+ [switch]$DryRun
94
+ )
95
+
96
+ try {
97
+ $root = (Resolve-Path -Path $ProjectRoot -ErrorAction Stop).Path
98
+ } catch {
99
+ Write-Host ("[INFO] ProjectRoot tidak valid: {0}. Tidak ada yang di-rollback." -f $ProjectRoot)
100
+ return @{ status = 'no-project-root'; restored = 0; skipped = 0 }
101
+ }
102
+
103
+ $manifestPath = Find-ManifestPath -ProjectRoot $root
104
+ if (-not $manifestPath) {
105
+ Write-Host '[INFO] Tidak ada manifest. Belum pernah install atau setup belum jalan. Tidak ada yang di-rollback.'
106
+ return @{ status = 'no-manifest'; restored = 0; skipped = 0 }
107
+ }
108
+
109
+ if (Test-GitDirty -Root $root) {
110
+ Write-Warning ("Working tree git dirty di {0}. " + `
111
+ "Pertimbangkan stash/commit sebelum rollback." -f $root)
112
+ }
113
+
114
+ try {
115
+ $manifest = Read-ManifestJson -Path $manifestPath
116
+ } catch {
117
+ Write-Host ("[ERROR] Gagal parse manifest {0}: {1}" -f $manifestPath, $_.Exception.Message)
118
+ return @{ status = 'manifest-parse-error'; restored = 0; skipped = 0 }
119
+ }
120
+
121
+ $hasFilesProp = $false
122
+ if ($manifest -and $manifest.PSObject -and $manifest.PSObject.Properties) {
123
+ $hasFilesProp = (@($manifest.PSObject.Properties.Name) -contains 'files')
124
+ }
125
+ if (-not $hasFilesProp) {
126
+ Write-Host "[INFO] Manifest tidak punya properti 'files'. Tidak ada yang di-rollback."
127
+ return @{ status = 'manifest-no-files'; restored = 0; skipped = 0 }
128
+ }
129
+
130
+ # Pre-scan: ada minimal 1 .bak file yang nyambung ke manifest entry?
131
+ # Kalau zero .bak → fresh install belum pernah di-update, return graceful no-op.
132
+ $hasAnyBak = $false
133
+ foreach ($entry in $manifest.files) {
134
+ $absOrig = Join-Path $root $entry.path
135
+ $entryDir = Split-Path -Parent $absOrig
136
+ $entryLeaf = Split-Path -Leaf $entry.path
137
+ if (Test-Path $entryDir) {
138
+ $entryCandidates = Get-ChildItem -Path $entryDir -Filter "$entryLeaf.bak*" `
139
+ -File -ErrorAction SilentlyContinue
140
+ if ($entryCandidates) { $hasAnyBak = $true; break }
141
+ }
142
+ }
143
+ if (-not $hasAnyBak) {
144
+ Write-Host '[OK] Manifest ada tapi tidak ada file .bak. Tidak ada yang di-rollback (fresh install belum di-update).'
145
+ return @{ status = 'no-backups'; restored = 0; skipped = 0 }
146
+ }
147
+
148
+ $plan = New-Object System.Collections.ArrayList
149
+ foreach ($entry in $manifest.files) {
150
+ $orig = Join-Path $root $entry.path
151
+ $bak = Find-LatestBackup -OriginalPath $orig
152
+ $null = $plan.Add([pscustomobject]@{
153
+ Entry = $entry
154
+ Original = $orig
155
+ Backup = $bak
156
+ })
157
+ }
158
+
159
+ if ($DryRun) {
160
+ foreach ($p in $plan) {
161
+ if ($null -ne $p.Backup) {
162
+ Write-Host ("would restore: {0} <- {1}" -f `
163
+ $p.Original, $p.Backup)
164
+ } else {
165
+ Write-Host ("skip (no backup): {0}" -f $p.Original)
166
+ }
167
+ }
168
+ return [pscustomobject]@{
169
+ dryRun = $true
170
+ restored = 0
171
+ skipped = ($plan | Where-Object { $null -eq $_.Backup }).Count
172
+ items = $plan
173
+ }
174
+ }
175
+
176
+ if (-not (Confirm-Rollback -Force:$Force)) {
177
+ Write-Host 'Dibatalkan.'
178
+ return [pscustomobject]@{
179
+ cancelled = $true
180
+ restored = 0
181
+ skipped = $plan.Count
182
+ items = @()
183
+ }
184
+ }
185
+
186
+ $restored = 0
187
+ $skipped = 0
188
+ $items = New-Object System.Collections.ArrayList
189
+
190
+ foreach ($p in $plan) {
191
+ if ($null -eq $p.Backup) {
192
+ $skipped++
193
+ $null = $items.Add([pscustomobject]@{
194
+ path = $p.Entry.path
195
+ action = 'skip'
196
+ reason = 'no-backup'
197
+ })
198
+ continue
199
+ }
200
+ Copy-Item -Path $p.Backup -Destination $p.Original -Force
201
+ $newHash = Get-FileSha256 -Path $p.Original
202
+ # Update manifest entry in place.
203
+ if ($p.Entry.PSObject.Properties.Name -contains 'sha256') {
204
+ $p.Entry.sha256 = $newHash
205
+ } else {
206
+ $p.Entry | Add-Member -NotePropertyName 'sha256' `
207
+ -NotePropertyValue $newHash -Force
208
+ }
209
+ if ($p.Entry.PSObject.Properties.Name -contains 'rolledBackAt') {
210
+ $p.Entry.rolledBackAt = (Get-Date).ToString('o')
211
+ } else {
212
+ $p.Entry | Add-Member -NotePropertyName 'rolledBackAt' `
213
+ -NotePropertyValue ((Get-Date).ToString('o')) -Force
214
+ }
215
+ $restored++
216
+ $null = $items.Add([pscustomobject]@{
217
+ path = $p.Entry.path
218
+ action = 'restore'
219
+ backup = $p.Backup
220
+ sha256 = $newHash
221
+ })
222
+ }
223
+
224
+ Write-ManifestJson -Path $manifestPath -Manifest $manifest
225
+
226
+ return [pscustomobject]@{
227
+ restored = $restored
228
+ skipped = $skipped
229
+ items = $items
230
+ }
231
+ }
232
+
233
+ function Get-RollbackPreview {
234
+ [CmdletBinding()]
235
+ param(
236
+ [string]$ProjectRoot = (Get-Location).Path
237
+ )
238
+ return (Invoke-Rollback -ProjectRoot $ProjectRoot -DryRun)
239
+ }
240
+
241
+ # Functions auto-exposed via dot-source (no Export-ModuleMember karena .ps1 di-load via `. $path`)
package/lib/safety.ps1 ADDED
@@ -0,0 +1,193 @@
1
+ <#
2
+ .SYNOPSIS
3
+ lib/safety.ps1 - Shared safety helpers untuk lintasAI kit scripts.
4
+
5
+ .DESCRIPTION
6
+ Module ini berisi helper path-safety yang dipakai bersama oleh script kit
7
+ (setup-pola-b.ps1, uninstall.ps1, dst.) untuk mencegah path traversal,
8
+ symlink/junction redirect, dan operasi pada path di luar project root.
9
+
10
+ Helpers yang di-export:
11
+ - Initialize-SafeProjectPath : Set $script:ProjectRoot + $script:ProjectRootCanonical
12
+ sekaligus (containment base). Wajib dipanggil
13
+ sekali sebelum Resolve-SafeProjectPath kalau caller
14
+ ingin API eksplisit (bukan set variable manual).
15
+ - Resolve-SafeProjectPath : Validasi + resolve relative path dari manifest
16
+ ke absolute path dalam project root. Reject
17
+ absolute path, parent traversal ('..'), dan
18
+ path yang escape root. Accept -RelPath (legacy)
19
+ atau alias -RelativePath. Behavior reject =
20
+ throw exception (non-recoverable security boundary).
21
+ - Test-PathHasReparsePoint : Detect reparse point (junction/symlink) di
22
+ target path ATAU di parent segment manapun
23
+ antara $ProjectRoot dan target.
24
+ - Get-FileSha256 : SHA-256 file hash dalam lower-case hex string.
25
+
26
+ Kontrak penting:
27
+ Caller bisa pakai Initialize-SafeProjectPath -ProjectRoot <full path> ATAU
28
+ set manual: $script:ProjectRootCanonical = <full path with trailing separator>.
29
+ Variable ini dibaca oleh helper sebagai base containment check.
30
+
31
+ .NOTES
32
+ Versi : 1.0.0
33
+ Tanggal: 2026-06-04
34
+ Catatan keamanan: jangan ubah logic reject tanpa review - ini security boundary.
35
+ #>
36
+
37
+ # ---- Project-root initializer ----
38
+ # Set $script:ProjectRoot + $script:ProjectRootCanonical sekaligus. API eksplisit
39
+ # supaya caller (dan test harness) tidak perlu tahu detail internal variable name.
40
+ # Test/caller equivalent ke pattern manual lama:
41
+ # $ProjectRoot = $path
42
+ # $script:ProjectRootCanonical = $path + '\'
43
+ function Initialize-SafeProjectPath {
44
+ param(
45
+ [Parameter(Mandatory)][string]$ProjectRoot
46
+ )
47
+ # Normalize via GetFullPath supaya '.', '..' di input ter-resolve sebelum kita simpan.
48
+ try {
49
+ $normalized = [System.IO.Path]::GetFullPath($ProjectRoot)
50
+ } catch {
51
+ throw "Initialize-SafeProjectPath: invalid ProjectRoot '$ProjectRoot' ($($_.Exception.Message))"
52
+ }
53
+ $script:ProjectRoot = $normalized
54
+ # Pastikan trailing DirectorySeparatorChar supaya containment check structural (cegah prefix collision).
55
+ if (-not $normalized.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
56
+ $script:ProjectRootCanonical = $normalized + [System.IO.Path]::DirectorySeparatorChar
57
+ } else {
58
+ $script:ProjectRootCanonical = $normalized
59
+ }
60
+ # Set $ProjectRoot di parent scope juga supaya legacy callers yang baca $ProjectRoot tanpa $script:
61
+ # tetap dapat value. Set-Variable -Scope 1 = scope caller (yang dot-source / panggil function ini).
62
+ try { Set-Variable -Name ProjectRoot -Value $normalized -Scope 1 -ErrorAction SilentlyContinue } catch {}
63
+ }
64
+
65
+ # ---- Path-traversal & symlink helpers ----
66
+ # Resolve a manifest-supplied relative path safely:
67
+ # - Reject absolute paths (drive-letter, leading \ or /).
68
+ # - Reject paths containing '..' segments.
69
+ # - Normalize via [System.IO.Path]::GetFullPath() and verify containment in $ProjectRoot.
70
+ # - Throws on reject (security boundary - jangan silent-fallback).
71
+ function Resolve-SafeProjectPath {
72
+ param(
73
+ # Accept legacy nama -RelPath, alias -RelativePath untuk API yang lebih jelas.
74
+ [Parameter(Mandatory)]
75
+ [Alias('RelativePath')]
76
+ [string]$RelPath,
77
+ [string]$Label = 'entry'
78
+ )
79
+ # Defense-in-depth: pastikan $script:ProjectRootCanonical SELALU diakhiri DirectorySeparatorChar.
80
+ # Tanpa trailing separator, StartsWith() bisa false-match: "C:\proj" matches "C:\proj-evil\foo".
81
+ # Caller seharusnya sudah set ini, tapi normalize lagi di sini supaya containment check structural.
82
+ if ($script:ProjectRootCanonical -and -not $script:ProjectRootCanonical.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
83
+ $script:ProjectRootCanonical += [System.IO.Path]::DirectorySeparatorChar
84
+ }
85
+ if ([string]::IsNullOrWhiteSpace($script:ProjectRootCanonical)) {
86
+ throw "Resolve-SafeProjectPath: ProjectRoot belum di-initialize. Panggil Initialize-SafeProjectPath dulu."
87
+ }
88
+ if ([string]::IsNullOrWhiteSpace($RelPath)) {
89
+ $msg = "REJECT empty path for $Label"
90
+ Write-Host $msg -ForegroundColor Red
91
+ throw $msg
92
+ }
93
+ # Block absolute paths: drive-letter (C:\), UNC (\\server\), leading separators.
94
+ if ([System.IO.Path]::IsPathRooted($RelPath) -or $RelPath -match '^[a-zA-Z]:' -or $RelPath -match '^[\\/]') {
95
+ $msg = "REJECT absolute path in manifest ($Label): $RelPath"
96
+ Write-Host $msg -ForegroundColor Red
97
+ throw $msg
98
+ }
99
+ # Block parent traversal segments.
100
+ if ($RelPath -match '(^|[\\/])\.\.([\\/]|$)') {
101
+ $msg = "REJECT parent-traversal segment in manifest ($Label): $RelPath"
102
+ Write-Host $msg -ForegroundColor Red
103
+ throw $msg
104
+ }
105
+ # Pakai $script:ProjectRoot (ter-set oleh Initialize-SafeProjectPath) atau fallback ke $ProjectRoot caller scope.
106
+ $rootForJoin = if ($script:ProjectRoot) { $script:ProjectRoot } else { $ProjectRoot }
107
+ $candidate = Join-Path $rootForJoin ($RelPath -replace '/', '\')
108
+ try {
109
+ $full = [System.IO.Path]::GetFullPath($candidate)
110
+ } catch {
111
+ $msg = "REJECT invalid path in manifest ($Label): $RelPath"
112
+ Write-Host $msg -ForegroundColor Red
113
+ throw $msg
114
+ }
115
+ # Containment check (case-insensitive prefix-with-separator).
116
+ if (-not $full.StartsWith($script:ProjectRootCanonical, [System.StringComparison]::OrdinalIgnoreCase)) {
117
+ $msg = "REJECT path escapes project root ($Label): $RelPath -> $full"
118
+ Write-Host $msg -ForegroundColor Red
119
+ throw $msg
120
+ }
121
+ return $full
122
+ }
123
+
124
+ # Detect reparse points (junction / symlink) anywhere in the path between $ProjectRoot
125
+ # and the target. Junction in mid-path lets attacker redirect a contained path to outside.
126
+ # Returns $true kalau target ATAU parent segment apapun adalah reparse point.
127
+ function Test-PathHasReparsePoint {
128
+ param([Parameter(Mandatory)][string]$FullPath)
129
+ $rootClean = $script:ProjectRootCanonical.TrimEnd([System.IO.Path]::DirectorySeparatorChar)
130
+ # Structural ancestor walk: pakai DirectoryInfo.Parent (bukan Split-Path string-based)
131
+ # supaya loop exit kondisi struktural (root drive), bukan length-based yang bisa kena edge case
132
+ # (mis. UNC path, drive root tanpa trailing sep, atau path lebih pendek dari rootClean).
133
+ $current = $null
134
+ if (Test-Path -LiteralPath $FullPath) {
135
+ try {
136
+ $startItem = Get-Item -Force -LiteralPath $FullPath -ErrorAction Stop
137
+ if ($startItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
138
+ return $true
139
+ }
140
+ # Untuk file: pakai Directory; untuk folder: pakai DirectoryInfo itself.
141
+ if ($startItem.PSIsContainer) {
142
+ $current = [System.IO.DirectoryInfo]$startItem
143
+ } else {
144
+ $current = $startItem.Directory
145
+ }
146
+ } catch {
147
+ # Kalau Get-Item gagal, defensive return true (treat as suspicious).
148
+ return $true
149
+ }
150
+ } else {
151
+ # Path tidak ada di disk: walk parents tetap perlu (parent bisa reparse point).
152
+ try {
153
+ $current = [System.IO.DirectoryInfo](Split-Path -Parent $FullPath)
154
+ } catch {
155
+ return $false
156
+ }
157
+ }
158
+ while ($current -and $current.FullName -ne $current.Root.FullName) {
159
+ try {
160
+ $info = Get-Item -Force -LiteralPath $current.FullName -ErrorAction Stop
161
+ if ($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
162
+ return $true
163
+ }
164
+ } catch {
165
+ return $true
166
+ }
167
+ $current = $current.Parent
168
+ if (-not $current) { break }
169
+ # Stop kalau sudah keluar (atau sama dengan) project root canonical.
170
+ if ($current.FullName.Length -lt $rootClean.Length) { break }
171
+ }
172
+ return $false
173
+ }
174
+
175
+ # ---- File hashing helper ----
176
+ # Compute SHA-256 hash dari file, return lower-case hex string.
177
+ # Wrapper tipis di atas Get-FileHash supaya caller (manifest, integrity check, test) tidak
178
+ # perlu mikirin algo + casing setiap kali. Throws kalau file tidak ada / tidak bisa dibaca.
179
+ function Get-FileSha256 {
180
+ param(
181
+ [Parameter(Mandatory)][string]$FilePath
182
+ )
183
+ if (-not (Test-Path -LiteralPath $FilePath)) {
184
+ throw "Get-FileSha256: file tidak ditemukan: $FilePath"
185
+ }
186
+ $hash = Get-FileHash -LiteralPath $FilePath -Algorithm SHA256 -ErrorAction Stop
187
+ return $hash.Hash.ToLowerInvariant()
188
+ }
189
+
190
+ # Functions auto-exposed via dot-source (no Export-ModuleMember karena .ps1 di-load via `. $path`).
191
+ # Caller pakai pattern: . (Join-Path $PSScriptRoot 'lib\safety.ps1')
192
+ # Setelah dot-source, Initialize-SafeProjectPath, Resolve-SafeProjectPath,
193
+ # Test-PathHasReparsePoint, Get-FileSha256 accessible di caller scope.
@@ -0,0 +1,242 @@
1
+ <#
2
+ .SYNOPSIS
3
+ lib/template-deploy.ps1 - Helper deploy template file dari kit ke project.
4
+
5
+ .DESCRIPTION
6
+ Extract logic copy template + placeholder substitution dari setup-pola-b.ps1
7
+ supaya bisa di-reuse oleh script lain (update-kit.ps1, kit.ps1, dst.) dan
8
+ di-test independen.
9
+
10
+ Helpers yang di-export:
11
+ - Copy-TemplateWithPlaceholder : Copy file dari kit ke target dengan
12
+ placeholder substitution. Mode: skip kalau
13
+ target sudah ada, backup, atau overwrite.
14
+ Returns object { copied, action, sha256 }.
15
+ - Copy-StaticTemplate : Copy file kit -> target TANPA substitution
16
+ (verbatim, untuk file generic seperti
17
+ _PATTERNS.md). Mode existing-file sama.
18
+ Returns object { copied, action, sha256 }.
19
+ - Get-SupportedPlaceholders : List placeholder yang di-support setup
20
+ standar (NAMA_PROYEK, TANGGAL_HARI_INI,
21
+ NAMA_KAMU, URL_REPO_STANDAR, VERSI_KIT).
22
+
23
+ Kontrak penting:
24
+ - Pakai .Replace() literal (bukan -replace regex) supaya input user dengan
25
+ karakter $0/$1/$ tidak corrupt output.
26
+ - Write file dengan UTF-8 NO-BOM (System.Text.UTF8Encoding $false) -- PS 5.1
27
+ default UTF8 with BOM, sebagian tool sensitif.
28
+ - SHA256 hash di-compute dari file TARGET setelah write (bukan source) supaya
29
+ reflect content actual yang ada di disk (post-substitution).
30
+ - Returns object dengan field: copied (bool), action (string), sha256 (string|null).
31
+ action = 'created' : target tidak ada sebelumnya, write sukses.
32
+ action = 'updated' : target ada, di-overwrite (dengan/tanpa backup).
33
+ action = 'skipped' : target ada, mode = Skip (default anti-overwrite).
34
+ action = 'missing' : source tidak ada (graceful skip, return copied=false).
35
+ - No global state: semua input via parameter, output via return value. Function
36
+ TIDAK baca/tulis $script:installedItems atau global lain.
37
+
38
+ .NOTES
39
+ Versi : 1.0.0
40
+ Tanggal: 2026-06-06
41
+ PowerShell 5.1+ compatible.
42
+ #>
43
+
44
+ # ---- Placeholder catalog ----
45
+ # Single source of truth untuk placeholder yang di-support oleh setup standar.
46
+ # Caller boleh kirim placeholder tambahan lewat -Placeholders, ini cuma referensi.
47
+ function Get-SupportedPlaceholders {
48
+ [CmdletBinding()]
49
+ param()
50
+ return @(
51
+ '<NAMA_PROYEK>',
52
+ '<TANGGAL_HARI_INI>',
53
+ '<NAMA_KAMU>',
54
+ '<URL_REPO_STANDAR>',
55
+ '<VERSI_KIT>'
56
+ )
57
+ }
58
+
59
+ # ---- Internal helper: hitung SHA256 lower-case hex dari file ----
60
+ # Local helper supaya template-deploy.ps1 self-contained (tidak depend ke safety.ps1).
61
+ # Caller yang sudah load safety.ps1 boleh ignore -- ini private scope.
62
+ function _Get-FileSha256Hex {
63
+ param([Parameter(Mandatory)][string]$Path)
64
+ if (-not (Test-Path -LiteralPath $Path)) { return $null }
65
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
66
+ }
67
+
68
+ # ---- Internal helper: write UTF-8 NO-BOM ----
69
+ # PS 5.1 default UTF8 = with BOM (corrupt sebagian linter/parser).
70
+ # Pakai System.IO.File untuk write tanpa BOM, konsisten dengan setup-pola-b.ps1.
71
+ function _Write-Utf8NoBom {
72
+ param(
73
+ [Parameter(Mandatory)][string]$Path,
74
+ [Parameter(Mandatory)][string]$Content
75
+ )
76
+ $enc = New-Object System.Text.UTF8Encoding $false
77
+ [System.IO.File]::WriteAllText($Path, $Content, $enc)
78
+ }
79
+
80
+ # ---- Internal helper: handle existing target file ----
81
+ # Return $true kalau caller boleh proceed write, $false kalau harus skip.
82
+ # Mode:
83
+ # 'Skip' : target ada -> return $false (no write).
84
+ # 'Backup' : target ada -> backup ke .backup-<timestamp>, return $true.
85
+ # 'Overwrite' : target ada -> langsung overwrite, return $true.
86
+ function _Resolve-ExistingTarget {
87
+ param(
88
+ [Parameter(Mandatory)][string]$TargetPath,
89
+ [Parameter(Mandatory)][ValidateSet('Skip','Backup','Overwrite')][string]$Mode,
90
+ [string]$BackupSuffix
91
+ )
92
+ if (-not (Test-Path -LiteralPath $TargetPath)) {
93
+ # Target tidak ada -- caller bebas write.
94
+ return $true
95
+ }
96
+ switch ($Mode) {
97
+ 'Skip' { return $false }
98
+ 'Overwrite' { return $true }
99
+ 'Backup' {
100
+ if (-not $BackupSuffix) {
101
+ $BackupSuffix = Get-Date -Format 'yyyyMMdd-HHmmss'
102
+ }
103
+ $bakPath = "$TargetPath.backup-$BackupSuffix"
104
+ Copy-Item -LiteralPath $TargetPath -Destination $bakPath -Force
105
+ return $true
106
+ }
107
+ }
108
+ return $false
109
+ }
110
+
111
+ # ---- Public: Copy template dengan placeholder substitution ----
112
+ # Source: file template di kit (mis. AGENTS.md.template, templates/architecture.md).
113
+ # Target: lokasi tujuan di project (mis. <proj>/AGENTS.md, <proj>/docs/architecture.md).
114
+ # Placeholders: hashtable @{ '<NAMA_PROYEK>' = 'akses'; '<TANGGAL_HARI_INI>' = '2026-06-06' }.
115
+ # Key harus exact match (termasuk angle brackets) ke string di template.
116
+ # Pakai .Replace() literal -- safe untuk value dengan $/regex chars.
117
+ # IfExists: 'Skip' (default), 'Backup', 'Overwrite'.
118
+ # BackupSuffix: optional, default = timestamp now (yyyyMMdd-HHmmss).
119
+ #
120
+ # Returns: PSCustomObject {
121
+ # copied: bool -- true kalau file ditulis (created atau updated).
122
+ # action: string -- 'created' | 'updated' | 'skipped' | 'missing'.
123
+ # sha256: string|null -- SHA256 hash file TARGET setelah write, null kalau skipped/missing.
124
+ # }
125
+ function Copy-TemplateWithPlaceholder {
126
+ [CmdletBinding()]
127
+ param(
128
+ [Parameter(Mandatory)][string]$SourcePath,
129
+ [Parameter(Mandatory)][string]$TargetPath,
130
+ [hashtable]$Placeholders = @{},
131
+ [ValidateSet('Skip','Backup','Overwrite')][string]$IfExists = 'Skip',
132
+ [string]$BackupSuffix
133
+ )
134
+
135
+ # ---- Validasi source ada ----
136
+ if (-not (Test-Path -LiteralPath $SourcePath)) {
137
+ return [PSCustomObject]@{
138
+ copied = $false
139
+ action = 'missing'
140
+ sha256 = $null
141
+ }
142
+ }
143
+
144
+ # ---- Cek target existing, decide skip/backup/overwrite ----
145
+ $targetExists = Test-Path -LiteralPath $TargetPath
146
+ $proceed = _Resolve-ExistingTarget -TargetPath $TargetPath -Mode $IfExists -BackupSuffix $BackupSuffix
147
+ if (-not $proceed) {
148
+ return [PSCustomObject]@{
149
+ copied = $false
150
+ action = 'skipped'
151
+ sha256 = $null
152
+ }
153
+ }
154
+
155
+ # ---- Ensure parent dir exists ----
156
+ $parentDir = Split-Path -Parent $TargetPath
157
+ if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) {
158
+ New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
159
+ }
160
+
161
+ # ---- Read template + apply substitution ----
162
+ $content = Get-Content -LiteralPath $SourcePath -Raw -Encoding UTF8
163
+ if ($null -eq $content) { $content = '' }
164
+
165
+ if ($Placeholders -and $Placeholders.Count -gt 0) {
166
+ foreach ($key in $Placeholders.Keys) {
167
+ $value = [string]$Placeholders[$key]
168
+ # Pakai .Replace() literal (bukan -replace regex) supaya value dengan
169
+ # karakter $0/$1/$/backslash tidak corrupt output.
170
+ $content = $content.Replace($key, $value)
171
+ }
172
+ }
173
+
174
+ # ---- Write target ----
175
+ _Write-Utf8NoBom -Path $TargetPath -Content $content
176
+
177
+ # ---- Compute hash dari file TARGET (post-substitution) ----
178
+ $sha = _Get-FileSha256Hex -Path $TargetPath
179
+
180
+ $action = if ($targetExists) { 'updated' } else { 'created' }
181
+ return [PSCustomObject]@{
182
+ copied = $true
183
+ action = $action
184
+ sha256 = $sha
185
+ }
186
+ }
187
+
188
+ # ---- Public: Copy template tanpa substitution (verbatim copy) ----
189
+ # Untuk file generic yang tidak punya placeholder (mis. _PATTERNS.md, _EXAMPLE.md,
190
+ # CODEOWNERS.template, decisions/_TEMPLATE.md).
191
+ #
192
+ # Implementation: tetap pakai _Write-Utf8NoBom (bukan Copy-Item) supaya konsisten
193
+ # encoding output -- file template di kit-dev mungkin di-save dengan BOM oleh editor,
194
+ # kita normalize ke NO-BOM saat deploy.
195
+ #
196
+ # Returns: PSCustomObject sama seperti Copy-TemplateWithPlaceholder.
197
+ function Copy-StaticTemplate {
198
+ [CmdletBinding()]
199
+ param(
200
+ [Parameter(Mandatory)][string]$SourcePath,
201
+ [Parameter(Mandatory)][string]$TargetPath,
202
+ [ValidateSet('Skip','Backup','Overwrite')][string]$IfExists = 'Skip',
203
+ [string]$BackupSuffix
204
+ )
205
+
206
+ if (-not (Test-Path -LiteralPath $SourcePath)) {
207
+ return [PSCustomObject]@{
208
+ copied = $false
209
+ action = 'missing'
210
+ sha256 = $null
211
+ }
212
+ }
213
+
214
+ $targetExists = Test-Path -LiteralPath $TargetPath
215
+ $proceed = _Resolve-ExistingTarget -TargetPath $TargetPath -Mode $IfExists -BackupSuffix $BackupSuffix
216
+ if (-not $proceed) {
217
+ return [PSCustomObject]@{
218
+ copied = $false
219
+ action = 'skipped'
220
+ sha256 = $null
221
+ }
222
+ }
223
+
224
+ $parentDir = Split-Path -Parent $TargetPath
225
+ if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) {
226
+ New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
227
+ }
228
+
229
+ # Re-read + re-write supaya output guaranteed UTF-8 NO-BOM (normalize encoding).
230
+ $content = Get-Content -LiteralPath $SourcePath -Raw -Encoding UTF8
231
+ if ($null -eq $content) { $content = '' }
232
+ _Write-Utf8NoBom -Path $TargetPath -Content $content
233
+
234
+ $sha = _Get-FileSha256Hex -Path $TargetPath
235
+
236
+ $action = if ($targetExists) { 'updated' } else { 'created' }
237
+ return [PSCustomObject]@{
238
+ copied = $true
239
+ action = $action
240
+ sha256 = $sha
241
+ }
242
+ }