@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.
- package/.github/workflows/publish-npm.yml +40 -0
- package/.github/workflows/validate.yml +93 -0
- package/AUDIT_POST_SETUP_PROMPT_v1.md +280 -0
- package/BOOTSTRAP_PROJECT_DOCS_PROMPT_v1.md +3 -0
- package/CHANGELOG.md +313 -0
- package/CLAUDE_universal_v1.md +1021 -0
- package/CONTRIBUTING.md +101 -0
- package/FIRST_SESSION_PROMPT_v1.md +7 -0
- package/JALANKAN_KIT.md +188 -0
- package/LICENSE +21 -0
- package/MULAI_DI_SINI.md +145 -0
- package/PROJECT_KICKOFF_PROMPT_v1.md +3 -0
- package/PROJECT_LIFECYCLE_PROMPT_v1.md +536 -0
- package/PROJECT_MIGRATION_PROMPT_v1.md +3 -0
- package/README.md +505 -0
- package/SETUP_POLA_B_PROMPT_v1.md +5 -0
- package/SPLIT_REPO_MIGRATION_PROMPT_v1.md +485 -0
- package/TEAM_ROLLOUT_GUIDE_v1.md +172 -0
- package/UPDATE_DOCS_PROMPT_v1.md +3 -0
- package/UPDATE_KIT_PROMPT_v1.md +213 -0
- package/bin/lintasai.js +81 -0
- package/docs/SIGNED_RELEASE.md +162 -0
- package/install-windows.ps1 +225 -0
- package/kit.ps1 +508 -0
- package/lib/agents-md.ps1 +174 -0
- package/lib/git-helpers.ps1 +104 -0
- package/lib/kit-files.psd1 +133 -0
- package/lib/manifest-signing.ps1 +65 -0
- package/lib/manifest.ps1 +267 -0
- package/lib/rollback.ps1 +241 -0
- package/lib/safety.ps1 +193 -0
- package/lib/template-deploy.ps1 +242 -0
- package/lib/version-detect.ps1 +161 -0
- package/package.json +36 -0
- package/setup-pola-b.ps1 +687 -0
- package/templates/ANALOGI_LIBRARY.md +7 -0
- package/templates/CLAUDE_TEAM_GUIDE.md +505 -0
- package/templates/CROSS_REPO_TYPES_PIPELINE.md +473 -0
- package/templates/DB_SCHEMA_SCAN_PROMPT.md +194 -0
- package/templates/DISCORD_BOT_INTEGRATION.md +187 -0
- package/templates/GLOSSARY_NON_PROGRAMMER.md +361 -0
- package/templates/INDEX.md +157 -0
- package/templates/MCP_SETUP.md +1145 -0
- package/templates/MIGRATE_TO_SUBFOLDER_PROMPT_v1.md +220 -0
- package/templates/ONBOARDING.md +172 -0
- package/templates/PROJECT_STARTER_TEMPLATES.md +264 -0
- package/templates/PROMPT_LIBRARY.md +790 -0
- package/templates/RLS_SETUP_PROMPT.md +167 -0
- package/templates/SECURITY_INCIDENT_PLAYBOOK.md +191 -0
- package/templates/SPLIT_REPO_AGENTS_TEMPLATES.md +32 -0
- package/templates/SPLIT_REPO_NON_PROGRAMMER_PROMPTS.md +604 -0
- package/templates/SPLIT_REPO_TOOLS_SETUP.md +388 -0
- package/templates/STACK_DETECTION_PATTERN.md +261 -0
- package/templates/STACK_GUIDE.md +564 -0
- package/templates/STACK_MIGRATION_GUIDE.md +154 -0
- package/templates/STACK_VERSIONS.md +31 -0
- package/templates/UPDATE_GUIDE.md +246 -0
- package/templates/_EXAMPLE.md +110 -0
- package/templates/_PATTERNS.md +173 -0
- package/templates/architecture.md +180 -0
- package/templates/architecture_auto.md +61 -0
- package/templates/decisions/README.md +108 -0
- package/templates/decisions/_TEMPLATE.md +84 -0
- package/templates/feature-flags-advanced.md +171 -0
- package/templates/github/CODEOWNERS.template +61 -0
- package/templates/github/GENERATE_TYPES_SCRIPT.md +77 -0
- package/templates/github/PUBLISH_SHARED_WORKFLOW.yml +52 -0
- package/templates/github/RECEIVE_BACKEND_UPDATE.yml +106 -0
- package/templates/github/RENOVATE_FRONTEND.json +28 -0
- package/templates/github/TRIGGER_FRONTEND_UPDATE.yml +29 -0
- package/templates/github/pull_request_template.md +44 -0
- package/templates/github/scripts/ai-review.js +153 -0
- package/templates/github/workflows/ai-review.yml +61 -0
- package/templates/github/workflows/backup-schemas.yml +169 -0
- package/templates/glossary.md +110 -0
- package/templates/split-agents/BACKEND.md +149 -0
- package/templates/split-agents/FRONTEND.md +141 -0
- package/templates/split-agents/SHARED.md +82 -0
- package/templates/split-agents/TOOLS.md +77 -0
- package/tests/Run-Tests.ps1 +19 -0
- package/tests/lib-safety.Tests.ps1 +66 -0
- package/tests/rollback.Tests.ps1 +66 -0
- package/tests/uninstall.Tests.ps1 +265 -0
- package/tests/update-kit.Tests.ps1 +78 -0
- package/uninstall.ps1 +794 -0
- package/update-kit.ps1 +907 -0
package/lib/rollback.ps1
ADDED
|
@@ -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
|
+
}
|