-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup.ps1
More file actions
390 lines (349 loc) · 15.7 KB
/
Copy pathsetup.ps1
File metadata and controls
390 lines (349 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
#requires -Version 5.1
<#
.SYNOPSIS
Snoozle OpenCode WordPress settings - validation and project scaffolding.
.DESCRIPTION
This repo IS the global OpenCode config (~/.config/opencode), so there is
nothing to install. This script:
* Default (-Validate): checks skill/agent/command frontmatter and structure.
* -NewTheme/-NewPlugin: scaffolds into an explicit target directory.
* -Theme/-Plugin: scaffolds into wp-content\themes|plugins\{slug} of the
current WordPress root (Local site shell) or of -Site.
.PARAMETER Validate
Run structural validation only (default).
.PARAMETER NewTheme
Target directory for a new theme project (explicit path).
.PARAMETER NewPlugin
Target directory for a new plugin project (explicit path).
.PARAMETER Theme
Slug for a theme scaffolded into wp-content\themes\{slug}. The WordPress root is
resolved by walking up from the current directory (Local's site shell starts in
<site>\app\public, a WordPress root) or from -Site.
.PARAMETER Plugin
Slug for a plugin scaffolded into wp-content\plugins\{slug}. Same root resolution.
.PARAMETER Site
Local site name; root resolves to {SitesDir}\{site}\app\public. Not needed when the
current directory is already inside a WordPress root.
.PARAMETER SitesDir
Local sites directory (default: $HOME\Local Sites). Only used with -Site.
.PARAMETER Install
After scaffolding, run npm install and composer install in the new project. When
the PHP on PATH is Local's bundled build (lightning-services), a temp php.ini with
openssl + mbstring enabled is used via PHPRC - Local ships both disabled.
.PARAMETER Force
Allow scaffolding into an existing non-empty target directory (files are merged;
existing files are overwritten, extra files are kept).
.PARAMETER Slug
Project slug (folder name + text domain, dashes allowed). Default: target folder
name (for -Theme/-Plugin: the slug itself).
.PARAMETER Prefix
Function/class prefix (e.g. "ss_"). Default: first 4 letters of slug + "_".
.PARAMETER Name
Human-readable project name. Default: slug title-cased.
.PARAMETER DryRun
Report what would be done without writing.
.EXAMPLE
.\setup.ps1 -Validate
.\setup.ps1 -NewTheme ..\wp-content\themes\ss -Slug ss -Prefix ss_ -Name "Snoozle Studio"
.\setup.ps1 -NewPlugin .\my-plugin -Slug my-plugin -Prefix myp_ -Name "My Plugin"
# From Local's site shell (cmd.exe by default on Windows - use scaffold.cmd):
scaffold.cmd -Theme mytheme -Prefix mt_ -Name "My Theme"
scaffold.cmd -Plugin my-plugin -Install
# From Local's site shell configured to PowerShell:
.\setup.ps1 -Theme mytheme -Prefix mt_ -Name "My Theme"
# From anywhere, targeting a site by name:
.\setup.ps1 -Site mysite -Theme mytheme -Install
.\setup.ps1 -Theme demo -SitesDir D:\Local\Sites -DryRun
#>
param(
[switch]$Validate,
[string]$NewTheme,
[string]$NewPlugin,
[string]$Theme,
[string]$Plugin,
[string]$Site,
[string]$SitesDir,
[switch]$Install,
[switch]$Force,
[string]$Slug = "",
[string]$Prefix = "",
[string]$Name = "",
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
function Write-Step([string]$Message) { Write-Host "==> $Message" -ForegroundColor Cyan }
function Write-Ok([string]$Message) { Write-Host " $Message" -ForegroundColor Green }
function Write-Warn([string]$Message) { Write-Host " WARN: $Message" -ForegroundColor Yellow }
function Write-Fail([string]$Message) { Write-Host " FAIL: $Message" -ForegroundColor Red }
function Test-Frontmatter([string]$File, [string]$Kind, [string]$DirName) {
$content = Get-Content -LiteralPath $File -Raw -ErrorAction SilentlyContinue
if (-not $content) { Write-Fail "${Kind} $File is empty or unreadable"; return $false }
if ($content -notmatch '^---\r?\n') { Write-Fail "${Kind} $File has no frontmatter"; return $false }
if ($content -notmatch '(?m)^description:\s*.+') { Write-Fail "${Kind} $File has no description"; return $false }
# Plain YAML scalars cannot contain ': ' (colon+space) - it starts a mapping
# and the frontmatter fails to parse, silently killing the component's
# routing. Quoted values (starting with ' or ") are exempt.
if ($content -match '(?m)^description:\s*[^\s''"][^\r\n]*: ') {
Write-Fail "${Kind} $File - description contains ': ' (colon+space) - invalid in a plain YAML scalar, breaks frontmatter parsing"
return $false
}
if ($Kind -eq "skill" -and $content -notmatch "(?m)^name:\s*$([regex]::Escape($DirName))\s*$") {
Write-Fail "skill ${File}: frontmatter name must match directory name '$DirName'"
return $false
}
Write-Ok "${Kind}: $(Split-Path $File -Leaf)"
return $true
}
function Invoke-Validate {
Write-Step "Validating repo structure"
$failed = $false
if (-not (Test-Path "$RepoRoot\AGENTS.md")) { Write-Fail "AGENTS.md missing"; $failed = $true }
if (-not (Test-Path "$RepoRoot\opencode.json")) { Write-Fail "opencode.json missing"; $failed = $true }
foreach ($file in Get-ChildItem "$RepoRoot\agents" -Filter *.md -ErrorAction SilentlyContinue) {
if (-not (Test-Frontmatter $file.FullName "agent" "")) { $failed = $true }
}
foreach ($dir in Get-ChildItem "$RepoRoot\skills" -Directory -ErrorAction SilentlyContinue) {
$skill = Join-Path $dir.FullName "SKILL.md"
if (-not (Test-Path $skill)) { Write-Fail "skill $($dir.Name) has no SKILL.md"; $failed = $true; continue }
if (-not (Test-Frontmatter $skill "skill" $dir.Name)) { $failed = $true }
}
foreach ($file in Get-ChildItem "$RepoRoot\commands" -Filter *.md -ErrorAction SilentlyContinue) {
if (-not (Test-Frontmatter $file.FullName "command" "")) { $failed = $true }
}
if ($failed) { Write-Host ""; Write-Host "Validation FAILED - fix before committing." -ForegroundColor Red; exit 1 }
Write-Host ""
Write-Host "Validation OK." -ForegroundColor Green
}
function Expand-Template([string]$Source, [string]$Destination, [hashtable]$Tokens) {
Write-Step "Scaffolding from $Source"
$items = Get-ChildItem -LiteralPath $Source -Recurse -Force
foreach ($item in $items) {
$relative = $item.FullName.Substring($Source.Length).TrimStart("\")
foreach ($key in $Tokens.Keys) {
$relative = $relative.Replace($key, $Tokens[$key])
}
$dest = Join-Path $Destination $relative
if ($item.PSIsContainer) {
if (-not $DryRun) { New-Item -ItemType Directory -Path $dest -Force | Out-Null }
continue
}
if ($DryRun) {
# Render in memory and validate BEFORE anything would be written:
# no stray placeholders, no reserved Windows names, JSON/XML files
# parse. A dry run that prints "would write" for a broken scaffold
# is a lie.
$content = Get-Content -LiteralPath $item.FullName -Raw
foreach ($key in $Tokens.Keys) {
$content = $content.Replace($key, $Tokens[$key])
}
$stray = [regex]::Match($content, '\{[a-zA-Z_][a-zA-Z0-9_-]*\}')
if ($stray.Success) {
throw "Dry-run validation failed: stray placeholder '$($stray.Value)' in $relative"
}
$leaf = Split-Path $dest -Leaf
if ($leaf -match '^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)') {
throw "Dry-run validation failed: '$leaf' is a reserved Windows file name"
}
if ($relative.EndsWith(".json")) {
try {
[void](ConvertFrom-Json -InputObject $content -ErrorAction Stop)
} catch {
throw "Dry-run validation failed: $relative is not valid JSON"
}
} elseif ($relative.EndsWith(".xml")) {
try {
[void]([xml]$content)
} catch {
throw "Dry-run validation failed: $relative is not valid XML"
}
}
Write-Ok "would write $relative"
continue
}
New-Item -ItemType Directory -Path (Split-Path $dest -Parent) -Force | Out-Null
$content = Get-Content -LiteralPath $item.FullName -Raw
foreach ($key in $Tokens.Keys) {
$content = $content.Replace($key, $Tokens[$key])
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($dest, $content, $utf8NoBom)
Write-Ok "wrote $relative"
}
}
function Assert-SafePathValues {
# -Site/-Theme/-Plugin are slugs/names, never paths: reject traversal up
# front, BEFORE any site-root resolution or path construction. -Target stays
# unvalidated (it is a real path, e.g. "..\wp-content\themes\mytheme").
foreach ($v in @($Site, $Theme, $Plugin)) {
if ($v -and $v -match '[\\/:"]|\.\.') {
throw "Invalid value for -Site/-Theme/-Plugin: '$v' (no path separators, quotes or '..' allowed)."
}
}
}
function Resolve-Args([string]$Target, [string]$Kind) {
if (-not $Target) { throw "Missing target directory (use -NewTheme, -NewPlugin, -Theme or -Plugin)." }
$script:Target = $Target
if (-not $Slug) { $script:Slug = Split-Path $Target -Leaf }
if (-not $Prefix) {
$letters = ($Slug -replace '-', '')
$script:Prefix = $letters.Substring(0, [Math]::Min(4, $letters.Length)) + "_"
}
if (-not $Name) { $script:Name = (Get-Culture).TextInfo.ToTitleCase(($Slug -replace '-', ' ')) }
# Validate before values reach paths or generated identifiers. The
# Site/Theme/Plugin traversal check already ran in Assert-SafePathValues
# (before site-root resolution); here we validate the generated identifiers.
if ($script:Slug -notmatch '^[a-z0-9][a-z0-9-]*$') {
throw "Invalid slug '$script:Slug' - use lowercase letters, digits and hyphens only."
}
if ($script:Prefix -notmatch '^[a-z_][a-z0-9_]*$') {
throw "Invalid prefix '$script:Prefix' - use lowercase letters, digits and underscores only."
}
if ($script:Name -notmatch '^[\w .\-]+$') {
throw "Invalid name '$script:Name' - use letters, digits, spaces, dots and hyphens only."
}
# The prefix token carries no trailing underscore: templates use
# {prefix}_function_name, so prefix "tst_" yields "tst_function_name".
$base = $Prefix.TrimEnd("_")
$script:Tokens = New-Object "System.Collections.Generic.Dictionary[string,string]"
$script:Tokens.Add(("{" + $Kind + "_slug}"), $Slug)
$script:Tokens.Add(("{" + $Kind + "-slug}"), $Slug)
$script:Tokens.Add(("{" + $Kind + "_name}"), $Name)
$script:Tokens.Add("{text_domain}", $Slug)
$script:Tokens.Add("{prefix}", $base)
$script:Tokens.Add("{PREFIX}", $base.ToUpper())
$script:Tokens.Add("{Prefix}", ($base.Substring(0, 1).ToUpper() + $base.Substring(1)))
$script:Tokens.Add("{description}", "Initial description.")
Write-Ok "slug=$Slug prefix=$Prefix name=$Name text-domain=$Slug"
}
function Resolve-WpRoot {
# Walk up from the current directory until wp-load.php is found (WordPress root).
$dir = Get-Item (Get-Location).Path
while ($dir) {
if (Test-Path (Join-Path $dir.FullName "wp-load.php")) { return $dir.FullName }
$dir = $dir.Parent
}
return $null
}
function Resolve-LocalSiteRoot([string]$SiteName) {
$sitesRoot = if ($SitesDir) { $SitesDir } else { Join-Path $HOME "Local Sites" }
if (-not (Test-Path -LiteralPath $sitesRoot)) {
throw "Local sites directory not found: $sitesRoot (pass -SitesDir to override)."
}
$siteDir = Join-Path $sitesRoot $SiteName
if (-not (Test-Path -LiteralPath $siteDir)) {
$available = ((Get-ChildItem -LiteralPath $sitesRoot -Directory -ErrorAction SilentlyContinue).Name) -join ", "
throw "Local site '$SiteName' not found in $sitesRoot. Available sites: $available"
}
$public = Join-Path $siteDir "app\public"
if (-not (Test-Path (Join-Path $public "wp-load.php"))) {
throw "Site '$SiteName' root not found at $public (expected app\public containing wp-load.php)."
}
Write-Ok "Local site '$SiteName' root: $public"
return $public
}
function Resolve-SiteRoot {
if ($Site) {
return Resolve-LocalSiteRoot $Site
}
$root = Resolve-WpRoot
if (-not $root) {
throw "Not inside a WordPress root and no -Site given. Run from Local's site shell (app\public) or pass -Site <name>."
}
Write-Ok "Detected WordPress root: $root"
return $root
}
function Assert-EmptyTarget([string]$Target) {
if (-not (Test-Path -LiteralPath $Target)) { return }
$existing = Get-ChildItem -LiteralPath $Target -Force -ErrorAction SilentlyContinue
if (-not $existing) { return }
if (-not $Force) {
throw "Target directory already contains files: $Target (pass -Force to scaffold over it - existing files are overwritten, extra files are kept)."
}
Write-Warn "Target exists with files; -Force given, scaffolding over it."
}
function Invoke-ProjectInstall([string]$Dir) {
Write-Step "Installing dependencies in $Dir"
Push-Location $Dir
try {
if (Get-Command npm -ErrorAction SilentlyContinue) {
Write-Host " npm install --no-fund --no-audit" -ForegroundColor DarkGray
npm install --no-fund --no-audit
if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit code $LASTEXITCODE)." }
} else {
Write-Warn "npm not found on PATH - install Node.js, then run 'cd $Dir && npm install'"
}
} finally { Pop-Location }
if (-not (Get-Command composer -ErrorAction SilentlyContinue)) {
Write-Warn "composer not found on PATH - run 'cd $Dir && composer install' manually"
return
}
$phpExe = Get-Command php -ErrorAction SilentlyContinue
$envBackup = $env:PHPRC
$tempIni = $null
try {
if ($phpExe -and $phpExe.Source -match "lightning-services") {
Write-Ok "Local's bundled PHP detected - enabling openssl/mbstring via temp php.ini"
$phpDir = Split-Path $phpExe.Source -Parent
$origIni = Join-Path $phpDir "php.ini"
$workaround = "`r`n; setup.ps1 workaround: Local's bundled PHP ships with openssl/mbstring disabled`r`nextension=openssl`r`nextension=mbstring`r`n"
$content = if (Test-Path -LiteralPath $origIni) { (Get-Content -LiteralPath $origIni -Raw) + $workaround } else { $workaround }
$tempIni = Join-Path $env:TEMP ("local-php-" + [guid]::NewGuid().ToString("N") + ".ini")
[System.IO.File]::WriteAllText($tempIni, $content, (New-Object System.Text.UTF8Encoding($false)))
$env:PHPRC = $tempIni
} else {
Write-Ok "PHP on PATH is not Local's bundled build - plain composer install"
}
Push-Location $Dir
try {
Write-Host " composer install" -ForegroundColor DarkGray
composer install
if ($LASTEXITCODE -ne 0) { throw "composer install failed (exit code $LASTEXITCODE)." }
} finally { Pop-Location }
} finally {
if ($null -eq $envBackup) { Remove-Item Env:PHPRC -ErrorAction SilentlyContinue }
else { $env:PHPRC = $envBackup }
if ($tempIni) { Remove-Item -LiteralPath $tempIni -Force -ErrorAction SilentlyContinue }
}
}
if ($Validate -or (-not $NewTheme -and -not $NewPlugin -and -not $Theme -and -not $Plugin)) {
Invoke-Validate
exit 0
}
Assert-SafePathValues
$Kind = ""
$TargetPath = ""
if ($NewTheme) { $TargetPath = $NewTheme; $Kind = "theme" }
elseif ($NewPlugin) { $TargetPath = $NewPlugin; $Kind = "plugin" }
elseif ($Theme) {
if (-not $Slug) { $Slug = $Theme }
$TargetPath = Join-Path (Resolve-SiteRoot) "wp-content\themes\$Theme"
$Kind = "theme"
}
elseif ($Plugin) {
if (-not $Slug) { $Slug = $Plugin }
$TargetPath = Join-Path (Resolve-SiteRoot) "wp-content\plugins\$Plugin"
$Kind = "plugin"
}
Resolve-Args $TargetPath $Kind
Assert-EmptyTarget $TargetPath
New-Item -ItemType Directory -Path $TargetPath -Force | Out-Null
$ResolvedTarget = (Resolve-Path -LiteralPath $TargetPath).Path
Expand-Template "$RepoRoot\templates\$Kind" $ResolvedTarget $Tokens
if ($Install -and -not $DryRun) {
Invoke-ProjectInstall $ResolvedTarget
}
if (-not $DryRun) {
Write-Step "Next steps"
Write-Host " 1. cd $ResolvedTarget"
if (-not $Install) {
Write-Host " 2. npm install"
Write-Host " 3. composer install"
Write-Host " 4. Edit style.css/readme.txt metadata and ACF field groups"
Write-Host " 5. Activate in wp-admin, then run: npm run build / npm run format:all:check / vendor/bin/phpcs --standard=phpcs.xml -d memory_limit=1024M / vendor/bin/phpstan analyse --no-progress --memory-limit=1G"
} else {
Write-Host " 2. Activate in wp-admin (themes: Appearance > Themes; plugins: Plugins)"
Write-Host " ACF field groups load from acf-json/ on the ACF Sync page"
Write-Host " 3. Run: npm run build / npm run format:all:check / vendor/bin/phpcs --standard=phpcs.xml -d memory_limit=1024M / vendor/bin/phpstan analyse --no-progress --memory-limit=1G"
}
}