@caddis/cli 0.4.19 → 0.4.21

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 (40) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/skills/_registry.md +4 -2
  3. package/bundles/antigravity-plugin/skills/data-lineage/SKILL.md +32 -0
  4. package/bundles/antigravity-plugin/skills/hld/SKILL.md +20 -8
  5. package/bundles/antigravity-plugin/skills/lld/SKILL.md +27 -12
  6. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +231 -0
  7. package/bundles/antigravity-plugin/skills/port-to-github/references/ci-and-credentials.md +66 -0
  8. package/bundles/antigravity-plugin/skills/port-to-github/references/config.example.json +18 -0
  9. package/bundles/antigravity-plugin/skills/port-to-github/references/old-host.md +61 -0
  10. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
  11. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
  12. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Get-PortSource.ps1 +459 -0
  13. package/bundles/antigravity-plugin/skills/port-to-github/scripts/PortCommon.ps1 +130 -0
  14. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
  15. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
  16. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
  17. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
  18. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  19. package/bundles/antigravity-plugin-extras/skills/_registry.md +3 -2
  20. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +158 -0
  21. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +1430 -0
  22. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +155 -35
  23. package/bundles/antigravity-plugin-extras/skills/hld/references/gold-standard-hld.md +419 -0
  24. package/bundles/antigravity-plugin-extras/skills/hld/scripts/convert_doc_to_confluence.py +900 -184
  25. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +145 -41
  26. package/bundles/antigravity-plugin-extras/skills/lld/scripts/convert_doc_to_confluence.py +900 -184
  27. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +231 -0
  28. package/bundles/codex/.codex/skills/devops/port-to-github/references/ci-and-credentials.md +66 -0
  29. package/bundles/codex/.codex/skills/devops/port-to-github/references/config.example.json +18 -0
  30. package/bundles/codex/.codex/skills/devops/port-to-github/references/old-host.md +61 -0
  31. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
  32. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
  33. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Get-PortSource.ps1 +459 -0
  34. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/PortCommon.ps1 +130 -0
  35. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
  36. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
  37. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
  38. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
  39. package/bundles/manifest.json +3 -3
  40. package/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ # Shared helpers for the port-to-github scripts. Dot-source this file.
2
+ # ASCII only: these scripts may be read by Windows PowerShell 5.1.
3
+
4
+ Set-StrictMode -Version 2.0
5
+
6
+ function Write-PortStep([string]$Message) {
7
+ Write-Host "==> $Message" -ForegroundColor Cyan
8
+ }
9
+
10
+ function Write-PortWarn([string]$Message) {
11
+ Write-Host "[WARN] $Message" -ForegroundColor Yellow
12
+ }
13
+
14
+ function Write-PortFail([string]$Message) {
15
+ Write-Host "[FAIL] $Message" -ForegroundColor Red
16
+ }
17
+
18
+ function Write-PortOk([string]$Message) {
19
+ Write-Host "[OK] $Message" -ForegroundColor Green
20
+ }
21
+
22
+ # Run git and fail loud on a non-zero exit. Returns stdout lines.
23
+ # safe.directory=* lets us READ a repository another account owns (a UNC share,
24
+ # a service-owned prod checkout). -c is forwarded to git's subprocesses through
25
+ # GIT_CONFIG_PARAMETERS, so upload-pack sees it too.
26
+ function Invoke-PortGit {
27
+ param(
28
+ [Parameter(Mandatory)][string[]]$Arguments,
29
+ [switch]$AllowFailure
30
+ )
31
+ $all = @('-c', 'safe.directory=*') + $Arguments
32
+ $out = & git @all 2>&1
33
+ $code = $LASTEXITCODE
34
+ $text = @($out | ForEach-Object { "$_" })
35
+ if ($code -ne 0 -and -not $AllowFailure) {
36
+ throw "git $($Arguments -join ' ') failed with exit $code`n$($text -join "`n")"
37
+ }
38
+ if ($AllowFailure) {
39
+ return [pscustomobject]@{ ExitCode = $code; Output = $text }
40
+ }
41
+ return $text
42
+ }
43
+
44
+ # Read defaults from a project config file. Order: -ConfigPath, $env:PORT_TO_GITHUB_CONFIG,
45
+ # .caddis/port-to-github.json in the current git repository. Missing file = empty config.
46
+ function Get-PortConfig([string]$ConfigPath) {
47
+ $candidates = @()
48
+ if ($ConfigPath) { $candidates += $ConfigPath }
49
+ if ($env:PORT_TO_GITHUB_CONFIG) { $candidates += $env:PORT_TO_GITHUB_CONFIG }
50
+ $top = & git rev-parse --show-toplevel 2>$null
51
+ if ($LASTEXITCODE -eq 0 -and $top) {
52
+ $candidates += (Join-Path $top '.caddis/port-to-github.json')
53
+ }
54
+ foreach ($c in $candidates) {
55
+ if ($c -and (Test-Path -LiteralPath $c)) {
56
+ $cfg = Get-Content -LiteralPath $c -Raw | ConvertFrom-Json
57
+ $cfg | Add-Member -NotePropertyName _path -NotePropertyValue (Resolve-Path -LiteralPath $c).Path -Force
58
+ return $cfg
59
+ }
60
+ }
61
+ return [pscustomobject]@{ _path = $null }
62
+ }
63
+
64
+ function Get-PortConfigValue($Config, [string]$Name, $Default) {
65
+ if ($null -ne $Config -and $Config.PSObject.Properties.Name -contains $Name -and $null -ne $Config.$Name -and "$($Config.$Name)" -ne '') {
66
+ return $Config.$Name
67
+ }
68
+ return $Default
69
+ }
70
+
71
+ function Get-PortStagingRoot($Config, [string]$Override) {
72
+ if ($Override) { $root = $Override }
73
+ else { $root = Get-PortConfigValue $Config 'stagingRoot' (Join-Path ([IO.Path]::GetTempPath()) 'port-to-github') }
74
+ $root = [Environment]::ExpandEnvironmentVariables($root)
75
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
76
+ return (Resolve-Path -LiteralPath $root).Path
77
+ }
78
+
79
+ # Remove "user:secret@" from a URL. Returns @{ Url; HadCredential }.
80
+ function Remove-UrlCredential([string]$Url) {
81
+ if ($Url -match '^(?<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?<userinfo>[^@/]+)@(?<rest>.*)$') {
82
+ return [pscustomobject]@{ Url = "$($Matches.scheme)$($Matches.rest)"; HadCredential = ($Matches.userinfo -match ':') }
83
+ }
84
+ return [pscustomobject]@{ Url = $Url; HadCredential = $false }
85
+ }
86
+
87
+ # Directories that are never source, skipped when importing a plain folder (no .git).
88
+ # Only unambiguous ones: 'dist', 'build', 'bin' can hold real source in some projects,
89
+ # so they are imported and REPORTED instead (see $PortSuspectDirs).
90
+ $script:PortJunkDirs = @(
91
+ 'node_modules', '.venv', 'venv', '__pycache__', '.pytest_cache', '.mypy_cache',
92
+ '.ruff_cache', '.tox', '.next', '.nuxt', '.parcel-cache', '.turbo', 'htmlcov', '.git'
93
+ )
94
+ $script:PortSuspectDirs = @('dist', 'build', 'bin', 'obj', 'out', 'coverage', 'logs', 'data')
95
+
96
+ # File-name patterns that usually hold secrets.
97
+ $script:PortSecretNamePatterns = @(
98
+ '^\.env$', '^\.env\..+', '\.pem$', '\.key$', '\.pfx$', '\.p12$', '^id_rsa', '^id_ed25519',
99
+ '^credentials(\..+)?$', '\.kdbx$', '^\.netrc$', '^\.git-credentials$', '^\.npmrc$', '^\.pypirc$'
100
+ )
101
+ # .env.example / .env.sample / .env.template are documentation, not secrets.
102
+ $script:PortSecretNameAllow = @('\.example$', '\.sample$', '\.template$', '\.dist$')
103
+
104
+ # Content patterns that usually are a real credential.
105
+ $script:PortSecretContentPatterns = @(
106
+ 'gh[pousr]_[A-Za-z0-9]{36,}',
107
+ 'github_pat_[A-Za-z0-9_]{40,}',
108
+ 'AKIA[0-9A-Z]{16}',
109
+ '-----BEGIN [A-Z ]*PRIVATE KEY-----',
110
+ 'xox[baprs]-[A-Za-z0-9-]{10,}',
111
+ '(?i)(password|passwd|pwd|secret|api[_-]?key|token)\s*[:=]\s*[''"][^''"\s]{8,}[''"]',
112
+ '(?i)(password|pwd)=[^;\s]{6,};'
113
+ )
114
+
115
+ function Test-PortSecretName([string]$FileName) {
116
+ foreach ($a in $script:PortSecretNameAllow) { if ($FileName -match $a) { return $false } }
117
+ foreach ($p in $script:PortSecretNamePatterns) { if ($FileName -match $p) { return $true } }
118
+ return $false
119
+ }
120
+
121
+ function Test-PortSecretContent([string]$Text) {
122
+ foreach ($p in $script:PortSecretContentPatterns) {
123
+ if ($Text -match $p) { return $p }
124
+ }
125
+ return $null
126
+ }
127
+
128
+ function ConvertTo-PortJsonFile($Object, [string]$Path) {
129
+ $Object | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding UTF8
130
+ }
@@ -0,0 +1,156 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Create the GitHub repository (empty) and push a staged mirror to it, then prove every ref landed.
4
+
5
+ .DESCRIPTION
6
+ - Pushes with an EXPLICIT refspec (heads and tags). Never --mirror: a server's hidden refs
7
+ (refs/pull/*) are refused by GitHub, and --mirror would also DELETE anything on GitHub the
8
+ stage lacks.
9
+ - Never force-pushes. If the GitHub repo already exists it must be empty, or every one of its
10
+ branches must be an ancestor of (or equal to) the staged branch of the same name. Otherwise it
11
+ refuses: pushing on top of unrelated history welds a fake ancestry in permanently.
12
+ - Verifies by SHA with Compare-PortRefs.ps1 and exits 1 if any ref differs.
13
+
14
+ Uses the gh CLI for the API and your normal git credential helper for the push
15
+ (run `gh auth setup-git` once if git cannot authenticate to github.com).
16
+ #>
17
+ [CmdletBinding(SupportsShouldProcess)]
18
+ param(
19
+ [Parameter(Mandatory)][string]$Name,
20
+ [string]$Owner,
21
+ [string]$Repo,
22
+ [ValidateSet('private', 'internal', 'public')][string]$Visibility,
23
+ [string]$Description,
24
+ [string]$StagingRoot,
25
+ [string]$ConfigPath,
26
+ # Allow pushing into an existing NON-empty repo whose history is compatible (fast-forward only).
27
+ [switch]$AllowExisting,
28
+ # The staged branches already contain .github/workflows; pushing them WILL run those
29
+ # workflows (a deploy job included). Pass only after reading them.
30
+ [switch]$AllowWorkflowRuns
31
+ )
32
+
33
+ $ErrorActionPreference = 'Stop'
34
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
35
+
36
+ $config = Get-PortConfig $ConfigPath
37
+ $stage = Join-Path (Get-PortStagingRoot $config $StagingRoot) $Name
38
+ $mirror = Join-Path $stage 'mirror.git'
39
+ $manifestPath = Join-Path $stage 'manifest.json'
40
+ if (-not (Test-Path -LiteralPath $mirror)) { throw "No staged mirror at $mirror. Run Get-PortSource.ps1 first." }
41
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
42
+
43
+ $readinessPath = Join-Path $stage 'readiness.json'
44
+ if (-not (Test-Path -LiteralPath $readinessPath)) { throw "No readiness report. Run Test-PortReadiness.ps1 -Name $Name first." }
45
+ $readiness = Get-Content -LiteralPath $readinessPath -Raw | ConvertFrom-Json
46
+ if (-not $readiness.ready) { throw "Readiness has $(@($readiness.blockers).Count) blocker(s). Fix them and re-run Test-PortReadiness.ps1 before publishing." }
47
+ $live = @()
48
+ if ($readiness.PSObject.Properties.Name -contains 'branchesWithGithubWorkflows') { $live = @($readiness.branchesWithGithubWorkflows) }
49
+ if ($live.Count -gt 0 -and -not $AllowWorkflowRuns) {
50
+ throw "Branch(es) $($live -join ', ') contain .github/workflows. The push will RUN them on GitHub (deploy jobs included). Read them, then re-run with -AllowWorkflowRuns - or remove/disable them in the source first."
51
+ }
52
+
53
+ # The stage must still hold exactly what Get-PortSource recorded. Anything else (a stray fetch,
54
+ # a hand edit, a second tool) would be pushed and then "verified" against itself.
55
+ $nowRefs = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads', 'refs/tags') | Sort-Object)
56
+ $wasRefs = @(@($manifest.heads) + @($manifest.tags) | Where-Object { $_ } | Sort-Object)
57
+ $drift = @(Compare-Object $wasRefs $nowRefs)
58
+ $extra = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)') | Where-Object { $_ -and $_ -notlike 'refs/heads/*' -and $_ -notlike 'refs/tags/*' })
59
+ if ($drift.Count -gt 0 -or $extra.Count -gt 0) {
60
+ $d = @($drift | ForEach-Object { "$($_.SideIndicator) $($_.InputObject)" }) + @($extra | ForEach-Object { "extra $_" })
61
+ throw "The staged mirror no longer matches manifest.json (=> added, <= missing):`n $($d -join "`n ")`nRe-run Get-PortSource.ps1 -Force to rebuild the stage."
62
+ }
63
+
64
+ if (-not $Owner) { $Owner = Get-PortConfigValue $config 'owner' $null }
65
+ if (-not $Owner) { throw 'No -Owner and no "owner" in the config file.' }
66
+ if (-not $Repo) { $Repo = $Name }
67
+ if (-not $Visibility) { $Visibility = Get-PortConfigValue $config 'visibility' 'private' }
68
+ $full = "$Owner/$Repo"
69
+ $url = "https://github.com/$full.git"
70
+
71
+ # ---- 1. Who am I, and can I see the owner -------------------------------------------------------
72
+ $login = (& gh api user --jq .login 2>&1)
73
+ if ($LASTEXITCODE -ne 0) { throw "gh is not signed in: $login. Run gh auth login." }
74
+ Write-PortStep "Signed in to GitHub as $login; target $full ($Visibility)"
75
+
76
+ # ---- 2. Does the repo exist? -----------------------------------------------------------------------
77
+ $exists = $false
78
+ $null = & gh api "repos/$full" --jq .full_name 2>$null
79
+ if ($LASTEXITCODE -eq 0) { $exists = $true }
80
+ $global:LASTEXITCODE = 0
81
+
82
+ if ($exists) {
83
+ $remoteRefs = @(Invoke-PortGit @('ls-remote', '--heads', $url))
84
+ if ($remoteRefs.Count -eq 0 -or -not ($remoteRefs -join '')) {
85
+ Write-PortStep "$full exists and is empty. Using it."
86
+ }
87
+ else {
88
+ if (-not $AllowExisting) {
89
+ throw "$full already exists and has $($remoteRefs.Count) branch(es). Check it shares history with the source (see SKILL.md, 'An existing repo'), then re-run with -AllowExisting, or delete it and re-run."
90
+ }
91
+ Write-PortStep "$full exists with history. Checking every GitHub branch is an ancestor of the staged branch."
92
+ # --no-tags: without it git auto-follows the OTHER repo's tags into the stage, and the
93
+ # next push would publish them. Found by the dogfood run, 2026-09-19.
94
+ Invoke-PortGit @('-C', $mirror, 'fetch', '-q', '--no-tags', $url, '+refs/heads/*:refs/port-check/*') | Out-Null
95
+ try {
96
+ foreach ($line in $remoteRefs) {
97
+ $sha, $ref = $line -split "`t", 2
98
+ $b = $ref.Substring(11)
99
+ $local = Invoke-PortGit -AllowFailure @('-C', $mirror, 'rev-parse', '--verify', '-q', "refs/heads/$b")
100
+ if ($local.ExitCode -ne 0) { continue } # branch only on GitHub: left alone
101
+ $anc = Invoke-PortGit -AllowFailure @('-C', $mirror, 'merge-base', '--is-ancestor', $sha, ($local.Output -join ''))
102
+ if ($anc.ExitCode -ne 0) {
103
+ throw "GitHub branch '$b' ($($sha.Substring(0,12))) is NOT an ancestor of the staged '$b'. The histories are unrelated or diverged. Refusing: resolve this by hand."
104
+ }
105
+ }
106
+ }
107
+ finally {
108
+ foreach ($r in @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)', 'refs/port-check'))) {
109
+ if ($r) { Invoke-PortGit @('-C', $mirror, 'update-ref', '-d', $r) | Out-Null }
110
+ }
111
+ }
112
+ Write-PortOk 'Every GitHub branch is an ancestor. The push is a fast-forward.'
113
+ }
114
+ }
115
+ else {
116
+ if ($PSCmdlet.ShouldProcess($full, "Create $Visibility GitHub repository")) {
117
+ $createArgs = @('repo', 'create', $full, "--$Visibility", '--disable-wiki')
118
+ if ($Description) { $createArgs += @('--description', $Description) }
119
+ $out = & gh @createArgs 2>&1
120
+ if ($LASTEXITCODE -ne 0) { throw "gh repo create failed: $out" }
121
+ Write-PortOk "Created $full"
122
+ }
123
+ else { Write-Host "WhatIf: would create $full"; return }
124
+ }
125
+
126
+ # ---- 3. Push -------------------------------------------------------------------------------------------
127
+ if ($PSCmdlet.ShouldProcess($full, 'Push refs/heads/* and refs/tags/*')) {
128
+ Write-PortStep "Pushing branches and tags to $full"
129
+ # --no-verify is NOT used: no hooks exist in a fresh bare mirror anyway.
130
+ Invoke-PortGit @('-C', $mirror, 'push', '--porcelain', $url, 'refs/heads/*:refs/heads/*', 'refs/tags/*:refs/tags/*') | Out-Null
131
+ }
132
+ else { Write-Host "WhatIf: would push to $url"; return }
133
+
134
+ # ---- 4. Default branch ---------------------------------------------------------------------------------
135
+ $def = $manifest.defaultBranch
136
+ $cur = (& gh api "repos/$full" --jq .default_branch 2>$null)
137
+ if ($cur -ne $def) {
138
+ & gh api -X PATCH "repos/$full" -f "default_branch=$def" --silent 2>&1 | Out-Null
139
+ if ($LASTEXITCODE -ne 0) { Write-PortWarn "Could not set the default branch to $def. Set it by hand." }
140
+ }
141
+
142
+ # ---- 5. Prove it by SHA ---------------------------------------------------------------------------------
143
+ Write-PortStep 'Verifying every branch and tag by SHA'
144
+ $mode = if ($exists) { 'LeftInRight' } else { 'Equal' }
145
+ & (Join-Path $PSScriptRoot 'Compare-PortRefs.ps1') -Left $mirror -Right $url -Mode $mode -LeftLabel 'staged' -RightLabel 'github'
146
+ $verified = ($LASTEXITCODE -eq 0)
147
+
148
+ $manifest | Add-Member -NotePropertyName github -NotePropertyValue ([pscustomobject]@{
149
+ repo = $full; url = $url; pushedAt = (Get-Date).ToString('o'); pushedBy = $login; verified = $verified; defaultBranch = $def
150
+ }) -Force
151
+ $manifest.status = if ($verified) { 'published' } else { 'publish-unverified' }
152
+ ConvertTo-PortJsonFile $manifest $manifestPath
153
+
154
+ if (-not $verified) { Write-PortFail "Push finished but refs do not match. Do NOT continue to CI or cutover."; exit 1 }
155
+ Write-PortOk "Published $full - https://github.com/$full"
156
+ exit 0
@@ -0,0 +1,182 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Repoint an existing checkout (a deploy target, a dev box) from its old origin to GitHub,
4
+ prove it still agrees with GitHub, and roll back by itself if it does not.
5
+
6
+ .DESCRIPTION
7
+ Works on a local path, or on a remote box over WinRM (-ComputerName).
8
+
9
+ -Mode Inspect print origin, branch, HEAD, dirty count. Changes nothing.
10
+ -Mode Switch record the old URL in a local state file, keep it as a second remote
11
+ (-KeepOldAs), set origin to -NewUrl, fetch, and require HEAD to be contained
12
+ in some origin/* branch. On ANY failure the old URL is restored at once.
13
+ -Mode Rollback put the old URL back from the state file and remove the remote Switch added.
14
+
15
+ A deploy host usually holds NO GitHub credential of its own: CI supplies one per run.
16
+ -VerifyWithGhToken passes your current gh token to that ONE fetch as an HTTP header in the
17
+ process environment. It is never written to disk or to git config, and it is gone when the
18
+ command ends. Without it, a private repo fetch on such a host fails and the switch rolls back.
19
+
20
+ If the old URL embedded a credential (user:secret@host), the state file stores it WITHOUT
21
+ the credential and says so. Rotate that credential.
22
+ #>
23
+ [CmdletBinding(SupportsShouldProcess)]
24
+ param(
25
+ [Parameter(Mandatory)][string]$Path,
26
+ [string]$ComputerName,
27
+ [pscredential]$Credential,
28
+ [ValidateSet('Inspect', 'Switch', 'Rollback')][string]$Mode = 'Inspect',
29
+ [string]$NewUrl,
30
+ [string]$KeepOldAs,
31
+ [string]$StateFile,
32
+ [string]$StagingRoot,
33
+ [string]$ConfigPath,
34
+ [switch]$VerifyWithGhToken
35
+ )
36
+
37
+ $ErrorActionPreference = 'Stop'
38
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
39
+ $config = Get-PortConfig $ConfigPath
40
+ if (-not $KeepOldAs) { $KeepOldAs = Get-PortConfigValue $config 'oldRemoteName' 'old-origin' }
41
+
42
+ $where = if ($ComputerName) { $ComputerName } else { $env:COMPUTERNAME }
43
+ if (-not $StateFile) {
44
+ $dir = Join-Path (Get-PortStagingRoot $config $StagingRoot) 'cutover'
45
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
46
+ $safe = ($Path -replace '[:\\/ ]+', '_').Trim('_')
47
+ $StateFile = Join-Path $dir "$where-$safe.json"
48
+ }
49
+
50
+ # One script block, run locally or remotely. Windows PowerShell 5.1 compatible. Native stderr
51
+ # must not become a terminating error, so git runs under 'Continue' and is judged by exit code.
52
+ $block = {
53
+ param($P, $M, $New, $KeepAs, $OldUrl, $RestoreUrl, $AddedRemote, $Token)
54
+ $ErrorActionPreference = 'Continue'
55
+ function G { $o = & git -c safe.directory=* -C $P @args 2>&1; $c = $LASTEXITCODE; return @{ code = $c; out = @($o | ForEach-Object { "$_" }) } }
56
+ $r = @{ ok = $false; origin = $null; branch = $null; head = $null; dirty = 0; remotes = @(); message = $null; addedRemote = $false; rolledBack = $false }
57
+ if (-not (Test-Path -LiteralPath $P)) { $r.message = "Path not found: $P"; return $r }
58
+ $o = G remote get-url origin
59
+ if ($o.code -ne 0) { $r.message = "No origin remote in $P"; return $r }
60
+ $r.origin = ($o.out -join '')
61
+ $b = G symbolic-ref --short HEAD; if ($b.code -eq 0) { $r.branch = ($b.out -join '') }
62
+ $hd = G rev-parse --verify -q HEAD
63
+ if ($hd.code -ne 0 -or -not ($hd.out -join '')) { $r.message = "$P has no commit at HEAD (empty or unborn repository)"; return $r }
64
+ $r.head = ($hd.out -join '')
65
+ $r.dirty = @((G status --porcelain).out | Where-Object { $_ }).Count
66
+ $r.remotes = @((G remote).out)
67
+
68
+ if ($M -eq 'Inspect') { $r.ok = $true; return $r }
69
+
70
+ if ($M -eq 'Rollback') {
71
+ $s = G remote set-url origin $OldUrl
72
+ if ($s.code -ne 0) { $r.message = "set-url failed: $($s.out -join ' ')"; return $r }
73
+ if ($AddedRemote -and ($r.remotes -contains $KeepAs)) { G remote remove $KeepAs | Out-Null }
74
+ $r.origin = $OldUrl; $r.ok = $true; $r.message = 'rolled back'; return $r
75
+ }
76
+
77
+ # Switch
78
+ if ($r.origin -eq $New) { $r.ok = $true; $r.message = 'origin already points at the new URL'; return $r }
79
+ if (-not ($r.remotes -contains $KeepAs)) {
80
+ $a = G remote add $KeepAs $OldUrl
81
+ if ($a.code -ne 0) { $r.message = "could not add remote ${KeepAs}: $($a.out -join ' ')"; return $r }
82
+ $r.addedRemote = $true
83
+ }
84
+ $s = G remote set-url origin $New
85
+ if ($s.code -ne 0) { $r.message = "set-url failed: $($s.out -join ' ')"; return $r }
86
+
87
+ # From here origin points at the new URL. ANY failure, including an exception, must restore it.
88
+ $fail = $null
89
+ $env:GIT_TERMINAL_PROMPT = '0'
90
+ try {
91
+ if ($Token) {
92
+ $env:GIT_CONFIG_COUNT = '1'
93
+ $env:GIT_CONFIG_KEY_0 = 'http.https://github.com/.extraheader'
94
+ $env:GIT_CONFIG_VALUE_0 = 'AUTHORIZATION: basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("x-access-token:$Token"))
95
+ }
96
+ $f = G fetch -q origin
97
+ $contained = @()
98
+ if ($f.code -eq 0) { $contained = @((G for-each-ref --contains HEAD --format='%(refname)' refs/remotes/origin).out | Where-Object { $_ }) }
99
+ if ($f.code -ne 0) { $fail = "fetch from the new origin failed (exit $($f.code)): $(($f.out | Select-Object -First 3) -join ' ')" }
100
+ elseif ($contained.Count -eq 0) { $fail = "HEAD $($r.head.Substring(0,12)) is not contained in any origin/* branch: this checkout has commits GitHub does not" }
101
+ }
102
+ catch { $fail = "verification threw: $($_.Exception.Message)" }
103
+ finally {
104
+ Remove-Item Env:GIT_CONFIG_COUNT, Env:GIT_CONFIG_KEY_0, Env:GIT_CONFIG_VALUE_0 -ErrorAction SilentlyContinue
105
+ }
106
+ if ($fail) {
107
+ G remote set-url origin $RestoreUrl | Out-Null
108
+ if ($r.addedRemote) { G remote remove $KeepAs | Out-Null; $r.addedRemote = $false }
109
+ $r.rolledBack = $true; $r.message = $fail; return $r
110
+ }
111
+ $r.origin = $New; $r.ok = $true; $r.message = "HEAD is contained in: $($contained -join ', ')"; return $r
112
+ }
113
+
114
+ function Invoke-Block($ArgList) {
115
+ if ($ComputerName) {
116
+ $sa = @{ ComputerName = $ComputerName; ScriptBlock = $block; ArgumentList = $ArgList }
117
+ if ($Credential) { $sa.Credential = $Credential }
118
+ return Invoke-Command @sa
119
+ }
120
+ return & $block @ArgList
121
+ }
122
+
123
+ # Always inspect first.
124
+ $now = Invoke-Block @($Path, 'Inspect', $null, $KeepOldAs, $null, $null, $false, $null)
125
+ if (-not $now.ok) { throw "[$where] $($now.message)" }
126
+ $clean = Remove-UrlCredential $now.origin
127
+ Write-Host ("[{0}] {1}`n origin : {2}`n branch : {3} HEAD {4} uncommitted: {5} remotes: {6}" -f $where, $Path, $clean.Url, $now.branch, $now.head.Substring(0, 12), $now.dirty, ($now.remotes -join ', '))
128
+ if ($clean.HadCredential) { Write-PortWarn 'The current origin URL EMBEDS A CREDENTIAL. It is shown without it. Rotate that credential after the cutover.' }
129
+ if ($Mode -eq 'Inspect') { exit 0 }
130
+
131
+ if ($Mode -eq 'Switch') {
132
+ if (-not $NewUrl) { throw '-NewUrl is required for -Mode Switch.' }
133
+ if ($now.dirty -gt 0) { Write-PortWarn "The checkout has $($now.dirty) uncommitted change(s). A deploy that resets the tree will discard them. Look before you continue." }
134
+ if (-not $PSCmdlet.ShouldProcess("$where $Path", "Set origin to $NewUrl")) { exit 0 }
135
+ $token = $null
136
+ if ($VerifyWithGhToken) {
137
+ $token = (& gh auth token 2>$null)
138
+ if ($LASTEXITCODE -ne 0 -or -not $token) { throw 'gh auth token returned nothing. Sign in with gh auth login, or drop -VerifyWithGhToken.' }
139
+ }
140
+ # Record the rollback BEFORE changing anything.
141
+ $state = [ordered]@{
142
+ computer = $where; path = $Path; oldUrl = $clean.Url; oldUrlHadCredential = $clean.HadCredential
143
+ newUrl = $NewUrl; keepOldAs = $KeepOldAs; addedRemote = $false; switchedAt = (Get-Date).ToString('o'); headAtSwitch = $now.head
144
+ }
145
+ ConvertTo-PortJsonFile $state $StateFile
146
+ # The second remote gets the credential-free URL. On failure the ORIGINAL value is restored.
147
+ try {
148
+ $res = Invoke-Block @($Path, 'Switch', $NewUrl, $KeepOldAs, $clean.Url, $now.origin, $false, $token)
149
+ }
150
+ catch {
151
+ # The call itself failed (e.g. the WinRM transport dropped). The remote side may or may
152
+ # not have changed origin. Put the ORIGINAL value back; leave the kept remote for a human.
153
+ $token = $null
154
+ Write-PortFail "[$where] Switch call failed: $($_.Exception.Message). Restoring the original origin."
155
+ try { $null = Invoke-Block @($Path, 'Rollback', $null, $KeepOldAs, $now.origin, $null, $false, $null) } catch { Write-PortFail "[$where] Restore ALSO failed. Set origin back by hand to the URL in $StateFile." }
156
+ exit 1
157
+ }
158
+ $token = $null
159
+ if (-not $res.ok) {
160
+ if ($res.rolledBack) { Write-PortFail "[$where] Switch failed and was ROLLED BACK: $($res.message)" }
161
+ else { Write-PortFail "[$where] Switch failed: $($res.message)" }
162
+ Remove-Item -LiteralPath $StateFile -ErrorAction SilentlyContinue
163
+ exit 1
164
+ }
165
+ $state.addedRemote = [bool]$res.addedRemote
166
+ ConvertTo-PortJsonFile $state $StateFile
167
+ Write-PortOk "[$where] origin -> $NewUrl. $($res.message)"
168
+ Write-Host " old origin kept as remote '$KeepOldAs'. Rollback: $($MyInvocation.MyCommand.Name) -Path '$Path'$(if ($ComputerName) { " -ComputerName $ComputerName" }) -Mode Rollback"
169
+ Write-Host " state: $StateFile"
170
+ exit 0
171
+ }
172
+
173
+ if ($Mode -eq 'Rollback') {
174
+ if (-not (Test-Path -LiteralPath $StateFile)) { throw "No state file at $StateFile. Pass -StateFile." }
175
+ $state = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json
176
+ if (-not $PSCmdlet.ShouldProcess("$where $Path", "Set origin back to $($state.oldUrl)")) { exit 0 }
177
+ $res = Invoke-Block @($Path, 'Rollback', $null, $state.keepOldAs, $state.oldUrl, $null, [bool]$state.addedRemote, $null)
178
+ if (-not $res.ok) { Write-PortFail "[$where] Rollback failed: $($res.message)"; exit 1 }
179
+ if ($state.oldUrlHadCredential) { Write-PortWarn 'The old URL had an embedded credential. It was restored WITHOUT it; the credential helper must supply one.' }
180
+ Write-PortOk "[$where] origin restored to $($state.oldUrl)"
181
+ exit 0
182
+ }