From f60b2696860b76687ccb20177738bda065b8b02f Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:50:57 +0200 Subject: [PATCH] Publish curated ForgeFlow source from 2ed1787c0b52 --- .gitattributes | 3 + .gitea/workflows/quality.yml | 46 + .gitea/workflows/release.yml | 105 + .gitignore | 24 + .nvmrc | 1 + CHANGELOG.md | 257 + CONTRIBUTING.md | 13 + LICENSE | 21 + OVERLAY-INSTRUCTIONS.md | 5 + PUBLIC_SOURCE_EXPORT.md | 3 + PUBLIC_SOURCE_MANIFEST.json | 1266 +++++ PUBLISH-AND-ENABLE-UPDATE.cmd | 15 + Publish-ForgeFlow-Release.ps1 | 184 + Publish-Missing-Binary-Release.ps1 | 116 + README.md | 7 + SECURITY.md | 11 + START-FORGEFLOW-OVERLAY.ps1 | 19 + START_HERE.md | 60 + UPDATE_FROM_0.3.2.md | 19 + build-windows.ps1 | 58 + build/icon-128.png | Bin 0 -> 8830 bytes build/icon-16.png | Bin 0 -> 521 bytes build/icon-256.png | Bin 0 -> 28923 bytes build/icon-32.png | Bin 0 -> 1291 bytes build/icon-48.png | Bin 0 -> 2263 bytes build/icon-512.png | Bin 0 -> 85704 bytes build/icon-64.png | Bin 0 -> 3364 bytes build/icon.ico | Bin 0 -> 46223 bytes build/icon.png | Bin 0 -> 85704 bytes build/update-signing-public.pem | 3 + docs/ACCEPTANCE.md | 34 + docs/ARCHITECTURE.md | 221 + docs/COVERAGE_POLICY.md | 18 + docs/CURRENT_STATE.md | 54 + docs/DEPENDENCY_AUDIT.md | 46 + docs/DEPLOYMENT_MIGRATION_EXAMPLE.md | 63 + docs/DEPLOYMENT_SETUP.md | 58 + docs/DIAGNOSTICS.md | 150 + docs/ERROR_CODES.md | 15 + docs/MUTATION_MODEL.md | 26 + docs/PRODUCTION_READINESS_1.0.md | 78 + docs/RELEASE_AUDIT_0.6.0.md | 73 + docs/RELEASE_NOTES_0.10.0.md | 32 + docs/RELEASE_NOTES_0.10.1.md | 21 + docs/RELEASE_NOTES_0.10.10.md | 10 + docs/RELEASE_NOTES_0.10.11.md | 10 + docs/RELEASE_NOTES_0.10.12.md | 12 + docs/RELEASE_NOTES_0.10.13.md | 18 + docs/RELEASE_NOTES_0.10.14.md | 15 + docs/RELEASE_NOTES_0.10.15.md | 15 + docs/RELEASE_NOTES_0.10.2.md | 9 + docs/RELEASE_NOTES_0.10.3.md | 19 + docs/RELEASE_NOTES_0.10.4.md | 9 + docs/RELEASE_NOTES_0.10.5.md | 10 + docs/RELEASE_NOTES_0.10.6.md | 9 + docs/RELEASE_NOTES_0.10.7.md | 10 + docs/RELEASE_NOTES_0.10.8.md | 9 + docs/RELEASE_NOTES_0.10.9.md | 10 + docs/RELEASE_NOTES_0.2.0.md | 32 + docs/RELEASE_NOTES_0.3.0.md | 140 + docs/RELEASE_NOTES_0.3.1.md | 25 + docs/RELEASE_NOTES_0.3.2.md | 72 + docs/RELEASE_NOTES_0.4.0.md | 57 + docs/RELEASE_NOTES_0.4.1.md | 26 + docs/RELEASE_NOTES_0.4.2.md | 29 + docs/RELEASE_NOTES_0.4.3.md | 9 + docs/RELEASE_NOTES_0.4.4.md | 9 + docs/RELEASE_NOTES_0.4.5.md | 8 + docs/RELEASE_NOTES_0.5.0.md | 14 + docs/RELEASE_NOTES_0.5.1.md | 16 + docs/RELEASE_NOTES_0.5.2.md | 11 + docs/RELEASE_NOTES_0.5.3.md | 20 + docs/RELEASE_NOTES_0.5.4.md | 10 + docs/RELEASE_NOTES_0.6.0.md | 61 + docs/RELEASE_NOTES_0.6.1.md | 10 + docs/RELEASE_NOTES_0.7.0.md | 47 + docs/RELEASE_NOTES_0.8.0.md | 10 + docs/RELEASE_NOTES_0.8.1.md | 13 + docs/RELEASE_NOTES_0.8.2.md | 11 + docs/RELEASE_NOTES_0.8.3.md | 13 + docs/RELEASE_NOTES_0.8.4.md | 10 + docs/RELEASE_NOTES_0.8.5.md | 11 + docs/RELEASE_NOTES_0.8.6.md | 14 + docs/RELEASE_NOTES_0.8.7.md | 22 + docs/RELEASE_NOTES_0.8.8.md | 13 + docs/RELEASE_NOTES_0.8.9.md | 20 + docs/RELEASE_NOTES_0.9.0.md | 30 + docs/RELEASE_NOTES_0.9.1.md | 10 + docs/RELEASE_NOTES_0.9.2.md | 10 + docs/RELEASE_NOTES_0.9.3.md | 25 + docs/RELEASE_NOTES_0.9.4.md | 7 + docs/RELEASE_NOTES_0.9.5.md | 11 + docs/RELEASING.md | 56 + docs/ROADMAP.md | 106 + docs/SECURITY.md | 137 + docs/SETUP_GUIDE.md | 485 ++ docs/SSH_UNRAID_DEPLOYMENT.md | 73 + docs/STATUS_ENDPOINT.md | 59 + docs/STITCH_REVIEW.md | 99 + docs/TEST_MATRIX.md | 149 + docs/UPDATING.md | 69 + docs/screenshots/deploy-confirmation.png | Bin 0 -> 140415 bytes docs/screenshots/deployment-run.png | Bin 0 -> 107166 bytes docs/screenshots/deployment-success.png | Bin 0 -> 118819 bytes docs/screenshots/deployments.png | Bin 0 -> 95937 bytes docs/screenshots/git-validator.png | Bin 0 -> 103569 bytes docs/screenshots/overview.png | Bin 0 -> 85338 bytes docs/screenshots/repository-workspace.png | Bin 0 -> 112852 bytes eslint.config.js | 67 + examples/gitea-actions/deploy.yml | 51 + .../forgeflow-approved-deploy.yml | 90 + examples/gitea-actions/rollback.yml | 50 + examples/server/forgeflow-deploy | 224 + examples/server/forgeflow-runner.sudoers | 4 + examples/server/forgeflow-targets.conf | 9 + examples/server/nginx-forgeflow-status.conf | 8 + examples/server/status-example.json | 15 + main.cjs | 470 ++ package-lock.json | 5052 +++++++++++++++++ package.json | 177 + playwright.config.mjs | 42 + preload.cjs | 162 + scripts/acceptance.mjs | 88 + scripts/apply-binary-update.ps1 | 145 + scripts/apply-source-update.ps1 | 251 + scripts/architecture-audit.mjs | 53 + scripts/audit-installed-deployments.cjs | 141 + scripts/doctor.mjs | 91 + scripts/generate-source-manifest.mjs | 53 + scripts/prune-dist.mjs | 41 + scripts/publish-binary-release.cjs | 273 + scripts/serve-demo.mjs | 33 + scripts/setup-update-signing-key.mjs | 34 + scripts/sign-release-manifest.mjs | 46 + scripts/test-authenticode-chain.ps1 | 91 + scripts/validate-installed-connections.cjs | 95 + scripts/verify-release-signatures.mjs | 30 + scripts/verify.mjs | 598 ++ scripts/write-release-checksums.mjs | 44 + setup-windows.ps1 | 47 + src/main/audit-service.cjs | 57 + src/main/config-store.cjs | 703 +++ src/main/configuration-backup.cjs | 62 + src/main/deploy-key-lifecycle-service.cjs | 191 + src/main/deployment-identity.cjs | 35 + src/main/deployment-service.cjs | 427 ++ src/main/diagnostics-service.cjs | 385 ++ src/main/external-tools-service.cjs | 51 + src/main/git-service.cjs | 949 ++++ src/main/git-validator-policy.cjs | 89 + src/main/git-validator-service.cjs | 599 ++ src/main/gitea-service.cjs | 623 ++ src/main/inventory-classifier.cjs | 83 + src/main/inventory-review-service.cjs | 31 + src/main/ipc.cjs | 721 +++ src/main/ipc/channel.cjs | 77 + src/main/ipc/deployment-handlers.cjs | 284 + src/main/ipc/operations-handlers.cjs | 100 + src/main/ipc/repository-handlers.cjs | 450 ++ src/main/log-redaction.cjs | 101 + src/main/preflight-service.cjs | 208 + src/main/process-error-policy.cjs | 26 + src/main/process-runner.cjs | 50 + src/main/production-acceptance-harness.cjs | 149 + src/main/repository-monitor.cjs | 229 + src/main/repository-service.cjs | 303 + src/main/server-inventory.cjs | 577 ++ src/main/ssh-service.cjs | 512 ++ src/main/unraid-access-methods.cjs | 461 ++ src/main/unraid-deploy-key-host.cjs | 79 + src/main/unraid-deployment-methods.cjs | 582 ++ src/main/unraid-deployment-service.cjs | 524 ++ src/main/unraid-inventory-methods.cjs | 709 +++ src/main/unraid-preflight-methods.cjs | 622 ++ src/main/unraid-runtime-methods.cjs | 387 ++ src/main/unraid-state-methods.cjs | 293 + src/main/update-service.cjs | 871 +++ src/renderer/actions/command.js | 22 + src/renderer/actions/deployment-operation.js | 183 + src/renderer/actions/deployment-profile.js | 407 ++ src/renderer/actions/inventory.js | 185 + src/renderer/actions/recovery.js | 421 ++ src/renderer/actions/setup-and-settings.js | 440 ++ src/renderer/actions/shell.js | 530 ++ src/renderer/app.js | 737 +++ src/renderer/assets/itworx-mark.png | Bin 0 -> 85704 bytes src/renderer/assets/itworx-wordmark-dark.png | Bin 0 -> 82476 bytes src/renderer/assets/itworx-wordmark-light.png | Bin 0 -> 75240 bytes src/renderer/assets/itworx-wordmark.png | Bin 0 -> 82476 bytes src/renderer/dialogs.js | 434 ++ src/renderer/diff-view.js | 40 + src/renderer/events.js | 188 + src/renderer/index.html | 37 + src/renderer/mock-bridge.js | 589 ++ src/renderer/mock-deployment-bridge.js | 700 +++ src/renderer/mock-repository-bridge.js | 779 +++ src/renderer/operations.js | 211 + src/renderer/styles.css | 4336 ++++++++++++++ src/renderer/views.js | 875 +++ src/shared/clone-target.cjs | 28 + src/shared/deployment-policy.cjs | 44 + src/shared/git-status.cjs | 93 + src/shared/repository-match.cjs | 39 + src/shared/semver.cjs | 32 + src/shared/shell-verification.cjs | 94 + src/shared/tool-invocation.cjs | 42 + src/shared/validation.cjs | 130 + src/shared/zip-writer.cjs | 91 + tests/acceptance.test.mjs | 11 + tests/approved-deployment-evidence.test.mjs | 61 + tests/approved-deployment-one-shot.test.mjs | 21 + tests/audit-service.test.mjs | 25 + tests/browser/forgeflow.spec.mjs | 362 ++ tests/clone-target.test.mjs | 120 + tests/config-store.test.mjs | 275 + tests/configuration-backup.test.mjs | 45 + tests/dependency-wiring.test.mjs | 165 + tests/deploy-key-host.test.mjs | 216 + tests/deploy-key-lifecycle.test.mjs | 139 + tests/deployment-operations.test.mjs | 497 ++ tests/deployment-policy.test.mjs | 29 + tests/deployment-status.test.mjs | 193 + tests/diagnostics.test.mjs | 78 + tests/external-tools.test.mjs | 14 + tests/git-integration.test.mjs | 395 ++ tests/git-status.test.mjs | 34 + tests/git-validator-policy.test.mjs | 67 + tests/git-validator.test.mjs | 142 + tests/git-workflows.test.mjs | 46 + tests/gitea-actions.test.mjs | 343 ++ tests/inventory-classifier.test.mjs | 124 + tests/ipc-contract.test.mjs | 49 + tests/log-redaction.test.mjs | 54 + tests/partial-staging.test.mjs | 44 + tests/preflight.test.mjs | 177 + tests/process-error-policy.test.mjs | 26 + tests/production-acceptance.test.mjs | 129 + tests/renderer-workflow.test.mjs | 308 + tests/repository-matching.test.mjs | 18 + tests/repository-monitor.test.mjs | 152 + tests/repository-service.test.mjs | 290 + tests/security-validation.test.mjs | 68 + tests/semver.test.mjs | 13 + tests/server-inventory-branches.test.mjs | 150 + tests/shell-verification.test.mjs | 96 + tests/ssh-connection-pool.test.mjs | 255 + tests/ssh-connection.test.mjs | 98 + tests/ssh-service.test.mjs | 157 + tests/tool-invocation.test.mjs | 49 + tests/unraid-deployment.test.mjs | 1541 +++++ tests/update-service.test.mjs | 799 +++ tests/validation.test.mjs | 22 + tests/zip-writer.test.mjs | 41 + update-windows.ps1 | 35 + 254 files changed, 46204 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitea/workflows/quality.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 OVERLAY-INSTRUCTIONS.md create mode 100644 PUBLIC_SOURCE_EXPORT.md create mode 100644 PUBLIC_SOURCE_MANIFEST.json create mode 100644 PUBLISH-AND-ENABLE-UPDATE.cmd create mode 100644 Publish-ForgeFlow-Release.ps1 create mode 100644 Publish-Missing-Binary-Release.ps1 create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 START-FORGEFLOW-OVERLAY.ps1 create mode 100644 START_HERE.md create mode 100644 UPDATE_FROM_0.3.2.md create mode 100644 build-windows.ps1 create mode 100644 build/icon-128.png create mode 100644 build/icon-16.png create mode 100644 build/icon-256.png create mode 100644 build/icon-32.png create mode 100644 build/icon-48.png create mode 100644 build/icon-512.png create mode 100644 build/icon-64.png create mode 100644 build/icon.ico create mode 100644 build/icon.png create mode 100644 build/update-signing-public.pem create mode 100644 docs/ACCEPTANCE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/COVERAGE_POLICY.md create mode 100644 docs/CURRENT_STATE.md create mode 100644 docs/DEPENDENCY_AUDIT.md create mode 100644 docs/DEPLOYMENT_MIGRATION_EXAMPLE.md create mode 100644 docs/DEPLOYMENT_SETUP.md create mode 100644 docs/DIAGNOSTICS.md create mode 100644 docs/ERROR_CODES.md create mode 100644 docs/MUTATION_MODEL.md create mode 100644 docs/PRODUCTION_READINESS_1.0.md create mode 100644 docs/RELEASE_AUDIT_0.6.0.md create mode 100644 docs/RELEASE_NOTES_0.10.0.md create mode 100644 docs/RELEASE_NOTES_0.10.1.md create mode 100644 docs/RELEASE_NOTES_0.10.10.md create mode 100644 docs/RELEASE_NOTES_0.10.11.md create mode 100644 docs/RELEASE_NOTES_0.10.12.md create mode 100644 docs/RELEASE_NOTES_0.10.13.md create mode 100644 docs/RELEASE_NOTES_0.10.14.md create mode 100644 docs/RELEASE_NOTES_0.10.15.md create mode 100644 docs/RELEASE_NOTES_0.10.2.md create mode 100644 docs/RELEASE_NOTES_0.10.3.md create mode 100644 docs/RELEASE_NOTES_0.10.4.md create mode 100644 docs/RELEASE_NOTES_0.10.5.md create mode 100644 docs/RELEASE_NOTES_0.10.6.md create mode 100644 docs/RELEASE_NOTES_0.10.7.md create mode 100644 docs/RELEASE_NOTES_0.10.8.md create mode 100644 docs/RELEASE_NOTES_0.10.9.md create mode 100644 docs/RELEASE_NOTES_0.2.0.md create mode 100644 docs/RELEASE_NOTES_0.3.0.md create mode 100644 docs/RELEASE_NOTES_0.3.1.md create mode 100644 docs/RELEASE_NOTES_0.3.2.md create mode 100644 docs/RELEASE_NOTES_0.4.0.md create mode 100644 docs/RELEASE_NOTES_0.4.1.md create mode 100644 docs/RELEASE_NOTES_0.4.2.md create mode 100644 docs/RELEASE_NOTES_0.4.3.md create mode 100644 docs/RELEASE_NOTES_0.4.4.md create mode 100644 docs/RELEASE_NOTES_0.4.5.md create mode 100644 docs/RELEASE_NOTES_0.5.0.md create mode 100644 docs/RELEASE_NOTES_0.5.1.md create mode 100644 docs/RELEASE_NOTES_0.5.2.md create mode 100644 docs/RELEASE_NOTES_0.5.3.md create mode 100644 docs/RELEASE_NOTES_0.5.4.md create mode 100644 docs/RELEASE_NOTES_0.6.0.md create mode 100644 docs/RELEASE_NOTES_0.6.1.md create mode 100644 docs/RELEASE_NOTES_0.7.0.md create mode 100644 docs/RELEASE_NOTES_0.8.0.md create mode 100644 docs/RELEASE_NOTES_0.8.1.md create mode 100644 docs/RELEASE_NOTES_0.8.2.md create mode 100644 docs/RELEASE_NOTES_0.8.3.md create mode 100644 docs/RELEASE_NOTES_0.8.4.md create mode 100644 docs/RELEASE_NOTES_0.8.5.md create mode 100644 docs/RELEASE_NOTES_0.8.6.md create mode 100644 docs/RELEASE_NOTES_0.8.7.md create mode 100644 docs/RELEASE_NOTES_0.8.8.md create mode 100644 docs/RELEASE_NOTES_0.8.9.md create mode 100644 docs/RELEASE_NOTES_0.9.0.md create mode 100644 docs/RELEASE_NOTES_0.9.1.md create mode 100644 docs/RELEASE_NOTES_0.9.2.md create mode 100644 docs/RELEASE_NOTES_0.9.3.md create mode 100644 docs/RELEASE_NOTES_0.9.4.md create mode 100644 docs/RELEASE_NOTES_0.9.5.md create mode 100644 docs/RELEASING.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/SECURITY.md create mode 100644 docs/SETUP_GUIDE.md create mode 100644 docs/SSH_UNRAID_DEPLOYMENT.md create mode 100644 docs/STATUS_ENDPOINT.md create mode 100644 docs/STITCH_REVIEW.md create mode 100644 docs/TEST_MATRIX.md create mode 100644 docs/UPDATING.md create mode 100644 docs/screenshots/deploy-confirmation.png create mode 100644 docs/screenshots/deployment-run.png create mode 100644 docs/screenshots/deployment-success.png create mode 100644 docs/screenshots/deployments.png create mode 100644 docs/screenshots/git-validator.png create mode 100644 docs/screenshots/overview.png create mode 100644 docs/screenshots/repository-workspace.png create mode 100644 eslint.config.js create mode 100644 examples/gitea-actions/deploy.yml create mode 100644 examples/gitea-actions/forgeflow-approved-deploy.yml create mode 100644 examples/gitea-actions/rollback.yml create mode 100644 examples/server/forgeflow-deploy create mode 100644 examples/server/forgeflow-runner.sudoers create mode 100644 examples/server/forgeflow-targets.conf create mode 100644 examples/server/nginx-forgeflow-status.conf create mode 100644 examples/server/status-example.json create mode 100644 main.cjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.mjs create mode 100644 preload.cjs create mode 100644 scripts/acceptance.mjs create mode 100644 scripts/apply-binary-update.ps1 create mode 100644 scripts/apply-source-update.ps1 create mode 100644 scripts/architecture-audit.mjs create mode 100644 scripts/audit-installed-deployments.cjs create mode 100644 scripts/doctor.mjs create mode 100644 scripts/generate-source-manifest.mjs create mode 100644 scripts/prune-dist.mjs create mode 100644 scripts/publish-binary-release.cjs create mode 100644 scripts/serve-demo.mjs create mode 100644 scripts/setup-update-signing-key.mjs create mode 100644 scripts/sign-release-manifest.mjs create mode 100644 scripts/test-authenticode-chain.ps1 create mode 100644 scripts/validate-installed-connections.cjs create mode 100644 scripts/verify-release-signatures.mjs create mode 100644 scripts/verify.mjs create mode 100644 scripts/write-release-checksums.mjs create mode 100644 setup-windows.ps1 create mode 100644 src/main/audit-service.cjs create mode 100644 src/main/config-store.cjs create mode 100644 src/main/configuration-backup.cjs create mode 100644 src/main/deploy-key-lifecycle-service.cjs create mode 100644 src/main/deployment-identity.cjs create mode 100644 src/main/deployment-service.cjs create mode 100644 src/main/diagnostics-service.cjs create mode 100644 src/main/external-tools-service.cjs create mode 100644 src/main/git-service.cjs create mode 100644 src/main/git-validator-policy.cjs create mode 100644 src/main/git-validator-service.cjs create mode 100644 src/main/gitea-service.cjs create mode 100644 src/main/inventory-classifier.cjs create mode 100644 src/main/inventory-review-service.cjs create mode 100644 src/main/ipc.cjs create mode 100644 src/main/ipc/channel.cjs create mode 100644 src/main/ipc/deployment-handlers.cjs create mode 100644 src/main/ipc/operations-handlers.cjs create mode 100644 src/main/ipc/repository-handlers.cjs create mode 100644 src/main/log-redaction.cjs create mode 100644 src/main/preflight-service.cjs create mode 100644 src/main/process-error-policy.cjs create mode 100644 src/main/process-runner.cjs create mode 100644 src/main/production-acceptance-harness.cjs create mode 100644 src/main/repository-monitor.cjs create mode 100644 src/main/repository-service.cjs create mode 100644 src/main/server-inventory.cjs create mode 100644 src/main/ssh-service.cjs create mode 100644 src/main/unraid-access-methods.cjs create mode 100644 src/main/unraid-deploy-key-host.cjs create mode 100644 src/main/unraid-deployment-methods.cjs create mode 100644 src/main/unraid-deployment-service.cjs create mode 100644 src/main/unraid-inventory-methods.cjs create mode 100644 src/main/unraid-preflight-methods.cjs create mode 100644 src/main/unraid-runtime-methods.cjs create mode 100644 src/main/unraid-state-methods.cjs create mode 100644 src/main/update-service.cjs create mode 100644 src/renderer/actions/command.js create mode 100644 src/renderer/actions/deployment-operation.js create mode 100644 src/renderer/actions/deployment-profile.js create mode 100644 src/renderer/actions/inventory.js create mode 100644 src/renderer/actions/recovery.js create mode 100644 src/renderer/actions/setup-and-settings.js create mode 100644 src/renderer/actions/shell.js create mode 100644 src/renderer/app.js create mode 100644 src/renderer/assets/itworx-mark.png create mode 100644 src/renderer/assets/itworx-wordmark-dark.png create mode 100644 src/renderer/assets/itworx-wordmark-light.png create mode 100644 src/renderer/assets/itworx-wordmark.png create mode 100644 src/renderer/dialogs.js create mode 100644 src/renderer/diff-view.js create mode 100644 src/renderer/events.js create mode 100644 src/renderer/index.html create mode 100644 src/renderer/mock-bridge.js create mode 100644 src/renderer/mock-deployment-bridge.js create mode 100644 src/renderer/mock-repository-bridge.js create mode 100644 src/renderer/operations.js create mode 100644 src/renderer/styles.css create mode 100644 src/renderer/views.js create mode 100644 src/shared/clone-target.cjs create mode 100644 src/shared/deployment-policy.cjs create mode 100644 src/shared/git-status.cjs create mode 100644 src/shared/repository-match.cjs create mode 100644 src/shared/semver.cjs create mode 100644 src/shared/shell-verification.cjs create mode 100644 src/shared/tool-invocation.cjs create mode 100644 src/shared/validation.cjs create mode 100644 src/shared/zip-writer.cjs create mode 100644 tests/acceptance.test.mjs create mode 100644 tests/approved-deployment-evidence.test.mjs create mode 100644 tests/approved-deployment-one-shot.test.mjs create mode 100644 tests/audit-service.test.mjs create mode 100644 tests/browser/forgeflow.spec.mjs create mode 100644 tests/clone-target.test.mjs create mode 100644 tests/config-store.test.mjs create mode 100644 tests/configuration-backup.test.mjs create mode 100644 tests/dependency-wiring.test.mjs create mode 100644 tests/deploy-key-host.test.mjs create mode 100644 tests/deploy-key-lifecycle.test.mjs create mode 100644 tests/deployment-operations.test.mjs create mode 100644 tests/deployment-policy.test.mjs create mode 100644 tests/deployment-status.test.mjs create mode 100644 tests/diagnostics.test.mjs create mode 100644 tests/external-tools.test.mjs create mode 100644 tests/git-integration.test.mjs create mode 100644 tests/git-status.test.mjs create mode 100644 tests/git-validator-policy.test.mjs create mode 100644 tests/git-validator.test.mjs create mode 100644 tests/git-workflows.test.mjs create mode 100644 tests/gitea-actions.test.mjs create mode 100644 tests/inventory-classifier.test.mjs create mode 100644 tests/ipc-contract.test.mjs create mode 100644 tests/log-redaction.test.mjs create mode 100644 tests/partial-staging.test.mjs create mode 100644 tests/preflight.test.mjs create mode 100644 tests/process-error-policy.test.mjs create mode 100644 tests/production-acceptance.test.mjs create mode 100644 tests/renderer-workflow.test.mjs create mode 100644 tests/repository-matching.test.mjs create mode 100644 tests/repository-monitor.test.mjs create mode 100644 tests/repository-service.test.mjs create mode 100644 tests/security-validation.test.mjs create mode 100644 tests/semver.test.mjs create mode 100644 tests/server-inventory-branches.test.mjs create mode 100644 tests/shell-verification.test.mjs create mode 100644 tests/ssh-connection-pool.test.mjs create mode 100644 tests/ssh-connection.test.mjs create mode 100644 tests/ssh-service.test.mjs create mode 100644 tests/tool-invocation.test.mjs create mode 100644 tests/unraid-deployment.test.mjs create mode 100644 tests/update-service.test.mjs create mode 100644 tests/validation.test.mjs create mode 100644 tests/zip-writer.test.mjs create mode 100644 update-windows.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1a89dc5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +examples/server/forgeflow-deploy text eol=lf +scripts/* text eol=lf diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml new file mode 100644 index 0000000..1c2d280 --- /dev/null +++ b/.gitea/workflows/quality.yml @@ -0,0 +1,46 @@ +name: ForgeFlow quality gate + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + secret-scan: + runs-on: ubuntu-latest + steps: + - uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Secret scan + shell: bash + run: | + set -euo pipefail + scan_container="$(docker create ghcr.io/trufflesecurity/trufflehog:3.79.0 filesystem /scan --only-verified --fail --no-update)" + trap 'docker rm -f "${scan_container}" >/dev/null 2>&1 || true' EXIT + tar --exclude=.git --transform='s#^\.$#scan#;s#^\./#scan/#' -cf - . | docker cp - "${scan_container}:/" + docker start -a "${scan_container}" + + quality: + # Browser quality runs against the dedicated bounded Windows 11 VM runner. + runs-on: windows-native + steps: + - uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + - run: npm ci + # The native runner deliberately skips Electron's install-time binary + # download. Prime it once before Node's parallel test workers require + # Electron, otherwise they can race while creating the same directory. + - run: npx electron --version + - run: npm run quality + - run: npx playwright install chromium + - run: npm run test:browser:ci + - name: Preserve browser failure evidence + if: failure() + uses: https://gitea.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: forgeflow-browser-failure-evidence + path: artifacts/ + if-no-files-found: ignore + - run: npm audit --omit=dev --audit-level=high diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..ba36ad7 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,105 @@ +name: ForgeFlow signed release + +on: + workflow_dispatch: + +permissions: + code: read + releases: write + +jobs: + release: + if: ${{ gitea.repository == 'Jens/ForgeFlow-Public' }} + runs-on: windows-native + steps: + - uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 2 + - uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + - name: Validate version bump and build release artifacts + shell: powershell + env: + GITEA_EVENT_NAME: ${{ gitea.event_name }} + run: | + $ErrorActionPreference = "Stop" + Set-StrictMode -Version Latest + + $manifest = Get-Content -LiteralPath "package.json" -Raw | ConvertFrom-Json + $version = [string]$manifest.version + $previousVersion = "" + try { + $previousJson = (& git show "HEAD^:package.json" 2>$null | Out-String) + if ($LASTEXITCODE -eq 0 -and $previousJson.Trim()) { + $previousVersion = [string](ConvertFrom-Json $previousJson).version + } + } catch { + $previousVersion = "" + } + + if ($env:GITEA_EVENT_NAME -eq "push" -and $previousVersion -eq $version) { + Write-Host "package.json changed without a version bump ($version); no release will be published." + exit 0 + } + if ($version -notmatch '^\d+\.\d+\.\d+$') { + throw "ForgeFlow version '$version' is not a stable semantic version." + } + + & cmd.exe /d /s /c "npm ci --no-audit --no-fund" + if ($LASTEXITCODE -ne 0) { throw "npm ci failed." } + & cmd.exe /d /s /c "npx electron --version" + if ($LASTEXITCODE -ne 0) { throw "Electron preflight failed." } + & cmd.exe /d /s /c "npm run quality" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow release quality gate failed." } + & cmd.exe /d /s /c "npx playwright install chromium" + if ($LASTEXITCODE -ne 0) { throw "Playwright Chromium installation failed." } + & cmd.exe /d /s /c "npm run test:browser:ci" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow browser acceptance suite failed." } + & cmd.exe /d /s /c "npm audit --omit=dev --audit-level=high" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow production dependency audit failed." } + & cmd.exe /d /s /c "npx electron-builder --win nsis portable" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Windows build failed." } + & node scripts/write-release-checksums.mjs + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow checksum generation failed." } + + - name: Sign and publish validated artifacts + shell: powershell + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + FORGEFLOW_RELEASE_BASE_URL: ${{ gitea.server_url }} + FORGEFLOW_RELEASE_OWNER: Jens + FORGEFLOW_RELEASE_REPO: ForgeFlow-Public + FORGEFLOW_RELEASE_BRANCH: main + FORGEFLOW_RELEASE_SIGNING_KEY_PEM: ${{ secrets.FORGEFLOW_RELEASE_SIGNING_KEY_PEM }} + run: | + $ErrorActionPreference = "Stop" + Set-StrictMode -Version Latest + $privateKeyPath = Join-Path $env:RUNNER_TEMP "forgeflow-release-signing-private.pem" + try { + if (-not $env:FORGEFLOW_RELEASE_SIGNING_KEY_PEM) { + throw "FORGEFLOW_RELEASE_SIGNING_KEY_PEM is not configured." + } + if (-not $env:GITEA_TOKEN) { + throw "GITEA_TOKEN is not configured." + } + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($privateKeyPath, $env:FORGEFLOW_RELEASE_SIGNING_KEY_PEM, $utf8NoBom) + $env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY = $privateKeyPath + & node scripts/sign-release-manifest.mjs + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow manifest signing failed." } + & node scripts/verify-release-signatures.mjs + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow release signature verification failed." } + & node scripts/prune-dist.mjs + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow artifact pruning failed." } + & .\node_modules\.bin\electron.cmd scripts/publish-binary-release.cjs + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Gitea release publication failed." } + } finally { + $env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY = $null + if (Test-Path -LiteralPath $privateKeyPath) { + Remove-Item -LiteralPath $privateKeyPath -Force + } + } + + Write-Host "ForgeFlow artifacts were signed and published from exact main HEAD $env:GITEA_SHA." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2da572 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +node_modules/ +dist/ +.DS_Store +Thumbs.db +*.log +coverage/ +artifacts/ +playwright-report/ +.forgeflow/ +.playwright-mcp/ +.env +.env.* +!.env.example +*.pfx +*.p12 +*.key +*.pem +!build/update-signing-public.pem +.codex/ +.claude/ +.agents/ +.dyad/ +.idea/ +.vs/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b1039fa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,257 @@ +# Changelog + +## 0.10.0 - 2026-07-28 + +- Added safe Gitea Server pull with repository-scoped read-only deploy keys and pinned SSH host fingerprints. +- Automatically discovers and links unique running repository workloads while filtering system containers and stale release folders. +- Reconciles live server SHA, Gitea SHA and runtime health, including deployments changed outside ForgeFlow. +- Preserves adopted Compose and DockerMan identity and avoids duplicate repository links. +- Expanded Git Validator with editor, line-ending, dependency-lock and Gitea Actions checks plus reviewable fixes. +- Refined the dark deployment workspace with clearer workload identity, focused inventory and motion-safe illustration. +- Added live connection/deployment audit scripts and kept automatic cleanup of older packaged artifacts. + +## 0.9.5 - 2026-07-28 + +- Added a mandatory remote write-access preflight before every Direct copy deployment and again immediately before upload. +- The preflight reports the exact failing path, SSH user, owner, group and mode instead of allowing a partial copy or Docker activation. +- Added an in-app **Check / fix write access** action on every SSH / Unraid deployment and directly beside failed permission checks. +- The repair is restricted to the linked project source and `.forgeflow` state, assigns the Unraid `users` group where available, preserves executable bits and excludes configured runtime data. +- Explicit permission repair now also normalizes SMB/manual-copy access even when the SSH account is root and could already write. +- Direct copy is fail-closed: candidate Compose configuration and images are validated before live files change; no implicit `down`, `--remove-orphans` or `--force-recreate` is used. +- A failed activation restores the prior source and image tags, attempts to restore the previous runtime and retains rollback evidence for diagnosis. +- Removed orphan deletion from the legacy server deployment helper as well. + +## 0.9.4 - 2026-07-28 + +- Fixed imported deployments failing with `service has neither an image nor a build context` because a stale labels-only metadata overlay introduced a phantom service. +- Existing workloads now activate strictly from their real Compose files; ForgeFlow metadata is stored outside the active Compose model. +- Redeploy always uses `--force-recreate` and verifies every service returned by `docker compose config --services`. +- A deployment is rejected when the container ID did not change or when the previous hinted container remains running beside a duplicate workload. +- Deployment SHA is promoted only after recreation and runtime checks complete. + +## 0.9.3 - 2026-07-28 + +- removed every server-to-repository authentication check and all server-side Git deployment branches from SSH/Unraid deploy and rollback; +- forcibly migrated legacy SSH/Unraid profiles to direct local bundle copy, except explicit monitor-only profiles; +- discovered Compose YAML definitions directly from configured Unraid appdata roots even when Docker inspection fails; +- merged YAML definitions with runtime containers, stopped containers and DockerMan templates; +- automatically linked unique high-confidence Compose folder/project matches and added one-click linking for remaining strong matches with all fields prefilled; +- made inventory failure handling compatible with strict Bash execution, pruned large runtime folders during YAML discovery and normalized user-share/cache/disk appdata paths. + +## 0.9.0 - 2026-07-27 + +- replaced Git-checkout-only discovery with a complete Unraid workload inventory, including stopped, Compose, DockerMan and standalone containers; +- added persistent manual workload linking with repository, deployment mode, Compose project, files and service identity; +- made checksum-verified exact-commit push bundles the default for new SSH/Unraid profiles, so Unraid no longer needs a Gitea key; +- separated desktop-to-Unraid authentication, Docker/Compose capabilities and optional Unraid-to-Gitea access in diagnostics; +- preserved adopted DockerMan templates and disabled aggressive recreate/orphan flags by default; +- promoted deployment state only after Compose validation and service verification, with atomic manifests, rollback restoration and live lock ownership; +- retained server-side Git and monitor-only modes for explicit use cases; +- corrected release publication so source, installer, portable executable and SHA-256 sidecars are published together, with a recovery publisher for source-only Gitea releases. + +## 0.8.9 - 2026-07-26 + +- added a per-repository Git Validator with a weighted assurance score and evidence-backed checks; +- validates Gitea branch governance, repository identity, upstream tracking, effective author identity, safe synchronization defaults, README and gitignore hygiene, tracked secret-shaped files and oversized files; +- provides audited one-click repairs for origin alignment and repository-local safety configuration; +- offers confirmed repairs for default-branch protection and a reviewable uncommitted `.gitignore`; +- introduced a premium, theme-aware and container-responsive Validator workspace with safe-fix batching. + +## 0.8.8 - 2026-07-26 + +- fixed binary update downloads on Gitea servers that require release-scoped attachment routes; +- sends both the immutable release ID and attachment ID when downloading update assets; +- preserves strict same-origin token handling and SHA-256 verification. + +## 0.8.7 - 2026-07-26 + +- automatically inventories running workloads across configured Unraid servers; +- links server Git checkouts to Gitea repositories through exact normalized origins and strong container evidence; +- supports image-only discovery through OCI and ForgeFlow repository/commit labels; +- adopts uniquely matched workloads into Deployments without requiring a prior ForgeFlow release operation; +- verifies every SSH deployment refresh against the current full Gitea branch SHA; +- treats matching commits as in order only while the container is running and healthy, and rejects ambiguous matches. + +## 0.8.6 - 2026-07-26 + +- added contextual animated code maps to unused diff-canvas space; +- made illustrations respond to file type, diff size and pointer depth without obscuring code; +- enriched changed-file rows with clearer state chips, active hierarchy and premium interaction feedback; +- added responsive and reduced-motion safeguards for focused, accessible workspaces. + +## 0.8.5 - 2026-07-26 + +- fixed packaged update downloads when Gitea reports an asset URL with a different public origin; +- downloads release assets by immutable Gitea asset ID on the configured trusted origin; +- retains strict token isolation and never follows authenticated downloads to another host. + +## 0.8.4 - 2026-07-26 + +- added interactive animated release-flow illustrations to high-value project surfaces; +- introduced cursor-responsive depth, travelling deployment signals and living status nodes; +- added compact repository illustration watermarks without reducing usable header space; +- made every illustration theme-aware, responsive, semantic and reduced-motion safe. + +## 0.8.3 - 2026-07-26 + +- enriched light mode with layered color, depth and stronger navigation hierarchy; +- made every deployment visually identifiable by container, repository, environment and stable accent color; +- added live-versus-Gitea commit proof directly to deployment cards; +- reconciled stale failed operations against healthy live Unraid and current Gitea truth. + +## 0.8.2 - 2026-07-26 + +- enabled checksum-verified binary auto-update for installed and portable Windows builds; +- added an external binary updater that waits for ForgeFlow to close, applies the verified release and restarts the application; +- added reproducible SHA-256 sidecars and authenticated Gitea release publishing; +- made packaged update checks fail clearly when a matching published release asset is incomplete. + +## 0.8.1 - 2026-07-26 + +- introduced a refined premium visual system with clearer hierarchy, richer depth, responsive density and reduced-motion support; +- added a live open pull-request overview per repository instead of only pull-request creation; +- added a safe installed-connection self-test for DPAPI, Gitea identity, repository access and Actions access; +- verified the existing dedicated Unraid Ed25519 key and strict host fingerprint against the configured server; +- expanded compact-window visual acceptance at 1120 × 720 and the dashboard check at 1440 × 900. + +## 0.8.0 - 2026-07-25 + +- Added partial-hunk staging, conflict guidance, protected-branch awareness and Gitea pull requests. +- Added configurable editor/terminal integration, tray, notifications and login startup. +- Added deployment policies, release notes, append-only audit export and encrypted configuration backup/restore. +- Added a guarded end-to-end environment acceptance harness. + +## 0.7.0 + +- Added server-authoritative adoption of existing Unraid/Compose deployments. +- Added Docker, Compose, Git and DockerMan discovery with provenance and complete runtime metadata. +- Added a general one-click troubleshooter for common Git and deployment failures. +- Added safe abort recovery for interrupted Git operations and protected divergence repair. +- Fixed Unraid WebUI placeholder validation, serialized config saves, malformed config recovery and CRLF manifest verification. + +## 0.6.1 + +- Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake. +- Added helper-log diagnostics and update-request identity validation. + +## 0.6.0 + +- Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches. +- Added safe one-click fetch, fast-forward, push and backup-then-reset synchronization repairs. +- Reconciled interrupted SSH deployments from live Unraid state at startup and on demand. +- Added DockerMan WebUI, icon and shell metadata plus a persistent XML template fallback for repository and generated Compose deployments. +- Added a built-in high-contrast ITWorx icon, local PNG upload, DockerMan image storage and metadata/icon-cache invalidation. +- Enforced lowercase internal Compose identities while preserving visible names such as `Portfolio`. +- Hardened source updater startup, status reporting, rollback and restart behavior. +- Completed successful SSH operations before post-deployment reconciliation to prevent stale deployment mode. +- Added startup server-truth refresh, batch DockerMan repair and healthy-live-SHA deploy suppression. +- Added lockfile-aware updater installs and direct Electron restart. +- Expanded automated coverage to Git lock, divergence, DockerMan, deployment reconciliation and updater regressions. + +## 0.5.4 + +- Added exact Unraid-to-Gitea clone preflight. +- Made SSH deployments background operations with automatic polling. +- Preserved configured Compose casing such as Portfolio. +- Guaranteed failed remote operations become terminal failed records. + +## 0.5.3 + +- Confirmed updater handoff before application exit. +- Persistent lifecycle state and startup result notification. +- Reliable success/rollback restart tracking. + +## 0.5.2 + +- Made release verification and built-in update validation independent of Windows Bash shims. +- Added portable structural safety validation for the Unraid deployment script. +- Kept GNU Bash syntax validation on Linux and other non-Windows systems. + +## 0.5.1 + +- Fixed Windows publication quality gate by validating Bash syntax through standard input. +- Added regression coverage for path-independent shell validation. + +## 0.5.0 + +- Viewport-safe scrollable dialogs with persistent actions. +- NUL-delimited Git pathspec transport for large selections. +- Per-repository mutation serialization and stale lock repair. +- Bulk normalization of legacy Gitea origins. +- Expanded regression coverage. + +- Correctly stage deleted and renamed paths. +- Preserve and surface local commits when push fails. +- Always refresh the actual Git state after operation errors. +- Add end-to-end Git regression coverage for deletion and push recovery. + +## 0.4.3 + +- Removed the platform-dependent Unraid inspection integration test that mixed Windows temporary paths with remote Linux semantics. +- Added deterministic SSH inspection contract coverage. +- Released as one complete clean source archive. + +## 0.4.2 + +- Replaced nested SSH Bash quoting with a single-line base64 transport. +- Fixed Windows Git Bash inspection failures caused by multiline quote parsing. +- Made temporary test cleanup resilient to short-lived Windows directory locks. +- Corrected remote status-file base64 invocation. +- Added Windows-focused regression coverage and a recovery updater from 0.4.0/0.4.1. + +## 0.4.1 + +- Fixed Windows `bash -n` verification for ForgeFlow paths such as `C:\Projects\ForgeFlow`. +- Bash now receives a relative POSIX script path with the project root supplied through `cwd`. +- Added cross-platform regression tests, including a project root containing spaces. + +## 0.4.0 + +- Made the changed-file panel independently scrollable for large working trees. +- Added explicit commit-message readiness and clarified that commit actions stage selected files automatically. +- Integrated the supplied ITWorx.tech logo into the title bar, setup and desktop icons. +- Added a private-Gitea source updater pinned to an exact update-branch commit. +- Added secure SSH / Unraid server profiles with host-key pinning. +- Added exact-SHA deployment to Git-backed `/mnt/user/appdata/` folders. +- Added existing deployment inspection, tracked-change blocking, origin alignment and nested-Git warnings. +- Added repository Compose adoption and basic generated Compose for simple Dockerfile applications. +- Added exact previous-SHA rollback with repeat health verification. +- Added the LumaOps server-versus-Gitea migration audit. +- Expanded the automated suite to 59 passing tests. + +## 0.3.2 + +- Clone from Gitea now uses the first configured project root automatically. +- Repository-named target folders are created without reopening the folder picker. +- Added an explicit Choose another location action for exceptional clones. +- Existing matching checkouts are linked instead of cloned again. +- Different repositories, non-empty ordinary folders and file conflicts are blocked. +- Clone identity and destination are resolved again in the privileged backend. +- Added a Windows source-update script and upgrade guide. +- Expanded the automated suite from 39 to 45 passing tests. + +## 0.3.1 + +- Fixed the Windows environment doctor failing with `spawn npm ENOENT` after a successful npm installation. +- Added safe npm invocation through `npm_execpath` with a `cmd.exe` fallback. +- Added Windows and cross-platform regression tests for npm discovery. +- Removed the duplicated hard-coded doctor version. + +## 0.1.0 — Functional MVP foundation + +- Reworked the Stitch static screens into one coherent desktop application. +- Added secure Electron main/preload/renderer architecture. +- Added real Gitea connection and repository loading. +- Added bounded local Git repository discovery and remote matching. +- Added real Git status, diff, staging, commit, push, fetch and fast-forward pull. +- Added deployment profiles and exact-SHA Gitea Actions dispatch. +- Added deployment confirmation, operation history and healthcheck foundation. +- Added dark/light themes, onboarding and browser demo. +- Added unit tests and a real temporary Git remote integration test. +- Added safe workflow and server deployment examples. + +## 0.4.5 + +- Fixed commit/push after manually staging a deleted file. +- Already staged deletions and renames are no longer re-added as missing pathspecs. +- Added a real bare-remote regression test for `silent-zebra-glow.zip`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..98b5667 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing + +ForgeFlow changes must preserve exact-commit provenance, update integrity and safe deployment boundaries. + +Before opening a pull request: + +- do not commit tokens, SSH credentials, signing private keys, deployment secrets or local repository state; +- keep update manifests/checksums/signatures deterministic and reviewable; +- add regression tests for repository synchronization, dirty-file handling, update and deployment changes; +- keep real deployment targets configurable rather than embedding private infrastructure; +- run `npm run quality` and the managed validation workflow where supported. + +Release metadata should distinguish an unreleased package version from the latest published Gitea Release; do not advance public release claims until the corresponding release exists. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb0c466 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jens Caers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/OVERLAY-INSTRUCTIONS.md b/OVERLAY-INSTRUCTIONS.md new file mode 100644 index 0000000..7c153ae --- /dev/null +++ b/OVERLAY-INSTRUCTIONS.md @@ -0,0 +1,5 @@ +# ForgeFlow 0.6.0 overlay + +Close ForgeFlow, extract this archive directly over `C:\Projects\ForgeFlow`, replace existing files, and run `START-FORGEFLOW-OVERLAY.ps1`. + +This version verifies Unraid-to-Gitea access before deployment, runs SSH deployments in the background, automatically polls status, and preserves the configured name `Portfolio`. diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..4064c03 --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -0,0 +1,3 @@ +# Public source export + +This source snapshot was generated from the private canonical repository at revision `2ed1787c0b52de6ffb2bccce978b8f6172506404`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..7e494ac --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,1266 @@ +{ + "schemaVersion": 1, + "sourceRevision": "2ed1787c0b52de6ffb2bccce978b8f6172506404", + "files": [ + { + "path": ".gitattributes", + "sha256": "ec40b1ed8e5152ca4175bbe97be43f0e2e911894112dc6a47c2c348069f79abf", + "bytes": 87 + }, + { + "path": ".gitea/workflows/quality.yml", + "sha256": "d80f0fe159d2a1604002db4272d1d49cc3ec2097100b3a3bbb3741a855fb1a11", + "bytes": 1900 + }, + { + "path": ".gitea/workflows/release.yml", + "sha256": "57c23e82b677004d6a006b7c29990aef9fa28e77af3a83301bc1eb116150dfac", + "bytes": 4959 + }, + { + "path": ".gitignore", + "sha256": "83fac3efff45f3dc926080b280ae190b6bb40eb8dae7b8cb5d27255759bc9c47", + "bytes": 267 + }, + { + "path": ".nvmrc", + "sha256": "12d3a4efa6646b3ece4782f70033b9785bf0d167b553c43e22579b031cea5c4d", + "bytes": 4 + }, + { + "path": "CHANGELOG.md", + "sha256": "343ea8dc00a3257801ecc199518a65d4d4adb2644c3acf7b614eb66032ab2afb", + "bytes": 15969 + }, + { + "path": "CONTRIBUTING.md", + "sha256": "3754dcaa776ead5dc60b4955ed4294fd8580577ecb9ce29cb0d9fbbaf9253313", + "bytes": 811 + }, + { + "path": "LICENSE", + "sha256": "2033fd1ba7aeb8d2c6377d970516c4b7b82762dfc35efafb4be5a15e298c5e6b", + "bytes": 1088 + }, + { + "path": "OVERLAY-INSTRUCTIONS.md", + "sha256": "91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1", + "bytes": 352 + }, + { + "path": "PUBLISH-AND-ENABLE-UPDATE.cmd", + "sha256": "fc4a0cd199e4a6b3a8ed745261b770d8202d774bb1234cb9e074997b8a10b072", + "bytes": 499 + }, + { + "path": "Publish-ForgeFlow-Release.ps1", + "sha256": "b1bab5893b2999028473e93fe5952a2c1c1c068bab22f5a0b52943a5b8b0d311", + "bytes": 11165 + }, + { + "path": "Publish-Missing-Binary-Release.ps1", + "sha256": "2e2abdc31e9f81bf216a7f0a0d2e04ee39d570fbbf297d0d2a957d587c064147", + "bytes": 4509 + }, + { + "path": "README.md", + "sha256": "d818c655b835c2b72df57ce92fde946e921416fe947d57e57ad03c944aa1f151", + "bytes": 645 + }, + { + "path": "SECURITY.md", + "sha256": "247af21a5d7b42943ac5f6d297366f5e042a15a62c0c616872b9dadc7e47aa0f", + "bytes": 1072 + }, + { + "path": "START-FORGEFLOW-OVERLAY.ps1", + "sha256": "058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658", + "bytes": 743 + }, + { + "path": "START_HERE.md", + "sha256": "39260d5268764844f744278f99a1ce934cc3bf3e2f5eb56687122bb75482255f", + "bytes": 2496 + }, + { + "path": "UPDATE_FROM_0.3.2.md", + "sha256": "ebafe424ed9dd3d3558949bfcd523352565102da4221769187dcacf4777b4977", + "bytes": 786 + }, + { + "path": "build-windows.ps1", + "sha256": "89cfdec9f5e47fa1c4ce3f883462ffbd5a95938f1e228535782b2561142afdeb", + "bytes": 1754 + }, + { + "path": "build/icon-128.png", + "sha256": "0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86", + "bytes": 8830 + }, + { + "path": "build/icon-16.png", + "sha256": "09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2", + "bytes": 521 + }, + { + "path": "build/icon-256.png", + "sha256": "510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195", + "bytes": 28923 + }, + { + "path": "build/icon-32.png", + "sha256": "fd895bf8f1772359110432b89fffa2efbf7785a8a3d973a99e429930de559b8c", + "bytes": 1291 + }, + { + "path": "build/icon-48.png", + "sha256": "ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41", + "bytes": 2263 + }, + { + "path": "build/icon-512.png", + "sha256": "16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84", + "bytes": 85704 + }, + { + "path": "build/icon-64.png", + "sha256": "4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183", + "bytes": 3364 + }, + { + "path": "build/icon.ico", + "sha256": "25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50", + "bytes": 46223 + }, + { + "path": "build/icon.png", + "sha256": "16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84", + "bytes": 85704 + }, + { + "path": "build/update-signing-public.pem", + "sha256": "5cc93571f6c5648cad5116680ae5218f486407c5026ee37cb0ab2fe93a16a0b4", + "bytes": 116 + }, + { + "path": "docs/ACCEPTANCE.md", + "sha256": "33954dcf8b898885c94fa486a1753a1298dfe6846fe4c5c6dff91d22f64ffe60", + "bytes": 1684 + }, + { + "path": "docs/ARCHITECTURE.md", + "sha256": "b89ffb60db36a9cca0ed9ca70edd7e8e5d427d51846b93e8383ce6f406309a9d", + "bytes": 8514 + }, + { + "path": "docs/COVERAGE_POLICY.md", + "sha256": "6450bf6b1b5028a6dd9f928f6cbb5a84281a68c24361e2a4216679993e7fb149", + "bytes": 1175 + }, + { + "path": "docs/CURRENT_STATE.md", + "sha256": "af6d5af037687a09a43f14cc19ffe75f959a110765fbddc1ab2803833f735764", + "bytes": 3178 + }, + { + "path": "docs/DEPENDENCY_AUDIT.md", + "sha256": "1923c673d5cf8b5aa5fbd500eaef648f3b3eaf47527d34e3d0bb34474da8822e", + "bytes": 2251 + }, + { + "path": "docs/DEPLOYMENT_MIGRATION_EXAMPLE.md", + "sha256": "4d4b5539024c3805a087f92c85ab48e9aaab366aa68d3e4aa558139a019dc4ba", + "bytes": 2851 + }, + { + "path": "docs/DEPLOYMENT_SETUP.md", + "sha256": "e9163fbd6a0532f483e32b4009cedd92f84946e4b949107f8c21cc7c2e4f86d3", + "bytes": 2103 + }, + { + "path": "docs/DIAGNOSTICS.md", + "sha256": "8a1be38b097c1cbfaaaee150c64b99f44bbe007817200d675a2b583d8f2198e5", + "bytes": 4766 + }, + { + "path": "docs/ERROR_CODES.md", + "sha256": "ff0d8aea8eff6b211493c99fbb8ee7d67bb70eb1d0b4312f3961f651a17c5683", + "bytes": 1479 + }, + { + "path": "docs/MUTATION_MODEL.md", + "sha256": "303d2558da306550e50f2704bfae0fb23e8abee428db436097d9838a9c6956df", + "bytes": 1327 + }, + { + "path": "docs/PRODUCTION_READINESS_1.0.md", + "sha256": "52fd7c73bcf82ae27ffe12751590d0b05e5fefbafe4a4939e225a6b9258cf0a1", + "bytes": 3782 + }, + { + "path": "docs/RELEASE_AUDIT_0.6.0.md", + "sha256": "f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1", + "bytes": 4581 + }, + { + "path": "docs/RELEASE_NOTES_0.10.0.md", + "sha256": "2b07fd178fe6dd7c2233876c4aa7de5d2371635755e7ac1f2acbbd5b298d491f", + "bytes": 2328 + }, + { + "path": "docs/RELEASE_NOTES_0.10.1.md", + "sha256": "48da50bb0bd0628187ca2f879f1e58d54978f2a7e30cf0c71df9b55a240d7ba6", + "bytes": 1454 + }, + { + "path": "docs/RELEASE_NOTES_0.10.10.md", + "sha256": "b0202cf6615c149bd79ec8033e99966b54603df3ffad966d91ad0304371b8f97", + "bytes": 904 + }, + { + "path": "docs/RELEASE_NOTES_0.10.11.md", + "sha256": "4724c6f2ca9a8d5126552fa1577c4c53d3c6f14946dc29981408e2543f3a76b5", + "bytes": 1002 + }, + { + "path": "docs/RELEASE_NOTES_0.10.12.md", + "sha256": "876c6794ed47ac83d7042b681946c80ba66ac5cc3e24f5ac53f15c4f194850e8", + "bytes": 1304 + }, + { + "path": "docs/RELEASE_NOTES_0.10.13.md", + "sha256": "7ad75d0a052d9b99b91e78f9b8d3bfe55e3df15e7182d43bd7cfe405a8cfb5fd", + "bytes": 1949 + }, + { + "path": "docs/RELEASE_NOTES_0.10.14.md", + "sha256": "1d5832048dd834a773ee8f34e6599c590373358132203b022b521dd5dde2f179", + "bytes": 1571 + }, + { + "path": "docs/RELEASE_NOTES_0.10.15.md", + "sha256": "055ad0c73f0854a708eedc3bc4e9dfb991b4e6021348c05484da9a47022e995b", + "bytes": 1871 + }, + { + "path": "docs/RELEASE_NOTES_0.10.2.md", + "sha256": "5b10081a98fab0a0394f5216beb3c2e81d9451796d1c325965e984a4edcef44c", + "bytes": 586 + }, + { + "path": "docs/RELEASE_NOTES_0.10.3.md", + "sha256": "53e4c0d64342715a981ba0f695fa2a20cac22a33839db808816dbfa1a1e0296a", + "bytes": 1182 + }, + { + "path": "docs/RELEASE_NOTES_0.10.4.md", + "sha256": "a26021f356a0b78e4393e14cf5f2dd5752316698ac926c67b7978ebe8859c6b5", + "bytes": 783 + }, + { + "path": "docs/RELEASE_NOTES_0.10.5.md", + "sha256": "cd3f7bd1236013ba9fe0afe0ad8758fbbb9beddaf51872936b2c07323cd65eca", + "bytes": 894 + }, + { + "path": "docs/RELEASE_NOTES_0.10.6.md", + "sha256": "f1bbcb3a61c7b3ef255e921ef016d85f11b1eb2a1dcc7ad7bb8a738efe6e213f", + "bytes": 781 + }, + { + "path": "docs/RELEASE_NOTES_0.10.7.md", + "sha256": "a3b08b9680c5db304c05a0880ac1bb987b02dc20700e4ae61b0e4ebb71d63cfd", + "bytes": 913 + }, + { + "path": "docs/RELEASE_NOTES_0.10.8.md", + "sha256": "3b68776b94c73b72fd069fee7792680bd58523b8a5f34de74216c6255a9fdfdf", + "bytes": 651 + }, + { + "path": "docs/RELEASE_NOTES_0.10.9.md", + "sha256": "753e0b0f2c00a6c8ec687294625731d6c4f158509ac76bd08005fff626943bee", + "bytes": 971 + }, + { + "path": "docs/RELEASE_NOTES_0.2.0.md", + "sha256": "532414fab6a23780dd1fdf9905846582905de9ad2f978542a8376915e73bae9b", + "bytes": 1802 + }, + { + "path": "docs/RELEASE_NOTES_0.3.0.md", + "sha256": "9a79ba9626b852be368afdb687b39e5c12b4674dd716e565c1a3776acd365ef7", + "bytes": 6103 + }, + { + "path": "docs/RELEASE_NOTES_0.3.1.md", + "sha256": "cc9eed982cf7c99af2ccbb8e0dd9fd61f0d494603fc2fe7d14a3288aa4ea0d76", + "bytes": 1118 + }, + { + "path": "docs/RELEASE_NOTES_0.3.2.md", + "sha256": "8498aeb28590640608191add4dcc11f58adcd8d0caafc947cf98019ee557cd42", + "bytes": 2527 + }, + { + "path": "docs/RELEASE_NOTES_0.4.0.md", + "sha256": "48aec5e22fd5392114b8a862f9d2b42c0a891a3af0d935b765706bf21f694b64", + "bytes": 2211 + }, + { + "path": "docs/RELEASE_NOTES_0.4.1.md", + "sha256": "e583003bfcf6563e13855ad1c9eb6ff55f6bb1a618982fb0d7336fe6bf8de22a", + "bytes": 1160 + }, + { + "path": "docs/RELEASE_NOTES_0.4.2.md", + "sha256": "98c881fe56ca1fea2e4a6cc22926fb5345629677a525c87167f835b4d5184bab", + "bytes": 1634 + }, + { + "path": "docs/RELEASE_NOTES_0.4.3.md", + "sha256": "c4891e1c31aeee208855d496053a373ed9cbfd4d8353cb3ef5e4ba28dcd70c95", + "bytes": 679 + }, + { + "path": "docs/RELEASE_NOTES_0.4.4.md", + "sha256": "dc6402c30f48b57d000a207779e659f45319d890aceb2ec34b56c79668b10c6f", + "bytes": 626 + }, + { + "path": "docs/RELEASE_NOTES_0.4.5.md", + "sha256": "6b326bff00dada52d678c1d9da893227f27df2c5835349387477ac41151dbf4c", + "bytes": 386 + }, + { + "path": "docs/RELEASE_NOTES_0.5.0.md", + "sha256": "dcf07ac5b9ef7f08ef3c16631a9af7196c4e7589559eeb3512adfc8b354628aa", + "bytes": 1111 + }, + { + "path": "docs/RELEASE_NOTES_0.5.1.md", + "sha256": "1d89630a53d0d598b4fbf245be46773cef79eff58b3bdca2b79ae9975957464b", + "bytes": 973 + }, + { + "path": "docs/RELEASE_NOTES_0.5.2.md", + "sha256": "2a4268b28bb0ae3dea0b647d1ddada6406f7cccab12844e93d2306e5d5a76c81", + "bytes": 732 + }, + { + "path": "docs/RELEASE_NOTES_0.5.3.md", + "sha256": "319c39f7499e96513031e419501a0b01a4379b06e2b172d0e7d85876509bda9c", + "bytes": 1050 + }, + { + "path": "docs/RELEASE_NOTES_0.5.4.md", + "sha256": "aa00a9f187987419aedefa2c8667f664b4ad0123b3e7e205288d73b5b4f0b4e1", + "bytes": 720 + }, + { + "path": "docs/RELEASE_NOTES_0.6.0.md", + "sha256": "1ecca96cf8a6f01d7ead37d5a6b678549c2561bfd84011b6149f718a25971661", + "bytes": 4936 + }, + { + "path": "docs/RELEASE_NOTES_0.6.1.md", + "sha256": "23150c58e6416d48c2ed6e378fff99179ed810b766ed50b70d4d829c6774b8ef", + "bytes": 685 + }, + { + "path": "docs/RELEASE_NOTES_0.7.0.md", + "sha256": "d4e43b10b64e1c04a0e444ed39bdfcd05869cf5a5d8db5caa8583578ed2c3e9a", + "bytes": 2618 + }, + { + "path": "docs/RELEASE_NOTES_0.8.0.md", + "sha256": "9afcf47ae2feea72e04cabc08e2a5c058891c04e4a99a7f014d6e9b791d52f3d", + "bytes": 586 + }, + { + "path": "docs/RELEASE_NOTES_0.8.1.md", + "sha256": "674fc8b6c656bd2bfba583388d50e02d0a5c38db4c8396f78d217f4a8b58759c", + "bytes": 767 + }, + { + "path": "docs/RELEASE_NOTES_0.8.2.md", + "sha256": "65885a64005125a24e9d25b70f01dab6d97cdfc8ce017ea0850e913164a5b07b", + "bytes": 740 + }, + { + "path": "docs/RELEASE_NOTES_0.8.3.md", + "sha256": "0f7b3078069a9d7616690fc9aae89fcfa3916eb9b952b24b39ab2ff7bedb6879", + "bytes": 667 + }, + { + "path": "docs/RELEASE_NOTES_0.8.4.md", + "sha256": "9cb2b202fff4de466cc0bcf1945dcfacbda698a0306e293bd97c1a16ab6b9180", + "bytes": 515 + }, + { + "path": "docs/RELEASE_NOTES_0.8.5.md", + "sha256": "82059b894fee48bf9726e9e65c335e221f0c0ccc03a0d27720e918633fbc6421", + "bytes": 549 + }, + { + "path": "docs/RELEASE_NOTES_0.8.6.md", + "sha256": "a4997e09ff6b3ce5a1754ece03ab45fb9da8bfaa61e5c3d970be7aa953f13b99", + "bytes": 752 + }, + { + "path": "docs/RELEASE_NOTES_0.8.7.md", + "sha256": "f28b4e027a6417ada385070eff7436bdbc147f80229c65b1306cf8402f622675", + "bytes": 1207 + }, + { + "path": "docs/RELEASE_NOTES_0.8.8.md", + "sha256": "6b4c9bafcf50917129c64aba13491fb263116ef1a7e9393ed2b951671cecab9c", + "bytes": 645 + }, + { + "path": "docs/RELEASE_NOTES_0.8.9.md", + "sha256": "3a007e5d2081d33769f217f44de3eb4b5b720b564d646a8655910956bfc16f6a", + "bytes": 1066 + }, + { + "path": "docs/RELEASE_NOTES_0.9.0.md", + "sha256": "4e650b94caecc137735a7b27f8a17a907dfe3a5b9a3f9c7541dd6912fc1092d1", + "bytes": 3144 + }, + { + "path": "docs/RELEASE_NOTES_0.9.1.md", + "sha256": "318e665005a8246e49ed74678c09374f03148c96949f8cdce5dfac773f616959", + "bytes": 730 + }, + { + "path": "docs/RELEASE_NOTES_0.9.2.md", + "sha256": "6ec7ddf1290c63bdb1da3dd1f64381f0c92db7d560460f3264ed8f1f23a15f61", + "bytes": 837 + }, + { + "path": "docs/RELEASE_NOTES_0.9.3.md", + "sha256": "1b9f884aeaa030388b2a55b50daaa3dcc525b6771ba50b84908cb25a5437c58b", + "bytes": 2207 + }, + { + "path": "docs/RELEASE_NOTES_0.9.4.md", + "sha256": "bc3fa8731534e0b4f792ee2159bf7cd7d81bbbbe214caf269c5e6fc93f49152c", + "bytes": 940 + }, + { + "path": "docs/RELEASE_NOTES_0.9.5.md", + "sha256": "9c0256fc09525dc63650c1d1cb3afce8a2742a2e033a471595e5fb734bab40b8", + "bytes": 2120 + }, + { + "path": "docs/RELEASING.md", + "sha256": "96272da7be735148e8971717f198eea2819aac915105af1533573156aa9d6b57", + "bytes": 2298 + }, + { + "path": "docs/ROADMAP.md", + "sha256": "0e44c88c3a1bbb6899864132f55b02770e5ec53cd380eb4319648e25b774e8e0", + "bytes": 4301 + }, + { + "path": "docs/SECURITY.md", + "sha256": "11a7bafaa63c7746a938a247b9315e6f54c083ef219504c88d3658cc1b86e98c", + "bytes": 6173 + }, + { + "path": "docs/SETUP_GUIDE.md", + "sha256": "32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03", + "bytes": 13552 + }, + { + "path": "docs/SSH_UNRAID_DEPLOYMENT.md", + "sha256": "080438fd3e5cb17a46baee6f0aa51fc95ca4ba37c55905bdf60fb7cd44fd1ba5", + "bytes": 4494 + }, + { + "path": "docs/STATUS_ENDPOINT.md", + "sha256": "b1c3f399bddfc488060a88b58043a3faaa2ced67237ad21391efaf6af66cddb1", + "bytes": 2206 + }, + { + "path": "docs/STITCH_REVIEW.md", + "sha256": "baa7b621b95a9ad6ca44d7b1ec0bc80f400068e93716a525470b5121f380cb19", + "bytes": 3238 + }, + { + "path": "docs/TEST_MATRIX.md", + "sha256": "3610e0981257c5dd2455859902b20e7aa4568c3c93a1dfa6475be6032b564cf2", + "bytes": 6703 + }, + { + "path": "docs/UPDATING.md", + "sha256": "85c74adb8c7d4356b6f696013d911ba9041b8d8c4b8900dcb4b491115ba1fbc0", + "bytes": 4512 + }, + { + "path": "docs/screenshots/deploy-confirmation.png", + "sha256": "1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3", + "bytes": 140415 + }, + { + "path": "docs/screenshots/deployment-run.png", + "sha256": "b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa", + "bytes": 107166 + }, + { + "path": "docs/screenshots/deployment-success.png", + "sha256": "070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e", + "bytes": 118819 + }, + { + "path": "docs/screenshots/deployments.png", + "sha256": "ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f", + "bytes": 95937 + }, + { + "path": "docs/screenshots/git-validator.png", + "sha256": "3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8", + "bytes": 103569 + }, + { + "path": "docs/screenshots/overview.png", + "sha256": "007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda", + "bytes": 85338 + }, + { + "path": "docs/screenshots/repository-workspace.png", + "sha256": "1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536", + "bytes": 112852 + }, + { + "path": "eslint.config.js", + "sha256": "af13cb6d12f31904875cd5bf242a64bd92e15e81fe432fd423802db0f2040394", + "bytes": 2772 + }, + { + "path": "examples/gitea-actions/deploy.yml", + "sha256": "a24c5ce4c7147d286ef26258cb64a9f96a54c6d901e7dd5f925e48779df9053d", + "bytes": 1583 + }, + { + "path": "examples/gitea-actions/forgeflow-approved-deploy.yml", + "sha256": "5d2577d4f9f635a12dcc8795879c079b9e66630bb3d96e21510ef7ee13ebef05", + "bytes": 3319 + }, + { + "path": "examples/gitea-actions/rollback.yml", + "sha256": "aa016403cc795880e29d933b60b52192bc73ad8e1d1eb20a93cbe11b4808b3cf", + "bytes": 1527 + }, + { + "path": "examples/server/forgeflow-deploy", + "sha256": "4a84041fcf2d7f36d1807e2c19d6cab816f9635112756a675f32cd24d9757fbb", + "bytes": 9661 + }, + { + "path": "examples/server/forgeflow-runner.sudoers", + "sha256": "13f5b67c8896d9ae3437bae0c9542be9084347b28ee84b59ab1a608263828cba", + "bytes": 262 + }, + { + "path": "examples/server/forgeflow-targets.conf", + "sha256": "9e3dd8267eccebfc02583519ae2ef56b81e781b386d1b92b497d5cfa061be4a7", + "bytes": 578 + }, + { + "path": "examples/server/nginx-forgeflow-status.conf", + "sha256": "afb0480c781c800bf20834d91e36d60dcce520ab90c91c687745abe96bf87e3a", + "bytes": 405 + }, + { + "path": "examples/server/status-example.json", + "sha256": "a0de3fe4e09b6f246e1513bccc170334e60f63f98c24377192b4335cbf16dff4", + "bytes": 593 + }, + { + "path": "main.cjs", + "sha256": "b3d22a6d3222a02144c0ce147a93fa2ebd77c14b1b260503f338284cf9d9d107", + "bytes": 15178 + }, + { + "path": "package-lock.json", + "sha256": "63d403ada205000a0dfb158ec57cde1a4f58572d790589428ab5b00a99b32cbe", + "bytes": 184860 + }, + { + "path": "package.json", + "sha256": "12804991d4f9f967a82e5afb9779375a2978db0402906ef4682ae640d4326ed9", + "bytes": 6465 + }, + { + "path": "playwright.config.mjs", + "sha256": "c36f7f245023e31abe058ece7cc1d6899b83a6cd2c8e42c67fa396fb1085fc12", + "bytes": 1395 + }, + { + "path": "preload.cjs", + "sha256": "3020fb7661321b468a2d4532cb43ef50ec6a38cf973db453ec0c662f823c1da6", + "bytes": 12571 + }, + { + "path": "scripts/acceptance.mjs", + "sha256": "509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd", + "bytes": 6272 + }, + { + "path": "scripts/apply-binary-update.ps1", + "sha256": "5f220dc8ee24d2339aa3eb696ac7a5bd6f55c993b5fd9001ec9784788f2a6e46", + "bytes": 6747 + }, + { + "path": "scripts/apply-source-update.ps1", + "sha256": "358d0ecbd50d8ba1ff9460c761cc2a1990fb27104d6ad74104a8800877343e19", + "bytes": 10954 + }, + { + "path": "scripts/architecture-audit.mjs", + "sha256": "02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88", + "bytes": 4145 + }, + { + "path": "scripts/audit-installed-deployments.cjs", + "sha256": "47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9", + "bytes": 8985 + }, + { + "path": "scripts/doctor.mjs", + "sha256": "6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126", + "bytes": 3893 + }, + { + "path": "scripts/generate-source-manifest.mjs", + "sha256": "7b483476ddd909b085335cb78c9b0ffe71939c50011b5fbfcfdef6a340fa7ce8", + "bytes": 2032 + }, + { + "path": "scripts/prune-dist.mjs", + "sha256": "842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9", + "bytes": 1266 + }, + { + "path": "scripts/publish-binary-release.cjs", + "sha256": "19417a26a5af967b0f057fede45d1811a6d8ad65a0ed49ad4f5865f598457168", + "bytes": 9358 + }, + { + "path": "scripts/serve-demo.mjs", + "sha256": "558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3", + "bytes": 1694 + }, + { + "path": "scripts/setup-update-signing-key.mjs", + "sha256": "288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d", + "bytes": 1713 + }, + { + "path": "scripts/sign-release-manifest.mjs", + "sha256": "431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d", + "bytes": 2607 + }, + { + "path": "scripts/test-authenticode-chain.ps1", + "sha256": "c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6", + "bytes": 5246 + }, + { + "path": "scripts/validate-installed-connections.cjs", + "sha256": "4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5", + "bytes": 3109 + }, + { + "path": "scripts/verify-release-signatures.mjs", + "sha256": "e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac", + "bytes": 2411 + }, + { + "path": "scripts/verify.mjs", + "sha256": "41581e5c70e079775125e62509312f2c334b960590fc5bad4be71931c6126fcd", + "bytes": 22915 + }, + { + "path": "scripts/write-release-checksums.mjs", + "sha256": "c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613", + "bytes": 2434 + }, + { + "path": "setup-windows.ps1", + "sha256": "2785092555fad70fe679d1d15c8bee735705676c737a1ffcfc022d8d01999902", + "bytes": 2128 + }, + { + "path": "src/main/audit-service.cjs", + "sha256": "fd5c733f9faebd26f5fb14f38bef99b03cc71452bb8ede7d184c8eef0c0bdf78", + "bytes": 2469 + }, + { + "path": "src/main/config-store.cjs", + "sha256": "20566e02b0c9d85281f634e0ad7fa8ed35a29cf72996474f6b780247ca024ec7", + "bytes": 35332 + }, + { + "path": "src/main/configuration-backup.cjs", + "sha256": "9223757fcbf46fab6e4dbd60333bed78b771c25f3fe4824cf80850a46ba43b8d", + "bytes": 2793 + }, + { + "path": "src/main/deploy-key-lifecycle-service.cjs", + "sha256": "1c74a88f5ee97063915eccccce854c7d6433f38800ff99fda732a092c649bd16", + "bytes": 14788 + }, + { + "path": "src/main/deployment-identity.cjs", + "sha256": "b18f5cddbcd3eb1448ae227bddca7bd2d0866c3e309d852fb0ce45f0815eac3d", + "bytes": 2194 + }, + { + "path": "src/main/deployment-service.cjs", + "sha256": "a0284c366693b42d373794e55eec83bf5f6c500b95091b2796a283c1e69fdcdc", + "bytes": 24135 + }, + { + "path": "src/main/diagnostics-service.cjs", + "sha256": "774a6f4370e75bf086e04e7b4b7961463d97d798c2512525458dbaaa5c008140", + "bytes": 16860 + }, + { + "path": "src/main/external-tools-service.cjs", + "sha256": "5d70b96d045820891d3a33f2fc5d54b0d4730bdcf82cc0ab32149b4d3328eb64", + "bytes": 2448 + }, + { + "path": "src/main/git-service.cjs", + "sha256": "76fdc5576dcd6fdb88921009d4a923854650bd5efe5b837cd7438eba0dc733c8", + "bytes": 48002 + }, + { + "path": "src/main/git-validator-policy.cjs", + "sha256": "871553beef7613d30493a9af9fdcac4d3305c0d89ae96cff8050f310d4865ff0", + "bytes": 7340 + }, + { + "path": "src/main/git-validator-service.cjs", + "sha256": "e4384b175d2ebff809581861a8b7a950c2bf2780118f3610ca5f9ec673fbb617", + "bytes": 27727 + }, + { + "path": "src/main/gitea-service.cjs", + "sha256": "0eecfff92532523990bf4a4bf2fdd6f9f1150d9c453706cdadf003954c90a162", + "bytes": 21930 + }, + { + "path": "src/main/inventory-classifier.cjs", + "sha256": "2246cd52935cc4b5546a1412f1aa2e33be99da4891be588da95644cc6d1ee8f0", + "bytes": 6997 + }, + { + "path": "src/main/inventory-review-service.cjs", + "sha256": "180f142fcdf41c7c458bbf541a0c2c6e337d99555591593732bc22272b36c43c", + "bytes": 3435 + }, + { + "path": "src/main/ipc.cjs", + "sha256": "d618f9cd1625e291d1f48cad665b38cd5ef322214a1d293ab8c28dd1162d688e", + "bytes": 26441 + }, + { + "path": "src/main/ipc/channel.cjs", + "sha256": "619e89f4489115f60d7cf858e3dac6c6b3e562ca7439e2398e66ca90fadb46d1", + "bytes": 2327 + }, + { + "path": "src/main/ipc/deployment-handlers.cjs", + "sha256": "a2fb14037189bdad1f2913a713d04f8316d5ee60942dd0982f026cff9aa3ac8c", + "bytes": 12751 + }, + { + "path": "src/main/ipc/operations-handlers.cjs", + "sha256": "c38777b4fe0711a404003765ec4f4032f353d4c01622fa17752b96f5f41ca458", + "bytes": 3542 + }, + { + "path": "src/main/ipc/repository-handlers.cjs", + "sha256": "64c6dffcb232c547976a5ed8c98db4b5225eee10bdb650c8aaaace8eb3b67792", + "bytes": 17722 + }, + { + "path": "src/main/log-redaction.cjs", + "sha256": "0ae045dab3165a05a518e9a0aca4aba972de3da3be301f219512c7d198e59008", + "bytes": 5062 + }, + { + "path": "src/main/preflight-service.cjs", + "sha256": "f819385c391b24dfb173d0b9befe82edcc1fdd26492a6fab7b168d03462a4daa", + "bytes": 13117 + }, + { + "path": "src/main/process-error-policy.cjs", + "sha256": "31e449c00daf48639fdf3effddee52e5fc5f8ee4dc27c57cfe9212e6418535f5", + "bytes": 706 + }, + { + "path": "src/main/process-runner.cjs", + "sha256": "3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0", + "bytes": 1570 + }, + { + "path": "src/main/production-acceptance-harness.cjs", + "sha256": "bc9fbb70cc5cc51cbf9115c610fadcb61e83316a69ae3862bbc2f013353c1e4b", + "bytes": 10075 + }, + { + "path": "src/main/repository-monitor.cjs", + "sha256": "538ec017ee855b6585f1fb4efb88d828500b75545fccaacf66160a3d76d8a63e", + "bytes": 8945 + }, + { + "path": "src/main/repository-service.cjs", + "sha256": "a17983bf15aa0daa96bdc692e136386b3cae79b878e76694f19eab9e8aea2ace", + "bytes": 12457 + }, + { + "path": "src/main/server-inventory.cjs", + "sha256": "a80440baafc3655dca2c6b5a5726e80368ac686015c7c184c5cc0353e807376c", + "bytes": 28505 + }, + { + "path": "src/main/ssh-service.cjs", + "sha256": "b344669439fbc6ccadf77277119efb10f200fccf2c7ea417e2aae83456acae4d", + "bytes": 22859 + }, + { + "path": "src/main/unraid-access-methods.cjs", + "sha256": "79e96d402d8e7fddef722ebb9e528ac4a031d0286a994c0a2d3b8f21904ba0d7", + "bytes": 26362 + }, + { + "path": "src/main/unraid-deploy-key-host.cjs", + "sha256": "ee87b750d89f8df9ea4d8d7edb852b4681b3f826263bc8360d06a1e1f80f82e6", + "bytes": 9880 + }, + { + "path": "src/main/unraid-deployment-methods.cjs", + "sha256": "518c3f9d017923407b9ceffb5132caf99cae2a3d5a103d21b303207893d38067", + "bytes": 31375 + }, + { + "path": "src/main/unraid-deployment-service.cjs", + "sha256": "b6cd92952b7fb2cd6f03301f78c9c6bfa962d1f0ddd56cc3931c7688acc538ad", + "bytes": 17732 + }, + { + "path": "src/main/unraid-inventory-methods.cjs", + "sha256": "d2d76767c05bbd604e8e557e1ba06ceba12305096fa73c8a9edbe439f2eb8fc2", + "bytes": 43475 + }, + { + "path": "src/main/unraid-preflight-methods.cjs", + "sha256": "aa2b8681d72dc9c232ef9436854da9967ebef35d6a71c66ff6f9a566ad031c06", + "bytes": 28229 + }, + { + "path": "src/main/unraid-runtime-methods.cjs", + "sha256": "78fdb88c0ab376233c114b116559af26c47d83fced47c7a0a0b7248a0ecff8e2", + "bytes": 16946 + }, + { + "path": "src/main/unraid-state-methods.cjs", + "sha256": "a3d3c0327f9120ca956735bf4e63a61e0b48e5b120a2b9fbe7b47d8bcf69b286", + "bytes": 11901 + }, + { + "path": "src/main/update-service.cjs", + "sha256": "4c1c892391a418b5605bf3e0f9de26c29ce5c907236d0c9afdabcef136dc73f8", + "bytes": 28075 + }, + { + "path": "src/renderer/actions/command.js", + "sha256": "856119ff96dd343f5460eb830a586684c1dda7468ddaec858d38d960614f1a42", + "bytes": 1007 + }, + { + "path": "src/renderer/actions/deployment-operation.js", + "sha256": "6ea98804892017a27450047c6fc5a6193cd98b6f56ded9880eb02c06393683a1", + "bytes": 6889 + }, + { + "path": "src/renderer/actions/deployment-profile.js", + "sha256": "d58d8fed4128d6c512fc1c6cac42dfdf5878380cabab9f068abc371d29036d27", + "bytes": 18385 + }, + { + "path": "src/renderer/actions/inventory.js", + "sha256": "3efbd97d65ecd74aa30c7ab51c7ddd3da71c3b9f6296597a51e3d921cf7930e1", + "bytes": 7970 + }, + { + "path": "src/renderer/actions/recovery.js", + "sha256": "9e8adf1ba89ffc61a7b595f813c784688bdf50daa259204d74b2cdaa81650895", + "bytes": 15493 + }, + { + "path": "src/renderer/actions/setup-and-settings.js", + "sha256": "6c26c3604344230df8ee13129526a271b76db48809d02d4e9ff1e3db08dbe2ee", + "bytes": 16720 + }, + { + "path": "src/renderer/actions/shell.js", + "sha256": "058722de35ba33bfcfd29d355a75e1513a2be80c572773472cf9816dd13f894a", + "bytes": 20148 + }, + { + "path": "src/renderer/app.js", + "sha256": "0d48993bc26ae28bdab5fbfa8d9be4ef896efdb9a5c34094087721e4274593b4", + "bytes": 27566 + }, + { + "path": "src/renderer/assets/itworx-mark.png", + "sha256": "16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84", + "bytes": 85704 + }, + { + "path": "src/renderer/assets/itworx-wordmark-dark.png", + "sha256": "813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d", + "bytes": 82476 + }, + { + "path": "src/renderer/assets/itworx-wordmark-light.png", + "sha256": "094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988", + "bytes": 75240 + }, + { + "path": "src/renderer/assets/itworx-wordmark.png", + "sha256": "813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d", + "bytes": 82476 + }, + { + "path": "src/renderer/dialogs.js", + "sha256": "86a56bb0d1922081797ce624bf62550f99f64c7ebd5582f5799f1b5431eb3393", + "bytes": 55636 + }, + { + "path": "src/renderer/diff-view.js", + "sha256": "196ee9c174de6afaae524d6e1bc7c5fccc8165e653adec44b12627f3be2af42b", + "bytes": 2772 + }, + { + "path": "src/renderer/events.js", + "sha256": "b7698de13b872aa80d27b0a4d977c12ca2303b2246f05e6af4223db9b727e525", + "bytes": 7433 + }, + { + "path": "src/renderer/index.html", + "sha256": "4eae9d462d04b6c65399a13ed679fccd6fc0469b2da97ff6861b932486319621", + "bytes": 1561 + }, + { + "path": "src/renderer/mock-bridge.js", + "sha256": "f6db7ee9713b684b412f4800b4282ab5ee77fb02f8c5531f6daad20d43cb235d", + "bytes": 21944 + }, + { + "path": "src/renderer/mock-deployment-bridge.js", + "sha256": "9853a4b17e000efa81d76596d7fbdc7277103811302061fae7a0caf6085a8a0d", + "bytes": 29408 + }, + { + "path": "src/renderer/mock-repository-bridge.js", + "sha256": "4a314d7a0fa00d84c8431ade6d598084ac0fd6ded518f035029f230f6f03a1c1", + "bytes": 25815 + }, + { + "path": "src/renderer/operations.js", + "sha256": "297dcb573c61f553c82fb5fad9dae82bc6f1fe958b2b026a49f1dee0fc7600c0", + "bytes": 6733 + }, + { + "path": "src/renderer/styles.css", + "sha256": "9299c83e43eef194bac2946c43b2ffda6309ccbdcc5b9ce1775e668ae7f172ef", + "bytes": 92319 + }, + { + "path": "src/renderer/views.js", + "sha256": "75fd8896f7d4ccf849a57647615ecd4e5b7fbb5c328afec38c82467bd82a94bc", + "bytes": 113810 + }, + { + "path": "src/shared/clone-target.cjs", + "sha256": "e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b", + "bytes": 1411 + }, + { + "path": "src/shared/deployment-policy.cjs", + "sha256": "7d194738a75be91182d558dfdd22fdc66e8ca9713dcc9622605b9c49c59eaa90", + "bytes": 2541 + }, + { + "path": "src/shared/git-status.cjs", + "sha256": "bc13c4d35579e2c64dfd8c0245c48ca4f8bfc8bd1a80210821f4366b799764a9", + "bytes": 3150 + }, + { + "path": "src/shared/repository-match.cjs", + "sha256": "98bf82663ecd159c92eb3d4a9d05996797103e620cd9017f9ccee5c614c35ff7", + "bytes": 1334 + }, + { + "path": "src/shared/semver.cjs", + "sha256": "c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338", + "bytes": 1152 + }, + { + "path": "src/shared/shell-verification.cjs", + "sha256": "a31b275114a2ac376f3f8c69f4328286219767d22024ddeb01070550ad62109f", + "bytes": 3189 + }, + { + "path": "src/shared/tool-invocation.cjs", + "sha256": "f6acb9c9a3cb9ca771d9cfd133babd8132445322644fc23082313dd1ac1ab2b2", + "bytes": 1252 + }, + { + "path": "src/shared/validation.cjs", + "sha256": "f2bd787532454b7f52a19d4161458ced05b3496ceb37ef3db4db84a67a99393e", + "bytes": 5832 + }, + { + "path": "src/shared/zip-writer.cjs", + "sha256": "cfa9298d5ab390f5f1ad8988c73a157e8bee1c2854afc58c4fa0941cfe1da871", + "bytes": 3187 + }, + { + "path": "tests/acceptance.test.mjs", + "sha256": "1efc658df6e29f5db5aefd3c515115357d33fe7d920e37e1a99a118251babeee", + "bytes": 1020 + }, + { + "path": "tests/approved-deployment-evidence.test.mjs", + "sha256": "2f2b21754dccd8b734d6c7bd4fdd655df79c7739a1de140e562520c8234aa7a2", + "bytes": 2854 + }, + { + "path": "tests/approved-deployment-one-shot.test.mjs", + "sha256": "720ff5b549a3dd70854eb1bac3589c77a2019a61148be8e41e112196c8821ee3", + "bytes": 1079 + }, + { + "path": "tests/audit-service.test.mjs", + "sha256": "17ce23545d113267f414173ccd8cbbcc8e6eaeac4a831c5d53f37d7946775905", + "bytes": 1180 + }, + { + "path": "tests/browser/forgeflow.spec.mjs", + "sha256": "6e119cda76b2ee36b93623d98f41371798227f9b3f7c20fbef035a7d7a50cc95", + "bytes": 19402 + }, + { + "path": "tests/clone-target.test.mjs", + "sha256": "1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af", + "bytes": 5978 + }, + { + "path": "tests/config-store.test.mjs", + "sha256": "912c3daae8d3a3714a6ae8b4b5f387b24efcdfc541276fe4d898c019355e161b", + "bytes": 18600 + }, + { + "path": "tests/configuration-backup.test.mjs", + "sha256": "afc181d00bd191f3e8d8074c9fa382d29ddcc1d4bf9186f8f6395b93e3725f14", + "bytes": 2547 + }, + { + "path": "tests/dependency-wiring.test.mjs", + "sha256": "a460d1ebb731c89ef0be9a4679604c73d0151e44e24da94788c132c3b298bff5", + "bytes": 8294 + }, + { + "path": "tests/deploy-key-host.test.mjs", + "sha256": "aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2", + "bytes": 11130 + }, + { + "path": "tests/deploy-key-lifecycle.test.mjs", + "sha256": "636c62293a576e62fbeae3adca3ad6e1345e68da0db555dd3bfde3a532066bc3", + "bytes": 9493 + }, + { + "path": "tests/deployment-operations.test.mjs", + "sha256": "49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766", + "bytes": 24875 + }, + { + "path": "tests/deployment-policy.test.mjs", + "sha256": "41589cf470702c7a5af966a096be7ec469c8cfb6133957ec4f4d7ecba395a152", + "bytes": 1967 + }, + { + "path": "tests/deployment-status.test.mjs", + "sha256": "e8724cf72de796b763acff18aa01ea95a4ff5b2de211246ec89fb8c67d079e54", + "bytes": 9823 + }, + { + "path": "tests/diagnostics.test.mjs", + "sha256": "34d824c1bee1b1756c279c938ece61a5b7e4b0fd796ba4c0a594d131e2f1ef0e", + "bytes": 4142 + }, + { + "path": "tests/external-tools.test.mjs", + "sha256": "9f862a67d93edf4adc7c8b04040f9ea56394b56445a70cfd658b8def6cd336d1", + "bytes": 952 + }, + { + "path": "tests/git-integration.test.mjs", + "sha256": "26e94450c6ab1dd0d5149d6812e66814a719f80d6f95fcb3a9dee7a3d7f46293", + "bytes": 20559 + }, + { + "path": "tests/git-status.test.mjs", + "sha256": "cd294dcaf050d639472cb1f9c1a48484fbc74271e527bf9d8153b00e903e2101", + "bytes": 1273 + }, + { + "path": "tests/git-validator-policy.test.mjs", + "sha256": "8ac410d7173bfc3e4b967138d2d6343fb1796b2cbde4fb538b8a38b801377d24", + "bytes": 4350 + }, + { + "path": "tests/git-validator.test.mjs", + "sha256": "8950162b8ab1b644bca230cf9871f64e530e6d612a9d89a942c3e49b686fce30", + "bytes": 6239 + }, + { + "path": "tests/git-workflows.test.mjs", + "sha256": "eeb7ebc5b2deb882590f8abca3ce76a6e1f9ee26e75d37d3ed1d753790785973", + "bytes": 2449 + }, + { + "path": "tests/gitea-actions.test.mjs", + "sha256": "4e9391976989b302d626fb820738d49622e3bc20ad46441685015addc180009f", + "bytes": 19052 + }, + { + "path": "tests/inventory-classifier.test.mjs", + "sha256": "f7d9e24146ac87a180fd60e0a2514c971eaaac9001014f7d63336281aae7403e", + "bytes": 9759 + }, + { + "path": "tests/ipc-contract.test.mjs", + "sha256": "b73b49e177e0436988d4d38132ea008a35290187ee33deb289b12b621923caa2", + "bytes": 2074 + }, + { + "path": "tests/log-redaction.test.mjs", + "sha256": "c6614369c43ba42531fea6d365187d1a579018374b2fd5c317b4cbc902d1cf61", + "bytes": 3067 + }, + { + "path": "tests/partial-staging.test.mjs", + "sha256": "107d5eb14d1733709f05e817ef303b4c592470b6f9fd057fa804c9a1e2a10a7c", + "bytes": 2331 + }, + { + "path": "tests/preflight.test.mjs", + "sha256": "d5433897838ea42146cfc15e140e55d1f254e72186a0a5f50d2d43d50490f33c", + "bytes": 12004 + }, + { + "path": "tests/process-error-policy.test.mjs", + "sha256": "070f2857e70d2b36310f2636582ef0eec49f398b3f009bd3742c6c9e676ba508", + "bytes": 1231 + }, + { + "path": "tests/production-acceptance.test.mjs", + "sha256": "0abe5fcea0ab8769188f2131e99301979d4f4983cf9924266291bc716c9e5d33", + "bytes": 6564 + }, + { + "path": "tests/renderer-workflow.test.mjs", + "sha256": "11fd2029593c0f4e5c36f1ce8572734f8ac9afead5abca7f5b619c5814b40a6c", + "bytes": 12602 + }, + { + "path": "tests/repository-matching.test.mjs", + "sha256": "af43f29c5c1dd78095a3a471b904964c613f1eb567310ffcfc432cbbc9f8f986", + "bytes": 872 + }, + { + "path": "tests/repository-monitor.test.mjs", + "sha256": "4defa3c5f21db7fefbd79397c96b6c231a4330df60b54c0fea7e91412e336fd3", + "bytes": 6237 + }, + { + "path": "tests/repository-service.test.mjs", + "sha256": "d4d2f9e2f6e7c672273a4126966f739c10d584d9e976d8630fe4b988c02dbb47", + "bytes": 13784 + }, + { + "path": "tests/security-validation.test.mjs", + "sha256": "1e4807cbcb19f4dd7787dc1779e3b60a6462989b13bdf482b38a489a756b0b5e", + "bytes": 3639 + }, + { + "path": "tests/semver.test.mjs", + "sha256": "bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024", + "bytes": 627 + }, + { + "path": "tests/server-inventory-branches.test.mjs", + "sha256": "76ec40ba206203c89ee41212b75f53159a86ef94af9c64ff3fa204b160cb37eb", + "bytes": 11936 + }, + { + "path": "tests/shell-verification.test.mjs", + "sha256": "5df7f331cc1120a0914f5ddbf39d9a6b83bd5ada70a9953b3f6bd0701ba2485e", + "bytes": 4394 + }, + { + "path": "tests/ssh-connection-pool.test.mjs", + "sha256": "977fc36c38a3af16558fb2c3b2b38a9466aaf08b0e7234d4cab354dee181b4b0", + "bytes": 9345 + }, + { + "path": "tests/ssh-connection.test.mjs", + "sha256": "0149be4165bd5379c8324813cfe536f1620a5c324bead43dd0f463e63bb84d08", + "bytes": 4183 + }, + { + "path": "tests/ssh-service.test.mjs", + "sha256": "259f4952baea6e4631f1670c2dfc1cf2e2b4c4ec670a691be922a3784c294491", + "bytes": 9667 + }, + { + "path": "tests/tool-invocation.test.mjs", + "sha256": "c61d8ef97ec7f63f3ba0f7bbda11ba1d1bfbe1a5fd85caab2bfec612ad393aa0", + "bytes": 1843 + }, + { + "path": "tests/unraid-deployment.test.mjs", + "sha256": "8df71b18f53ac6fe4cb8dc11d526f66c4db374ef9f738a6d85bb5ee2de224515", + "bytes": 60887 + }, + { + "path": "tests/update-service.test.mjs", + "sha256": "34d49d3b91d087367d5129abbeb2428e8e259edfadcabf9bf68be9aa51329332", + "bytes": 31447 + }, + { + "path": "tests/validation.test.mjs", + "sha256": "9ecd063698bba4c49f308617399d6ad9ca87946f9ed885412f5a71fb0ee424bd", + "bytes": 970 + }, + { + "path": "tests/zip-writer.test.mjs", + "sha256": "b9b5ebe3ef0d15f5d6d45d2e8bc49097b59633e7684e72a1f8fad084e6010746", + "bytes": 1822 + }, + { + "path": "update-windows.ps1", + "sha256": "c2ea145fd3dc55cc82ae2c975d48e3aee1061fd184ca2a688b498a91743a8a2c", + "bytes": 1690 + } + ] +} diff --git a/PUBLISH-AND-ENABLE-UPDATE.cmd b/PUBLISH-AND-ENABLE-UPDATE.cmd new file mode 100644 index 0000000..5cdebe7 --- /dev/null +++ b/PUBLISH-AND-ENABLE-UPDATE.cmd @@ -0,0 +1,15 @@ +@echo off +setlocal +cd /d "%~dp0" +echo ForgeFlow source and binary release publisher +echo. +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Publish-ForgeFlow-Release.ps1" +set "forgeflowExitCode=%ERRORLEVEL%" +echo. +if not "%forgeflowExitCode%"=="0" ( + echo Publication failed. The existing ForgeFlow installation was not modified. +) else ( + echo Publication completed. The older ForgeFlow updater can now install this release. +) +pause +exit /b %forgeflowExitCode% diff --git a/Publish-ForgeFlow-Release.ps1 b/Publish-ForgeFlow-Release.ps1 new file mode 100644 index 0000000..4025f02 --- /dev/null +++ b/Publish-ForgeFlow-Release.ps1 @@ -0,0 +1,184 @@ +param( + [string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow-Public.git", + [string]$Branch = "main", + [string]$InstalledSource = "C:\Projects\ForgeFlow", + [string]$UserDataPath = "", + [switch]$SkipBinaryRelease +) + +$ErrorActionPreference = "Stop" +$source = $PSScriptRoot +if (-not (Test-Path -LiteralPath (Join-Path $source "PUBLIC_SOURCE_MANIFEST.json"))) { + throw "Publish only from a reviewed ForgeFlow-Public source export. The private canonical checkout must never be mirrored to a public repository." +} +$manifestPath = Join-Path $source "package.json" +if (-not (Test-Path -LiteralPath $manifestPath)) { throw "package.json was not found beside the publishing script." } +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ($manifest.name -ne "forgeflow") { throw "Run this script from an extracted ForgeFlow source release." } +$version = [string]$manifest.version +$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-publish-" + [guid]::NewGuid().ToString("N")) +$clone = Join-Path $temp "ForgeFlow" +$publishedCommit = $null + +try { + Write-Host "Validating ForgeFlow $version before publishing..." -ForegroundColor Cyan + Push-Location $source + try { + & cmd.exe /d /s /c "npm install --no-audit --no-fund" + if ($LASTEXITCODE -ne 0) { throw "npm install failed." } + if (-not (Test-Path -LiteralPath (Join-Path $source "package-lock.json"))) { throw "npm install did not create package-lock.json; publication was stopped to avoid a non-reproducible update." } + & cmd.exe /d /s /c "npm run check" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow quality gate failed." } + } finally { Pop-Location } + + New-Item -ItemType Directory -Force -Path $temp | Out-Null + Write-Host "Cloning $Remote..." -ForegroundColor Cyan + & git clone --branch $Branch --single-branch $Remote $clone + if ($LASTEXITCODE -ne 0) { throw "Could not clone the ForgeFlow update repository." } + + & robocopy.exe $source $clone /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP /XD .git node_modules dist /XF *.zip *.sha256 + if ($LASTEXITCODE -gt 7) { throw "Robocopy failed with exit code $LASTEXITCODE." } + + Push-Location $clone + try { + & git add -A + if ($LASTEXITCODE -ne 0) { throw "Could not stage the release source." } + $changes = @(& git status --porcelain) + if ($changes.Count -gt 0) { + & git commit -m "Release ForgeFlow $version" + if ($LASTEXITCODE -ne 0) { throw "Could not create the release commit." } + & git push origin $Branch + if ($LASTEXITCODE -ne 0) { throw "Could not push ForgeFlow $version to Gitea." } + } else { + Write-Host "Gitea already contains the ForgeFlow $version source; verifying the branch head." -ForegroundColor Yellow + } + + $localCommit = (& git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $localCommit -notmatch '^[0-9a-f]{40}$') { throw "Could not read the local release commit." } + $remoteLines = @(& git ls-remote origin "refs/heads/$Branch") + if ($LASTEXITCODE -ne 0 -or $remoteLines.Count -lt 1) { throw "Could not verify the Gitea release branch." } + $publishedCommit = ($remoteLines[0] -split "`t")[0].Trim() + if ($publishedCommit -ne $localCommit) { throw "Gitea did not report the exact release commit after publication." } + } finally { Pop-Location } + + Write-Host "ForgeFlow $version is available on Gitea at commit $($publishedCommit.Substring(0,7))." -ForegroundColor Green + + if (-not $SkipBinaryRelease) { + Write-Host "Building and publishing the matching Windows installer and portable release..." -ForegroundColor Cyan + Push-Location $clone + try { + & cmd.exe /d /s /c "npm ci --no-audit --no-fund" + if ($LASTEXITCODE -ne 0) { throw "npm ci failed in the exact published checkout." } + & cmd.exe /d /s /c "npm run check" + if ($LASTEXITCODE -ne 0) { throw "The exact published checkout failed the release quality gate." } + & cmd.exe /d /s /c "npm run dist:win" + if ($LASTEXITCODE -ne 0) { throw "The Windows release build failed." } + + $expectedAssets = @( + "ForgeFlow-Setup-$version-win-x64.exe", + "ForgeFlow-Setup-$version-win-x64.exe.sha256", + "ForgeFlow-Portable-$version-win-x64.exe", + "ForgeFlow-Portable-$version-win-x64.exe.sha256", + "ForgeFlow-$version-provenance.json", + "ForgeFlow-$version-sbom.cdx.json", + "ForgeFlow-$version-release-manifest.json", + "ForgeFlow-$version-release-manifest.json.sig" + ) + foreach ($assetName in $expectedAssets) { + if (-not (Test-Path -LiteralPath (Join-Path $clone "dist\$assetName"))) { + throw "The Windows build did not produce $assetName." + } + } + + $resolvedUserData = if ($UserDataPath) { $UserDataPath } else { Join-Path $env:APPDATA "forgeflow" } + $previousUserData = $env:FORGEFLOW_USER_DATA + $previousBranch = $env:FORGEFLOW_RELEASE_BRANCH + try { + $env:FORGEFLOW_USER_DATA = $resolvedUserData + $env:FORGEFLOW_RELEASE_BRANCH = $Branch + & cmd.exe /d /s /c "npm run release:binary" + if ($LASTEXITCODE -ne 0) { throw "The Gitea binary release publisher failed." } + } finally { + $env:FORGEFLOW_USER_DATA = $previousUserData + $env:FORGEFLOW_RELEASE_BRANCH = $previousBranch + } + } finally { Pop-Location } + Write-Host "ForgeFlow $version source and Windows release assets are both published." -ForegroundColor Green + } else { + Write-Host "Binary publication was skipped explicitly. Packaged ForgeFlow installations cannot auto-update until the release assets are published." -ForegroundColor Yellow + } + + $installedManifestPath = Join-Path $InstalledSource "package.json" + if (Test-Path -LiteralPath $installedManifestPath) { + try { + $installedManifest = Get-Content -LiteralPath $installedManifestPath -Raw | ConvertFrom-Json + if ($installedManifest.name -eq "forgeflow" -and [string]$installedManifest.version -ne $version) { + $helperSource = Join-Path $source "scripts\apply-source-update.ps1" + $helperTarget = Join-Path $InstalledSource "scripts\apply-source-update.ps1" + $serviceSource = Join-Path $source "src\main\update-service.cjs" + $serviceTarget = Join-Path $InstalledSource "src\main\update-service.cjs" + + $helperText = Get-Content -LiteralPath $helperSource -Raw + $serviceText = Get-Content -LiteralPath $serviceSource -Raw + if ($helperText.TrimStart() -notmatch '^param\(') { throw "The validated updater helper does not start with param(." } + if ($helperText -notmatch 'System\.IO\.File\]::Replace' -or $helperText -notmatch 'HandshakeOnly') { throw "The validated updater helper is missing the Windows status replacement fix." } + if ($serviceText -notmatch 'expectedUpdateId' -or $serviceText -notmatch 'readLogTail') { throw "The validated update service is missing the confirmed-handshake diagnostics." } + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $helperTarget) | Out-Null + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $serviceTarget) | Out-Null + Copy-Item -LiteralPath $helperSource -Destination $helperTarget -Force + Copy-Item -LiteralPath $serviceSource -Destination $serviceTarget -Force + + $copiedHelper = Get-Content -LiteralPath $helperTarget -Raw + $copiedService = Get-Content -LiteralPath $serviceTarget -Raw + if ($copiedHelper.TrimStart() -notmatch '^param\(' -or $copiedHelper -notmatch 'System\.IO\.File\]::Replace') { throw "The updater helper bootstrap copy failed validation." } + if ($copiedService -notmatch 'expectedUpdateId' -or $copiedService -notmatch 'readLogTail') { throw "The update-service bootstrap copy failed validation." } + + # Prove the exact STARTED handshake on this Windows PowerShell version before + # asking the installed ForgeFlow to close itself for a real update. + $handshakeRoot = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-handshake-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $handshakeRoot | Out-Null + try { + $handshakeId = "publisher-" + [guid]::NewGuid().ToString("N") + $handshakeStatus = Join-Path $handshakeRoot "status.json" + $handshakeLog = Join-Path $handshakeRoot "helper.log" + $dummyArchive = Join-Path $handshakeRoot "unused.zip" + $launching = @{ schemaVersion = 1; updateId = $handshakeId; state = "launching" } | ConvertTo-Json + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($handshakeStatus, $launching, $utf8NoBom) + [System.IO.File]::WriteAllBytes($dummyArchive, [byte[]](0x50,0x4b,0x03,0x04)) + + $windowsRoot = if ($env:SystemRoot) { $env:SystemRoot } else { $env:WINDIR } + $powershellExe = if ($windowsRoot) { Join-Path $windowsRoot "System32\WindowsPowerShell\v1.0\powershell.exe" } else { "powershell.exe" } + & $powershellExe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $helperTarget ` + -SourcePath $InstalledSource -ArchivePath $dummyArchive -ExpectedVersion $version ` + -ExpectedSha256 ("0" * 64) -ParentPid 2147483647 -LogPath $handshakeLog ` + -StatusPath $handshakeStatus -UpdateId $handshakeId -HandshakeOnly + if ($LASTEXITCODE -ne 0) { throw "The installed update helper failed its Windows handshake self-test with exit code $LASTEXITCODE." } + $handshakeResult = Get-Content -LiteralPath $handshakeStatus -Raw | ConvertFrom-Json + if ($handshakeResult.state -ne "started" -or $handshakeResult.updateId -ne $handshakeId) { + $tail = if (Test-Path -LiteralPath $handshakeLog) { Get-Content -LiteralPath $handshakeLog -Tail 20 | Out-String } else { "No helper log was created." } + throw "The installed update helper did not replace the launching state with the expected STARTED marker. $tail" + } + } finally { + Remove-Item -LiteralPath $handshakeRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-Host "Prepared and Windows-tested the installed ForgeFlow $($installedManifest.version) updater bootstrap without changing its version." -ForegroundColor Green + } + } catch { + throw "Release was published, but the installed updater bootstrap could not be prepared: $($_.Exception.Message)" + } + } else { + Write-Host "Installed source was not found at $InstalledSource; publication itself succeeded." -ForegroundColor Yellow + } + + if ($SkipBinaryRelease) { + Write-Host "Source publication completed. Run Publish-Missing-Binary-Release.ps1 before using the updater from an installed EXE." -ForegroundColor Yellow + } else { + Write-Host "Open the installed older ForgeFlow and use Settings -> ForgeFlow updates -> Check now." -ForegroundColor Cyan + } +} +finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/Publish-Missing-Binary-Release.ps1 b/Publish-Missing-Binary-Release.ps1 new file mode 100644 index 0000000..686e01e --- /dev/null +++ b/Publish-Missing-Binary-Release.ps1 @@ -0,0 +1,116 @@ +param( + [string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow-Public.git", + [string]$Branch = "main", + [string]$ExpectedVersion = "0.9.1", + [string]$UserDataPath = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-binary-release-" + [guid]::NewGuid().ToString("N")) +$clone = Join-Path $temp "ForgeFlow" + +function Invoke-CheckedCommand { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][scriptblock]$Action + ) + Write-Host "`n$Title" -ForegroundColor Cyan + & $Action + if ($LASTEXITCODE -ne 0) { + throw "$Title failed with exit code $LASTEXITCODE." + } +} + +try { + foreach ($command in @("git", "node", "npm")) { + if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { + throw "Required command '$command' was not found on PATH." + } + } + + New-Item -ItemType Directory -Force -Path $temp | Out-Null + Invoke-CheckedCommand "Cloning the exact published ForgeFlow source..." { + & git clone --branch $Branch --single-branch $Remote $clone + } + + $manifestPath = Join-Path $clone "package.json" + if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "The cloned repository does not contain package.json." + } + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($manifest.name -ne "forgeflow") { + throw "The cloned repository is not ForgeFlow." + } + $version = [string]$manifest.version + if ($ExpectedVersion -and $version -ne $ExpectedVersion) { + throw "Gitea branch '$Branch' contains ForgeFlow $version, not the expected $ExpectedVersion." + } + + $localCommit = (& git -C $clone rev-parse HEAD).Trim() + $remoteLines = @(& git -C $clone ls-remote origin "refs/heads/$Branch") + if ($LASTEXITCODE -ne 0 -or $remoteLines.Count -lt 1) { + throw "Could not verify origin/$Branch." + } + $remoteCommit = ($remoteLines[0] -split "`t")[0].Trim() + if ($localCommit -ne $remoteCommit) { + throw "The temporary checkout is not the current origin/$Branch commit." + } + + Push-Location $clone + try { + Invoke-CheckedCommand "Installing exact dependencies..." { + & cmd.exe /d /s /c "npm ci --no-audit --no-fund" + } + Invoke-CheckedCommand "Running the complete ForgeFlow quality gate..." { + & cmd.exe /d /s /c "npm run check" + } + Invoke-CheckedCommand "Building installer and portable Windows assets..." { + & cmd.exe /d /s /c "npm run dist:win" + } + + $expectedAssets = @( + "ForgeFlow-Setup-$version-win-x64.exe", + "ForgeFlow-Setup-$version-win-x64.exe.sha256", + "ForgeFlow-Portable-$version-win-x64.exe", + "ForgeFlow-Portable-$version-win-x64.exe.sha256", + "ForgeFlow-$version-provenance.json", + "ForgeFlow-$version-sbom.cdx.json", + "ForgeFlow-$version-release-manifest.json", + "ForgeFlow-$version-release-manifest.json.sig" + ) + foreach ($assetName in $expectedAssets) { + $assetPath = Join-Path $clone "dist\$assetName" + if (-not (Test-Path -LiteralPath $assetPath)) { + throw "The build did not produce $assetName." + } + } + + $resolvedUserData = if ($UserDataPath) { $UserDataPath } else { Join-Path $env:APPDATA "forgeflow" } + $configPath = Join-Path $resolvedUserData "forgeflow-config.json" + if (-not (Test-Path -LiteralPath $configPath)) { + throw "ForgeFlow configuration was not found at $configPath. Open ForgeFlow and sign in to Gitea once, then run this script again." + } + + $previousUserData = $env:FORGEFLOW_USER_DATA + $previousBranch = $env:FORGEFLOW_RELEASE_BRANCH + try { + $env:FORGEFLOW_USER_DATA = $resolvedUserData + $env:FORGEFLOW_RELEASE_BRANCH = $Branch + Invoke-CheckedCommand "Creating the Gitea release and uploading all four assets..." { + & cmd.exe /d /s /c "npm run release:binary" + } + } finally { + $env:FORGEFLOW_USER_DATA = $previousUserData + $env:FORGEFLOW_RELEASE_BRANCH = $previousBranch + } + } finally { + Pop-Location + } + + Write-Host "`nForgeFlow $version now has a published binary release for commit $($localCommit.Substring(0,7))." -ForegroundColor Green + Write-Host "Return to ForgeFlow $ExpectedVersion's predecessor and choose Check now -> Download update -> Apply & restart." -ForegroundColor Green +} +finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..76303dc --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# ForgeFlow — publieke broncode + +Dit is de gecontroleerde publieke broncode van ForgeFlow. De canonieke ontwikkelrepo is privé; wijzigingen komen hier via een gecontroleerde export zonder de private Git-geschiedenis. + +**Bestaande Windows-installaties:** gebruik voorlopig de [bestaande releases](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest). De release- en updatebestemming verhuist pas wanneer de nieuwe ondertekende releaseketen werkt. Deze bronrepo bevat momenteel geen nieuwe Windows-release. + +De broncode wordt verspreid onder de [MIT-licentie](LICENSE). Zie [START_HERE.md](START_HERE.md) voor installatie vanuit broncode. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1006433 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +ForgeFlow's detailed security model is documented in [`docs/SECURITY.md`](docs/SECURITY.md). + +Report suspected vulnerabilities privately to `security@itworx.tech`. Do not publish Gitea tokens, SSH credentials, update-signing material, private server addresses, support bundles containing sensitive data or other operational secrets in a public issue. + +For a useful report, include the affected ForgeFlow version/commit, component, minimal reproduction steps, expected and observed behaviour and security impact. Use sanitized or synthetic repository/server data whenever possible. + +The current release model requires exact-commit verification, origin-constrained credential use, signed update manifests, redacted diagnostics and bounded deployment adapters. Changes must not silently weaken those guarantees. + +Never commit Gitea tokens, SSH private keys, release-signing private keys, deployment credentials or local repository state. The packaged signing public key is intentionally public; private signing material must remain outside Git. diff --git a/START-FORGEFLOW-OVERLAY.ps1 b/START-FORGEFLOW-OVERLAY.ps1 new file mode 100644 index 0000000..f26fd13 --- /dev/null +++ b/START-FORGEFLOW-OVERLAY.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = "Stop" +$project = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $project + +$manifest = Get-Content (Join-Path $project "package.json") -Raw | ConvertFrom-Json +if ($manifest.name -ne "forgeflow" -or $manifest.version -ne "0.6.0") { + throw "This overlay is not ForgeFlow 0.6.0." +} + +Write-Host "ForgeFlow 0.6.0 overlay validation" -ForegroundColor Cyan +Write-Host "Project: $project" +& npm install --no-audit --no-fund +if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." } + +& npm run check +if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." } + +Write-Host "Starting ForgeFlow 0.6.0..." -ForegroundColor Green +& npm start diff --git a/START_HERE.md b/START_HERE.md new file mode 100644 index 0000000..30a6964 --- /dev/null +++ b/START_HERE.md @@ -0,0 +1,60 @@ +# Start here — ForgeFlow v0.9.0 + +You do **not** need to send anyone your Gitea token, SSH key or server password. +All credentials are entered locally in ForgeFlow during setup. Diagnostic logging +is designed to exclude them. + +## Already running an older ForgeFlow release? + +Run `Publish-ForgeFlow-Release.ps1` from the validated source. It now publishes the source commit and matching Windows installer/portable assets together. Leave the currently installed older folder or executable untouched, then test **Settings → ForgeFlow updates → Check now → Download update → Apply & restart**. Configuration and credentials remain outside the application directory. + +When version 0.9.0 source was already pushed without a Gitea binary release, run `Publish-Missing-Binary-Release.ps1 -ExpectedVersion 0.9.0` once. Afterwards the existing 0.8.9 updater can install 0.9.0 normally. + + +## Fast path on Windows + +1. Extract the complete ForgeFlow ZIP to a normal local folder. +2. Open PowerShell in that folder. +3. Run: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\setup-windows.ps1 +``` + +The script checks Node.js and Git, installs the desktop dependencies, runs the +full source/test quality gate and opens ForgeFlow. + +4. Follow the five-step setup wizard in the application. +5. Continue with [`docs/SETUP_GUIDE.md`](docs/SETUP_GUIDE.md) to connect one + repository to a staging or production environment. + +## When something fails + +Open **Diagnostics** in ForgeFlow and: + +1. Run **System preflight** or the environment **Deployment preflight**. +2. Resolve any blocking checks shown in red. +3. Choose **Create diagnostic ZIP**. +4. Prefer **Strict privacy** when the bundle will be shared. +5. Inspect the ZIP before sharing it. + +The bundle contains redacted application events, configuration structure, +repository/deployment states, preflight output and a local safety-audit result. +It intentionally excludes access tokens, encrypted token blobs and raw runner +logs. + +Detailed diagnostic behavior: [`docs/DIAGNOSTICS.md`](docs/DIAGNOSTICS.md). + + +## Unraid deployments + +After the desktop app opens: + +1. Open **Settings → SSH / Unraid servers**. +2. Add the Unraid host and `/mnt/user/appdata`. +3. Save it and run **Test & trust**. +4. Open a repository, go to **Deployments**, and choose **SSH / Unraid**. +5. Run **Preflight** before the first deployment. + +See [docs/SSH_UNRAID_DEPLOYMENT.md](docs/SSH_UNRAID_DEPLOYMENT.md). diff --git a/UPDATE_FROM_0.3.2.md b/UPDATE_FROM_0.3.2.md new file mode 100644 index 0000000..565d135 --- /dev/null +++ b/UPDATE_FROM_0.3.2.md @@ -0,0 +1,19 @@ +# Update ForgeFlow 0.3.2 to 0.4.0 + +1. Close ForgeFlow completely. +2. Extract `ForgeFlow-0.4.0-update-from-0.3.2.zip`. +3. Copy the contents of the included `ForgeFlow` folder into your existing + `C:\Users\your-name\Apps\ForgeFlow` folder and replace existing files. +4. Do not create a nested `ForgeFlow\ForgeFlow` folder. +5. Open Windows PowerShell in the existing ForgeFlow folder and run: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\update-windows.ps1 +``` + +The script installs the pinned SSH dependency, runs the environment doctor, +source verification and all automated tests, then starts ForgeFlow. + +Credentials, repository mappings, diagnostics, server definitions and + deployment profiles are stored outside the source folder and remain intact. diff --git a/build-windows.ps1 b/build-windows.ps1 new file mode 100644 index 0000000..5fe142e --- /dev/null +++ b/build-windows.ps1 @@ -0,0 +1,58 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +Set-Location $PSScriptRoot + +function Assert-Command { + param([Parameter(Mandatory = $true)][string]$Name) + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command '$Name' was not found on PATH. Read START_HERE.md for prerequisites." + } +} + +function Invoke-Step { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][scriptblock]$Action + ) + Write-Host "`n$Title" -ForegroundColor Yellow + & $Action + if ($LASTEXITCODE -ne 0) { + throw "$Title failed with exit code $LASTEXITCODE." + } +} + +Write-Host "ForgeFlow Windows executable build" -ForegroundColor Cyan +Write-Host "Artifacts are unsigned and intended for local testing." -ForegroundColor DarkGray + +Assert-Command node +Assert-Command npm +Assert-Command git + +$nodeVersionText = (node --version).Trim() +$nodeMajor = [int]($nodeVersionText.TrimStart('v').Split('.')[0]) +if ($nodeMajor -lt 22) { + throw "Node.js 22 or newer is required. Detected: $nodeVersionText" +} + +Invoke-Step "Installing exact project dependencies..." { + if (Test-Path ".\package-lock.json") { + npm ci --no-audit --no-fund + } else { + npm install --no-audit --no-fund + } +} + +Invoke-Step "Running the environment doctor..." { + npm run doctor +} + +Invoke-Step "Running the release quality gate..." { + npm run check +} + +Invoke-Step "Building NSIS installer and portable package..." { + npm run dist:win +} + +Write-Host "`nBuild complete. Artifacts are available in .\dist" -ForegroundColor Green +Get-ChildItem -Path .\dist -File | Select-Object Name, Length, LastWriteTime diff --git a/build/icon-128.png b/build/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..4940e2be2564a455c16dce7ed599fe9c0041df95 GIT binary patch literal 8830 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 MvW`-{f>qf60K?Fh+yDRo literal 0 HcmV?d00001 diff --git a/build/icon-16.png b/build/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..c53ef1f8dcc3c286eaacdf437c2b4f62166d2615 GIT binary patch literal 521 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 +Gitea -> Server chain. It never deploys unless an execution flag is supplied. + +Set `FORGEFLOW_GITEA_URL`, `FORGEFLOW_GITEA_TOKEN`, `FORGEFLOW_REPOSITORY` +(`owner/repository`), `FORGEFLOW_LOCAL_PATH`, `FORGEFLOW_BRANCH`, +`FORGEFLOW_STATUS_URL` and `FORGEFLOW_HEALTH_URL` locally. Optional variables +are `FORGEFLOW_WORKFLOW`, `FORGEFLOW_ROLLBACK_WORKFLOW` and +`FORGEFLOW_ENVIRONMENT`. Never commit the token. + +```powershell +npm run acceptance +npm run acceptance -- --execute-deployment +npm run acceptance -- --execute-rollback +``` + +The first command is read-only. The mutation flags require every read-only +check to pass, dispatch a controlled exact-SHA workflow with a unique request +ID, and wait for matching status plus a successful health endpoint. + +## Isolated production acceptance + +`npm run acceptance:isolated` provisions disposable bare Git remotes, working +trees, server appdata folders, Compose definitions, deployment keys, +configuration migrations and release manifests below the operating-system temp +directory. It covers clean and portable installs, the 0.10.0 migration path, +both authentication modes, exact-SHA server pull and Direct Copy, adoption, +external updates, deploy-key lifecycle, host-key changes, unhealthy activation, +rollback, network interruption, shutdown recovery, stale plans, corrupt config, +release integrity and inventories of more than twenty workloads. + +Every fixture is removed after its test. The suite never discovers or mutates +real project folders, configured servers, credentials, containers or releases. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2086f4e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,221 @@ +# Architecture + +## Process model + +```text ++---------------- Electron renderer ----------------+ +| Desktop UI | +| No Node.js, filesystem, process or token access | ++-------------------------+---------------------------+ + | + frozen preload API + | ++-------------------------v---------------------------+ +| Electron main process | +| | +| IPC validation + trusted sender checks | +| ConfigStore -------- schema 8 + protected Gitea/SSH secrets | +| GitService ----------- Git through execFile args | +| GiteaService --------- repositories + Actions API | +| RepositoryService ---- discovery + aggregation | +| RepositoryMonitor ---- local state awareness | +| PreflightService ----- system/deployment readiness | +| DeploymentService ---- dispatch/poll/verify | +| DiagnosticsService --- JSONL/redaction/support ZIP | ++----------------+--------------------+---------------+ + | | + local Git Gitea API + | | + working trees repositories/actions + | + trusted runner + | + fixed allowlisted entry point + | + app + independent status JSON +``` + +## Trust boundaries + +### Renderer + +The renderer is untrusted input. It can request only methods exposed by +`preload.cjs`. Node integration is disabled, context isolation and sandboxing +are enabled, and IPC requests are accepted only from the packaged file origin. +Renderer crashes and unhandled rejections are reported through a sanitized +one-way diagnostic method. + +### Main process + +Paths, URLs, file selections, branch names, commit messages, diagnostic export +modes and deployment requests are checked here. The renderer cannot supply a +server command. UI eligibility is advisory; main-process services re-read the +working tree and remote ancestry immediately before privileged actions. + +### Gitea + +Gitea supplies repository metadata and the Actions control plane. ForgeFlow +handles run-list response variants, retries servers that reject optional query +filters and can fall back to the older tasks listing. Requests log only method, +API path, status and duration—not authorization headers or request bodies. + +### Runner and server + +The runner consumes only committed trusted workflows. The root-owned server +entry point reads an exact repository/environment target from a root-owned, +non-writable data file. It validates the full SHA again and uses a +per-environment lock. The runner receives no free-form command from ForgeFlow. + +## Local state + +`forgeflow-config.json` lives below Electron's platform-specific user-data path +and is written atomically. Schema version 8 contains: + +- Gitea connection metadata and an OS-encrypted token blob where available; +- workspace roots and explicit repository mappings; +- favorites and application preferences; +- diagnostic retention and level preferences; +- multiple deployment profiles and last server state; +- up to 250 operation records. + +Renderer-visible public state never contains the plaintext or encrypted token. + +Structured diagnostic JSONL files live in a separate `diagnostics` directory. +They have independent rotation and retention and never block normal app use when +logging itself fails. + +## Repository aggregation + +1. Fetch accessible Gitea repositories. +2. Scan bounded workspace roots for Git working trees. +3. Normalize HTTPS and SCP-style SSH remotes. +4. Match local `origin` identity to `owner/repository`. +5. Apply explicit mappings where present. +6. Read Git state with bounded concurrency. +7. Attach favorites, deployment profiles and last server state. +8. Derive attention and ready-to-deploy status. +9. Feed linked paths to the repository monitor. + +## Repository clone lifecycle + +The first configured project root is the default. The renderer submits only the +current Gitea repository identity and either `default` or `custom` location +mode. The main process resolves the repository again, selects a configured root +or a native-dialog result, calculates the repository-named child path and asks +GitService to inspect it. + +```text +repository identity + | +current Gitea metadata + | +project root + safe repository folder name + | +missing / empty / matching checkout / conflict + | +clone or reuse -> save mapping -> refresh -> monitor +``` + +A matching existing checkout is reused. Different repositories and arbitrary +non-empty folders are rejected. + +## Git execution + +ForgeFlow invokes the installed Git executable through `execFile`; it never +builds shell command strings. File arguments must remain repository-relative +and cannot contain traversal segments. + +Core status command: + +```bash +git status --porcelain=v2 --branch -z --untracked-files=all +``` + +Synchronization is intentionally limited to: + +```bash +git pull --ff-only +``` + +Branch switching requires a clean working tree. Stash supports untracked files. +Deployment and rollback verify the full SHA against `origin/` +with `merge-base --is-ancestor`. + +## Preflight model + +### System preflight + +Checks Git, author identity, app storage, diagnostic storage, OS credential +protection, configured workspace roots and—when credentials are present—Gitea +connectivity and repository visibility. + +### Deployment preflight + +Checks repository link, Git working tree, allowed branch, clean state, upstream, +ahead/behind state, exact remote SHA, local and remote workflow presence, +Actions API access, server status endpoint and healthcheck. + +Only failed required checks block readiness. The deployment backend repeats +safety-critical Git/SHA validation after the user continues. + +## Repository monitor + +The current monitor periodically fingerprints Git state. It establishes a +baseline, reports later changes and pauses during mutating operations to avoid +intermediate noise. It is dependency-free rather than a native filesystem +watcher. + +## Deployment lifecycle + +```text +requested -> queued -> running -> health/version verification -> terminal +``` + +A deployment operation stores the exact SHA, fixed profile, environment, +workflow and a UUID request ID. Polling then: + +1. finds the matching Actions run by SHA, branch, workflow and dispatch time; +2. normalizes run status; +3. retrieves jobs and locally redacted runner output; +4. maps jobs to ForgeFlow stages; +5. reads the independent status endpoint and healthcheck after runner success; +6. verifies live SHA equality; +7. stores success, rolled-back, failed or cancelled. + +The request ID is sent to the workflow and server status document, making one +operation correlatable without using a credential as an identifier. + +## Diagnostics pipeline + +```text +event -> recursive sanitization -> ordered JSONL write + | + support export requested + | + fresh state + preflight + redacted logs + | + strict/standard privacy transformation + | + fail-closed local secret safety audit + | + ZIP + SHA-256 result +``` + +Raw runner output is deliberately omitted from exported support bundles. + +## Status endpoint + +The recommended endpoint is a static JSON file served independently from the +application. It reports live, previous and requested SHAs, request ID, health +and last exit code. See `STATUS_ENDPOINT.md`. + + +## v0.4 services + +- `UpdateService` reports `package.json` at an exact Gitea branch SHA, refuses + unsigned source replacement and applies only publisher-signed packaged updates. +- `SshService` provides pinned-host SSH execution with encrypted password or + private-key passphrase storage. +- `UnraidDeploymentService` inspects existing application folders and performs + exact-SHA Git and Docker Compose deployments without deleting untracked + runtime data. diff --git a/docs/COVERAGE_POLICY.md b/docs/COVERAGE_POLICY.md new file mode 100644 index 0000000..6df173c --- /dev/null +++ b/docs/COVERAGE_POLICY.md @@ -0,0 +1,18 @@ +# Coverage policy + +ForgeFlow treats coverage as release evidence, not as a target to game. `npm run coverage` +enforces 75% statements, 75% lines, 75% functions and 65% branches globally. + +The July 2026 hardening pass raised the measured baseline from 69.74% statements/lines, +68.82% functions and 55.38% branches to 81.48% statements/lines, 82.07% functions and +65.59% branches. Node/V8 discovered additional branch counters when previously unexecuted +functions became covered; the denominator grew from 2,537 to 3,473 while the new tests +added hundreds of asserted decisions. No command builders, platform guards or error +adapters were excluded to improve the result cosmetically. + +The 65% global gate is paired with scenario-level evidence for the critical +boundaries: deploy-key rollback, deployment verification, Gitea authentication and +redirects, SSH host identity and output limits, inventory reconciliation, stale plans, +configuration recovery, release integrity and updater failure modes. New code must not +reduce the global baseline. Future increases must come from additional asserted failure +scenarios, not ignore comments or source exclusions. diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md new file mode 100644 index 0000000..4bcbe78 --- /dev/null +++ b/docs/CURRENT_STATE.md @@ -0,0 +1,54 @@ +# ForgeFlow current state + +## Baseline + +- Baseline version: **0.10.0** +- Baseline commit: `56efd1a00c2e76251a0b2e7a7a424d94200ae33c` +- Baseline branch: `main` +- Desktop runtime: Electron 43 with Node.js 22+ required by the source project +- Primary supported packaged updater: Windows installer and portable executable + +The baseline was recorded before the 1.0 professionalization programme. It is the comparison point for functional, deployment and renderer regressions. + +## Known baseline evidence + +- `npm run check`: 155 tests, 152 passed, 3 environment-dependent Bash checks skipped, 0 failed. +- OS-backed secure storage, encrypted Gitea token, Gitea API, repository and Actions access were available. +- Unraid exposed Docker, Compose, Git, tar and SHA-256 tooling. +- The server inventory contained active repository workloads plus a large number of historical or unrelated definitions that require backend classification. +- Windows release artifacts were checksum-protected but not Authenticode-signed. + +No secret values, passwords, private keys or tokens are stored in this document. + +## Operation classes + +| Class | Default | Examples | +| --- | --- | --- | +| Read-only inspection | Allowed without confirmation | repository refresh, server inventory, deploy-key probe, preflight, audit export | +| Reconciliation | Preview required | adopt an exact workload, refresh a profile from server truth | +| Configuration mutation | Explicit action and audit record | save profile, rotate deploy key, change server settings | +| Deployment | Fresh preflight and confirmation | server pull, Direct Copy, Gitea Actions dispatch | +| Destructive maintenance | Recovery evidence and explicit confirmation | unlink, prune, key revocation, rollback | + +Discovery and audit never belong to a mutating class. Ambiguous evidence cannot be promoted automatically. + +## Recovery model + +ForgeFlow writes its configuration atomically. Explicit server reconciliation additionally creates a private recovery snapshot before changing profiles or deployment state. Encrypted user-created `.ffbackup` files remain the portable restore mechanism; recovery snapshots are local operational safeguards and can contain OS-encrypted credential material. + +## Issue priorities + +- **P0:** active data loss, credential disclosure, arbitrary execution or uncontrolled production mutation. +- **P1:** release-blocking incorrect deployment, unsafe implicit mutation, broken recovery or material security gap. +- **P2:** important functional, accessibility, performance or maintainability defect with a safe workaround. +- **P3:** polish, documentation or low-risk improvement. + +## 1.0 constraints + +- No force-push or implicit repository history rewrite. +- No automatic deployment deletion. +- No desktop Gitea token on a server. +- Read-only repository-scoped deploy keys for server pull. +- SSH host-key changes fail closed. +- Live commit, remote commit and runtime health remain separate evidence. +- Packaged updates fail closed on missing or mismatched release assets, SHA-256 evidence and the pinned Ed25519 publisher signature; paid Authenticode remains optional. diff --git a/docs/DEPENDENCY_AUDIT.md b/docs/DEPENDENCY_AUDIT.md new file mode 100644 index 0000000..2d83555 --- /dev/null +++ b/docs/DEPENDENCY_AUDIT.md @@ -0,0 +1,46 @@ +# Dependency security audit + +Audit date: 2026-07-29 + +## Outcome + +- Runtime/production dependency audit: **0 vulnerabilities** (`npm audit --omit=dev`). +- Full development toolchain: **19 high advisories**, reduced from 23. +- Critical advisories: **0**. + +Playwright was upgraded from 1.55.0 to 1.62.0, removing the browser-download +certificate-verification advisory. `c8` was upgraded from 10.1.3 to 12.0.0, +removing the vulnerable `test-exclude` chain. Compatible patched +`brace-expansion` releases were installed where dependency ranges allowed it. + +## Remaining development-only chain + +All remaining records collapse to one advisory: +`GHSA-mh99-v99m-4gvg`, an uncontrolled brace-expansion denial of service. npm +reports it through nested `minimatch` versions in two independent toolchains: + +- ESLint 10.8.0 (`@eslint/config-array`, `@eslint/eslintrc`); +- electron-builder 26.15.3 (`@electron/asar`, `@electron/universal`, `glob`, + `dir-compare`, `ejs`/`jake`, Windows packaging helpers). + +These packages are never loaded by the packaged ForgeFlow runtime. They run in +developer or CI processes against repository and build configuration owned by +the operator. A malicious repository could still attempt resource exhaustion +during linting or packaging, so the finding is not classified as harmless. +CI jobs must retain memory/time limits and untrusted pull requests must not run +release signing or publishing jobs. + +## Decisions + +- `npm audit fix --force` is prohibited. npm proposes ESLint 4.0.0 and an older + electron-builder; both are breaking downgrades and the tested older builder + dependency graph increased the result to 30 high and 1 critical advisory. +- No global `minimatch` override is used. Several affected consumers declare + older APIs, and forcing a new major could silently break packaging or lint + file selection. +- Latest stable ESLint and electron-builder versions are pinned exactly. The + residual chain will be retested whenever either publishes a dependency fix. + +The release gate treats `npm audit --omit=dev --audit-level=high` as blocking. +The complete development audit remains documented and visible rather than +being misrepresented as a production vulnerability count. diff --git a/docs/DEPLOYMENT_MIGRATION_EXAMPLE.md b/docs/DEPLOYMENT_MIGRATION_EXAMPLE.md new file mode 100644 index 0000000..9655014 --- /dev/null +++ b/docs/DEPLOYMENT_MIGRATION_EXAMPLE.md @@ -0,0 +1,63 @@ +# Deployment migration example + +This example shows how to bring an existing Git-backed Docker or Unraid application under ForgeFlow control without exposing or overwriting runtime data. + +Use synthetic names and values while testing. Replace them with your own repository, server and paths only in ForgeFlow's local configuration; do not commit credentials or environment-specific diagnostics. + +## 1. Establish the authoritative repository + +Before deploying, verify that the server checkout and Gitea repository represent the same application: + +- compare the complete 40-character commit SHA; +- confirm the configured remote belongs to the intended Gitea origin and repository; +- preserve the root `.git` directory for exact-SHA verification and rollback; +- resolve any remote URL mismatch explicitly instead of silently rewriting it. + +ForgeFlow blocks deployment when the existing origin conflicts with the selected repository unless the user explicitly approves alignment. + +## 2. Protect runtime data + +Typical persistent paths include: + +```text +.env +appdata/ +config/ +data/ +logs/ +compose.override.yml +``` + +Keep those paths outside the tracked deployment payload and add runtime-only directories to `.dockerignore` when they are not build inputs. ForgeFlow uses a controlled Git reset without `git clean`, but the repository's own Compose and ignore rules remain authoritative. + +## 3. Reuse the maintained Compose definition + +Prefer the repository's existing `compose.yml` or `docker-compose.yml` when it already defines ports, volumes, device mappings, labels and health checks. These application-specific settings should be reviewed and versioned with the application rather than regenerated during deployment. + +## 4. Handle nested repositories separately + +A historical checkout such as `source/` may contain another `.git` directory. Treat this as a migration warning: + +1. verify that the root Compose file builds from the intended root; +2. back up the application folder; +3. stop modifying the nested checkout; +4. rename it temporarily; +5. rebuild and verify the application from the root checkout; +6. remove the legacy copy only after rollback has also been tested. + +ForgeFlow reports nested repositories but does not delete them automatically. + +## 5. Recommended profile + +```text +Provider: SSH / Unraid +Server folder: example-app +Branch: main +Compose mode: Repository/server Compose +Compose file: compose.yml +Clone URL: a Git URL reachable from the server +Healthcheck: the application's existing health endpoint +Preserve paths: .env, appdata, config, data, logs, compose.override.yml +``` + +Complete a preflight first, deploy one exact commit, verify both the live SHA and runtime health, and test rollback before treating the migration as production-ready. diff --git a/docs/DEPLOYMENT_SETUP.md b/docs/DEPLOYMENT_SETUP.md new file mode 100644 index 0000000..0f572aa --- /dev/null +++ b/docs/DEPLOYMENT_SETUP.md @@ -0,0 +1,58 @@ +# Deployment setup guide + +This guide connects one Gitea repository to one server environment without giving the desktop arbitrary shell access. + +## 1. Add fixed workflows + +Copy these examples into the repository: + +```text +examples/gitea-actions/deploy.yml -> .gitea/workflows/deploy.yml +examples/gitea-actions/rollback.yml -> .gitea/workflows/rollback.yml +``` + +Change the runner label to the label registered for the target environment. + +## 2. Install the allowlisted server entry point + +Copy `examples/server/forgeflow-deploy` to `/usr/local/bin/forgeflow-deploy`, customize its repository/environment allowlist and make it root-owned: + +```bash +sudo install -o root -g root -m 0755 forgeflow-deploy /usr/local/bin/forgeflow-deploy +``` + +Grant the runner account permission to execute only this entry point where elevation is needed. Do not grant unrestricted shell or Docker administration merely for ForgeFlow. + +## 3. Expose deployment status + +The example script writes an atomic JSON document beneath `/var/lib/forgeflow-status`. Serve the appropriate file at a fixed HTTPS URL, for example with `examples/server/nginx-forgeflow-status.conf`. + +See `STATUS_ENDPOINT.md` for the contract. + +## 4. Configure the ForgeFlow profile + +Open the repository, choose **Deployments** and add an environment with: + +- Name: `Production` or `Staging`. +- Environment: the fixed workflow input. +- Branch: usually `main`. +- Workflow file: `deploy.yml`. +- Rollback workflow: `rollback.yml`. +- Status URL: the JSON endpoint. +- Healthcheck URL: the application health endpoint. +- Confirmation: enabled for production. + +## 5. Validate the complete path + +Test these scenarios before relying on production: + +1. Clean commit and push. +2. Successful deployment to the exact SHA. +3. Gitea runner failure. +4. Application healthcheck failure. +5. Server reports the wrong SHA. +6. Second deployment while the lock is held. +7. Rollback to the recorded previous SHA. +8. Token without sufficient permissions. + +Keep a manual recovery path documented even after rollback works. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md new file mode 100644 index 0000000..7ddf26f --- /dev/null +++ b/docs/DIAGNOSTICS.md @@ -0,0 +1,150 @@ +# Diagnostics, privacy and support bundles + +ForgeFlow v0.3.2 records development-oriented diagnostics locally so failures +can be investigated without requesting the user's Gitea token, SSH key or +server password. + +## Storage + +Diagnostic events are stored beneath the Electron application-data directory in: + +```text +diagnostics/forgeflow-YYYY-MM-DD.jsonl +``` + +The Diagnostics page displays an aliased path such as `` rather than the +Windows account name. Files use restrictive permissions where the operating +system supports them. + +Defaults: + +- enabled; +- minimum level `info`; +- 14-day retention; +- 8 MB maximum per log segment; +- daily filenames with numbered rotation; +- ordered asynchronous writes; +- no application crash when diagnostic storage itself fails. + +These values can be changed in **Diagnostics -> Recording policy**. + +## What an event can contain + +Useful fields include: + +- UTC timestamp; +- level and stable event name; +- per-launch session identifier; +- operation or deployment request identifier; +- Git/Gitea operation outcome; +- HTTP status, duration and endpoint path without request headers; +- repository state and branch/SHA metadata; +- preflight result; +- sanitized exception name, code, message and stack; +- renderer crash or unhandled-rejection metadata. + +ForgeFlow does not log IPC payloads, request authorization headers or Gitea +response bodies merely because a request was made. + +## Redaction + +Every event passes through a recursive sanitizer before it is written. +Redaction covers: + +- the currently active Gitea token; +- token, password, authorization, credential, API-key, client-secret and + encrypted-token object fields, including camelCase variants; +- bearer/token/basic authorization values in strings; +- common token query parameters; +- credentials embedded in URLs; +- PEM private-key blocks; +- known Gitea/Git hosting token patterns; +- Windows, macOS and Linux home-directory paths; +- application source path aliases; +- circular data structures and oversized strings. + +Credential values are replaced with `[REDACTED]`; user paths use aliases such as +``. + +## Support bundle + +**Create diagnostic ZIP** exports a local archive containing: + +```text +manifest.json +safety-audit.json +README.txt +system.json +diagnostics-status.json +configuration-sanitized.json +repositories-sanitized.json +operations-sanitized.json +preflight.json +context.json +logs/*.jsonl +``` + +The application returns the SHA-256 of the generated archive so a shared file +can be identified exactly. + +### Excluded data + +The bundle intentionally excludes: + +- plaintext Gitea access tokens; +- Electron `safeStorage` encrypted-token blobs; +- request authorization headers; +- passwords and private keys; +- raw Gitea runner logs; +- arbitrary environment-variable dumps; +- full local file contents and Git diffs. + +ForgeFlow does not automatically ingest or persist raw runner output. Job names, +statuses and safe operation summaries are stored locally; full runner output remains +available only in the trusted Gitea Actions interface when deeper server-side +investigation is necessary. + +## Privacy modes + +### Standard + +Preserves repository names and user-facing identifiers. Local home paths and +credentials are still redacted. Use when the recipient already knows the +project context. + +### Strict + +Additionally replaces repository and user identifiers with deterministic +SHA-256-based aliases. Related events remain correlatable without revealing the +original names. + +## Fail-closed bundle audit + +Immediately before writing the archive, ForgeFlow scans every prepared entry +for: + +- the known active runtime secret values; +- private-key begin markers; +- unredacted credentials embedded in HTTP(S) URLs. + +The result is stored as `safety-audit.json`. When a finding remains, archive +creation is aborted and the unsafe ZIP is not written. + +## Practical limitation + +No generic logger can mathematically identify every unknown secret if a third- +party process prints an arbitrary value without a label or recognizable format. +ForgeFlow reduces this risk by not including raw runner logs, not recording IPC +payloads and applying both structured and textual redaction. Always inspect a +support bundle before sharing it, particularly when custom integrations have +been added. + +## Development workflow after a failure + +1. Reproduce the issue once when safe. +2. Note the approximate time and repository/environment. +3. Run the relevant preflight. +4. Export a Strict diagnostic bundle. +5. Keep the returned SHA-256 with the bug report. +6. Describe the visible action that failed. +7. Share no separate token, key or password. diff --git a/docs/ERROR_CODES.md b/docs/ERROR_CODES.md new file mode 100644 index 0000000..372b622 --- /dev/null +++ b/docs/ERROR_CODES.md @@ -0,0 +1,15 @@ +# ForgeFlow error and recovery catalog + +| Code | Meaning | Recovery | +| --- | --- | --- | +| `RECONCILIATION_PLAN_REQUIRED` | A server mutation was requested without its reviewed plan. | Open Review reconciliation and apply the current plan ID. | +| `RECONCILIATION_PLAN_STALE` | Server truth changed after preview. | Rescan, review the new impact and apply that plan. | +| `SERVER_GIT_VERIFICATION_FAILED` | Branch, deploy key or pinned SSH evidence could not be proven. | Run Verify server pull; repair only the failing check before retrying. | +| `DEPLOY_KEY_NOT_READ_ONLY` | A matching key can write to Gitea. | Revoke it in Gitea and configure a dedicated read-only key. | +| `SSH_DEPLOYMENT_PREFLIGHT_FAILED` | One or more deployment safety checks failed. | Open preflight evidence and follow the failing check's detail. | +| `REMOTE_WRITE_ACCESS_REQUIRED` | The SSH user cannot safely write the managed source/state paths. | Use Check / fix write access after reviewing its scoped impact. | +| `UPDATE_ORIGIN_MISMATCH` | An update asset points outside the trusted Gitea origin. | Correct release asset URLs; never bypass the origin check. | +| `UPDATE_CHECKSUM_MISMATCH` | Downloaded bytes do not match the published checksum. | Keep the current version and republish the exact commit atomically. | + +Audit and discovery never repair these conditions automatically. Mutating recovery +actions require an explicit user flow and preserve rollback or snapshot evidence. diff --git a/docs/MUTATION_MODEL.md b/docs/MUTATION_MODEL.md new file mode 100644 index 0000000..dd9b541 --- /dev/null +++ b/docs/MUTATION_MODEL.md @@ -0,0 +1,26 @@ +# Mutation and reconciliation model + +ForgeFlow separates observation from state changes at the API boundary. + +## Discovery + +`scanServerInventory()` and `discoverServerWorkloads()` collect Docker, Compose, DockerMan and Git evidence. They may write diagnostic logs, but they do not save, update or delete deployment profiles and do not change containers. + +## Reconciliation planning + +`planServerInventoryReconciliation()` returns a content-addressed plan containing: + +- exact links that may be added; +- existing profiles whose observed metadata may be refreshed; +- stale profiles that require review; +- ambiguous workloads that block automatic application. + +The plan identifier changes whenever its proposed scope changes. + +## Reconciliation application + +`reconcileServerInventory()` requires the exact reviewed plan identifier. It rescans the server and refuses a stale plan. Before writing configuration it creates a private recovery snapshot. Stale profiles are reported but never removed automatically. + +## Direct mutations + +Manual linking, unlinking, deploy-key rotation, deployment and rollback remain separate explicit commands. Each must append an audit event with repository, profile, operation identifier and result. Destructive commands need a dedicated confirmation flow and recovery path. diff --git a/docs/PRODUCTION_READINESS_1.0.md b/docs/PRODUCTION_READINESS_1.0.md new file mode 100644 index 0000000..0149f2f --- /dev/null +++ b/docs/PRODUCTION_READINESS_1.0.md @@ -0,0 +1,78 @@ +# ForgeFlow 1.0 production-readiness evidence + +## 1. Scope and history + +The professionalization work started from `64ca267` on `main`. It preserves the +0.10.0 compatibility baseline and deliberately creates no 1.0 tag or public +release. The commits and their exact SHAs remain the authoritative audit trail. + +## 2. Deployment safety + +Server pull uses repository-scoped read-only deploy keys, exact commit SHAs, +pinned SSH/Gitea host identities, Compose validation, health evidence and bounded +rollback. Rotation is transactional and revocation requires reviewed impact and +recovery evidence. Direct Copy and monitor-only remain explicit alternatives. + +## 3. Inventory and reconciliation + +Canonical deployment identity combines repository, branch, server, environment, +Compose project/root, runtime labels, container, live SHA and profile. Duplicate, +stale, ambiguous, orphan and historical evidence has persistent content-addressed +review decisions. Discovery never deletes, stops or rewrites a workload. + +## 4. Architecture + +Renderer, IPC and Unraid responsibilities are split by domain. The generated +architecture audit currently reports zero source files above 750 or 1,000 lines. +Runtime schemas, bounded IPC capabilities, operation IDs and explicit error +contracts protect the process boundary. + +## 5. Repository assurance + +Git Validator 2.0 covers security, reproducibility, governance, collaboration, +performance/hygiene and release readiness. Minimal, Standard, Strict, Production +and custom policies support accountable expiring suppressions, trend history and +reviewable JSON/Markdown/HTML reports. Repairs always require preview and never +commit or push automatically. + +## 6. Automated verification + +The Node suite includes real temporary Git remotes and an isolated production +acceptance harness. Playwright adds 36 renderer cases across six viewport/theme/ +motion/scaling projects. Failure artifacts contain screenshots, traces, video, +console events, DOM, fixture details and test identity. + +## 7. Coverage and dependencies + +Coverage increased from 69.74% statements/lines, 68.82% functions and 55.38% +branches to 81.48%, 82.07% and 65.59%, respectively. The enforced gates are now +75/75/75/65 and are documented in `COVERAGE_POLICY.md`. Production dependencies have zero known +audit vulnerabilities. Remaining development findings belong to current upstream +ESLint/electron-builder toolchains and are assessed in `DEPENDENCY_AUDIT.md`. + +## 8. UX and accessibility + +Dark and light themes use the same semantic hierarchy, restrained project-signal +motion and status text that never depends on color alone. Deployment cards expose +container, repository, environment, commit parity and health distinctly. Dense +inventories, long names, keyboard focus, dialogs, reduced motion and high scaling +are part of the automated matrix. + +## 9. Packaging and updating + +Windows installer and portable packaging use deterministic names; old `dist` +versions are pruned after every successful build. Publication stays draft until +installer, portable, checksums, provenance and CycloneDX SBOM are complete. Binary +updates verify the exact release asset, executable format and published SHA-256 +before download staging and again before replacement. Authenticode is optional and +is not a release or updater dependency for this personal/internal application. + +## 10. Release decision + +No open P0 or P1 technical issue is known after the final quality, browser, +acceptance, signing and packaging gates. The technically correct status is: + +`TECHNICALLY_COMPLETE` + +There is no paid certificate or external signing-service dependency. Windows may +show its normal unknown-publisher warning during first installation. diff --git a/docs/RELEASE_AUDIT_0.6.0.md b/docs/RELEASE_AUDIT_0.6.0.md new file mode 100644 index 0000000..220cd12 --- /dev/null +++ b/docs/RELEASE_AUDIT_0.6.0.md @@ -0,0 +1,73 @@ +# ForgeFlow 0.6.0 release audit + +## Scope + +This audit covers the source release intended for publication to `Jens/ForgeFlow` and subsequent installation through ForgeFlow's built-in source updater. + +Reviewed areas: + +- local Git discovery, status, staging, commit, push, fetch and fast-forward; +- large Windows path selections and deleted/renamed files; +- stale Git lock diagnosis, conservative repair and automatic retry; +- divergence recovery with a safety branch; +- Gitea repository and Actions integration; +- SSH host identity, Unraid-to-Gitea preflight and exact-SHA deployment; +- Docker Compose identity normalization while preserving visible container names; +- DockerMan WebUI, icon and shell labels, XML fallback and cache refresh; +- interrupted/stale deployment reconciliation; +- renderer viewport behavior and guided troubleshooting; +- diagnostics redaction and support bundles; +- release publication and built-in source-update lifecycle. + +## Regression coverage + +The automated suite contains 99 passing tests, including real temporary Git repositories and bare remotes. High-risk regressions covered directly include: + +- staged and unstaged deletions; +- renamed files; +- a local commit followed by a failed push; +- 850 long selected paths transported through NUL-delimited stdin; +- stale `HEAD.lock` removal while excluding Git object/LFS storage; +- backup-before-reset repair of a diverged branch; +- exact remote-SHA checks; +- background SSH deployment completion without a stuck operation; +- startup/manual reconciliation of live Unraid state; +- lowercase-safe Compose project/service/image identities with visible `Portfolio` casing; +- DockerMan labels, built-in icon upload, XML fallback and cache invalidation; +- source-updater STARTED handshake, result acknowledgement, direct Electron restart and rollback state. + +## Product behavior added for the reported incidents + +- Git mutations are serialized per repository. +- A lock failure triggers a safe diagnosis and one automatic repair/retry when no active Git process is detected. +- Git Tools provides personalized scan, lock repair, origin repair, fast-forward, push and safety-branch divergence recovery actions. +- Successful SSH deployments become terminal before the secondary server refresh, preventing a live container from leaving ForgeFlow in deployment mode. +- ForgeFlow refreshes configured server truth after startup and through the combined refresh action. +- A healthy live SHA equal to local/Gitea is not offered for deployment again. +- Running containers missing DockerMan metadata can be repaired individually or in one batch from Deployments. +- Built-in/uploaded icons are placed in persistent DockerMan storage, referenced through a `file:///` label, written into a user template and copied into known icon caches. +- WebUI uses the Unraid label placeholders based on the configured host port and path. +- Update publication creates and publishes `package-lock.json`; the updater uses `npm ci` when it is present. +- Update success is persisted before restart, and restart invokes Electron directly rather than relying on a detached npm process. + +## Static and packaging checks + +- every JavaScript/CJS/MJS source file passes `node --check`; +- required source, branding, documentation, updater and deployment files are present; +- direct dependency versions are pinned; +- renderer privileged actions remain behind the preload/IPC boundary; +- the PowerShell update helper starts with `param(`, has no UTF-8 BOM and contains lifecycle state before shutdown/restart; +- release archives exclude `.git`, `node_modules`, `dist`, update downloads and generated ZIPs; +- the generated source manifest records SHA-256 and size for every distributed source file. + +## Remaining live acceptance step + +The automated environment cannot execute Windows PowerShell 5.1 or connect to the user's private Gitea/Unraid services. The final live acceptance is therefore deliberately the requested workflow: + +1. publish the release from an extracted Downloads folder; +2. leave the installed older source at `C:\Projects\ForgeFlow` untouched; +3. open that older ForgeFlow; +4. use **Settings → ForgeFlow updates → Check now → Download update → Apply & restart**; +5. confirm the restarted application reports version 0.6.0 and displays the persisted success result. + +A failed handoff must keep the old app open. A failed validation must restore the previous source. A successful installation remains installed even when only automatic restart fails. diff --git a/docs/RELEASE_NOTES_0.10.0.md b/docs/RELEASE_NOTES_0.10.0.md new file mode 100644 index 0000000..c685660 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.0.md @@ -0,0 +1,32 @@ +# ForgeFlow 0.10.0 + +ForgeFlow 0.10.0 makes existing Unraid workloads substantially easier and safer to adopt, verify and deploy. + +## Deployment discovery and verification + +- Automatic discovery links unique running server workloads to their matching Gitea repositories while excluding unrelated infrastructure and stale release folders. +- Deployment cards preserve the last verified live commit and compare it with the current Gitea branch, even when a container was updated outside ForgeFlow. +- Existing Compose project names, files, services, remote folders and DockerMan metadata are adopted from server truth instead of guessed or overwritten. + +## Safe Server pull + +- Server pull is now the recommended deployment route and fetches the exact requested commit from Gitea. +- Each repository receives its own repository-scoped read-only deploy key; ForgeFlow never installs the desktop Gitea token on Unraid. +- Gitea SSH host fingerprints are pinned and checked before trust is changed and before every pull. +- Fetch, archive, checksum, Compose validation, activation and rollback remain exact-commit and transactional. +- Direct copy remains available when server-side Git access is undesirable, and monitoring-only links cannot deploy accidentally. + +## Git hygiene + +- Git Validator now also checks `.gitattributes`, `.editorconfig`, dependency lockfiles and a Gitea Actions workflow. +- Safe repairs create reviewable files without committing or pushing them automatically. +- Existing identity, upstream, synchronization, branch-protection, documentation, secret-path and oversized-file checks remain available in one scored view. + +## Interface and reliability + +- The deployment workspace now emphasizes repository, container, environment and live-versus-Gitea evidence, with a focused animated project illustration and clearer server inventory. +- Large unrelated server inventories are summarized instead of producing dozens of indistinguishable cards. +- Connection validation consistently opens the same encrypted Electron profile as the installed app. +- Obsolete ForgeFlow artifacts are removed from `dist` after every successful packaged build. + +No container was restarted or replaced during automatic discovery. Deployments still require a successful preflight and an explicit user action. diff --git a/docs/RELEASE_NOTES_0.10.1.md b/docs/RELEASE_NOTES_0.10.1.md new file mode 100644 index 0000000..dff4fa8 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.1.md @@ -0,0 +1,21 @@ +# ForgeFlow 0.10.1 + +## Reliable certificate-free updates + +- Windows installer and portable releases are supported without paid signing services. +- Packaged updates remain protected by exact Gitea release assets, PE validation and SHA-256 verification before staging and immediately before replacement. +- Old ForgeFlow versions are pruned from `dist` after each successful build. + +## Deployment inventory correctness + +- Repository matching is case-insensitive, so `Jens/Repo` and `jens/repo` refresh the same deployment profile. +- Running repository workloads are linked conservatively; third-party DockerMan applications remain visible as external monitoring-only workloads instead of generating hundreds of false repository problems. +- Historical and stopped duplicate definitions no longer require repetitive manual review. +- Shadowed automatic profiles are retired only after a recovery snapshot and a stable reviewed reconciliation plan. +- Live runtime, container health and commit evidence are refreshed before readiness is reported. + +## Server pull verification + +- Existing running repository workloads can receive repository-scoped read-only deploy keys without changing containers. +- The audit command supports compact inventory, reconciliation and access evidence for operational verification. +- Release acceptance no longer assumes an external Authenticode certificate while retaining checksum, provenance and SBOM checks. diff --git a/docs/RELEASE_NOTES_0.10.10.md b/docs/RELEASE_NOTES_0.10.10.md new file mode 100644 index 0000000..3daafbe --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.10.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.10.10 + +## Complete server-pull deployment repair + +- Missing repository-scoped read-only deploy keys can be provisioned and verified from Unraid against the exact Gitea branch. +- A repository deployment root can now remain above its Compose working directory without breaking workload recognition or being overwritten by inventory refresh. +- Nested Compose files are preserved as repository-relative deployment paths, including Ludarium, Launchpad and ITWorx MCP Hub layouts. +- The **Fix write access** action now receives its permission-report parser correctly instead of reporting a false write-access failure. +- Server-pull preflight proves every required deployment file at the exact Gitea commit before any container activation starts. +- Runtime secrets remain in server-side `.env` files and preserved appdata paths; no secret values are written to Git or diagnostic output. diff --git a/docs/RELEASE_NOTES_0.10.11.md b/docs/RELEASE_NOTES_0.10.11.md new file mode 100644 index 0000000..4580eeb --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.11.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.10.11 + +## Resilient repository refresh and consistent server pull + +- Temporary Gitea list failures now use the in-session **last-known-good** repository inventory while local and server state continue to refresh. The UI clearly reports that remote data is stale. +- A **closed output pipe** from a detached parent process is no longer treated as a fatal desktop-app exception. +- Server pull, deploy-key verification, deployment, rollback and metadata now consistently prefer the verified **linked checkout origin** over a stale URL detected earlier on the server. +- Repository-scoped **read-only deploy key** checks remain fail-closed; a changed SSH host still requires explicit trust and access reconfiguration. +- The local **browser test server** now has a dedicated port and identity endpoint, preventing another localhost application from being mistaken for ForgeFlow. +- All 42 responsive browser flows pass across dark/light, compact/wide and reduced-motion configurations. diff --git a/docs/RELEASE_NOTES_0.10.12.md b/docs/RELEASE_NOTES_0.10.12.md new file mode 100644 index 0000000..4e49b9c --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.12.md @@ -0,0 +1,12 @@ +# ForgeFlow 0.10.12 + +## Faster awareness with stricter deployment truth + +- Repository refreshes are **coalesced** and briefly cache Gitea inventory and workspace discovery; a manual refresh remains fully forced and file changes arriving mid-refresh receive one trailing refresh. +- Server discovery reuses its Docker and Compose evidence for existing deployment profiles instead of opening a separate SSH session for every linked workload. +- Deployment status only claims **exact Gitea commit parity** after comparing a concrete branch SHA with the live server SHA; matching repository provenance alone is no longer sufficient. +- Container discovery uses **batched Docker inspect** with a safe per-container fallback when a container disappears during the scan. +- Active Gitea and SSH deployment polling uses **bounded worker pools**, improving multi-deployment latency without flooding external services. +- A **stopped container** can no longer be marked healthy because another process answers on its previous healthcheck port. +- Large repository and server-inventory lists use offscreen rendering containment to reduce layout and paint work. +- Inventory diagnostics now include scan and state-refresh durations, and Gitea bulk verification fails fast after a confirmed connectivity outage. diff --git a/docs/RELEASE_NOTES_0.10.13.md b/docs/RELEASE_NOTES_0.10.13.md new file mode 100644 index 0000000..0f894b2 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.13.md @@ -0,0 +1,18 @@ +# ForgeFlow 0.10.13 + +## Veilige synchronisatie en aantoonbare release-integriteit + +- **Gitea workspace sync** toont eerst de exacte additions, wijzigingen en deletions ten opzichte van de actuele upstream-SHA. Lokale commits worden beschermd in een recovery branch; staged, unstaged en untracked werk gaat naar een stash. Genegeerde runtimebestanden blijven onaangeroerd. +- Read-only achtergrondfetch houdt `ahead` en `behind` actueel zonder projectbestanden automatisch te wijzigen. Interval `0` schakelt netwerkfetch volledig uit. +- Stale deployment links blokkeren niet langer de automatische, bewijsgebaseerde koppeling van de werkelijk draaiende vervangende workload. +- SSH-hostidentiteit wordt vóór het verzenden van credentials getoond en bij bevestiging exact vastgepind. Gitea-tokens vereisen HTTPS, behalve bij expliciete loopbackontwikkeling. +- Packaged updates vereisen een **Ed25519-signed release manifest** dat versie, tag, broncommit, artifactnaam, bytegrootte en SHA-256 bindt aan de ingebouwde publisher key. Hiervoor is geen betaald certificaat of Azure-dienst nodig. +- Diagnostische bundels exporteren geen ruwe remote output meer. Untracked diffs kunnen geen junction of symlink buiten de repository volgen en zijn begrensd op bestandsgrootte. +- De Git-toolsgrid behoudt nu de volledige inhoudshoogte binnen zijn eigen scrollvlak; workspace sync en troubleshooting overlappen niet meer. De demo bridge ondersteunt dezelfde recoveryflow als de desktopapp. +- Repositorymonitoring, deploymentpolling, Docker-inspect en SSH-verbindingen gebruiken begrensde paralleliteit en hergebruik waar dat veilig is. + +## Verificatie + +- Volledige Node-testset, coveragepoort, architectuuraudit en dependency-audit. +- 72 browserflows over dark/light, compact/desktop/wide, 100–150% schaal en reduced motion. +- Windows installer en portable build, SHA-256-sidecars, provenance, CycloneDX-SBOM en ondertekend releasemanifest. diff --git a/docs/RELEASE_NOTES_0.10.14.md b/docs/RELEASE_NOTES_0.10.14.md new file mode 100644 index 0000000..ffe1657 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.14.md @@ -0,0 +1,15 @@ +# ForgeFlow 0.10.14 + +## Betere uitleg en een stabiele repositorywerkruimte + +- Een nieuw doorzoekbaar **Help center** legt de belangrijkste workflows stap voor stap uit: eerste configuratie, changes en commits, Gitea workspace sync, deploymentdetectie, exacte serverdeployments, deploykeys, Git Validator, updates en diagnose. +- Contextuele help vanuit **Gitea workspace sync** opent onmiddellijk de relevante uitleg. De instructies maken expliciet wat ForgeFlow wijzigt, welke recovery ForgeFlow vooraf maakt en welke genegeerde runtimebestanden onaangeroerd blijven. +- De variabele **repository context** is samengebracht in één structurele zone. Quick actions, Local → Gitea → Server-status en gekoppelde deployments kunnen daardoor niet langer over de tabnavigatie of inhoud heen schuiven. +- Smalle werkruimtes gebruiken gecontroleerde **horizontal tab navigation**. Elke tab behoudt zijn volledige label en blijft bereikbaar zonder dat tekst door andere bedieningselementen loopt. +- Het Help center heeft een eigen premium, responsieve presentatie met categorieën, zoekresultaten, uitklapbare stappen, veiligheidsnotities en motion-safe projectillustratie. + +## Verificatie + +- Volledige Node-testset en statische renderercontroles. +- **84 browser flows** over dark/light, compact/desktop/wide, 100–150% schaal en reduced motion; de drie tijdens een semantische testaanpassing geraakte flows zijn daarna opnieuw groen uitgevoerd. +- Extra layoutasserties bewijzen dat repository context, tabs en tabinhoud elkaar niet overlappen bij 1024 × 768. diff --git a/docs/RELEASE_NOTES_0.10.15.md b/docs/RELEASE_NOTES_0.10.15.md new file mode 100644 index 0000000..69ba67e --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.15.md @@ -0,0 +1,15 @@ +# ForgeFlow 0.10.15 + +## Veilige exacte workspace-sync en robuustere updates + +- **Workspace Sync** brengt een repository gecontroleerd naar de exacte Gitea-commit zonder lokale wijzigingen stilzwijgend terug naar de server te sturen. Lokale commits krijgen een recovery branch en gewijzigde of niet-getrackte bestanden worden in een expliciete ForgeFlow-quarantaine bewaard. +- Elke quarantaine krijgt een lokaal **Codex review manifest** met bron- en doelcommit, recovery branch, stash-identiteit en betrokken bestanden. Quarantainestashes kunnen niet via de normale ForgeFlow-herstelactie in één keer worden teruggezet; eerst moet de inhoud gericht worden nagekeken. +- ForgeFlow behandelt `forgeflow/recovery-*` branches als **local-only** en weigert ze via de normale pushactie te publiceren, zodat herstelmateriaal niet per ongeluk opnieuw in Gitea terechtkomt. +- De source updater voert checksum- en Git-working-tree-preflight uit **voordat** ForgeFlow de update aan de externe helper overdraagt. Een Git-checkout wordt niet meer destructief met een bronarchief overschreven. +- De Windows binary updater controleert het nieuwe uitvoerbare bestand vóór de ownership handoff, verifieert na update dat ForgeFlow werkelijk blijft draaien en kan bij een mislukte portable update de vorige executable herstellen en opnieuw starten. +- Een geslaagde installer-update waarbij alleen de automatische herstart mislukt, wordt correct als geïnstalleerd gerapporteerd met een duidelijke instructie om ForgeFlow handmatig te starten. + +## Verificatie + +- Managed full validation op de sync/updater-hardening is geslaagd op de exacte feature-head en opnieuw als verplichte pull-requestvalidatie vóór merge. +- De merge naar `main` is uitgevoerd via de beschermde pull-requestflow; de releaseversie wordt afzonderlijk gevalideerd voordat 0.10.15 wordt gepubliceerd. diff --git a/docs/RELEASE_NOTES_0.10.2.md b/docs/RELEASE_NOTES_0.10.2.md new file mode 100644 index 0000000..be85ee7 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.2.md @@ -0,0 +1,9 @@ +# ForgeFlow 0.10.2 + +## Packaged updater origin repair + +- Gitea release assets that expose an internal HTTP `ROOT_URL` are safely rewritten to ForgeFlow's configured public HTTPS Gitea origin. +- Authentication remains same-origin: the Gitea token is never forwarded to an internal address, CDN or unrelated redirect target. +- Published Windows executables are still validated as PE files and against their release SHA-256 sidecars before staging. +- The live authenticated updater acceptance downloads the exact published installer and proves its byte count and SHA-256 digest. + diff --git a/docs/RELEASE_NOTES_0.10.3.md b/docs/RELEASE_NOTES_0.10.3.md new file mode 100644 index 0000000..eb4eab7 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.3.md @@ -0,0 +1,19 @@ +# ForgeFlow 0.10.3 + +## Responsive large workspaces + +- Repository monitoring checks up to four local working trees concurrently while retaining overlap protection and per-repository pause controls. +- Global search, repository filtering and the command palette debounce full interface renders during rapid typing. +- Commit-message input updates readiness and action controls in place, preserving focus and cursor responsiveness. +- Interactive project illustrations and diff atmosphere effects perform at most one layout update per animation frame. + +## Git Validator reliability + +- Git Validator and every long repository tab now retain an explicit vertical scroll owner across compact, desktop and wide layouts. +- Standard, Strict and Production policies correctly treat configured warning severities as blockers; Minimal remains error-only. +- Documented suppressions no longer reduce the hygiene score or remain counted as active blockers. +- Repair requests are rescanned immediately before preview or execution, preventing stale or forged fixes. + +## Verification + +- Full quality gate, coverage thresholds and all responsive browser scenarios pass for this release. \ No newline at end of file diff --git a/docs/RELEASE_NOTES_0.10.4.md b/docs/RELEASE_NOTES_0.10.4.md new file mode 100644 index 0000000..10b030b --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.4.md @@ -0,0 +1,9 @@ +# ForgeFlow 0.10.4 + +## Permanent packaged updater handshake repair + +- Binary and source update helpers now use a Windows PowerShell 5.1-compatible atomic status replacement with a real temporary backup path. +- A deterministic overwrite fallback preserves lifecycle reporting on filesystems that do not implement atomic replacement. +- The binary helper exposes a side-effect-free handshake-only verification mode exercised by the real Windows PowerShell executable during tests. +- existing installations with the defective helper require this one-time installer upgrade; every subsequent packaged update uses the repaired helper automatically. +- Startup failures retain request-scoped status and helper-log evidence instead of collapsing into an unexplained exit-code message. \ No newline at end of file diff --git a/docs/RELEASE_NOTES_0.10.5.md b/docs/RELEASE_NOTES_0.10.5.md new file mode 100644 index 0000000..fc5685f --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.5.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.10.5 + +## Consistent repository and deployment links + +- Every repository workspace now shows all configured deployment environments in a compact, directly actionable strip. +- The repository deployment tab includes every detected server workload linked to that repository, including its container, Compose identity, server and runtime state. +- A workload is only labelled linked when its repository and resolved profile both exist in the current ForgeFlow configuration. +- Stale or incomplete metadata is shown as **Link unresolved** and routed through explicit reconciliation instead of being presented as a healthy deployment. +- The global deployment inventory links directly to the correct repository deployment profile. +- Responsive browser coverage now verifies valid links, unresolved links, repository navigation and scrolling across dark/light and scaled layouts. diff --git a/docs/RELEASE_NOTES_0.10.6.md b/docs/RELEASE_NOTES_0.10.6.md new file mode 100644 index 0000000..5e2e569 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.6.md @@ -0,0 +1,9 @@ +# ForgeFlow 0.10.6 + +## Permanent Windows updater launch repair + +- ForgeFlow no longer launches hidden PowerShell update helpers with Node's defective Windows `detached` process mode. +- Binary and source updater processes remain hidden, are explicitly unreferenced after their verified handshake, and continue independently when ForgeFlow closes. +- A real Windows regression test now exercises the exact production Node spawn options instead of using a different process API. +- Startup is still fail-closed: ForgeFlow remains open unless the request-scoped helper status reaches `started`. +- Versions 0.10.4 and 0.10.5 need a one-time direct installation of 0.10.6 because their installed launcher cannot execute its own helper; updates after 0.10.6 use the repaired path. diff --git a/docs/RELEASE_NOTES_0.10.7.md b/docs/RELEASE_NOTES_0.10.7.md new file mode 100644 index 0000000..1aef314 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.7.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.10.7 + +## Reliable server-to-repository recognition + +- Live, running workloads with one unique exact provenance or runtime-identity match are now linked automatically during normal server discovery. +- Automatic adoption creates only ForgeFlow configuration and observed state; it performs no container changes and never automatically removes stale profiles. +- Ambiguous, duplicate, external and monitoring-only workloads remain behind explicit **Review & link** confirmation. +- Every linked repository now displays an `S` deployment badge with its profile count in the repository sidebar. +- The repository release rail reports **Linked** with container and server identity even when a legacy workload has no verifiable live commit yet. +- DevRunbook-style DockerMan deployments therefore show the same linked relationship in Deployments, the repository sidebar and the repository workspace. diff --git a/docs/RELEASE_NOTES_0.10.8.md b/docs/RELEASE_NOTES_0.10.8.md new file mode 100644 index 0000000..587b770 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.8.md @@ -0,0 +1,9 @@ +# ForgeFlow 0.10.8 + +## Self-contained checksum verification + +- Binary and source update helpers no longer depend on the optional PowerShell `Get-FileHash` cmdlet. +- Both helpers calculate checksums directly with the built-in .NET SHA-256 implementation. +- A real Windows regression test clears `PSModulePath` and verifies the downloaded binary successfully in that minimal environment. +- The helper still validates the exact published checksum before waiting for ForgeFlow to exit or changing installed files. +- This release retains the reliable non-detached launcher and server-to-repository recognition improvements from 0.10.6 and 0.10.7. diff --git a/docs/RELEASE_NOTES_0.10.9.md b/docs/RELEASE_NOTES_0.10.9.md new file mode 100644 index 0000000..cd1f7be --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.9.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.10.9 + +## Reliable deployment inventory and preflight + +- Unraid inventory now includes containers without healthchecks. Docker's complete JSON state is parsed safely instead of using a failing Go-template lookup. +- ForgeFlow is single-instance: opening it again focuses the existing window, preventing concurrent inventory scans and configuration writes. +- Server pull verifies required Compose files or the Dockerfile at the exact Gitea commit before any deployment operation starts. +- Server-pull verification now separates deploy-ready access from optional live-SHA and runtime-health evidence. A recoverable workload is no longer shown as blocked merely because parity is not yet provable. +- Deployment cards and audit output show concrete access blockers and non-blocking warnings instead of a generic incomplete result. +- All discovery, verification and preflight checks remain non-destructive; no containers are changed during these checks. diff --git a/docs/RELEASE_NOTES_0.2.0.md b/docs/RELEASE_NOTES_0.2.0.md new file mode 100644 index 0000000..5cade5a --- /dev/null +++ b/docs/RELEASE_NOTES_0.2.0.md @@ -0,0 +1,32 @@ +# ForgeFlow 0.2.0 release notes + +ForgeFlow 0.2.0 turns the original visual prototype into a substantially more operational personal release cockpit. + +## Highlights + +- Automatic repository status monitoring with safe pause/resume around Git mutations. +- Commit-only and commit-and-push flows with recoverable push failures. +- Branch creation, switching, publication and stash workflows. +- Favorite repositories and action-oriented attention queues. +- Multiple deployment environments per repository. +- Exact remote-branch SHA verification before deploy and rollback. +- Gitea Actions run, job and available log polling. +- Live server version, previous version and health verification. +- Fixed-workflow rollback to a recorded full commit SHA. +- Stronger IPC, URL, path and secret-handling controls. +- Reworked renderer with command palette and live deployment states. +- 21 passing automated tests, including real temporary Git remotes. +- Headless browser smoke coverage at three desktop viewport sizes. + +## Upgrade notes + +Configuration is migrated automatically to schema version 2. Existing Gitea tokens are preserved when the settings form is saved with an empty token field. + +Deployment profiles now support independent branch, workflow, rollback workflow, healthcheck and status endpoint settings. Review existing profiles before using them against production. + +## Known limitations + +- No signed installer or automatic update channel is included in this source release. +- Partial-hunk staging, conflict resolution and protected-branch awareness are not yet implemented. +- Gitea Actions behavior still needs acceptance testing against the intended Gitea and act_runner versions. +- Native desktop notifications, tray mode and accessibility acceptance remain future work. diff --git a/docs/RELEASE_NOTES_0.3.0.md b/docs/RELEASE_NOTES_0.3.0.md new file mode 100644 index 0000000..671a267 --- /dev/null +++ b/docs/RELEASE_NOTES_0.3.0.md @@ -0,0 +1,140 @@ +# ForgeFlow 0.3.0 release notes + +Release date: 2026-07-24 +Release type: self-service test release + +## Goal + +Version 0.3.0 closes the gap between a functional developer preview and a build +that can be configured and tested by its owner without sharing credentials with +a developer. The release concentrates on setup guidance, deterministic +preflight checks, safe diagnostics and server-side allowlisting. + +## Setup and readiness + +- Replaced the lightweight onboarding with a five-step setup wizard. +- Added a computer readiness preflight for Git, Git identity, writable app data, + writable diagnostics and OS credential encryption. +- Added a Gitea validation stage before setup completion. +- Added visible repository discovery results. +- Added safe setup diagnostics before Gitea is connected. +- Added a comprehensive start page and end-to-end setup guide. +- Added a JSON-capable command-line doctor for local environment validation. + +## Deployment preflight + +A deployment now receives a visible preflight before confirmation and a second +mandatory backend validation immediately before dispatch. Checks include: + +- linked local Git repository; +- allowed deployment branch; +- clean working tree; +- configured upstream; +- local/remote ahead and behind state; +- exact full SHA on the remote branch; +- local deploy and rollback workflow files; +- remote workflow visibility through Gitea; +- Gitea Actions API availability; +- deployment status endpoint; +- application health endpoint. + +Optional environment checks can warn without hiding required failures. +Deployment cannot bypass the mandatory checks through the renderer. + +## Diagnostic logging + +- Added ordered structured JSONL logging in the Electron app-data directory. +- Added daily files, size rotation and retention pruning. +- Added configurable logging level, retention and file-size policy. +- Added process, renderer, Git, repository, Gitea, IPC, preflight and deployment + diagnostics. +- Added per-launch session IDs and per-operation deployment request IDs. +- Added a no-throw logging design so diagnostic storage does not crash the app. +- Added local clear and open-folder controls. + +## Secret and privacy protection + +- Added recursive sensitive-key detection, including camelCase variants. +- Added bearer/basic/token/password/API-key/client-secret redaction. +- Added runtime-secret replacement. +- Added URL credential, token query parameter and private-key redaction. +- Added common hosting-token pattern redaction. +- Added user-home and source-root path aliases. +- Added strict privacy mode with deterministic identifier hashing. +- Stopped automatically ingesting or persisting raw runner logs; full output stays in Gitea. +- Added fail-closed bundle auditing before the ZIP is written. +- Added SHA-256 output for every generated support bundle. + +No Gitea token, SSH key or server password is needed by the developer to use +these diagnostics. + +## Support bundle contents + +A support bundle can contain: + +- manifest and safety audit; +- system and application version information; +- sanitized public configuration; +- sanitized repository state; +- sanitized operation history; +- latest preflight report; +- safe diagnostic status; +- redacted JSONL logs. + +It intentionally excludes protected token blobs, authorization headers, +private keys, source files, Git diffs, environment dumps and raw runner output. + +## Server deployment hardening + +- Moved target definitions to a root-owned `/etc/forgeflow/targets.conf` file. +- Added exact repository/environment allowlisting. +- Made the server status URL mandatory and require matching SHA plus request ID before success. +- Added configuration ownership and permission checks. +- Added absolute and restricted path validation. +- Added exact remote-SHA and branch ancestry validation. +- Added per-target `flock` locking. +- Added Docker Compose result and health verification. +- Added current, previous, requested SHA, request ID and exit code to server + status output. +- Added a restrictive sudoers template for the runner. +- Added explicit deploy and rollback workflow request-ID inputs. +- Added backend repository re-resolution so renderer-supplied paths and identities + cannot select an arbitrary local folder or Gitea repository. +- Captured pre-dispatch Actions run IDs so polling cannot attach to an older run + with the same commit SHA. +- Required repository, environment, live SHA, requested SHA, request ID, zero + server exit code and explicit health success before marking a release complete. +- Restricted rollback to the exact previous SHA currently reported by the server + status endpoint. + +## User interface + +- Added a dedicated Diagnostics workspace. +- Added system and deployment preflight presentation. +- Added diagnostic policy controls. +- Added Standard and Strict support-bundle export. +- Added support-bundle checksum and reveal action. +- Added readiness explanations to onboarding. +- Replaced duplicate sidebar navigation with a compact safe-diagnostics state. + +## Validation + +- 39 required project files validated. +- 35 JavaScript files passed syntax checks. +- 36 of 36 automated tests passed. +- Two real temporary Git remotes remain part of the integration suite. +- New tests cover redaction, diagnostic rotation/export, support ZIP generation, + fail-closed safety auditing, preflight and Gitea workflow-file checks. + +## Known boundaries + +- The release is not code-signed. +- A platform-native installer is not guaranteed by the source ZIP alone. +- The private Gitea, runner and server environment still requires the documented + local acceptance test. +- Application-specific compose commands and health endpoints remain target + configuration, because they cannot be inferred safely. +- No redactor can mathematically identify an arbitrary unknown secret printed by + custom third-party code; raw runner logs therefore remain only in the trusted + Gitea Actions interface, and exported bundles should still be inspected before + sharing. diff --git a/docs/RELEASE_NOTES_0.3.1.md b/docs/RELEASE_NOTES_0.3.1.md new file mode 100644 index 0000000..e6c1dd6 --- /dev/null +++ b/docs/RELEASE_NOTES_0.3.1.md @@ -0,0 +1,25 @@ +# ForgeFlow 0.3.1 release notes + +## Windows environment-doctor hotfix + +Version 0.3.1 fixes a Windows-only false negative in the environment doctor. +The setup script could invoke npm successfully, install all dependencies and then +report `spawn npm ENOENT` from Node.js. Windows exposes npm through a command +shim (`npm.cmd`), which cannot always be executed directly through +`child_process.execFile`. + +The doctor now: + +- uses `npm_execpath` through the active Node executable when launched by npm; +- falls back to `cmd.exe /c npm --version` on Windows; +- continues to invoke npm directly on Linux and macOS; +- reports which safe invocation path succeeded; +- reads its displayed application version from `package.json` instead of a + duplicated hard-coded value. + +Three regression tests cover npm-script execution, the Windows command-shim +fallback and the normal non-Windows path. + +The npm deprecation messages printed during dependency installation are warnings +from transitive build-tool dependencies. They were not the cause of the setup +failure and do not prevent ForgeFlow from starting. diff --git a/docs/RELEASE_NOTES_0.3.2.md b/docs/RELEASE_NOTES_0.3.2.md new file mode 100644 index 0000000..795c406 --- /dev/null +++ b/docs/RELEASE_NOTES_0.3.2.md @@ -0,0 +1,72 @@ +# ForgeFlow 0.3.2 release notes + +## Automatic clone destinations + +The normal **Clone from Gitea** action no longer opens a Windows folder picker +for every repository. ForgeFlow now: + +1. uses the first configured project root as the default; +2. derives a safe folder name from the repository clone URL; +3. creates `/`; +4. clones into that folder; +5. validates the resulting Git repository; +6. saves the repository mapping; +7. starts monitoring the working tree; +8. opens the linked repository in ForgeFlow. + +Example: + +```text +Default project root: C:\Users\your-name\Projects +Gitea repository: Jens/Portfolio +Automatic target: C:\Users\your-name\Projects\Portfolio +``` + +A separate **Choose another location** action remains available for exceptional +cases. That choice selects a parent project root; ForgeFlow still creates the +repository-named subfolder itself. + +## Existing-folder safety + +The clone backend now inspects the automatic target before running Git: + +- a missing target is created through `git clone`; +- an existing empty directory is accepted; +- an existing Git checkout with the same normalized origin is linked; +- a different Git repository is blocked; +- an ordinary non-empty directory is blocked; +- a file at the target path is blocked. + +ForgeFlow never silently overwrites a conflicting folder and does not create a +duplicated `Repository\Repository` directory. + +## Security and consistency + +The renderer no longer supplies a free-form remote URL or clone destination to +the privileged Git operation. It sends the Gitea repository identity and a +location mode. The main process then: + +- resolves the current repository again from Gitea; +- selects the configured root or a native-dialog result; +- calculates the destination itself; +- performs conflict checks; +- clones or reuses the checkout; +- persists the mapping atomically. + +Clone diagnostics contain repository identity, target, branch and commit state, +but no Gitea token or authorization header. + +## Validation + +Version 0.3.2 contains 45 passing automated tests. New coverage includes: + +- HTTPS and SSH repository folder-name derivation; +- automatic target construction; +- missing and empty target handling; +- same-origin checkout reuse; +- different-repository rejection; +- non-empty ordinary-folder rejection; +- file-at-target rejection. + +Existing Git, deployment, diagnostics, redaction, rollback and Windows doctor +tests continue to pass. diff --git a/docs/RELEASE_NOTES_0.4.0.md b/docs/RELEASE_NOTES_0.4.0.md new file mode 100644 index 0000000..d8ba838 --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.0.md @@ -0,0 +1,57 @@ +# ForgeFlow 0.4.0 release notes + +## Scrollable change list + +The changed-file panel now has an independent bounded vertical scroll area. Large commits no longer make lower files unreachable. + +## Commit readiness + +The action panel now labels the commit message as required and displays the exact reason why commit actions are disabled. Selected files are staged automatically during commit; manual staging remains available as an optional index-review step. + +## ITWorx.tech branding + +The supplied ITWorx.tech logo is integrated into the title bar, first-run setup and application icons. + +## Built-in source updater + +ForgeFlow can check the configured private Gitea repository, defaulting to `Jens/ForgeFlow` on `main`. + +The updater: + +- reads the remote `package.json` at an exact branch commit; +- compares semantic versions; +- downloads an authenticated exact-SHA archive; +- verifies a SHA-256 checksum; +- closes ForgeFlow; +- backs up the current source; +- installs dependencies; +- runs the complete quality gate; +- restores the previous source when validation fails; +- restarts ForgeFlow. + +## SSH / Unraid deployment + +A server can be configured once with hostname, SSH port, username, encrypted credentials and `/mnt/user/appdata` as base path. + +Deployment profiles support: + +- existing Git-backed application folders; +- automatic new folder creation; +- exact commit verification; +- pinned SSH host identity; +- existing or generated Compose configuration; +- host and container ports; +- Unraid Web UI and icon labels; +- server-folder mapping; +- tracked-change blocking; +- nested-Git warnings; +- runtime-data preservation; +- rollback to the previous SHA. + +## Deployment migration example + +The documented migration flow keeps the root Git checkout and maintained Compose file in place, verifies the complete commit SHA and treats a stale nested `source/` checkout as separate, controlled cleanup. + +## Validation + +ForgeFlow 0.4.0 has 59 passing automated tests, including real temporary Git remotes, update exact-SHA checks, SSH path safety, renderer workflow contracts, diagnostics redaction, exact previous-SHA rollback enforcement and deployment controls. diff --git a/docs/RELEASE_NOTES_0.4.1.md b/docs/RELEASE_NOTES_0.4.1.md new file mode 100644 index 0000000..bade9eb --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.1.md @@ -0,0 +1,26 @@ +# ForgeFlow 0.4.1 release notes + +## Windows Bash path fix + +ForgeFlow 0.4.1 fixes the source quality gate on Windows when the project is stored at a path such as `C:\Projects\ForgeFlow`. + +The previous verifier passed an absolute Windows path directly to `bash -n`. Bash interpreted the backslashes as escape characters, producing a collapsed path such as `C:ProjectsForgeFlow...` and a false validation failure. + +The verifier now starts Bash with the ForgeFlow project root as its working directory and passes the deployment example as a relative POSIX path: + +```text +examples/server/forgeflow-deploy +``` + +This keeps the project root separate from the script argument and works across Windows Git Bash, Linux and macOS. + +## Regression coverage + +New automated coverage verifies that: + +- a Windows project root remains in `cwd`; +- no drive letter or backslash is passed as the Bash script argument; +- absolute and escaping script paths are rejected; +- Bash validation succeeds from a project root containing spaces. + +No Gitea token, repository mapping, SSH credential, deployment profile or diagnostic history is changed by this update. diff --git a/docs/RELEASE_NOTES_0.4.2.md b/docs/RELEASE_NOTES_0.4.2.md new file mode 100644 index 0000000..a9d0cc7 --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.2.md @@ -0,0 +1,29 @@ +# ForgeFlow 0.4.2 + +## Windows Git Bash reliability hotfix + +This release fixes the two remaining Windows-only failures, including the temporary-directory lock seen during cleanup while validating the 0.4.1 recovery update. + +### Remote shell transport + +SSH / Unraid scripts are now sent through a single-line base64 transport and decoded by Bash on the server. This removes nested quote parsing from the transport layer and prevents Git Bash from misreading multiline commands, single quotes, or newline-stripping expressions. + +The generated command contains no raw multiline payload. The decoded script still enables strict shell mode, disables interactive Git prompts, and requires batch-mode SSH for server-side Git operations. + +### Temporary-directory lock cleanup + +The Bash syntax regression test now retries cleanup when Windows briefly retains a working-directory handle after `bash -n` exits. A successful syntax validation is no longer reported as failed solely because of a short-lived `EBUSY`, `EPERM`, or `ENOTEMPTY` cleanup condition. + +### Additional correction + +The remote status reader now invokes `base64` with the status filename in the correct argument position before stripping CR/LF characters. + +## Regression coverage + +Coverage verifies: + +- Windows Git Bash execution from a project root containing spaces; +- a single-line base64 transport for generated Unraid inspection commands; +- preserved runtime-path inspection without nested quoting failures; +- strict, non-interactive server-side Git settings after decoding; +- safe rollback to ForgeFlow 0.4.0 when an update validation fails. diff --git a/docs/RELEASE_NOTES_0.4.3.md b/docs/RELEASE_NOTES_0.4.3.md new file mode 100644 index 0000000..dd92189 --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.3.md @@ -0,0 +1,9 @@ +# ForgeFlow 0.4.3 + +## Full clean release + +- Replaced the OS-dependent local Bash/Windows-temp-path Unraid inspection test with a platform-independent mocked SSH inspection contract. +- The SSH inspection command is still verified to use Base64 transport and the returned Unraid metadata is parsed and evaluated deterministically. +- Removed the false Windows failure where a local temporary path was interpreted as a remote Linux path. +- Regression coverage confirms preserved runtime paths, nested Git directories, `.dockerignore` handling, and remote target resolution. +- This release is distributed as a complete source package rather than another incremental updater. diff --git a/docs/RELEASE_NOTES_0.4.4.md b/docs/RELEASE_NOTES_0.4.4.md new file mode 100644 index 0000000..22bd2f1 --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.4.md @@ -0,0 +1,9 @@ +# ForgeFlow 0.4.4 + +## Correct Git change handling + +- Deleted files are staged with `git add -A` and are removed from Gitea after commit and push. +- Renames include both the new path and original path when staging or unstaging selected changes. +- A failed staging action always reloads the real repository state so changes remain visible. +- When commit succeeds but push failed, ForgeFlow clears obsolete file selection and shows the clean working tree as an ahead branch with a dedicated retry push action. +- Added real bare-remote regression tests for deleted files, renames, and push-after-commit failure recovery. diff --git a/docs/RELEASE_NOTES_0.4.5.md b/docs/RELEASE_NOTES_0.4.5.md new file mode 100644 index 0000000..e669d43 --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.5.md @@ -0,0 +1,8 @@ +# ForgeFlow 0.4.5 + +## Git staging correctness + +- Fixes committing and pushing a deleted file after it was already staged manually. +- Already staged deletions and renames are no longer passed to `git add -A` a second time. +- Only selected records that still contain unstaged worktree changes are restaged. +- Adds a real bare-remote regression test using `silent-zebra-glow.zip`. diff --git a/docs/RELEASE_NOTES_0.5.0.md b/docs/RELEASE_NOTES_0.5.0.md new file mode 100644 index 0000000..ef5d694 --- /dev/null +++ b/docs/RELEASE_NOTES_0.5.0.md @@ -0,0 +1,14 @@ +# ForgeFlow 0.5.0 + +## Reliability and viewport release + +- All dialogs are constrained to the visible desktop viewport. Long deployment configuration and preflight content scrolls independently while the action footer remains available. +- Large partial selections use Git's NUL-delimited `--pathspec-from-file` interface instead of thousands of command-line arguments. This removes Windows `ENAMETOOLONG` failures. +- Mutating Git operations are serialized per repository, preventing ForgeFlow background actions from competing for `.git/index.lock`. +- Added explicit stale index-lock inspection and repair APIs. +- Added one-click normalization of every linked repository origin to the current Gitea SSH URL, replacing legacy aliases and renamed owners without changing files or commits. +- Source updater remains exact-commit pinned and runs the full quality gate before restart. + +## Upgrade test + +Push the extracted source to `Jens/ForgeFlow` with package version `0.5.0`. A running 0.4.5 source installation can then use Settings → Updates → Check now → Download update → Apply & restart. diff --git a/docs/RELEASE_NOTES_0.5.1.md b/docs/RELEASE_NOTES_0.5.1.md new file mode 100644 index 0000000..b0719c8 --- /dev/null +++ b/docs/RELEASE_NOTES_0.5.1.md @@ -0,0 +1,16 @@ +# ForgeFlow 0.5.1 + +## Windows publication reliability + +- Validates the server deployment shell script through Bash standard input instead of passing a Windows working directory to Bash. +- Removes the Git Bash versus WSL path ambiguity that caused a blank-error quality-gate failure from Downloads. +- Adds regression coverage proving shell validation no longer depends on a Windows path or a path containing spaces. +- Retains all v0.5.0 viewport, Git batching, remote normalization, repository serialization and password-form fixes. + +Publish the extracted source to `Jens/ForgeFlow` with package version `0.5.1`. A running older source installation can then discover and apply it through the built-in updater. + +## Carried forward from 0.5.0 + +- Responsive viewport handling keeps long modals and their actions reachable. +- Large Git selections continue to use `--pathspec-from-file` with NUL separation. +- Mutating Git work remains serialized per repository. diff --git a/docs/RELEASE_NOTES_0.5.2.md b/docs/RELEASE_NOTES_0.5.2.md new file mode 100644 index 0000000..293494f --- /dev/null +++ b/docs/RELEASE_NOTES_0.5.2.md @@ -0,0 +1,11 @@ +# ForgeFlow 0.5.2 + +## Windows publication and update reliability + +- Removes the external Bash executable as a Windows publication/update prerequisite. +- Always performs deterministic structural validation of the Linux/Unraid deployment script. +- Runs GNU Bash `-n` syntax validation on non-Windows hosts and Linux CI. +- Prevents Git Bash, WSL launcher, or another `bash.exe` shim from blocking a valid Windows release. +- Keeps all ForgeFlow 0.5.0 and 0.5.1 viewport, Git batching, remote normalization, serialized per repository, lock handling, SSH form, and updater improvements. + +The release retains the viewport fixes, Git `--pathspec-from-file` batching, and Git mutations serialized per repository from 0.5.0/0.5.1. diff --git a/docs/RELEASE_NOTES_0.5.3.md b/docs/RELEASE_NOTES_0.5.3.md new file mode 100644 index 0000000..ee784f4 --- /dev/null +++ b/docs/RELEASE_NOTES_0.5.3.md @@ -0,0 +1,20 @@ +# ForgeFlow 0.5.3 + +## Confirmed source-update handoff + +- ForgeFlow writes a launch request and waits for an external PowerShell **STARTED marker** before closing. +- A helper launch failure or timeout leaves ForgeFlow open and surfaces the real error. +- The updater records structured lifecycle state alongside the detailed update log. +- Successful installation remains valid even when automatic restart is unavailable. +- The next manual start shows a **visible update result** for success, failure, or rollback. +- The PowerShell helper uses the absolute Windows PowerShell executable where available. +- Success and rollback both attempt an **automatic restart**, with the result persisted for diagnosis. + +## Included 0.5.x reliability improvements + +- viewport-safe, scrollable deployment and settings dialogs; +- sticky modal action bars; +- large Git selections through NUL-delimited pathspec input; +- serialized repository mutations; +- SSH password-form persistence correction; +- origin normalization and stale-index-lock repair. diff --git a/docs/RELEASE_NOTES_0.5.4.md b/docs/RELEASE_NOTES_0.5.4.md new file mode 100644 index 0000000..412e941 --- /dev/null +++ b/docs/RELEASE_NOTES_0.5.4.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.5.4 + +This release repairs the first real SSH / Unraid deployment path. + +- Preflight now verifies **Unraid → Gitea access** with the exact configured clone URL before deployment can start. +- SSH deployments run as a **background deployment** operation; the interface returns immediately and polls the real operation state. +- Failed SSH or Docker operations are written back as terminal failed operations instead of leaving the UI indefinitely active. +- Compose service and container casing are preserved, so the requested name **Portfolio** remains Portfolio. +- Generated Compose no longer forces service names to lowercase. +- Deployment status refreshes automatically until success or failure. diff --git a/docs/RELEASE_NOTES_0.6.0.md b/docs/RELEASE_NOTES_0.6.0.md new file mode 100644 index 0000000..ff198ae --- /dev/null +++ b/docs/RELEASE_NOTES_0.6.0.md @@ -0,0 +1,61 @@ +# ForgeFlow 0.6.0 + +ForgeFlow 0.6.0 is a product-level reliability release for Git recovery, SSH / Unraid deployment truth, DockerMan integration, source updating and high-contrast ITWorx branding. + +## Git operations that recover themselves + +- Every mutating Git action is serialized per repository. +- A Git lock error triggers a conservative stale-lock scan, a short grace period for very recent locks, safe removal and automatic retry when ForgeFlow can prove that no matching process is active. +- The scanner resolves the actual Git directory and covers `HEAD.lock`, `index.lock`, ref locks and worktree locks while skipping Git object/LFS storage. +- The Git tools page now provides repository-specific troubleshooting instead of generic terminal advice. +- Available automated actions are selected from the actual branch state: fetch, fast-forward, push, origin repair and divergence recovery. +- Divergence recovery creates a `forgeflow/backup--` safety branch before resetting the current branch to upstream. + +## Deployment truth instead of stuck spinners + +- SSH / Unraid operations are reconciled with the live Git SHA, container state and health. +- Startup deployment reconciliation converts an interrupted but successful deployment to `success` and refreshes repository cards immediately. +- Stale operations are marked failed instead of remaining indefinitely in `running`. +- Deployment cards provide **Reconcile**, **Open Web UI** and **Repair DockerMan integration** actions. +- Background completion broadcasts update the renderer and trigger repository refresh. + + +- A successful remote Compose run is marked terminal before the secondary Unraid inspection, so a slow refresh can no longer leave the UI stuck in deployment mode. +- Startup and manual refresh reconcile every configured environment and suppress a new Deploy action when the exact healthy SHA is already live. +- The Deployments page can repair all running containers that are missing DockerMan WebUI/icon metadata in one controlled batch. + +## DockerMan integration + +- ForgeFlow applies `net.unraid.docker.managed=dockerman`, `net.unraid.docker.webui`, `net.unraid.docker.icon` and `net.unraid.docker.shell` through a controlled Compose override. +- WebUI labels use Unraid's `[IP]` and `[PORT:]` placeholders. +- Internal Compose project, service and image identities are lowercase-safe while the visible container name can remain `Portfolio`. +- The built-in high-contrast ITWorx mark is the default DockerMan icon for new or migrated SSH profiles. +- A user can instead select a local PNG, use an HTTP(S) PNG URL or disable the icon. +- Built-in/uploaded PNGs are copied persistently to DockerMan's image storage under `/boot/config/plugins/dockerMan/images`. +- ForgeFlow writes a persistent `templates-user/my-.xml` fallback so WebUI and icon metadata remain available when label caching is unreliable. +- Known DockerMan icon caches and the volatile metadata cache are invalidated after container recreation so changes can be re-read. + +## SSH and server-side safety + +- Unraid-to-Gitea access remains part of preflight before any deployment starts. +- SFTP directory creation now distinguishes existing directories from permission and path errors instead of treating every generic SFTP failure as success. +- Repository Compose files receive the same metadata and exact-SHA controls as generated Compose files. +- Tracked server-side modifications continue to block deployment and rollback. + +## Source updater and publication + +- The PowerShell update helper starts directly with `param(`, without a UTF-8 BOM or stray leading character. +- ForgeFlow waits for a structured `started` marker before closing the running application. +- Update application performs backup, exact archive checksum validation, source replacement, lockfile-based `npm ci` when available and the complete quality gate. +- Success, restart failure and rollback status are persisted and shown on the next launch. +- Automatic restart launches the installed Electron executable directly, avoiding the unreliable detached `npm start` handoff. +- The publishing script runs the quality gate, mirrors a clean source tree and verifies that Gitea reports the exact pushed release commit. + +## Branding + +- The title bar, setup flow, desktop icon and installer artwork use the newly supplied higher-contrast ITWorx.tech logo. +- The cloud/check mark was recropped to remove wordmark fragments and remain legible at small icon sizes. + +## Verification + +The release includes real Git integration coverage for large selections, staged deletions, failed pushes, `HEAD.lock`, object-store exclusion and backup-before-reset divergence recovery. It also covers DockerMan labels and XML fallback, built-in icon upload, cache invalidation, operation reconciliation, viewport-safe dialogs and updater lifecycle behavior. diff --git a/docs/RELEASE_NOTES_0.6.1.md b/docs/RELEASE_NOTES_0.6.1.md new file mode 100644 index 0000000..e922952 --- /dev/null +++ b/docs/RELEASE_NOTES_0.6.1.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.6.1 + +## Windows source updater reliability + +- Fixes the Windows PowerShell 5.1 STARTED-handshake failure caused by replacing an already existing status file with `Move-Item -Force`. +- Uses `System.IO.File.Replace` with an overwrite-copy fallback for deterministic status persistence. +- Adds a handshake-only verification mode used by the local bootstrap overlay. +- Keeps ForgeFlow open when startup cannot be proven and now includes the helper log tail in the visible error. +- Requires the status `updateId` to match the current request, preventing an old status file from being accepted. +- Records the actual restart PID after a successful update or rollback. diff --git a/docs/RELEASE_NOTES_0.7.0.md b/docs/RELEASE_NOTES_0.7.0.md new file mode 100644 index 0000000..f61ae9f --- /dev/null +++ b/docs/RELEASE_NOTES_0.7.0.md @@ -0,0 +1,47 @@ +# ForgeFlow 0.7.0 + +## Existing deployment adoption + +ForgeFlow can now import an existing Unraid deployment directly from the server. The server is treated as the source of truth instead of relying on guessed defaults. + +The discovery pass reads: + +- the server-side Git checkout, origin, branch and live commit; +- the actual Compose file and normalized `docker compose config --format json` output; +- running and stopped containers through `docker inspect`; +- every detected port mapping, mount, network and environment-variable name; +- Compose project and service labels; +- image, restart policy and healthcheck metadata; +- the matching Unraid DockerMan XML template, including WebUI, icon and shell metadata. + +The primary values are imported into the deployment form. The complete multi-service and multi-port runtime description is retained as detected metadata. Imported values remain editable as explicit user overrides. + +ForgeFlow no longer invents a host port, container port, service name, WebUI or icon when the server does not report one. + +## One-click troubleshooter + +Diagnostics now contains a general troubleshooter that scans all linked repositories and SSH/Unraid deployment profiles. + +Safe one-click repairs cover: + +- interrupted rebase, merge, cherry-pick and revert operations; +- stale Git lock files; +- clean fast-forward synchronization; +- unpublished local commits; +- refresh and recalculation of repository truth. + +Diverged branches are treated as an explicit higher-impact repair. ForgeFlow creates a safety branch before resetting to the upstream version and never includes that action in the automatic safe-repair batch. + +The troubleshooter also reports non-automatic issues such as tracked server-side changes, missing deployment folders, SSH inspection failures and missing Docker context exclusions. + +## Reliability fixes + +- Unraid DockerMan WebUI templates such as `http://[IP]:[PORT:1223]/` are now accepted and preserved. +- Configuration writes are serialized so an older concurrent save cannot overwrite a newer snapshot. +- A malformed configuration file is preserved as a timestamped `.corrupt-*` file and replaced with safe defaults instead of making ForgeFlow unstartable. +- Source verification now handles Windows CRLF manifests correctly. +- Existing deployment metadata stores field provenance, detection time and server-source-of-truth status. + +## Validation + +The release includes real Git integration coverage for aborting an interrupted merge and server-discovery mapping coverage for Compose, Docker inspect and DockerMan metadata. diff --git a/docs/RELEASE_NOTES_0.8.0.md b/docs/RELEASE_NOTES_0.8.0.md new file mode 100644 index 0000000..b5dddd4 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.0.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.8.0 + +- Select and stage individual diff hunks without staging the remainder. +- Guide interrupted merge, rebase, cherry-pick and revert resolution. +- Read Gitea branch protection and create pull requests. +- Open configurable editors and terminals without a shell. +- Enforce freezes, maintenance windows, release notes and reasoned overrides. +- Export append-only audits and credential-free encrypted configuration backups. +- Provide native notifications, tray, close-to-tray and start-at-login. +- Run guarded read-only, deployment and rollback acceptance checks. diff --git a/docs/RELEASE_NOTES_0.8.1.md b/docs/RELEASE_NOTES_0.8.1.md new file mode 100644 index 0000000..4483db2 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.1.md @@ -0,0 +1,13 @@ +# ForgeFlow 0.8.1 + +ForgeFlow 0.8.1 is a premium UX and connection-assurance release. + +## Highlights + +- A more deliberate desktop design system with refined hierarchy, depth, typography, focus states and responsive density. +- Repository settings now show live open Gitea pull requests and link directly to them. +- `npm run connections:check` validates the installed DPAPI-protected token against the Gitea user, ForgeFlow repository and Actions APIs without printing credentials. +- Reduced-motion preferences are respected throughout the interface. +- Windows packaging and compact-window behavior are revalidated after the visual redesign. + +The existing token and SSH private key remain local and are never copied into logs, backups or release artifacts. diff --git a/docs/RELEASE_NOTES_0.8.2.md b/docs/RELEASE_NOTES_0.8.2.md new file mode 100644 index 0000000..22516da --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.2.md @@ -0,0 +1,11 @@ +# ForgeFlow 0.8.2 + +ForgeFlow 0.8.2 activates binary auto-update for packaged Windows releases. + +- Installed builds download the matching NSIS installer from the authenticated Gitea release. +- Portable builds download and safely replace the original portable executable. +- Every executable requires a separately published SHA-256 sidecar and is verified again immediately before installation. +- The updater runs outside ForgeFlow, waits for the old process to exit and records a durable success, failure or rollback result. +- Release publishing verifies that local `HEAD` equals `origin/main` before uploading artifacts. + +Users of 0.8.1 or older must install 0.8.2 once manually. Updates after 0.8.2 can use the built-in updater. diff --git a/docs/RELEASE_NOTES_0.8.3.md b/docs/RELEASE_NOTES_0.8.3.md new file mode 100644 index 0000000..4352d11 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.3.md @@ -0,0 +1,13 @@ +# ForgeFlow 0.8.3 + +ForgeFlow 0.8.3 refreshes the complete light appearance with richer surfaces, +subtle color, clearer depth and stronger active states. + +Deployment cards now lead with the exact container identity, repository, +environment and a stable visual accent. Live and Gitea commits are shown side by +side, with an explicit confirmation when both sources agree. + +Deployment reconciliation now revisits stale failed records. When the requested +commit is healthy on Unraid it is corrected to success. When a newer commit is +both live and current on Gitea, the old failure is marked as superseded instead +of remaining an apparently current incident. diff --git a/docs/RELEASE_NOTES_0.8.4.md b/docs/RELEASE_NOTES_0.8.4.md new file mode 100644 index 0000000..a40e386 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.4.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.8.4 + +ForgeFlow 0.8.4 adds interactive project illustrations where they reinforce the +release story. The overview now visualizes commits travelling through a release +topology, with animated status nodes and cursor-responsive depth. + +Repository headers reuse the same visual language as a subtle animated +watermark. Illustrations adapt to light and dark themes, collapse gracefully at +compact widths and disable non-essential movement when reduced motion is +requested by the operating system. diff --git a/docs/RELEASE_NOTES_0.8.5.md b/docs/RELEASE_NOTES_0.8.5.md new file mode 100644 index 0000000..3d2f237 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.5.md @@ -0,0 +1,11 @@ +# ForgeFlow 0.8.5 + +ForgeFlow 0.8.5 fixes packaged update downloads for Gitea instances whose +release metadata reports a public asset URL with a different scheme or origin. + +The updater now ignores that mutable browser URL and downloads each release +asset through its immutable asset ID on the configured, trusted Gitea origin. +Authentication tokens remain protected and are never sent to another host. + +Install 0.8.5 manually when upgrading from 0.8.4 because the affected download +path runs before the new updater code can be installed. diff --git a/docs/RELEASE_NOTES_0.8.6.md b/docs/RELEASE_NOTES_0.8.6.md new file mode 100644 index 0000000..7ba8a6a --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.6.md @@ -0,0 +1,14 @@ +# ForgeFlow 0.8.6 + +ForgeFlow 0.8.6 gives the changes workspace a richer, more purposeful visual +identity. Unused diff-canvas space now presents a contextual animated code map +that reflects the selected file type and its additions and removals. + +Subtle travelling signals, floating status nodes and pointer-responsive depth +bring the canvas to life while keeping every diff line fully readable. Dense +diffs automatically reduce the illustration's presence, and narrow panes hide +it entirely when there is no useful room. + +The changed-file list also gains clearer state chips, stronger active-file +hierarchy and refined hover feedback. All effects support light and dark themes +and respect the operating system's reduced-motion preference. diff --git a/docs/RELEASE_NOTES_0.8.7.md b/docs/RELEASE_NOTES_0.8.7.md new file mode 100644 index 0000000..8fcc14c --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.7.md @@ -0,0 +1,22 @@ +# ForgeFlow 0.8.7 + +ForgeFlow 0.8.7 automatically discovers applications already running on every +configured and trusted Unraid server. It inventories server-side Git checkouts, +Docker Compose metadata, bind mounts, container identity and image provenance, +then links each workload to a Gitea repository only when the evidence produces +one unambiguous match. + +Uniquely matched workloads are added to Deployments automatically, even when +they were originally deployed outside ForgeFlow. Every refresh resolves the +configured branch directly on Gitea and compares its full commit SHA with the +live server version. A deployment is reported as in order when the SHAs match, +the container is running and Docker health is not failing. + +Containers without a server-side Git checkout can also be discovered when the +image exposes standard OCI source/revision labels or ForgeFlow provenance +labels. New ForgeFlow deployments now write repository, branch and exact commit +labels so future discovery remains deterministic. + +Ambiguous or weak matches are intentionally left unlinked for manual review. +Server inventory is read-only; automatic adoption changes only ForgeFlow's local +configuration. diff --git a/docs/RELEASE_NOTES_0.8.8.md b/docs/RELEASE_NOTES_0.8.8.md new file mode 100644 index 0000000..bc00cf6 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.8.md @@ -0,0 +1,13 @@ +# ForgeFlow 0.8.8 + +ForgeFlow 0.8.8 fixes the HTTP 404 returned while downloading packaged updates +from the configured Gitea server. + +The server's API requires release attachments to be addressed using both the +immutable release ID and attachment ID. ForgeFlow now uses that exact +release-scoped endpoint for the executable and its checksum file. + +Strict same-origin token protection and SHA-256 verification remain unchanged. +Install 0.8.8 manually when upgrading from 0.8.7 because the affected download +code runs before the corrected updater can be installed. Future packaged +updates can again be completed from inside ForgeFlow. diff --git a/docs/RELEASE_NOTES_0.8.9.md b/docs/RELEASE_NOTES_0.8.9.md new file mode 100644 index 0000000..a328d5d --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.9.md @@ -0,0 +1,20 @@ +# ForgeFlow 0.8.9 + +ForgeFlow 0.8.9 introduces Git Validator, a dedicated repository assurance +workspace that checks whether practical Git and Gitea best practices are being +followed. + +The validator produces a weighted score with evidence for repository identity, +upstream tracking, working-tree state, effective commit identity, safe local +synchronization defaults, default-branch and force-push protection, README and +gitignore hygiene, tracked secret-shaped filenames and oversized tracked files. + +Every repair is deliberately bounded. Origin alignment and repository-local +fetch/pull/autostash safeguards can be applied as safe fixes. Creating default +branch protection or a recommended `.gitignore` requires explicit confirmation. +The generated `.gitignore` remains uncommitted for review, and secret/history +findings are never modified automatically. + +The new workspace includes grouped findings, an assurance score, safe-fix +batching, audit events, interactive project illustration and responsive premium +layouts for light and dark themes. diff --git a/docs/RELEASE_NOTES_0.9.0.md b/docs/RELEASE_NOTES_0.9.0.md new file mode 100644 index 0000000..3cc20c1 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.0.md @@ -0,0 +1,30 @@ +# ForgeFlow 0.9.0 + +ForgeFlow 0.9.0 changes Unraid deployments from a Git-checkout-first workflow into a server-inventory-first workflow. + +## Existing installations are now visible + +Server Inventory collects a safe, selected subset of Docker, Compose and DockerMan metadata for both running and stopped containers. It recognizes Compose project names, working directories, active Compose files and services, mounts, ports, runtime state, image provenance and existing DockerMan templates. Environment values and other container secrets are not collected. + +Exact repository provenance may be linked automatically. Name similarity is never treated as proof: uncertain workloads remain visible as suggestions and can be linked through the new manual wizard. The saved link preserves the workload identity rather than depending on the disposable container ID. + +## Push bundle is the new default + +New SSH/Unraid profiles use **Push bundle**. ForgeFlow creates a tar archive from the exact local Git commit, calculates its SHA-256 digest and uploads it over the already trusted desktop-to-Unraid SSH connection. Unraid therefore does not need a Git client, Gitea host-key entry or Gitea private key for this mode. + +The server verifies the checksum and archive paths, rejects symlink payloads, preserves configured runtime paths, updates only ForgeFlow-managed files and validates the merged Compose model before starting services. The active SHA and managed-file manifest are promoted atomically only after the expected services are running. Failure restoration keeps the previous deployment truth and restores overwritten files and Compose metadata. + +**Server-side Git** remains available as an explicit mode. Its Gitea access check is now reported separately from desktop SSH and Docker/Compose capabilities. **Monitor only** links an existing workload without granting ForgeFlow permission to deploy it. + +## Safer adoption + +Adopted workloads retain their existing Compose project, Compose files and service set. ForgeFlow no longer overrides their image or container name in the metadata overlay. Existing DockerMan templates are left untouched; generated templates are managed only for explicitly generated Compose profiles. `--force-recreate` and `--remove-orphans` are opt-in rather than defaults. + +A deployment lock records the live shell process, and an old lock is removed only when it is sufficiently old and its owner no longer runs. Deployment output truncation now fails explicitly instead of allowing ForgeFlow to interpret an incomplete inventory or command result. +## Release publication correction + +- The standard release publisher now publishes the validated source and matching Windows binary assets as one workflow. +- Added `Publish-Missing-Binary-Release.ps1` to repair a source-only Gitea release without reinstalling ForgeFlow manually. +- Binary publication now derives the repository owner, repository name and branch from ForgeFlow settings instead of hardcoding them. +- Missing-release errors now explain that packaged installations require both Windows executables and their SHA-256 sidecars. + diff --git a/docs/RELEASE_NOTES_0.9.1.md b/docs/RELEASE_NOTES_0.9.1.md new file mode 100644 index 0000000..97c5063 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.1.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.9.1 + +## Gitea binary updater repair + +- Downloads the actual release attachment through `browser_download_url` instead of treating the Gitea attachment metadata response as an executable. +- Keeps the Gitea token on the configured Gitea origin and follows HTTPS object-storage redirects without leaking credentials. +- Adds regression coverage for attachment metadata lookup, direct browser download URLs and cross-origin redirect safety. +- Improves diagnostics when an older updater receives JSON attachment metadata. + +Because ForgeFlow 0.8.9 and 0.9.0 contain the broken attachment endpoint, upgrading to 0.9.1 requires one manual installer run. in-app updates work normally again after 0.9.1 is installed. diff --git a/docs/RELEASE_NOTES_0.9.2.md b/docs/RELEASE_NOTES_0.9.2.md new file mode 100644 index 0000000..c5395f0 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.2.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.9.2 + +## Emergency deployment and discovery repair + +- Existing deployment profiles without an explicit mode migrate to **Push bundle**, never Server-side Git. +- Push bundle deploys the clean, committed local HEAD directly over SSH/SFTP and never requires a Gitea key, upstream, remote sync, or Git on Unraid. +- Desktop-to-Unraid authentication can use the server password; a failed key opens a password recovery flow instead of blocking deployment. +- Server inventory scans `docker ps -a`, complete Docker inspect data, Compose projects, and every DockerMan user template, including stopped and template-only workloads. +- Inventory connection failures are shown as failures rather than misleading zero counts. +- Existing Compose and DockerMan workloads can be linked manually without restarting or modifying them. diff --git a/docs/RELEASE_NOTES_0.9.3.md b/docs/RELEASE_NOTES_0.9.3.md new file mode 100644 index 0000000..2337c93 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.3.md @@ -0,0 +1,25 @@ +# ForgeFlow 0.9.3 + +This release removes server-side repository authentication from every SSH/Unraid deployment path and makes server Compose files the primary discovery source. + +## Direct deployment only + +- Every existing SSH/Unraid deployment profile is migrated to **Direct copy**, except profiles explicitly marked **Monitor only**. +- Deployment and rollback archive the exact committed local HEAD, upload it over the already configured desktop-to-Unraid connection and run Docker Compose on Unraid. +- Preflight contains no Unraid-to-repository access probe, no remote `git ls-remote`, no repository-key validation and no Git requirement on Unraid. +- Password authentication for the desktop-to-Unraid connection remains supported and is independent of repository access. + +## Compose-file inventory and automatic linking + +- Server Inventory scans Compose YAML files such as `compose.yml`, `compose.yaml`, `docker-compose.yml`, `docker-compose.yaml`, overrides and stack YAML files below the configured appdata roots. +- YAML discovery still runs when Docker inspection fails or Docker is unavailable, so a container-query problem no longer produces a false empty inventory. +- ForgeFlow reads the Compose project name, file set, service names and image names from the server. +- A unique high-confidence repository match based on both Compose folder and project identity is linked automatically. +- Remaining strong matches use a one-click link action with server folder, Compose project, Compose files, services, container identity and preservation paths already filled in. +- Runtime containers, stopped containers and DockerMan templates are merged with the YAML definition when available. `/mnt/user/appdata`, `/mnt/cache/appdata` and disk-backed appdata paths are treated as the same logical deployment location. + +## Reliability + +- The inventory shell script is safe under `set -euo pipefail`; Docker or Compose command failures are captured as warnings instead of aborting the scan. +- Large runtime, cache, log and database folders are pruned while searching for Compose files. +- A failed inventory operation remains visible as an error and is never presented as zero workloads. diff --git a/docs/RELEASE_NOTES_0.9.4.md b/docs/RELEASE_NOTES_0.9.4.md new file mode 100644 index 0000000..650b3a6 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.4.md @@ -0,0 +1,7 @@ +# ForgeFlow 0.9.4 + +ForgeFlow now treats the real Compose files discovered on Unraid as the only activation source for adopted workloads. A generated labels fragment is no longer merged into an imported project, so stale service hints such as `geointel` cannot create a phantom service without an image or build context. + +Direct copy redeployments always use `--force-recreate`. The runtime service list comes from `docker compose config --services`, not from manually stored service names. Before activation ForgeFlow records the existing container ID for every service; after activation it verifies that each service is running, not unhealthy, and uses a different container ID. + +ForgeFlow also rejects the deployment when Compose starts a duplicate workload while leaving the previous container running. The new SHA is written only after these checks pass. Existing DockerMan templates and the real Compose files remain untouched. diff --git a/docs/RELEASE_NOTES_0.9.5.md b/docs/RELEASE_NOTES_0.9.5.md new file mode 100644 index 0000000..db9309b --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.5.md @@ -0,0 +1,11 @@ +# ForgeFlow 0.9.5 + +ForgeFlow 0.9.5 makes Direct copy deployments fail closed and adds an in-app repair for Unraid write permissions. + +Before a bundle is created or uploaded, ForgeFlow probes the linked project folder, `.forgeflow` upload/state folders and every active Compose file using the configured SSH identity. A failed check names the exact path, user, owner, group and mode. Deployment stops before file transfer and before any Docker or Compose command changes the runtime. The same write-access check runs again immediately before upload to prevent a stale preflight result. + +Every SSH / Unraid deployment card now includes **Check / fix write access**. The same action appears beside a blocking preflight result. It normalizes the linked source tree and ForgeFlow state folders to safe shared access, uses the Unraid `users` group where available and preserves existing executable bits. Configured runtime locations such as `.env`, `appdata`, `data`, `config`, `logs`, mounted data paths and common generated dependency folders are excluded. It never runs Docker, stops a container, removes a container or uses `chmod 777`. The action also runs when the SSH account is root so manual SMB/file-copy access can be repaired, not only ForgeFlow's own write access. + +Direct copy now validates candidate Compose configuration and builds candidate images before replacing live source files. It never implicitly executes `docker compose down`, `--remove-orphans` or `--force-recreate`. Existing container IDs, image IDs and source files are captured first. When activation fails, ForgeFlow restores the prior source, retags the previous images, attempts to restore the previous runtime and retains the backup evidence. A release is only promoted after the exact linked Compose services are running and verified. + +This release does not claim live validation against a specific private Unraid server. The automated suite validates generated Bash syntax, permission-report parsing, scoped repair commands, no server-to-Gitea authentication, no destructive Compose flags and transactional deployment ordering. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..fc9f4bc --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,56 @@ +# Releasing ForgeFlow + +ForgeFlow releases are built only from a clean, reviewed commit on Node 22 LTS. + +## Quality gate + +```powershell +npm ci +npm run quality +npm audit --omit=dev --audit-level=high +``` + +## Windows build — no paid services required + +ForgeFlow is a personal/internal tool. The supported release path therefore has +no certificate, Azure or other paid-service dependency: + +```powershell +npm run dist:win +``` + +Run `npm run signing:setup` once on the release workstation. It stores the +private Ed25519 key outside the repository and writes only its public key into +the packaged app. `npm run dist:win` then produces the installer and portable +executable, SHA-256 sidecars, CycloneDX SBOM, provenance and an Ed25519-signed +manifest bound to the exact source commit. The updater verifies the pinned +publisher key before trusting the artifact digest and verifies that digest again +immediately before replacing the installed executable. + +Windows can display an `Unknown publisher` warning for an unsigned installer. +That warning concerns public publisher reputation; it does not prevent ForgeFlow +from installing or using its checksum-verified in-app updates. Authenticode can +be added later as an optional distribution convenience, but is not required for +correct operation. + +## Atomic publication + +`npm run release:binary` keeps the Gitea release in draft state while uploading +the installer, portable executable, two checksums, provenance, SBOM, signed +manifest and signature. It only publishes after all eight assets are present. A +failed upload leaves a draft rather than exposing an incomplete updater target. + +The optional signing acceptance fixture can still validate the complete local +Authenticode chain without purchasing or retaining a certificate: + +```powershell +npm run test:signing +``` + +This disposable fixture signs installer, portable, update-helper and uninstaller +stand-ins, requires an RFC 3161 timestamp, and proves rejection of a missing +timestamp, wrong publisher and a modified binary. Its certificate is removed +from the current-user certificate store after the test. + +The disposable test certificate is removed from the current-user certificate +store after the test and is never used for a published build. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..2753fa6 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,106 @@ +# ForgeFlow roadmap + +## Delivered in v0.8 + +- partial-hunk staging with staged-only commits; +- guided conflict resolution and safe continue/abort controls; +- Gitea branch-protection awareness and pull-request creation; +- configurable editor/terminal integration; +- deployment freezes, maintenance windows, release notes and overrides; +- append-only audit export and encrypted credential-free configuration backup; +- native notifications, tray, close-to-tray and start-at-login; +- guarded real-environment deploy/rollback acceptance harness. + +## Delivered through v0.4 + +- coherent Local -> Gitea -> Server desktop model; +- protected Gitea credential storage and strict IPC boundary; +- real Git status, diff, stage, commit, push, fetch and fast-forward pull; +- branches, stashes, favorites and automatic local awareness; +- multiple Gitea Actions and SSH / Unraid deployment profiles; +- exact-SHA remote-branch validation, runner polling, request-ID verification, health and rollback; +- five-step readiness/setup wizard; +- system and deployment preflight engine; +- structured rotating diagnostic JSONL logs; +- aggressive credential/path redaction; +- standard/strict support bundles with SHA-256 and fail-closed safety audit; +- no automatic ingestion or persistence of raw runner logs; +- root-owned declarative server target configuration; +- cross-layer request-ID correlation; +- canonical end-to-end setup guide; +- 36 automated tests. + +The source is now intended to be locally configured and testable without +sharing credentials. It remains a developer preview until a real environment +acceptance pass is completed. + +## Milestone A — Real personal acceptance + +- run the canonical setup guide on the target Windows machine; +- connect the actual Gitea instance locally; +- use one non-critical staging repository; +- register a narrowly scoped trusted runner; +- install the target configuration, entry point and status endpoint; +- pass Deployment preflight; +- validate commit -> push -> deploy -> status -> health -> rollback; +- deliberately test stopped runner, wrong branch, missing workflow, failed + health and lock contention; +- export/inspect a strict diagnostic bundle from a failed test; +- capture only non-secret environment-specific adjustments in documentation. + +Exit: one real application can be released and restored without code changes to +ForgeFlow itself. + +## Milestone B — Git completeness + +- partial-hunk staging/discard; +- amend and signing checks; +- richer branch publication/upstream controls; +- conflict helper and editor integration; +- protected-branch awareness; +- pull-request creation; +- submodule/worktree policy. + +## Milestone C — Desktop operations + +- native notifications and system tray; +- background start preference; +- notification center; +- native menus and expanded keyboard navigation; +- configurable editor/terminal commands; +- repository attention rules and snoozing; +- safer periodic remote fetch scheduling. + +## Milestone D — Recovery and audit + +- append-only audit export distinct from diagnostics; +- deployment notes and release annotations; +- explicit reconciliation of externally deployed versions; +- per-environment recovery runbook links; +- encrypted configuration backup/restore without token export; +- deployment freeze and maintenance-window policies. + +## Milestone E — Additional controlled adapters + +- mutually authenticated ForgeFlow server agent; +- Portainer stack deployment; +- systemd adapter; +- Kubernetes adapter. + +Every adapter must retain exact version identity, allowlisting, lock control, +health verification, diagnostic correlation and no arbitrary shell input. + +## Milestone F — Productization + +- Windows installer/portable acceptance; +- macOS/Linux package validation; +- optional code signing/notarization for future public distribution; +- dependency/secret/package scans; +- accessibility review; +- hundreds-of-repositories performance tests; +- opt-in privacy-aware crash reporting; +- documented Gitea/Git/runner support matrix; +- stable configuration migration rollback policy. + +- built-in private-Gitea source updater with backup and rollback; +- Unraid server inventory, generated basic Compose and exact-SHA SSH deployment. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..7a549ca --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,137 @@ +# Security model + +ForgeFlow bridges developer credentials, local source trees and production +release controls. The design favors constrained operations over arbitrary +flexibility. + +## Desktop boundary + +- `nodeIntegration: false`; +- `contextIsolation: true`; +- renderer sandbox enabled; +- Content Security Policy limited to packaged resources; +- narrow frozen preload API; +- IPC rejected unless it originates from the packaged file renderer; +- external navigation restricted to HTTP(S); +- renderer errors reported through sanitized diagnostic IPC; +- support-bundle reveal restricted to the last archive created by the main + process. + +## Credentials and persistence + +- Gitea token encrypted through Electron `safeStorage` where available; +- session-only fallback when OS encryption is unavailable; +- token omitted from renderer-visible public state; +- encrypted token blob excluded from diagnostic bundles; +- a blank settings token field preserves the existing token only when the normalized Gitea origin is unchanged; +- atomic config replacement and restrictive permissions where supported; +- service URLs reject embedded user credentials; +- no token is required by setup/build scripts or documentation. + +## Git operations + +- Git executed through `execFile` argument arrays, never shell interpolation; +- local repository and Git root verified; +- file actions accept only repository-relative paths; +- absolute paths, traversal and NUL characters rejected; +- clone remotes restricted to supported Git protocols; embedded passwords + rejected; +- renderer supplies repository identity rather than a free-form remote URL; +- automatic clone targets are calculated in the main process below a selected + project root; +- matching existing origins may be linked, while different repositories and + non-empty ordinary folders are blocked; +- branch names validated by Git; +- fast-forward-only pull; +- branch switching/creation require a clean tree; +- selected-commit flow refuses hidden staged files outside the selection; +- monitor pauses around mutating actions. + +## Deployment + +- fixed workflow filenames, branch and environment; +- full 40–64 character SHA required; +- local state re-read immediately before dispatch; +- clean, published and synchronized branch required; +- deployment SHA must equal local `HEAD`; +- deploy and rollback SHA must belong to the allowed remote branch; +- no free-form server commands over IPC or workflow inputs; +- mandatory deployment preflight in the normal UI flow; +- backend validation repeated after preflight; +- exact target displayed in confirmation; +- independent health and live-SHA checks; +- rollback uses a separate fixed workflow and previous full SHA; +- UUID request ID correlates desktop, workflow and server state. + +## Diagnostics and redaction + +- structured events are sanitized before writing; +- IPC payloads and HTTP authorization headers are not logged; +- sensitive object keys, including camelCase, are removed; +- known active tokens, authorization strings, query tokens, URL passwords, + private-key blocks and common token formats are redacted; +- home/source paths are aliased; +- strings and collections are bounded; +- logs rotate by day/size and expire by retention policy; +- support bundles offer deterministic strict-privacy aliases; +- raw runner logs, diffs and source file contents are omitted from bundles; +- bundle creation performs a final fail-closed scan for known secrets, + private-key markers and URL credentials; +- bundle SHA-256 is displayed for exact identification. + +No generic detector can identify a completely unknown arbitrary secret printed +without context by third-party code. ForgeFlow minimizes that residual risk by +not exporting raw runner output and by requiring user inspection before sharing. + +## Runner boundary + +Use a production-capable runner only for repositories you trust. Give it a +label unique to the intended environment and the narrowest repository or +organization scope. + +Avoid exposing a host Docker socket to untrusted jobs. Treat a runner capable of +host deployment as privileged infrastructure. + +## Server entry point + +The runner account should not receive unrestricted sudo or SSH access. The +included model uses: + +- root-owned `/usr/local/bin/forgeflow-deploy`; +- root-owned `/etc/forgeflow/targets.conf` without group/other write access; +- a sudoers rule for that exact executable only; +- exact repository/environment matching; +- absolute-path and branch validation; +- full-SHA remote ancestry proof; +- per-target `flock` lock; +- fixed Compose and healthcheck configuration; +- atomic non-secret status JSON; +- previous-SHA recording and non-zero failure exits. + +## Optional and future release hardening + +- optional code signing if ForgeFlow is ever distributed publicly; +- validate private CA/TLS behavior in the target network; +- dependency, secret and binary scans in CI; +- package-level IPC/navigation regression tests; +- OS-specific credential storage and installer acceptance; +- rate/approval policies for team use; +- threat-model every future deployment adapter separately. + + +## SSH and updater additions + +- SSH passwords and private-key passphrases use Electron `safeStorage`; +- diagnostics receive those runtime secrets only for redaction and never export + encrypted credential fields; +- SSH host identity is previewed without credentials and authenticated sessions + require the exact user-confirmed pinned fingerprint; +- remote folders and Compose paths are validated against traversal; +- tracked server-side changes block exact-SHA reset; +- updater tokens are sent only to the configured Gitea origin, and changing that origin requires a newly entered token; +- non-loopback Gitea connections require HTTPS; +- packaged updates require a publisher-signed Ed25519 manifest that binds the + source commit, artifact identity, byte length and SHA-256 digest; +- packaged update bytes are rehashed immediately before apply; +- integrated source replacement is disabled until source archives carry the + same independent publisher signature. diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md new file mode 100644 index 0000000..cc5599b --- /dev/null +++ b/docs/SETUP_GUIDE.md @@ -0,0 +1,485 @@ +# ForgeFlow setup and first-test guide + +This is the canonical guide for turning the source release into a locally +configured desktop application and testing one complete path: + +```text +local change -> commit -> push -> exact-SHA deployment -> healthcheck -> rollback +``` + +You never need to provide your Gitea token, SSH key or server credentials to a +developer. Enter them only on the computer or server where they belong. + +--- + +## Part 1 — Prepare the Windows desktop + +### 1. Extract the release + +Extract the complete ForgeFlow ZIP to a normal local directory, for example: + +```text +C:\Tools\ForgeFlow +``` + +Avoid running it directly from inside the ZIP or from a temporary email folder. + +### 2. Install the prerequisites + +Required: + +- Node.js 22 or newer; +- npm, normally installed with Node.js; +- Git for Windows available on `PATH`; +- a normal signed-in Windows desktop session so Electron can use OS credential + encryption. + +Optional manual check: + +```powershell +node --version +npm --version +git --version +git config --global user.name +git config --global user.email +``` + +Configure the Git identity when either value is empty: + +```powershell +git config --global user.name "YOUR NAME" +git config --global user.email "YOUR EMAIL" +``` + +### 3. Run the local setup command + +Open PowerShell in the extracted folder and run: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\setup-windows.ps1 +``` + +This command: + +1. checks Node.js, npm and Git; +2. installs the declared project dependency versions; +3. runs source validation and all automated tests; +4. starts the Electron desktop application. + +No Gitea or server credential is requested by the PowerShell script. + +--- + +## Part 2 — Complete the ForgeFlow desktop wizard + +The first launch uses five explicit steps. + +### Step 1. Readiness + +Select **Run readiness check**. ForgeFlow verifies: + +- Git CLI availability; +- Git author identity; +- writable application storage; +- writable diagnostic storage; +- availability of operating-system credential encryption. + +Warnings are informative. Red required checks block completion until resolved. +A safe setup diagnostic ZIP can already be exported at this stage. + +### Step 2. Gitea + +Create a Gitea access token (personal access token) in your own Gitea account. The exact scope labels +can vary by Gitea version. Give it only the minimum rights needed for: + +- reading the repositories you want to show in ForgeFlow; +- reading repository contents and branches; +- reading Actions runs and jobs; +- dispatching the fixed deployment and rollback workflows. + +Do not put this token in a Markdown file, `.env`, workflow or chat message. + +Enter locally in ForgeFlow: + +```text +Instance URL: https://YOUR-GITEA-HOST +Access token: PASTE LOCALLY IN THE PASSWORD FIELD +``` + +Select **Validate & continue**. ForgeFlow confirms the user identity and +repository access. When OS encryption is available, the token is stored with +Electron `safeStorage`; otherwise it remains session-only and must be entered +again after restarting. + +### Step 3. Folders + +Choose one or more project roots that contain local repositories, for +example: + +```text +C:\Development +D:\Projects +``` + +Do not select the entire system disk. A focused project root produces faster +and clearer discovery. + +The first configured root is also the default clone destination. When cloning +`owner/repository`, ForgeFlow automatically creates: + +```text +\repository +``` + +The normal **Clone from Gitea** action does not open a folder picker. Use +**Choose another location** only when a repository belongs under a different +parent directory. ForgeFlow still creates the repository-named subfolder. + +### Step 4. Discovery + +ForgeFlow scans Git metadata and matches each local `origin` to a Gitea +repository. Generated dependency directories are skipped. + +### Step 5. Ready + +Enter ForgeFlow. Repositories that could not be matched can still be linked or +cloned from their repository screen. A clone is automatically linked and +monitored after Git completes. + +--- + +## Part 3 — Prepare one repository for deployment + +Start with a non-critical staging application when possible. + +### 1. Verify the local repository + +The repository should have: + +- a configured `origin` pointing to the same Gitea repository; +- a normal branch such as `main`; +- no unresolved conflicts; +- an upstream branch after the first push. + +### 2. Add the fixed Gitea Actions workflows + +Copy: + +```text +examples/gitea-actions/deploy.yml +examples/gitea-actions/rollback.yml +``` + +to the target repository as: + +```text +.gitea/workflows/deploy.yml +.gitea/workflows/rollback.yml +``` + +Review the runner label in both files: + +```yaml +runs-on: forgeflow-production +``` + +Replace it with the exact label of the trusted runner that can reach the target +server environment. Commit and push these workflow files before running the +deployment preflight. + +The workflows accept only controlled inputs: + +```text +environment +commit_sha or target_sha +request_id +``` + +ForgeFlow creates the `request_id` automatically so desktop diagnostics, +Actions output and server status can be correlated without exposing a secret. + +--- + +## Part 4 — Prepare the server and trusted runner + +The example implementation targets a dedicated Git checkout deployed with +Docker Compose. Adapt the allowlisted target values, not the security model. + +### 1. Confirm the server prerequisites + +On the target server, verify: + +```bash +git --version +docker --version +docker compose version +curl --version +flock --version +``` + +The application checkout must already exist and have a working `origin` that the +server can fetch without interactive prompts. + +Example: + +```text +/srv/YOUR-APP +/srv/YOUR-APP/compose.yml +``` + +### 2. Install the target configuration + +Copy the template: + +```bash +sudo install -d -o root -g root -m 0755 /etc/forgeflow +sudo install -o root -g root -m 0640 \ + examples/server/forgeflow-targets.conf \ + /etc/forgeflow/targets.conf +``` + +Edit it as root: + +```bash +sudo nano /etc/forgeflow/targets.conf +``` + +Each active line has seven pipe-separated fields: + +```text +repository|environment|app_dir|branch|compose_file|healthcheck_url|status_file +``` + +Example: + +```text +jens/my-app|staging|/srv/my-app-staging|main|/srv/my-app-staging/compose.yml|http://127.0.0.1:18080/health|/var/lib/forgeflow-status/my-app-staging.json +``` + +Rules: + +- repository must exactly match `owner/repository` in Gitea; +- environment must exactly match the ForgeFlow profile value; +- all filesystem paths must be absolute; +- status files must stay under `/var/lib/forgeflow-status/`; +- the configuration must remain root-owned and not group/other writable. + +Validate permissions: + +```bash +sudo stat -c '%U %G %a %n' /etc/forgeflow/targets.conf +``` + +Expected owner is `root`; a mode such as `640` is appropriate. + +### 3. Install the allowlisted deployment entry point + +```bash +sudo install -o root -g root -m 0755 \ + examples/server/forgeflow-deploy \ + /usr/local/bin/forgeflow-deploy +``` + +The script: + +- accepts only a valid repository, environment, full SHA and request ID; +- resolves the repository/environment through the root-owned target file; +- rejects unsafe paths and branches; +- prevents concurrent deployments with `flock`; +- fetches the allowed branch; +- proves that the requested SHA is an ancestor of the remote branch; +- resets only the dedicated deployment checkout; +- runs the fixed Docker Compose redeploy; +- performs repeated healthchecks; +- writes live, previous and requested SHAs atomically; +- records the correlation request ID and last exit code. + +### 4. Restrict runner elevation + +Copy and edit the sudoers example: + +```bash +sudo install -o root -g root -m 0440 \ + examples/server/forgeflow-runner.sudoers \ + /etc/sudoers.d/forgeflow-runner +sudo visudo -cf /etc/sudoers.d/forgeflow-runner +``` + +Replace `act_runner` with the actual trusted runner account. Do not grant that +account unrestricted passwordless `sudo`, shell access or wildcard commands. + +### 5. Register and start the Gitea runner + +Register a dedicated trusted runner according to your Gitea instance and runner +version. Attach the exact label referenced by the workflow, for example: + +```text +forgeflow-production +``` + +Only repositories you control should be able to schedule jobs on a runner with +production access. + +--- + +## Part 5 — Publish server version status + +The server script writes one non-secret JSON status document per environment. +Serve it over HTTPS independently of the application process so it can still +report a failed release. + +Copy and adapt: + +```text +examples/server/nginx-forgeflow-status.conf +``` + +Example URL: + +```text +https://YOUR-APP-HOST/.well-known/forgeflow +``` + +Expected response: + +```json +{ + "repository": "jens/my-app", + "environment": "staging", + "request_id": "00000000-0000-0000-0000-000000000000", + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "previous_sha": "89abcdef0123456789abcdef0123456789abcdef", + "requested_sha": "0123456789abcdef0123456789abcdef01234567", + "deployed_at": "2026-07-24T12:00:00Z", + "health": "healthy", + "last_exit_code": 0 +} +``` + +Test from the ForgeFlow desktop computer: + +```powershell +Invoke-WebRequest "https://YOUR-APP-HOST/.well-known/forgeflow" +Invoke-WebRequest "https://YOUR-APP-HOST/health" +``` + +See [`STATUS_ENDPOINT.md`](STATUS_ENDPOINT.md) for the accepted contract. + +--- + +## Part 6 — Create the deployment profile in ForgeFlow + +Open the linked repository and add an environment. + +Fill in: + +```text +Profile name: Staging +Environment input: staging +Allowed branch: main +Deploy workflow: deploy.yml +Rollback workflow: rollback.yml +Status URL: https://YOUR-APP-HOST/.well-known/forgeflow +Healthcheck URL: https://YOUR-APP-HOST/health +Confirmation: enabled +``` + +Save the profile. + +--- + +## Part 7 — Run Deployment preflight + +Select **Preflight** on the environment card. ForgeFlow must verify: + +1. local repository link; +2. valid Git working tree; +3. allowed current branch; +4. clean working tree; +5. published upstream; +6. zero commits ahead and zero behind; +7. exact local SHA exists on the allowed remote branch; +8. local deploy workflow exists; +9. remote deploy workflow exists on Gitea; +10. Gitea Actions API is readable; +11. a configured server status endpoint; +12. current status-endpoint reachability; +13. application healthcheck result when configured. + +The status URL is mandatory because ForgeFlow uses it after the workflow to prove +that the server applied the exact SHA for the exact request ID. An unreachable +status document can be a warning before the very first deployment because the +server script may create it, but the operation cannot finish successfully until +the endpoint returns the requested SHA and request ID. Required failures block +the Continue button and the deployment backend repeats its own Git/SHA checks at +dispatch time. + +--- + +## Part 8 — First safe end-to-end test + +Use a staging profile first. + +1. Make a harmless visible change. +2. Review the diff in ForgeFlow. +3. Enter a commit message. +4. Select **Commit & push**. +5. Confirm that Local and Gitea show the same SHA. +6. Select **Deploy SHA -> Staging**. +7. Review and continue through Deployment preflight. +8. Confirm the exact SHA. +9. Follow workflow, job and healthcheck progress. +10. Confirm that the server status endpoint reports the same full SHA. +11. Create a second harmless commit and deploy it. +12. Use **Rollback** to restore the recorded previous SHA. + +Also test deliberately: + +- an uncommitted local change; +- a local commit that was not pushed; +- the wrong branch; +- a missing workflow file; +- a stopped runner; +- a failed healthcheck; +- a second deployment while the lock is held. + +ForgeFlow should block unsafe local states and clearly retain failed operation +metadata for diagnostics. + +--- + +## Part 9 — Export a diagnostic bundle without sharing credentials + +Open **Diagnostics**. + +1. Run **System preflight**. +2. Select **Strict privacy** when sharing externally. +3. Select **Create diagnostic ZIP**. +4. Inspect the ZIP before sending it. + +The bundle deliberately contains no encrypted token field and omits raw runner +logs. Before writing the ZIP, ForgeFlow runs a safety audit for known runtime +secrets, private-key markers and unredacted URL credentials. If that audit +fails, no bundle is written. + +See [`DIAGNOSTICS.md`](DIAGNOSTICS.md) for the exact contents and limitations. + + +--- + +## Part 8 — Configure an Unraid server + +Open **Settings → SSH / Unraid servers**. Enter the host, SSH port, username and +`/mnt/user/appdata` as the base path. Prefer a private key. Save, then run +**Test & trust** to record the server host-key fingerprint. + +For an existing project, enter its current server folder name. ForgeFlow +inspects the root Git repository, tracked modifications, Compose files and +nested repositories before it permits deployment. + +For a new project, use the repository name as server folder. Select the +repository Compose file or enable basic generated Compose and enter host and +container ports. + +See `docs/SSH_UNRAID_DEPLOYMENT.md`. diff --git a/docs/SSH_UNRAID_DEPLOYMENT.md b/docs/SSH_UNRAID_DEPLOYMENT.md new file mode 100644 index 0000000..2a1dd82 --- /dev/null +++ b/docs/SSH_UNRAID_DEPLOYMENT.md @@ -0,0 +1,73 @@ +# SSH / Unraid deployment + +ForgeFlow uses one deployment flow for Unraid: it copies the exact committed local project from the desktop to the server and activates the Compose definition found for that deployment. + +## Deployment modes + +### Direct copy — default + +ForgeFlow creates an archive from the exact local commit and uploads it through the configured desktop-to-Unraid connection. Unraid needs Docker, Docker Compose, `tar` and a SHA-256 checksum tool. Unraid does not clone, fetch or authenticate to a repository. + +### Monitor only + +ForgeFlow inventories and tracks the workload but refuses deploy and rollback operations until **Direct copy** is selected. + +All older SSH/Unraid profiles are migrated to Direct copy unless they were explicitly Monitor only. + +## Server Inventory and automatic linking + +Server Inventory reads the server itself instead of relying on ForgeFlow history. The default scan root is `/mnt/user/appdata`, together with the configured server base path and the cache-backed appdata path when present. It combines: + +- running and stopped containers from `docker ps -a` and Docker Inspect; +- active and stopped Compose projects; +- DockerMan templates; +- Compose YAML files below the configured appdata roots, including standard override files. + +YAML discovery continues even when Docker inspection fails. For each Compose definition ForgeFlow reads the working directory, project name, file set, services and images. It then compares those values with the linked local repositories. + +A unique high-confidence match based on both the Compose folder and project identity is linked automatically. Other strong matches show a one-click **Link to repository** action. The server folder, Compose project, Compose files, service list, visible container identity, ports and preservation paths are already filled in; linking does not recreate the container. + +## Compose identity + +An adopted installation retains the identity detected on the server: + +```text +Visible container: geointel +Server folder: GeoIntel +Compose project: geointel +Compose files: compose.yml, compose.override.yml +Compose services: web, worker +``` + +ForgeFlow adds `.forgeflow/compose.metadata.yml` as the final Compose overlay. For adopted workloads this overlay adds safe labels only; it does not replace the existing image, volumes, ports, networks or `container_name`. + +`--force-recreate` and `--remove-orphans` remain disabled by default. Existing DockerMan templates are not rewritten. + +## Direct-copy sequence + +1. Verify the selected local branch, clean working tree and exact committed HEAD. +2. Test the desktop-to-Unraid connection, Docker, Compose, `tar`, checksum tooling and deployment storage. +3. Create the release locally with `git archive`. +4. Upload a temporary `.part` file through SFTP. +5. Verify SHA-256 and reject unsafe archive paths or symbolic links. +6. Preserve `.forgeflow`, `.git` and configured runtime paths such as `.env`, `data`, `config`, `logs` and application-specific folders. +7. Update only files covered by the managed release manifests; unrelated server files remain untouched. +8. Validate the detected merged Compose configuration. +9. Activate the retained Compose project and verify every selected service is running and not unhealthy. +10. Promote the active SHA and manifests only after activation succeeds. +11. Run the optional desktop health check and persist runtime state. + +If activation fails, ForgeFlow restores the previous managed files and Compose metadata and leaves the previous active SHA authoritative. + +## Authentication model + +- The only remote authentication used for Direct copy is the configured desktop-to-Unraid connection. +- That connection may use an Unraid password or a private key. +- Passwords and private-key passphrases use Electron safe storage. +- The first trusted connection records the SSH host-key fingerprint; later changes fail closed. +- Remote inventory collects selected labels, mounts, ports and runtime state; it does not collect container environment values. +- The renderer cannot submit arbitrary shell commands; remote scripts are assembled from validated profile fields. + +## Rollback + +Rollback is allowed only to the exact `previousSha` recorded for the profile. ForgeFlow recreates that commit archive locally and uses the same upload, checksum, backup, Compose validation and atomic promotion flow. diff --git a/docs/STATUS_ENDPOINT.md b/docs/STATUS_ENDPOINT.md new file mode 100644 index 0000000..d63adcd --- /dev/null +++ b/docs/STATUS_ENDPOINT.md @@ -0,0 +1,59 @@ +# Server status endpoint contract + +A workflow can report success while the wrong application version is running. +ForgeFlow therefore supports a small server-side endpoint that independently +reports the deployed commit. + +## Canonical response + +```json +{ + "repository": "jens/example-app", + "environment": "production", + "request_id": "3a6ed71c-d52d-4d8d-9678-96e0c9456a81", + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "previous_sha": "89abcdef0123456789abcdef0123456789abcdef", + "requested_sha": "0123456789abcdef0123456789abcdef01234567", + "deployed_at": "2026-07-24T13:00:00Z", + "health": "healthy", + "last_exit_code": 0 +} +``` + +Required for exact version verification: + +- `commit_sha`: full 40–64 character hexadecimal commit identity. + +Recommended: + +- `previous_sha`: previous successful commit used by rollback; +- `requested_sha`: SHA requested by the latest deployment attempt; +- `request_id`: ForgeFlow operation correlation identifier; +- `deployed_at`: ISO-8601 timestamp; +- `health`: `healthy`, `deploying` or `unhealthy`; +- `last_exit_code`: server entry-point result, with `0` for success; +- `repository` and `environment`: useful for human consistency checks. + +ForgeFlow also accepts `commitSha`, `sha`, `previousSha`, `requestId` and nested +`version.sha`, but canonical snake-case fields are preferred. + +## Isolation + +Serve the endpoint independently from the deployed application when practical. +A static JSON file exposed by the reverse proxy remains readable when the +application fails to boot. The included deployment script writes it atomically. + +The JSON file is non-secret and can be served read-only. Do not include tokens, +host credentials, environment variables, registry secrets or stack traces. + +## Profile configuration + +Set both URLs when available: + +- **Status URL**: returns this document and exact live SHA; +- **Healthcheck URL**: returns a successful HTTP status only when the application + is operational. + +After an Actions run succeeds, ForgeFlow checks both. It marks the operation +failed when the healthcheck is unhealthy or the server reports another SHA than +the requested deployment. diff --git a/docs/STITCH_REVIEW.md b/docs/STITCH_REVIEW.md new file mode 100644 index 0000000..0b2f853 --- /dev/null +++ b/docs/STITCH_REVIEW.md @@ -0,0 +1,99 @@ +# Stitch review and design corrections + +## What worked well + +The Stitch export established a strong visual starting point: + +- A restrained graphite theme suitable for long sessions. +- Compact desktop density. +- Clear technical typography. +- Useful deployment progress and failure concepts. +- A credible developer-tool tone without excessive decoration. +- Good use of green, amber and red for operational state. + +## What was changed + +### 1. From IDE shell to release cockpit + +The export contained navigation for Editor, Monitoring and Extensions. Those features would blur the product into an incomplete IDE. ForgeFlow instead complements the user's existing editor and terminal. + +The product boundary is now: + +```text +Understand repository state -> perform safe Git action -> release exact version +``` + +### 2. One navigation model + +The mock-ups mixed a top product navigation with a broad left application navigation. The implementation uses: + +- A compact top bar for global search, identity, refresh and theme. +- A left rail for Overview, Deployments, Settings and repositories. +- Repository tabs only inside the selected project. + +### 3. Operational cards instead of generic statistics + +CPU, queue or server graphs are not useful unless ForgeFlow becomes a monitoring suite. The overview now answers: + +- Which projects have local changes? +- Which commits are not pushed? +- Which repositories are behind or conflicted? +- Which exact commits are ready to deploy? + +### 4. Persistent Local -> Gitea -> Server rail + +The most important state was made visible at the top of every repository workspace. The user no longer needs to infer synchronization from several unrelated badges. + +### 5. Contextual action panel + +The right panel now changes with state: + +- Link or clone. +- Resolve conflict. +- Commit and push. +- Fast-forward synchronize. +- Push commits. +- Configure deployment. +- Deploy exact SHA. +- Explain the blocking error. + +Only one action is visually dominant. + +### 6. Diff viewer, not editor + +ForgeFlow displays changed files and diffs, but deliberately opens the real project folder for editing. This avoids duplicating editor features and keeps the application technically realistic. + +### 7. Safer deployment language + +A generic **Deploy** button can hide too much. ForgeFlow displays the exact action: + +```text +Deploy b82f91a -> Production +``` + +The confirmation state shows the repository, branch, full SHA and workflow file. + +### 8. Desktop behavior + +The implementation adds details that static screens could not provide: + +- Native directory selection. +- External-link restrictions. +- Keyboard shortcut for global search. +- Ctrl/Cmd+Enter for commit and push. +- Resizable desktop layout. +- Offline and error handling foundations. +- Secure process boundary between UI and system operations. + +## Visual direction retained + +The implementation intentionally keeps: + +- Deep neutral background and panels. +- Blue primary actions. +- Green synchronization and health. +- Amber pending work. +- Red actual failures and conflicts. +- Compact status badges. +- Monospace only for branches, commits, paths and logs. +- Minimal decorative effects. diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md new file mode 100644 index 0000000..12e5e6c --- /dev/null +++ b/docs/TEST_MATRIX.md @@ -0,0 +1,149 @@ +# Test matrix + +## Automated baseline (0.10.x) + +The quality chain contains more than 230 Node and browser acceptance cases. The +latest Windows source run completed without failures and retains one explicitly +Bash-dependent skip. `npm run coverage` enforces 75% lines/statements/functions +and 65% branches; the measured hardening baseline is 81.48% statements/lines, +82.07% functions and 65.59% branches. See `COVERAGE_POLICY.md` for the +non-gamed branch policy. + +`npm run quality` is the local equivalent of `.gitea/workflows/quality.yml` and +runs source verification, ESLint, the complete suite and coverage on Node 22 LTS. +Production dependencies are separately checked with `npm audit --omit=dev +--audit-level=high`. + +### Server safety and reconciliation + +- inventory discovery is read-only and byte-stable for configuration; +- reconciliation requires a content-addressed preview plan and recovery snapshot; +- automatic linking requires unique exact provenance/runtime identity; +- server-pull verification checks Gitea branch, read-only deploy-key ID, pinned + host/key fingerprints, remote/live SHA, Compose evidence, runtime and health; +- a fresh access verification is mandatory immediately before server-pull deploy; +- writable or missing deploy keys fail closed. + +### Renderer regression matrix + +Playwright runs 36 cases across 1120×720, 1440×900 and 1920×1080, dark and +light themes, reduced motion, and simulated 100%, 125% and 150% Windows scaling. +It checks console/page errors, accessible names, labels, heading structure, +horizontal overflow, viewport containment, dialogs, keyboard focus, updater and +deployment failure evidence. CI retains screenshots, video, trace, console JSON, +DOM HTML and fixture context on failure. + +### Git and repository behavior + +- porcelain v2 ordinary and rename parsing; +- HTTPS and SCP-style remote matching; +- real temporary bare remote: status, diff, selected commit and push; +- real temporary bare remote: commit-only, branch creation/publication and + remote-SHA ancestry verification; +- real stash creation, listing, pop and untracked-file restoration; +- repository monitor baseline, change detection and pause/resume; +- safe repository folder-name derivation from HTTPS and SSH clone URLs; +- automatic target construction beneath the project root; +- missing, empty and matching-checkout clone target handling; +- different repository, ordinary non-empty folder and file conflict rejection. + +### Gitea and deployment behavior + +- Gitea URL/credential validation; +- Actions run normalization across payload shapes; +- optional query-filter compatibility retry; +- runs-to-tasks fallback; +- newest matching run selection; +- repository workflow contents lookup and 404 behavior; +- deployment terminal-status mapping; +- controlled dispatch inputs that cannot be overridden by profile data; +- exact post-workflow SHA and request-ID verification; +- rollback input allowlisting and exact current previous-SHA enforcement; +- complete deployment preflight with Git, workflow, Actions, status and health + mocks. + +### Security and diagnostics + +- repository path traversal and absolute-path rejection; +- workflow filename, branch, environment and full-SHA validation; +- clone protocol and embedded-password rejection; +- runtime token, authorization, query token, URL credential and private-key + redaction; +- camelCase and nested sensitive-key removal; +- home-path aliasing; +- deterministic strict-privacy identifier hashing; +- required versus optional preflight blocking behavior; +- system preflight before credentials are entered; +- structured JSONL diagnostic writes; +- support-bundle strict privacy and secret exclusion; +- ZIP structure, deflate payloads and CRC validation. +- Windows npm command-shim discovery through `npm_execpath` and `cmd.exe`; +- normal direct npm discovery on non-Windows systems. + +## Static source quality gate + +`npm run verify` checks: + +- all required source, documentation and server-template files; +- JavaScript syntax across the project; +- package version and required scripts; +- desktop packaging metadata and icons; +- Bash syntax for the server entry point; +- status JSON parsing; +- required setup-guide sections; +- renderer entry hooks. + +## Manual before a real production release + +- setup wizard against the installed Gitea version; +- repository discovery on the target Windows system; +- token persistence through Windows credential protection; +- HTTPS and/or SSH Git authentication; +- actual Actions dispatch, run resolution and job visibility; +- runner label and repository trust scope; +- server target-file ownership/mode enforcement; +- status endpoint through the real reverse proxy; +- deployment lock, failed healthcheck and rollback; +- diagnostic ZIP inspection after a deliberately failed deployment; +- locally test-signed installer, portable, helper and uninstaller fixtures with + RFC 3161 timestamp plus wrong-publisher, missing-timestamp and tamper rejection; +- keyboard-only and screen-reader smoke test. + +## Renderer smoke target + +The standalone demo should be checked at minimum at: + +- 1120 × 720; +- 1440 × 900; +- 1920 × 1080. + +Required views now include setup readiness, dashboard, repository workspace, +deployment preflight, active run, success/failure and Diagnostics. + +## v0.8 functional acceptance + +- real-repository partial hunk staging without staging the remaining changes; +- guided merge-conflict resolution and safe continue/abort actions; +- Gitea pull-request creation and protected-branch inspection; +- shell-free editor and terminal argument-template expansion; +- deployment freezes, maintenance windows, mandatory release notes and reasoned overrides; +- authenticated encrypted configuration backup without credentials or operation history; +- append-only audit JSONL and CSV export; +- desktop notification, tray and close-to-tray preference integration; +- read-only-by-default end-to-end Gitea Actions acceptance harness with explicit deploy/rollback flags; +- interactive demo verification for repository quick actions, hunk staging and pull-request dialogs. + + +### v0.4 additions + +- bounded independently scrollable changed-file layout; +- explicit commit-message and selection readiness contract; +- ITWorx.tech asset integration; +- semantic update-version comparison; +- exact-SHA Gitea update manifest lookup; +- update repository path-injection rejection; +- SSH host-key fingerprint helper; +- remote shell quoting; +- Unraid folder and Compose path escape rejection; +- server inspection payload decoding; +- SSH deployment preflight summary behavior. diff --git a/docs/UPDATING.md b/docs/UPDATING.md new file mode 100644 index 0000000..0b615b1 --- /dev/null +++ b/docs/UPDATING.md @@ -0,0 +1,69 @@ +# Updating ForgeFlow on Windows + +ForgeFlow stores credentials, repository mappings, preferences, deployment profiles, diagnostics and operation history outside the source directory. + +## Source checkouts + +Integrated source replacement is disabled until source archives are covered by the same independent publisher signature as packaged releases. A server-provided commit SHA and a checksum calculated from the downloaded archive do not independently authenticate its publisher, while dependency installation can execute package lifecycle scripts. + +Update a source checkout through Git instead: + +1. fetch the configured upstream; +2. review the exact commit and release notes; +3. switch to the intended release commit or tag; +4. run `npm ci --ignore-scripts` and review the dependency lifecycle allowlist; +5. run `npm run check` before starting ForgeFlow. + +The in-app updater remains available for signed packaged Windows releases. + +## Packaged Windows updates + +ForgeFlow uses authenticated Gitea release assets when running from the installer or portable executable. The updater selects the artifact that matches the current installation mode and requires its `.sha256` sidecar. From version 0.10.13 onward it also requires an Ed25519-signed release manifest. The embedded public key verifies that manifest before ForgeFlow trusts the artifact name, byte length, exact source commit or SHA-256 digest. The digest is checked again immediately before applying the update. + +`Publish-ForgeFlow-Release.ps1` treats source and binaries as one release transaction. It pushes the validated source, builds the exact published commit and uploads eight required assets: + +- `ForgeFlow-Setup--win-x64.exe` +- `ForgeFlow-Setup--win-x64.exe.sha256` +- `ForgeFlow-Portable--win-x64.exe` +- `ForgeFlow-Portable--win-x64.exe.sha256` +- `ForgeFlow--provenance.json` +- `ForgeFlow--sbom.cdx.json` +- `ForgeFlow--release-manifest.json` +- `ForgeFlow--release-manifest.json.sig` + +Run `npm run signing:setup` once on the release workstation. The private Ed25519 key stays outside the repository in ForgeFlow's user-data folder. This independent publisher signature is free; optional Authenticode can still be added later for Windows reputation. + +Use `-SkipBinaryRelease` only when intentionally publishing source without enabling packaged auto-update. + +When the source was already pushed without a binary release, run the recovery publisher from Windows: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\Publish-Missing-Binary-Release.ps1 -ExpectedVersion 0.9.1 +``` + +The recovery script clones the current Gitea branch into a temporary directory, verifies the exact branch commit, runs the complete quality gate, builds both Windows artifacts and creates or repairs the matching Gitea release. It uses the encrypted Gitea token already stored by ForgeFlow. + +Every platform build finishes by removing ForgeFlow artifacts for older versions from `dist`. The unpacked application directory and builder diagnostics are kept. + +The binary publisher refuses to upload when local `HEAD` differs from the configured Gitea branch. ForgeFlow 0.8.9 and 0.9.0 queried Gitea attachment metadata as though it were the executable. Those versions require one manual 0.9.1 installer run. From 0.9.1 onward, the updater follows the release asset browser download URL and in-app updates work normally. + +## Publishing a release from Downloads + +Extract the complete source ZIP so this file exists: + +```text +C:\Users\your-name\Downloads\ForgeFlow-\ForgeFlow\package.json +``` + +Run: + +```powershell +cd C:\Users\your-name\Downloads\ForgeFlow-\ForgeFlow +Set-ExecutionPolicy -Scope Process Bypass +.\Publish-ForgeFlow-Release.ps1 +``` + +The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads all binaries, checksums and signed release evidence to the matching Gitea release. Publication fails when either the source commit, publisher signature or any required asset cannot be verified. + +Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete. diff --git a/docs/screenshots/deploy-confirmation.png b/docs/screenshots/deploy-confirmation.png new file mode 100644 index 0000000000000000000000000000000000000000..364d034646653994e72929e6b8d14643c492c063 GIT binary patch literal 140415 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 Ef(=M>^ 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

bo5H10|1V&9C^^9hXcT1uc z-1Ou!D=W8#zIgTa_6CKX$DwNl$LHe*G_|xUa2&}-eXfG)y>JGHhGdmOBkL%Mxj1yZ zZw!RooOVl8x6|$(@64>sEs?zz(=k{N8+%)TM=Wx%50Kh6T7v*5)lxu->4qGsJu@vY z!|dcf6Z{ea)g1{bGMH+JCNLw)c_d}K^ztTyV>@lH)J=h!-tgU5fYw26S?^w~k!sozm+IdzF4qj)7dhe-8pp&zWR3J~#~?jebt$ ze1&NjHd>=#6^a(xzNFDYnCFW1Ul)sgnEQ}Q?pauvYRiD@Xec7K5X+f0dphG(qYaF^ z=^6G;PQvy>$wyeJPd?P3);)4hW>?I{!(4rTpN^w^>i~17?-%`Z6z*d>d~7 zS;?hEgpSpu^R3a&w)W(=M8{l~M^3%^tTaBTfr&GV9hd6c_I1`5ZWQ5Tyg8&O1r54! zKCr#Cn>Ze!k0R?oL}u?rFSKl!kR)9pBL%_i8El$AkQ&v`l*mbwf5fqHq=_@nQ1MqC zUmV+pHarD>{uE8KzyPMpyGJ|YwMhU!N5mfIKFoma#c>?sOWr{D{9ao~T zA+K-6_I)23`6SJAcHXDHKlKSPPFQoA?#F7AjQsJ{E?IzhX*J>nPtmxcCJ~XofWwa- zh&MLwpSX(sI6BL*x?_^bqJ4$q!VRusg9^m9qPCX?J;Uxu(c^MZ}<{Wx=n?~dHpP| zexZ^}o%YPf%+~OnhM~CzlbRYb`*kL?`-xFE4>AV(G$CyHUkP{ws3)ELL%ngz)%lHq z*ZDMgv(6fSSDz$?&5=}JQ`h70*LGRiaMV8p8Ue>oB4`>}*x5;6S=yS~x~%;cJ;HOf zImJ9o;^O0El$TUmFg3D`)k{Au6k{lNbJ;&SxGg80@pNM291`iq0)>deje)ev*g}IM z8(Ujbn^hGZm7j9O_aw{~_sjIvzs1I~d{a~)PX?NQ0YzI&UDLw&!`c!iEiO7+C>nG6 zKsqodw1YxkQK5HxErJerp9(08`+l75A6_s8s&%SME3ba_SPlO65q}}NySO`K29%n} zg^YhZ^*U%7U!;`|2bvym#;+RJG73`xkG3A;8X#0=FQrh|chAp0iJ(vm66YV5vpy^F3ds&D}0Vk9a?~5)n`zd@#6{%%OoPF~#ciqEL zpGRu8g>F6UOB^jW|5n~q+o1J!LV}ws-^R_EW^ppED}fAK6Ir_RVS&pI5{di@DxlgS z;o^TyFZfo~;04IrPV$$IFt&^^_Npa)K)_i+BMYKES@)Is>4&|h$U1y)0bL-Vny40^8%iom8{16;|<&O!_gOL?v=~)(QC;?J%MS4rm`A zRtC$dI)KjtXV`S=q${f&PPACbC)Z+R+ACd64P15)1)W4ej)0re8n6v}VKV}oWK9B7 zP8kO+d;2&Fl&u7u4yJDnsx~&($rFNXZvIeptQYu+>wyS_z#d`T|s$Zo{MG1IR1 z_HEI_c2mHhBZAV#*!Tn3o|>$pthBnSw5QviJ4F#{>r`Z3J$%fN{;oE{d#Y`HIVZI3 z>zJjk_XuC<<^tvwkPtz|yz^%ox5!z^>kki?0K=bzHF61=h$xH|vW%!OJe@b|+Mvh$ zi4sRn&Lc;Zk%57ZzCH}b%Go=>%E?&>EgTZyt*9tt?i$bzzd`i$Iem1JQNnk=_siE}-1Im)yo_)E+=G})3dQs`l8|jA zpVk3sDB@W;5Kzl4@~}(eHB|qj1)44-w~wu!{aN5#Og20P&t5kSdgr@K$_v={^tcTU ziV8Oqg+yml+vp3U?iqU4c`9+(Tdu6c52P!}B0p+1OghAds9TPYO)xTcFw2GZva#tS z)e7o-IIk0L(JX_M+~G9o92Pp!dTxYriX6gx*5}8Reb-dp`*1OglS1jKwLu>zG-QCu z%Q&#SI;^|Ls3Ut>%XLaJCi&O}Vv&69AgQE^iXF>L$pq_~i4E?1+$$3%jtE$t74Ivp zXgJpMmUXnY^UwszDV=f7M1JSZmK*V%D%CaT9o`CFL!$~5VwdvK7|yW`PAhUsNN$7Z zlkBRhTqRv{d|!Iujfi9>vn*PNuSRu?^zsF~7<5>HgIq2^;?*i(+S@;7WoPH;>cJmf zS<%wMAN8G3)!=%?quppupgy7&khxBKUkYZ%s5m0P_1f3nsGM~(bU%<{Ba)6ZIVqQ} zJui=8Yo@f;I^O<@^vA-Z#wG!vW3;_psuM40%HGfkasqZP$#G|Q^EO)J70v0(hdj=R z$nXu%Y*>>^!Q`ESy``jmgAz4V-7}$)VJIE(u%Q{tFVj0~fIm)Wn+l&ufolIeY^7DC zFUZ}TqJf5tGei!OLhy2A^ysP;oW8n~5Fqzr?4&kFYW`n*PKSbC4rFv zVZ3r*YkaR;&7&jUd+5i#$5lrrCnbcmsD4^31~s}=nN&d}Xv2f}LVoEm)E~JPz}cGd zeLV&u*k1{td_pDVfxy@r+CAFBk;F?nbvxku^FFRU-4DIyc$F8x(w zGGg(yEXg=tKe&W~XQJLZisCHR+>*(ygnP`9#d)Xwg$%1tDLYMVMOm)>5uuEYbE9$^ zMIAUaD_={Waa= zr{0?Rtj`mIO7==sIhBCp`j>|VZGKMV(t6A0R&`!adI!3RANThcWnu$}h4Azwpn{l*a>7FWPbAejuWoD{>>2G3UEub&}OdRJwF|oF`bJ$@cR#T6j7R63T zNI1hG%goD5PfB5WWK&R}A$~O7-{a~^yaoJ#vLH)6{okc!OZ~~Qgd~7^tv4g!kO*|s zhaDV_BqF*Cn$L!2es_2D&!GD=97A`t#MdYye>s78P$tJ8qMossj!3!ELfLOLB+v1z zE^jX1Y=@B3*n;bzyK*TS>H;PwMoPV&s^I6(HE(E?WpI406?Nt8J)Ej^!hIKo(^un^ zA9)lO)$y;0Pv!_47HVj!2rU#cS~z(Og=bedMC#r@gYrXq%XQ73#!)`sOg$w^6CT3P3T$L$*Dp0V2KFdhCt)vqBp7s zsQC)3c+cEb?NBOJJvFU^lfFGb2)G*eK4C3wFIXSrY$Gg8-d6LQ9Nv1GQ;~VBW}xa{ z-TpgpF)fVc{QP}~U)l>>haAphx&DIhNR$X5;jC{4tD0H$>S`pU`z^OX8Bp061WPGo ztwfbZ716I$UGDZQzy$Ybl~Wg_tJB zsOH@z;J!P3v&e5Hpv>d!JXF2b_0bH_O`M6ferTo$6X|K9F27EFt6|72fR+aYv#pSL?R(y=p$nY~iQ=cX9Pa%lbzn!RCH7MeEv6Um$}5oL#twkNRg7COzd^QfZ1nfReURl9ZXP zGi)Ck{Vejl(-0w7(FD7FH&pcM&MU{R)m^+j{=8J-sEX)pCC$E%K_)xL(|f&vH^+O; zSqfU>83(7bBZ_^!2TtY1pxU3foVo|~KEh0ElC)d1->3E-vQe1b9VLnZ%OMU1rjfj@ z@5D1;u+>#@ctM#kg>nrR)<5L8ya2#4Q!3+SDZt^MhzdvIWlsX`t~!4>}7!vU&>+~J{oc8aNo za%!vp4MqE(1BE2fz7pQ1cop!0>pN%+K{4y(#7pZ5V}0$ad-`wm>vmivanmd()*ql0 zis}BATAYOvO=LmoL@;Z8wF}ueWbAXfs~)RNjze5Gn>ax87XVKh$BU-f+@yEFAY!yu13O9sYNETX}f%pRbAna?|8ye0Dz@ z`SlFTxxF8)8G<`irwOjWl%Pt`;tf!6y}3z`N?itS$b;L?;-5nYl zrt{rmFKB4!JI|k@ zetAKhH-U!s8cph(h>CmK!6K$Mo*5Qc@9La$>%B@q+5U(MH8ub;l~c7t=ITRt!QUq2Hl`W@qc7wIHV z8UEMbnRu=LT9yOp#E0D!IC<9vIAR2`6J`D{kLjssnRL| z!K;MjXodc;%6EUq`aUEf1(MSh$zoxS^;3_I^h?f6QSU0YhCU@hYb)L^qV)a<0Kl#E zV=`iy3Rhq9i?qssKz-x*7ZD6Q!%Ai|5c#iasyYRm)ika3lkHgrRnw|ElmJ)F&Ns_a zM@6}6noLI#VzFyNpuGmd22&XB(U6_mU7q9wb(cfV*@-{J zYB5riQ!;Q%tFC5vr`tyd>8_tV)nks(jv<2OuzsAPR6pIxVPGe&Zj9xqD74%q+d-P`rIGkNDf~}3`;{5%yAuS?m zrhIpb4c8j^ri8a1^%v?X9-mP*FcV$+$*4+LXpNxbCjFt~u{{A1KgoeZ2p80|t19Up z;;;_s?Ck98lbEhA5nhBjIy%zD#0d@b4_lRW>oG9eq)%E2nH6{l=o*MThXYHaAVje?-}}exK(~)$9$J+35>)l#?qJ-zG^;A|W9T#=uO`(jZPwS-ft& zG>1q|+`DrR_qO*9xwWzMm9AxX0O;%D3_x>yJbYf-yly2G6=F{!LdvrW8pHfrY*kfN zm8CruC4VT=8qfV63uM2h6auMTF_bIuJ2>rrl~7-e9(KHcIudQHioeOJ0%0id{-P@y z|G-@0%@9Z|V6Ecf60v+KBWpJ1*0$1u4A$lW@A-ZQ+t@5F6Pu|=?N$QuAXv*&9bSRx42P=^Q#Aa~MgO1*jy_TKIZF^tlD=H@! z0f8({0@iMfZqKH{H>-DRYis4}f1)UbZCBedf`Y_R>6dVu($#SV&li)Fj^*>W!a|7FlS+fjYo*4iw{bw5L z(&ZDNz5U4j?YUa1Ht3fgi+Rs$==sB4vDt76!P~cdb`Rd3b+^~ccNeG4-nVB^w`HTb zb~in6A0r@-B}{W^z$5I{@gp}MTet`B?%r;1kMqI^$E&BLw7Rh-WVU(+cC&r)vSqlb;Q||b zf|G+OLzt0<#%#GO`6&aDn}VoO?2~}t#Ds(&|17C0D_`v$Q304~XaGdS6n&qy2j5bv z?Ck7;=4KWed?R*g>FF(fp1q2Sz{Y6-H^OMCIGN}N#*5XDA4Hil_#Y-XclLK9-_1Sl zMev(38y6PR;u6nv*Ct*MYPlt=t4DmX-}GEO8dFq{jkeHv{QlkRH})6I*!XCmy6Go! zD>?CN4vJBoQN`d4y%;A}+Vf5u%ioTceeF=od|ga(fEB+l=khV%xW~>e=__I`%VF5z zX}Qv6|LleSUKQ_GrTGlWq~vJVvzZS@ca*$vl?xzEeZt^3&`FiJtr>H1Yxg-5Q?%3MB8U~z0Np@xZJ zLr4r7o?AeG?CXZFc{pEgsjBI=I)2y)flHWfCFFF=Wm8u4g@(cP8u8yc4=??;m|E!4 z^LcpU<;b9_f%wC}-=Z-NUO{bOf}Ad#R-|{Br>tJCwy9iBEjh7HU%%duqCYy;ua!Sb znZrgrkrPuL{5?tsU|+uo>A0X;s!Bdz5NtMwxkc$_fbW^QqQ~SkuRHS3%@nXH_)>g- z33%G$3f_Al8~pFJQ#j3cB!R%nMgE#G@yKKrPJs>IN0E-5!#dZ^dt{^AUw7%dE4zKq z&x@)!iQP$2btcHI`JtQIR1IBk$RPZ8?ec5lSKHl}daA0YI|d^@V4+s%W813rcN1F! zLEkeDBfRsmwQNA*(3mzo8K-GXB%~#!p`kRLiahw>=I$Egl*n6OC-gH)u*{6VwqzhH ztUq_!RN@E5xH@NFfG_eim7*DeP01-l!+$>VpkomO0YWYJVs%qDk5PZ`*Z0n(a#ne7gs(-ocwA8nlf>*aP;>3^d;N=5l8TPV9*tZ);ULM8j710l9MG@Bh&?-8BEtlFP;W;^D`dn7aZ8KXjEeufa#($+91B^IPMr*jHA?-@Q`520Ut zCD9QLD9sMWeH&{bSOxHOy{@XRiHnYwk{fRF68=#Wyr%_9s&C}oHMb(bC&UOAAFIG_ zJC6n`84q`|kx%!#p6)b!bwZyPqcYhrW{+IR9ga(b#(eLOWg3g2WZ_FvI2Lj!AH^91 z5SnQ4A~A_mQ&hppS5swPWDF}1aG%&yfq zX90%j+_cj7F&8ky?NEv#g2}1NNvKwhZd9pSc|`?;-|dy0ck@vy>X>2HsR_@=#3h;N zo>teS5frr9vx1K0d@JvBpQs<>a!gg6SMaCaNWAGkvkxJ>cZ{gc@>wK=;r8{ zE!-U%igk2EQMH+plETqHe9Sfe>IicoTVGO3U4Y}^z#~jXCJc<+ozDrra(8z=-&+bR zUEkXqT0ixjJsI4TG3%Ctn4Y<_=PfsGF=|2}DQC#(4BdKX+qF1iYaX%N^4LnPMUn*p zye_kWP>Ho%ID?r2x!&CiPT>&JvIgYy&iH(~q2qxAtm|Dnl7Pwl4ZZ3l_qwQM#bkY`Q&zOd6SF=R) zyw7YsE!P$>`e)nU zc~I2hsG_D)#nb$De0+R(u-Db)T%Ye(3-=iy`F~n~pM$jFLM%M5rlzKjR&Tg%r^ssM z6k1iyu_ME9S5{IY!0<6BIvhj5va@ZbuCp04DmX~u`gprz>eSh-rix`Ayt6R{zA7q$ zdb*U}`O0TSWr~+M4X3Sd%Mb zk+Jhe76zW1h(juxPr|&oM_Nis9ydG5`@T=EYNp~e%3VrI2X4I|e&-aR2Xx%sUQJBC z$(ZgHve1{jfgrmpDxD;7C_mBA2*I<}hi#jn(DX-&7UxSsYPCH}=-76H>$osIpU~&4 z-wB6ayHw*7BqY}HD%ZA04SaJ%umu5MM0QMewnCJx^=;cjbB+<^!OivSO%v4SmXXu{ z&lmE@uZuK2?%$%GRfdbZ+9?KCBXZzsEzC!JImN}b`n_6uKCQdRsMe!NGqm_Rw@cl} zel>)W!6IT=_+jm?1wDu@26(u|?7JUk$Nf;|&|g>{o9wD@ZH+RU89Vwa8|Sj&C3^Zk z6W&7?rnv0vw@9qFq~w?92p8wDs0av$%V#m_fpZJ7@6?8SmkowM`cWgZH(U8)6i6rD z#Ykkez}^y_xC-}_SE(`UuEsIrz1P_`Wok#q*V*~4Ww6`cn&jix#Kc5OzE7_XkCt)6 zqrhMAY>#ZC2yd-qw6z7Og!+g5k5C?jp0>6um>8GBJMNr!ChXCYhMM~FXfoCIMaUZE zKV;4D&8ib$)WLw_9t5flfp+~0xlc$Ven@^JE=ZbcO9G&jxi znJlm&(j!RBznR-xt9-kLv|LWF>`pdURb3qH8mS8C^7v3SZZB85QPM|+V7WPz-kGj% ztcmM=pV=)LaBdhq5Bg`ZbW1(>pT(SX@8csIJoZyNi9u^|U0W_>sD|#4Aowb`p&>{d zkBUU{R;N`~*j|v>dQzfvZ6O(NbD<*h?g{TK2o=Qr)o#8tDk%YrY3=UMTnRQeEiEk- zbOK-y->r^O8<2o5_1#O4S_&a*YU+YW%G{WdmAmVEyURWP@1?QH(J9Fg`qvZxEH(ml zx(bd}E-)BYhU9=RWorB-RrbOAd~!F-4f$sJ?pG5^eLXw#aWyrO&=kJ-65EBhiQ~3p69eA<(9arT8 zb#%**FsyT@*82;cl>RDN1A`VAmHzg7$ol9ZjA9yiz@7nwo@*HGExqOe#|6*APbNmopA?g0)Af#sd^`EZ=fpA; zahaZWfINOigpWU(Vt2P(TTKW)SI_)R&DqMcN}6jHwl8UDfid598MQ{ zQNiQx-hL(6Yd#?>S}NfDGJhzk(ftlW4wo$ZJ^agQT3XXW zU?t7YPlzqiaC*LE*n8m#E{L_|paz{^=!c8478s3x2c7Fjmmt-|u8!#$Cd_r(s~Q$jmi2w|3pqR1)r^p?IvP zMoxZOlFV*4BByC?X!tQ5Cj65h`7okiJk#~^<-X^JsY#gEa{z!&=@xl+-5p83esGY+ z^a2`5&cAhdXkuoku13Fv%{6Q(T&3mga;d61IfXnaTsQO(TdXaX+5R!c zHgdMlu2*XXeN_p3kjkn+0KmpJ6R3-)_llwz2%;koi+syh6Y%lx;^Xh_?c=B{3lD$p z;h4~pWgsitIj%l>;bzcRQ^7`IqE$o}f_#Q!RB(&i`u&CFKkaj#dTsdeGrN|)Iv8B_ z`VxMHG0qtUDuG|Q5w|0a1@ORXMslvCWGzQZUFu(2y4UH+Pni)x`S~!lUO+(x$!>hb zW-_tG7~Gp1rc1_uS`Awg93NNqKfc}GsJdvk54^n<7te3L!%$P z^!1&2Jw#*_eG&fY>fp-G!jX@q5FVwQsEk_Stf=^6ExR3&J~yY}pyY>=i8Q6#V=FnT ztJueYkcMXnSD!`Z?_(eNNoBT~uHpcpxoY_ zOi`d{`WUsVEmF^eb7E3?oIMYh?lDGQT|gjr?G#FaQwF7k1*?6X9j_3p%&jz!Zcx`b z+ewKD6zek0U(2BhU7Cp{19{vA&S|cl%eQ|t^!8~wIJCyX!lEk$&@mdNj7*U1*~Q~y zsD@%;0Y67V^&~GX=4mfUxlJ`39A22{T0iYU6Tp0xNBA_Wq=|>Ih-sBx7-DdG9y%MM zDMB@^`zt=SvYddXiQSahP^O`-u3J3p(c<}w28mWx4bXf_ILr9xWKL`LVw2a^ECgj_1`gST$j_>+@-A}4!qCzt ztgN3N)(oz1fZS{Gx&C>;Mx< zMmGBIA8=@dnU8x-!otEXFPw40_bM1GJ}D9*)UQP zk}_O5Ia%UU;VWqcs!;qgCHH8P1z7k2lh^R=~^>jo9 znQ3H}+X}04Z-VWngg_GcOYqkp^ApBb7+(uc24pFfzQ!22rddWJb2r3LMgq_8s9!CoN=<%Db4FPYlKpTySZ zRr5?m-=6^(?N*Ol7&|}@S9hG=4*@qboj>YqmxKnV^O+CXDqOZLCBY1z{H~8Swv6v? zuJ*S!#~X*+F876OZbj55I`l;Ic9KdTI*N&gISUnkZ|c_D2U%cmT3L6B_bvvq(L!YF4oQC489cac%y@I z%s5!N26_hM6^50SlxjSA^wrRZA90eoGMlm;kNwsoO7F`jWunL_06sI?yQ*<FAy*l?$?#Cl-*GIG`ZoljOTI~tAt-mk!>x$u*R0dEq}=z zlpQ?HWL=sx!()j_-`5Asm(30q5)vdQ>#R3baUO_`kB#j_SA9jnff6WyYHF@)ogrf& zhtoAD>|kjafg~Z5#>wrr)!oHzN7X>U56pwL%V{!7%5KR9ldeCVV}w@+qwHKvu&OGK zPaG3dQ-6l!1rRHxSHx&x7YCCx{D4?rNVZ; z8tylk^M^rN5)!WRDW}Q#n`i_$6h+GGowzNbRy#c3_$;Bx^%y?KCIy!lI2CN;uMX$y zS5p<}+MQgFM@|d)0xl+Un-&fpI{wfL9NdXxhs$=$rp2edo4%hRV&K;Tu#Zc0)O`M& zM-KpuQZUgo#X~402L@$lvbwQxE|EtIjYmYUNi$|9cH~VK9GiptmB|a3Ttl%+g#-)g zs$|7LQmSHsK9Ycmk-|GfX-+9!X?{+rs@flu3>7^+bxjx?-t9V}tga4Vps#n?e)vsQ zP<-S4^5u9wq`ZieBUD!%1gg*SqqtL)k{X@JJ$fer1Pby}BhMzax)pRL#wVvn6wS~e zQDxbo$T}0Ua~XceN&!yw9ym;Lcv!%J2HD0bKK8yyKBxcl=Rb{Jp5vv#3K{U0<&C|m z>QT$sg27}K4^vC!_QCzcTX<@RbWr@Fd|=xo48B6nl{;SakLQWG!^ba%jn-skT@#{GOWH z<9Nx3qkOA_vp^nla~>sZj_8{#o`&mzE6%1`YgN_MG&MC5i%oCdlBocs_&}`>2rCCE z)=jW+mVnv6X`W48YH zhqX8f1qE&fmHTxqMg7-EU~Wzh9x<_MZ?W-Q3Jj+E=qDN!{>}+>W<^k2!tp`K^~|br z_ho2ADe_|WC9RE#m1Ut74h5gGnAl`xYHejPzn2nP(XW*|%{jgfiHICRSpv_S8^kh{ z^d9h$p=W7SYI%f(er;EnrXpoF{Lj8qJqbyQf{u-*rgZB=z-}0kO{ORj(cc{K3dU@s zla-0bZ6hTtUC3*7<^{a;UjJ~6TYU-VWPj33pUAAXfXR3MI3*N1rv;d+AE6R}l%iTW}A8O>G40%-A1!C8cQJx3zBe zmMy+3f{z9{pOaERJA1_8B-~=2SgY-McGr{~{w-?_ZkPEH40QB{4_>yqyjp|n5gB|1 z$8B#Gy)G%aO@>ZbW7my7Ht=|*q-Y7dIG9k2E$c2>_hc6qj?8xhxZ&TgSj1T3G%gR8 z;<3mNN`i5YtgJs#P-S$l6O;U;K}FAk3u&Y}W-78xVX^3{nHmKO$49yl+oxH-Eb{uK zbu~Q;U$Ddfrv(u5yEm0b|NiCYqn*Alj-{rmy1TV2ooZ%fWMO0ShWzfkk21<(xyZ#j zI}>aYoK#VL`&M~})_!B;$&sx**{klkF>NWO^52CF@dA*(j_{IEPL9gJ^3xZK%>uIF zI2JE4aZd7lx+I50(>QE%{aTnzTwS>ms%Sjo1$cQubG}zUzUh#c{zmsUG47OTiKFmz zb3!d8X^nnegR&avXP51+Uj(g7$q18^)YeZ(p9JYbZ2@(S`JHTuR|iaVbdXB@+3zA9 z$2U3=q1ZN6bAwhy<1Vqdy8Sa9`?P-v+ln+%NvYS@u5fl8Anjb&<;v=JBeOC$Ma( zPTj>Ux!kBK>|2!Yi`SE+*U$v=>%CDPP!!`G_LSX>qOOvV>4iBJ)%rqHlhrf~uGiW! zn&RMa>a;zW%I+1Jm9?tlcuYa97M}a)BriYI1;%pm0RQfQo6r7tsaj7cgX?~2#Vs=iaE2%|>g#OZ9;r;MoYGmeOeYiC*<5He% zHY5Ia=8Nq;6x#ZTh$jIuFF)z(!U%qKkN{)hqz0*pbMdY|;q=MxN|_56Uu^a2?wh;n zw*x=i2E)U`Qk#wjl4B)k@x87)hAS#7{Eu!)iPtRyP;2ePQ027v>R`dp#>!4#zoZ1} zi9EZlitB7|e~Q4d8}i@euX(t7FO;9$h2fu~2iD}it*(#FA5u|Nk(QBq@L6}`on5P3 zg+krI$gA~EI`+E@Qf(ELvg75}*jNe{8^0;1ip1D~4z4VBAwnI%~xyI3#Qpg|v@XgZ*-)611;T3HOgmjOQOmfG;shNpM?S zEG?mS+PtF6FwSb{9rEz!&3eC52R9S8j0LZwh480 zBK2`a^#XxNLy=xu{ke#vFW+fi0B+;#dy0xV>KM?-*I(AfxA%4bvCFpqqN>WM=lB_@ zB=Uf}FH|JVE%3?SMN;Rp{pK2|p?sXb2KlF|ax5l>5qv*Z`?wMUG#gTUdri8jswOTG zXmjtN^RoN7B_m?f=stHklAO>@INL{+;Wj;BFubCY?X!GxG$dh6-~2XVeJ6FMx@<3- za&4wW+Hp~2%K)?3(@3YhC_$1>b1LT?GdY--NXyYMoqZg_!O7|4b)DauRBOAK9US{v z{{H^F+QY*`H0YHP9{?y5JTT#&PmWE>t0tjt_t^T#GHkqgm$6vqDa3O^f&SNIp*e8l z-c!ay_lHlzwu`7>{;EG}Kk1Rn$caBP+}gv!0(n}=7Ze^|l9HcasFsut88OGO!i+L) zaS=8{FRO8JcDUyAv1UHU+CAEoCmxC4Kji#~^##-Y3b&;syaXCk(!?O4ruOZ(eT1U- z)(V~ve98A34_!28oaPnALd)-}>Mib;K;uo6EW_v#PvB8+<7Day#K*^{rb(UFkv2T| zQ55%2x{yPXkL0PEudZzX>pxmB0>Lu+{tOBg$?5O*W9(WlO8{OITyf?(b*$8bV{zC~tf~pW_h_Iu+nXP=}my;$VfojUE zFTYY2#)@SHaIp$$!GVJgc;sQ zjB(3&60~R#e&8bk% z8%8K&^VFv{Fwj(-4?UvZ<4~DYSFx7hF|G62K>=E^EyGo?*{iAjU{$SY+^`Oh60n+1 zJ2f$`Oog?yy$oiWCq?k5q(lk+bYaO9%D^mQ@Z%-muhF25c%mZt2){%V>RtS7Dj}l_yS|}r`VWCFY0=x4_<>Jryl;e$H@}g zZ$mqGjy$LjPh{iH=p`ogx-M(XCm8{hQp~Y!m_*_$HqizD z4}v6L)X*TUq?GKZ9_OFU*4#Cy@&yIVT?$+V=;^AJ*jfK}mVPEqxM?DWE!hKw?pHN& zwN~()Au<-SIi&M9@FzD3RP)viLS`J`dwO9ftL7k86I8nJ;l+cNs6rD>p}6bwzf+SQ zC#h1LTk@xv)@VgFH_xc2n89w#n~VePyZZ3B@J)5KYWyb3#3sEVpB4&DbU8pEUJPmz zJg3I{g$qps3B94}@io*2@E`o^eO6W;&1;oJe8Ytd^@s#mbXtiv$im=!@-MXaFI$H~ z>i)ylp&&aHTld@&>Q1nkCzkGH3?st$4|CXNMBAR3u>$Cq*`0PL6KFc ze$~O~fe>K`Kuz(PR}ij{v+sY&L=qHcq^dftqNo@X-|=gSnLF!?qK=NLl5XF2wIK^0 z>SYq=Dt%6}V*hXSqOZc=3HaOoU*aAA-inWt7bt`If59#PGdm;sv_$fexc`B^P-G|NgiAKeCg5|G>xj|0$mN|JM-JX1HY&6BB|B9sqj!a$J-mE*gMtk!@9bG}qv1 z{UJk-fW)~qJ3H-v{3x3HXSRXLv)#q%N-MYBju4>fI~3GJoEn)SAgU=z;CXe_?<^G8 z!l-c;R4k$Xe@-=D?e>me9xOT<;(0m7du3p^{9s9vLiPp->_{ZEG&hG9TUFTyCaBd= ziAUw&hvz#vIu1ejr1#C{g2PKn{QYB~Q2Kv&3lu=e;o%`>BS=L}zzLdtM9}hm&B}yk zHx;sIDFTrbeO1d<&NwKGG}&Z9C}O_B`lm_S*1=#1aPq8rue$1+nMbs%Yveg0GrXuM zBr-HJcnEeRv+QoxzZG#9XAmEsaDREHfPEi~LR1tWta^x(o)&U%p{Rdf|GRpae|AN5 zvYZP+)Nt|ZkZ%KE(%_Eh=y=ZZvSBF&D9rZgxumX0$Q5aARTZ@Gi;7nNm*AD2o}RuQ zlGNEv<4=Y`P#d|h1dSOM6?pyziAf12CMRfdn_jLe@o#GH*7Ht`NwYuO5rJ;G zt?%8kcNg9FUY02!*01#l2?#GpS)6YQ%O!9RUp5@u*;v)O&(5QS-X`}44s>RA4>6Ah z+&Y))>}>zf(BfNJn)SWa&BO7G&;M}xXJ_AMHu~1jt|W*a=DhOw!Epz%g$ zn^)~e(IY|c4?b1S7t*0&h9CXkQg%)(Qxu_e6~J~BE``wVg+9}6y|s;$mRdzl2^u#7 zPEJHP=Cg&K{@$1)EnkA;ktG}d6cSg}Y32;6=mXimYuJ5vMCk+hhPtYu_7>kq)(?lT zshmjNYG z&F#&LlT)4Rj?Y*5xh1j^-+Ak2>FJ?k?MH%M+fvE}TS|MzhL(hHoulO7l9tlSD#l6a zK15>Fc|AX0(>pj`>YL5FInXoGLL!2> zOhq;MmgN;C&AEmMn4*L_cxFdxPm?d?bky+-tNHF#dAVEm*~y3({sK?EdtXn_3@;CW zo=zeVQ;@S6+L}K4=0gJ_2%kmjB>>Q8;Uz5-x**W#=5U((7Q|csW?e7m3q2KQ^j1Ti z$L$6AnFjLL-9>*=rB&_HWglr?X^yb>06q@m;g10qJlcUb^ zYR}|ogx?6)*SYO46$>LlnFBIfT8UFRYRATAo`)1VUjIVeCuieh?9e~Sgb_usHTw#2 zf&Xa%7W>59oa(^tUs{@dZDFE!dNSRGMfJ?nq!ag67WHPwelGLJ;*QFwb;5R6R#^7E ze{-x8cYkV6paJUxGl2cPsF7Jx|- zZXk%RQ2i|NENEADJND*19~C1=V%dHn7sLe9eNqcA+l(W^Ty7oDWYg+dKlMbs;$Y#R zqhs=R477y^@{lE4Z7XaTo6!~*$|u+NOm+ZvMZz=lKQr9eMlGQqkm)qkd7_YZs*WRe z#y_?N4f*-;)y)kJpA!KOM$&%xHc>T^OE*PMI1#c&IbUta(@?p!4*#cLpLUT7yPlB+ zGr|0xaL?w-YCl5s%5tX(CY!dw>3WmK{#$#ya@W&Lkf9-2RYk>IF=ywCm@Yb9!bR+e zL312l&~BETQUjQI0^PXE797@8zjv@U8BqO<7AzLKNto zp&>tHi?2UB>n0^Hoh!PAc%qiGUsO_pLTx!%S=9?XG&Q$}$SB#Yv2F0XH0XMzcpMM} zirPt(?^e3S1@O%jd&YRt8Pp7PnM~j*b9`fOrPFuB@lMO5-E6f>^gt5B-;V&PJD*Eo zMXErZbShrbXmyZMQbs|sUE5G-2)2vk{@i3EH-^^rk#>P(m<0|o2qY(GGdBc8Lx6EUH428c+TYnS6;5#8-Zn|qnlrc9FE^9p3RR;n ze52@z60Xn$-OkX)*A7*bRCDSg1noFDd9zU9jBjvtc-1EWsn;BC9(CZq(NoiavB{?W z%@pk%;~=&LGpeq@0j|ryCuw17e)ZpJUZ{k(*V;WQ8PP~lD(^o&>?;@>Z!A6Dgdry7 zGkDg(cjD*8ebHo%B&5PqKBGM|aYyXb$zQ)|NKV@>rfV;tc&C188Rb~KqF;JC@u~g% zZhJ}2Z)(Pa-HrB2gSFIBq9LZoJH{qP1=&y%f;Uquv#FX2j$`P|#}GBH8UYIC9pAQ- z=ykFN9t#T#;mgf789)Dc-vIH2MyCL(j_BA}0R6$lE@v>+!+?zZkJ8x0)O4y|TdXsu zyudM)Ff6)E3vZo|O2{$|mFr(r$t5`T;DY7MXaWLltV)RXL~nI!Snza60KXN4#|z}S zrSJT$%o&znb2Xy0*7`Pix7XnJlb8a$#C`$K4+p{V=bcO!Jn7wDCVrP}|4r7!q^n_gx-1ayvqIuk zsHM1+gOaIu%HQaI-gg#s@cw8DcBF%cw}#q&E>1+WWmQ$s1x?(Hd2Jbz$+< zHp+%ginJ~+OhZSz z7Iam_9`(FBjhC+zySv#=&T_nIcv)c*#}C!j?L8+eX$M2|$j~>q+IciIGt;5rGY30GYi?sZq){qGb4HS@ z$iTA@&iv4*#IQ@LPPI#u2g?$hO&Xl!sba7yd^}H8sbE`#bG_{eZt!G9w_^SNDg5UW~A1jS6ou z72_XiX(c(8fIvAERt_B3`!Jy5{jQgLzc(G7Dk|T( zQ{UX_SZS`Fs7}l;;zHHDe>Xjj$0!j6iT8M z+r8fPE$Ca&GgKV{&O5Hp)U1(bRDE3^ziRPKsmdC&ERv*mMfzPDdl10cs9Be$e$S4~ zFmUrz(XkG7G}d}?Q>wbip|%bkK~$bqa`%h+K|XykR7)}c{(TTLF99nJ&G?@s3)G<> zHwc>L8}aRl*FO9}&oDBjd;c|NEvMS$G9r8sRSS`v5Q5#fTzveCHP+PBEPuQ&@GGhc zPalbeZyosT*~JBU+rLx$@7z9RkPfRsUhWsN;%RFyT8hcKs%`D!1y{0D;(+l8-fYd5 z@^4DeNxA6Ayrt-b2>6zn!F>h2Zn1u}%Mi%}_j2#-?{5$&$8U#aa%MsRg-Q0-N5F!k zu{YA9qfJdrVv|n3FMiEf@>w^^e)s*AvCTGZ;TwE{&E+QV{E}P^P5C0i#dp0k%42B7 zN^w|9`kj&pU8V0RZr}a%;td?)V@btyy zaLuff_M!kGpJ&Nhb5j#1Iqd=dM+nXt6{cEQZQo-;t#_2{v#Tv1&#{5LrL(-JlD6z72?s%DI}IVBRxKg21PhJJ4S>=>Qi1(@;IL}3y*x* z-v_8IH~EGzI=i|?L`F7w?vkOO=own>{vxCN^Y3SCduejGqV${5!UH+WUN8*dY%U2W z#eb^|>UDB*866wbd|V&)@VGs0tvwqg03_Gs6lI;RVjjEh?J%4s z5YE}%@+t^l?_6$%`S^G(H+jftI&Snhyb5Nw7<0c`&GbV*IIw<2DTG+W6=qHU0@oEx zWYRr3bQAs~?A|7TDN5DBJmu?%7^ojF7-l;ZU+&r9UCtcJNuC~a99}{PtVB}6LkkKE zJB0*X-R)o-b6}dtgK^&(9Qse6SU3vv%xL}_9yigl=eKehAtqXD9(?vLKz?6(2(R|% zxBIIz4rVUld3j>&{d;>mWc(Ew>4aE;yUEGiu;qmoBRBTxFS&8uwQlgtm65PSCiY>2 zzKsyO)=NZphVPNv{;TowDi`PMY@-_M#ih(NMq*w=RKlNA&feH0Rd0BJ7%rE_GBwkg zo89!0k8i8-Qh0M~gYB?wV&a>YmKLxwy1bIq#MZRN^HQ2d0@H+Gv$NCKQ>XU$23YT( z)>IJ_lb`=g4wsbAeQUr1d|!Wgv^bRH%i;b)tJbE~MeF{pscb6ZsPWP3u|ol-+dS0t z2_o1=&%C|V;&Iq}_Dlbh+J9Ez30rv?i(Zrc5>1BBE~R~~@2#A$!*#ZTkf+}J*8(oo z7Q4Go+>Wg*&2MfllDOSm)m(?_&xul@*2el@uJ^$UN%5Chk(IK$liOCpxA$Q#6p*9A zY482P&GYTSp?>B%_tlE>a;4>#mPj44f)=h~RQPFO6^u)nqxBL%N=kZsv(4<{a(Q!)iacAmmDwdGJ||=_DBj)MYiQsZPerMV2v5(8wlYOkHeGI2=n8t#)6t2Gi`(6qnQ3d^M~mKFTsjwRJqYqM>9in@myrCm zcDz1U+HQ8tViiF!UVB2wp15~9mTFLr_BacV{Cjnl% z+us2{K$ZDsrlvNFR|)|d+v&B-nL8455*FR^hl~8RaLwDRO~5qmkwBT<^#gjag^1>d zL8R#-5tRhC*6k4{$3AkhctJ;}4*)n`Y;N7X9=vGaAsad~2{Ro`$;vJ4VPt;XOk9r2 z;OVMr6??foCNp!fzlGZL#zavufewEy%zL}^oQP=>j*5<}&e92d?rtm#i^aM-ZD-0@ zAN<@;P9cJg!x{YZy}jjCRSYb&?&EN9TtdQ_v^kECw-?as6>il1I^2epm61IBhPRjH zKL(BVXybZSV_@(GRWT-&3=8!YfFXlb!XEc zvz`^dej?^=pgT-yNjf50-Xj*GGM|dBZ#=XFtkLPIi*sEiW}EFzL26@l;hW zdn0ev_gl=)-$yHI+?{w2(BK>_N3oGhugzS9THkq@85oRye}c?)F&#?sysLQ}HKOr! zzDBqY-i7BjE+~+;zzMKgA0N84YS9yzKs>ew<~0?lvJw;TIMz`uF0ZTbXsRHt$|4wS z2%n9D&skPM;d)#_h=YTpP`I|Zz|2xJG__%knl#rBEM7ox0L=w&&e+*(o7l^oL>$WV`gDxEwWNqm<^^fBNuYC-i8Ts7?|2}WrFq4 zeVTh?Y3ONtTF;N}tPeQLz{{wwsB|JRjw*wrgx$tf^a!;yb3HUIp=jv_YLr|FgRpw} z62L?CbU4kfWQmB(YP^U>71gIlCnJA`8dLJk%%>*3s~o5Ai+WlT9zHNpg`FZPD;fSX zJUh|O#G?Kx%*AZzC){LvcbC<;CzH7av9(cDd3qZ1=1ug)UP&oKqwA%;n^W(A^)gTb zRZok9b200rpSMOSV9xTrIorRzC%WSv{mAET@tLOv2y}UCMyd48Wuu4H$Hyns5S8}_ zb!|(Ci&GcmDtl3|8q$iz3L`IGs!mNzXrHfoE$QkUV?JX)#6=}0I3gc388w@dKBFqG zZI>Im?zu*ca=Ce-qO|edchql)Nt((M7;UCS(`jdtud- z<0zEb#wI>Gdi7v~$889kugB5(bw-$GE2(^|)oT`B5cb#$ChaE=5?0eii$!vL-@jHptNUim;CCl%_U$@!;)nw;GA_(B6RaLoW z!w^|7RF=KP^^i|4x1a!*obQLnW|OmA*oaxO&?v|0`7r4tVjp)4^nZ}{mSIt@?c2Ap z6a|5$w3Ld7v~+`lfOLbjba%Ik2$Iqb(#_BfgLHS-$j~{!&@eO4sq0?%^MAMZ{qR29 zGauaiz_2lMUDt6Qwg2{Nx8GOne3;+&dLsMV3ad`jwV()IIe-&5IDDSmHX*ubt(26uieN89Vea^=fuur+Wo0Vxe^C&p8 zxVVJ9l0BfUfdHSLt)PhcTlCAwjPyfu{Sse{(z_0b?{Vt)#V}?M>Kw4OZvoZ{lnlxP za`>(elI?td%PEKl9JaYZ}%&tXSUsqSi z>XMV8x4)n9VT4!7I>=`igM(#wDaaVCeTBYBxqQ3P7!z7-EhS|kE*{XFS^{FjS85&> zt)oj+>xaW2L@@S12u(QOc9XCREcw($JA-pQ4xZ|4LnTf>o0GFWO2d1Te)YwEJ=nwl0mJL}8JHvPHM(bve_nZT8Ow~yMjw#muK zgNZ`XKKU7?=*3!n-p5Ef4=;&$hKj?a$K6Bb6ekmOL)*?EcUwR?7NHLP)IKHrb!oO^Ipbo8{|PNY$#d zU)t%9ziu>xW@Rl#b>b+b@?Ujoz+TyGPs7mJVHfB|$VO+i$I>z>GZVh;2J2<)Nf$IRb#N%J3>gRE#l_j#R5rcM zIc8756`z`W)z1$4hV?lgZFRhY8x9T6Df&*!tg08$c>_stA^TqeDlcL>K) z`CY>Gb|c7JvdT_cGiWlkBBP>|-4op3mQ|Xu$|iH0bRAD09U0omDaSciwtBJ<0BD9-KO;ZKe)vZ&6W z)29DD2DPDMWyK8EY7C{I=K_l~3Vc~wu*n0|!Qyushn<7CxOkEJ$#Cgn`&Ya)5x3I| zBuOo*QbkJ1p{uIk^3wC-PQXsfMa#u;@X1;O0;YF(*;s@IYSzc(B+%vVFx7JuzDTLB z-P*z|M}OTyYwItO+9qsRkna_0cxHPCU}<|6amGQH#O;KE&s~M8qb5~ zW@PLm{LYFXP^0H7AWN*y{Rf1#3o#zmJB(Sb$#u-P?nLM{YBS5ymV+Rr~mS7$IjST zN3ByvMyA<$YYeb5tSl|F6@=XOn<9iSgC6Q1xLCe@Tjx9|6UW5H#+AzN0p#m0&Z-l+ za?rbXm(m3-j?V9|r0VL_EgXS|^!j)u!a9D!9}B;s(Tj5i4P?&DdQ~%{qmkW3eLe4> z&Rg&)8*Pu+rptNE7F{~&W3q5Ejw$fC&-WKKWu{CayU6W8luB=39tjchTHiGCzn!X-fPIA zQdwMFoYftkChStEX7pjyl+|w~>~N)P{8W+2ac{0#p9l4bfV_VwwuKdJq{^A6#s7U0Yw{o_moV zlMut}IguX+^~V*~v}cirL5wkfCSxjTUkpzE-I?8sX=wJI*H#p z0~ME!JXVGO+dG?GAW8?fq)g&OT*-XmR5BZ?+D)Sw^)pEUP&T@*&o;>u1vkhiJUEeB zT6jWbH}oEZiY=K3v%IoG!#p}VsaxS(V3*!fX*OK!mzfR@luYrRSlW0uH6^7qev`tQ zxKnMr=H4JybHfU+GiYwE3yL>C91Md31M`xT=YeQmWb5%vM!@Flq_LvQ`B%mVKP;k_ z14$40z6wDxOS^&8zVFZEbj@_R)T%w4HUQ9Ps^qF+bPt0%6Y)GbiWIJH?)ddnjF>4! zUnFdM9)c({XwSjdfcS!%Gax0$7>YR+h^-`gfcs!e zf-N{L4PB}WPbW!faK6|(8ywS+d$~Sy-p{H5(L=#7T0RbmxuIkn>mkF??B|(i)8noA z_|vX5A+NMz*tPoPFagNahd~F+EU%=b1g3jpOVZ6E8^SeXJvZ!s7dNxm9%+g_P9@+l zwz!CD#}|o-e2Aa+&daNBm0sej1bBQ6H(Jh?w#7q;rC={C-DxDlv&$@gUSEu|v5m!! z>>^q)pTZJJdlL(jG3WkT;Z>h&zeGnPuMiMZt=u=`fG{)W`$GqDG98~c(J1jiQvEcL zdTRV@IX?Xp^vN_VS2htqEX46)?kj^JUzng8U}oB!g1|n~hLGH;E@m`DHs%XULtOXf z_k(!PQ8IeuR`!!lX4~V>9R+!Q`GT03>SyaX1+Q~_l>=_o(2#t8JTqo%BR!jIV<0sdNW&;7 z7OJf@Km(6~OwhfoI!w+{c`T|^W~O6xb2JO2qu)MfwtYPj8CzIxmmKN4ed`u!@69qd zH@l>JKA9`MtMn=~S(%^x{kuXR%N>%HY=cI{V1Iw3D=I0qBsD&sWHOQ4W-da+_Jcsc zrpe|=d0wNgM<)`}OR6up_nHJ0QYnwpsC0eC}NN_!&)V)Vor zZF6&4!JnQzquDIDIzd1RGNiC(&Jc^NfC|((d%PVTAtLO(`YOO#z-^zMhsO^&$P-S* zr>v(W|LuO*!Nb;ndjVI4ATVMUo0Dt2N8#ezmfOxLm?>?$Js327thvSKNuUvYknYS3)_JUS6>#LaRJo{^>PUf-^!RWv znFnwR$eG5j)lt{TVObmi1}gU6pma@2J3F4`7?L02Fdg`ym5VmjZ*<*TxNzFH-LEtq zjEarbEjJW5H}Bb-ug!_2e_1ceqF+;9o%1fzFiF_wQqjayKOe=U*Oahrys?@h>=DII zBfbBUZfs&g3whY>WI7yI+A-D_-11NvAS?b(Pe^zx5GV(!xjxZwf6LokLhW1i)wQk8 zVZ|3boI;p=ygKrO3AZrM@3y6Tb60nd*LVINo%St}{Q}*IOd| zKt@Jhx%TGMC((@W8ogIww;FSeRIs&`mylSD6=-T|qJQ)E^nGmGGBP(?kS$r$%l&nJ zWw*p6j4LgtYqL4>^y1>8kD+g4D3ynu6R2wK-Q1vV2tCvtz^3UACGVH_t6IE`5T5WV)nT? zIX~oM^9fR{K>Vx5EX29-;nSug(5eyTRu6nObE$Y&{ZixM*Mf#Wm7_KEa z%1ia3L-HzOnYGH2l9G=P%X&#@grANg&kt8X{bKE*g81Cjbe)xZGAc<(J^CdbHt4j$ zIwG^P)Ern+Vq<8y7;Bh6$fVnFygo?IDP-6Y6h`isai6F6Tf^@C(9qRmv6}t_s9$T7 zhZ7+E#pzGvChAW7SZ7W4t#?3v4GGzS`Pmt%JoOQ3RuD$TJak)e6mb6_Qr#Pi59|2# zqZJFE>Qz*liS@UtSTw>9V|5|$9uutw!JIJ;4g<>}=dqljE_(OO1XS{FJ25>9=krz51Rl=$y@O|Lb+q`9T&}be`o`biG|1@P|L~$_c6rHipto^kW(Wv5<&; zFE>Q^_qSADXVmZHr0(P3t<$kYT|-0b^0^9>w|(&=YiVLKx(4 z47K(^ZA^|saEOJ3gmP{Y2XKuY2S*yaiU$Osu#WSdNN9?SgMkhI>jbc6I)1CPv~(=D zGgl)tnVIJ*aEu* z96~Rj$oslK+%a`El5thK9rG4e=a5yS&S|%F%ZAe7ui5@Q+b>*dvBb>>4e_ufw=y(c zQHvQ`=g(%VGbvaoX2lX0zvKGZkjeFY`|b>ltU3{5^nE|qWCU)`F(S?HD~vVw6x-` znc3%C{^sgxLUX=%E*kD2kw-ZJe0+SnUrGU15oNDPBuE&-EB5ovDVw$ zt6g*LvOB}JRm#Zhv0Jcpo|5@Yg8hbT|Lx-39>xhF86^iLneAHRBCWE=5_37>+UhTu z2YP!=v~ouW9f8E}sUK3O-jpn9uBnOJbLo8dwZR>+s0Id4$EW+Y zW@ZXJeNgohPo7r?=&p308v6&ue~ti+Nlr zA4X1Gyi+Xd&6gSrvqgB|vZz1Jb{^cZe`a(uOfFQWUwT$l%)g;j$Rz5RPJqsAvM@F& zNedL=d!vgeZe3NCExnEI;-ytwE<->-fNY4(xrKjbrZOrjQ}mF} z)?}gLZjp_iOpWy1vbg&GC~W(#(mNsu%gJ|Fw`ar=XG@tVG6+FPYeBoKCP3HlS6bH&A_1tos4CE=e@M zz>LyfHwL)Mt2hPRx(S;4U^TiG=NS!+7(!IVNxzgQ{{oEK|6CV(dN@akv$M+{nAlre z=lEo;4<@Ilr;`v9OGwGdOmTRh-&0XJEE}1xwF8yxGdb_`<)jPb1_zwwsjag@WZdU$ z1uTIyRqbzKVazNnQ{crjLd|ac(u??MfQi{~YIJmDNO-tm;4cqlS=p*j&r2VR_=;K= zpC6Xy~z6ApU5W`qgwC^I~%&2RwSZL3(&QGd?$~ zs9kU0z9Z-%p(9(=&4}!c`09>tE8XPG5urhkxt&SnLaBs2!D+o4pS{+vuis?s;GlFE zLC&25J(#K1$fiT`RH}{L=LQlbpd5`CS;aNg1W=H`7E3F5@j$ zgCZrvsSM1*9&<^HYX&SWB+Sf6K)F2K-ODI{gi{C{4K}z~XDIbIq)AQMX;m?Txqjcf zOt$nuvsHjZtT7e>6Isywzsz@&4nos?q*(2 zOipa647@unJKj!T=@J(D=&M|`^akiQFi-SM>Va-6e`!+YW9M?u0YlPtNl`@O`mUR&bv!6eUh4~78>lP zq}c1e+`%EP$Wx@vdCpTk0S<;)+%z6LD#@<| zi;as@w2n+lD$FS`Iklh2$luH_D;r5jcu$wdVp(X3v4)j{`k zZi>qWT5Tc{61u6sCez4+Zj=Nrsi9KkD3YJ?Fbb`ecz)zo#) zKX<)F$4(Im$yoz{^Cr$3TNGfqQaQUr@*uT_o!Ay0WVO3j2&mn%bPEvUV@GVh^4h21 z?@DK6&MP+VE~Vrg6=VPGWo<3nuke>u2ABZ#E-fu}cT1{>MMv>kco?frbmc+-2^)R~ zn~9iN>+N#SwFfe1jW7rV9jx6+Qjzn6c75O3?1F-7&y%Z@cp<0t0m@$5nW^H7UuQ6$ zy?rM z`vTYdjR*(cl$(!K-OqcL>7(;rF@H~vXI$KU^T?X-jv0UvB!dk1GL}`hg%gCf8Y^;; z=r&^ZFXCT({_GEC8xhm`e*RIpvyaB&p}wfE661^#g2wLeB_zHkCX(g&p55NLlu~*; zU!u)pktzWTxVz&qQN>xk%~Jw0+2RXx+;R4dj0|!fTknljH*>WUg|#f)^ZhHtjJ%q{ zB)I$$uKg!F(-NVi1sSCd>jRoKubw}DyK#t=LfI}EYE&B+)cG088cFH=8#>;|xyXA) zmDe*v3zeT2cGiEPU?USZi7lS^>(h_i0!uh5F4EZ8w4a%|G&Yo??{(mhEGnRWb73yY zYk4yp^>r~3_JbIpG=Qe;VC;vVEeW$0uPex7jJkMvT}P+JKn03}hdo#6hEJJaYfrni zwFPPx%?4*OS|$mJE^l8zb2#md?gn)SAe!btAa+JJVw0c6L=$Qx90mYE_=SARJBygq zRG|os*t9g)izC{;yqV~gqzZ%fOOUQAn|8Yj7run>f^Oluh%YM(gK75ESkS+{0LBre z%ZY+$@G#kEXz<*=?GKp86B99@M2<|WCLt#m@I21%3?Ug#;!37`CdB>>J`6=Sd0#jp z=dU|S^skSjiM^)uT;!?)a&nH7oE0*gLYKm8)tlrHkzk48PcM~82#1zEY|5@z-3PhH z1MV#viSUW>afNq5m>mf7!{ud=N=rCCZK|s~2f?wfzL98^gaY()ThX_fACzS$qt=6o zR=UCgBJl@c$LiN#@8rrUa2MwHU@$ zXe?J8cYbazN!roT$#sv=ioxER(8T^>ivXxVs%c=o%#NXhK9M<`p{82n+pLTsXTzG z>JIkHhD?ugs4FXFwOg!1PY*b_j7P<`7z#P zpmdnuHu>}XtzA3Cetj7nhhw?j1a3oXLuvi--wU=MG2B2My?Xv?y`ECUHxqD=IN3ao zvOz+di&FZl^;Af+%6)WC+lKEhaZ}Upnd;tEjl+sWusf;yE!T&j+`IAG%c?)7G*E&1 z>%&xD&+j)sZW!6wA(rE|o zifp(k2`Di@2-DX78KT4AEf_sX?W3fwbu^6-;b5KnkwLS!+j!l)HVsf;&#&`j)7ZuN zm&Uio^R!(aPaxknUkWw+qY?$8Qz<%r1$I^*ffs)WB2!C|nbbDA&Fxoi9IPBZKDT8U zt;q1O;;jSB6g7Kgq@?P7uBK{AwEyU0f1mngH59k4uCpzMU9q3e-)4L!XpDivtz_tX z7@6K*<_6@P#0`?GsC2B*o*nJ|1`{&NrGmw7@@bY%3CYdv?UKu1l^+Fs!8jsZzEnKm z4qZ4^&pQPZD#RyonTzA?qO79$^mw;5_-w*v5KxJ|P)PLv1X!&&h8Y3pMCf@~QT$NA zU05E`Qj_m&c}Pe`7%qBqAsj0^BG|;M^qb2p%SB*`~OpmEK ztMY&Rf(ML8EuOG?_-L=uxHCy0xG!T#$OmZVvY zJc$`k1OEMnZsFbKaXLQwrz|fDy@r?iVOnZBI&V*av;mL^=da_yE=^;D*V18QX>P&c zTxfk^q2)A0PgXW4PqPs{`-qZkdlryZF>e65`O4L1!#=^Hk{HbU|GoY<4mfS>v6sVk zG)#xJnds>Hg?FbG&DydpF#9uYf`PJ9`V6{0wRcnUp!PkAm8{*5G=TSy z<-MQ-BKt2dj7?0sv`o?>pU?mXf_eHL@v3%7gNBzC>N9LxMItD5VnCl%* zp3&ef{*~;DmVf!TqB8e~1A7&x5O95i9a#o5Ul)V*Jn&TJ(o<&T_4E#XkJtExEc#>n z@%!0vPvX8;1~U-)7A9atG!+PTUl1P5n!6w=>czrbGF@`?9h$hQY>_VyKSo67Is z|H#X8L-pO+dPdvX>8!6iNP>OG#KgqfK#eFOY$ItlYAouRYxe2&wN8VEqN?>luwhmk z7Ct4P&3cn9faxhmf4+_Q??oB8Wy2BVCF|T#-hM>~*Qc&QiTfceYdgg0idVsX;9(UZ4<3l!_m1n^p#(E*wA2F|d9{{E&*>wM{jUWypjU ze)1*oE*tasK%C;jsr~2iO^`bBCG(WHA&=R4YAJZ#zbGJlMSRXoT;#a^Bfq_IW6-j) z=DlyN5;8E**EawefE1uYuYAPy~ zRW;M&<2bnYWusQ*+z<g(>Yxce)x}1ZPN6%r{t%HwSZ)a$_zcSA%z=%AL^x1u>syrHL z(2a*c?g@N2s?zkRuLqBG8(yrqIWFM`dWM%0+>|0h1*6OsxxzU{x+%I>xTI|>YI^nF z@^ZS1MS&68)3yb@-{xy=*B$Hs=&gP~41&0Sw)oWn(T9+#x0fsJ^Zh$q=vvfoX|KSK z8&;E8=@$U=fgW&JPxLl_#ID*%k?N%Qm!b={s%D`LG_CB3mCvta2##UF<#Kw9Z zQe#@Z+-APzau*cU4*z&c<9of2q>HEIGtF$wt9||Q!`sZF_~F36?v}0j2ZrtMk4tKb z(>`Un>=?aml0$1<9~E^3Sf|A{ln=sW0oNOff{QU!Ic>6gPyA&>P+1WLN1BBP2HugH zh7@NXElKL1FE#;=kBZ?X{rE<(Wq>5!zJ;PZt65iyc(|^PWnmHG&Vr?V3lhGDA^7&z z4^#ckhu3l-8-Q4+AZ14Lc1jQ71WLLKf?vZ)^XxvvM8-L;uSQl)#OYl-ADY__T9!*~t(hLi z9)Q>%#!R^|P_TQ4M%anaipQRxZ~8RW0GWGbY`V7sWkVg42P*Y)c+CxU)N6$G*t#`X z&1xUkOfYgOUk5-XD|B*Bj)(cB1$e-;Vt-%{R?S zc-&MtWInwvyNE|ruybs)zK1^LLN5IgYE%`)bAgq{;q7t19&pW}SC(=vF4f6iwFQ;y zn1d6#wFPz+r+%_ZlCel{6O)Ud%>r4^L<=fHLqP+=^<@7k9V6xm%jL{+b00$?wQobX zX$k7Gn=WFnx#8@wpmR(^uDX^q{DOmSY&@z!QBK!r%&O9QuW{m_s9kh_{0%QHYYd^R zr;Y2|_x1qg2DU!_-v$Tt@b7DbZo;zCL0NxGYin;@CmQ|y_f zDw9+Cpq%o;xhkc?Dr%|`lWxCQ$)}WvSC%G|pJgWmjWGDCL4kWxXOi@Qk_@)V^z+UtF=Osj8U{Xo3OaLqIkRQfN_6;Wf$qRv>QLEEAa~eMT+T=uH*G)3pnL7Xu2Ly@`c}0 z&pk1(?Uo2saW}Kt;B~pwv1B$eap7B;ks1eHE6|g(^z2yO#YUydg=W++9G-b!#WXaT zS1pN1_#&;VTcQLFd*qt5)B@6FGH~=)r2-2aBBC__xTlluD+HM!H89QLplfBIXOK(d zPOgbta`RNcoGo>;DJ_4y?;uNnYu);5`B-zl4$UYGwY*;4GNO~eLQdnV3Y*MP0F}9j z-@Kx(3Wv*O%6fVgy_}D+c^u#(94M`9dQ?`jr!}ORF8#^pKe~c_uwS%JJ6&sO!0UpN zE&N#q8-aeKBbqZuBjy{EkI~wsjG3KI^Co z_K>MTmmgMgA-pg4x>$uh;)#o)rrKWJz=(!&p_Ybo(-%ONiJ3f*fiCIlYKQAA-nvin zFAkHdV}u5Et5Vdx4j1khLUMEUR1+|00U0`lWaMS=S!Pzd2kmRb z_8v8RGjOtT4G#`wia7wQE8C%KlZMN?fFta@s<2F%LmIlupS>j961sYxg4@$GEpjb! zrkS`^r3QRPxV(jv@BFodM6B~PvqC>N$`=Qgm=k<02MmC%1<8-P#?D{iMKbm$|;RO6y8~^ zTdGC#c)ez4)2Rho!gor`UwS!! z2Ar@+{0o*FV#^EJm805A#p`rEf?2}s&Mc$m_#47n@=o*-2TDf2t*2_r@IfWfC6}PC zH^j`$xcrKJ%u=3ZpQcN8Vw~g0`FahE%L1A$;l^66T_|RUyZADNi>uO@afkeIGq+D# zN)s5WF2shIiGAKz&d99^4-S5utfj2Y+%w2kS(bgHsNk-tv0>8jlW<`NPD&C2@x4Ii z;h`Zxx6{Ao=PZnv0KwhSa)$7`=KDFtT~dOFr!Q~V6gL$_40P*X15SlpK#}nKeiroB z730ep%c`ebCrkfi$iHO(uk*NVrbGcf4Qyxd)ooBG{rLFdumYo|p^~lQg1n?<$WXLs zuT3v+4$OSk<{m33jk~E>Zw6@rkP{OHegRkWh))?x9K(~1!ykcn*UMi&wstgF2#JUl zsf^8yMKij<3V;3&>w@AhV~K~+xs^BL-l_JdEwB0QB!MK^3@XAvc<;JYp9&m?fK5M{ zrwOdWn)we{nR z>Mt*(I044HA&8IrI5&ikW6W_XP=YR+tWEFmH~ZU%IdF!o$Nl z0NDN*8L7AimeDaN3y*E?xHut9KNvUd4L7J<&)^A!K7amL$kx(UUR~Yv1}oMv$Vz6*H)eYVL{2|5(Yfa&?jEH7y%QL*hHsz zbmI!EC11?JkmdouQ|&&H(hGNUOlimY3`^<$xEb?18M)k;yn;oq<=y+P0aG=ndP1J5 zd;lP471vz9eHj}PQ(9@GpjSpShC}|Of38r zl8~0DzhyI$H54qO+}Ge)U-CmL$!RO$JgRSeFM`ir%FS0gfw>eq)5MO1aCH%Il9r?65vMNdpsD2Xr;qyqQCEcwhf+XP(YO0-Qq6^~@%|Ss>pKpV!um2Q zAr@p>Pv<-RZ$UtEsyRP1`ju#P<3bl&VY*ifH-nTe9iFWxZ6{sI@HGYt1YRHZUjh+J zQq)iKSQ4nOo1Yix?0Ui6}ei2srKN`?Ko6 zn!H$rR3^T#W_)6z>)x0qk(yfWov+~(GD<c`V+A`l5}7pD%tR}m1MxkNePIi0dd#pD3Sbq_85z4zakEKCsR_hG(uG{UoZjs0 zb|Psh1uaOu;Y6bo|9Uzl0cXTEYVdL}lvkVSaxV|@Se2B+w-D&MP8c^VlHlUf`m=|X z41N6o7RAgJVcM5|8Cd`+_&PCnhWDqW!UYzz?)1Gu-2x;I#@qbkq-4n~UOUYXu!|m1 zKY!67=V?Q%E&ud$=!3j~ofy?PkBWrlo(YK9nPbH;7q4m&@Y9({&;1beLa@OFG1a`m zO5V~pI9O9rlg{Qfnhv$;=^r#L@;KY%vfN!2@k0T5Qc)sTyi@<_KHBR%-+`uE78@To z+gXes?v)jzrb;K5YGPmlZ2$V-J^JKHu8y`(h0n;#iDgw(h$=}%wlDv+(4wTJCEI_* zknRgwhm7RFdd>p+6f~61bnD%}8b;DWpvZ2B$m+&gp;f+y{f@GDOM<(RDlHkAkjtXF zntHyBYkWy_R$TaghK~TbkFs>UupY{k#O$n`Aa4pgfUqx=ROsn5ri8SdH*(lafED#l z{=2a}#nRQ}85HCPz3)8B)dU64D$zmjfYaL+eAn`Wc?aHUhd~Edk$Y?mxjM3_RF_|p zd1`htRYUV9XV@dzLTZACap{apR8rhpL!2S5UT)O-*mKz+b+8nqQn*yS)HBbwiqo*q zSsQ!vRnORu({J{5_I zhT}d6%^lQwucf>&z(w`~bdfZa4j1FC01>>|>ypJ|B{kjeT=``&ImD;$xc-DZS!I_60sio-39&NF$?hMyK>Lcmp8vO=J9lX1x`{66%`;I z;BEBe=WA4|QdQT=W$<8;aO6&`4igjgwhnl{nPs=sgfDH}_0bbroh4;cJM-+I@ zxxwt*&A!ETO|bb{pN{O{fMDYZa48(jN=|FJxhGZQ2pYRaDo8)fMM#aDre+v3;k^Jj z!7D5AoHcldw3@EJ4N+3koYvf20lgFp04sT3*j^saw|y406tRMq%B&qB9Up{-7O)F& zkAp_PQ}Q60g#!TxPK``t`L}w-_u4wwF*4M10{kjNM^A*+wSdzA2n^4C@^kgh82b98 zCnveG*SKBrJnH%2(I({c&B55gw*y0eEOxG_y`U4SvtRE3g#~>pJT8`ym{{>(;sb~E z9t8tK-ENRID8XZxRHSEQKbpWgPj(Avtm*0+-E2dX&y&JSshX!jk^eiKt{-ne$Y+*p z;LNPd>S`*{EHd?ZuV3~I4_Xv9B}NQHCt1EL@j?6!GdJeGKe&B^m54uuO2lKm6Pfoi z7$nY*xs@rh6!Wul3Q9^d3Q9^i{6@*8rCqwW-B5O6$%0{y*tf-2G?DQQ4Ib=KwuFT5 z&7~!5ONU{I1_cBnY+fejv+~O<||`v2|}Zk*iuN zG`l;@O9R`1RN#t5OKa}iH~6PdQPGb|tasHWJGv*oO1x4r<+3FmlYeixa52WZP{BfMpF~2W+u6Ah}pnMXH2=1y`hGq{jf+8ruIv7 zDxXWtGa_jSsgz{4K~|TS3qo8wzc^d8UO~FToyQk*(mi$w9Mv|4DYA}(`+)L(Z+|PN zs7P{LEHEO&IwwE>qvKf6!FOOn$`RGYXSDy!vmCydlc71pY&OieyJ+^qJ&-YISWwRU zZ;ga_8G+!)P#;?Z3!kg?YSYtiu#^5puj9CQrg`8r+lb~#<~u9d-zzHzt)-Z>v%Vk& z61i;aQN0SWmjtRRMKZ>p_pbz;9Y%Rx89&deXZ3O-n!jH@y4YV-m~C!%o_`2BE#PRb zVw`!}3SY@AxbK44>MwN~I4>Z4IsR7KF*B|vydd$8-1vTVO?lo-QQ}}OeiCR4&6mEE zVq&H@xKfZyuKq6k1lVUr=4$Ll2fu~;s)ZUm*U&;B*;Q4EN4di6VU~qYt52K%^pwrT zHa~&%YLdDmb$G+?2(3*cslc!icXy%X4Q(L)uIElF{yq#D5&j@c6&7+uH|&x@~-3Ju-L}xw)`qgcH}4NJ?D`0Ngplu?c^>nZt&Dws!-6 zVOC+`?RZ@MN~{6Q{sOznT*@T1U+;#-oke7x3%gB8fn3zG{Y!l1@A2u4n=T|85&a}X zWd(obkOIdS8JG@j@x!2mITXg$h;=~n=`G5X%B*|k*+v{f-}Dxt0+`2qrek$d<4C8< zTGSy<0HLbzKmEiCmzPJRLavYYmi%hafB<>aM#~r~H}F!9*KPg$sCFr}b~7y1&u-n` z__P1M(gS0wjSsdK%~n^`yA{9(F#E*&y7LT#^S+On&pH+xU$ST(T?&lZyvOtdA<R{Z7%zsdwuGnpZ6^}1*U*o(@)Sh1Uzdlo2k#An-;$R@|uR|nCJSMdT!TqbjLN7 zE6d4oFf&WBzl)ps=~t_e!oLF$FViDq@-p&G_Ua1*ZKAh)uJCkDerg5xmg?}!%vYLk z&HJHi(zMl2H|)r}LetqTce}DX&ViXuIa)JNAvpM6&idhH@TBE-5-{}Th6cDaDrwjL zG*jv}59#2pq z$Z$u+B*rEk`Vk`XY20}l>1e`~(L7cGj?_L-N?{huw>4D6{_O<>oIc3Wb#$zlcxMYE zjL4Z(y)EE+02?L9E!9C4yneD87i)c`BG^V5U=mXb;+*sDGg{itovD^#;Vod7iccXZ zs-YnA{6O#BFWs#!LCvKJ$y|Clu59&Yw?CoPCMe6!u=~dAWcF(3cmI=j`vZ&k&kulq zoGt%@?FIk;q4NJg^4?cHZYmprY14K#86c5_Q7X!^Q}0- z^*?Mz@Mn$?rxjtv1qW|~daBpaSp3=O(4JpsH<0LQSxO^>QPrH)VZ_jKt1Er5>mj3_ z1FSrqJ4m&?B)MEp7Iupp-L6GHBY0H&Mn)oCPj%5tM@5QH6sJd0*t`tf-z&E6C3DJ1 zX=*+<{P!FAHF=(F;u|}1E;iDEQIprH3z%K%j{UlD8t`U4+dDht@3l1h?RiDNDt(X7+Zs4lb)A9=)cpII0AJm` z^hQx#{yPcvI5~)_+GGQ7BRl*CV+G*zVuPF#h^Lt~@tLixHvWD{<#^rW>*Q!^cMbvU<%b35=s-qw;27O-SQ19JNk zX0sp2j=_Ub5Ey|tml_YJSQrP#6{s8jS;K?Ch@Fklw6*MQc{O; zgCZ&yL8An_lnsh&#mC3$hg$SglSB0DUyoCzErI6W(9pmP)D0^_ zgU@H`78qUZXs}j@>Fiya$-FygroqDGZ1n_ zM$i6%W@aWjjoy)CY+yUOCfAGy(SpLtc}}-KU!B@+021XNfcW(91#ZPT*%c}S5T^Y* z3)n5xug8}beH@+GBPf?7$3Y`wbA558FUJWCm*ivzfsi=8p%B=+f~H_Ng%G-3VH!-n zRidNmoBR4?^EAO#7#!>sE_0)kGc(QhIq%h=S_FVa&+^O^8Ct3P4sf)7)Kf^Gv~*N7 zm0fiLaagYnFplVHCgA}shvU0>JK zQs?C2(2~Pd9E^QTrMlXMlap&2RVJl1-Z~Ow5q2$j*!k;a5j-xgOsKG`sv76WX_kW; z2M5PpkV>0_o5A~FBof?DJL`)a%v|*UaRm7D?Xk{PV!iJa7YE)max!xF?>C%15w3xK ze2OH70-~7}$QZz?goIxPDt1eFs7Afe)Mpy2D z!T-rN$48%pYmE3{@`NcT%PVS9`P>ZNDSiMU@XoE9+DpJ)Tq`3sM^06> z&OeI)8XO-R+4t`k)`~-0R9MwfM&6!;&1RgLtLKib9@|zU^i{pS(=V^_Hr>j&o6tD zPJDQ{NB8KP`QdJ{EEyia+9nqyUZiSIGclKV2Y-z(p&6>u?E({D{p zF0`~f#giBEI>`ylc>2kAI$d}S@P+?*HT^y8h5ht~;QhS6!GitoS5*f04&asTNs7tn zYpUrgDM_izr~T*A-z@&}Imyyj%|smRDBK)(1$(m2mNDhN%);=N-*^%5I#ABKtxcrHwO$X} zP@CC=gt3-|7yo@{2%a?&QEcMI*Eb6{`RRd=fsWJP|GZDn()oi-VC{g@|5+;Y@#e>& z2me?e{Qf=vKeM9MiR$BjZwauTCmpV>+zQgIv5^UbfBu>qX@L(UC6wMsya}hFYmZz5 zK0302e}A(S7ma+UsIASMzox-a{VbQDmHx|r-f$x|D5(8^G4_@LRcKqdCF!QNx}>|iyHk+v?#@Lmy5mmw*8R>o@BX;{Ltrs`jyc8`-Faj^PQo;=bX((WnL zn3m~cCEW45e*>*#9t8mn#ioc%MVU&DO8H8&{y;Angx`NORrdRoR9F}!NEyj2s?mq; zWEIfRet*yLNLLP35A`EdNs{jQaiw=Z!q5{G_6D)!08A^z z&*|yQGrhigCnrn{41fw#Yj)j8!EFGZDMkv8N~8;b)>O~yi#+lZaOsxRtMUP6v%v+$ zeg-gMY1B$v$NMV-x&lK27J;b4^730yL9RG~2dHj6v%K8Y%VqAiw02*KWcH51Uwqao z;|-wpcy@XUZl=cvi+39_;($TGAPN#ei;Y*jzKCU|m84W;P52m>0Pvgzh#p-3^m>^~ zNJ98aJ7jMm$PmGZ&AZV!yZu+jGt2aDmnPsF(2>Gp_p2!_RjU6*Z|4MrTGySg09OBC zIpE6>1{72}3Uxy!7WA85x|I!edE&$n22?JcszW zj(_Ih_M)4#m!9TH$XE6gg2$AJJbxYT00+U$(}4Gr?nfPkGd86B^;T=0Q51?~F2?TM zjEq%!B{UiSuSq13F+Z9fkCSo#;3m({2aZ}jeBVD)0gsFa z4DK!5|9MEXdb|+4-`LhUO&5Uhpip`$3OcbWlA?knUUQ3cRYio&XPMP&B(Cc9Ms^1W z2QwVPY@#26=M)6b#4 z4dk#?2#_wt#Z59_Xr$I?{J9Yzsh$W_ZyLY4nwsw2-`%vg6Y>$fL}Q}=J;N>Au)$Ol z#u!f!V3K8lLf*~=&~Ult&>_r#McAzHMyFO&ySu*`pbqhlzExx{43T%Cg>F)7gp318pBg4w1sNWA5Kwx9Ah z{%Y9@49W7u%KG~0MYUNBwVGdO=+bRh3J}jsqynz6+CQ5Lek#J@a2RI-xh;SHp4u{< zECc~QzBg|IZZrm>Z8OuKrvQ5Ay_-wRB~PPI>dhBhE4O?E!rmUvCxOAf@`)4eGLSXmM6lJlg;(?@&LobLmPyf?QR;djbxj4S>R9z-VDO!NRJ#kgz(z0^SCQG$ix|KwRMTK06w@=SaK% zq|E-XTP)b%pW|zp>(j_#7N)Fsit1?owV8ELknCTNFv9$-te`uqlTn%j03TAA7gkeQ zeR42!ai4?@@HiaKD7CH!7{4Q?Ljol#9CHQ{_5;gvNJAbQ^-NJ>uCX5Kc?(x$P$h?l z3;jw>1ngA%K=v6k#yYp%^8>z|{kq=2jIkrT$;ecwFw-U`LNuk~n{Pyfh0+6p!`6Jq zQ0>OA_~&a&E)=pic9ASAG(M^*a2HcDao8_3t7tHe8G$QMD{+B6Nxi`bZb{rP@TI@; z3=chm$Z6u6e`e64vWUI+%+y~_TsOq`V$)k zwhmF`krgmt?B#vVPH|wZ&HjF)i;Kie@u-H*sgE_D`k8p4SYJgVTPC-E&$q=&xRN4@ z8UAsce)0$#29Cmgg`l-gUDT^CDQ$j{{qE~;rbiBs@De!I4U~30E-uCQ#WOR~hE1F;M zTPD#2(m_UE)-Om;@)v2w6nyO0=Wm$;p1YjmX_*?k-wvBIA`MtY=ua-iuD0FUtjV2=r9pb79M*fS{RSy;-D$3`6Mby+&kOM8c zyikcD>J_ND|B~gjp{|1&4O@aFu_!Up?ohC@IZIkqb6YfR%4vxx-L% z{*JMOxw^FC)ZW2ZNq&IfS(apPL2j+}3aa=iHVY*U2^-V<^lSlFIDLAGNORBPUt2LeZ5*6h$;h;IopKf=boy_dO^5FN3 zN=Z@G0FDaLtE-FR5(*M;QKhueLV+W~4~6A%epfqh+uhaGGz%?0;qMt0zEW=+?T;d* zNQJ~jFgUbT(5H$`7c&df)f801lft40K6LPVHwGMEONyM$oXPgf^%Q*PRHUaUYb=s z08-F5aY_svmGH1A>AFGqCq@k22u&29Q6dsK@3qUx@@#~A_g20kw=TEiKPa=_St>~dh<9xFhkJIg+?d};u;9RcApL3dCU4fSKf-ao*?pv*)ph3;| zRv6%UC=1o)u-3YGlO{F2{dImc+yq_HVSlkeG36bw{Hw808ypnUF#uroG!vAgC?qGe zsAosCR{MMnQd|L=F(XU8ZKt1t)GS)oS(7xBZph8NDH-= z5T_A_inXr?8=EXh~;4?y&&h}CL3wA zrTe4v^%_ZYHbAbbtGgXf|Jb8E>c9sXa^Ocd*H^OyTuy#VdYf^}QLJ`b$4Z1&M>xG? zxqfet2qVPePs6j9*#l3 z`ssuL)Wq0mK=bsvC}FCx(T$<9vihzrFSEA0b=VNti~`6MPulW4WDQj8Bb~$(KaRDH z<~Ih32Kqi-La4|8V7jjKyscQXPKcOd>B01Z%L9O?K7Oo2#-eMiR^W4ijMm9|d&4er zI3rTb_@lacXlZjo1FHofyY=y z0sO>#^F~dv21vKf^Ljxg6Y12kektKoD;?{b6-m8)ak)^7g&q79=kHCWA}Ap%uOOTZ zIsGQj_;oZufok-9S9cM}UL6`|zz}dTCpZz4=H-tI6-!I05a4|n>>SkIO=%Hgdm|pp za5q@A3#_CgmBgLQ1|@e}(v?Ismkwi)mPkp-n@5TKV~cMFX!)6=YYi!G}4OuW1ix!soWej$hnOg4B$->T!xEAe-@ z@{*hHt0>TOL;Uk*Kn0KRpDc^#Y`<#EXOIkANrw1;0A)FS95^U|)W?z2at? zR$6@y(1&M|a!eJaL1Eu0jq=EP^TCteII=M_i%1%`H*|DtEMBF>KhMcZ`cal511>I_ z1ao#rp_yq!#H*)|9}(iQaV?Ja`h>qdvi+>C?c`KfU$0O9DB5D_5hkRvM|gl;)Lw!K z{9;&mq!11HHs@nRoSbA=R8sD} zE?w_|ISdkFFd#epJ}?#1b%?lGH8s@ZAU~gO$kQ0d7E9pnJ^IAf(=Sw0Ws~}h-lp&P zxI7@>82B^F(%X=SMMcmv*Ju^UALWmaGhpAbb@AiRt6NhbTaaB~v? z)c&1tpwBB>pHgsGr0}0mKrnW4-Gt=^d6dB6=Q9sZPEG@@XT znS+eaC?Tntq9~0Ce2lz-^-&^+2dK#51G~b?NT7@AXoEoEAXx(VDp?kVna_zkyx}kQ zjcvWz@hpzYYXSoUMMMKXe4>z#kt5wU?(Vj)zZa-#o81XNMt6+jQ}ce2y5E#r@H?H}Dk|wiSn^K- z6|3s8wzw6mEXC(!Ver^p`_~rMQzAn_PQ4pVj&tg4X6}XAGqWN397QwOe{=FMwgfmn zFRcr{p}Au6!(zg+h9n{iMvrNulcN64_EO-T255?pOdzn^NIm~I)BI^mmLh-SNOQTC zlZ>SHPc8tG^!(oh@BgbKEeA*IYIip|DQOQGDY)P0U>>xAxmG-uJ5q}XP~Z6-Zf>C!?^hUtj(EA?a5zqG{C%tuU=? z-`cQ%`SR}o73L$KjgD4NclT;PwFKF0-09h$tUIVaoT+h?W{YDM;`ozX<0{$jYJKInVj9ZJ zt?lfBn~KmT5T6GNiHYd-O@I;=!#LvU+5Lh?kiza<-`x05AyQOMA)z{S3=zA2vEZ8v zNRN3?dAZEeB1cd#Qof`7(X*a++R}U1`=&+pPPO^Dq+6RCODM=W{U)6zYZDW4zKDAf zP!-3$aac2|0ldXz0fB2Zb0;x19x>C$Pln&s62tzgl$; zJ($n&Cix1|O+}tl?RNdzunv1Gn zRW(#8>d{dJ@}i}tKeXl-u1hEKRfNlo%E*i*;q1>G&o_Sk_7nAaBbW-{@>8tVz|V;A zJn-(x*F>ECof2+ARZ(8`yXc3z01-SYv{!E+S{A$;)Dp~!bV9$LYl zhL>kR<+i+{!%KSv?xEoUW0c}|<2*x78#zkh&YC06XWmKD}4EXUS0#A-xjV7Y5!rf6BWQmP+!7j%ATdOffnVm8{98%u_4J# zQ<|As8JLmO)KnE%u?A*_+=>0q#s@oJFoj2GLo*w{ zmD8DfJtxl6%g-mTWIBIW1gxlKX1M}UW1^)6vh&m1IDx3i$gos~Kb<+NxM5i(HDv0`_=b;d)k< z!5MUmSJK#2_k*q$zM_F3d1!#YgOzn@HC@hOfh6=mBia0M&tGcn8d352j$ULFSgf|5 z4GFJ(+smpM6lLTx4TB#2JI|vp72gqAp@f6IiawOm{4m_(m&+3H1?&)MMOi9pG7b+& zr!2Z;U-#(9NIaK|h_LX?z?{R3=nE#xjcYTJRE-4oYygzud`Ct^na>TYGCbAgvRb=x zy4RBjae(^Dyz9`IGQW1Qvy+vT7a!{X=)B6#_Klrtu^=a=BI1eJVbpF7sow6+flTLy z0AEM8f$o1y|F4IV_<-!xC`G@xfkx$l#sSvOPNXDlk%7MPe2HXe6THL+7&8%okW!Ty z9#{Jj~BQ2ZBLU!Ne+Xkcy{76HpwYzz++j2LHSBj{bJ1Zx4pvQcy6H5eFM_ zFavuw^#z_bg5RnlK(N86=_yAkU4>1Y!K;^OqRjs;APxP2v)23~G;3Xruhc3qxIvug zrx2~*pPS0d5F`1_IA2d!^N70~CizQYAwXIDD`kM2UI$YUKO{Rft_QPhWgP#J(N*oA zmkI#Cc`=}C`5T)6tNTjZ;PrJ>E9!UP%3EJYt2_+?{|OoUb9{XAb)3cgO5mf|BQGN# zp(qs@7N!St-)?N;4NC7C{FsEV{tV=>bTa49sNCx6`uuS7-$yAx=lHf`?Eh{s%y1qk zI{yB`Qv#I1|9s>Bu>=hY{J($WQx1?}hPi*uFkgh^V=M1Kr&u-|BP(sZBJ(vMzqiuW zTufzgW4PQ;7cN!VP=EN*aDQr3S}-^KCwebrPyO?qmFa0Z^6MkV$MKlIe%{c+Q0MQqJV}kS z;{nA1%61r-#(L+ZTQv>UyQ_yY0#J26{cVLwe}9VhKvDFJEYg!1VM5eFUG&?ZS)wtB=1Lx;OD_lr+S#4koeinCPs{ zT=SkQJF%KsxY#LDz=4i@d>W5g$y`bMt0TV}rr`vhDa#X?V_p2xnh{3u&p#U1HxDBI z`s;uFck`Q$!DO@+2-h@t1@T}fUf&b3>hUimhah@p#`RRo(#uMZo;R`!6BAahx;_&KU+pzx!b!!{8j(IJB;_M!;B+PBC!Uesi<~Q zJ_-IXBj){jYe)b8>1&kle}+%LOzsXbwC(JsOK$Q5L@+(=z5f4mM-vEg+}5`C7dtC` z62NmI1b3eQH8q|F@WCb`{s}u@FM{H7fn8{zWqW*x7q1{KpY*}*U>`e1HUfWp2NR^h zCdNm}$h#40-HH-b1<FmjO8D?8l5wNj8^Nn08BnV2`5viWCm3 z?+3f+8XsLKxo8B3TBj>i9jI`DF6QlWD~t-)6PQDs1Gl;=BF+x0Nm95}xADpGzq?8A zb?(FT_u2zXVMa95F6YIh+=+!ogV5_Lo=2*m0bMpBNzYhcIP7)1->6J@p()NdV%V;a#)YgeDE`-CJ~?rd{@N%eUnDJgp1;GFy>ZJmOOnERS9 z(h_7Nfh=>A0TehIs?ls&=kbZFd+C><7^TcQki4p*9Fj!vMn zLGd8zP9rV^Jy~-R`M7WWOataR-1Z`n3aqT+|Ji=lMhj%$s;biG_YmuW`};7jNVe@6 zVjC}%f|MLstOJaXK>nwv2k1a1~NW+dM1+IK=W8cRHD{tgI%5}LN0giu`(tj zk`?cYC7u~#hw3S%_tolwoUP=T=`yh!DRthl4 zTMiQgNMJ=sQM?$9Ap+}K5wvk}xbyGr1C9t6NM$j6bb9z`|5eG@Q^_Dh=ZvN~6OVPU$8vh-k>+Hmr5A-K^Q?d)`8U^xd3-g$>W@U*KnWiKgUetZ~cvfzxt?1{;2wVMNjYnJvO`+oT< z0khGv2%+>+US9D0fzZv++^8ruw_893puT~xxvWT3a`^g~L+O_<{y~T&gj8|*N95#k zg*&e(fXW;IB=lDNMBz(69nW-eFc(W?swqv)`Y|^18eWu$1cfeqQ5BG26$eua(VjKj zoI=^OrjK|VFkw7%Uh6F&u56&)xSqig3YYW}wKS3L8qvd;Wf#YtDN)?#`7{%OX0m}J z_`lY2iQOXwH}KBAhU;W5NzREB5f|5)k5uk_ug^Mz8HY(m5LRjux(N6?6Rw;SVj@8{ zN86i_jfkWuZJjM8b@ilZa^z|Z;T%fN7IF^anU00PHp=WMzN&P0FP|J@5&>`ciXm8- z;a8_9@+ra4E04pzCYqXIUUQXds83XHy$Y3PdvG(9_Aq7Y7TPqb|K@UF2{9g%o#H=_ zyGjRoFZ5zN9jyrib3NW?G7<`K;xREXKxj)!IXt3z% zeqQuA6q1*(65R?xFwWmCA?IY`5mc018qOphVl%!qq2FqeV0L>#7$#(UF=>ZHl@zv? z1M;-Rk#U<@(sWW6}KTDrss^1Hjw)I&X*Qq{ph@az8i&%py^gZ~1(Cx=#f3F;fO zKN-sR=6!c4+F`&zVVqA}^bP;olN($oU;g`a9V`?N2}`*HFMm(PR{Gm9*kP6b%Vl}Y z@PrbX zU$GYpE{ng8al$oBs2hAVPRK8GV6lHhdAtO-ej(AYr$o-7exy4Wv)*g<>>~LMRP;-J zIv7M*OG34plmZbKhKl*s+k}_D?#D{5|GY(mbtA}k>dE7MyjgoDZbg8Nm}L48tlP)u z*5UFDi1G}l7~7G(1mOsG878&t+2YU09buUX>qd-zjZF7A!9#4dh5kn4 z^eq1W7jS^aKc>VbyeTJ0TzqzDtJhKY(Z?RqR>2=za{SnY&uofUf(W_vQn)2a7Y0vc z-m$T|-p5F`O~ie)#8Ch6um2fhv*4#n0p#%$u85^5hqhoSt{XoL`@@RehHEO#zDGxT zDW`CBB61*cAOFZvt@Rtt73_Hb?D7_;alv^1w7C(A&D?n+KN6;ad34oQztYxdiP|A>ZBE9yBVGA&@lO(FqPvJPXV)>5Lmt6gqAUw! zj|M+7l+=?Zv(D_aKfwBq71b_|V-40CC5{n}R=Pc{iSaQEB01_cDV%q}-0s_=_U zC$I{O`pIrpsy;tEah+Fd7Z0A1TfKqod~?6ZIFj+MV{2KZ!6;L7OP`A^w{;AGTe75=N?zbEq zCR@q8=p~mUHjrINW5Z|vii(Wza2`J0y-D1g8OqI`-o58WlZWr_APRD7=@~`*f$(Zv zWtLYiBV{PbuJ86lf)Gz%N5~E(wjArOPHw+2#G}XBaG16>y-t7`s{nn-u&uFlj zS(f30u@lH_2mP`ZL%hpncbU>(xEGJb?LdR#9Q+mQhi}1>?ZIcsipK}uuJ;3ymGIk1 z>An#pL0Al}4(s3QDuuYUN51MoWweLz)MIGk&2;pn8y#rj;2XFlO_RMcrwzzU6 zu*^Q|ZhO#N-lQ<0qsK6v2{5!$-*-)VxH;R~RaW@>1>ET0mDD-?NnpX;UNm0H{v{ZS zA;b*_OROV+e7`#dCCxl+z#X3J+6@LeJc?_UEL`p7nOFrxO*@B`oVc)y0roSK?!pu0aCa@xPwS?%$nh={A@umI|wmzU=R_2^@t zSKCoEG>(YpF~OKFnYt34qS0ukHJSaXZ_HGouMCuNWV>)#tjXC4>_;!dZ1g44+N?5s zn(J&eK7W30X6)p&kz=x0_JLk+m(9-V13e?B>z=8!735- zRb3tI(!(h{P*t^o_E|7ZF2UJ=0KWNNNnCy-N3%wNziS|g)oHPiY_zWL&48GML|{+| z{_vCmq%BUo>7{rar`=wXXX{!Hwe5xV!{GvRoIOEbm~?c^gjD#Ld>Xd+FfY08?`LVM zjpHJoQaN2Z3dxSULm{v)4uZese)n>z@If5p%G!DsqM{nBG#~hsC*lZ;Ly)V_3eL`t zs>>^7cG}BYe}H-9b@_W9W*123>!Y3Avh{zFB-4TMaqN{V}R|DlUdQ6BM(_s0-H2bCaMdRjN$^qXZm-Fgg+CmE=_Pj%%MMIvO ziohh0>`6=x78hD<(P=Mz=jJ|J8G#qTmyT)kMjpXi&fToXg&o&i#rch9D3`+~s-}fx zK1fGp<&aV~xbK=T=wUGHV^Ey{b9QVyuJhrMF_4_f5JX%oHw)dIS-QYmXtay@(tJPS zmf+SjZjn35oKAa#l5}a-<(|EXsOIKkS5eMDLr$%ij1wi&!8oqB3n|?(Z>%>Jh$)bP zkn_v%S4IBLJ$O!@*FKuem1-wz3W6GlATJc_;|X(rxvu@t>#1w)BrJ?6`$ZAZTkFcPNE_?tqyuF&6+HtKs8g9naWkwbg&JMsi9eg{!`}>a^z)72f}I1@8^+o3H+ zXz{Hf!DcH2#ka_xe>8(+$f%br57Waof%{RkZ^Q0u#8M?3rm5JWO=J9TLSb(NHGMnQ zx?w*Hr2Qchrk=4YfO<^98$|kyHi~xfP(C?|hn@x3IyDaRfHvHsq5N21Uc>e0u)Ze- z$NaQU3-oOViroZH5CR)~?anXG4yI}Zm0|`1qnnqM*4_uG@*!OqzG%@xIH3jfG9JMRHUVS`6R^;%cMtZYFA+#CiPwUu3fTaqB>1Y zST=Q7O7;Uiz4FZl{O7Xu{ws>Iqn;->XS(s3-}@lReH-Mcj9yX3Z(?r*J>_qjb7Ot) zOxy@bo1AS%+I60h{VYT(HTL?Dt&ha@!|U46srJ3|UJIMV_klIb;Inl-9yplgBc=2G zxBZl;*=SNT-Ai#reH5W$Rv1ee%VL>4Z$HCebvaaySJdSCiAamABeu#0xRb3?yo7;? zKUdmn#lrkd8kP4oI#X1)zgvoTGbJ*t2rqJd|LU-|%^N}g0V+;K!rwbUV`*PGwN?Os z!@~AaNhQ``DzUb=Yb>cG;{?!pxVS8iiu`g*sd4S>?66GcU+jh+t}lc(Z%d2zWlQ(ie( z@4o>>&BPny!vs`L7m4J0P6~>tw*J%3{f3z0dB1vm6Ft4?DD{W4Yey#xEDY0!yX9G^ zyYj`|dps!3OM`Y#8aC#X1KyAJmpaxLjb91El=>NXcCpJYTK#8Zm_D}^&UQ98C!-)R zc12`Q1Z_DJu=v#PZ6<954CvV)+F}?+*cOUiYuwA&AS~36mrKHLrex}veV&}~U9pZg z-z;-f3)R7=J(NS3yOG!uFPY**yxSl6uDN9D{K{9@jmjBb=7&Dt)FAd7I5$?wsnO}H zx-UM-9`wgGC!54yQU>%>lZI`ovn_~XW_WW=akhE%N|;Hcl&3m_dW&f|#!lBOjhtva zs?CF&3&gv#r@We-f3OV8-|dwf#8n3r4dqO2vsBSMd1$0&Q#8E4wVQh=-bHASv+rvi zOXeh#8>Xc-9aJQ!Bx7plH6qM}atyS$(rk__73xc<-gxVe_VA8*EyNy-W4lE#(8}A) zGBBYH?WHWJdwXCmEfO5|p*tsQ%;!I-||^06yZk35kMQx@4~*TSpmVz^gzpr%@+g2BziEo z?3KJfX_*<(8ntz2kPI@K+8-X~NkE^vHXj#_8MJqW%0I9!RXngPv{cd=bKepbAI}j9Ns0tD55iw)1l73TV{dP{cPc=Gx}7MWQ*oH3ysMsL~rHTpr@0jOM-U-aJ<`zI_e05Eh%o@V)s)Zo(AK`4)z99XIBuGsMRI6-9L@V^gQ2mM6E^zLTwu+GE5qMvr>ERbJ!hGCvH5Ad!bh~xJQICIs;|t1kJIlA` z?0)0+5I{^LnZ&?EOID@rDL>b)7v#_(7mn53fRhr->l*7_>*Zxd-qP=8M z?cy$bgwQw0UEfcQda2P)asP*N6)Ew2t=?AYk2_A+3*gO7K&Az7NLkm%$Gr*Dh=^bjBkh7k~ zc@ubcn99$jnk6$4f{;O%aCec$q8d7t?wO2LarH5Poc4)XIB7xYp2Fky&xJRzV^GKB zvgEGxG69YAD61KN%>y(8bZiI9GiRIh<_h`6Fl^*CXMz5^Stk`PUK-!IF|H|_((UV; zhgHQp4NHQy{f-r;cAmWU#3zas?rcM}%kb{BQvtz@hC5Xe8BTmz-LjAm-74Rt_GZdSE8 z$$&CKnMssTAZkg8g4k$J_C@r>>dRU_R?;0iywXRS&7mS#mBKNWNoE5|76s;H+NV$jwc%KVC=LlYW%rMFM1wuMxIizmXhgfO%u zs_a2@S%6;WbIWGaa<0TOtIbnF!Mj+!jkf&oBd9xOYOGD_ro~iRt-3HX-2G_E+}#~t zjQP~6p5u(ZXzqhqjeo(#bTCAMLk<6d7Pdq~v@^ls1Pwhh$IT=2RR8f%TQ@W*FFT3d zI&@xlS(AvWAf3y@HUp-QWjHFML=^h+&O1CxK5wa(O;}lVXtW`*ielc~6vfJ1_;~io zhC?_c@3^5yKTQKMMtq5h)rHz7_-wZ+$F8Pl-ffDZqTqrN?GR#mE$VF3o0E(nIEQ%P zx;7K#Y`=L{jsFCNZOB_E*cJEbGgc#P$x$Cgi-NudkBR(HXG7zd{k{X58~zj1o--CG zb7z9@)hI&$tP|}+xA?$jOxaoD$*DUUEyN0cKNSV}iJ4M6&Firjk}W6(+p!o5#G8CH z5Bv&hc}9>H>*$cUw7b{?N@Ud+DdLP0vezt}b2;R!f+qr8w}X{TRKY?*_nWEufcFCT zbmB%n+m3vdJ%h{5LxAe4Zq=Ob$$p&t!`tgYf7gwM-fafwZu&{EHZ#9on;0A^@!|^x zoI#+390m8BjZvR8ttv|}ZhkN=U1Up-M;-M+8hNK6fqt{HZ~6BTBo@SPKUk;i)1pIWWZ5bcDm}~uLXvU#KhZ7i63a)h7LCJ$V&dEk77itE{-S zr59k?tactR#C9_75m9jzn4_p{5xkHC>HX64l^=v8jNawt0a+GBg+(Q7^LeZl#ddo? zXYPD=BpY-t-VGQQ)(eQ=_WdBkuDR8K;#^^wG~dOUBa!&mVsq?vTxWRiHqlgp;-JMh z1u`S$v*_Ud*qbepF&vRR?irkgSbeQ3*L9M>K-}(X(V?E%!h+C++z;eqOZllK8l<5T z!EP=EU8^IHC=3?kuA+tlgiY0x)=N-W#C8^xNUAg1aB|KYE_N=+;~GyS5jtjw>zUq1 zYAnQE1Y5x~U#DqWGe*0Y%ifXroI<^I$nkZh zg69y1a#1l#E+PwpM4}>}Q#xM)k__IaAeZ^mvbcrwCRvNQjOh1Yil1rjq3OSo@#IIL zXG+;rO(;FH4R4;+oKtNd2t7u=8Cf_EnU7SEYv{*#+v5_BuI254l{Ky4_HjEF8}o)0 zJt@>Wem1lXmQb@RKNBZ}bLk=}@9Sk@W&70pCdb;*ac(V24eeR=>o>1!jiY7q8XsB) za5!+Eptv*`)br6SkoLOOVoI@W(Aj)Bj` zt(Q8GN|jbkDKw~8=@zGSdOEb=K?U6&pl+egvM62Wy8mlb)o5}igMggTMX^@6lr5uq z@D+*E0`~xRlFMQIH5)w>z0(%|tQ1+n#5Xm&In=}^Rj8tJ=O+DGPJk@_<>%R%h*65` zavtY1)M1-)nLIhP5uc6Tp*{hRI<6WndWoonKcWsR55NBxjf zhD0+hT9m!!H82Doz2VSXHcqI!ORZlA{6cqv?UH+A#5k#V>0z5UbhDIBfkrDIYO ztA`aiZ>7${I6V$)c?pG{_7nel6`Q=x-3&cdYB@R>GmL_;3QYh$tf#@EMEBE)IZ)_9 z^@#g+qht{1Wr!$&aAtDYL{1?a-$SwC_P*{6h3A(Xbx4&`!n-6^AJ+(E>MIh0ZOxq>GnaUJ+sJt_ z(k3ibHhDYiVzIaDEkT7jcgE?u*oIG>a1@VPTs9NdkXHp@#KOWQAiZe+w=q$!Mg!j zftLIYl!p%^C|1f@QWZ|aT-;;rg=XmUnivxPUTHNtGf6~t8k=iM?le(nU1%ynx9a!y zfySoT#a*pqU)&EaST374)10%LB{m!)$~{nMSX@fFzFc5j*2LPMredRsSdgsa)@idf z22Sepqo?@D4X5ovU|khM{=?oArNM}``)9;F9u znzg)HiO0rvVk0WyQnTDsnAv|C#rK18yr{->wC%aFGUL>zP?`MM626GH7sq~2&)(R* z2ime110P+;P41Qb&hhj*qAC%3YiRXSsr(;IYxKnucjw&?ZhYUGe#Yh)*acoc^ zb8IyBd6`V=1U_V7ZG6E*=frN?gku$T3%Y820AvMJNrJDn! zVXl4{IZO-~LOD<$h$05E`nP@!TLOfAqyP@lQ?w7mkE67R%XS7jtS1GTJsqwD6 z?AXx=@+#AJuDelo&hbw+1+L5V=o!Y{#V)Yd9@uGp^%E_B7MA48;%UJBC@;CbJt~av z%gkTjzs!IiP;{k@zh9ZMvev9dF1b9u^O;-Z?QOvinX)`NQDQGXKduykrDmTR9A@?^ zOrOp7DCHq2aQ)8hvYDO6smAN>Bxk(9#ku2lJlkbg{dyK}d#iSBos(D*?{>dY?m2AW zK(R=7bhP43Fmi;VLd=2$!?W`bhozD?kdmCU+ub)=R~6Y;lcTQqO!Y`6)K71_gmkm` zM6hqYYO2RMq3kxp?Spz4uGdiQyx8;;>7xR7sQV$u853cYiTxHsd;g5x{fcr za^t+qHR563xK;nee9Yvx*U_&h5GQRMt7}4tCHp#4`*zE>sFaoEg)iOe9}*6*8b{)J zA@U+>qE)#h;4Wp=fV z)W3OulyZ=7mO8#SFwuAwuMT(CnR?}bJCV|0AHV-C>s~}|9Cu^*Fffb>>MO}Oy}rD3 z>bE8`Z{~O!EUlD=gOk~!-Hfz=9!at7fG|NnJR6e4-g4fciMoIFHrr(cWt*6YIEVfL zrYJ{eD}KkRk(R*$x_`?XCX(048u6*X#qpZ{jjR(_2K)N~BN5FkHDT@Ov(%7!CVhAO z{_VpF_ADv2BxxyhMru{D4vGHLp@!eRA*`RIX^{w%{ZTL-X z&hyvKmyI06I=eHCUQ^TT#UXWJ@8jrpSJg3N_;0e1UpqsT629GDXZIz(Lqnm9wDum0 zbl>E-H7tqx^58kj)^kiAwtq-{Ol~!kqv?Ma@Z)4S zc-YG zVJW1S51L#ieOR--@ z*KT?iNBNJ9yMq{Dwkrc`?xp&G3+&V4$8gE-6o!4xS5pM(WXj_l% zhtoWeXHEkKM$7b3%86|y^{(O3EN=4@%roL!)?%eMO~Y##i{1q2NkZ0G%)aM{M!Zd}Bm5$l^`7@W5+ zzBQ-2>y3%xVAs~{iGqjCfnFBJu0I_0-|D=~s(FndAsD?R{QSx(Y;D{fQ*}@-XESEv z>6?4}{$)%ENePD`Te(S0k*Igxl@okPUjT({Gl%Z0)fGAuOURiL+&#j2z-YozGeT!&tg<(8GJm~F>GZfhkl3fx$L-CX1EQRWK8t) zRmJ^bhl&#qkB`KUdrVM_lGq&{7_Xll-1m}J7e{-bQzD1<%v&vLyQ0J>^Oj#|46^2O zb}?-zFbLL{i1cPj@kXi=^gNt|7uwb2=F6QizpE0y{#?==5!Khvo#l~uma)Q=SgFtT ze;E7fxG1}*TTB!cR7zSvYDnpn4u^)JOS-$HRRjbCL>N+FzG+?(TbhU;Xa; z{d0f!`EwZNsdM((XYIAux=IPYkR*rbv9MY)m;FE_zB-H?&Gq}7YROBHkCk=>WVI=W z54!gb*MT66;Z~K1*Mf;Yb@j6Z(kVrffZQP>rJhCzsEuFm>#D*s~^*WhOudfEeT7Od8EOqfm8V|00->?PiBl|-q z$x91oN3%C~wxp3kf567Y= zS?Bh<$sd$h@USs2X7amcx0)Un2OJX86H%QY>b{?)U)MyKH!`+>bn?Y1Zeh+Q--goJ z=iqi|<>;Y7pjZ_8hC#uc!`a;AaLmW(lt3IZsVB=#^^srQ6h?Hb2 zD;vRO)HY1ql7E1p(_*N`8^wWjz@edEKc)-qA)+Lav!MQY-MHPO4d|>Wmn+R41#usb zm!I9qq59d|+S%9E*Ve|wm|x%0a^5rTc3V)%PrwUUly>+kC=A#g451>OH^&;fhQi|q zzShqlI0_4MzePOvxO4UM=ls~%-DUa0v7Ef5xUXNI*xMZ}EQC2YI?^D~^6|ok>%8(0 zztW+{E-cU6trc;9UY|@iR#p!F`0-;xLOFwMx?8zi*4JRqmtnNLd{c}%wLM0~7l&&y zg0Y$Yh2@dP$})MDahN;o?pl3Si*T|$|Ct2b)_&igP!~kh-(sKgQ3&DQs1(FEGb=9* zCXuNCL{uhKHHuW}LRH=8=9dUKc23V-O=G63aWRPwR^X{LicD;5wKp%X=bN+9(T_FJ z#m!eV8Ol}i3yAru?C?zmsCfbd{E;?u0p7^mcw!yWNJVQ+;z%RGez+zBroX6hk5)L; z@VUPfWu$ykMaANIsm8F-+!8Ivs#q7K4?iW#MY`nR%*IIvyzlsZ4~*x1EhX1iB0@nDCnbv#;Bqpgz|pvAHg6> zpeiKihl1wq;P5_3MoIeN_b+IAg{tBzDhxw*rC31rS@O{%$A$s2x15bN9b?=mz>jWTzYg!mQp1KOz(z!R=-&QuN2 z?XI=EqX?74*2B~A>zub$Wiz%>Y!8P-3(>$AYW_%}p;(I0qN--a^fN-1brllRTTc#c zs_l;M$9CJ=+FCe3mS(9Q0iSvVg&`WED5K)DDY2p=COLV^0dAfg)-ciycReXN`FppX zkFOJifuXx&z;m2ANiHfnn$N)I=4UclsEDRkSkCZZ*XX@m;UtBGB{Zt4Ka_NnY;~^s ztk$V1sh1haa-BUR>Y462dyICKyq6tEX_GHrtlL*=nweTyXyOrA19RxGw``3$SavSm ziI6n$kb=XnU6eAUy=ig*ja;7dIUkd>%kz?ew_g|kS=GNU08A3|3FQw!y=HN9ajaNe z)k-^Fg6{a>fPtDCZTDo(&c(e}W=DIPx!7+1;88Ty$Df%2Xde^u%R~L2b<5C2^mPvp z19>iDI-Kkx1vzzfBfu9iGg@!ao6?Lk{f=7r5T=$-f>1EEFkm&1;Lj9srDBdTllmI; z>CJz4R{TOaC#6R-dY?a$Ti$__k#2gi9u5IfFy2w97& z#J~S&GCJhP@K&Ttf6(*!N%E#Dk!M6@ZhB=9rmT3#tq!Tpy(lfL0D8;)_iz*Hg>)hOZA zaI`<1t;AftVlG}HEp$KQcLqXh@coBGFwiuZD4j9|q2g;88-mXEsj8@ic=xORd!2p0 zG>gpsBCoP=ctgoi%|Z8t2`Z#Ba24>i|2ZzH{?m}b{-1`764U>X*8cY!jdfW++?=*< z>B8mIG_e2kLnyM|=}h%xI1v#RIN91u_Ql$#r^WfvHz@o!^|(5o)6K(x)%x#Yaufek z7plUjlS*(vVq{=3Kfj)qpWplUhf2J1@!qMasxqiu&n!u<$P)8NV6;mAz@I3+NGCz& zW@k$tMKb&-EGs#j~?y=(knVYO6r7uWt!@`-Kc~Fny^aXwb8dQH^S;LVLVkVZ37PasTn_*X)(J zE*E2ScOIvh<-}9k+8(@kqxUr^&I2xBv(uAnZ!E*wkHN=g(WJ^~t;6yta4IJ!JCd=f zxy3|_SCV#P4>NsyVv>rY$*KzXvREf$j~T8-|Hq z;ey_dPBviWA0O@=te<{8x}3VlUgnf8OT^=LuA2Uhp)np$ks>42laTO71|A=xa}>0v z|2jBYO+q5|mBoBD84|dhft>Q$u@1-fByxIk)a&$1;irz@ZS&UFI^9!Om$xf5tt_Ol z2GKKN5cJ6ObZY|>3o8pVQ!}gFO1xck@-{Ol+h>c5#Tv*yN*4MALpG>>xKgk#vo(@dotJrZ?4@%N< zlhaDIzGEOP2i}6RiDOm4X*yBxg#|12nbf z>UKC(bQ*g`yFk@KSRa!ia&(slJ+`r53DpI{2N$8k^3VDs3<%U)i&~!)v>cU0zwRUD zmM++V-Z~J$#>F;61ESPG*JyRsgct{VakT7pc>&rcu7Rncxw)}8hD%MI$n#|*90yra znQn3=PtSN`=?{p;!8JKd6w|4Y;MB1U|2%5PfkQdDZ!62&<05>HLMN46Fj}0$oVPS<=R*nmL z(RmG;3ryE5AfKo8L>)vz?E@E^K7$#{#Z?VFQNUD=j*8;qsSYO~7%V=-n%>yha9*Dp zZfI}+`Lm;KOhhv*r&(kgfzmn3C_wbi<-#Z}UEZmsgw}$+wXf}S_gO4`!VFt&{H@4z z@*Es$Y`ecCT=ic4dtihW+vLz<JbZWJ&1ITe3G*zg&5G@*d+d4Mul2z!TwtSytRRWdDFO)9ggG={~12vR6B z(GA@%SB~0D8$WxQG`;Zn-o3Ng{EJTmtW;Ey*LVCJH~NjcjY;HPm8##vv9YnkI<6(&fRyJtryT!})E^2p4k!q69Dx&51#H??;wfe@HA6qD&Ey6_XG5 zP6>m<*?e>;4UMjE5mA?ew}UV$J4SbZb93lV4rIF1{NjYG+s%?Wo|6!;fKVwzs`cXN zSE-ZJWumJbcDW%{dGayvV|gzBKkb~ksm-@We#zi?eCi^iJ98(VoRq&wv7~`u7hg-X z@C@=&9+zQvhI2K~>vKG^Zc@3lRJr{zF)ItO3>q^EdyB>`2sGT%C`{~t$@-HNci`1< z3lISPEBdQv>*e(+HF>=z7@49P7v89=G>Uz#d~UT$GA1vpa|2A{rH5u~U2sDbta@kLUL;)MOIhvR!A!Wk1bSG^lXArC9_)kE!sxE z1|{6UFb%j>zKD%l-#;^!Aw;A@Xosy6LK3*Q(6%O~+u9`PaLl+vs+|39)5Xv@W~+TT z{?xd%zJC;J@@sv0rdWjp6SMgA6m_LHK9gvxb!DT|@VO5m>hnLzen~Knj!$1dbSIiY z+0^AHhNtBE)p>U#$B=Or_xs#$9VAwg&|ci&RkNk7cYR$mG6|eHFi7>$s->kRrppS> z;PY@<(HZ14Ma~MaNM6v_$7ct^yE908T5L)g7a2Mn3W_Fvb(PUPO+p^snAq6X;oV0n z3|c11@$>Q0{9d%r0$Zh`<~Qe^mWK+{CKGtv$RNyQspjQ0EJp3vMc^B=60cGx%9heE zr=}W&eKl0owQ?)l-eCq3kvQ}7v2jsX6zA)p@J&^E*gH8b0~<_A*#xC;33d7#l+wlF zO?QDP=HVf$m`7?7H6w?(N@Rj6i(Ga|#g`fUBPDd>o)kZ>@#xYxTwH7%oW-^MBVt|? z<57~K;bG&#^@K2w`b5$stwvTuki3O2E>2gM`FwVAbg;{@6jlT=`+pjKp{mNq@$_*< zbE=UlVqA5{TAORtUR)e~-+FLnYv=2(ZI;XZn4{8vE}F7whIshGQnxkFF#y)#wmlU>?4p=QnC^GUV(KfTv|MtmJy z1tUaVPbJ~ANPH%f=G?DBg=qubG9hMfX2Z@8{4MUsQ+KFE)E?;qfiIx7-r3nHRPB_X zg|oO^^3&DH!OqssZtwV5R_d#{&KkDq`)#L6O>I><0onuIJM3-);dm{Slow7n+8oHN z=wsXb(Ld7RC?T8AoI5!ZdOR(^2CAmz+Jn}0y3V|)iacD#^mtUbVM)pbrokuRQLQGl&h=Kh0%M9bg19!36o2z6I}riw=O z@&RS~pT4mVBYR%}M>tN<6z+aAe0imU77j7$t}WR^T5Qs3{>Zg0WH(7keO?IM9b>N| zA3cLM&DX`rAz)Anvj15q+sTQAOf0h$l_|p7Z|wA5Y7qzKH)dvd?&~XQP9up3weoxu zbmLh`ZeFTz$RZTrFTD84=`;N!gvDhl9JW8JzA{Lh9sre+L6&#w5-=@_(2`A$R?^RZ zQa#x|^5A+1T_k%>cpL2bIKN%0lB*fnbQF~|LeKrqrw}4gCT*oyxxDSIHC3YoF$98t z7Kd87incD?p0^r;Wf*|3xMdDV%4Kg7Wx_FLLW0|%vWAJ4-@ar4!wfF^%G5-F?gVD5 z(c%0RjLBX64Umy#=mY$m1(T&X=PRq5c%=E9F)eL}P}Olsnd7&11OLK)&;mI`mm|(^ zFS*gN&)ofh4%k}8d))VLCLiim! z&H0#Tno*6CD>S*F3Q}Po!|E3G>(bcCKF3*P$lh zk-E!Oi8;z-Ju!LvbBl0}$r-$Ay6f(2_SV01Noe6dOyEvIS)C+qK zFDhd`)+Rv_STNp5PYPPGdYvbRomf-4S}4vy;wE|d(Aw)Drz zFF9gO+0mF?kBKzDMaD)9Y${q6MqSoyZp^Ki(Gvd9>~`VC`Uo~jHy?t1^yFD|a4%!2 zwXq)FlOhoN*dNd5#tMYxLZhScJh?7ot!r@}YHWS~LWeVW*jD<5$C=Z@DqW`GPg++W zHrR1XnJ;3*365=8;wOdoP2uc>MhSC~c`X_fRh<=}LHJAtFgra9*Y(EEXUgI57^ijU zJ>WqiOpckW2*<(o`FxyeYFfzS>rh96Ef0$trAfU&R&$tI&rXam?DshGpsr9IRsE_< ztH?NA-AjSJ;IkX*S}~4*M_ipQU`UyN@%P^zd)AKi??f;8G{TFjEbPJ+eSY8fj$d&9 z+yUPL3t&6A}Xakd&o2I!k%smu;8acZ!AOK7#?i6#B?py#UqL^q zE?);UkOl#qtrOGFAHOg%u@Jf^OX5{1ar$yuX6L%mHzYbWC51eFQHspJ%GrLGMIZGE z5fPrPFeMyd&5s2Ru&{Bxj@Qy1E`Bv)tj+x*6<+lmOzYm;dpKR~oKh4Si3`UdnS)obBbkj}{B06O=BP7=ry$ER_dIPtT{1LG zmx(hNYQ%P2?v8VwZ}R_m=YH+^x;f}RYYic}IbD$L>gqx#;x;fh=W#!^$%Iw8pE3WL zNr^5KBVc7ngvVB2nCBzKe40)_r)dDQb2^uWpqwr#^jH@Cmap|6P^3Q$7ra@Y;e+N6 z3=AySvFaccY`Heu71Pl1IjOI#Y8XHZTpvuc~E;|s1ftc6e zi~bWT;}w6*|54TH9xRiYM@Km_k(s}~zSxm>*-xc$f0H1Ok^a0N>5U|xc5`)Q4tYgS zu0<9)rmCh>VKcWmUJONiZ`Lz!F}}*(%pruP-A5;sSn|AcKImc8y~~hBS-(D>jf;JL zxFvkT#l?jafwRyWBny=y1ZEUth5Czjl?nOzlw*IZ0_b0Vqo#U#$&A`HWBEFDxH|5( z(^ZZSAMRlh)8EVnpf?C6k&4yzjHo%qnC{*2HRugS6? z7g&N7gt?<?%c+4RGS}G4+znM1 z{%a;KmP>67mZ|VM31DHIl?q(6d~D3~_}DG7(MK=O8R%uFz^p6TEc;ka|Jp{G$ z!UEkaKMa#SFML0{6Z_=xSFzv(;0hWS@m&J$Z+lYAJeVP;q?TkVhQXM?zeX}tsZvLJ z;d}*&Y^UZ_q`@xF*D5M0+G2UPnyHTp4Sb}f$>)B_Eyvt!RLt$`z}VIa?CZ1QR8D=u zEZBjq4XG*3+o7byqOZED>KCRK4QALli>*`DxmJ|V20aE3?!6}i@7W|psm<_j%;k9% z*?AYfBv#_4q20tAA8}7rrpJmola-XA%6L{#Gd@_8(vaBSJM7VPu_|4+@W;eY%qhOz z`$a4~PUtMm%tpoI%Qz7rkxh+^i;od8WF)zXZT2B#5JxC<7!2fm9jdsHKcZxWwCN8C zkHSi?z=q#^E4}buHbv-{bq3vkF6r>e*Q{*FpC0@VPLi45HVE)Wx+U$f@kRX397+^n)OUs z#WemoObP$R3wcsLw(+s19Le%GT2)6f($ESI@|v~EjWq(3-&b;R;KNgG8Xx3eLxhRl zKX7)N_coE1rwb5r`1jrRS+%D=2YK6aVd=~Id7Fx|<6un&=Yd0)!@lw1V#KWoX!Dom zD$l%VxXrMmt`f1_BD-{qb|ATaJ`uY%p*)LJbXOGpsIH!WEphnpF#`Y zimBTTO~+DF8UdqvI(mBO+pp$&x9~T9KOF>DYT>c2G-7PqKF`F=H^+eJjugo<*x>AYV#$ZZF}J4YtJIdfnmM4G4R{_#g69rf6!3ii$Z}=TOa*OG~Fj zP&Tu83A^zJ(W~Z82tQy`A+1rOzY@;^Wb$;syRAFj&3S1RCkEOH#G z;q?4p&s6Z?2QR$;MDVX0@Pt*-fgVciEJNTDPX#jFw8 ztCAT`(Jb2UY`wB#_!{NrElBr?c$`6*6T(PeMPTdwhVpRF*3K#0GO_Y8Lu_|ElOc|* zxbsb(PBU3jX0+&5^IO(nk_k(&hkb!nv~$vZ*f%|xZXV)UXFV-oC~NHnMy!WN> Jwsxg-wLud5rPca?KUoyr* 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%0DV(^=%#x%n7-#+nsx_PxHs0@18N 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 Ud0HfDjUr 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

PF#J&CDrs} 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 U@)re_Rfp?YVKc_)!7oey0_jt+O#lD@ literal 0 HcmV?d00001 diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..785e75dbafe0a26b88a905a31ba00f90fab19233 GIT binary patch literal 85338 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


JfeO=Y-pyf2<`O<8=a4#MZk722?p>JMnv3{!3 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 i?2LC3|QcP}>G6vMcG&i)UGE<%C; literal 0 HcmV?d00001 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..12c320c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,67 @@ +import js from "@eslint/js"; + +export default [ + { + ignores: ["build/**", "dist/**", "node_modules/**", ".playwright-mcp/**", "SOURCE_MANIFEST.txt"], + }, + { + files: ["**/*.{js,cjs,mjs}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + Buffer: "readonly", clearInterval: "readonly", clearTimeout: "readonly", console: "readonly", + document: "readonly", fetch: "readonly", FormData: "readonly", globalThis: "readonly", + process: "readonly", queueMicrotask: "readonly", requestAnimationFrame: "readonly", + setInterval: "readonly", setTimeout: "readonly", URL: "readonly", URLSearchParams: "readonly", + window: "readonly", confirm: "readonly", localStorage: "readonly", structuredClone: "readonly", + }, + }, + rules: { + ...js.configs.recommended.rules, + "no-control-regex": "off", + "no-empty": ["error", { allowEmptyCatch: true }], + "no-undef": "off", + "no-unused-vars": "off", + "no-useless-escape": "off", + "no-eval": "error", + "no-implied-eval": "error", + eqeqeq: ["error", "always", { null: "ignore" }], + }, + }, + { + // The main process and shared modules are plain CommonJS with an explicit + // dependency graph, so undefined identifiers there are always real bugs + // (missing require, missing injected dependency) rather than a global that + // another script tag happens to define. + files: ["src/main/**/*.cjs", "src/shared/**/*.cjs", "main.cjs", "preload.cjs"], + languageOptions: { + sourceType: "commonjs", + globals: { + require: "readonly", module: "writable", exports: "writable", + __dirname: "readonly", __filename: "readonly", + Buffer: "readonly", process: "readonly", console: "readonly", + setTimeout: "readonly", clearTimeout: "readonly", + setInterval: "readonly", clearInterval: "readonly", setImmediate: "readonly", + queueMicrotask: "readonly", structuredClone: "readonly", globalThis: "readonly", + URL: "readonly", URLSearchParams: "readonly", fetch: "readonly", + FormData: "readonly", Blob: "readonly", + AbortController: "readonly", AbortSignal: "readonly", + TextEncoder: "readonly", TextDecoder: "readonly", + }, + }, + rules: { + "no-undef": "error", + // Also catches code that a refactor left behind, such as a value computed + // from a dependency that is no longer injected. + "no-unused-vars": ["error", { args: "none", caughtErrors: "none", ignoreRestSiblings: true }], + }, + }, + { + files: ["tests/**/*.mjs"], + rules: { + "no-regex-spaces": "off", + "no-unsafe-finally": "off", + }, + }, +]; diff --git a/examples/gitea-actions/deploy.yml b/examples/gitea-actions/deploy.yml new file mode 100644 index 0000000..e0ca2f4 --- /dev/null +++ b/examples/gitea-actions/deploy.yml @@ -0,0 +1,51 @@ +name: ForgeFlow deployment + +on: + workflow_dispatch: + inputs: + environment: + description: Fixed ForgeFlow deployment environment + required: true + default: production + commit_sha: + description: Exact commit verified by ForgeFlow + required: true + request_id: + description: ForgeFlow correlation identifier + required: true + +concurrency: + group: forgeflow-${{ gitea.repository }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + deploy: + # Register a trusted runner with this label, or replace it with your own. + runs-on: forgeflow-production + steps: + - name: Validate dispatch inputs + shell: bash + run: | + set -euo pipefail + [[ "${{ inputs.environment }}" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || { + echo "Invalid environment identifier" >&2 + exit 64 + } + [[ "${{ inputs.commit_sha }}" =~ ^[0-9a-fA-F]{40,64}$ ]] || { + echo "Invalid commit SHA" >&2 + exit 64 + } + [[ "${{ inputs.request_id }}" =~ ^[A-Za-z0-9._:-]{1,100}$ ]] || { + echo "Invalid request identifier" >&2 + exit 64 + } + + - name: Deploy exact allowlisted version + shell: bash + run: | + set -euo pipefail + sudo /usr/local/bin/forgeflow-deploy \ + "${{ gitea.repository }}" \ + "${{ inputs.environment }}" \ + "${{ inputs.commit_sha }}" \ + "${{ inputs.request_id }}" diff --git a/examples/gitea-actions/forgeflow-approved-deploy.yml b/examples/gitea-actions/forgeflow-approved-deploy.yml new file mode 100644 index 0000000..857c953 --- /dev/null +++ b/examples/gitea-actions/forgeflow-approved-deploy.yml @@ -0,0 +1,90 @@ +name: ForgeFlow approved deploy + +on: + workflow_dispatch: + inputs: + repository: + description: Signed allowlisted deployment target (owner/repository) + required: true + type: string + environment: + description: Allowlisted ForgeFlow environment + required: true + type: string + commit_sha: + description: Exact approved commit SHA + required: true + type: string + request_id: + description: Immutable AppOps request identifier + required: true + type: string + approval_id: + description: AppOps approval identifier + required: true + type: string + approval_fingerprint: + description: Immutable AppOps approval fingerprint + required: true + type: string + evidence_issued_at: + description: Signed evidence UNIX timestamp + required: true + type: string + evidence_signature: + description: Base64 Ed25519 signature over the exact deployment evidence + required: true + type: string + +concurrency: + group: forgeflow-approved-${{ inputs.repository }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: forgeflow + steps: + - name: Validate signed deployment inputs + shell: bash + env: + FF_REPOSITORY: ${{ inputs.repository }} + FF_ENVIRONMENT: ${{ inputs.environment }} + FF_COMMIT_SHA: ${{ inputs.commit_sha }} + FF_REQUEST_ID: ${{ inputs.request_id }} + FF_APPROVAL_ID: ${{ inputs.approval_id }} + FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }} + FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }} + FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }} + run: | + set -Eeuo pipefail + [[ "$FF_REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] + [[ "$FF_ENVIRONMENT" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] + [[ "$FF_COMMIT_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] + [[ "$FF_REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]] + [[ "$FF_APPROVAL_ID" == "$FF_REQUEST_ID" ]] + [[ "$FF_APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] + [[ "$FF_EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]] + [[ "$FF_EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]] + + - name: Execute root-owned verified deployment + shell: bash + env: + FF_REPOSITORY: ${{ inputs.repository }} + FF_ENVIRONMENT: ${{ inputs.environment }} + FF_COMMIT_SHA: ${{ inputs.commit_sha }} + FF_REQUEST_ID: ${{ inputs.request_id }} + FF_APPROVAL_ID: ${{ inputs.approval_id }} + FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }} + FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }} + FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }} + run: | + set -Eeuo pipefail + sudo /usr/local/bin/forgeflow-deploy \ + "$FF_REPOSITORY" \ + "$FF_ENVIRONMENT" \ + "$FF_COMMIT_SHA" \ + "$FF_REQUEST_ID" \ + "$FF_APPROVAL_ID" \ + "$FF_APPROVAL_FINGERPRINT" \ + "$FF_EVIDENCE_ISSUED_AT" \ + "$FF_EVIDENCE_SIGNATURE" diff --git a/examples/gitea-actions/rollback.yml b/examples/gitea-actions/rollback.yml new file mode 100644 index 0000000..88fd532 --- /dev/null +++ b/examples/gitea-actions/rollback.yml @@ -0,0 +1,50 @@ +name: ForgeFlow rollback + +on: + workflow_dispatch: + inputs: + environment: + description: Fixed ForgeFlow deployment environment + required: true + default: production + target_sha: + description: Exact previously successful commit verified by ForgeFlow + required: true + request_id: + description: ForgeFlow correlation identifier + required: true + +concurrency: + group: forgeflow-${{ gitea.repository }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + rollback: + runs-on: forgeflow-production + steps: + - name: Validate rollback inputs + shell: bash + run: | + set -euo pipefail + [[ "${{ inputs.environment }}" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || { + echo "Invalid environment identifier" >&2 + exit 64 + } + [[ "${{ inputs.target_sha }}" =~ ^[0-9a-fA-F]{40,64}$ ]] || { + echo "Invalid target SHA" >&2 + exit 64 + } + [[ "${{ inputs.request_id }}" =~ ^[A-Za-z0-9._:-]{1,100}$ ]] || { + echo "Invalid request identifier" >&2 + exit 64 + } + + - name: Restore exact allowlisted version + shell: bash + run: | + set -euo pipefail + sudo /usr/local/bin/forgeflow-deploy \ + "${{ gitea.repository }}" \ + "${{ inputs.environment }}" \ + "${{ inputs.target_sha }}" \ + "${{ inputs.request_id }}" diff --git a/examples/server/forgeflow-deploy b/examples/server/forgeflow-deploy new file mode 100644 index 0000000..4218671 --- /dev/null +++ b/examples/server/forgeflow-deploy @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 027 + +# Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by +# the Gitea runner. Targets are read from the root-owned data file below. +# Approved machine deployments additionally verify an AppOps Ed25519 signature +# using the root-controlled public key; the Actions runner never receives that +# trust anchor's private key. Every verified approval id is consumed exactly +# once in a root-owned replay journal before target lookup or mutation. + +readonly CONFIG_FILE="/etc/forgeflow/targets.conf" +readonly EVIDENCE_PUBLIC_KEY_FILE="/etc/forgeflow/evidence.pub" +readonly EVIDENCE_REPLAY_DIR="/var/lib/forgeflow-status/approved-requests" +readonly REPOSITORY="${1:-}" +readonly ENVIRONMENT="${2:-}" +readonly SHA="${3:-}" +readonly REQUEST_ID="${4:-manual-$(date +%s)}" +readonly APPROVAL_ID="${5:-}" +readonly APPROVAL_FINGERPRINT="${6:-}" +readonly EVIDENCE_ISSUED_AT="${7:-}" +readonly EVIDENCE_SIGNATURE="${8:-}" + +fail_usage() { + echo "Usage: forgeflow-deploy [request-id] [approval-id approval-fingerprint evidence-issued-at evidence-signature]" >&2 + exit 64 +} + +(( $# == 3 || $# == 4 || $# == 8 )) || fail_usage +[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage +[[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage +[[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail_usage +[[ "$REQUEST_ID" =~ ^[A-Za-z0-9._:-]{1,100}$ ]] || fail_usage +[[ -f "$CONFIG_FILE" ]] || { echo "Missing target configuration: $CONFIG_FILE" >&2; exit 78; } + +# The target file is security-sensitive because it controls root-executed paths. +config_owner="$(stat -c '%U' "$CONFIG_FILE")" +config_mode="$(stat -c '%a' "$CONFIG_FILE")" +[[ "$config_owner" == "root" ]] || { echo "Target configuration must be owned by root" >&2; exit 78; } +# Reject group/other write bits. GNU stat returns an octal string such as 640. +(( (8#$config_mode & 8#022) == 0 )) || { echo "Target configuration may not be group/other writable" >&2; exit 78; } + +EVIDENCE_VERIFIED=false +if (( $# == 8 )); then + [[ "$REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]] || { echo "Approved deployment request ID is invalid" >&2; exit 64; } + [[ "$APPROVAL_ID" == "$REQUEST_ID" ]] || { echo "Approval ID must equal the immutable request ID" >&2; exit 65; } + [[ "$APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] || { echo "Approval fingerprint is invalid" >&2; exit 64; } + [[ "$EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]] || { echo "Evidence timestamp is invalid" >&2; exit 64; } + [[ "$EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]] || { echo "Evidence signature encoding is invalid" >&2; exit 64; } + [[ -f "$EVIDENCE_PUBLIC_KEY_FILE" ]] || { echo "Missing AppOps evidence public key: $EVIDENCE_PUBLIC_KEY_FILE" >&2; exit 78; } + + evidence_owner="$(stat -c '%U' "$EVIDENCE_PUBLIC_KEY_FILE")" + evidence_mode="$(stat -c '%a' "$EVIDENCE_PUBLIC_KEY_FILE")" + [[ "$evidence_owner" == "root" ]] || { echo "Evidence public key must be owned by root" >&2; exit 78; } + (( (8#$evidence_mode & 8#022) == 0 )) || { echo "Evidence public key may not be group/other writable" >&2; exit 78; } + command -v openssl >/dev/null 2>&1 || { echo "OpenSSL is required for approved deployment evidence verification" >&2; exit 69; } + + now_epoch="$(date +%s)" + (( EVIDENCE_ISSUED_AT <= now_epoch + 60 )) || { echo "Deployment evidence is issued too far in the future" >&2; exit 65; } + (( EVIDENCE_ISSUED_AT >= now_epoch - 1800 )) || { echo "Deployment evidence expired before execution" >&2; exit 65; } + + evidence_tmp="$(mktemp -d /run/forgeflow-evidence.XXXXXX)" + cleanup_evidence() { rm -rf "$evidence_tmp"; } + trap cleanup_evidence EXIT + printf 'forgeflow-evidence-v1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + "$APPROVAL_ID" \ + "$APPROVAL_FINGERPRINT" \ + "$REPOSITORY" \ + "$ENVIRONMENT" \ + "${SHA,,}" \ + "$REQUEST_ID" \ + "$EVIDENCE_ISSUED_AT" > "$evidence_tmp/message" + printf '%s' "$EVIDENCE_SIGNATURE" | base64 --decode > "$evidence_tmp/signature" 2>/dev/null || { + echo "Deployment evidence signature could not be decoded" >&2 + exit 65 + } + openssl pkeyutl -verify \ + -pubin \ + -inkey "$EVIDENCE_PUBLIC_KEY_FILE" \ + -rawin \ + -in "$evidence_tmp/message" \ + -sigfile "$evidence_tmp/signature" >/dev/null 2>&1 || { + echo "Deployment evidence signature verification failed" >&2 + exit 65 + } + + # Consume the verified approval before any target lookup. mkdir is atomic, + # making this a cross-process replay fence. A failed first deployment still + # requires a fresh human approval, matching AppOps' terminal execution model. + install -d -o root -g root -m 0700 "$EVIDENCE_REPLAY_DIR" + if ! mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID" 2>/dev/null; then + echo "Approved deployment evidence was already consumed" >&2 + exit 65 + fi + EVIDENCE_VERIFIED=true +fi + +APP_DIR="" +BRANCH="" +COMPOSE_FILE="" +HEALTHCHECK_URL="" +STATUS_FILE="" +while IFS='|' read -r config_repository config_environment config_app_dir config_branch config_compose config_health config_status extra; do + [[ -z "${config_repository// }" || "$config_repository" == \#* ]] && continue + [[ -z "${extra:-}" ]] || { echo "Invalid extra field in $CONFIG_FILE" >&2; exit 78; } + if [[ "$config_repository" == "$REPOSITORY" && "$config_environment" == "$ENVIRONMENT" ]]; then + APP_DIR="$config_app_dir" + BRANCH="$config_branch" + COMPOSE_FILE="$config_compose" + HEALTHCHECK_URL="$config_health" + STATUS_FILE="$config_status" + break + fi +done < "$CONFIG_FILE" + +[[ -n "$APP_DIR" ]] || { echo "Repository/environment is not allowlisted" >&2; exit 64; } +[[ "$APP_DIR" == /* && "$COMPOSE_FILE" == /* && "$STATUS_FILE" == /var/lib/forgeflow-status/* ]] || { + echo "Target configuration contains an unsafe path" >&2 + exit 78 +} +[[ "$BRANCH" =~ ^[A-Za-z0-9._/-]+$ && "$BRANCH" != *..* ]] || { echo "Unsafe branch in target configuration" >&2; exit 78; } +[[ -d "$APP_DIR/.git" ]] || { echo "Application directory is not a Git working tree: $APP_DIR" >&2; exit 72; } +[[ -f "$COMPOSE_FILE" ]] || { echo "Compose file does not exist: $COMPOSE_FILE" >&2; exit 72; } + +install -d -o root -g root -m 0755 "$(dirname "$STATUS_FILE")" + +json_string() { + # Inputs accepted by this script are deliberately restricted to characters + # that do not need JSON escaping. This guard prevents accidental expansion. + [[ "$1" =~ ^[A-Za-z0-9._:/-]*$ ]] || return 1 + printf '%s' "$1" +} + +write_status() { + local health="$1" + local live_sha="$2" + local previous_sha="$3" + local exit_code="${4:-0}" + local deployed_at temporary + deployed_at="$(date --utc +%Y-%m-%dT%H:%M:%SZ)" + temporary="${STATUS_FILE}.${$}.tmp" + json_string "$health" >/dev/null + json_string "$REQUEST_ID" >/dev/null + json_string "$APPROVAL_ID" >/dev/null + json_string "$APPROVAL_FINGERPRINT" >/dev/null + json_string "$EVIDENCE_ISSUED_AT" >/dev/null + [[ "$live_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid live SHA for status output" >&2; return 1; } + [[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; } + cat > "$temporary" <"/run/lock/forgeflow-${REPOSITORY//\//-}-${ENVIRONMENT}.lock" +flock -n 9 || { echo "Another deployment is already running" >&2; exit 75; } + +current_sha="$(git -C "$APP_DIR" rev-parse HEAD)" +previous_sha="$(cat "$APP_DIR/.forgeflow-live-sha" 2>/dev/null || printf '%s' "$current_sha")" +[[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || previous_sha="$current_sha" + +echo "ForgeFlow request: $REQUEST_ID" +echo "Target: $REPOSITORY / $ENVIRONMENT" +echo "Current SHA: $current_sha" +echo "Requested SHA: $SHA" +if [[ "$EVIDENCE_VERIFIED" == "true" ]]; then + echo "Approval: $APPROVAL_ID (signed evidence verified)" +fi + +on_error() { + local exit_code=$? + local actual_sha + trap - ERR + actual_sha="$(git -C "$APP_DIR" rev-parse HEAD 2>/dev/null || printf '%s' "$current_sha")" + write_status "unhealthy" "$actual_sha" "$previous_sha" "$exit_code" || true + echo "Deployment failed with exit code $exit_code" >&2 + exit "$exit_code" +} +trap on_error ERR + +git -C "$APP_DIR" fetch --prune origin "$BRANCH" +git -C "$APP_DIR" cat-file -e "$SHA^{commit}" +git -C "$APP_DIR" merge-base --is-ancestor "$SHA" "origin/$BRANCH" || { + echo "Requested SHA is not part of origin/$BRANCH" >&2 + exit 65 +} + +write_status "deploying" "$current_sha" "$previous_sha" 0 +git -C "$APP_DIR" reset --hard "$SHA" +docker compose -f "$COMPOSE_FILE" up -d --build + +for attempt in $(seq 1 30); do + if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then + printf '%s\n' "$SHA" > "$APP_DIR/.forgeflow-live-sha" + printf '%s\n' "$previous_sha" > "$APP_DIR/.forgeflow-previous-sha" + chmod 0640 "$APP_DIR/.forgeflow-live-sha" "$APP_DIR/.forgeflow-previous-sha" + write_status "healthy" "$SHA" "$previous_sha" 0 + trap - ERR + echo "Deployment healthy at $SHA" + exit 0 + fi + echo "Healthcheck attempt $attempt/30 did not pass yet" + sleep 2 +done + +trap - ERR +write_status "unhealthy" "$SHA" "$previous_sha" 70 +echo "Healthcheck failed after deployment" >&2 +exit 70 diff --git a/examples/server/forgeflow-runner.sudoers b/examples/server/forgeflow-runner.sudoers new file mode 100644 index 0000000..a871e02 --- /dev/null +++ b/examples/server/forgeflow-runner.sudoers @@ -0,0 +1,4 @@ +# Validate with: sudo visudo -cf /etc/sudoers.d/forgeflow-runner +# Replace "act_runner" with the account that executes your trusted Gitea runner. +# Do not add shell wildcards or other commands. +act_runner ALL=(root) NOPASSWD: /usr/local/bin/forgeflow-deploy diff --git a/examples/server/forgeflow-targets.conf b/examples/server/forgeflow-targets.conf new file mode 100644 index 0000000..87b7717 --- /dev/null +++ b/examples/server/forgeflow-targets.conf @@ -0,0 +1,9 @@ +# ForgeFlow deployment targets +# +# Format (one target per line, no shell syntax): +# repository|environment|app_dir|branch|compose_file|healthcheck_url|status_file +# +# Keep this file root-owned and not writable by the runner account. + +jens/example-app|production|/srv/example-app|main|/srv/example-app/compose.yml|http://127.0.0.1:8080/health|/var/lib/forgeflow-status/example-app-production.json +jens/example-app|staging|/srv/example-app-staging|main|/srv/example-app-staging/compose.yml|http://127.0.0.1:18080/health|/var/lib/forgeflow-status/example-app-staging.json diff --git a/examples/server/nginx-forgeflow-status.conf b/examples/server/nginx-forgeflow-status.conf new file mode 100644 index 0000000..81665ed --- /dev/null +++ b/examples/server/nginx-forgeflow-status.conf @@ -0,0 +1,8 @@ +# Example virtual-host fragment. Keep this endpoint outside the application +# process so it can still report a failed release when the app itself is down. +location = /.well-known/forgeflow { + default_type application/json; + alias /var/lib/forgeflow-status/example-app-production.json; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; +} diff --git a/examples/server/status-example.json b/examples/server/status-example.json new file mode 100644 index 0000000..4723ace --- /dev/null +++ b/examples/server/status-example.json @@ -0,0 +1,15 @@ +{ + "repository": "jens/example-app", + "environment": "production", + "request_id": "appr-3a6ed71cd52d", + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "previous_sha": "89abcdef0123456789abcdef0123456789abcdef", + "requested_sha": "0123456789abcdef0123456789abcdef01234567", + "approval_id": "appr-3a6ed71cd52d", + "approval_fingerprint": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "evidence_verified": true, + "evidence_issued_at": "1787778000", + "deployed_at": "2026-08-26T21:00:00Z", + "health": "healthy", + "last_exit_code": 0 +} diff --git a/main.cjs b/main.cjs new file mode 100644 index 0000000..5ad4778 --- /dev/null +++ b/main.cjs @@ -0,0 +1,470 @@ +"use strict"; + +const path = require("node:path"); +const { + app, + BrowserWindow, + shell, + session, + safeStorage, + Tray, + Menu, + Notification, +} = require("electron"); +const { ConfigStore } = require("./src/main/config-store.cjs"); +const { GitService } = require("./src/main/git-service.cjs"); +const { GiteaService } = require("./src/main/gitea-service.cjs"); +const { RepositoryService } = require("./src/main/repository-service.cjs"); +const { DeploymentService } = require("./src/main/deployment-service.cjs"); +const { RepositoryMonitor } = require("./src/main/repository-monitor.cjs"); +const { DiagnosticsService } = require("./src/main/diagnostics-service.cjs"); +const { PreflightService } = require("./src/main/preflight-service.cjs"); +const { UpdateService } = require("./src/main/update-service.cjs"); +const { SshService } = require("./src/main/ssh-service.cjs"); +const { + UnraidDeploymentService, +} = require("./src/main/unraid-deployment-service.cjs"); +const { AuditService } = require("./src/main/audit-service.cjs"); +const { DeployKeyLifecycleService } = require("./src/main/deploy-key-lifecycle-service.cjs"); +const { UnraidDeployKeyHost } = require("./src/main/unraid-deploy-key-host.cjs"); +const { InventoryReviewService } = require("./src/main/inventory-review-service.cjs"); +const { GitValidatorService } = require("./src/main/git-validator-service.cjs"); +const { + ExternalToolsService, +} = require("./src/main/external-tools-service.cjs"); +const { registerIpc } = require("./src/main/ipc.cjs"); +const { + installOutputPipeGuards, + isBrokenPipeError, +} = require("./src/main/process-error-policy.cjs"); + +let mainWindow; +let repositoryMonitor; +let sshService; +let operationTimer; +let diagnostics; +let configStore; +let tray; +let quitCleanupStarted = false; +const reportBrokenOutputPipe = (error) => { + const report = diagnostics?.warning("process.output-pipe.closed", { + code: error?.code || null, + message: error?.message || "The parent output pipe was closed.", + }); + report?.catch(() => {}); +}; +installOutputPipeGuards({ onBrokenPipe: reportBrokenOutputPipe }); +const ownsSingleInstanceLock = app.requestSingleInstanceLock(); + +if (!ownsSingleInstanceLock) app.quit(); +else app.on("second-instance", () => showMainWindow()); + +function broadcast(channel, payload) { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send(channel, payload); + } +} + +function showMainWindow() { + if (!mainWindow || mainWindow.isDestroyed()) createWindow(); + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); +} + +function notify(title, body) { + if ( + !configStore?.data.preferences.notificationsEnabled || + !Notification.isSupported() + ) + return; + const notification = new Notification({ + title, + body, + icon: path.join(__dirname, "build", "icon.png"), + }); + notification.on("click", showMainWindow); + notification.show(); +} + +function configureDesktopIntegration() { + const preferences = configStore?.data.preferences || {}; + if (preferences.trayEnabled && !tray) { + tray = new Tray( + path.join( + __dirname, + "build", + process.platform === "win32" ? "icon.ico" : "icon.png", + ), + ); + tray.setToolTip("ForgeFlow"); + tray.on("double-click", showMainWindow); + } else if (!preferences.trayEnabled && tray) { + tray.destroy(); + tray = null; + } + if (tray) + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: "Open ForgeFlow", click: showMainWindow }, + { type: "separator" }, + { label: "Quit", click: () => app.quit() }, + ]), + ); + if (app.isPackaged && ["win32", "darwin"].includes(process.platform)) { + app.setLoginItemSettings({ + openAtLogin: Boolean(preferences.startAtLogin), + }); + } +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1480, + height: 940, + minWidth: 1120, + minHeight: 720, + show: false, + backgroundColor: "#0b0e14", + title: "ForgeFlow", + icon: path.join(__dirname, "build", "icon.png"), + autoHideMenuBar: true, + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", + webPreferences: { + preload: path.join(__dirname, "preload.cjs"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + spellcheck: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, "src", "renderer", "index.html")); + mainWindow.once("ready-to-show", () => { + mainWindow.show(); + diagnostics?.info("window.ready", { size: mainWindow.getSize() }); + }); + mainWindow.on("unresponsive", () => + diagnostics?.warning("window.unresponsive", {}), + ); + mainWindow.webContents.on("render-process-gone", (_event, details) => + diagnostics?.error("renderer.process.gone", details), + ); + mainWindow.webContents.on( + "did-fail-load", + (_event, code, description, validatedUrl) => + diagnostics?.error("renderer.load.failed", { + code, + description, + validatedUrl, + }), + ); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (/^https?:\/\//i.test(url)) + shell.openExternal(url).catch((error) => + diagnostics?.warning("external-link.open.failed", { + url, + message: error.message, + }), + ); + return { action: "deny" }; + }); + mainWindow.webContents.on("will-navigate", (event, url) => { + if (url !== mainWindow.webContents.getURL()) event.preventDefault(); + }); + mainWindow.on("close", (event) => { + if ( + !quitCleanupStarted && + configStore?.data.preferences.closeToTray && + configStore?.data.preferences.trayEnabled + ) { + event.preventDefault(); + mainWindow.hide(); + } + }); +} + +app + .whenReady() + .then(async () => { + if (!ownsSingleInstanceLock) return; + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'", + ], + }, + }); + }); + + const userDataPath = app.getPath("userData"); + const store = new ConfigStore(userDataPath); + configStore = store; + await store.load(); + diagnostics = new DiagnosticsService({ + userDataPath, + appInfo: { + name: app.getName(), + version: app.getVersion(), + packaged: app.isPackaged, + }, + secretProvider: () => [ + store.getToken(), + ...(store.data.servers || []).flatMap((server) => { + try { + const credentials = store.getServerCredentials(server.id); + return [credentials.password, credentials.passphrase]; + } catch { + return []; + } + }), + ], + preferencesProvider: () => store.data.preferences, + }); + await diagnostics.initialize(); + const audit = new AuditService({ + userDataPath, + appInfo: { version: app.getVersion() }, + }); + await audit.initialize(); + + process.on("uncaughtException", (error) => { + if (isBrokenPipeError(error)) { + reportBrokenOutputPipe(error); + return; + } + diagnostics + ?.error("process.uncaught-exception", error) + .finally(() => app.exit(1)); + }); + process.on("unhandledRejection", (reason) => + diagnostics?.error( + "process.unhandled-rejection", + reason instanceof Error ? reason : { reason }, + ), + ); + + const git = new GitService(); + const externalTools = new ExternalToolsService(store); + const gitea = new GiteaService(store, diagnostics); + const repositories = new RepositoryService(store, git, gitea, diagnostics); + const deployments = new DeploymentService(store, gitea, git, diagnostics); + const ssh = new SshService({ store, diagnostics }); + sshService = ssh; + const auditedOperationStates = new Set(); + const reportOperationChange = (payload) => { + broadcast("operations:changed", payload); + const operation = payload?.operation; + if ( + operation && + ["success", "failed", "rolled-back"].includes(operation.status) + ) { + const key = `${operation.id}:${operation.status}`; + if (!auditedOperationStates.has(key)) { + // One entry per completed deployment, so the set is trimmed rather + // than kept for the lifetime of the process. + if (auditedOperationStates.size >= 500) { + auditedOperationStates.delete(auditedOperationStates.values().next().value); + } + auditedOperationStates.add(key); + notify( + `Deployment ${operation.status}`, + `${operation.repository || "Repository"} · ${operation.shortSha || operation.sha?.slice(0, 7) || ""}`, + ); + audit + .append("deployment.completed", { + repository: operation.repository, + profileId: operation.profileId, + sha: operation.sha, + result: operation.status, + note: operation.releaseNote || "", + }) + .catch((error) => diagnostics.warning("audit.write.failed", error)); + } + } + }; + const unraid = new UnraidDeploymentService({ + store, + ssh, + git, + gitea, + diagnostics, + sourcePath: app.getAppPath(), + onOperationChange: reportOperationChange, + }); + const deployKeys = new DeployKeyLifecycleService({ + store, + gitea, + keyHost: new UnraidDeployKeyHost({ ssh }), + audit, + }); + const inventoryReviews = new InventoryReviewService({ store, audit }); + const updates = new UpdateService({ + store, + gitea, + diagnostics, + appInfo: { + version: app.getVersion(), + packaged: app.isPackaged, + executablePath: process.execPath, + portableExecutablePath: process.env.PORTABLE_EXECUTABLE_FILE || null, + }, + sourcePath: app.getAppPath(), + userDataPath, + }); + const preflight = new PreflightService({ + store, + git, + gitea, + deployments, + diagnostics, + userDataPath, + secureStorageAvailable: () => safeStorage.isEncryptionAvailable(), + }); + const gitValidator = new GitValidatorService({ + git, + gitea, + diagnostics, + store, + }); + repositoryMonitor = new RepositoryMonitor({ + store, + git, + diagnostics, + onChange: (payload) => broadcast("repositories:changed", payload), + }); + repositoryMonitor.restart(); + registerIpc({ + store, + git, + gitea, + repositories, + deployments, + unraid, + deployKeys, + inventoryReviews, + ssh, + updates, + preflight, + gitValidator, + diagnostics, + audit, + externalTools, + monitor: repositoryMonitor, + onPreferencesChanged: configureDesktopIntegration, + }); + configureDesktopIntegration(); + createWindow(); + + if ( + store.data.setupComplete && + store.data.updates?.autoCheck && + store.getToken() + ) { + setTimeout(async () => { + try { + const status = await updates.check(); + broadcast("updates:changed", status); + } catch (error) { + await diagnostics.warning("updates.startup-check.failed", { + message: error.message, + code: error.code, + }); + } + }, 2500).unref?.(); + } + + const gitAvailability = await git.isAvailable(); + await diagnostics.info("app.ready", { + platform: process.platform, + arch: process.arch, + setupComplete: store.data.setupComplete, + git: gitAvailability, + secureStorageAvailable: safeStorage.isEncryptionAvailable(), + }); + + const scheduleOperationPoll = () => { + if (operationTimer) clearTimeout(operationTimer); + const intervalMs = + Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * + 1000; + operationTimer = setTimeout(async () => { + try { + if (store.data.setupComplete) { + const active = store.data.operations.some( + (item) => + item.type === "deployment" && + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ); + if (active) { + const [actions, sshOperations] = await Promise.all([ + store.getToken() + ? deployments + .refreshActiveOperations() + .catch(async (error) => { + await diagnostics.error( + "operation-monitor.actions.failed", + error, + ); + return []; + }) + : [], + unraid.refreshActiveOperations().catch(async (error) => { + await diagnostics.error( + "operation-monitor.unraid.failed", + error, + ); + return []; + }), + ]); + const updated = [...actions, ...sshOperations]; + if (updated.length) { + broadcast("operations:changed", { operations: updated }); + for (const operation of updated.filter((item) => + ["success", "failed", "rolled-back"].includes(item.status), + )) + reportOperationChange({ operation }); + } + } + } + } catch (error) { + await diagnostics.error("operation-monitor.tick.failed", error); + } finally { + if (!quitCleanupStarted) scheduleOperationPoll(); + } + }, intervalMs); + operationTimer.unref?.(); + }; + scheduleOperationPoll(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); + }) + .catch(async (error) => { + await diagnostics?.error("app.startup.failed", error); + await diagnostics?.flush(); + app.exit(1); + }); + +app.on("before-quit", (event) => { + if (quitCleanupStarted) return; + event.preventDefault(); + quitCleanupStarted = true; + repositoryMonitor?.stop(); + sshService?.closeAll(); + if (operationTimer) clearTimeout(operationTimer); + Promise.resolve() + .then(() => diagnostics?.info("app.quitting", {})) + .then(() => diagnostics?.flush()) + .finally(() => app.quit()); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0a116b5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5052 @@ +{ + "name": "forgeflow", + "version": "0.10.15", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forgeflow", + "version": "0.10.15", + "dependencies": { + "ssh2": "1.17.0" + }, + "devDependencies": { + "@eslint/js": "9.39.5", + "@playwright/test": "1.62.0", + "c8": "12.0.0", + "electron": "43.2.0", + "electron-builder": "26.15.3", + "eslint": "9.39.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/c8/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/c8/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/c8/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/c8/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c8/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/c8/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c8/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/c8/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/c8/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c8/node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/c8/node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "43.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", + "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", + "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..82ee272 --- /dev/null +++ b/package.json @@ -0,0 +1,177 @@ +{ + "name": "forgeflow", + "version": "0.10.15", + "private": true, + "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", + "main": "main.cjs", + "type": "module", + "scripts": { + "start": "electron .", + "dev": "electron . --dev", + "demo": "node scripts/serve-demo.mjs", + "test": "node --test tests/*.test.mjs", + "lint": "eslint .", + "coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test tests/*.test.mjs && npm run coverage:modules", + "coverage:modules": "c8 report --check-coverage --per-file --include src/** --statements 60 --lines 60 --functions 50 --branches 36 --reporter=text-summary", + "verify": "node scripts/verify.mjs", + "dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/sign-release-manifest.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs", + "dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs", + "dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs", + "doctor": "node scripts/doctor.mjs", + "acceptance": "node scripts/acceptance.mjs", + "acceptance:isolated": "node --test tests/production-acceptance.test.mjs", + "architecture:audit": "node scripts/architecture-audit.mjs", + "test:browser": "playwright test", + "test:browser:ci": "playwright test --reporter=line,html", + "test:signing": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/test-authenticode-chain.ps1", + "signing:setup": "node scripts/setup-update-signing-key.mjs", + "connections:check": "electron scripts/validate-installed-connections.cjs", + "deployments:audit": "electron scripts/audit-installed-deployments.cjs", + "release:binary": "electron scripts/publish-binary-release.cjs", + "manifest": "node scripts/generate-source-manifest.mjs", + "check": "npm run verify && npm run lint && npm test", + "quality": "npm run check && npm run coverage" + }, + "devDependencies": { + "@eslint/js": "9.39.5", + "@playwright/test": "1.62.0", + "c8": "12.0.0", + "electron": "43.2.0", + "electron-builder": "26.15.3", + "eslint": "9.39.5" + }, + "build": { + "appId": "be.jenscaers.forgeflow", + "productName": "ForgeFlow", + "asar": true, + "files": [ + "main.cjs", + "preload.cjs", + "src/**/*", + "package.json", + "build/icon.png", + "build/icon.ico", + "build/update-signing-public.pem", + "docs/SETUP_GUIDE.md", + "docs/DIAGNOSTICS.md", + "docs/STATUS_ENDPOINT.md", + "examples/gitea-actions/**/*", + "examples/server/**/*", + "README.md", + "START_HERE.md", + "docs/DEPLOYMENT_SETUP.md", + "docs/SECURITY.md", + "docs/TEST_MATRIX.md", + "docs/RELEASE_NOTES_0.3.0.md", + "docs/RELEASE_NOTES_0.3.1.md", + "docs/RELEASE_NOTES_0.3.2.md", + "docs/UPDATING.md", + "scripts/apply-source-update.ps1", + "scripts/apply-binary-update.ps1", + "scripts/prune-dist.mjs", + "scripts/sign-release-manifest.mjs", + "docs/RELEASE_NOTES_0.4.0.md", + "docs/DEPLOYMENT_MIGRATION_EXAMPLE.md", + "docs/SSH_UNRAID_DEPLOYMENT.md", + "docs/RELEASE_NOTES_0.4.1.md", + "docs/RELEASE_NOTES_0.4.2.md", + "docs/RELEASE_NOTES_0.4.3.md", + "docs/RELEASE_NOTES_0.4.4.md", + "docs/RELEASE_NOTES_0.4.5.md", + "docs/RELEASE_NOTES_0.5.0.md", + "docs/RELEASE_NOTES_0.5.1.md", + "PUBLISH-AND-ENABLE-UPDATE.cmd", + "Publish-ForgeFlow-Release.ps1", + "Publish-Missing-Binary-Release.ps1", + "docs/RELEASE_NOTES_0.5.2.md", + "docs/RELEASE_NOTES_0.5.3.md", + "docs/RELEASE_NOTES_0.5.4.md", + "START-FORGEFLOW-OVERLAY.ps1", + "docs/RELEASE_NOTES_0.6.0.md", + "docs/RELEASE_AUDIT_0.6.0.md", + "docs/RELEASE_NOTES_0.6.1.md", + "docs/RELEASE_NOTES_0.7.0.md", + "docs/RELEASE_NOTES_0.8.0.md", + "docs/RELEASE_NOTES_0.8.1.md", + "docs/RELEASE_NOTES_0.8.2.md", + "docs/RELEASE_NOTES_0.8.3.md", + "docs/RELEASE_NOTES_0.8.4.md", + "docs/RELEASE_NOTES_0.8.5.md", + "docs/RELEASE_NOTES_0.8.6.md", + "docs/RELEASE_NOTES_0.8.7.md", + "docs/RELEASE_NOTES_0.8.8.md", + "docs/RELEASE_NOTES_0.8.9.md", + "docs/RELEASE_NOTES_0.9.0.md", + "docs/RELEASE_NOTES_0.9.1.md", + "docs/ACCEPTANCE.md", + "docs/RELEASE_NOTES_0.9.2.md", + "docs/RELEASE_NOTES_0.9.3.md", + "docs/RELEASE_NOTES_0.9.4.md", + "docs/RELEASE_NOTES_0.9.5.md", + "docs/RELEASE_NOTES_0.10.0.md", + "docs/RELEASE_NOTES_0.10.1.md", + "docs/RELEASE_NOTES_0.10.2.md", + "docs/RELEASE_NOTES_0.10.3.md", + "docs/RELEASE_NOTES_0.10.4.md", + "docs/RELEASE_NOTES_0.10.5.md", + "docs/RELEASE_NOTES_0.10.6.md", + "docs/RELEASE_NOTES_0.10.7.md", + "docs/RELEASE_NOTES_0.10.8.md", + "docs/RELEASE_NOTES_0.10.9.md", + "docs/RELEASE_NOTES_0.10.10.md", + "docs/RELEASE_NOTES_0.10.11.md", + "docs/RELEASE_NOTES_0.10.12.md", + "docs/RELEASE_NOTES_0.10.13.md", + "docs/RELEASE_NOTES_0.10.14.md", + "docs/RELEASE_NOTES_0.10.15.md", + "docs/CURRENT_STATE.md", + "docs/MUTATION_MODEL.md", + "docs/RELEASING.md", + "docs/COVERAGE_POLICY.md", + "docs/DEPENDENCY_AUDIT.md", + "docs/PRODUCTION_READINESS_1.0.md", + "docs/ERROR_CODES.md" + ], + "asarUnpack": [ + "scripts/apply-binary-update.ps1" + ], + "directories": { + "output": "dist" + }, + "win": { + "target": [ + "nsis", + "portable" + ], + "icon": "build/icon.ico" + }, + "nsis": { + "artifactName": "${productName}-Setup-${version}-${os}-${arch}.${ext}" + }, + "portable": { + "artifactName": "${productName}-Portable-${version}-${os}-${arch}.${ext}" + }, + "linux": { + "target": [ + "AppImage" + ], + "category": "Development", + "icon": "build/icon.png" + }, + "mac": { + "target": [ + "dmg" + ], + "category": "public.app-category.developer-tools", + "icon": "build/icon.png" + }, + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}" + }, + "engines": { + "node": ">=22" + }, + "author": "Jens", + "dependencies": { + "ssh2": "1.17.0" + } +} diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..31e2afa --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,42 @@ +import { defineConfig } from "@playwright/test"; + +const matrices = [ + ["compact-dark-100", 1120, 720, "dark", 1, false], + ["desktop-light-125", 1440, 900, "light", 1.25, false], + ["wide-dark-150", 1920, 1080, "dark", 1.5, false], + ["compact-light-reduced", 1120, 720, "light", 1, true], + ["desktop-dark-reduced", 1440, 900, "dark", 1.25, true], + ["wide-light-100", 1920, 1080, "light", 1, false], +]; + +export default defineConfig({ + testDir: "./tests/browser", + outputDir: "artifacts/browser", + timeout: 45_000, + expect: { timeout: 7_000 }, + fullyParallel: false, + workers: process.env.CI ? 2 : 3, + reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]], + use: { + baseURL: "http://127.0.0.1:41737", + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + }, + webServer: { + command: "node scripts/serve-demo.mjs", + url: "http://127.0.0.1:41737/__forgeflow_test_ready__", + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + projects: matrices.map(([name, width, height, theme, scale, reduced]) => ({ + name, + metadata: { theme, scale, reduced }, + use: { + viewport: { width, height }, + deviceScaleFactor: scale, + colorScheme: theme, + reducedMotion: reduced ? "reduce" : "no-preference", + }, + })), +}); diff --git a/preload.cjs b/preload.cjs new file mode 100644 index 0000000..d7fd251 --- /dev/null +++ b/preload.cjs @@ -0,0 +1,162 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +async function invoke(channel, payload) { + const result = await ipcRenderer.invoke(channel, payload); + if (!result?.ok) { + const error = new Error(result?.error?.message || 'ForgeFlow operation failed.'); + error.code = result?.error?.code; + error.status = result?.error?.status; + error.recoverable = result?.error?.recoverable; + error.commitSha = result?.error?.commitSha; + throw error; + } + return result.data; +} + +function subscribe(channel, listener) { + if (typeof listener !== 'function') return () => {}; + const handler = (_event, payload) => listener(payload); + ipcRenderer.on(channel, handler); + return () => ipcRenderer.removeListener(channel, handler); +} + +contextBridge.exposeInMainWorld( + 'forgeflow', + Object.freeze({ + bootstrap: () => invoke('app:bootstrap'), + selectDirectory: (payload) => invoke('dialog:select-directory', payload), + selectKeyFile: (payload) => invoke('dialog:select-key-file', payload), + selectImageFile: (payload) => invoke('dialog:select-image-file', payload), + setupPreflight: (payload) => invoke('setup:preflight', payload), + validateGitea: (payload) => invoke('setup:validate-gitea', payload), + completeSetup: (payload) => invoke('setup:complete', payload), + updateGitea: (payload) => invoke('settings:update-gitea', payload), + setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }), + setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }), + setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }), + exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }), + importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }), + listAuditEvents: (limit = 250) => invoke('audit:list', { limit }), + exportAuditLog: (format = 'json') => invoke('audit:export', { format }), + setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }), + checkForUpdates: () => invoke('updates:check'), + downloadUpdate: () => invoke('updates:download'), + applyUpdate: () => invoke('updates:apply'), + saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }), + deleteServer: (serverId) => invoke('server:delete', { serverId }), + testServer: (serverId, expectedFingerprint = '') => invoke('server:test', { serverId, expectedFingerprint }), + inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }), + discoverExistingDeployment: (repository, serverId, remoteFolder) => + invoke('server:discover-existing', { + repository, + serverId, + remoteFolder, + }), + refreshRepositories: (options = {}) => invoke('repositories:refresh', options), + discoverRepositories: (roots) => invoke('repositories:discover', { roots }), + favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }), + linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }), + unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }), + repositoryStatus: (localPath) => invoke('repository:status', { localPath }), + repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }), + repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }), + stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }), + conflictState: (localPath) => invoke('repository:conflicts', { localPath }), + resolveConflict: (localPath, filePath, resolution) => + invoke('repository:resolve-conflict', { + localPath, + filePath, + resolution, + }), + continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }), + abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }), + stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }), + unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }), + commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }), + commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }), + commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }), + commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }), + push: (localPath) => invoke('repository:push', { localPath }), + fetch: (localPath) => invoke('repository:fetch', { localPath }), + pull: (localPath) => invoke('repository:pull', { localPath }), + history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }), + branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }), + pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }), + createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }), + branches: (localPath) => invoke('repository:branches', { localPath }), + checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }), + createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }), + stash: (localPath, message) => invoke('repository:stash', { localPath, message }), + stashList: (localPath) => invoke('repository:stash-list', { localPath }), + popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }), + indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }), + repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }), + gitRecoveryStatus: (localPath) => invoke('repository:git-recovery-status', { localPath }), + repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }), + reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }), + repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }), + previewWorkspaceSync: (localPath) => invoke('repository:workspace-sync-preview', { localPath }), + applyWorkspaceSync: (localPath, expectedPlanId) => invoke('repository:workspace-sync-apply', { localPath, expectedPlanId }), + setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }), + normalizeOrigins: () => invoke('repositories:normalize-origins'), + cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }), + openPath: (localPath) => invoke('repository:open-path', { localPath }), + openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }), + openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }), + openExternal: (url) => invoke('external:open', { url }), + saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }), + deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }), + repairDeploymentWriteAccess: (repository, profileId) => invoke('deployment:repair-write-access', { repository, profileId }), + deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }), + deploy: (repository, profileId, sha, options = {}) => + invoke('deployment:dispatch', { + repository, + profileId, + sha, + note: options.note || '', + override: options.override === true, + overrideReason: options.overrideReason || '', + }), + rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }), + refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), + discoverServerDeployments: () => invoke('deployment:discover-server-workloads'), + planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }), + applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, planId }), + planInventoryReview: (serverId, workloadId, action, reason = '', repositoryFullName = null) => invoke('deployment:plan-inventory-review', { serverId, workloadId, action, reason, repositoryFullName }), + applyInventoryReview: (serverId, workloadId, action, reason, repositoryFullName, planId) => invoke('deployment:apply-inventory-review', { serverId, workloadId, action, reason, repositoryFullName, planId }), + linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }), + configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }), + verifyServerGitProfile: (repository, profileId) => invoke('deployment:verify-server-git-profile', { repository, profileId }), + deployKeyInventory: (repository, profileId) => invoke('deployment:deploy-key-inventory', { repository, profileId }), + planDeployKeyRotation: (repository, profileId) => invoke('deployment:plan-deploy-key-rotation', { repository, profileId }), + applyDeployKeyRotation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-rotation', { repository, profileId, planId }), + planDeployKeyRevocation: (repository, profileId) => invoke('deployment:plan-deploy-key-revocation', { repository, profileId }), + applyDeployKeyRevocation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-revocation', { repository, profileId, planId }), + restoreDeployKey: (repository, profileId) => invoke('deployment:restore-deploy-key', { repository, profileId }), + applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), + reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), + refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), + getOperation: (operationId) => invoke('operations:get', { operationId }), + troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }), + troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }), + troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }), + gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }), + gitValidatorSetPolicy: (fullName, policy) => invoke('git-validator:set-policy', { fullName, policy }), + gitValidatorSuppress: (fullName, suppression) => invoke('git-validator:suppress', { fullName, suppression }), + gitValidatorPreviewRepair: (fullName, check) => invoke('git-validator:preview-repair', { fullName, check }), + gitValidatorExport: (fullName, format = 'json') => invoke('git-validator:export', { fullName, format }), + gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }), + diagnosticsStatus: () => invoke('diagnostics:status'), + clearDiagnostics: () => invoke('diagnostics:clear'), + openDiagnosticsFolder: () => invoke('diagnostics:open-folder'), + exportDiagnostics: (privacyMode = 'standard') => invoke('diagnostics:export', { privacyMode }), + showDiagnosticBundle: (filePath) => invoke('diagnostics:show-bundle', { filePath }), + reportRendererEvent: (level, event, details = {}) => invoke('renderer:report', { level, event, details }), + onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener), + onOperationsChanged: (listener) => subscribe('operations:changed', listener), + onUpdatesChanged: (listener) => subscribe('updates:changed', listener), + reset: () => invoke('app:reset'), + }), +); diff --git a/scripts/acceptance.mjs b/scripts/acceptance.mjs new file mode 100644 index 0000000..5d83ee3 --- /dev/null +++ b/scripts/acceptance.mjs @@ -0,0 +1,88 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import crypto from 'node:crypto'; +import process from 'node:process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const exec = promisify(execFile); +const required = ['FORGEFLOW_GITEA_URL', 'FORGEFLOW_GITEA_TOKEN', 'FORGEFLOW_REPOSITORY', 'FORGEFLOW_LOCAL_PATH', 'FORGEFLOW_BRANCH', 'FORGEFLOW_STATUS_URL', 'FORGEFLOW_HEALTH_URL']; + +export function readAcceptanceConfig(env = process.env) { + const missing = required.filter((name) => !String(env[name] || '').trim()); + if (missing.length) throw new Error(`Missing acceptance environment variables: ${missing.join(', ')}`); + const [owner, repo, extra] = env.FORGEFLOW_REPOSITORY.split('/'); + if (!owner || !repo || extra) throw new Error('FORGEFLOW_REPOSITORY must use owner/repository.'); + return { + baseUrl: env.FORGEFLOW_GITEA_URL.replace(/\/+$/, ''), token: env.FORGEFLOW_GITEA_TOKEN, + owner, repo, localPath: env.FORGEFLOW_LOCAL_PATH, branch: env.FORGEFLOW_BRANCH, + workflow: env.FORGEFLOW_WORKFLOW || 'deploy.yml', rollbackWorkflow: env.FORGEFLOW_ROLLBACK_WORKFLOW || 'rollback.yml', + environment: env.FORGEFLOW_ENVIRONMENT || 'staging', statusUrl: env.FORGEFLOW_STATUS_URL, healthUrl: env.FORGEFLOW_HEALTH_URL + }; +} + +async function git(config, args) { return (await exec('git', args, { cwd: config.localPath, encoding: 'utf8' })).stdout.trim(); } +async function api(config, pathname, options = {}) { + const response = await fetch(`${config.baseUrl}/api/v1${pathname}`, { method: options.method || 'GET', headers: { Authorization: `token ${config.token}`, Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}) }, body: options.body ? JSON.stringify(options.body) : undefined, signal: AbortSignal.timeout(30_000) }); + const text = await response.text(); + if (!response.ok) throw new Error(`Gitea ${response.status}: ${text.slice(0, 500)}`); + return text ? JSON.parse(text) : null; +} +async function publicJson(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15_000), cache: 'no-store' }); if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); return response.json(); } +async function health(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15_000), cache: 'no-store' }); return { ok: response.ok, status: response.status }; } + +export async function inspectAcceptanceEnvironment(config) { + const [head, branch, porcelain, upstream, repository, remoteBranch, workflow, server, healthResult] = await Promise.all([ + git(config, ['rev-parse', 'HEAD']), git(config, ['branch', '--show-current']), git(config, ['status', '--porcelain']), git(config, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']).catch(() => ''), + api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}`), + api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/branches/${encodeURIComponent(config.branch)}`), + api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/contents/.gitea/workflows/${encodeURIComponent(config.workflow)}?ref=${encodeURIComponent(config.branch)}`), + publicJson(config.statusUrl), health(config.healthUrl) + ]); + const checks = [ + { id: 'clean', ok: !porcelain, detail: porcelain ? 'Working tree has changes' : 'Working tree clean' }, + { id: 'branch', ok: branch === config.branch, detail: `Local ${branch}; expected ${config.branch}` }, + { id: 'upstream', ok: Boolean(upstream), detail: upstream || 'No upstream' }, + { id: 'repository', ok: repository.full_name?.toLowerCase() === `${config.owner}/${config.repo}`.toLowerCase(), detail: repository.full_name }, + { id: 'remote-sha', ok: remoteBranch.commit?.id === head, detail: `local ${head.slice(0, 7)}; remote ${(remoteBranch.commit?.id || '').slice(0, 7)}` }, + { id: 'workflow', ok: workflow.type === 'file', detail: config.workflow }, + { id: 'status', ok: Boolean(server && typeof server === 'object'), detail: server?.liveSha || 'No live SHA' }, + { id: 'health', ok: healthResult.ok, detail: `HTTP ${healthResult.status}` } + ]; + return { generatedAt: new Date().toISOString(), head, server, checks, ready: checks.every((check) => check.ok) }; +} + +async function waitForSha(config, sha, requestId, timeoutMs = 15 * 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = await publicJson(config.statusUrl); + if (state.requestId === requestId && state.liveSha === sha) { + const probe = await health(config.healthUrl); + if (probe.ok) return state; + } + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + throw new Error(`Timed out waiting for exact live SHA ${sha}.`); +} + +export async function executeAcceptanceDeployment(config, sha, workflow = config.workflow, inputName = 'commit_sha') { + const requestId = crypto.randomUUID(); + await api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { method: 'POST', body: { ref: config.branch, inputs: { environment: config.environment, [inputName]: sha, request_id: requestId } } }); + return { requestId, state: await waitForSha(config, sha, requestId) }; +} + +if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) { + const config = readAcceptanceConfig(); + const report = await inspectAcceptanceEnvironment(config); + if (process.argv.includes('--execute-deployment')) { + if (!report.ready) throw new Error('Read-only acceptance checks must pass before deployment execution.'); + report.deployment = await executeAcceptanceDeployment(config, report.head); + } + if (process.argv.includes('--execute-rollback')) { + const target = report.server?.previousSha; + if (!target) throw new Error('Status endpoint does not report a previousSha for rollback acceptance.'); + report.rollback = await executeAcceptanceDeployment(config, target, config.rollbackWorkflow, 'target_sha'); + } + console.log(JSON.stringify(report, null, 2)); + if (!report.ready) process.exitCode = 1; +} diff --git a/scripts/apply-binary-update.ps1 b/scripts/apply-binary-update.ps1 new file mode 100644 index 0000000..0a8de52 --- /dev/null +++ b/scripts/apply-binary-update.ps1 @@ -0,0 +1,145 @@ +param( + [Parameter(Mandatory = $true)][string]$BinaryPath, + [Parameter(Mandatory = $true)][string]$ExpectedSha256, + [Parameter(Mandatory = $true)][string]$ExpectedVersion, + [Parameter(Mandatory = $true)][string]$CurrentExecutable, + [Parameter(Mandatory = $true)][string]$Portable, + [Parameter(Mandatory = $true)][int]$ParentPid, + [Parameter(Mandatory = $true)][string]$LogPath, + [Parameter(Mandatory = $true)][string]$StatusPath, + [Parameter(Mandatory = $true)][string]$UpdateId, + [switch]$HandshakeOnly, + [switch]$VerifyOnly +) + +$ErrorActionPreference = "Stop" +$isPortable = $Portable -eq "True" + +function Write-UpdateState { + param([string]$State, [string]$Message = "", [bool]$RestartLaunched = $false) + $payload = [ordered]@{ + schemaVersion = 1 + updateId = $UpdateId + state = $State + expectedVersion = $ExpectedVersion + installedVersion = if ($State -eq "success") { $ExpectedVersion } else { $null } + message = $Message + restartLaunched = $RestartLaunched + logPath = $LogPath + updatedAt = [DateTime]::UtcNow.ToString("o") + } + if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") } + $directory = Split-Path -Parent $StatusPath + if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } + $temporary = "$StatusPath.$PID.tmp" + $backup = "$StatusPath.$PID.bak" + $json = $payload | ConvertTo-Json -Depth 4 + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText($temporary, $json, $utf8NoBom) + try { + if ([IO.File]::Exists($StatusPath)) { + [IO.File]::Replace($temporary, $StatusPath, $backup) + [IO.File]::Delete($backup) + } else { + [IO.File]::Move($temporary, $StatusPath) + } + } catch { + [IO.File]::Copy($temporary, $StatusPath, $true) + [IO.File]::Delete($temporary) + if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) } + } +} +function Write-Log([string]$Message) { + "{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8 +} +function Get-Sha256([string]$Path) { + $stream = [IO.File]::OpenRead($Path) + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant() + } finally { + $algorithm.Dispose() + $stream.Dispose() + } +} + +function Start-ForgeFlowAndVerify([string]$Executable) { + $process = Start-Process -FilePath $Executable -WorkingDirectory (Split-Path -Parent $Executable) -PassThru + Start-Sleep -Milliseconds 1500 + if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application could stay running." } + return $process +} + +try { + Write-Log "Validating ForgeFlow $ExpectedVersion binary update." + if ($HandshakeOnly) { + Write-UpdateState -State "started" -Message "Binary updater owns the update request." + Write-Log "Handshake-only verification completed successfully." + exit 0 + } + $actualSha256 = Get-Sha256 -Path $BinaryPath + if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." } + if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." } + Write-UpdateState -State "started" -Message "Binary preflight passed; updater owns the update request." + if ($VerifyOnly) { + Write-Log "Verification-only SHA-256 check completed successfully." + exit 0 + } + + Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close." + try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch { + if (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { throw "ForgeFlow did not close within 60 seconds." } + } + + if ($isPortable) { + Write-UpdateState -State "applying" -Message "Replacing the portable executable." + $backupPath = "$CurrentExecutable.previous" + Copy-Item -LiteralPath $CurrentExecutable -Destination $backupPath -Force + try { + Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force + } catch { + $copyFailure = $_.Exception.Message + try { + Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force + $rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable + Write-Log "Portable replacement failed; previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)." + Write-UpdateState -State "rolled-back" -Message $copyFailure -RestartLaunched $true + } catch { + Write-UpdateState -State "failed" -Message "$copyFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false + } + throw $copyFailure + } + } else { + Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer." + $installer = Start-Process -FilePath $BinaryPath -ArgumentList "/S" -PassThru -Wait -WindowStyle Hidden + if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." } + } + + try { + $restart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable + Write-Log "ForgeFlow $ExpectedVersion installed; verified restart PID $($restart.Id)." + Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true + } catch { + $restartFailure = $_.Exception.Message + if ($isPortable -and $backupPath -and (Test-Path -LiteralPath $backupPath -PathType Leaf)) { + Write-Log "Updated portable executable failed its restart probe; restoring the previous executable." + try { + Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force + $rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable + Write-Log "Previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)." + Write-UpdateState -State "rolled-back" -Message $restartFailure -RestartLaunched $true + } catch { + Write-UpdateState -State "failed" -Message "$restartFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false + } + exit 1 + } + Write-Log "ForgeFlow $ExpectedVersion installed, but automatic restart failed: $restartFailure" + Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully, but must be started manually." -RestartLaunched $false + } +} catch { + Write-Log $_.Exception.Message + $current = $null + try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {} + if ($current.state -notin @("rolled-back", "failed")) { Write-UpdateState -State "failed" -Message $_.Exception.Message } + exit 1 +} diff --git a/scripts/apply-source-update.ps1 b/scripts/apply-source-update.ps1 new file mode 100644 index 0000000..1a139ba --- /dev/null +++ b/scripts/apply-source-update.ps1 @@ -0,0 +1,251 @@ +param( + [Parameter(Mandatory=$true)][string]$SourcePath, + [Parameter(Mandatory=$true)][string]$ArchivePath, + [Parameter(Mandatory=$true)][string]$ExpectedVersion, + [Parameter(Mandatory=$true)][string]$ExpectedSha256, + [Parameter(Mandatory=$true)][int]$ParentPid, + [Parameter(Mandatory=$true)][string]$LogPath, + [Parameter(Mandatory=$true)][string]$StatusPath, + [Parameter(Mandatory=$true)][string]$UpdateId, + [switch]$HandshakeOnly +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +$working = $null +$backup = $null + +function Write-UpdateLog { + param([string]$Message) + $line = "$(Get-Date -Format o) $Message" + $directory = Split-Path -Parent $LogPath + if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } + Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 +} + +function Write-UpdateState { + param( + [Parameter(Mandatory=$true)][string]$State, + [string]$Message = "", + [hashtable]$Extra = @{} + ) + + $payload = [ordered]@{ + schemaVersion = 1 + updateId = $UpdateId + state = $State + expectedVersion = $ExpectedVersion + sourcePath = $SourcePath + logPath = $LogPath + statusPath = $StatusPath + message = $Message + updatedAt = (Get-Date).ToUniversalTime().ToString("o") + } + foreach ($key in $Extra.Keys) { $payload[$key] = $Extra[$key] } + + $directory = Split-Path -Parent $StatusPath + if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } + $temporary = "$StatusPath.$PID.tmp" + $json = $payload | ConvertTo-Json -Depth 8 + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($temporary, $json, $utf8NoBom) + + try { + if ([System.IO.File]::Exists($StatusPath)) { + # Windows PowerShell 5.1 does not reliably let Move-Item -Force replace + # an existing file. File.Replace is atomic on the local NTFS volume. + $backup = "$StatusPath.$PID.bak" + [System.IO.File]::Replace($temporary, $StatusPath, $backup) + } else { + [System.IO.File]::Move($temporary, $StatusPath) + } + } catch { + # Some filesystems do not implement File.Replace. Copy with overwrite is + # the deterministic fallback; the temporary file is removed afterwards. + if ([System.IO.File]::Exists($temporary)) { + [System.IO.File]::Copy($temporary, $StatusPath, $true) + [System.IO.File]::Delete($temporary) + } + } finally { + if ([System.IO.File]::Exists($backup)) { [System.IO.File]::Delete($backup) } + } +} + +function Get-Sha256([string]$Path) { + $stream = [IO.File]::OpenRead($Path) + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant() + } finally { + $algorithm.Dispose() + $stream.Dispose() + } +} + +function Invoke-Robocopy { + param([string]$From, [string]$To) + New-Item -ItemType Directory -Force -Path $To | Out-Null + & robocopy.exe $From $To /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP /XD node_modules .git dist | Out-Null + if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" } +} + +function Install-ForgeFlowDependencies { + param([string]$WorkingDirectory) + Push-Location $WorkingDirectory + try { + if (Test-Path -LiteralPath (Join-Path $WorkingDirectory "package-lock.json")) { + Write-UpdateLog "Installing dependencies from package-lock.json with npm ci." + & cmd.exe /d /s /c "npm ci --no-audit --no-fund" *>> $LogPath + if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE." } + } else { + Write-UpdateLog "No package-lock.json was supplied; installing pinned direct dependencies with npm install." + & cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath + if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." } + } + } finally { Pop-Location } +} + +function Start-ForgeFlow { + param([string]$WorkingDirectory) + $electron = Join-Path $WorkingDirectory "node_modules\electron\dist\electron.exe" + if (-not (Test-Path -LiteralPath $electron)) { throw "electron.exe was not found after dependency installation." } + $process = Start-Process -FilePath $electron -WorkingDirectory $WorkingDirectory -ArgumentList @(".") -PassThru + Start-Sleep -Milliseconds 1200 + if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application window could start." } + return $process +} + +try { + Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion." + + if ($HandshakeOnly) { + Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") } + Write-UpdateLog "Handshake-only verification completed successfully." + exit 0 + } + + if (Test-Path -LiteralPath (Join-Path $SourcePath ".git")) { + throw "Integrated source update refuses to overwrite a Git working tree. Use normal Git/ForgeFlow workspace sync so local commits and dirty files remain reviewable." + } + $actualHash = Get-Sha256 -Path $ArchivePath + if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." } + Write-UpdateState -State "started" -Message "Source update preflight passed; the external helper owns the request." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") } + + Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit." + $deadline = (Get-Date).AddMinutes(2) + while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { + if ((Get-Date) -gt $deadline) { throw "ForgeFlow did not exit before the update timeout." } + Start-Sleep -Milliseconds 500 + } + + $working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N")) + $extract = Join-Path $working "extract" + $backup = Join-Path $working "backup" + New-Item -ItemType Directory -Force -Path $extract | Out-Null + + Write-UpdateLog "Creating source backup." + Write-UpdateState -State "backing-up" -Message "Creating a restorable backup of the current source." + Invoke-Robocopy -From $SourcePath -To $backup + + Write-UpdateLog "Extracting update archive." + Write-UpdateState -State "extracting" -Message "Extracting the verified update archive." + Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extract -Force + $manifest = Get-ChildItem -Path $extract -Filter package.json -File -Recurse | + Where-Object { + try { + $json = Get-Content $_.FullName -Raw | ConvertFrom-Json + return $json.name -eq "forgeflow" -and $json.version -eq $ExpectedVersion + } catch { return $false } + } | + Select-Object -First 1 + + if (-not $manifest) { throw "The update does not contain ForgeFlow version $ExpectedVersion." } + $incoming = Split-Path -Parent $manifest.FullName + Write-UpdateLog "Applying verified source files." + Write-UpdateState -State "applying" -Message "Replacing the local source with ForgeFlow $ExpectedVersion." + Invoke-Robocopy -From $incoming -To $SourcePath + + Write-UpdateState -State "validating" -Message "Installing dependencies and running the complete quality gate." + Install-ForgeFlowDependencies -WorkingDirectory $SourcePath + Push-Location $SourcePath + try { + Write-UpdateLog "Running ForgeFlow quality gate." + & cmd.exe /d /s /c "npm run check" *>> $LogPath + if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." } + } finally { Pop-Location } + + $completedAt = (Get-Date).ToUniversalTime().ToString("o") + Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully." -Extra @{ + installedVersion = $ExpectedVersion + completedAt = $completedAt + restartLaunched = $true + restartPid = $null + restartError = $null + } + + try { + $restart = Start-ForgeFlow -WorkingDirectory $SourcePath + Write-UpdateLog "Update validated successfully. ForgeFlow was restarted directly with Electron PID $($restart.Id)." + Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed and restarted successfully." -Extra @{ + installedVersion = $ExpectedVersion + completedAt = $completedAt + restartLaunched = $true + restartPid = $restart.Id + restartError = $null + } + } catch { + $restartError = $_.Exception.Message + Write-UpdateLog "Update validated successfully, but automatic restart failed: $restartError" + Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully, but must be started manually." -Extra @{ + installedVersion = $ExpectedVersion + completedAt = $completedAt + restartLaunched = $false + restartPid = $null + restartError = $restartError + } + } + if ($working) { Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue } + exit 0 +} +catch { + $failureMessage = $_.Exception.Message + try { Write-UpdateLog ("Update failed: " + $failureMessage) } catch {} + try { Write-UpdateState -State "failed" -Message $failureMessage -Extra @{ failedAt = (Get-Date).ToUniversalTime().ToString("o") } } catch {} + try { + if ($backup -and (Test-Path -LiteralPath $backup)) { + Write-UpdateLog "Restoring previous source version." + Invoke-Robocopy -From $backup -To $SourcePath + Install-ForgeFlowDependencies -WorkingDirectory $SourcePath + $rollbackCompletedAt = (Get-Date).ToUniversalTime().ToString("o") + Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{ + completedAt = $rollbackCompletedAt + restartLaunched = $true + restartPid = $null + restartError = $null + } + try { + $rollbackRestart = Start-ForgeFlow -WorkingDirectory $SourcePath + Write-UpdateLog "Rollback restored and ForgeFlow restarted directly with Electron PID $($rollbackRestart.Id)." + Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{ + completedAt = $rollbackCompletedAt + restartLaunched = $true + restartPid = $rollbackRestart.Id + restartError = $null + } + } catch { + $rollbackRestartError = $_.Exception.Message + Write-UpdateLog ("Rollback restart failed: " + $rollbackRestartError) + Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{ + completedAt = $rollbackCompletedAt + restartLaunched = $false + restartPid = $null + restartError = $rollbackRestartError + } + } + } + } catch { + try { Write-UpdateLog ("Rollback failed: " + $_.Exception.Message) } catch {} + try { Write-UpdateState -State "failed" -Message ("$failureMessage Rollback also failed: " + $_.Exception.Message) -Extra @{ completedAt = (Get-Date).ToUniversalTime().ToString("o") } } catch {} + } + exit 1 +} diff --git a/scripts/architecture-audit.mjs b/scripts/architecture-audit.mjs new file mode 100644 index 0000000..fb34978 --- /dev/null +++ b/scripts/architecture-audit.mjs @@ -0,0 +1,53 @@ +import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +const sourceRoots = ["main.cjs", "preload.cjs", "src/main", "src/renderer", "src/shared"]; +const extensions = new Set([".js", ".cjs", ".mjs"]); + +async function filesBelow(entry) { + const absolute = path.join(root, entry); + const stat = await import("node:fs/promises").then(({ stat }) => stat(absolute)); + if (stat.isFile()) return [entry]; + const result = []; + for (const child of await readdir(absolute, { withFileTypes: true })) { + const relative = path.join(entry, child.name); + if (child.isDirectory()) result.push(...await filesBelow(relative)); + else if (extensions.has(path.extname(child.name))) result.push(relative); + } + return result; +} + +function analyze(relative, source) { + const lines = source.split(/\r?\n/).length; + const branches = (source.match(/\b(?:if|else if|for|while|case|catch)\b|\?\?/g) || []).length; + const functions = (source.match(/\b(?:async\s+)?function\b|=>|\b(?:async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/g) || []).length; + const ipcHandlers = (source.match(/\bregister\(\s*["']/g) || []).length; + const responsibilities = [ + ["inventory", /inventory|workload/i], ["deployment", /deploy|rollback|activation/i], + ["git", /\bgit|repository/i], ["ipc", /ipc|register\(/i], ["renderer", /render|modal|document\./i], + ["security", /key|credential|signature|checksum/i], ["updates", /update|release|artifact/i], + ].filter(([, pattern]) => pattern.test(source)).map(([name]) => name); + return { file: relative.replaceAll("\\", "/"), lines, branches, functions, ipcHandlers, responsibilities, hotspotScore: branches + Math.max(0, responsibilities.length - 2) * 10 }; +} + +const files = (await Promise.all(sourceRoots.map(filesBelow))).flat(); +const results = []; +for (const file of files) results.push(analyze(file, await readFile(path.join(root, file), "utf8"))); +results.sort((a, b) => b.hotspotScore - a.hotspotScore || b.lines - a.lines); +const report = { + generatedAt: new Date().toISOString(), + thresholds: { preferredMaximumLines: 750, justificationRequiredLines: 1000 }, + over750: results.filter((item) => item.lines > 750), + over1000: results.filter((item) => item.lines > 1000), + cyclomaticHotspots: results.filter((item) => item.branches >= 75).slice(0, 20), + mixedResponsibilityModules: results.filter((item) => item.responsibilities.length >= 4), + ipcHotspots: results.filter((item) => item.ipcHandlers >= 10), +}; +const reportDir = path.join(root, "reports"); +await mkdir(reportDir, { recursive: true }); +await writeFile(path.join(reportDir, "architecture-audit.json"), `${JSON.stringify(report, null, 2)}\n`); +const table = (items) => items.length ? items.map((item) => `| \`${item.file}\` | ${item.lines} | ${item.branches} | ${item.functions} | ${item.ipcHandlers} | ${item.responsibilities.join(", ")} |`).join("\n") : "No findings."; +const markdown = `# ForgeFlow architecture audit\n\nGenerated ${report.generatedAt}. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.\n\n## Files above 750 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over750)}\n\n## Files above 1,000 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over1000)}\n\n## Cyclomatic hotspots\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.cyclomaticHotspots)}\n\n## Interpretation\n\nFiles above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.\n`; +await writeFile(path.join(reportDir, "architecture-audit.md"), markdown); +console.log(`Audited ${results.length} source files; ${report.over750.length} exceed 750 lines and ${report.over1000.length} exceed 1,000 lines.`); diff --git a/scripts/audit-installed-deployments.cjs b/scripts/audit-installed-deployments.cjs new file mode 100644 index 0000000..059afd8 --- /dev/null +++ b/scripts/audit-installed-deployments.cjs @@ -0,0 +1,141 @@ +"use strict"; + +const path = require("node:path"); +const { app } = require("electron"); +const { ConfigStore } = require("../src/main/config-store.cjs"); +const { GitService } = require("../src/main/git-service.cjs"); +const { GiteaService } = require("../src/main/gitea-service.cjs"); +const { RepositoryService } = require("../src/main/repository-service.cjs"); +const { SshService } = require("../src/main/ssh-service.cjs"); +const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs"); + +const userDataPath = process.env.FORGEFLOW_USER_DATA + ? path.resolve(process.env.FORGEFLOW_USER_DATA) + : path.join(app.getPath("appData"), "forgeflow"); +app.setPath("userData", userDataPath); + +app.whenReady().then(async () => { + try { + const configureAccess = process.argv.includes("--configure-access"); + const reconcile = process.argv.includes("--reconcile"); + const summaryOnly = process.argv.includes("--summary"); + const inventoryOnly = process.argv.includes("--inventory-only"); + const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "") + .slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean)); + const store = new ConfigStore(userDataPath); + await store.load(); + const git = new GitService(); + const gitea = new GiteaService(store); + const repositories = await new RepositoryService(store, git, gitea).refresh(); + const ssh = new SshService({ store }); + const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") }); + const reports = []; + for (const server of store.data.servers || []) { + const report = await deployments.scanServerInventory(server.id, repositories); + let reconciliation = null; + if (reconcile) { + for (let attempt = 1; attempt <= 3 && !reconciliation; attempt += 1) { + const preview = await deployments.planServerInventoryReconciliation(server.id, repositories, { autoLink: true }); + try { + reconciliation = await deployments.reconcileServerInventory(server.id, repositories, { autoLink: true, expectedPlanId: preview.plan.id }); + } catch (error) { + if (error.code !== "RECONCILIATION_PLAN_STALE" || attempt === 3) throw error; + } + } + } + const access = []; + const seenProfiles = new Set(); + for (const repository of inventoryOnly || configureAccess ? [] : repositories) { + for (const profile of repository.deploymentProfiles || store.getDeploymentProfiles(repository.fullName) || []) { + if (profile.serverId !== server.id || profile.deploymentMode !== "server-git" || seenProfiles.has(profile.id)) continue; + seenProfiles.add(profile.id); + try { + let verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id }); + await deployments.refreshProfileState(repository.fullName, profile.id, verification.branchSha); + verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id }); + access.push(verification); + } catch (error) { + access.push({ repository: repository.fullName, profileId: profile.id, readiness: "Verification incomplete", ready: false, error: error.message }); + } + } + } + if (configureAccess) { + const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh(); + for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) { + const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()); + if (!repository) continue; + if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue; + try { + const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId }); + access.push({ repository: repository.fullName, profileId: workload.link.profileId, action: "configured", ready: true, created: configured.created, remoteSha: configured.remoteSha }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + } catch (error) { + access.push({ repository: repository.fullName, ready: false, error: error.message }); + } + } + } + reports.push({ + server: server.name, + reconciliation: reconciliation ? { + adopted: reconciliation.adopted, + refreshed: reconciliation.refreshed, + retired: reconciliation.retired, + staleProfiles: reconciliation.staleProfiles, + recoverySnapshot: reconciliation.recoverySnapshot, + } : null, + capabilities: report.capabilities, + warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)), + summary: { + detected: report.detected, + running: report.running, + linked: report.linked, + needsReview: report.needsReview, + }, + reviewBreakdown: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).reduce((counts, workload) => { + const key = `${workload.classification?.type || "unknown"}:${workload.status || "unknown"}`; + counts[key] = (counts[key] || 0) + 1; + return counts; + }, {}), + reviewSamples: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).slice(0, 30).map((workload) => ({ name: workload.displayName, type: workload.classification?.type, status: workload.status, running: workload.runtime?.running, folder: workload.remoteFolderCandidate, containers: (workload.containers || []).map((container) => container.name) })), + access, + workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({ + name: workload.displayName, + running: workload.runtime?.running === true, + health: workload.runtime?.health || "unknown", + repository: workload.link?.repositoryFullName || workload.suggestedRepository?.fullName || null, + confidence: workload.matchConfidence || workload.status, + folder: workload.remoteFolderCandidate || null, + containers: (workload.containers || []).map((container) => container.name), + })), + }); + } + const output = summaryOnly ? reports.map((report) => ({ + server: report.server, + capabilities: report.capabilities, + warnings: report.warnings, + summary: report.summary, + reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))), + reviewSamples: report.reviewSamples, + reconciliation: report.reconciliation, + access: report.access.map((item) => ({ + repository: item.repository, + profileId: item.profileId || null, + ready: item.ready, + deployReady: item.deployReady ?? item.ready, + readiness: item.readiness || item.action || null, + remoteSha: item.remoteSha || item.branchSha || null, + liveSha: item.liveSha || null, + blockers: (item.deploymentBlockers || []).map((check) => ({ id: check.id, detail: check.detail })), + warnings: (item.checks || []).filter((check) => check.status !== "pass" && !(item.deploymentBlockers || []).some((blocker) => blocker.id === check.id)).map((check) => ({ id: check.id, status: check.status, detail: check.detail })), + error: item.error || null, + })), + review: report.workloads.filter((item) => !item.repository && item.running).map((item) => ({ name: item.name, confidence: item.confidence, folder: item.folder })), + })) : reports; + console.log(JSON.stringify(output, null, 2)); + } catch (error) { + console.error(error?.stack || error?.message || String(error)); + process.exitCode = 1; + } finally { + app.quit(); + } +}); diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 0000000..ba81a70 --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,91 @@ +import { execFile } from 'node:child_process'; +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import toolInvocation from '../src/shared/tool-invocation.cjs'; + +const exec = promisify(execFile); +const { npmProbeCandidates } = toolInvocation; +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const checks = []; +const jsonMode = process.argv.includes('--json'); + +function add(id, name, ok, detail, help = '', severity = 'required') { + const status = ok ? 'pass' : severity === 'warning' ? 'warning' : 'fail'; + checks.push({ id, name, status, ok: ok || severity === 'warning', detail, help, severity }); +} + +const major = Number(process.versions.node.split('.')[0]); +add('node', 'Node.js', major >= 22, process.version, 'Install Node.js 22 or newer.'); + +try { + const failures = []; + let version = ''; + let source = ''; + for (const candidate of npmProbeCandidates()) { + try { + const { stdout } = await exec(candidate.file, candidate.args, { windowsHide: true }); + version = stdout.trim(); + source = candidate.source; + if (version) break; + } catch (error) { + failures.push(`${candidate.source}: ${error.message}`); + } + } + if (!version) throw new Error(failures.join(' | ') || 'No npm invocation candidate succeeded.'); + add('npm', 'npm', true, `${version} (${source})`); +} catch (error) { + add('npm', 'npm', false, error.message, 'Install npm together with Node.js and ensure npm.cmd is available on PATH.'); +} + +try { + const { stdout } = await exec('git', ['--version']); + add('git', 'Git', true, stdout.trim()); + const [name, email] = await Promise.all([ + exec('git', ['config', '--global', '--get', 'user.name']).then((result) => result.stdout.trim()).catch(() => ''), + exec('git', ['config', '--global', '--get', 'user.email']).then((result) => result.stdout.trim()).catch(() => '') + ]); + add('git-identity', 'Git identity', Boolean(name && email), name && email ? `${name} <${email}>` : 'user.name or user.email is missing; commits will remain disabled until configured', 'Configure git config --global user.name and user.email.', 'warning'); +} catch (error) { + add('git', 'Git', false, error.message, 'Install Git and ensure git is on PATH.'); +} + +try { + await access(new URL('../node_modules/electron/package.json', import.meta.url)); + add('electron', 'Electron dependency', true, 'installed'); +} catch { + add('electron', 'Electron dependency', false, 'not installed', 'Run npm install.'); +} + +let markerDirectory = null; +try { + markerDirectory = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-doctor-')); + await writeFile(path.join(markerDirectory, 'write-test'), 'ok'); + add('temp-storage', 'Local diagnostic storage', true, markerDirectory.replace(os.homedir(), '')); +} catch (error) { + add('temp-storage', 'Local diagnostic storage', false, error.message, 'Check local disk permissions and free space.'); +} finally { + if (markerDirectory) await rm(markerDirectory, { recursive: true, force: true }).catch(() => {}); +} + +const blockingChecks = checks.filter((check) => check.status === 'fail'); + +const report = { + product: 'ForgeFlow', + version: packageJson.version, + generatedAt: new Date().toISOString(), + platform: process.platform, + arch: process.arch, + ready: blockingChecks.length === 0, + checks +}; + +if (jsonMode) console.log(JSON.stringify(report, null, 2)); +else { + console.log('ForgeFlow doctor\n'); + for (const check of checks) console.log(`${check.status === 'pass' ? 'PASS' : check.status === 'warning' ? 'WARN' : 'FAIL'} ${check.name.padEnd(26)} ${check.detail}`); + console.log(`\n${report.ready ? 'Environment is ready.' : 'Resolve failed checks before starting ForgeFlow.'}`); +} + +if (!report.ready) process.exitCode = 1; diff --git a/scripts/generate-source-manifest.mjs b/scripts/generate-source-manifest.mjs new file mode 100644 index 0000000..1b3c2c4 --- /dev/null +++ b/scripts/generate-source-manifest.mjs @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const excludedFiles = new Set(['SOURCE_MANIFEST.txt']); +const execFileAsync = promisify(execFile); + +async function collect() { + const { stdout } = await execFileAsync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { cwd: root, encoding: 'buffer', maxBuffer: 16 * 1024 * 1024 }, + ); + const relativePaths = stdout + .toString('utf8') + .split('\0') + .filter(Boolean) + .filter((relative) => !excludedFiles.has(relative)); + + const existing = []; + for (const relative of relativePaths) { + const absolute = path.resolve(root, relative); + try { + if ((await stat(absolute)).isFile()) existing.push(absolute); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + return existing; +} + +const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); +const files = (await collect()).sort((left, right) => left.localeCompare(right, 'en')); +const lines = [ + `ForgeFlow ${packageJson.version} source manifest`, + 'SHA-256 BYTES PATH', + '(The manifest includes tracked and non-ignored source files, excluding itself.)' +]; + +for (const absolute of files) { + const bytes = await readFile(absolute); + const size = (await stat(absolute)).size; + const digest = createHash('sha256').update(bytes).digest('hex'); + const relative = path.relative(root, absolute).replaceAll('\\', '/'); + lines.push(`${digest} ${String(size).padStart(12)} ${relative}`); +} + +await writeFile(path.join(root, 'SOURCE_MANIFEST.txt'), `${lines.join('\n')}\n`, 'utf8'); +console.log(`Wrote ${files.length} entries for ForgeFlow ${packageJson.version}.`); diff --git a/scripts/prune-dist.mjs b/scripts/prune-dist.mjs new file mode 100644 index 0000000..f8a3e35 --- /dev/null +++ b/scripts/prune-dist.mjs @@ -0,0 +1,41 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const distDirectory = path.join(projectRoot, "dist"); +const manifest = JSON.parse( + await fs.readFile(path.join(projectRoot, "package.json"), "utf8"), +); +const currentVersion = String(manifest.version || "").trim(); + +if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(currentVersion)) { + throw new Error("package.json contains an invalid release version."); +} + +const entries = await fs + .readdir(distDirectory, { withFileTypes: true }) + .catch((error) => { + if (error.code === "ENOENT") return []; + throw error; + }); +const removed = []; + +for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith("ForgeFlow-")) continue; + if (entry.name.includes(`-${currentVersion}-`)) continue; + await fs.rm(path.join(distDirectory, entry.name), { force: true }); + removed.push(entry.name); +} + +if (removed.length) { + console.log(`Removed ${removed.length} obsolete dist artifact(s):`); + for (const name of removed) console.log(`- ${name}`); +} else { + console.log( + `No ForgeFlow dist artifacts older than ${currentVersion} found.`, + ); +} diff --git a/scripts/publish-binary-release.cjs b/scripts/publish-binary-release.cjs new file mode 100644 index 0000000..ba556a6 --- /dev/null +++ b/scripts/publish-binary-release.cjs @@ -0,0 +1,273 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { app, safeStorage } = require("electron"); +const { normalizeBaseUrl } = require("../src/shared/validation.cjs"); + +const root = path.resolve(__dirname, ".."); +const configuredUserData = + process.env.FORGEFLOW_USER_DATA || + path.join(app.getPath("appData"), "forgeflow"); +app.setPath("userData", path.resolve(configuredUserData)); + +function safeRepositoryPart(value, label) { + const text = String(value || "").trim(); + if (!/^[a-zA-Z0-9_.-]+$/.test(text)) { + throw new Error(`${label} contains unsupported characters.`); + } + return text; +} + +async function readOptionalConfig(configPath) { + try { + return JSON.parse(await fs.readFile(configPath, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +async function api(baseUrl, token, pathname, options = {}) { + const response = await fetch(`${baseUrl}/api/v1${pathname}`, { + ...options, + headers: { + Accept: "application/json", + Authorization: `token ${token}`, + ...(options.headers || {}), + }, + signal: AbortSignal.timeout(options.timeout || 180_000), + }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + if (!response.ok) { + throw new Error( + `Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`, + ); + } + return data; +} + +app.whenReady().then(async () => { + try { + const manifest = JSON.parse( + await fs.readFile(path.join(root, "package.json"), "utf8"), + ); + const configPath = path.join(configuredUserData, "forgeflow-config.json"); + const config = (await readOptionalConfig(configPath)) || {}; + const actionsToken = String( + process.env.GITEA_TOKEN || process.env.FORGEFLOW_RELEASE_TOKEN || "", + ).trim(); + let token = actionsToken; + if (!token) { + if (!config.gitea?.encryptedToken) { + throw new Error( + `No release token was supplied and no encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow or run from Gitea Actions with GITEA_TOKEN.`, + ); + } + token = safeStorage.decryptString( + Buffer.from(config.gitea.encryptedToken, "base64"), + ); + } + const configuredBaseUrl = + process.env.FORGEFLOW_RELEASE_BASE_URL || config.gitea?.baseUrl; + if (!configuredBaseUrl) { + throw new Error( + "No Gitea release base URL was supplied. Set FORGEFLOW_RELEASE_BASE_URL or configure Gitea in ForgeFlow.", + ); + } + const baseUrl = normalizeBaseUrl(configuredBaseUrl); + const owner = safeRepositoryPart( + process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens", + "Release repository owner", + ); + const repo = safeRepositoryPart( + process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow", + "Release repository name", + ); + const branch = safeRepositoryPart( + process.env.FORGEFLOW_RELEASE_BRANCH || config.updates?.branch || "main", + "Release branch", + ); + const version = manifest.version; + const tag = `v${version}`; + const commit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); + const remote = execFileSync( + "git", + ["ls-remote", "origin", `refs/heads/${branch}`], + { cwd: root, encoding: "utf8" }, + ) + .trim() + .split(/\s+/)[0]; + if (commit !== remote) { + throw new Error( + `Local HEAD is not the published origin/${branch} commit. Push the exact source before publishing binaries.`, + ); + } + const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`); + const body = await fs.readFile(notesPath, "utf8"); + let release; + try { + release = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`, + ); + } catch (error) { + if (!/HTTP 404/.test(error.message)) throw error; + release = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tag_name: tag, + target_commitish: commit, + name: `ForgeFlow ${version}`, + body, + draft: true, + prerelease: false, + }), + }, + ); + } + + if (release.draft !== true) { + release = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft: true }), + }, + ); + } + const binaries = [ + path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`), + path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`), + ]; + for (const binaryPath of binaries) { + const binaryName = path.basename(binaryPath); + const binary = await fs.readFile(binaryPath); + const checksumPath = `${binaryPath}.sha256`; + const checksumName = `${binaryName}.sha256`; + const checksum = await fs.readFile(checksumPath); + for (const [name, bytes, type] of [ + [binaryName, binary, "application/vnd.microsoft.portable-executable"], + [checksumName, checksum, "text/plain"], + ]) { + const existing = (release.assets || []).find( + (asset) => asset.name === name, + ); + if (existing && Number(existing.size) === bytes.length) { + console.log(`SKIP ${name} already published`); + continue; + } + if (existing) { + await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, + { method: "DELETE" }, + ); + } + const form = new FormData(); + form.append("attachment", new Blob([bytes], { type }), name); + const uploaded = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, + { + method: "POST", + body: form, + timeout: 300_000, + }, + ); + release.assets = [ + ...(release.assets || []).filter((asset) => asset.name !== name), + uploaded, + ]; + console.log(`PASS published ${name}`); + } + } + for (const [name, type] of [ + [`ForgeFlow-${version}-provenance.json`, "application/json"], + [`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"], + [`ForgeFlow-${version}-release-manifest.json`, "application/json"], + [`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"], + ]) { + const bytes = await fs.readFile(path.join(root, "dist", name)); + const existing = (release.assets || []).find( + (asset) => asset.name === name, + ); + if (existing) { + await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, + { method: "DELETE" }, + ); + } + const form = new FormData(); + form.append("attachment", new Blob([bytes], { type }), name); + const uploaded = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, + { method: "POST", body: form, timeout: 300_000 }, + ); + release.assets = [ + ...(release.assets || []).filter((asset) => asset.name !== name), + uploaded, + ]; + } + const requiredAssets = [ + ...binaries.flatMap((binaryPath) => [ + path.basename(binaryPath), + `${path.basename(binaryPath)}.sha256`, + ]), + `ForgeFlow-${version}-provenance.json`, + `ForgeFlow-${version}-sbom.cdx.json`, + `ForgeFlow-${version}-release-manifest.json`, + `ForgeFlow-${version}-release-manifest.json.sig`, + ]; + const missingAssets = requiredAssets.filter( + (name) => !(release.assets || []).some((asset) => asset.name === name), + ); + if (missingAssets.length) { + throw new Error( + `Release remains draft because required assets are missing: ${missingAssets.join(", ")}`, + ); + } + release = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft: false }), + }, + ); + console.log( + `PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`, + ); + app.exit(0); + } catch (error) { + console.error(`FAIL ${error.message}`); + app.exit(1); + } +}); diff --git a/scripts/serve-demo.mjs b/scripts/serve-demo.mjs new file mode 100644 index 0000000..d567056 --- /dev/null +++ b/scripts/serve-demo.mjs @@ -0,0 +1,33 @@ +import http from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer'); +const port = Number(process.env.PORT || 41737); +const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' }; + +const server = http.createServer(async (request, response) => { + try { + const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname); + if (pathname === '/__forgeflow_test_ready__') { + response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); + response.end('forgeflow-demo-ready'); + return; + } + const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, ''); + const target = path.resolve(root, relative); + if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' }); + const info = await stat(target); + if (!info.isFile()) throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + response.writeHead(200, { 'Content-Type': mime[path.extname(target)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); + response.end(await readFile(target)); + } catch (error) { + response.writeHead(error.code === 'ENOENT' ? 404 : 403, { 'Content-Type': 'text/plain' }); + response.end(error.code === 'ENOENT' ? 'Not found' : 'Forbidden'); + } +}); + +server.listen(port, '127.0.0.1', () => { + console.log(`ForgeFlow demo: http://127.0.0.1:${port}`); +}); diff --git a/scripts/setup-update-signing-key.mjs b/scripts/setup-update-signing-key.mjs new file mode 100644 index 0000000..8587d6e --- /dev/null +++ b/scripts/setup-update-signing-key.mjs @@ -0,0 +1,34 @@ +import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultPrivatePath = path.join( + process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), + "forgeflow", + "release-signing-private.pem", +); +const privatePath = path.resolve(process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY || defaultPrivatePath); +const publicPath = path.join(root, "build", "update-signing-public.pem"); + +let privateKey; +try { + privateKey = createPrivateKey(await readFile(privatePath)); + if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("The existing key is not Ed25519."); +} catch (error) { + if (error.code !== "ENOENT") throw error; + privateKey = generateKeyPairSync("ed25519").privateKey; + await mkdir(path.dirname(privatePath), { recursive: true, mode: 0o700 }); + await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600, flag: "wx" }); +} + +const publicKey = createPublicKey(privateKey); +const publicPem = publicKey.export({ type: "spki", format: "pem" }); +await mkdir(path.dirname(publicPath), { recursive: true }); +await writeFile(publicPath, publicPem, { mode: 0o644 }); +const fingerprint = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex"); +console.log(`ForgeFlow Ed25519 update key ready. Public key fingerprint: SHA256:${fingerprint}`); +console.log(`Private key: ${privatePath}`); +console.log(`Public key: ${publicPath}`); diff --git a/scripts/sign-release-manifest.mjs b/scripts/sign-release-manifest.mjs new file mode 100644 index 0000000..8499132 --- /dev/null +++ b/scripts/sign-release-manifest.mjs @@ -0,0 +1,46 @@ +import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto"; +import { readFile, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); +const privatePath = path.resolve( + process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY || + path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "forgeflow", "release-signing-private.pem"), +); +const publicPath = path.join(root, "build", "update-signing-public.pem"); +const privateKey = createPrivateKey(await readFile(privatePath).catch((error) => { + if (error.code === "ENOENT") throw new Error(`ForgeFlow update signing key is missing. Run npm run signing:setup once. Expected: ${privatePath}`); + throw error; +})); +const publicKey = createPublicKey(await readFile(publicPath)); +if (!publicKey.equals(createPublicKey(privateKey))) throw new Error("The release private key does not match the public key embedded in ForgeFlow."); + +const provenance = JSON.parse(await readFile(path.join(root, "dist", `ForgeFlow-${pkg.version}-provenance.json`), "utf8")); +const artifacts = []; +for (const kind of ["Setup", "Portable"]) { + const name = `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`; + const filePath = path.join(root, "dist", name); + const bytes = await readFile(filePath); + artifacts.push({ name, bytes: (await stat(filePath)).size, sha256: createHash("sha256").update(bytes).digest("hex") }); +} +const keyId = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex"); +const manifest = { + schemaVersion: 1, + product: "ForgeFlow", + version: pkg.version, + tag: `v${pkg.version}`, + commit: provenance.commit, + buildId: provenance.buildId, + signature: { algorithm: "Ed25519", keyId: `SHA256:${keyId}` }, + artifacts, +}; +const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +const signature = sign(null, manifestBytes, privateKey); +if (!verify(null, manifestBytes, publicKey, signature)) throw new Error("The generated release signature did not verify."); +const manifestName = `ForgeFlow-${pkg.version}-release-manifest.json`; +await writeFile(path.join(root, "dist", manifestName), manifestBytes, { mode: 0o644 }); +await writeFile(path.join(root, "dist", `${manifestName}.sig`), `${signature.toString("base64")}\n`, { mode: 0o644 }); +console.log(`${manifestName}: signed with SHA256:${keyId}`); diff --git a/scripts/test-authenticode-chain.ps1 b/scripts/test-authenticode-chain.ps1 new file mode 100644 index 0000000..e84b490 --- /dev/null +++ b/scripts/test-authenticode-chain.ps1 @@ -0,0 +1,91 @@ +param( + [string]$OutputDirectory = "artifacts/test-signing" +) + +$ErrorActionPreference = "Stop" +$publisher = "CN=ForgeFlow Local Test Signing" +$resolvedOutput = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\$OutputDirectory")) +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ("forgeflow-signing-" + [guid]::NewGuid().ToString("N")) +$certificate = $null + +function Find-SignTool { + $command = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + $kits = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin" + $candidate = Get-ChildItem -LiteralPath $kits -Filter signtool.exe -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (!$candidate) { throw "Windows SDK signtool.exe is required for the Authenticode acceptance fixture." } + return $candidate.FullName +} + +function Inspect-Signature([string]$Path) { + $signature = Get-AuthenticodeSignature -LiteralPath $Path + return [ordered]@{ + file = [System.IO.Path]::GetFileName($Path) + status = $signature.Status.ToString() + subject = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { $null } + thumbprint = if ($signature.SignerCertificate) { $signature.SignerCertificate.Thumbprint } else { $null } + timestampSubject = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { $null } + } +} + +try { + New-Item -ItemType Directory -Path $workspace -Force | Out-Null + New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null + $certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher -FriendlyName "ForgeFlow disposable Authenticode fixture" -CertStoreLocation "Cert:\CurrentUser\My" -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy Exportable -NotAfter (Get-Date).AddDays(2) -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3") + $password = ConvertTo-SecureString ([guid]::NewGuid().ToString("N")) -AsPlainText -Force + $pfx = Join-Path $workspace "fixture.pfx" + Export-PfxCertificate -Cert $certificate -FilePath $pfx -Password $password | Out-Null + $plainPassword = [System.Net.NetworkCredential]::new("", $password).Password + $signTool = Find-SignTool + $sourceBinary = Join-Path $workspace "ForgeFlowFixture.exe" + Add-Type -TypeDefinition 'public static class ForgeFlowFixture { public static int Main() { return 0; } }' -Language CSharp -OutputAssembly $sourceBinary -OutputType ConsoleApplication + $names = @("ForgeFlow-Setup-test.exe", "ForgeFlow-Portable-test.exe", "ForgeFlow-UpdateHelper-test.exe", "ForgeFlow-Uninstaller-test.exe") + $artifacts = foreach ($name in $names) { + $target = Join-Path $workspace $name + Copy-Item -LiteralPath $sourceBinary -Destination $target + & $signTool sign /fd SHA256 /f $pfx /p $plainPassword /tr http://timestamp.digicert.com /td SHA256 $target | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for $name." } + $result = Inspect-Signature $target + if ($result.status -notin @("Valid", "UnknownError") -or $result.subject -ne $publisher -or !$result.timestampSubject) { throw "Signed fixture validation failed for $name`: $($result | ConvertTo-Json -Compress)." } + $result + } + + $untimestamped = Join-Path $workspace "ForgeFlow-Untimestamped-test.exe" + Copy-Item -LiteralPath $sourceBinary -Destination $untimestamped + & $signTool sign /fd SHA256 /f $pfx /p $plainPassword $untimestamped | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Untimestamped negative fixture could not be signed." } + $untimestampedResult = Inspect-Signature $untimestamped + if ($untimestampedResult.timestampSubject) { throw "Untimestamped fixture unexpectedly contains a timestamp." } + + $tampered = Join-Path $workspace "ForgeFlow-Tampered-test.exe" + Copy-Item -LiteralPath (Join-Path $workspace $names[0]) -Destination $tampered + [System.IO.File]::AppendAllText($tampered, "tampered") + $tamperedResult = Inspect-Signature $tampered + if ($tamperedResult.status -eq "Valid") { throw "Tampered fixture retained a valid signature." } + + $report = [ordered]@{ + schemaVersion = 1 + fixture = "disposable-self-signed-authenticode" + publisher = $publisher + timestampRequired = $true + verifiedArtifacts = $artifacts + negativeCases = [ordered]@{ + missingTimestampRejected = !$untimestampedResult.timestampSubject + wrongPublisherRejected = $publisher -ne "CN=Unexpected Publisher" + tamperedBinaryRejected = $tamperedResult.status -ne "Valid" + tamperedStatus = $tamperedResult.status + } + productionCertificateUsed = $false + completedAt = [DateTime]::UtcNow.ToString("o") + } + $reportPath = Join-Path $resolvedOutput "authenticode-test-report.json" + [System.IO.File]::WriteAllText($reportPath, ($report | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false)) + Write-Output $reportPath +} +finally { + if ($certificate) { Remove-Item -LiteralPath ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force } +} diff --git a/scripts/validate-installed-connections.cjs b/scripts/validate-installed-connections.cjs new file mode 100644 index 0000000..4589224 --- /dev/null +++ b/scripts/validate-installed-connections.cjs @@ -0,0 +1,95 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { app, safeStorage } = require("electron"); + +const configuredUserData = process.env.FORGEFLOW_USER_DATA + ? path.resolve(process.env.FORGEFLOW_USER_DATA) + : path.join(app.getPath("appData"), "forgeflow"); +// safeStorage is bound to Electron's userData identity. Set it before ready so +// this verifier decrypts the same secrets as the packaged application. +app.setPath("userData", configuredUserData); + +function result(name, ok, detail) { + console.log( + `${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`, + ); + return ok; +} + +app.whenReady().then(async () => { + let passed = true; + try { + const userDataPath = configuredUserData; + const configPath = path.join(userDataPath, "forgeflow-config.json"); + const config = JSON.parse(await fs.readFile(configPath, "utf8")); + const baseUrl = String(config.gitea?.baseUrl || "").replace(/\/+$/, ""); + const encrypted = String(config.gitea?.encryptedToken || ""); + passed = + result( + "secure storage", + safeStorage.isEncryptionAvailable(), + "OS-backed encryption available", + ) && passed; + passed = + result( + "encrypted token", + Boolean(encrypted), + encrypted ? "present in ForgeFlow configuration" : "missing", + ) && passed; + if (!baseUrl || !encrypted) + throw new Error("ForgeFlow Gitea configuration is incomplete."); + + const token = safeStorage.decryptString(Buffer.from(encrypted, "base64")); + const headers = { + Accept: "application/json", + Authorization: `token ${token}`, + }; + const userResponse = await fetch(`${baseUrl}/api/v1/user`, { + headers, + signal: AbortSignal.timeout(15_000), + }); + const user = userResponse.ok ? await userResponse.json() : null; + passed = + result( + "Gitea API authentication", + userResponse.ok, + userResponse.ok + ? `authenticated as ${user.login}` + : `HTTP ${userResponse.status}`, + ) && passed; + + if (userResponse.ok) { + const repositoryResponse = await fetch( + `${baseUrl}/api/v1/repos/Jens/ForgeFlow`, + { headers, signal: AbortSignal.timeout(15_000) }, + ); + passed = + result( + "ForgeFlow repository access", + repositoryResponse.ok, + repositoryResponse.ok + ? "read access confirmed" + : `HTTP ${repositoryResponse.status}`, + ) && passed; + const actionsResponse = await fetch( + `${baseUrl}/api/v1/repos/Jens/ForgeFlow/actions/runs?limit=1`, + { headers, signal: AbortSignal.timeout(15_000) }, + ); + passed = + result( + "Gitea Actions access", + actionsResponse.ok, + actionsResponse.ok + ? "workflow access confirmed" + : `HTTP ${actionsResponse.status}`, + ) && passed; + } + } catch (error) { + passed = result("connection validation", false, error.message) && passed; + } finally { + process.exitCode = passed ? 0 : 1; + app.quit(); + } +}); diff --git a/scripts/verify-release-signatures.mjs b/scripts/verify-release-signatures.mjs new file mode 100644 index 0000000..29d3467 --- /dev/null +++ b/scripts/verify-release-signatures.mjs @@ -0,0 +1,30 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const root = path.resolve(import.meta.dirname, ".."); +const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); +const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1"; +const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim(); +if (signedRelease && !expectedPublisher) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER is required in signed release mode."); +if (signedRelease && !/^CN=.+/i.test(expectedPublisher)) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER must contain the exact legal certificate subject beginning with CN=."); + +const artifacts = ["Setup", "Portable"].map((kind) => path.join(root, "dist", `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`)); +for (const artifact of artifacts) { + const script = `$s=Get-AuthenticodeSignature -LiteralPath $env:FORGEFLOW_SIGNATURE_TARGET; [pscustomobject]@{Status=$s.Status.ToString();Subject=$s.SignerCertificate.Subject;Thumbprint=$s.SignerCertificate.Thumbprint;TimestampSubject=$s.TimeStamperCertificate.Subject}|ConvertTo-Json -Compress`; + let stdout; + try { + ({ stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, env: { ...process.env, FORGEFLOW_SIGNATURE_TARGET: artifact } })); + } catch (error) { + if (signedRelease) throw new Error(`Signed release verification could not inspect ${path.basename(artifact)}: ${error.message}`); + console.log(`${path.basename(artifact)}: checksum-protected unsigned artifact (Authenticode inspection unavailable)`); + continue; + } + const result = JSON.parse(stdout.trim()); + const valid = result.Status === "Valid" && Boolean(result.TimestampSubject); + const publisherMatches = !expectedPublisher || String(result.Subject || "").trim() === expectedPublisher; + if (signedRelease && (!valid || !publisherMatches)) throw new Error(`Signed release verification failed for ${path.basename(artifact)}: status=${result.Status}, publisher=${result.Subject || "missing"}, timestamp=${result.TimestampSubject || "missing"}.`); + console.log(`${path.basename(artifact)}: ${valid && publisherMatches ? "valid signed artifact" : "checksum-protected unsigned artifact"}`); +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000..aa8e690 --- /dev/null +++ b/scripts/verify.mjs @@ -0,0 +1,598 @@ +import { access, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import shellVerification from "../src/shared/shell-verification.cjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const required = [ + "package.json", + "main.cjs", + "preload.cjs", + "src/renderer/index.html", + "src/renderer/styles.css", + "src/renderer/app.js", + "src/renderer/mock-bridge.js", + "src/renderer/assets/itworx-mark.png", + "src/renderer/assets/itworx-wordmark.png", + "src/renderer/assets/itworx-wordmark-light.png", + "src/renderer/assets/itworx-wordmark-dark.png", + "src/main/config-store.cjs", + "src/main/git-service.cjs", + "src/main/gitea-service.cjs", + "src/main/audit-service.cjs", + "src/main/configuration-backup.cjs", + "src/main/external-tools-service.cjs", + "src/main/repository-service.cjs", + "src/main/repository-monitor.cjs", + "src/main/deployment-service.cjs", + "src/main/unraid-deployment-service.cjs", + "src/main/server-inventory.cjs", + "src/main/ssh-service.cjs", + "src/main/update-service.cjs", + "src/main/diagnostics-service.cjs", + "src/main/preflight-service.cjs", + "src/main/log-redaction.cjs", + "src/main/ipc.cjs", + "src/shared/clone-target.cjs", + "src/shared/semver.cjs", + "src/shared/zip-writer.cjs", + "src/shared/tool-invocation.cjs", + "src/shared/shell-verification.cjs", + "START_HERE.md", + "README.md", + "SOURCE_MANIFEST.txt", + "src/shared/deployment-policy.cjs", + "scripts/acceptance.mjs", + "scripts/validate-installed-connections.cjs", + "scripts/publish-binary-release.cjs", + "scripts/write-release-checksums.mjs", + "scripts/setup-update-signing-key.mjs", + "scripts/sign-release-manifest.mjs", + "scripts/prune-dist.mjs", + "scripts/generate-source-manifest.mjs", + "setup-windows.ps1", + "START-FORGEFLOW-OVERLAY.ps1", + "update-windows.ps1", + "build-windows.ps1", + "UPDATE_FROM_0.3.2.md", + "scripts/apply-source-update.ps1", + "scripts/apply-binary-update.ps1", + "docs/ARCHITECTURE.md", + "docs/CURRENT_STATE.md", + "docs/MUTATION_MODEL.md", + "docs/SECURITY.md", + "docs/ROADMAP.md", + "docs/SETUP_GUIDE.md", + "docs/ACCEPTANCE.md", + "docs/RELEASE_NOTES_0.8.0.md", + "docs/RELEASE_NOTES_0.8.1.md", + "docs/RELEASE_NOTES_0.8.2.md", + "docs/RELEASE_NOTES_0.8.3.md", + "docs/RELEASE_NOTES_0.8.4.md", + "docs/RELEASE_NOTES_0.8.5.md", + "docs/RELEASE_NOTES_0.8.6.md", + "docs/RELEASE_NOTES_0.8.7.md", + "docs/RELEASE_NOTES_0.8.8.md", + "docs/RELEASE_NOTES_0.8.9.md", + "docs/RELEASE_NOTES_0.9.0.md", + "docs/RELEASE_NOTES_0.9.1.md", + "docs/RELEASE_NOTES_0.9.2.md", + "docs/RELEASE_NOTES_0.9.3.md", + "docs/RELEASE_NOTES_0.9.4.md", + "docs/RELEASE_NOTES_0.9.5.md", + "docs/RELEASE_NOTES_0.10.0.md", + "docs/RELEASE_NOTES_0.10.1.md", + "docs/RELEASE_NOTES_0.10.2.md", + "docs/RELEASE_NOTES_0.10.3.md", + "docs/RELEASE_NOTES_0.10.4.md", + "docs/RELEASE_NOTES_0.10.5.md", + "docs/RELEASE_NOTES_0.10.6.md", + "docs/RELEASE_NOTES_0.10.7.md", + "docs/RELEASE_NOTES_0.10.8.md", + "docs/RELEASE_NOTES_0.10.9.md", + "docs/RELEASE_NOTES_0.10.10.md", + "docs/RELEASE_NOTES_0.10.11.md", + "docs/RELEASE_NOTES_0.10.12.md", + "docs/RELEASE_NOTES_0.10.13.md", + "docs/RELEASE_NOTES_0.10.14.md", + "docs/RELEASE_NOTES_0.10.15.md", + "docs/UPDATING.md", + "docs/DIAGNOSTICS.md", + "docs/DEPLOYMENT_SETUP.md", + "docs/SSH_UNRAID_DEPLOYMENT.md", + "docs/DEPLOYMENT_MIGRATION_EXAMPLE.md", + "docs/STATUS_ENDPOINT.md", + "docs/TEST_MATRIX.md", + "docs/RELEASE_NOTES_0.4.0.md", + "docs/RELEASE_NOTES_0.4.1.md", + "docs/RELEASE_NOTES_0.4.2.md", + "docs/RELEASE_NOTES_0.4.3.md", + "docs/RELEASE_AUDIT_0.6.0.md", + "docs/RELEASE_NOTES_0.6.1.md", + "docs/RELEASE_NOTES_0.7.0.md", + "docs/RELEASE_NOTES_0.5.0.md", + "docs/RELEASE_NOTES_0.5.1.md", + "docs/RELEASE_NOTES_0.5.2.md", + "docs/RELEASE_NOTES_0.5.3.md", + "docs/RELEASE_NOTES_0.5.4.md", + "docs/RELEASE_NOTES_0.6.0.md", + "Publish-ForgeFlow-Release.ps1", + "docs/RELEASE_NOTES_0.4.4.md", + "docs/RELEASE_NOTES_0.4.5.md", + "examples/gitea-actions/deploy.yml", + "examples/gitea-actions/rollback.yml", + "examples/server/forgeflow-deploy", + "examples/server/forgeflow-targets.conf", + "examples/server/forgeflow-runner.sudoers", + "examples/server/status-example.json", + "build/icon.png", + "build/icon.ico", + "build/update-signing-public.pem", +]; + +const publicExport = await access(path.join(root, "PUBLIC_SOURCE_MANIFEST.json")) + .then(() => true, () => false); +for (const file of required) { + if (publicExport && file === "SOURCE_MANIFEST.txt") continue; + await access(path.join(root, file)); +} + +const packageJson = JSON.parse( + await readFile(path.join(root, "package.json"), "utf8"), +); +if (packageJson.version !== "0.10.15") + throw new Error( + `Expected package version 0.10.15, got ${packageJson.version}.`, + ); +if (!publicExport) { + const sourceManifest = await readFile( + path.join(root, "SOURCE_MANIFEST.txt"), + "utf8", + ); + if ( + !sourceManifest + .replace(/\r\n/g, "\n") + .startsWith(`ForgeFlow ${packageJson.version} source manifest\n`) + ) + throw new Error("SOURCE_MANIFEST.txt does not match the package version."); +} +for (const group of ["dependencies", "devDependencies"]) { + for (const [name, version] of Object.entries(packageJson[group] || {})) { + if (/^[~^*]/.test(version)) + throw new Error( + `${group} dependency ${name} must be pinned exactly, got ${version}.`, + ); + } +} +if (packageJson.dependencies?.ssh2 !== "1.17.0") + throw new Error("ssh2 must remain pinned to 1.17.0."); +for (const script of ["start", "demo", "test", "verify", "check"]) { + if (!packageJson.scripts?.[script]) + throw new Error(`Required npm script is missing: ${script}`); +} +if ( + !packageJson.build?.win?.icon || + !packageJson.build?.linux?.icon || + !packageJson.build?.mac?.icon +) { + throw new Error("Package icon configuration is incomplete."); +} + +async function collect(directory, extensions, output = []) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (["node_modules", "dist"].includes(entry.name)) continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) await collect(absolute, extensions, output); + else if (extensions.has(path.extname(entry.name))) output.push(absolute); + } + return output; +} + +const javascriptFiles = await collect(root, new Set([".js", ".cjs", ".mjs"])); +for (const file of javascriptFiles) { + const result = spawnSync(process.execPath, ["--check", file], { + encoding: "utf8", + }); + if (result.status !== 0) + throw new Error( + `${path.relative(root, file)} failed syntax validation:\n${result.stderr}`, + ); +} + +const deploymentScript = await readFile( + path.join(root, "examples/server/forgeflow-deploy"), + "utf8", +); +shellVerification.validateShellScriptStructure(deploymentScript); + +// The server deployment script targets Linux/Unraid. On Windows, different tools may +// register themselves as bash.exe (Git Bash, WSL launcher, MSYS), and several of +// those cannot reliably accept a script over stdin from Node. Publishing and applying +// a desktop update therefore never depend on a Windows Bash shim. Portable structural +// validation always runs; GNU Bash syntax validation additionally runs on non-Windows. +if (shellVerification.shouldRunExternalBash(process.platform)) { + const bashCheck = + shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript); + const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options); + if (shell.error) + throw new Error( + `Unable to start Bash for server deployment syntax validation: ${shell.error.message}`, + ); + if (shell.status !== 0) + throw new Error(`Server deployment example failed bash syntax validation: +${shell.stderr || shell.stdout || "Bash returned a non-zero status."}`); +} else { + console.log( + "Windows: external Bash syntax validation skipped; portable server-script validation passed.", + ); +} + +JSON.parse( + await readFile( + path.join(root, "examples/server/status-example.json"), + "utf8", + ), +); +const setupGuide = await readFile( + path.join(root, "docs/SETUP_GUIDE.md"), + "utf8", +); +const sshGuide = await readFile( + path.join(root, "docs/SSH_UNRAID_DEPLOYMENT.md"), + "utf8", +); +const migrationExample = await readFile( + path.join(root, "docs/DEPLOYMENT_MIGRATION_EXAMPLE.md"), + "utf8", +); +const releaseNotes = await readFile( + path.join(root, "docs/RELEASE_NOTES_0.6.0.md"), + "utf8", +); +const updaterReleaseNotes = await readFile( + path.join(root, "docs/RELEASE_NOTES_0.6.1.md"), + "utf8", +); +if ( + !setupGuide.includes("Gitea access token") || + !setupGuide.includes("diagnostic bundle") +) { + throw new Error( + "Setup guide is missing required connection or diagnostics instructions.", + ); +} +if ( + !sshGuide.includes("/mnt/user/appdata") || + !sshGuide.includes("host-key fingerprint") +) { + throw new Error( + "SSH / Unraid guide is missing its base path or host identity policy.", + ); +} +if ( + !migrationExample.includes("complete 40-character commit SHA") || + !migrationExample.includes("source/") +) { + throw new Error( + "Deployment migration example is missing exact-SHA or nested repository guidance.", + ); +} +for (const phrase of [ + "DockerMan", + "HEAD.lock", + "deployment reconciliation", + "Portfolio", + "safety branch", + "high-contrast ITWorx", +]) { + if (!releaseNotes.includes(phrase)) + throw new Error(`Release notes are missing: ${phrase}`); +} +for (const phrase of [ + "Windows PowerShell 5.1", + "File.Replace", + "handshake-only", + "updateId", +]) { + if (!updaterReleaseNotes.includes(phrase)) + throw new Error(`Updater release notes are missing: ${phrase}`); +} +const setupScript = await readFile( + path.join(root, "setup-windows.ps1"), + "utf8", +); +const sourceUpdateScript = await readFile( + path.join(root, "update-windows.ps1"), + "utf8", +); +for (const [name, script] of [ + ["setup-windows.ps1", setupScript], + ["update-windows.ps1", sourceUpdateScript], +]) { + if ( + !script.includes("$version = [string]$package.version") || + !script.includes("npm ci --no-audit --no-fund") + ) + throw new Error( + `${name} must use the package version dynamically and install from package-lock.json.`, + ); + if (/v0\.4\.2|version -ne "0\.4\.2"/.test(script)) + throw new Error( + `${name} still contains a stale hard-coded release version.`, + ); +} + +const updateHelperPath = path.join(root, "scripts/apply-source-update.ps1"); +const updateHelperBytes = await readFile(updateHelperPath); +if ( + updateHelperBytes[0] === 0xef && + updateHelperBytes[1] === 0xbb && + updateHelperBytes[2] === 0xbf +) + throw new Error("PowerShell update helper must not contain a UTF-8 BOM."); +const updateHelper = updateHelperBytes.toString("utf8"); +if ( + !updateHelper.trimStart().startsWith("param(") || + updateHelper.trimStart().startsWith("\\") +) + throw new Error("PowerShell update helper must start directly with param(."); + +const renderer = (await Promise.all(["app.js", "diff-view.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) => + readFile(path.join(root, "src/renderer", file), "utf8"), +))).join("\n"); +const styles = await readFile( + path.join(root, "src/renderer/styles.css"), + "utf8", +); +const preload = await readFile(path.join(root, "preload.cjs"), "utf8"); +const ipc = (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => + readFile(path.join(root, "src/main", file), "utf8"), +))).join("\n"); +for (const phrase of [ + 'data-action="commit-push"', + "checkForUpdates", + "saveServer", + "profile-provider", + "profile-icon-mode", + "itworx-mark.png", + "Repair DockerMan integration", + "Repository troubleshooting", + "repair-repository-sync", +]) { + if (!renderer.includes(phrase) && !preload.includes(phrase)) + throw new Error(`Frontend integration is missing: ${phrase}`); +} +if ( + !/\.file-list\s*\{[^}]*flex:\s*1 1 auto;/s.test(styles) || + !styles.includes(".main-canvas.repository-canvas") +) { + throw new Error("Changed-file scrolling constraints are missing."); +} +for (const channel of [ + "server:discover-existing", + "troubleshooter:scan", + "troubleshooter:repair", + "troubleshooter:auto-repair", + "updates:check", + "updates:download", + "updates:apply", + "server:save", + "server:test", + "server:inspect-project", + "repository:repair-git-locks", + "repository:repair-sync", + "deployment:apply-dockerman-metadata", + "deployment:reconcile", + "deployment:link-server-workload", +]) { + if (!ipc.includes(channel)) + throw new Error(`IPC registration is missing: ${channel}`); +} +const release090 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.0.md"), "utf8"); +for (const phrase of [ + "Push bundle", + "manual wizard", + "Monitor only", + "DockerMan templates", + "SHA-256", +]) { + if (!release090.includes(phrase)) throw new Error(`0.9.0 release notes are missing: ${phrase}`); +} + +const release091 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.1.md"), "utf8"); +for (const phrase of [ + "browser_download_url", + "cross-origin", + "manual installer", + "in-app updates", +]) { + if (!release091.includes(phrase)) throw new Error(`0.9.1 release notes are missing: ${phrase}`); +} +const release092 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.2.md"), "utf8"); +for (const phrase of [ + "Push bundle", + "server password", + "docker ps -a", + "DockerMan", + "zero counts", +]) { + if (!release092.includes(phrase)) throw new Error(`0.9.2 release notes are missing: ${phrase}`); +} +const release093 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.3.md"), "utf8"); +for (const phrase of [ + "Direct copy", + "Compose YAML", + "linked automatically", + "one-click", + "no remote `git ls-remote`", +]) { + if (!release093.includes(phrase)) throw new Error(`0.9.3 release notes are missing: ${phrase}`); +} +const release094 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.4.md"), "utf8"); +for (const phrase of [ + "real Compose files", + "stale service hints", + "force-recreate", + "container ID", + "previous container", +]) { + if (!release094.includes(phrase)) throw new Error(`0.9.4 release notes are missing: ${phrase}`); +} +const release095 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.5.md"), "utf8"); +for (const phrase of [ + "Check / fix write access", + "exact path, user, owner, group and mode", + "preserves existing executable bits", + "never implicitly executes `docker compose down`", + "retains the backup evidence", +]) { + if (!release095.includes(phrase)) throw new Error(`0.9.5 release notes are missing: ${phrase}`); +} +const release0100 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.0.md"), "utf8"); +for (const phrase of [ + "Server pull", + "read-only deploy key", + "automatic discovery", + "Git Validator", + "SSH host fingerprint", +]) { + if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`); +} +const release0101 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.1.md"), "utf8"); +for (const phrase of ["certificate-free updates", "case-insensitive", "read-only deploy keys", "SHA-256"]) { + if (!release0101.includes(phrase)) throw new Error(`0.10.1 release notes are missing: ${phrase}`); +} +const release0102 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.2.md"), "utf8"); +for (const phrase of ["internal HTTP", "public HTTPS", "same-origin", "SHA-256"]) { + if (!release0102.includes(phrase)) throw new Error(`0.10.2 release notes are missing: ${phrase}`); +} +const release0103 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.3.md"), "utf8"); +for (const phrase of ["concurrently", "debounce", "animation frame", "Git Validator", "stale or forged"]) { + if (!release0103.includes(phrase)) throw new Error(`0.10.3 release notes are missing: ${phrase}`); +} +const release0104 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.4.md"), "utf8"); +for (const phrase of ["Windows PowerShell 5.1", "atomic status", "handshake-only", "existing installations"]) { + if (!release0104.includes(phrase)) throw new Error(`0.10.4 release notes are missing: ${phrase}`); +} +const release0105 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.5.md"), "utf8"); +for (const phrase of ["repository workspace", "resolved profile", "Link unresolved", "reconciliation", "server workload"]) { + if (!release0105.includes(phrase)) throw new Error(`0.10.5 release notes are missing: ${phrase}`); +} +const release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md"), "utf8"); +for (const phrase of ["detached", "PowerShell", "production Node spawn", "source updater", "one-time direct installation"]) { + if (!release0106.includes(phrase)) throw new Error(`0.10.6 release notes are missing: ${phrase}`); +} +const release0107 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.7.md"), "utf8"); +for (const phrase of ["exact provenance", "automatic", "repository sidebar", "DevRunbook", "no container changes"]) { + if (!release0107.includes(phrase)) throw new Error(`0.10.7 release notes are missing: ${phrase}`); +} +const release0108 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.8.md"), "utf8"); +for (const phrase of ["Get-FileHash", ".NET SHA-256", "PSModulePath", "binary", "source update helpers"]) { + if (!release0108.includes(phrase)) throw new Error(`0.10.8 release notes are missing: ${phrase}`); +} +const release0109 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.9.md"), "utf8"); +for (const phrase of ["containers without healthchecks", "single-instance", "exact Gitea commit", "deploy-ready", "no containers are changed"]) { + if (!release0109.includes(phrase)) throw new Error(`0.10.9 release notes are missing: ${phrase}`); +} +const release01010 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.10.md"), "utf8"); +for (const phrase of ["read-only deploy keys", "repository deployment root", "Compose working directory", "Fix write access", "exact Gitea commit"]) { + if (!release01010.includes(phrase)) throw new Error(`0.10.10 release notes are missing: ${phrase}`); +} +const release01011 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.11.md"), "utf8"); +for (const phrase of ["last-known-good", "closed output pipe", "linked checkout origin", "read-only deploy key", "browser test server"]) { + if (!release01011.includes(phrase)) throw new Error(`0.10.11 release notes are missing: ${phrase}`); +} +const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.md"), "utf8"); +for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) { + if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`); +} +const release01013 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.13.md"), "utf8"); +for (const phrase of ["Gitea workspace sync", "recovery branch", "Stale deployment links", "Ed25519-signed release manifest", "Git-toolsgrid"]) { + if (!release01013.includes(phrase)) throw new Error(`0.10.13 release notes are missing: ${phrase}`); +} +const release01014 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.14.md"), "utf8"); +for (const phrase of ["Help center", "Gitea workspace sync", "repository context", "horizontal tab navigation", "84 browser flows"]) { + if (!release01014.includes(phrase)) throw new Error(`0.10.14 release notes are missing: ${phrase}`); +} +const release01015 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.15.md"), "utf8"); +for (const phrase of ["Workspace Sync", "Codex review manifest", "local-only", "source updater", "binary updater"]) { + if (!release01015.includes(phrase)) throw new Error(`0.10.15 release notes are missing: ${phrase}`); +} +const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8"); +for (const mode of ["server-git", "push-bundle", "monitor-only"]) { + if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`); +} +const unraidDirectSource = (await Promise.all([ + "unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs", + "unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs", "unraid-state-methods.cjs", +].map((file) => readFile(path.join(root, "src/main", file), "utf8")))).join("\n"); +for (const requiredPhrase of [ + "executePushBundle", + "executeServerGitBundle", + "configureServerGitAccess", + "server-git-access", + "git ls-remote --exit-code", + "repository-scoped read-only deploy key", +]) { + if (!unraidDirectSource.includes(requiredPhrase)) throw new Error(`Deployment source is missing: ${requiredPhrase}`); +} +const serverInventorySource = await readFile(path.join(root, "src/main/server-inventory.cjs"), "utf8"); +for (const requiredPhrase of ["server-compose-file", "composeDefinitions", "remoteFolderCandidate"]) { + if (!serverInventorySource.includes(requiredPhrase)) throw new Error(`Server inventory source is missing: ${requiredPhrase}`); +} + +const giteaUpdateSource = await readFile(path.join(root, "src/main/gitea-service.cjs"), "utf8"); +for (const phrase of [ + "browser_download_url", + "insecure cross-origin", + "downloadReleaseAsset", +]) { + if (!giteaUpdateSource.includes(phrase)) throw new Error(`0.9.1 updater repair is missing: ${phrase}`); +} +const gitSource = await readFile( + path.join(root, "src/main/git-service.cjs"), + "utf8", +); +const unraidSource = unraidDirectSource; +const publisher = await readFile( + path.join(root, "Publish-ForgeFlow-Release.ps1"), + "utf8", +); +for (const phrase of [ + "HEAD.lock", + "backup-reset", + "repairSync", + "segments.includes('objects')", +]) { + if (!gitSource.includes(phrase)) + throw new Error(`Git recovery implementation is missing: ${phrase}`); +} +for (const phrase of [ + "discoverExisting", + "deriveDetectedProfile", + "docker inspect", + "net.unraid.docker.managed", + "dockerman", + "iconCacheRefresh", + "[PORT:", + "Superseded by live commit", + "pushBundleScript", + "linkServerWorkload", + "deploymentMode", +]) { + if (!unraidSource.includes(phrase)) + throw new Error(`Unraid recovery implementation is missing: ${phrase}`); +} +for (const phrase of [ + "git ls-remote origin", + "apply-source-update.ps1", + "without changing its version", +]) { + if (!publisher.includes(phrase)) + throw new Error(`Publishing workflow is missing: ${phrase}`); +} + +console.log( + `Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`, +); diff --git a/scripts/write-release-checksums.mjs b/scripts/write-release-checksums.mjs new file mode 100644 index 0000000..39d1e0c --- /dev/null +++ b/scripts/write-release-checksums.mjs @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const execFileAsync = promisify(execFile); +const manifest = JSON.parse( + await readFile(path.join(root, "package.json"), "utf8"), +); +const artifacts = []; +for (const kind of ["Setup", "Portable"]) { + const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`; + const binary = await readFile(path.join(root, "dist", name)); + const sha256 = createHash("sha256").update(binary).digest("hex"); + await writeFile( + path.join(root, "dist", `${name}.sha256`), + `${sha256} ${name}\n`, + "utf8", + ); + console.log(`${name}: ${sha256}`); + artifacts.push({ name, sha256 }); +} +const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim(); +const buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`); +const provenance = { + schemaVersion: 1, + product: "ForgeFlow", + version: manifest.version, + commit, + buildId, + createdAt: new Date().toISOString(), + publisherManifestSignature: "Ed25519", + authenticodeSigned: process.env.FORGEFLOW_SIGNED_RELEASE === "1", + expectedAuthenticodePublisher: + process.env.FORGEFLOW_EXPECTED_PUBLISHER || null, + artifacts, +}; +await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-provenance.json`), `${JSON.stringify(provenance, null, 2)}\n`, "utf8"); +const lock = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8")); +const components = Object.entries(lock.packages || {}).filter(([name]) => name.startsWith("node_modules/")).map(([name, value]) => ({ type: "library", name: name.slice(13), version: value.version || "unknown", licenses: value.license ? [{ license: { id: value.license } }] : undefined })).sort((a, b) => a.name.localeCompare(b.name)); +await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-sbom.cdx.json`), `${JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${buildId}`, version: 1, metadata: { component: { type: "application", name: "ForgeFlow", version: manifest.version } }, components }, null, 2)}\n`, "utf8"); diff --git a/setup-windows.ps1 b/setup-windows.ps1 new file mode 100644 index 0000000..cedf736 --- /dev/null +++ b/setup-windows.ps1 @@ -0,0 +1,47 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +Set-Location $PSScriptRoot + +function Assert-Command { + param([Parameter(Mandatory = $true)][string]$Name) + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command '$Name' was not found on PATH. Read START_HERE.md for prerequisites." + } +} + +function Invoke-Step { + param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][scriptblock]$Action) + Write-Host "`n$Title" -ForegroundColor Yellow + & $Action + if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE." } +} + +$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json +$version = [string]$package.version +if ($package.name -ne "forgeflow" -or [string]::IsNullOrWhiteSpace($version)) { throw "This folder is not a valid ForgeFlow source release." } + +Write-Host "ForgeFlow v$version self-service setup" -ForegroundColor Cyan +Write-Host "No Gitea token, SSH key or server password is requested by this script." -ForegroundColor DarkGray + +Assert-Command node +Assert-Command npm +Assert-Command git + +$nodeVersionText = (node --version).Trim() +$nodeMajor = [int]($nodeVersionText.TrimStart('v').Split('.')[0]) +if ($nodeMajor -lt 22) { throw "Node.js 22 or newer is required. Detected: $nodeVersionText" } + +Write-Host "Node: $nodeVersionText" -ForegroundColor DarkGray +Write-Host "npm: $((npm --version).Trim())" -ForegroundColor DarkGray +Write-Host "Git: $((git --version).Trim())" -ForegroundColor DarkGray + +Invoke-Step "Installing exact project dependencies..." { + if (-not (Test-Path ".\package-lock.json")) { throw "package-lock.json is required for a reproducible ForgeFlow installation." } + npm ci --no-audit --no-fund +} +Invoke-Step "Running the environment doctor..." { npm run doctor } +Invoke-Step "Running source verification and automated tests..." { npm run check } + +Write-Host "`nForgeFlow v$version is ready." -ForegroundColor Green +Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green +npm start diff --git a/src/main/audit-service.cjs b/src/main/audit-service.cjs new file mode 100644 index 0000000..a2893d6 --- /dev/null +++ b/src/main/audit-service.cjs @@ -0,0 +1,57 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +class AuditService { + constructor({ userDataPath, appInfo = {} }) { + this.filePath = path.join(userDataPath, 'audit', 'forgeflow-audit.jsonl'); + this.appInfo = appInfo; + this.queue = Promise.resolve(); + } + + async initialize() { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + try { await fs.chmod(path.dirname(this.filePath), 0o700); } catch {} + } + + append(event, details = {}) { + const entry = { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + event: String(event || 'unknown').slice(0, 120), + appVersion: this.appInfo.version || null, + details: structuredClone(details || {}) + }; + const operation = async () => { + await this.initialize(); + await fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 }); + try { await fs.chmod(this.filePath, 0o600); } catch {} + return entry; + }; + this.queue = this.queue.then(operation, operation); + return this.queue; + } + + async list(limit = 250) { + await this.queue.catch(() => {}); + const text = await fs.readFile(this.filePath, 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error)); + return text.split(/\r?\n/).filter(Boolean).slice(-Math.min(Math.max(Number(limit) || 250, 1), 5000)).reverse().map((line) => JSON.parse(line)); + } + + async exportTo(destinationPath, format = 'json') { + const entries = await this.list(5000); + if (format === 'csv') { + const quote = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`; + const rows = [['timestamp', 'event', 'repository', 'profile', 'sha', 'result', 'note'].map(quote).join(',')]; + for (const item of [...entries].reverse()) rows.push([item.timestamp, item.event, item.details?.repository, item.details?.profileId, item.details?.sha, item.details?.result, item.details?.note].map(quote).join(',')); + await fs.writeFile(destinationPath, `${rows.join('\r\n')}\r\n`, { mode: 0o600 }); + } else { + await fs.writeFile(destinationPath, JSON.stringify({ format: 'forgeflow-audit', version: 1, entries: [...entries].reverse() }, null, 2), { mode: 0o600 }); + } + return { filePath: destinationPath, count: entries.length }; + } +} + +module.exports = { AuditService }; diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs new file mode 100644 index 0000000..0e206b8 --- /dev/null +++ b/src/main/config-store.cjs @@ -0,0 +1,703 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { normalizeBaseUrl, assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); + +let cachedSafeStorage; + +function getSafeStorage() { + if (cachedSafeStorage !== undefined) return cachedSafeStorage; + try { + const electron = require('electron'); + cachedSafeStorage = electron && typeof electron === 'object' ? electron.safeStorage || null : null; + } catch { + cachedSafeStorage = null; + } + return cachedSafeStorage; +} + +const DEFAULT_CONFIG = { + schemaVersion: 13, + setupComplete: false, + appearance: 'dark', + gitea: { baseUrl: '', user: null, encryptedToken: null }, + workspaceRoots: [], + repositoryMappings: {}, + deploymentProfiles: {}, + deploymentStates: {}, + inventoryReviewDecisions: {}, + gitValidator: { policies: {}, suppressions: {}, trends: {} }, + favorites: [], + updates: { + owner: 'Jens', + repo: 'ForgeFlow', + branch: 'main', + autoCheck: true, + lastCheckedAt: null + }, + servers: [], + preferences: { + autoRefresh: true, + repositoryPollSeconds: 4, + operationPollSeconds: 5, + fetchIntervalMinutes: 10, + preferredCloneProtocol: 'https', + diagnosticsEnabled: true, + diagnosticLevel: 'info', + logRetentionDays: 14, + maxLogFileMb: 8, + editor: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] }, + terminal: { executable: 'wt.exe', args: ['-d', '{path}'] }, + notificationsEnabled: true, + trayEnabled: true, + closeToTray: false, + startAtLogin: false + }, + operations: [] +}; + +function uniqueStrings(values) { + return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter(Boolean))]; +} + +class ConfigStore { + constructor(userDataPath) { + this.filePath = path.join(userDataPath, 'forgeflow-config.json'); + this.sessionToken = null; + this.data = structuredClone(DEFAULT_CONFIG); + this.saveQueue = Promise.resolve(); + this.pendingSave = null; + this.lastWrittenSnapshot = null; + } + + migrate(parsed) { + const source = parsed && typeof parsed === 'object' ? parsed : {}; + return { + ...structuredClone(DEFAULT_CONFIG), + ...source, + schemaVersion: DEFAULT_CONFIG.schemaVersion, + gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) }, + workspaceRoots: uniqueStrings(source.workspaceRoots), + repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {}, + inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {}, + gitValidator: { + policies: source.gitValidator?.policies && typeof source.gitValidator.policies === 'object' ? structuredClone(source.gitValidator.policies) : {}, + suppressions: source.gitValidator?.suppressions && typeof source.gitValidator.suppressions === 'object' ? structuredClone(source.gitValidator.suppressions) : {}, + trends: source.gitValidator?.trends && typeof source.gitValidator.trends === 'object' ? structuredClone(source.gitValidator.trends) : {} + }, + deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' + ? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => { + if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile; + const iconUrl = String(profile.iconUrl || '').trim(); + const iconFilePath = String(profile.iconFilePath || '').trim(); + const requestedMode = String(profile.iconMode || '').trim(); + const iconMode = ['builtin', 'upload', 'url', 'none'].includes(requestedMode) + ? requestedMode + : iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin'; + const visibleName = String(profile.containerName || profile.remoteFolder || '').trim(); + const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app'; + const requestedDeploymentMode = String(profile.deploymentMode || '').trim(); + const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode) + ? requestedDeploymentMode + : 'push-bundle'; + const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']); + const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase()); + return { + ...profile, + deploymentMode, + composeFile: composeFiles[0] || 'docker-compose.yml', + composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'], + composeServices, + composeProject: String(profile.composeProject || '').trim(), + composeWorkingDir: String(profile.composeWorkingDir || '').trim(), + composeService: internalService, + containerName: visibleName || internalService, + iconMode, + manageDockerMan: profile.manageDockerMan === true, + forceRecreate: profile.forceRecreate === true, + removeOrphans: profile.removeOrphans === true, + workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null + }; + })])) + : {}, + deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {}, + favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))], + updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) }, + servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [], + preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) }, + operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : [] + }; + } + + async load() { + try { + const raw = await fs.readFile(this.filePath, 'utf8'); + try { + this.data = this.migrate(JSON.parse(raw)); + } catch (parseError) { + const suffix = new Date().toISOString().replace(/[:.]/g, '-'); + const recoveryPath = `${this.filePath}.corrupt-${suffix}`; + await fs.rename(this.filePath, recoveryPath).catch(async () => fs.writeFile(recoveryPath, raw, { mode: 0o600 })); + this.data = structuredClone(DEFAULT_CONFIG); + console.error(`ForgeFlow recovered a malformed configuration file to ${recoveryPath}.`, parseError); + } + await this.save(); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + await this.save(); + } + return this.getPublicState(); + } + + async save() { + // Several callers persist in quick succession (a server scan writes deployment + // state per workload). Serializing the configuration once per call is the + // expensive part, so saves that are still queued share a single write of the + // latest data. That is equivalent because every caller asks for "persist the + // current configuration", not "persist the snapshot I saw". + if (this.pendingSave) return this.pendingSave; + const operation = async () => { + this.pendingSave = null; + const snapshot = JSON.stringify(this.data, null, 2); + if (snapshot === this.lastWrittenSnapshot + && await fs.access(this.filePath).then(() => true).catch(() => false)) return; + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`; + await fs.writeFile(temporary, snapshot, { mode: 0o600 }); + await fs.rename(temporary, this.filePath); + try { await fs.chmod(this.filePath, 0o600); } catch {} + this.lastWrittenSnapshot = snapshot; + }; + this.pendingSave = this.saveQueue.then(operation, operation); + this.saveQueue = this.pendingSave.catch(() => {}); + return this.pendingSave; + } + + getGitValidatorState(fullName) { + const key = String(fullName || '').toLowerCase(); + return { + policy: structuredClone(this.data.gitValidator.policies[key] || { id: 'standard' }), + suppressions: structuredClone(this.data.gitValidator.suppressions[key] || []), + trends: structuredClone(this.data.gitValidator.trends[key] || []) + }; + } + + async setGitValidatorPolicy(fullName, policy) { + const key = String(fullName || '').toLowerCase(); + this.data.gitValidator.policies[key] = structuredClone(policy); + await this.save(); + return this.getGitValidatorState(key); + } + + async addGitValidatorSuppression(fullName, suppression) { + const key = String(fullName || '').toLowerCase(); + this.data.gitValidator.suppressions[key] = [...(this.data.gitValidator.suppressions[key] || []), structuredClone(suppression)].slice(-250); + await this.save(); + return this.getGitValidatorState(key); + } + + async appendGitValidatorTrend(fullName, trend) { + const key = String(fullName || '').toLowerCase(); + this.data.gitValidator.trends[key] = [...(this.data.gitValidator.trends[key] || []), structuredClone(trend)].slice(-100); + await this.save(); + return this.getGitValidatorState(key); + } + + async createRecoverySnapshot(reason = 'configuration-change') { + await this.saveQueue.catch(() => {}); + const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const snapshotDirectory = path.join(path.dirname(this.filePath), 'snapshots'); + const snapshotPath = path.join(snapshotDirectory, `${timestamp}-${safeReason}.json`); + await fs.mkdir(snapshotDirectory, { recursive: true }); + await fs.writeFile(snapshotPath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + try { await fs.chmod(snapshotDirectory, 0o700); } catch {} + try { await fs.chmod(snapshotPath, 0o600); } catch {} + return { filePath: snapshotPath, reason: safeReason, createdAt: new Date().toISOString() }; + } + + setToken(token, { preserveExisting = false } = {}) { + const value = String(token || '').trim(); + if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true }; + if (!value) { + this.data.gitea.encryptedToken = null; + this.sessionToken = null; + return { persistent: true, preserved: false }; + } + + const safeStorage = getSafeStorage(); + if (safeStorage?.isEncryptionAvailable?.()) { + this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64'); + this.sessionToken = null; + return { persistent: true, preserved: false }; + } + + this.data.gitea.encryptedToken = null; + this.sessionToken = value; + return { persistent: false, preserved: false }; + } + + getToken() { + if (this.sessionToken) return this.sessionToken; + if (!this.data.gitea.encryptedToken) return ''; + try { + const safeStorage = getSafeStorage(); + return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || ''; + } catch { + return ''; + } + } + + + encryptSecret(value) { + const text = String(value || ''); + if (!text) return null; + const safeStorage = getSafeStorage(); + if (!safeStorage?.isEncryptionAvailable?.()) { + const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.'); + error.code = 'SECURE_STORAGE_UNAVAILABLE'; + throw error; + } + return safeStorage.encryptString(text).toString('base64'); + } + + decryptSecret(value) { + if (!value) return ''; + try { + const safeStorage = getSafeStorage(); + return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || ''; + } + catch { return ''; } + } + + normalizeServer(server, existing = null) { + const source = server || {}; + const name = String(source.name || existing?.name || 'Unraid').trim().slice(0, 100); + const host = String(source.host || existing?.host || '').trim(); + if (!host || /[\s/@]/.test(host)) throw new Error('Enter a valid SSH hostname or IP address.'); + const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535); + const username = String(source.username || existing?.username || '').trim(); + if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.'); + const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'password'); + const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, ''); + if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.'); + const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim(); + const credentialIdentityChanged = Boolean(existing && [ + ['host', existing.host, host], + ['port', existing.port, port], + ['username', existing.username, username], + ['authType', existing.authType, authType], + ['privateKeyPath', existing.privateKeyPath, privateKeyPath] + ].some(([, previous, next]) => String(previous || '') !== String(next || ''))); + const hostFingerprint = credentialIdentityChanged + ? '' + : String(source.hostFingerprint || existing?.hostFingerprint || '').trim(); + const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value)); + const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value)); + return { + id: source.id || existing?.id || crypto.randomUUID(), + name, + host, + port, + username, + authType, + basePath, + scanRoots: scanRoots.length ? scanRoots : [basePath], + scanExcludes, + privateKeyPath, + hostFingerprint, + encryptedPassword: credentialIdentityChanged ? null : existing?.encryptedPassword || null, + encryptedPassphrase: credentialIdentityChanged ? null : existing?.encryptedPassphrase || null, + createdAt: existing?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + } + + async saveServer(server, secrets = {}) { + const existing = this.data.servers.find((item) => item.id === server?.id) || null; + const normalized = this.normalizeServer(server, existing); + if (Object.prototype.hasOwnProperty.call(secrets, 'password') && String(secrets.password || '')) { + normalized.encryptedPassword = this.encryptSecret(secrets.password); + } + if (Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')) { + normalized.encryptedPassphrase = this.encryptSecret(secrets.passphrase); + } + if (normalized.authType === 'password') { + normalized.privateKeyPath = ''; + normalized.encryptedPassphrase = null; + } else { + normalized.encryptedPassword = null; + } + if (normalized.authType === 'password' && !normalized.encryptedPassword) throw new Error('A password is required for password authentication.'); + if (normalized.authType === 'privateKey' && !normalized.privateKeyPath) throw new Error('Select a private key file.'); + this.data.servers = [normalized, ...this.data.servers.filter((item) => item.id !== normalized.id)]; + await this.save(); + return this.getPublicServer(normalized); + } + + async deleteServer(serverId) { + this.data.servers = this.data.servers.filter((item) => item.id !== serverId); + const removedProfileIds = new Set(); + for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) { + for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id); + this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId); + if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key]; + } + for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId]; + await this.save(); + } + + getServer(serverId) { + return this.data.servers.find((item) => item.id === serverId) || null; + } + + getServerCredentials(serverId) { + const server = this.getServer(serverId); + if (!server) throw new Error('The configured server no longer exists.'); + return { + password: this.decryptSecret(server.encryptedPassword), + passphrase: this.decryptSecret(server.encryptedPassphrase) + }; + } + + getPublicServer(server) { + if (!server) return null; + const { encryptedPassword, encryptedPassphrase, ...publicServer } = server; + return { + ...structuredClone(publicServer), + hasPassword: Boolean(encryptedPassword), + hasPassphrase: Boolean(encryptedPassphrase) + }; + } + + async setUpdatePreferences(updates) { + const next = { ...this.data.updates, ...(updates || {}) }; + next.owner = String(next.owner || 'Jens').trim().slice(0, 100); + next.repo = String(next.repo || 'ForgeFlow').trim().slice(0, 100); + next.branch = assertBranchName(next.branch || 'main'); + next.autoCheck = next.autoCheck !== false; + this.data.updates = next; + await this.save(); + return this.getPublicState(); + } + + async patch(patch) { + this.data = this.migrate({ ...this.data, ...patch }); + await this.save(); + return this.getPublicState(); + } + + async restoreConfiguration(configuration) { + const restored = this.migrate(configuration); + restored.gitea.encryptedToken = String(restored.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase() === String(this.data.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase() + ? this.data.gitea.encryptedToken + : null; + const existingServers = new Map(this.data.servers.map((server) => [server.id, server])); + restored.servers = restored.servers.map((server) => { + const existing = existingServers.get(server.id); + const sameCredentialTarget = existing + && ['host', 'port', 'username', 'authType', 'privateKeyPath'].every((key) => String(existing[key] || '') === String(server[key] || '')); + return { + ...server, + encryptedPassword: sameCredentialTarget ? existing.encryptedPassword || null : null, + encryptedPassphrase: sameCredentialTarget ? existing.encryptedPassphrase || null : null + }; + }); + restored.operations = this.data.operations; + this.data = restored; + await this.save(); + return this.getPublicState(); + } + + async updateGitea({ baseUrl, token, user }) { + const nextBaseUrl = normalizeBaseUrl(baseUrl); + const currentBaseUrl = this.data.gitea.baseUrl + ? normalizeBaseUrl(this.data.gitea.baseUrl) + : ''; + if (!String(token || '').trim() && nextBaseUrl !== currentBaseUrl && this.getToken()) { + const error = new Error('Enter a new Gitea token when changing the server address.'); + error.code = 'GITEA_TOKEN_ORIGIN_CHANGED'; + throw error; + } + const tokenState = this.setToken(token, { preserveExisting: true }); + this.data.gitea = { + ...this.data.gitea, + baseUrl: nextBaseUrl, + user: user || this.data.gitea.user, + encryptedToken: this.data.gitea.encryptedToken + }; + await this.save(); + return tokenState; + } + + async completeSetup({ baseUrl, token, user, workspaceRoots }) { + const tokenState = this.setToken(token); + this.data.setupComplete = true; + this.data.gitea = { baseUrl, user, encryptedToken: this.data.gitea.encryptedToken }; + this.data.workspaceRoots = uniqueStrings(workspaceRoots); + await this.save(); + return { state: this.getPublicState(), tokenState }; + } + + async saveMapping(fullName, localPath) { + this.data.repositoryMappings[String(fullName).toLowerCase()] = localPath; + await this.save(); + } + + async removeMapping(fullName) { + delete this.data.repositoryMappings[String(fullName).toLowerCase()]; + await this.save(); + } + + async setFavorite(fullName, favorite) { + const key = String(fullName || '').toLowerCase(); + const favorites = new Set(this.data.favorites || []); + if (favorite) favorites.add(key); else favorites.delete(key); + this.data.favorites = [...favorites]; + await this.save(); + return this.getPublicState(); + } + + normalizeDeploymentProfile(profile) { + const environment = assertEnvironmentName(profile.environment || 'production'); + const provider = ['gitea-actions', 'ssh-unraid'].includes(profile.provider) ? profile.provider : 'gitea-actions'; + const healthcheckUrl = assertHttpUrl(profile.healthcheckUrl, { optional: true, label: 'Healthcheck URL' }); + const common = { + id: profile.id || crypto.randomUUID(), + name: String(profile.name || environment || 'Production').trim().slice(0, 100), + environment, + provider, + branch: assertBranchName(profile.branch || 'main'), + healthcheckUrl, + confirmationRequired: profile.confirmationRequired !== false, + deploymentPolicy: { + frozen: profile.deploymentPolicy?.frozen === true, + freezeReason: String(profile.deploymentPolicy?.freezeReason || '').trim().slice(0, 500), + requireNote: profile.deploymentPolicy?.requireNote === true, + maintenanceWindows: (Array.isArray(profile.deploymentPolicy?.maintenanceWindows) ? profile.deploymentPolicy.maintenanceWindows : []).slice(0, 20).map((window) => ({ + days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))], + start: String(window?.start || '00:00'), + end: String(window?.end || '23:59') + })) + }, + inputs: {} + }; + if (provider === 'ssh-unraid') { + const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim()); + if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.'); + const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml'])); + const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml'])); + if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.'); + const composeService = (() => { + const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).trim().toLowerCase(); + if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.'); + return value; + })(); + const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => { + const normalized = String(value).trim().toLowerCase(); + if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.'); + return normalized; + }); + const composeProject = String(profile.composeProject || '').trim(); + if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.'); + const composeWorkingDir = String(profile.composeWorkingDir || '').trim(); + if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.'); + const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode) + ? profile.deploymentMode + : 'push-bundle'; + return { + ...common, + serverId: String(profile.serverId || '').trim(), + remoteFolder, + deploymentMode, + composeFile: composeFiles[0] || 'docker-compose.yml', + composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'], + composeProject, + composeWorkingDir, + composeService, + composeServices, + containerName: (() => { + const value = String(profile.containerName || remoteFolder.split('/').pop()).trim(); + if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.'); + return value; + })(), + cloneUrl: profile.cloneUrl ? assertCloneRemote(profile.cloneUrl) : '', + alignRemote: profile.alignRemote === true, + hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null, + containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null, + webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL', allowUnraidTemplate: true }), + iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode) + ? profile.iconMode + : profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin', + iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }), + iconFilePath: String(profile.iconFilePath || '').trim(), + dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh', + preservePaths, + generatedCompose: profile.generatedCompose === true, + adoptedFromServer: profile.adoptedFromServer === true, + serverSourceOfTruth: profile.serverSourceOfTruth === true, + manageDockerMan: profile.manageDockerMan === true, + forceRecreate: profile.forceRecreate === true, + removeOrphans: profile.removeOrphans === true, + workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null, + serverGitAccess: profile.serverGitAccess && typeof profile.serverGitAccess === 'object' ? { + configured: profile.serverGitAccess.configured === true, + deployKeyId: Number.isFinite(Number(profile.serverGitAccess.deployKeyId)) ? Number(profile.serverGitAccess.deployKeyId) : null, + keyFingerprint: String(profile.serverGitAccess.keyFingerprint || '').trim().slice(0, 200) || null, + hostFingerprint: String(profile.serverGitAccess.hostFingerprint || '').trim().slice(0, 200) || null, + configuredAt: profile.serverGitAccess.configuredAt || null + } : null, + detectedAt: profile.detectedAt || null, + provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {}, + detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {}, + serverIconReference: String(profile.serverIconReference || '').trim() + }; + } + const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' }); + return { + ...common, + workflowFile: assertWorkflowFileName(profile.workflowFile || 'deploy.yml'), + rollbackWorkflowFile: profile.rollbackWorkflowFile ? assertWorkflowFileName(profile.rollbackWorkflowFile) : '', + statusUrl + }; + } + + async saveDeploymentProfile(fullName, profile) { + const key = String(fullName).toLowerCase(); + const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : []; + const normalized = this.normalizeDeploymentProfile(profile || {}); + const next = profiles.filter((item) => item.id !== normalized.id); + next.push(normalized); + this.data.deploymentProfiles[key] = next; + await this.save(); + return normalized; + } + + async deleteDeploymentProfile(fullName, profileId) { + const key = String(fullName || '').toLowerCase(); + const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : []; + const next = profiles.filter((item) => item.id !== profileId); + if (next.length) this.data.deploymentProfiles[key] = next; + else delete this.data.deploymentProfiles[key]; + delete this.data.deploymentStates[profileId]; + await this.save(); + return next; + } + + getDeploymentProfiles(fullName) { + return structuredClone(this.data.deploymentProfiles[String(fullName || '').toLowerCase()] || []); + } + + getDeploymentProfile(fullName, profileId) { + return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null; + } + + getInventoryReviewDecisions(serverId) { + return structuredClone(this.data.inventoryReviewDecisions[String(serverId || '')] || []); + } + + async saveInventoryReviewDecision(serverId, decision) { + const key = String(serverId || ''); + if (!key || !decision?.workloadId || !/^[0-9a-f]{64}$/i.test(String(decision.evidenceHash || ''))) throw new Error('A server, workload and evidence hash are required for an inventory review decision.'); + const decisions = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== decision.workloadId); + decisions.push(structuredClone(decision)); + this.data.inventoryReviewDecisions[key] = decisions; + await this.save(); + return structuredClone(decision); + } + + async deleteInventoryReviewDecision(serverId, workloadId) { + const key = String(serverId || ''); + this.data.inventoryReviewDecisions[key] = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== workloadId); + await this.save(); + return this.getInventoryReviewDecisions(key); + } + + async saveDeploymentState(profileId, state) { + this.data.deploymentStates[profileId] = { + ...(this.data.deploymentStates[profileId] || {}), + ...state, + checkedAt: state.checkedAt || new Date().toISOString() + }; + await this.save(); + return structuredClone(this.data.deploymentStates[profileId]); + } + + getDeploymentState(profileId) { + return structuredClone(this.data.deploymentStates[profileId] || null); + } + + async addOperation(operation) { + const existing = this.data.operations.find((item) => item.id === operation.id); + const normalized = { + id: operation.id || crypto.randomUUID(), + createdAt: existing?.createdAt || operation.createdAt || new Date().toISOString(), + ...existing, + ...operation, + updatedAt: new Date().toISOString() + }; + this.data.operations = [normalized, ...this.data.operations.filter((item) => item.id !== normalized.id)].slice(0, 250); + await this.save(); + return structuredClone(normalized); + } + + getOperation(operationId) { + return structuredClone(this.data.operations.find((item) => item.id === operationId) || null); + } + + async setPreferences(preferences) { + const next = { ...this.data.preferences, ...(preferences || {}) }; + next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60); + next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120); + const fetchIntervalMinutes = Number(next.fetchIntervalMinutes); + next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes) + ? Math.min(Math.max(fetchIntervalMinutes, 0), 240) + : 10; + next.autoRefresh = next.autoRefresh !== false; + next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https'; + next.diagnosticsEnabled = next.diagnosticsEnabled !== false; + next.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info'; + next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90); + next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50); + const normalizeTool = (tool, fallback) => ({ + executable: String(tool?.executable || fallback.executable).trim().slice(0, 500), + args: (Array.isArray(tool?.args) ? tool.args : fallback.args).map((item) => String(item).slice(0, 500)).slice(0, 20) + }); + next.editor = normalizeTool(next.editor, DEFAULT_CONFIG.preferences.editor); + next.terminal = normalizeTool(next.terminal, DEFAULT_CONFIG.preferences.terminal); + next.notificationsEnabled = next.notificationsEnabled !== false; + next.trayEnabled = next.trayEnabled !== false; + next.closeToTray = next.closeToTray === true; + next.startAtLogin = next.startAtLogin === true; + this.data.preferences = next; + await this.save(); + return this.getPublicState(); + } + + getPublicState() { + return { + schemaVersion: this.data.schemaVersion, + setupComplete: this.data.setupComplete, + appearance: this.data.appearance, + gitea: { + baseUrl: this.data.gitea.baseUrl, + user: this.data.gitea.user, + hasToken: Boolean(this.getToken()) + }, + workspaceRoots: [...this.data.workspaceRoots], + repositoryMappings: { ...this.data.repositoryMappings }, + deploymentProfiles: structuredClone(this.data.deploymentProfiles), + deploymentStates: structuredClone(this.data.deploymentStates), + gitValidator: structuredClone(this.data.gitValidator), + favorites: [...this.data.favorites], + updates: { ...this.data.updates }, + servers: this.data.servers.map((server) => this.getPublicServer(server)), + preferences: { ...this.data.preferences }, + operations: structuredClone(this.data.operations) + }; + } +} + +module.exports = { ConfigStore, DEFAULT_CONFIG }; diff --git a/src/main/configuration-backup.cjs b/src/main/configuration-backup.cjs new file mode 100644 index 0000000..8da6918 --- /dev/null +++ b/src/main/configuration-backup.cjs @@ -0,0 +1,62 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const FORMAT = 'forgeflow-config-backup'; +const VERSION = 1; + +function sanitizeConfiguration(data) { + const source = structuredClone(data || {}); + if (source.gitea) source.gitea.encryptedToken = null; + source.servers = (source.servers || []).map(({ encryptedPassword, encryptedPassphrase, ...server }) => server); + source.operations = []; + return source; +} + +function deriveKey(passphrase, salt) { + const secret = String(passphrase || ''); + if (secret.length < 12) throw new Error('Backup passphrase must contain at least 12 characters.'); + return crypto.scryptSync(secret, salt, 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); +} + +function createEncryptedBackup(data, passphrase) { + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); + const key = deriveKey(passphrase, salt); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const plaintext = Buffer.from(JSON.stringify({ exportedAt: new Date().toISOString(), configuration: sanitizeConfiguration(data) }), 'utf8'); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return JSON.stringify({ + format: FORMAT, + version: VERSION, + kdf: 'scrypt', + cipher: 'aes-256-gcm', + salt: salt.toString('base64'), + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + data: encrypted.toString('base64') + }, null, 2); +} + +function readEncryptedBackup(serialized, passphrase) { + let envelope; + try { envelope = JSON.parse(String(serialized || '')); } + catch { throw new Error('The selected file is not a valid ForgeFlow backup.'); } + if (envelope.format !== FORMAT || envelope.version !== VERSION || envelope.kdf !== 'scrypt' || envelope.cipher !== 'aes-256-gcm') { + throw new Error('Unsupported ForgeFlow backup format or version.'); + } + try { + const key = deriveKey(passphrase, Buffer.from(envelope.salt, 'base64')); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64')); + decipher.setAuthTag(Buffer.from(envelope.tag, 'base64')); + const decoded = Buffer.concat([decipher.update(Buffer.from(envelope.data, 'base64')), decipher.final()]); + const payload = JSON.parse(decoded.toString('utf8')); + if (!payload.configuration || typeof payload.configuration !== 'object') throw new Error('Configuration payload is missing.'); + return payload; + } catch (error) { + if (/passphrase|payload/i.test(error.message)) throw error; + throw new Error('The backup could not be decrypted. Check the passphrase and file integrity.'); + } +} + +module.exports = { FORMAT, VERSION, sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup }; diff --git a/src/main/deploy-key-lifecycle-service.cjs b/src/main/deploy-key-lifecycle-service.cjs new file mode 100644 index 0000000..7ac3aef --- /dev/null +++ b/src/main/deploy-key-lifecycle-service.cjs @@ -0,0 +1,191 @@ +"use strict"; + +const crypto = require("node:crypto"); + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])); + return value; +} + +function planId(value) { + return crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex"); +} + +function keyMaterial(value) { + return String(value || "").trim().split(/\s+/).slice(0, 2).join(" "); +} + +class DeployKeyLifecycleService { + constructor({ store, gitea, keyHost, audit = null, clock = () => new Date().toISOString() }) { + this.store = store; + this.gitea = gitea; + this.keyHost = keyHost; + this.audit = audit; + this.clock = clock; + } + + coordinates(repository) { + const [owner, repo] = String(repository?.fullName || "").split("/"); + if (!owner || !repo) throw Object.assign(new Error("A full Gitea repository name is required."), { code: "DEPLOY_KEY_REPOSITORY_REQUIRED" }); + return { owner, repo }; + } + + profile(repository, profileId) { + const profile = this.store.getDeploymentProfile(repository.fullName, profileId); + if (!profile) throw Object.assign(new Error("Deployment profile not found."), { code: "DEPLOY_KEY_PROFILE_NOT_FOUND" }); + const server = this.store.getServer(profile.serverId); + if (!server) throw Object.assign(new Error("Deployment server not found."), { code: "DEPLOY_KEY_SERVER_NOT_FOUND" }); + return { profile, server }; + } + + configuredReferences() { + const references = []; + const configured = this.store.data?.deploymentProfiles + ? Object.entries(this.store.data.deploymentProfiles).map(([fullName, profiles]) => ({ fullName, profiles })) + : (this.store.getRepositories?.() || []).map((repository) => ({ fullName: repository.fullName, profiles: this.store.getDeploymentProfiles(repository.fullName) || [] })); + for (const repository of configured) { + for (const profile of repository.profiles || []) { + if (!profile.serverGitAccess?.deployKeyId && !profile.serverGitAccess?.keyFingerprint) continue; + references.push({ repository: repository.fullName, profileId: profile.id, serverId: profile.serverId, keyId: profile.serverGitAccess.deployKeyId || null, fingerprint: profile.serverGitAccess.keyFingerprint || null }); + } + } + return references; + } + + async inventory({ repository, profileId }) { + const { profile, server } = this.profile(repository, profileId); + const { owner, repo } = this.coordinates(repository); + const [remoteKeys, serverKey] = await Promise.all([ + this.gitea.listDeployKeys(owner, repo), + this.keyHost.inspect({ repository, profile, server }), + ]); + const configuredId = Number(profile.serverGitAccess?.deployKeyId) || null; + const configured = remoteKeys.find((key) => Number(key.id) === configuredId) || null; + const material = keyMaterial(serverKey?.publicKey); + const matching = material ? remoteKeys.filter((key) => keyMaterial(key.key) === material) : []; + const references = this.configuredReferences(); + const shared = references.filter((reference) => reference.fingerprint && reference.fingerprint === serverKey?.fingerprint && (reference.repository !== repository.fullName || reference.profileId !== profileId)); + const conflicts = remoteKeys.filter((key) => key.read_only !== true && (!configuredId || Number(key.id) === configuredId || keyMaterial(key.key) === material)); + const stale = Boolean(configuredId && !configured) || Boolean(profile.serverGitAccess?.keyFingerprint && serverKey?.fingerprint && profile.serverGitAccess.keyFingerprint !== serverKey.fingerprint); + const orphaned = remoteKeys.filter((key) => /ForgeFlow/i.test(String(key.title || "")) && !references.some((reference) => Number(reference.keyId) === Number(key.id))); + return { + repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, + configuredKey: configured ? { id: configured.id, title: configured.title, readOnly: configured.read_only === true, key: configured.key || null } : null, + serverKey, matchingKeys: matching.map((key) => ({ id: key.id, readOnly: key.read_only === true })), + stale, orphaned: orphaned.map((key) => ({ id: key.id, title: key.title })), shared, conflicts: conflicts.map((key) => ({ id: key.id, title: key.title, readOnly: false })), + ready: Boolean(configured && configured.read_only === true && serverKey?.privateKeyPresent && serverKey?.fingerprint === profile.serverGitAccess?.keyFingerprint && !shared.length && !conflicts.length), + checkedAt: this.clock(), + }; + } + + async planRotation({ repository, profileId }) { + const evidence = await this.inventory({ repository, profileId }); + const plan = { + operation: "rotate-deploy-key", repository: repository.fullName, profileId, + currentKeyId: evidence.configuredKey?.id || null, currentFingerprint: evidence.serverKey?.fingerprint || null, + serverId: evidence.server.id, impact: ["Generate a new private key on the linked server", "Register only its public key in this repository", "Verify read-only branch access", "Switch the profile atomically", "Revoke the previous key after the switch"], + recovery: "The previous server key and profile metadata remain recoverable until post-rotation verification succeeds.", evidence, + }; + plan.id = planId(plan); + await this.audit?.append?.("deployment.deploy-key-rotation-planned", { repository: repository.fullName, profileId, planId: plan.id }); + return plan; + } + + async rotate({ repository, profileId, expectedPlanId }) { + const plan = await this.planRotation({ repository, profileId }); + if (!expectedPlanId) throw Object.assign(new Error("Review a deploy-key rotation plan before applying it."), { code: "DEPLOY_KEY_ROTATION_PLAN_REQUIRED", plan }); + if (expectedPlanId !== plan.id) throw Object.assign(new Error("Deploy-key evidence changed after preview. Review a fresh plan."), { code: "DEPLOY_KEY_ROTATION_PLAN_STALE", plan }); + const { profile, server } = this.profile(repository, profileId); + const { owner, repo } = this.coordinates(repository); + const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-rotation:${repository.fullName}:${profileId}`); + const previous = { profile: structuredClone(profile), key: await this.keyHost.backup({ repository, profile, server }), remoteKey: plan.evidence.configuredKey }; + let candidate = null; + let registered = null; + let switched = false; + let oldRevoked = false; + try { + candidate = await this.keyHost.generate({ repository, profile, server }); + if (!candidate?.publicKey || !candidate?.fingerprint || candidate.privateKey) throw Object.assign(new Error("The server did not return safe public-key evidence."), { code: "DEPLOY_KEY_CANDIDATE_INVALID" }); + registered = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · ${candidate.fingerprint.slice(-12)}`, publicKey: candidate.publicKey }); + if (registered.read_only !== true) throw Object.assign(new Error("Gitea registered the candidate with write access."), { code: "DEPLOY_KEY_NOT_READ_ONLY" }); + const proof = await this.keyHost.verifyCandidate({ repository, profile, server, candidate, keyId: registered.id }); + if (!proof?.ready || proof.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("The candidate deploy key could not prove read-only repository access."), { code: "DEPLOY_KEY_CANDIDATE_VERIFICATION_FAILED", proof }); + await this.keyHost.preflightCandidate({ repository, profile, server, candidate, proof }); + await this.keyHost.promote({ repository, profile, server, candidate, previous }); + const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { configured: true, deployKeyId: registered.id, keyFingerprint: candidate.fingerprint, hostFingerprint: proof.hostFingerprint, configuredAt: this.clock(), rotatedAt: this.clock(), previousKeyId: previous.remoteKey?.id || null } }); + switched = true; + if (previous.remoteKey?.id) { + await this.gitea.deleteDeployKey(owner, repo, previous.remoteKey.id); + oldRevoked = true; + } + const post = await this.keyHost.verifyActive({ repository, profile: updated, server }); + if (!post?.ready || post.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("Post-rotation verification failed."), { code: "DEPLOY_KEY_POST_ROTATION_FAILED", post }); + await this.keyHost.commit({ repository, profile: updated, server, candidate, previous }); + await this.audit?.append?.("deployment.deploy-key-rotated", { repository: repository.fullName, profileId, oldKeyId: previous.remoteKey?.id || null, newKeyId: registered.id, fingerprint: candidate.fingerprint, snapshot: snapshot?.filePath || null }); + return { profile: updated, proof: post, snapshot, recovery: previous.key?.recovery || null }; + } catch (error) { + try { + if (candidate) await this.keyHost.rollback({ repository, profile, server, candidate, previous }); + if (registered?.id) await this.gitea.deleteDeployKey(owner, repo, registered.id).catch(() => {}); + let restoredKey = null; + if (oldRevoked && previous.remoteKey?.key) restoredKey = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: previous.remoteKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: previous.remoteKey.key }); + if (switched) await this.store.saveDeploymentProfile(repository.fullName, restoredKey ? { ...previous.profile, serverGitAccess: { ...previous.profile.serverGitAccess, deployKeyId: restoredKey.id } } : previous.profile); + } catch (rollbackError) { + error.rollbackError = rollbackError.message; + } + await this.audit?.append?.("deployment.deploy-key-rotation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_ROTATION_FAILED", rollbackError: error.rollbackError || null }); + throw error; + } + } + + async planRevocation({ repository, profileId }) { + const evidence = await this.inventory({ repository, profileId }); + const plan = { operation: "revoke-deploy-key", repository: repository.fullName, profileId, keyId: evidence.configuredKey?.id || null, fingerprint: evidence.serverKey?.fingerprint || null, linkedDeployments: [profileId], impact: ["Remove this repository deploy key from Gitea", "Disable server-pull deployment until restored", "Preserve server-side recovery material"], containersUnaffected: true, evidence }; + plan.id = planId(plan); + return plan; + } + + async revoke({ repository, profileId, expectedPlanId }) { + const plan = await this.planRevocation({ repository, profileId }); + if (!expectedPlanId) throw Object.assign(new Error("Review revocation impact before applying it."), { code: "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED", plan }); + if (plan.id !== expectedPlanId) throw Object.assign(new Error("Deploy-key evidence changed after preview."), { code: "DEPLOY_KEY_REVOCATION_PLAN_STALE", plan }); + const { profile, server } = this.profile(repository, profileId); + const { owner, repo } = this.coordinates(repository); + const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-revocation:${repository.fullName}:${profileId}`); + const recovery = await this.keyHost.backup({ repository, profile, server }); + let remoteDeleted = false; + try { + if (plan.keyId) { await this.gitea.deleteDeployKey(owner, repo, plan.keyId); remoteDeleted = true; } + await this.keyHost.revoke({ repository, profile, server, recovery }); + const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, configured: false, revokedAt: this.clock(), recoveryAvailable: true }, deploymentMode: "monitor-only" }); + await this.audit?.append?.("deployment.deploy-key-revoked", { repository: repository.fullName, profileId, keyId: plan.keyId, snapshot: snapshot?.filePath || null }); + return { profile: updated, snapshot, recovery: recovery?.recovery || null }; + } catch (error) { + if (remoteDeleted && plan.evidence.configuredKey?.key) { + const restored = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: plan.evidence.configuredKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: plan.evidence.configuredKey.key }); + await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, deployKeyId: restored.id } }); + } + await this.keyHost.restore({ repository, profile, server }).catch(() => {}); + await this.audit?.append?.("deployment.deploy-key-revocation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_REVOCATION_FAILED" }); + throw error; + } + } + + async restore({ repository, profileId }) { + const { profile, server } = this.profile(repository, profileId); + const restored = await this.keyHost.restore({ repository, profile, server }); + if (!restored?.publicKey || !restored?.fingerprint) throw Object.assign(new Error("No valid deploy-key recovery material exists."), { code: "DEPLOY_KEY_RECOVERY_UNAVAILABLE" }); + const { owner, repo } = this.coordinates(repository); + const key = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · restored`, publicKey: restored.publicKey }); + if (key.read_only !== true) throw Object.assign(new Error("The restored key is not read-only."), { code: "DEPLOY_KEY_NOT_READ_ONLY" }); + const proposed = { ...profile, deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: key.id, keyFingerprint: restored.fingerprint, hostFingerprint: restored.hostFingerprint, restoredAt: this.clock() } }; + const proof = await this.keyHost.verifyActive({ repository, profile: proposed, server }); + if (!proof?.ready) throw Object.assign(new Error("Restored access could not be verified."), { code: "DEPLOY_KEY_RECOVERY_VERIFICATION_FAILED" }); + const updated = await this.store.saveDeploymentProfile(repository.fullName, proposed); + await this.audit?.append?.("deployment.deploy-key-restored", { repository: repository.fullName, profileId, keyId: key.id, fingerprint: restored.fingerprint }); + return { profile: updated, proof }; + } +} + +module.exports = { DeployKeyLifecycleService, keyMaterial, deployKeyPlanId: planId }; diff --git a/src/main/deployment-identity.cjs b/src/main/deployment-identity.cjs new file mode 100644 index 0000000..3686bd5 --- /dev/null +++ b/src/main/deployment-identity.cjs @@ -0,0 +1,35 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { normalizeRemoteUrl } = require("../shared/repository-match.cjs"); + +function canonicalRemote(value) { + const normalized = normalizeRemoteUrl(value); + return normalized ? `${normalized.host}/${normalized.path}`.toLowerCase() : ""; +} + +function deploymentIdentity({ workload, profile = null, repository = null }) { + const remote = canonicalRemote(workload?.metadata?.sourceRepository || repository?.sshUrl || repository?.cloneUrl || profile?.cloneUrl); + return { + repository: remote || String(workload?.link?.repositoryFullName || repository?.fullName || profile?._repositoryFullName || "").toLowerCase(), + branch: String(workload?.metadata?.branch || profile?.branch || repository?.defaultBranch || "").toLowerCase(), + serverId: String(workload?.serverId || profile?.serverId || ""), + environment: String(profile?.environment || "production").toLowerCase(), + composeProject: String(workload?.compose?.project || profile?.composeProject || "").toLowerCase(), + deploymentRoot: String(workload?.compose?.workingDir || profile?.composeWorkingDir || workload?.remoteFolderCandidate || profile?.remoteFolder || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(), + containers: (workload?.containers || []).map((item) => String(item.id || item.name || "").toLowerCase()).sort(), + liveSha: String(workload?.metadata?.liveRevision || "").toLowerCase(), + profileId: String(profile?.id || workload?.link?.profileId || ""), + }; +} + +function evidenceHash(identity, evidence = {}) { + const stable = (value) => Array.isArray(value) ? value.map(stable) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])) : value; + return crypto.createHash("sha256").update(JSON.stringify(stable({ identity, evidence }))).digest("hex"); +} + +function authorityKey(identity) { + return [identity.repository, identity.serverId, identity.environment].join("|"); +} + +module.exports = { canonicalDeploymentRemote: canonicalRemote, deploymentIdentity, deploymentEvidenceHash: evidenceHash, deploymentAuthorityKey: authorityKey }; diff --git a/src/main/deployment-service.cjs b/src/main/deployment-service.cjs new file mode 100644 index 0000000..7e72474 --- /dev/null +++ b/src/main/deployment-service.cjs @@ -0,0 +1,427 @@ +'use strict'; + +const crypto = require('node:crypto'); +const { assertDeploymentRequest, assertFullCommitSha, assertHttpUrl } = require('../shared/validation.cjs'); +const { redactSecrets } = require('./log-redaction.cjs'); + +const TERMINAL_STATUSES = new Set(['success', 'failed', 'cancelled', 'rolled-back']); + +function applicationVerificationFailure(operation, state) { + if (!state?.statusConfigured) return { stage: 'version-verification', message: 'No server status endpoint is configured.' }; + if (!state.statusReachable) return { stage: 'version-verification', message: state.error || 'The server status endpoint is not reachable.' }; + if (!state.statusRepository) return { stage: 'version-verification', message: 'The server status endpoint did not identify its repository.' }; + if (state.statusRepository !== operation.repository) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusRepository}, not ${operation.repository}.` }; + if (!state.statusEnvironment) return { stage: 'version-verification', message: 'The server status endpoint did not identify its environment.' }; + if (state.statusEnvironment !== operation.environment) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusEnvironment}, not ${operation.environment}.` }; + if (!state.liveSha) return { stage: 'version-verification', message: 'The server status endpoint did not return a valid full commit SHA.' }; + if (state.liveSha !== operation.sha) return { stage: 'version-verification', message: `Server reports ${state.liveSha.slice(0, 7)} instead of ${operation.shortSha}.` }; + if (!state.requestedSha) return { stage: 'version-verification', message: 'The server status endpoint did not return the requested commit SHA.' }; + if (state.requestedSha !== operation.sha) return { stage: 'version-verification', message: 'The server status document was created for a different requested commit.' }; + if (!state.requestId) return { stage: 'version-verification', message: 'The server status endpoint did not return the deployment request ID.' }; + if (state.requestId !== operation.id) return { stage: 'version-verification', message: 'The server status belongs to a different deployment request.' }; + if (state.lastExitCode !== 0) return { stage: 'server-command', message: `The server deployment command reported exit code ${state.lastExitCode ?? 'unknown'}.` }; + if (state.healthy !== true) return { stage: 'healthcheck', message: state.error || `The server did not report a healthy application state (${state.healthStatus || 'unknown'}).` }; + return null; +} + +function terminalRunConclusion(run) { + const value = String(run?.conclusion || run?.status || '').toLowerCase(); + if (['success'].includes(value)) return 'success'; + if (['failure', 'failed', 'timed_out', 'startup_failure'].includes(value)) return 'failed'; + if (['cancelled', 'canceled', 'skipped'].includes(value)) return 'cancelled'; + return null; +} + +function isRunningStatus(value) { + return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase()); +} + +class DeploymentService { + constructor(store, giteaService, gitService, diagnostics = null) { + this.store = store; + this.gitea = giteaService; + this.git = gitService; + this.diagnostics = diagnostics; + this.refreshLocks = new Set(); + } + + splitRepository(fullName) { + const [owner, repo, ...unexpected] = String(fullName || '').split('/'); + if (!owner || !repo || unexpected.length) throw new Error('Invalid Gitea repository identity.'); + return { owner, repo }; + } + + makeStages() { + return [ + { id: 'requested', label: 'Requested', status: 'complete' }, + { id: 'verified', label: 'Verified', status: 'complete' }, + { id: 'queued', label: 'Workflow queued', status: 'active' }, + { id: 'runner', label: 'Runner execution', status: 'pending' }, + { id: 'healthcheck', label: 'Healthcheck', status: 'pending' }, + { id: 'complete', label: 'Complete', status: 'pending' } + ]; + } + + setStage(operation, id, status) { + const stage = operation.stages?.find((item) => item.id === id); + if (stage) stage.status = status; + } + + appendLog(operation, line) { + const clean = redactSecrets(line, [this.store.getToken()]); + operation.logs = Array.isArray(operation.logs) ? operation.logs : []; + if (operation.logs.at(-1) !== clean) operation.logs.push(clean); + operation.logs = operation.logs.slice(-1000); + } + + async captureBaselineRunIds(owner, repo, branch, operation) { + try { + const result = await this.gitea.listWorkflowRuns({ owner, repo, branch, limit: 50 }); + const ids = (result.runs || []).map((run) => run.id).filter((id) => id !== null && id !== undefined).map(String); + operation.baselineRunIds = [...new Set(ids)].slice(0, 100); + this.appendLog(operation, `[info] Captured ${operation.baselineRunIds.length} existing Actions run identifier(s) before dispatch.`); + } catch (error) { + operation.baselineRunIds = []; + this.appendLog(operation, `[warning] Could not capture the pre-dispatch run baseline: ${error.message}`); + } + } + + async validateDeploy(repository, profile, sha) { + assertDeploymentRequest(profile, sha); + const localStatus = await this.git.status(repository.localPath); + if (localStatus.head !== sha) throw new Error('The selected commit no longer matches the local repository. Refresh before deploying.'); + if (localStatus.branch.head !== profile.branch) throw new Error(`This profile only allows deployments from ${profile.branch}.`); + if (localStatus.counts.changed) throw new Error('Commit local changes before deploying.'); + if (localStatus.branch.ahead) throw new Error('Push all local commits before deploying.'); + if (localStatus.branch.behind) throw new Error('Synchronize with Gitea before deploying.'); + if (!localStatus.branch.upstream) throw new Error('Publish this branch to Gitea before deploying.'); + await this.git.verifyCommitOnRemoteBranch(repository.localPath, sha, profile.branch); + return localStatus; + } + + async deploy({ repository, profileId, sha }) { + if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for deployment.'); + const profile = this.store.getDeploymentProfile(repository.fullName, profileId); + const fullSha = assertFullCommitSha(sha); + await this.validateDeploy(repository, profile, fullSha); + const { owner, repo } = this.splitRepository(repository.fullName); + + const operation = { + id: crypto.randomUUID(), + type: 'deployment', + action: 'deploy', + status: 'requested', + repository: repository.fullName, + profileId, + profileName: profile.name, + environment: profile.environment, + workflowFile: profile.workflowFile, + branch: profile.branch, + sha: fullSha, + shortSha: fullSha.slice(0, 7), + dispatchedAt: new Date().toISOString(), + stages: this.makeStages(), + logs: [ + `[info] Verified clean ${profile.branch} at ${fullSha}`, + `[info] Dispatching ${profile.workflowFile} for ${repository.fullName}` + ] + }; + await this.captureBaselineRunIds(owner, repo, profile.branch, operation); + await this.store.addOperation(operation); + await this.diagnostics?.info('deployment.dispatch.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile }); + + try { + await this.gitea.dispatchWorkflow({ + owner, + repo, + workflowFile: profile.workflowFile, + ref: profile.branch, + inputs: { environment: profile.environment, commit_sha: fullSha, request_id: operation.id } + }); + operation.status = 'queued'; + this.appendLog(operation, '[ok] Gitea accepted the workflow dispatch request.'); + this.appendLog(operation, '[info] Resolving the corresponding Actions run…'); + const saved = await this.store.addOperation(operation); + await this.diagnostics?.info('deployment.dispatch.accepted', { operationId: operation.id, repository: operation.repository, status: operation.status }); + return saved; + } catch (error) { + operation.status = 'failed'; + this.setStage(operation, 'queued', 'failed'); + operation.failure = { stage: 'dispatch', message: error.message }; + this.appendLog(operation, `[error] ${error.message}`); + await this.store.addOperation(operation); + await this.diagnostics?.error('deployment.dispatch.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status }); + throw error; + } + } + + async rollback({ repository, profileId, targetSha }) { + if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for rollback.'); + const profile = this.store.getDeploymentProfile(repository.fullName, profileId); + if (!profile) throw new Error('Deployment profile not found.'); + if (!profile.rollbackWorkflowFile) throw new Error('No rollback workflow is configured for this profile.'); + const fullSha = assertFullCommitSha(targetSha); + assertDeploymentRequest({ ...profile, workflowFile: profile.rollbackWorkflowFile }, fullSha); + const state = await this.refreshProfileState(repository.fullName, profileId); + if (!state.statusReachable) throw new Error(state.error || 'The server status endpoint must be reachable before rollback.'); + if (state.statusRepository !== repository.fullName || state.statusEnvironment !== profile.environment) throw new Error('The status endpoint does not match this repository and environment.'); + if (!state.previousSha) throw new Error('The server status endpoint does not report a previous version.'); + if (state.previousSha !== fullSha) throw new Error('The requested rollback SHA is no longer the previous server version. Refresh the environment state.'); + if (state.liveSha === fullSha) throw new Error('The requested rollback version is already live.'); + await this.git.verifyCommitOnRemoteBranch(repository.localPath, fullSha, profile.branch); + const { owner, repo } = this.splitRepository(repository.fullName); + + const operation = { + id: crypto.randomUUID(), + type: 'deployment', + action: 'rollback', + status: 'requested', + repository: repository.fullName, + profileId, + profileName: profile.name, + environment: profile.environment, + workflowFile: profile.rollbackWorkflowFile, + branch: profile.branch, + sha: fullSha, + shortSha: fullSha.slice(0, 7), + dispatchedAt: new Date().toISOString(), + stages: this.makeStages(), + logs: [ + `[warning] Rollback target verified on origin/${profile.branch}: ${fullSha}`, + `[info] Dispatching ${profile.rollbackWorkflowFile}` + ] + }; + await this.captureBaselineRunIds(owner, repo, profile.branch, operation); + await this.store.addOperation(operation); + await this.diagnostics?.info('deployment.rollback.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile }); + + try { + await this.gitea.dispatchWorkflow({ + owner, + repo, + workflowFile: profile.rollbackWorkflowFile, + ref: profile.branch, + inputs: { environment: profile.environment, target_sha: fullSha, request_id: operation.id } + }); + operation.status = 'queued'; + this.appendLog(operation, '[ok] Gitea accepted the rollback request.'); + const saved = await this.store.addOperation(operation); + await this.diagnostics?.info('deployment.rollback.accepted', { operationId: operation.id, repository: operation.repository }); + return saved; + } catch (error) { + operation.status = 'failed'; + this.setStage(operation, 'queued', 'failed'); + operation.failure = { stage: 'dispatch', message: error.message }; + this.appendLog(operation, `[error] ${error.message}`); + await this.store.addOperation(operation); + await this.diagnostics?.error('deployment.rollback.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status }); + throw error; + } + } + + mapJobsToStages(operation, jobs) { + operation.jobs = jobs; + if (!jobs.length) return; + const running = jobs.some((job) => isRunningStatus(job.status)); + const failed = jobs.some((job) => terminalRunConclusion(job) === 'failed'); + const allDone = jobs.every((job) => terminalRunConclusion(job)); + this.setStage(operation, 'queued', 'complete'); + this.setStage(operation, 'runner', failed ? 'failed' : allDone ? 'complete' : running ? 'active' : 'pending'); + } + + async refreshOperation(operationId) { + if (this.refreshLocks.has(operationId)) return this.store.getOperation(operationId); + const operation = this.store.getOperation(operationId); + if (!operation || operation.type !== 'deployment') throw new Error('Deployment operation not found.'); + if (TERMINAL_STATUSES.has(operation.status)) return operation; + + this.refreshLocks.add(operationId); + try { + const profile = this.store.getDeploymentProfile(operation.repository, operation.profileId); + if (!profile) throw new Error('The deployment profile used by this operation no longer exists.'); + const { owner, repo } = this.splitRepository(operation.repository); + const found = await this.gitea.findWorkflowRun({ + owner, + repo, + sha: operation.sha, + branch: operation.branch, + workflowFile: operation.workflowFile, + dispatchedAt: operation.dispatchedAt || operation.createdAt, + excludeRunIds: operation.baselineRunIds || [] + }); + + if (!found.run) { + operation.status = 'queued'; + this.setStage(operation, 'queued', 'active'); + this.appendLog(operation, '[info] Workflow is queued or not visible through the Actions API yet.'); + return await this.store.addOperation(operation); + } + + operation.run = { ...found.run, source: found.source }; + operation.runUrl = found.run.htmlUrl || `${this.store.data.gitea.baseUrl}/${operation.repository}/actions/runs/${found.run.runNumber}`; + this.setStage(operation, 'queued', 'complete'); + const runConclusion = terminalRunConclusion(found.run); + if (!runConclusion) { + operation.status = isRunningStatus(found.run.status) ? 'running' : 'queued'; + this.setStage(operation, 'runner', operation.status === 'running' ? 'active' : 'pending'); + } + + try { + const jobs = await this.gitea.listWorkflowJobs({ owner, repo, runNumber: found.run.runNumber }); + this.mapJobsToStages(operation, jobs); + for (const job of jobs) { + const conclusion = job.conclusion || job.status; + this.appendLog(operation, `[job] ${job.name}: ${conclusion}`); + } + // Raw runner output is intentionally not ingested or persisted. Open the trusted Gitea run for full logs. + } catch (error) { + this.appendLog(operation, `[warning] Job details unavailable: ${error.message}`); + } + + if (runConclusion === 'success') { + this.setStage(operation, 'runner', 'complete'); + this.setStage(operation, 'healthcheck', 'active'); + const state = await this.refreshProfileState(operation.repository, operation.profileId, { expectedSha: operation.sha }); + operation.applicationState = state; + const verificationFailure = applicationVerificationFailure(operation, state); + if (verificationFailure) { + operation.status = 'failed'; + this.setStage(operation, 'healthcheck', 'failed'); + this.setStage(operation, 'complete', 'failed'); + operation.failure = verificationFailure; + this.appendLog(operation, `[error] ${verificationFailure.message}`); + } else { + operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success'; + this.setStage(operation, 'healthcheck', 'complete'); + this.setStage(operation, 'complete', 'complete'); + this.appendLog(operation, `[ok] ${operation.action === 'rollback' ? 'Rollback' : 'Deployment'} completed successfully.`); + } + } else if (runConclusion === 'failed' || runConclusion === 'cancelled') { + operation.status = runConclusion; + this.setStage(operation, 'runner', runConclusion === 'failed' ? 'failed' : 'cancelled'); + this.setStage(operation, 'healthcheck', 'skipped'); + this.setStage(operation, 'complete', runConclusion === 'failed' ? 'failed' : 'cancelled'); + operation.failure = { stage: 'runner', message: `Gitea Actions finished with ${runConclusion}.` }; + this.appendLog(operation, `[error] ${operation.failure.message}`); + } + + const saved = await this.store.addOperation(operation); + if (TERMINAL_STATUSES.has(operation.status)) { + await this.diagnostics?.info('deployment.operation.terminal', { operationId: operation.id, repository: operation.repository, status: operation.status, failure: operation.failure || null, applicationState: operation.applicationState || null }); + } else { + await this.diagnostics?.debug('deployment.operation.refreshed', { operationId: operation.id, repository: operation.repository, status: operation.status, run: operation.run ? { id: operation.run.id, runNumber: operation.run.runNumber, status: operation.run.status, conclusion: operation.run.conclusion } : null }); + } + return saved; + } catch (error) { + operation.pollError = error.message; + this.appendLog(operation, `[warning] Status refresh failed: ${error.message}`); + await this.diagnostics?.warning('deployment.operation.poll-failed', { operationId: operation.id, repository: operation.repository, message: error.message }); + return await this.store.addOperation(operation); + } finally { + this.refreshLocks.delete(operationId); + } + } + + async refreshActiveOperations() { + const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status)); + const queue = active.slice(0, 20); + const results = []; + const workers = Array.from({ length: Math.min(4, queue.length) }, async () => { + while (queue.length) { + const operation = queue.shift(); + results.push(await this.refreshOperation(operation.id)); + } + }); + await Promise.all(workers); + return results; + } + + async checkHealth(url) { + if (!url) return { configured: false, healthy: null }; + const normalized = assertHttpUrl(url, { label: 'Healthcheck URL' }); + const started = Date.now(); + try { + const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json, text/plain, */*' } }); + return { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started }; + } catch (error) { + return { configured: true, healthy: false, error: error.message, latencyMs: Date.now() - started }; + } + } + + async readStatusEndpoint(url) { + if (!url) return { configured: false }; + const normalized = assertHttpUrl(url, { label: 'Application status URL' }); + const started = Date.now(); + try { + const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json' } }); + if (!response.ok) return { configured: true, reachable: true, ok: false, status: response.status, latencyMs: Date.now() - started }; + const payload = await response.json(); + const liveSha = payload.commit_sha || payload.commitSha || payload.sha || payload.version?.commit_sha || payload.version?.sha || null; + const previousSha = payload.previous_sha || payload.previousSha || payload.previous?.sha || null; + const requestId = payload.request_id || payload.requestId || null; + const requestedSha = payload.requested_sha || payload.requestedSha || null; + const repository = payload.repository || null; + const environment = payload.environment || null; + const rawExitCode = payload.last_exit_code ?? payload.lastExitCode ?? null; + return { + configured: true, + reachable: true, + ok: true, + status: response.status, + latencyMs: Date.now() - started, + liveSha: /^[a-f0-9]{40,64}$/i.test(String(liveSha || '')) ? String(liveSha).toLowerCase() : null, + previousSha: /^[a-f0-9]{40,64}$/i.test(String(previousSha || '')) ? String(previousSha).toLowerCase() : null, + requestId: typeof requestId === 'string' ? requestId.slice(0, 100) : null, + requestedSha: /^[a-f0-9]{40,64}$/i.test(String(requestedSha || '')) ? String(requestedSha).toLowerCase() : null, + repository: typeof repository === 'string' ? repository.slice(0, 200) : null, + environment: typeof environment === 'string' ? environment.slice(0, 64).toLowerCase() : null, + lastExitCode: rawExitCode !== null && rawExitCode !== '' && Number.isInteger(Number(rawExitCode)) ? Number(rawExitCode) : null, + deployedAt: payload.deployed_at || payload.deployedAt || null, + health: payload.health || payload.status || null, + payload + }; + } catch (error) { + return { configured: true, reachable: false, ok: false, error: error.message, latencyMs: Date.now() - started }; + } + } + + async refreshProfileState(fullName, profileId, { expectedSha = null } = {}) { + const profile = this.store.getDeploymentProfile(fullName, profileId); + if (!profile) throw new Error('Deployment profile not found.'); + const [status, health] = await Promise.all([ + this.readStatusEndpoint(profile.statusUrl), + this.checkHealth(profile.healthcheckUrl) + ]); + const state = { + profileId, + repository: fullName, + environment: profile.environment, + liveSha: status.liveSha || null, + previousSha: status.previousSha || null, + deployedAt: status.deployedAt || null, + statusConfigured: Boolean(status.configured), + statusReachable: status.configured ? Boolean(status.reachable && status.ok) : null, + statusCode: status.status || null, + statusRepository: status.repository || null, + statusEnvironment: status.environment || null, + requestedSha: status.requestedSha || null, + lastExitCode: status.lastExitCode, + healthConfigured: Boolean(health.configured), + healthy: health.configured + ? Boolean(health.healthy) + : (['healthy', 'ok', 'success', 'ready'].includes(String(status.health || '').toLowerCase()) + ? true + : (['unhealthy', 'failed', 'error', 'degraded'].includes(String(status.health || '').toLowerCase()) ? false : null)), + healthStatus: health.status || status.health || null, + latencyMs: health.latencyMs ?? status.latencyMs ?? null, + expectedSha: expectedSha || null, + requestId: status.requestId || null, + versionMatches: expectedSha && status.liveSha ? status.liveSha === expectedSha : null, + error: health.error || status.error || null, + checkedAt: new Date().toISOString() + }; + return this.store.saveDeploymentState(profileId, state); + } +} + +module.exports = { DeploymentService, TERMINAL_STATUSES, terminalRunConclusion, applicationVerificationFailure }; diff --git a/src/main/diagnostics-service.cjs b/src/main/diagnostics-service.cjs new file mode 100644 index 0000000..817cd5e --- /dev/null +++ b/src/main/diagnostics-service.cjs @@ -0,0 +1,385 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { createZip } = require('../shared/zip-writer.cjs'); +const { sanitizeForDiagnostics } = require('./log-redaction.cjs'); + +const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 }; + +function dateKey(value = new Date()) { + return value.toISOString().slice(0, 10); +} + +function byteSizeLabel(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function safeJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function auditBundleEntries(entries, secrets = []) { + const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))]; + const findings = []; + for (const entry of entries) { + const text = Buffer.isBuffer(entry.data) ? entry.data.toString('utf8') : String(entry.data ?? ''); + for (const secret of candidates) { + if (text.includes(secret)) findings.push({ file: entry.name, type: 'known-runtime-secret' }); + } + if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(text)) findings.push({ file: entry.name, type: 'private-key-marker' }); + if (/https?:\/\/[^\s:@/]+:(?!\[REDACTED\])[^@\s/]+@/i.test(text)) findings.push({ file: entry.name, type: 'url-credential' }); + } + return { passed: findings.length === 0, checkedFiles: entries.length, knownRuntimeSecretCount: candidates.length, findings }; +} + +class DiagnosticsService { + constructor({ userDataPath, appInfo = {}, secretProvider = () => [], preferencesProvider = () => ({}) }) { + this.userDataPath = userDataPath; + this.logDirectory = path.join(userDataPath, 'diagnostics'); + this.appInfo = appInfo; + this.secretProvider = secretProvider; + this.preferencesProvider = preferencesProvider; + this.sessionId = crypto.randomUUID(); + this.writeChain = Promise.resolve(); + this.pendingLines = []; + this.pendingFlush = null; + this.securedFiles = new Set(); + this.initialized = false; + this.lastWriteError = null; + this.lastBundlePath = null; + } + + preferences() { + const source = this.preferencesProvider?.() || {}; + return { + enabled: source.diagnosticsEnabled !== false, + level: ['debug', 'info', 'warning', 'error'].includes(source.diagnosticLevel) ? source.diagnosticLevel : 'info', + retentionDays: Math.min(Math.max(Number(source.logRetentionDays) || 14, 1), 90), + maxFileMb: Math.min(Math.max(Number(source.maxLogFileMb) || 8, 1), 50) + }; + } + + sanitize(value, options = {}) { + return sanitizeForDiagnostics(value, { + secrets: this.secretProvider?.() || [], + homeDir: os.homedir(), + cwd: process.cwd(), + ...options + }); + } + + async initialize() { + await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 }); + try { await fs.chmod(this.logDirectory, 0o700); } catch {} + this.initialized = true; + await this.prune(); + await this.info('diagnostics.session.started', { + sessionId: this.sessionId, + app: this.appInfo, + platform: process.platform, + arch: process.arch, + node: process.versions.node, + electron: process.versions.electron || null + }); + } + + shouldWrite(level) { + const preferences = this.preferences(); + return preferences.enabled && LEVELS[level] >= LEVELS[preferences.level]; + } + + filePathForToday() { + return path.join(this.logDirectory, `forgeflow-${dateKey()}.jsonl`); + } + + async rotateIfNeeded(filePath) { + const limit = this.preferences().maxFileMb * 1024 * 1024; + const stat = await fs.stat(filePath).catch(() => null); + if (!stat || stat.size < limit) return filePath; + for (let index = 1; index < 100; index += 1) { + const candidate = path.join(this.logDirectory, `forgeflow-${dateKey()}-${String(index).padStart(2, '0')}.jsonl`); + const candidateStat = await fs.stat(candidate).catch(() => null); + if (!candidateStat || candidateStat.size < limit) return candidate; + } + return path.join(this.logDirectory, `forgeflow-${dateKey()}-${Date.now()}.jsonl`); + } + + log(level, event, details = {}) { + if (!this.shouldWrite(level)) return Promise.resolve(false); + const record = this.sanitize({ + timestamp: new Date().toISOString(), + level, + event: String(event || 'diagnostics.event').slice(0, 160), + sessionId: this.sessionId, + details + }); + this.pendingLines.push(`${JSON.stringify(record)}\n`); + // At the debug level every IPC call and every Gitea request writes a line. + // Records that queue up while a write is in flight are appended together, so + // a burst costs one open/write/close instead of one per record. + if (this.pendingFlush) return this.pendingFlush; + this.pendingFlush = this.writeChain.then(async () => { + this.pendingFlush = null; + const lines = this.pendingLines.splice(0).join(''); + if (!lines) return true; + try { + if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 }); + const target = await this.rotateIfNeeded(this.filePathForToday()); + await fs.appendFile(target, lines, { encoding: 'utf8', mode: 0o600 }); + // The mode above only applies when appendFile creates the file, so the + // explicit chmod is needed once per file rather than once per record. + if (!this.securedFiles.has(target)) { + try { await fs.chmod(target, 0o600); } catch { /* best effort */ } + this.securedFiles.add(target); + } + this.lastWriteError = null; + return true; + } catch (error) { + this.lastWriteError = error.message; + return false; + } + }); + this.writeChain = this.pendingFlush.catch(() => {}); + return this.pendingFlush; + } + + debug(event, details) { return this.log('debug', event, details); } + info(event, details) { return this.log('info', event, details); } + warning(event, details) { return this.log('warning', event, details); } + error(event, details) { return this.log('error', event, details); } + + async flush() { + await this.writeChain; + } + + async listLogFiles() { + await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 }); + const entries = await fs.readdir(this.logDirectory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (!entry.isFile() || !/^forgeflow-.*\.jsonl$/i.test(entry.name)) continue; + const absolute = path.join(this.logDirectory, entry.name); + const stat = await fs.stat(absolute).catch(() => null); + if (stat) files.push({ name: entry.name, path: absolute, size: stat.size, modifiedAt: stat.mtime.toISOString() }); + } + return files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)); + } + + async prune() { + const cutoff = Date.now() - this.preferences().retentionDays * 24 * 60 * 60 * 1000; + for (const file of await this.listLogFiles()) { + if (new Date(file.modifiedAt).getTime() < cutoff) await fs.rm(file.path, { force: true }).catch(() => {}); + } + } + + async getStatus() { + await this.flush(); + const files = await this.listLogFiles(); + const totalBytes = files.reduce((sum, file) => sum + file.size, 0); + return { + enabled: this.preferences().enabled, + level: this.preferences().level, + retentionDays: this.preferences().retentionDays, + maxFileMb: this.preferences().maxFileMb, + directory: this.sanitize(this.logDirectory), + fileCount: files.length, + totalBytes, + totalSize: byteSizeLabel(totalBytes), + latestAt: files[0]?.modifiedAt || null, + lastWriteError: this.lastWriteError + }; + } + + async clear() { + await this.flush(); + for (const file of await this.listLogFiles()) await fs.rm(file.path, { force: true }); + await this.info('diagnostics.logs.cleared', {}); + return this.getStatus(); + } + + async collectLogs(maxBytes = 20 * 1024 * 1024, { strictIdentifiers = false } = {}) { + await this.flush(); + const output = []; + let used = 0; + for (const file of await this.listLogFiles()) { + if (used >= maxBytes) break; + const remaining = maxBytes - used; + const content = await fs.readFile(file.path); + const slice = content.length > remaining ? content.subarray(content.length - remaining) : content; + output.push({ + name: `logs/${file.name}`, + data: Buffer.from( + sanitizeForDiagnostics(slice.toString('utf8'), { + secrets: this.secretProvider?.() || [], + strictIdentifiers, + }), + 'utf8', + ), + }); + used += slice.length; + } + return output; + } + + async exportSupportBundle({ destinationPath, publicState, repositories = [], operations = [], preflight = null, privacyMode = 'standard', extra = {} }) { + if (!destinationPath) throw new Error('No support bundle destination was selected.'); + if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.'); + if (path.extname(destinationPath).toLowerCase() !== '.zip') throw new Error('Diagnostic bundles must use the .zip extension.'); + await this.info('diagnostics.bundle.requested', { privacyMode, repositoryCount: repositories.length, operationCount: operations.length }); + const strict = privacyMode === 'strict'; + const sanitize = (value) => this.sanitize(value, { strictIdentifiers: strict }); + const generatedAt = new Date().toISOString(); + const diagnosticsStatus = await this.getStatus(); + const system = sanitize({ + app: this.appInfo, + generatedAt, + sessionId: this.sessionId, + platform: process.platform, + arch: process.arch, + release: os.release(), + type: os.type(), + cpus: os.cpus()?.map((cpu) => cpu.model).filter((value, index, array) => array.indexOf(value) === index), + cpuCount: os.cpus()?.length || null, + totalMemoryBytes: os.totalmem(), + freeMemoryBytes: os.freemem(), + uptimeSeconds: os.uptime(), + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + versions: process.versions + }); + + const sanitizedState = sanitize(publicState || {}); + if (sanitizedState.gitea) sanitizedState.gitea.hasToken = Boolean(publicState?.gitea?.hasToken); + const sanitizedRepositories = sanitize(repositories.map((repository) => ({ + id: repository.id, + fullName: repository.fullName, + linkState: repository.linkState, + localPath: repository.localPath, + attention: repository.attention, + attentionReason: repository.attentionReason, + readyToDeploy: repository.readyToDeploy, + localStatus: repository.localStatus ? { + branch: repository.localStatus.branch, + head: repository.localStatus.head, + counts: repository.localStatus.counts, + clean: repository.localStatus.clean, + remoteUrl: repository.localStatus.remoteUrl + } : null, + deploymentProfiles: repository.deploymentProfiles?.map((profile) => ({ + id: profile.id, + name: profile.name, + environment: profile.environment, + branch: profile.branch, + workflowFile: profile.workflowFile, + rollbackWorkflowFile: profile.rollbackWorkflowFile, + healthcheckUrl: profile.healthcheckUrl, + statusUrl: profile.statusUrl, + state: profile.state + })) || [] + }))); + const sanitizedOperations = sanitize(operations.map((operation) => ({ + id: operation.id, + type: operation.type, + action: operation.action, + status: operation.status, + repository: operation.repository, + profileId: operation.profileId, + profileName: operation.profileName, + environment: operation.environment, + workflowFile: operation.workflowFile, + branch: operation.branch, + sha: operation.sha, + shortSha: operation.shortSha, + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + dispatchedAt: operation.dispatchedAt, + stages: operation.stages, + jobs: operation.jobs, + remoteOutput: operation.logs || operation.failure || operation.pollError ? { + included: false, + reason: 'Remote build and command output is intentionally omitted because it may contain application secrets unknown to ForgeFlow.', + logCharacters: String(operation.logs || '').length, + failureRecorded: Boolean(operation.failure), + pollErrorRecorded: Boolean(operation.pollError) + } : null, + applicationState: operation.applicationState, + run: operation.run ? { + id: operation.run.id, + runNumber: operation.run.runNumber, + name: operation.run.name, + status: operation.run.status, + conclusion: operation.run.conclusion, + headSha: operation.run.headSha, + headBranch: operation.run.headBranch, + workflowPath: operation.run.workflowPath, + createdAt: operation.run.createdAt, + updatedAt: operation.run.updatedAt + } : null, + runnerLog: operation.runnerLog ? { + included: false, + reason: 'Raw runner output is intentionally omitted from diagnostic bundles.', + characters: String(operation.runnerLog).length, + lines: String(operation.runnerLog).split(/\r?\n/).length + } : null + }))); + const manifest = { + schemaVersion: 1, + product: 'ForgeFlow Support Bundle', + generatedAt, + privacyMode, + containsSecrets: false, + redaction: { + knownRuntimeSecrets: true, + sensitiveObjectKeys: true, + authorizationHeaders: true, + credentialUrls: true, + privateKeys: true, + userHomePaths: true, + identifiersHashed: strict + }, + files: [] + }; + + const entries = [ + { name: 'README.txt', data: `ForgeFlow diagnostic support bundle\nGenerated: ${generatedAt}\nPrivacy mode: ${privacyMode}\n\nThis bundle is generated locally. Access tokens, passwords, authorization headers, embedded URL credentials, encrypted token blobs and private keys are removed. Review the bundle before sharing it.\n` }, + { name: 'system.json', data: safeJson(system) }, + { name: 'diagnostics-status.json', data: safeJson(sanitize(diagnosticsStatus)) }, + { name: 'configuration-sanitized.json', data: safeJson(sanitizedState) }, + { name: 'repositories-sanitized.json', data: safeJson(sanitizedRepositories) }, + { name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) }, + { name: 'preflight.json', data: safeJson(sanitize(preflight || {})) }, + { name: 'context.json', data: safeJson(sanitize(extra || {})) }, + ...(await this.collectLogs(20 * 1024 * 1024, { strictIdentifiers: strict })) + ]; + + const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []); + if (!safetyAudit.passed) { + await this.error('diagnostics.bundle.safety-check-failed', { findings: safetyAudit.findings }); + throw new Error('The diagnostic bundle failed its local secret-safety check and was not written.'); + } + entries.push({ name: 'safety-audit.json', data: safeJson(safetyAudit) }); + manifest.files = entries.map((entry) => ({ name: entry.name, bytes: Buffer.byteLength(entry.data) })); + entries.unshift({ name: 'manifest.json', data: safeJson(manifest) }); + const archive = createZip(entries); + const temporary = `${destinationPath}.${process.pid}.${Date.now()}.tmp`; + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + await fs.writeFile(temporary, archive, { mode: 0o600 }); + await fs.rename(temporary, destinationPath); + try { await fs.chmod(destinationPath, 0o600); } catch {} + this.lastBundlePath = path.resolve(destinationPath); + const sha256 = crypto.createHash('sha256').update(archive).digest('hex'); + await this.info('diagnostics.bundle.created', { destinationPath, bytes: archive.length, sha256, privacyMode }); + return { path: destinationPath, bytes: archive.length, size: byteSizeLabel(archive.length), sha256, privacyMode, generatedAt }; + } + + isKnownBundlePath(filePath) { + return Boolean(filePath && this.lastBundlePath && path.resolve(filePath) === this.lastBundlePath); + } +} + +module.exports = { DiagnosticsService, dateKey, byteSizeLabel, auditBundleEntries, LEVELS }; diff --git a/src/main/external-tools-service.cjs b/src/main/external-tools-service.cjs new file mode 100644 index 0000000..0c2c78a --- /dev/null +++ b/src/main/external-tools-service.cjs @@ -0,0 +1,51 @@ +'use strict'; + +const { spawn } = require('node:child_process'); +const path = require('node:path'); + +const TOOL_PROFILES = Object.freeze({ + editor: Object.freeze({ + code: ['--reuse-window', '--goto', '{file}:{line}'], + 'code.exe': ['--reuse-window', '--goto', '{file}:{line}'], + codium: ['--reuse-window', '--goto', '{file}:{line}'], + 'codium.exe': ['--reuse-window', '--goto', '{file}:{line}'], + }), + terminal: Object.freeze({ + wt: ['-d', '{path}'], + 'wt.exe': ['-d', '{path}'], + }), +}); + +function normalizeTool(tool, defaults, kind) { + const source = tool && typeof tool === 'object' ? tool : {}; + const executable = String(source.executable || defaults.executable).trim(); + if (!executable || /[\r\n\0]/.test(executable)) throw new Error('Tool executable is invalid.'); + const profile = TOOL_PROFILES[kind]?.[executable.toLowerCase()]; + if (!profile) throw new Error(`Unsupported ${kind || 'external'} tool. Select a built-in trusted tool profile.`); + return { executable, args: [...profile] }; +} + +function expandTool(tool, context) { + const values = { path: context.path, file: context.file || context.path, line: String(context.line || 1) }; + return { executable: tool.executable, args: tool.args.map((argument) => argument.replace(/\{(path|file|line)\}/g, (_, key) => values[key])) }; +} + +class ExternalToolsService { + constructor(store) { this.store = store; } + + launch(kind, repositoryPath, filePath = '', line = 1) { + const root = path.resolve(repositoryPath); + const candidate = filePath ? path.resolve(root, filePath) : root; + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('External tool target escapes the repository.'); + const defaults = kind === 'terminal' + ? { executable: 'wt.exe', args: ['-d', '{path}'] } + : { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] }; + const configured = normalizeTool(this.store.data.preferences?.[kind], defaults, kind); + const invocation = expandTool(configured, { path: root, file: candidate, line }); + const child = spawn(invocation.executable, invocation.args, { cwd: root, detached: true, stdio: 'ignore', windowsHide: false, shell: false }); + child.unref(); + return { launched: true, executable: invocation.executable }; + } +} + +module.exports = { ExternalToolsService, normalizeTool, expandTool, TOOL_PROFILES }; diff --git a/src/main/git-service.cjs b/src/main/git-service.cjs new file mode 100644 index 0000000..c5c1d90 --- /dev/null +++ b/src/main/git-service.cjs @@ -0,0 +1,949 @@ +'use strict'; + +const path = require('node:path'); +const fs = require('node:fs/promises'); +const crypto = require('node:crypto'); +const { run } = require('./process-runner.cjs'); +const { parsePorcelainV2 } = require('../shared/git-status.cjs'); +const { normalizeRemoteUrl } = require('../shared/repository-match.cjs'); + +const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock']; +const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024; +const { + assertSafeRepositoryPath, + assertRepositoryRelativePath, + assertRepositoryRelativePaths, + assertCommitMessage, + assertFullCommitSha, + assertCloneRemote +} = require('../shared/validation.cjs'); + +function parseUnifiedDiff(diffText) { + const text = String(diffText || '').replace(/\r\n/g, '\n'); + const firstHunk = text.search(/^@@ /m); + if (firstHunk < 0) return { header: text, hunks: [] }; + const header = text.slice(0, firstHunk); + const hunks = text.slice(firstHunk).split(/(?=^@@ )/m).filter(Boolean).map((patch, index) => { + const heading = patch.split('\n', 1)[0]; + return { index, heading, patch, additions: (patch.match(/^\+(?!\+\+)/gm) || []).length, deletions: (patch.match(/^-(?!---)/gm) || []).length }; + }); + return { header, hunks }; +} + +function parseNameStatus(output) { + const entries = String(output || '').split('\0'); + const changes = []; + for (let index = 0; index < entries.length;) { + const rawStatus = entries[index++]; + if (!rawStatus) continue; + const code = rawStatus[0]; + if (code === 'R' || code === 'C') { + const originalPath = entries[index++] || ''; + const filePath = entries[index++] || ''; + if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath }); + continue; + } + const filePath = entries[index++] || ''; + if (!filePath) continue; + const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' }; + changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null }); + } + return changes; +} + +function parseCompactLog(output) { + return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { + const [sha, shortSha, date, subject] = record.split('\x1f'); + return { sha, shortSha, date, subject }; + }); +} + +class GitService { + constructor() { + // `git remote get-url` is only re-run when the repository configuration file + // itself changed. Status polling asks for the remote URL of every repository + // every few seconds, and on Windows the child process dominates that cost. + this.remoteUrlCache = new Map(); + } + + async isAvailable() { + try { + const result = await run('git', ['--version'], { timeout: 10_000 }); + return { available: true, version: result.stdout.trim() }; + } catch (error) { + return { available: false, version: null, error: error.message }; + } + } + + async ensureRepository(repoPath) { + const resolved = assertSafeRepositoryPath(repoPath); + const stat = await fs.stat(resolved).catch(() => null); + if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.'); + // A directory that carries its own `.git` entry is by definition the top level + // of that working tree, for plain repositories as well as for submodules and + // linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn + // that again is pure overhead, and every status poll passes an already + // resolved repository root back in. + const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null); + if (marker) return resolved; + const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 }); + return path.resolve(result.stdout.trim()); + } + + async status(repoPath) { + const root = await this.ensureRepository(repoPath); + // `--no-optional-locks` keeps a status read from refreshing and rewriting the + // index. Without it every read writes inside .git, which both fights a + // concurrent Git command for the index lock and retriggers the filesystem + // watcher that asked for this read in the first place. + const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { + cwd: root, + timeout: 30_000 + }); + const parsed = parsePorcelainV2(result.stdout); + const remoteUrl = await this.getRemoteUrl(root).catch(() => ''); + const head = parsed.branch.oid && parsed.branch.oid !== '(initial)' ? parsed.branch.oid : null; + return { ...parsed, root, remoteUrl, head, shortHead: head ? head.slice(0, 7) : null }; + } + + statusFingerprint(status) { + return JSON.stringify({ + head: status?.head || null, + branch: status?.branch || null, + files: (status?.files || []).map((file) => [file.path, file.originalPath, file.indexCode, file.worktreeCode]) + }); + } + + remoteUrlCacheKey(repoPath, remote) { + return JSON.stringify([path.resolve(repoPath), remote]); + } + + async getRemoteUrl(repoPath, remote = 'origin') { + const cacheKey = this.remoteUrlCacheKey(repoPath, remote); + const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null); + const cached = this.remoteUrlCache.get(cacheKey); + if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) { + if (cached.error) throw cached.error; + return cached.url; + } + const remember = (entry) => { + if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size }); + else this.remoteUrlCache.delete(cacheKey); + }; + try { + const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 }); + const url = result.stdout.trim(); + remember({ url, error: null }); + return url; + } catch (error) { + // A repository that has no such remote keeps failing until its configuration + // changes, so the failure is remembered too. Without this, every status poll + // of an unmatched local repository spawns a child process that cannot succeed. + remember({ url: '', error }); + throw error; + } + } + + + pathspecInput(paths) { + const selected = assertRepositoryRelativePaths(paths); + return selected.length ? `${selected.join('\0')}\0` : ''; + } + + async runWithPathspec(root, args, paths, options = {}) { + const selected = assertRepositoryRelativePaths(paths); + if (!selected.length) return run('git', args, { cwd: root, ...options }); + return run('git', [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], { + cwd: root, + input: this.pathspecInput(selected), + ...options + }); + } + + + async gitDirectory(repoPath) { + const root = await this.ensureRepository(repoPath); + const result = await run('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd: root, timeout: 15_000 }); + return { root, gitDir: path.resolve(result.stdout.trim()) }; + } + + async writeWorkspaceReviewManifest(repoPath, plan, { backupBranch = null, stash = null } = {}) { + const { root, gitDir } = await this.gitDirectory(repoPath); + const reviewId = String(plan?.id || '').trim(); + if (!/^[0-9a-f]{64}$/i.test(reviewId)) throw new Error('Workspace review manifest requires a valid synchronization plan.'); + const reviewDirectory = path.join(gitDir, 'forgeflow', 'workspace-reviews'); + await fs.mkdir(reviewDirectory, { recursive: true }); + const manifestPath = path.join(reviewDirectory, `${reviewId}.json`); + const payload = { + schemaVersion: 1, + kind: 'workspace-sync-quarantine', + id: reviewId, + status: 'pending-codex-review', + createdAt: new Date().toISOString(), + repositoryRoot: root, + branch: plan.branch, + upstream: plan.upstream, + sourceSha: plan.currentSha, + targetSha: plan.targetSha, + recoveryBranch: backupBranch, + stashRef: stash?.ref || null, + stashSha: stash?.sha || null, + files: (plan.localFiles || []).map((file) => ({ + path: file.path, + originalPath: file.originalPath || null, + status: file.status, + staged: Boolean(file.staged), + unstaged: Boolean(file.unstaged), + untracked: Boolean(file.untracked) + })), + instructions: [ + 'Review the recovery branch and quarantine stash with Codex before restoring anything.', + 'ForgeFlow recovery branches are local-only and cannot be pushed to Gitea.', + 'Restore only files that are still useful; obsolete files can be dropped after review.' + ], + manifestPath + }; + const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomUUID()}.tmp`; + await fs.writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + await fs.rename(temporaryPath, manifestPath); + return payload; + } + + isGitLockError(error) { + const message = String(error?.message || error || ''); + return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message) + || COMMON_GIT_LOCK_FILES.some((lockName) => message.toLowerCase().includes(lockName.toLowerCase())); + } + + async gitProcessProbe(root) { + if (process.platform !== 'win32') return { available: false, active: [], reason: 'process probe is Windows-only' }; + const escaped = root.replace(/'/g, "''"); + const script = `$root='${escaped}'; Get-CimInstance Win32_Process -Filter \"Name='git.exe' OR Name='git-remote-https.exe' OR Name='ssh.exe'\" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($root,[System.StringComparison]::OrdinalIgnoreCase) -ge 0 } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`; + try { + const result = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { timeout: 15_000, allowExitCodes: [1] }); + const text = result.stdout.trim(); + const parsed = text ? JSON.parse(text) : []; + return { available: true, active: Array.isArray(parsed) ? parsed : [parsed] }; + } catch (error) { + return { available: false, active: [], reason: error.message }; + } + } + + async listGitLocks(repoPath) { + const { root, gitDir } = await this.gitDirectory(repoPath); + const locks = []; + const walk = async (directory, depth = 0) => { + if (depth > 8) return; + const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + const relative = path.relative(gitDir, fullPath).replace(/\\/g, '/'); + if (entry.isDirectory()) { + const segments = relative.split('/'); + if (segments.includes('objects') || segments.includes('lfs')) continue; + await walk(fullPath, depth + 1); + } else if (entry.isFile() && entry.name.endsWith('.lock')) { + const stat = await fs.stat(fullPath).catch(() => null); + if (stat) locks.push({ + name: path.relative(gitDir, fullPath).replace(/\\/g, '/'), + lockPath: fullPath, + ageMs: Math.max(0, Date.now() - stat.mtimeMs), + size: stat.size, + modifiedAt: stat.mtime.toISOString() + }); + } + } + }; + await walk(gitDir); + const processes = await this.gitProcessProbe(root); + return { root, gitDir, locks: locks.sort((a, b) => a.name.localeCompare(b.name)), processes }; + } + + async repairStaleGitLocks(repoPath, { minimumAgeMs = 15_000, allowWithoutProcessProbe = false } = {}) { + const report = await this.listGitLocks(repoPath); + if (!report.locks.length) return { ...report, removed: [], skipped: [], repaired: false }; + if (report.processes.active.length) { + const error = new Error(`A Git-related process is still using this repository (${report.processes.active.map((item) => `${item.Name || 'process'} ${item.ProcessId || ''}`.trim()).join(', ')}). Close it before repairing locks.`); + error.code = 'GIT_PROCESS_ACTIVE'; + error.processes = report.processes.active; + throw error; + } + if (!report.processes.available && !allowWithoutProcessProbe) { + const error = new Error('ForgeFlow could not prove that no Git process is active. Use the explicit force repair only after closing Git tools for this repository.'); + error.code = 'GIT_PROCESS_PROBE_UNAVAILABLE'; + error.recoverable = true; + throw error; + } + const removed = []; + const skipped = []; + for (const lock of report.locks) { + if (lock.ageMs < minimumAgeMs) { skipped.push({ ...lock, reason: 'recent' }); continue; } + await fs.rm(lock.lockPath, { force: true }); + removed.push(lock); + } + if (!removed.length && skipped.length) { + const error = new Error('All Git lock files are recent. Wait a few seconds after closing Git tools, then scan again.'); + error.code = 'GIT_LOCKS_RECENT'; + error.recoverable = true; + throw error; + } + return { ...report, removed, skipped, repaired: removed.length > 0 }; + } + + async getIndexLockInfo(repoPath) { + const report = await this.listGitLocks(repoPath); + const lock = report.locks.find((item) => item.name === 'index.lock'); + return lock ? { exists: true, ...lock } : { exists: false, lockPath: path.join(report.gitDir, 'index.lock'), ageMs: 0 }; + } + + async removeStaleIndexLock(repoPath, minimumAgeMs = 15_000) { + const result = await this.repairStaleGitLocks(repoPath, { minimumAgeMs }); + const removed = result.removed.find((item) => item.name === 'index.lock'); + return removed ? { removed: true, ...removed } : { removed: false, reason: 'missing', ...(await this.getIndexLockInfo(repoPath)) }; + } + + async reconcile(repoPath) { + const root = await this.ensureRepository(repoPath); + await this.fetch(root).catch(() => null); + const status = await this.status(root); + const upstream = status.branch?.upstream || ''; + return { + status, + lockReport: await this.listGitLocks(root), + recommendations: [ + { id: 'fetch', label: 'Fetch and recalculate remote state', action: 'fetch', safe: true }, + ...(status.branch?.behind > 0 && status.branch?.ahead === 0 && status.clean && upstream ? [{ id: 'pull', label: `Fast-forward from ${upstream}`, action: 'fast-forward', safe: true }] : []), + ...(status.branch?.ahead > 0 && status.branch?.behind === 0 && upstream ? [{ id: 'push', label: `Push ${status.branch.ahead} local commit(s)`, action: 'push', safe: true }] : []), + ...(status.branch?.ahead > 0 && status.branch?.behind > 0 && upstream ? [ + { id: 'diverged', label: `Branch diverged (${status.branch.ahead} ahead, ${status.branch.behind} behind)`, action: null, safe: false }, + { id: 'backup-reset', label: `Create a safety branch and reset to ${upstream}`, action: 'backup-reset', safe: false } + ] : []) + ] + }; + } + + async abortInterruptedOperation(repoPath) { + const root = await this.ensureRepository(repoPath); + const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 }); + const gitDir = path.resolve(root, gitDirResult.stdout.trim()); + const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false); + let aborted = null; + if (await exists('rebase-merge') || await exists('rebase-apply')) { + await run('git', ['rebase', '--abort'], { cwd: root, timeout: 120_000 }); + aborted = 'rebase'; + } else if (await exists('MERGE_HEAD')) { + await run('git', ['merge', '--abort'], { cwd: root, timeout: 120_000 }); + aborted = 'merge'; + } else if (await exists('CHERRY_PICK_HEAD')) { + await run('git', ['cherry-pick', '--abort'], { cwd: root, timeout: 120_000 }); + aborted = 'cherry-pick'; + } else if (await exists('REVERT_HEAD')) { + await run('git', ['revert', '--abort'], { cwd: root, timeout: 120_000 }); + aborted = 'revert'; + } + return { aborted, status: await this.status(root), lockReport: await this.listGitLocks(root) }; + } + + async detectInterruptedOperation(repoPath) { + const root = await this.ensureRepository(repoPath); + const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 }); + const gitDir = path.resolve(root, gitDirResult.stdout.trim()); + const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false); + if (await exists('rebase-merge') || await exists('rebase-apply')) return 'rebase'; + if (await exists('MERGE_HEAD')) return 'merge'; + if (await exists('CHERRY_PICK_HEAD')) return 'cherry-pick'; + if (await exists('REVERT_HEAD')) return 'revert'; + return null; + } + + async repairSync(repoPath, strategy) { + const root = await this.ensureRepository(repoPath); + const requested = String(strategy || '').trim(); + if (!['fetch', 'fast-forward', 'push', 'backup-reset'].includes(requested)) throw new Error('Unsupported Git synchronization repair strategy.'); + await this.fetch(root); + let status = await this.status(root); + const branch = status.branch?.head; + const upstream = status.branch?.upstream; + if (!branch || branch === '(detached)') throw new Error('Synchronization repair requires a named local branch.'); + if (!upstream && requested !== 'fetch') throw new Error('The current branch has no upstream branch. Repair origin or publish the branch first.'); + + if (requested === 'fast-forward') { + if (!status.clean) throw new Error('Fast-forward repair requires a clean working tree. Commit or stash changes first.'); + if (status.branch.ahead > 0) throw new Error('Fast-forward repair is only safe when there are no local commits ahead of upstream.'); + await run('git', ['merge', '--ff-only', upstream], { cwd: root, timeout: 2 * 60_000 }); + } else if (requested === 'push') { + if (status.branch.behind > 0) throw new Error('Push repair is blocked because the remote branch contains commits that are not local.'); + await this.push(root); + } else if (requested === 'backup-reset') { + if (!status.clean) throw new Error('Backup-and-reset requires a clean working tree. Commit or stash changes first.'); + if (!(status.branch.ahead > 0 && status.branch.behind > 0)) throw new Error('Backup-and-reset is only offered for a diverged branch.'); + const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); + const backupBranch = `forgeflow/backup-${branch.replace(/[^A-Za-z0-9._-]/g, '-')}-${stamp}`; + await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 }); + await run('git', ['reset', '--hard', upstream], { cwd: root, timeout: 2 * 60_000 }); + status = await this.status(root); + return { strategy: requested, backupBranch, status, lockReport: await this.listGitLocks(root) }; + } + status = await this.status(root); + return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) }; + } + + async previewWorkspaceSync(repoPath) { + const root = await this.ensureRepository(repoPath); + const { status } = await this.fetch(root); + const branch = status.branch?.head; + const upstream = status.branch?.upstream; + if (!status.head || !branch || branch === '(detached)') { + const error = new Error('Workspace synchronization requires a named branch with at least one commit.'); + error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED'; + throw error; + } + if (!upstream) { + const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.'); + error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED'; + throw error; + } + + const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim(); + const changes = parseNameStatus((await run('git', [ + 'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--' + ], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout); + const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e'; + const [incomingResult, localResult, interruptedOperation] = await Promise.all([ + run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }), + run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }), + this.detectInterruptedOperation(root) + ]); + const blockers = []; + if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`); + if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`); + const summary = { + resultingTrackedChanges: changes.length, + added: changes.filter((item) => item.code === 'A').length, + modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length, + deleted: changes.filter((item) => item.code === 'D').length, + renamed: changes.filter((item) => item.code === 'R').length, + localFilesToStash: status.counts.changed, + untrackedFilesToStash: status.counts.untracked, + localCommitsToProtect: status.branch.ahead, + incomingCommits: status.branch.behind + }; + const planId = crypto.createHash('sha256').update(JSON.stringify({ + head: status.head, + targetSha, + branch, + upstream, + fingerprint: this.statusFingerprint(status) + })).digest('hex'); + return { + id: planId, + repositoryRoot: root, + branch, + upstream, + currentSha: status.head, + targetSha, + needsSync: status.head !== targetSha || !status.clean, + cleanBeforeSync: status.clean, + blockers, + summary, + changes: changes.slice(0, 250), + changesTruncated: changes.length > 250, + localFiles: status.files.slice(0, 250), + localFilesTruncated: status.files.length > 250, + incomingCommits: parseCompactLog(incomingResult.stdout), + localCommits: parseCompactLog(localResult.stdout), + recovery: { + safetyBranch: status.branch.ahead > 0, + stash: status.counts.changed > 0, + untrackedCleanup: status.counts.untracked > 0, + ignoredFilesPreserved: true + } + }; + } + + async synchronizeWorkspace(repoPath, expectedPlanId) { + const expected = String(expectedPlanId || '').trim(); + if (!/^[0-9a-f]{64}$/i.test(expected)) { + const error = new Error('Apply workspace synchronization only from a reviewed preview.'); + error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED'; + throw error; + } + const plan = await this.previewWorkspaceSync(repoPath); + if (plan.id !== expected) { + const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.'); + error.code = 'WORKSPACE_SYNC_PLAN_STALE'; + error.recoverable = true; + throw error; + } + if (plan.blockers.length) { + const error = new Error(plan.blockers.join(' ')); + error.code = 'WORKSPACE_SYNC_BLOCKED'; + error.recoverable = true; + throw error; + } + if (!plan.needsSync) { + return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] }; + } + + const root = plan.repositoryRoot; + const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); + let backupBranch = null; + let stash = null; + let review = null; + if (plan.summary.localCommitsToProtect > 0) { + const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-'); + backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`; + await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 }); + await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 }); + } + if (plan.summary.localFilesToStash > 0) { + const label = `FORGEFLOW-QUARANTINE:${plan.id} workspace sync ${plan.branch} ${stamp}`; + await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 }); + stash = (await this.stashList(root))[0] || null; + } + if (backupBranch || stash) { + review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash }); + } + + const protectedStatus = await this.status(root); + if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) { + const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.'); + error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE'; + error.recoverable = true; + error.backupBranch = backupBranch; + error.stash = stash; + throw error; + } + + await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 }); + const status = await this.status(root); + if (status.head !== plan.targetSha || !status.clean) { + const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.'); + error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED'; + error.recoverable = true; + error.backupBranch = backupBranch; + error.stash = stash; + throw error; + } + return { + applied: true, + unchanged: false, + plan, + status, + backupBranch, + stash, + review, + cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path), + ignoredFilesPreserved: true + }; + } + + async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') { + const root = await this.ensureRepository(repoPath); + const safeRemote = assertCloneRemote(remoteUrl); + const name = String(remote || 'origin').trim(); + if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.'); + await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 }); + this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name)); + return this.status(root); + } + + async diff(repoPath, filePath, staged = false) { + const root = await this.ensureRepository(repoPath); + const safeFile = filePath ? assertRepositoryRelativePath(filePath) : ''; + const args = ['diff', '--no-ext-diff', '--no-color', '--unified=4']; + if (staged) args.push('--cached'); + if (safeFile) args.push('--', safeFile); + const result = await run('git', args, { cwd: root, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); + if (!result.stdout && safeFile && !staged) { + const candidate = path.resolve(root, safeFile); + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.'); + const [realRoot, realCandidate, candidateStat] = await Promise.all([ + fs.realpath(root).catch(() => root), + fs.realpath(candidate).catch(() => candidate), + fs.stat(candidate).catch(() => null) + ]); + const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value; + const normalizedRoot = normalize(realRoot); + const normalizedCandidate = normalize(realCandidate); + if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) { + const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.'); + error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY'; + throw error; + } + if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) { + const error = new Error('The untracked file is too large to render safely as a diff.'); + error.code = 'DIFF_FILE_TOO_LARGE'; + error.recoverable = true; + throw error; + } + const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : ''; + if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`; + } + return result.stdout; + } + + async diffHunks(repoPath, filePath) { + const safeFile = assertRepositoryRelativePath(filePath); + const diff = await this.diff(repoPath, safeFile, false); + const parsed = parseUnifiedDiff(diff); + return { filePath: safeFile, partialSupported: parsed.hunks.length > 0, hunks: parsed.hunks.map(({ patch, ...hunk }) => ({ ...hunk, lines: patch.split('\n') })) }; + } + + async stageHunks(repoPath, filePath, hunkIndexes) { + const root = await this.ensureRepository(repoPath); + const safeFile = assertRepositoryRelativePath(filePath); + const indexes = [...new Set((Array.isArray(hunkIndexes) ? hunkIndexes : []).map(Number))]; + if (!indexes.length || indexes.some((index) => !Number.isInteger(index) || index < 0)) throw new Error('Select at least one valid diff hunk.'); + const parsed = parseUnifiedDiff(await this.diff(root, safeFile, false)); + if (!parsed.hunks.length) throw new Error('Partial staging is unavailable for this file. Stage the complete file instead.'); + if (indexes.some((index) => index >= parsed.hunks.length)) throw new Error('The file changed after its diff was loaded. Refresh the diff and try again.'); + const patch = `${parsed.header}${indexes.map((index) => parsed.hunks[index].patch).join('')}`; + await run('git', ['apply', '--cached', '--whitespace=nowarn', '-'], { cwd: root, input: patch, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + return this.status(root); + } + + async conflictState(repoPath) { + const root = await this.ensureRepository(repoPath); + const operation = await this.detectInterruptedOperation(root); + const result = await run('git', ['diff', '--name-only', '--diff-filter=U', '-z'], { cwd: root, timeout: 30_000 }); + const files = result.stdout.split('\0').filter(Boolean).map(assertRepositoryRelativePath); + return { operation, files, canContinue: Boolean(operation) && files.length === 0, status: await this.status(root) }; + } + + async resolveConflict(repoPath, filePath, resolution) { + const root = await this.ensureRepository(repoPath); + const safeFile = assertRepositoryRelativePath(filePath); + const choice = String(resolution || 'resolved'); + if (!['ours', 'theirs', 'resolved'].includes(choice)) throw new Error('Unsupported conflict resolution choice.'); + if (choice !== 'resolved') await this.runWithPathspec(root, ['checkout', `--${choice}`], [safeFile], { timeout: 30_000 }); + await this.runWithPathspec(root, ['add'], [safeFile], { timeout: 30_000 }); + return this.conflictState(root); + } + + async continueInterruptedOperation(repoPath) { + const root = await this.ensureRepository(repoPath); + const state = await this.conflictState(root); + if (!state.operation) throw new Error('No interrupted Git operation is active.'); + if (state.files.length) throw new Error('Resolve every conflicted file before continuing.'); + const commands = { rebase: ['rebase', '--continue'], merge: ['merge', '--continue'], 'cherry-pick': ['cherry-pick', '--continue'], revert: ['revert', '--continue'] }; + await run('git', commands[state.operation], { cwd: root, env: { GIT_EDITOR: 'true' }, timeout: 120_000 }); + return this.conflictState(root); + } + + selectedStatusFiles(status, files) { + const selected = assertRepositoryRelativePaths(files); + if (!selected.length) return { selected, matches: status.files }; + const selectedSet = new Set(selected); + const matches = status.files.filter((file) => selectedSet.has(file.path) || (file.originalPath && selectedSet.has(file.originalPath))); + return { selected, matches }; + } + + expandStatusPaths(status, files, { unstagedOnly = false } = {}) { + const { selected, matches } = this.selectedStatusFiles(status, files); + if (!selected.length) return []; + const expanded = new Set(); + for (const file of matches) { + if (unstagedOnly && !file.unstaged) continue; + expanded.add(file.path); + if (file.originalPath) expanded.add(file.originalPath); + } + return [...expanded]; + } + + async expandSelectedPaths(root, files, options = {}) { + return this.expandStatusPaths(await this.status(root), files, options); + } + + // Callers that already read the status pass it in. Reading it again costs a + // child process, and a commit used to pay for four of them. + async applyStage(root, files, knownStatus = null) { + const requested = assertRepositoryRelativePaths(files); + if (!requested.length) { + await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 }); + return; + } + + // Only stage records that still have a worktree-side change. Re-running + // `git add -A -- deleted-file` after that deletion is already staged makes + // Git fail with "pathspec did not match any files" because the file no + // longer exists in either the worktree or HEAD. Staged-only deletions and + // renames are already ready for commit and must therefore be left alone. + const status = knownStatus || await this.status(root); + const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true }); + if (selected.length) { + await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 }); + } + } + + async stage(repoPath, files) { + const root = await this.ensureRepository(repoPath); + await this.applyStage(root, files); + return this.status(root); + } + + async unstage(repoPath, files) { + const root = await this.ensureRepository(repoPath); + const selected = await this.expandSelectedPaths(root, files); + const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] }); + if (hasHead.exitCode === 0) { + if (selected.length) await this.runWithPathspec(root, ['restore', '--staged'], selected, { timeout: 120_000 }); + else await run('git', ['restore', '--staged', '.'], { cwd: root }); + } else { + if (selected.length) await this.runWithPathspec(root, ['rm', '--cached', '--ignore-unmatch'], selected, { timeout: 120_000, allowExitCodes: [1] }); + else await run('git', ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] }); + } + return this.status(root); + } + + async prepareSelectedStage(root, files) { + const selected = assertRepositoryRelativePaths(files); + let current = null; + if (selected.length) { + current = await this.status(root); + const excludedStaged = current.files + .filter((file) => file.staged) + .filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath))) + .map((file) => file.path); + if (excludedStaged.length) { + throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`); + } + } + await this.applyStage(root, selected, current); + const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] }); + if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.'); + return selected; + } + + async commit(repoPath, message, files = []) { + const root = await this.ensureRepository(repoPath); + const commitMessage = assertCommitMessage(message); + await this.prepareSelectedStage(root, files); + const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }); + const status = await this.status(root); + return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status }; + } + + async commitStaged(repoPath, message) { + const root = await this.ensureRepository(repoPath); + const commitMessage = assertCommitMessage(message); + const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] }); + if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.'); + const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }); + const status = await this.status(root); + return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status }; + } + + async commitStagedAndPush(repoPath, message) { + const committed = await this.commitStaged(repoPath, message); + try { + const pushed = await this.push(repoPath); + return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha }; + } catch (error) { + const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`); + wrapped.code = 'PUSH_AFTER_COMMIT_FAILED'; wrapped.commitSha = committed.sha; wrapped.recoverable = true; + throw wrapped; + } + } + + async commitAndPush(repoPath, message, files = []) { + const committed = await this.commit(repoPath, message, files); + try { + const pushed = await this.push(repoPath); + return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha }; + } catch (error) { + const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`); + wrapped.code = 'PUSH_AFTER_COMMIT_FAILED'; + wrapped.commitSha = committed.sha; + wrapped.recoverable = true; + throw wrapped; + } + } + + async push(repoPath) { + const root = await this.ensureRepository(repoPath); + const status = await this.status(root); + const branch = status.branch.head; + if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.'); + if (/^forgeflow\/recovery-/.test(branch)) { + const error = new Error('ForgeFlow recovery branches are local quarantine references and cannot be pushed to Gitea. Review them with Codex and move only approved work onto a normal branch.'); + error.code = 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY'; + error.recoverable = true; + throw error; + } + const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch]; + const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 }); + return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) }; + } + + async fetch(repoPath) { + const root = await this.ensureRepository(repoPath); + const result = await run('git', ['fetch', '--prune'], { cwd: root, timeout: 180_000 }); + return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) }; + } + + async pullFastForward(repoPath) { + const root = await this.ensureRepository(repoPath); + const status = await this.status(root); + if (!status.clean) throw new Error('Commit or stash local changes before synchronizing.'); + if (!status.branch.upstream) throw new Error('This branch has no upstream branch. Publish it first.'); + const result = await run('git', ['pull', '--ff-only'], { cwd: root, timeout: 180_000 }); + return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) }; + } + + async history(repoPath, limit = 20) { + const root = await this.ensureRepository(repoPath); + const format = '%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e'; + const result = await run('git', ['log', `-${Math.min(Math.max(Number(limit) || 20, 1), 100)}`, `--format=${format}`], { cwd: root, allowExitCodes: [128] }); + if (result.exitCode === 128) return []; + return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { + const [sha, shortSha, author, email, date, subject] = record.split('\x1f'); + return { sha, shortSha, author, email, date, subject }; + }); + } + + async branches(repoPath) { + const root = await this.ensureRepository(repoPath); + const format = '%(refname:short)%x1f%(objectname)%x1f%(HEAD)%x1f%(upstream:short)%x1f%(upstream:track)%x1e'; + const result = await run('git', ['for-each-ref', `--format=${format}`, 'refs/heads'], { cwd: root }); + return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { + const [name, sha, current, upstream, track] = record.split('\x1f'); + const ahead = Number(track?.match(/ahead (\d+)/)?.[1] || 0); + const behind = Number(track?.match(/behind (\d+)/)?.[1] || 0); + return { name, sha, shortSha: sha?.slice(0, 7), current: current === '*', upstream: upstream || null, ahead, behind }; + }); + } + + assertBranchName(branch) { + const value = String(branch || '').trim(); + if (!value) throw new Error('Branch name is required.'); + return value; + } + + async checkoutBranch(repoPath, branch) { + const root = await this.ensureRepository(repoPath); + const status = await this.status(root); + if (!status.clean) throw new Error('Commit or stash local changes before switching branches.'); + const value = this.assertBranchName(branch); + await run('git', ['check-ref-format', '--branch', value], { cwd: root }); + await run('git', ['switch', value], { cwd: root, timeout: 60_000 }); + return this.status(root); + } + + async createBranch(repoPath, branch) { + const root = await this.ensureRepository(repoPath); + const status = await this.status(root); + if (!status.clean) throw new Error('Commit or stash local changes before creating a branch.'); + const value = this.assertBranchName(branch); + await run('git', ['check-ref-format', '--branch', value], { cwd: root }); + await run('git', ['switch', '-c', value], { cwd: root, timeout: 60_000 }); + return this.status(root); + } + + async stash(repoPath, message = '') { + const root = await this.ensureRepository(repoPath); + const status = await this.status(root); + if (status.clean) throw new Error('There are no changes to stash.'); + const args = ['stash', 'push', '--include-untracked']; + const label = String(message || '').trim(); + if (label) args.push('-m', label.slice(0, 200)); + const result = await run('git', args, { cwd: root, timeout: 120_000 }); + return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) }; + } + + async stashList(repoPath) { + const root = await this.ensureRepository(repoPath); + const format = '%gd%x1f%H%x1f%aI%x1f%gs%x1e'; + const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root }); + return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { + const [ref, sha, date, subject] = record.split('\x1f'); + const quarantine = String(subject || '').match(/FORGEFLOW-QUARANTINE:([0-9a-f]{64})/i); + return { + ref, + sha, + shortSha: sha.slice(0, 7), + date, + subject, + quarantined: Boolean(quarantine), + reviewId: quarantine?.[1] || null + }; + }); + } + + async popStash(repoPath, ref = 'stash@{0}') { + const root = await this.ensureRepository(repoPath); + const value = String(ref || 'stash@{0}'); + if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.'); + const candidate = (await this.stashList(root)).find((item) => item.ref === value); + if (candidate?.quarantined) { + const error = new Error(`This stash is quarantined for Codex review (${candidate.reviewId}). ForgeFlow will not apply and drop it wholesale; restore only reviewed files manually.`); + error.code = 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED'; + error.recoverable = true; + throw error; + } + const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 }); + return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) }; + } + + async verifyCommitOnRemoteBranch(repoPath, sha, branch) { + const root = await this.ensureRepository(repoPath); + const fullSha = assertFullCommitSha(sha); + const branchName = this.assertBranchName(branch); + await run('git', ['fetch', '--prune', 'origin', branchName], { cwd: root, timeout: 180_000 }); + await run('git', ['cat-file', '-e', `${fullSha}^{commit}`], { cwd: root, timeout: 30_000 }); + const ancestor = await run('git', ['merge-base', '--is-ancestor', fullSha, `origin/${branchName}`], { cwd: root, allowExitCodes: [1] }); + if (ancestor.exitCode !== 0) throw new Error(`Commit ${fullSha.slice(0, 7)} is not contained in origin/${branchName}.`); + return { valid: true, sha: fullSha, branch: branchName }; + } + + async inspectCloneTarget(remoteUrl, destination) { + const remote = assertCloneRemote(remoteUrl); + const target = assertSafeRepositoryPath(destination); + const existing = await fs.stat(target).catch(() => null); + + if (!existing) return { state: 'missing', remote, target }; + if (!existing.isDirectory()) { + const error = new Error('The automatic clone target exists and is not a folder.'); + error.code = 'CLONE_TARGET_NOT_DIRECTORY'; + throw error; + } + + const entries = await fs.readdir(target); + if (!entries.length) return { state: 'empty', remote, target }; + + const existingRemote = await this.getRemoteUrl(target).catch(() => ''); + const expected = normalizeRemoteUrl(remote); + const actual = normalizeRemoteUrl(existingRemote); + const sameRepository = Boolean( + expected && actual + && expected.host === actual.host + && expected.path === actual.path + ); + + if (sameRepository) return { state: 'matching-repository', remote, target }; + + const error = new Error(existingRemote + ? 'The automatic clone target already contains a different Git repository.' + : 'The automatic clone target already contains files. Choose another location or link the existing folder.'); + error.code = existingRemote ? 'CLONE_TARGET_DIFFERENT_REPOSITORY' : 'CLONE_TARGET_NOT_EMPTY'; + throw error; + } + + async clone(remoteUrl, destination) { + const assessment = await this.inspectCloneTarget(remoteUrl, destination); + if (assessment.state === 'matching-repository') { + const status = await this.status(assessment.target); + return { ...status, reused: true }; + } + + if (assessment.state === 'missing') { + await fs.mkdir(path.dirname(assessment.target), { recursive: true }); + } + + await run('git', ['clone', '--progress', assessment.remote, assessment.target], { timeout: 15 * 60_000, maxBuffer: 32 * 1024 * 1024 }); + const status = await this.status(assessment.target); + return { ...status, reused: false }; + } +} + +module.exports = { GitService, parseUnifiedDiff }; diff --git a/src/main/git-validator-policy.cjs b/src/main/git-validator-policy.cjs new file mode 100644 index 0000000..2c8805b --- /dev/null +++ b/src/main/git-validator-policy.cjs @@ -0,0 +1,89 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const PROFILE_DEFINITIONS = Object.freeze({ + minimal: { label: "Minimal", requiredScore: 55, severities: ["error"], allowSuppressions: true, maxSuppressionDays: 180 }, + standard: { label: "Standard", requiredScore: 70, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 90 }, + strict: { label: "Strict", requiredScore: 82, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 30 }, + production: { label: "Production", requiredScore: 90, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 14 }, +}); + +function normalizePolicy(policy = {}) { + const id = String(policy.id || policy.profile || "standard").toLowerCase(); + const base = PROFILE_DEFINITIONS[id] || PROFILE_DEFINITIONS.standard; + const custom = id === "organization" ? policy : {}; + return { + id, + label: custom.label || base.label || "Organization custom", + requiredScore: Math.min(100, Math.max(0, Number(custom.requiredScore ?? base.requiredScore ?? 80))), + enabledChecks: Array.isArray(custom.enabledChecks) ? [...new Set(custom.enabledChecks.map(String))] : null, + severityOverrides: custom.severityOverrides && typeof custom.severityOverrides === "object" ? { ...custom.severityOverrides } : {}, + blockingChecks: [...new Set((custom.blockingChecks || policy.blockingChecks || []).map(String))], + blockingSeverities: [...new Set((custom.blockingSeverities || policy.blockingSeverities || base.severities || ["error"]).map(String))] + .filter((severity) => ["warning", "error"].includes(severity)), + allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true, + maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)), + }; +} + +function validateSuppression(input, policy, now = new Date()) { + if (!policy.allowSuppressions) throw new Error("The selected policy does not allow suppressions."); + const checkId = String(input?.checkId || "").trim(); + const reason = String(input?.reason || "").trim(); + const author = String(input?.author || "").trim(); + const scope = String(input?.scope || "repository").trim(); + const evidence = String(input?.evidence || "").trim(); + const expiresAt = new Date(input?.expiresAt || ""); + if (!checkId || reason.length < 10 || !author || !evidence) throw new Error("A suppression requires a check ID, author, evidence and a reason of at least 10 characters."); + if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now) throw new Error("A suppression expiry must be in the future."); + const maximum = new Date(now.getTime() + policy.maxSuppressionDays * 86_400_000); + if (expiresAt > maximum) throw new Error(`This policy permits suppressions for at most ${policy.maxSuppressionDays} days.`); + return { + id: crypto.randomUUID(), checkId, reason, author, + createdAt: now.toISOString(), ticket: String(input.ticket || "").trim() || null, + expiresAt: expiresAt.toISOString(), scope, evidence, + }; +} + +function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) { + const policy = normalizePolicy(policyInput); + const enabled = policy.enabledChecks ? new Set(policy.enabledChecks) : null; + const relevant = checks.filter((check) => !enabled || enabled.has(check.id)).map((check) => { + const status = policy.severityOverrides[check.id] || check.status; + const suppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) > now); + const expiredSuppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) <= now); + return { + ...check, + status, + suppressed: Boolean(suppression), + suppression: suppression || null, + expiredSuppression: expiredSuppression || null, + blocking: !suppression && status !== "pass" && (policy.blockingSeverities.includes(status) || policy.blockingChecks.includes(check.id)), + }; + }); + return { policy, checks: relevant }; +} + +function buildTrend(previous, report) { + const prior = new Map((previous?.checks || []).map((check) => [check.id, check])); + const current = new Map(report.checks.map((check) => [check.id, check])); + const active = (check) => check && check.status !== "pass" && !check.suppressed; + const newlyFound = [...current.values()].filter((check) => active(check) && !active(prior.get(check.id))).map((check) => check.id); + const resolved = [...prior.values()].filter((check) => active(check) && !active(current.get(check.id))).map((check) => check.id); + const regressions = [...current.values()].filter((check) => active(check) && prior.get(check.id)?.status === "warning" && check.status === "error").map((check) => check.id); + return { score: report.score, categories: report.categories, newlyFound, resolved, regressions, suppressions: report.checks.filter((check) => check.suppressed).map((check) => check.id), checkedAt: report.checkedAt, commitSha: report.commitSha || null, checks: report.checks.map(({ id, status, suppressed }) => ({ id, status, suppressed })) }; +} + +function exportReport(report, format = "json") { + if (format === "json") return { extension: "json", mimeType: "application/json", content: `${JSON.stringify(report, null, 2)}\n` }; + const rows = report.checks.map((check) => `| ${check.id} | ${check.category} | ${check.status}${check.suppressed ? " (suppressed)" : ""} | ${String(check.detail).replace(/\|/g, "\\|")} |`).join("\n"); + const markdown = `# Git assurance report — ${report.repository}\n\nPolicy: **${report.policy.label}** · Score: **${report.score}/100** · Commit: \`${report.commitSha || "unknown"}\`\n\n| Check | Category | Status | Evidence |\n|---|---|---|---|\n${rows}\n`; + if (format === "markdown") return { extension: "md", mimeType: "text/markdown", content: markdown }; + if (format !== "html") throw new Error("Unsupported Git Validator export format."); + const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]); + const htmlRows = report.checks.map((check) => `${escape(check.id)}${escape(check.category)}${escape(check.status)}${check.suppressed ? " (suppressed)" : ""}${escape(check.detail)}`).join(""); + return { extension: "html", mimeType: "text/html", content: `Git assurance — ${escape(report.repository)}

Git assurance — ${escape(report.repository)}

Policy: ${escape(report.policy.label)} · Score: ${report.score}/100 · Commit: ${escape(report.commitSha || "unknown")}

${htmlRows}
CheckCategoryStatusEvidence
` }; +} + +module.exports = { PROFILE_DEFINITIONS, normalizePolicy, validateSuppression, applyPolicy, buildTrend, exportReport }; diff --git a/src/main/git-validator-service.cjs b/src/main/git-validator-service.cjs new file mode 100644 index 0000000..623f10f --- /dev/null +++ b/src/main/git-validator-service.cjs @@ -0,0 +1,599 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { run } = require("./process-runner.cjs"); +const { normalizeRemoteUrl } = require("../shared/repository-match.cjs"); +const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs"); + +const RECOMMENDED_GITIGNORE = `# Local configuration and secrets +.env +.env.* +!.env.example +!.env.sample + +# Dependencies and generated output +node_modules/ +dist/ +build/ +coverage/ + +# Editors and operating systems +.idea/ +.vscode/ +.DS_Store +Thumbs.db +`; + +const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +`; + +const RECOMMENDED_EDITORCONFIG = `root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.{bat,cmd,ps1}] +end_of_line = crlf +`; + +function sameRemote(left, right) { + const a = normalizeRemoteUrl(left); + const b = normalizeRemoteUrl(right); + return Boolean(a && b && a.host === b.host && a.path === b.path); +} + +function result(id, category, title, status, detail, options = {}) { + return { + id, + category, + title, + status, + detail, + weight: options.weight || 5, + fixAction: options.fixAction || null, + safe: options.safe === true, + confirmation: options.confirmation || null, + evidence: options.evidence || null, + }; +} + +function isSensitiveTrackedPath(filePath) { + const value = String(filePath || "") + .replace(/\\/g, "/") + .toLowerCase(); + if (/\.env\.(example|sample|template)$/.test(value)) return false; + return ( + /(^|\/)\.env($|\.)/.test(value) || + /(^|\/)(id_rsa|id_ed25519)$/.test(value) || + /\.(pem|p12|pfx|key)$/.test(value) || + /(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value) + ); +} + +class GitValidatorService { + constructor({ git, gitea, diagnostics, store }) { + this.git = git; + this.gitea = gitea; + this.diagnostics = diagnostics; + this.store = store; + } + + async config(root, key, { local = true } = {}) { + const response = await run( + "git", + ["config", ...(local ? ["--local"] : []), "--get", key], + { + cwd: root, + timeout: 10_000, + allowExitCodes: [1], + }, + ); + return response.stdout.trim(); + } + + async trackedFiles(root) { + const response = await run("git", ["ls-files", "-z"], { + cwd: root, + timeout: 30_000, + maxBuffer: 16 * 1024 * 1024, + }); + return response.stdout.split("\0").filter(Boolean); + } + + async scan(repository) { + const checks = []; + const defaultBranch = repository.defaultBranch || "main"; + const owner = repository.owner?.login; + try { + const protection = await this.gitea.getBranchProtection( + owner, + repository.name, + defaultBranch, + ); + checks.push( + result( + "default-branch-protection", + "Gitea governance", + "Default branch protection", + protection.protected ? "pass" : "warning", + protection.protected + ? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.` + : `${defaultBranch} accepts unprotected direct changes.`, + { + weight: 18, + fixAction: protection.protected ? null : "protect-default-branch", + safe: false, + confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`, + }, + ), + ); + if (protection.protected) + checks.push( + result( + "force-push", + "Gitea governance", + "Force-push protection", + protection.enableForcePush ? "warning" : "pass", + protection.enableForcePush + ? "Force pushes remain enabled on the protected branch." + : "Force pushes are blocked on the protected branch.", + { weight: 8 }, + ), + ); + } catch (error) { + checks.push( + result( + "branch-protection-unavailable", + "Gitea governance", + "Branch protection could not be verified", + "warning", + error.message, + { weight: 18 }, + ), + ); + } + + if (!repository.localPath) { + checks.push( + result( + "local-link", + "Local repository", + "Local working tree", + "warning", + "Link or clone this repository to validate files and local Git configuration.", + { weight: 35 }, + ), + ); + return this.finalize(repository, checks, null); + } + + const root = await this.git.ensureRepository(repository.localPath); + const status = await this.git.status(root); + const tracked = await this.trackedFiles(root); + const lowerFiles = tracked.map((file) => file.toLowerCase()); + const desiredRemote = + repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl; + checks.push( + result( + "origin", + "Repository identity", + "Origin matches Gitea", + sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error", + sameRemote(status.remoteUrl, desiredRemote) + ? status.remoteUrl + : `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`, + { + weight: 15, + fixAction: sameRemote(status.remoteUrl, desiredRemote) + ? null + : "align-origin", + safe: true, + }, + ), + ); + checks.push( + result( + "upstream", + "Branch hygiene", + "Current branch has an upstream", + status.branch?.upstream ? "pass" : "warning", + status.branch?.upstream + ? `${status.branch.head} tracks ${status.branch.upstream}.` + : `${status.branch?.head || "The current branch"} is not published or tracked.`, + { weight: 8 }, + ), + ); + checks.push( + result( + "working-tree", + "Branch hygiene", + "Working tree is intentional", + status.clean ? "pass" : "warning", + status.clean + ? "No uncommitted changes." + : `${status.counts.changed} changed file(s) require review, commit or stash.`, + { weight: 5 }, + ), + ); + + const [userName, userEmail, fetchPrune, pullFf, autoStash] = + await Promise.all([ + this.config(root, "user.name", { local: false }), + this.config(root, "user.email", { local: false }), + this.config(root, "fetch.prune"), + this.config(root, "pull.ff"), + this.config(root, "rebase.autoStash"), + ]); + checks.push( + result( + "identity", + "Commit integrity", + "Repository author identity", + userName && userEmail ? "pass" : "warning", + userName && userEmail + ? `${userName} <${userEmail}>` + : "The effective Git user.name or user.email is missing.", + { weight: 7 }, + ), + ); + const safetyReady = + fetchPrune === "true" && pullFf === "only" && autoStash === "true"; + checks.push( + result( + "local-safety", + "Local configuration", + "Safe synchronization defaults", + safetyReady ? "pass" : "warning", + safetyReady + ? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled." + : "Recommended repository-local fetch, pull and autostash safeguards are incomplete.", + { + weight: 10, + fixAction: safetyReady ? null : "configure-local-safety", + safe: true, + }, + ), + ); + + const hasReadme = lowerFiles.some((file) => + /(^|\/)readme(\.[^/]+)?$/.test(file), + ); + checks.push( + result( + "readme", + "Repository documentation", + "README is versioned", + hasReadme ? "pass" : "warning", + hasReadme + ? "Repository purpose and usage can be documented at the source." + : "No tracked README was found.", + { weight: 7 }, + ), + ); + const hasGitignore = lowerFiles.includes(".gitignore"); + checks.push( + result( + "gitignore", + "Repository hygiene", + ".gitignore is versioned", + hasGitignore ? "pass" : "warning", + hasGitignore + ? "Generated and local-only files can be excluded centrally." + : "No tracked .gitignore was found.", + { + weight: 8, + fixAction: hasGitignore ? null : "add-gitignore", + safe: false, + confirmation: + "Create a recommended .gitignore in the working tree? It will remain uncommitted for review.", + }, + ), + ); + for (const [id, title, filename, action] of [ + ["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"], + ["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"], + ]) { + const present = lowerFiles.includes(filename); + checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning", + present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, { + weight: 5, + fixAction: present ? null : action, + safe: false, + confirmation: `Create a recommended ${filename} in the working tree for review?`, + })); + } + + const packageManagers = [ + { manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] }, + { manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] }, + { manifests: ["composer.json"], locks: ["composer.lock"] }, + { manifests: ["gemfile"], locks: ["gemfile.lock"] }, + ]; + const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name))); + if (lockCheck) { + const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name)); + checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning", + lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 })); + } + + const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file)); + checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning", + hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 })); + + const sensitive = tracked.filter(isSensitiveTrackedPath); + checks.push( + result( + "tracked-secrets", + "Security", + "No secret-shaped files are tracked", + sensitive.length ? "error" : "pass", + sensitive.length + ? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.` + : "No tracked environment, private-key or credential filenames were detected.", + { weight: 22 }, + ), + ); + + const large = []; + const candidates = tracked.slice(0, 5000); + for ( + let index = 0; + index < candidates.length && large.length < 12; + index += 64 + ) { + const batch = candidates.slice(index, index + 64); + const stats = await Promise.all( + batch.map(async (file) => ({ + file, + stat: await fs.stat(path.join(root, file)).catch(() => null), + })), + ); + for (const item of stats) { + if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024) + large.push({ file: item.file, size: item.stat.size }); + if (large.length >= 12) break; + } + } + checks.push( + result( + "large-files", + "Repository performance", + "No oversized tracked files", + large.length ? "warning" : "pass", + large.length + ? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.` + : "No tracked files above 10 MB were found.", + { weight: 7 }, + ), + ); + await this.addAssuranceChecks(root, tracked, lowerFiles, checks); + return this.finalize(repository, checks, status); + } + + async addAssuranceChecks(root, tracked, lowerFiles, checks) { + const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file))); + const fileCheck = (id, category, title, patterns, detail, weight = 5) => { + const present = has(...patterns); + checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight })); + }; + fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8); + fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6); + fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5); + fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7); + fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4); + fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3); + fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4); + fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7); + fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6); + + const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file)); + checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) })); + const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file)); + checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) })); + + const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file)); + const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n"); + const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref)); + checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) })); + const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText); + checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 })); + + const [commitSignature, tagSignature, recentSubjects] = await Promise.all([ + run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"), + run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""), + run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []), + ]); + checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 })); + checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 })); + const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject)); + checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 })); + + const releaseFiles = { + "release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/, + "release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/, + "release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/, + }; + for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4); + checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 })); + } + + async finalize(repository, checks, status) { + const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] }; + const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions); + const report = this.summarize(repository, governedChecks); + report.policy = policy; + report.commitSha = status?.head || status?.branch?.oid || null; + report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => { + const categoryChecks = governedChecks.filter((check) => check.category === category); + return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)]; + })); + report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking); + report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id); + report.trend = buildTrend(repositoryState.trends.at(-1), report); + if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend); + return report; + } + + async setPolicy(repository, policyInput) { + const policy = normalizePolicy(policyInput); + if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable."); + await this.store.setGitValidatorPolicy(repository.fullName, policy); + return policy; + } + + async suppress(repository, input) { + const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } }; + const suppression = validateSuppression(input, normalizePolicy(state.policy)); + await this.store.addGitValidatorSuppression(repository.fullName, suppression); + return suppression; + } + + export(report, format) { return exportReport(report, format); } + + async previewRepair(repository, check) { + if (!check?.fixAction) throw new Error("This validator check has no repair action."); + const root = repository.localPath ? await this.git.ensureRepository(repository.localPath) : null; + const fileDefinitions = { + "add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE], + "add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES], + "add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG], + }; + if (fileDefinitions[check.fixAction]) { + const [name, content] = fileDefinitions[check.fixAction]; + if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`); + return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false }; + } + if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false }; + if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false }; + if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true }; + throw new Error("Unsupported Git Validator repair action."); + } + + async resolveRepairCheck(repository, candidate) { + const checkId = String(candidate?.id || candidate?.checkId || "").trim(); + if (!checkId) throw new Error("A current Git Validator check ID is required."); + const report = await this.scan(repository); + const current = report.checks.find((check) => check.id === checkId); + if (!current?.fixAction) + throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing."); + if (candidate?.fixAction && candidate.fixAction !== current.fixAction) + throw new Error("The Git Validator repair request is stale. Scan again before repairing."); + return current; + } + summarize(repository, checks) { + const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0); + const earned = checks.reduce( + (sum, check) => + sum + + (check.status === "pass" || check.suppressed + ? check.weight + : check.status === "warning" + ? check.weight * 0.45 + : 0), + 0, + ); + const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0; + return { + repository: repository.fullName, + checkedAt: new Date().toISOString(), + score, + grade: + score >= 90 + ? "Excellent" + : score >= 75 + ? "Good" + : score >= 55 + ? "Needs attention" + : "High risk", + checks, + summary: { + passed: checks.filter((check) => check.status === "pass").length, + warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).length, + errors: checks.filter((check) => check.status === "error" && !check.suppressed).length, + suppressed: checks.filter((check) => check.suppressed).length, + repairable: checks.filter((check) => check.fixAction).length, + }, + }; + } + + async repair(repository, check) { + if (!check?.fixAction) + throw new Error("This validator check has no repair action."); + const root = repository.localPath + ? await this.git.ensureRepository(repository.localPath) + : null; + if (check.fixAction === "align-origin") { + return this.git.setRemoteUrl( + root, + repository.preferredCloneUrl || + repository.cloneUrl || + repository.sshUrl, + ); + } + if (check.fixAction === "configure-local-safety") { + for (const [key, value] of [ + ["fetch.prune", "true"], + ["pull.ff", "only"], + ["rebase.autoStash", "true"], + ]) + await run("git", ["config", "--local", key, value], { + cwd: root, + timeout: 10_000, + }); + return { configured: true }; + } + if (check.fixAction === "add-gitignore") { + const target = path.join(root, ".gitignore"); + const exists = await fs.stat(target).catch(() => null); + if (exists) + throw new Error(".gitignore already exists; rescan before repairing."); + await fs.writeFile(target, RECOMMENDED_GITIGNORE, { + encoding: "utf8", + flag: "wx", + }); + return { created: ".gitignore" }; + } + if (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) { + const definition = check.fixAction === "add-gitattributes" + ? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES } + : { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG }; + const target = path.join(root, definition.name); + if (await fs.stat(target).catch(() => null)) + throw new Error(`${definition.name} already exists; rescan before repairing.`); + await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" }); + return { created: definition.name }; + } + if (check.fixAction === "protect-default-branch") { + return this.gitea.createBranchProtection( + repository.owner.login, + repository.name, + repository.defaultBranch || "main", + ); + } + throw new Error("Unsupported Git Validator repair action."); + } +} + +module.exports = { + GitValidatorService, + RECOMMENDED_GITIGNORE, + RECOMMENDED_GITATTRIBUTES, + RECOMMENDED_EDITORCONFIG, + sameRemote, + isSensitiveTrackedPath, +}; diff --git a/src/main/gitea-service.cjs b/src/main/gitea-service.cjs new file mode 100644 index 0000000..95f9ced --- /dev/null +++ b/src/main/gitea-service.cjs @@ -0,0 +1,623 @@ +"use strict"; + +const { + normalizeBaseUrl, + assertBranchName, +} = require("../shared/validation.cjs"); +const { redactSecrets } = require("./log-redaction.cjs"); + +class GiteaService { + constructor(store, diagnostics = null) { + this.store = store; + this.diagnostics = diagnostics; + } + + async request(pathname, options = {}) { + const baseUrl = normalizeBaseUrl( + options.baseUrl || this.store.data.gitea.baseUrl, + ); + const token = options.token || this.store.getToken(); + if (!token && options.auth !== false) + throw new Error("No Gitea access token is available."); + + const headers = { + Accept: options.accept || "application/json", + ...(token && options.auth !== false + ? { Authorization: `token ${token}` } + : {}), + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...(options.headers || {}), + }; + + const started = Date.now(); + let response; + try { + response = await fetch(`${baseUrl}/api/v1${pathname}`, { + method: options.method || "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: AbortSignal.timeout(options.timeout || 30_000), + redirect: "follow", + }); + } catch (error) { + const wrapped = new Error( + `Could not reach Gitea: ${redactSecrets(error.message, [token])}`, + ); + wrapped.code = error.code || "GITEA_NETWORK_ERROR"; + await this.diagnostics?.warning("gitea.request.failed", { + method: options.method || "GET", + pathname, + durationMs: Date.now() - started, + code: wrapped.code, + message: wrapped.message, + }); + throw wrapped; + } + + let text = ""; + let payload = null; + if (options.responseType === "buffer") { + payload = Buffer.from(await response.arrayBuffer()); + } else { + text = await response.text(); + if (text) { + if (options.responseType === "text") payload = text; + else { + try { + payload = JSON.parse(text); + } catch { + payload = text; + } + } + } + } + + if (!response.ok) { + const detail = + typeof payload === "object" && + !Buffer.isBuffer(payload) && + payload?.message + ? payload.message + : text || response.statusText; + const error = new Error( + `Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`, + ); + error.status = response.status; + error.payload = payload; + await this.diagnostics?.warning("gitea.request.rejected", { + method: options.method || "GET", + pathname, + status: response.status, + durationMs: Date.now() - started, + message: error.message, + }); + throw error; + } + + await this.diagnostics?.debug("gitea.request.completed", { + method: options.method || "GET", + pathname, + status: response.status, + durationMs: Date.now() - started, + }); + return { + status: response.status, + headers: response.headers, + data: payload, + }; + } + + async validateConnection(baseUrl, token) { + const normalized = normalizeBaseUrl(baseUrl); + const user = await this.request("/user", { baseUrl: normalized, token }); + const repositories = await this.listRepositories({ + baseUrl: normalized, + token, + limitPages: 1, + }); + const version = await this.request("/version", { + baseUrl: normalized, + token, + }) + .then((result) => result.data?.version || null) + .catch(() => null); + return { + baseUrl: normalized, + user: user.data, + repositoryCount: repositories.length, + version, + }; + } + + async listRepositories(options = {}) { + const repositories = []; + const pageSize = 50; + const limitPages = options.limitPages || 20; + for (let page = 1; page <= limitPages; page += 1) { + const result = await this.request( + `/user/repos?limit=${pageSize}&page=${page}&sort=updated`, + options, + ); + const batch = Array.isArray(result.data) ? result.data : []; + repositories.push(...batch); + if (batch.length < pageSize) break; + } + return repositories; + } + + async getRepository(owner, repo) { + return ( + await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + ) + ).data; + } + + async repositoryFileExists({ owner, repo, filePath, ref }) { + const encodedPath = String(filePath || "") + .split("/") + .map(encodeURIComponent) + .join("/"); + const query = ref ? `?ref=${encodeURIComponent(ref)}` : ""; + try { + await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`, + ); + return true; + } catch (error) { + if (error.status === 404) return false; + throw error; + } + } + + async getBranch(owner, repo, branch) { + return ( + await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`, + ) + ).data; + } + + async getBranchProtection(owner, repo, branch) { + const branchInfo = await this.getBranch(owner, repo, branch); + let rule = null; + try { + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`, + ); + const rules = Array.isArray(result.data) ? result.data : []; + rule = + rules.find( + (item) => item.branch_name === branch || item.rule_name === branch, + ) || null; + } catch (error) { + if (![403, 404].includes(error.status)) throw error; + } + return { + branch, + protected: Boolean(branchInfo?.protected || rule), + enablePush: rule?.enable_push ?? null, + enableForcePush: rule?.enable_force_push ?? false, + requiredApprovals: Number(rule?.required_approvals || 0), + requireSignedCommits: Boolean(rule?.require_signed_commits), + rule, + }; + } + + async createBranchProtection(owner, repo, branch) { + const target = assertBranchName(branch); + return ( + await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`, + { + method: "POST", + body: { + rule_name: target, + branch_name: target, + enable_push: false, + enable_force_push: false, + required_approvals: 0, + dismiss_stale_approvals: true, + block_on_rejected_reviews: true, + block_on_outdated_branch: true, + }, + }, + ) + ).data; + } + + async listDeployKeys(owner, repo) { + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys?limit=100`, + ); + return Array.isArray(result.data) ? result.data : []; + } + + async ensureReadOnlyDeployKey({ owner, repo, title, publicKey }) { + const key = String(publicKey || "").trim(); + if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) + throw new Error("The server did not return a valid SSH public key."); + const keys = await this.listDeployKeys(owner, repo); + const keyMaterial = key.split(/\s+/).slice(0, 2).join(" "); + const existing = keys.find((item) => + String(item?.key || "").trim().split(/\s+/).slice(0, 2).join(" ") === keyMaterial, + ); + if (existing) { + if (existing.read_only !== true) { + const error = new Error("The matching Gitea deploy key has write access. Revoke it before ForgeFlow configures a read-only server key."); + error.code = "DEPLOY_KEY_NOT_READ_ONLY"; + throw error; + } + return { ...existing, created: false }; + } + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, + { + method: "POST", + body: { + title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), + key, + read_only: true, + }, + }, + ); + return { ...result.data, created: true }; + } + + async createReadOnlyDeployKey({ owner, repo, title, publicKey }) { + const key = String(publicKey || "").trim(); + if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) throw new Error("A valid SSH public key is required."); + const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, { method: "POST", body: { title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), key, read_only: true } }); + return result.data; + } + + async deleteDeployKey(owner, repo, keyId) { + if (!Number.isInteger(Number(keyId)) || Number(keyId) <= 0) throw new Error("A valid deploy-key ID is required."); + await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys/${Number(keyId)}`, { method: "DELETE" }); + return { deleted: true, keyId: Number(keyId) }; + } + + async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) { + const query = new URLSearchParams({ + state, + limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)), + }); + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`, + ); + return Array.isArray(result.data) ? result.data : []; + } + + async createPullRequest({ owner, repo, head, base, title, body = "" }) { + const cleanTitle = String(title || "").trim(); + if (!cleanTitle || cleanTitle.length > 255) + throw new Error("Pull request title must contain 1-255 characters."); + const cleanBody = String(body || "") + .trim() + .slice(0, 50_000); + const source = assertBranchName(head); + const target = assertBranchName(base); + if (source === target) + throw new Error( + "Pull request source and target branches must be different.", + ); + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, + { + method: "POST", + body: { + head: source, + base: target, + title: cleanTitle, + body: cleanBody, + }, + timeout: 60_000, + }, + ); + return result.data; + } + + async getRepositoryFile({ owner, repo, filePath, ref }) { + const encodedPath = String(filePath || "") + .split("/") + .map(encodeURIComponent) + .join("/"); + const query = ref ? `?ref=${encodeURIComponent(ref)}` : ""; + const payload = ( + await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`, + ) + ).data; + if (!payload || Array.isArray(payload)) + throw new Error(`Repository path ${filePath} is not a file.`); + if (payload.encoding === "base64" && typeof payload.content === "string") { + return { + ...payload, + decoded: Buffer.from( + payload.content.replace(/\s/g, ""), + "base64", + ).toString("utf8"), + }; + } + if (typeof payload.content === "string") + return { ...payload, decoded: payload.content }; + throw new Error(`Gitea did not return readable content for ${filePath}.`); + } + + async getLatestRelease(owner, repo) { + try { + return ( + await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`, + ) + ).data; + } catch (error) { + if (error.status === 404) return null; + throw error; + } + } + + async getReleaseByTag(owner, repo, tag) { + try { + return ( + await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`, + ) + ).data; + } catch (error) { + if (error.status === 404) return null; + throw error; + } + } + + async downloadAuthenticated(url, { timeout = 180_000 } = {}) { + const baseUrl = normalizeBaseUrl(this.store.data.gitea.baseUrl); + const base = new URL(baseUrl); + const token = this.store.getToken(); + let target = new URL(url, `${baseUrl}/`); + for (let redirects = 0; redirects <= 5; redirects += 1) { + const sameOrigin = target.origin === base.origin; + if (!sameOrigin && target.protocol !== "https:") { + throw new Error( + "Refusing an insecure cross-origin update download redirect.", + ); + } + const response = await fetch(target, { + headers: { + ...(sameOrigin && token ? { Authorization: `token ${token}` } : {}), + Accept: "application/octet-stream", + }, + signal: AbortSignal.timeout(timeout), + redirect: "manual", + }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get("location"); + if (!location) + throw new Error( + "The update download redirect did not contain a destination.", + ); + target = new URL(location, target); + continue; + } + if (!response.ok) + throw new Error(`Update download failed with HTTP ${response.status}.`); + return Buffer.from(await response.arrayBuffer()); + } + throw new Error("The update download exceeded the redirect limit."); + } + + async downloadReleaseAsset(owner, repo, releaseId, assetId, options = {}) { + const numericReleaseId = Number(releaseId); + const numericId = Number(assetId); + if (!Number.isSafeInteger(numericReleaseId) || numericReleaseId <= 0) + throw new Error("Gitea returned an invalid release ID."); + if (!Number.isSafeInteger(numericId) || numericId <= 0) + throw new Error("Gitea returned an invalid release asset ID."); + + let downloadUrl = String(options.downloadUrl || "").trim(); + if (!downloadUrl) { + const metadataPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`; + const metadata = (await this.request(metadataPath)).data; + if (Number(metadata?.id) !== numericId) { + throw new Error("Gitea returned metadata for a different release asset."); + } + downloadUrl = String(metadata?.browser_download_url || "").trim(); + } + if (!downloadUrl) { + throw new Error("Gitea did not provide a release asset download URL."); + } + const configuredBase = new URL(normalizeBaseUrl(this.store.data.gitea.baseUrl)); + const publishedUrl = new URL(downloadUrl, configuredBase); + const releasePrefix = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/`.toLowerCase(); + if (publishedUrl.origin !== configuredBase.origin && publishedUrl.protocol === "http:" && publishedUrl.pathname.toLowerCase().startsWith(releasePrefix)) { + downloadUrl = new URL(`${publishedUrl.pathname}${publishedUrl.search}`, configuredBase).toString(); + } + return this.downloadAuthenticated(downloadUrl, options); + } + + async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) { + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`, + { method: "POST", body: { ref, inputs }, timeout: 60_000 }, + ); + return { + accepted: [200, 201, 204].includes(result.status), + status: result.status, + }; + } + + normalizeRun(run) { + if (!run || typeof run !== "object") return null; + const status = String(run.status || run.conclusion || "").toLowerCase(); + const conclusion = + String(run.conclusion || "").toLowerCase() || + (["success", "failure", "cancelled", "skipped"].includes(status) + ? status + : null); + return { + id: run.id ?? run.run_id ?? run.task_id ?? null, + runNumber: run.run_number ?? run.index ?? run.id ?? null, + name: run.name || run.workflow_name || run.workflow_id || "Workflow", + event: run.event || null, + status, + conclusion, + headSha: run.head_sha || run.commit_sha || run.commit?.sha || null, + headBranch: run.head_branch || run.ref || run.branch || null, + workflowPath: run.path || run.workflow_path || run.workflow_file || null, + displayTitle: run.display_title || run.title || run.name || null, + actor: + run.actor?.login || run.trigger_user?.login || run.user?.login || null, + createdAt: run.created_at || run.started || run.start_time || null, + updatedAt: run.updated_at || run.stopped || run.end_time || null, + htmlUrl: run.html_url || run.url || null, + raw: run, + }; + } + + async listWorkflowRuns({ owner, repo, sha, branch, limit = 30 } = {}) { + const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions`; + const normalizedLimit = String(Math.min(Math.max(limit, 1), 100)); + const filtered = new URLSearchParams({ limit: normalizedLimit }); + if (sha) filtered.set("head_sha", sha); + if (branch) filtered.set("branch", branch); + const basic = new URLSearchParams({ limit: normalizedLimit }); + + const tryEndpoint = async (endpoint) => { + try { + return await this.request(`${base}/${endpoint}?${filtered}`); + } catch (error) { + // Action API query support differs across Gitea releases. Retry without + // optional filters and apply SHA/branch matching locally. + if ( + ![400, 422].includes(error.status) || + String(filtered) === String(basic) + ) + throw error; + return this.request(`${base}/${endpoint}?${basic}`); + } + }; + + let result; + let source = "runs"; + try { + result = await tryEndpoint("runs"); + } catch (error) { + if (![404, 405].includes(error.status)) throw error; + source = "tasks"; + result = await tryEndpoint("tasks"); + } + + const data = result.data; + const items = Array.isArray(data) + ? data + : data?.workflow_runs || data?.runs || data?.tasks || []; + return { + source, + runs: items.map((item) => this.normalizeRun(item)).filter(Boolean), + totalCount: data?.total_count ?? items.length, + }; + } + + async findWorkflowRun({ + owner, + repo, + sha, + branch, + workflowFile, + dispatchedAt, + excludeRunIds = [], + }) { + const { runs, source } = await this.listWorkflowRuns({ + owner, + repo, + sha, + branch, + limit: 50, + }); + const earliest = dispatchedAt + ? new Date(dispatchedAt).getTime() - 120_000 + : 0; + const workflowBase = String(workflowFile || "") + .split("/") + .pop(); + const excluded = new Set( + (excludeRunIds || []).map((value) => String(value)), + ); + const candidates = runs.filter((run) => { + if ( + run.id !== null && + run.id !== undefined && + excluded.has(String(run.id)) + ) + return false; + if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase()) + return false; + if ( + branch && + run.headBranch && + run.headBranch.replace(/^refs\/heads\//, "") !== branch + ) + return false; + if ( + earliest && + run.createdAt && + new Date(run.createdAt).getTime() < earliest + ) + return false; + if (workflowBase && run.workflowPath) { + const runBase = String(run.workflowPath).split("/").pop(); + if (runBase && runBase !== workflowBase) return false; + } + return true; + }); + candidates.sort( + (a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0), + ); + return { source, run: candidates[0] || null }; + } + + async listWorkflowJobs({ owner, repo, runNumber }) { + if (runNumber === null || runNumber === undefined) return []; + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`, + ); + const data = result.data; + const jobs = Array.isArray(data) ? data : data?.jobs || []; + return jobs.map((job) => ({ + id: job.id, + name: job.name || job.job_name || `Job ${job.id}`, + status: String(job.status || "").toLowerCase(), + conclusion: String(job.conclusion || "").toLowerCase() || null, + startedAt: job.started_at || null, + completedAt: job.completed_at || null, + steps: Array.isArray(job.steps) + ? job.steps.map((step) => ({ + name: step.name, + status: String(step.status || "").toLowerCase(), + conclusion: String(step.conclusion || "").toLowerCase() || null, + number: step.number, + })) + : [], + })); + } + + async getJobLogs({ owner, repo, jobId }) { + if (!jobId) return ""; + try { + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`, + { + accept: "text/plain, application/octet-stream", + responseType: "text", + timeout: 60_000, + }, + ); + return String(result.data || "").slice(-500_000); + } catch (error) { + if ([404, 410].includes(error.status)) return ""; + throw error; + } + } +} + +module.exports = { GiteaService }; diff --git a/src/main/inventory-classifier.cjs b/src/main/inventory-classifier.cjs new file mode 100644 index 0000000..450f512 --- /dev/null +++ b/src/main/inventory-classifier.cjs @@ -0,0 +1,83 @@ +"use strict"; + +const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("./deployment-identity.cjs"); + +const BACKUP = /(?:^|[\\/._-])(backup|bak|archive|snapshot|old|previous)(?:[\\/._-]|$)/i; +const RELEASE = /(?:^|[\\/])(releases?|versions?)(?:[\\/]|$)/i; +const STAGING = /(?:^|[\\/._-])(staging|stage|test|qa|preview)(?:[\\/._-]|$)/i; +const TEMPORARY = /(?:^|[\\/._-])(candidate|rollback|ephemeral)(?:[\\/._-]|$)|^GITEA-ACTIONS-TASK-/i; +const SYSTEM = /^(?:traefik|nginx-proxy-manager|watchtower|portainer|dockerman|unraid-|cloudflared|redis|postgres|mariadb|mysql)(?:$|[-_.])/i; + +function baseClassification(workload) { + const location = `${workload.compose?.workingDir || ""} ${(workload.compose?.configFiles || []).join(" ")}`; + const sourceRepository = String(workload.metadata?.sourceRepository || "").trim(); + const hasGitProvenance = /^(?:git@|ssh:\/\/|https?:\/\/)/i.test(sourceRepository); + const decision = workload.reviewDecision; + if (["manual-exclude", "exclude-scan-root", "ignore"].includes(decision?.action)) return { type: "manually-excluded", reason: decision.reason || "Persisted manual exclusion", decisionAction: decision.action }; + if (["mark-historical", "archive-link"].includes(decision?.action)) return { type: "historical-compose", reason: decision.reason || "Reviewed as historical", decisionAction: decision.action }; + if (decision?.action === "monitor-only") return { type: "monitor-only", reason: decision.reason || "Reviewed for monitoring only", decisionAction: decision.action }; + if (workload.metadata?.staleLink) return { type: "stale-link", reason: "The linked deployment profile has no matching server workload" }; + if (BACKUP.test(location)) return { type: "backup", reason: "Path matches backup/archive evidence" }; + if (RELEASE.test(location)) return { type: "release-folder", reason: "Path is below a release/version directory" }; + if (STAGING.test(location)) return { type: "staging", reason: "Path or project identifies a staging/test workload" }; + if (TEMPORARY.test(`${workload.displayName || ""} ${location}`)) return { type: "temporary-runtime", reason: "Runtime identity marks a candidate, rollback or CI workload" }; + if (SYSTEM.test(workload.displayName || "") && !workload.metadata?.sourceRepository) return { type: "system-container", reason: "Known infrastructure identity without repository provenance" }; + if (workload.link && workload.runtime?.running) return { type: "active-application", reason: "Linked deployment with running container evidence" }; + if (workload.link && !workload.runtime?.running) return { type: "stopped-application", reason: "Linked deployment without a running container" }; + if (!workload.containers?.length && workload.compose?.configFiles?.length) return { type: "historical-compose", reason: "Compose definition exists without container runtime" }; + if (workload.status === "ambiguous") return { type: "ambiguous", reason: "Multiple candidates have equivalent evidence" }; + if (!workload.candidates?.length) return { type: "external-container", reason: hasGitProvenance ? "Repository provenance does not match an accessible configured Gitea repository" : "Runtime has no Git repository provenance and remains monitoring-only" }; + if (!workload.runtime?.running && workload.candidates?.length) return { type: "stopped-application", reason: "Stopped runtime has repository evidence" }; + return { type: workload.runtime?.running ? "active-application" : "ambiguous", reason: workload.runtime?.running ? "Running application evidence" : "Insufficient authoritative evidence" }; +} + +function classifyInventory(workloads, profiles = [], decisions = []) { + const profileById = new Map(profiles.map((profile) => [profile.id, profile])); + const decisionByWorkload = new Map(decisions.map((decision) => [decision.workloadId, decision])); + const authorities = new Map(); + const result = workloads.map((source) => { + const workload = structuredClone(source); + const profile = profileById.get(workload.link?.profileId) || null; + const identity = deploymentIdentity({ workload, profile }); + const evidence = { candidates: (workload.candidates || []).map((item) => ({ repository: item.repositoryFullName, score: item.score, exact: item.exact === true })), running: workload.runtime?.running === true, health: workload.runtime?.health || null, configFiles: workload.compose?.configFiles || [] }; + const hash = deploymentEvidenceHash(identity, evidence); + const stored = decisionByWorkload.get(workload.workloadId); + workload.reviewDecision = stored?.evidenceHash === hash ? stored : null; + workload.reviewDecisionStale = Boolean(stored && stored.evidenceHash !== hash); + workload.identity = identity; + if (workload.reviewDecision?.action === "manual-link" && workload.reviewDecision.repositoryFullName) { + workload.identity.repository = String(workload.reviewDecision.repositoryFullName).toLowerCase(); + } + workload.evidenceHash = hash; + workload.classification = baseClassification(workload); + if (workload.link && ["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) { + workload.shadowedLink = workload.link; + workload.link = null; + } + const key = deploymentAuthorityKey(identity); + if (identity.repository && (workload.link || workload.candidates?.length) && !["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) { + const group = authorities.get(key) || []; + group.push(workload); + authorities.set(key, group); + } + return workload; + }); + for (const group of authorities.values()) { + if (group.length < 2) { + group[0].authoritative = true; + continue; + } + const ranked = [...group].sort((a, b) => Number(b.reviewDecision?.action === "select-authoritative") - Number(a.reviewDecision?.action === "select-authoritative") || Number(b.runtime?.running) - Number(a.runtime?.running) || Number(Boolean(b.link)) - Number(Boolean(a.link)) || Number(Boolean(b.metadata?.liveRevision)) - Number(Boolean(a.metadata?.liveRevision))); + ranked[0].authoritative = true; + for (const duplicate of ranked.slice(1)) { + duplicate.authoritative = false; + duplicate.classification = { type: "duplicate", reason: `Conflicts with authoritative workload ${ranked[0].workloadId}`, authoritativeWorkloadId: ranked[0].workloadId }; + duplicate.status = "duplicate"; + duplicate.shadowedLink = duplicate.link; + duplicate.link = null; + } + } + return result; +} + +module.exports = { classifyInventory, classifyWorkload: baseClassification, inventoryPathPatterns: { BACKUP, RELEASE, STAGING, TEMPORARY, SYSTEM } }; diff --git a/src/main/inventory-review-service.cjs b/src/main/inventory-review-service.cjs new file mode 100644 index 0000000..72d1abe --- /dev/null +++ b/src/main/inventory-review-service.cjs @@ -0,0 +1,31 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const ACTIONS = new Set(["keep-link", "select-authoritative", "mark-historical", "archive-link", "monitor-only", "exclude-scan-root", "manual-link", "ignore", "manual-exclude"]); + +class InventoryReviewService { + constructor({ store, audit = null }) { this.store = store; this.audit = audit; } + list(serverId) { return this.store.getInventoryReviewDecisions(serverId); } + preview({ serverId, workload, action, reason = "", repositoryFullName = null }) { + if (!ACTIONS.has(action)) throw Object.assign(new Error("Unsupported inventory review action."), { code: "INVENTORY_REVIEW_ACTION_INVALID" }); + if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && String(reason).trim().length < 5) throw Object.assign(new Error("A meaningful review reason is required."), { code: "INVENTORY_REVIEW_REASON_REQUIRED" }); + if (action === "manual-link" && !repositoryFullName) throw Object.assign(new Error("Select the repository to link."), { code: "INVENTORY_REVIEW_REPOSITORY_REQUIRED" }); + const linkedProfile = workload.link?.profileId && workload.link?.repositoryFullName ? { profileId: workload.link.profileId, repositoryFullName: workload.link.repositoryFullName } : null; + const configurationChanges = [`Persist review decision ${action} for workload ${workload.workloadId}`]; + if (action === "archive-link" && linkedProfile) configurationChanges.push(`Archive deployment profile ${linkedProfile.profileId}`); + if (action === "manual-link") configurationChanges.push(`Remember ${repositoryFullName} as the reviewed repository match; use Save environment to create the deployment profile`); + const mutation = { serverId, workloadId: workload.workloadId, evidenceHash: workload.evidenceHash, action, reason: String(reason).trim(), repositoryFullName, linkedProfile, classification: workload.classification?.type || workload.status, containersUnaffected: true, configurationChanges, recovery: "Restore the configuration snapshot or rescan after evidence changes." }; + return { ...mutation, id: crypto.createHash("sha256").update(JSON.stringify(mutation)).digest("hex") }; + } + async apply({ plan, expectedPlanId }) { + if (!expectedPlanId || plan.id !== expectedPlanId) throw Object.assign(new Error("Inventory review requires the exact preview plan."), { code: expectedPlanId ? "INVENTORY_REVIEW_PLAN_STALE" : "INVENTORY_REVIEW_PLAN_REQUIRED" }); + const snapshot = await this.store.createRecoverySnapshot?.(`inventory-review:${plan.serverId}:${plan.workloadId}`); + if (plan.action === "archive-link" && plan.linkedProfile) await this.store.deleteDeploymentProfile(plan.linkedProfile.repositoryFullName, plan.linkedProfile.profileId); + const decision = await this.store.saveInventoryReviewDecision(plan.serverId, { workloadId: plan.workloadId, evidenceHash: plan.evidenceHash, action: plan.action, reason: plan.reason, repositoryFullName: plan.repositoryFullName || null, classification: plan.classification, decidedAt: new Date().toISOString() }); + await this.audit?.append?.("deployment.inventory-review-applied", { serverId: plan.serverId, workloadId: plan.workloadId, action: plan.action, evidenceHash: plan.evidenceHash, snapshot: snapshot?.filePath || null }); + return { decision, snapshot }; + } +} + +module.exports = { InventoryReviewService, INVENTORY_REVIEW_ACTIONS: [...ACTIONS] }; diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs new file mode 100644 index 0000000..9be7b56 --- /dev/null +++ b/src/main/ipc.cjs @@ -0,0 +1,721 @@ +"use strict"; +const path = require("node:path"); +const fs = require("node:fs/promises"); +const { dialog, shell, app } = require("electron"); +const { matchRemoteToRepository } = require("../shared/repository-match.cjs"); +const { + cloneDirectoryName, + resolveCloneTarget, +} = require("../shared/clone-target.cjs"); +const { + createChannelRegistrar, + assertTrustedSender, + toErrorPayload, +} = require("./ipc/channel.cjs"); +const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs"); +const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs"); +const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs"); +const { + createEncryptedBackup, + readEncryptedBackup, +} = require("./configuration-backup.cjs"); +const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs"); +const { normalizeBaseUrl } = require("../shared/validation.cjs"); +function registerIpc({ + store, + git, + gitea, + repositories, + deployments, + unraid, + deployKeys, + inventoryReviews, + ssh, + updates, + preflight, + gitValidator, + diagnostics, + audit, + externalTools, + monitor, + onPreferencesChanged, +}) { + const register = createChannelRegistrar(diagnostics); + const repositoryMutations = new Map(); + const withRepositoryPause = async (localPath, action) => { + monitor?.pause(localPath); + try { + return await action(); + } finally { + monitor?.resume(localPath); + } + }; + const withRepositoryMutation = async (localPath, action) => { + const key = path.resolve(localPath); + const previous = repositoryMutations.get(key) || Promise.resolve(); + const execute = async () => { + try { + return await withRepositoryPause(key, action); + } catch (error) { + if (!git.isGitLockError(error)) throw error; + let repair = null; + let lockDiagnosis = null; + try { + repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000 }); + } catch (repairError) { + lockDiagnosis = repairError; + if (repairError?.code === "GIT_LOCKS_RECENT") { + await new Promise((resolve) => setTimeout(resolve, 2_500)); + try { + repair = await git.repairStaleGitLocks(key, { + minimumAgeMs: 2_000, + }); + lockDiagnosis = null; + } catch (retryError) { + lockDiagnosis = retryError; + } + } + } + if (!repair?.repaired) throw lockDiagnosis || error; + await diagnostics.info("git.lock.auto-repaired", { + localPath: key, + locks: repair.removed.map((item) => item.name), + }); + return withRepositoryPause(key, action); + } + }; + const current = previous.catch(() => {}).then(execute); + repositoryMutations.set(key, current); + try { + return await current; + } finally { + if (repositoryMutations.get(key) === current) + repositoryMutations.delete(key); + } + }; + + const canonicalPath = async (value) => { + const resolved = path.resolve(String(value || "")); + return fs.realpath(resolved).catch(() => resolved); + }; + + const assertKnownRepositoryPath = async (localPath) => { + const candidate = await canonicalPath(localPath); + let knownPaths = repositories.getWatchPaths(); + if (!knownPaths.length && store.data.setupComplete) { + await repositories.refresh(); + knownPaths = repositories.getWatchPaths(); + } + // Watch paths are already canonical, so re-resolving all of them on every + // guarded call is only needed when the cheap comparison finds no match. + const matched = knownPaths.some((known) => path.resolve(known) === candidate) + || (await Promise.all(knownPaths.map(canonicalPath))).some((known) => known === candidate); + if (!matched) + throw new Error( + "The requested local repository is not linked or discovered by ForgeFlow.", + ); + return candidate; + }; + + const resolveRepository = async (repositoryPayload) => { + const fullName = String(repositoryPayload?.fullName || "").trim(); + if (!fullName) throw new Error("Repository identity is required."); + const current = await repositories.resolveByFullName(fullName); + if (!current) + throw new Error( + "The repository is no longer available through the configured Gitea account.", + ); + return current; + }; + + const assertProjectRoot = async (rootValue) => { + const root = await canonicalPath(rootValue); + const stat = await fs.stat(root).catch(() => null); + if (!stat?.isDirectory()) + throw new Error("The selected project root no longer exists."); + return root; + }; + + const cloneRepositoryInto = async (fullName, projectRoot) => { + const current = await resolveRepository({ fullName }); + if (current.localPath) + throw new Error("This repository already has a linked local folder."); + + const remoteUrl = + current.preferredCloneUrl || current.cloneUrl || current.sshUrl; + if (!remoteUrl) + throw new Error( + "Gitea did not provide a usable clone URL for this repository.", + ); + + const root = await assertProjectRoot(projectRoot); + const { target } = resolveCloneTarget(root, remoteUrl); + const status = await git.clone(remoteUrl, target); + + await store.saveMapping(current.fullName, target); + const result = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + await diagnostics.info( + status.reused ? "repository.clone.reused" : "repository.cloned", + { + fullName: current.fullName, + projectRoot: root, + target, + head: status.head, + branch: status.branch?.head, + }, + ); + + return { + target, + status, + reused: Boolean(status.reused), + repositories: result, + state: store.getPublicState(), + }; + }; + + register("app:bootstrap", async () => ({ + appVersion: app.getVersion(), + platform: process.platform, + state: store.getPublicState(), + git: await git.isAvailable(), + diagnostics: await diagnostics.getStatus(), + updateResult: await updates.consumeLatestResult(), + })); + + register( + "dialog:select-directory", + async ({ title = "Select folder", defaultPath }) => { + const result = await dialog.showOpenDialog({ + title, + defaultPath, + properties: ["openDirectory", "createDirectory"], + }); + return result.canceled ? null : result.filePaths[0]; + }, + ); + + register( + "dialog:select-key-file", + async ({ title = "Select SSH private key", defaultPath }) => { + const result = await dialog.showOpenDialog({ + title, + defaultPath, + properties: ["openFile"], + }); + return result.canceled ? null : result.filePaths[0]; + }, + ); + + register( + "dialog:select-image-file", + async ({ title = "Select PNG image", defaultPath }) => { + const result = await dialog.showOpenDialog({ + title, + defaultPath, + properties: ["openFile"], + filters: [{ name: "PNG image", extensions: ["png"] }], + }); + return result.canceled ? null : result.filePaths[0]; + }, + ); + + register("setup:preflight", ({ baseUrl, token, roots }) => + preflight.runSystem({ baseUrl, token, roots }), + ); + register("setup:validate-gitea", ({ baseUrl, token }) => + gitea.validateConnection(baseUrl, token), + ); + register("setup:complete", async ({ baseUrl, token, workspaceRoots }) => { + const report = await preflight.runSystem({ + baseUrl, + token, + roots: workspaceRoots, + }); + if (!report.summary.ready || !report.giteaValidation) + throw new Error( + "Setup readiness checks must pass before configuration can be completed.", + ); + const validation = report.giteaValidation; + const result = await store.completeSetup({ + baseUrl: validation.baseUrl, + token, + user: validation.user, + workspaceRoots, + }); + await diagnostics.info("setup.completed", { + baseUrl: validation.baseUrl, + user: validation.user?.login || null, + workspaceRootCount: workspaceRoots?.length || 0, + tokenPersistent: result.tokenState.persistent, + }); + return result; + }); + + register("settings:update-gitea", async ({ baseUrl, token }) => { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const currentBaseUrl = store.data.gitea.baseUrl + ? normalizeBaseUrl(store.data.gitea.baseUrl) + : ""; + const submittedToken = String(token || "").trim(); + if (!submittedToken && normalizedBaseUrl !== currentBaseUrl) { + const error = new Error( + "Enter a new Gitea token when changing the server address. Stored tokens are bound to their original origin.", + ); + error.code = "GITEA_TOKEN_ORIGIN_CHANGED"; + throw error; + } + const effectiveToken = submittedToken || store.getToken(); + const validation = await gitea.validateConnection( + normalizedBaseUrl, + effectiveToken, + ); + const tokenState = await store.updateGitea({ + baseUrl: validation.baseUrl, + token, + user: validation.user, + }); + await diagnostics.info("settings.gitea.updated", { + baseUrl: validation.baseUrl, + user: validation.user?.login || null, + tokenPersistent: tokenState.persistent, + tokenPreserved: tokenState.preserved, + }); + return { validation, tokenState, state: store.getPublicState() }; + }); + + register("settings:set-roots", async ({ roots }) => { + store.data.workspaceRoots = [...new Set((roots || []).filter(Boolean))]; + await store.save(); + await diagnostics.info("settings.workspace-roots.updated", { + rootCount: store.data.workspaceRoots.length, + roots: store.data.workspaceRoots, + }); + return store.getPublicState(); + }); + + register("settings:set-appearance", async ({ appearance }) => { + if (!["dark", "light", "system"].includes(appearance)) + throw new Error("Unsupported appearance setting."); + store.data.appearance = appearance; + await store.save(); + return store.getPublicState(); + }); + + register("settings:set-preferences", async ({ preferences }) => { + const state = await store.setPreferences(preferences); + monitor?.restart(); + onPreferencesChanged?.(); + await diagnostics.info("settings.preferences.updated", { + preferences: state.preferences, + }); + return state; + }); + + register("settings:export-backup", async ({ passphrase }) => { + const result = await dialog.showSaveDialog({ + title: "Export encrypted ForgeFlow configuration", + defaultPath: path.join( + app.getPath("documents"), + `ForgeFlow-Configuration-${new Date().toISOString().slice(0, 10)}.ffbackup`, + ), + filters: [ + { name: "ForgeFlow encrypted backup", extensions: ["ffbackup"] }, + ], + }); + if (result.canceled || !result.filePath) return null; + const destinationPath = result.filePath.toLowerCase().endsWith(".ffbackup") + ? result.filePath + : `${result.filePath}.ffbackup`; + await fs + .writeFile( + destinationPath, + createEncryptedBackup(store.data, passphrase), + { mode: 0o600, flag: "wx" }, + ) + .catch(async (error) => { + if (error.code !== "EEXIST") throw error; + await fs.writeFile( + destinationPath, + createEncryptedBackup(store.data, passphrase), + { mode: 0o600 }, + ); + }); + await audit.append("configuration.backup.exported", { + fileName: path.basename(destinationPath), + }); + return { filePath: destinationPath }; + }); + + register("settings:import-backup", async ({ passphrase }) => { + const result = await dialog.showOpenDialog({ + title: "Import encrypted ForgeFlow configuration", + properties: ["openFile"], + filters: [ + { name: "ForgeFlow encrypted backup", extensions: ["ffbackup"] }, + ], + }); + if (result.canceled || !result.filePaths[0]) return null; + const payload = readEncryptedBackup( + await fs.readFile(result.filePaths[0], "utf8"), + passphrase, + ); + const state = await store.restoreConfiguration(payload.configuration); + monitor?.restart(); + await audit.append("configuration.backup.imported", { + fileName: path.basename(result.filePaths[0]), + exportedAt: payload.exportedAt, + }); + return { state, exportedAt: payload.exportedAt }; + }); + + register("audit:list", ({ limit = 250 }) => audit.list(limit)); + register("audit:export", async ({ format = "json" }) => { + if (!["json", "csv"].includes(format)) + throw new Error("Unsupported audit export format."); + const extension = format === "csv" ? "csv" : "json"; + const result = await dialog.showSaveDialog({ + title: "Export ForgeFlow audit log", + defaultPath: path.join( + app.getPath("documents"), + `ForgeFlow-Audit-${new Date().toISOString().slice(0, 10)}.${extension}`, + ), + filters: [ + { name: `${extension.toUpperCase()} file`, extensions: [extension] }, + ], + }); + if (result.canceled || !result.filePath) return null; + return audit.exportTo( + result.filePath.toLowerCase().endsWith(`.${extension}`) + ? result.filePath + : `${result.filePath}.${extension}`, + format, + ); + }); + + register("updates:preferences", ({ updates: next }) => + store.setUpdatePreferences(next), + ); + register("updates:check", () => updates.check()); + register("updates:download", () => updates.download()); + register("updates:apply", async () => { + const result = await updates.apply(); + if (!result?.confirmed) + throw new Error( + "The update helper did not confirm ownership of the update. ForgeFlow will remain open.", + ); + setTimeout(() => app.quit(), 350).unref?.(); + return result; + }); + + register( + "server:save", + async ({ server, password = "", passphrase = "" }) => { + await ssh.validateServerConfiguration(server, { password, passphrase }); + const saved = await store.saveServer(server, { password, passphrase }); + await diagnostics.info("server.saved", { + serverId: saved.id, + name: saved.name, + host: saved.host, + port: saved.port, + username: saved.username, + authType: saved.authType, + basePath: saved.basePath, + }); + return { server: saved, state: store.getPublicState() }; + }, + ); + register("server:delete", async ({ serverId }) => { + await store.deleteServer(serverId); + await diagnostics.info("server.deleted", { serverId }); + return store.getPublicState(); + }); + register("server:test", async ({ serverId, expectedFingerprint = "" }) => { + const server = store.getServer(serverId); + if (!server) throw new Error("The configured server no longer exists."); + const expected = String(expectedFingerprint || "").trim(); + if (!server.hostFingerprint && !expected) { + const probe = await ssh.probeHostFingerprint(serverId); + return { ...probe, connected: false, needsTrust: true, state: store.getPublicState() }; + } + if (!server.hostFingerprint && !/^SHA256:[A-Za-z0-9+/]{40,44}$/.test(expected)) + throw new Error("Confirm the exact SSH host fingerprint returned by ForgeFlow."); + const result = await ssh.test(serverId, { + expectedFingerprint: server.hostFingerprint ? null : expected, + }); + if (!server.hostFingerprint) { + if (result.fingerprint !== expected) { + const error = new Error("The SSH host identity changed between preview and confirmation."); + error.code = "SSH_HOST_KEY_MISMATCH"; + throw error; + } + await store.saveServer( + { ...server, hostFingerprint: result.fingerprint }, + {}, + ); + result.trusted = true; + } + return { ...result, state: store.getPublicState() }; + }); + register("server:inspect-project", async ({ repository, profileId }) => + unraid.inspect({ + repository: await resolveRepository(repository), + profileId, + }), + ); + register( + "server:discover-existing", + async ({ repository, serverId, remoteFolder }) => + unraid.discoverExisting({ + repository: await resolveRepository(repository), + serverId, + remoteFolder, + }), + ); + + registerRepositoryIpc({ + register, repositories, store, git, gitea, monitor, diagnostics, audit, + externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath, + resolveRepository, cloneRepositoryInto, cloneDirectoryName, + matchRemoteToRepository, shell, dialog, + }); + + register("troubleshooter:scan", async ({ fullName = null }) => { + const currentRepositories = await repositories.refresh(); + const candidates = fullName + ? currentRepositories.filter((item) => item.fullName === fullName) + : currentRepositories; + const issues = []; + for (const repository of candidates) { + if (!repository.localPath) { + issues.push({ + id: `${repository.fullName}:not-linked`, + repository: repository.fullName, + severity: "warning", + title: "Local repository is not linked", + detail: + "Link or clone the repository before running local Git repairs.", + repairable: false, + }); + continue; + } + try { + const interrupted = await git.detectInterruptedOperation( + repository.localPath, + ); + if (interrupted) + issues.push({ + id: `${repository.fullName}:abort-operation`, + repository: repository.fullName, + localPath: repository.localPath, + severity: "error", + title: `Interrupted Git ${interrupted}`, + detail: `A ${interrupted} is still active and blocks normal Git operations. Aborting it can discard conflict-resolution work and therefore always requires separate confirmation.`, + repairable: true, + action: "abort-operation", + safe: false, + }); + const report = await git.reconcile(repository.localPath); + for (const lock of report.lockReport?.locks || []) { + const stale = lock.ageMs >= 10_000; + const processProbeSafe = + report.lockReport.processes?.available === true && + !report.lockReport.processes.active?.length; + issues.push({ + id: `${repository.fullName}:locks:${lock.name}`, + repository: repository.fullName, + localPath: repository.localPath, + severity: stale ? "error" : "warning", + title: stale + ? "Stale Git lock detected" + : "Recent Git lock detected", + detail: lock.name, + repairable: stale, + action: "repair-locks", + safe: stale && processProbeSafe, + }); + } + const branch = report.status?.branch || {}; + if (branch.behind > 0 && branch.ahead === 0 && report.status.clean) + issues.push({ + id: `${repository.fullName}:fast-forward`, + repository: repository.fullName, + localPath: repository.localPath, + severity: "warning", + title: "Local branch is behind Gitea", + detail: `${branch.behind} commit(s) can be fast-forwarded safely.`, + repairable: true, + action: "fast-forward", + safe: true, + }); + if (branch.ahead > 0 && branch.behind === 0) + issues.push({ + id: `${repository.fullName}:push`, + repository: repository.fullName, + localPath: repository.localPath, + severity: "warning", + title: "Local commits are not published", + detail: `${branch.ahead} commit(s) can be pushed to Gitea after explicit confirmation.`, + repairable: true, + action: "push", + safe: false, + }); + if (branch.ahead > 0 && branch.behind > 0) + issues.push({ + id: `${repository.fullName}:diverged`, + repository: repository.fullName, + localPath: repository.localPath, + severity: "error", + title: "Local and Gitea branches have diverged", + detail: `${branch.ahead} ahead and ${branch.behind} behind. ForgeFlow can preserve the local HEAD on a safety branch and use the upstream version.`, + repairable: report.status.clean, + action: "backup-reset", + safe: false, + }); + } catch (error) { + issues.push({ + id: `${repository.fullName}:git-error`, + repository: repository.fullName, + severity: "error", + title: "Git health scan failed", + detail: error.message, + repairable: false, + }); + } + for (const profile of repository.deploymentProfiles || []) { + if (profile.provider !== "ssh-unraid") continue; + try { + const inspection = await unraid.inspect({ + repository, + profileId: profile.id, + }); + if (!inspection.exists) + issues.push({ + id: `${profile.id}:server-folder`, + repository: repository.fullName, + profileId: profile.id, + severity: "error", + title: "Deployment folder is missing on the server", + detail: inspection.remotePath, + repairable: false, + }); + if (inspection.trackedChanges?.length) + issues.push({ + id: `${profile.id}:tracked-server-changes`, + repository: repository.fullName, + profileId: profile.id, + severity: "error", + title: "Tracked server-side changes detected", + detail: `${inspection.trackedChanges.length} tracked change(s) must be reviewed before deployment.`, + repairable: false, + }); + if (inspection.dockerContextExclusionsMissing?.length) + issues.push({ + id: `${profile.id}:dockerignore`, + repository: repository.fullName, + profileId: profile.id, + severity: "warning", + title: "Runtime paths are missing from .dockerignore", + detail: inspection.dockerContextExclusionsMissing.join(", "), + repairable: false, + }); + } catch (error) { + issues.push({ + id: `${profile.id}:server-error`, + repository: repository.fullName, + profileId: profile.id, + severity: "error", + title: "Server inspection failed", + detail: error.message, + repairable: false, + }); + } + } + } + const summary = { + total: issues.length, + errors: issues.filter((item) => item.severity === "error").length, + warnings: issues.filter((item) => item.severity === "warning").length, + repairable: issues.filter((item) => item.repairable).length, + }; + return { checkedAt: new Date().toISOString(), issues, summary }; + }); + + register("troubleshooter:repair", async ({ issue }) => { + if (!issue || !issue.action) + throw new Error("No repair action was supplied."); + const localPath = issue.localPath + ? await assertKnownRepositoryPath(issue.localPath) + : null; + let result; + if (issue.action === "abort-operation") + result = await withRepositoryMutation(localPath, () => + git.abortInterruptedOperation(localPath), + ); + else if (issue.action === "repair-locks") + result = await withRepositoryMutation(localPath, () => + git.repairStaleGitLocks(localPath, { minimumAgeMs: 2_000 }), + ); + else if ( + ["fast-forward", "push", "backup-reset", "fetch"].includes(issue.action) + ) + result = await withRepositoryMutation(localPath, () => + git.repairSync(localPath, issue.action), + ); + else throw new Error("Unsupported troubleshooter repair action."); + await diagnostics.info("troubleshooter.repair.completed", { + repository: issue.repository, + action: issue.action, + }); + return result; + }); + + register("troubleshooter:auto-repair", async ({ issues }) => { + const results = []; + for (const issue of (issues || []).filter( + (item) => item.repairable && item.safe, + )) { + try { + const localPath = issue.localPath + ? await assertKnownRepositoryPath(issue.localPath) + : null; + let result; + if (issue.action === "repair-locks") + result = await withRepositoryMutation(localPath, () => + git.repairStaleGitLocks(localPath, { minimumAgeMs: 10_000 }), + ); + else if (["fast-forward", "fetch"].includes(issue.action)) + result = await withRepositoryMutation(localPath, () => + git.repairSync(localPath, issue.action), + ); + else continue; + results.push({ id: issue.id, ok: true, result }); + } catch (error) { + results.push({ id: issue.id, ok: false, error: error.message }); + } + } + await diagnostics.info("troubleshooter.auto-repair.completed", { + attempted: results.length, + succeeded: results.filter((item) => item.ok).length, + }); + return results; + }); + + registerDeploymentIpc({ + register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy, + audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh, + preflight, + }); + registerOperationsIpc({ + register, store, unraid, deployments, diagnostics, shell, dialog, path, app, + repositories, preflight, monitor, + }); +} + +module.exports = { + registerIpc, + cloneDirectoryName, + assertTrustedSender, + toErrorPayload, +}; diff --git a/src/main/ipc/channel.cjs b/src/main/ipc/channel.cjs new file mode 100644 index 0000000..ee8a252 --- /dev/null +++ b/src/main/ipc/channel.cjs @@ -0,0 +1,77 @@ +"use strict"; + +const path = require("node:path"); +const { fileURLToPath } = require("node:url"); +const { ipcMain } = require("electron"); + +const TRUSTED_RENDERER_PATH = path.resolve( + __dirname, + "..", + "..", + "renderer", + "index.html", +); + +function toErrorPayload(error) { + return { + message: error?.message || "Unknown error", + code: error?.code || null, + status: error?.status || null, + recoverable: Boolean(error?.recoverable), + commitSha: error?.commitSha || null, + }; +} + +function assertTrustedSender(event) { + const url = event?.senderFrame?.url || event?.sender?.getURL?.() || ""; + try { + const parsed = new URL(url); + if (parsed.protocol !== "file:") throw new Error("not a file URL"); + const senderPath = path.resolve(fileURLToPath(parsed)); + const normalize = (value) => + process.platform === "win32" ? value.toLowerCase() : value; + if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH)) + throw new Error("unexpected renderer file"); + } catch { + throw new Error("Rejected IPC request from an untrusted renderer origin."); + } +} + +// Built per registerIpc() call so the diagnostics sink is an argument instead of +// module-level mutable state that every handler silently depends on. +function createChannelRegistrar(diagnostics) { + return function register(channel, handler) { + ipcMain.handle(channel, async (event, payload) => { + const started = Date.now(); + try { + assertTrustedSender(event); + const data = await handler(payload || {}, event); + await diagnostics?.debug("ipc.completed", { + channel, + durationMs: Date.now() - started, + }); + return { ok: true, data }; + } catch (error) { + await diagnostics?.error("ipc.failed", { + channel, + durationMs: Date.now() - started, + error: { + name: error?.name, + message: error?.message, + code: error?.code, + status: error?.status, + stack: error?.stack, + }, + }); + return { ok: false, error: toErrorPayload(error) }; + } + }); + }; +} + +module.exports = { + createChannelRegistrar, + assertTrustedSender, + toErrorPayload, + TRUSTED_RENDERER_PATH, +}; diff --git a/src/main/ipc/deployment-handlers.cjs b/src/main/ipc/deployment-handlers.cjs new file mode 100644 index 0000000..aafcc36 --- /dev/null +++ b/src/main/ipc/deployment-handlers.cjs @@ -0,0 +1,284 @@ +"use strict"; + +function registerDeploymentIpc({ + register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy, + audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh, + preflight, +}) { + register("deployment:save-profile", async ({ fullName, profile }) => { + const saved = await store.saveDeploymentProfile(fullName, profile); + await diagnostics.info("deployment.profile.saved", { + repository: fullName, + profile: saved, + }); + return { profile: saved, state: store.getPublicState() }; + }); + register("deployment:delete-profile", async ({ fullName, profileId }) => { + const profiles = await store.deleteDeploymentProfile(fullName, profileId); + await diagnostics.info("deployment.profile.deleted", { + repository: fullName, + profileId, + }); + return { profiles, state: store.getPublicState() }; + }); + register("deployment:preflight", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + if (profile?.provider === "ssh-unraid") + return unraid.preflight({ repository: current, profileId }); + return preflight.runDeployment({ repository: current, profileId }); + }); + register("deployment:repair-write-access", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + if (profile?.provider !== "ssh-unraid") + throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles."); + const result = await unraid.repairWriteAccess({ repository: current, profileId }); + await audit.append("deployment.write-access.repaired", { + repository: current.fullName, + profileId, + changed: result.changed, + remotePath: result.after?.remotePath || result.before?.remotePath || null, + }); + return result; + }); + register( + "deployment:dispatch", + async ({ + repository, + profileId, + sha, + note = "", + override = false, + overrideReason = "", + }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + const policy = evaluateDeploymentPolicy(profile, { + note, + override, + reason: overrideReason, + }); + await audit.append("deployment.requested", { + repository: current.fullName, + profileId, + sha, + note: policy.note, + overridden: policy.overridden, + overrideReason: policy.reason, + }); + const operation = + profile?.provider === "ssh-unraid" + ? await unraid.deploy({ repository: current, profileId, sha }) + : await deployments.deploy({ repository: current, profileId, sha }); + if (operation?.id) + await store.addOperation({ + ...operation, + releaseNote: policy.note, + policyOverride: policy.overridden + ? { reason: policy.reason, violations: policy.violations } + : null, + }); + return operation; + }, + ); + register( + "deployment:rollback", + async ({ repository, profileId, targetSha }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + if (profile?.provider === "ssh-unraid") + return unraid.rollback({ repository: current, profileId, targetSha }); + return deployments.rollback({ + repository: current, + profileId, + targetSha, + }); + }, + ); + register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => { + const current = await resolveRepository(repository); + const result = await unraid.linkServerWorkload({ + repository: current, + serverId, + workloadId, + deploymentMode, + remoteFolder, + }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:configure-server-git-access", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const result = await unraid.configureServerGitAccess({ repository: current, profileId }); + await audit.append("deployment.server-git-access-configured", { + repository: current.fullName, + profileId, + keyFingerprint: result.keyFingerprint, + hostFingerprint: result.hostFingerprint, + }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:verify-server-git-profile", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const result = await unraid.verifyServerGitProfile({ repository: current, profileId }); + await audit.append("deployment.server-git-access-verified", { + repository: current.fullName, + profileId, + readiness: result.readiness, + ready: result.ready, + checkedAt: result.checkedAt, + }); + return result; + }); + register("deployment:deploy-key-inventory", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return deployKeys.inventory({ repository: current, profileId }); + }); + register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return deployKeys.planRotation({ repository: current, profileId }); + }); + register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => { + const current = await resolveRepository(repository); + const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return deployKeys.planRevocation({ repository: current, profileId }); + }); + register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => { + const current = await resolveRepository(repository); + const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:restore-deploy-key", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const result = await deployKeys.restore({ repository: current, profileId }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:discover-server-workloads", async () => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter( + (repository) => repository.owner?.login !== "local", + ); + const results = []; + for (const server of store.data.servers || []) { + try { + results.push( + await unraid.discoverServerWorkloads(server.id, remoteRepositories), + ); + } catch (error) { + results.push({ + serverId: server.id, + serverName: server.name, + detected: 0, + adopted: 0, + verified: 0, + linked: 0, + unmatched: 0, + needsReview: 0, + capabilities: {}, + warnings: [], + workloads: [], + error: error.message, + }); + } + } + return results; + }); + register("deployment:plan-server-reconciliation", async ({ serverId }) => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local"); + const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true }); + await audit.append("deployment.server-reconciliation-planned", { + serverId, + planId: result.plan.id, + summary: result.plan.summary, + }); + return result; + }); + register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local"); + const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId }); + await audit.append("deployment.server-reconciliation-applied", { + serverId, + planId, + adopted: result.adopted, + refreshed: result.refreshed, + retired: result.retired, + recoverySnapshot: result.recoverySnapshot?.filePath || null, + }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => { + const repositoryList = await repositories.refresh(); + const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" }); + return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName }); + }); + register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => { + const repositoryList = await repositories.refresh(); + const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" }); + const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName }); + const result = await inventoryReviews.apply({ plan, expectedPlanId: planId }); + return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() }; + }); + register("deployment:profile-state", async ({ fullName, profileId }) => { + const profile = store.getDeploymentProfile(fullName, profileId); + if (profile?.provider === "ssh-unraid") { + let giteaSha = null; + try { + const [owner, repo] = String(fullName || "").split("/"); + const branch = await gitea.getBranch(owner, repo, profile.branch); + giteaSha = branch?.commit?.id || branch?.commit?.sha || null; + } catch {} + return unraid.refreshProfileState(fullName, profileId, giteaSha); + } + return deployments.refreshProfileState(fullName, profileId); + }); + register( + "deployment:apply-dockerman-metadata", + async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return unraid.applyDockerManMetadata({ repository: current, profileId }); + }, + ); + register("deployment:reconcile", async ({ fullName, profileId }) => { + const profile = store.getDeploymentProfile(fullName, profileId); + if (profile?.provider !== "ssh-unraid") + return deployments.refreshProfileState(fullName, profileId); + const [owner, repo] = String(fullName || "").split("/"); + const branch = await gitea.getBranch(owner, repo, profile.branch); + const giteaSha = + branch?.commit?.id || + branch?.commit?.sha || + branch?.commit?.commit?.id || + null; + const state = await unraid.refreshProfileState( + fullName, + profileId, + giteaSha, + ); + const operations = store.data.operations.filter( + (item) => + item.profileId === profileId && + item.provider === "ssh-unraid" && + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ); + for (const operation of operations) + await unraid.refreshOperation(operation.id); + return { + state, + operations: await unraid.reconcileRecordedOperations(profileId, state), + }; + }); +} + +module.exports = { registerDeploymentIpc }; diff --git a/src/main/ipc/operations-handlers.cjs b/src/main/ipc/operations-handlers.cjs new file mode 100644 index 0000000..821a842 --- /dev/null +++ b/src/main/ipc/operations-handlers.cjs @@ -0,0 +1,100 @@ +"use strict"; + +function registerOperationsIpc({ + register, store, unraid, deployments, diagnostics, shell, dialog, path, app, + repositories, preflight, monitor, +}) { + register("operations:refresh", async ({ operationId }) => { + if (operationId) { + const operation = store.getOperation(operationId); + if (operation?.provider === "ssh-unraid") + return unraid.refreshOperation(operationId); + return deployments.refreshOperation(operationId); + } + const [actions, sshOperations] = await Promise.all([ + deployments.refreshActiveOperations(), + unraid.refreshActiveOperations(), + ]); + return [...actions, ...sshOperations]; + }); + register("operations:get", ({ operationId }) => + store.getOperation(operationId), + ); + + register("diagnostics:status", () => diagnostics.getStatus()); + register("diagnostics:clear", () => diagnostics.clear()); + register("diagnostics:open-folder", async () => { + const error = await shell.openPath(diagnostics.logDirectory); + if (error) throw new Error(error); + return true; + }); + register("diagnostics:export", async ({ privacyMode = "standard" }) => { + if (!["standard", "strict"].includes(privacyMode)) + throw new Error("Unsupported diagnostic privacy mode."); + const result = await dialog.showSaveDialog({ + title: "Export ForgeFlow diagnostic bundle", + defaultPath: path.join( + app.getPath("downloads"), + `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`, + ), + filters: [{ name: "ZIP archive", extensions: ["zip"] }], + }); + if (result.canceled || !result.filePath) return null; + const repositoryState = await repositories.refresh().catch((error) => { + diagnostics.warning("diagnostics.repository-snapshot.failed", error); + return []; + }); + const systemPreflight = await preflight + .runSystem() + .catch((error) => ({ error: error.message })); + const destinationPath = + path.extname(result.filePath).toLowerCase() === ".zip" + ? result.filePath + : `${result.filePath}.zip`; + return diagnostics.exportSupportBundle({ + destinationPath, + publicState: store.getPublicState(), + repositories: repositoryState, + operations: store.data.operations, + preflight: systemPreflight, + privacyMode, + extra: { + appVersion: app.getVersion(), + setupComplete: store.data.setupComplete, + }, + }); + }); + register("diagnostics:show-bundle", async ({ filePath }) => { + if (!diagnostics.isKnownBundlePath(filePath)) + throw new Error( + "Only the most recently generated support bundle can be revealed.", + ); + shell.showItemInFolder(filePath); + return true; + }); + register( + "renderer:report", + async ({ level = "info", event = "renderer.event", details = {} }) => { + const method = ["debug", "info", "warning", "error"].includes(level) + ? level + : "info"; + await diagnostics[method]( + `renderer.${String(event || "event").slice(0, 120)}`, + details, + ); + return true; + }, + ); + + register("app:reset", async () => { + await diagnostics.info("app.reset.requested", {}); + store.data = store.migrate({}); + store.sessionToken = null; + await store.save(); + monitor?.setPaths([]); + monitor?.restart(); + return store.getPublicState(); + }); +} + +module.exports = { registerOperationsIpc }; diff --git a/src/main/ipc/repository-handlers.cjs b/src/main/ipc/repository-handlers.cjs new file mode 100644 index 0000000..aeec63e --- /dev/null +++ b/src/main/ipc/repository-handlers.cjs @@ -0,0 +1,450 @@ +"use strict"; + +function registerRepositoryIpc({ + register, repositories, store, git, gitea, monitor, diagnostics, audit, + externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath, + resolveRepository, cloneRepositoryInto, cloneDirectoryName, + matchRemoteToRepository, shell, dialog, +}) { + register("repositories:refresh", async ({ force = false }) => { + const result = await repositories.refresh({ force: force === true }); + monitor?.setPaths(repositories.getWatchPaths()); + return result; + }); + + register("repositories:discover", async ({ roots }) => { + const paths = await repositories.discoverAll( + roots || store.data.workspaceRoots, + ); + return repositories.getLocalDescriptors(paths); + }); + + register("repository:favorite", async ({ fullName, favorite }) => + store.setFavorite(fullName, favorite), + ); + + register("repository:link", async ({ fullName, localPath }) => { + await git.ensureRepository(localPath); + const remoteUrl = await git.getRemoteUrl(localPath).catch(() => ""); + if ( + !remoteUrl || + !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }]) + ) { + throw new Error( + `The selected folder's origin does not match ${fullName}.`, + ); + } + await store.saveMapping(fullName, localPath); + await diagnostics.info("repository.linked", { fullName, localPath }); + const result = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + return result; + }); + + register("repository:unlink", async ({ fullName }) => { + await store.removeMapping(fullName); + await diagnostics.info("repository.unlinked", { fullName }); + const result = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + return result; + }); + + register("repository:status", async ({ localPath }) => + git.status(await assertKnownRepositoryPath(localPath)), + ); + register("repository:diff", async ({ localPath, filePath, staged }) => + git.diff(await assertKnownRepositoryPath(localPath), filePath, staged), + ); + register("repository:diff-hunks", async ({ localPath, filePath }) => + git.diffHunks(await assertKnownRepositoryPath(localPath), filePath), + ); + register( + "repository:stage-hunks", + async ({ localPath, filePath, hunkIndexes }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.stageHunks(safePath, filePath, hunkIndexes), + ); + }, + ); + register("repository:conflicts", async ({ localPath }) => + git.conflictState(await assertKnownRepositoryPath(localPath)), + ); + register( + "repository:resolve-conflict", + async ({ localPath, filePath, resolution }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.resolveConflict(safePath, filePath, resolution), + ); + await audit.append("git.conflict.resolved", { + localPath: safePath, + filePath, + resolution, + }); + return result; + }, + ); + register("repository:continue-operation", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.continueInterruptedOperation(safePath), + ); + await audit.append("git.operation.continued", { localPath: safePath }); + return result; + }); + register("repository:abort-operation", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.abortInterruptedOperation(safePath), + ); + await audit.append("git.operation.aborted", { + localPath: safePath, + operation: result.aborted, + }); + return result; + }); + register("repository:stage", async ({ localPath, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.stage(safePath, files)); + }); + register("repository:unstage", async ({ localPath, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); + }); + register("repository:commit", async ({ localPath, message, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commit(safePath, message, files), + ); + }); + register("repository:commit-staged", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitStaged(safePath, message), + ); + }); + register("repository:commit-staged-push", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitStagedAndPush(safePath, message), + ); + }); + register("repository:commit-push", async ({ localPath, message, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitAndPush(safePath, message, files), + ); + }); + register("repository:push", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.push(safePath)); + }); + register("repository:fetch", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.fetch(safePath)); + }); + register("repository:pull", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.pullFastForward(safePath), + ); + }); + register("repository:history", async ({ localPath, limit }) => + git.history(await assertKnownRepositoryPath(localPath), limit), + ); + register("repository:branch-protection", async ({ fullName, branch }) => { + const repository = await resolveRepository({ fullName }); + return gitea.getBranchProtection( + repository.owner.login, + repository.name, + branch || + repository.localStatus?.branch?.head || + repository.defaultBranch, + ); + }); + register("repository:pull-requests", async ({ fullName, state = "open" }) => { + const repository = await resolveRepository({ fullName }); + return gitea.listPullRequests({ + owner: repository.owner.login, + repo: repository.name, + state, + }); + }); + register( + "repository:create-pull-request", + async ({ fullName, title, body, base }) => { + const repository = await resolveRepository({ fullName }); + if (!repository.localPath || !repository.localStatus?.clean) + throw new Error( + "A clean linked repository is required before creating a pull request.", + ); + const head = repository.localStatus.branch?.head; + if (!head || !repository.localStatus.branch?.upstream) + throw new Error( + "Publish the current branch before creating a pull request.", + ); + if (repository.localStatus.branch.ahead > 0) + throw new Error( + "Push all local commits before creating a pull request.", + ); + const pullRequest = await gitea.createPullRequest({ + owner: repository.owner.login, + repo: repository.name, + head, + base: base || repository.defaultBranch, + title, + body, + }); + await audit.append("pull-request.created", { + repository: repository.fullName, + number: pullRequest.number, + head, + base: base || repository.defaultBranch, + url: pullRequest.html_url, + }); + return pullRequest; + }, + ); + register("repository:branches", async ({ localPath }) => + git.branches(await assertKnownRepositoryPath(localPath)), + ); + register("repository:checkout-branch", async ({ localPath, branch }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.checkoutBranch(safePath, branch), + ); + }); + register("repository:create-branch", async ({ localPath, branch }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.createBranch(safePath, branch), + ); + }); + register("repository:stash", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.stash(safePath, message)); + }); + register("repository:stash-list", async ({ localPath }) => + git.stashList(await assertKnownRepositoryPath(localPath)), + ); + register("repository:stash-pop", async ({ localPath, ref }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); + }); + register("repository:index-lock", async ({ localPath }) => + git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)), + ); + register("repository:git-recovery-status", async ({ localPath }) => + git.reconcile(await assertKnownRepositoryPath(localPath)), + ); + register("repository:repair-index-lock", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.removeStaleIndexLock(safePath), + ); + }); + register( + "repository:repair-git-locks", + async ({ localPath, force = false }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.repairStaleGitLocks(safePath, { + minimumAgeMs: force ? 0 : 10_000, + allowWithoutProcessProbe: force === true, + }), + ); + }, + ); + register("repository:reconcile", async ({ localPath }) => + git.reconcile(await assertKnownRepositoryPath(localPath)), + ); + register("repository:repair-sync", async ({ localPath, strategy }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.repairSync(safePath, strategy), + ); + }); + register("repository:workspace-sync-preview", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const plan = await withRepositoryMutation(safePath, () => + git.previewWorkspaceSync(safePath), + ); + await diagnostics.info("repository.workspace-sync.previewed", { + localPath: safePath, + branch: plan.branch, + upstream: plan.upstream, + currentSha: plan.currentSha, + targetSha: plan.targetSha, + planId: plan.id, + summary: plan.summary, + blockers: plan.blockers, + }); + return plan; + }); + register( + "repository:workspace-sync-apply", + async ({ localPath, expectedPlanId }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.synchronizeWorkspace(safePath, expectedPlanId), + ); + await audit.append("repository.workspace-synchronized", { + localPath: safePath, + branch: result.plan.branch, + upstream: result.plan.upstream, + previousSha: result.plan.currentSha, + targetSha: result.plan.targetSha, + backupBranch: result.backupBranch, + stashSha: result.stash?.sha || null, + ignoredFilesPreserved: true, + applied: result.applied, + }); + return result; + }, + ); + register("repository:set-origin", async ({ localPath, remoteUrl }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.setRemoteUrl(safePath, remoteUrl), + ); + }); + + register("repositories:normalize-origins", async () => { + const current = await repositories.refresh(); + const changes = []; + for (const repository of current) { + if (!repository.localPath || !repository.sshUrl) continue; + const actual = await git + .getRemoteUrl(repository.localPath) + .catch(() => ""); + if (actual === repository.sshUrl) continue; + await withRepositoryMutation(repository.localPath, () => + git.setRemoteUrl(repository.localPath, repository.sshUrl), + ); + changes.push({ + fullName: repository.fullName, + previous: actual, + next: repository.sshUrl, + }); + } + const refreshed = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + await diagnostics.info("repositories.origins.normalized", { + count: changes.length, + changes, + }); + return { changes, repositories: refreshed }; + }); + + register("repository:clone", async ({ fullName, mode = "default" }) => { + if (!["default", "custom"].includes(mode)) + throw new Error("Unsupported clone location mode."); + + let projectRoot = store.data.workspaceRoots[0] || null; + if (mode === "custom" || !projectRoot) { + const result = await dialog.showOpenDialog({ + title: `Choose a project root for ${String(fullName || "repository")}`, + defaultPath: projectRoot || undefined, + buttonLabel: "Use this project root", + properties: ["openDirectory", "createDirectory"], + }); + if (result.canceled || !result.filePaths[0]) return { cancelled: true }; + projectRoot = result.filePaths[0]; + } + + return cloneRepositoryInto(fullName, projectRoot); + }); + + register("repository:open-path", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const error = await shell.openPath(safePath); + if (error) throw new Error(error); + return true; + }); + register( + "repository:open-editor", + async ({ localPath, filePath = "", line = 1 }) => + externalTools.launch( + "editor", + await assertKnownRepositoryPath(localPath), + filePath, + line, + ), + ); + register("repository:open-terminal", async ({ localPath }) => + externalTools.launch( + "terminal", + await assertKnownRepositoryPath(localPath), + ), + ); + + register("external:open", async ({ url }) => { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) + throw new Error("Only HTTP and HTTPS links can be opened."); + await shell.openExternal(parsed.toString()); + return true; + }); + + register("git-validator:scan", async ({ fullName }) => { + const repository = await resolveRepository({ fullName }); + const report = await gitValidator.scan(repository); + await diagnostics.info("git-validator.scan.completed", { + repository: repository.fullName, + score: report.score, + summary: report.summary, + }); + return report; + }); + register("git-validator:set-policy", async ({ fullName, policy }) => { + const repository = await resolveRepository({ fullName }); + const saved = await gitValidator.setPolicy(repository, policy); + await audit.append("git-validator.policy.changed", { repository: repository.fullName, policy: saved.id }); + return saved; + }); + register("git-validator:suppress", async ({ fullName, suppression }) => { + const repository = await resolveRepository({ fullName }); + const saved = await gitValidator.suppress(repository, suppression); + await audit.append("git-validator.finding.suppressed", { repository: repository.fullName, checkId: saved.checkId, expiresAt: saved.expiresAt, ticket: saved.ticket }); + return saved; + }); + register("git-validator:preview-repair", async ({ fullName, check }) => { + const repository = await resolveRepository({ fullName }); + const currentCheck = await gitValidator.resolveRepairCheck(repository, check); + return gitValidator.previewRepair(repository, currentCheck); + }); + register("git-validator:export", async ({ fullName, format = "json" }) => { + const repository = await resolveRepository({ fullName }); + const report = await gitValidator.scan(repository); + return gitValidator.export(report, format); + }); + register("git-validator:repair", async ({ fullName, check }) => { + const repository = await resolveRepository({ fullName }); + const allowed = new Set([ + "align-origin", + "configure-local-safety", + "add-gitignore", + "add-gitattributes", + "add-editorconfig", + "protect-default-branch", + ]); + const currentCheck = await gitValidator.resolveRepairCheck(repository, check); + if (!allowed.has(currentCheck.fixAction)) + throw new Error("Unsupported Git Validator repair request."); + const result = await gitValidator.repair(repository, currentCheck); + await audit.append("git-validator.repair", { + repository: repository.fullName, + checkId: currentCheck.id, + action: currentCheck.fixAction, + }); + await diagnostics.info("git-validator.repair.completed", { + repository: repository.fullName, + checkId: currentCheck.id, + action: currentCheck.fixAction, + }); + return result; + }); +} + +module.exports = { registerRepositoryIpc }; diff --git a/src/main/log-redaction.cjs b/src/main/log-redaction.cjs new file mode 100644 index 0000000..09ed3bc --- /dev/null +++ b/src/main/log-redaction.cjs @@ -0,0 +1,101 @@ +'use strict'; + +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); + +const SENSITIVE_KEY = /(^|_)(token|password|passwd|authorization|secret|credential|clientsecret|client_secret|apikey|api_key|privatekey|private_key|encryptedtoken|encrypted_token)($|_)/i; +const MAX_DIAGNOSTIC_STRING = 200_000; + +function redactSecrets(value, secrets = []) { + let text = String(value ?? ''); + const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))] + .sort((a, b) => b.length - a.length); + for (const secret of candidates) text = text.split(secret).join('[REDACTED]'); + + text = text + .replace(/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/gi, '[REDACTED PRIVATE KEY]') + .replace(/(authorization\s*[:=]\s*(?:token|bearer|basic)\s+)[^\s,;]+/gi, '$1[REDACTED]') + .replace(/([?&](?:access_token|token|api_key|apikey|key|secret|password)=)[^&#\s]+/gi, '$1[REDACTED]') + .replace(/((?:access_token|token|api_key|apikey|client_secret|password|passwd|secret)\s*[=:]\s*)[^\s,;]+/gi, '$1[REDACTED]') + .replace(/("(?:access_token|token|api_key|apikey|client_secret|password|passwd|secret)"\s*:\s*")[^"]+("?)/gi, '$1[REDACTED]$2') + .replace(/(https?:\/\/[^\s:@/]+:)[^@\s/]+@/gi, '$1[REDACTED]@') + .replace(/\b(?:ghp|github_pat|glpat|gitea)_[A-Za-z0-9_-]{16,}\b/g, '[REDACTED TOKEN]'); + + return text.length > MAX_DIAGNOSTIC_STRING ? `${text.slice(0, MAX_DIAGNOSTIC_STRING)}\n[TRUNCATED]` : text; +} + +function pathAlias(value, { homeDir = os.homedir(), cwd = process.cwd() } = {}) { + let text = String(value ?? ''); + const replacements = [ + [homeDir, ''], + [cwd, ''] + ].filter(([candidate]) => candidate && candidate.length > 3) + .sort((a, b) => b[0].length - a[0].length); + for (const [candidate, replacement] of replacements) { + const normalized = path.resolve(candidate); + text = text.split(normalized).join(replacement); + text = text.split(normalized.replace(/\\/g, '/')).join(replacement); + text = text.split(normalized.replace(/\//g, '\\')).join(replacement); + } + text = text + .replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, '') + .replace(/\/(?:home|Users)\/[^/\s]+/g, ''); + return text; +} + +function stableAlias(value, prefix = 'item') { + const hash = crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 12); + return `${prefix}-${hash}`; +} + +function redactPrivateInfrastructure(value) { + return String(value ?? '') + .replace(/\b(?:10(?:\.\d{1,3}){3}|127(?:\.\d{1,3}){3}|169\.254(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2})\b/g, '') + .replace(/\b(?:https?|ssh):\/\/[^\s"'<>]+/gi, '') + .replace(/\/(?:mnt|srv|opt|var\/lib)\/[^\s"'<>]*/g, ''); +} + +function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) { + const { + secrets = [], + pathMode = 'alias', + homeDir = os.homedir(), + cwd = process.cwd(), + strictIdentifiers = false + } = options; + + if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'string') { + let output = redactSecrets(value, secrets); + if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd }); + if (strictIdentifiers) output = redactPrivateInfrastructure(output); + return output; + } + if (value instanceof Error) { + return sanitizeForDiagnostics({ name: value.name, message: value.message, code: value.code, stack: value.stack }, options, seen); + } + if (Array.isArray(value)) return value.slice(0, 1000).map((item) => sanitizeForDiagnostics(item, options, seen)); + if (typeof value !== 'object') return redactSecrets(String(value), secrets); + if (seen.has(value)) return '[CIRCULAR]'; + seen.add(value); + + const output = {}; + for (const [key, item] of Object.entries(value)) { + const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[-.]/g, '_'); + if (SENSITIVE_KEY.test(normalizedKey)) { + output[key] = '[REDACTED]'; + continue; + } + if (strictIdentifiers && ['full_name', 'repository', 'owner', 'user', 'login', 'email', 'host', 'hostname', 'username', 'base_path', 'private_key_path', 'local_path', 'remote_folder', 'remote_url', 'clone_url', 'status_url', 'healthcheck_url', 'web_ui_url', 'workspace_roots', 'scan_roots'].includes(normalizedKey.toLowerCase())) { + output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase()); + continue; + } + output[key] = sanitizeForDiagnostics(item, options, seen); + } + seen.delete(value); + return output; +} + +module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure, SENSITIVE_KEY }; diff --git a/src/main/preflight-service.cjs b/src/main/preflight-service.cjs new file mode 100644 index 0000000..d52630f --- /dev/null +++ b/src/main/preflight-service.cjs @@ -0,0 +1,208 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { run } = require('./process-runner.cjs'); + +function check(id, label, status, detail, { required = false, help = '' } = {}) { + return { id, label, status, detail, required, help }; +} + +function summarize(checks) { + const counts = checks.reduce((acc, item) => { + acc[item.status] = (acc[item.status] || 0) + 1; + return acc; + }, { pass: 0, warning: 0, fail: 0, skipped: 0 }); + const blocking = checks.filter((item) => item.required && item.status === 'fail'); + return { counts, blocking: blocking.map((item) => item.id), ready: blocking.length === 0 }; +} + +class PreflightService { + constructor({ store, git, gitea, deployments, diagnostics, userDataPath, secureStorageAvailable = () => false }) { + this.store = store; + this.git = git; + this.gitea = gitea; + this.deployments = deployments; + this.diagnostics = diagnostics; + this.userDataPath = userDataPath; + this.secureStorageAvailable = secureStorageAvailable; + } + + async writableDirectory(directory) { + const marker = path.join(directory, `.forgeflow-write-test-${process.pid}-${Date.now()}`); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(marker, 'ok', { mode: 0o600 }); + await fs.rm(marker, { force: true }); + return true; + } + + async gitIdentity() { + const [name, email] = await Promise.all([ + run('git', ['config', '--global', '--get', 'user.name'], { allowExitCodes: [1], timeout: 10_000 }), + run('git', ['config', '--global', '--get', 'user.email'], { allowExitCodes: [1], timeout: 10_000 }) + ]); + return { name: name.stdout.trim(), email: email.stdout.trim() }; + } + + async runSystem({ baseUrl = '', token = '', roots = [] } = {}) { + const startedAt = new Date().toISOString(); + const checks = []; + + const git = await this.git.isAvailable(); + checks.push(check('git.available', 'Git command line', git.available ? 'pass' : 'fail', git.available ? git.version : git.error || 'Git was not found on PATH.', { + required: true, + help: 'Install Git for Windows and ensure git.exe is available on PATH.' + })); + + if (git.available) { + try { + const identity = await this.gitIdentity(); + checks.push(check('git.identity', 'Git author identity', identity.name && identity.email ? 'pass' : 'warning', identity.name && identity.email ? `${identity.name} <${identity.email}>` : 'Global user.name or user.email is missing.', { + help: 'Set git config --global user.name and user.email before creating commits.' + })); + } catch (error) { + checks.push(check('git.identity', 'Git author identity', 'warning', error.message)); + } + } + + try { + await this.writableDirectory(this.userDataPath); + checks.push(check('storage.userdata', 'Application data storage', 'pass', 'ForgeFlow can write its local configuration.', { required: true })); + } catch (error) { + checks.push(check('storage.userdata', 'Application data storage', 'fail', error.message, { required: true })); + } + + try { + await this.writableDirectory(this.diagnostics.logDirectory); + checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'pass', 'The diagnostic directory is writable.', { required: true })); + } catch (error) { + checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'fail', error.message, { required: true })); + } + + checks.push(check('storage.credentials', 'Protected credential storage', this.secureStorageAvailable() ? 'pass' : 'warning', this.secureStorageAvailable() + ? 'The operating system can encrypt the Gitea token at rest.' + : 'OS credential encryption is unavailable; the token will remain session-only.', { + help: 'Use a normal signed-in desktop session and make sure the OS credential service is available.' + })); + + const normalizedRoots = [...new Set((roots || []).map((item) => String(item || '').trim()).filter(Boolean))]; + if (!normalizedRoots.length) { + checks.push(check('workspace.roots', 'Development folders', 'warning', 'No development folder has been selected yet.')); + } else { + for (let index = 0; index < normalizedRoots.length; index += 1) { + const root = normalizedRoots[index]; + try { + const stat = await fs.stat(root); + checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, stat.isDirectory() ? 'pass' : 'fail', stat.isDirectory() ? root : 'The selected path is not a directory.', { required: true })); + } catch (error) { + checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, 'fail', error.message, { required: true })); + } + } + } + + const effectiveBaseUrl = String(baseUrl || this.store.data.gitea.baseUrl || '').trim(); + const effectiveToken = String(token || this.store.getToken() || '').trim(); + let giteaValidation = null; + if (!effectiveBaseUrl || !effectiveToken) { + checks.push(check('gitea.connection', 'Gitea connection', 'warning', 'Enter the Gitea URL and a local access token to test the connection.')); + } else { + try { + giteaValidation = await this.gitea.validateConnection(effectiveBaseUrl, effectiveToken); + checks.push(check('gitea.connection', 'Gitea connection', 'pass', `Connected to Gitea ${giteaValidation.version || 'unknown version'} as ${giteaValidation.user?.login || 'user'}.`, { required: true })); + checks.push(check('gitea.repositories', 'Repository access', giteaValidation.repositoryCount >= 0 ? 'pass' : 'warning', `${giteaValidation.repositoryCount} accessible repositories returned.`)); + } catch (error) { + checks.push(check('gitea.connection', 'Gitea connection', 'fail', error.message, { required: true })); + } + } + + const result = { kind: 'system', startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks), giteaValidation }; + await this.diagnostics.info('preflight.system.completed', { summary: result.summary, checks }); + return result; + } + + async fileExists(filePath) { + const stat = await fs.stat(filePath).catch(() => null); + return Boolean(stat?.isFile()); + } + + async runDeployment({ repository, profileId }) { + const checks = []; + const startedAt = new Date().toISOString(); + if (!repository?.fullName) throw new Error('Repository identity is required.'); + const profile = this.store.getDeploymentProfile(repository.fullName, profileId); + if (!profile) throw new Error('Deployment profile not found.'); + + checks.push(check('repository.linked', 'Local repository link', repository.localPath ? 'pass' : 'fail', repository.localPath || 'No local folder is linked.', { required: true })); + if (!repository.localPath) { + const result = { kind: 'deployment', repository: repository.fullName, profileId, startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks) }; + await this.diagnostics.info('preflight.deployment.completed', result); + return result; + } + + let status = null; + try { + status = await this.git.status(repository.localPath); + checks.push(check('git.repository', 'Git working tree', 'pass', status.root, { required: true })); + checks.push(check('git.branch', 'Allowed branch', status.branch.head === profile.branch ? 'pass' : 'fail', `Current: ${status.branch.head || 'detached'}; required: ${profile.branch}.`, { required: true })); + checks.push(check('git.clean', 'Clean working tree', status.clean ? 'pass' : 'fail', status.clean ? 'No uncommitted changes.' : `${status.counts.changed} changed file(s) remain.`, { required: true })); + checks.push(check('git.upstream', 'Published upstream', status.branch.upstream ? 'pass' : 'fail', status.branch.upstream || 'No upstream branch configured.', { required: true })); + checks.push(check('git.sync', 'Local and Gitea synchronized', !status.branch.ahead && !status.branch.behind ? 'pass' : 'fail', `${status.branch.ahead || 0} ahead, ${status.branch.behind || 0} behind.`, { required: true })); + if (status.head) { + try { + await this.git.verifyCommitOnRemoteBranch(repository.localPath, status.head, profile.branch); + checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'pass', `${status.head.slice(0, 7)} exists on origin/${profile.branch}.`, { required: true })); + } catch (error) { + checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'fail', error.message, { required: true })); + } + } + } catch (error) { + checks.push(check('git.repository', 'Git working tree', 'fail', error.message, { required: true })); + } + + const workflowPath = path.join(repository.localPath, '.gitea', 'workflows', profile.workflowFile); + checks.push(check('workflow.deploy.local', 'Deploy workflow in local repository', await this.fileExists(workflowPath) ? 'pass' : 'fail', workflowPath, { required: true })); + if (profile.rollbackWorkflowFile) { + const rollbackPath = path.join(repository.localPath, '.gitea', 'workflows', profile.rollbackWorkflowFile); + checks.push(check('workflow.rollback.local', 'Rollback workflow in local repository', await this.fileExists(rollbackPath) ? 'pass' : 'warning', rollbackPath)); + } + + try { + const [owner, repo] = repository.fullName.split('/'); + const remoteWorkflow = await this.gitea.repositoryFileExists({ owner, repo, filePath: `.gitea/workflows/${profile.workflowFile}`, ref: profile.branch }); + checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', remoteWorkflow ? 'pass' : 'fail', remoteWorkflow ? `${profile.workflowFile} exists on ${profile.branch}.` : `${profile.workflowFile} is not present on ${profile.branch}.`, { required: true })); + try { + await this.gitea.listWorkflowRuns({ owner, repo, branch: profile.branch, limit: 1 }); + checks.push(check('gitea.actions', 'Gitea Actions API', 'pass', 'The Actions runs endpoint is accessible.', { required: true })); + } catch (error) { + checks.push(check('gitea.actions', 'Gitea Actions API', 'fail', error.message, { required: true })); + } + } catch (error) { + checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', 'fail', error.message, { required: true })); + } + + if (profile.statusUrl) { + const state = await this.deployments.readStatusEndpoint(profile.statusUrl); + checks.push(check('server.status.configured', 'Server version endpoint configured', 'pass', profile.statusUrl, { required: true })); + checks.push(check('server.status.reachable', 'Server version endpoint reachable', state.reachable && state.ok ? 'pass' : 'warning', state.reachable && state.ok ? `Endpoint reachable${state.liveSha ? `; live ${state.liveSha.slice(0, 7)}` : '; no live SHA reported yet'}.` : state.error || `HTTP ${state.status || 'unavailable'}.`, { help: 'The first deployment may create the status file. Successful completion still requires the endpoint to return the exact SHA and request ID.' })); + if (state.reachable && state.ok) { + const identityMatches = (!state.repository || state.repository === repository.fullName) && (!state.environment || state.environment === profile.environment); + checks.push(check('server.status.identity', 'Status endpoint target identity', identityMatches ? (state.repository && state.environment ? 'pass' : 'warning') : 'fail', state.repository && state.environment ? `${state.repository} / ${state.environment}` : 'Repository or environment is not present in the current status document.', { required: !identityMatches })); + } + } else checks.push(check('server.status.configured', 'Server version endpoint configured', 'fail', 'A status URL is required for exact post-deployment verification.', { required: true })); + + if (profile.healthcheckUrl) { + const health = await this.deployments.checkHealth(profile.healthcheckUrl); + checks.push(check('server.health', 'Application healthcheck', health.healthy ? 'pass' : 'warning', health.healthy ? `HTTP ${health.status} in ${health.latencyMs} ms.` : health.error || `HTTP ${health.status || 'unavailable'}.`)); + } else checks.push(check('server.health', 'Application healthcheck', 'warning', 'No healthcheck URL is configured.')); + + const result = { + kind: 'deployment', repository: repository.fullName, profileId, profileName: profile.name, + startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks), + head: status?.head || null + }; + await this.diagnostics.info('preflight.deployment.completed', { repository: repository.fullName, profileId, summary: result.summary, checks }); + return result; + } +} + +module.exports = { PreflightService, summarize, check }; diff --git a/src/main/process-error-policy.cjs b/src/main/process-error-policy.cjs new file mode 100644 index 0000000..118c54f --- /dev/null +++ b/src/main/process-error-policy.cjs @@ -0,0 +1,26 @@ +'use strict'; + +function isBrokenPipeError(error) { + return error?.code === 'EPIPE'; +} + +function installOutputPipeGuards({ + stdout = process.stdout, + stderr = process.stderr, + onBrokenPipe = () => {} +} = {}) { + const guardedStreams = [stdout, stderr].filter(Boolean); + const handlers = guardedStreams.map((stream) => { + const handler = (error) => { + if (!isBrokenPipeError(error)) throw error; + onBrokenPipe(error); + }; + stream.on('error', handler); + return { stream, handler }; + }); + return () => { + for (const { stream, handler } of handlers) stream.off('error', handler); + }; +} + +module.exports = { installOutputPipeGuards, isBrokenPipeError }; diff --git a/src/main/process-runner.cjs b/src/main/process-runner.cjs new file mode 100644 index 0000000..4f9070c --- /dev/null +++ b/src/main/process-runner.cjs @@ -0,0 +1,50 @@ +'use strict'; + +const { execFile } = require('node:child_process'); + +function run(command, args = [], options = {}) { + const { + cwd, + timeout = 60_000, + maxBuffer = 8 * 1024 * 1024, + env, + input = null, + allowExitCodes = [] + } = options; + + return new Promise((resolve, reject) => { + const child = execFile(command, args, { + cwd, + timeout, + maxBuffer, + windowsHide: true, + encoding: 'utf8', + env: { ...process.env, ...(env || {}) } + }, (error, stdout, stderr) => { + if (error && !allowExitCodes.includes(error.code)) { + const message = (stderr || stdout || error.message).trim(); + const wrapped = new Error(message); + wrapped.code = error.code; + if (/\.git[\\/]index\.lock[\s\S]*File exists/i.test(message) || /Unable to create .*index\.lock/i.test(message)) { + wrapped.code = 'GIT_INDEX_LOCKED'; + wrapped.recoverable = true; + } else if (error.code === 'ENAMETOOLONG') { + wrapped.code = 'GIT_ARGUMENT_LIST_TOO_LONG'; + wrapped.recoverable = true; + } + wrapped.stdout = stdout; + wrapped.stderr = stderr; + wrapped.command = `${command} ${args.join(' ')}`; + reject(wrapped); + return; + } + resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 }); + }); + if (input !== null && input !== undefined) { + child.stdin.on('error', () => {}); + child.stdin.end(input); + } + }); +} + +module.exports = { run }; diff --git a/src/main/production-acceptance-harness.cjs b/src/main/production-acceptance-harness.cjs new file mode 100644 index 0000000..8188fb7 --- /dev/null +++ b/src/main/production-acceptance-harness.cjs @@ -0,0 +1,149 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { run } = require("./process-runner.cjs"); + +class ProductionAcceptanceHarness { + constructor(root) { + this.root = root; + this.paths = { + remote: path.join(root, "gitea", "owner", "app.git"), + source: path.join(root, "workspace", "app"), + server: path.join(root, "server", "appdata", "app"), + releases: path.join(root, "releases"), + config: path.join(root, "user-data", "forgeflow-config.json"), + keys: path.join(root, "keys"), + }; + this.state = { installed: false, version: null, tokenVersion: 1, auth: null, liveSha: null, previousSha: null, healthy: false, deployment: null, recovery: null, hostFingerprint: "SHA256:fixture-host", keyReadOnly: true }; + } + + static async create() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "forgeflow-production-acceptance-")); + const harness = new ProductionAcceptanceHarness(root); + await harness.provision(); + return harness; + } + + async provision() { + await Promise.all(Object.values(this.paths).filter((value) => !path.extname(value)).map((directory) => fs.mkdir(directory, { recursive: true }))); + await fs.mkdir(path.dirname(this.paths.remote), { recursive: true }); + await run("git", ["init", "--bare", this.paths.remote], { cwd: this.root, timeout: 30_000 }); + await fs.mkdir(this.paths.source, { recursive: true }); + await run("git", ["init", "-b", "main"], { cwd: this.paths.source, timeout: 30_000 }); + await run("git", ["config", "user.name", "ForgeFlow Acceptance"], { cwd: this.paths.source }); + await run("git", ["config", "user.email", "acceptance@example.invalid"], { cwd: this.paths.source }); + await fs.writeFile(path.join(this.paths.source, "compose.yml"), "services:\n app:\n image: forgeflow-fixture:latest\n", "utf8"); + await fs.writeFile(path.join(this.paths.source, "README.md"), "# Acceptance fixture\n", "utf8"); + await run("git", ["add", "."], { cwd: this.paths.source }); + await run("git", ["commit", "-m", "feat: initial fixture"], { cwd: this.paths.source }); + await run("git", ["remote", "add", "origin", this.paths.remote], { cwd: this.paths.source }); + await run("git", ["push", "-u", "origin", "main"], { cwd: this.paths.source, timeout: 30_000 }); + this.initialSha = (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim(); + await fs.mkdir(this.paths.releases, { recursive: true }); + await fs.mkdir(path.dirname(this.paths.config), { recursive: true }); + await fs.mkdir(this.paths.keys, { recursive: true }); + await fs.writeFile(path.join(this.paths.keys, "deploy_key.pub"), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture forgeflow-acceptance\n", "utf8"); + } + + async cleanup() { await fs.rm(this.root, { recursive: true, force: true }); } + + async install(version = "0.10.0", mode = "installed") { + this.state.installed = true; this.state.version = version; this.state.mode = mode; + await this.saveConfig({ schemaVersion: 12, version, mode }); + return structuredClone(this.state); + } + + async migrate(targetVersion = "1.0.0") { + if (!this.state.installed) throw new Error("Clean installation is required before migration."); + const previous = JSON.parse(await fs.readFile(this.paths.config, "utf8")); + await fs.writeFile(`${this.paths.config}.backup`, JSON.stringify(previous, null, 2), "utf8"); + this.state.version = targetVersion; + await this.saveConfig({ ...previous, schemaVersion: 13, version: targetVersion, migratedAt: new Date().toISOString() }); + return { previousVersion: previous.version, version: targetVersion, backup: `${this.paths.config}.backup` }; + } + + async saveConfig(data) { await fs.writeFile(this.paths.config, `${JSON.stringify(data, null, 2)}\n`, "utf8"); } + rotateToken() { this.state.tokenVersion += 1; return { tokenVersion: this.state.tokenVersion }; } + authenticate(type, options = {}) { + if (!['password', 'ssh-key'].includes(type)) throw new Error("Unsupported authentication fixture."); + if (type === 'ssh-key' && options.hostFingerprint !== this.state.hostFingerprint) throw new Error("SSH host fingerprint changed."); + this.state.auth = type; return { authenticated: true, type }; + } + setKeyAccess(readOnly) { this.state.keyReadOnly = readOnly; } + + async createCommit(message = "fix: acceptance change") { + const target = path.join(this.paths.source, "fixture.txt"); + await fs.writeFile(target, `${crypto.randomUUID()}\n`, "utf8"); + await run("git", ["add", "fixture.txt"], { cwd: this.paths.source }); + await run("git", ["commit", "-m", message], { cwd: this.paths.source }); + await run("git", ["push", "origin", "main"], { cwd: this.paths.source }); + return (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim(); + } + + plan(sha, mode = "server-git") { + return { id: crypto.randomUUID(), evidenceHash: crypto.createHash("sha256").update(JSON.stringify({ sha, mode, liveSha: this.state.liveSha, keyReadOnly: this.state.keyReadOnly })).digest("hex"), sha, mode, previousSha: this.state.liveSha }; + } + + async deploy(plan, fault = null) { + if (!this.state.auth) throw new Error("Server authentication is required."); + if (plan.mode === "server-git" && !this.state.keyReadOnly) throw new Error("Writable deploy key rejected."); + if (this.plan(plan.sha, plan.mode).evidenceHash !== plan.evidenceHash) throw new Error("Stale reconciliation plan."); + this.state.recovery = structuredClone(this.state); + this.state.deployment = { id: crypto.randomUUID(), sha: plan.sha, mode: plan.mode, status: "running" }; + if (fault === "fetch-network") return this.fail("Network interrupted during fetch", false); + await fs.mkdir(this.paths.server, { recursive: true }); + await fs.writeFile(path.join(this.paths.server, "compose.yml"), await fs.readFile(path.join(this.paths.source, "compose.yml"))); + if (fault === "activation-network") return this.fail("Network interrupted during activation", true); + if (fault === "shutdown") { this.state.deployment.status = "interrupted"; return structuredClone(this.state.deployment); } + this.state.previousSha = this.state.liveSha; + this.state.liveSha = plan.sha; + this.state.healthy = fault !== "unhealthy"; + this.state.deployment.status = this.state.healthy ? "success" : "failed"; + return structuredClone(this.state.deployment); + } + + fail(message, partial) { this.state.deployment.status = "failed"; this.state.deployment.failure = { message, partial }; return structuredClone(this.state.deployment); } + recover() { + if (this.state.deployment?.status !== "interrupted") throw new Error("No interrupted deployment to recover."); + this.state.deployment.status = this.state.liveSha === this.state.deployment.sha && this.state.healthy ? "success" : "failed"; + return structuredClone(this.state.deployment); + } + rollback(targetSha) { + if (!targetSha || targetSha !== this.state.previousSha) throw new Error("Rollback target is not the exact recorded previous SHA."); + [this.state.liveSha, this.state.previousSha] = [targetSha, this.state.liveSha]; this.state.healthy = true; + return { status: "rolled-back", liveSha: this.state.liveSha }; + } + + adoptExisting(sha = this.initialSha) { this.state.liveSha = sha; this.state.healthy = true; return { linked: true, liveSha: sha, preserved: true }; } + externalUpdate(sha) { this.state.liveSha = sha; this.state.healthy = true; return { reconciled: true, liveSha: sha }; } + rotateDeployKey() { if (!this.state.keyReadOnly) throw new Error("Candidate deploy key is writable."); this.state.keyVersion = (this.state.keyVersion || 1) + 1; return { rotated: true, keyVersion: this.state.keyVersion }; } + revokeDeployKey() { this.state.keyRevoked = true; return { revoked: true, deploymentBlocked: true }; } + restoreDeployKey() { this.state.keyRevoked = false; this.state.keyReadOnly = true; return { restored: true }; } + inventory(count = 20, partial = false) { return { workloads: Array.from({ length: count }, (_, index) => ({ id: `workload-${index + 1}`, classification: index === 1 ? "duplicate" : "active" })), partial, warnings: partial ? ["One scan root was unavailable"] : [] }; } + + async publishRelease(version, options = {}) { + const binary = Buffer.from(options.binary || "MZ-forgeflow-acceptance-binary"); + const name = `ForgeFlow-Portable-${version}-win-x64.exe`; + const checksum = crypto.createHash("sha256").update(binary).digest("hex"); + const manifest = { version, draft: options.draft === true, assets: options.missingAsset ? [] : [{ name, sha256: options.badChecksum ? "0".repeat(64) : checksum }], provenance: { commitSha: options.commitSha || this.initialSha }, sbom: { bomFormat: "CycloneDX" } }; + await fs.writeFile(path.join(this.paths.releases, `${version}.json`), JSON.stringify(manifest, null, 2)); + if (!options.missingAsset) await fs.writeFile(path.join(this.paths.releases, name), binary); + return manifest; + } + + async verifyRelease(version) { + const manifest = JSON.parse(await fs.readFile(path.join(this.paths.releases, `${version}.json`), "utf8")); + if (manifest.draft) throw new Error("Incomplete draft release rejected."); + const asset = manifest.assets[0]; + if (!asset) throw new Error("Required release asset is missing."); + const binary = await fs.readFile(path.join(this.paths.releases, asset.name)); + if (crypto.createHash("sha256").update(binary).digest("hex") !== asset.sha256) throw new Error("Release checksum mismatch."); + if (!manifest.provenance?.commitSha || manifest.sbom?.bomFormat !== "CycloneDX") throw new Error("Release provenance or SBOM is missing."); + return { verified: true, version, asset: asset.name }; + } +} + +module.exports = { ProductionAcceptanceHarness }; diff --git a/src/main/repository-monitor.cjs b/src/main/repository-monitor.cjs new file mode 100644 index 0000000..a319c10 --- /dev/null +++ b/src/main/repository-monitor.cjs @@ -0,0 +1,229 @@ +'use strict'; + +const fs = require('node:fs'); + +// A watched repository is only re-read when the filesystem reports activity. The +// interval below stays as a safety net for watchers that silently stop +// delivering, which happens on network shares and removed folders. +const SAFETY_CHECK_INTERVAL_MS = 30_000; +const WATCH_DEBOUNCE_MS = 250; +// Busy trees (a build, an install, a fetch) produce a continuous event stream. +// This bounds how often that can turn into a Git read. +const MIN_WATCH_CHECK_INTERVAL_MS = 1_000; + +class RepositoryMonitor { + constructor({ store, git, onChange, diagnostics = null }) { + this.store = store; + this.git = git; + this.onChange = onChange; + this.diagnostics = diagnostics; + this.paths = []; + this.fingerprints = new Map(); + this.timer = null; + this.running = false; + this.paused = new Set(); + this.active = false; + this.watchers = new Map(); + this.changed = new Set(); + this.lastCheckedAt = new Map(); + this.lastFetchedAt = new Map(); + this.watchTimer = null; + this.fetchRunning = false; + } + + setPaths(paths) { + this.paths = [...new Set((paths || []).filter(Boolean))]; + const watched = new Set(this.paths); + for (const existing of [...this.fingerprints.keys()]) { + if (!watched.has(existing)) this.fingerprints.delete(existing); + } + // A repository that is unlinked while a mutation holds it paused would keep + // that pause forever, silently freezing its status once it is watched again. + for (const existing of [...this.paused]) { + if (!watched.has(existing)) this.paused.delete(existing); + } + for (const existing of [...this.changed]) { + if (!watched.has(existing)) this.changed.delete(existing); + } + for (const existing of [...this.lastCheckedAt.keys()]) { + if (!watched.has(existing)) this.lastCheckedAt.delete(existing); + } + for (const existing of [...this.lastFetchedAt.keys()]) { + if (!watched.has(existing)) this.lastFetchedAt.delete(existing); + } + const now = Date.now(); + for (const localPath of this.paths) { + if (!this.lastFetchedAt.has(localPath)) this.lastFetchedAt.set(localPath, now); + } + this.syncWatchers(); + } + + syncWatchers() { + for (const [localPath, watcher] of [...this.watchers]) { + if (this.active && this.paths.includes(localPath)) continue; + this.closeWatcher(localPath, watcher); + } + if (!this.active) return; + for (const localPath of this.paths) { + if (this.watchers.has(localPath)) continue; + try { + const watcher = fs.watch( + localPath, + { recursive: true, persistent: false }, + () => this.noteFilesystemChange(localPath) + ); + watcher.on('error', () => this.dropWatcher(localPath)); + this.watchers.set(localPath, watcher); + } catch { + // Watching is unavailable for this folder. Leaving it unwatched makes + // shouldCheck() fall back to the interval for that repository only. + } + } + } + + closeWatcher(localPath, watcher = this.watchers.get(localPath)) { + if (!watcher) return; + try { watcher.close(); } catch { /* already closed */ } + this.watchers.delete(localPath); + } + + dropWatcher(localPath) { + this.closeWatcher(localPath); + this.changed.add(localPath); + } + + noteFilesystemChange(localPath) { + this.changed.add(localPath); + this.scheduleWatchTick(); + } + + scheduleWatchTick() { + if (this.watchTimer) return; + this.watchTimer = setTimeout(() => { + this.watchTimer = null; + this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)); + }, WATCH_DEBOUNCE_MS); + this.watchTimer.unref?.(); + } + + shouldCheck(localPath, now) { + if (this.paused.has(localPath)) return false; + if (!this.watchers.has(localPath)) return true; + const sinceLastCheck = now - (this.lastCheckedAt.get(localPath) || 0); + if (this.changed.has(localPath)) return sinceLastCheck >= MIN_WATCH_CHECK_INTERVAL_MS; + return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS; + } + + fetchIntervalMs() { + const minutes = Number(this.store.data.preferences.fetchIntervalMinutes); + return Number.isFinite(minutes) && minutes > 0 ? Math.min(minutes, 240) * 60_000 : 0; + } + + shouldFetch(localPath, now) { + const interval = this.fetchIntervalMs(); + return interval > 0 + && !this.paused.has(localPath) + && now - (this.lastFetchedAt.get(localPath) || now) >= interval; + } + + async recordStatus(localPath, status, reason) { + const next = this.git.statusFingerprint(status); + const previous = this.fingerprints.get(localPath); + this.fingerprints.set(localPath, next); + if (previous && previous !== next) { + await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts, reason }); + this.onChange?.({ localPath, status, reason }); + } + } + + async fetchRemoteUpdates(now = Date.now()) { + if (this.fetchRunning) return; + const queue = this.paths.filter((localPath) => this.shouldFetch(localPath, now)); + if (!queue.length) return; + this.fetchRunning = true; + try { + const workers = Array.from({ length: Math.min(2, queue.length) }, async () => { + while (queue.length) { + const localPath = queue.shift(); + // Mark the attempt before awaiting the network. A failing remote should + // not be retried every local poll interval. + this.lastFetchedAt.set(localPath, Date.now()); + try { + const result = await this.git.fetch(localPath); + await this.recordStatus(localPath, result.status, 'remote-state-changed'); + await this.diagnostics?.debug('repository-monitor.fetch.completed', { + localPath, + branch: result.status?.branch?.head, + ahead: result.status?.branch?.ahead, + behind: result.status?.branch?.behind, + }); + } catch (error) { + await this.diagnostics?.warning('repository-monitor.fetch.failed', { localPath, message: error.message }); + } + } + }); + await Promise.all(workers); + } finally { + this.fetchRunning = false; + } + } + + pause(localPath) { if (localPath) this.paused.add(localPath); } + resume(localPath) { if (localPath) this.paused.delete(localPath); } + + restart() { + this.stop(); + if (!this.store.data.preferences.autoRefresh) return; + this.active = true; + this.syncWatchers(); + const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60); + this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000); + this.timer.unref?.(); + } + + stop() { + if (this.timer) clearInterval(this.timer); + this.timer = null; + if (this.watchTimer) clearTimeout(this.watchTimer); + this.watchTimer = null; + this.active = false; + this.syncWatchers(); + } + + async tick() { + void this.fetchRemoteUpdates().catch((error) => this.diagnostics?.warning('repository-monitor.fetch-cycle.failed', error)); + if (this.running || !this.paths.length) return; + this.running = true; + try { + const now = Date.now(); + const queue = this.paths.filter((localPath) => this.shouldCheck(localPath, now)); + const workers = Array.from({ length: Math.min(4, queue.length) }, async () => { + while (queue.length) { + const localPath = queue.shift(); + this.changed.delete(localPath); + this.lastCheckedAt.set(localPath, Date.now()); + try { + const status = await this.git.status(localPath); + await this.recordStatus(localPath, status, 'working-tree-changed'); + } catch (error) { + const next = `error:${error.message}`; + const previous = this.fingerprints.get(localPath); + this.fingerprints.set(localPath, next); + if (previous && previous !== next) { + await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message }); + this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' }); + } + } + } + }); + await Promise.all(workers); + } finally { + this.running = false; + // Activity that arrived while the check was running keeps its flag set, so + // it must not wait for the safety interval. + if (this.active && this.changed.size) this.scheduleWatchTick(); + } + } +} + +module.exports = { RepositoryMonitor, SAFETY_CHECK_INTERVAL_MS, WATCH_DEBOUNCE_MS, MIN_WATCH_CHECK_INTERVAL_MS }; diff --git a/src/main/repository-service.cjs b/src/main/repository-service.cjs new file mode 100644 index 0000000..797f3dd --- /dev/null +++ b/src/main/repository-service.cjs @@ -0,0 +1,303 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { matchRemoteToRepository, repositoryKey } = require('../shared/repository-match.cjs'); + +const SKIP_DIRECTORIES = new Set([ + '.git', '.svn', '.hg', 'node_modules', '.next', '.nuxt', 'dist', 'build', 'coverage', + '.cache', '.venv', 'venv', '__pycache__', '$RECYCLE.BIN', 'System Volume Information' +]); + +async function mapLimit(items, limit, mapper) { + const output = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const index = cursor++; + output[index] = await mapper(items[index], index); + } + }); + await Promise.all(workers); + return output; +} + +class RepositoryService { + constructor(store, gitService, giteaService, diagnostics = null) { + this.store = store; + this.git = gitService; + this.gitea = giteaService; + this.diagnostics = diagnostics; + this.lastKnownLocalPaths = []; + this.lastKnownRemoteRepositories = []; + this.lastSuccessfulRemoteRefreshAt = null; + this.lastRemoteRefreshAtMs = 0; + this.lastDiscoveredPaths = []; + this.lastDiscoveryAtMs = 0; + this.refreshPromise = null; + this.lastResult = null; + } + + async discoverInRoot(root, maxDepth = 4) { + const found = []; + const seen = new Set(); + + const visit = async (directory, depth) => { + let real; + try { real = await fs.realpath(directory); } catch { return; } + if (seen.has(real)) return; + seen.add(real); + + const gitMarker = path.join(directory, '.git'); + const marker = await fs.stat(gitMarker).catch(() => null); + if (marker) { + found.push(real); + return; + } + if (depth >= maxDepth) return; + + let entries; + try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; } + // Directory entries report as a symbolic link instead of a directory, which + // is how Windows junctions surface. Skipping those made a project folder + // that is mapped through a junction invisible; visit() resolves each entry + // and the `seen` set above keeps links that point back into the tree from + // being scanned twice. + await mapLimit(entries + .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !SKIP_DIRECTORIES.has(entry.name)), 12, + (entry) => visit(path.join(real, entry.name), depth + 1)); + }; + + await visit(root, 0); + return found; + } + + async discoverAll(roots) { + const grouped = await mapLimit((roots || []).filter(Boolean), 4, (root) => this.discoverInRoot(root)); + return [...new Set(grouped.flat())]; + } + + async getLocalDescriptors(paths) { + return mapLimit(paths, 5, async (localPath) => { + try { + const status = await this.git.status(localPath); + return { localPath: status.root, remoteUrl: status.remoteUrl, status }; + } catch (error) { + return { localPath, remoteUrl: '', status: null, error: error.message }; + } + }); + } + + getWatchPaths() { + return [...this.lastKnownLocalPaths]; + } + + async getRemoteRepositories({ force = false } = {}) { + if (!this.store.data.gitea.baseUrl || !this.store.getToken()) { + this.lastKnownRemoteRepositories = []; + this.lastSuccessfulRemoteRefreshAt = null; + return { repositories: [], stale: false, error: null }; + } + + if (!force && this.lastSuccessfulRemoteRefreshAt && Date.now() - this.lastRemoteRefreshAtMs < 15_000) { + return { + repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })), + stale: false, + error: null, + cached: true + }; + } + + try { + const repositories = await this.gitea.listRepositories(); + this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository })); + this.lastSuccessfulRemoteRefreshAt = new Date().toISOString(); + this.lastRemoteRefreshAtMs = Date.now(); + return { repositories, stale: false, error: null }; + } catch (error) { + if (!this.lastSuccessfulRemoteRefreshAt) throw error; + await this.diagnostics?.warning('repositories.remote-refresh.degraded', { + message: error.message, + cachedCount: this.lastKnownRemoteRepositories.length, + lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt + }); + return { + repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })), + stale: true, + error: error.message + }; + } + } + + async getDiscoveredPaths({ force = false } = {}) { + if (!force && this.lastDiscoveryAtMs && Date.now() - this.lastDiscoveryAtMs < 30_000) { + return [...this.lastDiscoveredPaths]; + } + const paths = await this.discoverAll(this.store.data.workspaceRoots); + this.lastDiscoveredPaths = [...paths]; + this.lastDiscoveryAtMs = Date.now(); + return paths; + } + + // Resolving a single repository used to go through a full refresh, which runs + // `git status` for every discovered repository. Handlers that act on one + // repository only need that one, so its local state is read directly. Anything + // this cannot answer confidently still falls back to the full scan. + async resolveByFullName(fullName) { + const name = String(fullName || '').trim(); + if (!name) return null; + const fromFullRefresh = async () => (await this.refresh()).find((item) => item.fullName === name) || null; + + const remoteResult = await this.getRemoteRepositories({}); + const remote = remoteResult.repositories.find((item) => item.full_name === name); + if (!remote) return fromFullRefresh(); + + const explicitPath = this.store.data.repositoryMappings[repositoryKey(remote)]; + const knownPath = explicitPath || (this.lastResult || []).find((item) => item.fullName === name)?.localPath || null; + // Without a known path the link can still exist through remote-URL matching, + // which only the discovery pass can establish. + if (!knownPath && !this.lastResult) return fromFullRefresh(); + + const local = knownPath ? (await this.getLocalDescriptors([knownPath]))[0] : null; + const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({ + ...profile, + state: this.store.getDeploymentState(profile.id) + })); + return { + ...this.decorate(remote, local, profiles), + remoteStale: remoteResult.stale, + remoteRefreshError: remoteResult.error, + remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt + }; + } + + async refresh(options = {}) { + if (this.refreshPromise) return this.refreshPromise; + this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; }); + return this.refreshPromise; + } + + async performRefresh({ force = false } = {}) { + const started = Date.now(); + const remoteResult = await this.getRemoteRepositories({ force }); + const remoteRepositories = remoteResult.repositories; + + const discoveredPaths = await this.getDiscoveredPaths({ force }); + const mappedPaths = Object.values(this.store.data.repositoryMappings || {}); + const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])]; + const localDescriptors = await this.getLocalDescriptors(localPaths); + this.lastKnownLocalPaths = localDescriptors.filter((item) => item.status).map((item) => item.status.root); + + const usedLocalPaths = new Set(); + const repositories = []; + + for (const remote of remoteRepositories) { + const key = repositoryKey(remote); + const explicitPath = this.store.data.repositoryMappings[key]; + let local = explicitPath ? localDescriptors.find((item) => path.resolve(item.localPath) === path.resolve(explicitPath)) : null; + if (!local) local = localDescriptors.find((item) => !usedLocalPaths.has(item.localPath) && matchRemoteToRepository(item.remoteUrl, [remote])); + if (local) usedLocalPaths.add(local.localPath); + + const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({ + ...profile, + state: this.store.getDeploymentState(profile.id) + })); + repositories.push({ + ...this.decorate(remote, local, profiles), + remoteStale: remoteResult.stale, + remoteRefreshError: remoteResult.error, + remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt + }); + } + + for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) { + const name = path.basename(local.localPath); + repositories.push({ + id: `local:${local.localPath}`, + name, + fullName: name, + owner: { login: 'local' }, + description: 'Local repository not matched to Gitea', + private: true, + defaultBranch: local.status?.branch.head || 'main', + htmlUrl: null, + cloneUrl: null, + sshUrl: null, + preferredCloneUrl: null, + localPath: local.localPath, + localStatus: local.status, + linkState: 'unmatched-local', + deploymentProfiles: [], + readyToDeploy: false, + favorite: false, + attention: Boolean(local.error), + attentionReason: local.error || null + }); + } + + const sorted = repositories.sort((a, b) => { + const score = (repo) => (repo.attention ? 100 : 0) + + (repo.localStatus?.counts.changed ? 50 : 0) + + (repo.localStatus?.branch.ahead ? 30 : 0) + + (repo.readyToDeploy ? 20 : 0) + + (repo.favorite ? 5 : 0); + return score(b) - score(a) || a.fullName.localeCompare(b.fullName); + }); + await this.diagnostics?.debug('repositories.refresh.completed', { + durationMs: Date.now() - started, + remoteCount: remoteRepositories.length, + remoteStale: remoteResult.stale, + remoteCached: remoteResult.cached === true, + discoveredCount: discoveredPaths.length, + linkedCount: sorted.filter((item) => item.localPath).length, + attentionCount: sorted.filter((item) => item.attention).length, + readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length + }); + this.lastResult = sorted; + return sorted; + } + + decorate(remote, local, profiles) { + const status = local?.status || null; + const hasChanges = Boolean(status?.counts.changed); + const ahead = status?.branch.ahead || 0; + const behind = status?.branch.behind || 0; + const conflict = Boolean(status?.counts.conflicts); + const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head); + const synchronized = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0); + const alreadyLiveAndHealthy = Boolean( + synchronized + && profileForBranch?.state?.liveSha === status.head + && profileForBranch?.state?.healthy !== false + ); + const readyToDeploy = synchronized && !alreadyLiveAndHealthy; + const key = String(remote.full_name || '').toLowerCase(); + const preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh' + ? (remote.ssh_url || remote.clone_url) + : (remote.clone_url || remote.ssh_url); + return { + id: remote.id, + name: remote.name, + fullName: remote.full_name, + owner: remote.owner, + description: remote.description || '', + private: remote.private, + defaultBranch: remote.default_branch || 'main', + htmlUrl: remote.html_url, + cloneUrl: remote.clone_url, + sshUrl: remote.ssh_url, + preferredCloneUrl, + updatedAt: remote.updated_at, + localPath: local?.localPath || null, + localStatus: status, + linkState: local ? 'linked' : 'remote-only', + deploymentProfiles: profiles, + readyToDeploy, + favorite: (this.store.data.favorites || []).includes(key), + attention: conflict || behind > 0 || Boolean(local?.error), + attentionReason: conflict ? 'Merge conflict' : behind > 0 ? `${behind} commit${behind === 1 ? '' : 's'} behind remote` : local?.error || null + }; + } +} + +module.exports = { RepositoryService, SKIP_DIRECTORIES, mapLimit }; diff --git a/src/main/server-inventory.cjs b/src/main/server-inventory.cjs new file mode 100644 index 0000000..1eb6d9a --- /dev/null +++ b/src/main/server-inventory.cjs @@ -0,0 +1,577 @@ +'use strict'; + +const crypto = require('node:crypto'); +const path = require('node:path').posix; +const { normalizeRemoteUrl } = require('../shared/repository-match.cjs'); + +function decodeBase64(value) { + try { return Buffer.from(String(value || ''), 'base64').toString('utf8'); } + catch { return ''; } +} + +function remoteIdentity(value) { + const normalized = normalizeRemoteUrl(value); + return normalized ? `${normalized.host}/${normalized.path}` : ''; +} + +function normalizedName(value) { + return String(value || '').toLowerCase().replace(/\.git$/i, '').replace(/[^a-z0-9]/g, ''); +} + +function safeJson(value, fallback) { + try { return JSON.parse(value); } + catch { return fallback; } +} + +function sanitizeLegacyContainer(container) { + const labels = container?.Config?.Labels || {}; + return { + id: container?.Id || '', + name: String(container?.Name || '').replace(/^\//, ''), + image: container?.Config?.Image || '', + imageId: container?.Image || '', + running: container?.State?.Running === true, + status: container?.State?.Status || '', + health: container?.State?.Health?.Status || null, + labels: { + 'com.docker.compose.project': labels['com.docker.compose.project'] || '', + 'com.docker.compose.project.working_dir': labels['com.docker.compose.project.working_dir'] || '', + 'com.docker.compose.project.config_files': labels['com.docker.compose.project.config_files'] || '', + 'com.docker.compose.service': labels['com.docker.compose.service'] || '', + 'org.opencontainers.image.source': labels['org.opencontainers.image.source'] || '', + 'org.opencontainers.image.revision': labels['org.opencontainers.image.revision'] || '', + 'tech.itworx.forgeflow.repository': labels['tech.itworx.forgeflow.repository'] || '', + 'tech.itworx.forgeflow.commit': labels['tech.itworx.forgeflow.commit'] || '', + 'tech.itworx.forgeflow.branch': labels['tech.itworx.forgeflow.branch'] || '', + 'net.unraid.docker.webui': labels['net.unraid.docker.webui'] || '', + 'net.unraid.docker.icon': labels['net.unraid.docker.icon'] || '', + 'net.unraid.docker.shell': labels['net.unraid.docker.shell'] || '', + 'net.unraid.docker.managed': labels['net.unraid.docker.managed'] || '', + }, + ports: container?.NetworkSettings?.Ports || {}, + mounts: Array.isArray(container?.Mounts) ? container.Mounts : [], + networks: container?.NetworkSettings?.Networks || {}, + restartPolicy: container?.HostConfig?.RestartPolicy?.Name || '', + }; +} + +function parseServerInventory(output) { + const marker = '__FORGEFLOW_INVENTORY__'; + const index = String(output || '').lastIndexOf(marker); + if (index < 0) throw new Error('The server did not return a ForgeFlow workload inventory.'); + const inventory = { + capabilities: {}, + checkouts: [], + containers: [], + dockerMan: [], + composeProjects: [], + composeDefinitions: [], + warnings: [], + }; + for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) { + if (!line) continue; + const [kind, ...parts] = line.split('\t'); + if (kind === 'H') { + inventory.capabilities = { + docker: parts[0] === 'true', + compose: parts[1] === 'true', + git: parts[2] === 'true', + tar: parts[3] === 'true', + checksum: parts[4] === 'true', + baseWritable: parts[5] === 'true', + composeVersion: decodeBase64(parts[6]), + platform: decodeBase64(parts[7]), + }; + } else if (kind === 'R' && parts.length >= 4) { + inventory.checkouts.push({ + root: decodeBase64(parts[0]), + remote: decodeBase64(parts[1]), + liveSha: parts[2] || '', + branch: decodeBase64(parts[3]), + }); + } else if (kind === 'C' && parts[0]) { + const parsed = safeJson(decodeBase64(parts[0]), null); + if (!parsed) continue; + if (Array.isArray(parsed)) { + for (const item of parsed) if (item) inventory.containers.push(sanitizeLegacyContainer(item)); + } else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed)); + else inventory.containers.push({ + ...parsed, + name: String(parsed.name || '').replace(/^\//, ''), + labels: parsed.labels && typeof parsed.labels === 'object' ? parsed.labels : {}, + mounts: Array.isArray(parsed.mounts) ? parsed.mounts : [], + ports: parsed.ports && typeof parsed.ports === 'object' ? parsed.ports : {}, + networks: parsed.networks && typeof parsed.networks === 'object' ? parsed.networks : {}, + }); + } else if (kind === 'D' && parts[0]) { + inventory.dockerMan.push({ + name: decodeBase64(parts[0]), + templatePath: decodeBase64(parts[1]), + webUiUrl: decodeBase64(parts[2]), + iconUrl: decodeBase64(parts[3]), + shell: decodeBase64(parts[4]), + repository: decodeBase64(parts[5]), + network: decodeBase64(parts[6]), + }); + } else if (kind === 'P' && parts[0]) { + const parsed = safeJson(decodeBase64(parts[0]), []); + const projects = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []; + for (const project of projects) { + const name = String(project?.Name || project?.name || '').trim(); + if (!name) continue; + const rawFiles = project?.ConfigFiles || project?.configFiles || project?.config_files || []; + const configFiles = (Array.isArray(rawFiles) ? rawFiles : String(rawFiles || '').split(',')) + .map((item) => String(item || '').trim()) + .filter(Boolean); + inventory.composeProjects.push({ + name, + status: String(project?.Status || project?.status || ''), + configFiles, + }); + } + } else if (kind === 'Y' && parts[0]) { + inventory.composeDefinitions.push({ + workingDir: decodeBase64(parts[0]).replace(/\/+$/, ''), + configFiles: decodeBase64(parts[1]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean), + projectName: decodeBase64(parts[2]).trim(), + services: decodeBase64(parts[3]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean), + images: decodeBase64(parts[4]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean), + valid: parts[5] === 'true', + error: decodeBase64(parts[6]).trim(), + }); + } else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0])); + } + return inventory; +} + +function configFilesFor(container) { + return String(container?.labels?.['com.docker.compose.project.config_files'] || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function containerPorts(container) { + const ports = []; + for (const [containerKey, bindings] of Object.entries(container?.ports || {})) { + const [containerPortText, protocol = 'tcp'] = containerKey.split('/'); + const containerPort = Number(containerPortText) || null; + if (Array.isArray(bindings) && bindings.length) { + for (const binding of bindings) ports.push({ + hostIp: binding?.HostIp || '', + hostPort: Number(binding?.HostPort) || null, + containerPort, + protocol, + }); + } else ports.push({ hostIp: '', hostPort: null, containerPort, protocol }); + } + return ports; +} + +function safeRelativeToBase(basePath, candidate) { + const base = String(basePath || '').replace(/\/+$/, ''); + const value = String(candidate || '').replace(/\/+$/, ''); + if (!base || !value || !value.startsWith(`${base}/`)) return ''; + const relative = value.slice(base.length + 1).replace(/^\/+|\/+$/g, ''); + if (!relative || relative.split('/').some((part) => !part || part === '.' || part === '..')) return ''; + return relative; +} + +function topLevelRelativeToBase(basePath, candidate) { + const relative = safeRelativeToBase(basePath, candidate); + return relative ? relative.split('/')[0] : ''; +} + +function canonicalServerAppdataPath(basePath, candidate) { + const value = String(candidate || '').replace(/\\/g, '/').replace(/\/+$/, ''); + if (!value) return ''; + const bases = [...new Set([ + String(basePath || '').replace(/\/+$/, ''), + '/mnt/user/appdata', + '/mnt/cache/appdata', + ].filter(Boolean))]; + for (const base of bases) { + const relative = safeRelativeToBase(base, value); + if (relative) return `${String(basePath || base).replace(/\/+$/, '')}/${relative}`; + if (value === base) return String(basePath || base).replace(/\/+$/, ''); + } + const diskMatch = value.match(/^\/mnt\/disk\d+\/appdata\/(.+)$/i); + if (diskMatch) return `${String(basePath || '/mnt/user/appdata').replace(/\/+$/, '')}/${diskMatch[1]}`; + return value; +} + +function isDeploymentBackupPath(value) { + const segments = String(value || '').replace(/\\/g, '/').split('/').filter(Boolean); + return segments.some((segment) => + /^source-pre-[0-9a-f]{7,64}$/i.test(segment) + || /^forgeflow-(backup|staging|rollback)(?:[-_.].*)?$/i.test(segment) + || ['.forgeflow', 'releases', 'backups', 'staging', 'incoming', '_audit_quarantine', 'devrunbook-validation'].includes(segment.toLowerCase()), + ); +} + +function deploymentRootCandidate(relativePath) { + const segments = String(relativePath || '').replace(/\\/g, '/').split('/').filter(Boolean); + const forgeFlowIndex = segments.indexOf('.forgeflow'); + if (forgeFlowIndex > 0) return segments.slice(0, forgeFlowIndex).join('/'); + const releasesIndex = segments.indexOf('releases'); + if (releasesIndex > 0 && segments.length > releasesIndex + 1) return segments.slice(0, releasesIndex).join('/'); + const backupIndex = segments.findIndex((segment) => /^source-pre-[0-9a-f]{7,64}$/i.test(segment)); + if (backupIndex > 0) return segments.slice(0, backupIndex).join('/'); + return segments.join('/'); +} + +function workloadSelector(group) { + if (group.composeProject) return { + kind: 'compose', + composeProject: group.composeProject, + workingDir: group.workingDir || '', + configFiles: group.configFiles, + }; + const dockerMan = group.dockerMan || null; + if (dockerMan?.templatePath) return { + kind: 'dockerman-container', + templatePath: dockerMan.templatePath, + containerName: group.containers[0]?.name || '', + }; + return { kind: 'docker-container', containerName: group.containers[0]?.name || '' }; +} + +function stableWorkloadId(serverId, selector) { + return `workload-${crypto.createHash('sha256').update(`${serverId}:${JSON.stringify(selector)}`).digest('hex').slice(0, 24)}`; +} + +function profileMatchesWorkload(profile, workload) { + if (!profile || profile.provider !== 'ssh-unraid' || profile.serverId !== workload.serverId) return false; + const identity = profile.workloadIdentity || {}; + if (identity.workloadId && identity.workloadId === workload.workloadId) return true; + if (identity.selector && JSON.stringify(identity.selector) === JSON.stringify(workload.selector)) return true; + if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) { + if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true; + } + if (profile.remoteFolder && workload.remoteFolderCandidate && profile.remoteFolder === workload.remoteFolderCandidate) return true; + return workload.containers.some((container) => container.name === profile.containerName); +} + +function repositoryRemoteMap(repositories) { + const map = new Map(); + for (const repository of repositories || []) { + for (const value of [repository.cloneUrl, repository.sshUrl, repository.htmlUrl, repository.preferredCloneUrl]) { + const id = remoteIdentity(value); + if (id) map.set(id, repository); + } + } + return map; +} + +function candidateRepositories(workload, repositories, checkouts) { + const candidates = new Map(); + const add = (repository, points, reason, exact = false, identityExact = false) => { + if (!repository?.fullName) return; + const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, identityExact: false, reasons: [] }; + current.score += points; + current.exact ||= exact; + current.identityExact ||= identityExact; + if (reason && !current.reasons.includes(reason)) current.reasons.push(reason); + candidates.set(repository.fullName, current); + }; + const remotes = repositoryRemoteMap(repositories); + const exactRemoteHints = new Set(); + for (const container of workload.containers) { + const labels = container.labels || {}; + for (const value of [labels['tech.itworx.forgeflow.repository'], labels['org.opencontainers.image.source']]) { + const id = remoteIdentity(value); + if (id) exactRemoteHints.add(id); + } + } + for (const checkout of checkouts || []) { + const root = String(checkout.root || '').replace(/\/+$/, ''); + const matchesPath = root && (root === workload.compose.workingDir || workload.containers.some((container) => (container.mounts || []).some((mount) => { + const source = String(mount?.Source || '').replace(/\/+$/, ''); + return source === root || source.startsWith(`${root}/`); + }))); + if (matchesPath) { + const id = remoteIdentity(checkout.remote); + if (id) exactRemoteHints.add(id); + } + } + for (const id of exactRemoteHints) { + const repository = remotes.get(id); + if (repository) add(repository, 100, 'Exact repository provenance from container or server checkout', true); + } + const composeProjectName = normalizedName(workload.compose.project); + const composeFolderName = normalizedName(path.basename(workload.compose.workingDir || '')); + const deploymentFolderName = normalizedName(String(workload.remoteFolderCandidate || '').split('/')[0]); + const serviceNames = new Set((workload.compose.services || []).map(normalizedName).filter(Boolean)); + const containerNames = new Set(workload.containers.map((container) => normalizedName(container.name)).filter(Boolean)); + const imageNames = new Set([ + ...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]), + ...(workload.metadata?.images || []).map((image) => String(image || '').split('/').pop()?.split(':')[0]), + ].map(normalizedName).filter(Boolean)); + for (const repository of repositories || []) { + const repoName = normalizedName(repository.name); + if (!repoName) continue; + if (composeProjectName && composeProjectName === repoName) add(repository, 55, 'Compose project name matches repository', false, true); + if (deploymentFolderName && deploymentFolderName === repoName) add(repository, 70, 'Top-level appdata folder exactly matches repository', false, true); + if (composeFolderName && composeFolderName === repoName) add(repository, 50, 'Compose file folder matches repository'); + if (serviceNames.has(repoName)) add(repository, 25, 'Compose service name matches repository'); + if (containerNames.has(repoName)) add(repository, 70, 'Container name exactly matches repository', false, true); + if (imageNames.has(repoName)) add(repository, 20, 'Container image name matches repository'); + } + return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({ + ...candidate, + reasons: candidate.exact + ? candidate.reasons + : [...candidate.reasons, 'Manual confirmation is reduced to one click; Compose identity and paths are prefilled from the server.'], + confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak', + })); +} + +function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) { + const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [String(item.name || '').toLowerCase(), item])); + const groups = new Map(); + for (const container of inventory.containers || []) { + const labels = container.labels || {}; + const composeProject = String(labels['com.docker.compose.project'] || '').trim(); + const workingDir = canonicalServerAppdataPath(server.basePath, labels['com.docker.compose.project.working_dir']); + const configFiles = [...new Set(configFilesFor(container).map((file) => canonicalServerAppdataPath(server.basePath, file)))]; + const key = composeProject + ? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}` + : `container:${container.name}`; + const group = groups.get(key) || { + composeProject, + workingDir, + configFiles, + services: [], + images: [], + containers: [], + dockerMan: null, + }; + group.containers.push(container); + const service = String(labels['com.docker.compose.service'] || '').trim(); + if (service && !group.services.includes(service)) group.services.push(service); + group.dockerMan ||= dockerManByName.get(String(container.name || '').toLowerCase()) || null; + groups.set(key, group); + } + for (const project of inventory.composeProjects || []) { + const configFiles = [...new Set((project.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))]; + const workingDir = configFiles.length ? canonicalServerAppdataPath(server.basePath, path.dirname(configFiles[0])) : ''; + const key = `compose:${project.name}:${workingDir}:${configFiles.join('|')}`; + if (groups.has(key)) continue; + const existingByProject = [...groups.values()].find((group) => group.composeProject === project.name); + if (existingByProject) { + if (!existingByProject.configFiles.length && configFiles.length) existingByProject.configFiles = configFiles; + if (!existingByProject.workingDir && workingDir) existingByProject.workingDir = workingDir; + continue; + } + groups.set(key, { + composeProject: project.name, + workingDir, + configFiles, + services: [], + images: [], + containers: [], + dockerMan: dockerManByName.get(String(project.name || '').toLowerCase()) || null, + composeStatus: project.status || '', + }); + } + for (const definition of inventory.composeDefinitions || []) { + const configFiles = [...new Set((definition.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))]; + const workingDir = canonicalServerAppdataPath(server.basePath, definition.workingDir || (configFiles[0] ? path.dirname(configFiles[0]) : '')); + if (isDeploymentBackupPath(workingDir) || configFiles.some(isDeploymentBackupPath)) continue; + const projectName = String(definition.projectName || path.basename(workingDir || '')).trim(); + const existing = [...groups.values()].find((group) => { + if (workingDir && group.workingDir && group.workingDir === workingDir) return true; + if (configFiles.length && (group.configFiles || []).some((file) => configFiles.includes(file))) return true; + return Boolean(projectName && group.composeProject === projectName && (!workingDir || !group.workingDir)); + }); + if (existing) { + existing.composeProject ||= projectName; + existing.workingDir ||= workingDir; + existing.configFiles = [...new Set([...(existing.configFiles || []), ...configFiles])]; + existing.services = [...new Set([...(existing.services || []), ...(definition.services || [])])]; + existing.images = [...new Set([...(existing.images || []), ...(definition.images || [])])]; + existing.composeDefinitionValid = definition.valid; + existing.composeDefinitionError = definition.error || ''; + existing.composeSource = 'server-compose-file'; + continue; + } + const key = `compose-file:${projectName}:${workingDir}:${configFiles.join('|')}`; + groups.set(key, { + composeProject: projectName, + workingDir, + configFiles, + services: [...new Set(definition.services || [])], + images: [...new Set(definition.images || [])], + containers: [], + dockerMan: dockerManByName.get(projectName.toLowerCase()) || null, + composeStatus: '', + composeDefinitionValid: definition.valid, + composeDefinitionError: definition.error || '', + composeSource: 'server-compose-file', + }); + } + const containerNames = new Set((inventory.containers || []).map((container) => String(container.name || '').toLowerCase())); + for (const dockerMan of inventory.dockerMan || []) { + const normalized = String(dockerMan.name || '').toLowerCase(); + if (!normalized || containerNames.has(normalized)) continue; + const key = `container:${dockerMan.name}`; + if (groups.has(key)) continue; + groups.set(key, { + composeProject: '', + workingDir: '', + configFiles: [], + services: [], + images: dockerMan.repository ? [dockerMan.repository] : [], + containers: [{ + id: '', + name: dockerMan.name, + image: dockerMan.repository || '', + imageId: '', + running: false, + status: 'template-only', + health: null, + labels: {}, + ports: {}, + mounts: [], + networks: dockerMan.network ? { [dockerMan.network]: {} } : {}, + restartPolicy: '', + }], + dockerMan, + }); + } + const workloads = []; + for (const group of groups.values()) { + const selector = workloadSelector(group); + const workloadId = stableWorkloadId(server.id, selector); + const primary = group.containers.find((item) => item.running) || group.containers[0]; + const ports = group.containers.flatMap(containerPorts); + const mounts = group.containers.flatMap((container) => container.mounts || []); + const remoteFolderCandidate = deploymentRootCandidate(safeRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, group.workingDir))) + || mounts.map((mount) => topLevelRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, mount?.Source))).find(Boolean) + || ''; + const workload = { + workloadId, + serverId: server.id, + serverName: server.name, + kind: selector.kind, + selector, + displayName: group.composeProject || primary?.name || group.dockerMan?.name || 'Unnamed workload', + compose: { + project: group.composeProject, + workingDir: group.workingDir, + configFiles: group.configFiles, + services: group.services, + }, + containers: group.containers.map((container) => ({ + id: container.id, + name: container.name, + image: container.image, + imageId: container.imageId, + running: container.running === true, + status: container.status || '', + health: container.health || null, + service: container.labels?.['com.docker.compose.service'] || '', + ports: containerPorts(container), + mounts: (container.mounts || []).map((mount) => ({ + type: mount?.Type || '', + source: mount?.Source || '', + target: mount?.Destination || '', + readOnly: mount?.RW === false, + })), + networks: Object.keys(container.networks || {}), + restartPolicy: container.restartPolicy || '', + })), + dockerMan: group.dockerMan, + metadata: { + webUiUrl: primary?.labels?.['net.unraid.docker.webui'] || group.dockerMan?.webUiUrl || '', + iconUrl: primary?.labels?.['net.unraid.docker.icon'] || group.dockerMan?.iconUrl || '', + shell: primary?.labels?.['net.unraid.docker.shell'] || group.dockerMan?.shell || '/bin/sh', + sourceRepository: primary?.labels?.['tech.itworx.forgeflow.repository'] || primary?.labels?.['org.opencontainers.image.source'] || '', + liveRevision: primary?.labels?.['tech.itworx.forgeflow.commit'] || primary?.labels?.['org.opencontainers.image.revision'] || '', + branch: primary?.labels?.['tech.itworx.forgeflow.branch'] || '', + composeStatus: group.composeStatus || '', + images: [...new Set(group.images || [])], + composeSource: group.composeSource || (group.configFiles?.length ? 'docker-compose-runtime' : ''), + composeDefinitionValid: group.composeDefinitionValid !== false, + composeDefinitionError: group.composeDefinitionError || '', + }, + runtime: { + running: group.containers.some((container) => container.running === true), + allRunning: group.containers.length > 0 && group.containers.every((container) => container.running === true), + health: group.containers.some((container) => container.health === 'unhealthy') + ? 'unhealthy' + : group.containers.length && group.containers.every((container) => container.health === 'healthy') + ? 'healthy' + : 'unverified', + ports, + }, + remoteFolderCandidate, + observedAt: new Date().toISOString(), + }; + const matchingCheckout = (inventory.checkouts || []).find((checkout) => { + const root = String(checkout.root || '').replace(/\/+$/, ''); + if (!root) return false; + if (root === workload.compose.workingDir) return true; + return mounts.some((mount) => { + const source = String(mount?.Source || '').replace(/\/+$/, ''); + return source === root || source.startsWith(`${root}/`); + }); + }); + if (matchingCheckout) { + workload.metadata.sourceRepository ||= matchingCheckout.remote || ''; + workload.metadata.liveRevision ||= matchingCheckout.liveSha || ''; + workload.metadata.branch ||= matchingCheckout.branch || ''; + } + workload.candidates = candidateRepositories(workload, repositories, inventory.checkouts || []); + const linked = profiles.find((profile) => profileMatchesWorkload(profile, workload)); + if (linked) { + workload.link = { + status: 'linked', + profileId: linked.id, + repositoryFullName: linked.repositoryFullName || linked._repositoryFullName || '', + source: linked.workloadIdentity?.linkSource || (linked.adoptedFromServer ? 'automatic' : 'manual'), + }; + workload.status = 'linked'; + } else if (workload.candidates.length === 1 && workload.candidates[0].exact) workload.status = 'exact-match'; + else if (workload.candidates.length) workload.status = workload.candidates[1]?.score === workload.candidates[0]?.score ? 'ambiguous' : 'suggested'; + else workload.status = 'unmatched'; + workloads.push(workload); + } + workloads.sort((a, b) => Number(b.runtime.running) - Number(a.runtime.running) || a.displayName.localeCompare(b.displayName)); + return workloads; +} + +function inventoryContainerMatch(checkout, repository, container) { + const safe = container?.Config || container?.State ? sanitizeLegacyContainer(container) : container; + if (!safe?.running) return 0; + const labels = safe.labels || {}; + const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/$/, ''); + const source = remoteIdentity(labels['org.opencontainers.image.source'] || labels['tech.itworx.forgeflow.repository'] || ''); + const mounts = Array.isArray(safe.mounts) ? safe.mounts : []; + const root = String(checkout.root || '').replace(/\/$/, ''); + const name = String(safe.name || '').replace(/^\//, ''); + const project = String(labels['com.docker.compose.project'] || ''); + const expectedNames = new Set([repository.name, root.split('/').pop()].filter(Boolean).map(normalizedName)); + if (workingDir && workingDir === root) return 100; + if (mounts.some((mount) => { + const mountSource = String(mount.Source || '').replace(/\/$/, ''); + return mountSource === root || mountSource.startsWith(`${root}/`); + })) return 90; + if (source && source === remoteIdentity(checkout.remote)) return 85; + if (expectedNames.has(normalizedName(project))) return 70; + if (expectedNames.has(normalizedName(name))) return 60; + return 0; +} + +module.exports = { + parseServerInventory, + buildWorkloadInventory, + inventoryContainerMatch, + remoteIdentity, + stableWorkloadId, + profileMatchesWorkload, + sanitizeLegacyContainer, + safeRelativeToBase, + canonicalServerAppdataPath, + deploymentRootCandidate, +}; diff --git a/src/main/ssh-service.cjs b/src/main/ssh-service.cjs new file mode 100644 index 0000000..45c1fe8 --- /dev/null +++ b/src/main/ssh-service.cjs @@ -0,0 +1,512 @@ +'use strict'; + +const fsp = require('node:fs/promises'); +const crypto = require('node:crypto'); +const path = require('node:path').posix; + +function loadSshModule() { + try { return require('ssh2'); } + catch { + const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.'); + error.code = 'SSH2_NOT_INSTALLED'; + throw error; + } +} + +function loadSshClient() { + return loadSshModule().Client; +} + +function fingerprintKey(key) { + const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key); + return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`; +} + +function shellQuote(value) { + return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`; +} + +function parseCapabilityOutput(output) { + const marker = '__FORGEFLOW_SERVER_TEST__'; + const index = String(output || '').lastIndexOf(marker); + if (index < 0) return { platform: String(output || '').trim(), docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false }; + const fields = {}; + for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) { + const separator = line.indexOf('='); + if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1); + } + const decode = (value) => { + try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; } + catch { return ''; } + }; + return { + platform: decode(fields.platform), + docker: fields.docker === 'true', + dockerReady: fields.dockerReady === 'true', + compose: fields.compose === 'true', + composeVersion: decode(fields.composeVersion), + git: fields.git === 'true', + tar: fields.tar === 'true', + checksum: fields.checksum === 'true', + baseWritable: fields.baseWritable === 'true', + }; +} + +class SshService { + constructor({ store, diagnostics, idleConnectionMs = 60_000, clientFactory = loadSshClient }) { + this.store = store; + this.diagnostics = diagnostics; + this.clientFactory = clientFactory; + // Every command used to pay for a TCP handshake, a key exchange and an + // authentication round trip. Sessions are kept per server for a short while + // so a sequence of commands shares one connection. + this.sessions = new Map(); + this.idleConnectionMs = idleConnectionMs; + } + + // A connection is only reusable for a server whose identity and credentials + // are unchanged. Anything in this key changing means a new connection. + sessionKey(server) { + return JSON.stringify([ + server.id, + server.host, + server.port || 22, + server.username, + server.authType, + server.privateKeyPath || '', + server.hostFingerprint || '', + ]); + } + + async validateServerConfiguration(server, secrets = {}) { + if (server?.authType !== 'privateKey') return { valid: true, method: 'password' }; + const privateKeyPath = String(server.privateKeyPath || '').trim(); + if (!privateKeyPath) throw new Error('Select a private key file.'); + const stat = await fsp.stat(privateKeyPath).catch(() => null); + if (!stat?.isFile()) { + const error = new Error(`The SSH private key file was not found: ${privateKeyPath}`); + error.code = 'SSH_PRIVATE_KEY_NOT_FOUND'; + throw error; + } + const existing = server.id ? this.store.getServer(server.id) : null; + const sameKey = existing && String(existing.privateKeyPath || '') === privateKeyPath; + const storedPassphrase = sameKey ? this.store.getServerCredentials(existing.id).passphrase : ''; + const passphrase = Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '') + ? String(secrets.passphrase) + : storedPassphrase; + const key = await fsp.readFile(privateKeyPath); + const parsed = loadSshModule().utils.parseKey(key, passphrase || undefined); + const errorResult = Array.isArray(parsed) ? parsed.find((item) => item instanceof Error) : parsed instanceof Error ? parsed : null; + if (errorResult) { + const error = new Error(`The selected file is not a usable SSH private key${passphrase ? ' with the supplied passphrase' : ''}: ${errorResult.message}`); + error.code = /encrypted|passphrase|decrypt/i.test(errorResult.message) ? 'SSH_PRIVATE_KEY_PASSPHRASE_INVALID' : 'SSH_PRIVATE_KEY_INVALID'; + throw error; + } + return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath }; + } + + async connectionOptions(server, { trustOnFirstUse = false, expectedFingerprint = null } = {}) { + const credentials = this.store.getServerCredentials(server.id); + let observedFingerprint = null; + const options = { + host: server.host, + port: server.port || 22, + username: server.username, + readyTimeout: 20_000, + keepaliveInterval: 10_000, + keepaliveCountMax: 3, + hostVerifier: (key) => { + observedFingerprint = fingerprintKey(key); + const trustedFingerprint = String(server.hostFingerprint || expectedFingerprint || '').trim(); + return trustOnFirstUse || Boolean(trustedFingerprint && observedFingerprint === trustedFingerprint); + }, + }; + if (server.authType === 'password') options.password = credentials.password; + else { + try { options.privateKey = await fsp.readFile(server.privateKeyPath); } + catch (error) { + const wrapped = new Error(`Could not read SSH private key ${server.privateKeyPath}: ${error.message}`); + wrapped.code = 'SSH_PRIVATE_KEY_READ_FAILED'; + throw wrapped; + } + if (credentials.passphrase) options.passphrase = credentials.passphrase; + } + return { options, getObservedFingerprint: () => observedFingerprint }; + } + + async withClient(serverId, action, options = {}) { + const server = this.store.getServer(serverId); + if (!server) throw new Error('The configured SSH server no longer exists.'); + // A trust-on-first-use connection is established without checking the + // fingerprint, so it must never serve a later verified call. + if (options.trustOnFirstUse || options.expectedFingerprint) return this.withDedicatedClient(server, action, options); + return this.withPooledClient(server, action, options); + } + + // Retrying is only safe while the command has not reached the server. Once a + // stream is open the remote side may already be deploying, and repeating that + // is not something this layer is allowed to decide. + isPreCommandFailure(error) { + return error?.beforeCommand === true; + } + + async withPooledClient(server, action, options) { + const key = this.sessionKey(server); + for (let attempt = 0; ; attempt += 1) { + const session = await this.leaseSession(server, key, options); + try { + const result = await action(session.client, server, session.fingerprint); + this.releaseSession(session); + return result; + } catch (error) { + const staleConnection = session.reused && attempt === 0 && this.isPreCommandFailure(error); + this.discardSession(session); + if (!staleConnection) throw error; + await this.diagnostics?.debug('ssh.session.stale-retry', { serverId: server.id, host: server.host, message: error.message }); + } + } + } + + createSession(server, key, options) { + const entry = { key, client: null, fingerprint: null, leases: 0, dead: false, established: false, idleTimer: null, opening: null }; + entry.opening = this + .withDedicatedClient(server, async (client, _server, fingerprint) => ({ client, fingerprint }), options, { keepOpen: true }) + .then((opened) => { + entry.client = opened.client; + entry.fingerprint = opened.fingerprint; + entry.established = true; + // Without a standing listener an error on an idle connection is + // unhandled, which terminates the main process. + opened.client.on('error', () => this.markSessionDead(entry)); + opened.client.on('close', () => this.markSessionDead(entry)); + opened.client.on('end', () => this.markSessionDead(entry)); + }); + this.sessions.set(key, entry); + return entry; + } + + async leaseSession(server, key, options) { + const pooled = this.sessions.get(key); + // Only a connection that was already up before this call may be retried on + // failure. Callers that arrive while one is still being opened share both + // the connection and its outcome. + const reused = Boolean(pooled && !pooled.dead && pooled.established); + const entry = pooled && !pooled.dead ? pooled : this.createSession(server, key, options); + entry.leases += 1; + if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null; } + try { + await entry.opening; + } catch (error) { + entry.leases -= 1; + this.markSessionDead(entry); + throw error; + } + return { client: entry.client, fingerprint: entry.fingerprint, reused, entry }; + } + + markSessionDead(entry) { + entry.dead = true; + if (this.sessions.get(entry.key) === entry) this.sessions.delete(entry.key); + if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null; } + if (entry.leases <= 0) this.endSession(entry); + } + + endSession(entry) { + if (!entry.client) return; + try { entry.client.end(); } catch { /* already closed */ } + } + + releaseSession(session) { + const entry = session.entry; + entry.leases -= 1; + if (entry.dead) { if (entry.leases <= 0) this.endSession(entry); return; } + if (entry.leases > 0) return; + entry.idleTimer = setTimeout(() => { + entry.idleTimer = null; + this.markSessionDead(entry); + }, this.idleConnectionMs); + entry.idleTimer.unref?.(); + } + + discardSession(session) { + const entry = session.entry; + entry.leases -= 1; + this.markSessionDead(entry); + } + + // Closes every pooled connection. The application calls this while quitting so + // no socket outlives the process. + closeAll() { + for (const entry of [...this.sessions.values()]) { + entry.leases = 0; + this.markSessionDead(entry); + } + } + + async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) { + const serverId = server.id; + const Client = this.clientFactory(); + const connection = await this.connectionOptions(server, options); + const client = new Client(); + const started = Date.now(); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + // A session that stays in the pool is closed by the pool, not here. + if (!(keepOpen && callback === resolve)) { try { client.end(); } catch { /* already closed */ } } + callback(value); + }; + client.once('ready', async () => { + try { + const data = await action(client, server, connection.getObservedFingerprint()); + await this.diagnostics?.debug('ssh.connection.completed', { serverId, host: server.host, durationMs: Date.now() - started }); + finish(resolve, data); + } catch (error) { finish(reject, error); } + }); + // Deliberately not `once`: a connection that already failed can emit a + // second error while it is being torn down, and an unhandled 'error' event + // on an EventEmitter terminates the main process. + client.on('error', async (error) => { + if (settled) return; + const observed = connection.getObservedFingerprint(); + const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed); + const wrapped = new Error(mismatch + ? `SSH host identity changed. Expected ${server.hostFingerprint}, but the server presented ${observed}.` + : `SSH connection failed: ${error.message}`); + wrapped.code = mismatch ? 'SSH_HOST_KEY_MISMATCH' : (error.code || 'SSH_CONNECTION_FAILED'); + wrapped.expectedFingerprint = mismatch ? server.hostFingerprint : undefined; + wrapped.observedFingerprint = mismatch ? observed : undefined; + await this.diagnostics?.warning('ssh.connection.failed', { serverId, host: server.host, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message }); + finish(reject, wrapped); + }); + client.connect(connection.options); + }); + } + + execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) { + return new Promise((resolve, reject) => { + let completed = false; + const timer = setTimeout(() => { + if (completed) return; + completed = true; + reject(new Error('The SSH command timed out.')); + }, timeout); + client.exec(command, (error, stream) => { + if (error) { + clearTimeout(timer); + completed = true; + // The channel never opened, so the command did not reach the server. + // This is the only failure the pool is allowed to retry. + error.beforeCommand = true; + reject(error); + return; + } + let stdout = ''; + let stderr = ''; + let stdoutBytes = 0; + let stderrBytes = 0; + let truncated = false; + const append = (target, chunk) => { + const text = chunk.toString(); + const bytes = Buffer.byteLength(text); + if (target === 'stdout') { + if (stdoutBytes + bytes <= maxOutput) stdout += text; + else truncated = true; + stdoutBytes += bytes; + } else { + if (stderrBytes + bytes <= maxOutput) stderr += text; + else truncated = true; + stderrBytes += bytes; + } + }; + stream.on('data', (chunk) => append('stdout', chunk)); + stream.stderr.on('data', (chunk) => append('stderr', chunk)); + stream.on('close', (code, signal) => { + if (completed) return; + completed = true; + clearTimeout(timer); + if (truncated) { + const failure = new Error(`Remote command output exceeded the ${maxOutput}-byte safety limit. ForgeFlow refused to use an incomplete result.`); + failure.code = 'SSH_OUTPUT_TRUNCATED'; + failure.stdoutBytes = stdoutBytes; + failure.stderrBytes = stderrBytes; + reject(failure); + } else if (code !== 0) { + const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`); + failure.code = 'SSH_COMMAND_FAILED'; + failure.exitCode = code; + failure.signal = signal; + reject(failure); + } else resolve({ stdout, stderr, exitCode: code, truncated: false }); + }); + }); + }); + } + + ensureUploadTarget(target) { + const normalized = String(target || '').replace(/\\/g, '/'); + if (!normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.'); + return normalized; + } + + async withSftp(serverId, remotePath, action) { + const server = this.store.getServer(serverId); + if (!server?.hostFingerprint) { + const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.'); + error.code = 'SSH_HOST_NOT_TRUSTED'; + throw error; + } + const target = this.ensureUploadTarget(remotePath); + return this.withClient(serverId, (client) => new Promise((resolve, reject) => { + client.sftp((sftpError, sftp) => { + if (sftpError) { reject(sftpError); return; } + const parts = path.dirname(target).split('/').filter(Boolean); + let current = ''; + const ensureNext = (index) => { + if (index >= parts.length) { + Promise.resolve(action(sftp, target)).then(resolve, reject); + return; + } + current += `/${parts[index]}`; + sftp.stat(current, (statError, attributes) => { + if (!statError) { + if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); return; } + ensureNext(index + 1); + return; + } + if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; } + sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => { + if (!mkdirError) { ensureNext(index + 1); return; } + sftp.stat(current, (retryError, retryAttributes) => { + if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) ensureNext(index + 1); + else reject(mkdirError); + }); + }); + }); + }; + ensureNext(0); + }); + }), { trustOnFirstUse: false }); + } + + async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) { + const data = Buffer.isBuffer(content) ? content : Buffer.from(content); + return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => { + const stream = sftp.createWriteStream(target, { mode }); + stream.once('error', reject); + stream.once('close', () => resolve({ remotePath: target, size: data.length })); + stream.end(data); + })); + } + + async uploadFile(serverId, localPath, remotePath, { mode = 0o600, onProgress = null } = {}) { + const stat = await fsp.stat(localPath); + if (!stat.isFile()) throw new Error(`Local upload source is not a file: ${localPath}`); + return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => { + const options = { + mode, + step: (totalTransferred, _chunk, total) => onProgress?.({ transferred: totalTransferred, total: total || stat.size }), + }; + sftp.fastPut(localPath, target, options, (error) => { + if (error) { reject(error); return; } + resolve({ remotePath: target, size: stat.size }); + }); + })); + } + + async probeHostFingerprint(serverId) { + const server = this.store.getServer(serverId); + if (!server) throw new Error('The configured SSH server no longer exists.'); + const Client = this.clientFactory(); + const client = new Client(); + let observedFingerprint = null; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { client.end(); } catch { /* handshake already closed */ } + callback(value); + }; + const completeProbe = (error = null) => { + if (observedFingerprint) { + finish(resolve, { + fingerprint: observedFingerprint, + server: { id: server.id, name: server.name, host: server.host, port: server.port || 22 }, + }); + return; + } + const wrapped = new Error(`Could not read the SSH host fingerprint: ${error?.message || 'the server closed the handshake'}`); + wrapped.code = error?.code || 'SSH_HOST_KEY_PROBE_FAILED'; + finish(reject, wrapped); + }; + const timer = setTimeout(() => completeProbe(new Error('The SSH host-key probe timed out.')), 25_000); + client.on('error', completeProbe); + client.on('close', () => completeProbe()); + client.on('end', () => completeProbe()); + client.connect({ + host: server.host, + port: server.port || 22, + username: server.username, + readyTimeout: 20_000, + hostVerifier: (key) => { + observedFingerprint = fingerprintKey(key); + return false; + }, + }); + }); + } + + async test(serverId, { trustOnFirstUse = false, expectedFingerprint = null } = {}) { + return this.withClient(serverId, async (client, server, fingerprint) => { + const script = ` +platform=$(uname -srm 2>/dev/null || true) +docker=false; docker_ready=false; compose=false; compose_version=''; git=false; tar_ok=false; checksum=false; base_writable=false +command -v docker >/dev/null 2>&1 && docker=true +[ "$docker" = true ] && docker info >/dev/null 2>&1 && docker_ready=true +if [ "$docker" = true ]; then + if docker compose version >/dev/null 2>&1; then compose=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi +fi +command -v git >/dev/null 2>&1 && git=true +command -v tar >/dev/null 2>&1 && tar_ok=true +(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum=true +base=${shellQuote(server.basePath)} +if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi +printf '__FORGEFLOW_SERVER_TEST__\\n' +printf 'platform=%s\\n' "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')" +printf 'docker=%s\\n' "$docker" +printf 'dockerReady=%s\\n' "$docker_ready" +printf 'compose=%s\\n' "$compose" +printf 'composeVersion=%s\\n' "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" +printf 'git=%s\\n' "$git" +printf 'tar=%s\\n' "$tar_ok" +printf 'checksum=%s\\n' "$checksum" +printf 'baseWritable=%s\\n' "$base_writable" +`; + const result = await this.execClient(client, script, { timeout: 30_000, maxOutput: 256 * 1024 }); + const capabilities = parseCapabilityOutput(result.stdout); + return { + connected: true, + fingerprint, + server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, + capabilities, + output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'), + }; + }, { trustOnFirstUse, expectedFingerprint }); + } + + async exec(serverId, command, options = {}) { + const server = this.store.getServer(serverId); + if (!server?.hostFingerprint) { + const error = new Error('Test and trust the SSH server fingerprint before running deployment commands.'); + error.code = 'SSH_HOST_NOT_TRUSTED'; + throw error; + } + return this.withClient(serverId, (client) => this.execClient(client, command, options), { trustOnFirstUse: false }); + } +} + +module.exports = { SshService, shellQuote, fingerprintKey, parseCapabilityOutput }; diff --git a/src/main/unraid-access-methods.cjs b/src/main/unraid-access-methods.cjs new file mode 100644 index 0000000..7e5489c --- /dev/null +++ b/src/main/unraid-access-methods.cjs @@ -0,0 +1,461 @@ +"use strict"; + +function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) { + class UnraidAccessMethods { + serverGitRemote(repository, profile) { + const candidates = [ + repository.localStatus?.remoteUrl, + repository.sshUrl, + repository.preferredCloneUrl, + profile.cloneUrl, + ] + .map((value) => String(value || "").trim()) + .filter(Boolean); + const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate)); + if (!value) { + const error = new Error("Server pull requires the repository SSH clone URL from Gitea."); + error.code = "SERVER_GIT_SSH_URL_REQUIRED"; + throw error; + } + return value; + } + + serverGitHost(repository, profile) { + const remote = this.serverGitRemote(repository, profile); + if (/^ssh:\/\//i.test(remote)) { + const parsed = new URL(remote); + return { host: parsed.hostname, port: Number(parsed.port || 22) }; + } + const match = remote.match(/^[^@\s]+@([^:\s]+):/); + if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL."); + return { host: match[1], port: 22 }; + } + + serverGitCredentialPaths(repository, server) { + const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); + const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId); + return { + directory, + privateKey: path.join(directory, "deploy-key"), + publicKey: path.join(directory, "deploy-key.pub"), + knownHosts: path.join(directory, "known_hosts"), + }; + } + + serverGitEnvironment(repository, profile, server) { + const credentials = this.serverGitCredentialPaths(repository, server); + return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`; + } + + async configureServerGitAccess({ repository, profileId }) { + const { profile, server } = this.resolve(repository, profileId); + const remote = this.serverGitRemote(repository, profile); + const { host, port } = this.serverGitHost(repository, profile); + const credentials = this.serverGitCredentialPaths(repository, server); + const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); + const marker = "__FORGEFLOW_DEPLOY_KEY__"; + const setupScript = ` + command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; } + command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; } + command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; } + credential_dir=${shellQuote(credentials.directory)} + private_key=${shellQuote(credentials.privateKey)} + public_key=${shellQuote(credentials.publicKey)} + known_hosts=${shellQuote(credentials.knownHosts)} + expected_host_fingerprint=${shellQuote(trustedHostFingerprint)} + mkdir -p "$credential_dir" + chmod 700 "$credential_dir" + if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then + rm -f "$private_key" "$public_key" + ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key" + fi + chmod 600 "$private_key" + chmod 644 "$public_key" + scan_tmp="$known_hosts.$$.tmp" + scan_ok=false + for attempt in 1 2 3; do + ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true + if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi + sleep $((attempt * 2)) + done + [ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; } + scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" + if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then + rm -f "$scan_tmp" + echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2 + exit 46 + fi + mv "$scan_tmp" "$known_hosts" + chmod 600 "$known_hosts" + printf '%s\n' ${shellQuote(marker)} + printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')" + printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')" + printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint" + `; + const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 }); + const output = String(setup.stdout || ""); + const markerIndex = output.lastIndexOf(marker); + if (markerIndex < 0) throw new Error("The server did not return the generated deploy key."); + const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf("="); + return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; + })); + if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) { + const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust."); + error.code = "GITEA_SSH_HOST_KEY_MISMATCH"; + throw error; + } + const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim(); + const [owner, repo] = String(repository.fullName || "").split("/"); + if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull."); + const deployKey = await this.gitea.ensureReadOnlyDeployKey({ + owner, + repo, + title: `ForgeFlow · ${server.name} · read-only`, + publicKey, + }); + const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`; + const probe = await this.ssh.exec( + server.id, + bash(`probe_error='' + for attempt in 1 2 3; do + if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi + probe_error=$probe_output + sleep $((attempt * 2)) + done + printf '%s\n' "$probe_error" >&2 + exit 45`), + { timeout: 45_000, maxOutput: 256 * 1024 }, + ); + const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null; + const updated = await this.store.saveDeploymentProfile(repository.fullName, { + ...profile, + deploymentMode: "server-git", + cloneUrl: remote, + serverGitAccess: { + configured: true, + deployKeyId: deployKey.id || null, + keyFingerprint: fields.fingerprint || null, + hostFingerprint: fields.hostFingerprint || null, + configuredAt: new Date().toISOString(), + }, + }); + return { + profile: updated, + created: deployKey.created === true, + remoteSha, + keyFingerprint: fields.fingerprint || null, + hostFingerprint: fields.hostFingerprint || null, + }; + } + + async probeServerGitAccess({ repository, profile, server }) { + try { + const remote = this.serverGitRemote(repository, profile); + const credentials = this.serverGitCredentialPaths(repository, server); + const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); + const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim(); + const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`; + const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 }); + const output = String(result.stdout || ""); + const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__"); + if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence."); + const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf("="); + return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; + })); + return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null }; + } catch (error) { + return { ready: false, error: error.message }; + } + } + + async verifyServerGitProfile({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const checks = []; + const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence }); + if (profile.deploymentMode === "monitor-only") { + add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy."); + return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks }; + } + if (profile.deploymentMode !== "server-git") { + add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles."); + return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks }; + } + let branchSha = null; + try { + const [owner, repo] = String(repository.fullName || "").split("/"); + const branch = await this.gitea.getBranch(owner, repo, profile.branch); + branchSha = branch?.commit?.id || branch?.commit?.sha || null; + add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha }); + const keys = await this.gitea.listDeployKeys(owner, repo); + const keyId = Number(profile.serverGitAccess?.deployKeyId); + const key = keys.find((item) => Number(item.id) === keyId); + add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true }); + } catch (error) { + add("gitea-access", "Gitea verification", "fail", error.message); + } + const access = await this.probeServerGitAccess({ repository, profile, server }); + add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access); + let inspection = null; + try { + inspection = await this.inspect({ repository, profileId }); + const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile); + const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file)); + add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists }); + add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose }); + add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] }); + add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] }); + } catch (error) { + add("server-inspection", "Server inspection", "fail", error.message); + } + const state = this.store.getDeploymentState(profile.id) || {}; + const liveSha = state.liveSha || inspection?.head || null; + const running = state.containerRunning; + const healthy = state.healthy; + add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { liveSha }); + add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha }); + add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified."); + add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available."); + const deploymentCheckIds = new Set(["gitea-access", "remote-branch", "deploy-key-scope", "server-git-access", "server-inspection"]); + const deploymentBlockers = checks.filter((item) => deploymentCheckIds.has(item.id) && item.status !== "pass"); + const deployReady = Boolean(branchSha) && deploymentBlockers.length === 0; + const failed = checks.some((item) => item.status === "fail"); + const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status)); + const readiness = deploymentBlockers.length + ? "Access failed" + : failed + ? "Deploy-ready; runtime unhealthy" + : incomplete + ? (branchSha && liveSha && branchSha !== liveSha ? "Deployable update available" : "Deploy-ready; runtime verification incomplete") + : "Ready"; + return { readiness, ready: deployReady, deployReady, deploymentBlockers, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks }; + } + + permissionTargets(profile, server, remotePath) { + const targets = [ + { + id: "server-base", + label: "Configured deployment base", + path: server.basePath, + kind: "directory", + required: false, + }, + { + id: "project-root", + label: "Project folder", + path: remotePath, + kind: "directory", + required: true, + }, + { + id: "forgeflow-state", + label: "ForgeFlow upload and rollback storage", + path: path.join(remotePath, ".forgeflow"), + kind: "directory", + required: true, + }, + { + id: "forgeflow-incoming", + label: "ForgeFlow incoming upload folder", + path: path.join(remotePath, ".forgeflow", "incoming"), + kind: "directory", + required: true, + }, + ]; + if (!profile.generatedCompose) { + for (const file of this.deploymentComposeFiles(profile)) { + targets.push({ + id: `compose:${file}`, + label: `Compose file ${file}`, + path: path.join(remotePath, file), + kind: "file", + required: true, + }); + } + } + const unique = new Map(); + for (const target of targets) unique.set(`${target.kind}:${target.path}`, target); + return [...unique.values()]; + } + + permissionInspectionScript(profile, server, remotePath) { + const targetCalls = this.permissionTargets(profile, server, remotePath) + .map( + (target) => + `probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`, + ) + .join("\n"); + return ` + encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; } + can_elevate=false + [ "$(id -u)" = 0 ] && can_elevate=true + if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi + has_acl=false + command -v setfacl >/dev/null 2>&1 && has_acl=true + printf '__FORGEFLOW_PERMISSIONS__\\n' + printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ + "$(encode "$(id -un 2>/dev/null || echo unknown)")" \ + "$(id -u 2>/dev/null || echo -1)" \ + "$(id -g 2>/dev/null || echo -1)" \ + "$(encode "$(id -Gn 2>/dev/null || true)")" \ + "$has_acl" "$can_elevate" + probe() { + target_id=$1 + label=$2 + target=$3 + kind=$4 + required=$5 + exists=false; readable=false; writable=false; parent_writable=false; effective=false + owner=''; group=''; mode=''; detail=''; nearest='' + if [ -e "$target" ] || [ -L "$target" ]; then + exists=true + [ -r "$target" ] && readable=true + [ -w "$target" ] && writable=true + owner=$(stat -c '%U' "$target" 2>/dev/null || true) + group=$(stat -c '%G' "$target" 2>/dev/null || true) + mode=$(stat -c '%a' "$target" 2>/dev/null || true) + fi + parent=$(dirname "$target") + ancestor=$parent + while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done + nearest=$ancestor + marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}" + if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then + rm -f -- "$marker" >/dev/null 2>&1 || true + parent_writable=true + fi + if [ "$kind" = directory ]; then + if [ -d "$target" ]; then + marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}" + if (umask 077; : > "$marker") 2>/dev/null; then + rm -f -- "$marker" >/dev/null 2>&1 || true + effective=true + fi + elif [ "$parent_writable" = true ]; then + effective=true + fi + else + if [ "$exists" = true ] && [ ! -f "$target" ]; then + detail='Path exists but is not a regular file.' + elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then + effective=true + elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then + effective=true + detail='File is absent but can be created by the deployment user.' + fi + fi + if [ -z "$detail" ]; then + if [ "$effective" = true ]; then detail='Read/write probe passed.' + else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi + fi + printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ + "$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \ + "$exists" "$readable" "$writable" "$parent_writable" "$effective" \ + "$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")" + } + ${targetCalls} + `; + } + + async inspectWriteAccess({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const result = await this.ssh.exec( + server.id, + bash(this.permissionInspectionScript(profile, server, remotePath)), + { timeout: 45_000, maxOutput: 2 * 1024 * 1024 }, + ); + const report = parsePermissionInspection(result.stdout); + report.serverId = server.id; + report.remotePath = remotePath; + return report; + } + + permissionRepairScript(profile, server, remotePath) { + const preserve = [ + ".git", + "node_modules", + ".venv", + "venv", + "__pycache__", + ...(profile.preservePaths || []), + ] + .map((value) => safeRelativeRemoteFile(value)) + .filter(Boolean); + const pruneExpression = preserve.length + ? preserve + .map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`) + .join(" -o ") + : "-false"; + const composePaths = this.deploymentComposeFiles(profile) + .map((file) => shellQuote(path.join(remotePath, file))) + .join(" "); + return ` + root=${shellQuote(remotePath)} + base=${shellQuote(server.basePath)} + case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac + run_privileged() { + if [ "$(id -u)" = 0 ]; then "$@"; + elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@"; + else "$@"; + fi + } + mkdir_cmd=mkdir + if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then + run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" + fi + share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) + if command -v setfacl >/dev/null 2>&1; then + run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true + run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true + fi + run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true + run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" + if [ -d "$root" ]; then + while IFS= read -r -d '' entry; do + case "$entry" in + "$root/.forgeflow"|"$root/.forgeflow"/*) continue ;; + esac + run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true + if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi + done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0) + fi + for compose_file in ${composePaths || ""}; do + [ -e "$compose_file" ] || continue + run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true + run_privileged chmod u+rw,g+rw "$compose_file" + done + echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths." + `; + } + + async repairWriteAccess({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const before = await this.inspectWriteAccess({ repository, profileId }); + await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), { + timeout: 5 * 60_000, + maxOutput: 4 * 1024 * 1024, + }); + const after = await this.inspectWriteAccess({ repository, profileId }); + if (!after.ready) { + const error = new Error( + `Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`, + ); + error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE"; + error.permissionReport = after; + throw error; + } + await this.diagnostics?.info("unraid.write-access.repaired", { + repository: repository.fullName, + profileId, + serverId: server.id, + remotePath, + user: after.identity.user, + }); + return { changed: true, normalized: true, before, after }; + } + } + return UnraidAccessMethods.prototype; +} + +module.exports = { createUnraidAccessMethods }; diff --git a/src/main/unraid-deploy-key-host.cjs b/src/main/unraid-deploy-key-host.cjs new file mode 100644 index 0000000..3add84f --- /dev/null +++ b/src/main/unraid-deploy-key-host.cjs @@ -0,0 +1,79 @@ +"use strict"; + +const crypto = require("node:crypto"); +const path = require("node:path").posix; +const { shellQuote } = require("./ssh-service.cjs"); + +const bash = (command) => `printf '%s' ${shellQuote(Buffer.from(`set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n${command}`, "utf8").toString("base64"))} | base64 -d | bash`; +function parseMarker(stdout, marker) { + const text = String(stdout || ""); + const index = text.lastIndexOf(marker); + if (index < 0) throw new Error(`Server key operation did not return ${marker}.`); + return Object.fromEntries(text.slice(index + marker.length).trim().split(/\r?\n/).map((line) => { const separator = line.indexOf("="); return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; })); +} + +class UnraidDeployKeyHost { + constructor({ ssh }) { this.ssh = ssh; } + paths(repository, server) { + const id = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); + const directory = path.join(server.basePath, ".forgeflow", "git-credentials", id); + return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") }; + } + remote(repository, profile) { + const value = [repository.localStatus?.remoteUrl, repository.sshUrl, repository.preferredCloneUrl, profile.cloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item)); + if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" }); + return value; + } + environment(paths) { return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${paths.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${paths.knownHosts}`)}`; } + async execute(server, script, options = {}) { return this.ssh.exec(server.id, bash(script), { timeout: options.timeout || 30_000, maxOutput: options.maxOutput || 128 * 1024 }); } + async inspect({ repository, server }) { + const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_INSPECT__"; + const script = `printf '%s\\n' ${shellQuote(marker)}; printf 'privateKeyPresent=%s\\n' "$([ -s ${shellQuote(p.privateKey)} ] && echo true || echo false)"; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"; printf 'fingerprint=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}' || true)"; printf 'hostFingerprint=%s\\n' "$([ -s ${shellQuote(p.knownHosts)} ] && ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, - || true)"`; + const f = parseMarker((await this.execute(server, script)).stdout, marker); + return { privateKeyPresent: f.privateKeyPresent === "true", publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null }; + } + async backup({ repository, server }) { + const p = this.paths(repository, server); const slot = path.join(p.recovery, `backup-${Date.now()}-${crypto.randomUUID()}`); const marker = "__FORGEFLOW_KEY_BACKUP__"; + const script = `umask 077; mkdir -p ${shellQuote(slot)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || cp -p ${shellQuote(p.directory)}/"$name" ${shellQuote(slot)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'recovery=%s\\n' ${shellQuote(slot)}; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"`; + const f = parseMarker((await this.execute(server, script)).stdout, marker); + return { recovery: f.recovery, publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null }; + } + async generate({ repository, server }) { + const active = this.paths(repository, server); const directory = path.join(active.directory, `candidate-${crypto.randomUUID()}`); const p = { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts") }; const marker = "__FORGEFLOW_KEY_CANDIDATE__"; + const script = `umask 077; mkdir -p ${shellQuote(directory)}; ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow-rotation:${repository.fullName}`)} -f ${shellQuote(p.privateKey)}; cp -p ${shellQuote(active.knownHosts)} ${shellQuote(p.knownHosts)}; chmod 600 ${shellQuote(p.privateKey)} ${shellQuote(p.knownHosts)}; chmod 644 ${shellQuote(p.publicKey)}; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`; + const f = parseMarker((await this.execute(server, script)).stdout, marker); + return { paths: p, publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint }; + } + async verifyCandidate({ repository, profile, server, candidate }) { + const marker = "__FORGEFLOW_KEY_PROOF__"; const remote = this.remote(repository, profile); const p = candidate.paths; + const script = `output="$(${this.environment(p)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})"; printf '%s\\n' ${shellQuote(marker)}; printf 'remoteSha=%s\\n' "$(printf '%s' "$output" | awk 'NR==1 {print $1}')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`; + const f = parseMarker((await this.execute(server, script, { timeout: 45_000, maxOutput: 256 * 1024 })).stdout, marker); + return { ready: /^[0-9a-f]{40}$/i.test(f.remoteSha || ""), remoteSha: f.remoteSha || null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null }; + } + // A caller that just verified this candidate passes its proof in. Re-running + // `git ls-remote` would open a second SSH connection to ask the same question, + // with nothing in between that could change the answer. + async preflightCandidate(context) { const proof = context?.proof?.remoteSha ? context.proof : await this.verifyCandidate(context); if (!proof.ready) throw new Error("Candidate preflight did not prove the remote branch."); return proof; } + async promote({ repository, server, candidate }) { + const p = this.paths(repository, server); const c = candidate.paths; + await this.execute(server, `test -s ${shellQuote(c.privateKey)}; test -s ${shellQuote(c.publicKey)}; test -s ${shellQuote(c.knownHosts)}; cp -p ${shellQuote(c.privateKey)} ${shellQuote(p.privateKey)}.new; cp -p ${shellQuote(c.publicKey)} ${shellQuote(p.publicKey)}.new; cp -p ${shellQuote(c.knownHosts)} ${shellQuote(p.knownHosts)}.new; mv ${shellQuote(p.privateKey)}.new ${shellQuote(p.privateKey)}; mv ${shellQuote(p.publicKey)}.new ${shellQuote(p.publicKey)}; mv ${shellQuote(p.knownHosts)}.new ${shellQuote(p.knownHosts)}`); + } + async verifyActive({ repository, profile, server }) { const paths = this.paths(repository, server); return this.verifyCandidate({ repository, profile, server, candidate: { paths } }); } + async rollback({ repository, server, candidate, previous }) { + const p = this.paths(repository, server); const recovery = previous.key.recovery; + await this.execute(server, `for name in deploy-key deploy-key.pub known_hosts; do test ! -s ${shellQuote(recovery)}/"$name" || cp -p ${shellQuote(recovery)}/"$name" ${shellQuote(p.directory)}/"$name"; done; rm -rf -- ${shellQuote(candidate.paths.directory)}`); + } + async commit({ server, candidate }) { await this.execute(server, `rm -rf -- ${shellQuote(candidate.paths.directory)}`); } + async revoke({ repository, server }) { + const p = this.paths(repository, server); const revoked = path.join(p.recovery, `revoked-${Date.now()}-${crypto.randomUUID()}`); + await this.execute(server, `umask 077; mkdir -p ${shellQuote(revoked)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || mv ${shellQuote(p.directory)}/"$name" ${shellQuote(revoked)}/"$name"; done`); + } + async restore({ repository, server }) { + const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_RESTORE__"; + const script = `slot="$(find ${shellQuote(p.recovery)} -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort | tail -1)"; test -n "$slot"; for name in deploy-key deploy-key.pub known_hosts; do test -s "$slot/$name"; cp -p "$slot/$name" ${shellQuote(p.directory)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`; + const f = parseMarker((await this.execute(server, script)).stdout, marker); + return { publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint }; + } +} + +module.exports = { UnraidDeployKeyHost, parseDeployKeyMarker: parseMarker }; diff --git a/src/main/unraid-deployment-methods.cjs b/src/main/unraid-deployment-methods.cjs new file mode 100644 index 0000000..0d47680 --- /dev/null +++ b/src/main/unraid-deployment-methods.cjs @@ -0,0 +1,582 @@ +"use strict"; + +function createUnraidDeploymentMethods({ + path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs, +}) { + class UnraidDeploymentMethods { + pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest, metadata, generated, iconReference, rollback = false }) { + const compose = this.composeInvocation(profile, repository); + const project = String( + profile.composeProject || this.internalSlug(profile, repository), + ).trim(); + const candidateFiles = [...this.deploymentComposeFiles(profile)]; + if (profile.generatedCompose) candidateFiles.push(".forgeflow/compose.metadata.yml"); + const candidateCompose = `forgeflow_compose -p ${shellQuote(project)} ${candidateFiles + .map((file) => `-f "$release"/${shellQuote(file)}`) + .join(" ")}`; + const preservePayload = Buffer.from( + [".forgeflow", ".git", ...(profile.preservePaths || [])].join("\n"), + "utf8", + ).toString("base64"); + const statusJson = this.deploymentStatusDocument({ + repository, + profile, + targetSha, + requestId, + rollback, + }); + const verification = this.containerVerificationScript( + profile, + repository, + compose, + { requireRecreated: true }, + ); + const containerHint = String( + profile.containerName || profile.remoteFolder || repository.name || "", + ).trim(); + return ` + root=${shellQuote(remotePath)} + expected_project=${shellQuote(project)} + tracked_container_hint=${shellQuote(containerHint)} + target=${shellQuote(targetSha)} + request_id=${shellQuote(requestId)} + incoming=${shellQuote(remotePart)} + expected_digest=${shellQuote(digest)} + release_root="$root/.forgeflow/releases/$target" + release="$release_root/source" + staging="$root/.forgeflow/staging/$request_id" + backup="$root/.forgeflow/backups/$request_id" + lock="$root/.forgeflow/deploy.lock" + mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" + if [ -d "$lock" ] && find "$lock" -maxdepth 0 -mmin +120 -print -quit | grep -q .; then + lock_pid=$(cat "$lock/pid" 2>/dev/null || true) + if [ -z "$lock_pid" ] || ! kill -0 "$lock_pid" 2>/dev/null; then rm -rf "$lock"; fi + fi + mkdir "$lock" 2>/dev/null || { echo "Another ForgeFlow deployment is active for $root" >&2; exit 70; } + printf '%s\n' "$request_id" > "$lock/request-id" + printf '%s\n' "$$" > "$lock/pid" + date -u +%Y-%m-%dT%H:%M:%SZ > "$lock/started-at" + restore_needed=false + activation_started=false + is_preserved() { + rel="$1" + while IFS= read -r keep; do + [ -n "$keep" ] || continue + if [ "$rel" = "$keep" ] || [[ "$rel" == "$keep/"* ]]; then return 0; fi + done < "$staging.preserve" + return 1 + } + restore_files() { + if [ -f "$backup/present" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + mkdir -p -- "$root/$(dirname "$rel")" + temp="$root/$rel.forgeflow-restore-$request_id" + cp -a -- "$backup/source/$rel" "$temp" && mv -f -- "$temp" "$root/$rel" + done < "$backup/present" + fi + if [ -f "$backup/absent" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + case "$rel" in .forgeflow/*) continue ;; esac + [ -e "$root/$rel" ] || [ -L "$root/$rel" ] || continue + rm -f -- "$root/$rel" + done < "$backup/absent" + fi + if [ -f "$backup/generated.present" ]; then + cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml" + elif [ -f "$backup/generated.created" ]; then + rm -f "$root/.forgeflow/compose.forgeflow.yml" + fi + if [ -f "$backup/metadata.present" ]; then + cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml" + elif [ -f "$backup/metadata.created" ]; then + rm -f "$root/.forgeflow/compose.metadata.yml" + fi + } + restore_images() { + [ -f "$backup/containers.before" ] || return 0 + while IFS=$'\t' read -r service container_id image_id image_ref_b64; do + [ -n "$image_id" ] || continue + docker image inspect "$image_id" >/dev/null 2>&1 || continue + image_ref=$(printf '%s' "$image_ref_b64" | base64 -d 2>/dev/null || true) + case "$image_ref" in ''|sha256:*|*@sha256:*) continue ;; esac + docker image tag "$image_id" "$image_ref" >/dev/null 2>&1 || true + done < "$backup/containers.before" + } + restore_runtime() { + [ "$activation_started" = true ] || return 0 + restore_images + if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then + ${compose} up -d --no-build >/dev/null 2>&1 || return 1 + old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + printf '%s\n' "$old_services" | while IFS= read -r service; do + [ -n "$service" ] || continue + old_id=$(${compose} ps -q "$service" | head -n1) + [ -n "$old_id" ] || exit 1 + [ "$(docker inspect -f '{{.State.Running}}' "$old_id" 2>/dev/null || echo false)" = true ] || exit 1 + done + fi + } + finish() { + status=$? + trap - EXIT + set +e + if [ "$status" -ne 0 ] && [ "$restore_needed" = true ]; then + restore_files + if ! restore_runtime; then + echo "CRITICAL: source files were restored, but the previous Compose runtime could not be restarted automatically. Backup: $backup" >&2 + else + echo "ForgeFlow restored the previous source and runtime after the failed activation." >&2 + fi + fi + rm -rf "$staging" "$lock" + exit "$status" + } + trap finish EXIT + actual_digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi) + [ "$actual_digest" = "$expected_digest" ] || { echo "Uploaded bundle checksum mismatch" >&2; exit 71; } + tar -tf "$incoming" > "$staging.entries" + if grep -E '(^/|(^|/)\\.\\.(/|$))' "$staging.entries" >/dev/null; then echo "Unsafe path detected in deployment bundle" >&2; exit 72; fi + rm -rf "$staging" "$release_root.pending" + mkdir -p "$staging/source" "$release_root.pending" + tar -xf "$incoming" -C "$staging/source" + if find "$staging/source" -type l -print -quit | grep -q .; then echo "Symbolic links are not accepted in push bundles" >&2; exit 73; fi + mv "$staging/source" "$release_root.pending/source" + find "$release_root.pending/source" -type f -printf '%P\n' | LC_ALL=C sort > "$release_root.pending/managed-files" + rm -rf "$release_root" + mv "$release_root.pending" "$release_root" + rm -f "$incoming" "$staging.entries" + printf '%s' ${shellQuote(preservePayload)} | base64 -d > "$staging.preserve" + for runtime_config in .env compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do + if [ -f "$root/$runtime_config" ] && [ ! -e "$release/$runtime_config" ]; then + mkdir -p "$release/$(dirname "$runtime_config")" + cp -a "$root/$runtime_config" "$release/$runtime_config" + fi + done + ${profile.generatedCompose ? `mkdir -p "$release/.forgeflow" + cat > "$release/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE' + ${generated}FORGEFLOW_COMPOSE + cat > "$release/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' + ${metadata}FORGEFLOW_METADATA` : ""} + cd "$release" + ${candidateCompose} config >/dev/null + candidate_services=$(${candidateCompose} config --services 2>/dev/null | sed '/^$/d') + [ -n "$candidate_services" ] || { echo "Candidate Compose project defines no services" >&2; exit 60; } + mkdir -p "$backup/source" + : > "$backup/present" + : > "$backup/absent" + : > "$backup/containers.before" + had_existing_compose=false + if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then + had_existing_compose=true + old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + printf '%s\n' "$old_services" | while IFS= read -r service; do + [ -n "$service" ] || continue + container_id=$(${compose} ps -q "$service" | head -n1) + image_id=''; image_ref='' + if [ -n "$container_id" ] && docker inspect "$container_id" >/dev/null 2>&1; then + image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true) + image_ref=$(docker inspect -f '{{.Config.Image}}' "$container_id" 2>/dev/null || true) + fi + printf '%s\t%s\t%s\t%s\n' "$service" "$container_id" "$image_id" "$(printf '%s' "$image_ref" | base64 | tr -d '\r\n')" + done >> "$backup/containers.before" + fi + if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then + hint_project=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$tracked_container_hint" 2>/dev/null || true) + if [ -n "$hint_project" ] && [ "$hint_project" != "$expected_project" ]; then + echo "Refusing activation: container $tracked_container_hint belongs to Compose project $hint_project, not $expected_project" >&2 + exit 67 + fi + fi + # Build all candidate images before any running container is touched. + cd "$release" + ${candidateCompose} build + new_manifest="$release_root/managed-files" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + parent=$(dirname "$rel") + current="$root" + if [ "$parent" != . ]; then + old_ifs=$IFS; IFS='/'; read -r -a parts <<< "$parent"; IFS=$old_ifs + for part in "\${parts[@]}"; do + current="$current/$part" + [ ! -L "$current" ] || { echo "Refusing to deploy through symlinked parent $current" >&2; exit 74; } + done + fi + [ ! -L "$root/$rel" ] || { echo "Refusing to replace symlinked managed path $rel" >&2; exit 74; } + if [ -d "$root/$rel" ]; then echo "A directory conflicts with managed file $rel" >&2; exit 75; fi + if [ -e "$root/$rel" ]; then + mkdir -p "$backup/source/$(dirname "$rel")" + cp -a -- "$root/$rel" "$backup/source/$rel" + printf '%s\n' "$rel" >> "$backup/present" + else + printf '%s\n' "$rel" >> "$backup/absent" + fi + done < "$new_manifest" + [ -f "$root/.forgeflow/compose.metadata.yml" ] && { cp -a "$root/.forgeflow/compose.metadata.yml" "$backup/compose.metadata.yml"; touch "$backup/metadata.present"; } + [ -f "$root/.forgeflow/compose.forgeflow.yml" ] && { cp -a "$root/.forgeflow/compose.forgeflow.yml" "$backup/compose.forgeflow.yml"; touch "$backup/generated.present"; } + restore_needed=true + while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + mkdir -p -- "$root/$(dirname "$rel")" + temp="$root/$rel.forgeflow-new-$request_id" + cp -a -- "$release/$rel" "$temp" + mv -f -- "$temp" "$root/$rel" + done < "$new_manifest" + mkdir -p "$root/.forgeflow" + ${profile.generatedCompose ? `if [ ! -f "$backup/generated.present" ]; then touch "$backup/generated.created"; fi + if [ ! -f "$backup/metadata.present" ]; then touch "$backup/metadata.created"; fi + cat > "$root/.forgeflow/compose.forgeflow.yml.pending" <<'FORGEFLOW_COMPOSE' + ${generated}FORGEFLOW_COMPOSE + mv "$root/.forgeflow/compose.forgeflow.yml.pending" "$root/.forgeflow/compose.forgeflow.yml" + cat > "$root/.forgeflow/compose.metadata.yml.pending" <<'FORGEFLOW_METADATA' + ${metadata}FORGEFLOW_METADATA + mv "$root/.forgeflow/compose.metadata.yml.pending" "$root/.forgeflow/compose.metadata.yml"` : `cat > "$root/.forgeflow/deployment-metadata.json.pending" <<'FORGEFLOW_METADATA_JSON' + ${JSON.stringify({ repository: repository.fullName, environment: profile.environment, commit: targetSha, requestId })} + FORGEFLOW_METADATA_JSON + mv "$root/.forgeflow/deployment-metadata.json.pending" "$root/.forgeflow/deployment-metadata.json"`} + share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) + chgrp "$share_group" "$root" "$root/.forgeflow" 2>/dev/null || true + chmod g+rwx "$root" "$root/.forgeflow" 2>/dev/null || true + chmod g+s "$root" "$root/.forgeflow" 2>/dev/null || true + while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + chgrp "$share_group" "$root/$rel" 2>/dev/null || true + chmod u+rw,g+rw "$root/$rel" 2>/dev/null || true + parent="$root/$(dirname "$rel")" + chgrp "$share_group" "$parent" 2>/dev/null || true + chmod g+rwx,g+s "$parent" 2>/dev/null || true + done < "$new_manifest" + cd "$root" + ${compose} config >/dev/null + actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + [ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; } + if [ "$(printf '%s\n' "$candidate_services" | LC_ALL=C sort)" != "$(printf '%s\n' "$actual_services" | LC_ALL=C sort)" ]; then + echo "Refusing activation because candidate and server Compose service sets differ" >&2 + exit 68 + fi + before_containers="$backup/containers.before" + hint_before_id='' + if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then + hint_before_id=$(docker inspect -f '{{.Id}}' "$tracked_container_hint" 2>/dev/null || true) + fi + activation_started=true + ${compose} up -d --no-build + ${verification} + if [ -n "$hint_before_id" ] && docker inspect "$hint_before_id" >/dev/null 2>&1; then + old_hint_running=$(docker inspect -f '{{.State.Running}}' "$hint_before_id" 2>/dev/null || echo false) + [ "$old_hint_running" != true ] || { echo "Compose left the previous container $tracked_container_hint ($hint_before_id) running" >&2; exit 66; } + fi + ${this.dockerManRefreshScript(profile, repository, iconReference)} + previous=$(cat "$root/.forgeflow/current-sha" 2>/dev/null || true) + [ -n "$previous" ] || previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) + [ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha" + cp "$new_manifest" "$root/.forgeflow/managed-files.pending" + mv "$root/.forgeflow/managed-files.pending" "$root/.forgeflow/managed-files" + printf '%s' "$target" > "$root/.forgeflow/current-sha.pending" + mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha" + cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS' + ${statusJson} + FORGEFLOW_STATUS + mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json" + restore_needed=false + printf '%s\n' "successful" > "$backup/result" + date -u +%Y-%m-%dT%H:%M:%SZ > "$backup/completed-at" + echo "ForgeFlow safely activated push bundle $target; rollback evidence retained at $backup" + `; + } + + async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { + const permissionReport = await this.inspectWriteAccess({ + repository, + profileId: profile.id, + }); + if (!permissionReport.ready) { + const error = new Error( + `Deployment stopped before upload because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`, + ); + error.code = "REMOTE_WRITE_ACCESS_REQUIRED"; + error.permissionReport = permissionReport; + throw error; + } + const bundle = await this.createCommitBundle(repository, targetSha, requestId); + const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); + try { + await this.ssh.exec(server.id, bash(`mkdir -p ${shellQuote(path.dirname(remotePart))}`), { timeout: 30_000 }); + await this.ssh.uploadFile(server.id, bundle.archivePath, remotePart, { mode: 0o600 }); + const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest: bundle.sha256, metadata, generated, iconReference, rollback }); + return await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); + } finally { + await fs.rm(bundle.archivePath, { force: true }).catch(() => {}); + } + } + + async createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }) { + const remote = this.serverGitRemote(repository, profile); + const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); + const cache = path.join(server.basePath, ".forgeflow", "git-cache", `${repositoryId}.git`); + const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); + const marker = "__FORGEFLOW_SERVER_ARCHIVE__"; + const script = ` + cache=${shellQuote(cache)} + incoming=${shellQuote(remotePart)} + remote=${shellQuote(remote)} + branch=${shellQuote(profile.branch)} + target=${shellQuote(targetSha)} + mkdir -p "$(dirname "$cache")" "$(dirname "$incoming")" + if [ ! -d "$cache" ]; then git init --bare "$cache" >/dev/null; fi + if git --git-dir="$cache" remote get-url origin >/dev/null 2>&1; then + git --git-dir="$cache" remote set-url origin "$remote" + else + git --git-dir="$cache" remote add origin "$remote" + fi + ${this.serverGitEnvironment(repository, profile, server)} git --git-dir="$cache" fetch --force --prune origin "+refs/heads/$branch:refs/remotes/origin/$branch" + git --git-dir="$cache" cat-file -e "$target^{commit}" + git --git-dir="$cache" merge-base --is-ancestor "$target" "refs/remotes/origin/$branch" + archive_tmp="$incoming.$$.tmp" + git --git-dir="$cache" archive --format=tar --output="$archive_tmp" "$target" + [ -s "$archive_tmp" ] || { rm -f "$archive_tmp"; echo "Gitea produced an empty deployment archive" >&2; exit 45; } + mv "$archive_tmp" "$incoming" + digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi) + printf '%s\n' ${shellQuote(marker)} + printf 'digest=%s\n' "$digest" + `; + const result = await this.ssh.exec(server.id, bash(script), { timeout: 5 * 60_000, maxOutput: 512 * 1024 }); + const output = String(result.stdout || ""); + const markerIndex = output.lastIndexOf(marker); + const digest = markerIndex >= 0 + ? String(output.slice(markerIndex + marker.length).match(/(?:^|\n)digest=([0-9a-f]{64})(?:\n|$)/i)?.[1] || "").toLowerCase() + : ""; + if (!digest) throw new Error("The server did not return a valid checksum for the Gitea archive."); + return { remotePart, digest }; + } + + async executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { + const permissionReport = await this.inspectWriteAccess({ repository, profileId: profile.id }); + if (!permissionReport.ready) { + const error = new Error(`Deployment stopped before the Gitea fetch because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`); + error.code = "REMOTE_WRITE_ACCESS_REQUIRED"; + error.permissionReport = permissionReport; + throw error; + } + const bundle = await this.createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }); + const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart: bundle.remotePart, digest: bundle.digest, metadata, generated, iconReference, rollback }); + return this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); + } + + async deploy({ repository, profileId, sha }) { + const targetSha = assertFullCommitSha(sha); + const { profile, server, remotePath } = this.resolve(repository, profileId); + if (profile.deploymentMode === "server-git") { + const verification = await this.verifyServerGitProfile({ repository, profileId }); + const requiredChecks = ["remote-branch", "deploy-key-scope", "server-git-access"]; + const blocked = verification.checks.filter((check) => requiredChecks.includes(check.id) && check.status !== "pass"); + if (blocked.length || !verification.branchSha) { + const error = new Error(`Server pull verification failed: ${blocked.map((check) => check.detail).join("; ") || "the target branch could not be proven"}`); + error.code = "SERVER_GIT_VERIFICATION_FAILED"; + error.verification = verification; + throw error; + } + } + const preflight = await this.preflight({ repository, profileId, sha: targetSha }); + if (!preflight.summary.ready) { + const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`); + error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED"; + throw error; + } + const requestId = crypto.randomUUID(); + const mode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; + const operation = await this.saveOperation({ + id: requestId, + type: "deployment", + action: "deploy", + provider: "ssh-unraid", + repository: repository.fullName, + environment: profile.environment, + profileId, + serverId: server.id, + remotePath, + sha: targetSha, + shortSha: targetSha.slice(0, 7), + status: "running", + logs: [ + "Preflight passed.", + mode === "push-bundle" + ? "Creating and uploading the exact committed local project directly to Unraid." + : mode === "server-git" + ? "Fetching the exact commit from Gitea with a repository-scoped read-only deploy key." + : "This workload is monitor-only and cannot be deployed.", + `Deploying exact commit ${targetSha} in the background.`, + ], + }); + + const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; + const iconReference = await this.prepareIcon(profile, repository, server); + const deploymentRepositoryUrl = mode === "server-git" + ? this.serverGitRemote(repository, profile) + : repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName; + const metadata = this.metadataCompose(profile, repository, iconReference, { + sha: targetSha, + repositoryUrl: deploymentRepositoryUrl, + }); + const previousState = this.store.getDeploymentState?.(profileId) || null; + + void (async () => { + try { + if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before deploying."); + const result = mode === "server-git" + ? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }) + : await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }); + const health = await this.checkHealth(profile.healthcheckUrl); + const finalStatus = health.healthy === false ? "failed" : "success"; + const completed = await this.saveOperation({ + ...operation, + status: finalStatus, + previousSha: previousState?.liveSha || preflight.inspection?.head || null, + health, + logs: [ + ...operation.logs, + ...result.stdout.trim().split("\n").filter(Boolean).slice(-80), + "Docker Compose activation and runtime verification completed.", + health.configured + ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` + : "No desktop healthcheck configured; running containers were verified and health remains unverified.", + ], + error: health.healthy === false ? "The application healthcheck did not pass after deployment." : null, + }); + await this.store.saveDeploymentState(profileId, { + liveSha: targetSha, + previousSha: previousState?.liveSha || preflight.inspection?.head || null, + healthy: health.configured ? health.healthy : null, + runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified", + healthStatus: health.status ?? null, + healthLatencyMs: health.latencyMs ?? null, + requestId, + remotePath, + provider: "ssh-unraid", + deploymentMode: mode, + containerName: String(profile.containerName || profile.remoteFolder || repository.name), + containerRunning: true, + dockerMan: { + webUi: this.dockerManWebUi(profile), + icon: iconReference, + shell: this.dockerManShell(profile), + templateExists: profile.manageDockerMan === true || previousState?.dockerMan?.templateExists === true, + configured: Boolean(this.dockerManWebUi(profile) || iconReference || previousState?.dockerMan?.configured), + }, + webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null), + }); + void this.refreshProfileState(repository.fullName, profileId).catch(() => {}); + await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, status: completed.status }); + } catch (error) { + await this.saveOperation({ + ...operation, + status: "failed", + error: error.message, + failure: { stage: mode === "server-git" ? "Gitea server pull / Compose activation" : "Direct copy / Compose activation", message: error.message }, + logs: [...operation.logs, error.message, "The live SHA was not promoted. Previous release evidence remains authoritative."], + }); + await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error }); + } + })(); + + return operation; + } + + async rollback({ repository, profileId, targetSha }) { + const target = assertFullCommitSha(targetSha); + const { profile, server, remotePath } = this.resolve(repository, profileId); + const deploymentState = this.store.getDeploymentState(profileId); + if (!deploymentState?.previousSha || deploymentState.previousSha !== target) { + const error = new Error("Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile."); + error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA"; + throw error; + } + const rollbackMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; + if (rollbackMode === "push-bundle" && !repository.localPath) + throw new Error("A linked local repository is required for Direct copy rollback verification."); + const requestId = crypto.randomUUID(); + const operation = await this.saveOperation({ + id: requestId, + type: "deployment", + action: "rollback", + provider: "ssh-unraid", + repository: repository.fullName, + environment: profile.environment, + profileId, + serverId: server.id, + remotePath, + sha: target, + shortSha: target.slice(0, 7), + status: "running", + logs: [`Rolling back to exact commit ${target}.`], + }); + const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; + const iconReference = await this.prepareIcon(profile, repository, server); + const rollbackRepositoryUrl = rollbackMode === "server-git" + ? this.serverGitRemote(repository, profile) + : repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName; + const metadata = this.metadataCompose(profile, repository, iconReference, { + sha: target, + repositoryUrl: rollbackRepositoryUrl, + }); + try { + const mode = rollbackMode; + if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before rolling back."); + const result = mode === "server-git" + ? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }) + : await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }); + const health = await this.checkHealth(profile.healthcheckUrl); + const finalStatus = health.healthy === false ? "failed" : "rolled-back"; + const completed = await this.saveOperation({ + ...operation, + status: finalStatus, + previousSha: deploymentState.liveSha || null, + health, + error: health.healthy === false ? "The application healthcheck did not pass after rollback." : null, + logs: [ + ...operation.logs, + ...result.stdout.trim().split("\n").filter(Boolean).slice(-80), + "Rollback activation completed.", + health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}.` : "Runtime is running; no desktop healthcheck was configured.", + ], + }); + await this.store.saveDeploymentState(profileId, { + liveSha: target, + previousSha: deploymentState.liveSha || null, + healthy: health.configured ? health.healthy : null, + runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified", + healthStatus: health.status ?? null, + healthLatencyMs: health.latencyMs ?? null, + requestId, + remotePath, + provider: "ssh-unraid", + containerRunning: true, + }); + if (health.healthy === false) { + const error = new Error("Rollback completed, but the configured healthcheck failed."); + error.code = "ROLLBACK_HEALTHCHECK_FAILED"; + error.operationId = completed.id; + throw error; + } + return completed; + } catch (error) { + if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") { + await this.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message] }); + } + throw error; + } + } + } + return UnraidDeploymentMethods.prototype; +} + +module.exports = { createUnraidDeploymentMethods }; diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs new file mode 100644 index 0000000..b1b980a --- /dev/null +++ b/src/main/unraid-deployment-service.cjs @@ -0,0 +1,524 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const fileSystem = require("node:fs"); +const path = require("node:path").posix; +const nativePath = require("node:path"); +const crypto = require("node:crypto"); +const os = require("node:os"); +const { shellQuote } = require("./ssh-service.cjs"); +const { assertFullCommitSha } = require("../shared/validation.cjs"); +const { run } = require("./process-runner.cjs"); +const { + parseServerInventory: parseWorkloadInventory, + buildWorkloadInventory, + inventoryContainerMatch: matchInventoryContainer, + remoteIdentity: inventoryRemoteIdentity, +} = require("./server-inventory.cjs"); +const { classifyInventory } = require("./inventory-classifier.cjs"); +const { createUnraidInventoryMethods } = require("./unraid-inventory-methods.cjs"); +const { createUnraidAccessMethods } = require("./unraid-access-methods.cjs"); +const { createUnraidPreflightMethods } = require("./unraid-preflight-methods.cjs"); +const { createUnraidRuntimeMethods } = require("./unraid-runtime-methods.cjs"); +const { createUnraidDeploymentMethods } = require("./unraid-deployment-methods.cjs"); +const { createUnraidStateMethods } = require("./unraid-state-methods.cjs"); + +function safeRemoteFolder(value) { + const text = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, ""); + if ( + !text || + path.isAbsolute(text) || + text.split("/").some((part) => !part || part === "." || part === ".." || !/^[a-zA-Z0-9._-]+$/.test(part)) + ) throw new Error("Remote folder must be a safe path below the configured server base path."); + return text; +} + +function safeRelativeRemoteFile(value, fallback = "") { + const text = String(value || fallback) + .trim() + .replace(/\\/g, "/"); + if ( + !text || + text.startsWith("/") || + text.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new Error("Remote file path must remain inside the project folder."); + } + return text; +} + +function bash(command) { + const script = `set -euo pipefail +export GIT_TERMINAL_PROMPT=0 +export GIT_SSH_COMMAND='ssh -o BatchMode=yes' +forgeflow_compose() { + if docker compose version >/dev/null 2>&1; then docker compose "$@"; + elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@"; + else echo "Docker Compose is not available on the server." >&2; return 127; + fi +} +${command}`; + const payload = Buffer.from(script, "utf8").toString("base64"); + return `printf '%s' ${shellQuote(payload)} | base64 -d | bash`; +} + +function parseInspection(text) { + const jsonMarker = "__FORGEFLOW_JSON__"; + const jsonIndex = text.lastIndexOf(jsonMarker); + if (jsonIndex >= 0) + return JSON.parse(text.slice(jsonIndex + jsonMarker.length).trim()); + + const kvMarker = "__FORGEFLOW_KV__"; + const kvIndex = text.lastIndexOf(kvMarker); + if (kvIndex < 0) + throw new Error("The server inspection did not return a ForgeFlow result."); + const fields = {}; + for (const line of text + .slice(kvIndex + kvMarker.length) + .trim() + .split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator > 0) + fields[line.slice(0, separator)] = line.slice(separator + 1); + } + const decodeLines = (value) => { + try { + return value + ? Buffer.from(value, "base64") + .toString("utf8") + .split(/\r?\n/) + .filter(Boolean) + : []; + } catch { + return []; + } + }; + const decodeText = (value) => { + try { + return value ? Buffer.from(value, "base64").toString("utf8") : ""; + } catch { + return ""; + } + }; + return { + exists: fields.exists === "true", + rootGit: fields.rootGit === "true", + head: fields.head || null, + branch: fields.branch || null, + remote: fields.remote + ? Buffer.from(fields.remote, "base64").toString("utf8") + : null, + trackedChanges: decodeLines(fields.trackedChanges), + composeFiles: decodeLines(fields.composeFiles), + nestedGit: decodeLines(fields.nestedGit), + dockerfile: fields.dockerfile === "true", + dockerignoreContent: decodeText(fields.dockerignoreContent), + existingPreservePaths: decodeLines(fields.existingPreservePaths), + }; +} + +function dockerIgnoreHasPath(content, value) { + const target = String(value || "") + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/^\//, "") + .replace(/\/$/, ""); + if (!target) return false; + return String(content || "") + .split(/\r?\n/) + .some((line) => { + let rule = line.trim(); + if (!rule || rule.startsWith("#") || rule.startsWith("!")) return false; + rule = rule.replace(/^\.\//, "").replace(/^\//, "").replace(/\/$/, ""); + return ( + rule === target || rule === `${target}/**` || rule === `${target}/**/*` + ); + }); +} + +function checksSummary(checks) { + const counts = { + pass: checks.filter((item) => item.status === "pass").length, + warning: checks.filter((item) => item.status === "warning").length, + fail: checks.filter((item) => item.status === "fail").length, + }; + return { + ready: counts.fail === 0, + counts, + blocking: checks + .filter((item) => item.status === "fail") + .map((item) => item.id), + }; +} + +function xmlEscape(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function decodeBase64Json(value, fallback) { + try { + return value + ? JSON.parse(Buffer.from(value, "base64").toString("utf8")) + : fallback; + } catch { + return fallback; + } +} + +function parseDockerManXml(xml) { + const text = String(xml || ""); + const tag = (name) => { + const match = text.match( + new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, "i"), + ); + return match + ? match[1] + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .trim() + : ""; + }; + return { + name: tag("Name"), + webUiUrl: tag("WebUI"), + iconUrl: tag("Icon"), + shell: tag("Shell"), + }; +} + +function parsePermissionInspection(text) { + const marker = "__FORGEFLOW_PERMISSIONS__"; + const index = String(text || "").lastIndexOf(marker); + if (index < 0) + throw new Error("The server permission check did not return a ForgeFlow marker."); + const decode = (value) => { + try { + return value ? Buffer.from(value, "base64").toString("utf8") : ""; + } catch { + return ""; + } + }; + const result = { + identity: { user: "", uid: null, gid: null, groups: [], hasAcl: false, canElevate: false }, + targets: [], + }; + for (const line of String(text) + .slice(index + marker.length) + .trim() + .split(/\r?\n/)) { + const parts = line.split("\t"); + if (parts[0] === "I") { + result.identity = { + user: decode(parts[1]), + uid: Number(parts[2]), + gid: Number(parts[3]), + groups: decode(parts[4]).split(/\s+/).filter(Boolean), + hasAcl: parts[5] === "true", + canElevate: parts[6] === "true", + }; + } else if (parts[0] === "P") { + result.targets.push({ + id: decode(parts[1]), + label: decode(parts[2]), + path: decode(parts[3]), + kind: parts[4] || "directory", + required: parts[5] === "true", + exists: parts[6] === "true", + readable: parts[7] === "true", + writable: parts[8] === "true", + parentWritable: parts[9] === "true", + effectiveWritable: parts[10] === "true", + owner: decode(parts[11]), + group: decode(parts[12]), + mode: parts[13] || "", + nearestWritableAncestor: decode(parts[14]), + detail: decode(parts[15]), + }); + } + } + result.blocking = result.targets.filter( + (target) => target.required && !target.effectiveWritable, + ); + result.ready = result.blocking.length === 0; + result.repairable = result.blocking.some((target) => target.id !== "server-base"); + return result; +} + +function deriveDetectedProfile({ + repository, + server, + remoteFolder, + remotePath, + payload, +}) { + const compose = payload.compose || {}; + const services = + compose.services && typeof compose.services === "object" + ? compose.services + : {}; + const inspections = Array.isArray(payload.containers) + ? payload.containers + : []; + const primaryContainer = + inspections.find((item) => item?.State?.Running) || inspections[0] || null; + const labels = primaryContainer?.Config?.Labels || {}; + const serviceName = + labels["com.docker.compose.service"] || + Object.keys(services)[0] || + remoteFolder; + const service = services[serviceName] || {}; + const containerName = String( + primaryContainer?.Name || service.container_name || serviceName, + ).replace(/^\//, ""); + const ports = []; + for (const [containerKey, bindings] of Object.entries( + primaryContainer?.NetworkSettings?.Ports || {}, + )) { + const [containerPortText, protocol = "tcp"] = containerKey.split("/"); + const containerPort = Number(containerPortText) || null; + if (Array.isArray(bindings) && bindings.length) { + for (const binding of bindings) + ports.push({ + hostIp: binding.HostIp || "", + hostPort: Number(binding.HostPort) || null, + containerPort, + protocol, + }); + } else ports.push({ hostIp: "", hostPort: null, containerPort, protocol }); + } + const primaryPort = ports.find((item) => item.hostPort) || ports[0] || {}; + const mounts = (primaryContainer?.Mounts || []).map((item) => ({ + type: item.Type, + source: item.Source, + target: item.Destination, + readOnly: item.RW === false, + })); + const networks = Object.keys( + primaryContainer?.NetworkSettings?.Networks || {}, + ); + const envNames = (primaryContainer?.Config?.Env || []) + .map((item) => String(item).split("=")[0]) + .filter(Boolean); + const dockerMan = parseDockerManXml(payload.dockerManXml || ""); + const webUiUrl = + dockerMan.webUiUrl || labels["net.unraid.docker.webui"] || ""; + const iconUrl = dockerMan.iconUrl || labels["net.unraid.docker.icon"] || ""; + const shell = + dockerMan.shell || labels["net.unraid.docker.shell"] || "/bin/sh"; + const preservePaths = [ + ...new Set([ + ".env", + "appdata", + "data", + "logs", + "config", + "compose.override.yml", + ...mounts + .filter((item) => + String(item.source || "").startsWith(`${remotePath}/`), + ) + .map( + (item) => + String(item.source) + .slice(remotePath.length + 1) + .split("/")[0], + ) + .filter(Boolean), + ]), + ]; + const source = (value, origin, confidence = "confirmed") => ({ + value, + origin, + confidence, + detectedAt: new Date().toISOString(), + overridden: false, + }); + const composeFiles = payload.composeFiles || []; + const composeFile = + composeFiles[0] || + labels["com.docker.compose.project.config_files"] + ?.split(",")[0] + ?.replace(`${remotePath}/`, "") || + "docker-compose.yml"; + return { + profile: { + name: "Production", + environment: "production", + provider: "ssh-unraid", + branch: payload.branch || repository.defaultBranch || "main", + serverId: server.id, + remoteFolder, + cloneUrl: payload.remote || repository.sshUrl || "", + alignRemote: false, + generatedCompose: false, + composeFile, + composeService: serviceName, + containerName, + hostPort: primaryPort.hostPort || null, + containerPort: primaryPort.containerPort || null, + webUiUrl, + iconMode: /^https?:\/\//i.test(iconUrl) ? "url" : "none", + iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : "", + serverIconReference: iconUrl, + iconFilePath: "", + dockerShell: ["/bin/bash", "/bin/sh"].includes(shell) ? shell : "/bin/sh", + healthcheckUrl: "", + preservePaths, + confirmationRequired: true, + adoptedFromServer: true, + serverSourceOfTruth: true, + detectedAt: new Date().toISOString(), + detectedMetadata: { + head: payload.head || null, + composeProject: labels["com.docker.compose.project"] || "", + composeFiles, + services: Object.keys(services), + ports, + mounts, + networks, + envNames, + restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || "", + healthcheck: primaryContainer?.Config?.Healthcheck || null, + image: primaryContainer?.Config?.Image || service.image || "", + dockerMan, + }, + }, + provenance: { + remoteFolder: source(remoteFolder, "server-path"), + cloneUrl: source(payload.remote || "", "git-origin"), + branch: source(payload.branch || "", "git"), + composeFile: source(composeFile, "docker-compose"), + composeService: source(serviceName, "docker-labels"), + containerName: source(containerName, "docker-inspect"), + hostPort: source(primaryPort.hostPort || null, "docker-inspect"), + containerPort: source( + primaryPort.containerPort || null, + "docker-inspect", + ), + webUiUrl: source( + webUiUrl, + dockerMan.webUiUrl ? "unraid-dockerman" : "docker-labels", + ), + iconUrl: source( + iconUrl, + dockerMan.iconUrl ? "unraid-dockerman" : "docker-labels", + ), + dockerShell: source( + shell, + dockerMan.shell ? "unraid-dockerman" : "docker-labels", + ), + }, + runtime: { + remotePath, + containerRunning: Boolean(primaryContainer?.State?.Running), + containers: inspections.length, + services: Object.keys(services).length, + ports, + mounts, + networks, + envNames, + }, + }; +} + +function iconReferenceLocalPath(iconReference) { + const value = String(iconReference || "").trim(); + if (value.startsWith("file:///")) return `/${value.slice("file:///".length)}`; + if (value.startsWith("/")) return value; + return ""; +} + +class UnraidDeploymentService { + constructor({ + store, + ssh, + git, + gitea, + diagnostics, + sourcePath = process.cwd(), + onOperationChange = null, + }) { + this.store = store; + this.ssh = ssh; + this.git = git; + this.gitea = gitea; + this.diagnostics = diagnostics; + this.sourcePath = sourcePath; + this.onOperationChange = onOperationChange; + } + + + + + + + +} + +const stateMethods = createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }); +for (const name of Object.getOwnPropertyNames(stateMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(stateMethods, name)); +} + +const deploymentMethods = createUnraidDeploymentMethods({ + path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs, +}); +for (const name of Object.getOwnPropertyNames(deploymentMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(deploymentMethods, name)); +} + +const runtimeMethods = createUnraidRuntimeMethods({ + safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run, + bash, shellQuote, iconReferenceLocalPath, +}); +for (const name of Object.getOwnPropertyNames(runtimeMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(runtimeMethods, name)); +} + +const preflightMethods = createUnraidPreflightMethods({ + safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary, + inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote, + assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs, +}); +for (const name of Object.getOwnPropertyNames(preflightMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name)); +} + +const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }); +for (const name of Object.getOwnPropertyNames(accessMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name)); +} + +const inventoryMethods = createUnraidInventoryMethods({ + shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory, + inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer, + safeRemoteFolder, bash, +}); +for (const name of Object.getOwnPropertyNames(inventoryMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(inventoryMethods, name)); +} + +module.exports = { + UnraidDeploymentService, + safeRemoteFolder, + safeRelativeRemoteFile, + parseInspection, + dockerIgnoreHasPath, + checksSummary, + xmlEscape, + iconReferenceLocalPath, + decodeBase64Json, + parseDockerManXml, + parsePermissionInspection, + parseServerInventory: parseWorkloadInventory, + inventoryContainerMatch: matchInventoryContainer, + remoteIdentity: inventoryRemoteIdentity, + deriveDetectedProfile, + bash, +}; diff --git a/src/main/unraid-inventory-methods.cjs b/src/main/unraid-inventory-methods.cjs new file mode 100644 index 0000000..d058fd9 --- /dev/null +++ b/src/main/unraid-inventory-methods.cjs @@ -0,0 +1,709 @@ +"use strict"; + +function createUnraidInventoryMethods({ + shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory, + inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer, + safeRemoteFolder, bash, +}) { + class UnraidInventoryMethods { + inventoryScript(server) { + const configuredRoots = [...new Set([server.basePath, ...(server.scanRoots || [])])].map((root) => ` add_scan_root ${shellQuote(root)}`).join("\n"); + const configuredExcludes = (server.scanExcludes || []).map((name) => ` -o -name ${shellQuote(name)}`).join(""); + return ` + base=${shellQuote(server.basePath)} + platform=$(uname -srm 2>/dev/null || true) + docker_ok=false; compose_ok=false; compose_v2=false; git_ok=false; tar_ok=false; checksum_ok=false; base_writable=false; compose_version='' + command -v docker >/dev/null 2>&1 && docker_ok=true + if [ "$docker_ok" = true ]; then + if docker compose version >/dev/null 2>&1; then compose_ok=true; compose_v2=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose_ok=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi + fi + command -v git >/dev/null 2>&1 && git_ok=true + command -v tar >/dev/null 2>&1 && tar_ok=true + (command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum_ok=true + if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi + printf '__FORGEFLOW_INVENTORY__\\n' + printf 'H\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$docker_ok" "$compose_ok" "$git_ok" "$tar_ok" "$checksum_ok" "$base_writable" "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')" + ids='' + if [ "$docker_ok" != true ]; then + printf 'W\\t%s\\n' "$(printf '%s' 'Docker is not installed or not in PATH. Compose files and DockerMan templates will still be scanned.' | base64 | tr -d '\\r\\n')" + else + if ! ids=$(docker ps -aq --no-trunc 2>&1); then + printf 'W\\t%s\\n' "$(printf '%s' "Docker inventory failed: $ids. Compose files and DockerMan templates will still be scanned." | head -c 2000 | base64 | tr -d '\\r\\n')" + ids='' + fi + fi + if [ -n "$ids" ]; then + disappeared=0 + mapfile -t container_ids <<< "$ids" + # Docker accepts multiple IDs and returns one JSON array. This avoids one + # daemon round-trip per container on larger Unraid installations. + if inspect=$(docker inspect "\${container_ids[@]}" 2>/dev/null); then + printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')" + else + # A container can disappear between docker ps and inspect. Fall back to + # individual reads so the remaining inventory stays complete. + for container_id in "\${container_ids[@]}"; do + [ -n "$container_id" ] || continue + if inspect=$(docker inspect "$container_id" 2>/dev/null); then + printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')" + else + disappeared=$((disappeared + 1)) + fi + done + fi + if [ "$disappeared" -gt 0 ]; then + printf 'W\\t%s\\n' "$(printf '%s' "$disappeared stale container reference(s) disappeared during inventory; current containers were still processed." | base64 | tr -d '\\r\\n')" + fi + fi + templates_dir=/boot/config/plugins/dockerMan/templates-user + if [ -d "$templates_dir" ]; then + find "$templates_dir" -maxdepth 1 -type f -name '*.xml' -print0 2>/dev/null | while IFS= read -r -d '' template; do + read_tag() { sed -n "s#.*<$1>\\(.*\\).*#\\1#p" "$template" | head -n1; } + name=$(read_tag Name) + [ -n "$name" ] || continue + printf 'D\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\ + "$(printf '%s' "$name" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$template" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag WebUI)" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag Icon)" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag Shell)" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag Repository)" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag Network)" | base64 | tr -d '\\r\\n')" + done + fi + if [ "$compose_ok" = true ]; then + compose_projects=$(docker compose ls --all --format json 2>/dev/null || docker-compose ls --all --format json 2>/dev/null || true) + if [ -n "$compose_projects" ]; then + printf 'P\\t%s\\n' "$(printf '%s' "$compose_projects" | base64 | tr -d '\\r\\n')" + fi + + fi + + scan_roots=() + add_scan_root() { + candidate=$1 + [ -d "$candidate" ] || return 0 + for existing in "\${scan_roots[@]}"; do [ "$existing" = "$candidate" ] && return 0; done + scan_roots+=("$candidate") + } + ${configuredRoots} + + for root in "\${scan_roots[@]}"; do + scan_error=$(mktemp) + while IFS= read -r -d '' primary; do + dir=$(dirname "$primary") + filename=$(basename "$primary") + case "$filename" in + compose.override.yml|compose.override.yaml|docker-compose.override.yml|docker-compose.override.yaml) continue ;; + compose.yml) ;; + compose.yaml) [ -f "$dir/compose.yml" ] && continue ;; + docker-compose.yml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ]; } && continue ;; + docker-compose.yaml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; } && continue ;; + *) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && continue ;; + esac + ( + set -- -f "$primary" + files_text=$primary + has_override=false + for extra in "$dir/compose.override.yml" "$dir/compose.override.yaml" "$dir/docker-compose.override.yml" "$dir/docker-compose.override.yaml"; do + [ -f "$extra" ] || continue + has_override=true + set -- "$@" -f "$extra" + files_text="$files_text + $extra" + done + project_name=$(sed -n 's/^name:[[:space:]]*//p' "$primary" 2>/dev/null | head -n1 | cut -d'#' -f1 | tr -d '"' | tr -d "'" | xargs 2>/dev/null || true) + [ -n "$project_name" ] || project_name=$(basename "$dir") + valid=false; services=''; compose_error='' + images=$(awk ' + /^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next } + in_services && /^[^[:space:]]/ { exit } + in_services && /^[[:space:]]+image:[[:space:]]*/ { + line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line + } + ' "$primary" 2>/dev/null || true) + if [ "$compose_ok" != true ]; then + compose_error='Docker Compose is unavailable; file metadata was still detected.' + elif [ "$compose_v2" = true ]; then + if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then + valid=true + if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true); fi + else compose_error=$services; services=''; fi + else + if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then + valid=true + if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true); fi + else compose_error=$services; services=''; fi + fi + if [ -z "$services" ]; then + services=$(awk ' + /^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next } + in_services && /^[^[:space:]]/ { exit } + in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*($|#)/ { + line=$0; sub(/^[[:space:]]*/, "", line); sub(/:.*/, "", line); print line + } + ' "$primary" 2>/dev/null || true) + fi + printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\ + "$(printf '%s' "$dir" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$project_name" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$services" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$images" | base64 | tr -d '\\r\\n')" \\ + "$valid" \\ + "$(printf '%s' "$compose_error" | head -c 2000 | base64 | tr -d '\\r\\n')" + ) + done < <(find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases${configuredExcludes} \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>"$scan_error" || true) + if [ -s "$scan_error" ]; then + scan_message=$(printf 'Inventory scan partially failed for %s: %s' "$root" "$(head -n 1 "$scan_error")") + printf 'W\\t%s\\n' "$(printf '%s' "$scan_message" | base64 | tr -d '\\r\\n')" + fi + rm -f "$scan_error" + done + `; + } + + allSshProfiles() { + const result = []; + for (const [repositoryFullName, profiles] of Object.entries(this.store.data?.deploymentProfiles || {})) { + for (const profile of profiles || []) { + if (profile?.provider === "ssh-unraid") result.push({ ...profile, _repositoryFullName: repositoryFullName }); + } + } + return result; + } + + relativeComposeFiles(workload) { + const workingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, ""); + const files = (workload.compose?.configFiles || []).map((file) => { + const value = String(file || "").trim(); + if (workingDir && value.startsWith(`${workingDir}/`)) return value.slice(workingDir.length + 1); + return value.startsWith("/") ? path.basename(value) : value; + }).filter(Boolean); + return [...new Set(files.length ? files : ["docker-compose.yml"])]; + } + + profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "server-git", remoteFolder = "" } = {}) { + const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode) + ? deploymentMode + : "server-git"; + const selectedFolder = safeRemoteFolder(remoteFolder || workload.remoteFolderCandidate || repository.name); + const composeFiles = this.relativeComposeFiles(workload); + const services = [...new Set((workload.compose?.services || []) + .map((service) => String(service || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")) + .filter(Boolean))]; + const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; + const primaryPort = (primary.ports || []).find((item) => item.hostPort) || primary.ports?.[0] || {}; + const remotePath = path.join(server.basePath, selectedFolder); + const preservePaths = new Set([".env", "appdata", "data", "logs", "config", "compose.override.yml"]); + for (const container of workload.containers || []) { + for (const mount of container.mounts || []) { + const source = String(mount.source || ""); + if (!source.startsWith(`${remotePath}/`)) continue; + const relative = source.slice(remotePath.length + 1).split("/")[0]; + if (relative) preservePaths.add(relative); + } + } + const idPrefix = String(linkSource).startsWith("automatic") ? "auto" : "link"; + const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`; + return { + id: profileId, + name: `${server.name} · ${workload.displayName}`, + environment: "production", + provider: "ssh-unraid", + branch: workload.metadata?.branch || repository.defaultBranch || "main", + serverId: server.id, + remoteFolder: selectedFolder, + deploymentMode: effectiveDeploymentMode, + composeFile: composeFiles[0], + composeFiles, + composeProject: workload.compose?.project || "", + composeWorkingDir: workload.compose?.workingDir || "", + composeService: services[0] || String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app", + composeServices: services.length ? services : [String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app"], + containerName: primary.name || selectedFolder.split("/").pop(), + cloneUrl: workload.metadata?.sourceRepository || repository.sshUrl || repository.cloneUrl || "", + alignRemote: false, + hostPort: primaryPort.hostPort || null, + containerPort: primaryPort.containerPort || null, + webUiUrl: workload.metadata?.webUiUrl || workload.dockerMan?.webUiUrl || "", + iconMode: "none", + iconUrl: "", + iconFilePath: "", + serverIconReference: workload.metadata?.iconUrl || workload.dockerMan?.iconUrl || "", + dockerShell: ["/bin/bash", "/bin/sh"].includes(workload.metadata?.shell) ? workload.metadata.shell : "/bin/sh", + preservePaths: [...preservePaths], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: { + workloadId: workload.workloadId, + selector: workload.selector, + linkSource, + linkedAt: new Date().toISOString(), + }, + detectedAt: new Date().toISOString(), + detectedMetadata: { + source: `${linkSource}-server-inventory`, + kind: workload.kind, + composeProject: workload.compose?.project || "", + composeFiles, + services, + image: primary.image || "", + dockerManTemplatePath: workload.dockerMan?.templatePath || "", + }, + confirmationRequired: !String(linkSource).startsWith("automatic"), + }; + } + + refreshedProfileFromWorkload(repository, server, workload, existingProfile) { + const configuredRoot = path.join(server.basePath, existingProfile.remoteFolder || "").replace(/\/+$/, ""); + const composeWorkingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, ""); + const configuredRootOwnsCompose = Boolean( + configuredRoot + && composeWorkingDir + && (composeWorkingDir === configuredRoot || composeWorkingDir.startsWith(`${configuredRoot}/`)), + ); + const detected = this.profileFromWorkload(repository, server, workload, { + linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose", + deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode) + ? existingProfile.deploymentMode + : "push-bundle", + remoteFolder: configuredRootOwnsCompose + ? existingProfile.remoteFolder + : workload.remoteFolderCandidate || existingProfile.remoteFolder, + }); + const repositoryRelativeComposeFiles = configuredRootOwnsCompose + ? [...new Set((workload.compose?.configFiles || []).map((file) => { + const value = String(file || "").trim().replace(/\\/g, "/"); + if (value.startsWith(`${configuredRoot}/`)) return value.slice(configuredRoot.length + 1); + return value.startsWith("/") ? "" : value; + }).filter(Boolean))] + : []; + const composeFiles = repositoryRelativeComposeFiles.length + ? repositoryRelativeComposeFiles + : detected.composeFiles; + return { + ...existingProfile, + deploymentMode: detected.deploymentMode, + remoteFolder: detected.remoteFolder, + composeFile: composeFiles[0], + composeFiles, + composeProject: detected.composeProject, + composeWorkingDir: detected.composeWorkingDir, + composeService: detected.composeService, + composeServices: detected.composeServices, + containerName: detected.containerName || existingProfile.containerName, + hostPort: detected.hostPort || existingProfile.hostPort || null, + containerPort: detected.containerPort || existingProfile.containerPort || null, + webUiUrl: detected.webUiUrl || existingProfile.webUiUrl || "", + serverIconReference: detected.serverIconReference || existingProfile.serverIconReference || "", + dockerShell: detected.dockerShell || existingProfile.dockerShell || "/bin/sh", + preservePaths: [...new Set([...(existingProfile.preservePaths || []), ...(detected.preservePaths || [])])], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: detected.workloadIdentity, + detectedAt: detected.detectedAt, + detectedMetadata: detected.detectedMetadata, + }; + } + + async saveWorkloadState(profile, workload, server, { expectedGiteaSha = null, health = null } = {}) { + const candidateSha = String(workload.metadata?.liveRevision || ""); + const previousState = this.store.getDeploymentState?.(profile.id) || {}; + const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null; + const liveSha = observedLiveSha || previousState.liveSha || null; + const profileRemote = inventoryRemoteIdentity(profile.cloneUrl); + const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository); + const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote); + const verifiedGiteaSha = /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) + ? String(expectedGiteaSha).toLowerCase() + : null; + const matchesGitea = Boolean(repositoryMatches && verifiedGiteaSha && observedLiveSha === verifiedGiteaSha); + const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; + const dockerHealthy = workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null; + const effectiveHealthy = workload.runtime.running === false + ? false + : health?.configured ? health.healthy : dockerHealthy; + return this.store.saveDeploymentState(profile.id, { + liveSha, + healthy: effectiveHealthy, + healthStatus: health?.status ?? null, + healthLatencyMs: health?.latencyMs ?? null, + runtimeVerification: workload.runtime.running === false ? "stopped" : health?.configured ? "desktop-healthcheck" : workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health, + containerRunning: workload.runtime.running, + dockerHealth: primary.health || null, + containerName: primary.name || profile.containerName, + remotePath: path.join(server.basePath, profile.remoteFolder), + provider: "ssh-unraid", + workloadId: workload.workloadId, + composeProject: workload.compose?.project || null, + observedAt: workload.observedAt, + evidence: liveSha ? "container-provenance-label" : "runtime-only", + giteaSha: verifiedGiteaSha, + matchesGitea, + previousSha: previousState.previousSha || null, + }); + } + + async collectServerInventory(serverId, repositories) { + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), { + timeout: 180_000, + maxOutput: 64 * 1024 * 1024, + }); + const inventory = parseWorkloadInventory(result.stdout); + const profiles = this.allSshProfiles(); + const detectedWorkloads = buildWorkloadInventory({ + inventory, + server, + repositories, + profiles, + }); + const detectedIds = new Set(detectedWorkloads.map((item) => item.workloadId)); + const staleLinks = profiles.filter((profile) => profile.serverId === serverId && profile.workloadIdentity?.workloadId && !detectedIds.has(profile.workloadIdentity.workloadId)).map((profile) => ({ + workloadId: profile.workloadIdentity.workloadId, + serverId, + displayName: profile.name || profile.remoteFolder || profile._repositoryFullName, + status: "stale", + link: { profileId: profile.id, repositoryFullName: profile._repositoryFullName }, + compose: { project: profile.composeProject || "", workingDir: profile.composeWorkingDir || path.join(server.basePath, profile.remoteFolder || ""), configFiles: profile.composeFiles || [profile.composeFile].filter(Boolean), services: profile.composeServices || [profile.composeService].filter(Boolean) }, + containers: [], + runtime: { running: false, health: "missing" }, + metadata: { sourceRepository: profile.cloneUrl || "", liveRevision: "", branch: profile.branch || "", staleLink: true }, + candidates: [{ repositoryFullName: profile._repositoryFullName, repositoryName: profile._repositoryFullName.split("/").pop(), score: 100, exact: true, reasons: ["persisted deployment profile"] }], + remoteFolderCandidate: profile.remoteFolder || "", + observedAt: new Date().toISOString(), + })); + const workloads = classifyInventory([...detectedWorkloads, ...staleLinks], profiles, this.store.getInventoryReviewDecisions?.(serverId) || []); + return { server, inventory, workloads }; + } + + inventoryResponse(server, inventory, workloads, changes = {}) { + const summary = { + serverId: server.id, + serverName: server.name, + detected: workloads.length, + adopted: Number(changes.adopted || 0), + refreshed: Number(changes.refreshed || 0), + retired: Number(changes.retired || 0), + staleProfiles: Array.isArray(changes.staleProfiles) ? changes.staleProfiles : [], + verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length, + linked: workloads.filter((item) => item.status === "linked").length, + unmatched: workloads.filter((item) => !item.link).length, + needsReview: workloads.filter((item) => { + if (item.reviewDecision) return false; + const type = item.classification?.type; + if (type === "duplicate") return item.runtime?.running === true; + if (type === "stale-link") return true; + if (["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(type)) return false; + return item.runtime?.running && ["suggested", "ambiguous", "unmatched"].includes(item.status); + }).length, + duplicates: workloads.filter((item) => item.classification?.type === "duplicate").length, + excluded: workloads.filter((item) => ["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(item.classification?.type)).length, + running: workloads.filter((item) => item.runtime.running).length, + stopped: workloads.filter((item) => !item.runtime.running).length, + }; + return { + ...summary, + server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, + capabilities: inventory.capabilities, + warnings: inventory.warnings, + workloads, + observedAt: new Date().toISOString(), + }; + } + + async scanServerInventory(serverId, repositories, { autoLink = false } = {}) { + const started = Date.now(); + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + let adopted = 0; + const adoptedLinks = []; + if (autoLink) { + const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true }); + if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`); + const linkedRepositories = new Set(workloads + .filter((workload) => workload.classification?.type !== "stale-link" && workload.link?.repositoryFullName) + .map((workload) => String(workload.link.repositoryFullName).toLowerCase())); + for (const addition of plan.additions) { + const workload = workloads.find((item) => item.workloadId === addition.workloadId); + const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(addition.repositoryFullName).toLowerCase()); + const key = String(repository?.fullName || "").toLowerCase(); + if (!workload || !repository || linkedRepositories.has(key)) continue; + const linkSource = addition.evidence === "exact-provenance" ? "automatic" : "automatic-runtime-identity"; + const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" }); + const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); + await this.saveWorkloadState(saved, workload, server); + workload.status = "linked"; + workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource }; + linkedRepositories.add(key); + adopted += 1; + adoptedLinks.push({ repositoryFullName: repository.fullName, profileId: saved.id, workloadId: workload.workloadId }); + } + } + const response = this.inventoryResponse(server, inventory, workloads, { adopted }); + await this.diagnostics?.info("unraid.workloads.scanned", { + serverId, + detected: response.detected, + linked: response.linked, + needsReview: response.needsReview, + adopted, + adoptedLinks, + readOnly: !autoLink, + durationMs: Date.now() - started, + }); + return response; + } + + reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) { + const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id); + const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId)); + const linkedRepositories = new Set(workloads + .filter((item) => item.classification?.type !== "stale-link" && item.link?.repositoryFullName) + .map((item) => String(item.link.repositoryFullName).toLowerCase())); + const additions = []; + const updates = []; + const conflicts = []; + for (const workload of workloads) { + if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "external-container", "temporary-runtime", "manually-excluded", "stale-link"].includes(workload.classification?.type)) { + if (!workload.reviewDecision && (["historical-compose", "stale-link"].includes(workload.classification?.type) || (workload.classification?.type === "duplicate" && workload.runtime?.running))) conflicts.push({ workloadId: workload.workloadId, displayName: workload.displayName, status: workload.classification.type, reason: workload.classification.reason, candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })) }); + continue; + } + if (workload.link?.profileId && workload.link?.repositoryFullName) { + updates.push({ + workloadId: workload.workloadId, + profileId: workload.link.profileId, + repositoryFullName: workload.link.repositoryFullName, + impact: "Refresh detected Compose identity and observed deployment state", + }); + continue; + } + const candidate = workload.candidates?.[0]; + const unique = workload.candidates?.length === 1; + const exact = unique && (candidate?.exact === true || (candidate?.identityExact === true && candidate.score >= 70)); + if (autoLink && exact && workload.runtime?.running && !linkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) { + additions.push({ + workloadId: workload.workloadId, + repositoryFullName: candidate.repositoryFullName, + evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity", + impact: "Create a server-pull deployment profile; no container changes", + }); + linkedRepositories.add(String(candidate.repositoryFullName).toLowerCase()); + } else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) { + conflicts.push({ + workloadId: workload.workloadId, + displayName: workload.displayName, + status: workload.status, + candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })), + }); + } + } + const stale = profiles.filter((profile) => + String(profile.workloadIdentity?.linkSource || "").startsWith("automatic") + && profile.workloadIdentity?.workloadId + && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), + ).map((profile) => ({ + profileId: profile.id, + repositoryFullName: profile._repositoryFullName, + reason: "workload-missing", + impact: "Review only; ForgeFlow will not remove this profile automatically", + })); + const payload = { serverId: server.id, additions, updates, stale, conflicts }; + return { + id: crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"), + createdAt: new Date().toISOString(), + ...payload, + summary: { additions: additions.length, updates: updates.length, stale: stale.length, conflicts: conflicts.length }, + }; + } + + async planServerInventoryReconciliation(serverId, repositories, options = {}) { + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + const plan = this.reconciliationPlan(server, workloads, repositories, options); + return { inventory: this.inventoryResponse(server, inventory, workloads), plan }; + } + + async reconcileServerInventory(serverId, repositories, { autoLink = true, expectedPlanId = "" } = {}) { + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink }); + if (!expectedPlanId || expectedPlanId !== plan.id) { + const error = new Error(expectedPlanId ? "The server inventory changed after the reconciliation preview. Review a fresh plan before applying it." : "Apply reconciliation only with an explicitly reviewed plan ID."); + error.code = expectedPlanId ? "RECONCILIATION_PLAN_STALE" : "RECONCILIATION_PLAN_REQUIRED"; + error.plan = plan; + throw error; + } + const recoverySnapshot = await this.store.createRecoverySnapshot?.(`server-reconciliation-${serverId}`) || null; + let adopted = 0; + let refreshed = 0; + let retired = 0; + let staleProfiles = []; + const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning)); + if (inventoryStable && workloads.length) { + const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId)); + const staleAutomaticProfiles = this.allSshProfiles().filter((profile) => + profile.serverId === serverId + && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic") + && profile.workloadIdentity?.workloadId + && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), + ); + const runningRepositoryLinks = new Set(workloads + .filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName) + .map((workload) => String(workload.link.repositoryFullName).toLowerCase())); + const runningProfileIds = new Set(workloads + .filter((workload) => workload.runtime?.running && workload.link?.profileId) + .map((workload) => workload.link.profileId)); + const shadowedAutomaticProfiles = workloads + .filter((workload) => !workload.runtime?.running && workload.shadowedLink?.profileId && !runningProfileIds.has(workload.shadowedLink.profileId) && runningRepositoryLinks.has(String(workload.shadowedLink.repositoryFullName).toLowerCase())) + .map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.shadowedLink.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.shadowedLink.repositoryFullName).toLowerCase())) + .filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")); + staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, { + profileId: profile.id, + repositoryFullName: profile._repositoryFullName, + reason: staleAutomaticProfiles.includes(profile) ? "workload-missing" : "shadowed-by-running-workload", + }])).values()]; + } + for (const workload of workloads) { + if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue; + const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()); + const existingProfile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId) + || this.allSshProfiles().find((item) => item.id === workload.link.profileId && item._repositoryFullName === workload.link.repositoryFullName); + if (!repository || !existingProfile) continue; + const updated = this.refreshedProfileFromWorkload(repository, server, workload, existingProfile); + const saved = await this.store.saveDeploymentProfile(repository.fullName, updated); + await this.saveWorkloadState(saved, workload, server); + refreshed += 1; + } + if (autoLink) { + const alreadyLinkedRepositories = new Set(workloads + .filter((item) => item.runtime?.running && item.link?.repositoryFullName) + .map((item) => String(item.link.repositoryFullName).toLowerCase())); + for (const workload of workloads) { + if (workload.status === "linked") continue; + if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "external-container", "temporary-runtime", "manually-excluded"].includes(workload.classification?.type)) continue; + const candidate = workload.candidates[0]; + const uniqueCandidate = workload.candidates.length === 1; + if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue; + const exactMatch = uniqueCandidate && candidate?.exact === true; + const exactRuntimeIdentity = uniqueCandidate + && candidate?.identityExact === true + && candidate.score >= 70 + && workload.runtime?.running === true + && Boolean(workload.remoteFolderCandidate) + && !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase()); + if (!exactMatch && !exactRuntimeIdentity) continue; + const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(candidate.repositoryFullName).toLowerCase()); + if (!repository) continue; + const linkSource = exactMatch ? "automatic" : "automatic-runtime-identity"; + const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" }); + const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); + await this.saveWorkloadState(saved, workload, server); + workload.status = "linked"; + workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource }; + alreadyLinkedRepositories.add(String(repository.fullName).toLowerCase()); + adopted += 1; + } + } + for (const stale of staleProfiles) { + await this.store.deleteDeploymentProfile(stale.repositoryFullName, stale.profileId); + retired += 1; + } + const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles }); + response.recoverySnapshot = recoverySnapshot; + await this.diagnostics?.info("unraid.workloads.reconciled", { + serverId, + detected: response.detected, + adopted, + refreshed, + retired, + }); + return response; + } + + async discoverServerWorkloads(serverId, repositories) { + const started = Date.now(); + const inventory = await this.scanServerInventory(serverId, repositories, { autoLink: true }); + const server = this.store.getServer(serverId); + const queue = inventory.workloads.filter((workload) => workload.link?.profileId && workload.link?.repositoryFullName); + const refreshedProfileIds = []; + let giteaUnavailable = false; + let giteaFailureReported = false; + const workers = Array.from({ length: Math.min(5, queue.length) }, async () => { + while (queue.length) { + const workload = queue.shift(); + const repository = repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()); + const profile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId) + || this.store.getDeploymentProfiles?.(workload.link.repositoryFullName)?.find((item) => item.id === workload.link.profileId) + || this.allSshProfiles().find((item) => item.id === workload.link.profileId); + if (!repository || !profile) continue; + let expectedGiteaSha = null; + const status = repository.localStatus; + if (status?.head && status.branch?.head === profile.branch && status.branch?.upstream && status.branch.ahead === 0 && status.branch.behind === 0) { + expectedGiteaSha = status.head; + } else if (!giteaUnavailable) { + try { + const [owner, repo] = String(repository.fullName).split("/"); + const branch = await this.gitea.getBranch(owner, repo, profile.branch); + expectedGiteaSha = branch?.commit?.id || branch?.commit?.sha || null; + } catch (error) { + if (!error?.status || Number(error.status) >= 500) { + giteaUnavailable = true; + if (!giteaFailureReported) { + giteaFailureReported = true; + await this.diagnostics?.warning("unraid.workloads.gitea-verification-degraded", { + serverId, + message: error.message, + }); + } + } + } + } + const health = workload.runtime.running + ? await this.checkHealth(profile.healthcheckUrl) + : { configured: false, healthy: false, skipped: "container-stopped" }; + await this.saveWorkloadState(profile, workload, server, { expectedGiteaSha, health }); + refreshedProfileIds.push(profile.id); + } + }); + await Promise.all(workers); + await this.diagnostics?.debug("unraid.workloads.states-refreshed", { + serverId, + profiles: refreshedProfileIds.length, + durationMs: Date.now() - started, + }); + return { ...inventory, refreshedProfiles: refreshedProfileIds.length, refreshedProfileIds }; + } + + async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) { + const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode) + ? deploymentMode + : "server-git"; + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const inventory = await this.scanServerInventory(serverId, [repository]); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again."); + const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId); + if (existing && String(existing._repositoryFullName).toLowerCase() !== String(repository.fullName).toLowerCase()) { + const error = new Error(`This workload is already linked to ${existing._repositoryFullName}. Remove or edit that link first.`); + error.code = "WORKLOAD_ALREADY_LINKED"; + throw error; + } + const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode: effectiveDeploymentMode, remoteFolder }); + const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); + const state = await this.saveWorkloadState(saved, workload, server); + await this.diagnostics?.info("unraid.workload.linked", { serverId, workloadId, repository: repository.fullName, profileId: saved.id, deploymentMode: effectiveDeploymentMode }); + return { profile: saved, state, workload }; + } + } + return UnraidInventoryMethods.prototype; +} + +module.exports = { createUnraidInventoryMethods }; diff --git a/src/main/unraid-preflight-methods.cjs b/src/main/unraid-preflight-methods.cjs new file mode 100644 index 0000000..99b1d65 --- /dev/null +++ b/src/main/unraid-preflight-methods.cjs @@ -0,0 +1,622 @@ +"use strict"; + +function createUnraidPreflightMethods({ + safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary, + inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote, + assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs, +}) { + class UnraidPreflightMethods { + async saveOperation(operation) { + const saved = await this.store.addOperation(operation); + this.onOperationChange?.({ operations: [saved] }); + return saved; + } + + resolve(repository, profileId) { + const profile = this.store.getDeploymentProfile( + repository.fullName, + profileId, + ); + if (!profile || profile.provider !== "ssh-unraid") + throw new Error("The SSH / Unraid deployment profile no longer exists."); + const server = this.store.getServer(profile.serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const remoteFolder = safeRemoteFolder( + profile.remoteFolder || repository.name, + ); + const remotePath = path.join(server.basePath, remoteFolder); + if (!remotePath.startsWith(`${server.basePath}/`)) + throw new Error( + "Remote project path escapes the configured server base path.", + ); + const effectiveProfile = { + ...profile, + deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle", + }; + return { profile: effectiveProfile, server, remoteFolder, remotePath }; + } + + async discoverExisting({ repository, serverId, remoteFolder = "" }) { + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const folder = safeRemoteFolder(remoteFolder || repository.name); + const remotePath = path.join(server.basePath, folder); + const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false }); + const workload = inventory.workloads.find((item) => + item.remoteFolderCandidate === folder || + item.compose?.workingDir === remotePath || + item.containers.some((container) => (container.mounts || []).some((mount) => { + const source = String(mount.source || "").replace(/\/+$/, ""); + return source === remotePath || source.startsWith(`${remotePath}/`); + })) + ); + if (!workload) { + const error = new Error(`No Docker or Compose workload could be matched to ${remotePath}. Use Server Inventory to select the running container directly.`); + error.code = "SERVER_WORKLOAD_NOT_FOUND"; + throw error; + } + const profile = this.profileFromWorkload(repository, server, workload, { + linkSource: "manual", + deploymentMode: "server-git", + remoteFolder: folder, + }); + const source = (value, origin, confidence = "confirmed") => ({ + value, + origin, + confidence, + detectedAt: new Date().toISOString(), + overridden: false, + }); + const provenance = { + remoteFolder: source(folder, "server-inventory"), + cloneUrl: source(profile.cloneUrl, workload.metadata?.sourceRepository ? "container-provenance" : "repository"), + branch: source(profile.branch, workload.metadata?.branch ? "container-provenance" : "repository"), + composeFile: source(profile.composeFile, "docker-compose-labels"), + composeService: source(profile.composeService, "docker-compose-labels"), + containerName: source(profile.containerName, "docker-inspect"), + hostPort: source(profile.hostPort, "docker-inspect"), + containerPort: source(profile.containerPort, "docker-inspect"), + webUiUrl: source(profile.webUiUrl, workload.dockerMan?.webUiUrl ? "unraid-dockerman" : "docker-labels"), + iconUrl: source(profile.serverIconReference, workload.dockerMan?.iconUrl ? "unraid-dockerman" : "docker-labels"), + dockerShell: source(profile.dockerShell, workload.dockerMan?.shell ? "unraid-dockerman" : "docker-labels"), + }; + return { + repository: repository.fullName, + profile: { ...profile, id: undefined, provenance }, + provenance, + workload, + runtime: { + remotePath, + containerRunning: workload.runtime.running, + containers: workload.containers.length, + services: workload.compose?.services?.length || workload.containers.length, + ports: workload.runtime.ports, + mounts: workload.containers.flatMap((container) => container.mounts || []), + networks: [...new Set(workload.containers.flatMap((container) => container.networks || []))], + envNames: [], + }, + }; + } + + async inspect({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const preserveProbe = (profile.preservePaths || []) + .map( + (relativePath) => + `if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`, + ) + .join("\n"); + const script = ` + root=${shellQuote(remotePath)} + exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths="" + if [ -d "$root" ]; then + exists=true + if [ -d "$root/.git" ]; then + root_git=true + head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) + branch=$(git -C "$root" branch --show-current 2>/dev/null || true) + remote=$(git -C "$root" remote get-url origin 2>/dev/null || true) + tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true) + fi + compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true) + nested_git=$(find "$root" -mindepth 2 -maxdepth 4 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true) + [ -f "$root/Dockerfile" ] && dockerfile=true + [ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true) + existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true) + fi + printf '__FORGEFLOW_KV__\\n' + printf 'exists=%s\\n' "$exists" + printf 'rootGit=%s\\n' "$root_git" + printf 'head=%s\\n' "$head" + printf 'branch=%s\\n' "$branch" + printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" + printf 'trackedChanges=%s\\n' "$tracked_changes" + printf 'composeFiles=%s\\n' "$compose_files" + printf 'nestedGit=%s\\n' "$nested_git" + printf 'dockerfile=%s\\n' "$dockerfile" + printf 'dockerignoreContent=%s\\n' "$dockerignore_content" + printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" + `; + const wrapped = bash(script); + const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 }); + const parsed = parseInspection(result.stdout); + const contextCandidates = [ + ...new Set([ + ...(parsed.existingPreservePaths || []), + ...(parsed.nestedGit || []), + ]), + ]; + const inspection = { + ...parsed, + dockerignore: Boolean(parsed.dockerignoreContent), + dockerignoreGitExcluded: dockerIgnoreHasPath( + parsed.dockerignoreContent, + ".git", + ), + dockerContextExclusionsMissing: parsed.dockerfile + ? contextCandidates.filter( + (item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item), + ) + : [], + serverId: server.id, + serverName: server.name, + remotePath, + profileId: profile.id, + }; + await this.diagnostics?.info("unraid.inspected", { + repository: repository.fullName, + serverId: server.id, + remotePath, + exists: inspection.exists, + rootGit: inspection.rootGit, + head: inspection.head, + composeFiles: inspection.composeFiles, + nestedGitCount: inspection.nestedGit.length, + trackedChangeCount: inspection.trackedChanges.length, + dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing, + }); + return inspection; + } + + async preflight({ repository, profileId, sha = null }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const deploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; + let requestedSha = sha || repository.localStatus?.head; + if (deploymentMode === "server-git" && !sha) { + const [owner, repo] = String(repository.fullName || "").split("/"); + const branch = await this.gitea.getBranch(owner, repo, profile.branch); + requestedSha = branch?.commit?.id || branch?.commit?.sha || null; + } + const targetSha = assertFullCommitSha(requestedSha); + const checks = []; + let inspection = null; + let connectionCapabilities = null; + let permissions = null; + + if (deploymentMode === "monitor-only") checks.push({ + id: "deployment-mode", + label: "Deployment mode", + status: "fail", + detail: "This workload is linked for monitoring only. Select Server pull or Direct copy before deploying.", + }); + else checks.push({ + id: "deployment-mode", + label: "Deployment mode", + status: "pass", + detail: deploymentMode === "server-git" + ? "Unraid fetches the exact Gitea commit with a repository-scoped read-only deploy key." + : "ForgeFlow copies the exact committed local project directly to Unraid and runs Docker Compose there.", + }); + + if (!repository.localPath) { + checks.push({ + id: "local-repository", + label: "Local repository", + status: deploymentMode === "server-git" ? "pass" : "fail", + detail: deploymentMode === "server-git" + ? "Not required: the exact commit is fetched from Gitea by the server." + : "Link or clone the repository locally before using Direct copy.", + }); + } else { + try { + const localStatus = await this.git.status(repository.localPath); + checks.push({ + id: "local-repository", + label: "Local repository", + status: "pass", + detail: localStatus.root, + }); + checks.push({ + id: "local-branch", + label: "Allowed branch", + status: localStatus.branch.head === profile.branch ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", + detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`, + }); + checks.push({ + id: "local-clean", + label: "Clean local working tree", + status: localStatus.clean ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", + detail: localStatus.clean + ? "No uncommitted changes." + : `${localStatus.counts.changed} changed file(s) remain.`, + }); + checks.push({ + id: "deployment-source", + label: deploymentMode === "server-git" ? "Gitea deployment source" : "Direct deployment source", + status: "pass", + detail: deploymentMode === "server-git" + ? "Local files are not uploaded; the exact requested commit is fetched from Gitea." + : "The exact committed local HEAD is archived and copied directly to Unraid. No server-side repository access is involved.", + }); + + + const localDeploymentFiles = deploymentMode === "push-bundle" && profile.generatedCompose + ? [nativePath.join(repository.localPath, "Dockerfile")] + : deploymentMode === "push-bundle" ? this.deploymentComposeFiles(profile).map((file) => + nativePath.join(repository.localPath, safeRelativeRemoteFile(file)), + ) : []; + const missingDeploymentFiles = []; + for (const file of localDeploymentFiles) { + if (!(await fs.stat(file).catch(() => null))?.isFile()) missingDeploymentFiles.push(file); + } + if (deploymentMode === "push-bundle") checks.push({ + id: "local-deployment-file", + label: profile.generatedCompose + ? "Dockerfile in repository" + : localDeploymentFiles.length > 1 ? "Compose files in repository" : "Compose file in repository", + status: missingDeploymentFiles.length ? "fail" : "pass", + detail: missingDeploymentFiles.length + ? `Missing from the exact local checkout: ${missingDeploymentFiles.join(", ")}` + : localDeploymentFiles.join(", "), + }); + } catch (error) { + checks.push({ + id: "local-repository", + label: "Local repository", + status: "fail", + detail: error.message, + }); + } + } + + if (deploymentMode === "server-git") { + const [owner, repo] = String(repository.fullName || "").split("/"); + const deploymentFiles = profile.generatedCompose + ? ["Dockerfile"] + : this.deploymentComposeFiles(profile); + try { + const existence = await Promise.all(deploymentFiles.map(async (filePath) => ({ + filePath, + exists: await this.gitea.repositoryFileExists({ owner, repo, filePath, ref: targetSha }), + }))); + const missing = existence.filter((item) => !item.exists).map((item) => item.filePath); + checks.push({ + id: "gitea-deployment-files", + label: profile.generatedCompose ? "Dockerfile at Gitea commit" : "Compose files at Gitea commit", + status: missing.length ? "fail" : "pass", + detail: missing.length + ? `Missing at exact commit ${targetSha.slice(0, 12)}: ${missing.join(", ")}.` + : `${deploymentFiles.join(", ")} verified at exact commit ${targetSha.slice(0, 12)}.`, + }); + } catch (error) { + checks.push({ + id: "gitea-deployment-files", + label: "Deployment files at Gitea commit", + status: "fail", + detail: error.message, + }); + } + } + + try { + const connection = await this.ssh.test(server.id, { trustOnFirstUse: false }); + connectionCapabilities = connection.capabilities || {}; + checks.push({ + id: "ssh", + label: "Desktop → Unraid SSH", + status: "pass", + detail: `${server.username}@${server.host}:${server.port}`, + }); + checks.push({ + id: "docker-runtime", + label: "Docker runtime", + status: connectionCapabilities.docker && connectionCapabilities.dockerReady ? "pass" : "fail", + detail: connectionCapabilities.dockerReady + ? "Docker is reachable by the configured SSH user." + : connectionCapabilities.docker + ? "Docker is installed, but the configured SSH user cannot query the daemon." + : "Docker was not detected on the server.", + }); + checks.push({ + id: "compose-command", + label: "Docker Compose", + status: connectionCapabilities.compose ? "pass" : "fail", + detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.", + }); + checks.push({ + id: "bundle-tools", + label: deploymentMode === "server-git" ? "Server pull tools" : "Direct copy tools", + status: connectionCapabilities.tar && connectionCapabilities.checksum && (deploymentMode !== "server-git" || connectionCapabilities.git) ? "pass" : "fail", + detail: deploymentMode === "server-git" + ? `Git ${connectionCapabilities.git ? "available" : "missing"}; tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum ${connectionCapabilities.checksum ? "available" : "missing"}.` + : connectionCapabilities.tar && connectionCapabilities.checksum + ? "tar and a SHA-256 checksum tool are available." + : `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`, + }); + checks.push({ + id: "server-base-writable", + label: "Deployment storage writable", + status: connectionCapabilities.baseWritable ? "pass" : "fail", + detail: connectionCapabilities.baseWritable ? `${server.basePath} is writable.` : `${server.basePath} cannot be created or written by this SSH user.`, + }); + } catch (error) { + checks.push({ + id: "ssh", + label: "Desktop → Unraid SSH", + status: "fail", + detail: error.message, + }); + } + if (!server.hostFingerprint) checks.push({ + id: "host-key", + label: "Server identity", + status: "fail", + detail: "Test and trust the SSH host key first.", + }); + else checks.push({ + id: "host-key", + label: "Server identity", + status: "pass", + detail: server.hostFingerprint, + }); + + if (deploymentMode === "server-git") { + const access = await this.probeServerGitAccess({ repository, profile, server }); + checks.push({ + id: "server-git-access", + label: "Unraid → Gitea read access", + status: access.ready ? "pass" : "fail", + detail: access.ready + ? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.` + : access.error, + repairAction: access.ready ? null : "configure-server-git-access", + repairLabel: "Configure read-only deploy key", + }); + } else checks.push({ + id: "transfer-path", + label: "Desktop → Unraid transfer", + status: "pass", + detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.", + }); + + try { + permissions = await this.inspectWriteAccess({ repository, profileId }); + const blockingPaths = permissions.blocking.map((target) => target.path); + checks.push({ + id: "project-write-access", + label: "Project write access", + status: permissions.ready ? "pass" : "fail", + detail: permissions.ready + ? `${permissions.identity.user} can create and atomically replace deployment files in ${remotePath}.` + : `No safe write access for ${permissions.identity.user}: ${blockingPaths.join(", ")}`, + help: permissions.ready + ? "ForgeFlow rechecks these paths immediately before every upload and Compose activation." + : "Use Fix write access to repair only the linked project source and ForgeFlow state folders. Preserved runtime data is excluded.", + repairAction: permissions.ready ? null : "repair-deployment-write-access", + repairLabel: "Fix write access", + }); + for (const target of permissions.targets.filter( + (item) => item.required && !item.effectiveWritable, + )) { + checks.push({ + id: `write-path:${target.id}`, + label: target.label, + status: "fail", + detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`, + repairAction: "repair-deployment-write-access", + repairLabel: "Fix write access", + }); + } + } catch (error) { + checks.push({ + id: "project-write-access", + label: "Project write access", + status: "fail", + detail: error.message, + }); + } + + + try { + inspection = await this.inspect({ repository, profileId }); + if (!inspection.exists) { + checks.push({ + id: "remote-folder", + label: "Remote project folder", + status: "pass", + detail: `${remotePath} will be created.`, + }); + } else { + checks.push({ + id: "remote-folder", + label: inspection.rootGit ? "Remote project folder" : "Existing server installation", + status: "pass", + detail: inspection.rootGit + ? `${remotePath} currently contains Git commit ${String(inspection.head || "").slice(0, 7) || "unknown"}.` + : `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`, + }); + } + if (inspection.rootGit) { + checks.push({ + id: "tracked-changes", + label: "Server-side tracked changes", + status: inspection.trackedChanges.length ? "warning" : "pass", + detail: inspection.trackedChanges.length + ? `${inspection.trackedChanges.length} tracked server edit(s) exist. Direct copy preserves unknown runtime data and does not depend on the server Git checkout.` + : "No tracked server-only edits detected.", + }); + } + + if (inspection.nestedGit.length) { + checks.push({ + id: "nested-git", + label: "Nested Git repositories", + status: "warning", + detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`, + }); + } + if (inspection.dockerfile && !inspection.dockerignore) { + checks.push({ + id: "dockerignore", + label: "Docker build context", + status: "warning", + detail: + "A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.", + }); + } else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) { + checks.push({ + id: "dockerignore-git", + label: "Git metadata excluded from Docker", + status: "warning", + detail: ".dockerignore does not explicitly exclude .git.", + }); + } else if (inspection.dockerfile) { + checks.push({ + id: "dockerignore-git", + label: "Git metadata excluded from Docker", + status: "pass", + detail: ".git is excluded from the Docker build context.", + }); + } + if (inspection.dockerContextExclusionsMissing.length) { + checks.push({ + id: "dockerignore-runtime", + label: "Runtime data excluded from Docker", + status: "warning", + detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`, + }); + } else if ( + inspection.dockerfile && + inspection.existingPreservePaths.length + ) { + checks.push({ + id: "dockerignore-runtime", + label: "Runtime data excluded from Docker", + status: "pass", + detail: + "Detected preserved runtime paths are excluded from the Docker build context.", + }); + } + const composeFiles = profile.generatedCompose + ? [".forgeflow/compose.forgeflow.yml"] + : (profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]) + .map((value) => safeRelativeRemoteFile(value)); + const missingRemoteCompose = composeFiles.filter((composeFile) => !inspection.composeFiles.includes(composeFile)); + checks.push({ + id: "compose-file", + label: "Compose configuration", + status: "pass", + detail: profile.generatedCompose + ? "ForgeFlow will generate an isolated Compose file." + : missingRemoteCompose.length + ? `${composeFiles.join(", ")} will be uploaded from the exact local commit.` + : composeFiles.join(", "), + }); + } catch (error) { + checks.push({ + id: "inspection", + label: "Server project inspection", + status: "fail", + detail: error.message, + }); + } + const iconMode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); + if (iconMode === "upload") { + const iconStat = await fs.stat(profile.iconFilePath).catch(() => null); + checks.push({ + id: "dockerman-icon-file", + label: "DockerMan icon upload", + status: + iconStat?.isFile() && + nativePath.extname(profile.iconFilePath).toLowerCase() === ".png" + ? "pass" + : "fail", + detail: iconStat?.isFile() + ? profile.iconFilePath + : "The selected local PNG icon file was not found.", + }); + } else if (iconMode === "builtin") { + const builtinIcon = nativePath.join( + this.sourcePath, + "src", + "renderer", + "assets", + "itworx-mark.png", + ); + const iconStat = await fs.stat(builtinIcon).catch(() => null); + checks.push({ + id: "dockerman-icon-builtin", + label: "DockerMan icon", + status: iconStat?.isFile() ? "pass" : "fail", + detail: iconStat?.isFile() + ? "Built-in high-contrast ITWorx mark." + : "The built-in ITWorx icon asset is missing.", + }); + } else if (iconMode === "url") + checks.push({ + id: "dockerman-icon", + label: "DockerMan icon", + status: profile.iconUrl ? "pass" : "fail", + detail: + profile.iconUrl || "Icon URL mode requires an HTTPS or HTTP PNG URL.", + }); + else + checks.push({ + id: "dockerman-icon", + label: "DockerMan icon", + status: "warning", + detail: "Custom DockerMan icon disabled.", + }); + const webUiLabel = this.dockerManWebUi(profile); + checks.push({ + id: "dockerman-webui", + label: "DockerMan Web UI action", + status: webUiLabel ? "pass" : "warning", + detail: webUiLabel || "No Web UI URL or host port is configured.", + }); + checks.push({ + id: "compose-identity", + label: "Safe Docker Compose identity", + status: "pass", + detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.`, + }); + checks.push({ + id: "exact-sha", + label: "Exact deployment commit", + status: "pass", + detail: targetSha, + }); + return { + provider: "ssh-unraid", + repository: repository.fullName, + environment: profile.environment, + sha: targetSha, + server: { id: server.id, name: server.name, host: server.host }, + remotePath, + inspection, + permissions, + checks, + summary: checksSummary(checks), + }; + } + } + return UnraidPreflightMethods.prototype; +} + +module.exports = { createUnraidPreflightMethods }; diff --git a/src/main/unraid-runtime-methods.cjs b/src/main/unraid-runtime-methods.cjs new file mode 100644 index 0000000..520b854 --- /dev/null +++ b/src/main/unraid-runtime-methods.cjs @@ -0,0 +1,387 @@ +"use strict"; + +function createUnraidRuntimeMethods({ + safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run, + bash, shellQuote, iconReferenceLocalPath, +}) { + class UnraidRuntimeMethods { + internalSlug(profile, repository) { + return ( + String( + profile.remoteFolder || + repository.name || + profile.composeService || + "app", + ) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || "app" + ); + } + + generatedCompose(profile, repository) { + const service = + String(profile.composeService || repository.name || "app") + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") || "app"; + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + service, + ).replace(/[^A-Za-z0-9._-]/g, "-") || service; + if (!profile.hostPort || !profile.containerPort) + throw new Error( + "Host and container ports are required for generated Compose.", + ); + return ( + [ + "services:", + ` ${service}:`, + ` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase()}`, + " build:", + " context: ..", + ` container_name: ${containerName}`, + " restart: unless-stopped", + " ports:", + ` - "${profile.hostPort}:${profile.containerPort}"`, + ].join("\n") + "\n" + ); + } + + dockerManWebUi(profile) { + if (profile.hostPort) { + let suffix = "/"; + try { + const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null; + suffix = parsed + ? `${parsed.pathname || "/"}${parsed.search || ""}${parsed.hash || ""}` + : "/"; + } catch {} + if (!suffix.startsWith("/")) suffix = `/${suffix}`; + return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`; + } + return profile.webUiUrl || ""; + } + + dockerManShell(profile) { + return String(profile.dockerShell || "/bin/sh") + .toLowerCase() + .includes("bash") + ? "bash" + : "sh"; + } + + dockerManTemplatePath(profile, repository) { + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`; + } + + dockerManTemplate(profile, repository, iconReference = "") { + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + const slug = this.internalSlug(profile, repository); + const environment = + String(profile.environment || "production") + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") || "production"; + const image = `forgeflow/${slug}:${environment}`; + const webUi = this.dockerManWebUi(profile); + return ( + [ + '', + '', + ` ${xmlEscape(containerName)}`, + ` ${xmlEscape(image)}`, + " ", + " bridge", + " ", + ` ${xmlEscape(this.dockerManShell(profile))}`, + " false", + " ", + " ", + " Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.", + " Tools:", + ` ${xmlEscape(webUi)}`, + " ", + ` ${xmlEscape(iconReference)}`, + " ", + " ", + " ", + " ", + " ", + "", + ].join("\n") + "\n" + ); + } + + iconCacheRefresh(profile, repository, iconReference = "") { + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`; + const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`; + const localIconPath = iconReferenceLocalPath(iconReference); + if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`; + return `${cacheLoop} + if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi + ${invalidateMetadata}`; + } + + dockerManRefreshScript(profile, repository, iconReference = "") { + if (profile.manageDockerMan !== true || profile.adoptedFromServer === true || profile.generatedCompose !== true) { + return `echo 'ForgeFlow left the existing DockerMan template unchanged.'`; + } + const templatePath = this.dockerManTemplatePath(profile, repository); + const template = this.dockerManTemplate(profile, repository, iconReference); + return `mkdir -p /boot/config/plugins/dockerMan/templates-user + cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE' + ${template}FORGEFLOW_DOCKERMAN_TEMPLATE + chmod 0644 ${shellQuote(templatePath)} + ${this.iconCacheRefresh(profile, repository, iconReference)}`; + } + + deploymentServices(profile, repository) { + const values = profile.generatedCompose + ? [profile.composeService || repository.name || "app"] + : (profile.composeServices?.length ? profile.composeServices : [profile.composeService || repository.name || "app"]); + return [...new Set(values.map((value) => String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")).filter(Boolean))]; + } + + deploymentComposeFiles(profile) { + if (profile.generatedCompose) return [".forgeflow/compose.forgeflow.yml"]; + const values = profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]; + return [...new Set(values + .map((value) => safeRelativeRemoteFile(value)) + .filter((value) => value !== ".forgeflow/compose.metadata.yml" && value !== ".forgeflow/compose.forgeflow.yml"))]; + } + + metadataCompose(profile, repository, iconReference = "", deployment = {}) { + const services = this.deploymentServices(profile, repository); + const labels = { + "net.unraid.docker.managed": "dockerman", + "net.unraid.docker.shell": this.dockerManShell(profile), + "tech.itworx.forgeflow.repository": + deployment.repositoryUrl || + profile.cloneUrl || + repository.sshUrl || + repository.cloneUrl || + repository.htmlUrl || + repository.fullName || repository.name || "unknown", + "tech.itworx.forgeflow.branch": profile.branch || "main", + }; + if (deployment.sha) labels["tech.itworx.forgeflow.commit"] = deployment.sha; + const webUiLabel = this.dockerManWebUi(profile); + if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel; + if (iconReference) labels["net.unraid.docker.icon"] = iconReference; + + const output = ["services:"]; + for (const service of services) { + output.push(` ${service}:`); + if (profile.generatedCompose) { + const containerName = String( + profile.containerName || profile.remoteFolder || repository.name || service, + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + output.push(` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase().replace(/[^a-z0-9._-]/g, "-")}`); + output.push(` container_name: ${containerName}`); + } + output.push(" labels:"); + output.push(...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`)); + } + return `${output.join("\n")}\n`; + } + + async prepareIcon(profile, repository, server) { + const mode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); + if (mode === "none") return profile.serverIconReference || ""; + if (mode === "url") { + if (!profile.iconUrl) + throw new Error( + "DockerMan icon URL mode is selected, but no icon URL is configured.", + ); + return profile.iconUrl; + } + const localIconPath = + mode === "builtin" + ? nativePath.join( + this.sourcePath, + "src", + "renderer", + "assets", + "itworx-mark.png", + ) + : profile.iconFilePath; + const stat = await fs.stat(localIconPath).catch(() => null); + if (!stat?.isFile()) + throw new Error( + mode === "builtin" + ? "The built-in ITWorx DockerMan icon is missing." + : `The selected DockerMan icon file no longer exists: ${localIconPath}`, + ); + if (nativePath.extname(localIconPath).toLowerCase() !== ".png") + throw new Error( + "DockerMan icon upload currently accepts PNG files only.", + ); + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`; + await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { + mode: 0o644, + }); + return `file://${remoteIconPath}`; + } + + composeInvocation(profile, repository) { + const project = String(profile.composeProject || this.internalSlug(profile, repository)).trim(); + const files = [...this.deploymentComposeFiles(profile)]; + // A labels-only Compose fragment is valid only when every service key also + // exists in the base definition. Imported profiles can contain stale service + // hints, so adopted workloads must activate from their real server Compose + // files only. ForgeFlow tracks the deployed SHA in .forgeflow/status.json. + if (profile.generatedCompose) files.push(".forgeflow/compose.metadata.yml"); + return `forgeflow_compose -p ${shellQuote(project)} ${files.map((file) => `-f ${shellQuote(file)}`).join(" ")}`; + } + + composeUpFlags(profile) { + // ForgeFlow never adds destructive recreation or orphan-removal flags. + // Compose may replace a service when its built image or configuration changed, + // but unrelated containers are never deleted by ForgeFlow. + void profile; + return ""; + } + + containerVerificationScript(profile, repository, compose, { requireRecreated = false } = {}) { + const recreationCheck = requireRecreated + ? ` before_id=$(awk -F '\t' -v wanted="$service" '$1 == wanted { print $2; exit }' "$before_containers" 2>/dev/null || true) + if [ -n "$before_id" ] && [ "$before_id" = "$container_id" ]; then + echo "Compose reported success but service $service still uses the previous container $container_id" >&2 + exit 65 + fi` + : ` before_id=""`; + return `actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + [ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; } + printf '%s\n' "$actual_services" | while IFS= read -r service; do + [ -n "$service" ] || continue + attempt=0; container_id=''; running=false; health='' + while [ "$attempt" -lt 30 ]; do + container_id=$(${compose} ps -q "$service" | head -n1) + if [ -n "$container_id" ]; then + running=$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false) + health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true) + if [ "$running" = true ] && [ "$health" != unhealthy ] && [ "$health" != starting ]; then break; fi + fi + attempt=$((attempt + 1)); sleep 2 + done + [ -n "$container_id" ] || { echo "Compose service $service did not create a container" >&2; exit 61; } + [ "$running" = true ] || { echo "Compose service $service is not running after 60 seconds" >&2; exit 62; } + [ "$health" != unhealthy ] && [ "$health" != starting ] || { echo "Compose service $service did not become healthy" >&2; exit 63; } + ${recreationCheck} + image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true) + printf 'ForgeFlow verified service %s: container=%s previous=%s image=%s\n' "$service" "$container_id" "\${before_id:-none}" "\${image_id:-unknown}" + done`; + } + + async checkHealth(url) { + if (!url) + return { + configured: false, + healthy: null, + status: null, + latencyMs: null, + }; + let last = null; + for (let attempt = 1; attempt <= 5; attempt += 1) { + const started = Date.now(); + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(8_000), + redirect: "manual", + }); + last = { + configured: true, + healthy: response.ok, + status: response.status, + latencyMs: Date.now() - started, + }; + if (response.ok) return last; + } catch (error) { + last = { + configured: true, + healthy: false, + status: null, + latencyMs: Date.now() - started, + error: error.message, + }; + } + if (attempt < 5) + await new Promise((resolve) => setTimeout(resolve, 3_000)); + } + return last; + } + + hashFile(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fileSystem.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); + } + + async createCommitBundle(repository, sha, requestId) { + if (!repository.localPath) throw new Error("A linked local repository is required to create a push bundle."); + const bundleDirectory = nativePath.join(os.tmpdir(), "forgeflow-bundles"); + await fs.mkdir(bundleDirectory, { recursive: true }); + const archivePath = nativePath.join(bundleDirectory, `${requestId}-${sha}.tar`); + await run("git", ["-C", repository.localPath, "archive", "--format=tar", `--output=${archivePath}`, sha], { + timeout: 5 * 60_000, + maxBuffer: 4 * 1024 * 1024, + }); + const stat = await fs.stat(archivePath); + if (!stat.isFile() || stat.size <= 0) throw new Error("Git produced an empty deployment bundle."); + return { archivePath, bytes: stat.size, sha256: await this.hashFile(archivePath) }; + } + + deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback = false }) { + return JSON.stringify({ + repository: repository.fullName, + environment: profile.environment, + requested_sha: targetSha, + live_sha: targetSha, + request_id: requestId, + healthy: null, + healthcheck_url_configured: Boolean(profile.healthcheckUrl), + rollback, + deployment_mode: profile.deploymentMode || "push-bundle", + deployed_at: new Date().toISOString(), + }); + } + } + return UnraidRuntimeMethods.prototype; +} + +module.exports = { createUnraidRuntimeMethods }; diff --git a/src/main/unraid-state-methods.cjs b/src/main/unraid-state-methods.cjs new file mode 100644 index 0000000..0ace1f9 --- /dev/null +++ b/src/main/unraid-state-methods.cjs @@ -0,0 +1,293 @@ +"use strict"; + +function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }) { + class UnraidStateMethods { + async refreshProfileState(fullName, profileId, expectedGiteaSha = null) { + const repository = { fullName, name: fullName.split("/").pop() }; + const { profile, server, remotePath } = this.resolve(repository, profileId); + const containerName = String( + profile.containerName || profile.remoteFolder || repository.name, + ); + const script = ` + root=${shellQuote(remotePath)} + container=${shellQuote(containerName)} + template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")} + live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false + [ -f "$template_path" ] && template_exists=true + [ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha") + [ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) + [ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha") + if docker inspect "$container" >/dev/null 2>&1; then + running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false) + docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true) + webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true) + icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true) + shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true) + [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true) + [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true) + fi + printf '__FORGEFLOW_KV__\n' + printf 'liveSha=%s\n' "$live" + printf 'previousSha=%s\n' "$previous" + printf 'containerRunning=%s\n' "$running" + printf 'dockerHealth=%s\n' "$docker_health" + printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')" + printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')" + printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')" + printf 'templateExists=%s\n' "$template_exists" + `; + const result = await this.ssh.exec(server.id, bash(script), { + timeout: 30_000, + }); + const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__"); + if (marker < 0) + throw new Error( + "Unraid state inspection did not return a ForgeFlow marker.", + ); + const fields = {}; + for (const line of result.stdout + .slice(marker + "__FORGEFLOW_KV__".length) + .trim() + .split(/\r?\n/)) { + const index = line.indexOf("="); + if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1); + } + const decode = (value) => { + try { + return value ? Buffer.from(value, "base64").toString("utf8") : ""; + } catch { + return ""; + } + }; + const containerRunning = fields.containerRunning === "true"; + const health = containerRunning + ? await this.checkHealth(profile.healthcheckUrl) + : { configured: false, healthy: false, skipped: "container-stopped" }; + const dockerHealthy = fields.dockerHealth + ? fields.dockerHealth === "healthy" + : null; + const effectiveHealthy = !containerRunning ? false : health.configured ? health.healthy : dockerHealthy; + const runtimeVerification = !containerRunning + ? "stopped" + : health.configured + ? "desktop-healthcheck" + : dockerHealthy === true + ? "docker-healthcheck" + : dockerHealthy === false + ? "docker-unhealthy" + : containerRunning + ? "running-unverified" + : "stopped"; + return this.store.saveDeploymentState(profile.id, { + liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "") + ? fields.liveSha + : null, + previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "") + ? fields.previousSha + : null, + healthy: effectiveHealthy, + runtimeVerification, + healthStatus: health.status, + healthLatencyMs: health.latencyMs, + containerName, + containerRunning, + dockerHealth: fields.dockerHealth || null, + dockerMan: { + webUi: decode(fields.webUiLabel), + icon: decode(fields.iconLabel), + shell: decode(fields.shellLabel), + templateExists: fields.templateExists === "true", + configured: Boolean( + decode(fields.webUiLabel) || + decode(fields.iconLabel) || + fields.templateExists === "true", + ), + }, + webUiUrl: + profile.webUiUrl || + (profile.hostPort + ? `http://${server.host}:${profile.hostPort}/` + : null), + remotePath, + provider: "ssh-unraid", + giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) + ? expectedGiteaSha + : null, + matchesGitea: + /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) && + fields.liveSha === expectedGiteaSha, + }); + } + + async applyDockerManMetadata({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + if (profile.generatedCompose !== true) { + // Existing Compose files remain authoritative. Applying a generated + // labels-only service fragment can create a phantom service when a stale + // profile hint no longer matches the real Compose service keys. + return this.refreshProfileState(repository.fullName, profileId); + } + const iconReference = await this.prepareIcon(profile, repository, server); + const metadata = this.metadataCompose(profile, repository, iconReference); + const compose = this.composeInvocation(profile, repository); + const flags = this.composeUpFlags(profile); + const script = ` + root=${shellQuote(remotePath)} + test -d "$root" + mkdir -p "$root/.forgeflow" + cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' + ${metadata}FORGEFLOW_METADATA + cd "$root" + ${compose} config >/dev/null + ${compose} up -d --build ${flags} + ${this.containerVerificationScript(profile, repository, compose)} + ${this.dockerManRefreshScript(profile, repository, iconReference)} + `; + await this.ssh.exec(server.id, bash(script), { + timeout: 10 * 60_000, + maxOutput: 2 * 1024 * 1024, + }); + return this.refreshProfileState(repository.fullName, profileId); + } + + async refreshOperation( + operationId, + { includeTerminal = false, state: suppliedState = null } = {}, + ) { + const operation = this.store.getOperation(operationId); + if (!operation || operation.provider !== "ssh-unraid") return operation; + if ( + !includeTerminal && + ["success", "failed", "cancelled", "rolled-back"].includes( + operation.status, + ) + ) + return operation; + try { + const state = + suppliedState || + (await this.refreshProfileState( + operation.repository, + operation.profileId, + )); + if ( + state.liveSha === operation.sha && + state.containerRunning && + state.healthy !== false + ) { + return this.saveOperation({ + ...operation, + status: operation.action === "rollback" ? "rolled-back" : "success", + health: { healthy: state.healthy, status: state.healthStatus }, + logs: [ + ...(operation.logs || []), + "Deployment state reconciled from Unraid.", + ], + }); + } + if ( + /^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) && + state.liveSha !== operation.sha && + state.containerRunning && + state.healthy !== false + ) { + return this.saveOperation({ + ...operation, + status: "cancelled", + error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`, + health: { healthy: state.healthy, status: state.healthStatus }, + logs: [ + ...(operation.logs || []), + `Operation superseded by live Unraid commit ${state.liveSha}.`, + ], + }); + } + const ageMs = + Date.now() - + new Date(operation.updatedAt || operation.createdAt || 0).getTime(); + if (ageMs > 45 * 60_000) { + return this.saveOperation({ + ...operation, + status: "failed", + error: + "Deployment was interrupted or did not reach the requested commit within 45 minutes.", + logs: [ + ...(operation.logs || []), + "Stale deployment was marked failed during reconciliation.", + ], + }); + } + return operation; + } catch { + return operation; + } + } + + async reconcileRecordedOperations(profileId, state) { + const operations = this.store.data.operations + .filter( + (item) => + item.profileId === profileId && item.provider === "ssh-unraid", + ) + .sort( + (left, right) => + new Date(right.updatedAt || right.createdAt || 0) - + new Date(left.updatedAt || left.createdAt || 0), + ); + const matching = operations.find( + (item) => item.sha === state.liveSha && item.status === "failed", + ); + if (matching && state.containerRunning && state.healthy !== false) { + await this.refreshOperation(matching.id, { + includeTerminal: true, + state, + }); + } + const latestFailed = operations.find((item) => item.status === "failed"); + if ( + latestFailed && + latestFailed.id !== matching?.id && + state.matchesGitea && + state.containerRunning && + state.healthy !== false + ) { + await this.saveOperation({ + ...latestFailed, + status: "cancelled", + error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`, + logs: [ + ...(latestFailed.logs || []), + `Reconciled: Gitea and Unraid now both report ${state.liveSha}.`, + ], + }); + } + return this.store.data.operations + .filter((item) => item.profileId === profileId) + .slice(0, 10); + } + + async refreshActiveOperations() { + const active = this.store.data.operations.filter( + (item) => + item.provider === "ssh-unraid" && + item.type === "deployment" && + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ); + const queue = [...active]; + const results = []; + const workers = Array.from({ length: Math.min(4, queue.length) }, async () => { + while (queue.length) { + const operation = queue.shift(); + results.push(await this.refreshOperation(operation.id)); + } + }); + await Promise.all(workers); + return results; + } + } + return UnraidStateMethods.prototype; +} + +module.exports = { createUnraidStateMethods }; diff --git a/src/main/update-service.cjs b/src/main/update-service.cjs new file mode 100644 index 0000000..a8b2b5f --- /dev/null +++ b/src/main/update-service.cjs @@ -0,0 +1,871 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const fsSync = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { spawn } = require("node:child_process"); +const { isNewerVersion } = require("../shared/semver.cjs"); + +function safeRepositoryPart(value, label) { + const text = String(value || "").trim(); + if (!/^[a-zA-Z0-9_.-]+$/.test(text)) + throw new Error(`${label} contains unsupported characters.`); + return text; +} + +function verifyReleaseManifest({ + manifestBytes, + signatureBytes, + publicKey, + update, + assetName, +}) { + if ( + !Buffer.isBuffer(manifestBytes) || + manifestBytes.length < 100 || + manifestBytes.length > 1_000_000 + ) { + throw new Error("The signed release manifest has an invalid size."); + } + const signatureText = Buffer.from(signatureBytes || "") + .toString("utf8") + .trim(); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(signatureText)) { + throw new Error("The release manifest signature is invalid."); + } + const signature = Buffer.from(signatureText, "base64"); + if (signature.length !== 64) { + throw new Error("The release manifest signature is invalid."); + } + let verified = false; + try { + verified = crypto.verify(null, manifestBytes, publicKey, signature); + } catch { + verified = false; + } + if (!verified) { + const error = new Error( + "The release manifest was not signed by the trusted ForgeFlow publisher key.", + ); + error.code = "RELEASE_SIGNATURE_INVALID"; + throw error; + } + + let manifest; + try { + manifest = JSON.parse(manifestBytes.toString("utf8")); + } catch { + throw new Error("The signed release manifest is not valid JSON."); + } + const expectedVersion = String(update.remoteVersion || "").trim(); + const expectedCommit = String(update.remoteSha || "").toLowerCase(); + if ( + manifest.schemaVersion !== 1 || + manifest.product !== "ForgeFlow" || + manifest.version !== expectedVersion || + manifest.tag !== `v${expectedVersion}` || + manifest.signature?.algorithm !== "Ed25519" || + (expectedCommit && + String(manifest.commit || "").toLowerCase() !== expectedCommit) + ) { + const error = new Error( + "The signed release manifest does not match the requested ForgeFlow update.", + ); + error.code = "RELEASE_MANIFEST_MISMATCH"; + throw error; + } + const artifact = Array.isArray(manifest.artifacts) + ? manifest.artifacts.find((item) => item?.name === assetName) + : null; + if ( + !artifact || + !Number.isSafeInteger(artifact.bytes) || + artifact.bytes < 1_000_000 || + !/^[a-f0-9]{64}$/.test(String(artifact.sha256 || "")) + ) { + const error = new Error( + `The signed release manifest has no valid entry for ${assetName}.`, + ); + error.code = "RELEASE_MANIFEST_INCOMPLETE"; + throw error; + } + return { manifest, artifact }; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function requireSignedSourceUpdate(message) { + const error = new Error(message); + error.code = "SIGNED_SOURCE_UPDATE_REQUIRED"; + throw error; +} + +function resolveWindowsPowerShellPath(environment = process.env) { + const windowsRoot = environment.SystemRoot || environment.WINDIR; + if (windowsRoot) { + const absolute = path.join( + windowsRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + if (fsSync.existsSync(absolute)) return absolute; + } + return "powershell.exe"; +} + +function windowsUpdaterSpawnOptions(cwd) { + return { + // A detached hidden PowerShell child can exit successfully on Windows + // without ever executing its -File script. Normal Windows children survive + // their parent; unref() below releases the event-loop reference instead. + detached: false, + stdio: "ignore", + windowsHide: true, + cwd, + }; +} + +async function readJsonFile(filePath) { + try { + return JSON.parse(await fs.readFile(filePath, "utf8")); + } catch { + return null; + } +} + +async function readLogTail(filePath, maxLines = 12) { + if (!filePath) return ""; + try { + const text = await fs.readFile(filePath, "utf8"); + return text.split(/\r?\n/).filter(Boolean).slice(-maxLines).join("\n"); + } catch { + return ""; + } +} + +async function updaterStartupError( + message, + code, + { statusPath, logPath, expectedUpdateId } = {}, +) { + const status = statusPath ? await readJsonFile(statusPath) : null; + const logTail = await readLogTail(logPath); + const details = []; + if ( + status?.updateId && + expectedUpdateId && + status.updateId !== expectedUpdateId + ) + details.push("The helper wrote a status for a different update request."); + if (status?.message) details.push(status.message); + if (logTail) details.push(`Update helper log:\n${logTail}`); + const error = new Error([message, ...details].filter(Boolean).join("\n\n")); + error.code = code; + error.status = status; + error.logPath = logPath || null; + return error; +} + +async function waitForUpdaterStarted( + statusPath, + { + timeoutMs = 15000, + pollMs = 100, + childState = null, + expectedUpdateId = null, + logPath = null, + } = {}, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const status = await readJsonFile(statusPath); + const belongsToRequest = + !expectedUpdateId || status?.updateId === expectedUpdateId; + if ( + status && + belongsToRequest && + [ + "started", + "waiting-for-exit", + "backing-up", + "extracting", + "applying", + "validating", + ].includes(status.state) + ) { + return status; + } + if ( + status && + belongsToRequest && + ["failed", "rolled-back"].includes(status.state) + ) { + throw await updaterStartupError( + "The update helper reported a failure before ForgeFlow could close.", + "UPDATE_HELPER_START_FAILED", + { statusPath, logPath, expectedUpdateId }, + ); + } + if (childState?.error) throw childState.error; + if (childState?.exited) { + throw await updaterStartupError( + `The update helper exited before it confirmed startup (exit code ${childState.code ?? "unknown"}).`, + "UPDATE_HELPER_EXITED_EARLY", + { statusPath, logPath, expectedUpdateId }, + ); + } + await delay(pollMs); + } + throw await updaterStartupError( + "The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.", + "UPDATE_HELPER_START_TIMEOUT", + { statusPath, logPath, expectedUpdateId }, + ); +} + +class UpdateService { + constructor({ + store, + gitea, + diagnostics, + appInfo, + sourcePath, + userDataPath, + platform = process.platform, + spawnProcess = spawn, + powershellPath = null, + handshakeTimeoutMs = 12000, + handshakePollMs = 100, + updatePublicKey = null, + }) { + this.store = store; + this.gitea = gitea; + this.diagnostics = diagnostics; + this.appInfo = appInfo; + this.sourcePath = sourcePath; + this.updateDirectory = path.join(userDataPath, "updates"); + this.platform = platform; + this.spawnProcess = spawnProcess; + this.powershellPath = powershellPath; + this.handshakeTimeoutMs = handshakeTimeoutMs; + this.handshakePollMs = handshakePollMs; + this.updatePublicKey = updatePublicKey; + this.staged = null; + } + + async check() { + const settings = this.store.data.updates || {}; + const owner = safeRepositoryPart( + settings.owner || "Jens", + "Update repository owner", + ); + const repo = safeRepositoryPart( + settings.repo || "ForgeFlow", + "Update repository name", + ); + const branchName = String(settings.branch || "main").trim(); + const branch = await this.gitea.getBranch(owner, repo, branchName); + const remoteSha = + branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id; + if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || ""))) + throw new Error( + "Gitea did not return a full commit SHA for the update branch.", + ); + + const file = await this.gitea.getRepositoryFile({ + owner, + repo, + filePath: "package.json", + ref: remoteSha, + }); + let manifest; + try { + manifest = JSON.parse(file.decoded); + } catch { + throw new Error("The remote ForgeFlow package.json is not valid JSON."); + } + if (manifest.name !== "forgeflow") + throw new Error( + "The configured update repository is not a ForgeFlow source repository.", + ); + const remoteVersion = String(manifest.version || "").trim(); + const currentVersion = String(this.appInfo.version || "").trim(); + const available = isNewerVersion(remoteVersion, currentVersion); + const result = { + checkedAt: new Date().toISOString(), + owner, + repo, + branch: branchName, + currentVersion, + remoteVersion, + remoteSha, + shortSha: remoteSha.slice(0, 7), + available, + packaged: Boolean(this.appInfo.packaged), + mode: this.appInfo.packaged ? "packaged" : "source", + }; + this.store.data.updates.lastCheckedAt = result.checkedAt; + await this.store.save(); + await this.diagnostics?.info("updates.checked", { + repository: `${owner}/${repo}`, + branch: branchName, + currentVersion, + remoteVersion, + remoteSha, + available, + mode: result.mode, + }); + return result; + } + + async download(expected = null) { + const update = expected?.remoteSha ? expected : await this.check(); + if (!update.available) + return { ...update, downloaded: false, reason: "up-to-date" }; + if (this.appInfo.packaged) { + return this.downloadPackaged(update); + } + + requireSignedSourceUpdate( + "Integrated source updates are disabled because source archives do not yet carry an independently signed publisher manifest. Update a source checkout with Git after reviewing the exact commit.", + ); + + /* c8 ignore start -- retained for a future signed source-archive implementation */ + await fs.mkdir(this.updateDirectory, { recursive: true }); + const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`; + const archive = await this.gitea.downloadAuthenticated(archiveUrl); + if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b) + throw new Error("The downloaded update is not a valid ZIP archive."); + const sha256 = crypto.createHash("sha256").update(archive).digest("hex"); + const archivePath = path.join( + this.updateDirectory, + `ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`, + ); + const metadataPath = `${archivePath}.json`; + await fs.writeFile(archivePath, archive, { mode: 0o600 }); + const metadata = { + ...update, + archivePath, + sha256, + downloadedAt: new Date().toISOString(), + }; + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), { + mode: 0o600, + }); + this.staged = metadata; + await this.diagnostics?.info("updates.downloaded", { + remoteVersion: update.remoteVersion, + remoteSha: update.remoteSha, + bytes: archive.length, + sha256, + }); + return { ...metadata, downloaded: true }; + /* c8 ignore stop */ + } + + async downloadPackaged(update) { + if (this.platform !== "win32") + throw new Error("Packaged auto-update currently supports Windows only."); + const release = + (await this.gitea.getReleaseByTag( + update.owner, + update.repo, + `v${update.remoteVersion}`, + )) || + (await this.gitea.getReleaseByTag( + update.owner, + update.repo, + update.remoteVersion, + )); + if (!release || release.draft || release.prerelease) { + const error = new Error( + `ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching signed Windows release was not published. Run Publish-Missing-Binary-Release.ps1 from the release source.`, + ); + error.code = "BINARY_RELEASE_NOT_FOUND"; + throw error; + } + + const portable = Boolean(this.appInfo.portableExecutablePath); + const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`; + const checksumName = `${assetName}.sha256`; + const manifestName = `ForgeFlow-${update.remoteVersion}-release-manifest.json`; + const signatureName = `${manifestName}.sig`; + const assets = Array.isArray(release.assets) ? release.assets : []; + const asset = assets.find((item) => item.name === assetName); + const checksumAsset = assets.find((item) => item.name === checksumName); + const manifestAsset = assets.find((item) => item.name === manifestName); + const signatureAsset = assets.find((item) => item.name === signatureName); + if ( + !asset?.id || + !checksumAsset?.id || + !manifestAsset?.id || + !signatureAsset?.id + ) { + const error = new Error( + `Release v${update.remoteVersion} is incomplete: the executable, SHA-256 file, signed manifest and signature are all required.`, + ); + error.code = "BINARY_RELEASE_INCOMPLETE"; + throw error; + } + + const [binary, checksumBytes, manifestBytes, signatureBytes] = + await Promise.all([ + this.gitea.downloadReleaseAsset( + update.owner, + update.repo, + release.id, + asset.id, + { downloadUrl: asset.browser_download_url }, + ), + this.gitea.downloadReleaseAsset( + update.owner, + update.repo, + release.id, + checksumAsset.id, + { downloadUrl: checksumAsset.browser_download_url }, + ), + this.gitea.downloadReleaseAsset( + update.owner, + update.repo, + release.id, + manifestAsset.id, + { downloadUrl: manifestAsset.browser_download_url }, + ), + this.gitea.downloadReleaseAsset( + update.owner, + update.repo, + release.id, + signatureAsset.id, + { downloadUrl: signatureAsset.browser_download_url }, + ), + ]); + + const publicKey = + this.updatePublicKey || + (await fs.readFile( + path.join(this.sourcePath, "build", "update-signing-public.pem"), + )); + const { manifest, artifact } = verifyReleaseManifest({ + manifestBytes, + signatureBytes, + publicKey, + update, + assetName, + }); + if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) { + const preview = binary.subarray(0, 200).toString("utf8").trim(); + const looksLikeMetadata = + /^\s*[{[]/.test(preview) || /browser_download_url/i.test(preview); + const error = new Error( + looksLikeMetadata + ? "Gitea returned release-asset metadata instead of the Windows executable. Upgrade ForgeFlow with the 0.9.1 installer once; later in-app updates use the actual browser download URL." + : "The downloaded Windows update is not a valid executable.", + ); + error.code = looksLikeMetadata + ? "RELEASE_ASSET_METADATA_RECEIVED" + : "INVALID_WINDOWS_UPDATE"; + throw error; + } + const expectedSha256 = checksumBytes + .toString("utf8") + .trim() + .split(/\s+/)[0] + ?.toLowerCase(); + if (!/^[a-f0-9]{64}$/.test(expectedSha256 || "")) + throw new Error("The release SHA-256 file is invalid."); + if (expectedSha256 !== artifact.sha256) { + throw new Error( + "The release checksum does not match the signed publisher manifest.", + ); + } + if (binary.length !== artifact.bytes) { + throw new Error( + "The downloaded Windows update size does not match the signed publisher manifest.", + ); + } + const sha256 = crypto.createHash("sha256").update(binary).digest("hex"); + if (sha256 !== expectedSha256) + throw new Error( + "The downloaded Windows update failed SHA-256 verification.", + ); + + await fs.mkdir(this.updateDirectory, { recursive: true }); + const binaryPath = path.join(this.updateDirectory, assetName); + await fs.writeFile(binaryPath, binary, { mode: 0o600 }); + const metadata = { + ...update, + kind: "binary", + binaryPath, + assetName, + sha256, + portable, + executablePath: portable + ? this.appInfo.portableExecutablePath + : this.appInfo.executablePath, + releaseTag: release.tag_name, + publisherKeyId: manifest.signature.keyId, + releaseManifest: manifestName, + downloadedAt: new Date().toISOString(), + downloaded: true, + }; + await fs.writeFile( + `${binaryPath}.json`, + JSON.stringify(metadata, null, 2), + { mode: 0o600 }, + ); + this.staged = metadata; + await this.diagnostics?.info("updates.binary-downloaded", { + remoteVersion: update.remoteVersion, + assetName, + bytes: binary.length, + sha256, + portable, + publisherKeyId: manifest.signature.keyId, + }); + return metadata; + } + + async apply(staged = null) { + const update = + staged?.archivePath || staged?.binaryPath ? staged : this.staged; + if (!update?.archivePath && !update?.binaryPath) + throw new Error("Download an update before applying it."); + if (this.platform !== "win32") + throw new Error( + "The integrated updater currently supports Windows only.", + ); + if (update.kind === "binary") return this.applyPackaged(update); + requireSignedSourceUpdate( + "This source archive cannot be applied because it has no independently signed publisher manifest.", + ); + /* c8 ignore start -- legacy helper retained only for migration compatibility */ + const stat = await fs.stat(update.archivePath).catch(() => null); + if (!stat?.isFile()) + throw new Error("The staged update archive is no longer available."); + + const scriptPath = path.join( + this.sourcePath, + "scripts", + "apply-source-update.ps1", + ); + const scriptStat = await fs.stat(scriptPath).catch(() => null); + if (!scriptStat?.isFile()) + throw new Error("The source update helper is missing."); + + await fs.mkdir(this.updateDirectory, { recursive: true }); + const updateId = `${Date.now()}-${crypto.randomUUID()}`; + const logPath = path.join(this.updateDirectory, `apply-${updateId}.log`); + const statusPath = path.join( + this.updateDirectory, + `apply-${updateId}.status.json`, + ); + const launching = { + schemaVersion: 1, + updateId, + state: "launching", + expectedVersion: update.remoteVersion, + sourcePath: this.sourcePath, + logPath, + statusPath, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), { + mode: 0o600, + }); + + const executable = this.powershellPath || resolveWindowsPowerShellPath(); + const args = [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-SourcePath", + this.sourcePath, + "-ArchivePath", + update.archivePath, + "-ExpectedVersion", + update.remoteVersion, + "-ExpectedSha256", + update.sha256, + "-ParentPid", + String(process.pid), + "-LogPath", + logPath, + "-StatusPath", + statusPath, + "-UpdateId", + updateId, + ]; + + const childState = { exited: false, code: null, error: null }; + let child; + try { + child = this.spawnProcess( + executable, + args, + windowsUpdaterSpawnOptions(this.sourcePath), + ); + } catch (error) { + error.code ||= "UPDATE_HELPER_SPAWN_FAILED"; + throw error; + } + + child.once?.("error", (error) => { + childState.error = error; + }); + child.once?.("exit", (code) => { + childState.exited = true; + childState.code = code; + }); + await new Promise((resolve, reject) => { + let settled = false; + const finish = (handler, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + handler(value); + }; + const timer = setTimeout( + () => + finish( + reject, + Object.assign( + new Error("Windows did not start the update helper process."), + { code: "UPDATE_HELPER_SPAWN_TIMEOUT" }, + ), + ), + 5000, + ); + child.once?.("spawn", () => finish(resolve)); + // Kept attached rather than `once`: a process that fails to start can + // report a second error, and an unhandled 'error' event ends this process. + child.on?.("error", (error) => finish(reject, error)); + if (!child.once) finish(resolve); + }); + + const started = await waitForUpdaterStarted(statusPath, { + timeoutMs: this.handshakeTimeoutMs, + pollMs: this.handshakePollMs, + childState, + expectedUpdateId: updateId, + logPath, + }); + child.unref?.(); + + await this.diagnostics?.info("updates.apply-started", { + updateId, + remoteVersion: update.remoteVersion, + remoteSha: update.remoteSha, + logPath, + statusPath, + helperPid: child.pid, + helperState: started.state, + }); + return { + launched: true, + confirmed: true, + updateId, + version: update.remoteVersion, + logPath, + statusPath, + }; + /* c8 ignore stop */ + } + + async applyPackaged(update) { + const stat = await fs.stat(update.binaryPath).catch(() => null); + if (!stat?.isFile()) + throw new Error("The staged Windows update is no longer available."); + const actualSha256 = crypto + .createHash("sha256") + .update(await fs.readFile(update.binaryPath)) + .digest("hex"); + if (actualSha256 !== update.sha256) + throw new Error( + "The staged Windows update failed its final SHA-256 check.", + ); + const helperRoot = this.sourcePath.toLowerCase().endsWith("app.asar") + ? `${this.sourcePath}.unpacked` + : this.sourcePath; + const scriptPath = path.join( + helperRoot, + "scripts", + "apply-binary-update.ps1", + ); + if (!(await fs.stat(scriptPath).catch(() => null))?.isFile()) + throw new Error("The binary update helper is missing."); + + await fs.mkdir(this.updateDirectory, { recursive: true }); + const updateId = `${Date.now()}-${crypto.randomUUID()}`; + const logPath = path.join(this.updateDirectory, `binary-${updateId}.log`); + const statusPath = path.join( + this.updateDirectory, + `binary-${updateId}.status.json`, + ); + const launching = { + schemaVersion: 1, + updateId, + state: "launching", + expectedVersion: update.remoteVersion, + logPath, + statusPath, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), { + mode: 0o600, + }); + const executable = this.powershellPath || resolveWindowsPowerShellPath(); + const args = [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-BinaryPath", + update.binaryPath, + "-ExpectedSha256", + update.sha256, + "-ExpectedVersion", + update.remoteVersion, + "-CurrentExecutable", + update.executablePath || this.appInfo.executablePath, + "-Portable", + String(Boolean(update.portable)), + "-ParentPid", + String(process.pid), + "-LogPath", + logPath, + "-StatusPath", + statusPath, + "-UpdateId", + updateId, + ]; + const child = this.spawnProcess( + executable, + args, + windowsUpdaterSpawnOptions(this.updateDirectory), + ); + const childState = { exited: false, code: null, error: null }; + child.once?.("error", (error) => { + childState.error = error; + }); + child.once?.("exit", (code) => { + childState.exited = true; + childState.code = code; + }); + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + Object.assign( + new Error("Windows did not start the binary update helper."), + { code: "UPDATE_HELPER_SPAWN_TIMEOUT" }, + ), + ), + 5000, + ); + child.once?.("spawn", () => { + clearTimeout(timer); + resolve(); + }); + // Kept attached rather than `once`: a second error would otherwise have no + // listener left, and an unhandled 'error' event ends this process. + child.on?.("error", (error) => { + clearTimeout(timer); + reject(error); + }); + if (!child.once) { + clearTimeout(timer); + resolve(); + } + }); + const started = await waitForUpdaterStarted(statusPath, { + timeoutMs: this.handshakeTimeoutMs, + pollMs: this.handshakePollMs, + childState, + expectedUpdateId: updateId, + logPath, + }); + child.unref?.(); + await this.diagnostics?.info("updates.binary-apply-started", { + updateId, + remoteVersion: update.remoteVersion, + assetName: update.assetName, + helperState: started.state, + }); + return { + launched: true, + confirmed: true, + updateId, + version: update.remoteVersion, + logPath, + statusPath, + }; + } + + async consumeLatestResult() { + await fs.mkdir(this.updateDirectory, { recursive: true }); + const entries = await fs + .readdir(this.updateDirectory, { withFileTypes: true }) + .catch(() => []); + const candidates = []; + for (const entry of entries) { + if ( + !entry.isFile() || + !/^(?:apply|binary)-.*\.status\.json$/i.test(entry.name) + ) + continue; + const filePath = path.join(this.updateDirectory, entry.name); + const stat = await fs.stat(filePath).catch(() => null); + if (stat) candidates.push({ filePath, mtimeMs: stat.mtimeMs }); + } + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + for (const candidate of candidates) { + const status = await readJsonFile(candidate.filePath); + if ( + !status || + status.acknowledgedAt || + !["success", "rolled-back", "failed"].includes(status.state) + ) + continue; + status.acknowledgedAt = new Date().toISOString(); + await fs.writeFile(candidate.filePath, JSON.stringify(status, null, 2), { + mode: 0o600, + }); + return { + state: status.state, + expectedVersion: status.expectedVersion || null, + installedVersion: status.installedVersion || null, + message: status.message || "", + logPath: status.logPath || null, + restartLaunched: Boolean(status.restartLaunched), + completedAt: status.completedAt || status.updatedAt || null, + }; + } + return null; + } +} + +module.exports = { + UpdateService, + safeRepositoryPart, + verifyReleaseManifest, + resolveWindowsPowerShellPath, + windowsUpdaterSpawnOptions, + waitForUpdaterStarted, + readJsonFile, + readLogTail, + requireSignedSourceUpdate, +}; diff --git a/src/renderer/actions/command.js b/src/renderer/actions/command.js new file mode 100644 index 0000000..970f4a5 --- /dev/null +++ b/src/renderer/actions/command.js @@ -0,0 +1,22 @@ +async function handleCommandActions(event, target, action, repository) { + if (action === "run-command") { + const command = target.dataset.command; + ui.modal = null; + if (command === "overview") ui.currentView = "overview"; + else if (command === "deployments") ui.currentView = "deployments"; + else if (command === "diagnostics") ui.currentView = "diagnostics"; + else if (command === "settings") ui.currentView = "settings"; + else if (command === "refresh") await refreshRepositories(true); + else if (command === "open-folder" && repository?.localPath) + await window.forgeflow.openPath(repository.localPath); + else if (command === "git-tools" && repository) + await loadGitTools(repository); + else if (command === "deploy-selected" && canDeploy(repository)) { + const profile = selectedProfile(repository); + if (profile) await runDeploymentPreflight(repository, profile.id); + } + render(); + } + else return false; + return true; +} diff --git a/src/renderer/actions/deployment-operation.js b/src/renderer/actions/deployment-operation.js new file mode 100644 index 0000000..8a06369 --- /dev/null +++ b/src/renderer/actions/deployment-operation.js @@ -0,0 +1,183 @@ +async function handleDeploymentOperationActions(event, target, action, repository) { + if (action === "deploy-profile") { + if (repository && String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + ) || selectedProfile(repository); + ui.selectedProfileId = profile?.id || null; + if (!profile) return; + const report = await runDeploymentPreflight(repository, profile.id, { + showModal: true, + }); + if (!report) return; + } else if (action === "continue-after-preflight") { + const profile = selectedRepository()?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + ); + if (!profile || !ui.deploymentPreflight?.summary?.ready) return; + if (profile.confirmationRequired !== false) { + ui.modal = { type: "deploy-confirm", profileId: profile.id }; + render(); + } else await executeDeployment(profile.id); + } else if (action === "confirm-deploy") + await executeDeployment(target.dataset.profileId); + else if (action === "rollback-profile") { + if (!repository) repository = profileRepository(target.dataset.profileId); + if (repository && String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); + ui.modal = { + type: "rollback-confirm", + profileId: target.dataset.profileId, + }; + render(); + } else if (action === "confirm-rollback") + await executeRollback(target.dataset.profileId); + else if (action === "refresh-profile-state") { + if (!repository) repository = profileRepository(target.dataset.profileId); + const profile = repository?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + ); + setLoading(true, `Checking ${profile?.environment || "environment"}…`); + try { + const state = await window.forgeflow.refreshProfileState( + repository.fullName, + target.dataset.profileId, + ); + profile.state = state; + showToast( + "Environment checked", + state.healthy === false + ? "Healthcheck reports an unhealthy state." + : state.liveSha + ? `Server reports ${shortSha(state.liveSha)}.` + : "Connection checked; no live SHA reported.", + state.healthy === false ? "error" : "success", + ); + } catch (error) { + showToast("Status check failed", error.message, "error"); + } + setLoading(false); + } else if (action === "repair-missing-dockerman") { + const targets = ui.repositories.flatMap((candidate) => + (candidate.deploymentProfiles || []) + .filter( + (profile) => + profile.provider === "ssh-unraid" && + profile.state?.containerRunning && + !dockerManIntegration(profile).ready, + ) + .map((profile) => ({ repository: candidate, profile })), + ); + if (!targets.length) return; + if ( + !confirm( + `Recreate ${targets.length} running container${targets.length === 1 ? "" : "s"} with the missing DockerMan WebUI, icon and template metadata?`, + ) + ) + return; + setLoading(true, "Repairing missing DockerMan integrations…"); + let repaired = 0; + const failures = []; + for (const item of targets) { + try { + await window.forgeflow.applyDockerManMetadata( + item.repository, + item.profile.id, + ); + repaired += 1; + } catch (error) { + failures.push(`${item.repository.name}: ${error.message}`); + } + } + await refreshDeploymentTruth(false); + showToast( + failures.length + ? "DockerMan repair partially completed" + : "DockerMan integrations repaired", + failures.length + ? `${repaired} repaired, ${failures.length} failed.` + : `${repaired} running container${repaired === 1 ? "" : "s"} updated.`, + failures.length ? "error" : "success", + ); + setLoading(false); + } else if (action === "apply-dockerman-metadata") { + if (!repository) repository = profileRepository(target.dataset.profileId); + setLoading( + true, + "Applying DockerMan labels, template, icon and WebUI metadata…", + ); + try { + await window.forgeflow.applyDockerManMetadata( + repository, + target.dataset.profileId, + ); + await refreshRepositories(false); + showToast( + "DockerMan integration repaired", + "The container was recreated with labels, a persistent template, WebUI and icon metadata.", + "success", + ); + } catch (error) { + showToast( + "Could not repair DockerMan integration", + error.message, + "error", + ); + } + setLoading(false); + } else if (action === "reconcile-deployment") { + if (!repository) repository = profileRepository(target.dataset.profileId); + setLoading(true, "Reconciling ForgeFlow with the live Unraid container…"); + try { + await window.forgeflow.reconcileDeployment( + repository.fullName, + target.dataset.profileId, + ); + await refreshActiveOperations(false); + await refreshRepositories(false); + showToast( + "Deployment reconciled", + "Live SHA, container health and operation status were refreshed.", + "success", + ); + } catch (error) { + showToast("Could not reconcile deployment", error.message, "error"); + } + setLoading(false); + } else if (action === "open-profile-webui") + await window.forgeflow.openExternal(target.dataset.url); + else if (action === "open-operation") { + const operation = await window.forgeflow.getOperation( + target.dataset.operationId, + ); + if (operation) { + ui.activeDeployment = operation; + ui.currentView = "deployment-run"; + render(); + startOperationPolling(); + } + } else if (action === "refresh-current-operation") { + setLoading(true, "Refreshing deployment status…"); + try { + const operation = await window.forgeflow.refreshOperations( + ui.activeDeployment.id, + ); + updateOperationInState(operation); + if (!isTerminalOperation(operation.status)) startOperationPolling(); + } catch (error) { + showToast("Status refresh failed", error.message, "error"); + } + setLoading(false); + } else if (action === "open-run-url") + await window.forgeflow.openExternal(ui.activeDeployment.runUrl); + else if (action === "close-deployment") { + stopOperationPolling(); + ui.activeDeployment = null; + ui.currentView = selectedRepository() ? "repository" : "deployments"; + render(); + } + else return false; + return true; +} diff --git a/src/renderer/actions/deployment-profile.js b/src/renderer/actions/deployment-profile.js new file mode 100644 index 0000000..49dfd92 --- /dev/null +++ b/src/renderer/actions/deployment-profile.js @@ -0,0 +1,407 @@ +async function handleDeploymentProfileActions(event, target, action, repository) { + if (action === "configure-deployment") { + ui.deploymentDiscovery = null; + ui.modal = { + type: "deployment-config", + profileId: null, + provider: (ui.boot.state.servers || []).length + ? "ssh-unraid" + : "gitea-actions", + }; + render(); + } else if (action === "edit-deployment-profile") { + ui.deploymentDiscovery = null; + repository = profileRepository(target.dataset.profileId) || repository; + if (repository && String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); + ui.modal = { + type: "deployment-config", + profileId: target.dataset.profileId || null, + provider: repository?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + )?.provider, + }; + render(); + } else if (action === "close-modal") { + if (ui.modal?.type === "workspace-sync") ui.workspaceSyncPlan = null; + ui.modal = null; + render(); + } else if (action === "select-profile-icon") { + const iconPath = await window.forgeflow.selectImageFile({ + title: "Select DockerMan PNG icon", + defaultPath: + document.querySelector("#profile-icon-file")?.value || undefined, + }); + if (iconPath) { + document.querySelector("#profile-icon-file").value = iconPath; + const mode = document.querySelector("#profile-icon-mode"); + if (mode) mode.value = "upload"; + } + } else if (action === "clear-profile-icon") { + const input = document.querySelector("#profile-icon-file"); + if (input) input.value = ""; + const mode = document.querySelector("#profile-icon-mode"); + if (mode) mode.value = "builtin"; + } else if (action === "discover-existing-deployment") { + const serverId = document.querySelector("#profile-server")?.value; + const remoteFolder = + document.querySelector("#profile-remote-folder")?.value.trim() || + safeCloneFolderName(repository); + if (!serverId) { + showToast( + "Select an Unraid server", + "Configure and select the server before importing an existing deployment.", + "error", + ); + return; + } + setLoading( + true, + "Reading Git, Compose, Docker and DockerMan from the server…", + ); + try { + const result = await window.forgeflow.discoverExistingDeployment( + repository, + serverId, + remoteFolder, + ); + ui.deploymentDiscovery = { ...result, repository: repository.fullName }; + showToast( + "Existing deployment imported", + `${result.runtime.containers} container(s), ${result.runtime.services} service(s) and ${result.runtime.ports.length} port mapping(s) detected.`, + "success", + ); + render(); + } catch (error) { + showToast("Could not import deployment", error.message, "error"); + } + setLoading(false); + } else if (action === "save-deployment-profile") { + const previousProfile = + repository?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + ) || {}; + const provider = document.querySelector("#profile-provider").value; + let maintenanceWindows = []; + try { + maintenanceWindows = ( + document.querySelector("#profile-policy-windows")?.value || "" + ) + .split("|") + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => { + const match = item.match( + /^([0-6](?:,[0-6])*)\s*:\s*((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d)$/, + ); + if (!match) throw new Error(`Invalid maintenance window: ${item}`); + return { + days: match[1].split(",").map(Number), + start: match[2], + end: match[3], + }; + }); + } catch (error) { + showToast("Could not save profile", error.message, "error"); + return; + } + const composeFiles = + provider === "ssh-unraid" + ? (document.querySelector("#profile-compose-files")?.value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : []; + const composeServices = + provider === "ssh-unraid" + ? (document.querySelector("#profile-compose-services")?.value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : []; + const profile = { + id: target.dataset.profileId || undefined, + provider, + name: document.querySelector("#profile-name").value.trim(), + environment: document.querySelector("#profile-environment").value.trim(), + branch: document.querySelector("#profile-branch").value.trim(), + healthcheckUrl: + document.querySelector("#profile-healthcheck")?.value.trim() || "", + confirmationRequired: document.querySelector("#profile-confirmation") + .checked, + deploymentPolicy: { + frozen: + document.querySelector("#profile-policy-frozen")?.checked === true, + freezeReason: + document + .querySelector("#profile-policy-freeze-reason") + ?.value.trim() || "", + requireNote: + document.querySelector("#profile-policy-note")?.checked === true, + maintenanceWindows, + }, + ...(provider === "ssh-unraid" + ? { + serverId: document.querySelector("#profile-server").value, + remoteFolder: document + .querySelector("#profile-remote-folder") + .value.trim(), + deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes( + document.querySelector("#profile-deployment-mode")?.value, + ) ? document.querySelector("#profile-deployment-mode").value : "server-git", + cloneUrl: previousProfile.cloneUrl || "", + alignRemote: false, + generatedCompose: + document.querySelector("#profile-generated-compose").value === + "true", + composeProject: + document.querySelector("#profile-compose-project")?.value.trim() || + previousProfile.composeProject || + "", + composeWorkingDir: previousProfile.composeWorkingDir || "", + composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"], + composeFile: composeFiles[0] || "docker-compose.yml", + composeServices: composeServices.length + ? composeServices + : [safeCloneFolderName(repository).toLowerCase()], + composeService: + composeServices[0] || safeCloneFolderName(repository).toLowerCase(), + containerName: document + .querySelector("#profile-container-name") + .value.trim(), + hostPort: + Number(document.querySelector("#profile-host-port").value) || + null, + containerPort: + Number(document.querySelector("#profile-container-port").value) || + null, + webUiUrl: document.querySelector("#profile-web-ui").value.trim(), + iconMode: document.querySelector("#profile-icon-mode").value, + iconUrl: document.querySelector("#profile-icon-url").value.trim(), + iconFilePath: document + .querySelector("#profile-icon-file") + .value.trim(), + dockerShell: document.querySelector("#profile-docker-shell").value, + preservePaths: document + .querySelector("#profile-preserve-paths") + .value.split(",") + .map((item) => item.trim()) + .filter(Boolean), + manageDockerMan: + document.querySelector("#profile-manage-dockerman")?.checked === + true, + forceRecreate: false, + removeOrphans: false, + adoptedFromServer: Boolean( + ui.deploymentDiscovery || previousProfile.adoptedFromServer, + ), + serverSourceOfTruth: Boolean( + ui.deploymentDiscovery || previousProfile.serverSourceOfTruth, + ), + workloadIdentity: + ui.deploymentDiscovery?.profile?.workloadIdentity || + previousProfile.workloadIdentity || + null, + detectedAt: + ui.deploymentDiscovery?.profile?.detectedAt || + previousProfile.detectedAt || + null, + provenance: + ui.deploymentDiscovery?.provenance || + previousProfile.provenance || + {}, + detectedMetadata: + ui.deploymentDiscovery?.profile?.detectedMetadata || + previousProfile.detectedMetadata || + {}, + } + : { + workflowFile: document + .querySelector("#profile-workflow") + .value.trim(), + rollbackWorkflowFile: document + .querySelector("#profile-rollback-workflow") + .value.trim(), + statusUrl: document + .querySelector("#profile-status-url") + .value.trim(), + }), + }; + setLoading(true, "Saving deployment environment…"); + try { + const result = await window.forgeflow.saveDeploymentProfile( + repository.fullName, + profile, + ); + ui.boot.state = result.state; + ui.modal = null; + ui.deploymentDiscovery = null; + await refreshRepositories(false); + ui.selectedProfileId = result.profile.id; + showToast( + "Deployment configured", + `${profile.name} targets ${profile.environment}.`, + "success", + ); + } catch (error) { + showToast("Could not save profile", error.message, "error"); + } + setLoading(false); + } else if (action === "delete-deployment-profile") { + if ( + !confirm("Delete this deployment profile? Operation history is retained.") + ) + return; + setLoading(true, "Deleting deployment profile…"); + try { + const result = await window.forgeflow.deleteDeploymentProfile( + repository.fullName, + target.dataset.profileId, + ); + ui.boot.state = result.state; + ui.modal = null; + await refreshRepositories(false); + showToast( + "Profile deleted", + "Deployment environment removed.", + "success", + ); + } catch (error) { + showToast("Could not delete profile", error.message, "error"); + } + setLoading(false); + } else if (action === "run-deployment-preflight") { + if (!repository) repository = profileRepository(target.dataset.profileId); + await runDeploymentPreflight(repository, target.dataset.profileId); + } else if (action === "manage-deploy-key") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + setLoading(true, "Inspecting deploy-key lifecycle without changing access…"); + try { + const [inventory, rotation, revocation] = await Promise.all([ + window.forgeflow.deployKeyInventory(repository, profileId), + window.forgeflow.planDeployKeyRotation(repository, profileId), + window.forgeflow.planDeployKeyRevocation(repository, profileId), + ]); + ui.deployKeyLifecycle = { repositoryId: repository.id, profileId, inventory, rotation, revocation }; + ui.modal = { type: "deploy-key-lifecycle" }; + render(); + } catch (error) { + showToast("Could not inspect deploy key", error.message, "error"); + } finally { setLoading(false); } + } else if (action === "confirm-rotate-deploy-key") { + const lifecycle = ui.deployKeyLifecycle; + const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); + if (!targetRepository || !lifecycle?.rotation?.id) return; + setLoading(true, "Rotating and verifying the repository deploy key…"); + try { + const result = await window.forgeflow.applyDeployKeyRotation(targetRepository, lifecycle.profileId, lifecycle.rotation.id); + if (result.state) ui.boot.state = result.state; + ui.modal = null; ui.deployKeyLifecycle = null; + await refreshRepositories(false, true); + showToast("Deploy key rotated", `New fingerprint ${result.profile?.serverGitAccess?.keyFingerprint || "verified"}.`, "success"); + } catch (error) { showToast("Deploy-key rotation failed safely", error.message, "error"); } + finally { setLoading(false); } + } else if (action === "confirm-revoke-deploy-key") { + const lifecycle = ui.deployKeyLifecycle; + const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); + if (!targetRepository || !lifecycle?.revocation?.id) return; + setLoading(true, "Revoking repository access while preserving recovery…"); + try { + const result = await window.forgeflow.applyDeployKeyRevocation(targetRepository, lifecycle.profileId, lifecycle.revocation.id); + if (result.state) ui.boot.state = result.state; + ui.modal = null; ui.deployKeyLifecycle = null; + await refreshRepositories(false, true); + showToast("Deploy key revoked", "Server pull is disabled; containers were not changed and recovery is available.", "success"); + } catch (error) { showToast("Deploy-key revocation failed", error.message, "error"); } + finally { setLoading(false); } + } else if (action === "restore-deploy-key") { + const lifecycle = ui.deployKeyLifecycle; + const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); + if (!targetRepository || !lifecycle?.profileId) return; + setLoading(true, "Restoring and verifying repository access…"); + try { + const result = await window.forgeflow.restoreDeployKey(targetRepository, lifecycle.profileId); + if (result.state) ui.boot.state = result.state; + ui.modal = null; ui.deployKeyLifecycle = null; + await refreshRepositories(false, true); + showToast("Deploy key restored", "Read-only server pull access is verified again.", "success"); + } catch (error) { showToast("Deploy-key recovery failed", error.message, "error"); } + finally { setLoading(false); } + } else if (action === "verify-server-git-access") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + setLoading(true, "Verifying Gitea, deploy key, server commit and runtime…"); + try { + const result = await window.forgeflow.verifyServerGitProfile(repository, profileId); + ui.serverGitVerifications[profileId] = result; + render(); + const blockers = result.deploymentBlockers || []; + const warnings = result.checks.filter((check) => check.status !== "pass" && !blockers.some((blocker) => blocker.id === check.id)); + showToast( + result.readiness, + blockers[0]?.detail || warnings[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, + blockers.length ? "error" : warnings.length ? "warning" : "success", + ); + } catch (error) { + showToast("Server-pull verification failed", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "configure-server-git-access") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + const approved = confirm( + `Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`, + ); + if (!approved) return; + setLoading(true, "Configuring repository-scoped Gitea access…"); + try { + const result = await window.forgeflow.configureServerGitAccess(repository, profileId); + if (result.state) ui.boot.state = result.state; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Server pull ready", + `Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`, + "success", + ); + await runDeploymentPreflight(repository, profileId, { showModal: true }); + } catch (error) { + showToast("Could not configure Gitea access", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "repair-deployment-write-access") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + const profile = repository.deploymentProfiles?.find((item) => item.id === profileId); + const approved = confirm( + `Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`, + ); + if (!approved) return; + setLoading(true, "Repairing scoped Unraid write access…"); + try { + const result = await window.forgeflow.repairDeploymentWriteAccess( + repository, + profileId, + ); + showToast( + "Write access normalized", + "Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.", + "success", + ); + await runDeploymentPreflight(repository, profileId, { showModal: true }); + } catch (error) { + showToast("Write-access repair failed", error.message, "error"); + } finally { + setLoading(false); + } + } + else return false; + return true; +} diff --git a/src/renderer/actions/inventory.js b/src/renderer/actions/inventory.js new file mode 100644 index 0000000..f8feb30 --- /dev/null +++ b/src/renderer/actions/inventory.js @@ -0,0 +1,185 @@ +async function handleInventoryActions(event, target, action, repository) { + if (action === "scan-server-inventory") { + setLoading(true, "Scanning Docker, Compose and DockerMan workloads…"); + try { + await refreshDeploymentTruth(true); + const detected = (ui.serverDiscovery || []).reduce( + (total, item) => total + Number(item.detected || 0), + 0, + ); + const review = (ui.serverDiscovery || []).reduce( + (total, item) => total + Number(item.needsReview || 0), + 0, + ); + const failures = (ui.serverDiscovery || []).filter((item) => item.error); + if (failures.length) { + showToast( + "Server scan failed", + failures.map((item) => `${item.serverName || item.serverId}: ${item.error}`).join(" · "), + "error", + ); + } else { + showToast( + "Server inventory updated", + `${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`, + review ? "info" : "success", + ); + } + } catch (error) { + showToast("Server scan failed", error.message, "error"); + } + setLoading(false); + } else if (action === "plan-server-reconciliation") { + setLoading(true, "Building a read-only reconciliation preview…"); + try { + const result = await window.forgeflow.planServerReconciliation(target.dataset.serverId); + ui.modal = { type: "server-reconciliation-plan", result }; + render(); + } catch (error) { + showToast("Could not build reconciliation plan", error.message, "error"); + } + setLoading(false); + } else if (action === "apply-server-reconciliation") { + setLoading(true, "Applying the reviewed configuration plan…"); + try { + const result = await window.forgeflow.applyServerReconciliation(target.dataset.serverId, target.dataset.planId); + if (result.state) ui.boot.state = result.state; + ui.modal = null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast("Reconciliation applied", `${result.adopted || 0} link(s) added and ${result.refreshed || 0} profile(s) refreshed. No containers were changed.`, "success"); + } catch (error) { + showToast("Reconciliation was not applied", error.message, "error"); + } + setLoading(false); + } else if (action === "quick-link-server-workload") { + const serverResult = (ui.serverDiscovery || []).find( + (item) => item.serverId === target.dataset.serverId, + ); + const workload = serverResult?.workloads?.find( + (item) => item.workloadId === target.dataset.workloadId, + ); + const linkedRepository = ui.repositories.find( + (item) => item.fullName === target.dataset.repository, + ); + if (!workload || !linkedRepository || !workload.remoteFolderCandidate) { + showToast("Automatic link unavailable", "Scan the server again and use Review & link.", "error"); + return; + } + setLoading(true, `Linking ${workload.displayName} to ${linkedRepository.fullName}…`); + try { + const result = await window.forgeflow.linkServerWorkload( + linkedRepository, + target.dataset.serverId, + target.dataset.workloadId, + "server-git", + workload.remoteFolderCandidate, + ); + if (result.state) ui.boot.state = result.state; + ui.selectedProfileId = result.profile?.id || null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Deployment linked", + `${linkedRepository.fullName} is linked to ${workload.compose?.workingDir || workload.remoteFolderCandidate}. Compose values were read from the server.`, + "success", + ); + } catch (error) { + showToast("Could not link deployment", error.message, "error"); + } + setLoading(false); + } else if (action === "preview-inventory-review") { + const reviewAction = document.querySelector("#inventory-review-action")?.value || "ignore"; + const reason = document.querySelector("#inventory-review-reason")?.value.trim() || ""; + const serverId = target.dataset.serverId; + const workloadId = target.dataset.workloadId; + try { + ui.inventoryReviewPlan = await window.forgeflow.planInventoryReview(serverId, workloadId, reviewAction, reason, document.querySelector("#workload-repository")?.value || null); + ui.modal = { type: "inventory-review-plan" }; + render(); + } catch (error) { showToast("Review preview unavailable", error.message, "error"); } + } else if (action === "apply-inventory-review") { + const plan = ui.inventoryReviewPlan; + if (!plan?.id) return; + setLoading(true, "Saving the evidence-bound inventory decision…"); + try { + const result = await window.forgeflow.applyInventoryReview(plan.serverId, plan.workloadId, plan.action, plan.reason, plan.repositoryFullName, plan.id); + if (result.state) ui.boot.state = result.state; + ui.serverDiscovery = (ui.serverDiscovery || []).map((item) => item.serverId === plan.serverId ? result.inventory : item); + ui.inventoryReviewPlan = null; ui.modal = null; render(); + showToast("Inventory decision saved", "Containers and Compose runtime were not changed.", "success"); + } catch (error) { showToast("Inventory review failed safely", error.message, "error"); } + finally { setLoading(false); } + } else if (action === "link-server-workload") { + const serverResult = (ui.serverDiscovery || []).find( + (item) => item.serverId === target.dataset.serverId, + ); + const workload = serverResult?.workloads?.find( + (item) => item.workloadId === target.dataset.workloadId, + ); + if (!workload) { + showToast( + "Workload unavailable", + "Scan the server inventory again before linking this workload.", + "error", + ); + return; + } + ui.modal = { + type: "workload-link", + serverId: target.dataset.serverId, + workloadId: target.dataset.workloadId, + repositoryFullName: + workload.candidates?.[0]?.repositoryFullName || + repository?.fullName || + ui.repositories[0]?.fullName || + "", + remoteFolder: workload.remoteFolderCandidate || "", + }; + render(); + } else if (action === "confirm-link-server-workload") { + const repositoryFullName = document + .querySelector("#workload-repository") + ?.value.trim(); + const deploymentMode = document.querySelector("#workload-deployment-mode")?.value || "server-git"; + const remoteFolder = document + .querySelector("#workload-remote-folder") + ?.value.trim(); + const linkedRepository = ui.repositories.find( + (item) => item.fullName === repositoryFullName, + ); + if (!linkedRepository) { + showToast( + "Choose a repository", + "The workload must be linked to a ForgeFlow project.", + "error", + ); + return; + } + setLoading(true, "Saving the permanent server workload link…"); + try { + const result = await window.forgeflow.linkServerWorkload( + linkedRepository, + target.dataset.serverId, + target.dataset.workloadId, + deploymentMode, + remoteFolder, + ); + if (result.state) ui.boot.state = result.state; + ui.modal = null; + ui.selectedProfileId = result.profile?.id || null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Workload linked", + `${linkedRepository.fullName} now uses direct desktop-to-Unraid copy and the Compose configuration detected on the server.`, + "success", + ); + } catch (error) { + showToast("Could not link workload", error.message, "error"); + } + setLoading(false); + } + else return false; + return true; +} diff --git a/src/renderer/actions/recovery.js b/src/renderer/actions/recovery.js new file mode 100644 index 0000000..ed98546 --- /dev/null +++ b/src/renderer/actions/recovery.js @@ -0,0 +1,421 @@ +async function handleRecoveryActions(event, target, action, repository) { + if (action === "repair-origin") { + if (!repository?.localPath || !repository.sshUrl) return; + if ( + !confirm( + `Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`, + ) + ) + return; + setLoading(true, "Updating Git origin…"); + try { + await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl); + await refreshRepositories(false); + showToast("Git origin updated", repository.sshUrl, "success"); + } catch (error) { + showToast("Could not update origin", error.message, "error"); + } + setLoading(false); + } else if (action === "normalize-origins") { + if ( + !confirm( + "Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.", + ) + ) + return; + setLoading(true, "Normalizing linked Git origins…"); + try { + const result = await window.forgeflow.normalizeOrigins(); + ui.repositories = result.repositories; + showToast( + "Git origins normalized", + `${result.changes.length} repository origin${result.changes.length === 1 ? "" : "s"} updated.`, + "success", + ); + } catch (error) { + showToast("Could not normalize origins", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "scan-git-recovery") { + if (!repository?.localPath) return; + setLoading(true, "Scanning Git directory and active processes…"); + try { + ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( + repository.localPath, + ); + ui.repositoryTab = "gittools"; + showToast( + "Git health scan complete", + `${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`, + ui.gitRecovery.lockReport.locks.length ? "info" : "success", + ); + } catch (error) { + showToast("Git health scan failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "repair-git-locks" || action === "repair-index-lock") { + if ( + !repository?.localPath || + !confirm( + "Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.", + ) + ) + return; + setLoading(true, "Safely repairing stale Git locks…"); + try { + const result = await window.forgeflow.repairGitLocks( + repository.localPath, + false, + ); + ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( + repository.localPath, + ); + await refreshRepositories(false); + showToast( + "Git locks repaired", + `${result.removed.length} stale lock file(s) removed.`, + "success", + ); + } catch (error) { + if ( + error.code === "GIT_PROCESS_PROBE_UNAVAILABLE" && + confirm(`${error.message} + +Force repair after you have closed all Git tools for this repository?`) + ) { + try { + const result = await window.forgeflow.repairGitLocks( + repository.localPath, + true, + ); + showToast( + "Git locks force-repaired", + `${result.removed.length} lock file(s) removed.`, + "success", + ); + await refreshRepositories(false); + } catch (forceError) { + showToast("Could not repair Git locks", forceError.message, "error"); + } + } else showToast("Could not repair Git locks", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "reconcile-repository") { + if (!repository?.localPath) return; + setLoading(true, "Refreshing repository truth from Git…"); + try { + ui.gitRecovery = await window.forgeflow.reconcileRepository( + repository.localPath, + ); + await refreshRepositories(false); + showToast( + "Repository reconciled", + "Branch, upstream, lock and working-tree state were refreshed.", + "success", + ); + } catch (error) { + showToast("Could not reconcile repository", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "preview-workspace-sync") { + if (!repository?.localPath) return; + setLoading(true, "Fetching Gitea and building a safe synchronization plan…"); + try { + ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync( + repository.localPath, + ); + ui.modal = { type: "workspace-sync" }; + showToast( + ui.workspaceSyncPlan.needsSync + ? "Workspace sync preview ready" + : "Workspace already synchronized", + ui.workspaceSyncPlan.needsSync + ? `${ui.workspaceSyncPlan.summary.resultingTrackedChanges} tracked change(s) and ${ui.workspaceSyncPlan.summary.localFilesToStash} local file(s) reviewed.` + : `Local ${ui.workspaceSyncPlan.branch} already matches ${ui.workspaceSyncPlan.upstream}.`, + ui.workspaceSyncPlan.blockers?.length ? "error" : "success", + ); + } catch (error) { + showToast("Could not preview Gitea sync", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "confirm-workspace-sync") { + if (!repository?.localPath || !ui.workspaceSyncPlan) return; + const expectedPlanId = target.dataset.planId; + setLoading(true, "Protecting local work and synchronizing exact Gitea state…"); + try { + const result = await window.forgeflow.applyWorkspaceSync( + repository.localPath, + expectedPlanId, + ); + ui.modal = null; + ui.workspaceSyncPlan = null; + await refreshRepositories(false); + [ui.branches, ui.stashes] = await Promise.all([ + window.forgeflow.branches(repository.localPath), + window.forgeflow.stashList(repository.localPath), + ]); + const recovery = [ + result.backupBranch ? `recovery branch ${result.backupBranch}` : null, + result.stash ? `quarantine stash ${result.stash.ref}` : null, + result.review ? `Codex review manifest ${result.review.manifestPath}` : null, + ].filter(Boolean).join(" and "); + showToast( + "Workspace synchronized with Gitea", + recovery + ? `Local work is quarantined in ${recovery}. Review it before restoring anything; ignored runtime files were retained.` + : `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`, + "success", + ); + } catch (error) { + if (error.code === "WORKSPACE_SYNC_PLAN_STALE") { + try { + ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync( + repository.localPath, + ); + ui.modal = { type: "workspace-sync" }; + } catch { + ui.modal = null; + ui.workspaceSyncPlan = null; + } + } + showToast("Workspace synchronization stopped", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "repair-repository-sync") { + if (!repository?.localPath) return; + const strategy = target.dataset.strategy; + const destructive = strategy === "backup-reset"; + const message = destructive + ? "Create a safety branch from the current HEAD and reset this branch to its upstream? Uncommitted changes are never discarded." + : `Run the repository-specific ${strategy} repair now?`; + if (!confirm(message)) return; + setLoading( + true, + destructive + ? "Creating safety branch and repairing divergence…" + : "Repairing repository synchronization…", + ); + try { + const result = await window.forgeflow.repairRepositorySync( + repository.localPath, + strategy, + ); + ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( + repository.localPath, + ); + await refreshRepositories(false); + showToast( + "Repository synchronization repaired", + result.backupBranch + ? `Safety branch created: ${result.backupBranch}` + : `Completed ${strategy}.`, + "success", + ); + } catch (error) { + showToast("Synchronization repair failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "run-troubleshooter") { + setLoading( + true, + "Scanning repositories, Git operations and deployment servers…", + ); + try { + ui.troubleshooter = await window.forgeflow.troubleshooterScan(); + showToast( + "Troubleshooter completed", + ui.troubleshooter.summary.total + ? `${ui.troubleshooter.summary.total} issue(s) found; ${ui.troubleshooter.summary.repairable} repairable.` + : "No problems were detected.", + ui.troubleshooter.summary.errors ? "error" : "success", + ); + } catch (error) { + showToast("Troubleshooter failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "troubleshooter-auto-repair") { + const safeIssues = (ui.troubleshooter?.issues || []).filter( + (item) => item.repairable && item.safe, + ); + if ( + !safeIssues.length || + !confirm( + `Repair ${safeIssues.length} safe issue(s) now? ForgeFlow will not run destructive reset actions automatically.`, + ) + ) + return; + setLoading(true, "Applying safe one-click repairs…"); + try { + const results = + await window.forgeflow.troubleshooterAutoRepair(safeIssues); + ui.troubleshooter = await window.forgeflow.troubleshooterScan(); + await refreshRepositories(false); + const failed = results.filter((item) => !item.ok); + showToast( + failed.length + ? "Repairs partially completed" + : "Safe repairs completed", + `${results.length - failed.length} repaired, ${failed.length} failed.`, + failed.length ? "error" : "success", + ); + } catch (error) { + showToast("Automatic repair failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "troubleshooter-repair") { + const issue = + ui.troubleshooter?.issues?.[Number(target.dataset.issueIndex)]; + if (!issue) return; + const warning = issue.safe + ? `Repair “${issue.title}” now?` + : `“${issue.title}” requires a safety branch or another potentially destructive change. Continue?`; + if (!confirm(warning)) return; + setLoading(true, `Repairing ${issue.title}…`); + try { + const result = await window.forgeflow.troubleshooterRepair(issue); + ui.troubleshooter = await window.forgeflow.troubleshooterScan(); + await refreshRepositories(false); + showToast( + "Problem repaired", + result?.backupBranch + ? `Safety branch created: ${result.backupBranch}` + : issue.title, + "success", + ); + } catch (error) { + showToast("Repair failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "run-system-preflight") await runSystemPreflight(); + else if (action === "load-audit-log") { + try { + ui.auditEvents = await window.forgeflow.listAuditEvents(500); + render(); + } catch (error) { + showToast("Could not load audit log", error.message, "error"); + } + } else if (action === "export-audit-json" || action === "export-audit-csv") { + try { + const result = await window.forgeflow.exportAuditLog( + action.endsWith("csv") ? "csv" : "json", + ); + if (result) + showToast( + "Audit log exported", + `${result.count} records exported.`, + "success", + ); + } catch (error) { + showToast("Could not export audit log", error.message, "error"); + } + } else if (action === "save-diagnostics-preferences") { + const preferences = { + diagnosticsEnabled: + document.querySelector("#diagnostics-enabled").value === "true", + diagnosticLevel: document.querySelector("#diagnostic-level").value, + logRetentionDays: Number( + document.querySelector("#diagnostic-retention").value, + ), + maxLogFileMb: Number( + document.querySelector("#diagnostic-max-file").value, + ), + }; + setLoading(true, "Saving diagnostic policy…"); + try { + ui.boot.state = await window.forgeflow.setPreferences(preferences); + ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); + showToast( + "Diagnostic policy saved", + "New events now use the updated retention and logging level.", + "success", + ); + } catch (error) { + showToast("Could not save diagnostics", error.message, "error"); + } + setLoading(false); + } else if (action === "export-diagnostics") { + const privacyMode = + document.querySelector("#diagnostic-privacy")?.value || "standard"; + setLoading(true, "Creating redacted diagnostic bundle…"); + try { + const bundle = await window.forgeflow.exportDiagnostics(privacyMode); + if (bundle) { + ui.lastDiagnosticBundle = bundle; + ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); + showToast( + "Diagnostic bundle created", + `${bundle.size} · SHA-256 ${shortSha(bundle.sha256)}`, + "success", + ); + } + } catch (error) { + showToast("Could not export diagnostics", error.message, "error"); + } + setLoading(false); + } else if (action === "show-diagnostic-bundle") { + if (!ui.lastDiagnosticBundle?.path) return; + await window.forgeflow + .showDiagnosticBundle(ui.lastDiagnosticBundle.path) + .catch((error) => + showToast("Could not show bundle", error.message, "error"), + ); + } else if (action === "open-diagnostics-folder") + await window.forgeflow + .openDiagnosticsFolder() + .catch((error) => + showToast("Could not open diagnostic folder", error.message, "error"), + ); + else if (action === "clear-diagnostics") { + if ( + !confirm( + "Clear local ForgeFlow diagnostic logs? This does not affect repositories or configuration.", + ) + ) + return; + try { + ui.diagnosticsStatus = await window.forgeflow.clearDiagnostics(); + showToast( + "Diagnostic logs cleared", + "A new session marker was created.", + "success", + ); + render(); + } catch (error) { + showToast("Could not clear logs", error.message, "error"); + } + } else if (action === "reset-app") { + if ( + !confirm( + "Reset ForgeFlow configuration? Your Git repositories and Gitea data are not modified.", + ) + ) + return; + ui.boot.state = await window.forgeflow.reset(); + ui.repositories = []; + ui.setupStep = 0; + ui.setupValidation = null; + ui.systemPreflight = null; + ui.deploymentPreflight = null; + ui.lastDiagnosticBundle = null; + ui.setupDraft = { + baseUrl: "https://", + token: "", + user: null, + roots: [], + discovered: [], + }; + render(); + } + else return false; + return true; +} diff --git a/src/renderer/actions/setup-and-settings.js b/src/renderer/actions/setup-and-settings.js new file mode 100644 index 0000000..339c3f8 --- /dev/null +++ b/src/renderer/actions/setup-and-settings.js @@ -0,0 +1,440 @@ +async function handleSetupAndSettingsActions(event, target, action, repository) { + if (action === "setup-run-preflight") + await runSystemPreflight({ setup: true }); + else if (action === "setup-continue") { + if (ui.systemPreflight?.summary?.ready) { + ui.setupStep = 1; + render(); + } + } else if (action === "setup-validate") { + setLoading(true, "Validating Gitea connection…"); + try { + ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft); + ui.setupDraft.baseUrl = ui.setupValidation.baseUrl; + ui.setupDraft.user = ui.setupValidation.user; + ui.setupStep = 2; + } catch (error) { + showToast("Connection failed", error.message, "error"); + } + setLoading(false); + } else if (action === "setup-add-root") { + const root = await window.forgeflow.selectDirectory({ + title: "Select a development folder", + }); + if (root && !ui.setupDraft.roots.includes(root)) + ui.setupDraft.roots.push(root); + render(); + } else if (action === "setup-remove-root") { + ui.setupDraft.roots.splice(Number(target.dataset.index), 1); + render(); + } else if (action === "setup-next") { + if (ui.setupStep === 2) { + ui.setupStep = 3; + ui.setupDraft.discovered = []; + render(); + try { + ui.setupDraft.discovered = await window.forgeflow.discoverRepositories( + ui.setupDraft.roots, + ); + } catch (error) { + showToast("Discovery failed", error.message, "error"); + } + ui.setupStep = 4; + render(); + } + } else if (action === "setup-back") { + ui.setupStep = Math.max(0, ui.setupStep - 1); + render(); + } else if (action === "setup-finish") { + setLoading(true, "Saving configuration…"); + try { + const result = await window.forgeflow.completeSetup({ + baseUrl: ui.setupDraft.baseUrl, + token: ui.setupDraft.token, + user: ui.setupDraft.user, + workspaceRoots: ui.setupDraft.roots, + }); + ui.boot.state = result.state; + await refreshRepositories(false); + showToast( + "Setup complete", + result.tokenState.persistent + ? "Your token is stored securely." + : "Your token is available for this session only.", + "success", + ); + } catch (error) { + showToast("Could not complete setup", error.message, "error"); + } + setLoading(false); + } else if (action === "check-updates") { + ui.updateChecking = true; + render(); + try { + ui.updateStatus = await window.forgeflow.checkForUpdates(); + showToast( + ui.updateStatus.available ? "Update available" : "ForgeFlow is current", + ui.updateStatus.available + ? `Version ${ui.updateStatus.remoteVersion} can be downloaded.` + : `Version ${ui.updateStatus.currentVersion} is the newest release.`, + ui.updateStatus.available ? "success" : "info", + ); + } catch (error) { + showToast("Update check failed", error.message, "error"); + } + ui.updateChecking = false; + render(); + } else if (action === "save-update-settings") { + const updates = { + owner: document.querySelector("#update-owner").value.trim(), + repo: document.querySelector("#update-repo").value.trim(), + branch: document.querySelector("#update-branch").value.trim(), + autoCheck: document.querySelector("#update-auto-check").value === "true", + }; + try { + ui.boot.state = await window.forgeflow.setUpdatePreferences(updates); + ui.updateStatus = null; + showToast( + "Update settings saved", + "The next check will use this repository and branch.", + "success", + ); + } catch (error) { + showToast("Could not save update settings", error.message, "error"); + } + render(); + } else if (action === "download-update") { + setLoading(true, "Downloading and verifying the exact ForgeFlow update…"); + try { + ui.updateStatus = await window.forgeflow.downloadUpdate(); + showToast( + "Update downloaded", + `Version ${ui.updateStatus.remoteVersion} passed the integrity check.`, + "success", + ); + } catch (error) { + showToast("Update download failed", error.message, "error"); + } + setLoading(false); + } else if (action === "apply-update") { + if ( + !confirm( + `Apply ForgeFlow ${ui.updateStatus?.remoteVersion || "update"} now? ForgeFlow closes, validates the update and restarts automatically.`, + ) + ) + return; + setLoading(true, "Launching safe updater…"); + try { + await window.forgeflow.applyUpdate(); + showToast( + "Update launched", + "ForgeFlow will close and restart after validation.", + "success", + ); + } catch (error) { + showToast("Could not launch update", error.message, "error"); + setLoading(false); + } + } else if (action === "use-server-password") { + ui.modal = { + type: "server-password", + serverId: target.dataset.serverId, + retry: { type: target.dataset.retry || "scan" }, + }; + render(); + } else if (action === "confirm-server-password") { + const server = (ui.boot?.state?.servers || []).find((item) => item.id === target.dataset.serverId); + const password = document.querySelector("#quick-server-password")?.value || ""; + if (!server || !password) { + showToast("Password required", "Enter the Unraid SSH password.", "error"); + return; + } + const retry = ui.modal?.retry || { type: "scan" }; + setLoading(true, "Switching the server connection to password authentication…"); + try { + const saved = await window.forgeflow.saveServer( + { ...server, authType: "password", privateKeyPath: "" }, + password, + "", + ); + ui.boot.state = saved.state; + const tested = await window.forgeflow.testServer(server.id); + ui.boot.state = tested.state; + ui.modal = null; + showToast("Server password saved", "ForgeFlow will no longer use an SSH key for this server.", "success"); + if (retry.type === "deploy") { + const retryRepository = ui.repositories.find((item) => item.fullName === retry.repositoryFullName); + if (retryRepository) ui.selectedRepoId = retryRepository.id; + await executeDeployment(retry.profileId); + } else { + await refreshDeploymentTruth(true); + } + } catch (error) { + showToast("Server authentication failed", error.message, "error"); + } + setLoading(false); + } else if (action === "open-add-server") { + ui.modal = { + type: "server-config", + serverId: null, + authType: "password", + }; + render(); + } else if (action === "edit-server") { + const server = (ui.boot.state.servers || []).find( + (item) => item.id === target.dataset.serverId, + ); + ui.modal = { + type: "server-config", + serverId: target.dataset.serverId, + authType: server?.authType || "password", + }; + render(); + } else if (action === "select-private-key") { + const keyPath = await window.forgeflow.selectKeyFile({ + title: "Select SSH private key", + defaultPath: + document.querySelector("#server-private-key")?.value || undefined, + }); + if (keyPath) document.querySelector("#server-private-key").value = keyPath; + } else if (action === "save-server") { + const authType = document.querySelector("#server-auth-type").value; + const server = { + id: target.dataset.serverId || undefined, + name: document.querySelector("#server-name").value.trim(), + host: document.querySelector("#server-host").value.trim(), + port: Number(document.querySelector("#server-port").value), + username: document.querySelector("#server-username").value.trim(), + authType, + basePath: document.querySelector("#server-base-path").value.trim(), + scanRoots: document.querySelector("#server-scan-roots").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), + scanExcludes: document.querySelector("#server-scan-excludes").value.split(",").map((value) => value.trim()).filter(Boolean), + privateKeyPath: + document.querySelector("#server-private-key")?.value.trim() || "", + hostFingerprint: document + .querySelector("#server-fingerprint") + .value.trim(), + }; + const password = document.querySelector("#server-password")?.value || ""; + const passphrase = + document.querySelector("#server-passphrase")?.value || ""; + setLoading(true, "Saving encrypted SSH configuration…"); + try { + const result = await window.forgeflow.saveServer( + server, + password, + passphrase, + ); + ui.boot.state = result.state; + ui.modal = null; + showToast( + "Server saved", + "Run Test & trust before creating a deployment.", + "success", + ); + } catch (error) { + showToast("Could not save server", error.message, "error"); + } + setLoading(false); + } else if (action === "test-server") { + setLoading( + true, + "Checking SSH identity, Docker, Compose and optional Git capabilities…", + ); + try { + let result = await window.forgeflow.testServer(target.dataset.serverId); + if (result.needsTrust) { + const approved = confirm( + `Verify this fingerprint on the SSH server before trusting it:\n\n${result.fingerprint}\n\nServer: ${result.server.host}:${result.server.port}\n\nTrust this exact host identity and continue with authentication?`, + ); + if (!approved) { + showToast("SSH trust cancelled", "No credentials were sent and the host identity was not saved.", "info"); + setLoading(false); + return true; + } + result = await window.forgeflow.testServer(target.dataset.serverId, result.fingerprint); + } + ui.boot.state = result.state; + const capabilities = result.capabilities || {}; + const deploymentReady = + capabilities.docker && capabilities.dockerReady && capabilities.compose; + showToast( + deploymentReady ? "SSH server ready" : "SSH connected with missing tools", + `${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}.`, + deploymentReady ? "success" : "info", + ); + } catch (error) { + showToast("SSH test failed", error.message, "error"); + } + setLoading(false); + } else if (action === "delete-server") { + if ( + !confirm("Delete this server and all deployment profiles linked to it?") + ) + return; + try { + ui.boot.state = await window.forgeflow.deleteServer( + target.dataset.serverId, + ); + ui.modal = null; + await refreshRepositories(false); + showToast( + "Server deleted", + "Linked SSH deployment profiles were removed.", + "success", + ); + } catch (error) { + showToast("Could not delete server", error.message, "error"); + } + } else if (action === "add-root") { + const root = await window.forgeflow.selectDirectory({ + title: "Add development folder", + }); + if (root && !ui.boot.state.workspaceRoots.includes(root)) + ui.boot.state.workspaceRoots.push(root); + render(); + } else if (action === "remove-root") { + ui.boot.state.workspaceRoots.splice(Number(target.dataset.index), 1); + render(); + } else if (action === "save-roots") { + const roots = [...document.querySelectorAll("[data-root-index]")] + .map((input) => input.value.trim()) + .filter(Boolean); + setLoading(true, "Saving workspace folders…"); + try { + ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); + await refreshRepositories(false); + showToast( + "Folders saved", + "Repository discovery has been refreshed.", + "success", + ); + } catch (error) { + showToast("Could not save folders", error.message, "error"); + } + setLoading(false); + } else if (action === "save-gitea-settings") { + const baseUrl = document.querySelector("#settings-gitea-url").value.trim(); + const token = document.querySelector("#settings-gitea-token").value.trim(); + setLoading(true, "Validating Gitea…"); + try { + const result = await window.forgeflow.updateGitea({ baseUrl, token }); + ui.boot.state = result.state; + await refreshRepositories(false); + showToast( + "Gitea connected", + `Signed in as ${result.validation.user.login}.`, + "success", + ); + } catch (error) { + showToast("Connection failed", error.message, "error"); + } + setLoading(false); + } else if (action === "save-preferences") { + const preferences = { + autoRefresh: + document.querySelector("#pref-auto-refresh").value === "true", + repositoryPollSeconds: Number( + document.querySelector("#pref-repo-poll").value, + ), + operationPollSeconds: Number( + document.querySelector("#pref-operation-poll").value, + ), + fetchIntervalMinutes: Number( + document.querySelector("#pref-fetch-interval").value, + ), + preferredCloneProtocol: document.querySelector("#pref-clone-protocol") + .value, + }; + setLoading(true, "Saving background settings…"); + try { + ui.boot.state = await window.forgeflow.setPreferences(preferences); + await refreshRepositories(false); + showToast( + "Settings saved", + "Background awareness has been updated.", + "success", + ); + } catch (error) { + showToast("Could not save settings", error.message, "error"); + } + setLoading(false); + } else if (action === "save-desktop-preferences") { + const splitArgs = (selector) => + document + .querySelector(selector) + .value.split("|") + .map((item) => item.trim()) + .filter(Boolean); + const preferences = { + editor: { + executable: document + .querySelector("#pref-editor-executable") + .value.trim(), + args: splitArgs("#pref-editor-args"), + }, + terminal: { + executable: document + .querySelector("#pref-terminal-executable") + .value.trim(), + args: splitArgs("#pref-terminal-args"), + }, + notificationsEnabled: document.querySelector("#pref-notifications") + .checked, + trayEnabled: document.querySelector("#pref-tray").checked, + closeToTray: document.querySelector("#pref-close-tray").checked, + startAtLogin: document.querySelector("#pref-login").checked, + }; + try { + ui.boot.state = await window.forgeflow.setPreferences(preferences); + showToast( + "Desktop integration saved", + "Editor, terminal, tray and notification settings are active.", + "success", + ); + } catch (error) { + showToast("Could not save desktop integration", error.message, "error"); + } + render(); + } else if ( + action === "export-config-backup" || + action === "import-config-backup" + ) { + const passphrase = document.querySelector("#backup-passphrase").value; + if (passphrase.length < 12) { + showToast("Passphrase too short", "Use at least 12 characters.", "error"); + return; + } + setLoading( + true, + action === "export-config-backup" + ? "Encrypting configuration backup…" + : "Decrypting and validating configuration…", + ); + try { + const result = + action === "export-config-backup" + ? await window.forgeflow.exportConfigurationBackup(passphrase) + : await window.forgeflow.importConfigurationBackup(passphrase); + if (result?.state) { + ui.boot.state = result.state; + await refreshRepositories(false); + } + if (result) + showToast( + action === "export-config-backup" + ? "Encrypted backup created" + : "Configuration restored", + action === "export-config-backup" + ? result.filePath + : `Backup from ${result.exportedAt} imported; credentials were preserved only where already present.`, + "success", + ); + } catch (error) { + showToast("Configuration backup failed", error.message, "error"); + } + setLoading(false); + } + else return false; + return true; +} diff --git a/src/renderer/actions/shell.js b/src/renderer/actions/shell.js new file mode 100644 index 0000000..4dc8a12 --- /dev/null +++ b/src/renderer/actions/shell.js @@ -0,0 +1,530 @@ +async function handleShellActions(event, target, action, repository) { + if (action === "navigate") { + ui.currentView = target.dataset.view; + ui.modal = null; + render(); + if (ui.currentView === "deployments" && (ui.boot?.state?.servers || []).length && !(ui.serverDiscovery || []).length) { + setLoading(true, "Reading Docker, Compose and DockerMan inventory from Unraid…"); + await refreshDeploymentTruth(true); + setLoading(false); + } + } else if (action === "open-context-help" || action === "help-topic") { + ui.helpTopic = target.dataset.topic || "getting-started"; + ui.helpQuery = ""; + ui.currentView = "help"; + ui.modal = null; + render(); + requestAnimationFrame(() => + document.querySelector(`[data-help-topic="${ui.helpTopic}"]`)?.scrollIntoView({ block: "start", behavior: "smooth" }), + ); + } else if (action === "clear-help-search") { + ui.helpQuery = ""; + render(); + requestAnimationFrame(() => document.querySelector("#help-search")?.focus()); + } else if (action === "select-repo") selectRepository(target.dataset.id); + else if (action === "open-deployment-link") { + if (!repository) return true; + selectRepository(repository.id, false); + ui.selectedProfileId = target.dataset.profileId || selectedProfile(repository)?.id || null; + ui.repositoryTab = "deployments"; + ui.currentView = "repository"; + render(); + } else if (action === "select-deployment-profile") { + ui.selectedProfileId = target.dataset.profileId || null; + ui.repositoryTab = "deployments"; + render(); + } + else if (action === "refresh") { + await refreshRepositories(true); + await refreshActiveOperations(false); + await refreshDeploymentTruth(false); + } else if (action === "refresh-operations") { + setLoading(true, "Refreshing deployment operations and live server state…"); + await refreshActiveOperations(); + await refreshDeploymentTruth(true); + setLoading(false); + } else if (action === "toggle-theme") { + const appearance = + document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + applyTheme(appearance); + ui.boot.state = await window.forgeflow.setAppearance(appearance); + render(); + } else if (action === "open-palette") { + ui.paletteQuery = ""; + ui.modal = { type: "command-palette" }; + render(); + } else if (action === "repo-tab") { + ui.repositoryTab = target.dataset.tab; + if (ui.repositoryTab === "gittools" && !ui.branches.length) + await loadGitTools(repository); + else if (ui.repositoryTab === "validator" && !ui.gitValidation) { + setLoading(true, "Validating Git and Gitea best practices…"); + try { + ui.gitValidation = await window.forgeflow.gitValidatorScan( + repository.fullName, + ); + } catch (error) { + showToast("Git Validator failed", error.message, "error"); + } finally { + setLoading(false); + } + render(); + } else if (ui.repositoryTab === "settings") { + try { + ui.pullRequests = await window.forgeflow.pullRequests( + repository.fullName, + "open", + ); + } catch (error) { + ui.pullRequests = []; + showToast("Could not load pull requests", error.message, "error"); + } + render(); + } else render(); + } else if (action === "git-validator-scan") { + setLoading(true, "Validating Git and Gitea best practices…"); + try { + ui.gitValidation = await window.forgeflow.gitValidatorScan( + repository.fullName, + ); + showToast( + "Git validation complete", + `${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`, + ui.gitValidation.summary.errors ? "error" : "success", + ); + } catch (error) { + showToast("Git Validator failed", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "git-validator-repair") { + const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)]; + if (!check?.fixAction) return; + let preview; + try { + preview = await window.forgeflow.gitValidatorPreviewRepair(repository.fullName, check); + } catch (error) { + showToast("Preview failed", error.message, "error"); + return; + } + if (!confirm(`${check.confirmation || `Apply ${check.title}?`}\n\nReviewable change:\n${preview.diff}\n\nNothing will be committed or pushed.`)) return; + setLoading(true, `Repairing ${check.title}…`); + try { + await window.forgeflow.gitValidatorRepair(repository.fullName, check); + await refreshRepositories(false, true); + ui.gitValidation = await window.forgeflow.gitValidatorScan( + repository.fullName, + ); + showToast("Git best practice repaired", check.title, "success"); + } catch (error) { + showToast("Repair failed", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "git-validator-policy") { + setLoading(true, "Applying assurance policy…"); + try { + await window.forgeflow.gitValidatorSetPolicy(repository.fullName, { id: target.value }); + ui.gitValidation = await window.forgeflow.gitValidatorScan(repository.fullName); + showToast("Policy updated", ui.gitValidation.policy.label, "success"); + } catch (error) { + showToast("Policy update failed", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "git-validator-export") { + try { + const exported = await window.forgeflow.gitValidatorExport(repository.fullName, target.dataset.format || "markdown"); + const url = URL.createObjectURL(new Blob([exported.content], { type: exported.mimeType })); + const link = document.createElement("a"); + link.href = url; + link.download = `${repository.name || "repository"}-git-assurance.${exported.extension}`; + link.click(); + URL.revokeObjectURL(url); + showToast("Report exported", link.download, "success"); + } catch (error) { + showToast("Export failed", error.message, "error"); + } + } else if (action === "git-validator-suppress") { + const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)]; + if (!check) return; + const reason = prompt("Reason for this temporary exception (minimum 10 characters):", "Accepted temporarily while remediation is tracked."); + if (!reason) return; + const author = prompt("Exception owner:", ui.boot?.state?.gitea?.user?.login || ""); + if (!author) return; + const ticket = prompt("Ticket reference (optional):", "") || ""; + const expiresAt = new Date(Date.now() + 7 * 86_400_000).toISOString(); + try { + await window.forgeflow.gitValidatorSuppress(repository.fullName, { checkId: check.id, reason, author, ticket, expiresAt, scope: "repository", evidence: `${ui.gitValidation.commitSha || "unknown"}:${check.id}:${check.status}` }); + ui.gitValidation = await window.forgeflow.gitValidatorScan(repository.fullName); + showToast("Exception documented", `Expires ${formatDate(expiresAt)}.`, "success"); + } catch (error) { + showToast("Exception rejected", error.message, "error"); + } + } else if (action === "toggle-favorite") { + ui.boot.state = await window.forgeflow.favoriteRepository( + repository.fullName, + !repository.favorite, + ); + repository.favorite = !repository.favorite; + render(); + } else if (action === "select-file") { + if (event.target.matches("input[type=checkbox]")) return; + ui.selectedFile = target.dataset.path; + await loadDiff(repository, ui.selectedFile); + } else if (action === "toggle-all-files") { + const files = repository.localStatus?.files || []; + ui.selectedFiles = + ui.selectedFiles.size === files.length + ? new Set() + : new Set(files.map((file) => file.path)); + render(); + } else if (action === "copy-diff") { + await navigator.clipboard.writeText(ui.diff || ""); + showToast("Copied", "Diff copied to clipboard.", "success"); + } else if (action === "open-hunk-staging") { + if (ui.diffHunks?.partialSupported) { + ui.modal = { type: "hunk-staging" }; + render(); + } + } else if (action === "stage-chosen-hunks") { + const indexes = [ + ...document.querySelectorAll("[data-hunk-index]:checked"), + ].map((input) => Number(input.dataset.hunkIndex)); + if (!indexes.length) return; + const result = await runOperation( + "Staging selected hunks…", + () => + window.forgeflow.stageHunks( + repository.localPath, + ui.selectedFile, + indexes, + ), + "Selected hunks staged.", + ); + if (result) { + ui.modal = null; + await loadDiff(selectedRepository(), ui.selectedFile); + } + } else if (action === "open-file-editor") { + await window.forgeflow + .openEditor(repository.localPath, ui.selectedFile) + .catch((error) => + showToast("Could not open editor", error.message, "error"), + ); + } else if (action === "open-editor") { + await window.forgeflow + .openEditor(repository.localPath) + .catch((error) => + showToast("Could not open editor", error.message, "error"), + ); + } else if (action === "open-terminal") { + await window.forgeflow + .openTerminal(repository.localPath) + .catch((error) => + showToast("Could not open terminal", error.message, "error"), + ); + } else if (action === "load-conflicts") { + ui.conflictState = await window.forgeflow.conflictState( + repository.localPath, + ); + ui.modal = { type: "conflict-guide" }; + render(); + } else if (action === "resolve-conflict") { + if ( + !ui.selectedFile || + !confirm(`Apply “${target.dataset.resolution}” to ${ui.selectedFile}?`) + ) + return; + ui.conflictState = await window.forgeflow.resolveConflict( + repository.localPath, + ui.selectedFile, + target.dataset.resolution, + ); + await refreshRepositories(false); + ui.modal = { type: "conflict-guide" }; + render(); + } else if (action === "open-conflict-file") { + await window.forgeflow.openEditor( + repository.localPath, + target.dataset.path, + ); + } else if (action === "continue-git-operation") { + ui.conflictState = await window.forgeflow.continueGitOperation( + repository.localPath, + ); + ui.modal = null; + await refreshRepositories(false); + showToast( + "Git operation continued", + "The repository operation completed.", + "success", + ); + } else if (action === "abort-git-operation") { + if ( + !confirm( + "Abort the active Git operation? Conflict-resolution work may be discarded.", + ) + ) + return; + await window.forgeflow.abortGitOperation(repository.localPath); + ui.modal = null; + await refreshRepositories(false); + } else if (action === "check-branch-protection") { + ui.branchProtection = await window.forgeflow.branchProtection( + repository.fullName, + repository.localStatus.branch.head, + ); + showToast( + ui.branchProtection.protected + ? "Protected branch" + : "Branch is not protected", + ui.branchProtection.protected + ? `${ui.branchProtection.requiredApprovals} approval(s) required.` + : "Direct pushes are permitted by the reported branch rule.", + ui.branchProtection.protected ? "info" : "success", + ); + render(); + } else if (action === "open-pull-request") { + const subject = + ui.history[0]?.subject || repository.localStatus.branch.head; + ui.modal = { + type: "pull-request", + title: subject, + body: `## Summary\n\nChanges from ${repository.localStatus.branch.head}.`, + }; + render(); + } else if (action === "load-pull-requests") { + try { + ui.pullRequests = await window.forgeflow.pullRequests( + repository.fullName, + "open", + ); + render(); + } catch (error) { + showToast("Could not load pull requests", error.message, "error"); + } + } else if (action === "open-pull-request-url") { + if (target.dataset.url) + await window.forgeflow.openExternal(target.dataset.url); + } else if (action === "create-pull-request") { + setLoading(true, "Creating pull request…"); + try { + const pull = await window.forgeflow.createPullRequest( + repository.fullName, + document.querySelector("#pr-title").value, + document.querySelector("#pr-body").value, + document.querySelector("#pr-base").value, + ); + ui.modal = null; + ui.pullRequests = await window.forgeflow + .pullRequests(repository.fullName, "open") + .catch(() => ui.pullRequests); + showToast("Pull request created", `#${pull.number}`, "success"); + if (pull.html_url) await window.forgeflow.openExternal(pull.html_url); + } catch (error) { + showToast("Could not create pull request", error.message, "error"); + } + setLoading(false); + } else if (action === "copy-logs") { + const text = (ui.activeDeployment?.logs || []).join("\n"); + await navigator.clipboard.writeText(text); + showToast( + "Copied", + "Safe operation output copied. Open Gitea for raw runner logs.", + "success", + ); + } else if (action === "stage-selected") { + if (!repository?.localPath || !ui.selectedFiles.size) return; + await runOperation( + "Staging selected files…", + () => + window.forgeflow.stageFiles(repository.localPath, [ + ...ui.selectedFiles, + ]), + "Files staged.", + ); + } else if (action === "unstage-selected") { + if (!repository?.localPath || !ui.selectedFiles.size) return; + await runOperation( + "Unstaging selected files…", + () => + window.forgeflow.unstageFiles(repository.localPath, [ + ...ui.selectedFiles, + ]), + "Files unstaged.", + ); + } else if (action === "commit-push" || action === "commit-only") { + if ( + !repository?.localPath || + !ui.commitMessage.trim() || + (!ui.selectedFiles.size && !repository.localStatus?.counts?.staged) + ) + return; + const selected = [...ui.selectedFiles]; + const result = await runOperation( + action === "commit-push" + ? "Committing and pushing…" + : "Creating local commit…", + () => + action === "commit-push" + ? selected.length + ? window.forgeflow.commitAndPush( + repository.localPath, + ui.commitMessage, + selected, + ) + : window.forgeflow.commitStagedAndPush( + repository.localPath, + ui.commitMessage, + ) + : selected.length + ? window.forgeflow.commit( + repository.localPath, + ui.commitMessage, + selected, + ) + : window.forgeflow.commitStaged( + repository.localPath, + ui.commitMessage, + ), + action === "commit-push" + ? "Changes committed and pushed to Gitea." + : "Local commit created.", + ); + if (result) { + ui.commitMessage = ""; + ui.selectedFiles.clear(); + ui.selectedFile = null; + ui.diff = ""; + } + } else if (action === "push") + await runOperation( + "Pushing local commits…", + () => window.forgeflow.push(repository.localPath), + "Push completed.", + ); + else if (action === "fetch") + await runOperation( + "Fetching from Gitea…", + () => window.forgeflow.fetch(repository.localPath), + "Remote state refreshed.", + ); + else if (action === "pull") + await runOperation( + "Synchronizing from Gitea…", + () => window.forgeflow.pull(repository.localPath), + "Local branch fast-forwarded.", + ); + else if (action === "load-history") { + setLoading(true, "Loading commit history…"); + try { + ui.history = await window.forgeflow.history(repository.localPath, 50); + } catch (error) { + showToast("History unavailable", error.message, "error"); + } + setLoading(false); + } else if (action === "load-git-tools") await loadGitTools(repository); + else if (action === "create-branch") { + const branch = document.querySelector("#new-branch-name")?.value.trim(); + if (branch) + await runOperation( + `Creating ${branch}…`, + () => window.forgeflow.createBranch(repository.localPath, branch), + `Switched to ${branch}.`, + ); + await loadGitTools(selectedRepository()); + } else if (action === "checkout-branch") { + await runOperation( + `Switching to ${target.dataset.branch}…`, + () => + window.forgeflow.checkoutBranch( + repository.localPath, + target.dataset.branch, + ), + `Switched to ${target.dataset.branch}.`, + ); + await loadGitTools(selectedRepository()); + } else if (action === "stash-changes") { + const result = await runOperation( + "Stashing local changes…", + () => + window.forgeflow.stash( + repository.localPath, + `ForgeFlow ${new Date().toLocaleString()}`, + ), + "Local changes stashed.", + ); + if (result) ui.stashes = result.stashes; + } else if (action === "pop-stash") { + const result = await runOperation( + `Applying ${target.dataset.stashRef}…`, + () => + window.forgeflow.popStash( + repository.localPath, + target.dataset.stashRef, + ), + "Stash applied.", + ); + if (result) ui.stashes = result.stashes; + } else if (action === "open-path") + await window.forgeflow + .openPath(repository.localPath) + .catch((error) => + showToast("Could not open folder", error.message, "error"), + ); + else if (action === "open-gitea") + await window.forgeflow + .openExternal(repository.htmlUrl) + .catch((error) => + showToast("Could not open Gitea", error.message, "error"), + ); + else if (action === "link-repo") { + const localPath = await window.forgeflow.selectDirectory({ + title: `Link local folder for ${repository.name}`, + }); + if (localPath) { + ui.repositories = + (await runOperation( + "Linking local repository…", + () => window.forgeflow.linkRepository(repository.fullName, localPath), + "Local folder linked.", + { refresh: false }, + )) || ui.repositories; + selectRepository(repository.id); + } + } else if (action === "unlink-repo") { + ui.repositories = + (await runOperation( + "Removing local link…", + () => window.forgeflow.unlinkRepository(repository.fullName), + "Repository link removed.", + { refresh: false }, + )) || ui.repositories; + selectRepository(repository.id); + } else if (action === "clone-repo" || action === "clone-repo-custom") { + const mode = action === "clone-repo-custom" ? "custom" : "default"; + const clone = await runOperation( + mode === "custom" + ? "Choosing location and cloning repository…" + : `Cloning ${repository.name} into the default project root…`, + () => window.forgeflow.cloneRepository(repository.fullName, mode), + null, + { refresh: false }, + ); + if (clone?.cancelled) return; + if (clone?.target) { + if (clone.state) ui.boot.state = clone.state; + ui.repositories = + clone.repositories || (await window.forgeflow.refreshRepositories()); + selectRepository(repository.id); + showToast( + clone.reused ? "Existing repository linked" : "Repository cloned", + clone.target, + "success", + ); + } + } + else return false; + return true; +} diff --git a/src/renderer/app.js b/src/renderer/app.js new file mode 100644 index 0000000..cfaf45b --- /dev/null +++ b/src/renderer/app.js @@ -0,0 +1,737 @@ +const app = document.querySelector("#app"); +const toastRoot = document.querySelector("#toast-root"); + +const icons = { + overview: + '', + repository: + '', + deploy: '', + settings: + '', + search: '', + refresh: '', + folder: '', + git: '', + branch: + '', + file: '', + check: '', + warning: '', + error: '', + arrowRight: '', + arrowUp: '', + arrowDown: '', + external: + '', + play: '', + terminal: '', + clock: '', + sun: '', + moon: '', + plus: '', + trash: '', + close: '', + copy: '', + chevron: '', + link: '', + cloud: + '', + pulse: '', + history: + '', + more: '', + star: '', + archive: '', + shield: + '', + rocket: + '', + layers: + '', + undo: '', + menu: '', + download: '', + server: + '', + key: '', + update: + '', + wrench: + '', + help: + '', +}; + +function icon(name, className = "") { + return ``; +} +function escapeHtml(value) { + return String(value ?? "").replace( + /[&<>'"]/g, + (character) => + ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[ + character + ], + ); +} +function attr(value) { + return escapeHtml(value).replace(/`/g, "`"); +} +function formatDate(value) { + if (!value) return "Unknown"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + const diff = Date.now() - date.getTime(); + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.max(1, Math.floor(diff / 60_000))}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`; + return date.toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: + date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined, + }); +} +function truncate(value, length = 76) { + const text = String(value || ""); + return text.length > length ? `${text.slice(0, length - 1)}…` : text; +} +function shortSha(value) { + return String(value || "").slice(0, 7) || "—"; +} +function defaultWorkspaceRoot() { + return ui.boot?.state?.workspaceRoots?.[0] || null; +} +function safeCloneFolderName(repository) { + return ( + String(repository?.name || "repository") + .replace(/\.git$/i, "") + .replace(/[^a-zA-Z0-9._-]/g, "-") || "repository" + ); +} +function displayCloneTarget(repository) { + const root = defaultWorkspaceRoot(); + if (!root) return null; + const separator = ui.boot?.platform === "win32" ? "\\" : "/"; + return `${String(root).replace(/[\\/]+$/, "")}${separator}${safeCloneFolderName(repository)}`; +} +function clonePrimaryLabel(repository) { + return defaultWorkspaceRoot() + ? `Clone to ${safeCloneFolderName(repository)}` + : "Choose project root & clone"; +} +function isTerminalOperation(status) { + return ["success", "failed", "cancelled", "rolled-back"].includes(status); +} +function toneForStatus(status) { + if (["success", "healthy", "complete", "rolled-back"].includes(status)) + return "success"; + if (["failed", "failure", "unhealthy", "danger"].includes(status)) + return "danger"; + if (["queued", "running", "requested", "warning", "active"].includes(status)) + return "warning"; + return ""; +} + +const ui = { + boot: null, + repositories: [], + currentView: "overview", + selectedRepoId: null, + repositoryTab: "changes", + selectedFile: null, + selectedFiles: new Set(), + selectedProfileId: null, + diff: "", + history: [], + branches: [], + stashes: [], + search: "", + repoSearch: "", + commitMessage: "", + loading: false, + loadingMessage: "", + modal: null, + setupStep: 0, + systemPreflight: null, + deploymentPreflight: null, + serverGitVerifications: {}, + deployKeyLifecycle: null, + inventoryReviewPlan: null, + diagnosticsStatus: null, + troubleshooter: null, + deploymentDiscovery: null, + serverDiscovery: [], + lastDiagnosticBundle: null, + setupDraft: { + baseUrl: "https://", + token: "", + user: null, + roots: [], + discovered: [], + }, + setupValidation: null, + activeDeployment: null, + operationPollTimer: null, + inputRenderTimer: null, + isMock: false, + refreshError: null, + refreshWarning: null, + autoRefreshPending: false, + repositoryRefreshPromise: null, + repositoryRefreshRequest: null, + deploymentTruthPromise: null, + deploymentTruthRequest: null, + paletteQuery: "", + helpQuery: "", + helpTopic: "getting-started", + updateStatus: null, + updateChecking: false, + servers: [], + serverInspection: null, + gitRecovery: null, + workspaceSyncPlan: null, + gitValidation: null, + diffHunks: null, + conflictState: null, + branchProtection: null, + pullRequests: [], + auditEvents: [], +}; + +function scheduleInputRender(delay = 120) { + if (ui.inputRenderTimer) clearTimeout(ui.inputRenderTimer); + ui.inputRenderTimer = setTimeout(() => { + ui.inputRenderTimer = null; + render(); + }, delay); +} + +function selectedRepository() { + return ( + ui.repositories.find( + (repository) => String(repository.id) === String(ui.selectedRepoId), + ) || null + ); +} +function selectedProfile(repository = selectedRepository()) { + if (!repository?.deploymentProfiles?.length) return null; + return ( + repository.deploymentProfiles.find( + (profile) => profile.id === ui.selectedProfileId, + ) || + repository.deploymentProfiles.find( + (profile) => profile.branch === repository.localStatus?.branch.head, + ) || + repository.deploymentProfiles[0] + ); +} + +function canDirectPushDeploy(repository, profile = selectedProfile(repository)) { + const status = repository?.localStatus; + const mode = deploymentMode(profile); + return Boolean( + repository?.localPath + && status?.head + && !status?.counts?.changed + && !status?.counts?.conflicts + && profile + && mode === "push-bundle" + && profile.branch === status.branch?.head, + ); +} + +function deploymentMode(profile) { + if (profile?.provider !== "ssh-unraid") return "gitea-actions"; + return ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; +} + +function deploymentTargetSha(repository, profile = selectedProfile(repository)) { + return deploymentMode(profile) === "server-git" + ? profile?.state?.giteaSha || null + : repository?.localStatus?.head || null; +} + +function canServerGitDeploy(repository, profile = selectedProfile(repository)) { + const target = deploymentTargetSha(repository, profile); + return Boolean( + profile + && deploymentMode(profile) === "server-git" + && target + && profile.branch + && !(profile.state?.liveSha === target && profile.state?.healthy !== false), + ); +} + +function canDeploy(repository, profile = selectedProfile(repository)) { + const mode = deploymentMode(profile); + if (mode === "server-git") return canServerGitDeploy(repository, profile); + if (mode === "push-bundle") return canDirectPushDeploy(repository, profile); + return profile?.provider === "gitea-actions" && repository?.readyToDeploy; +} +function operations() { + return ui.boot?.state?.operations || []; +} +function repositoryOperations(repository) { + return operations().filter( + (operation) => operation.repository === repository?.fullName, + ); +} + +function applyTheme(appearance) { + const resolved = + appearance === "system" + ? matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark" + : appearance; + document.documentElement.dataset.theme = resolved || "dark"; +} + +function showToast(title, message, type = "info") { + const toast = document.createElement("div"); + toast.className = `toast ${type}`; + toast.innerHTML = `${icon(type === "error" ? "error" : type === "success" ? "check" : "warning")}
${escapeHtml(title)}${escapeHtml(message)}
`; + toastRoot.append(toast); + setTimeout(() => toast.remove(), 5600); +} + +function isSshCredentialError(error) { + const code = String(error?.code || ""); + const message = String(error?.message || ""); + return ["SSH_PRIVATE_KEY_READ_FAILED", "SSH_PRIVATE_KEY_NOT_FOUND", "SSH_CONNECTION_FAILED"].includes(code) + || /private key|publickey|authentication methods failed|permission denied|authentication failed/i.test(message); +} + +function setLoading(loading, message = "") { + ui.loading = loading; + ui.loadingMessage = message; + render(); +} +function updateOperationInState(operation) { + if (!operation || !ui.boot) return; + const list = operations(); + ui.boot.state.operations = [ + operation, + ...list.filter((item) => item.id !== operation.id), + ].slice(0, 250); + if (ui.activeDeployment?.id === operation.id) ui.activeDeployment = operation; +} + +function stopOperationPolling() { + if (ui.operationPollTimer) clearTimeout(ui.operationPollTimer); + ui.operationPollTimer = null; +} + +function startOperationPolling() { + stopOperationPolling(); + const operationId = ui.activeDeployment?.id; + if (!operationId || isTerminalOperation(ui.activeDeployment.status)) return; + const seconds = Math.max( + 2, + Number(ui.boot?.state?.preferences?.operationPollSeconds) || 3, + ); + ui.operationPollTimer = setTimeout(async () => { + try { + const operation = await window.forgeflow.refreshOperations(operationId); + if (operation) updateOperationInState(operation); + render(); + if (operation && !isTerminalOperation(operation.status)) + startOperationPolling(); + else stopOperationPolling(); + } catch (error) { + showToast("Deployment status refresh failed", error.message, "error"); + stopOperationPolling(); + } + }, seconds * 1000); +} + +async function bootstrap() { + try { + ui.boot = await window.forgeflow.bootstrap(); + ui.isMock = String(ui.boot.appVersion).includes("demo"); + ui.diagnosticsStatus = ui.boot.diagnostics || null; + applyTheme(ui.boot.state.appearance); + ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])]; + if (ui.boot.state.setupComplete) { + await refreshRepositories(false); + const reconciled = await refreshActiveOperations(false); + if ( + (Array.isArray(reconciled) ? reconciled : []).some((operation) => + isTerminalOperation(operation.status), + ) + ) + await refreshRepositories(false); + } + window.forgeflow.onRepositoriesChanged?.(() => scheduleAutoRefresh()); + window.forgeflow.onOperationsChanged?.((payload) => { + const changed = payload?.operations || []; + for (const operation of changed) updateOperationInState(operation); + if (changed.some((operation) => isTerminalOperation(operation.status))) + scheduleAutoRefresh(250); + render(); + }); + window.forgeflow.onUpdatesChanged?.((payload) => { + ui.updateStatus = payload; + render(); + if (payload?.available) + showToast( + "ForgeFlow update available", + `Version ${payload.remoteVersion} is ready to download.`, + "success", + ); + }); + render(); + const updateResult = ui.boot.updateResult; + if (updateResult?.state === "success") { + const restartNote = updateResult.restartLaunched + ? "" + : " Automatic restart was unavailable, but the update itself succeeded."; + showToast( + "ForgeFlow updated successfully", + `Version ${updateResult.installedVersion || updateResult.expectedVersion || ui.boot.appVersion} is installed.${restartNote}`, + "success", + ); + } else if (updateResult?.state === "rolled-back") { + showToast( + "ForgeFlow update rolled back", + updateResult.message || + "The update failed and the previous version was restored.", + "error", + ); + } else if (updateResult?.state === "failed") { + showToast( + "ForgeFlow update failed", + updateResult.message || "See the update log for technical details.", + "error", + ); + } + setTimeout(() => { + void refreshDeploymentTruth(false); + }, 500); + } catch (error) { + app.innerHTML = `
${icon("error")}ForgeFlow could not start${escapeHtml(error.message)}
`; + } +} + +function scheduleAutoRefresh(delay = 450) { + if ( + ui.loading || + ui.autoRefreshPending || + !ui.boot?.state?.preferences?.autoRefresh + ) + return; + ui.autoRefreshPending = true; + setTimeout(async () => { + ui.autoRefreshPending = false; + await refreshRepositories(false, true); + }, delay); +} + +async function refreshRepositories(withLoader = true, silent = false) { + ui.repositoryRefreshRequest = { + withLoader: ui.repositoryRefreshRequest?.withLoader === true || withLoader, + silent: ui.repositoryRefreshRequest ? ui.repositoryRefreshRequest.silent && silent : silent, + }; + if (ui.repositoryRefreshPromise) return ui.repositoryRefreshPromise; + ui.repositoryRefreshPromise = (async () => { + let result; + while (ui.repositoryRefreshRequest) { + const request = ui.repositoryRefreshRequest; + ui.repositoryRefreshRequest = null; + result = await performRepositoryRefresh(request.withLoader, request.silent); + } + return result; + })(); + try { + return await ui.repositoryRefreshPromise; + } finally { + ui.repositoryRefreshPromise = null; + } +} + +async function performRepositoryRefresh(withLoader = true, silent = false) { + if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…"); + try { + const selectedId = ui.selectedRepoId; + ui.repositories = await window.forgeflow.refreshRepositories({ force: withLoader }); + ui.refreshError = null; + const staleRepository = ui.repositories.find( + (repository) => repository.remoteStale, + ); + ui.refreshWarning = staleRepository + ? `Gitea could not be reached. Showing repository data last refreshed ${formatDate(staleRepository.remoteLastRefreshedAt)} while local and server state continue to refresh.` + : null; + if (selectedId && !selectedRepository()) ui.selectedRepoId = null; + const repository = selectedRepository(); + if ( + repository && + !repository.deploymentProfiles.some( + (profile) => profile.id === ui.selectedProfileId, + ) + ) + ui.selectedProfileId = selectedProfile(repository)?.id || null; + if (repository) { + const availablePaths = new Set( + (repository.localStatus?.files || []).map((file) => file.path), + ); + ui.selectedFiles = new Set( + [...ui.selectedFiles].filter((filePath) => + availablePaths.has(filePath), + ), + ); + if (ui.selectedFile && !availablePaths.has(ui.selectedFile)) { + ui.selectedFile = repository.localStatus?.files?.[0]?.path || null; + ui.diff = ""; + } + } + if ( + !ui.selectedRepoId && + ui.currentView === "repository" && + ui.repositories.length + ) + selectRepository(ui.repositories[0].id, false); + } catch (error) { + ui.refreshError = error.message; + ui.refreshWarning = null; + if (!silent) showToast("Refresh failed", error.message, "error"); + } finally { + if (withLoader) setLoading(false); + else render(); + } +} + +async function refreshActiveOperations(showErrors = true) { + try { + const updated = await window.forgeflow.refreshOperations(); + for (const operation of Array.isArray(updated) ? updated : []) + updateOperationInState(operation); + return updated; + } catch (error) { + if (showErrors) + showToast("Deployment status unavailable", error.message, "error"); + return []; + } +} + +async function refreshDeploymentTruth(showErrors = false) { + ui.deploymentTruthRequest = { showErrors: ui.deploymentTruthRequest?.showErrors === true || showErrors }; + if (ui.deploymentTruthPromise) return ui.deploymentTruthPromise; + ui.deploymentTruthPromise = (async () => { + let result; + while (ui.deploymentTruthRequest) { + const request = ui.deploymentTruthRequest; + ui.deploymentTruthRequest = null; + result = await performDeploymentTruthRefresh(request.showErrors); + } + return result; + })(); + try { + return await ui.deploymentTruthPromise; + } finally { + ui.deploymentTruthPromise = null; + } +} + +async function performDeploymentTruthRefresh(showErrors = false) { + let discovery = []; + try { + discovery = (await window.forgeflow.discoverServerDeployments?.()) || []; + ui.serverDiscovery = discovery; + const adopted = discovery.reduce( + (total, server) => total + Number(server.adopted || 0), + 0, + ); + if (adopted > 0) { + await refreshRepositories(false, true); + showToast( + "Server workloads discovered", + `${adopted} workload${adopted === 1 ? " was" : "s were"} linked automatically from exact repository provenance.`, + "success", + ); + } + } catch (error) { + if (showErrors) + showToast("Server discovery unavailable", error.message, "error"); + } + const targets = ui.repositories.flatMap((repository) => + (repository.deploymentProfiles || []).map((profile) => ({ + repository, + profile, + })), + ); + if (!targets.length) return { checked: 0, failed: 0, discovery }; + + const inventoryRefreshedProfiles = new Set(discovery.flatMap((server) => server.refreshedProfileIds || [])); + const pendingTargets = targets.filter(({ profile }) => !inventoryRefreshedProfiles.has(profile.id)); + + const failures = []; + const queue = [...pendingTargets]; + const workers = Array.from( + { length: Math.min(3, queue.length) }, + async () => { + while (queue.length) { + const target = queue.shift(); + try { + target.profile.state = await window.forgeflow.refreshProfileState( + target.repository.fullName, + target.profile.id, + ); + } catch (error) { + failures.push({ + repository: target.repository.fullName, + profile: target.profile.name, + message: error.message, + }); + } + } + }, + ); + await Promise.all(workers); + await refreshRepositories(false); + if (showErrors && failures.length) { + showToast( + "Some environments could not be checked", + `${failures.length} profile${failures.length === 1 ? "" : "s"} could not be refreshed. Open Deployments for details.`, + "error", + ); + } + return { checked: targets.length, reusedInventory: targets.length - pendingTargets.length, failed: failures.length, discovery }; +} + +function selectRepository(id, shouldRender = true) { + ui.selectedRepoId = id; + ui.currentView = "repository"; + ui.repositoryTab = "changes"; + ui.commitMessage = ""; + ui.history = []; + ui.branches = []; + ui.stashes = []; + ui.gitRecovery = null; + ui.workspaceSyncPlan = null; + ui.gitValidation = null; + ui.branchProtection = null; + const repository = selectedRepository(); + ui.selectedProfileId = selectedProfile(repository)?.id || null; + const files = repository?.localStatus?.files || []; + ui.selectedFiles = new Set(files.map((file) => file.path)); + ui.selectedFile = files[0]?.path || null; + ui.diff = ""; + if (ui.selectedFile && repository?.localPath) + loadDiff(repository, ui.selectedFile); + if (repository?.owner?.login && repository?.localStatus?.branch?.head) { + window.forgeflow + .branchProtection(repository.fullName, repository.localStatus.branch.head) + .then((protection) => { + if (String(ui.selectedRepoId) === String(id)) { + ui.branchProtection = protection; + render(); + } + }) + .catch(() => {}); + } + if (shouldRender) render(); +} + +async function loadDiff(repository, filePath) { + ui.diff = "Loading diff…"; + render(); + try { + const file = repository.localStatus?.files.find( + (item) => item.path === filePath, + ); + ui.diff = await window.forgeflow.repositoryDiff( + repository.localPath, + filePath, + Boolean(file?.staged && !file?.unstaged), + ); + ui.diffHunks = file?.unstaged + ? await window.forgeflow + .repositoryDiffHunks(repository.localPath, filePath) + .catch(() => null) + : null; + } catch (error) { + ui.diff = `Unable to load diff: ${error.message}`; + } + render(); +} + +function repositoryAction(repository) { + if (!repository.localPath) + return { + kind: "link", + title: "Connect this repository", + detail: "Link an existing local folder or clone it from Gitea.", + }; + const status = repository.localStatus; + if (!status) + return { + kind: "error", + title: "Local repository unavailable", + detail: repository.attentionReason || "The linked folder could not be read.", + }; + if (status.counts.conflicts) + return { + kind: "conflict", + title: "Resolve merge conflicts", + detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? "" : "s"} block deployment.`, + }; + if (status.counts.changed) + return { + kind: "commit", + title: "Commit local changes", + detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? "" : "s"} detected.`, + }; + if (!repository.deploymentProfiles?.length) + return { + kind: "configure", + title: "Configure deployment", + detail: "Connect an Unraid server or a Gitea Actions workflow before deploying.", + }; + const profile = selectedProfile(repository); + if (profile?.branch !== status.branch.head) + return { + kind: "branch-profile", + title: "No deployment for this branch", + detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.`, + }; + if (canDirectPushDeploy(repository, profile)) + return { + kind: "deploy", + title: "Ready for direct redeploy", + detail: `ForgeFlow will copy committed HEAD ${status.shortHead} directly to ${profile.environment} over the configured desktop → Unraid connection.`, + }; + if (status.branch.behind && status.branch.ahead) + return { + kind: "diverged", + title: "Branches have diverged", + detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind.`, + }; + if (status.branch.behind) + return { + kind: "pull", + title: "Synchronize from Gitea", + detail: `Local ${status.branch.head} is ${status.branch.behind} commit${status.branch.behind === 1 ? "" : "s"} behind.`, + }; + if (status.branch.ahead) + return { + kind: "push", + title: "Push local commits", + detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? "" : "s"} ready to push.`, + }; + if (repository.readyToDeploy) + return { + kind: "deploy", + title: "Ready for deployment", + detail: `Commit ${status.shortHead} can be released to ${profile.environment}.`, + }; + return { + kind: "clean", + title: "Repository synchronized", + detail: "No local or remote action is required.", + }; +} diff --git a/src/renderer/assets/itworx-mark.png b/src/renderer/assets/itworx-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..3fbfa6c2cc877968fb1be9bce25ac5bad5afa7dc GIT binary patch literal 85704 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

$0XVR1b`zc4;`06!qnRQzaK928e|9wstBR5 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ğMQkL(F3h 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 literal 0 HcmV?d00001 diff --git a/src/renderer/assets/itworx-wordmark.png b/src/renderer/assets/itworx-wordmark.png new file mode 100644 index 0000000000000000000000000000000000000000..fba2e3c78f251302cda7dd39577cb7f0625e0c1e GIT binary patch literal 82476 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 literal 0 HcmV?d00001 diff --git a/src/renderer/dialogs.js b/src/renderer/dialogs.js new file mode 100644 index 0000000..4cab39c --- /dev/null +++ b/src/renderer/dialogs.js @@ -0,0 +1,434 @@ +// These three sections used to be pushed into the DOM after render() had already +// written the shell. Keeping them in the markup makes the rendered output the +// single source of truth, so an unchanged render can be skipped safely. +function renderDeploymentPolicyFields(policy) { + const windows = (policy.maintenanceWindows || []) + .map((window) => `${window.days.join(",")}:${window.start}-${window.end}`) + .join(" | "); + return `

Day 0 is Sunday. Separate windows with |.
`; +} + +function renderReleaseNoteFields(profile) { + return `
`; +} + +function renderWorkloadClassificationFields(workload) { + const type = workload?.classification?.type || "ambiguous"; + const recommended = type === "duplicate" ? "select-authoritative" : type === "stale-link" ? "archive-link" : type === "historical-compose" ? "mark-historical" : type === "orphan-container" ? "monitor-only" : "manual-link"; + const actions = [["manual-link", "Confirm selected repository match"], ["select-authoritative", "Select as authoritative instance"], ["mark-historical", "Mark historical definition"], ["archive-link", "Archive stale link"], ["monitor-only", "Keep for monitoring only"], ["manual-exclude", "Exclude this workload"], ["ignore", "Ignore with reason"]]; + const options = actions.map(([value, label]) => ``).join(""); + return `

Classify without touching containers

${icon("info")}
${escapeHtml(type)}

${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}

The decision is tied to current evidence and becomes stale automatically when server truth changes.

`; +} + +function renderModal() { + if (!ui.modal) return ""; + const repository = + selectedRepository() || + ui.repositories.find( + (repo) => repo.fullName === ui.modal.repositoryFullName, + ); + if (ui.modal.type === "server-password") { + const server = (ui.boot?.state?.servers || []).find((item) => item.id === ui.modal.serverId); + if (!server) return ``; + const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan"; + return ``; + } + if (ui.modal.type === "server-reconciliation-plan") { + const plan = ui.modal.result?.plan || {}; + const summary = plan.summary || {}; + const rows = [ + ...(plan.additions || []).map((item) => ({ tone: "success", title: `Link ${item.repositoryFullName}`, detail: `${item.evidence} · ${item.impact}` })), + ...(plan.updates || []).map((item) => ({ tone: "", title: `Refresh ${item.repositoryFullName}`, detail: item.impact })), + ...(plan.stale || []).map((item) => ({ tone: "warning", title: `Review stale link ${item.repositoryFullName}`, detail: `${item.reason} · no automatic removal` })), + ...(plan.conflicts || []).map((item) => ({ tone: "danger", title: `Manual review: ${item.displayName}`, detail: `${item.status} · ${(item.candidates || []).map((candidate) => candidate.repositoryFullName).join(", ") || "no unique repository"}` })), + ]; + return ``; + } + if (ui.modal.type === "workspace-sync") { + const plan = ui.workspaceSyncPlan; + if (!plan) return ""; + const summary = plan.summary || {}; + const blocked = Boolean(plan.blockers?.length); + const changeRows = (plan.changes || []).map((change) => `
${escapeHtml(change.path)}${change.originalPath ? `${escapeHtml(change.originalPath)} → ` : ""}${escapeHtml(change.status)}
${escapeHtml(change.code)}
`).join(""); + const recoveryRows = [ + plan.recovery?.safetyBranch ? "Local commits → recovery branch" : "No local commits require a recovery branch", + plan.recovery?.stash ? "Modified and untracked files → named Git stash" : "No working-tree files require a stash", + plan.recovery?.untrackedCleanup ? "Untracked files are removed after they are stashed" : "No untracked cleanup required", + "Ignored runtime files remain in place", + ]; + return ``; + } + if (ui.modal.type === "workload-link") { + const serverResult = (ui.serverDiscovery || []).find( + (item) => item.serverId === ui.modal.serverId, + ); + const workload = serverResult?.workloads?.find( + (item) => item.workloadId === ui.modal.workloadId, + ); + if (!workload) { + return ``; + } + const availableRepositories = ui.repositories.filter((item) => item.fullName); + const suggestedRepository = + ui.modal.repositoryFullName || + workload.candidates?.[0]?.repositoryFullName || + selectedRepository()?.fullName || + availableRepositories[0]?.fullName || + ""; + const selectedLinkRepository = availableRepositories.find( + (item) => item.fullName === suggestedRepository, + ); + const remoteFolder = + ui.modal.remoteFolder || + workload.remoteFolderCandidate || + safeCloneFolderName(selectedLinkRepository); + const candidateSummary = workload.candidates?.length + ? workload.candidates + .slice(0, 4) + .map( + (candidate) => + `
${escapeHtml(candidate.repositoryFullName)}${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}
`, + ) + .join("") + : '
Repository candidatesNo confident match; choose manually.
'; + const containerNames = (workload.containers || []) + .map((container) => container.name) + .filter(Boolean) + .join(", "); + return ``; + } + if (ui.modal.type === "deployment-config") { + const storedProfile = + repository?.deploymentProfiles?.find( + (profile) => profile.id === ui.modal.profileId, + ) || {}; + const discovery = + ui.deploymentDiscovery?.repository === repository?.fullName + ? ui.deploymentDiscovery + : null; + const existing = { ...storedProfile, ...(discovery?.profile || {}) }; + if (discovery?.provenance) existing.provenance = discovery.provenance; + const servers = ui.boot.state.servers || []; + const provider = + ui.modal.provider || + existing.provider || + (servers.length ? "ssh-unraid" : "gitea-actions"); + const ssh = provider === "ssh-unraid"; + const remoteFolder = + existing.remoteFolder || safeCloneFolderName(repository); + return ``; + } + if (ui.modal.type === "inventory-review-plan") { + const plan = ui.inventoryReviewPlan; + return ``; + } + if (ui.modal.type === "deploy-key-lifecycle") { + const lifecycle = ui.deployKeyLifecycle; + const inventory = lifecycle?.inventory; + const rotation = lifecycle?.rotation; + const revocation = lifecycle?.revocation; + return ``; + } + if (ui.modal.type === "deployment-preflight") { + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ) || selectedProfile(repository); + const report = ui.deploymentPreflight; + return ``; + } + if (ui.modal.type === "deploy-confirm") { + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ) || selectedProfile(repository); + const targetSha = deploymentTargetSha(repository, profile); + return ``; + } + if (ui.modal.type === "rollback-confirm") { + const profile = repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ); + const target = profile?.state?.previousSha; + return ``; + } + if (ui.modal.type === "server-config") { + const server = + (ui.boot.state.servers || []).find( + (item) => item.id === ui.modal.serverId, + ) || {}; + const authType = ui.modal.authType || server.authType || "password"; + return ``; + } + if (ui.modal.type === "hunk-staging") { + const hunks = ui.diffHunks?.hunks || []; + return ``; + } + if (ui.modal.type === "pull-request") { + return ``; + } + if (ui.modal.type === "conflict-guide") { + const state = ui.conflictState || {}; + return ``; + } + if (ui.modal.type === "command-palette") return renderCommandPalette(); + return ""; +} + +function paletteCommands() { + const repository = selectedRepository(); + return [ + { + id: "overview", + label: "Go to release overview", + detail: "Workspace", + icon: "overview", + enabled: true, + }, + { + id: "refresh", + label: "Refresh all repositories", + detail: "Local and Gitea", + icon: "refresh", + enabled: true, + }, + { + id: "deployments", + label: "Open deployments", + detail: "Release history", + icon: "deploy", + enabled: true, + }, + { + id: "diagnostics", + label: "Open diagnostics", + detail: "Logs, preflight and support bundle", + icon: "shield", + enabled: true, + }, + { + id: "settings", + label: "Open settings", + detail: "Connections and awareness", + icon: "settings", + enabled: true, + }, + { + id: "open-folder", + label: "Open selected project folder", + detail: repository?.name || "No repository selected", + icon: "folder", + enabled: Boolean(repository?.localPath), + }, + { + id: "git-tools", + label: "Open branch and stash tools", + detail: repository?.name || "No repository selected", + icon: "branch", + enabled: Boolean(repository?.localPath), + }, + { + id: "deploy-selected", + label: "Deploy selected repository", + detail: canDeploy(repository) + ? `${repository.name} ${shortSha(deploymentTargetSha(repository))}` + : "Not ready", + icon: "rocket", + enabled: canDeploy(repository), + }, + ]; +} +function renderCommandPalette() { + const query = ui.paletteQuery.toLowerCase(); + const commands = paletteCommands().filter( + (command) => + !query || + `${command.label} ${command.detail}`.toLowerCase().includes(query), + ); + return ``; +} + +function enhanceRenderedUi() { + document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => { + const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " "); + button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1)); + }); + document.querySelectorAll(".field > label:not([for])").forEach((label, index) => { + const control = label.parentElement?.querySelector("input, select, textarea"); + if (!control) return; + if (!control.id) control.id = `forgeflow-field-${index}`; + label.htmlFor = control.id; + }); + document.querySelectorAll("input:not([aria-label]), select:not([aria-label]), textarea:not([aria-label])").forEach((control) => { + if (control.labels?.length) return; + const fallback = String(control.placeholder || control.name || control.id || "Form control").replaceAll("-", " ").trim(); + control.setAttribute("aria-label", fallback.charAt(0).toUpperCase() + fallback.slice(1)); + }); +} + +// A render replaces the complete application shell. Without this, a background +// repository poll or deployment poll destroys the element the user is typing in, +// discarding the caret position and every scroll offset on screen. +function elementRenderPath(element) { + const parts = []; + let node = element; + while (node && node !== app) { + const parent = node.parentElement; + if (!parent) return null; + parts.push(`${node.tagName}.${Array.prototype.indexOf.call(parent.children, node)}`); + node = parent; + } + return node === app ? parts.reverse().join(">") : null; +} + +function elementAtRenderPath(renderPath) { + let node = app; + for (const part of renderPath.split(">")) { + const separator = part.lastIndexOf("."); + node = node?.children?.[Number(part.slice(separator + 1))]; + // The shell can be structurally different after a view change, in which case + // the old offset belongs to an unrelated element and must be dropped. + if (!node || node.tagName !== part.slice(0, separator)) return null; + } + return node; +} + +// enhanceRenderedUi() re-injects these controls empty on every render, so a +// background refresh would otherwise discard a release note or review reason +// while the user is still writing it. +const INJECTED_FIELD_IDS = [ + "deployment-note", + "deployment-override", + "deployment-override-reason", + "inventory-review-action", + "inventory-review-reason", + "profile-policy-frozen", + "profile-policy-note", + "profile-policy-freeze-reason", + "profile-policy-windows", +]; + +function captureInjectedFieldValues() { + const values = []; + for (const id of INJECTED_FIELD_IDS) { + const element = document.getElementById(id); + if (!element) continue; + if (element.type === "checkbox") values.push({ id, checked: element.checked }); + else if (element.value) values.push({ id, value: element.value }); + } + return values; +} + +function restoreInjectedFieldValues(values) { + for (const entry of values) { + const element = document.getElementById(entry.id); + if (!element) continue; + // Never overwrite a value the freshly rendered control already carries; only + // fill back in what the injection left empty. + if ("checked" in entry) { + if (!element.checked) element.checked = entry.checked; + } else if (!element.value) element.value = entry.value; + } +} + +function captureInteractionState() { + const scroll = []; + for (const element of app.querySelectorAll("*")) { + if (!element.scrollTop && !element.scrollLeft) continue; + const renderPath = elementRenderPath(element); + if (renderPath) scroll.push({ renderPath, top: element.scrollTop, left: element.scrollLeft }); + } + const injectedFields = captureInjectedFieldValues(); + const active = document.activeElement; + if (!active?.id || !app.contains(active)) return { scroll, injectedFields, focus: null }; + const focus = { id: active.id, start: null, end: null, direction: "none" }; + try { + focus.start = active.selectionStart; + focus.end = active.selectionEnd; + focus.direction = active.selectionDirection || "none"; + } catch {} + return { scroll, injectedFields, focus }; +} + +function restoreInteractionState(state) { + restoreInjectedFieldValues(state.injectedFields); + for (const entry of state.scroll) { + const element = elementAtRenderPath(entry.renderPath); + if (!element) continue; + element.scrollTop = entry.top; + element.scrollLeft = entry.left; + } + if (!state.focus) return; + const element = document.getElementById(state.focus.id); + if (!element || !app.contains(element)) return; + element.focus({ preventScroll: true }); + if (state.focus.start === null) return; + try { + element.setSelectionRange(state.focus.start, state.focus.end, state.focus.direction); + } catch {} +} + +let lastRenderedMarkup = null; + +function render() { + if (!ui.boot) return; + const repository = selectedRepository(); + const main = + ui.currentView === "overview" + ? renderOverview() + : ui.currentView === "deployments" + ? renderDeployments() + : ui.currentView === "help" + ? renderHelp() + : ui.currentView === "settings" + ? renderSettings() + : ui.currentView === "diagnostics" + ? renderDiagnostics() + : ui.currentView === "deployment-run" + ? renderPipelineView() + : repository + ? renderRepositoryWorkspace(repository) + : renderOverview(); + const withPanel = ui.currentView === "repository" && repository; + const markup = `
${renderTitlebar()}
${renderSidebar()}
${main}
${withPanel ? renderActionPanel(repository) : ""}${ui.loading ? `
${escapeHtml(ui.loadingMessage || "Working…")}
` : ""}
${renderStatusbar()}
${ui.boot.state.setupComplete ? "" : renderSetup()}${renderModal()}`; + // Most renders are triggered by a poll that found nothing new. Rebuilding an + // identical shell would only cost layout work and interrupt the user. The + // markup is the complete rendered state, so comparing it is sufficient: + // enhanceRenderedUi() only derives labels and ids from what is already there. + if (markup === lastRenderedMarkup) return; + const interaction = captureInteractionState(); + app.innerHTML = markup; + enhanceRenderedUi(); + restoreInteractionState(interaction); + lastRenderedMarkup = markup; + if (ui.modal?.type === "command-palette") + requestAnimationFrame(() => + document.querySelector("#palette-input")?.focus(), + ); +} diff --git a/src/renderer/diff-view.js b/src/renderer/diff-view.js new file mode 100644 index 0000000..00584e4 --- /dev/null +++ b/src/renderer/diff-view.js @@ -0,0 +1,40 @@ +// Rendering a unified diff is a self-contained concern with its own size +// limits, kept out of views.js so that file stays within the project's +// architecture budget. +// A regenerated lock file runs into tens of thousands of lines, and one element +// per line freezes the window. Only the rendered view is capped. +const DIFF_RENDER_LINE_LIMIT = 2000; + +function diffAtmosphere(diff, allLines = null) { + if (!ui.selectedFile) return ""; + const lines = allLines || String(diff || "").split("\n"); + const additions = lines.filter( + (line) => line.startsWith("+") && !line.startsWith("+++"), + ).length; + const removals = lines.filter( + (line) => line.startsWith("-") && !line.startsWith("---"), + ).length; + const extension = + String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() || + "FILE"; + return ``; +} + +function diffLineType(line) { + if (line.startsWith("+") && !line.startsWith("+++")) return "add"; + if (line.startsWith("-") && !line.startsWith("---")) return "remove"; + return line.startsWith("@@") ? "hunk" : ""; +} + +function renderDiff(diff) { + if (!diff) + return '
↔

No textual diff

Select another file or open the project folder for binary changes.

'; + const lines = String(diff).split("\n"); + const rendered = lines + .slice(0, DIFF_RENDER_LINE_LIMIT) + .map((line) => `${escapeHtml(line) || " "}`) + .join(""); + const hidden = Math.max(0, lines.length - DIFF_RENDER_LINE_LIMIT); + const notice = hidden ? `… ${hidden.toLocaleString()} more line${hidden === 1 ? "" : "s"} are not shown. Copy diff and the editor still give you the complete change.` : ""; + return `${rendered}${notice}${diffAtmosphere(diff, lines)}`; +} diff --git a/src/renderer/events.js b/src/renderer/events.js new file mode 100644 index 0000000..2fb6aa5 --- /dev/null +++ b/src/renderer/events.js @@ -0,0 +1,188 @@ +app.addEventListener("click", async (event) => { + const target = event.target.closest("[data-action]"); + if (!target) return; + const action = target.dataset.action; + let repository = selectedRepository(); + if (target.dataset.repositoryId) { + const actionRepository = ui.repositories.find( + (item) => String(item.id) === String(target.dataset.repositoryId), + ); + if (actionRepository) repository = actionRepository; + } + + const handlers = [handleShellActions, handleInventoryActions, handleDeploymentProfileActions, handleDeploymentOperationActions, handleSetupAndSettingsActions, handleRecoveryActions, handleCommandActions]; + for (const handler of handlers) if (await handler(event, target, action, repository)) return; +}); + +app.addEventListener("input", (event) => { + if (event.target.id === "global-search") { + ui.search = event.target.value; + scheduleInputRender(); + } else if (event.target.id === "repo-filter") { + ui.repoSearch = event.target.value; + scheduleInputRender(); + } else if (event.target.id === "commit-message") { + ui.commitMessage = event.target.value; + const repository = selectedRepository(); + const hasSelection = Boolean(ui.selectedFiles.size || repository?.localStatus?.counts?.staged); + const ready = Boolean(hasSelection && ui.commitMessage.trim()); + const blocker = !hasSelection + ? "Select files or stage one or more hunks." + : ready + ? ui.selectedFiles.size + ? "Ready to commit. ForgeFlow stages the selected files automatically." + : "Ready to commit only the reviewed staged hunks." + : "Enter a commit message to enable commit and push."; + const readiness = document.querySelector(".commit-readiness"); + if (readiness) { + readiness.classList.toggle("ready", ready); + readiness.classList.toggle("blocked", !ready); + readiness.innerHTML = `${icon(ready ? "check" : "warning")}${escapeHtml(blocker)}`; + } + for (const button of document.querySelectorAll('[data-action="commit-push"], [data-action="commit-only"]')) { + button.disabled = !ready; + if (ready) button.removeAttribute("title"); + else button.title = blocker; + } + } else if (event.target.id === "setup-url") + ui.setupDraft.baseUrl = event.target.value; + else if (event.target.id === "setup-token") + ui.setupDraft.token = event.target.value; + else if (event.target.id === "palette-input") { + ui.paletteQuery = event.target.value; + scheduleInputRender(60); + } else if (event.target.id === "help-search") { + ui.helpQuery = event.target.value; + scheduleInputRender(60); + } +}); + +app.addEventListener("change", async (event) => { + if (event.target.matches("[data-file-select]")) { + const filePath = event.target.dataset.fileSelect; + if (event.target.checked) ui.selectedFiles.add(filePath); + else ui.selectedFiles.delete(filePath); + render(); + } else if (event.target.id === "appearance-select") { + ui.boot.state = await window.forgeflow.setAppearance(event.target.value); + applyTheme(event.target.value); + render(); + } else if (event.target.id === "action-profile-select") { + ui.selectedProfileId = event.target.value; + render(); + } else if (event.target.id === "validator-policy") { + await handleShellActions(event, event.target, "git-validator-policy", selectedRepository()); + } else if (event.target.id === "profile-provider") { + ui.modal.provider = event.target.value; + render(); + } else if (event.target.id === "server-auth-type") { + ui.modal.authType = event.target.value; + render(); + } +}); + +document.addEventListener("keydown", (event) => { + if ( + (event.key === "Enter" || event.key === " ") && + event.target.matches('.file-row[data-action="select-file"]') + ) { + event.preventDefault(); + event.target.click(); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + ui.paletteQuery = ""; + ui.modal = { type: "command-palette" }; + render(); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f" && ui.currentView === "help") { + event.preventDefault(); + document.querySelector("#help-search")?.focus(); + return; + } + if ( + (event.ctrlKey || event.metaKey) && + event.key === "Enter" && + ui.currentView === "repository" + ) { + const button = document.querySelector( + '[data-action="commit-push"]:not(:disabled)', + ); + if (button) button.click(); + } + if (event.key === "F5") { + event.preventDefault(); + refreshRepositories(true); + } + if (event.key === "Escape" && ui.modal) { + ui.modal = null; + render(); + } +}); + +let pointerAnimationFrame = null; +let pendingPointer = null; + +document.addEventListener("pointermove", (event) => { + pendingPointer = { target: event.target, clientX: event.clientX, clientY: event.clientY }; + if (pointerAnimationFrame) return; + pointerAnimationFrame = requestAnimationFrame(() => { + pointerAnimationFrame = null; + const current = pendingPointer; + pendingPointer = null; + if (!current) return; + const illustration = current.target.closest?.("[data-project-illustration]"); + if (illustration) { + const bounds = illustration.getBoundingClientRect(); + illustration.style.setProperty("--tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`); + illustration.style.setProperty("--tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`); + } + const diffPanel = current.target.closest?.(".diff-panel"); + const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]"); + if (atmosphere) { + const bounds = diffPanel.getBoundingClientRect(); + atmosphere.style.setProperty("--diff-tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`); + atmosphere.style.setProperty("--diff-tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`); + } + }); +}); +document.addEventListener("pointerout", (event) => { + const illustration = event.target.closest?.("[data-project-illustration]"); + if (illustration && !illustration.contains(event.relatedTarget)) { + illustration.style.removeProperty("--tilt-x"); + illustration.style.removeProperty("--tilt-y"); + } + + const diffPanel = event.target.closest?.(".diff-panel"); + if (diffPanel && !diffPanel.contains(event.relatedTarget)) { + const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]"); + atmosphere?.style.removeProperty("--diff-tilt-x"); + atmosphere?.style.removeProperty("--diff-tilt-y"); + } +}); + +window.addEventListener("error", (event) => { + window.forgeflow + .reportRendererEvent?.("error", "uncaught-error", { + message: event.message, + filename: event.filename, + line: event.lineno, + column: event.colno, + stack: event.error?.stack, + }) + .catch(() => {}); +}); + +window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason; + window.forgeflow + .reportRendererEvent?.("error", "unhandled-rejection", { + message: reason?.message || String(reason || "Unknown rejection"), + stack: reason?.stack, + }) + .catch(() => {}); +}); + +bootstrap(); diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..e08491a --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,37 @@ + + + + + + + ForgeFlow + + + + +
+
+ + Starting ForgeFlow + Checking Git and local configuration… +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js new file mode 100644 index 0000000..dfb7ed0 --- /dev/null +++ b/src/renderer/mock-bridge.js @@ -0,0 +1,589 @@ +(() => { + if (window.forgeflow) return; + + const wait = (ms = 180) => new Promise((resolve) => setTimeout(resolve, ms)); + const clone = (value) => JSON.parse(JSON.stringify(value)); + const iso = (offset = 0) => new Date(Date.now() + offset).toISOString(); + const storage = { + get(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + set(key, value) { + try { + localStorage.setItem(key, value); + } catch {} + }, + }; + const repositoryListeners = new Set(); + const operationListeners = new Set(); + const updateListeners = new Set(); + const emitRepositories = () => + repositoryListeners.forEach((listener) => + listener({ reason: "demo-change" }), + ); + const emitOperations = (operations) => + operationListeners.forEach((listener) => + listener({ operations: clone(operations) }), + ); + const randomSha = () => + `${Math.random().toString(16).slice(2)}${Date.now().toString(16)}` + .padEnd(40, "a") + .slice(0, 40); + + const makeStatus = ({ + head, + branch = "main", + ahead = 0, + behind = 0, + upstream = `origin/${branch}`, + files = [], + }) => ({ + branch: { oid: head, head: branch, upstream, ahead, behind }, + files, + counts: { + changed: files.length, + staged: files.filter((item) => item.staged).length, + unstaged: files.filter((item) => item.unstaged).length, + conflicts: files.filter((item) => item.conflict).length, + untracked: files.filter((item) => item.untracked).length, + }, + clean: files.length === 0, + root: "", + remoteUrl: "", + head, + shortHead: head.slice(0, 7), + fingerprint: `${head}:${branch}:${ahead}:${behind}:${files.map((item) => `${item.path}:${item.indexCode}${item.worktreeCode}`).join("|")}`, + }); + + const makeFile = (path, status = "modified", options = {}) => ({ + path, + originalPath: options.originalPath || null, + indexCode: options.staged + ? status === "added" + ? "A" + : status === "deleted" + ? "D" + : "M" + : ".", + worktreeCode: options.staged + ? "." + : status === "untracked" + ? "?" + : status === "deleted" + ? "D" + : status === "conflict" + ? "U" + : "M", + staged: Boolean(options.staged), + unstaged: !options.staged, + untracked: status === "untracked", + conflict: status === "conflict", + status, + }); + + const profile = (id, name, environment, options = {}) => ({ + id, + name, + environment, + provider: "gitea-actions", + branch: options.branch || "main", + workflowFile: options.workflowFile || "deploy.yml", + rollbackWorkflowFile: options.rollbackWorkflowFile ?? "rollback.yml", + healthcheckUrl: + options.healthcheckUrl || `https://${environment}.internal/health`, + statusUrl: + options.statusUrl || + `https://${environment}.internal/.well-known/forgeflow`, + confirmationRequired: options.confirmationRequired !== false, + inputs: {}, + state: { + liveSha: options.liveSha || null, + previousSha: options.previousSha || null, + healthy: options.healthy ?? null, + healthConfigured: true, + statusConfigured: true, + healthStatus: options.healthy === false ? 503 : 200, + healthLatencyMs: 42, + checkedAt: options.checkedAt || iso(-120000), + }, + }); + + const sshProfile = (id, name, environment, options = {}) => ({ + id, name, environment, provider: "ssh-unraid", branch: options.branch || "main", + serverId: "demo-unraid", remoteFolder: options.remoteFolder || name, + deploymentMode: "server-git", composeFiles: ["compose.yml"], + composeProject: options.composeProject || String(options.remoteFolder || name).toLowerCase(), + composeServices: options.composeServices || [String(options.remoteFolder || name).toLowerCase()], + containerName: options.containerName || options.remoteFolder || name, + generatedCompose: false, adoptedFromServer: true, serverSourceOfTruth: true, + confirmationRequired: true, + serverGitAccess: { configured: options.accessConfigured !== false, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso(-3600000) }, + state: { + liveSha: options.liveSha || null, giteaSha: options.giteaSha || options.liveSha || null, + previousSha: options.previousSha || null, healthy: options.healthy ?? true, + containerRunning: true, runtimeVerification: "verified", matchesGitea: options.matchesGitea ?? true, + checkedAt: iso(-120000), dockerMan: { templateExists: true, webUi: true, icon: true }, + }, + }); + + const now = iso(); + const defaultPreferences = { + autoRefresh: true, + repositoryPollSeconds: 4, + operationPollSeconds: 5, + fetchIntervalMinutes: 10, + preferredCloneProtocol: "https", + diagnosticsEnabled: true, + diagnosticLevel: "info", + logRetentionDays: 14, + maxLogFileMb: 8, + }; + + let state = { + schemaVersion: 8, + setupComplete: storage.get("forgeflow-demo-setup") !== "false", + appearance: storage.get("forgeflow-theme") || "dark", + gitea: { + baseUrl: "https://gitea.internal", + user: { login: "jens", full_name: "Jens" }, + hasToken: true, + }, + workspaceRoots: ["C:\\Development"], + repositoryMappings: {}, + deploymentProfiles: {}, + deploymentStates: {}, + favorites: [ + "jens/microsoft-cloud-operations-platform", + "jens/unraid-appops-gateway", + ], + updates: { + owner: "Jens", + repo: "ForgeFlow", + branch: "main", + autoCheck: true, + lastCheckedAt: null, + }, + servers: [ + { + id: "server-unraid", + name: "Unraid", + host: "192.168.1.10", + port: 22, + username: "root", + authType: "privateKey", + basePath: "/mnt/user/appdata", + privateKeyPath: "C:\\Users\\your-name\\.ssh\\id_ed25519", + hostFingerprint: "SHA256:demo", + hasPassword: false, + hasPassphrase: false, + }, + ], + preferences: { ...defaultPreferences }, + operations: [ + { + id: "op-success", + type: "deployment", + action: "deploy", + status: "success", + repository: "jens/microsoft-cloud-operations-platform", + profileId: "profile-mcop-prod", + profileName: "Production", + environment: "production", + workflowFile: "deploy.yml", + branch: "main", + sha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0", + shortSha: "b82f91a", + createdAt: now, + updatedAt: now, + stages: [ + { id: "requested", label: "Requested", status: "complete" }, + { id: "verified", label: "Verified", status: "complete" }, + { id: "queued", label: "Workflow queued", status: "complete" }, + { id: "runner", label: "Runner execution", status: "complete" }, + { id: "healthcheck", label: "Healthcheck", status: "complete" }, + { id: "complete", label: "Complete", status: "complete" }, + ], + logs: [ + "[info] Exact commit verified.", + "[job] deploy: success", + "[ok] Server reports b82f91a and healthcheck returned 200.", + ], + run: { + id: 48, + runNumber: 48, + status: "completed", + conclusion: "success", + name: "ForgeFlow deployment", + }, + runUrl: + "https://gitea.internal/jens/microsoft-cloud-operations-platform/actions/runs/48", + }, + { + id: "op-failed", + type: "deployment", + action: "deploy", + status: "failed", + repository: "jens/portfolio", + profileId: "profile-portfolio", + profileName: "Production", + environment: "production", + workflowFile: "deploy.yml", + branch: "main", + sha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719", + shortSha: "a7f2e1c", + createdAt: iso(-86400000), + updatedAt: iso(-86300000), + failure: { stage: "healthcheck", message: "Healthcheck returned 502." }, + stages: [ + { id: "requested", label: "Requested", status: "complete" }, + { id: "verified", label: "Verified", status: "complete" }, + { id: "queued", label: "Workflow queued", status: "complete" }, + { id: "runner", label: "Runner execution", status: "complete" }, + { id: "healthcheck", label: "Healthcheck", status: "failed" }, + { id: "complete", label: "Complete", status: "failed" }, + ], + logs: ["[job] deploy: success", "[error] Healthcheck returned 502."], + }, + ], + }; + + let repositories = [ + { + id: 1, + name: "microsoft-cloud-operations-platform", + fullName: "jens/microsoft-cloud-operations-platform", + owner: { login: "jens" }, + description: "Tenant-aware Microsoft cloud operations console.", + private: true, + defaultBranch: "main", + htmlUrl: + "https://gitea.internal/jens/microsoft-cloud-operations-platform", + cloneUrl: + "https://gitea.internal/jens/microsoft-cloud-operations-platform.git", + sshUrl: "git@gitea.internal:jens/microsoft-cloud-operations-platform.git", + updatedAt: now, + localPath: "C:\\Development\\Microsoft-Cloud-Operations-Platform", + localStatus: makeStatus({ + head: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0", + }), + linkState: "linked", + deploymentProfiles: [ + profile("profile-mcop-prod", "Production", "production", { + liveSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0", + previousSha: "6ac991ab0173cd4346ca0f0f7dcc3e8182cc8fd0", + healthy: true, + }), + profile("profile-mcop-stage", "Staging", "staging", { + liveSha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0", + previousSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0", + healthy: true, + confirmationRequired: false, + }), + ], + }, + { + id: 2, + name: "vacancyradar", + fullName: "jens/vacancyradar", + owner: { login: "jens" }, + description: "Local-first vacancy intelligence cockpit.", + private: true, + defaultBranch: "main", + htmlUrl: "https://gitea.internal/jens/vacancyradar", + cloneUrl: "https://gitea.internal/jens/vacancyradar.git", + sshUrl: "git@gitea.internal:jens/vacancyradar.git", + updatedAt: now, + localPath: "C:\\Development\\VacancyRadar", + localStatus: makeStatus({ + head: "c9182d0d28318c8cf0af109edc054732426aadf1", + branch: "feature/deployment-api", + files: [ + makeFile("src/api/deploy.ts", "added", { staged: true }), + makeFile("src/main.tsx"), + makeFile("src/components/Sidebar.tsx"), + ], + }), + linkState: "linked", + deploymentProfiles: [ + profile("profile-vr", "Production", "production", { + liveSha: "c117ab9d28318c8cf0af109edc054732426aadf1", + previousSha: "b1f57aad28318c8cf0af109edc054732426aadf1", + healthy: true, + }), + ], + }, + { + id: 3, + name: "unraid-appops-gateway", + fullName: "jens/unraid-appops-gateway", + owner: { login: "jens" }, + description: "Safe operations gateway for Unraid and Portainer.", + private: true, + defaultBranch: "main", + htmlUrl: "https://gitea.internal/jens/unraid-appops-gateway", + cloneUrl: "https://gitea.internal/jens/unraid-appops-gateway.git", + sshUrl: "git@gitea.internal:jens/unraid-appops-gateway.git", + updatedAt: now, + localPath: "C:\\Development\\Unraid-AppOps-Gateway", + localStatus: makeStatus({ + head: "f2d1e0a1bb6147fc8b6633d2b08500c93402a719", + ahead: 2, + }), + linkState: "linked", + deploymentProfiles: [ + profile("profile-appops", "Production", "production", { + liveSha: "8ac731b1bb6147fc8b6633d2b08500c93402a719", + previousSha: "7bc198a1bb6147fc8b6633d2b08500c93402a719", + healthy: true, + }), + ], + }, + { + id: 4, + name: "support-bundle-collector", + fullName: "jens/support-bundle-collector", + owner: { login: "jens" }, + description: "Privacy-aware Windows support bundle collector.", + private: true, + defaultBranch: "main", + htmlUrl: "https://gitea.internal/jens/support-bundle-collector", + cloneUrl: "https://gitea.internal/jens/support-bundle-collector.git", + sshUrl: "git@gitea.internal:jens/support-bundle-collector.git", + updatedAt: now, + localPath: null, + localStatus: null, + linkState: "remote-only", + deploymentProfiles: [], + }, + { + id: 5, + name: "portfolio", + fullName: "jens/portfolio", + owner: { login: "jens" }, + description: "Professional infrastructure and automation portfolio.", + private: false, + defaultBranch: "main", + htmlUrl: "https://gitea.internal/jens/portfolio", + cloneUrl: "https://gitea.internal/jens/portfolio.git", + sshUrl: "git@gitea.internal:jens/portfolio.git", + updatedAt: now, + localPath: "C:\\Development\\portfolio", + localStatus: makeStatus({ + head: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719", + behind: 1, + }), + linkState: "linked", + deploymentProfiles: [ + sshProfile("profile-portfolio", "Production", "production", { + remoteFolder: "Portfolio", + containerName: "Portfolio", + liveSha: "4c20dd11bb6147fc8b6633d2b08500c93402a719", + giteaSha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719", + previousSha: "31adfe11bb6147fc8b6633d2b08500c93402a719", + healthy: false, + matchesGitea: false, + }), + ], + }, + ]; + + const diffs = { + "src/api/deploy.ts": `diff --git a/src/api/deploy.ts b/src/api/deploy.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/api/deploy.ts\n@@ -0,0 +1,18 @@\n+export interface DeploymentRequest {\n+ environment: 'staging' | 'production';\n+ commitSha: string;\n+}\n+\n+export async function deploy(request: DeploymentRequest) {\n+ return api.post('/deployments', request);\n+}`, + "src/main.tsx": `diff --git a/src/main.tsx b/src/main.tsx\nindex 45ad1a2..939fc17 100644\n--- a/src/main.tsx\n+++ b/src/main.tsx\n@@ -24,8 +24,9 @@ import { Router } from './routes';\n-const API_ENDPOINT = 'http://localhost:3000';\n+const API_ENDPOINT = process.env.VITE_API_URL || '/api';\n+const DEPLOY_VERSION = '1.0.4-rc1';`, + "src/components/Sidebar.tsx": `diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx\nindex a7bbd82..bf21e90 100644\n--- a/src/components/Sidebar.tsx\n+++ b/src/components/Sidebar.tsx\n@@ -31,6 +31,7 @@ export function Sidebar() {\n+ Deployments`, + }; + + const findRepo = (localPath) => + repositories.find((item) => item.localPath === localPath); + const findProfileRepo = (profileId) => + repositories.find((item) => + item.deploymentProfiles.some((entry) => entry.id === profileId), + ); + const syncState = () => { + state.deploymentProfiles = {}; + state.deploymentStates = {}; + state.repositoryMappings = {}; + for (const repository of repositories) { + if (repository.localPath) + state.repositoryMappings[repository.fullName.toLowerCase()] = + repository.localPath; + state.deploymentProfiles[repository.fullName.toLowerCase()] = + repository.deploymentProfiles.map( + ({ state: profileState, ...entry }) => entry, + ); + for (const entry of repository.deploymentProfiles) + if (entry.state) state.deploymentStates[entry.id] = clone(entry.state); + } + }; + const recompute = (repository) => { + const status = repository.localStatus; + if (status) { + status.counts = { + changed: status.files.length, + staged: status.files.filter((item) => item.staged).length, + unstaged: status.files.filter((item) => item.unstaged).length, + conflicts: status.files.filter((item) => item.conflict).length, + untracked: status.files.filter((item) => item.untracked).length, + }; + status.clean = status.files.length === 0; + status.shortHead = status.head.slice(0, 7); + status.branch.oid = status.head; + } + repository.favorite = state.favorites.includes( + repository.fullName.toLowerCase(), + ); + repository.readyToDeploy = Boolean( + repository.localPath && + status?.clean && + status.branch.upstream && + status.branch.ahead === 0 && + status.branch.behind === 0 && + repository.deploymentProfiles.some( + (entry) => entry.branch === status.branch.head, + ), + ); + repository.attention = + !repository.localPath || + Boolean( + status?.counts.conflicts || + status?.branch.behind || + status?.branch.ahead || + status?.counts.changed, + ); + repository.attentionReason = !repository.localPath + ? "No local folder linked" + : status?.counts.conflicts + ? `${status.counts.conflicts} conflict(s)` + : status?.counts.changed + ? `${status.counts.changed} local change(s)` + : status?.branch.behind + ? `${status.branch.behind} commit(s) behind remote` + : status?.branch.ahead + ? `${status.branch.ahead} unpushed commit(s)` + : null; + repository.preferredCloneUrl = + state.preferences.preferredCloneProtocol === "ssh" + ? repository.sshUrl + : repository.cloneUrl; + }; + const snapshot = () => { + repositories.forEach(recompute); + syncState(); + return clone(repositories); + }; + syncState(); + + const commitHistory = [ + { + sha: "c9182d0d28318c8cf0af109edc054732426aadf1", + shortSha: "c9182d0", + author: "Jens", + date: now, + subject: "feat: add deployment provider contract", + }, + { + sha: "1fa7399d28318c8cf0af109edc054732426aadf1", + shortSha: "1fa7399", + author: "Jens", + date: iso(-86400000), + subject: "refactor: consolidate repository state", + }, + { + sha: "a251a11d28318c8cf0af109edc054732426aadf1", + shortSha: "a251a11", + author: "Jens", + date: iso(-172800000), + subject: "docs: define deployment safety gates", + }, + ]; + const branchesByRepo = new Map(); + const stashesByRepo = new Map(); + + function updateOperation(operation) { + state.operations = [ + clone(operation), + ...state.operations.filter((item) => item.id !== operation.id), + ].slice(0, 250); + emitOperations([operation]); + return clone(operation); + } + + function advanceOperation(operation) { + if ( + !operation || + ["success", "failed", "cancelled", "rolled-back"].includes( + operation.status, + ) + ) + return operation; + operation.demoPolls = (operation.demoPolls || 0) + 1; + if (operation.demoPolls === 1) { + operation.status = "running"; + operation.run = { + id: 81, + runNumber: 81, + status: "running", + conclusion: null, + name: + operation.action === "rollback" + ? "ForgeFlow rollback" + : "ForgeFlow deployment", + }; + operation.runUrl = `https://gitea.internal/${operation.repository}/actions/runs/81`; + operation.stages.find((item) => item.id === "queued").status = "complete"; + operation.stages.find((item) => item.id === "runner").status = "active"; + operation.jobs = [ + { + id: 201, + name: operation.action === "rollback" ? "rollback" : "deploy", + status: "running", + conclusion: null, + }, + ]; + operation.logs.push(`[job] ${operation.jobs[0].name}: running`); + } else if (operation.demoPolls >= 2) { + operation.status = + operation.action === "rollback" ? "rolled-back" : "success"; + operation.stages.forEach((item) => { + item.status = "complete"; + }); + operation.jobs = [ + { + id: 201, + name: operation.action === "rollback" ? "rollback" : "deploy", + status: "completed", + conclusion: "success", + }, + ]; + operation.logs.push( + "[ok] Runner completed successfully.", + `[ok] Server status endpoint confirms ${operation.shortSha}.`, + ); + const repository = repositories.find( + (item) => item.fullName === operation.repository, + ); + const targetProfile = repository?.deploymentProfiles.find( + (item) => item.id === operation.profileId, + ); + if (targetProfile) { + const oldLive = targetProfile.state.liveSha; + targetProfile.state.previousSha = oldLive; + targetProfile.state.liveSha = operation.sha; + targetProfile.state.healthy = true; + targetProfile.state.checkedAt = iso(); + } + } + operation.updatedAt = iso(); + return operation; + } + + const bridgeContext = { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation }; + window.forgeflow = Object.freeze({ + ...createMockRepositoryBridge(bridgeContext), + ...createMockDeploymentBridge(bridgeContext), + }); +})(); diff --git a/src/renderer/mock-deployment-bridge.js b/src/renderer/mock-deployment-bridge.js new file mode 100644 index 0000000..903e6b1 --- /dev/null +++ b/src/renderer/mock-deployment-bridge.js @@ -0,0 +1,700 @@ +function createMockDeploymentBridge(context) { + const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context; + return { + async saveDeploymentProfile(fullName, input) { + const repo = repositories.find((item) => item.fullName === fullName); + const existing = repo.deploymentProfiles.find( + (item) => item.id === input.id, + ); + const saved = { + ...(existing || + profile( + input.id || `profile-${Date.now()}`, + input.name || input.environment, + input.environment || "production", + )), + ...input, + id: input.id || `profile-${Date.now()}`, + provider: input.provider || existing?.provider || "gitea-actions", + inputs: existing?.inputs || {}, + state: existing?.state || { + liveSha: null, + previousSha: null, + healthy: null, + healthConfigured: Boolean(input.healthcheckUrl), + statusConfigured: Boolean(input.statusUrl), + checkedAt: null, + }, + }; + repo.deploymentProfiles = [ + ...repo.deploymentProfiles.filter((item) => item.id !== saved.id), + saved, + ]; + snapshot(); + return { profile: clone(saved), state: clone(state) }; + }, + async deleteDeploymentProfile(fullName, profileId) { + const repo = repositories.find((item) => item.fullName === fullName); + repo.deploymentProfiles = repo.deploymentProfiles.filter( + (item) => item.id !== profileId, + ); + snapshot(); + return { profiles: clone(repo.deploymentProfiles), state: clone(state) }; + }, + async deploymentPreflight(repository, profileId) { + await wait(280); + const profile = repository.deploymentProfiles.find( + (item) => item.id === profileId, + ); + const status = repository.localStatus; + const checks = [ + { + id: "repository.linked", + label: "Local repository link", + status: repository.localPath ? "pass" : "fail", + detail: repository.localPath || "No local folder linked.", + required: true, + }, + { + id: "git.branch", + label: "Allowed branch", + status: status?.branch.head === profile?.branch ? "pass" : "fail", + detail: `Current: ${status?.branch.head || "unknown"}; required: ${profile?.branch || "unknown"}.`, + required: true, + }, + { + id: "git.clean", + label: "Clean working tree", + status: status?.clean ? "pass" : "fail", + detail: status?.clean + ? "No uncommitted changes." + : `${status?.counts.changed || 0} changed file(s).`, + required: true, + }, + { + id: "git.sync", + label: "Local and Gitea synchronized", + status: + !status?.branch.ahead && !status?.branch.behind ? "pass" : "fail", + detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`, + required: true, + }, + { + id: "workflow.deploy.remote", + label: "Deploy workflow on Gitea branch", + status: "pass", + detail: `${profile?.workflowFile || "deploy.yml"} exists on ${profile?.branch || "main"}.`, + required: true, + }, + { + id: "gitea.actions", + label: "Gitea Actions API", + status: "pass", + detail: "The Actions runs endpoint is accessible.", + required: true, + }, + { + id: "server.status", + label: "Server version endpoint", + status: profile?.statusUrl ? "pass" : "warning", + detail: profile?.statusUrl + ? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0, 7) || "unknown"}.` + : "No status URL configured.", + required: false, + }, + { + id: "server.health", + label: "Application healthcheck", + status: profile?.healthcheckUrl ? "pass" : "warning", + detail: profile?.healthcheckUrl + ? "HTTP 200 in 42 ms." + : "No healthcheck URL configured.", + required: false, + }, + ]; + const blocking = checks + .filter((i) => i.required && i.status === "fail") + .map((i) => i.id); + return { + kind: "deployment", + repository: repository.fullName, + profileId, + startedAt: iso(-100), + completedAt: iso(), + checks, + summary: { + counts: { + pass: checks.filter((i) => i.status === "pass").length, + warning: checks.filter((i) => i.status === "warning").length, + fail: checks.filter((i) => i.status === "fail").length, + skipped: 0, + }, + blocking, + ready: blocking.length === 0, + }, + head: status?.head || null, + }; + }, + async deploy(repository, profileId, sha) { + await wait(320); + const selected = repository.deploymentProfiles.find( + (item) => item.id === profileId, + ); + const operation = { + id: `deploy-${Date.now()}`, + type: "deployment", + action: "deploy", + status: "queued", + repository: repository.fullName, + profileId, + profileName: selected.name, + environment: selected.environment, + workflowFile: selected.workflowFile, + branch: selected.branch, + sha, + shortSha: sha.slice(0, 7), + dispatchedAt: iso(), + createdAt: iso(), + updatedAt: iso(), + demoPolls: 0, + stages: [ + { id: "requested", label: "Requested", status: "complete" }, + { id: "verified", label: "Verified", status: "complete" }, + { id: "queued", label: "Workflow queued", status: "active" }, + { id: "runner", label: "Runner execution", status: "pending" }, + { id: "healthcheck", label: "Healthcheck", status: "pending" }, + { id: "complete", label: "Complete", status: "pending" }, + ], + logs: [ + `[info] Verified clean ${selected.branch} at ${sha}`, + `[ok] Gitea accepted ${selected.workflowFile}.`, + ], + }; + return updateOperation(operation); + }, + async rollback(repository, profileId, targetSha) { + await wait(320); + const selected = repository.deploymentProfiles.find( + (item) => item.id === profileId, + ); + const operation = { + id: `rollback-${Date.now()}`, + type: "deployment", + action: "rollback", + status: "queued", + repository: repository.fullName, + profileId, + profileName: selected.name, + environment: selected.environment, + workflowFile: selected.rollbackWorkflowFile, + branch: selected.branch, + sha: targetSha, + shortSha: targetSha.slice(0, 7), + dispatchedAt: iso(), + createdAt: iso(), + updatedAt: iso(), + demoPolls: 0, + stages: [ + { id: "requested", label: "Requested", status: "complete" }, + { id: "verified", label: "Verified", status: "complete" }, + { id: "queued", label: "Workflow queued", status: "active" }, + { id: "runner", label: "Runner execution", status: "pending" }, + { id: "healthcheck", label: "Healthcheck", status: "pending" }, + { id: "complete", label: "Complete", status: "pending" }, + ], + logs: [ + `[warning] Rollback target verified: ${targetSha}`, + `[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`, + ], + }; + return updateOperation(operation); + }, + async healthcheck() { + await wait(160); + return { configured: true, healthy: true, status: 200, latencyMs: 42 }; + }, + async refreshProfileState(fullName, profileId) { + await wait(240); + const repo = + repositories.find((item) => item.fullName === fullName) || + findProfileRepo(profileId); + const target = repo?.deploymentProfiles.find( + (item) => item.id === profileId, + ); + if (!target) throw new Error("Deployment profile not found."); + target.state = { + ...target.state, + checkedAt: iso(), + healthy: target.state.healthy !== false, + healthConfigured: Boolean(target.healthcheckUrl), + statusConfigured: Boolean(target.statusUrl), + }; + syncState(); + return clone(target.state); + }, + async discoverServerDeployments() { + await wait(80); + return [ + { + serverId: "server-unraid", + serverName: "Unraid", + detected: 3, + adopted: 0, + verified: 1, + refreshedProfiles: 1, + refreshedProfileIds: ["profile-portfolio"], + linked: 2, + unmatched: 0, + needsReview: 2, + running: 3, + stopped: 0, + capabilities: { + docker: true, + dockerReady: true, + compose: true, + git: false, + tar: true, + checksum: true, + }, + warnings: [], + workloads: [ + { + workloadId: "workload-demo-linked", + displayName: "Portfolio", + status: "linked", + runtime: { running: true, health: "healthy" }, + compose: { + project: "portfolio", + workingDir: "/mnt/user/appdata/portfolio", + configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"], + services: ["web"], + }, + containers: [{ name: "Portfolio", running: true }], + candidates: [], + link: { + profileId: "profile-portfolio", + repositoryFullName: "jens/portfolio", + source: "manual", + }, + }, + { + workloadId: "workload-demo-review", + displayName: "OmniRoute", + status: "suggested", + runtime: { running: true, health: "unverified" }, + compose: { + project: "omniroute", + workingDir: "/mnt/user/appdata/OmniRoute", + configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"], + services: ["omniroute"], + }, + containers: [{ name: "omniroute", running: true }], + remoteFolderCandidate: "OmniRoute", + candidates: repositories.slice(0, 1).map((repository) => ({ + repositoryFullName: repository.fullName, + repositoryName: repository.name, + score: 55, + exact: false, + reasons: ["container and repository names are similar"], + })), + }, + { + workloadId: "workload-demo-unresolved", + displayName: "Legacy Worker", + status: "linked", + runtime: { running: true, health: "healthy" }, + containers: [{ name: "legacy-worker", running: true }], + candidates: [], + link: { + profileId: "profile-that-no-longer-exists", + repositoryFullName: "jens/removed-repository", + source: "manual", + }, + }, + ], + }, + ]; + }, + async planServerReconciliation(serverId) { + await wait(90); + const id = "a".repeat(64); + return { + inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId), + plan: { + id, + serverId, + summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 }, + additions: [], + updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }], + stale: [], + conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }], + }, + }; + }, + async applyServerReconciliation(serverId, planId) { + await wait(120); + if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale."); + return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) }; + }, + async planInventoryReview(serverId, workloadId, action, reason = "", repositoryFullName = null) { + if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && reason.length < 5) throw new Error("A meaningful review reason is required."); + return { id: "b".repeat(64), serverId, workloadId, action, reason, repositoryFullName, evidenceHash: "c".repeat(64), classification: "ambiguous", containersUnaffected: true, configurationChanges: [`Persist review decision ${action}`], recovery: "Remove the decision or rescan after evidence changes." }; + }, + async applyInventoryReview(serverId, workloadId, action, reason, repositoryFullName, planId) { + if (planId !== "b".repeat(64)) throw new Error("The inventory review plan is stale."); + const inventory = (await this.discoverServerDeployments()).find((item) => item.serverId === serverId); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (workload) workload.reviewDecision = { action, reason, repositoryFullName, evidenceHash: "c".repeat(64) }; + return { decision: workload?.reviewDecision, inventory, state: clone(state) }; + }, + async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") { + await wait(120); + const repo = repositories.find((item) => item.fullName === repository.fullName); + if (!repo) throw new Error("Repository not found."); + const id = `profile-${workloadId}`; + const saved = { + id, + name: `Unraid · ${remoteFolder || repo.name}`, + environment: "production", + provider: "ssh-unraid", + branch: repo.defaultBranch || "main", + serverId, + remoteFolder: remoteFolder || repo.name, + deploymentMode, + composeFile: "docker-compose.yml", + composeFiles: ["docker-compose.yml"], + composeProject: String(remoteFolder || repo.name).toLowerCase(), + composeService: String(remoteFolder || repo.name).toLowerCase(), + composeServices: [String(remoteFolder || repo.name).toLowerCase()], + containerName: remoteFolder || repo.name, + preservePaths: [".env", "appdata", "data", "logs", "config"], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() }, + confirmationRequired: true, + state: { + liveSha: null, + healthy: null, + containerRunning: true, + runtimeVerification: "running-unverified", + checkedAt: iso(), + }, + }; + repo.deploymentProfiles = [ + ...repo.deploymentProfiles.filter((item) => item.id !== id), + saved, + ]; + syncState(); + return { profile: clone(saved), state: clone(state) }; + }, + async configureServerGitAccess(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); + const target = repo?.deploymentProfiles.find((item) => item.id === profileId); + if (!target) throw new Error("Deployment profile not found."); + target.deploymentMode = "server-git"; + target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() }; + syncState(); + return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head }; + }, + async verifyServerGitProfile(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); + const target = repo?.deploymentProfiles.find((item) => item.id === profileId); + if (!target) throw new Error("Deployment profile not found."); + const branchSha = target.state?.giteaSha || repo.localStatus?.head || null; + const liveSha = target.state?.liveSha || null; + return { + readiness: branchSha && liveSha === branchSha ? "Ready" : "Commit mismatch", + ready: true, + checkedAt: iso(), + repository: repo.fullName, + profileId, + branchSha, + liveSha, + checks: [ + { id: "remote-branch", label: "Gitea branch", status: "pass", detail: "Exact branch resolved." }, + { id: "deploy-key-scope", label: "Repository deploy key", status: "pass", detail: "Repository-scoped and read-only." }, + { id: "server-git-access", label: "Unraid to Gitea", status: "pass", detail: "Pinned SSH access verified." }, + ], + }; + }, + async deployKeyInventory(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); + const profile = repo?.deploymentProfiles.find((item) => item.id === profileId); + return { repository: repo.fullName, profileId, server: { id: profile.serverId, name: "Unraid" }, configuredKey: { id: profile.serverGitAccess?.deployKeyId || 17, readOnly: true }, serverKey: { privateKeyPresent: true, fingerprint: profile.serverGitAccess?.keyFingerprint || "SHA256:demo" }, stale: false, orphaned: [], shared: [], conflicts: [], ready: true, checkedAt: iso() }; + }, + async planDeployKeyRotation(repository, profileId) { + const evidence = await this.deployKeyInventory(repository, profileId); + return { id: `rotation-${profileId}`, operation: "rotate-deploy-key", impact: ["Generate a new server-side key", "Verify read-only access", "Switch atomically", "Revoke the previous key"], recovery: "Previous access remains recoverable until verification succeeds.", evidence }; + }, + async applyDeployKeyRotation(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); + profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 18, keyFingerprint: "SHA256:rotated", rotatedAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state) }; + }, + async planDeployKeyRevocation(repository, profileId) { + const evidence = await this.deployKeyInventory(repository, profileId); + return { id: `revocation-${profileId}`, operation: "revoke-deploy-key", impact: ["Remove the repository key", "Disable server pull", "Preserve recovery material"], containersUnaffected: true, evidence }; + }, + async applyDeployKeyRevocation(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); + profile.deploymentMode = "monitor-only"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: false, revokedAt: iso(), recoveryAvailable: true }; syncState(); return { profile: clone(profile), state: clone(state) }; + }, + async restoreDeployKey(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); + profile.deploymentMode = "server-git"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 19, keyFingerprint: "SHA256:restored", restoredAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state), proof: { ready: true } }; + }, + async refreshOperations(operationId = null) { + await wait(300); + if (operationId) { + const operation = state.operations.find( + (item) => item.id === operationId, + ); + if (!operation) throw new Error("Operation not found."); + return updateOperation(advanceOperation(operation)); + } + const active = state.operations + .filter( + (item) => + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ) + .map(advanceOperation); + if (active.length) emitOperations(active); + state.operations = state.operations.map( + (item) => active.find((entry) => entry.id === item.id) || item, + ); + return clone(active); + }, + async getOperation(operationId) { + return clone( + state.operations.find((item) => item.id === operationId) || null, + ); + }, + async gitValidatorScan(fullName) { + await wait(260); + const policy = state.gitValidatorPolicy || { id: "standard", label: "Standard", requiredScore: 70 }; + const activeWarnings = 3; + return { + repository: fullName, + checkedAt: iso(), + score: 78, + grade: "Good", + policy, + ready: 78 >= policy.requiredScore && (policy.id === "minimal" || activeWarnings === 0), + commitSha: "8cbaf303aa3bb9b4023a7c89aa13fb70ce612847", + trend: { newlyFound: ["working-tree"], resolved: ["editorconfig"], regressions: [], suppressions: [] }, + expiredSuppressions: [], + summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 }, + checks: [ + { + id: "origin", + category: "Repository identity", + title: "Origin matches Gitea", + status: "pass", + detail: "The local origin resolves to this Gitea repository.", + weight: 15, + }, + { + id: "default-branch-protection", + category: "Gitea governance", + title: "Default branch protection", + status: "warning", + detail: "main accepts unprotected direct changes.", + weight: 18, + fixAction: "protect-default-branch", + safe: false, + confirmation: + "Protect main on Gitea and block direct and force pushes?", + }, + { + id: "force-push", + category: "Gitea governance", + title: "Force-push protection", + status: "pass", + detail: "Force pushes are blocked.", + weight: 8, + }, + { + id: "upstream", + category: "Branch hygiene", + title: "Current branch has an upstream", + status: "pass", + detail: "main tracks origin/main.", + weight: 8, + }, + { + id: "working-tree", + category: "Branch hygiene", + title: "Working tree is intentional", + status: "warning", + detail: "3 changed files require review, commit or stash.", + weight: 5, + }, + { + id: "identity", + category: "Commit integrity", + title: "Repository author identity", + status: "pass", + detail: "Jens ", + weight: 7, + }, + { + id: "local-safety", + category: "Local configuration", + title: "Safe synchronization defaults", + status: "warning", + detail: "Recommended repository-local safeguards are incomplete.", + weight: 10, + fixAction: "configure-local-safety", + safe: true, + }, + { + id: "readme", + category: "Repository documentation", + title: "README is versioned", + status: "pass", + detail: "Repository documentation is tracked.", + weight: 7, + }, + { + id: "gitignore", + category: "Repository hygiene", + title: ".gitignore is versioned", + status: "pass", + detail: "Generated files are excluded centrally.", + weight: 8, + }, + { + id: "tracked-secrets", + category: "Security", + title: "No secret-shaped files are tracked", + status: "pass", + detail: + "No tracked environment, key or credential filenames detected.", + weight: 22, + }, + { + id: "large-files", + category: "Repository performance", + title: "No oversized tracked files", + status: "pass", + detail: "No tracked files above 10 MB were found.", + weight: 7, + }, + ], + }; + }, + async gitValidatorSetPolicy(_fullName, policy) { + const requiredScores = { minimal: 55, standard: 70, strict: 82, production: 90 }; + state.gitValidatorPolicy = { + id: policy.id, + label: policy.id[0].toUpperCase() + policy.id.slice(1), + requiredScore: requiredScores[policy.id] || 70, + }; + return clone(state.gitValidatorPolicy); + }, + async gitValidatorSuppress(_fullName, suppression) { + return { ...suppression, id: `suppression-${Date.now()}`, createdAt: iso() }; + }, + async gitValidatorPreviewRepair(_fullName, check) { + return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ reviewed configuration change\n", remoteMutation: check.fixAction === "protect-default-branch" }; + }, + async gitValidatorExport(fullName, format) { + return { extension: format === "markdown" ? "md" : format, mimeType: "text/plain", content: `# Git assurance — ${fullName}\n` }; + }, + async gitValidatorRepair() { + await wait(180); + return { repaired: true }; + }, + async diagnosticsStatus() { + return { + enabled: state.preferences.diagnosticsEnabled !== false, + level: state.preferences.diagnosticLevel, + retentionDays: state.preferences.logRetentionDays, + maxFileMb: state.preferences.maxLogFileMb, + directory: "/AppData/Roaming/ForgeFlow/diagnostics", + fileCount: 2, + totalBytes: 18432, + totalSize: "18.0 KB", + latestAt: iso(-2000), + lastWriteError: null, + }; + }, + async exportConfigurationBackup() { + return { + filePath: "C:\\Downloads\\ForgeFlow-Configuration-demo.ffbackup", + }; + }, + async importConfigurationBackup() { + return { state: clone(state), exportedAt: iso(-86400000) }; + }, + async listAuditEvents() { + return [ + { + id: "audit-1", + timestamp: iso(-60000), + event: "deployment.completed", + details: { repository: "Jens/ForgeFlow", result: "success" }, + }, + ]; + }, + async exportAuditLog() { + return { filePath: "C:\\Downloads\\ForgeFlow-Audit-demo.json", count: 1 }; + }, + async clearDiagnostics() { + return { + enabled: true, + level: state.preferences.diagnosticLevel, + retentionDays: state.preferences.logRetentionDays, + maxFileMb: state.preferences.maxLogFileMb, + directory: "/AppData/Roaming/ForgeFlow/diagnostics", + fileCount: 1, + totalBytes: 256, + totalSize: "256 B", + latestAt: iso(), + lastWriteError: null, + }; + }, + async openDiagnosticsFolder() { + return true; + }, + async exportDiagnostics(privacyMode = "standard") { + await wait(500); + return { + path: `C:\Users\your-name\Downloads\ForgeFlow-Diagnostics-demo.zip`, + bytes: 38221, + size: "37.3 KB", + sha256: "b".repeat(64), + privacyMode, + generatedAt: iso(), + }; + }, + async showDiagnosticBundle() { + return true; + }, + async reportRendererEvent() { + return true; + }, + onRepositoriesChanged(listener) { + repositoryListeners.add(listener); + return () => repositoryListeners.delete(listener); + }, + onOperationsChanged(listener) { + operationListeners.add(listener); + return () => operationListeners.delete(listener); + }, + onUpdatesChanged(listener) { + updateListeners.add(listener); + return () => updateListeners.delete(listener); + }, + async reset() { + state.setupComplete = false; + storage.set("forgeflow-demo-setup", "false"); + return clone(state); + }, + }; +} diff --git a/src/renderer/mock-repository-bridge.js b/src/renderer/mock-repository-bridge.js new file mode 100644 index 0000000..dc9c81d --- /dev/null +++ b/src/renderer/mock-repository-bridge.js @@ -0,0 +1,779 @@ +function createMockRepositoryBridge(context) { + const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context; + return { + async bootstrap() { + await wait(80); + snapshot(); + return { + appVersion: "0.10.15-demo", + platform: "win32", + state: clone(state), + git: { available: true, version: "git version 2.47.3" }, + diagnostics: { + enabled: true, + level: state.preferences.diagnosticLevel, + retentionDays: state.preferences.logRetentionDays, + maxFileMb: state.preferences.maxLogFileMb, + directory: "/AppData/Roaming/ForgeFlow/diagnostics", + fileCount: 2, + totalBytes: 18432, + totalSize: "18.0 KB", + latestAt: iso(-2000), + lastWriteError: null, + }, + }; + }, + async selectDirectory() { + await wait(); + return "C:\\Development"; + }, + async selectKeyFile() { + await wait(); + return "C:\\Users\\your-name\\.ssh\\id_ed25519"; + }, + async setupPreflight({ baseUrl, token, roots = [] }) { + await wait(240); + const checks = [ + { + id: "git.available", + label: "Git command line", + status: "pass", + detail: "git version 2.47.3", + required: true, + }, + { + id: "git.identity", + label: "Git author identity", + status: "pass", + detail: "Jens ", + required: false, + }, + { + id: "storage.userdata", + label: "Application data storage", + status: "pass", + detail: "ForgeFlow can write its local configuration.", + required: true, + }, + { + id: "storage.diagnostics", + label: "Diagnostic log storage", + status: "pass", + detail: "The diagnostic directory is writable.", + required: true, + }, + { + id: "storage.credentials", + label: "Protected credential storage", + status: "pass", + detail: "The operating system can encrypt the Gitea token at rest.", + required: false, + }, + { + id: "workspace.roots", + label: "Development folders", + status: roots.length ? "pass" : "warning", + detail: roots.length + ? `${roots.length} folder(s) selected.` + : "No development folder selected yet.", + required: false, + }, + { + id: "gitea.connection", + label: "Gitea connection", + status: baseUrl && token ? "pass" : "warning", + detail: + baseUrl && token + ? "Connection parameters are ready for validation." + : "Enter the Gitea URL and token.", + required: false, + }, + ]; + return { + kind: "system", + startedAt: iso(-100), + completedAt: iso(), + checks, + summary: { + counts: { + pass: checks.filter((i) => i.status === "pass").length, + warning: checks.filter((i) => i.status === "warning").length, + fail: 0, + skipped: 0, + }, + blocking: [], + ready: true, + }, + }; + }, + async validateGitea({ baseUrl, token }) { + await wait(320); + if (!baseUrl || !token) + throw new Error("Enter an instance URL and access token."); + return { + baseUrl: baseUrl.replace(/\/$/, ""), + user: { login: "jens", full_name: "Jens" }, + repositoryCount: repositories.length, + version: "1.26.0", + }; + }, + async completeSetup(payload) { + await wait(300); + state.setupComplete = true; + state.gitea = { + baseUrl: payload.baseUrl, + user: payload.user, + hasToken: true, + }; + state.workspaceRoots = payload.workspaceRoots; + storage.set("forgeflow-demo-setup", "true"); + return { state: clone(state), tokenState: { persistent: true } }; + }, + async updateGitea(payload) { + const validation = await this.validateGitea({ + ...payload, + token: payload.token || "preserved-demo-token", + }); + state.gitea = { + baseUrl: validation.baseUrl, + user: validation.user, + hasToken: true, + }; + return { + validation, + tokenState: { persistent: true, preserved: !payload.token }, + state: clone(state), + }; + }, + async setWorkspaceRoots(roots) { + state.workspaceRoots = [...new Set(roots)]; + return clone(state); + }, + async setAppearance(appearance) { + state.appearance = appearance; + storage.set("forgeflow-theme", appearance); + return clone(state); + }, + async setPreferences(preferences) { + state.preferences = { ...state.preferences, ...preferences }; + snapshot(); + return clone(state); + }, + async setUpdatePreferences(updates) { + state.updates = { ...state.updates, ...updates }; + return clone(state); + }, + async checkForUpdates() { + await wait(300); + return { + checkedAt: iso(), + owner: state.updates.owner, + repo: state.updates.repo, + branch: state.updates.branch, + currentVersion: "0.5.4", + remoteVersion: "0.6.0", + remoteSha: "a".repeat(40), + shortSha: "aaaaaaa", + available: true, + mode: "source", + }; + }, + async downloadUpdate() { + await wait(500); + return { + ...(await this.checkForUpdates()), + downloaded: true, + archivePath: "C:\\Temp\\ForgeFlow-0.4.1.zip", + sha256: "b".repeat(64), + }; + }, + async applyUpdate() { + await wait(200); + return { launched: true, confirmed: true, version: "0.6.0" }; + }, + async saveServer(server) { + const saved = { + ...server, + id: server.id || `server-${Date.now()}`, + hasPassword: server.authType === "password", + hasPassphrase: false, + }; + state.servers = [ + saved, + ...state.servers.filter((item) => item.id !== saved.id), + ]; + return { server: clone(saved), state: clone(state) }; + }, + async deleteServer(serverId) { + state.servers = state.servers.filter((item) => item.id !== serverId); + return clone(state); + }, + async testServer(serverId) { + const server = state.servers.find((item) => item.id === serverId); + server.hostFingerprint = server.hostFingerprint || "SHA256:demo"; + return { + connected: true, + fingerprint: server.hostFingerprint, + server: clone(server), + output: "Linux\n/usr/bin/git\nDocker Compose version v2", + state: clone(state), + }; + }, + async inspectServerProject() { + return { + exists: true, + rootGit: true, + head: "d42d4a7".padEnd(40, "0"), + branch: "main", + trackedChanges: [], + composeFiles: ["docker-compose.yml"], + nestedGit: ["source"], + dockerfile: true, + }; + }, + async refreshRepositories() { + await wait(260); + return snapshot(); + }, + async discoverRepositories() { + await wait(360); + return snapshot() + .filter((repo) => repo.localPath) + .map((repo) => ({ + localPath: repo.localPath, + remoteUrl: repo.cloneUrl, + status: repo.localStatus, + })); + }, + async favoriteRepository(fullName, favorite) { + const key = fullName.toLowerCase(); + state.favorites = favorite + ? [...new Set([...state.favorites, key])] + : state.favorites.filter((item) => item !== key); + snapshot(); + return clone(state); + }, + async linkRepository(fullName, localPath) { + const repo = repositories.find((item) => item.fullName === fullName); + repo.localPath = localPath; + repo.linkState = "linked"; + repo.localStatus = makeStatus({ head: randomSha() }); + emitRepositories(); + return snapshot(); + }, + async unlinkRepository(fullName) { + const repo = repositories.find((item) => item.fullName === fullName); + repo.localPath = null; + repo.localStatus = null; + repo.linkState = "remote-only"; + emitRepositories(); + return snapshot(); + }, + async repositoryStatus(localPath) { + return clone(findRepo(localPath)?.localStatus); + }, + async repositoryDiff(localPath, filePath) { + await wait(80); + return ( + diffs[filePath] || + `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new` + ); + }, + async repositoryDiffHunks(localPath, filePath) { + const diff = await this.repositoryDiff(localPath, filePath); + return { + filePath, + partialSupported: true, + hunks: [ + { + index: 0, + heading: "@@ -1 +1 @@", + additions: 1, + deletions: 1, + lines: diff.split("\n").slice(-4), + }, + ], + }; + }, + async stageHunks(localPath, filePath) { + return this.stageFiles(localPath, [filePath]); + }, + async conflictState(localPath) { + const repo = findRepo(localPath); + const files = repo.localStatus.files + .filter((item) => item.conflict) + .map((item) => item.path); + return { + operation: files.length ? "merge" : null, + files, + canContinue: false, + status: clone(repo.localStatus), + }; + }, + async resolveConflict(localPath, filePath) { + const repo = findRepo(localPath); + const file = repo.localStatus.files.find( + (item) => item.path === filePath, + ); + if (file) { + file.conflict = false; + file.staged = true; + file.unstaged = false; + } + recompute(repo); + return this.conflictState(localPath); + }, + async continueGitOperation(localPath) { + return this.conflictState(localPath); + }, + async abortGitOperation(localPath) { + return this.conflictState(localPath); + }, + async stageFiles(localPath, files) { + const repo = findRepo(localPath); + repo.localStatus.files.forEach((item) => { + if (!files?.length || files.includes(item.path)) { + item.staged = true; + item.unstaged = false; + item.indexCode = item.untracked ? "A" : "M"; + item.worktreeCode = "."; + } + }); + recompute(repo); + emitRepositories(); + return clone(repo.localStatus); + }, + async unstageFiles(localPath, files) { + const repo = findRepo(localPath); + repo.localStatus.files.forEach((item) => { + if (!files?.length || files.includes(item.path)) { + item.staged = false; + item.unstaged = true; + item.indexCode = "."; + item.worktreeCode = item.untracked ? "?" : "M"; + } + }); + recompute(repo); + emitRepositories(); + return clone(repo.localStatus); + }, + async commit(localPath, message, files) { + await wait(520); + if (!message?.trim()) throw new Error("Enter a commit message."); + const repo = findRepo(localPath); + repo.localStatus.files = repo.localStatus.files.filter( + (item) => !files?.includes(item.path), + ); + repo.localStatus.head = randomSha(); + repo.localStatus.branch.ahead += 1; + recompute(repo); + emitRepositories(); + return { + commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`, + commitSha: repo.localStatus.head, + status: clone(repo.localStatus), + }; + }, + async commitAndPush(localPath, message, files) { + const result = await this.commit(localPath, message, files); + const repo = findRepo(localPath); + await wait(240); + repo.localStatus.branch.ahead = 0; + recompute(repo); + emitRepositories(); + return { + ...result, + pushOutput: "Push completed.", + status: clone(repo.localStatus), + }; + }, + async commitStaged(localPath, message) { + const repo = findRepo(localPath); + return this.commit( + localPath, + message, + repo.localStatus.files + .filter((item) => item.staged) + .map((item) => item.path), + ); + }, + async commitStagedAndPush(localPath, message) { + const repo = findRepo(localPath); + return this.commitAndPush( + localPath, + message, + repo.localStatus.files + .filter((item) => item.staged) + .map((item) => item.path), + ); + }, + async push(localPath) { + await wait(360); + const repo = findRepo(localPath); + repo.localStatus.branch.ahead = 0; + recompute(repo); + emitRepositories(); + return { output: "Push completed.", status: clone(repo.localStatus) }; + }, + async fetch() { + await wait(260); + return { output: "Fetch completed." }; + }, + async pull(localPath) { + await wait(380); + const repo = findRepo(localPath); + repo.localStatus.branch.behind = 0; + recompute(repo); + emitRepositories(); + return { output: "Fast-forwarded.", status: clone(repo.localStatus) }; + }, + async previewWorkspaceSync(localPath) { + await wait(260); + const repo = findRepo(localPath); + const status = repo.localStatus; + const targetSha = status.branch.behind ? "f".repeat(40) : status.head; + return { + id: `demo-${String(status.head).slice(0, 7)}-${status.branch.ahead}-${status.branch.behind}`.padEnd(64, "0").slice(0, 64), + branch: status.branch.head, + upstream: status.branch.upstream || `origin/${status.branch.head}`, + currentSha: status.head, + targetSha, + needsSync: !status.clean || status.head !== targetSha || status.branch.ahead > 0, + blockers: [], + summary: { + resultingTrackedChanges: status.branch.behind ? 3 : 0, + added: status.branch.behind ? 1 : 0, + modified: status.branch.behind ? 1 : 0, + deleted: status.branch.behind ? 1 : 0, + renamed: 0, + localFilesToStash: status.counts.changed, + untrackedFilesToStash: status.counts.untracked, + localCommitsToProtect: status.branch.ahead, + incomingCommits: status.branch.behind, + }, + changes: status.branch.behind + ? [ + { code: "A", status: "added", path: "src/remote-feature.js" }, + { code: "M", status: "modified", path: "README.md" }, + { code: "D", status: "deleted", path: "docs/obsolete.md" }, + ] + : [], + localFiles: clone(status.files), + incomingCommits: [], + localCommits: [], + recovery: { + safetyBranch: status.branch.ahead > 0, + stash: status.counts.changed > 0, + untrackedCleanup: status.counts.untracked > 0, + ignoredFilesPreserved: true, + }, + }; + }, + async applyWorkspaceSync(localPath, expectedPlanId) { + const plan = await this.previewWorkspaceSync(localPath); + if (plan.id !== expectedPlanId) throw new Error("The workspace sync preview is stale."); + const repo = findRepo(localPath); + const hadChanges = repo.localStatus.counts.changed > 0; + repo.localStatus.head = plan.targetSha; + repo.localStatus.shortHead = plan.targetSha.slice(0, 7); + repo.localStatus.files = []; + repo.localStatus.branch.ahead = 0; + repo.localStatus.branch.behind = 0; + recompute(repo); + emitRepositories(); + return { + applied: plan.needsSync, + unchanged: !plan.needsSync, + plan, + status: clone(repo.localStatus), + backupBranch: plan.summary.localCommitsToProtect ? `forgeflow/recovery-${plan.branch}-demo` : null, + stash: hadChanges ? { ref: "stash@{0}", shortSha: "demo123", subject: "ForgeFlow workspace sync" } : null, + ignoredFilesPreserved: true, + cleaned: [], + }; + }, + async history() { + await wait(100); + return clone(commitHistory); + }, + async branchProtection(fullName, branch) { + return { + branch, + protected: branch === "main", + requiredApprovals: branch === "main" ? 1 : 0, + requireSignedCommits: false, + }; + }, + async pullRequests() { + return [ + { + number: 42, + title: "Harden deployment preflight", + html_url: "https://gitea.internal/jens/vacancyradar/pulls/42", + created_at: iso(-7_200_000), + updated_at: iso(-900_000), + head: { ref: "feature/deployment-api" }, + base: { ref: "main" }, + }, + ]; + }, + async createPullRequest(fullName, title, body, base) { + return { + number: 42, + title, + body, + base, + html_url: `https://gitea.internal/${fullName}/pulls/42`, + }; + }, + async branches(localPath) { + const repo = findRepo(localPath); + if (!branchesByRepo.has(localPath)) + branchesByRepo.set(localPath, [ + { + name: repo.localStatus.branch.head, + current: true, + sha: repo.localStatus.head, + shortSha: repo.localStatus.shortHead, + upstream: repo.localStatus.branch.upstream, + }, + { + name: "main", + current: repo.localStatus.branch.head === "main", + sha: repo.localStatus.head, + shortSha: repo.localStatus.shortHead, + upstream: "origin/main", + }, + ]); + return clone(branchesByRepo.get(localPath)); + }, + async checkoutBranch(localPath, branch) { + const repo = findRepo(localPath); + if (!repo.localStatus.clean) + throw new Error( + "Commit or stash local changes before switching branches.", + ); + const list = await this.branches(localPath); + list.forEach((item) => { + item.current = item.name === branch; + }); + branchesByRepo.set(localPath, list); + repo.localStatus.branch.head = branch; + repo.localStatus.branch.upstream = `origin/${branch}`; + recompute(repo); + emitRepositories(); + return { status: clone(repo.localStatus), branches: clone(list) }; + }, + async createBranch(localPath, branch) { + const repo = findRepo(localPath); + const list = await this.branches(localPath); + list.forEach((item) => { + item.current = false; + }); + list.unshift({ + name: branch, + current: true, + sha: repo.localStatus.head, + shortSha: repo.localStatus.shortHead, + upstream: null, + }); + branchesByRepo.set(localPath, list); + repo.localStatus.branch.head = branch; + repo.localStatus.branch.upstream = null; + recompute(repo); + emitRepositories(); + return { status: clone(repo.localStatus), branches: clone(list) }; + }, + async stash(localPath, message) { + const repo = findRepo(localPath); + const list = stashesByRepo.get(localPath) || []; + list.unshift({ + ref: `stash@{${list.length}}`, + subject: message || "ForgeFlow stash", + date: iso(), + }); + stashesByRepo.set(localPath, list); + repo.localStatus.files = []; + recompute(repo); + emitRepositories(); + return { + output: "Saved working directory and index state.", + status: clone(repo.localStatus), + stashes: clone(list), + }; + }, + async stashList(localPath) { + return clone(stashesByRepo.get(localPath) || []); + }, + async popStash(localPath, ref) { + const repo = findRepo(localPath); + const list = stashesByRepo.get(localPath) || []; + const index = list.findIndex((item) => item.ref === ref); + if (index < 0) throw new Error("Stash not found."); + list.splice(index, 1); + stashesByRepo.set(localPath, list); + repo.localStatus.files = [makeFile("src/restored-from-stash.ts")]; + recompute(repo); + emitRepositories(); + return { + output: "Stash applied.", + status: clone(repo.localStatus), + stashes: clone(list), + }; + }, + async gitRecoveryStatus(localPath) { + const repo = findRepo(localPath); + const status = clone(repo.localStatus); + const upstream = status.branch?.upstream; + const recommendations = [ + { + id: "fetch", + label: "Fetch and recalculate remote state", + action: "fetch", + safe: true, + }, + ]; + if ( + status.clean && + status.branch.behind > 0 && + status.branch.ahead === 0 && + upstream + ) { + recommendations.push({ + id: "pull", + label: `Fast-forward from ${upstream}`, + action: "fast-forward", + safe: true, + }); + } + if ( + status.branch.ahead > 0 && + status.branch.behind === 0 && + upstream + ) { + recommendations.push({ + id: "push", + label: `Push ${status.branch.ahead} local commit(s)`, + action: "push", + safe: true, + }); + } + return { + status, + lockReport: { + root: localPath, + gitDir: `${localPath}\\.git`, + locks: [], + processes: { available: true, active: [] }, + }, + recommendations, + }; + }, + async reconcileRepository(localPath) { + await wait(160); + return this.gitRecoveryStatus(localPath); + }, + async repairGitLocks(localPath) { + return { + ...(await this.gitRecoveryStatus(localPath)).lockReport, + removed: [], + skipped: [], + repaired: false, + }; + }, + async repairRepositorySync(localPath, strategy) { + const repo = findRepo(localPath); + if (strategy === "fast-forward") { + repo.localStatus.branch.behind = 0; + repo.localStatus.head = "f".repeat(40); + } else if (strategy === "push") { + repo.localStatus.branch.ahead = 0; + } else if (strategy !== "fetch") { + throw new Error("Unsupported demo synchronization strategy."); + } + recompute(repo); + emitRepositories(); + return { + strategy, + backupBranch: null, + status: clone(repo.localStatus), + lockReport: (await this.gitRecoveryStatus(localPath)).lockReport, + }; + }, + async indexLockInfo() { + return { exists: false, ageMs: 0 }; + }, + async repairIndexLock() { + return { removed: true }; + }, + async setOrigin(localPath, remoteUrl) { + const repo = findRepo(localPath); + repo.localStatus.remoteUrl = remoteUrl; + repo.sshUrl = remoteUrl; + emitRepositories(); + return clone(repo.localStatus); + }, + async normalizeOrigins() { + const changes = []; + repositories + .filter((repo) => repo.localPath && repo.sshUrl) + .forEach((repo) => { + if (repo.localStatus.remoteUrl !== repo.sshUrl) { + changes.push({ + fullName: repo.fullName, + previous: repo.localStatus.remoteUrl, + next: repo.sshUrl, + }); + repo.localStatus.remoteUrl = repo.sshUrl; + } + }); + emitRepositories(); + return { changes, repositories: snapshot() }; + }, + async cloneRepository(fullName, mode = "default") { + await wait(620); + const repository = repositories.find( + (item) => item.fullName === fullName, + ); + if (!repository) throw new Error("Repository not found."); + if (repository.localPath) + throw new Error("This repository already has a linked local folder."); + const root = + mode === "custom" ? "D:\\OtherProjects" : state.workspaceRoots[0]; + if (!root) return { cancelled: true }; + const target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`; + const head = randomSha(); + repository.localPath = target; + repository.localStatus = makeStatus({ + head, + branch: repository.defaultBranch || "main", + }); + repository.localStatus.root = target; + repository.localStatus.remoteUrl = + repository.preferredCloneUrl || repository.cloneUrl; + repository.linkState = "linked"; + recompute(repository); + const current = snapshot(); + emitRepositories(); + return { + target, + status: clone(repository.localStatus), + reused: false, + repositories: current, + state: clone(state), + }; + }, + async openPath() { + return true; + }, + async openEditor() { + return { launched: true, executable: "code" }; + }, + async openTerminal() { + return { launched: true, executable: "wt.exe" }; + }, + async openExternal() { + return true; + }, + }; +} diff --git a/src/renderer/operations.js b/src/renderer/operations.js new file mode 100644 index 0000000..6538dc6 --- /dev/null +++ b/src/renderer/operations.js @@ -0,0 +1,211 @@ +async function runOperation( + message, + operation, + successMessage, + { refresh = true } = {}, +) { + setLoading(true, message); + try { + const result = await operation(); + if (successMessage) showToast("Done", successMessage, "success"); + if (refresh) await refreshRepositories(false); + return result; + } catch (error) { + const pushAfterCommit = error.code === "PUSH_AFTER_COMMIT_FAILED"; + showToast( + pushAfterCommit + ? "Commit saved locally; push failed" + : "Operation failed", + error.message, + "error", + ); + // Always reload the real Git state. A failed stage must keep changes visible, while a + // failed push after a successful commit must immediately surface as an ahead branch. + await refreshRepositories(false, true); + if (pushAfterCommit) { + ui.selectedFiles.clear(); + ui.selectedFile = null; + ui.diff = ""; + ui.commitMessage = ""; + render(); + } + return null; + } finally { + setLoading(false); + } +} + +async function executeDeployment(profileId) { + const repository = selectedRepository(); + const profile = + repository?.deploymentProfiles?.find((item) => item.id === profileId) || + selectedProfile(repository); + if (!repository || !profile) return; + const targetSha = deploymentTargetSha(repository, profile); + if (!targetSha) { + showToast("Refresh required", "Refresh Gitea and server truth before deploying this environment.", "error"); + return; + } + const deploymentOptions = { + note: document.querySelector("#deployment-note")?.value.trim() || "", + override: document.querySelector("#deployment-override")?.checked === true, + overrideReason: + document.querySelector("#deployment-override-reason")?.value.trim() || "", + }; + ui.modal = null; + setLoading( + true, + profile.provider === "ssh-unraid" + ? `Deploying ${repository.name} to ${profile.remoteFolder} over SSH…` + : `Dispatching ${profile.name} workflow…`, + ); + try { + ui.activeDeployment = await window.forgeflow.deploy( + repository, + profile.id, + targetSha, + deploymentOptions, + ); + updateOperationInState(ui.activeDeployment); + ui.currentView = "deployment-run"; + showToast( + "Deployment started", + `${repository.name} ${shortSha(targetSha)} → ${profile.environment}`, + "success", + ); + startOperationPolling(); + } catch (error) { + if (profile.provider === "ssh-unraid" && isSshCredentialError(error)) { + ui.modal = { + type: "server-password", + serverId: profile.serverId, + retry: { + type: "deploy", + repositoryFullName: repository.fullName, + profileId: profile.id, + }, + }; + showToast("SSH key rejected", "Enter the Unraid server password once; ForgeFlow will retry the direct desktop → Unraid connection.", "error"); + render(); + } else { + showToast("Deployment failed to start", error.message, "error"); + } + } + setLoading(false); +} + +async function executeRollback(profileId) { + const repository = selectedRepository(); + const profile = repository?.deploymentProfiles?.find( + (item) => item.id === profileId, + ); + const target = profile?.state?.previousSha; + if (!repository || !profile || !target) return; + ui.modal = null; + setLoading( + true, + profile?.provider === "ssh-unraid" + ? `Rolling back ${profile.remoteFolder} over SSH…` + : `Dispatching rollback to ${shortSha(target)}…`, + ); + try { + ui.activeDeployment = await window.forgeflow.rollback( + repository, + profile.id, + target, + ); + updateOperationInState(ui.activeDeployment); + ui.currentView = "deployment-run"; + showToast( + "Rollback requested", + `${profile.environment} → ${shortSha(target)}`, + "success", + ); + } catch (error) { + showToast("Rollback failed to start", error.message, "error"); + } + setLoading(false); +} + +async function loadGitTools(repository) { + if (!repository?.localPath) return; + setLoading(true, "Loading branches and stashes…"); + try { + [ui.branches, ui.stashes, ui.gitRecovery] = await Promise.all([ + window.forgeflow.branches(repository.localPath), + window.forgeflow.stashList(repository.localPath), + window.forgeflow.gitRecoveryStatus(repository.localPath), + ]); + ui.repositoryTab = "gittools"; + } catch (error) { + showToast("Git tools unavailable", error.message, "error"); + } + setLoading(false); +} + +function profileRepository(profileId) { + return ui.repositories.find((repository) => + repository.deploymentProfiles?.some((profile) => profile.id === profileId), + ); +} + +async function runSystemPreflight({ setup = false } = {}) { + setLoading(true, "Checking local readiness…"); + try { + ui.systemPreflight = await window.forgeflow.setupPreflight({ + baseUrl: ui.setupDraft.baseUrl, + token: ui.setupDraft.token, + roots: setup ? ui.setupDraft.roots : ui.boot.state.workspaceRoots, + }); + if (!setup) + ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); + showToast( + ui.systemPreflight.summary.ready + ? "Readiness checks passed" + : "Readiness needs attention", + ui.systemPreflight.summary.ready + ? `${ui.systemPreflight.summary.counts.pass} checks passed.` + : `${ui.systemPreflight.summary.blocking.length} blocking check(s) must be resolved.`, + ui.systemPreflight.summary.ready ? "success" : "error", + ); + return ui.systemPreflight; + } catch (error) { + showToast("Readiness check failed", error.message, "error"); + return null; + } finally { + setLoading(false); + } +} + +async function runDeploymentPreflight( + repository, + profileId, + { showModal = true } = {}, +) { + if (!repository || !profileId) return null; + if (String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); + ui.selectedProfileId = profileId; + ui.deploymentPreflight = null; + setLoading(true, "Verifying repository, workflow and server…"); + try { + const report = await window.forgeflow.deploymentPreflight( + repository, + profileId, + ); + ui.deploymentPreflight = report; + if (showModal) + ui.modal = { + type: "deployment-preflight", + profileId, + repositoryFullName: repository.fullName, + }; + return report; + } catch (error) { + showToast("Deployment preflight failed", error.message, "error"); + return null; + } finally { + setLoading(false); + } +} + diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..ec00243 --- /dev/null +++ b/src/renderer/styles.css @@ -0,0 +1,4336 @@ +:root { + color-scheme: dark; + --bg: #0b0e14; + --surface-0: #0f131b; + --surface-1: #151a24; + --surface-2: #1b2130; + --surface-3: #252c3a; + --surface-hover: #202838; + --line: #30394a; + --line-soft: #222a38; + --text: #e7ebf4; + --text-muted: #9aa4b6; + --text-faint: #6f7a8d; + --primary: #8fb4ff; + --primary-strong: #5b8ff9; + --primary-soft: rgba(91, 143, 249, 0.15); + --success: #54ddb0; + --success-soft: rgba(84, 221, 176, 0.12); + --warning: #f2ba63; + --warning-soft: rgba(242, 186, 99, 0.13); + --danger: #ff817a; + --danger-soft: rgba(255, 129, 122, 0.13); + --shadow: 0 18px 70px rgba(0, 0, 0, 0.32); + --radius: 7px; + --sidebar: 286px; + --action-panel: 352px; + --font-ui: + Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: + "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +html[data-theme="light"] { + color-scheme: light; + --bg: #e9eef7; + --surface-0: #f2f6fc; + --surface-1: #ffffff; + --surface-2: #f3f6fa; + --surface-3: #e8edf4; + --surface-hover: #edf2f8; + --line: #c5d0df; + --line-soft: #dce4ef; + --text: #172033; + --text-muted: #526078; + --text-faint: #7b879a; + --primary: #2857bf; + --primary-strong: #346ee8; + --primary-soft: rgba(52, 110, 232, 0.13); + --success: #087a57; + --success-soft: rgba(8, 122, 87, 0.1); + --warning: #9b5b00; + --warning-soft: rgba(155, 91, 0, 0.1); + --danger: #c73737; + --danger-soft: rgba(199, 55, 55, 0.1); + --shadow: 0 18px 54px rgba(31, 55, 94, 0.14); +} + +html[data-theme="light"] body, +html[data-theme="light"] .app-shell { + background: + radial-gradient( + circle at 82% 2%, + rgba(67, 123, 235, 0.13), + transparent 31% + ), + radial-gradient( + circle at 20% 100%, + rgba(31, 170, 141, 0.08), + transparent 33% + ), + var(--bg); +} +html[data-theme="light"] .titlebar, +html[data-theme="light"] .sidebar { + background: rgba(250, 252, 255, 0.92); + backdrop-filter: blur(18px); +} +html[data-theme="light"] .panel, +html[data-theme="light"] .summary-card, +html[data-theme="light"] .deploy-card, +html[data-theme="light"] .settings-group, +html[data-theme="light"] .pipeline-card { + border-color: rgba(151, 169, 197, 0.48); + box-shadow: 0 8px 28px rgba(49, 75, 116, 0.08); +} +html[data-theme="light"] .nav-button.active { + background: linear-gradient( + 105deg, + rgba(52, 110, 232, 0.16), + rgba(48, 181, 151, 0.08) + ); + box-shadow: inset 3px 0 var(--primary-strong); +} +html[data-theme="light"] .button.primary { + box-shadow: 0 7px 18px rgba(52, 110, 232, 0.22); +} + +* { + box-sizing: border-box; +} +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 13px; +} +button, +input, +textarea, +select { + font: inherit; + color: inherit; +} +button { + border: 0; +} +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; +} +::selection { + background: rgba(91, 143, 249, 0.35); +} +::-webkit-scrollbar { + width: 9px; + height: 9px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text-faint) 38%, transparent); + border: 3px solid transparent; + background-clip: padding-box; + border-radius: 20px; +} + +.boot-screen { + height: 100vh; + display: grid; + place-content: center; + justify-items: center; + gap: 10px; + color: var(--text-muted); +} +.boot-screen strong { + color: var(--text); + font-size: 16px; +} +.boot-brand-logo { + width: 72px; + height: 56px; + object-fit: contain; + filter: drop-shadow(0 10px 28px rgba(0, 174, 255, 0.24)); +} +.brand-mark { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 10px; + background: linear-gradient(145deg, var(--primary), var(--primary-strong)); + color: #07152e; + font-weight: 800; + font-size: 20px; + box-shadow: 0 8px 30px rgba(91, 143, 249, 0.25); +} + +.app-shell { + height: 100vh; + display: grid; + grid-template-rows: 48px minmax(0, 1fr) 25px; + background: var(--bg); +} +.titlebar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px 0 14px; + border-bottom: 1px solid var(--line); + background: var(--surface-1); + -webkit-app-region: drag; +} +.titlebar-left, +.titlebar-right { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} +.titlebar button, +.titlebar input { + -webkit-app-region: no-drag; +} +.wordmark { + display: flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 720; + letter-spacing: -0.02em; +} +.wordmark .brand-mark { + width: 25px; + height: 25px; + border-radius: 6px; + font-size: 13px; + box-shadow: none; +} +.brand-logo { + width: 32px; + height: 25px; + object-fit: contain; + display: block; +} +.wordmark small { + color: var(--text-faint); + font-size: 9px; + font-weight: 620; + letter-spacing: 0.01em; + margin-left: -4px; +} +.workspace-name { + color: var(--text-muted); + border-left: 1px solid var(--line); + padding-left: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; +} +.connection-chip { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 11px; + font-weight: 650; + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-0); +} +.connection-chip .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 3px var(--success-soft); +} +.global-search { + width: clamp(180px, 23vw, 330px); + height: 30px; + padding: 0 10px 0 31px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); + color: var(--text); +} +.search-wrap { + position: relative; +} +.search-wrap .icon { + position: absolute; + left: 9px; + top: 7px; + color: var(--text-faint); + pointer-events: none; +} + +.app-body { + display: grid; + grid-template-columns: var(--sidebar) minmax(0, 1fr); + min-height: 0; +} +.sidebar { + min-width: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + background: var(--surface-1); + overflow: hidden; +} +.primary-nav { + padding: 10px 8px 8px; + border-bottom: 1px solid var(--line-soft); +} +.nav-button { + width: 100%; + height: 34px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + border-radius: 5px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + text-align: left; + font-weight: 560; +} +.nav-button:hover { + background: var(--surface-hover); + color: var(--text); +} +.nav-button.active { + color: var(--primary); + background: var(--primary-soft); +} +.nav-button .nav-count { + margin-left: auto; + min-width: 20px; + text-align: center; + font-family: var(--font-mono); + color: var(--text-faint); + font-size: 10px; +} +.sidebar-section { + display: flex; + align-items: center; + justify-content: space-between; + padding: 13px 12px 7px; + color: var(--text-faint); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.sidebar-section button { + background: none; + color: inherit; + cursor: pointer; + padding: 2px; +} +.repo-filter { + margin: 0 9px 8px; + width: calc(100% - 18px); + height: 29px; + border: 1px solid var(--line-soft); + border-radius: 5px; + background: var(--surface-0); + padding: 0 9px; +} +.repo-list { + min-height: 0; + overflow: auto; + padding: 0 6px 10px; +} +.repo-row { + content-visibility: auto; + contain-intrinsic-size: auto 48px; + width: 100%; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-height: 44px; + padding: 6px 8px; + background: transparent; + border-radius: 5px; + color: var(--text-muted); + cursor: pointer; + text-align: left; + border: 1px solid transparent; +} +.repo-row:hover { + background: var(--surface-hover); + color: var(--text); +} +.repo-row.active { + background: var(--primary-soft); + border-color: color-mix(in srgb, var(--primary) 26%, transparent); + color: var(--text); +} +.repo-row.attention .repo-icon { + color: var(--warning); +} +.repo-main { + min-width: 0; +} +.repo-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 620; +} +.repo-sub { + display: flex; + gap: 6px; + margin-top: 3px; + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 10px; + overflow: hidden; +} +.repo-sub span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.repo-badges { + display: flex; + gap: 3px; + align-items: center; +} +.mini-badge { + min-width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + padding: 0 5px; + border-radius: 9px; + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + color: var(--text-muted); + background: var(--surface-3); +} +.mini-badge.warning { + background: var(--warning-soft); + color: var(--warning); +} +.mini-badge.success { + background: var(--success-soft); + color: var(--success); +} +.mini-badge.danger { + background: var(--danger-soft); + color: var(--danger); +} +.sidebar-footer { + margin-top: auto; + border-top: 1px solid var(--line-soft); + padding: 10px 11px; +} +.sidebar-diagnostic-state { + display: grid; + grid-template-columns: 9px minmax(0, 1fr); + gap: 9px; + align-items: start; + color: var(--text-muted); +} +.sidebar-diagnostic-state .state-dot { + margin-top: 4px; +} +.sidebar-diagnostic-state strong, +.sidebar-diagnostic-state span { + display: block; +} +.sidebar-diagnostic-state strong { + color: var(--text); + font-size: 10px; + font-weight: 650; +} +.sidebar-diagnostic-state span { + margin-top: 2px; + color: var(--text-faint); + font-size: 9px; + line-height: 1.35; +} + +.workspace { + min-width: 0; + min-height: 0; + display: grid; + background: var(--surface-0); +} +.workspace.with-panel { + grid-template-columns: minmax(0, 1fr) var(--action-panel); +} +.main-canvas { + min-width: 0; + min-height: 0; + overflow: auto; +} +.main-canvas.repository-canvas { + overflow: hidden; + height: 100%; +} +.action-panel { + min-width: 0; + border-left: 1px solid var(--line); + background: var(--surface-1); + overflow: auto; +} +.page { + min-height: 100%; + padding: 22px 24px 40px; +} +.page.nopad { + padding: 0; +} +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + margin-bottom: 22px; +} +.page-header h1, +.repo-heading h1 { + margin: 0; + font-size: 20px; + line-height: 1.3; + letter-spacing: -0.025em; +} +.page-header p, +.repo-heading p { + margin: 5px 0 0; + color: var(--text-muted); + max-width: 720px; +} +.eyebrow { + color: var(--text-faint); + font-size: 10px; + font-weight: 760; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.button { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-2); + color: var(--text); + cursor: pointer; + font-weight: 620; + white-space: nowrap; +} +.button:hover { + background: var(--surface-3); +} +.button.primary { + background: var(--primary-strong); + border-color: var(--primary-strong); + color: #fff; +} +.button.primary:hover { + filter: brightness(1.07); +} +.button.success { + background: var(--success); + border-color: var(--success); + color: #06251b; +} +.button.danger { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); + background: var(--danger-soft); +} +.button.ghost { + background: transparent; + border-color: transparent; + color: var(--text-muted); +} +.button.ghost:hover { + color: var(--text); + background: var(--surface-hover); +} +.button.block { + width: 100%; + min-height: 38px; +} +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.icon-button { + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + border-radius: 5px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.icon-button:hover { + color: var(--text); + background: var(--surface-hover); + border-color: var(--line-soft); +} +.icon { + width: 16px; + height: 16px; + display: inline-block; + flex: 0 0 auto; +} +.icon svg { + width: 100%; + height: 100%; + display: block; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface-1); +} +.summary-card { + min-height: 118px; + padding: 15px; + border-right: 1px solid var(--line); + position: relative; +} +.summary-card:last-child { + border-right: 0; +} +.summary-value { + margin-top: 18px; + font-size: 28px; + font-weight: 720; + letter-spacing: -0.04em; +} +.summary-label { + color: var(--text-muted); + margin-top: 2px; +} +.summary-card .icon { + position: absolute; + top: 14px; + right: 14px; + color: var(--text-faint); +} +.modal .summary-grid { + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); +} +.modal .summary-card { + min-height: 108px; +} +.modal .summary-card > span { + display: block; + max-width: 12ch; + color: var(--text-muted); + line-height: 1.35; +} +.modal .summary-card > strong { + display: block; + margin-top: 15px; + font: 700 27px/1 var(--font-sans); + color: var(--text); +} +.summary-card.warning .summary-value, +.summary-card.warning .icon { + color: var(--warning); +} +.summary-card.success .summary-value, +.summary-card.success .icon { + color: var(--success); +} +.summary-card.danger .summary-value, +.summary-card.danger .icon { + color: var(--danger); +} + +.section-block { + margin-top: 24px; +} +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 9px; +} +.section-heading h2 { + margin: 0; + font-size: 13px; + letter-spacing: -0.01em; +} +.section-heading .meta { + color: var(--text-faint); + font-size: 11px; +} +.action-queue { + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface-1); +} +.queue-row { + display: grid; + grid-template-columns: 28px minmax(160px, 1.2fr) minmax(230px, 2fr) auto; + gap: 12px; + align-items: center; + min-height: 62px; + padding: 10px 13px; + border-bottom: 1px solid var(--line-soft); +} +.queue-row:last-child { + border-bottom: 0; +} +.queue-row:hover { + background: var(--surface-hover); +} +.queue-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--surface-2); + color: var(--text-muted); +} +.queue-icon.warning { + background: var(--warning-soft); + color: var(--warning); +} +.queue-icon.success { + background: var(--success-soft); + color: var(--success); +} +.queue-icon.danger { + background: var(--danger-soft); + color: var(--danger); +} +.queue-title { + font-weight: 640; +} +.queue-sub { + color: var(--text-faint); + margin-top: 3px; + font-size: 11px; + font-family: var(--font-mono); +} +.queue-reason { + color: var(--text-muted); +} +.queue-reason strong { + color: var(--text); + display: block; + font-weight: 600; +} + +.two-column { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); + gap: 16px; +} +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-1); +} +.panel-header { + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 13px; + border-bottom: 1px solid var(--line-soft); +} +.panel-header h2, +.panel-header h3 { + font-size: 12px; + margin: 0; +} +.panel-body { + padding: 14px; +} +.activity-list { + padding: 3px 0; +} +.activity-item { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + gap: 10px; + padding: 10px 13px; + align-items: start; +} +.activity-dot { + width: 7px; + height: 7px; + margin-top: 5px; + border-radius: 50%; + background: var(--text-faint); +} +.activity-dot.success { + background: var(--success); +} +.activity-dot.warning { + background: var(--warning); +} +.activity-dot.danger { + background: var(--danger); +} +.activity-title { + font-weight: 590; +} +.activity-sub, +.activity-time { + color: var(--text-faint); + font-size: 11px; +} + +.repo-workspace { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto auto 39px minmax(0, 1fr); +} +.repo-context { + min-width: 0; + min-height: 0; +} +.repo-header { + padding: 16px 18px 13px; + background: var(--surface-1); + border-bottom: 1px solid var(--line); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.repo-heading { + min-width: 0; +} +.repo-heading h1 { + display: flex; + align-items: center; + gap: 9px; + font-size: 17px; +} +.repo-heading p { + font-family: var(--font-mono); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.repo-header-actions { + display: flex; + gap: 7px; +} +.release-rail { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + background: var(--surface-0); + border-bottom: 1px solid var(--line); +} +.release-node { + min-width: 0; + padding: 10px 16px 11px; + border-right: 1px solid var(--line-soft); + position: relative; +} +.release-node:last-child { + border-right: 0; +} +.repository-deployment-summary { + display: flex; + align-items: center; + gap: 10px; + min-height: 48px; + padding: 7px 12px; + border-bottom: 1px solid var(--line); + background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 7%, var(--surface-1)), var(--surface-1) 42%); +} +.repository-deployment-summary-label { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + color: var(--text-muted); + font-size: 10px; + font-weight: 760; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.repository-deployment-summary-label svg { + width: 14px; + height: 14px; + color: var(--accent); +} +.repository-deployment-chips { + display: flex; + gap: 7px; + min-width: 0; + flex: 1; + overflow-x: auto; + scrollbar-width: thin; +} +.repository-deployment-chip { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + max-width: 280px; + padding: 6px 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-2) 88%, transparent); + color: var(--text); + cursor: pointer; +} +.repository-deployment-chip:hover { + border-color: color-mix(in srgb, var(--accent) 48%, var(--line)); + background: color-mix(in srgb, var(--accent) 10%, var(--surface-2)); +} +.repository-deployment-chip strong, +.repository-deployment-chip span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.repository-deployment-chip strong { font-size: 11px; } +.repository-deployment-chip span:last-child { color: var(--text-muted); font-size: 10px; } +.release-node:not(:last-child)::after { + content: "›"; + position: absolute; + right: -6px; + top: 19px; + z-index: 2; + width: 12px; + height: 12px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--surface-0); + color: var(--text-faint); +} +.release-label { + color: var(--text-faint); + font-size: 9px; + font-weight: 760; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.release-value { + display: flex; + align-items: center; + gap: 7px; + margin-top: 4px; + min-width: 0; +} +.release-value strong { + font-family: var(--font-mono); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.release-value span { + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.state-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-faint); + flex: 0 0 auto; +} +.state-dot.success { + background: var(--success); + box-shadow: 0 0 0 3px var(--success-soft); +} +.state-dot.warning { + background: var(--warning); + box-shadow: 0 0 0 3px var(--warning-soft); +} +.state-dot.danger { + background: var(--danger); + box-shadow: 0 0 0 3px var(--danger-soft); +} +.tabs { + display: flex; + align-items: flex-end; + gap: 2px; + padding: 0 12px; + border-bottom: 1px solid var(--line); + background: var(--surface-1); + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +.tab { + flex: 0 0 auto; + white-space: nowrap; + height: 38px; + padding: 0 12px; + background: transparent; + color: var(--text-muted); + border-bottom: 2px solid transparent; + cursor: pointer; +} +.tab:hover { + color: var(--text); +} +.tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} +.repo-content { + min-height: 0; + overflow: hidden; +} +.repo-content > .tab-page, +.repo-content > .validator-page { + height: 100%; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.repo-content > .validator-empty, +.repo-content > .empty-state { + max-height: 100%; + overflow-y: auto; + overscroll-behavior: contain; +} +.changes-layout { + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: 290px minmax(0, 1fr); +} +.file-panel { + min-width: 0; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--line); + background: var(--surface-1); + display: flex; + flex-direction: column; +} +.file-panel-tools { + min-height: 38px; + padding: 0 9px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line-soft); +} +.file-list { + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + min-height: 0; + padding: 5px; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.file-row { + position: relative; + width: 100%; + min-height: 38px; + display: grid; + grid-template-columns: 17px 17px minmax(0, 1fr) 16px; + gap: 7px; + align-items: center; + padding: 4px 8px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + text-align: left; + transition: + transform 160ms ease, + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease; +} +.file-row:hover { + background: var(--surface-hover); + color: var(--text); + border-color: color-mix(in srgb, var(--primary) 18%, transparent); + transform: translateX(2px); +} +.file-row.active { + background: + linear-gradient(90deg, var(--primary-soft), transparent 110%), + var(--surface-1); + border-color: color-mix(in srgb, var(--primary) 36%, var(--line)); + box-shadow: + inset 3px 0 0 var(--primary), + 0 7px 22px rgba(0, 0, 0, 0.12); + color: var(--text); +} +.file-row > span:last-child { + color: var(--text-faint); + filter: drop-shadow(0 0 5px transparent); + transition: + color 160ms ease, + filter 160ms ease; +} +.file-row:hover > span:last-child, +.file-row.active > span:last-child { + color: var(--primary); + filter: drop-shadow( + 0 0 5px color-mix(in srgb, var(--primary) 45%, transparent) + ); +} +.file-row input { + margin: 0; + accent-color: var(--primary-strong); +} +.file-path { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); + font-size: 11px; +} +.file-status { + display: inline-grid; + place-items: center; + width: 17px; + height: 17px; + border-radius: 5px; + background: color-mix(in srgb, currentColor 11%, transparent); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 750; + color: var(--warning); +} +.file-status.added, +.file-status.untracked { + color: var(--success); +} +.file-status.deleted, +.file-status.conflict { + color: var(--danger); +} +.diff-panel { + container-type: inline-size; + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: 38px minmax(0, 1fr); + background: var(--bg); +} +.diff-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 11px; + border-bottom: 1px solid var(--line-soft); + background: var(--surface-0); +} +.diff-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + color: var(--text-muted); +} +.diff-view { + position: relative; + isolation: isolate; + overflow: auto; + padding: 8px 0 36px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 19px; + white-space: pre; + tab-size: 2; + background: + radial-gradient( + circle at 84% 72%, + color-mix(in srgb, var(--primary) 8%, transparent), + transparent 28% + ), + radial-gradient( + circle at 72% 88%, + color-mix(in srgb, var(--success) 5%, transparent), + transparent 24% + ); +} +.diff-line { + position: relative; + z-index: 2; + display: block; + min-height: 19px; + padding: 0 14px; +} +.diff-atmosphere { + --diff-tilt-x: 0deg; + --diff-tilt-y: 0deg; + position: absolute; + right: clamp(24px, 7vw, 110px); + bottom: clamp(28px, 8vh, 90px); + z-index: 0; + width: min(360px, 34vw); + color: var(--primary); + opacity: 0.38; + pointer-events: none; + transform: perspective(850px) rotateX(var(--diff-tilt-x)) + rotateY(var(--diff-tilt-y)); + transform-style: preserve-3d; + transition: + transform 220ms ease-out, + opacity 180ms ease; +} +.diff-atmosphere.dense { + opacity: 0.14; +} +.diff-atmosphere svg { + display: block; + width: 100%; + overflow: visible; +} +.code-route { + fill: none; + stroke: currentColor; + stroke-width: 1.2; + stroke-dasharray: 4 8; + opacity: 0.42; +} +.code-route.route-b { + color: var(--success); +} +.code-card rect { + fill: color-mix(in srgb, var(--surface-2) 72%, transparent); + stroke: color-mix(in srgb, var(--primary) 60%, var(--line)); + stroke-width: 1.2; + filter: drop-shadow(0 18px 28px rgba(0, 0, 0, 0.24)); +} +.code-card path { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-width: 4; + opacity: 0.62; +} +.code-node circle { + fill: var(--surface-2); + stroke: currentColor; + stroke-width: 1.5; +} +.code-node path { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} +.node-one { + color: var(--success); + animation: code-node-float 5s ease-in-out infinite; +} +.node-two { + animation: code-node-float 5s ease-in-out -2.5s infinite; +} +.code-packet { + fill: var(--primary); + filter: drop-shadow(0 0 8px currentColor); + offset-path: path("M38 195 C92 84 178 214 318 74"); + animation: code-packet-travel 5.4s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} +.code-packet.packet-two { + fill: var(--success); + offset-path: path("M52 74 C132 8 230 34 310 156"); + animation-delay: -2.7s; +} +.diff-atmosphere-caption { + display: flex; + align-items: center; + justify-content: space-between; + margin: -10px 42px 0; + padding-top: 10px; + border-top: 1px solid color-mix(in srgb, var(--primary) 32%, transparent); + color: var(--text-muted); + font: 700 9px/1 var(--font-mono); + letter-spacing: 0.11em; + text-transform: uppercase; +} +.diff-atmosphere-caption strong { + display: flex; + gap: 8px; +} +.diff-atmosphere-caption i { + color: var(--success); + font-style: normal; +} +.diff-atmosphere-caption i + i { + color: var(--danger); +} +@keyframes code-packet-travel { + 0% { + offset-distance: 0%; + opacity: 0; + } + 12%, + 82% { + opacity: 1; + } + 100% { + offset-distance: 100%; + opacity: 0; + } +} +@keyframes code-node-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} +html[data-theme="light"] .diff-view { + background: + radial-gradient( + circle at 84% 72%, + rgba(72, 92, 220, 0.12), + transparent 30% + ), + radial-gradient( + circle at 72% 88%, + rgba(15, 148, 108, 0.08), + transparent 25% + ), + linear-gradient( + 135deg, + rgba(248, 251, 255, 0.88), + rgba(239, 245, 255, 0.62) + ); +} +html[data-theme="light"] .diff-atmosphere { + opacity: 0.46; +} +html[data-theme="light"] .diff-atmosphere.dense { + opacity: 0.18; +} +@container (max-width: 560px) { + .diff-atmosphere { + display: none; + } +} +.diff-line.add { + background: rgba(38, 166, 115, 0.14); + color: #8ef0c6; +} +.diff-line.remove { + background: rgba(229, 83, 75, 0.14); + color: #ffaaa5; +} +html[data-theme="light"] .diff-line.add { + color: #006642; +} +html[data-theme="light"] .diff-line.remove { + color: #a31f1f; +} +.diff-line.meta { + color: var(--primary); +} +.diff-line.hunk { + color: #caa7ff; + background: rgba(148, 97, 214, 0.08); +} + +.validator-page { + container-type: inline-size; + padding: 18px; + display: grid; + gap: 14px; + align-content: start; +} +.validator-empty { + min-height: 360px; + margin: 18px; + padding: 38px; + display: flex; + align-items: center; + justify-content: center; + gap: 36px; + text-align: left; + overflow: hidden; +} +.validator-empty > div:last-child { + max-width: 520px; +} +.validator-empty h2 { + margin: 5px 0 8px; + font-size: 24px; +} +.validator-empty p { + margin: 0 0 18px; + color: var(--text-muted); + line-height: 1.65; +} +.validator-hero { + position: relative; + min-height: 160px; + padding: 24px; + display: grid; + grid-template-columns: auto minmax(220px, 1fr) minmax(180px, 260px) auto; + align-items: center; + gap: 22px; + overflow: hidden; + background: + radial-gradient( + circle at 68% 16%, + color-mix(in srgb, var(--primary) 16%, transparent), + transparent 28% + ), + linear-gradient( + 120deg, + var(--surface-1), + color-mix(in srgb, var(--surface-2) 84%, var(--primary-soft)) + ); +} +.validator-hero.success { + --validator-accent: var(--success); +} +.validator-hero.warning { + --validator-accent: var(--warning); +} +.validator-hero.danger { + --validator-accent: var(--danger); +} +.validator-score { + width: 116px; + height: 116px; + border-radius: 32px; + display: grid; + place-content: center; + text-align: center; + background: color-mix(in srgb, var(--validator-accent) 10%, var(--surface-2)); + border: 1px solid color-mix(in srgb, var(--validator-accent) 42%, var(--line)); + box-shadow: + inset 0 0 34px color-mix(in srgb, var(--validator-accent) 10%, transparent), + 0 18px 38px rgba(0, 0, 0, 0.15); +} +.validator-score strong { + color: var(--validator-accent); + font-size: 42px; + line-height: 0.9; + letter-spacing: -0.05em; +} +.validator-score span { + margin-top: 7px; + color: var(--text-muted); + font: 700 10px/1 var(--font-mono); +} +.validator-hero h2 { + margin: 4px 0 6px; + font-size: 24px; +} +.validator-hero p { + margin: 0; + color: var(--text-muted); +} +.validator-hero .project-illustration { + width: 220px; + opacity: 0.82; +} +.validator-actions { + display: grid; + gap: 8px; + min-width: 150px; +} +.validator-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-items: start; +} +.validator-group { + overflow: hidden; +} +.validator-checks { + display: grid; +} +.validator-check { + min-height: 78px; + padding: 13px 14px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 11px; + align-items: center; + border-top: 1px solid var(--line-soft); + transition: + background 160ms ease, + transform 160ms ease; +} +.validator-check:hover { + background: var(--surface-hover); +} +.validator-check-icon { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 9px; + color: var(--text-muted); + background: var(--surface-2); +} +.validator-check.pass .validator-check-icon { + color: var(--success); + background: color-mix(in srgb, var(--success) 12%, transparent); +} +.validator-check.warning .validator-check-icon { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 12%, transparent); +} +.validator-check.error .validator-check-icon { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} +.validator-check strong { + display: block; + font-size: 12px; +} +.validator-check p { + margin: 4px 0 0; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; +} +html[data-theme="light"] .validator-hero { + background: + radial-gradient( + circle at 68% 16%, + rgba(66, 91, 220, 0.18), + transparent 30% + ), + linear-gradient( + 120deg, + rgba(255, 255, 255, 0.98), + rgba(236, 243, 255, 0.96) + ); +} +@media (max-width: 1180px) { + .validator-hero { + grid-template-columns: auto 1fr auto; + } + .validator-hero .project-illustration { + display: none; + } + .validator-groups { + grid-template-columns: 1fr; + } +} +@container (max-width: 900px) { + .validator-hero { + grid-template-columns: auto minmax(0, 1fr); + } + .validator-hero .project-illustration { + display: none; + } + .validator-actions { + grid-column: 1 / -1; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .validator-groups { + grid-template-columns: 1fr; + } +} +@container (max-width: 520px) { + .validator-hero { + grid-template-columns: 1fr; + } + .validator-score { + width: 92px; + height: 92px; + border-radius: 25px; + } + .validator-actions { + grid-template-columns: 1fr; + } + .validator-check { + grid-template-columns: 30px minmax(0, 1fr); + } + .validator-check > .button, + .validator-check > .status-pill { + grid-column: 2; + justify-self: start; + } +} +.empty-state { + height: 100%; + min-height: 260px; + display: grid; + place-content: center; + justify-items: center; + text-align: center; + padding: 30px; + color: var(--text-muted); +} +.empty-state .large-icon { + width: 48px; + height: 48px; + display: grid; + place-items: center; + border-radius: 12px; + background: var(--surface-2); + color: var(--text-faint); + margin-bottom: 12px; +} +.empty-state h3 { + margin: 0 0 6px; + color: var(--text); + font-size: 14px; +} +.empty-state p { + margin: 0; + max-width: 420px; + line-height: 1.55; +} + +.inspector { + padding: 16px; +} +.inspector-header { + margin-bottom: 16px; +} +.inspector-header h2 { + margin: 3px 0 0; + font-size: 15px; +} +.inspector-section { + padding: 15px 0; + border-top: 1px solid var(--line-soft); +} +.inspector-section:first-of-type { + border-top: 0; + padding-top: 0; +} +.inspector-label { + color: var(--text-faint); + font-size: 9px; + font-weight: 760; + letter-spacing: 0.09em; + text-transform: uppercase; + margin-bottom: 8px; +} +.textarea, +.input, +.select { + width: 100%; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-0); + color: var(--text); +} +.input, +.select { + height: 33px; + padding: 0 9px; +} +.textarea { + min-height: 98px; + resize: vertical; + padding: 9px 10px; + line-height: 1.45; +} +.field-hint { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 6px; + color: var(--text-faint); + font-size: 10px; +} +.context-summary { + padding: 11px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); +} +.context-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 5px 0; + color: var(--text-muted); +} +.context-row strong { + color: var(--text); + font-family: var(--font-mono); + font-size: 11px; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; +} +.notice { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 10px 11px; + border-radius: 5px; + border: 1px solid var(--line); + background: var(--surface-0); + color: var(--text-muted); + line-height: 1.45; +} +.notice.warning { + border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); + background: var(--warning-soft); + color: var(--warning); +} +.notice.danger { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); + background: var(--danger-soft); + color: var(--danger); +} +.notice.success { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); + background: var(--success-soft); + color: var(--success); +} +.stack { + display: grid; + gap: 8px; +} +.divider-text { + display: flex; + align-items: center; + gap: 9px; + color: var(--text-faint); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} +.divider-text::before, +.divider-text::after { + content: ""; + height: 1px; + background: var(--line-soft); + flex: 1; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} +.data-table th { + height: 34px; + padding: 0 11px; + color: var(--text-faint); + text-align: left; + font-size: 9px; + letter-spacing: 0.07em; + text-transform: uppercase; + border-bottom: 1px solid var(--line); +} +.data-table td { + padding: 10px 11px; + border-bottom: 1px solid var(--line-soft); + vertical-align: middle; +} +.data-table tr:last-child td { + border-bottom: 0; +} +.data-table tbody tr:hover { + background: var(--surface-hover); +} +.mono { + font-family: var(--font-mono); +} +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 22px; + padding: 0 8px; + border-radius: 11px; + background: var(--surface-3); + color: var(--text-muted); + font-size: 10px; + font-weight: 680; +} +.status-pill.success { + background: var(--success-soft); + color: var(--success); +} +.status-pill.warning { + background: var(--warning-soft); + color: var(--warning); +} +.status-pill.danger { + background: var(--danger-soft); + color: var(--danger); +} + +.settings-layout { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + min-height: 100%; +} +.settings-nav { + padding: 15px 8px; + border-right: 1px solid var(--line); + background: var(--surface-1); +} +.settings-content { + padding: 25px 30px 60px; + overflow: auto; +} +.settings-group { + max-width: 860px; + margin-bottom: 28px; +} +.settings-group > h2 { + font-size: 12px; + margin: 0 0 12px; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.field { + display: grid; + gap: 6px; +} +.field label { + color: var(--text-muted); + font-size: 11px; + font-weight: 650; +} +.field.full { + grid-column: 1 / -1; +} +.connection-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 13px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-1); +} +.root-row { + display: flex; + align-items: center; + gap: 8px; +} +.root-row .input { + flex: 1; + font-family: var(--font-mono); + font-size: 11px; +} + +.deploy-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 365px), 1fr)); + gap: 14px; +} +.deploy-card { + --card-accent: var(--primary-strong); + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-1); + overflow: hidden; + box-shadow: 0 10px 34px rgba(0, 0, 0, 0.1); + transition: + transform 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} +.deploy-card:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--card-accent) 42%, var(--line)); + box-shadow: 0 16px 42px + color-mix(in srgb, var(--card-accent) 12%, transparent); +} +.deploy-card.accent-0 { + --card-accent: #4d7df3; +} +.deploy-card.accent-1 { + --card-accent: #8b5cf6; +} +.deploy-card.accent-2 { + --card-accent: #0ea5a0; +} +.deploy-card.accent-3 { + --card-accent: #e2783f; +} +.deploy-card.accent-4 { + --card-accent: #d24e83; +} +.deploy-card.accent-5 { + --card-accent: #4b9b55; +} +.container-identity { + min-height: 78px; + display: flex; + align-items: center; + gap: 12px; + padding: 13px 14px; + border-top: 4px solid var(--card-accent); + border-bottom: 1px solid var(--line-soft); + background: linear-gradient( + 110deg, + color-mix(in srgb, var(--card-accent) 15%, var(--surface-1)), + var(--surface-1) 68% + ); +} +.container-avatar { + width: 44px; + height: 44px; + flex: 0 0 44px; + display: grid; + place-items: center; + border-radius: 12px; + color: #fff; + background: linear-gradient( + 145deg, + color-mix(in srgb, var(--card-accent) 72%, #fff), + var(--card-accent) + ); + box-shadow: 0 8px 20px color-mix(in srgb, var(--card-accent) 28%, transparent); + font-size: 19px; + font-weight: 780; +} +.container-identity > div { + flex: 1 1 auto; + min-width: 0; +} +.container-identity span:not(.container-avatar):not(.sync-proof), +.container-identity small { + display: block; + color: var(--text-faint); + font-size: 10px; +} +.container-identity strong { + display: block; + margin: 2px 0 3px; + overflow: hidden; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} +.container-identity small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sync-proof { + margin-left: auto; + padding: 6px 8px; + border-radius: 6px; + background: var(--surface-2); + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} +.sync-proof.success { + color: var(--success); + background: var(--success-soft); +} +.sync-proof.warning { + color: var(--warning); + background: var(--warning-soft); +} +.sync-proof .icon { + width: 12px; + height: 12px; + vertical-align: -2px; +} +.deploy-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 14px; + border-bottom: 1px solid var(--line-soft); +} +.deploy-card-body { + padding: 13px 14px; +} +.deploy-metadata { + display: grid; + grid-template-columns: 1fr auto; + gap: 7px 15px; + color: var(--text-muted); +} +.deploy-metadata strong { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text); +} + +.deployment-view { + min-height: 100%; + padding: 22px; +} +.pipeline-card { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-1); + overflow: hidden; +} +.pipeline-head { + display: flex; + justify-content: space-between; + gap: 15px; + padding: 16px; + border-bottom: 1px solid var(--line-soft); +} +.pipeline-head h2 { + margin: 0; + font-size: 16px; +} +.pipeline-head p { + margin: 5px 0 0; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; +} +.pipeline-stages { + display: grid; + grid-template-columns: repeat(5, 1fr); + padding: 25px 20px 20px; +} +.pipeline-stage { + position: relative; + display: grid; + justify-items: center; + gap: 8px; + color: var(--text-faint); + text-align: center; + font-size: 10px; + font-weight: 680; +} +.pipeline-stage::before { + content: ""; + position: absolute; + height: 2px; + left: -50%; + right: 50%; + top: 15px; + background: var(--line); +} +.pipeline-stage:first-child::before { + display: none; +} +.pipeline-stage.complete::before, +.pipeline-stage.active::before { + background: var(--success); +} +.stage-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 9px; + background: var(--surface-3); + border: 1px solid var(--line); + z-index: 1; +} +.pipeline-stage.complete { + color: var(--success); +} +.pipeline-stage.complete .stage-icon { + background: var(--success); + border-color: var(--success); + color: #06251b; +} +.pipeline-stage.active { + color: var(--primary); +} +.pipeline-stage.active .stage-icon { + background: var(--primary-strong); + border-color: var(--primary); + color: #fff; + box-shadow: 0 0 0 5px var(--primary-soft); +} +.log-view { + margin-top: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #080b11; + overflow: hidden; +} +.log-toolbar { + height: 35px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 11px; + border-bottom: 1px solid #262d3b; + color: #9aa4b6; +} +.log-lines { + min-height: 290px; + max-height: 500px; + overflow: auto; + padding: 12px 14px; + font: 11px/19px var(--font-mono); + color: #c3cada; + white-space: pre-wrap; +} +.log-lines .ok { + color: #54ddb0; +} +.log-lines .warn { + color: #f2ba63; +} +.log-lines .err { + color: #ff817a; +} + +.setup-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 25px; + background: rgba(4, 7, 12, 0.75); + backdrop-filter: blur(8px); +} +.setup-window { + width: min(920px, 96vw); + min-height: 590px; + max-height: 92vh; + display: grid; + grid-template-columns: 230px minmax(0, 1fr); + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; + background: var(--surface-1); + box-shadow: var(--shadow); +} +.setup-sidebar { + padding: 24px 17px; + border-right: 1px solid var(--line); + background: var(--surface-0); +} +.setup-sidebar h2 { + margin: 16px 0 5px; + font-size: 18px; +} +.setup-sidebar p { + margin: 0 0 22px; + color: var(--text-muted); + line-height: 1.5; +} +.setup-step { + min-height: 38px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 9px; + border-radius: 5px; + color: var(--text-faint); + margin-bottom: 3px; +} +.setup-step .step-number { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 9px; +} +.setup-step.active { + background: var(--primary-soft); + color: var(--primary); +} +.setup-step.complete { + color: var(--success); +} +.setup-content { + min-width: 0; + padding: 34px 38px 24px; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + overflow: auto; +} +.setup-body h1 { + margin: 0; + font-size: 22px; +} +.setup-body > p { + color: var(--text-muted); + line-height: 1.55; + max-width: 620px; +} +.setup-actions { + display: flex; + justify-content: space-between; + gap: 10px; + padding-top: 22px; + border-top: 1px solid var(--line-soft); +} +.discovery-list { + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + max-height: 260px; + overflow-y: auto; +} +.discovery-row { + min-height: 45px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 7px 10px; + border-bottom: 1px solid var(--line-soft); +} +.discovery-row:last-child { + border-bottom: 0; +} +.discovery-row strong { + display: block; + font-size: 12px; +} +.discovery-row span { + display: block; + margin-top: 2px; + color: var(--text-faint); + font: 10px var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + align-items: center; + justify-items: center; + padding: clamp(8px, 2vh, 20px); + overflow: auto; + overscroll-behavior: contain; + background: rgba(4, 7, 12, 0.7); + backdrop-filter: blur(4px); +} +.modal { + width: min(520px, 94vw); + max-height: calc(100dvh - clamp(16px, 4vh, 40px)); + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface-1); + box-shadow: var(--shadow); + overflow: hidden; +} +.modal-header { + flex: 0 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 17px; + border-bottom: 1px solid var(--line); + background: var(--surface-1); +} +.modal-header h2 { + margin: 0; + font-size: 15px; +} +.modal-body { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + padding: 17px; +} +.modal-footer { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + padding: 12px 17px; + border-top: 1px solid var(--line-soft); + background: var(--surface-0); + box-shadow: 0 -8px 18px rgba(0, 0, 0, 0.08); +} + +.statusbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + padding: 0 9px; + border-top: 1px solid var(--line); + background: var(--surface-1); + color: var(--text-faint); + font-size: 10px; + font-weight: 620; +} +.statusbar-left, +.statusbar-right { + display: flex; + align-items: center; + gap: 13px; + min-width: 0; +} +.statusbar-item { + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} +.statusbar .success { + color: var(--success); +} +.statusbar .warning { + color: var(--warning); +} +.statusbar .danger { + color: var(--danger); +} + +.toast-root { + position: fixed; + right: 15px; + bottom: 38px; + z-index: 90; + display: grid; + gap: 8px; + pointer-events: none; +} +.toast { + width: min(380px, calc(100vw - 30px)); + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 9px; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface-2); + box-shadow: var(--shadow); + pointer-events: auto; + animation: toast-in 0.18s ease-out; +} +.toast.success { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); +} +.toast.error { + border-color: color-mix(in srgb, var(--danger) 40%, var(--line)); +} +.toast strong { + display: block; + margin-bottom: 2px; +} +.toast span { + color: var(--text-muted); + line-height: 1.4; +} +@keyframes toast-in { + from { + transform: translateY(8px); + opacity: 0; + } +} + +.loading-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--surface-0) 72%, transparent); + backdrop-filter: blur(2px); +} +.spinner { + width: 22px; + height: 22px; + border: 2px solid var(--line); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1250px) { + :root { + --sidebar: 250px; + --action-panel: 320px; + } + .summary-grid { + grid-template-columns: repeat(2, 1fr); + } + .summary-card:nth-child(2) { + border-right: 0; + } + .summary-card:nth-child(-n + 2) { + border-bottom: 1px solid var(--line); + } + .queue-row { + grid-template-columns: 28px minmax(130px, 1fr) minmax(180px, 1.5fr) auto; + } +} + +@media (max-width: 1120px) { + :root { + --sidebar: 220px; + --action-panel: 300px; + } + .global-search { + width: 190px; + } + .changes-layout { + grid-template-columns: 245px minmax(0, 1fr); + } + .page { + padding-left: 18px; + padding-right: 18px; + } +} + +/* ForgeFlow v0.2 interaction and workflow refinements */ +.command-trigger { + height: 30px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); + color: var(--text-muted); + cursor: pointer; + -webkit-app-region: no-drag; +} +.command-trigger:hover { + color: var(--text); + background: var(--surface-hover); +} +kbd { + min-width: 24px; + padding: 2px 5px; + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--surface-2); + color: var(--text-faint); + font: 9px var(--font-mono); + text-align: center; +} +.repo-group-label { + padding: 10px 8px 4px; + color: var(--text-faint); + font-size: 9px; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.favorite-button { + width: 25px; + height: 25px; + display: inline-grid; + place-items: center; + margin-left: -5px; + border-radius: 5px; + background: transparent; + color: var(--text-faint); + cursor: pointer; +} +.favorite-button:hover, +.favorite-button.active { + color: var(--warning); + background: var(--warning-soft); +} +.favorite-button.active svg { + fill: currentColor; +} +.repo-row .repo-icon .icon { + width: 15px; + height: 15px; +} +.repo-row .repo-icon:has(svg path[d^="m12 3"]) { + color: var(--warning); +} +.button.small { + min-height: 25px; + padding: 0 7px; + font-size: 10px; +} +.stack.horizontal.compact { + gap: 5px; +} +.empty-state.compact { + min-height: 105px; + padding: 16px; +} +.empty-state.full { + height: 100%; + min-height: 320px; +} +.readiness-list { + display: grid; + gap: 3px; +} +.readiness-row { + display: grid; + grid-template-columns: 12px minmax(0, 1fr); + gap: 9px; + align-items: start; + padding: 9px 4px; + border-bottom: 1px solid var(--line-soft); +} +.readiness-row:last-child { + border-bottom: 0; +} +.readiness-row strong { + display: block; + font-size: 11px; +} +.readiness-row span:not(.state-dot) { + display: block; + margin-top: 2px; + color: var(--text-faint); + font-size: 10px; +} +.action-panel-head { + padding: 18px 17px 14px; + border-bottom: 1px solid var(--line); +} +.action-panel-head h2 { + margin: 5px 0 5px; + font-size: 16px; +} +.action-panel-head p { + margin: 0; + color: var(--text-muted); + line-height: 1.45; +} +.action-panel-body { + padding: 15px 16px; +} +.action-panel-footer { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; + margin: auto 10px 10px; + padding-top: 10px; + border-top: 1px solid var(--line-soft); +} +.action-panel { + display: flex; + flex-direction: column; +} +.panel-callout { + display: grid; + gap: 9px; +} +.panel-callout h2 { + margin: 3px 0 0; + font-size: 15px; +} +.panel-callout p { + margin: 0 0 5px; + color: var(--text-muted); + line-height: 1.5; +} +.callout-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 9px; + background: var(--primary-soft); + color: var(--primary); +} +.callout-icon.success { + background: var(--success-soft); + color: var(--success); +} +.callout-icon.warning { + background: var(--warning-soft); + color: var(--warning); +} +.callout-icon.danger { + background: var(--danger-soft); + color: var(--danger); +} +.field-hint { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 6px; + color: var(--text-faint); + font-size: 10px; +} +.field-label { + display: block; + margin-top: 8px; + color: var(--text-muted); + font-size: 10px; + font-weight: 650; +} +.textarea { + width: 100%; + min-height: 92px; + resize: vertical; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); + line-height: 1.45; +} +.input, +.select { + width: 100%; + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-0); + color: var(--text); +} +.select { + cursor: pointer; +} +.stack { + display: grid; + gap: 8px; +} +.stack.horizontal { + display: flex; + flex-wrap: wrap; + align-items: center; +} +.deploy-proof { + display: grid; + grid-template-columns: 1fr auto; + gap: 7px 12px; + margin: 7px 0 5px; + padding: 11px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); +} +.deploy-proof span { + color: var(--text-faint); +} +.deploy-proof strong { + font: 11px var(--font-mono); +} +.tab-page { + min-height: 0; + padding: 18px 19px 42px; +} +.git-tools-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: max-content; + gap: 15px; + align-items: start; +} +.inline-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + margin-bottom: 13px; +} +.tool-list { + display: grid; + border: 1px solid var(--line-soft); + border-radius: 6px; + overflow: hidden; +} +.tool-row { + min-height: 51px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-bottom: 1px solid var(--line-soft); +} +.tool-row:last-child { + border-bottom: 0; +} +.tool-row:hover { + background: var(--surface-hover); +} +.tool-row strong { + display: block; +} +.tool-row span { + display: block; + margin-top: 3px; + color: var(--text-faint); + font: 10px var(--font-mono); +} +.server-inventory-panel { + position: relative; + overflow: hidden; + border-color: color-mix(in srgb, var(--primary) 22%, var(--line)); + background: + radial-gradient(circle at 94% 0%, color-mix(in srgb, var(--primary) 10%, transparent), transparent 34%), + var(--surface-1); +} +.server-inventory-panel::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: linear-gradient(180deg, var(--primary), var(--success)); +} +.server-inventory-panel .tool-row { + content-visibility: auto; + contain-intrinsic-size: auto 76px; + transition: background 150ms ease, transform 150ms ease; +} +.server-inventory-panel .tool-row:hover { + transform: translateX(2px); +} +.deploy-card-header h3 { + margin: 4px 0 3px; + font-size: 14px; +} +.deploy-card-header p { + margin: 0; + color: var(--text-faint); + font: 10px var(--font-mono); +} +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--line-soft); +} +.compact-card .deploy-card-body { + padding-bottom: 11px; +} +.check-field { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 0; + color: var(--text-muted); +} +.check-field input { + accent-color: var(--primary-strong); +} +.wide-modal { + width: min(650px, 95vw); +} +.modal-spacer { + flex: 1; +} +.confirm-hero { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--success) 30%, var(--line)); + border-radius: 7px; + background: var(--success-soft); +} +.confirm-hero.danger { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); + background: var(--danger-soft); +} +.confirm-hero > .icon { + width: 27px; + height: 27px; + color: var(--success); +} +.confirm-hero.danger > .icon { + color: var(--danger); +} +.confirm-hero strong, +.confirm-hero span { + display: block; +} +.confirm-hero span { + margin-top: 3px; + color: var(--text-muted); +} +.confirm-grid { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 9px 14px; + margin-top: 16px; +} +.confirm-grid span { + color: var(--text-faint); +} +.confirm-grid strong { + overflow-wrap: anywhere; +} +.notice.danger { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); + background: var(--danger-soft); + color: var(--danger); +} +.notice.warning { + border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); + background: var(--warning-soft); + color: var(--warning); +} +.danger-zone { + padding: 15px; + border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--line)); + border-radius: 7px; + background: var(--danger-soft); +} +.danger-zone p { + color: var(--text-muted); + line-height: 1.5; +} +.pipeline-stages { + grid-template-columns: repeat(6, 1fr); +} +.pipeline-stage.failed { + color: var(--danger); +} +.pipeline-stage.failed .stage-icon { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.pipeline-stage.cancelled, +.pipeline-stage.skipped { + color: var(--text-faint); +} +.log-lines { + word-break: break-word; +} +.palette-backdrop { + align-items: start; + padding-top: 12vh; +} +.command-palette { + width: min(650px, 94vw); + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-1); + box-shadow: var(--shadow); + overflow: hidden; +} +.palette-search { + height: 54px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 9px; + align-items: center; + padding: 0 15px; + border-bottom: 1px solid var(--line); +} +.palette-search input { + height: 100%; + border: 0; + outline: 0; + background: transparent; + font-size: 15px; +} +.palette-list { + max-height: 380px; + overflow: auto; + padding: 6px; +} +.palette-row { + width: 100%; + min-height: 52px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 7px 10px; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + text-align: left; + cursor: pointer; +} +.palette-row:hover, +.palette-row:focus-visible { + background: var(--primary-soft); + color: var(--text); +} +.palette-row:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.palette-row strong, +.palette-row small { + display: block; +} +.palette-row small { + margin-top: 3px; + color: var(--text-faint); +} +.palette-footer { + padding: 8px 13px; + border-top: 1px solid var(--line-soft); + color: var(--text-faint); + font-size: 10px; +} +.action-panel-body .panel-callout > h2, +.action-panel-body .panel-callout > p { + display: none; +} +@media (max-height: 760px) { + .palette-backdrop { padding-top: 3vh; } + .palette-list { max-height: calc(100vh - 150px); } +} +.discovery-progress { + min-height: 260px; + display: grid; + place-content: center; + justify-items: center; + gap: 13px; + color: var(--text-muted); +} + +@media (max-width: 1240px) { + .command-trigger span { + display: none; + } + .command-trigger kbd { + display: none; + } + .git-tools-grid { + grid-template-columns: 1fr; + } + .pipeline-stages { + grid-template-columns: repeat(3, 1fr); + row-gap: 18px; + } + .pipeline-stage:nth-child(4)::before { + display: none; + } +} + +/* v0.3 diagnostics and preflight */ +.diagnostics-page { + display: grid; + gap: 18px; + padding: 20px 22px 44px; + overflow: auto; +} +.diagnostic-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + align-items: start; +} +.diagnostic-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border: 1px solid var(--line-soft); + border-radius: 7px; + overflow: hidden; +} +.diagnostic-metrics > div { + min-height: 68px; + display: grid; + align-content: center; + gap: 5px; + padding: 11px 12px; + border-right: 1px solid var(--line-soft); + border-bottom: 1px solid var(--line-soft); + background: var(--surface-0); +} +.diagnostic-metrics > div:nth-child(2n) { + border-right: 0; +} +.diagnostic-metrics > div:nth-last-child(-n + 2) { + border-bottom: 0; +} +.diagnostic-metrics span { + color: var(--text-faint); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.diagnostic-metrics strong { + overflow-wrap: anywhere; + font-size: 12px; +} +.preflight-summary { + min-height: 48px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 9px; + padding: 11px 13px; + border-bottom: 1px solid var(--line-soft); + color: var(--text-muted); +} +.preflight-list { + display: grid; +} +.preflight-row { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + padding: 12px 13px; + border-bottom: 1px solid var(--line-soft); +} +.preflight-row:last-child { + border-bottom: 0; +} +.preflight-row > div { + min-width: 0; +} +.preflight-row strong { + display: block; + margin-top: 1px; + font-size: 11px; +} +.preflight-row span:not(.preflight-state):not(.status-pill), +.preflight-row small { + display: block; + margin-top: 3px; + color: var(--text-faint); + line-height: 1.4; + overflow-wrap: anywhere; +} +.preflight-row small { + color: var(--text-muted); +} +.preflight-row .compact-button { + width: fit-content; + margin-top: 9px; +} +.preflight-state { + width: 25px; + height: 25px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--surface-2); + color: var(--text-faint); +} +.preflight-state .icon { + width: 14px; + height: 14px; +} +.preflight-state.success { + background: var(--success-soft); + color: var(--success); +} +.preflight-state.warning { + background: var(--warning-soft); + color: var(--warning); +} +.preflight-state.danger { + background: var(--danger-soft); + color: var(--danger); +} +.setup-preflight { + max-height: 330px; + margin-top: 17px; + border: 1px solid var(--line); + border-radius: 8px; + overflow: auto; + background: var(--surface-0); +} +.setup-summary { + display: grid; + margin: 18px 0; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 12px; + background: var(--surface-0); +} +.setup-support-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.diagnostics-page .panel-body { + padding: 14px; +} +.diagnostics-page .section-block { + margin: 0; +} + +@media (max-width: 1100px) { + .diagnostic-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .diagnostics-page { + padding: 15px; + } + .diagnostic-metrics { + grid-template-columns: 1fr; + } + .diagnostic-metrics > div { + border-right: 0; + } + .diagnostic-metrics > div:nth-last-child(-n + 2) { + border-bottom: 1px solid var(--line-soft); + } + .diagnostic-metrics > div:last-child { + border-bottom: 0; + } + .preflight-row { + grid-template-columns: 28px minmax(0, 1fr); + } + .preflight-row > .status-pill { + grid-column: 2; + justify-self: start; + } +} + +.required-mark { + color: var(--warning); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + margin-left: 5px; +} +.commit-readiness { + margin-top: 9px; + padding: 8px 9px; + display: flex; + align-items: flex-start; + gap: 7px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--text-muted); + background: var(--surface-0); + font-size: 11px; + line-height: 1.4; +} +.commit-readiness.blocked { + border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); + background: var(--warning-soft); + color: var(--text); +} +.commit-readiness.ready { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); + background: var(--success-soft); + color: var(--text); +} +.commit-readiness .icon { + flex: 0 0 auto; + margin-top: 1px; +} +.stage-note { + margin-top: 10px; + color: var(--text-faint); + font-size: 10px; + line-height: 1.45; +} + +.update-card { + margin-top: 12px; + padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface-0); +} +.update-card.available { + border-color: color-mix(in srgb, var(--primary) 45%, var(--line)); + background: var(--primary-soft); +} +.update-card > div:first-child { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.update-card > div:first-child > span { + min-width: 0; + display: grid; + gap: 2px; +} +.update-card small, +.server-card small { + color: var(--text-faint); +} +.server-list { + display: grid; + gap: 8px; +} +.server-card { + padding: 11px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface-0); +} +.server-card-main { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} +.server-card-main > div { + min-width: 0; + display: grid; + gap: 2px; +} +.server-card-main span { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; +} +.provider-choice { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 14px; +} +.provider-note { + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); + color: var(--text-muted); + font-size: 11px; +} +.server-inspection { + margin-top: 12px; +} +@media (max-width: 1240px) { + .wordmark small { + display: none; + } + .update-card, + .server-card { + align-items: flex-start; + flex-direction: column; + } +} + +.setup-brand-logo { + width: 180px; + max-height: 76px; + object-fit: contain; + object-position: left center; + display: block; + margin-bottom: 12px; +} +.setup-brand-logo-light { + display: none; +} +html[data-theme="light"] .setup-brand-logo-dark { + display: none; +} +html[data-theme="light"] .setup-brand-logo-light { + display: block; +} + +/* v0.5 viewport-safe dialogs */ +.modal-body > .preflight-list:last-child { + margin-bottom: 2px; +} +.modal-footer .button { + flex: 0 0 auto; +} +@media (max-height: 720px) { + .modal-backdrop { + align-items: start; + } + .modal { + max-height: calc(100dvh - 16px); + } + .modal-header { + padding-block: 11px; + } + .modal-body { + padding-block: 13px; + } + .modal-footer { + padding-block: 10px; + } +} +@media (max-width: 680px) { + .modal-backdrop { + padding: 0; + align-items: stretch; + } + .modal, + .wide-modal { + width: 100vw; + max-height: 100dvh; + border-radius: 0; + } + .modal-footer .modal-spacer { + display: none; + } + .modal-footer .button { + flex: 1 1 auto; + } + .form-grid { + grid-template-columns: 1fr; + } + .field.full, + .check-field.full { + grid-column: 1; + } +} + +/* ForgeFlow 0.6 recovery and DockerMan controls */ +.git-tools-grid .troubleshooting-panel { + grid-column: 1 / -1; +} +.git-tools-grid .workspace-sync-panel { + grid-column: 1 / -1; + overflow: hidden; + background: + radial-gradient(circle at 88% 10%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 34%), + var(--surface-1); +} +.workspace-sync-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 24px; +} +.workspace-sync-layout h3 { + margin: 0 0 6px; +} +.workspace-sync-layout p { + margin: 0 0 13px; + color: var(--text-muted); + max-width: 820px; +} +.workspace-sync-actions { + display: grid; + justify-items: end; + gap: 10px; + min-width: 220px; +} +.troubleshooting-summary { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + color: var(--text-muted); +} +.text-success { + color: var(--success) !important; +} +.text-warning { + color: var(--warning) !important; +} +.deploy-card .card-actions { + flex-wrap: wrap; +} +.field small { + display: block; + margin-top: 5px; + color: var(--text-faint); + line-height: 1.35; +} +.repo-quick-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 20px 12px; +} +.repo-quick-actions .status-pill { + margin-left: auto; +} +@media (max-width: 760px) { + .workspace-sync-layout { + grid-template-columns: 1fr; + } + .workspace-sync-actions { + justify-items: stretch; + min-width: 0; + } + .repo-quick-actions { + margin-inline: 12px; + } + .repo-quick-actions .button { + flex: 1 1 145px; + } + .repo-quick-actions .status-pill { + width: 100%; + margin-left: 0; + justify-content: center; + } +} + +/* ForgeFlow 0.8 premium visual system */ +:root { + --bg: #080b12; + --surface-0: #0b1019; + --surface-1: #101722; + --surface-2: #16202e; + --surface-3: #1c2939; + --surface-hover: #172434; + --line: rgba(148, 163, 184, 0.17); + --line-soft: rgba(148, 163, 184, 0.09); + --text: #f3f7fc; + --text-muted: #a8b4c5; + --text-faint: #718096; + --primary: #6ee7f9; + --primary-strong: #0ea5e9; + --primary-soft: rgba(14, 165, 233, 0.13); + --success: #5ee5b0; + --warning: #f4c56a; + --danger: #ff8585; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.42); + --shadow-soft: 0 10px 36px rgba(0, 0, 0, 0.2); + --radius: 12px; + --sidebar: 292px; + --action-panel: 368px; +} + +html[data-theme="light"] { + --bg: #edf2f7; + --surface-0: #f5f8fb; + --surface-1: rgba(255, 255, 255, 0.94); + --surface-2: #f1f5f9; + --surface-3: #e7eef6; + --surface-hover: #edf4fa; + --line: rgba(51, 65, 85, 0.16); + --line-soft: rgba(51, 65, 85, 0.08); + --text: #101828; + --text-muted: #526277; + --text-faint: #7d899a; + --primary: #0284c7; + --primary-strong: #0369a1; + --primary-soft: rgba(2, 132, 199, 0.1); + --shadow: 0 24px 72px rgba(30, 41, 59, 0.16); + --shadow-soft: 0 8px 30px rgba(30, 41, 59, 0.08); +} + +body { + letter-spacing: -0.005em; + background: + radial-gradient( + circle at 78% -12%, + rgba(14, 165, 233, 0.1), + transparent 34% + ), + var(--bg); +} + +.app-shell { + grid-template-rows: 56px minmax(0, 1fr) 28px; + background: transparent; +} + +.titlebar { + padding-inline: 18px 14px; + border-bottom-color: var(--line-soft); + background: color-mix(in srgb, var(--surface-1) 91%, transparent); + backdrop-filter: blur(18px) saturate(145%); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.025); +} + +.wordmark { + font-size: 15px; + font-weight: 760; +} + +.brand-logo { + width: 34px; + filter: drop-shadow(0 0 14px rgba(110, 231, 249, 0.2)); +} + +.workspace-name { + color: var(--text-muted); + font-weight: 560; +} + +.global-search, +.repo-filter, +.input, +input, +textarea, +select { + border-radius: 9px; + border-color: var(--line); + transition: + border-color 150ms ease, + box-shadow 150ms ease, + background 150ms ease; +} + +.global-search:focus, +.repo-filter:focus, +.input:focus, +input:focus, +textarea:focus, +select:focus { + border-color: color-mix(in srgb, var(--primary) 58%, var(--line)); + box-shadow: 0 0 0 3px var(--primary-soft); +} + +.connection-chip { + min-height: 31px; + padding-inline: 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--surface-2) 82%, transparent); +} + +.sidebar { + border-right-color: var(--line-soft); + background: + linear-gradient(180deg, rgba(110, 231, 249, 0.025), transparent 28%), + color-mix(in srgb, var(--surface-1) 96%, var(--bg)); +} + +.primary-nav { + padding: 14px 10px 12px; + gap: 4px; +} + +.primary-nav button, +.repo-row { + border-radius: 9px; + transition: + color 150ms ease, + background 150ms ease, + transform 150ms ease; +} + +.primary-nav button { + min-height: 38px; + padding-inline: 11px; +} + +.primary-nav button.active { + color: var(--text); + background: linear-gradient( + 90deg, + rgba(14, 165, 233, 0.19), + rgba(14, 165, 233, 0.07) + ); + box-shadow: + inset 2px 0 var(--primary), + inset 0 0 0 1px rgba(110, 231, 249, 0.08); +} + +.repo-row { + margin: 1px 6px; + padding: 8px 9px; +} + +.repo-row:hover { + transform: translateX(2px); +} + +.repo-row.active { + background: linear-gradient( + 90deg, + rgba(14, 165, 233, 0.16), + rgba(14, 165, 233, 0.045) + ); + box-shadow: inset 0 0 0 1px rgba(110, 231, 249, 0.1); +} + +.sidebar-section > span, +.repo-group-label, +.eyebrow { + letter-spacing: 0.12em; + font-weight: 780; +} + +.page { + max-width: 1640px; + margin-inline: auto; + padding: 30px 34px 48px; +} + +.page-header { + margin-bottom: 26px; +} + +.page-header h1 { + font-size: clamp(24px, 2vw, 30px); + font-weight: 735; + letter-spacing: -0.04em; +} + +.page-header p { + margin-top: 8px; + font-size: 13px; + line-height: 1.6; +} + +.button, +.icon-button { + border-radius: 8px; + transition: + transform 140ms ease, + background 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + color 140ms ease; +} + +.button { + min-height: 34px; + padding-inline: 13px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-3) 82%, transparent), + var(--surface-2) + ); + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.035), + 0 1px 2px rgba(0, 0, 0, 0.14); +} + +.button:hover:not(:disabled), +.icon-button:hover:not(:disabled) { + transform: translateY(-1px); +} + +.button.primary { + background: linear-gradient(135deg, #0ea5e9, #2563eb); + border-color: rgba(110, 231, 249, 0.34); + box-shadow: + 0 8px 24px rgba(14, 165, 233, 0.2), + inset 0 1px rgba(255, 255, 255, 0.18); +} + +.button.primary:hover:not(:disabled) { + filter: brightness(1.08); + box-shadow: + 0 10px 30px rgba(14, 165, 233, 0.27), + inset 0 1px rgba(255, 255, 255, 0.2); +} + +.summary-grid { + gap: 12px; + border: 0; + overflow: visible; + background: transparent; +} + +.summary-card { + min-height: 130px; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.035), transparent 46%), + var(--surface-1); + box-shadow: var(--shadow-soft); + overflow: hidden; +} + +.summary-card::after { + content: ""; + position: absolute; + inset: auto -24px -42px auto; + width: 96px; + height: 96px; + border-radius: 50%; + background: currentColor; + opacity: 0.035; + filter: blur(3px); +} + +.summary-card:last-child { + border-right: 1px solid var(--line); +} + +.summary-value { + margin-top: 21px; + font-size: 34px; + font-weight: 760; +} + +.action-queue, +.panel { + border-color: var(--line); + box-shadow: var(--shadow-soft); + background: color-mix(in srgb, var(--surface-1) 96%, transparent); +} + +.queue-row { + min-height: 68px; + padding: 12px 15px; +} + +.queue-row:hover { + background: linear-gradient( + 90deg, + var(--surface-hover), + color-mix(in srgb, var(--surface-hover) 55%, transparent) + ); +} + +.queue-icon { + width: 32px; + height: 32px; + border-radius: 9px; +} + +.repo-header { + padding: 20px 22px 16px; + background: + linear-gradient(180deg, rgba(110, 231, 249, 0.025), transparent), + var(--surface-1); +} + +.repo-heading h1 { + font-size: 19px; + font-weight: 730; + letter-spacing: -0.03em; +} + +.repo-quick-actions { + margin: 12px 22px 14px; + padding: 10px; + border: 1px solid var(--line-soft); + border-radius: 11px; + background: color-mix(in srgb, var(--surface-2) 60%, transparent); +} + +.release-rail { + margin: 0 22px 12px; + border: 1px solid var(--line); + border-radius: 11px; + overflow: hidden; + background: var(--surface-0); +} + +.tabs { + padding-inline: 18px; + background: var(--surface-1); +} + +.tab.active { + color: var(--primary); + box-shadow: + inset 0 -2px var(--primary), + 0 5px 16px -12px var(--primary); +} + +.status-pill { + border-radius: 999px; + padding-inline: 9px; +} + +.modal-backdrop { + background: rgba(3, 7, 18, 0.74); + backdrop-filter: blur(10px) saturate(120%); +} + +.modal { + border-color: color-mix(in srgb, var(--line) 82%, var(--primary)); + border-radius: 16px; + box-shadow: + 0 36px 120px rgba(0, 0, 0, 0.62), + inset 0 1px rgba(255, 255, 255, 0.04); +} + +.modal-header { + background: + linear-gradient(180deg, rgba(110, 231, 249, 0.035), transparent), + var(--surface-1); +} + +.toast { + border-radius: 12px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(16px); +} + +.visual-page-header { + min-height: 142px; + position: relative; + overflow: hidden; + padding: 20px 22px; + border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); + border-radius: 16px; + background: + linear-gradient( + 105deg, + var(--surface-1) 0 52%, + color-mix(in srgb, var(--primary-soft) 62%, var(--surface-1)) + ), + var(--surface-1); + box-shadow: var(--shadow); +} + +.project-illustration { + --tilt-x: 0deg; + --tilt-y: 0deg; + width: 238px; + height: 128px; + position: relative; + flex: 0 0 238px; + perspective: 700px; + cursor: crosshair; + isolation: isolate; +} +.project-illustration svg { + width: 100%; + height: 100%; + position: relative; + z-index: 1; + overflow: visible; + transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y)); + filter: drop-shadow(0 16px 24px rgba(10, 20, 48, 0.2)); + transition: transform 140ms ease-out; +} +.illustration-glow { + width: 150px; + height: 85px; + position: absolute; + inset: 24px auto auto 49px; + border-radius: 50%; + background: color-mix(in srgb, var(--primary) 24%, transparent); + filter: blur(24px); + animation: illustration-breathe 4.8s ease-in-out infinite; +} +.project-illustration .orbit { + fill: none; + stroke: color-mix(in srgb, var(--primary) 48%, var(--line)); + stroke-width: 1.5; + stroke-dasharray: 4 7; +} +.project-illustration .orbit-b { + stroke: color-mix(in srgb, var(--success) 48%, var(--line)); +} +.illustration-core rect { + fill: color-mix(in srgb, var(--surface-1) 86%, var(--primary)); + stroke: color-mix(in srgb, var(--primary) 58%, var(--line)); + stroke-width: 1.5; +} +.illustration-core path { + fill: none; + stroke: var(--text-muted); + stroke-width: 3; + stroke-linecap: round; +} +.illustration-core circle { + fill: var(--success); + animation: illustration-pulse 2.4s ease-in-out infinite; +} +.illustration-node { + animation: illustration-float 4s ease-in-out infinite; +} +.illustration-node circle { + fill: var(--surface-1); + stroke: var(--primary); + stroke-width: 2; +} +.illustration-node path { + fill: none; + stroke: var(--primary); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.illustration-node.node-b { + animation-delay: -1.4s; +} +.illustration-node.node-c { + animation-delay: -2.7s; +} +.illustration-node.node-c circle { + fill: var(--warning); + stroke: color-mix(in srgb, var(--warning) 55%, var(--surface-1)); +} +.project-illustration .signal { + fill: var(--primary); +} +.project-illustration .signal-a { + offset-path: path("M32 92 C72 20 190 18 230 82"); + animation: signal-travel 3.6s linear infinite; +} +.project-illustration .signal-b { + fill: var(--success); + offset-path: path("M42 116 C98 150 190 136 222 60"); + animation: signal-travel 4.4s -2s linear infinite; +} +.illustration-label { + position: absolute; + right: 8px; + bottom: 3px; + z-index: 2; + color: var(--text-faint); + font: 650 9px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; +} +.project-illustration.repo { + width: 150px; + height: 82px; + position: absolute; + top: -9px; + left: 43%; + z-index: 0; + margin: 0; + opacity: 0.28; + pointer-events: none; +} +.project-illustration.repo .illustration-label { + display: none; +} +.illustrated-repo-header { + position: relative; + overflow: hidden; +} +.illustrated-repo-header > :not(.project-illustration) { + position: relative; + z-index: 1; +} + +@keyframes signal-travel { + to { + offset-distance: 100%; + } +} +@keyframes illustration-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-5px); + } +} +@keyframes illustration-breathe { + 0%, + 100% { + opacity: 0.55; + transform: scale(0.92); + } + 50% { + opacity: 0.95; + transform: scale(1.08); + } +} +@keyframes illustration-pulse { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 1; + } +} + +html[data-theme="light"] .visual-page-header { + background: + radial-gradient( + circle at 74% 20%, + rgba(102, 82, 235, 0.12), + transparent 28% + ), + linear-gradient( + 105deg, + rgba(255, 255, 255, 0.96) 0 52%, + rgba(229, 239, 255, 0.94) + ); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } + .project-illustration svg { + transform: none !important; + } + .diff-atmosphere { + transform: none !important; + } +} + +@media (max-width: 1180px) { + :root { + --sidebar: 264px; + --action-panel: 340px; + } + .page { + padding-inline: 24px; + } + .summary-grid { + gap: 8px; + } + .project-illustration { + width: 190px; + flex-basis: 190px; + } + .project-illustration.repo { + display: none; + } +} + +/* Help center and compact repository-shell safeguards */ +.panel-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} + +.help-page { + max-width: 1500px; +} +.help-hero { + position: relative; + isolation: isolate; + display: flex; + justify-content: space-between; + gap: 32px; + overflow: hidden; + margin-bottom: 24px; + padding: 30px 34px; + border: 1px solid var(--line); + border-radius: 18px; + background: + radial-gradient(circle at 78% 15%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 32%), + linear-gradient(125deg, color-mix(in srgb, var(--surface-2) 92%, transparent), var(--surface-1)); + box-shadow: 0 22px 65px -48px color-mix(in srgb, var(--accent) 65%, transparent); +} +.help-hero::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: radial-gradient(color-mix(in srgb, var(--text-faint) 20%, transparent) 0.8px, transparent 0.8px); + background-size: 18px 18px; + mask-image: linear-gradient(90deg, transparent, black 65%, transparent); + opacity: 0.42; +} +.help-hero > div:first-child { + flex: 1; + min-width: 0; + max-width: 750px; +} +.help-hero h1 { + margin: 8px 0 6px; + font-size: clamp(28px, 3vw, 42px); + line-height: 1.1; + letter-spacing: -0.045em; +} +.help-hero p { + max-width: 680px; + margin: 0; + color: var(--text-muted); + font-size: 13px; + line-height: 1.6; +} +.help-search { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + max-width: 680px; + margin-top: 22px; + padding: 0 12px; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); + border-radius: 11px; + background: color-mix(in srgb, var(--surface-0) 90%, transparent); + box-shadow: 0 12px 36px -30px var(--accent); +} +.help-search:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); +} +.help-search > .icon { + color: var(--primary); +} +.help-search input { + min-width: 0; + height: 46px; + border: 0; + outline: 0; + background: transparent; + color: var(--text); +} +.help-search kbd { + padding: 3px 6px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-2); + color: var(--text-faint); + font: 10px var(--font-mono); +} +.help-layout { + display: grid; + grid-template-columns: minmax(190px, 240px) minmax(0, 1fr); + align-items: start; + gap: 20px; +} +.help-navigation { + position: sticky; + top: 0; + display: grid; + gap: 16px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 14px; + background: color-mix(in srgb, var(--surface-1) 94%, transparent); +} +.help-category { + display: grid; + gap: 3px; +} +.help-category > strong { + padding: 4px 8px; + color: var(--text-faint); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.help-category button { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 8px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + text-align: left; + font-size: 11px; + line-height: 1.3; +} +.help-category button:hover, +.help-category button.active { + background: color-mix(in srgb, var(--accent) 10%, var(--surface-2)); + color: var(--text); +} +.help-category button.active { + box-shadow: inset 2px 0 var(--primary); +} +.help-category button .icon { + flex: 0 0 auto; + width: 15px; + height: 15px; + color: var(--primary); +} +.help-results { + min-width: 0; +} +.help-results-header { + display: flex; + justify-content: space-between; + align-items: end; + min-height: 48px; + margin-bottom: 10px; +} +.help-results-header h2 { + margin: 0 0 4px; + font-size: 18px; +} +.help-results-header span { + color: var(--text-faint); + font-size: 11px; +} +.help-topic { + overflow: hidden; + margin-bottom: 10px; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--surface-1); + transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease; + scroll-margin-top: 14px; +} +.help-topic:hover { + border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); +} +.help-topic[open] { + border-color: color-mix(in srgb, var(--accent) 26%, var(--line)); + box-shadow: 0 18px 42px -40px var(--accent); +} +.help-topic summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 16px 18px; + cursor: pointer; + list-style: none; +} +.help-topic summary::-webkit-details-marker { + display: none; +} +.help-topic summary > span:nth-child(2) { + display: grid; + gap: 3px; + min-width: 0; +} +.help-topic summary small { + color: var(--primary); + font-size: 9px; + font-weight: 750; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.help-topic summary strong { + font-size: 14px; +} +.help-topic summary span span { + overflow: hidden; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; + text-overflow: ellipsis; +} +.help-topic-icon { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line)); + border-radius: 11px; + background: color-mix(in srgb, var(--accent) 9%, var(--surface-2)); + color: var(--primary); +} +.help-topic-icon .icon { + width: 19px; + height: 19px; +} +.help-chevron { + color: var(--text-faint); + transition: transform 150ms ease; +} +.help-topic[open] .help-chevron { + transform: rotate(90deg); +} +.help-topic-body { + padding: 0 18px 18px 70px; + border-top: 1px solid var(--line-soft); +} +.help-topic-body ol { + display: grid; + gap: 11px; + margin: 16px 0; + padding-left: 22px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.55; +} +.help-topic-body li::marker { + color: var(--primary); + font-family: var(--font-mono); + font-weight: 700; +} +.help-note { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 11px 12px; + border: 1px solid color-mix(in srgb, var(--success) 20%, var(--line)); + border-radius: 9px; + background: color-mix(in srgb, var(--success) 6%, var(--surface-0)); + color: var(--text-muted); + font-size: 11px; + line-height: 1.5; +} +.help-note .icon { + flex: 0 0 auto; + color: var(--success); +} + +@media (max-width: 900px) { + .help-layout { + grid-template-columns: 1fr; + } + .help-navigation { + position: static; + display: none; + } + .help-hero .project-illustration { + display: none; + } +} +@media (max-width: 680px) { + .help-hero { + padding: 22px; + } + .help-search kbd { + display: none; + } + .help-search { + grid-template-columns: auto minmax(0, 1fr); + } + .help-topic-body { + padding-left: 18px; + } +} diff --git a/src/renderer/views.js b/src/renderer/views.js new file mode 100644 index 0000000..0048068 --- /dev/null +++ b/src/renderer/views.js @@ -0,0 +1,875 @@ +function navButton(view, label, iconName, count = "") { + return ``; +} + +function renderTitlebar() { + const state = ui.boot?.state; + const user = state?.gitea?.user; + const connected = Boolean(state?.gitea?.hasToken); + const repository = selectedRepository(); + const title = + ui.currentView === "repository" && repository + ? repository.fullName + : { + overview: "Release overview", + deployments: "Deployments", + diagnostics: "Diagnostics", + settings: "Settings", + help: "Help center", + "deployment-run": "Deployment run", + }[ui.currentView] || "Workspace"; + return `
+
ForgeFlowby ITWorx.tech
${escapeHtml(title)}
+
+ +
${icon("search")}
+ ${connected ? escapeHtml(user?.login || "Gitea") : "Offline"} + + +
+
`; +} + +function renderRepositoryRow(repository) { + const status = repository.localStatus; + const profiles = repository.deploymentProfiles || []; + const workloads = linkedWorkloadsForRepository(repository); + const runningWorkloads = workloads.filter((workload) => workload.runtime?.running); + const badges = []; + if (status?.counts.conflicts) + badges.push('!'); + else if (status?.counts.changed) + badges.push( + `${status.counts.changed}`, + ); + if (status?.branch.ahead) + badges.push( + `↑${status.branch.ahead}`, + ); + if (status?.branch.behind) + badges.push( + `↓${status.branch.behind}`, + ); + if (repository.readyToDeploy) + badges.push( + '↗', + ); + if (profiles.length) + badges.push( + `S${profiles.length}`, + ); + if (!repository.localPath) + badges.push('—'); + const branch = status?.branch.head || repository.defaultBranch || "remote"; + return ``; +} + +function renderSidebar() { + const query = `${ui.search} ${ui.repoSearch}`.trim().toLowerCase(); + const repositories = ui.repositories.filter( + (repository) => + !query || + `${repository.name} ${repository.fullName} ${repository.description}` + .toLowerCase() + .includes(query), + ); + const favorites = repositories.filter((repository) => repository.favorite); + const others = repositories.filter((repository) => !repository.favorite); + const attention = ui.repositories.filter( + (repository) => + repository.attention || + repository.localStatus?.counts.changed || + repository.localStatus?.branch.ahead || + repository.readyToDeploy, + ).length; + const rows = (list) => list.map(renderRepositoryRow).join(""); + return ``; +} + +function renderSummaryCard(label, value, note, iconName, tone = "") { + return `
${icon(iconName)}
${label}
${value}
${note}
`; +} + +function queueActionFor(repository) { + const action = repositoryAction(repository); + const mapping = { + link: ["folder", "Link folder", "Local project is not connected", ""], + error: ["error", "Inspect problem", action.detail, "danger"], + conflict: ["warning", "Resolve conflicts", action.detail, "danger"], + commit: ["file", "Review & commit", action.detail, "warning"], + diverged: ["warning", "Resolve divergence", action.detail, "danger"], + pull: ["arrowDown", "Synchronize", action.detail, "warning"], + push: ["arrowUp", "Push commits", action.detail, ""], + configure: ["settings", "Configure deploy", action.detail, ""], + "branch-profile": ["branch", "Select profile", action.detail, ""], + deploy: ["rocket", "Deploy release", action.detail, "success"], + clean: ["check", "Synchronized", action.detail, "success"], + }; + return mapping[action.kind] || mapping.clean; +} + +function projectIllustration(kind = "flow") { + return ``; +} + +function renderOverview() { + const changed = ui.repositories.filter( + (repository) => repository.localStatus?.counts.changed, + ).length; + const unpushed = ui.repositories.filter( + (repository) => repository.localStatus?.branch.ahead, + ).length; + const deployable = ui.repositories.filter( + (repository) => repository.readyToDeploy, + ).length; + const unhealthy = ui.repositories + .flatMap((repository) => repository.deploymentProfiles || []) + .filter((profile) => profile.state?.healthy === false).length; + const queue = ui.repositories + .filter((repository) => repositoryAction(repository).kind !== "clean") + .slice(0, 8); + const recent = operations().slice(0, 7); + const active = recent.filter( + (operation) => !isTerminalOperation(operation.status), + ); + return `
+ + ${ui.refreshError ? `
${icon("error")} ${escapeHtml(ui.refreshError)}
` : ""} + ${ui.refreshWarning ? `
${icon("warning")} ${escapeHtml(ui.refreshWarning)}
` : ""} +
+ ${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")} + ${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")} + ${renderSummaryCard("Ready", deployable, "exact commits ready to deploy", "rocket", deployable ? "success" : "")} + ${renderSummaryCard("Health", unhealthy || active.length, unhealthy ? "unhealthy environments" : active.length ? "operations in progress" : "all checked environments healthy", "pulse", unhealthy ? "danger" : active.length ? "warning" : "success")} +
+

Action queue

Sorted by required attention
+ ${ + queue.length + ? queue + .map((repository) => { + const [iconName, label, reason, tone] = + queueActionFor(repository); + return `
${icon(iconName)}
${escapeHtml(repository.name)}
${escapeHtml(repository.localStatus?.branch.head || repository.defaultBranch || "remote")} ${repository.localStatus?.shortHead ? `• ${repository.localStatus.shortHead}` : ""}
${escapeHtml(label)}${escapeHtml(reason)}
`; + }) + .join("") + : '
✓

Everything is synchronized

No repository needs immediate attention.

' + } +
+
+

Recent deployments

${recent.length ? recent.map((operation) => `
${escapeHtml(operation.repository)} → ${escapeHtml(operation.environment || "environment")}
${escapeHtml(operation.action === "rollback" ? "Rollback" : "Deploy")} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} · ${escapeHtml(operation.status)}
${formatDate(operation.updatedAt || operation.createdAt)}
`).join("") : '

No deployment history yet.

'}
+

Workspace readiness

+ ${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)} + ${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")} + ${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)} + ${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Local every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s · Gitea every ${ui.boot.state.preferences?.fetchIntervalMinutes || "manual"}${ui.boot.state.preferences?.fetchIntervalMinutes ? " min" : ""}`)} +
+
+
`; +} + +function readinessRow(label, ok, detail) { + return `
${escapeHtml(label)}${escapeHtml(detail)}
`; +} +function releaseNode(label, value, description, tone = "") { + return `
${label}
${escapeHtml(value)}${escapeHtml(description)}
`; +} + +function linkedWorkloadsForRepository(repository) { + const fullName = String(repository?.fullName || "").toLowerCase(); + if (!fullName) return []; + return (ui.serverDiscovery || []).flatMap((server) => + (server.workloads || []) + .filter((workload) => String(workload.link?.repositoryFullName || "").toLowerCase() === fullName) + .map((workload) => ({ ...workload, serverId: server.serverId, serverName: server.serverName || server.server?.name || "Server" })), + ); +} + +function fileStatusCode(file) { + if (file.conflict) return "U"; + if (file.untracked) return "?"; + return ( + { + modified: "M", + added: "A", + deleted: "D", + renamed: "R", + copied: "C", + "type-changed": "T", + }[file.status] || "M" + ); +} + +function renderChanges(repository) { + const status = repository.localStatus; + if (!repository.localPath) { + const target = displayCloneTarget(repository); + return `
${icon("link")}

Connect a local project

Clone directly into your default project root, or link an existing working tree.

${target ? `
${icon("folder")}Automatic destination${escapeHtml(target)}
` : '
No default project root is configured. ForgeFlow will ask for one.
'}
`; + } + if (!status) + return `
${icon("error")}

Repository unavailable

${escapeHtml(repository.attentionReason || "The local working tree could not be read.")}

`; + if (!status.files.length) + return `
${icon("check")}

Working tree clean

Local ${escapeHtml(status.branch.head)} is at ${escapeHtml(status.shortHead)} with no uncommitted files.

`; + const selected = status.files.find((file) => file.path === ui.selectedFile); + const conflictActions = selected?.conflict + ? `
Conflicted file

Choose one side, or edit the file and mark it resolved.

` + : ""; + return `
${ui.selectedFiles.size} selected · ${status.counts.changed} changed · ${status.counts.staged} staged
${status.files.map((file) => `
${fileStatusCode(file)}${escapeHtml(file.path)}${file.staged ? "●" : "○"}
`).join("")}
${status.counts.conflicts ? `
` : ""}
${conflictActions}
${escapeHtml(ui.selectedFile || "Select a file")}
${ui.diffHunks?.partialSupported ? `` : ""}${ui.selectedFile ? `` : ""}${ui.selectedFile ? escapeHtml(selected?.status || "") : ""}
${renderDiff(ui.diff)}
`; +} + +function renderHistory(repository) { + if (!repository.localPath) + return '

Link a local repository to view commit history.

'; + if (!ui.history.length) + return `
${icon("history")}

Load local commit history

Review the last commits from this working tree.

`; + return `
${ui.history.map((commit) => ``).join("")}
CommitMessageAuthorDate
${escapeHtml(commit.shortSha)}${escapeHtml(commit.subject)}${escapeHtml(commit.author)}${formatDate(commit.date)}
`; +} + +function environmentState(profile) { + const state = profile.state || {}; + if (state.healthy === false) return { label: "Unhealthy", tone: "danger" }; + if (state.healthy === true) return { label: "Healthy", tone: "success" }; + if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" }; + if (state.containerRunning === false) return { label: "Stopped", tone: "danger" }; + if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured) + return { label: "Not checked", tone: "" }; + return { label: "Status not configured", tone: "" }; +} + +function dockerManIntegration(profile) { + const state = profile.state || {}; + const iconMode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); + const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort); + const iconExpected = iconMode !== "none"; + const templateReady = Boolean(state.dockerMan?.templateExists); + const webUiReady = + !webUiExpected || Boolean(state.dockerMan?.webUi) || templateReady; + const iconReady = + !iconExpected || Boolean(state.dockerMan?.icon) || templateReady; + return { + iconMode, + templateReady, + webUiReady, + iconReady, + ready: Boolean(state.containerRunning && webUiReady && iconReady), + }; +} + +function deploymentIdentity(profile, repository) { + const name = String( + profile.state?.containerName || + profile.containerName || + profile.remoteFolder || + repository.name || + "container", + ); + let hash = 0; + for (const character of name) + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 }; +} + +function renderProfileCard(repository, profile, compact = false) { + const state = profile.state || {}; + const health = environmentState(profile); + const isSsh = profile.provider === "ssh-unraid"; + const mode = deploymentMode(profile); + const verification = ui.serverGitVerifications[profile.id]; + const targetSha = deploymentTargetSha(repository, profile); + const ready = canDeploy(repository, profile); + const modeLabel = { + "push-bundle": "Direct copy", + "server-git": "Server pull from Gitea", + "monitor-only": "Monitor only", + }[mode] || mode; + const providerDetail = isSsh + ? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}` + : `${profile.workflowFile} · ${profile.branch}`; + const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile); + const dockerMan = dockerManIntegration(profile); + const { templateReady, webUiReady, iconReady } = dockerMan; + const dockerManReady = dockerMan.ready; + const managesDockerMan = isSsh && profile.manageDockerMan === true; + const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; + const identity = deploymentIdentity(profile, repository); + const syncLabel = isSsh + ? state.matchesGitea + ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` + : state.liveSha && state.giteaSha + ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` + : state.liveSha ? `${icon("check")}Live · ${shortSha(state.liveSha)}` : "" + : state.matchesGitea + ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` + : state.giteaSha && state.liveSha + ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` + : ""; + const dockerManLabel = managesDockerMan + ? dockerManReady + ? templateReady + ? "Managed labels/template active" + : "Managed labels active" + : `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}` + : "Existing DockerMan template preserved"; + const sourceLabel = isSsh + ? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD" + : state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"; + const serverAccessAction = isSsh && mode === "server-git" + ? `` + : ""; + return `
${escapeHtml(identity.initial)}
Container${escapeHtml(identity.name)}${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}
${syncLabel}
${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${isSsh ? `` : ""}${serverAccessAction}${webUi ? `` : ""}${managesDockerMan ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; +} +function renderRepositoryDeployments(repository) { + const profiles = repository.deploymentProfiles || []; + const workloads = linkedWorkloadsForRepository(repository); + const profileIds = new Set(profiles.map((profile) => profile.id)); + const workloadRows = workloads.map((workload) => { + const containers = (workload.containers || []).map((container) => container.name).filter(Boolean); + const profileResolved = Boolean(workload.link?.profileId && profileIds.has(workload.link.profileId)); + return `
${escapeHtml(workload.displayName || containers[0] || "Server workload")}${escapeHtml(workload.serverName)} · ${containers.length ? escapeHtml(containers.join(", ")) : "container identity unavailable"} · ${workload.runtime?.running ? "running" : "stopped"}${escapeHtml(workload.compose?.project ? `Compose ${workload.compose.project}` : workload.remoteFolderCandidate || "Docker workload")}
${profileResolved ? "Repository linked" : "Link needs reconciliation"}${profileResolved ? `` : ``}
`; + }).join(""); + const repoOps = repositoryOperations(repository).slice(0, 10); + return `

Deployment environments

${profiles.length} configured profile${profiles.length === 1 ? "" : "s"} · ${workloads.length} server workload${workloads.length === 1 ? "" : "s"} linked to this repository
${workloads.length ? `

Detected on server

Live Docker / Compose identities resolved back to this repository
${workloadRows}
` : ""}${profiles.length ? `
${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}
` : '
↗

No deployment profile

Connect a Gitea Actions workflow or a trusted SSH / Unraid server.

'}

Release history

${repoOps.length ? `${repoOps.map((operation) => ``).join("")}
ActionEnvironmentCommitStatusUpdated
${escapeHtml(operation.action || "deploy")}${escapeHtml(operation.environment)}${escapeHtml(operation.shortSha || shortSha(operation.sha))}${escapeHtml(operation.status)}${formatDate(operation.updatedAt || operation.createdAt)}
` : '

No releases for this repository yet.

'}
`; +} + +function renderGitTools(repository) { + if (!repository.localPath) + return '

Link a local repository to manage branches and stashes.

'; + const recovery = ui.gitRecovery; + const locks = recovery?.lockReport?.locks || []; + const activeProcesses = recovery?.lockReport?.processes?.active || []; + const recommendations = recovery?.recommendations || []; + const status = repository.localStatus || {}; + const branchRows = ui.branches.length + ? ui.branches.map((branch) => `
${escapeHtml(branch.name)}${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}
${branch.current ? 'Current' : ``}
`).join("") + : '

Load branch information.

'; + const stashRows = ui.stashes.length + ? ui.stashes.map((stash) => `
${escapeHtml(stash.ref)}${escapeHtml(stash.subject)} · ${formatDate(stash.date)}
${stash.quarantined ? `Codex review required` : ``}
`).join("") + : '

No stashes, or Git tools have not been loaded.

'; + const recoveryBody = recovery + ? `
${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}
${locks.length ? `
${locks.map((lock) => `
${escapeHtml(lock.name)}${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}
`).join("")}
` : ""}${recommendations.length ? `
${recommendations.map((item) => `
${escapeHtml(item.label)}${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}
${item.action ? `` : ""}
`).join("")}
` : ""}` + : '

Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.

'; + const syncState = status.counts?.changed + ? `${status.counts.changed} local file${status.counts.changed === 1 ? "" : "s"} need protection` + : status.branch?.ahead || status.branch?.behind + ? `${status.branch.ahead || 0} ahead · ${status.branch.behind || 0} behind` + : "Preview against Gitea before changing files"; + + return `
+

Branches

${branchRows}
+

Stashes

${stashRows}
+

Gitea workspace sync

Make tracked files match the current upstream branch exactly
${escapeHtml(syncState)}

Safe mirror, never silent overwrite

ForgeFlow fetches Gitea, previews additions, changes and deletions, then protects local Codex work before resetting. Local commits go to a recovery branch; modified and untracked files go to a stash.

${icon("shield")}Ignored runtime data such as .env, dependency folders and local databases is preserved. Background awareness only fetches; it never applies this sync automatically.
${escapeHtml(status.branch?.head || "No branch")} → ${escapeHtml(status.branch?.upstream || "No upstream")}
+

Repository troubleshooting

Safe, repository-specific recovery actions
${recoveryBody}
${repository.sshUrl && status.remoteUrl !== repository.sshUrl ? `` : ""}
Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.
+
`; +} + +function renderRepositorySettings(repository) { + const automaticTarget = displayCloneTarget(repository); + const currentOrigin = repository.localStatus?.remoteUrl || "Unavailable"; + const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || ""; + const originNeedsRepair = Boolean( + repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin, + ); + const pullRequests = ui.pullRequests || []; + return `

Repository identity

${desiredOrigin ? `
` : ""}
${originNeedsRepair ? `` : ""}${repository.localPath ? `` : ``}

Open pull requests

Live from Gitea
${pullRequests.length ? `
${pullRequests.map((pull) => `
#${pull.number} · ${escapeHtml(pull.title)}${escapeHtml(pull.head?.ref || pull.head?.label || "source")} → ${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}
`).join("")}
` : '

No open pull requests.

'}

Repository behavior

${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.
`; +} + +function renderGitValidator(repository) { + const report = ui.gitValidation; + if (!report) + return `
${projectIllustration("diagnostics")}
Repository assurance

Validate Git best practices

Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.

`; + const tone = + report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger"; + const groups = report.checks.reduce((grouped, check) => { + (grouped[check.category] ||= []).push(check); + return grouped; + }, {}); + const trend = report.trend || {}; + return `
${report.score}/ 100
${escapeHtml(report.policy?.label || "Standard")} policy · ${report.ready ? "release-ready" : "review required"}

${escapeHtml(report.grade)}

${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical

${trend.newlyFound?.length || 0} new · ${trend.resolved?.length || 0} resolved · ${trend.regressions?.length || 0} regressions · ${report.expiredSuppressions?.length || 0} expired exceptions

${projectIllustration("diagnostics")}
${Object.entries( + groups, + ) + .map( + ([category, checks]) => + `

${escapeHtml(category)}

${checks.filter((check) => check.status === "pass").length}/${checks.length} passed
${checks + .map((check) => { + const checkIndex = report.checks.indexOf(check); + return `
${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}
${escapeHtml(check.title)}

${escapeHtml(check.detail)}

${check.suppressed ? `Suppressed until ${formatDate(check.suppression.expiresAt)} · ${escapeHtml(check.suppression.reason)}` : check.expiredSuppression ? `Exception expired; finding is active again.` : ""}
${check.fixAction ? `` : check.status !== "pass" && !check.suppressed ? `` : `${check.suppressed ? "Suppressed" : check.status === "pass" ? "Best practice" : "Review"}`}
`; + }) + .join("")}
`, + ) + .join("")}
`; +} + +function renderRepositoryWorkspace(repository) { + const status = repository.localStatus; + const profiles = repository.deploymentProfiles || []; + const linkedWorkloads = linkedWorkloadsForRepository(repository); + const profile = selectedProfile(repository); + const profileWorkload = linkedWorkloads.find((workload) => workload.link?.profileId === profile?.id); + const serverState = profile?.state || {}; + const localTone = status?.counts.conflicts + ? "danger" + : status?.counts.changed + ? "warning" + : status + ? "success" + : ""; + const remoteTone = status?.branch.behind + ? "danger" + : status?.branch.ahead + ? "warning" + : status?.branch.upstream + ? "success" + : ""; + const serverTone = + serverState.healthy === false + ? "danger" + : serverState.healthy === true + ? "success" + : profileWorkload?.runtime?.running + ? "success" + : ""; + const content = ( + { + changes: renderChanges, + history: renderHistory, + deployments: renderRepositoryDeployments, + gittools: renderGitTools, + validator: renderGitValidator, + settings: renderRepositorySettings, + }[ui.repositoryTab] || renderChanges + )(repository); + const deploymentLinks = profiles.length + ? `
${icon("server")}Linked deployments
${profiles.map((item) => { + const workload = linkedWorkloads.find((candidate) => candidate.link?.profileId === item.id); + const itemState = item.state || {}; + const tone = itemState.healthy === false ? "danger" : itemState.healthy === true ? "success" : workload?.runtime?.running ? "success" : "warning"; + const identity = workload?.displayName || item.containerName || item.remoteFolder || item.environment; + return ``; + }).join("")}
` + : ""; + return `

${escapeHtml(repository.fullName)}

${escapeHtml(repository.localPath || "No local working tree linked")}

${projectIllustration("repo")}
+
+ ${repository.localPath ? `
${ui.branchProtection ? `${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}` : ""}
` : ""} +
${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : profile ? "Linked" : "Unknown", profileWorkload ? `${profileWorkload.displayName || profile.containerName || "Container"} · ${profileWorkload.runtime?.running ? "running" : "stopped"} on ${profileWorkload.serverName}` : profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "profile linked · awaiting live scan") : "No deployment profile", serverTone)}
+ ${deploymentLinks} +
+
${content}
`; +} + +function renderActionPanel(repository) { + const action = repositoryAction(repository); + const status = repository.localStatus; + const profile = selectedProfile(repository); + let body = ""; + if (action.kind === "link") { + const target = displayCloneTarget(repository); + body = `
${icon("link")}

${action.title}

${action.detail}

${target ? `
Project root${escapeHtml(defaultWorkspaceRoot())}New folder${escapeHtml(safeCloneFolderName(repository))}
` : '
No default project root is configured yet.
'}
`; + } else if (action.kind === "commit") { + const hasStagedSelection = status.counts.staged > 0; + const commitReady = Boolean( + (ui.selectedFiles.size || hasStagedSelection) && ui.commitMessage.trim(), + ); + const commitBlocker = + !ui.selectedFiles.size && !hasStagedSelection + ? "Select files or stage one or more hunks." + : !ui.commitMessage.trim() + ? "Enter a commit message to enable commit and push." + : ui.selectedFiles.size + ? "Ready to commit. ForgeFlow stages the selected files automatically." + : "Ready to commit only the reviewed staged hunks."; + body = `
${ui.selectedFiles.size ? `${ui.selectedFiles.size} of ${status.counts.changed} files selected` : `${status.counts.staged} staged file(s)`}Ctrl+Enter
${icon(commitReady ? "check" : "warning")}${escapeHtml(commitBlocker)}
Partial hunk staging is preserved when no complete files are selected.
`; + } else if (action.kind === "pull") + body = `
${icon("arrowDown")}

${action.title}

${action.detail}

`; + else if (action.kind === "push") + body = `
${icon("arrowUp")}

${action.title}

${action.detail}

`; + else if ( + action.kind === "diverged" || + action.kind === "conflict" || + action.kind === "error" + ) + body = `
${icon("error")}

${action.title}

${action.detail}

${action.kind === "diverged" ? `` : ""}
`; + else if (action.kind === "configure") + body = `
${icon("settings")}

${action.title}

${action.detail}

`; + else if (action.kind === "branch-profile") + body = `
${icon("branch")}

${action.title}

${action.detail}

${repository.deploymentProfiles.length > 1 ? `` : ""}
`; + else if (action.kind === "deploy") + body = `
${icon("rocket")}

Release ${escapeHtml(status.shortHead)}

${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.

${repository.deploymentProfiles.length > 1 ? `` : ""}
Local${escapeHtml(status.shortHead)}Gitea${escapeHtml(status.shortHead)}Target${escapeHtml(profile.environment)}
${profile.state?.previousSha && profile.rollbackWorkflowFile ? `` : ""}
`; + else + body = `
${icon("check")}

${action.title}

${action.detail}

${profile ? `` : ""}
`; + return ``; +} + +function renderServerInventory() { + const servers = ui.serverDiscovery || []; + const configuredServers = ui.boot?.state?.servers || []; + const hiddenClassifications = new Set(["backup", "release-folder", "system-container", "manually-excluded"]); + const visibleForServer = (server) => (server.workloads || []).filter((workload) => + workload.reviewDecisionStale || workload.classification?.type === "duplicate" || (!hiddenClassifications.has(workload.classification?.type) && (workload.link || workload.runtime?.running || ["ambiguous", "orphan-container", "stopped-application", "historical-compose", "stale-link", "monitor-only"].includes(workload.classification?.type))), + ); + const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link || workload.classification?.type === "stale-link" || workload.reviewDecisionStale).length, 0); + const serverCards = servers.map((server) => { + const capabilities = server.capabilities || {}; + const capabilityText = [ + capabilities.docker ? "Docker" : "Docker missing", + capabilities.compose ? "Compose" : "Compose missing", + capabilities.git ? "Git available" : "Git optional", + capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete", + ].join(" · "); + const errorBlock = server.error + ? `
${icon("error")}
Server scan failed

${escapeHtml(server.error)}

` + : ""; + const warnings = (server.warnings || []).map((warning) => `
${icon("warning")}${escapeHtml(warning)}
`).join(""); + const visibleWorkloads = visibleForServer(server); + const hiddenCount = Math.max(0, (server.workloads || []).length - visibleWorkloads.length); + const workloads = visibleWorkloads.length + ? visibleWorkloads.map((workload) => { + const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", "); + const topCandidate = workload.candidates?.[0]; + const linkedRepository = ui.repositories.find((repository) => String(repository.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase()); + const linkedProfile = linkedRepository?.deploymentProfiles?.find((profile) => profile.id === workload.link?.profileId); + const claimsLink = workload.status === "linked" || Boolean(workload.link); + const linked = Boolean(claimsLink && linkedRepository && linkedProfile) && workload.classification?.type !== "stale-link"; + const inconsistentLink = claimsLink && !linked; + const classification = workload.classification?.type || workload.status || "review"; + const statusTone = linked && !workload.reviewDecisionStale ? "success" : inconsistentLink || ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning"; + const detail = workload.compose?.project + ? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}` + : workload.dockerMan?.templatePath + ? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}` + : `Container installation · ${containers || "unnamed"}`; + const candidate = linked + ? `Linked to ${workload.link?.repositoryFullName || "repository"}` + : inconsistentLink + ? `Stored link cannot be resolved to a loaded repository profile` + : topCandidate + ? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || topCandidate.status || "review required"}` + : "No repository candidate; select one manually"; + const canQuickLink = !linked && topCandidate && ["exact", "strong"].includes(topCandidate.confidence) && Boolean(workload.remoteFolderCandidate); + const linkButton = canQuickLink + ? `` + : ``; + const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review"; + return `
${escapeHtml(workload.displayName)}${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}${escapeHtml(candidate)}${escapeHtml(inconsistentLink ? "Reconcile this inventory link before deployment" : evidenceNote)}${workload.metadata?.composeDefinitionError ? `Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}` : ""}
${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : inconsistentLink ? "Link unresolved" : classification)}${linked ? `` : inconsistentLink ? `` : linkButton}
`; + }).join("") + : `

${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}

`; + const resolvedLinks = visibleWorkloads.filter((workload) => { + const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase()); + return repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId); + }).length; + const unresolvedLinks = visibleWorkloads.filter((workload) => { + if (!(workload.status === "linked" || workload.link)) return false; + const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase()); + return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId); + }).length; + return `

${escapeHtml(server.serverName || server.server?.name || server.serverId)}

${server.running || 0} running · ${resolvedLinks} visible repository link${resolvedLinks === 1 ? "" : "s"}${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}
${server.error ? "Scan failed" : escapeHtml(capabilityText)}${server.error ? "" : ``}
${errorBlock}${warnings}
${workloads}
`; + }).join(""); + const empty = configuredServers.length + ? `

Server inventory has not completed

ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.

` + : `

No Unraid server configured

Add the server with password authentication and ForgeFlow can copy and deploy projects directly.

`; + return `

Server inventory

Live Docker, Compose and DockerMan discovery, linked to Gitea
${servers.length ? `
${serverCards}
` : empty}
${icon("shield")}Server pull fetches an exact Gitea commit through a repository-scoped read-only deploy key, validates Compose and only then promotes the release. Direct copy remains an explicit fallback.
`; +} + +function renderDeployments() { + const cards = ui.repositories.flatMap((repository) => + (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })), + ); + const active = operations().filter((operation) => !isTerminalOperation(operation.status)); + const missingDockerMan = cards.filter(({ profile }) => + profile.provider === "ssh-unraid" && + profile.manageDockerMan === true && + profile.state?.containerRunning && + !dockerManIntegration(profile).ready, + ); + return `
${active.length ? `
${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.
` : ""}${renderServerInventory()}

Linked deployment environments

Stable Compose identity, live container health and exact Gitea commit parity
${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '

No deployment environments configured

Scan a server and link an existing workload, or open a repository and add an environment.

'}

All operations

Newest first
${operations().length ? `${operations().map((operation) => ``).join("")}
RepositoryActionEnvironmentCommitStatusUpdated
${escapeHtml(operation.repository)}${escapeHtml(operation.action || "deploy")}${escapeHtml(operation.environment || "—")}${escapeHtml(operation.shortSha || shortSha(operation.sha))}${escapeHtml(operation.status)}${formatDate(operation.updatedAt || operation.createdAt)}
` : '

No operations recorded.

'}
`; +} + +const HELP_TOPICS = [ + { + id: "getting-started", + icon: "rocket", + category: "Basics", + title: "Start with a repository", + summary: "Connect Gitea, discover local projects and understand the Local → Gitea → Server flow.", + keywords: "setup connect token roots clone local remote overview", + steps: [ + "Open Settings and validate the Gitea URL and token.", + "Add the parent folders that contain your projects, then save and rescan.", + "Select a repository. The release rail shows local changes, Gitea parity and the linked server release.", + "Use Changes to review work; use Git tools for branches, synchronization and pull requests.", + ], + note: "ForgeFlow does not modify a project merely because you opened it or refreshed the overview.", + }, + { + id: "workspace-sync", + icon: "refresh", + category: "Git & Gitea", + title: "Make a local project match Gitea", + summary: "Clean tracked leftovers without losing local Codex work or ignored runtime data.", + keywords: "sync mirror pull reset deleted files cleanup stash recovery codex upstream dirty", + steps: [ + "Open the repository, choose Git tools and select Preview Gitea sync.", + "Review every file that will be added, changed or removed.", + "Choose Protect local work & synchronize only when the preview matches your intention.", + "ForgeFlow creates a recovery branch for local commits and a stash for modified or untracked files before matching the upstream commit.", + ], + note: "Ignored files such as .env, local databases and dependency folders remain untouched. Background awareness only fetches metadata and never applies a sync.", + }, + { + id: "changes", + icon: "file", + category: "Git & Gitea", + title: "Review, commit and publish changes", + summary: "Keep intentional local changes separate from remote updates.", + keywords: "changes stage hunk commit push branch pull request conflict", + steps: [ + "Review selected files or individual hunks in Changes.", + "Enter a clear commit message and commit the reviewed selection.", + "Fetch before publishing; if Gitea changed, synchronize or resolve divergence first.", + "Push directly only when branch protection permits it, otherwise create a pull request.", + ], + note: "The action panel explains the current blocker and only enables operations that are safe for the selected state.", + }, + { + id: "deployment-linking", + icon: "link", + category: "Deployments", + title: "Link server workloads to repositories", + summary: "Turn Docker, Compose and DockerMan evidence into one explicit repository deployment.", + keywords: "server inventory docker compose dockerman container detect linked review reconcile", + steps: [ + "Open Deployments and scan the configured server.", + "Review proposed matches. ForgeFlow uses Compose paths, Git provenance, labels and container metadata; names alone are not trusted.", + "Confirm Review & link for a correct candidate, or choose the repository manually.", + "Use Review reconciliation whenever a saved link conflicts with current server evidence.", + ], + note: "External and system containers remain monitoring-only until you explicitly link them. A linked workload also appears on the matching repository.", + }, + { + id: "deployments", + icon: "deploy", + category: "Deployments", + title: "Deploy an exact Gitea commit", + summary: "Validate, pull, build and promote a release without damaging the live service.", + keywords: "deploy release unraid preflight rollback health exact sha commit server pull", + steps: [ + "Open the repository Deployments tab and select the intended environment.", + "Run preflight and repair blocking configuration before deployment.", + "Deploy only the shown target commit. Server pull fetches that exact commit instead of an ambiguous latest branch state.", + "Follow the run stages and health result. Failed promotion keeps or restores the previous release where supported.", + ], + note: "Commit parity means Local, Gitea and Server identify the same revision; a running container alone does not prove a correct release.", + }, + { + id: "deploy-keys", + icon: "key", + category: "Deployments", + title: "Repair repository deploy keys", + summary: "Give the server read-only access to exactly the repository it must pull.", + keywords: "ssh key deploy key gitea permission server pull fingerprint authentication", + steps: [ + "Run deployment preflight for the environment.", + "Use the offered deploy-key repair when repository access is missing.", + "ForgeFlow creates or reuses a repository-scoped key, registers the public key read-only in Gitea and verifies access from the server.", + "Re-run preflight and deploy only after the server can resolve and fetch the target commit.", + ], + note: "The private key remains on the configured server. ForgeFlow does not copy your personal Gitea token into deployment commands.", + }, + { + id: "git-validator", + icon: "shield", + category: "Quality", + title: "Use Git Validator safely", + summary: "Find repository hygiene issues and apply only reviewed repairs.", + keywords: "validator hygiene gitignore readme license branch protection secrets large files fix repair", + steps: [ + "Open a repository and choose Git Validator.", + "Select the policy that fits the repository and run a fresh scan.", + "Open each finding to understand its evidence and recommended repair.", + "Preview repairable findings before applying them, then rescan to verify the result.", + ], + note: "A score is guidance, not proof of correctness. Repairs that can alter repository policy or files always require an explicit action.", + }, + { + id: "updates-diagnostics", + icon: "update", + category: "Maintenance", + title: "Update or troubleshoot ForgeFlow", + summary: "Install signed releases and collect useful diagnostics without exposing credentials.", + keywords: "update installer signed release diagnostics logs support error updater restart", + steps: [ + "Open Settings and choose Check now under ForgeFlow updates.", + "Download the signed packaged release, then choose Apply & restart.", + "If an operation fails, open Diagnostics and run the troubleshooter.", + "Export a diagnostic bundle when deeper inspection is needed; tokens, passwords and private keys are redacted.", + ], + note: "Binary auto-update is available only from a packaged installation. Source checkouts continue to use the normal development workflow.", + }, +]; + +function renderHelp() { + const query = String(ui.helpQuery || "").trim().toLowerCase(); + const topics = HELP_TOPICS.filter((topic) => + !query || `${topic.category} ${topic.title} ${topic.summary} ${topic.keywords} ${topic.steps.join(" ")} ${topic.note}`.toLowerCase().includes(query), + ); + const categories = [...new Set(HELP_TOPICS.map((topic) => topic.category))]; + const topicMarkup = topics.map((topic) => { + const isOpen = topic.id === ui.helpTopic || Boolean(query); + return `
${icon(topic.icon)}${escapeHtml(topic.category)}${escapeHtml(topic.title)}${escapeHtml(topic.summary)}${icon("chevron", "help-chevron")}
    ${topic.steps.map((step) => `
  1. ${escapeHtml(step)}
  2. `).join("")}
${icon("shield")}${escapeHtml(topic.note)}
`; + }).join(""); + return `
ForgeFlow guide

How can we help?

Clear, practical instructions for repositories, Gitea synchronization, server deployments and maintenance.

${projectIllustration("flow")}

${query ? `Search results` : "Everything you need to operate ForgeFlow"}

${topics.length} topic${topics.length === 1 ? "" : "s"}${query ? ` matching “${escapeHtml(ui.helpQuery)}”` : ""}
${topicMarkup || `

No help topic found

Try a broader term such as sync, deployment, keys, validator or update.

`}
`; +} + +function renderSettings() { + const state = ui.boot.state; + const prefs = state.preferences || {}; + const update = ui.updateStatus; + const servers = state.servers || []; + return `
+

Gitea connection

${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}
${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}
+

ForgeFlow updates

Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}
${icon(update?.available ? "download" : "check")}${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available${update.packaged ? "" : " in the source repository"}` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}
${update?.available && update.packaged && !update.downloaded ? `` : ""}${update?.downloaded ? `` : ""}
${icon("shield")}${update && !update.packaged ? "Source checkouts must be updated with Git after reviewing the exact commit. Integrated source replacement remains disabled until source archives are publisher-signed." : "Packaged updates require an Ed25519 publisher signature that binds the exact commit, artifact name, size and SHA-256 digest."}
+

SSH / Unraid servers

Credentials are encrypted locally; a new host fingerprint is shown before authentication.
${servers.length ? `
${servers.map((server) => `
${icon("server")}
${escapeHtml(server.name)}${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}
`).join("")}
` : '

No SSH server configured. Add your Unraid server before creating an SSH deployment profile.

'}
+

Git remote maintenance

Standardize linked repositories to the current Gitea SSH URLs.

This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.

+

Project roots

The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.

${state.workspaceRoots.map((root, index) => `
${index === 0 ? 'Default' : ""}
`).join("")}
+

Background awareness

Read-only remote awareness. Use 0 to disable; fetching never changes project files.
${icon("shield")}Remote awareness only fetches branch metadata. ForgeFlow never resets, cleans or overwrites a workspace in the background.
+

Desktop integration

Separate arguments with |. Placeholders: {path}, {file}, {line}
+

Encrypted configuration backup

Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.

+

Appearance

+

Danger zone

Reset removes local ForgeFlow configuration, repository links, profiles and operation history. It does not modify Git repositories or Gitea.

+
`; +} + +function preflightTone(status) { + return status === "pass" + ? "success" + : status === "fail" + ? "danger" + : status === "warning" + ? "warning" + : ""; +} + +function renderPreflightChecks( + report, + emptyMessage = "Run the preflight to verify this configuration.", +) { + if (!report?.checks?.length) + return `

${escapeHtml(emptyMessage)}

`; + return `
${report.checks.map((item) => `
${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}
${escapeHtml(item.label)}${escapeHtml(item.detail)}${item.help ? `${escapeHtml(item.help)}` : ""}${item.repairAction ? `` : ""}
${escapeHtml(item.status)}
`).join("")}
`; +} + +function renderDiagnostics() { + const prefs = ui.boot.state.preferences || {}; + const status = ui.diagnosticsStatus || ui.boot.diagnostics || {}; + const report = ui.systemPreflight; + const trouble = ui.troubleshooter; + const troubleRows = + trouble?.issues + ?.map( + (item, index) => + `
${icon(item.severity === "error" ? "error" : "warning")}
${escapeHtml(item.title)}${escapeHtml(item.repository || "System")} · ${escapeHtml(item.detail)}
${item.repairable ? `` : 'Manual review'}
`, + ) + .join("") || ""; + return `
+
${icon("shield")}Credentials are never added to the diagnostic bundle. Known runtime secrets are redacted again during export. You can inspect the ZIP before sharing it.
+
+

Log storage

${status.lastWriteError ? "Write error" : status.enabled ? "Recording" : "Disabled"}
Files${escapeHtml(status.fileCount ?? "—")}
Total size${escapeHtml(status.totalSize || "—")}
Latest event${status.latestAt ? formatDate(status.latestAt) : "None"}
Retention${escapeHtml(status.retentionDays || prefs.logRetentionDays || 14)} days
Location${escapeHtml(status.directory || "Unavailable")}
Level${escapeHtml(status.level || prefs.diagnosticLevel || "info")}
${status.lastWriteError ? `
Error${escapeHtml(status.lastWriteError)}
` : ""}
+

Recording policy

+
+

One-click troubleshooter

Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies
${trouble?.issues?.some((item) => item.repairable && item.safe) ? `` : ""}
${trouble ? `${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable` : "Run the troubleshooter to inspect all linked repositories and deployments."}
${troubleRows || '

No problems detected.

'}
+

System preflight

Git, writable storage, credential protection, folders and Gitea
${report ? `${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed` : "Not run in this session"}
${renderPreflightChecks(report)}
+

Export support bundle

Configuration summary, repository states, operations, preflight and redacted JSONL logs
${ui.lastDiagnosticBundle ? `
${icon("check")}
${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created

SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}

` : ""}
+

Operational audit log

Append-only release, pull-request and recovery events
${ui.auditEvents.length ? `${ui.auditEvents.map((item) => ``).join("")}
TimeEventRepositoryResult
${formatDate(item.timestamp)}${escapeHtml(item.event)}${escapeHtml(item.details?.repository || "—")}${escapeHtml(item.details?.result || item.details?.note || "—")}
` : '

Load the operational audit log.

'}
+
`; +} + +function renderPipelineView() { + const operation = ui.activeDeployment; + if (!operation) + return '

No deployment operation selected.

'; + const logs = (operation.logs || []).join("\n"); + return `

${escapeHtml(operation.status)}

${escapeHtml(operation.profileName || operation.workflowFile || "")} · ${escapeHtml(operation.shortSha || shortSha(operation.sha))}

${escapeHtml(operation.status)}
${(operation.stages || []).map((stage) => `
${stage.status === "complete" ? icon("check") : stage.status === "failed" ? icon("error") : stage.status === "active" ? icon("pulse") : icon("clock")}${escapeHtml(stage.label)}
`).join("")}
${operation.jobs?.length ? `

Runner jobs

${operation.jobs.map((job) => ``).join("")}
JobStatusStartedCompleted
${escapeHtml(job.name)}${escapeHtml(job.conclusion || job.status)}${job.startedAt ? formatDate(job.startedAt) : "—"}${job.completedAt ? formatDate(job.completedAt) : "—"}
` : ""}
Deployment output
${escapeHtml(logs || "Waiting for operation output…")}
${operation.failure ? `
${icon("error")}
${escapeHtml(operation.failure.stage)}

${escapeHtml(operation.failure.message)}

` : ""}
`; +} + +function renderStatusbar() { + const state = ui.boot?.state; + const repository = selectedRepository(); + const active = operations().filter( + (operation) => !isTerminalOperation(operation.status), + ).length; + return `
${icon("git")}${escapeHtml(ui.boot?.git?.version || "Git unavailable")}${icon("folder")}${state?.workspaceRoots?.length || 0} roots${repository?.localStatus ? `${icon("branch")}${escapeHtml(repository.localStatus.branch.head)}` : ""}
${ui.autoRefreshPending ? `${icon("refresh")}Change detected` : ""}${active ? `${icon("pulse")}${active} active` : ""}ForgeFlow ${escapeHtml(ui.boot?.appVersion || "")}
`; +} + +function renderSetup() { + const steps = ["Readiness", "Gitea", "Folders", "Discovery", "Ready"]; + let body = ""; + if (ui.setupStep === 0) { + body = `

Check this computer

ForgeFlow verifies Git, writable storage and protected credential support before you enter any connection details.

${icon("shield")}Your Gitea token is entered only inside this local desktop application. It is never included in diagnostic logs or support bundles.
${renderPreflightChecks(ui.systemPreflight, "Run the readiness check to verify this computer.")}
Available even before Gitea is connected.
`; + } else if (ui.setupStep === 1) { + body = `

Connect your Gitea instance

Enter the URL and a personal access token created on your own Gitea server. ForgeFlow validates it locally and stores it using operating-system encryption when available.

${ui.setupValidation ? `
${icon("check")}Connected as ${escapeHtml(ui.setupValidation.user.login)} · ${ui.setupValidation.repositoryCount} repositories · Gitea ${escapeHtml(ui.setupValidation.version || "version unknown")}
` : `
${icon("shield")}Use the narrowest permissions that allow repository reads and Actions workflow dispatch. The setup guide explains this without requiring you to share the token.
`}
`; + } else if (ui.setupStep === 2) { + body = `

Select development folders

Choose parent folders. ForgeFlow discovers Git working trees below them and matches their origin to Gitea.

${ui.setupDraft.roots.map((root, index) => `
`).join("")}
`; + } else if (ui.setupStep === 3) { + body = `

Discovering repositories

Inspecting local Git metadata. Generated folders and nested dependency trees are skipped.

Scanning configured folders…
`; + } else { + body = `

ForgeFlow is ready

${ui.setupDraft.discovered.length} local repositories were found. You can add deployment environments after opening a repository.

Gitea connected${escapeHtml(ui.setupDraft.baseUrl)} · ${escapeHtml(ui.setupDraft.user?.login || "user")}
Workspace discovery${ui.setupDraft.roots.length} root folder(s), ${ui.setupDraft.discovered.length} repository/repositories
Safe diagnosticsStructured local logs with credential redaction are enabled by default.
${ + ui.setupDraft.discovered.length + ? ui.setupDraft.discovered + .slice(0, 8) + .map( + (item) => + `
${icon(item.error ? "error" : "git")}
${escapeHtml(item.localPath.split(/[\\/]/).pop())}${escapeHtml(item.localPath)}
${item.error ? "Unreadable" : "Ready"}
`, + ) + .join("") + : '

No repositories found. You can link or clone repositories later.

' + }
`; + } + const nextAction = + ui.setupStep === 0 + ? ui.systemPreflight?.summary?.ready + ? '' + : '' + : ui.setupStep === 1 + ? '' + : ui.setupStep === 2 + ? `` + : ui.setupStep === 4 + ? '' + : ""; + return `
${body}
${nextAction}
`; +} diff --git a/src/shared/clone-target.cjs b/src/shared/clone-target.cjs new file mode 100644 index 0000000..3f39427 --- /dev/null +++ b/src/shared/clone-target.cjs @@ -0,0 +1,28 @@ +'use strict'; + +const path = require('node:path'); + +function cloneDirectoryName(remoteUrl) { + const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, ''); + const segment = raw.split(/[\\/:]/).filter(Boolean).at(-1) || 'repository'; + const name = segment.replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-'); + // A name made only of dots is not a usable directory. Windows strips trailing + // dots, so "..." would resolve back to the project root itself and slip past + // the escape check in resolveCloneTarget below. + return !name || /^\.+$/.test(name) ? 'repository' : name; +} + +function resolveCloneTarget(workspaceRoot, remoteUrl) { + const root = path.resolve(String(workspaceRoot || '')); + if (!String(workspaceRoot || '').trim()) throw new Error('A project root is required.'); + const target = path.resolve(root, cloneDirectoryName(remoteUrl)); + const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value; + const normalizedRoot = normalize(root); + const normalizedTarget = normalize(target); + if (normalizedTarget === normalizedRoot || !normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)) { + throw new Error('Clone target escapes the selected project root.'); + } + return { root, target, directoryName: path.basename(target) }; +} + +module.exports = { cloneDirectoryName, resolveCloneTarget }; diff --git a/src/shared/deployment-policy.cjs b/src/shared/deployment-policy.cjs new file mode 100644 index 0000000..dff0f44 --- /dev/null +++ b/src/shared/deployment-policy.cjs @@ -0,0 +1,44 @@ +'use strict'; + +function parseClock(value) { + const match = String(value || '').match(/^([01]\d|2[0-3]):([0-5]\d)$/); + if (!match) throw new Error('Maintenance window times must use HH:mm.'); + return Number(match[1]) * 60 + Number(match[2]); +} + +function normalizeMaintenanceWindows(windows) { + return (Array.isArray(windows) ? windows : []).slice(0, 20).map((window) => ({ + days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))], + start: String(window?.start || '00:00'), + end: String(window?.end || '23:59') + })).map((window) => ({ ...window, startMinutes: parseClock(window.start), endMinutes: parseClock(window.end) })); +} + +function isInsideWindow(window, date) { + const minutes = date.getHours() * 60 + date.getMinutes(); + if (window.startMinutes <= window.endMinutes) return window.days.includes(date.getDay()) && minutes >= window.startMinutes && minutes <= window.endMinutes; + if (minutes >= window.startMinutes) return window.days.includes(date.getDay()); + const previousDay = (date.getDay() + 6) % 7; + return minutes <= window.endMinutes && window.days.includes(previousDay); +} + +function evaluateDeploymentPolicy(profile, { now = new Date(), override = false, reason = '', note = '' } = {}) { + const cleanReason = String(reason || '').trim(); + const cleanNote = String(note || '').trim(); + const policy = profile?.deploymentPolicy || {}; + const windows = normalizeMaintenanceWindows(policy.maintenanceWindows); + const violations = []; + if (policy.frozen) violations.push(policy.freezeReason ? `Deployment frozen: ${policy.freezeReason}` : 'Deployment is frozen.'); + if (windows.length && !windows.some((window) => isInsideWindow(window, now))) violations.push('Current time is outside the configured maintenance windows.'); + if (policy.requireNote && !cleanNote) violations.push('A release note is required for this environment.'); + if (violations.length && override && !cleanReason) throw new Error('An override reason is required to bypass deployment policy.'); + if (violations.length && !override) { + const error = new Error(violations.join(' ')); + error.code = 'DEPLOYMENT_POLICY_BLOCKED'; + error.recoverable = true; + throw error; + } + return { allowed: true, overridden: violations.length > 0, violations, reason: cleanReason, note: cleanNote }; +} + +module.exports = { parseClock, normalizeMaintenanceWindows, isInsideWindow, evaluateDeploymentPolicy }; diff --git a/src/shared/git-status.cjs b/src/shared/git-status.cjs new file mode 100644 index 0000000..630c938 --- /dev/null +++ b/src/shared/git-status.cjs @@ -0,0 +1,93 @@ +'use strict'; + +function parseBranchHeader(line, branch) { + if (line.startsWith('# branch.oid ')) branch.oid = line.slice(13).trim(); + if (line.startsWith('# branch.head ')) branch.head = line.slice(14).trim(); + if (line.startsWith('# branch.upstream ')) branch.upstream = line.slice(18).trim(); + if (line.startsWith('# branch.ab ')) { + const match = line.match(/\+(\d+)\s+-(\d+)/); + if (match) { + branch.ahead = Number(match[1]); + branch.behind = Number(match[2]); + } + } +} + +function statusLabel(code) { + const map = { + M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied', + U: 'conflict', T: 'type-changed', '?': 'untracked', '!': 'ignored', '.': 'clean', ' ': 'clean' + }; + return map[code] || 'changed'; +} + +function buildFile(path, originalPath, xy, kind) { + const indexCode = xy?.[0] || '.'; + const worktreeCode = xy?.[1] || '.'; + const conflict = kind === 'u' || indexCode === 'U' || worktreeCode === 'U'; + const untracked = kind === '?'; + return { + path, + originalPath: originalPath || null, + indexCode, + worktreeCode, + staged: !untracked && indexCode !== '.' && indexCode !== ' ', + unstaged: untracked || (worktreeCode !== '.' && worktreeCode !== ' '), + untracked, + conflict, + status: conflict ? 'conflict' : untracked ? 'untracked' : statusLabel(worktreeCode !== '.' ? worktreeCode : indexCode) + }; +} + +function parsePorcelainV2(output) { + const branch = { oid: null, head: null, upstream: null, ahead: 0, behind: 0 }; + const files = []; + const entries = String(output || '').split('\0'); + + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (!entry) continue; + if (entry.startsWith('# ')) { + parseBranchHeader(entry, branch); + continue; + } + + const kind = entry[0]; + if (kind === '1') { + const parts = entry.split(' '); + const xy = parts[1]; + const path = parts.slice(8).join(' '); + files.push(buildFile(path, null, xy, kind)); + } else if (kind === '2') { + const parts = entry.split(' '); + const xy = parts[1]; + const path = parts.slice(9).join(' '); + const originalPath = entries[index + 1] || null; + index += 1; + files.push(buildFile(path, originalPath, xy, kind)); + } else if (kind === 'u') { + const parts = entry.split(' '); + const xy = parts[1]; + const path = parts.slice(10).join(' '); + files.push(buildFile(path, null, xy, kind)); + } else if (kind === '?' || kind === '!') { + const path = entry.slice(2); + if (kind === '?') files.push(buildFile(path, null, '??', kind)); + } + } + + return { + branch, + files, + counts: { + changed: files.length, + staged: files.filter((file) => file.staged).length, + unstaged: files.filter((file) => file.unstaged).length, + conflicts: files.filter((file) => file.conflict).length, + untracked: files.filter((file) => file.untracked).length + }, + clean: files.length === 0 + }; +} + +module.exports = { parsePorcelainV2, statusLabel }; diff --git a/src/shared/repository-match.cjs b/src/shared/repository-match.cjs new file mode 100644 index 0000000..6e47a15 --- /dev/null +++ b/src/shared/repository-match.cjs @@ -0,0 +1,39 @@ +'use strict'; + +function stripGitSuffix(value) { + return value.replace(/\.git$/i, '').replace(/^\/+|\/+$/g, ''); +} + +function normalizeRemoteUrl(remote) { + const raw = String(remote || '').trim(); + if (!raw) return null; + + const scp = raw.match(/^(?:[^@]+@)?([^:]+):(.+)$/); + if (scp && !raw.includes('://') && !/^[a-zA-Z]:[\\/]/.test(raw)) { + return { host: scp[1].toLowerCase(), path: stripGitSuffix(scp[2]).toLowerCase() }; + } + + try { + const url = new URL(raw); + return { host: url.hostname.toLowerCase(), path: stripGitSuffix(url.pathname).toLowerCase() }; + } catch { + return { host: '', path: stripGitSuffix(raw.replace(/\\/g, '/')).toLowerCase() }; + } +} + +function repositoryKey(repository) { + return String(repository?.full_name || `${repository?.owner?.login || repository?.owner || ''}/${repository?.name || ''}`) + .replace(/^\/+|\/+$/g, '') + .toLowerCase(); +} + +function matchRemoteToRepository(remote, repositories) { + const normalized = normalizeRemoteUrl(remote); + if (!normalized) return null; + return repositories.find((repository) => { + const key = repositoryKey(repository); + return normalized.path === key || normalized.path.endsWith(`/${key}`); + }) || null; +} + +module.exports = { normalizeRemoteUrl, repositoryKey, matchRemoteToRepository }; diff --git a/src/shared/semver.cjs b/src/shared/semver.cjs new file mode 100644 index 0000000..6a59cb3 --- /dev/null +++ b/src/shared/semver.cjs @@ -0,0 +1,32 @@ +'use strict'; + +function parseVersion(value) { + const match = String(value || '').trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/); + if (!match) return null; + return { + raw: String(value).trim(), + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] || '' + }; +} + +function compareVersions(leftValue, rightValue) { + const left = parseVersion(leftValue); + const right = parseVersion(rightValue); + if (!left || !right) throw new Error('Both versions must use semantic versioning (for example 1.2.3).'); + for (const key of ['major', 'minor', 'patch']) { + if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1; + } + if (left.prerelease === right.prerelease) return 0; + if (!left.prerelease) return 1; + if (!right.prerelease) return -1; + return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true }) > 0 ? 1 : -1; +} + +function isNewerVersion(candidate, current) { + return compareVersions(candidate, current) > 0; +} + +module.exports = { parseVersion, compareVersions, isNewerVersion }; diff --git a/src/shared/shell-verification.cjs b/src/shared/shell-verification.cjs new file mode 100644 index 0000000..a5b1f14 --- /dev/null +++ b/src/shared/shell-verification.cjs @@ -0,0 +1,94 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function normalizeRelativePosixPath(value) { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('Shell validation path must be a non-empty string.'); + } + const trimmed = value.trim(); + if (path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) { + throw new Error('Shell validation path must be relative to the project root.'); + } + const normalized = trimmed.replace(/\\/g, '/').replace(/^\.\//, ''); + if (normalized.split('/').some((segment) => segment === '..')) { + throw new Error('Shell validation path may not escape the project root.'); + } + return normalized; +} + +function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow-deploy') { + if (typeof root !== 'string' || !root.trim()) { + throw new Error('Project root is required for shell validation.'); + } + const relativeScriptPath = normalizeRelativePosixPath(scriptPath); + const scriptText = fs.readFileSync(path.join(root, ...relativeScriptPath.split('/')), 'utf8'); + return { + command: 'bash', + args: ['-n'], + options: { + cwd: root, + input: scriptText.replace(/\r\n?/g, '\n'), + encoding: 'utf8', + windowsHide: true + } + }; +} + +function bashSyntaxCheckFromTextInvocation(scriptText) { + if (typeof scriptText !== 'string' || !scriptText.trim()) { + throw new Error('Shell script text is required for syntax validation.'); + } + return { + command: 'bash', + args: ['-n'], + options: { + input: scriptText, + encoding: 'utf8', + windowsHide: true + } + }; +} + +function shouldRunExternalBash(platform = process.platform) { + return platform !== 'win32'; +} + +function validateShellScriptStructure(scriptText) { + if (typeof scriptText !== 'string' || !scriptText.trim()) { + throw new Error('Shell script text is required for structural validation.'); + } + if (scriptText.includes('\0')) { + throw new Error('Shell script may not contain NUL bytes.'); + } + const normalized = scriptText.replace(/\r\n/g, '\n'); + const firstLine = normalized.split('\n', 1)[0]; + if (!/^#!\/(?:usr\/bin\/env bash|bin\/bash)$/.test(firstLine)) { + throw new Error('Server deployment script must declare Bash in its shebang.'); + } + if (!/^set -E?euo pipefail$/m.test(normalized)) { + throw new Error('Server deployment script must enable strict Bash error handling.'); + } + for (const marker of [ + 'readonly CONFIG_FILE="/etc/forgeflow/targets.conf"', + 'Target configuration must be owned by root', + 'flock -n 9', + 'git -C "$APP_DIR" fetch', + 'git -C "$APP_DIR" reset --hard "$SHA"', + 'docker compose -f "$COMPOSE_FILE" up -d --build', + 'write_status "healthy"', + 'write_status "unhealthy"' + ]) { + if (!normalized.includes(marker)) { + throw new Error(`Server deployment script is missing required safety marker: ${marker}`); + } + } + return true; +} + +module.exports = { + bashSyntaxCheckInvocation, + bashSyntaxCheckFromTextInvocation, + normalizeRelativePosixPath, + shouldRunExternalBash, + validateShellScriptStructure +}; diff --git a/src/shared/tool-invocation.cjs b/src/shared/tool-invocation.cjs new file mode 100644 index 0000000..5463f0b --- /dev/null +++ b/src/shared/tool-invocation.cjs @@ -0,0 +1,42 @@ +'use strict'; + +function uniqueCandidates(candidates) { + const seen = new Set(); + return candidates.filter((candidate) => { + const key = JSON.stringify([candidate.file, candidate.args]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function npmProbeCandidates(options = {}) { + const platform = options.platform || process.platform; + const env = options.env || process.env; + const execPath = options.execPath || process.execPath; + const candidates = []; + + // npm exposes the exact CLI entry point while running an npm script. Calling + // it through Node avoids Windows' inability to exec .cmd shims directly. + if (env.npm_execpath) { + candidates.push({ + file: env.npm_node_execpath || execPath, + args: [env.npm_execpath, '--version'], + source: 'npm_execpath' + }); + } + + if (platform === 'win32') { + candidates.push({ + file: env.ComSpec || env.COMSPEC || 'cmd.exe', + args: ['/d', '/s', '/c', 'npm --version'], + source: 'windows-command-shim' + }); + } else { + candidates.push({ file: 'npm', args: ['--version'], source: 'path' }); + } + + return uniqueCandidates(candidates); +} + +module.exports = { npmProbeCandidates }; diff --git a/src/shared/validation.cjs b/src/shared/validation.cjs new file mode 100644 index 0000000..f40d608 --- /dev/null +++ b/src/shared/validation.cjs @@ -0,0 +1,130 @@ +'use strict'; + +const path = require('node:path'); + +function normalizeBaseUrl(value) { + const raw = String(value || '').trim().replace(/\/+$/, ''); + if (!raw) throw new Error('Gitea URL is required.'); + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.'); + if (url.username || url.password) throw new Error('Do not include credentials in the Gitea URL.'); + const loopback = new Set(['localhost', '127.0.0.1', '[::1]']); + if (url.protocol !== 'https:' && !loopback.has(url.hostname.toLowerCase())) { + throw new Error('Gitea must use HTTPS so access tokens are never sent over plaintext HTTP. Loopback HTTP is allowed for local development only.'); + } + url.hash = ''; + url.search = ''; + return url.toString().replace(/\/$/, ''); +} + +function assertSafeRepositoryPath(value) { + if (!value || typeof value !== 'string') throw new Error('A repository path is required.'); + if (value.includes('\0')) throw new Error('Invalid repository path.'); + return path.resolve(value); +} + +function assertRepositoryRelativePath(value) { + const filePath = String(value || ''); + if (!filePath || filePath.includes('\0')) throw new Error('A repository-relative file path is required.'); + const normalized = filePath.replace(/\\/g, '/'); + if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:\//.test(normalized)) throw new Error('Absolute file paths are not allowed.'); + if (normalized.split('/').some((segment) => segment === '..')) throw new Error('File path may not escape the repository.'); + return normalized.replace(/^\.\//, ''); +} + +function assertRepositoryRelativePaths(values) { + if (!Array.isArray(values)) return []; + return [...new Set(values.filter(Boolean).map(assertRepositoryRelativePath))]; +} + +function assertCommitMessage(value) { + const message = String(value || '').trim(); + if (!message) throw new Error('Enter a commit message.'); + if (message.length > 5000) throw new Error('Commit message is too long.'); + if (message.includes('\0')) throw new Error('Commit message contains an invalid character.'); + return message; +} + +function assertFullCommitSha(value) { + const sha = String(value || '').trim(); + if (!/^[a-f0-9]{40,64}$/i.test(sha)) throw new Error('A full commit SHA is required.'); + return sha.toLowerCase(); +} + +function assertWorkflowFile(value) { + const workflow = assertRepositoryRelativePath(String(value || '').trim()); + if (!/^[a-zA-Z0-9._/-]+\.ya?ml$/i.test(workflow)) throw new Error('Workflow file must be a YAML filename.'); + return workflow; +} + + +function assertBranchName(value) { + const branch = String(value || '').trim(); + if (!branch) throw new Error('A branch name is required.'); + if (branch.length > 255) throw new Error('The branch name is too long.'); + if (branch === '@' || branch.startsWith('-') || branch.startsWith('/') || branch.endsWith('/') || branch.endsWith('.')) throw new Error('The branch name is invalid.'); + if (branch.includes('..') || branch.includes('@{') || branch.includes('//') || /[\x00-\x20\x7f~^:?*\[\\]/.test(branch)) throw new Error('The branch name is invalid.'); + if (branch.split('/').some((part) => !part || part.startsWith('.') || part.endsWith('.lock'))) throw new Error('The branch name is invalid.'); + return branch; +} + +function assertEnvironmentName(value) { + const environment = String(value || '').trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(environment)) { + throw new Error('Environment must use 1-64 lowercase letters, numbers, dots, dashes or underscores.'); + } + return environment; +} + +function assertWorkflowFileName(value) { + const workflow = assertWorkflowFile(value); + if (workflow.includes('/')) throw new Error('Workflow must be a filename from .gitea/workflows, not a path.'); + return workflow; +} + +function assertDeploymentRequest(profile, sha) { + if (!profile) throw new Error('Deployment profile not found.'); + assertFullCommitSha(sha); + assertWorkflowFileName(profile.workflowFile); + assertBranchName(profile.branch); + assertEnvironmentName(profile.environment); + assertHttpUrl(profile.statusUrl, { label: 'Application status URL' }); +} + +function assertHttpUrl(value, { optional = false, label = 'URL', allowUnraidTemplate = false } = {}) { + const raw = String(value || '').trim(); + if (!raw && optional) return ''; + if (!raw) throw new Error(`${label} is required.`); + const validationValue = allowUnraidTemplate ? raw.replace(/\[IP\]/gi, '127.0.0.1').replace(/\[PORT(?::\d+)?\]/gi, '8080') : raw; + const url = new URL(validationValue); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`); + if (url.username || url.password) throw new Error(`${label} may not contain credentials.`); + return allowUnraidTemplate ? raw : url.toString(); +} + +function assertCloneRemote(value) { + const remote = String(value || '').trim(); + if (!remote || remote.includes('\0')) throw new Error('Clone URL is required.'); + const scp = /^(?:[^@\s]+@)?[^:\s]+:[^\s]+$/.test(remote) && !remote.includes('://'); + if (scp) return remote; + const url = new URL(remote); + if (!['http:', 'https:', 'ssh:', 'git:'].includes(url.protocol)) throw new Error('Unsupported Git remote protocol.'); + if (url.password) throw new Error('Do not include a password in the clone URL.'); + return remote; +} + +module.exports = { + normalizeBaseUrl, + assertSafeRepositoryPath, + assertRepositoryRelativePath, + assertRepositoryRelativePaths, + assertCommitMessage, + assertFullCommitSha, + assertWorkflowFile, + assertWorkflowFileName, + assertBranchName, + assertEnvironmentName, + assertDeploymentRequest, + assertHttpUrl, + assertCloneRemote +}; diff --git a/src/shared/zip-writer.cjs b/src/shared/zip-writer.cjs new file mode 100644 index 0000000..63c6cb4 --- /dev/null +++ b/src/shared/zip-writer.cjs @@ -0,0 +1,91 @@ +'use strict'; + +const zlib = require('node:zlib'); + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const day = date.getDate(); + const month = date.getMonth() + 1; + const dosDate = ((year - 1980) << 9) | (month << 5) | day; + return { time, date: dosDate }; +} + +function createZip(entries) { + const localParts = []; + const centralParts = []; + let offset = 0; + const stamp = dosDateTime(); + + for (const entry of entries) { + const name = Buffer.from(String(entry.name).replace(/\\/g, '/').replace(/^\/+/, ''), 'utf8'); + const source = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data ?? ''), 'utf8'); + const compressed = zlib.deflateRawSync(source, { level: 6 }); + const checksum = crc32(source); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(8, 8); + local.writeUInt16LE(stamp.time, 10); + local.writeUInt16LE(stamp.date, 12); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(source.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, name, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(stamp.time, 12); + central.writeUInt16LE(stamp.date, 14); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(source.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, name); + offset += local.length + name.length + compressed.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + return Buffer.concat([...localParts, centralDirectory, end]); +} + +module.exports = { createZip, crc32 }; diff --git a/tests/acceptance.test.mjs b/tests/acceptance.test.mjs new file mode 100644 index 0000000..607f683 --- /dev/null +++ b/tests/acceptance.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readAcceptanceConfig } from '../scripts/acceptance.mjs'; + +test('acceptance harness requires an explicit complete environment', () => { + assert.throws(() => readAcceptanceConfig({}), /Missing acceptance environment variables/); + const config = readAcceptanceConfig({ FORGEFLOW_GITEA_URL: 'https://gitea.test/', FORGEFLOW_GITEA_TOKEN: 'token', FORGEFLOW_REPOSITORY: 'owner/app', FORGEFLOW_LOCAL_PATH: 'C:/Projects/App', FORGEFLOW_BRANCH: 'main', FORGEFLOW_STATUS_URL: 'https://app.test/status', FORGEFLOW_HEALTH_URL: 'https://app.test/health' }); + assert.equal(config.baseUrl, 'https://gitea.test'); + assert.equal(config.workflow, 'deploy.yml'); + assert.throws(() => readAcceptanceConfig({ ...process.env, FORGEFLOW_GITEA_URL: 'x', FORGEFLOW_GITEA_TOKEN: 'x', FORGEFLOW_REPOSITORY: 'invalid', FORGEFLOW_LOCAL_PATH: 'x', FORGEFLOW_BRANCH: 'x', FORGEFLOW_STATUS_URL: 'x', FORGEFLOW_HEALTH_URL: 'x' }), /owner\/repository/); +}); diff --git a/tests/approved-deployment-evidence.test.mjs b/tests/approved-deployment-evidence.test.mjs new file mode 100644 index 0000000..67ebc46 --- /dev/null +++ b/tests/approved-deployment-evidence.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const workflowUrl = new URL('../examples/gitea-actions/forgeflow-approved-deploy.yml', import.meta.url); +const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url); + + +test('central approved workflow transports signed target evidence only to the root-owned deploy wrapper', async () => { + const workflow = await readFile(workflowUrl, 'utf8'); + + for (const input of [ + 'repository', + 'environment', + 'commit_sha', + 'request_id', + 'approval_id', + 'approval_fingerprint', + 'evidence_issued_at', + 'evidence_signature', + ]) { + assert.match(workflow, new RegExp(`\\b${input}:`)); + } + assert.match(workflow, /\$\{\{ inputs\.repository \}\}/); + assert.doesNotMatch(workflow, /\$\{\{ gitea\.repository \}\}/); + assert.match(workflow, /FF_APPROVAL_ID.*FF_REQUEST_ID/s); + assert.match(workflow, /sudo \/usr\/local\/bin\/forgeflow-deploy/); + assert.doesNotMatch(workflow, /actions\/checkout/); + assert.doesNotMatch(workflow, /docker compose/); + assert.doesNotMatch(workflow, /git\s+-C/); +}); + + +test('server wrapper verifies Ed25519 evidence before any live git or compose mutation', async () => { + const script = await readFile(deployUrl, 'utf8'); + const verifyIndex = script.indexOf('openssl pkeyutl -verify'); + const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"'); + const composeIndex = script.indexOf('docker compose -f "$COMPOSE_FILE" up -d --build'); + + assert.ok(verifyIndex > 0, 'expected cryptographic verification'); + assert.ok(resetIndex > verifyIndex, 'git reset must happen after evidence verification'); + assert.ok(composeIndex > verifyIndex, 'compose mutation must happen after evidence verification'); + assert.match(script, /EVIDENCE_PUBLIC_KEY_FILE="\/etc\/forgeflow\/evidence\.pub"/); + assert.match(script, /evidence_owner.*root/s); + assert.match(script, /8#022/); + assert.match(script, /EVIDENCE_ISSUED_AT >= now_epoch - 1800/); + assert.match(script, /"evidence_verified": \$EVIDENCE_VERIFIED/); + assert.match(script, /\(\( \$# == 3 \|\| \$# == 4 \|\| \$# == 8 \)\)/); +}); + + +test('signed message fields match the AppOps evidence v1 contract and exclude runner-chosen workflow/ref', async () => { + const script = await readFile(deployUrl, 'utf8'); + const marker = "printf 'forgeflow-evidence-v1\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n'"; + assert.ok(script.includes(marker)); + assert.match( + script, + /"\$APPROVAL_ID"[\s\\]+"\$APPROVAL_FINGERPRINT"[\s\\]+"\$REPOSITORY"[\s\\]+"\$ENVIRONMENT"[\s\\]+"\$\{SHA,,\}"[\s\\]+"\$REQUEST_ID"[\s\\]+"\$EVIDENCE_ISSUED_AT"/s, + ); + assert.doesNotMatch(script.slice(0, script.indexOf('APP_DIR=""')), /WORKFLOW|workflow|ref=/); +}); diff --git a/tests/approved-deployment-one-shot.test.mjs b/tests/approved-deployment-one-shot.test.mjs new file mode 100644 index 0000000..28bd943 --- /dev/null +++ b/tests/approved-deployment-one-shot.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url); + +test('a signed approved request is consumed once before target selection or mutation', async () => { + const script = await readFile(deployUrl, 'utf8'); + const verifyIndex = script.indexOf('openssl pkeyutl -verify'); + const consumeIndex = script.indexOf('mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID"'); + const targetIndex = script.indexOf('APP_DIR=""'); + const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"'); + + assert.ok(verifyIndex > 0); + assert.ok(consumeIndex > verifyIndex); + assert.ok(targetIndex > consumeIndex); + assert.ok(resetIndex > consumeIndex); + assert.match(script, /EVIDENCE_REPLAY_DIR="\/var\/lib\/forgeflow-status\/approved-requests"/); + assert.match(script, /install -d -o root -g root -m 0700 "\$EVIDENCE_REPLAY_DIR"/); + assert.match(script, /Approved deployment evidence was already consumed/); +}); diff --git a/tests/audit-service.test.mjs b/tests/audit-service.test.mjs new file mode 100644 index 0000000..9e5612d --- /dev/null +++ b/tests/audit-service.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import auditModule from '../src/main/audit-service.cjs'; + +const { AuditService } = auditModule; + +test('audit service appends ordered records and exports CSV', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-audit-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const audit = new AuditService({ userDataPath: root, appInfo: { version: 'test' } }); + await Promise.all([ + audit.append('deployment.requested', { repository: 'owner/app', sha: 'a'.repeat(40), note: 'Release, wave 1' }), + audit.append('deployment.completed', { repository: 'owner/app', result: 'success' }) + ]); + const entries = await audit.list(); + assert.equal(entries.length, 2); + assert.equal(entries[0].event, 'deployment.completed'); + const destination = path.join(root, 'audit.csv'); + const result = await audit.exportTo(destination, 'csv'); + assert.equal(result.count, 2); + assert.match(await fs.readFile(destination, 'utf8'), /"Release, wave 1"/); +}); diff --git a/tests/browser/forgeflow.spec.mjs b/tests/browser/forgeflow.spec.mjs new file mode 100644 index 0000000..7d6c395 --- /dev/null +++ b/tests/browser/forgeflow.spec.mjs @@ -0,0 +1,362 @@ +import { test, expect } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; + +const consoleEntries = new WeakMap(); +const runtimeErrors = new WeakMap(); + +test.beforeEach(async ({ page }, testInfo) => { + const logs = []; + const errors = []; + consoleEntries.set(page, logs); + runtimeErrors.set(page, errors); + await page.emulateMedia({ colorScheme: testInfo.project.metadata.theme, reducedMotion: testInfo.project.metadata.reduced ? "reduce" : "no-preference" }); + page.on("console", (message) => logs.push({ type: message.type(), text: message.text() })); + page.on("pageerror", (error) => errors.push({ name: error.name, message: error.message, stack: error.stack })); + await page.goto("/"); + await expect(page.locator(".app-shell")).toBeVisible(); + const theme = testInfo.project.metadata.theme; + await page.evaluate((requested) => { + document.documentElement.dataset.theme = requested; + localStorage.setItem("forgeflow-demo-theme", requested); + }, theme); +}); + +test.afterEach(async ({ page }, testInfo) => { + const logs = consoleEntries.get(page) || []; + const errors = runtimeErrors.get(page) || []; + if (testInfo.status !== testInfo.expectedStatus) { + const prefix = testInfo.outputPath("failure"); + await writeFile(`${prefix}-console.json`, JSON.stringify({ test: testInfo.title, project: testInfo.project.name, metadata: testInfo.project.metadata, logs, errors }, null, 2)); + await writeFile(`${prefix}-dom.html`, await page.content()); + await writeFile(`${prefix}-fixture.json`, JSON.stringify({ url: page.url(), viewport: page.viewportSize(), theme: await page.locator("html").getAttribute("data-theme") }, null, 2)); + } + expect(errors, "page errors").toEqual([]); + expect(logs.filter((entry) => entry.type === "error"), "console errors").toEqual([]); +}); + +async function assertSurface(page) { + const audit = await page.evaluate(() => { + const interactive = [...document.querySelectorAll('button,input,select,textarea,a[href],[role="button"]')].filter((element) => { + const style = getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden" && element.getBoundingClientRect().width > 0; + }); + const unnamed = interactive.filter((element) => !String(element.getAttribute("aria-label") || element.getAttribute("title") || element.labels?.[0]?.textContent || element.textContent || element.value || "").trim()); + const outside = interactive.filter((element) => { const rect = element.getBoundingClientRect(); const fixed = ["fixed", "sticky"].includes(getComputedStyle(element).position) || Boolean(element.closest('[role="dialog"]')); return rect.left < -1 || rect.right > innerWidth + 1 || (fixed && (rect.top < -1 || rect.bottom > innerHeight + 1)); }); + const text = document.body.innerText; + return { + horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + unnamed: unnamed.map((element) => element.outerHTML.slice(0, 180)), + outside: outside.map((element) => element.outerHTML.slice(0, 180)), + badTokens: ["undefined", "[object Object]", "â", "Â", "Ã"].filter((token) => text.includes(token)), + nullText: /(^|\s)null($|\s)/i.test(text), + headings: [...document.querySelectorAll("h1,h2,h3")].map((heading) => Number(heading.tagName[1])), + }; + }); + expect(audit.horizontalOverflow).toBe(false); + expect(audit.unnamed).toEqual([]); + expect(audit.outside).toEqual([]); + expect(audit.badTokens).toEqual([]); + expect(audit.nullText).toBe(false); + expect(audit.headings.length).toBeGreaterThan(0); +} + +async function assertScrollableWhenOverflowing(page, selector) { + const target = page.locator(selector); + await expect(target).toBeVisible(); + await expect(target).toHaveCSS("overflow-y", /auto|scroll/); + let metrics; + await expect.poll(async () => { + metrics = await target.evaluate((element) => ({ + connected: element.isConnected, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + return metrics.connected && metrics.clientHeight > 0; + }).toBe(true); + if (metrics.scrollHeight > metrics.clientHeight + 1) { + await expect.poll(() => target.evaluate((element) => { + if (element.scrollHeight <= element.clientHeight + 1) return 1; + element.scrollTop = element.scrollHeight; + return element.scrollTop; + })).toBeGreaterThan(0); + } +} +test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => { + await assertSurface(page); + for (const view of ["overview", "deployments", "settings", "help"]) { + await page.locator(`.nav-button[data-action="navigate"][data-view="${view}"]`).click(); + await expect(page.locator("main")).toBeVisible(); + await assertSurface(page); + } + await expect(page.locator("html")).toHaveAttribute("data-theme", String(testInfo.project.metadata.theme)); + if (testInfo.project.metadata.reduced) { + expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true); + } +}); + +test("repository changes, Git tools and Git Validator complete their primary flow", async ({ page }) => { + await page.locator('[data-action="select-repo"]').first().click(); + await expect(page.locator('[data-action="repo-tab"]')).toHaveCount(6); + for (const tab of ["changes", "history", "deployments", "gittools", "validator", "settings"]) { + const control = page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`); + if (await control.count()) { + await control.click(); + await expect(control).toHaveClass(/active/); + await assertSurface(page); + } + } + await page.locator('[data-action="repo-tab"][data-tab="validator"]').click(); + await expect(page.locator(".validator-score")).toBeVisible(); + await assertScrollableWhenOverflowing(page, ".validator-page"); + await expect(page.locator("#validator-policy")).toBeVisible(); + await page.locator("#validator-policy").selectOption("production"); + await expect(page.locator(".validator-hero")).toContainText(/Production policy/i); + await expect(page.locator(".validator-hero")).toContainText(/review required/i); + await page.keyboard.press("Tab"); + await expect(page.locator(":focus")).toBeVisible(); +}); + +test("repository context, tabs and content never overlap in a compact workspace", async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 768 }); + await page.locator('[data-action="select-repo"][data-deployment-count]:not([data-deployment-count="0"])').first().click(); + await page.locator('[data-action="repo-tab"][data-tab="gittools"]').click(); + const layout = await page.evaluate(() => { + const context = document.querySelector(".repo-context")?.getBoundingClientRect(); + const tabs = document.querySelector(".tabs")?.getBoundingClientRect(); + const content = document.querySelector(".repo-content")?.getBoundingClientRect(); + return { + contextEndsBeforeTabs: Boolean(context && tabs && context.bottom <= tabs.top + 0.5), + tabsEndBeforeContent: Boolean(tabs && content && tabs.bottom <= content.top + 0.5), + horizontalTabFallback: Boolean(tabs && document.querySelector(".tabs").scrollWidth >= document.querySelector(".tabs").clientWidth), + }; + }); + expect(layout).toEqual({ contextEndsBeforeTabs: true, tabsEndBeforeContent: true, horizontalTabFallback: true }); +}); + +test("Help center is searchable and contextual guidance opens the requested topic", async ({ page }) => { + await page.locator('.nav-button[data-view="help"]').click(); + await expect(page.getByRole("heading", { name: "How can we help?" })).toBeVisible(); + await expect(page.locator(".help-topic")).toHaveCount(8); + await page.locator("#help-search").fill("deploy key"); + await expect(page.locator(".help-topic")).toHaveCount(1); + await expect(page.locator(".help-topic")).toContainText("Repair repository deploy keys"); + await page.locator('[data-action="select-repo"]').first().click(); + await page.locator('[data-action="repo-tab"][data-tab="gittools"]').click(); + await page.locator('[data-action="open-context-help"][data-topic="workspace-sync"]').click(); + await expect(page.locator('[data-help-topic="workspace-sync"]')).toHaveAttribute("open", ""); + await expect(page.locator('[data-help-topic="workspace-sync"]')).toContainText("Make a local project match Gitea"); + await assertSurface(page); +}); + +test("every long application surface retains a working vertical scroll owner", async ({ page }) => { + for (const view of ["overview", "deployments", "diagnostics", "settings", "help"]) { + await test.step(`${view} view scrolls`, async () => { + const navigation = page.locator(`.nav-button[data-view="${view}"]`); + await navigation.click(); + await expect(navigation).toHaveClass(/active/); + await assertScrollableWhenOverflowing(page, ".main-canvas"); + }); + } + await page.locator('[data-action="select-repo"]').first().click(); + for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) { + await page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`).click(); + const scrollRoot = page.locator(".repo-content > .tab-page, .repo-content > .validator-page"); + if (await scrollRoot.count()) + await assertScrollableWhenOverflowing(page, ".repo-content > .tab-page, .repo-content > .validator-page"); + } +}); +test("deployment inventory supports dense workloads without ambiguous blank cards", async ({ page }) => { + await page.locator('.nav-button[data-action="navigate"][data-view="deployments"]').click(); + await expect(page.locator(".deploy-card, .server-inventory-panel .tool-row").first()).toBeVisible(); + const cards = page.locator(".deploy-card, .server-inventory-panel .tool-row"); + const count = await cards.count(); + expect(count).toBeGreaterThan(0); + for (let index = 0; index < Math.min(count, 25); index += 1) { + await expect(cards.nth(index)).not.toHaveText(/^\s*$/); + } + const unresolved = page.locator(".tool-row", { hasText: "Legacy Worker" }); + await expect(unresolved).toContainText("Link unresolved"); + await expect(unresolved).not.toContainText(/^Linked$/); + await expect(page.locator(".server-inventory-panel").first()).toContainText("1 unresolved"); + const repositoryLink = page.locator('[data-action="open-deployment-link"]'); + if (await repositoryLink.count()) { + await repositoryLink.first().click(); + await expect(page.locator('.repo-row.active')).toHaveAttribute("data-deployment-count", /^[1-9]/); + await expect(page.locator('.repo-row.active .deployment-badge')).toBeVisible(); + await expect(page.locator('.tab[data-action="repo-tab"][data-tab="deployments"]')).toHaveClass(/active/); + await expect(page.locator(".repository-workloads")).toBeVisible(); + await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked"); + } + await assertSurface(page); +}); + +test("dialogs expose semantics, labels, keyboard close and focus restoration", async ({ page }) => { + await page.locator('[data-action="select-repo"]').first().click(); + const trigger = page.locator('[data-action="edit-deployment-profile"], [data-action="add-deployment-profile"]').first(); + if (await trigger.count()) { + await trigger.focus(); + await trigger.click(); + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator("button").first()).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + } + await assertSurface(page); +}); + +test("onboarding and updater states remain usable without an existing configuration", async ({ page }) => { + await page.evaluate(() => localStorage.setItem("forgeflow-demo-setup", "false")); + await page.reload(); + await expect(page.locator(".setup-window")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Check this computer" })).toBeVisible(); + await page.locator('[data-action="setup-run-preflight"]').click(); + await expect(page.locator('[data-action="setup-continue"]')).toBeEnabled(); + await assertSurface(page); + await page.evaluate(() => localStorage.setItem("forgeflow-demo-setup", "true")); + await page.reload(); + await page.locator('.nav-button[data-view="settings"]').click(); + await page.locator('[data-action="check-updates"]').first().click(); + await expect(page.locator(".update-card")).toContainText(/ForgeFlow/i); + await assertSurface(page); +}); + +test("inventory, deployment safety and failure evidence dialogs are reviewable", async ({ page }) => { + await page.locator('.nav-button[data-view="deployments"]').click(); + const reconciliation = page.locator('[data-action="plan-server-reconciliation"]').first(); + if (await reconciliation.count()) { + await reconciliation.click(); + await expect(page.locator('[role="dialog"]')).toContainText(/reconciliation/i); + await page.keyboard.press("Escape"); + } + const keyLifecycle = page.locator('[data-action="manage-deploy-key"]').first(); + if (await keyLifecycle.count()) { + await keyLifecycle.click(); + await expect(page.locator('[role="dialog"]')).toContainText(/Deploy key lifecycle/i); + await page.keyboard.press("Escape"); + } + const preflight = page.locator('[data-action="run-deployment-preflight"]').first(); + await preflight.click(); + await expect(page.locator('[role="dialog"]')).toContainText(/preflight/i); + await page.keyboard.press("Escape"); + const failed = page.locator('[data-action="open-operation"]').last(); + if (await failed.count()) { + await failed.click(); + await expect(page.locator('[role="dialog"]')).toContainText(/failed|failure|healthcheck/i); + await page.keyboard.press("Escape"); + } + await assertSurface(page); +}); + +// A repository or deployment poll renders the whole shell again. Changing an +// unrelated part of the state is what a poll effectively does, and it must not +// take the caret or the scroll position away from the user. +async function forceUnrelatedRerender(page) { + await page.evaluate(() => { + ui.diagnosticsStatus = { ...(ui.diagnosticsStatus || {}), enabled: !(ui.diagnosticsStatus?.enabled === false) }; + render(); + }); +} + +test("a background refresh keeps typing and caret position intact", async ({ page }) => { + const search = page.locator("#global-search"); + await search.click(); + await search.fill("Forge"); + // Typing schedules a debounced render. Wait for it, otherwise the caret below + // can land on the element that render is about to replace. + await expect.poll(() => page.evaluate(() => ui.inputRenderTimer === null)).toBe(true); + await search.evaluate((element) => element.setSelectionRange(1, 3)); + + await forceUnrelatedRerender(page); + + await expect(search).toBeFocused(); + expect(await search.inputValue()).toBe("Forge"); + expect(await search.evaluate((element) => [element.selectionStart, element.selectionEnd])).toEqual([1, 3]); +}); + +test("a background refresh keeps scroll offsets intact", async ({ page }) => { + await page.locator('.nav-button[data-action="navigate"][data-view="settings"]').click(); + const canvas = page.locator(".main-canvas"); + const scrolled = await canvas.evaluate((element) => { + element.scrollTop = Math.min(120, Math.max(0, element.scrollHeight - element.clientHeight)); + return element.scrollTop; + }); + expect(scrolled).toBeGreaterThan(0); + + await forceUnrelatedRerender(page); + + expect(await canvas.evaluate((element) => element.scrollTop)).toBe(scrolled); +}); + +test("sections that used to be injected after render are part of the rendered markup", async ({ page }) => { + await page.locator('.nav-button[data-action="navigate"][data-view="diagnostics"]').click(); + const auditPanel = page.locator(".diagnostics-page .section-block", { hasText: "Operational audit log" }); + await expect(auditPanel).toBeVisible(); + await expect(auditPanel).toContainText("Load the operational audit log"); + + // The audit rows are state the shell renders itself now, so a plain render has + // to pick them up without any post-render injection step. + await page.evaluate(() => { + ui.auditEvents = [{ timestamp: new Date().toISOString(), event: "deployment.requested", details: { repository: "Jens/Probe", result: "queued" } }]; + render(); + }); + await expect(auditPanel.locator("table.data-table")).toContainText("Jens/Probe"); + await expect(auditPanel.locator("table.data-table")).toContainText("deployment.requested"); +}); + +test("a very large diff is capped instead of freezing the window", async ({ page }) => { + const selected = await page.evaluate(() => { + const withChanges = ui.repositories.find((repository) => repository.localStatus?.counts?.changed); + if (!withChanges) return null; + selectRepository(withChanges.id); + return withChanges.fullName; + }); + expect(selected, "the demo needs a repository with local changes").not.toBeNull(); + await expect(page.locator(".diff-view")).toBeVisible(); + // Selecting a repository loads its diff asynchronously; that load would + // otherwise overwrite the diff injected below. + await expect.poll(() => page.evaluate(() => Boolean(ui.diff) && !ui.diff.startsWith("Loading"))).toBe(true); + + const measured = await page.evaluate(() => { + const newline = String.fromCharCode(10); + const lines = ["diff --git a/package-lock.json b/package-lock.json"]; + for (let index = 0; index < 40_000; index += 1) lines.push(`+ "package-${index}": "^1.2.3",`); + ui.diff = lines.join(newline); + ui.repositoryTab = "changes"; + const started = performance.now(); + render(); + return { + renderMs: performance.now() - started, + rendered: document.querySelectorAll(".diff-line").length, + storedLines: ui.diff.split(newline).length, + }; + }); + + expect(measured.storedLines).toBe(40_001); + expect(measured.rendered).toBeLessThan(2100); + expect(measured.renderMs).toBeLessThan(3000); + await expect(page.locator(".diff-view")).toContainText("more lines are not shown"); +}); + +test("an unchanged render leaves the existing DOM in place", async ({ page }) => { + await page.locator('[data-action="select-repo"]').first().click(); + const marked = await page.evaluate(() => { + // Relative timestamps ("just now" turning into "1m ago") and pending async + // state legitimately change the markup between two renders that are seconds + // apart. Rendering twice inside one synchronous block removes that window, + // so the second render can only be skipped because nothing changed. + render(); + document.querySelector(".repo-list").dataset.renderProbe = "kept"; + render(); + return document.querySelector(".repo-list")?.dataset.renderProbe || null; + }); + expect(marked).toBe("kept"); + + const replaced = await page.evaluate(() => { + document.querySelector(".repo-list").dataset.renderProbe = "kept"; + ui.repoSearch = `probe-${Date.now()}`; + render(); + return document.querySelector(".repo-list")?.dataset.renderProbe || null; + }); + expect(replaced).toBe(null); +}); diff --git a/tests/clone-target.test.mjs b/tests/clone-target.test.mjs new file mode 100644 index 0000000..78770ba --- /dev/null +++ b/tests/clone-target.test.mjs @@ -0,0 +1,120 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import cloneTargetModule from '../src/shared/clone-target.cjs'; +import gitModule from '../src/main/git-service.cjs'; + +const exec = promisify(execFile); +const { cloneDirectoryName, resolveCloneTarget } = cloneTargetModule; +const { GitService } = gitModule; + +test('derives a safe repository folder name from HTTPS and SSH clone URLs', () => { + assert.equal(cloneDirectoryName('https://gitea.example.test/jens/ForgeFlow.git'), 'ForgeFlow'); + assert.equal(cloneDirectoryName('git@gitea.example.test:jens/my-app.git'), 'my-app'); + assert.equal(cloneDirectoryName('ssh://git@gitea.example.test/jens/app.git?ref=main'), 'app'); +}); + +test('resolves the automatic clone target inside the configured project root', () => { + const root = path.join(os.tmpdir(), 'forgeflow-projects'); + const plan = resolveCloneTarget(root, 'https://gitea.example.test/jens/portfolio.git'); + assert.equal(plan.root, path.resolve(root)); + assert.equal(plan.target, path.join(path.resolve(root), 'portfolio')); + assert.equal(plan.directoryName, 'portfolio'); +}); + +test('a clone target that would leave the project root is refused', () => { + const root = path.join(os.tmpdir(), 'forgeflow-projects'); + const resolved = path.resolve(root); + + // The escape guard inside resolveCloneTarget stays as a backstop, but no + // sanitised folder name can reach it any more: the name is a single path + // segment and a dots-only segment falls back to "repository". + for (const remote of ['..', '.', '../escape', '/', '', '....git', 'https://gitea.example.test/jens/....git']) { + const plan = resolveCloneTarget(root, remote); + assert.ok( + plan.target.startsWith(`${resolved}${path.sep}`) && plan.target !== resolved, + `${remote} resolved outside the project root: ${plan.target}`, + ); + } + for (const badRoot of ['', ' ', null, undefined]) { + assert.throws(() => resolveCloneTarget(badRoot, 'https://gitea.example.test/jens/app.git'), /project root is required/); + } +}); + +test('a folder name that sanitises away still produces a usable directory', () => { + // Windows strips trailing dots, so a dots-only name would land on the project + // root itself instead of a subdirectory. + assert.equal(cloneDirectoryName('https://gitea.example.test/jens/....git'), 'repository'); + assert.equal(cloneDirectoryName('..'), 'repository'); + assert.equal(cloneDirectoryName(''), 'repository'); + assert.equal(cloneDirectoryName('https://gitea.example.test/jens/app.git#readme'), 'app'); + assert.equal(cloneDirectoryName('https://gitea.example.test/jens/spaced name.git'), 'spaced-name'); +}); + +test('clone target inspection accepts missing and empty destinations', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const service = new GitService(); + const remote = 'https://gitea.example.test/jens/app.git'; + + const missing = await service.inspectCloneTarget(remote, path.join(root, 'missing-app')); + assert.equal(missing.state, 'missing'); + + const emptyPath = path.join(root, 'empty-app'); + await fs.mkdir(emptyPath); + const empty = await service.inspectCloneTarget(remote, emptyPath); + assert.equal(empty.state, 'empty'); +}); + +test('clone target inspection reuses an existing checkout with the same origin', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-reuse-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const target = path.join(root, 'app'); + await fs.mkdir(target); + await exec('git', ['init'], { cwd: target, encoding: 'utf8' }); + await exec('git', ['remote', 'add', 'origin', 'git@gitea.example.test:jens/app.git'], { cwd: target, encoding: 'utf8' }); + + const service = new GitService(); + const assessment = await service.inspectCloneTarget('https://gitea.example.test/jens/app.git', target); + assert.equal(assessment.state, 'matching-repository'); +}); + +test('clone target inspection blocks a different repository and ordinary files', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-conflict-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const service = new GitService(); + + const otherRepository = path.join(root, 'repository'); + await fs.mkdir(otherRepository); + await exec('git', ['init'], { cwd: otherRepository, encoding: 'utf8' }); + await exec('git', ['remote', 'add', 'origin', 'https://gitea.example.test/jens/other.git'], { cwd: otherRepository, encoding: 'utf8' }); + await assert.rejects( + service.inspectCloneTarget('https://gitea.example.test/jens/app.git', otherRepository), + (error) => error.code === 'CLONE_TARGET_DIFFERENT_REPOSITORY' + ); + + const ordinaryFolder = path.join(root, 'ordinary'); + await fs.mkdir(ordinaryFolder); + await fs.writeFile(path.join(ordinaryFolder, 'notes.txt'), 'do not overwrite\n'); + await assert.rejects( + service.inspectCloneTarget('https://gitea.example.test/jens/app.git', ordinaryFolder), + (error) => error.code === 'CLONE_TARGET_NOT_EMPTY' + ); +}); + +test('clone target inspection blocks a file at the automatic destination', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-file-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const target = path.join(root, 'app'); + await fs.writeFile(target, 'not a directory'); + + const service = new GitService(); + await assert.rejects( + service.inspectCloneTarget('https://gitea.example.test/jens/app.git', target), + (error) => error.code === 'CLONE_TARGET_NOT_DIRECTORY' + ); +}); diff --git a/tests/config-store.test.mjs b/tests/config-store.test.mjs new file mode 100644 index 0000000..d12a493 --- /dev/null +++ b/tests/config-store.test.mjs @@ -0,0 +1,275 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import configModule from "../src/main/config-store.cjs"; + +const { ConfigStore } = configModule; + +async function storeFixture(t) { + const directory = await mkdtemp(path.join(os.tmpdir(), "forgeflow-config-store-")); + t.after(() => rm(directory, { recursive: true, force: true })); + return { directory, store: new ConfigStore(directory) }; +} + +test("config migration normalizes legacy deployments, inventory and validator state", async (t) => { + const { store } = await storeFixture(t); + const migrated = store.migrate({ + schemaVersion: 2, + workspaceRoots: [" C:/Projects ", "C:/Projects", ""], + favorites: ["Owner/App", "owner/app"], + inventoryReviewDecisions: { server: [{ workloadId: "one" }] }, + gitValidator: { policies: { "owner/app": { id: "strict" } }, suppressions: { "owner/app": [{ checkId: "x" }] }, trends: { "owner/app": [{ score: 70 }] } }, + deploymentProfiles: { + "owner/app": [{ id: "legacy", provider: "ssh-unraid", remoteFolder: "MyApp", composeFile: "compose.yml", containerName: "MyApp", iconUrl: "https://itworx.tech/assets/itworx-icon.png", serverGitAccess: { deployKeyId: "4" } }], + }, + operations: [{ id: "op", runnerLog: "secret", status: "success" }], + }); + assert.equal(migrated.schemaVersion, 13); + assert.deepEqual(migrated.workspaceRoots, ["C:/Projects"]); + assert.deepEqual(migrated.favorites, ["owner/app"]); + const profile = migrated.deploymentProfiles["owner/app"][0]; + assert.equal(profile.deploymentMode, "push-bundle"); + assert.equal(profile.composeService, "myapp"); + assert.equal(profile.iconMode, "builtin"); + assert.equal("runnerLog" in migrated.operations[0], false); + assert.equal(migrated.gitValidator.policies["owner/app"].id, "strict"); +}); + +test("load creates missing config and recovers malformed JSON", async (t) => { + const { directory, store } = await storeFixture(t); + let state = await store.load(); + assert.equal(state.schemaVersion, 13); + assert.equal(JSON.parse(await readFile(store.filePath, "utf8")).appearance, "dark"); + await writeFile(store.filePath, "{ malformed", "utf8"); + state = await store.load(); + assert.equal(state.setupComplete, false); + assert.ok((await readdir(directory)).some((name) => name.includes(".corrupt-"))); +}); + +test("server normalization rejects unsafe targets and preserves bounded scan configuration", async (t) => { + const { store } = await storeFixture(t); + assert.throws(() => store.normalizeServer({ host: "bad host", username: "root" }), /hostname/); + assert.throws(() => store.normalizeServer({ host: "unraid", username: "bad user" }), /username/); + assert.throws(() => store.normalizeServer({ host: "unraid", username: "root", basePath: "relative" }), /absolute Unix/); + const server = store.normalizeServer({ host: "unraid.local", username: "root", port: 70000, basePath: "/mnt/user/appdata/", scanRoots: ["/mnt/user/appdata/", "relative"], scanExcludes: ["backup*", "bad/path"], authType: "privateKey", privateKeyPath: "C:/key" }); + assert.equal(server.port, 65535); + assert.deepEqual(server.scanRoots, ["/mnt/user/appdata"]); + assert.deepEqual(server.scanExcludes, ["backup*"]); + store.data.servers = [{ ...server, encryptedPassword: "hidden", encryptedPassphrase: "hidden" }]; + assert.equal(store.getPublicServer(store.data.servers[0]).hasPassphrase, true); + assert.equal("encryptedPassword" in store.getPublicState().servers[0], false); + assert.throws(() => store.getServerCredentials("missing"), /no longer exists/); +}); + +test("deployment profiles validate both Gitea Actions and safe Unraid topology", async (t) => { + const { store } = await storeFixture(t); + const actions = await store.saveDeploymentProfile("Owner/App", { id: "actions", environment: "production", branch: "main", provider: "gitea-actions", workflowFile: "deploy.yml", rollbackWorkflowFile: "rollback.yml", statusUrl: "https://app.test/status" }); + assert.equal(actions.provider, "gitea-actions"); + const unraid = await store.saveDeploymentProfile("Owner/App", { id: "unraid", name: "Production", environment: "production", branch: "main", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", deploymentMode: "server-git", composeFiles: ["compose.yml"], composeServices: ["Web", "worker"], containerName: "Visible-App", cloneUrl: "git@gitea.test:owner/app.git", hostPort: 99999, containerPort: 0, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "none", dockerShell: "/bin/bash", preservePaths: [".env", "data"], composeProject: "App_prod", composeWorkingDir: "/mnt/user/appdata/App", serverGitAccess: { configured: true, deployKeyId: "42", keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" } }); + assert.equal(unraid.composeService, "web"); + assert.deepEqual(unraid.composeServices, ["web", "worker"]); + assert.equal(unraid.hostPort, 65535); + assert.equal(unraid.containerPort, null); + assert.equal(unraid.serverGitAccess.deployKeyId, 42); + assert.equal(store.getDeploymentProfiles("owner/app").length, 2); + assert.equal(store.getDeploymentProfile("owner/app", "unraid").containerName, "Visible-App"); + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", environment: "prod", branch: "main", remoteFolder: "../escape", composeFiles: ["compose.yml"] }), /escape|relative path|safe path/i); + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", environment: "prod", branch: "main", remoteFolder: "app", composeFiles: ["compose.yml"], composeService: "bad service" }), /Compose service/); + await store.deleteDeploymentProfile("owner/app", "actions"); + assert.equal(store.getDeploymentProfiles("owner/app").length, 1); +}); + +test("configuration mutations persist mappings, favorites, reviews, trends, operations and bounded preferences", async (t) => { + const { store } = await storeFixture(t); + await store.saveMapping("Owner/App", "C:/Projects/App"); + assert.equal(store.data.repositoryMappings["owner/app"], "C:/Projects/App"); + await store.setFavorite("Owner/App", true); + await store.setFavorite("Owner/App", false); + assert.deepEqual(store.data.favorites, []); + await store.setUpdatePreferences({ owner: " Team ", repo: " App ", branch: "release/1", autoCheck: false }); + assert.equal(store.data.updates.branch, "release/1"); + await store.saveInventoryReviewDecision("server", { workloadId: "workload", evidenceHash: "a".repeat(64), action: "monitor-only" }); + assert.equal(store.getInventoryReviewDecisions("server").length, 1); + await store.deleteInventoryReviewDecision("server", "workload"); + await assert.rejects(() => store.saveInventoryReviewDecision("", {}), /evidence hash/); + await store.setGitValidatorPolicy("Owner/App", { id: "production" }); + await store.addGitValidatorSuppression("Owner/App", { checkId: "signed-tags" }); + await store.appendGitValidatorTrend("Owner/App", { score: 81 }); + assert.equal(store.getGitValidatorState("owner/app").trends[0].score, 81); + await store.saveDeploymentState("profile", { liveSha: "a".repeat(40) }); + assert.equal(store.getDeploymentState("profile").liveSha.length, 40); + await store.addOperation({ id: "operation", status: "running" }); + await store.addOperation({ id: "operation", status: "success" }); + assert.equal(store.getOperation("operation").status, "success"); + const state = await store.setPreferences({ repositoryPollSeconds: 0, operationPollSeconds: 999, fetchIntervalMinutes: 999, preferredCloneProtocol: "invalid", diagnosticLevel: "invalid", logRetentionDays: 0, maxLogFileMb: 100, editor: { executable: "code", args: ["{file}"] }, terminal: null, closeToTray: true, startAtLogin: true }); + assert.equal(state.preferences.repositoryPollSeconds, 4); + assert.equal(state.preferences.operationPollSeconds, 120); + assert.equal(state.preferences.fetchIntervalMinutes, 240); + assert.equal(state.preferences.preferredCloneProtocol, "https"); + assert.equal(state.preferences.diagnosticLevel, "info"); + assert.equal(state.preferences.maxLogFileMb, 50); + const manualRemoteAwareness = await store.setPreferences({ fetchIntervalMinutes: 0 }); + assert.equal(manualRemoteAwareness.preferences.fetchIntervalMinutes, 0); + await store.removeMapping("owner/app"); + assert.equal(store.data.repositoryMappings["owner/app"], undefined); +}); + +test("server deletion removes only linked profiles and their deployment state", async (t) => { + const { store } = await storeFixture(t); + store.data.servers = [{ id: "remove", host: "old" }, { id: "keep", host: "new" }]; + store.data.deploymentProfiles = { + "owner/app": [{ id: "old-profile", serverId: "remove" }, { id: "keep-profile", serverId: "keep" }], + "owner/only-old": [{ id: "only-old", serverId: "remove" }] + }; + store.data.deploymentStates = { "old-profile": { healthy: true }, "only-old": { healthy: true }, "keep-profile": { healthy: true } }; + await store.deleteServer("remove"); + assert.deepEqual(store.data.servers.map((server) => server.id), ["keep"]); + assert.deepEqual(store.data.deploymentProfiles["owner/app"].map((profile) => profile.id), ["keep-profile"]); + assert.equal(store.data.deploymentProfiles["owner/only-old"], undefined); + assert.equal(store.data.deploymentStates["old-profile"], undefined); + assert.equal(store.data.deploymentStates["only-old"], undefined); + assert.equal(store.data.deploymentStates["keep-profile"].healthy, true); +}); + +test("configuration restore retains credentials only for unchanged endpoints and never restores operations", async (t) => { + const { store } = await storeFixture(t); + store.data.gitea = { baseUrl: "https://gitea.test", user: { login: "jens" }, encryptedToken: "encrypted-token" }; + store.data.servers = [ + { ...store.normalizeServer({ id: "same", host: "same", username: "root", authType: "password" }), encryptedPassword: "password", encryptedPassphrase: null }, + { ...store.normalizeServer({ id: "changed", host: "old", username: "root", authType: "privateKey", privateKeyPath: "C:/old" }), encryptedPassword: null, encryptedPassphrase: "passphrase" } + ]; + store.data.operations = [{ id: "current-operation" }]; + const backup = structuredClone(store.data); + backup.servers[1].host = "new"; + backup.operations = [{ id: "untrusted-operation" }]; + await store.restoreConfiguration(backup); + assert.equal(store.data.gitea.encryptedToken, "encrypted-token"); + assert.equal(store.getServer("same").encryptedPassword, "password"); + assert.equal(store.getServer("changed").encryptedPassphrase, null); + assert.deepEqual(store.data.operations, [{ id: "current-operation" }]); + + await store.restoreConfiguration({ ...backup, gitea: { ...backup.gitea, baseUrl: "https://other.test" } }); + assert.equal(store.data.gitea.encryptedToken, null); +}); + +test("profile, review and operation lookups return safe empty values", async (t) => { + const { store } = await storeFixture(t); + assert.equal(store.getPublicServer(null), null); + assert.equal(store.getServer("missing"), null); + assert.deepEqual(store.getDeploymentProfiles("missing/repo"), []); + assert.equal(store.getDeploymentProfile("missing/repo", "profile"), null); + assert.deepEqual(store.getInventoryReviewDecisions("missing"), []); + assert.equal(store.getDeploymentState("missing"), null); + assert.equal(store.getOperation("missing"), null); + assert.deepEqual(store.getGitValidatorState("missing/repo"), { policy: { id: "standard" }, suppressions: [], trends: [] }); + await store.deleteInventoryReviewDecision("missing", "workload"); + await store.deleteDeploymentProfile("missing/repo", "profile"); +}); + +test("session credentials preserve, replace and clear safely when OS encryption is unavailable", async (t) => { + const { store } = await storeFixture(t); + assert.deepEqual(store.setToken(" session-token "), { persistent: false, preserved: false }); + assert.equal(store.getToken(), "session-token"); + assert.deepEqual(store.setToken("", { preserveExisting: true }), { persistent: false, preserved: true }); + assert.equal(store.getToken(), "session-token"); + assert.deepEqual(store.setToken("replacement"), { persistent: false, preserved: false }); + assert.equal(store.getToken(), "replacement"); + assert.deepEqual(store.setToken(""), { persistent: true, preserved: false }); + assert.equal(store.getToken(), ""); + assert.throws(() => store.encryptSecret("password"), (error) => error.code === "SECURE_STORAGE_UNAVAILABLE"); + assert.equal(store.encryptSecret(""), null); + assert.equal(store.decryptSecret(null), ""); + assert.equal(store.decryptSecret("not-base64-encrypted-data"), ""); +}); + +test("setup, Gitea updates and generic patches retain normalized public state", async (t) => { + const { store } = await storeFixture(t); + const completed = await store.completeSetup({ + baseUrl: "https://gitea.test", token: "token", user: { login: "jens" }, + workspaceRoots: [" C:/Projects ", "C:/Projects", ""] + }); + assert.equal(completed.state.setupComplete, true); + assert.equal(completed.state.gitea.hasToken, true); + assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]); + await assert.rejects( + store.updateGitea({ baseUrl: "https://new.test", token: "", user: null }), + (error) => error.code === "GITEA_TOKEN_ORIGIN_CHANGED" + ); + const update = await store.updateGitea({ baseUrl: "https://gitea.test", token: "", user: null }); + assert.equal(update.preserved, true); + assert.equal(store.data.gitea.user.login, "jens"); + const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] }); + assert.equal(patched.appearance, "light"); + assert.deepEqual(patched.workspaceRoots, ["D:/Code"]); + assert.equal("encryptedToken" in patched.gitea, false); +}); + +test("server saves reject absent credentials before mutating configuration", async (t) => { + const { store } = await storeFixture(t); + await assert.rejects( + store.saveServer({ host: "unraid", username: "root", authType: "password", basePath: "/mnt/apps" }), + /password is required/i + ); + await assert.rejects( + store.saveServer({ host: "unraid", username: "root", authType: "privateKey", basePath: "/mnt/apps", privateKeyPath: "" }), + /select a private key/i + ); + assert.deepEqual(store.data.servers, []); +}); + +test("server credentials and trust are cleared when the connection identity changes", async (t) => { + const { store } = await storeFixture(t); + store.encryptSecret = (value) => `encrypted:${value}`; + const saved = await store.saveServer({ host: "server-one", username: "deploy", authType: "password", basePath: "/mnt/apps", hostFingerprint: "SHA256:trusted" }, { password: "test-password" }); + await assert.rejects( + store.saveServer({ ...saved, host: "server-two" }, {}), + /password is required/i + ); + assert.equal(store.data.servers[0].host, "server-one"); + const changed = await store.saveServer({ ...saved, host: "server-two" }, { password: "replacement-password" }); + assert.equal(changed.hostFingerprint, ""); + assert.equal(changed.hasPassword, true); +}); + +test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => { + const { store } = await storeFixture(t); + const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" }); + assert.equal(actions.provider, "gitea-actions"); + assert.equal(actions.name, "qa"); + assert.equal(actions.branch, "main"); + assert.equal(actions.workflowFile, "deploy.yml"); + assert.equal(actions.rollbackWorkflowFile, ""); + assert.equal(actions.confirmationRequired, true); + + const unraid = store.normalizeDeploymentProfile({ + id: "all-options", name: " Server ", environment: "production", provider: "ssh-unraid", branch: "release", + serverId: " server ", remoteFolder: "apps/App", deploymentMode: "monitor-only", generatedCompose: true, + composeFiles: [], composeServices: ["WEB", "Worker"], composeProject: "App.prod", composeWorkingDir: "/mnt/apps/App", + containerName: "Visible.App", cloneUrl: "https://gitea.test/Owner/App.git", alignRemote: true, + hostPort: -2, containerPort: 70000, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "upload", + iconFilePath: "C:/icon.png", dockerShell: "/bin/bash", preservePaths: [], adoptedFromServer: true, + serverSourceOfTruth: true, manageDockerMan: true, forceRecreate: true, removeOrphans: true, + workloadIdentity: { workloadId: "one" }, serverGitAccess: { configured: true, deployKeyId: "invalid", keyFingerprint: "", hostFingerprint: "", configuredAt: "now" }, + provenance: { remoteFolder: "server" }, detectedMetadata: { source: "docker" }, serverIconReference: " icon ", + deploymentPolicy: { frozen: true, freezeReason: " maintenance ", requireNote: true, maintenanceWindows: [{ days: [0, 0, 6, 7, "bad"], start: "01:00", end: "02:00" }] } + }); + assert.equal(unraid.name, "Server"); + assert.equal(unraid.serverId, "server"); + assert.equal(unraid.composeFile, "docker-compose.yml"); + assert.deepEqual(unraid.composeServices, ["web", "worker"]); + assert.equal(unraid.hostPort, 1); + assert.equal(unraid.containerPort, 65535); + assert.equal(unraid.iconMode, "upload"); + assert.equal(unraid.dockerShell, "/bin/bash"); + assert.equal(unraid.serverGitAccess.deployKeyId, null); + assert.equal(unraid.serverGitAccess.keyFingerprint, null); + assert.deepEqual(unraid.deploymentPolicy.maintenanceWindows[0].days, [0, 6]); + assert.equal(unraid.serverIconReference, "icon"); + + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeService: "app", composeServices: ["bad service"] }), /Compose services/); + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeProject: "bad project!" }), /Compose project/); + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeWorkingDir: "relative" }), /working directory/); + assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", containerName: "bad name" }), /Container name/); +}); diff --git a/tests/configuration-backup.test.mjs b/tests/configuration-backup.test.mjs new file mode 100644 index 0000000..912a89a --- /dev/null +++ b/tests/configuration-backup.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import backupModule from '../src/main/configuration-backup.cjs'; +import configModule from '../src/main/config-store.cjs'; + +const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule; +const { ConfigStore } = configModule; + +test('configuration backups exclude credentials and operation history', () => { + const clean = sanitizeConfiguration({ + gitea: { baseUrl: 'https://gitea.test', encryptedToken: 'secret-token' }, + servers: [{ id: 'server', host: 'unraid.test', encryptedPassword: 'password', encryptedPassphrase: 'passphrase' }], + operations: [{ id: 'operation', sha: 'a'.repeat(40) }], + preferences: { autoRefresh: true } + }); + assert.equal(clean.gitea.encryptedToken, null); + assert.equal('encryptedPassword' in clean.servers[0], false); + assert.equal('encryptedPassphrase' in clean.servers[0], false); + assert.deepEqual(clean.operations, []); +}); + +test('configuration backups round-trip with authenticated encryption', () => { + const serialized = createEncryptedBackup({ workspaceRoots: ['C:/Projects'], gitea: { encryptedToken: 'secret' } }, 'correct horse battery staple'); + assert.doesNotMatch(serialized, /C:\/Projects|secret/); + const restored = readEncryptedBackup(serialized, 'correct horse battery staple'); + assert.deepEqual(restored.configuration.workspaceRoots, ['C:/Projects']); + assert.equal(restored.configuration.gitea.encryptedToken, null); + assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i); +}); + +test('recovery snapshots preserve the exact in-memory configuration before a mutation', async (context) => { + const directory = await mkdtemp(path.join(tmpdir(), 'forgeflow-config-snapshot-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const store = new ConfigStore(directory); + store.data.workspaceRoots = ['C:/Projects']; + store.data.deploymentProfiles = { 'jens/example': [{ id: 'production', provider: 'gitea-actions' }] }; + await store.save(); + const before = `${JSON.stringify(store.data, null, 2)}\n`; + const snapshot = await store.createRecoverySnapshot('server reconciliation / production'); + assert.equal(await readFile(snapshot.filePath, 'utf8'), before); + assert.equal(snapshot.reason, 'server-reconciliation-production'); +}); diff --git a/tests/dependency-wiring.test.mjs b/tests/dependency-wiring.test.mjs new file mode 100644 index 0000000..b26d5a7 --- /dev/null +++ b/tests/dependency-wiring.test.mjs @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { registerDeploymentIpc } = require("../src/main/ipc/deployment-handlers.cjs"); +const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs"); + +const REPOSITORY = { fullName: "Jens/ForgeFlow", owner: { login: "Jens" }, localPath: "C:/Projects/ForgeFlow" }; +const WORKLOAD = { workloadId: "workload-1", classification: { type: "ambiguous" } }; + +// Every collaborator answers, so a channel can only fail on a dependency the +// module references but never receives. +function harness(overrides = {}) { + const calls = []; + const record = (name, result) => async (...args) => { calls.push({ name, args }); return typeof result === "function" ? result(...args) : result; }; + const handlers = new Map(); + const profile = overrides.profile || { id: "profile-1", provider: "ssh-unraid", branch: "main", name: "Production" }; + + const dependencies = { + register: (channel, handler) => handlers.set(channel, handler), + store: { + data: { servers: [{ id: "server-1", name: "Unraid" }], operations: [] }, + getDeploymentProfile: () => profile, + getPublicState: () => ({ ok: true }), + saveDeploymentProfile: record("store.saveDeploymentProfile", profile), + deleteDeploymentProfile: record("store.deleteDeploymentProfile", []), + addOperation: record("store.addOperation", null), + }, + resolveRepository: record("resolveRepository", REPOSITORY), + unraid: { + preflight: record("unraid.preflight", { ok: true }), + repairWriteAccess: record("unraid.repairWriteAccess", { changed: true, after: {}, before: {} }), + deploy: record("unraid.deploy", { id: "operation-1" }), + rollback: record("unraid.rollback", { id: "operation-2" }), + linkServerWorkload: record("unraid.linkServerWorkload", { linked: true }), + configureServerGitAccess: record("unraid.configureServerGitAccess", { keyFingerprint: "a", hostFingerprint: "b" }), + verifyServerGitProfile: record("unraid.verifyServerGitProfile", { readiness: "ready", ready: true, checkedAt: "now" }), + discoverServerWorkloads: record("unraid.discoverServerWorkloads", { serverId: "server-1", workloads: [] }), + planServerInventoryReconciliation: record("unraid.planServerInventoryReconciliation", { plan: { id: "plan-1", summary: {} } }), + reconcileServerInventory: record("unraid.reconcileServerInventory", { adopted: 0, refreshed: 0, retired: 0 }), + scanServerInventory: record("unraid.scanServerInventory", { workloads: [WORKLOAD] }), + refreshProfileState: record("unraid.refreshProfileState", { liveSha: null }), + applyDockerManMetadata: record("unraid.applyDockerManMetadata", { applied: true }), + refreshOperation: record("unraid.refreshOperation", null), + reconcileRecordedOperations: record("unraid.reconcileRecordedOperations", []), + }, + deployments: { + deploy: record("deployments.deploy", { id: "operation-3" }), + rollback: record("deployments.rollback", { id: "operation-4" }), + checkHealth: record("deployments.checkHealth", { healthy: true }), + refreshProfileState: record("deployments.refreshProfileState", { liveSha: null }), + }, + evaluateDeploymentPolicy: () => ({ note: "", overridden: false, reason: "", violations: [] }), + audit: { append: record("audit.append", null) }, + deployKeys: { + inventory: record("deployKeys.inventory", { keys: [] }), + planRotation: record("deployKeys.planRotation", { id: "rotation-1" }), + rotate: record("deployKeys.rotate", { rotated: true }), + planRevocation: record("deployKeys.planRevocation", { id: "revocation-1" }), + revoke: record("deployKeys.revoke", { revoked: true }), + restore: record("deployKeys.restore", { restored: true }), + }, + repositories: { refresh: record("repositories.refresh", [REPOSITORY]) }, + inventoryReviews: { + preview: (...args) => { calls.push({ name: "inventoryReviews.preview", args }); return { id: "review-1" }; }, + apply: record("inventoryReviews.apply", { applied: true }), + }, + diagnostics: { info: record("diagnostics.info"), warning: record("diagnostics.warning"), error: record("diagnostics.error"), debug: record("diagnostics.debug") }, + git: {}, + gitea: { getBranch: record("gitea.getBranch", { commit: { id: "c".repeat(40) } }) }, + ssh: {}, + preflight: { runDeployment: record("preflight.runDeployment", { ok: "actions" }) }, + ...overrides.dependencies, + }; + + registerDeploymentIpc(dependencies); + return { handlers, calls, names: () => calls.map((item) => item.name) }; +} + +const PAYLOAD = { + repository: REPOSITORY, + fullName: REPOSITORY.fullName, + profileId: "profile-1", + sha: "a".repeat(40), + serverId: "server-1", + workloadId: WORKLOAD.workloadId, + planId: "plan-1", + action: "manual-link", + url: "https://app.example/health", + profile: { name: "Production" }, + targetSha: "b".repeat(40), +}; + +// Both provider paths have to run: a dependency that only the Gitea Actions +// branch reads stays invisible while every channel is exercised as SSH/Unraid. +for (const provider of ["ssh-unraid", "gitea-actions"]) { + test(`every deployment IPC channel runs with the dependencies it is given (${provider})`, async () => { + const { handlers } = harness({ profile: { id: "profile-1", provider, branch: "main", name: "Production" } }); + assert.ok(handlers.size >= 20, "expected the complete deployment channel surface"); + + const failures = []; + for (const [channel, handler] of handlers) { + try { + await handler({ ...PAYLOAD }); + } catch (error) { + // A refusal is a decision the handler made; a missing dependency is not. + if (error instanceof ReferenceError || error instanceof TypeError) { + failures.push(`${channel}: ${error.name}: ${error.message}`); + } + } + } + assert.deepEqual(failures, []); + }); +} + +test("deployment preflight routes by provider", async () => { + const actions = harness({ profile: { id: "profile-1", provider: "gitea-actions" } }); + assert.deepEqual(await actions.handlers.get("deployment:preflight")({ ...PAYLOAD }), { ok: "actions" }); + assert.ok(actions.names().includes("preflight.runDeployment")); + + const unraid = harness(); + assert.deepEqual(await unraid.handlers.get("deployment:preflight")({ ...PAYLOAD }), { ok: true }); + assert.ok(unraid.names().includes("unraid.preflight")); + assert.ok(!unraid.names().includes("preflight.runDeployment")); +}); + +test("write-access repair is refused for anything but an SSH/Unraid profile", async () => { + const actions = harness({ profile: { id: "profile-1", provider: "gitea-actions" } }); + await assert.rejects( + () => actions.handlers.get("deployment:repair-write-access")({ ...PAYLOAD }), + /available only for SSH \/ Unraid/, + ); +}); + +test("a stale workload blocks an inventory review instead of guessing", async () => { + const { handlers } = harness({ + dependencies: { unraid: { scanServerInventory: async () => ({ workloads: [] }) } }, + }); + for (const channel of ["deployment:plan-inventory-review", "deployment:apply-inventory-review"]) { + await assert.rejects(() => handlers.get(channel)({ ...PAYLOAD }), (error) => { + assert.equal(error.code, "INVENTORY_REVIEW_WORKLOAD_STALE"); + return true; + }); + } +}); + +test("write-access repair builds a repair script that preserves runtime paths", () => { + const service = new UnraidDeploymentService({}); + const profile = { + id: "profile-3", + provider: "ssh-unraid", + remoteFolder: "portfolio", + composeFiles: ["docker-compose.yml"], + preservePaths: ["data/uploads"], + }; + const server = { id: "server-1", basePath: "/mnt/user/appdata" }; + + const script = service.permissionRepairScript(profile, server, "/mnt/user/appdata/portfolio"); + + assert.equal(typeof script, "string"); + assert.match(script, /data\/uploads/); + assert.match(script, /node_modules/); + assert.match(script, /ForgeFlow repaired project write access/); +}); diff --git a/tests/deploy-key-host.test.mjs b/tests/deploy-key-host.test.mjs new file mode 100644 index 0000000..1e54772 --- /dev/null +++ b/tests/deploy-key-host.test.mjs @@ -0,0 +1,216 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs"); + +const SERVER = { id: "unraid", basePath: "/mnt/user/appdata" }; +const REPOSITORY = { fullName: "Jens/Portfolio" }; + +// The host reaches the server through a single exec call, so capturing the script +// it sends is the only way to assert what actually happens to the key material. +function keyHost(stdout = "") { + const scripts = []; + const ssh = { + exec: async (serverId, command, options) => { + const encoded = command.match(/printf '%s' '([^']+)'/)?.[1] || ""; + scripts.push({ serverId, options, script: Buffer.from(encoded, "base64").toString("utf8") }); + return { stdout }; + }, + }; + return { host: new UnraidDeployKeyHost({ ssh }), scripts }; +} + +test("deploy-key storage is repository-scoped, deterministic and stays under the server base path", () => { + const { host } = keyHost(); + const first = host.paths(REPOSITORY, SERVER); + const again = host.paths({ fullName: "jens/portfolio" }, SERVER); + const other = host.paths({ fullName: "Jens/Other" }, SERVER); + + assert.deepEqual(first, again, "the same repository always resolves to the same directory"); + assert.notEqual(first.directory, other.directory, "a different repository never shares a key directory"); + for (const value of Object.values(first)) { + assert.ok(value.startsWith("/mnt/user/appdata/.forgeflow/git-credentials/"), value); + assert.ok(!value.includes("..")); + } + assert.ok(!first.directory.toLowerCase().includes("portfolio"), "the repository name is hashed, not embedded"); +}); + +test("the server pull remote is taken from the first usable SSH URL and refused when there is none", () => { + const { host } = keyHost(); + assert.equal( + host.remote({ ...REPOSITORY, localStatus: { remoteUrl: "https://gitea.example/Jens/Portfolio.git" }, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, {}), + "git@gitea.example:Jens/Portfolio.git", + "an HTTPS remote is skipped in favour of the SSH URL", + ); + assert.equal( + host.remote({ ...REPOSITORY }, { cloneUrl: "ssh://git@gitea.example:2222/Jens/Portfolio.git" }), + "ssh://git@gitea.example:2222/Jens/Portfolio.git", + ); + assert.throws( + () => host.remote({ ...REPOSITORY, sshUrl: "https://gitea.example/Jens/Portfolio.git" }, {}), + (error) => { + assert.equal(error.code, "SERVER_GIT_SSH_URL_REQUIRED"); + return true; + }, + ); +}); + +test("the Git SSH environment pins the scoped key and refuses an unknown host", () => { + const { host } = keyHost(); + const paths = host.paths(REPOSITORY, SERVER); + const environment = host.environment(paths); + + assert.match(environment, /IdentitiesOnly=yes/); + assert.match(environment, /BatchMode=yes/); + assert.match(environment, /StrictHostKeyChecking=yes/); + assert.ok(environment.includes(paths.knownHosts), "the pinned host key file is repository-scoped"); + assert.ok(environment.includes(paths.privateKey)); +}); + +test("a backup copies the current key material into a fresh recovery slot", async () => { + const publicKey = "ssh-ed25519 QkFL forgeflow"; + const { host, scripts } = keyHost( + `__FORGEFLOW_KEY_BACKUP__\nrecovery=/mnt/user/appdata/.forgeflow/git-credentials/abc/recovery/backup-1\npublicKey=${Buffer.from(publicKey).toString("base64")}\n`, + ); + + const backup = await host.backup({ repository: REPOSITORY, server: SERVER }); + assert.equal(backup.publicKey, publicKey); + assert.match(backup.recovery, /recovery\/backup-1$/); + assert.match(scripts[0].script, /umask 077/, "recovered key material is not world readable"); + assert.match(scripts[0].script, /deploy-key deploy-key\.pub known_hosts/); +}); + +test("candidate verification only reports ready on a real remote commit", async () => { + const remoteSha = "d".repeat(40); + const candidate = { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } }; + const context = { + repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, + profile: { branch: "main" }, + server: SERVER, + candidate, + }; + + const proven = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${remoteSha}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n`); + const proof = await proven.host.verifyCandidate(context); + assert.deepEqual(proof, { ready: true, remoteSha, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" }); + assert.match(proven.scripts[0].script, /git ls-remote --exit-code/); + assert.match(proven.scripts[0].script, /refs\/heads\/main/); + assert.equal(proven.scripts[0].options.timeout, 45_000); + assert.deepEqual(await proven.host.preflightCandidate(context), proof); + + const unproven = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n"); + assert.equal((await unproven.host.verifyCandidate(context)).ready, false); + await assert.rejects(() => unproven.host.preflightCandidate(context), /did not prove the remote branch/); +}); + +test("a preflight reuses a proof it was handed instead of asking the server again", async () => { + const reused = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n"); + const proof = { ready: true, remoteSha: "f".repeat(40), fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" }; + const context = { + repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, + profile: { branch: "main" }, + server: SERVER, + candidate: { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } }, + proof, + }; + + assert.deepEqual(await reused.host.preflightCandidate(context), proof); + assert.equal(reused.scripts.length, 0, "no second connection is opened"); + + // A proof that never established a remote commit is not a shortcut. + await assert.rejects( + () => reused.host.preflightCandidate({ ...context, proof: { ready: false } }), + /did not prove the remote branch/, + ); + assert.equal(reused.scripts.length, 1, "an unusable proof falls back to verifying"); +}); + +test("verifying the active key uses the repository-scoped paths rather than a candidate", async () => { + const { host, scripts } = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${"e".repeat(40)}\nfingerprint=SHA256:active\nhostFingerprint=SHA256:host\n`); + const paths = host.paths(REPOSITORY, SERVER); + + const proof = await host.verifyActive({ + repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, + profile: { branch: "main" }, + server: SERVER, + }); + assert.equal(proof.ready, true); + assert.ok(scripts[0].script.includes(paths.privateKey)); + assert.ok(scripts[0].script.includes(paths.knownHosts)); +}); + +test("promotion only replaces key material after proving the candidate is complete", async () => { + const { host, scripts } = keyHost(); + const paths = host.paths(REPOSITORY, SERVER); + const candidate = { paths: { directory: "/c", privateKey: "/c/deploy-key", publicKey: "/c/deploy-key.pub", knownHosts: "/c/known_hosts" } }; + + await host.promote({ repository: REPOSITORY, server: SERVER, candidate }); + const script = scripts[0].script; + assert.ok(script.includes("test -s '/c/deploy-key'"), "an empty candidate key is refused before anything is replaced"); + assert.ok(script.includes("test -s '/c/known_hosts'")); + assert.ok(script.indexOf("test -s") < script.indexOf("mv "), "the checks run before the swap"); + // The staging suffix is appended outside the quoted path, so the command reads + // mv ''.new '' rather than mv '.new' ''. + assert.ok(script.includes(`mv '${paths.privateKey}'.new '${paths.privateKey}'`), "the swap is atomic"); + assert.ok(script.includes(`cp -p '/c/deploy-key' '${paths.privateKey}'.new`), "the copy lands on the staging name first"); +}); + +test("rollback restores the recovery slot and removes the candidate", async () => { + const { host, scripts } = keyHost(); + const paths = host.paths(REPOSITORY, SERVER); + + await host.rollback({ + repository: REPOSITORY, + server: SERVER, + candidate: { paths: { directory: "/candidate" } }, + previous: { key: { recovery: "/recovery/backup-1" } }, + }); + assert.ok(scripts[0].script.includes("cp -p '/recovery/backup-1'")); + assert.ok(scripts[0].script.includes(paths.directory)); + assert.ok(scripts[0].script.includes("rm -rf -- '/candidate'")); +}); + +test("committing a rotation discards only the candidate directory", async () => { + const { host, scripts } = keyHost(); + await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } }); + // Every script carries the strict-mode preamble that bash() prepends. + assert.equal(scripts[0].script.split("\n").at(-1), "rm -rf -- '/candidate'"); + assert.ok(!scripts[0].script.includes(".forgeflow/git-credentials"), "the active key directory is never touched on commit"); +}); + +test("every server script runs under strict mode with Git prompts disabled", async () => { + const { host, scripts } = keyHost(); + await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } }); + assert.match(scripts[0].script, /^set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n/); + assert.equal(scripts[0].serverId, SERVER.id); +}); + +test("revocation moves key material aside so it can still be restored", async () => { + const { host, scripts } = keyHost(); + const paths = host.paths(REPOSITORY, SERVER); + + await host.revoke({ repository: REPOSITORY, server: SERVER }); + const script = scripts[0].script; + assert.ok(script.includes(`${paths.recovery}/revoked-`), "revoked material is kept in the recovery area"); + assert.match(script, /mv /, "the key is moved, never deleted"); + assert.ok(!/rm -rf/.test(script), "revocation must not destroy the recovery path"); +}); + +test("restore reinstates the newest recovery slot and reports the public evidence", async () => { + const publicKey = "ssh-ed25519 UkVT forgeflow"; + const { host, scripts } = keyHost( + `__FORGEFLOW_KEY_RESTORE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:restored\nhostFingerprint=SHA256:host\n`, + ); + + const restored = await host.restore({ repository: REPOSITORY, server: SERVER }); + assert.deepEqual(restored, { publicKey, fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" }); + assert.match(scripts[0].script, /sort \| tail -1/, "the newest slot is chosen deterministically"); + assert.ok(scripts[0].script.includes('test -n "$slot"'), "restoring without a recovery slot fails loudly"); +}); + +test("marker parsing keeps values that themselves contain separators", () => { + const parsed = parseDeployKeyMarker("noise\n__M__\nkey=a=b=c\nempty\nother=1\n", "__M__"); + assert.deepEqual(parsed, { key: "a=b=c", empty: "", other: "1" }); +}); diff --git a/tests/deploy-key-lifecycle.test.mjs b/tests/deploy-key-lifecycle.test.mjs new file mode 100644 index 0000000..d3cdf9b --- /dev/null +++ b/tests/deploy-key-lifecycle.test.mjs @@ -0,0 +1,139 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { DeployKeyLifecycleService } = require("../src/main/deploy-key-lifecycle-service.cjs"); +const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs"); + +const repository = { fullName: "Jens/Portfolio" }; +const baseProfile = { id: "production", serverId: "unraid", deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: 7, keyFingerprint: "SHA256:old", hostFingerprint: "SHA256:host" } }; + +function fixture(overrides = {}) { + let profile = structuredClone(baseProfile); + const events = []; + const remoteKeys = overrides.remoteKeys || [{ id: 7, title: "ForgeFlow old", read_only: true, key: "ssh-ed25519 T0xE old" }]; + const store = { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid" }), + getRepositories: () => [{ fullName: repository.fullName }], + getDeploymentProfiles: () => [profile, ...(overrides.otherProfiles || [])], + saveDeploymentProfile: async (_name, value) => { if (overrides.saveFails) throw new Error("switch failed"); profile = structuredClone(value); events.push("profile-saved"); return profile; }, + createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }), + }; + const gitea = { + listDeployKeys: async () => structuredClone(remoteKeys), + createReadOnlyDeployKey: async () => { if (overrides.registrationFails) throw new Error("registration failed"); return { id: 8, title: "new", read_only: overrides.writable !== true, key: "ssh-ed25519 TkVX new" }; }, + deleteDeployKey: async (_owner, _repo, id) => { events.push(`delete:${id}`); if (overrides.deleteOldFails && id === 7) throw new Error("old revoke failed"); return { deleted: true }; }, + }; + const keyHost = { + inspect: async () => overrides.inspect || ({ privateKeyPresent: true, publicKey: "ssh-ed25519 T0xE old", fingerprint: overrides.changedFingerprint ? "SHA256:changed" : "SHA256:old", hostFingerprint: "SHA256:host" }), + backup: async () => ({ recovery: "server-backup", publicKey: "ssh-ed25519 T0xE old" }), + generate: async () => ({ publicKey: "ssh-ed25519 TkVX new", fingerprint: "SHA256:new" }), + verifyCandidate: async () => overrides.verifyFails ? ({ ready: false, fingerprint: "SHA256:new" }) : ({ ready: true, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host", remoteSha: "a".repeat(40) }), + preflightCandidate: async () => { if (overrides.preflightFails) throw new Error("preflight failed"); events.push("preflight"); }, + promote: async () => { events.push("promote"); }, + verifyActive: async () => overrides.postFails ? ({ ready: false }) : ({ ready: true, fingerprint: "SHA256:new" }), + commit: async () => { events.push("commit"); }, + rollback: async () => { events.push("rollback"); }, + revoke: async () => { events.push("revoke-server"); if (overrides.revokeFails) throw new Error("server revoke failed"); }, + restore: async () => ({ publicKey: "ssh-ed25519 UkVTVE9SRQ restored", fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" }), + }; + const audit = { append: async (name) => events.push(name) }; + const service = new DeployKeyLifecycleService({ store, gitea, keyHost, audit, clock: () => "2026-07-29T00:00:00.000Z" }); + return { service, events, getProfile: () => profile }; +} + +test("deploy-key rotation verifies, switches, revokes and post-verifies in order", async () => { + const { service, events, getProfile } = fixture(); + const plan = await service.planRotation({ repository, profileId: "production" }); + const result = await service.rotate({ repository, profileId: "production", expectedPlanId: plan.id }); + assert.equal(result.profile.serverGitAccess.deployKeyId, 8); + assert.equal(getProfile().serverGitAccess.keyFingerprint, "SHA256:new"); + assert.deepEqual(events.filter((event) => ["preflight", "promote", "profile-saved", "delete:7", "commit"].includes(event)), ["preflight", "promote", "profile-saved", "delete:7", "commit"]); +}); + +for (const [name, overrides, message] of [ + ["registration failure", { registrationFails: true }, /registration failed/], + ["writable candidate", { writable: true }, /write access/], + ["candidate verification failure", { verifyFails: true }, /could not prove/], + ["candidate preflight failure", { preflightFails: true }, /preflight failed/], + ["atomic profile switch failure", { saveFails: true }, /switch failed/], + ["old key revocation failure", { deleteOldFails: true }, /old revoke failed/], + ["post-rotation failure", { postFails: true }, /Post-rotation verification failed/], +]) test(`deploy-key rotation rolls back on ${name}`, async () => { + const { service, events } = fixture(overrides); + const plan = await service.planRotation({ repository, profileId: "production" }); + await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: plan.id }), message); + assert.ok(events.includes("rollback")); +}); + +test("rotation rejects a stale content-addressed plan", async () => { + const { service } = fixture(); + await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: "stale" }), (error) => error.code === "DEPLOY_KEY_ROTATION_PLAN_STALE"); +}); + +test("inventory detects stale, orphaned, shared, conflicting and changed-fingerprint keys", async () => { + const { service } = fixture({ + changedFingerprint: true, + remoteKeys: [{ id: 9, title: "ForgeFlow orphan", read_only: true, key: "ssh-ed25519 T1JQSEFO orphan" }, { id: 10, title: "writable", read_only: false, key: "ssh-ed25519 T0xE old" }], + otherProfiles: [{ id: "staging", serverId: "unraid", serverGitAccess: { deployKeyId: 11, keyFingerprint: "SHA256:changed" } }], + }); + const report = await service.inventory({ repository, profileId: "production" }); + assert.equal(report.stale, true); + assert.equal(report.orphaned.length, 1); + assert.equal(report.shared.length, 1); + assert.equal(report.conflicts.length, 1); + assert.equal(report.ready, false); +}); + +test("revocation requires reviewed impact, disables pull and preserves recovery", async () => { + const { service, events, getProfile } = fixture(); + const plan = await service.planRevocation({ repository, profileId: "production" }); + assert.equal(plan.containersUnaffected, true); + await assert.rejects(service.revoke({ repository, profileId: "production" }), (error) => error.code === "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED"); + const result = await service.revoke({ repository, profileId: "production", expectedPlanId: plan.id }); + assert.equal(result.recovery, "server-backup"); + assert.equal(getProfile().deploymentMode, "monitor-only"); + assert.ok(events.includes("delete:7")); + assert.ok(events.includes("revoke-server")); +}); + +test("revoked access can be restored and verified", async () => { + const { service } = fixture(); + const result = await service.restore({ repository, profileId: "production" }); + assert.equal(result.profile.deploymentMode, "server-git"); + assert.equal(result.profile.serverGitAccess.keyFingerprint, "SHA256:restored"); + assert.equal(result.proof.ready, true); +}); + +test("failed server revocation restores repository access", async () => { + const { service, events, getProfile } = fixture({ revokeFails: true }); + const plan = await service.planRevocation({ repository, profileId: "production" }); + await assert.rejects(service.revoke({ repository, profileId: "production", expectedPlanId: plan.id }), /server revoke failed/); + assert.equal(getProfile().deploymentMode, "server-git"); + assert.ok(events.includes("delete:7")); +}); + +test("Unraid key host parser rejects unverifiable output", () => { + assert.throws(() => parseDeployKeyMarker("ordinary ssh output", "__FORGEFLOW_KEY__"), /did not return/); +}); + +test("Unraid candidate generation returns public evidence and paths but never private key content", async () => { + const publicKey = "ssh-ed25519 TkVX forgeflow"; + const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_CANDIDATE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n` }) }; + const host = new UnraidDeployKeyHost({ ssh }); + const candidate = await host.generate({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } }); + assert.equal(candidate.publicKey, publicKey); + assert.equal(candidate.fingerprint, "SHA256:new"); + assert.equal(candidate.privateKey, undefined); + assert.match(candidate.paths.privateKey, /candidate-[0-9a-f-]+\/deploy-key$/); +}); + +test("Unraid active key inspection exposes only public metadata", async () => { + const publicKey = "ssh-ed25519 T0xE forgeflow"; + const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_INSPECT__\nprivateKeyPresent=true\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:old\nhostFingerprint=SHA256:host\n` }) }; + const host = new UnraidDeployKeyHost({ ssh }); + const evidence = await host.inspect({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } }); + assert.deepEqual(evidence, { privateKeyPresent: true, publicKey, fingerprint: "SHA256:old", hostFingerprint: "SHA256:host" }); +}); diff --git a/tests/deployment-operations.test.mjs b/tests/deployment-operations.test.mjs new file mode 100644 index 0000000..feae762 --- /dev/null +++ b/tests/deployment-operations.test.mjs @@ -0,0 +1,497 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import deploymentModule from '../src/main/deployment-service.cjs'; + +const { DeploymentService } = deploymentModule; + +const SHA = 'a'.repeat(40); +const PREVIOUS_SHA = 'b'.repeat(40); + +async function serve(handler) { + const server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + url: `http://127.0.0.1:${server.address().port}/status`, + close: () => new Promise((resolve) => server.close(resolve)) + }; +} + +function jsonEndpoint(body, statusCode = 200) { + return serve((request, response) => { + response.writeHead(statusCode, { 'Content-Type': 'application/json' }); + response.end(typeof body === 'string' ? body : JSON.stringify(body)); + }); +} + +// A port nothing listens on, so the request fails instead of hanging. +async function unreachableUrl() { + const closed = await serve(() => {}); + await closed.close(); + return closed.url; +} + +function makeStore({ profile = null, operations = [] } = {}) { + const saved = new Map(operations.map((item) => [item.id, item])); + const states = new Map(); + return { + data: { operations, gitea: { baseUrl: 'https://gitea.example' } }, + getToken: () => 'gitea-secret-token', + getDeploymentProfile: () => profile, + getOperation: (id) => saved.get(id) || null, + addOperation: async (operation) => { + saved.set(operation.id, structuredClone(operation)); + return structuredClone(operation); + }, + saveDeploymentState: async (profileId, state) => { + states.set(profileId, state); + return state; + }, + saved, + states + }; +} + +function makeOperation(overrides = {}) { + return { + id: 'operation-1', + type: 'deployment', + action: 'deploy', + status: 'queued', + repository: 'jens/app', + profileId: 'production', + environment: 'production', + workflowFile: 'deploy.yml', + branch: 'main', + sha: SHA, + shortSha: SHA.slice(0, 7), + dispatchedAt: new Date().toISOString(), + stages: new DeploymentService({}, {}, {}).makeStages(), + logs: [], + ...overrides + }; +} + +function successPayload(overrides = {}) { + return { + repository: 'jens/app', + environment: 'production', + commit_sha: SHA, + previous_sha: PREVIOUS_SHA, + requested_sha: SHA, + request_id: 'operation-1', + last_exit_code: 0, + health: 'healthy', + ...overrides + }; +} + +test('the status endpoint reader accepts both key spellings and refuses anything that is not a commit SHA', async (context) => { + const service = new DeploymentService(makeStore(), {}, {}); + assert.deepEqual(await service.readStatusEndpoint(''), { configured: false }); + + const snake = await jsonEndpoint(successPayload()); + context.after(() => snake.close()); + const snakeResult = await service.readStatusEndpoint(snake.url); + assert.equal(snakeResult.ok, true); + assert.equal(snakeResult.liveSha, SHA); + assert.equal(snakeResult.previousSha, PREVIOUS_SHA); + assert.equal(snakeResult.requestedSha, SHA); + assert.equal(snakeResult.requestId, 'operation-1'); + assert.equal(snakeResult.lastExitCode, 0); + + const camel = await jsonEndpoint({ + repository: 'jens/app', + environment: 'PRODUCTION', + commitSha: SHA.toUpperCase(), + previousSha: PREVIOUS_SHA, + requestedSha: SHA, + requestId: 'operation-1', + lastExitCode: 3 + }); + context.after(() => camel.close()); + const camelResult = await service.readStatusEndpoint(camel.url); + assert.equal(camelResult.liveSha, SHA, 'a SHA is normalised to lower case'); + assert.equal(camelResult.environment, 'production', 'the environment is compared case-insensitively'); + assert.equal(camelResult.lastExitCode, 3); + + const untrusted = await jsonEndpoint({ commit_sha: 'HEAD', previous_sha: 'v1.2.3', request_id: 42, requested_sha: 'not-a-sha' }); + context.after(() => untrusted.close()); + const untrustedResult = await service.readStatusEndpoint(untrusted.url); + assert.equal(untrustedResult.liveSha, null); + assert.equal(untrustedResult.previousSha, null); + assert.equal(untrustedResult.requestedSha, null); + assert.equal(untrustedResult.requestId, null, 'a non-string request id is not accepted'); +}); + +test('an unreachable or failing status endpoint is reported instead of assumed healthy', async (context) => { + const service = new DeploymentService(makeStore(), {}, {}); + + const failing = await jsonEndpoint({ error: 'boom' }, 503); + context.after(() => failing.close()); + const failed = await service.readStatusEndpoint(failing.url); + assert.deepEqual( + { configured: failed.configured, reachable: failed.reachable, ok: failed.ok, status: failed.status }, + { configured: true, reachable: true, ok: false, status: 503 } + ); + + const offline = await service.readStatusEndpoint(await unreachableUrl()); + assert.equal(offline.reachable, false); + assert.equal(offline.ok, false); + assert.ok(offline.error); +}); + +test('healthchecks distinguish unconfigured, healthy, rejected and unreachable', async (context) => { + const service = new DeploymentService(makeStore(), {}, {}); + assert.deepEqual(await service.checkHealth(''), { configured: false, healthy: null }); + + const healthy = await jsonEndpoint({ ok: true }); + context.after(() => healthy.close()); + const healthyResult = await service.checkHealth(healthy.url); + assert.equal(healthyResult.healthy, true); + assert.equal(healthyResult.status, 200); + + const rejected = await jsonEndpoint({ ok: false }, 500); + context.after(() => rejected.close()); + assert.equal((await service.checkHealth(rejected.url)).healthy, false); + + const offline = await service.checkHealth(await unreachableUrl()); + assert.equal(offline.healthy, false); + assert.ok(offline.error); +}); + +test('profile state derives health from the status document when no healthcheck is configured', async (context) => { + const endpoint = await jsonEndpoint(successPayload({ health: 'degraded', deployed_at: '2026-08-01T10:00:00.000Z' })); + context.after(() => endpoint.close()); + const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' }; + const store = makeStore({ profile }); + const service = new DeploymentService(store, {}, {}); + + const state = await service.refreshProfileState('jens/app', 'production', { expectedSha: SHA }); + assert.equal(state.healthConfigured, false); + assert.equal(state.healthy, false, 'a degraded status document is not treated as healthy'); + assert.equal(state.liveSha, SHA); + assert.equal(state.versionMatches, true); + assert.equal(state.deployedAt, '2026-08-01T10:00:00.000Z'); + assert.equal(store.states.get('production').liveSha, SHA, 'the state is persisted'); +}); + +test('an unknown health word leaves the health state undecided rather than guessing', async (context) => { + const endpoint = await jsonEndpoint(successPayload({ health: 'starting' })); + context.after(() => endpoint.close()); + const store = makeStore({ profile: { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' } }); + const state = await new DeploymentService(store, {}, {}).refreshProfileState('jens/app', 'production'); + assert.equal(state.healthy, null); + assert.equal(state.versionMatches, null, 'without an expected SHA there is nothing to compare'); +}); + +test('refreshing the state of a removed profile fails loudly', async () => { + const service = new DeploymentService(makeStore({ profile: null }), {}, {}); + await assert.rejects(() => service.refreshProfileState('jens/app', 'gone'), /Deployment profile not found/); +}); + +test('a terminal operation is never polled again', async () => { + const operation = makeOperation({ status: 'success' }); + const store = makeStore({ operations: [operation] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => assert.fail('a finished deployment must not be polled'), + listWorkflowJobs: async () => assert.fail('a finished deployment must not be polled') + }, {}); + + assert.equal((await service.refreshOperation('operation-1')).status, 'success'); +}); + +test('an unknown operation is reported instead of silently ignored', async () => { + const service = new DeploymentService(makeStore(), {}, {}); + await assert.rejects(() => service.refreshOperation('missing'), /Deployment operation not found/); +}); + +test('a workflow run that is not visible yet keeps the deployment queued', async () => { + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => ({ run: null, source: 'actions' }) + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.status, 'queued'); + assert.equal(refreshed.stages.find((stage) => stage.id === 'queued').status, 'active'); + assert.match(refreshed.logs.at(-1), /queued or not visible/); +}); + +test('a failed runner marks the deployment failed and skips verification', async () => { + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => ({ run: { id: 7, runNumber: 7, status: 'completed', conclusion: 'failure', htmlUrl: 'https://gitea.example/run/7' }, source: 'actions' }), + listWorkflowJobs: async () => [{ name: 'build', status: 'completed', conclusion: 'failure' }] + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.status, 'failed'); + assert.equal(refreshed.failure.stage, 'runner'); + assert.equal(refreshed.stages.find((stage) => stage.id === 'healthcheck').status, 'skipped'); + assert.equal(refreshed.runUrl, 'https://gitea.example/run/7'); +}); + +test('a successful runner still fails when the server does not prove it runs the exact commit', async (context) => { + const endpoint = await jsonEndpoint(successPayload({ commit_sha: 'c'.repeat(40) })); + context.after(() => endpoint.close()); + const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' }; + const store = makeStore({ profile, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => ({ run: { id: 8, runNumber: 8, status: 'completed', conclusion: 'success' }, source: 'actions' }), + listWorkflowJobs: async () => [] + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.status, 'failed'); + assert.equal(refreshed.failure.stage, 'version-verification'); + assert.match(refreshed.failure.message, /instead of/); + assert.equal(refreshed.stages.find((stage) => stage.id === 'complete').status, 'failed'); +}); + +test('a verified deployment completes, and the same evidence marks a rollback as rolled back', async (context) => { + const endpoint = await jsonEndpoint(successPayload()); + context.after(() => endpoint.close()); + const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' }; + const gitea = { + findWorkflowRun: async () => ({ run: { id: 9, runNumber: 9, status: 'completed', conclusion: 'success' }, source: 'actions' }), + listWorkflowJobs: async () => [{ name: 'deploy', status: 'completed', conclusion: 'success' }] + }; + + const deployStore = makeStore({ profile, operations: [makeOperation()] }); + const deployed = await new DeploymentService(deployStore, gitea, {}).refreshOperation('operation-1'); + assert.equal(deployed.status, 'success'); + assert.equal(deployed.stages.find((stage) => stage.id === 'complete').status, 'complete'); + assert.equal(deployed.applicationState.liveSha, SHA); + assert.ok(deployed.logs.some((line) => line.includes('[job] deploy: success'))); + + const rollbackStore = makeStore({ profile, operations: [makeOperation({ action: 'rollback' })] }); + const rolledBack = await new DeploymentService(rollbackStore, gitea, {}).refreshOperation('operation-1'); + assert.equal(rolledBack.status, 'rolled-back'); +}); + +test('unavailable job details degrade to a warning instead of failing the refresh', async () => { + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => ({ run: { id: 10, runNumber: 10, status: 'in_progress', conclusion: null }, source: 'actions' }), + listWorkflowJobs: async () => { throw new Error('jobs API disabled'); } + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.status, 'running'); + assert.equal(refreshed.stages.find((stage) => stage.id === 'runner').status, 'active'); + assert.ok(refreshed.logs.some((line) => line.includes('Job details unavailable: jobs API disabled'))); +}); + +test('a failing poll is recorded on the operation without losing it', async () => { + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => { throw new Error('Gitea unreachable'); } + }, {}); + + const refreshed = await service.refreshOperation('operation-1'); + assert.equal(refreshed.pollError, 'Gitea unreachable'); + assert.equal(refreshed.status, 'queued', 'the operation keeps its last known state'); + assert.ok(refreshed.logs.some((line) => line.includes('Status refresh failed'))); +}); + +test('a deployment whose profile was deleted reports that instead of crashing the poll', async () => { + const store = makeStore({ profile: null, operations: [makeOperation()] }); + const refreshed = await new DeploymentService(store, {}, {}).refreshOperation('operation-1'); + assert.match(refreshed.pollError, /profile used by this operation no longer exists/); +}); + +test('a refresh already in flight is not started a second time', async () => { + let calls = 0; + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] }); + const service = new DeploymentService(store, { + findWorkflowRun: async () => { calls += 1; await gate; return { run: null, source: 'actions' }; } + }, {}); + + const first = service.refreshOperation('operation-1'); + const second = await service.refreshOperation('operation-1'); + assert.equal(second.status, 'queued'); + release(); + await first; + assert.equal(calls, 1, 'the second caller reuses the in-flight refresh'); +}); + +test('job states drive the runner stage', () => { + const service = new DeploymentService(makeStore(), {}, {}); + const stageOf = (jobs) => { + const operation = makeOperation(); + service.mapJobsToStages(operation, jobs); + return operation.stages.find((stage) => stage.id === 'runner').status; + }; + + assert.equal(stageOf([{ status: 'in_progress' }]), 'active'); + assert.equal(stageOf([{ conclusion: 'success' }, { conclusion: 'failure' }]), 'failed'); + assert.equal(stageOf([{ conclusion: 'success' }]), 'complete'); + assert.equal(stageOf([{ status: 'waiting' }]), 'pending'); + + const untouched = makeOperation(); + service.mapJobsToStages(untouched, []); + assert.equal(untouched.stages.find((stage) => stage.id === 'queued').status, 'active', 'no jobs leaves the stages alone'); +}); + +test('a rejected dispatch records the failure on the operation and still surfaces the error', async () => { + const profile = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', + statusUrl: 'https://app.example.test/.well-known/forgeflow' + }; + const store = makeStore({ profile }); + const service = new DeploymentService(store, { + listWorkflowRuns: async () => ({ runs: [{ id: 1 }, { id: 2 }] }), + dispatchWorkflow: async () => { throw new Error('workflow file not found'); } + }, { + status: async () => ({ head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }), + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }, { info: async () => {}, error: async () => {} }); + + await assert.rejects( + () => service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', sha: SHA }), + /workflow file not found/ + ); + + const stored = [...store.saved.values()].at(-1); + assert.equal(stored.status, 'failed'); + assert.equal(stored.failure.stage, 'dispatch'); + assert.deepEqual(stored.baselineRunIds, ['1', '2'], 'runs that existed before dispatch are never mistaken for this one'); + assert.equal(stored.stages.find((stage) => stage.id === 'queued').status, 'failed'); +}); + +test('a rejected rollback dispatch is recorded the same way as a rejected deployment', async (context) => { + const endpoint = await jsonEndpoint(successPayload()); + context.after(() => endpoint.close()); + const profile = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: '' + }; + const store = makeStore({ profile }); + const service = new DeploymentService(store, { + listWorkflowRuns: async () => ({ runs: [] }), + dispatchWorkflow: async () => { throw new Error('rollback workflow is disabled'); } + }, { + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }, { info: async () => {}, error: async () => {} }); + + await assert.rejects( + () => service.rollback({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', targetSha: PREVIOUS_SHA }), + /rollback workflow is disabled/ + ); + + const stored = [...store.saved.values()].at(-1); + assert.equal(stored.action, 'rollback'); + assert.equal(stored.status, 'failed'); + assert.equal(stored.failure.stage, 'dispatch'); + assert.equal(stored.workflowFile, 'rollback.yml'); +}); + +test('rollback refuses every state where the target is not the server-reported previous version', async (context) => { + const endpoint = await jsonEndpoint(successPayload()); + context.after(() => endpoint.close()); + const base = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: '' + }; + const git = { verifyCommitOnRemoteBranch: async () => ({ valid: true }) }; + const repository = { fullName: 'jens/app', localPath: '/repo' }; + const rollback = (profile, targetSha) => new DeploymentService(makeStore({ profile }), {}, git) + .rollback({ repository, profileId: 'production', targetSha }); + + await assert.rejects(() => rollback({ ...base, rollbackWorkflowFile: '' }, PREVIOUS_SHA), /No rollback workflow is configured/); + await assert.rejects(() => rollback(base, 'c'.repeat(40)), /no longer the previous server version/); + await assert.rejects(() => rollback(base, SHA), /no longer the previous server version/, 'the live commit is not the previous one either'); + + // The "already live" guard only remains reachable when the server reports the + // same commit as both its live and its previous version. + const stuck = await jsonEndpoint(successPayload({ previous_sha: SHA })); + context.after(() => stuck.close()); + await assert.rejects(() => rollback({ ...base, statusUrl: stuck.url }, SHA), /already live/); + + const noPrevious = await jsonEndpoint(successPayload({ previous_sha: null })); + context.after(() => noPrevious.close()); + await assert.rejects(() => rollback({ ...base, statusUrl: noPrevious.url }, PREVIOUS_SHA), /does not report a previous version/); + + const otherEnvironment = await jsonEndpoint(successPayload({ environment: 'staging' })); + context.after(() => otherEnvironment.close()); + await assert.rejects(() => rollback({ ...base, statusUrl: otherEnvironment.url }, PREVIOUS_SHA), /does not match this repository and environment/); + + // An unreachable endpoint surfaces the underlying network error rather than a + // generic message, so the reason a rollback was refused stays diagnosable. + const unreachable = { ...base, statusUrl: await unreachableUrl() }; + await assert.rejects(() => rollback(unreachable, PREVIOUS_SHA), /fetch failed|ECONNREFUSED|must be reachable/i); +}); + +test('an unavailable run baseline degrades to a warning rather than blocking the dispatch', async () => { + const profile = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/.well-known/forgeflow' + }; + const store = makeStore({ profile }); + const service = new DeploymentService(store, { + listWorkflowRuns: async () => { throw new Error('Actions API disabled'); }, + dispatchWorkflow: async () => ({ accepted: true }) + }, { + status: async () => ({ head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }), + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }, { info: async () => {}, error: async () => {} }); + + const operation = await service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', sha: SHA }); + assert.equal(operation.status, 'queued'); + assert.deepEqual(operation.baselineRunIds, []); + assert.ok(operation.logs.some((line) => line.includes('Could not capture the pre-dispatch run baseline'))); +}); + +test('deployment logs never repeat a line and never carry the Gitea token', () => { + const service = new DeploymentService(makeStore(), {}, {}); + const operation = makeOperation({ logs: undefined }); + + service.appendLog(operation, 'plain line'); + service.appendLog(operation, 'plain line'); + service.appendLog(operation, 'authorization: token gitea-secret-token'); + assert.equal(operation.logs.length, 2, 'a repeated line is not appended twice'); + assert.ok(!operation.logs.at(-1).includes('gitea-secret-token')); + + for (let index = 0; index < 1200; index += 1) service.appendLog(operation, `line ${index}`); + assert.equal(operation.logs.length, 1000, 'the log is bounded'); + assert.equal(operation.logs.at(-1), 'line 1199'); +}); + +test('a repository identity that is not exactly owner/repo is refused', () => { + const service = new DeploymentService(makeStore(), {}, {}); + assert.deepEqual(service.splitRepository('jens/app'), { owner: 'jens', repo: 'app' }); + for (const value of ['', 'app', 'jens/app/extra', '/app', 'jens/']) { + assert.throws(() => service.splitRepository(value), /Invalid Gitea repository identity/); + } +}); + +test('deployment is refused without a linked local repository', async () => { + const service = new DeploymentService(makeStore(), {}, {}); + await assert.rejects(() => service.deploy({ repository: { fullName: 'jens/app' }, profileId: 'production', sha: SHA }), /linked local repository/); + await assert.rejects(() => service.rollback({ repository: { localPath: '/repo' }, profileId: 'production', targetSha: SHA }), /linked local repository/); +}); + +test('validation refuses every local state that would deploy something other than the reviewed commit', async () => { + const profile = { id: 'production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/status' }; + const base = { head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }; + const cases = [ + [{ ...base, head: 'c'.repeat(40) }, /no longer matches the local repository/], + [{ ...base, branch: { ...base.branch, head: 'feature' } }, /only allows deployments from main/], + [{ ...base, counts: { changed: 2 } }, /Commit local changes/], + [{ ...base, branch: { ...base.branch, ahead: 1 } }, /Push all local commits/], + [{ ...base, branch: { ...base.branch, behind: 1 } }, /Synchronize with Gitea/], + [{ ...base, branch: { ...base.branch, upstream: '' } }, /Publish this branch/] + ]; + + for (const [status, expected] of cases) { + const service = new DeploymentService(makeStore({ profile }), {}, { + status: async () => status, + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }); + await assert.rejects(() => service.validateDeploy({ localPath: '/repo' }, profile, SHA), expected); + } +}); diff --git a/tests/deployment-policy.test.mjs b/tests/deployment-policy.test.mjs new file mode 100644 index 0000000..374d823 --- /dev/null +++ b/tests/deployment-policy.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import policyModule from '../src/shared/deployment-policy.cjs'; + +const { evaluateDeploymentPolicy } = policyModule; + +test('deployment freeze and maintenance windows fail closed with reasoned overrides', () => { + const profile = { deploymentPolicy: { frozen: true, freezeReason: 'Incident', requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } }; + const now = new Date(2026, 6, 28, 12, 0); // Tuesday + assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release' }), (error) => error.code === 'DEPLOYMENT_POLICY_BLOCKED'); + assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true }), /override reason/i); + const result = evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true, reason: 'Emergency recovery' }); + assert.equal(result.overridden, true); + assert.equal(result.violations.length, 2); +}); + +test('deployment policy accepts an in-window release with required note', () => { + const now = new Date(2026, 6, 27, 9, 30); // Monday + const profile = { deploymentPolicy: { requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } }; + assert.equal(evaluateDeploymentPolicy(profile, { now, note: 'Version 1.2' }).allowed, true); + assert.throws(() => evaluateDeploymentPolicy(profile, { now }), /release note/i); +}); + +test('overnight maintenance windows continue into the following day', () => { + const profile = { deploymentPolicy: { maintenanceWindows: [{ days: [1], start: '22:00', end: '02:00' }] } }; + assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 27, 23, 0) }).allowed, true); + assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 1, 0) }).allowed, true); + assert.throws(() => evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 3, 0) }), /outside/); +}); diff --git a/tests/deployment-status.test.mjs b/tests/deployment-status.test.mjs new file mode 100644 index 0000000..a1df0e6 --- /dev/null +++ b/tests/deployment-status.test.mjs @@ -0,0 +1,193 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import deploymentModule from '../src/main/deployment-service.cjs'; + +const { DeploymentService, terminalRunConclusion, applicationVerificationFailure } = deploymentModule; + +test('maps runner conclusions to ForgeFlow terminal states', () => { + assert.equal(terminalRunConclusion({ conclusion: 'success' }), 'success'); + assert.equal(terminalRunConclusion({ conclusion: 'failure' }), 'failed'); + assert.equal(terminalRunConclusion({ status: 'timed_out' }), 'failed'); + assert.equal(terminalRunConclusion({ conclusion: 'cancelled' }), 'cancelled'); + assert.equal(terminalRunConclusion({ status: 'running' }), null); +}); + + +test('dispatches only controlled deployment inputs', async () => { + const sha = 'a'.repeat(40); + let dispatched = null; + const operations = new Map(); + const profile = { + id: 'staging', name: 'Staging', environment: 'staging', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', + statusUrl: 'https://app.example.test/.well-known/forgeflow', + inputs: { commit_sha: 'b'.repeat(40), request_id: 'forged', arbitrary: 'ignored' } + }; + const store = { + getDeploymentProfile: () => profile, + getToken: () => '', + addOperation: async (operation) => { operations.set(operation.id, structuredClone(operation)); return structuredClone(operation); } + }; + const service = new DeploymentService(store, { + dispatchWorkflow: async (payload) => { dispatched = payload; return { accepted: true, status: 204 }; } + }, { + status: async () => ({ head: sha, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }), + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }, { info: async () => {}, error: async () => {} }); + + const operation = await service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: profile.id, sha }); + assert.deepEqual(Object.keys(dispatched.inputs).sort(), ['commit_sha', 'environment', 'request_id']); + assert.equal(dispatched.inputs.commit_sha, sha); + assert.equal(dispatched.inputs.environment, 'staging'); + assert.equal(dispatched.inputs.request_id, operation.id); + assert.equal(dispatched.inputs.arbitrary, undefined); +}); + +test('requires exact server SHA and matching request ID after a successful workflow', () => { + const operation = { id: 'request-1', repository: 'jens/app', environment: 'staging', sha: 'a'.repeat(40), shortSha: 'aaaaaaa' }; + assert.equal(applicationVerificationFailure(operation, { + statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging', + liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true + }), null); + assert.match(applicationVerificationFailure(operation, { + statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging', + liveSha: operation.sha, requestedSha: operation.sha, requestId: 'another-request', lastExitCode: 0, healthy: true + }).message, /different deployment request/i); + assert.match(applicationVerificationFailure(operation, { + statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging', + liveSha: 'b'.repeat(40), requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true + }).message, /server reports/i); + assert.match(applicationVerificationFailure(operation, { + statusConfigured: true, statusReachable: false, liveSha: null, + requestId: null, healthy: null + }).message, /not reachable/i); + assert.match(applicationVerificationFailure(operation, { + statusConfigured: true, statusReachable: true, statusRepository: 'other/app', statusEnvironment: 'staging', + liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true + }).message, /belongs to other\/app/i); + assert.match(applicationVerificationFailure(operation, { + statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging', + liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 70, healthy: false + }).message, /exit code 70/i); +}); + +test('server verification reports every incomplete or mismatched evidence field', () => { + const sha = 'a'.repeat(40); + const operation = { id: 'request-1', repository: 'jens/app', environment: 'production', sha, shortSha: sha.slice(0, 7) }; + const valid = { statusConfigured: true, statusReachable: true, statusRepository: operation.repository, statusEnvironment: operation.environment, liveSha: sha, requestedSha: sha, requestId: operation.id, lastExitCode: 0, healthy: true }; + const cases = [ + [{ ...valid, statusConfigured: false }, /is configured/i], + [{ ...valid, statusRepository: '' }, /did not identify its repository/i], + [{ ...valid, statusEnvironment: '' }, /did not identify its environment/i], + [{ ...valid, statusEnvironment: 'staging' }, /belongs to staging/i], + [{ ...valid, liveSha: '' }, /valid full commit SHA/i], + [{ ...valid, requestedSha: '' }, /requested commit SHA/i], + [{ ...valid, requestedSha: 'b'.repeat(40) }, /different requested commit/i], + [{ ...valid, requestId: '' }, /deployment request ID/i], + [{ ...valid, lastExitCode: null }, /exit code unknown/i], + [{ ...valid, healthy: false, healthStatus: 'degraded' }, /degraded/i], + [{ ...valid, healthy: null, error: 'probe failed' }, /probe failed/i] + ]; + for (const [state, pattern] of cases) assert.match(applicationVerificationFailure(operation, state).message, pattern); +}); + +test('rollback accepts only the currently reported previous SHA and dispatches controlled inputs', async () => { + const liveSha = 'a'.repeat(40); + const previousSha = 'b'.repeat(40); + let dispatched = null; + let verified = null; + const operations = new Map(); + const profile = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', + statusUrl: 'https://app.example.test/.well-known/forgeflow', + inputs: { target_sha: 'c'.repeat(40), request_id: 'forged', arbitrary: 'ignored' } + }; + const store = { + getDeploymentProfile: () => profile, + getToken: () => '', + addOperation: async (operation) => { operations.set(operation.id, structuredClone(operation)); return structuredClone(operation); } + }; + const service = new DeploymentService(store, { + listWorkflowRuns: async () => ({ runs: [] }), + dispatchWorkflow: async (payload) => { dispatched = payload; return { accepted: true, status: 204 }; } + }, { + verifyCommitOnRemoteBranch: async (...args) => { verified = args; return { valid: true }; } + }, { info: async () => {}, warning: async () => {}, error: async () => {} }); + service.refreshProfileState = async () => ({ + statusReachable: true, + statusRepository: 'jens/app', + statusEnvironment: 'production', + liveSha, + previousSha + }); + + const operation = await service.rollback({ + repository: { fullName: 'jens/app', localPath: '/repo' }, + profileId: profile.id, + targetSha: previousSha + }); + + assert.deepEqual(verified, ['/repo', previousSha, 'main']); + assert.deepEqual(Object.keys(dispatched.inputs).sort(), ['environment', 'request_id', 'target_sha']); + assert.equal(dispatched.inputs.environment, 'production'); + assert.equal(dispatched.inputs.target_sha, previousSha); + assert.equal(dispatched.inputs.request_id, operation.id); + assert.equal(dispatched.inputs.arbitrary, undefined); +}); + +test('rollback refuses a stale target that is no longer the server-reported previous SHA', async () => { + const previousSha = 'b'.repeat(40); + let dispatched = false; + const profile = { + id: 'production', name: 'Production', environment: 'production', branch: 'main', + workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', + statusUrl: 'https://app.example.test/.well-known/forgeflow' + }; + const service = new DeploymentService({ + getDeploymentProfile: () => profile, + getToken: () => '', + addOperation: async (operation) => operation + }, { + dispatchWorkflow: async () => { dispatched = true; } + }, { + verifyCommitOnRemoteBranch: async () => ({ valid: true }) + }, { info: async () => {}, warning: async () => {}, error: async () => {} }); + service.refreshProfileState = async () => ({ + statusReachable: true, + statusRepository: 'jens/app', + statusEnvironment: 'production', + liveSha: 'a'.repeat(40), + previousSha + }); + + await assert.rejects( + service.rollback({ + repository: { fullName: 'jens/app', localPath: '/repo' }, + profileId: profile.id, + targetSha: 'c'.repeat(40) + }), + /no longer the previous server version/i + ); + assert.equal(dispatched, false); +}); + +test('active deployment refreshes run concurrently with a bounded worker pool', async () => { + const operations = Array.from({ length: 9 }, (_, index) => ({ id: `operation-${index}`, type: 'deployment', status: 'running' })); + const service = new DeploymentService({ data: { operations } }, {}, {}); + let running = 0; + let peak = 0; + service.refreshOperation = async (id) => { + running += 1; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 10)); + running -= 1; + return { id }; + }; + + const refreshed = await service.refreshActiveOperations(); + assert.equal(refreshed.length, operations.length); + assert.ok(peak > 1); + assert.ok(peak <= 4); +}); + diff --git a/tests/diagnostics.test.mjs b/tests/diagnostics.test.mjs new file mode 100644 index 0000000..772880a --- /dev/null +++ b/tests/diagnostics.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import diagnosticsModule from '../src/main/diagnostics-service.cjs'; + +const { DiagnosticsService } = diagnosticsModule; + +function unzipLocalEntries(buffer) { + const entries = new Map(); + let offset = 0; + while (offset + 4 <= buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { + const method = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + nameLength + extraLength; + const name = buffer.subarray(nameStart, nameStart + nameLength).toString('utf8'); + const compressed = buffer.subarray(dataStart, dataStart + compressedSize); + entries.set(name, method === 8 ? zlib.inflateRawSync(compressed) : compressed); + offset = dataStart + compressedSize; + } + return entries; +} + +test('writes structured local diagnostics and exports a secret-free support bundle', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-diagnostics-')); + t.after(() => rm(root, { recursive: true, force: true })); + const secret = ['gitea', 'TEST', 'ONLY', 'ULTRA', 'SECRET', '1234567890'].join('_'); + const service = new DiagnosticsService({ + userDataPath: root, + appInfo: { name: 'ForgeFlow', version: '0.3.0-test' }, + secretProvider: () => [secret], + preferencesProvider: () => ({ diagnosticsEnabled: true, diagnosticLevel: 'debug', logRetentionDays: 14, maxLogFileMb: 8 }) + }); + await service.initialize(); + await service.error('test.failure', { + authorization: `token ${secret}`, + password: 'unsafe-password', + message: `request failed with ${secret}`, + path: path.join(os.homedir(), 'private', 'repository'), + host: '192.168.10.20', + basePath: '/mnt/user/appdata/private-app' + }); + await service.flush(); + + const status = await service.getStatus(); + assert.equal(status.enabled, true); + assert.ok(status.fileCount >= 1); + const raw = (await Promise.all((await service.listLogFiles()).map((file) => readFile(file.path, 'utf8')))).join('\n'); + assert.doesNotMatch(raw, new RegExp(secret)); + assert.doesNotMatch(raw, /unsafe-password/); + assert.doesNotMatch(raw, new RegExp(os.homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + + const destination = path.join(root, 'support.zip'); + const result = await service.exportSupportBundle({ + destinationPath: destination, + privacyMode: 'strict', + publicState: { gitea: { baseUrl: 'https://gitea.example.test', hasToken: true, encryptedToken: 'ciphertext' }, servers: [{ host: '192.168.10.20', username: 'deploy', basePath: '/mnt/user/appdata/private-app' }], preferences: {} }, + repositories: [{ id: 1, fullName: 'jens/private-repo', localPath: path.join(os.homedir(), 'private-repo'), localStatus: { head: 'a'.repeat(40), branch: { head: 'main' }, counts: {}, clean: true } }], + operations: [{ repository: 'jens/private-repo', status: 'failed', runnerLog: `Authorization: token ${secret}` }], + preflight: { checks: [] } + }); + assert.equal(service.isKnownBundlePath(result.path), true); + const entries = unzipLocalEntries(await readFile(destination)); + const bundleText = [...entries.values()].map((value) => value.toString('utf8')).join('\n'); + assert.doesNotMatch(bundleText, new RegExp(secret)); + assert.doesNotMatch(bundleText, /ciphertext|unsafe-password|jens\/private-repo/); + assert.doesNotMatch(bundleText, /192\.168\.10\.20|\/mnt\/user\/appdata\/private-app/); + assert.match(entries.get('manifest.json').toString(), /"containsSecrets": false/); + assert.match(entries.get('repositories-sanitized.json').toString(), /fullname-[a-f0-9]{12}/); + + const cleared = await service.clear(); + assert.ok(cleared.fileCount >= 1, 'clear writes a new safe session marker'); +}); diff --git a/tests/external-tools.test.mjs b/tests/external-tools.test.mjs new file mode 100644 index 0000000..3f54c9b --- /dev/null +++ b/tests/external-tools.test.mjs @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import toolsModule from '../src/main/external-tools-service.cjs'; + +const { normalizeTool, expandTool } = toolsModule; + +test('external tool templates expand as argument arrays without a shell', () => { + const tool = normalizeTool({ executable: 'code.exe', args: ['--malicious', 'ignored'] }, { executable: 'code.exe' }, 'editor'); + const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 }); + assert.equal(invocation.executable, 'code.exe'); + assert.deepEqual(invocation.args, ['--reuse-window', '--goto', 'C:\\Projects\\App\\src\\app.js:12']); + assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}, 'editor'), /invalid/); + assert.throws(() => normalizeTool({ executable: 'powershell.exe', args: ['-Command', 'calc'] }, {}, 'terminal'), /unsupported terminal tool/i); +}); diff --git a/tests/git-integration.test.mjs b/tests/git-integration.test.mjs new file mode 100644 index 0000000..9b8b819 --- /dev/null +++ b/tests/git-integration.test.mjs @@ -0,0 +1,395 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import gitModule from '../src/main/git-service.cjs'; + +const exec = promisify(execFile); +const { GitService } = gitModule; + +async function git(args, cwd) { + return exec('git', args, { cwd, encoding: 'utf8' }); +} + +test('GitService reads changes and commits/pushes selected files to a real bare remote', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'README.md'), '# ForgeFlow\n'); + await git(['add', 'README.md'], working); + await git(['commit', '-m', 'Initial commit'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + await fs.appendFile(path.join(working, 'README.md'), '\nDesktop release cockpit.\n'); + await fs.writeFile(path.join(working, 'feature.txt'), 'new file\n'); + + const service = new GitService(); + const before = await service.status(working); + assert.equal(before.branch.head, 'main'); + assert.equal(before.branch.ahead, 0); + assert.equal(before.branch.behind, 0); + assert.equal(before.counts.changed, 2); + assert.deepEqual(new Set(before.files.map((file) => file.path)), new Set(['README.md', 'feature.txt'])); + + const diff = await service.diff(working, 'README.md'); + assert.match(diff, /Desktop release cockpit/); + + const result = await service.commitAndPush(working, 'Add desktop cockpit copy', ['README.md', 'feature.txt']); + assert.equal(result.status.clean, true); + assert.equal(result.status.branch.ahead, 0); + assert.match(result.pushOutput, /main/); + + const remoteLog = await git(['--git-dir', remote, 'log', '-1', '--pretty=%s', 'refs/heads/main'], root); + assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy'); +}); + +test('untracked diff rendering refuses links outside the repository and oversized files', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diff-boundary-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const repository = path.join(root, 'repository'); + const outside = path.join(root, 'outside'); + await fs.mkdir(repository, { recursive: true }); + await fs.mkdir(outside, { recursive: true }); + await git(['init'], repository); + await fs.writeFile(path.join(outside, 'secret.txt'), 'outside-secret'); + try { + await fs.symlink(outside, path.join(repository, 'linked'), process.platform === 'win32' ? 'junction' : 'dir'); + } catch { + t.skip('this platform does not allow creating directory links'); + return; + } + + const service = new GitService(); + await assert.rejects( + service.diff(repository, 'linked/secret.txt'), + (error) => error.code === 'DIFF_TARGET_OUTSIDE_REPOSITORY', + ); + + await fs.writeFile(path.join(repository, 'too-large.txt'), Buffer.alloc(16 * 1024 * 1024 + 1, 0x61)); + await assert.rejects( + service.diff(repository, 'too-large.txt'), + (error) => error.code === 'DIFF_FILE_TOO_LARGE' && error.recoverable === true, + ); +}); + +test('stages and pushes deleted and renamed files selected from the working tree', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-delete-rename-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'silent-zebra-glow.zip'), 'obsolete archive\n'); + await fs.writeFile(path.join(working, 'old-name.txt'), 'rename me\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial files'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + await fs.rm(path.join(working, 'silent-zebra-glow.zip')); + await fs.rename(path.join(working, 'old-name.txt'), path.join(working, 'new-name.txt')); + + const service = new GitService(); + const before = await service.status(working); + assert.ok(before.files.some((file) => file.path === 'silent-zebra-glow.zip' && file.status === 'deleted')); + + const selected = before.files.map((file) => file.path); + const result = await service.commitAndPush(working, 'Remove obsolete archive and rename file', selected); + assert.equal(result.status.clean, true); + assert.equal(result.status.branch.ahead, 0); + + const tree = await git(['--git-dir', remote, 'ls-tree', '-r', '--name-only', 'refs/heads/main'], root); + const names = tree.stdout.trim().split(/\r?\n/).filter(Boolean); + assert.deepEqual(names, ['new-name.txt']); +}); + + +test('commits a deletion that was already staged manually without restaging its missing path', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-staged-delete-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'silent-zebra-glow.zip'), 'obsolete archive\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial archive'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + await fs.rm(path.join(working, 'silent-zebra-glow.zip')); + const service = new GitService(); + const staged = await service.stage(working, ['silent-zebra-glow.zip']); + assert.equal(staged.files[0].path, 'silent-zebra-glow.zip'); + assert.equal(staged.files[0].staged, true); + assert.equal(staged.files[0].unstaged, false); + + // This used to call git add -A for the same already-staged deletion again, + // which fails with a pathspec error because the file no longer exists. + const result = await service.commitAndPush(working, 'Remove obsolete archive', ['silent-zebra-glow.zip']); + assert.equal(result.status.clean, true); + assert.equal(result.status.branch.ahead, 0); + + const tree = await git(['--git-dir', remote, 'ls-tree', '-r', '--name-only', 'refs/heads/main'], root); + assert.equal(tree.stdout.trim(), ''); +}); + +test('keeps a successful local commit visible as ahead when the following push fails', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-push-failure-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'README.md'), '# Portfolio\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + await fs.appendFile(path.join(working, 'README.md'), '\nUpdated locally.\n'); + await git(['remote', 'set-url', 'origin', path.join(root, 'missing-remote.git')], working); + + const service = new GitService(); + await assert.rejects( + service.commitAndPush(working, 'Update portfolio', ['README.md']), + (error) => Boolean(error.code === 'PUSH_AFTER_COMMIT_FAILED' && error.commitSha) + ); + + const status = await service.status(working); + assert.equal(status.clean, true); + assert.equal(status.branch.ahead, 1); + const subject = await git(['log', '-1', '--pretty=%s'], working); + assert.equal(subject.stdout.trim(), 'Update portfolio'); +}); + + +test('stages a large Windows-sized partial selection through NUL-delimited stdin', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-large-selection-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const working = path.join(root, 'working'); + await fs.mkdir(working); + await git(['init'], working); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'README.md'), '# Large selection\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial'], working); + + const names = []; + for (let index = 0; index < 850; index += 1) { + const name = `generated/feature-${String(index).padStart(4, '0')}-${'x'.repeat(28)}.txt`; + names.push(name); + await fs.mkdir(path.dirname(path.join(working, name)), { recursive: true }); + await fs.writeFile(path.join(working, name), `file ${index}\n`); + } + const service = new GitService(); + const status = await service.stage(working, names); + assert.equal(status.counts.staged, names.length); + assert.equal(status.counts.unstaged, 0); +}); + + +test('detects and removes a stale HEAD.lock while skipping Git object storage', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-head-lock-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + await git(['init'], root); + await git(['config', 'user.name', 'ForgeFlow Test'], root); + await git(['config', 'user.email', 'forgeflow@example.invalid'], root); + await fs.writeFile(path.join(root, 'README.md'), 'lock test\n'); + await git(['add', '.'], root); + await git(['commit', '-m', 'Initial'], root); + const headLock = path.join(root, '.git', 'HEAD.lock'); + const ignoredObjectLock = path.join(root, '.git', 'objects', 'fake.lock'); + await fs.writeFile(headLock, 'stale'); + await fs.writeFile(ignoredObjectLock, 'not a repository mutation lock'); + const old = new Date(Date.now() - 60_000); + await fs.utimes(headLock, old, old); + const service = new GitService(); + const report = await service.listGitLocks(root); + assert.deepEqual(report.locks.map((item) => item.name), ['HEAD.lock']); + const repaired = await service.repairStaleGitLocks(root, { minimumAgeMs: 0, allowWithoutProcessProbe: true }); + assert.equal(repaired.removed.length, 1); + await assert.rejects(() => fs.stat(headLock), (error) => error.code === 'ENOENT'); + assert.ok(await fs.stat(ignoredObjectLock)); +}); + +test('previews and safely mirrors a workspace to Gitea while preserving every class of local work', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-workspace-sync-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + const external = path.join(root, 'external'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, '.gitignore'), 'runtime/\n'); + await fs.writeFile(path.join(working, 'README.md'), 'initial\n'); + await fs.writeFile(path.join(working, 'obsolete.txt'), 'remove remotely\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + await git(['clone', remote, external], root); + await git(['config', 'user.name', 'External Gitea Test'], external); + await git(['config', 'user.email', 'external@example.invalid'], external); + await git(['checkout', 'main'], external); + await fs.writeFile(path.join(external, 'README.md'), 'changed on Gitea\n'); + await fs.rm(path.join(external, 'obsolete.txt')); + await fs.writeFile(path.join(external, 'remote-only.txt'), 'new on Gitea\n'); + await git(['add', '-A'], external); + await git(['commit', '-m', 'External cleanup'], external); + await git(['push', 'origin', 'main'], external); + + await fs.writeFile(path.join(working, 'local-commit.txt'), 'local committed work\n'); + await git(['add', 'local-commit.txt'], working); + await git(['commit', '-m', 'Local Codex work'], working); + const localHead = (await git(['rev-parse', 'HEAD'], working)).stdout.trim(); + await fs.appendFile(path.join(working, 'README.md'), 'local uncommitted edit\n'); + await fs.writeFile(path.join(working, 'local-notes.txt'), 'untracked local notes\n'); + await fs.mkdir(path.join(working, 'runtime'), { recursive: true }); + await fs.writeFile(path.join(working, 'runtime', 'local.db'), 'ignored runtime state\n'); + + const service = new GitService(); + const firstPlan = await service.previewWorkspaceSync(working); + assert.match(firstPlan.id, /^[0-9a-f]{64}$/); + assert.equal(firstPlan.summary.localCommitsToProtect, 1); + assert.equal(firstPlan.summary.incomingCommits, 1); + assert.equal(firstPlan.summary.localFilesToStash, 2); + assert.equal(firstPlan.summary.untrackedFilesToStash, 1); + assert.ok(firstPlan.changes.some((item) => item.path === 'obsolete.txt' && item.code === 'D')); + assert.equal(firstPlan.recovery.ignoredFilesPreserved, true); + + await fs.writeFile(path.join(working, 'changed-after-preview.txt'), 'forces a stale plan\n'); + await assert.rejects( + service.synchronizeWorkspace(working, firstPlan.id), + (error) => error.code === 'WORKSPACE_SYNC_PLAN_STALE' + ); + assert.equal(await fs.readFile(path.join(working, 'changed-after-preview.txt'), 'utf8'), 'forces a stale plan\n'); + + const reviewedPlan = await service.previewWorkspaceSync(working); + const result = await service.synchronizeWorkspace(working, reviewedPlan.id); + assert.equal(result.applied, true); + assert.equal(result.status.clean, true); + assert.equal(result.status.head, reviewedPlan.targetSha); + assert.match(result.backupBranch, /^forgeflow\/recovery-main-/); + assert.ok(result.stash?.sha); + assert.equal(result.stash.quarantined, true); + assert.equal(result.review.id, reviewedPlan.id); + assert.equal(result.review.status, 'pending-codex-review'); + const reviewManifest = JSON.parse(await fs.readFile(result.review.manifestPath, 'utf8')); + assert.equal(reviewManifest.recoveryBranch, result.backupBranch); + assert.equal(reviewManifest.stashSha, result.stash.sha); + assert.deepEqual( + new Set(reviewManifest.files.map((file) => file.path)), + new Set(['README.md', 'local-notes.txt', 'changed-after-preview.txt']) + ); + assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead); + assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n'); + assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n'); + await assert.rejects(fs.stat(path.join(working, 'obsolete.txt')), (error) => error.code === 'ENOENT'); + await assert.rejects(fs.stat(path.join(working, 'local-commit.txt')), (error) => error.code === 'ENOENT'); + await assert.rejects(fs.stat(path.join(working, 'local-notes.txt')), (error) => error.code === 'ENOENT'); + assert.equal(await fs.readFile(path.join(working, 'runtime', 'local.db'), 'utf8'), 'ignored runtime state\n'); + const stashedPaths = (await git(['stash', 'show', '--include-untracked', '--name-only', result.stash.ref], working)).stdout; + assert.match(stashedPaths, /README\.md/); + assert.match(stashedPaths, /local-notes\.txt/); + assert.match(stashedPaths, /changed-after-preview\.txt/); + await assert.rejects( + service.popStash(working, result.stash.ref), + (error) => error.code === 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED' + ); + await git(['switch', result.backupBranch], working); + await assert.rejects( + service.push(working), + (error) => error.code === 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY' + ); +}); + +test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diverged-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + const other = path.join(root, 'other'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'README.md'), 'initial\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + await git(['clone', remote, other], root); + await git(['config', 'user.name', 'Other Test'], other); + await git(['config', 'user.email', 'other@example.invalid'], other); + await git(['checkout', 'main'], other); + await fs.writeFile(path.join(other, 'remote.txt'), 'remote\n'); + await git(['add', '.'], other); + await git(['commit', '-m', 'Remote commit'], other); + await git(['push', 'origin', 'main'], other); + await fs.writeFile(path.join(working, 'local.txt'), 'local\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Local commit'], working); + const localBefore = (await git(['rev-parse', 'HEAD'], working)).stdout.trim(); + const service = new GitService(); + const scan = await service.reconcile(working); + assert.equal(scan.status.branch.ahead, 1); + assert.equal(scan.status.branch.behind, 1); + assert.ok(scan.recommendations.some((item) => item.action === 'backup-reset')); + const repaired = await service.repairSync(working, 'backup-reset'); + assert.match(repaired.backupBranch, /^forgeflow\/backup-main-/); + assert.equal(repaired.status.branch.ahead, 0); + assert.equal(repaired.status.branch.behind, 0); + const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim(); + assert.equal(backupSha, localBefore); +}); + +test('troubleshooter detects and aborts an interrupted merge without discarding committed history', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-interrupted-merge-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + await git(['init'], root); + await git(['config', 'user.name', 'ForgeFlow Test'], root); + await git(['config', 'user.email', 'forgeflow@example.invalid'], root); + await fs.writeFile(path.join(root, 'file.txt'), 'base\n'); + await git(['add', '.'], root); + await git(['commit', '-m', 'Base'], root); + await git(['checkout', '-b', 'other'], root); + await fs.writeFile(path.join(root, 'file.txt'), 'other\n'); + await git(['commit', '-am', 'Other'], root); + await git(['checkout', 'master'], root); + await fs.writeFile(path.join(root, 'file.txt'), 'main\n'); + await git(['commit', '-am', 'Main'], root); + await assert.rejects(git(['merge', 'other'], root)); + + const service = new GitService(); + assert.equal(await service.detectInterruptedOperation(root), 'merge'); + const result = await service.abortInterruptedOperation(root); + assert.equal(result.aborted, 'merge'); + assert.equal(await service.detectInterruptedOperation(root), null); + assert.equal(result.status.clean, true); + const subject = await git(['log', '-1', '--pretty=%s'], root); + assert.equal(subject.stdout.trim(), 'Main'); +}); diff --git a/tests/git-status.test.mjs b/tests/git-status.test.mjs new file mode 100644 index 0000000..1f19a7f --- /dev/null +++ b/tests/git-status.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import gitStatus from '../src/shared/git-status.cjs'; + +const { parsePorcelainV2 } = gitStatus; + +test('parses branch metadata and ordinary changes', () => { + const output = [ + '# branch.oid 0123456789abcdef', + '# branch.head main', + '# branch.upstream origin/main', + '# branch.ab +2 -1', + '1 .M N... 100644 100644 100644 abc def src/main.js', + '1 M. N... 100644 100644 100644 abc def README.md', + '? new file.txt', + '' + ].join('\0'); + const parsed = parsePorcelainV2(output); + assert.equal(parsed.branch.head, 'main'); + assert.equal(parsed.branch.ahead, 2); + assert.equal(parsed.branch.behind, 1); + assert.equal(parsed.counts.changed, 3); + assert.equal(parsed.counts.staged, 1); + assert.equal(parsed.counts.untracked, 1); + assert.equal(parsed.files[0].path, 'src/main.js'); +}); + +test('parses rename records with original path', () => { + const output = '2 R. N... 100644 100644 100644 abc def R100 src/new.js\0src/old.js\0'; + const parsed = parsePorcelainV2(output); + assert.equal(parsed.files[0].path, 'src/new.js'); + assert.equal(parsed.files[0].originalPath, 'src/old.js'); + assert.equal(parsed.files[0].status, 'renamed'); +}); diff --git a/tests/git-validator-policy.test.mjs b/tests/git-validator-policy.test.mjs new file mode 100644 index 0000000..6cf6cb7 --- /dev/null +++ b/tests/git-validator-policy.test.mjs @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + normalizePolicy, + validateSuppression, + applyPolicy, + buildTrend, + exportReport, +} = require("../src/main/git-validator-policy.cjs"); + +test("Git Validator policies enforce score, blockers and enabled checks", () => { + const policy = normalizePolicy({ id: "organization", label: "ITWorx", requiredScore: 88, enabledChecks: ["security", "readme"], blockingChecks: ["security"] }); + const governed = applyPolicy([ + { id: "security", status: "warning", category: "Security", weight: 10 }, + { id: "readme", status: "pass", category: "Collaboration", weight: 5 }, + { id: "ignored", status: "error", category: "Other", weight: 5 }, + ], policy, []); + assert.equal(governed.policy.requiredScore, 88); + assert.deepEqual(governed.checks.map((check) => check.id), ["security", "readme"]); + assert.equal(governed.checks[0].blocking, true); +}); + +test("built-in policies enforce their declared blocking severities", () => { + const finding = [{ id: "readme", status: "warning", category: "Documentation", weight: 5 }]; + assert.equal(applyPolicy(finding, { id: "minimal" }, []).checks[0].blocking, false); + for (const id of ["standard", "strict", "production"]) + assert.equal(applyPolicy(finding, { id }, []).checks[0].blocking, true, `${id} must block active warnings`); +}); + +test("documented suppressions remove active blockers", () => { + const now = new Date("2026-07-01T00:00:00.000Z"); + const suppression = validateSuppression({ checkId: "readme", reason: "Tracked remediation work", author: "Jens", expiresAt: "2026-07-08T00:00:00.000Z", evidence: "ticket:FF-7" }, normalizePolicy({ id: "standard" }), now); + const check = applyPolicy([{ id: "readme", status: "warning", category: "Documentation", weight: 5 }], { id: "standard" }, [suppression], now).checks[0]; + assert.equal(check.suppressed, true); + assert.equal(check.blocking, false); +}); +test("suppressions require accountable evidence and reactivate after expiry", () => { + const now = new Date("2026-07-01T00:00:00.000Z"); + const suppression = validateSuppression({ checkId: "signed-tags", reason: "Tracked under release hardening", author: "Jens", ticket: "FF-42", expiresAt: "2026-07-08T00:00:00.000Z", scope: "repository", evidence: "sha:abc" }, normalizePolicy({ id: "standard" }), now); + let governed = applyPolicy([{ id: "signed-tags", status: "warning", category: "Governance", weight: 5 }], { id: "standard" }, [suppression], now); + assert.equal(governed.checks[0].suppressed, true); + governed = applyPolicy(governed.checks, { id: "standard" }, [suppression], new Date("2026-07-09T00:00:00.000Z")); + assert.equal(governed.checks[0].suppressed, false); + assert.equal(governed.checks[0].expiredSuppression.id, suppression.id); + assert.throws(() => validateSuppression({ checkId: "x", reason: "short", author: "a", expiresAt: "2026-07-02", evidence: "x" }, normalizePolicy(), now), /requires/); +}); + +test("trends report new, resolved and regressed findings without false precision", () => { + const previous = { checks: [{ id: "a", status: "warning" }, { id: "b", status: "error" }, { id: "c", status: "pass" }] }; + const report = { score: 74, categories: { Security: 50 }, checkedAt: "2026-07-02T00:00:00Z", commitSha: "abc", checks: [{ id: "a", status: "error" }, { id: "b", status: "pass" }, { id: "c", status: "warning", suppressed: true }] }; + const trend = buildTrend(previous, report); + assert.deepEqual(trend.regressions, ["a"]); + assert.deepEqual(trend.resolved.sort(), ["b"]); + assert.deepEqual(trend.suppressions, ["c"]); +}); + +test("reports export as JSON, Markdown and standalone escaped HTML", () => { + const report = { repository: "jens/", score: 80, commitSha: "abc", policy: { label: "Production" }, checks: [{ id: "readme", category: "Collaboration", status: "pass", detail: "Safe & ready" }] }; + assert.doesNotThrow(() => JSON.parse(exportReport(report, "json").content)); + assert.match(exportReport(report, "markdown").content, /\| readme \|/); + const html = exportReport(report, "html").content; + assert.match(html, //); + assert.match(html, /jens\/<app>/); +}); diff --git a/tests/git-validator.test.mjs b/tests/git-validator.test.mjs new file mode 100644 index 0000000..70910ac --- /dev/null +++ b/tests/git-validator.test.mjs @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { createRequire } from "node:module"; + +const exec = promisify(execFile); +const require = createRequire(import.meta.url); +const { GitService } = require("../src/main/git-service.cjs"); +const { + GitValidatorService, + isSensitiveTrackedPath, + sameRemote, +} = require("../src/main/git-validator-service.cjs"); + +async function git(args, cwd) { + return exec("git", args, { cwd, encoding: "utf8" }); +} + +test("Git Validator scores repository hygiene and offers bounded safe repairs", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-validator-")); + t.after(() => rm(root, { recursive: true, force: true })); + await git(["init", "-b", "main"], root); + await git(["config", "user.name", "ForgeFlow Test"], root); + await git(["config", "user.email", "forgeflow@example.invalid"], root); + await git( + ["remote", "add", "origin", "https://gitea.example.test/jens/app.git"], + root, + ); + await writeFile(path.join(root, "README.md"), "# App\n", "utf8"); + await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8"); + await git(["add", "."], root); + await git(["commit", "-m", "Initial"], root); + + const validator = new GitValidatorService({ + git: new GitService(), + gitea: { + getBranchProtection: async () => ({ + protected: false, + enableForcePush: false, + }), + }, + }); + const repository = { + fullName: "jens/app", + name: "app", + owner: { login: "jens" }, + defaultBranch: "main", + localPath: root, + cloneUrl: "https://gitea.example.test/jens/app.git", + sshUrl: "git@gitea.example.test:jens/app.git", + }; + const report = await validator.scan(repository); + assert.ok(report.score > 60); + assert.equal( + report.checks.find((check) => check.id === "origin").status, + "pass", + ); + assert.equal( + report.checks.find((check) => check.id === "default-branch-protection") + .fixAction, + "protect-default-branch", + ); + const safety = report.checks.find((check) => check.id === "local-safety"); + assert.equal(safety.safe, true); + await validator.repair(repository, safety); + const rescanned = await validator.scan(repository); + assert.equal( + rescanned.checks.find((check) => check.id === "local-safety").status, + "pass", + ); +}); + +test("Git Validator creates a reviewable gitignore without committing it", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-ignore-")); + t.after(() => rm(root, { recursive: true, force: true })); + await git(["init", "-b", "main"], root); + const validator = new GitValidatorService({ git: new GitService() }); + const repository = { localPath: root }; + await validator.repair(repository, { fixAction: "add-gitignore" }); + const content = await readFile(path.join(root, ".gitignore"), "utf8"); + assert.match(content, /\.env/); + const status = await git(["status", "--short"], root); + assert.match(status.stdout, /\?\? \.gitignore/); +}); + +test("Git Validator recognizes remote aliases and secret-shaped tracked paths", () => { + assert.equal( + sameRemote( + "git@gitea.example.test:jens/app.git", + "https://gitea.example.test/jens/app", + ), + true, + ); + assert.equal(isSensitiveTrackedPath(".env.production"), true); + assert.equal(isSensitiveTrackedPath("config/private.pem"), true); + assert.equal(isSensitiveTrackedPath(".env.example"), false); +}); + +test("Git Validator rejects stale or forged repair requests", async () => { + const validator = new GitValidatorService({ git: new GitService() }); + validator.scan = async () => ({ + checks: [{ id: "local-safety", fixAction: "configure-local-safety", status: "warning" }], + }); + assert.equal( + (await validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "configure-local-safety" })).id, + "local-safety", + ); + await assert.rejects( + validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "align-origin" }), + /stale/i, + ); + await assert.rejects( + validator.resolveRepairCheck({}, { id: "resolved-check", fixAction: "align-origin" }), + /resolved|no longer repairable/i, + ); +}); +test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-")); + t.after(() => rm(root, { recursive: true, force: true })); + await git(["init", "-b", "main"], root); + await git(["config", "user.name", "ForgeFlow Test"], root); + await git(["config", "user.email", "forgeflow@example.invalid"], root); + await git(["remote", "add", "origin", "https://gitea.example.test/jens/app.git"], root); + await writeFile(path.join(root, "package.json"), '{"name":"app"}\n', "utf8"); + await writeFile(path.join(root, "README.md"), "# App\n", "utf8"); + await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8"); + await git(["add", "."], root); + await git(["commit", "-m", "Initial"], root); + const validator = new GitValidatorService({ git: new GitService(), gitea: { getBranchProtection: async () => ({ protected: true, enableForcePush: false }) } }); + const repository = { fullName: "jens/app", name: "app", owner: { login: "jens" }, defaultBranch: "main", localPath: root, cloneUrl: "https://gitea.example.test/jens/app.git" }; + const report = await validator.scan(repository); + assert.equal(report.checks.find((check) => check.id === "dependency-lock").status, "warning"); + assert.equal(report.checks.find((check) => check.id === "continuous-integration").status, "warning"); + for (const action of ["add-gitattributes", "add-editorconfig"]) + await validator.repair(repository, { fixAction: action }); + assert.match(await readFile(path.join(root, ".gitattributes"), "utf8"), /text=auto/); + assert.match(await readFile(path.join(root, ".editorconfig"), "utf8"), /root = true/); +}); diff --git a/tests/git-workflows.test.mjs b/tests/git-workflows.test.mjs new file mode 100644 index 0000000..738d2fb --- /dev/null +++ b/tests/git-workflows.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import gitModule from '../src/main/git-service.cjs'; + +const exec = promisify(execFile); +const { GitService } = gitModule; +const git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' }); + +test('supports commit-only, branch creation, stash lifecycle and remote SHA verification', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-workflow-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const remote = path.join(root, 'remote.git'); + const working = path.join(root, 'working'); + await git(['init', '--bare', remote], root); + await git(['clone', remote, working], root); + await git(['config', 'user.name', 'ForgeFlow Test'], working); + await git(['config', 'user.email', 'forgeflow@example.invalid'], working); + await fs.writeFile(path.join(working, 'README.md'), '# ForgeFlow\n'); + await git(['add', '.'], working); + await git(['commit', '-m', 'Initial'], working); + await git(['branch', '-M', 'main'], working); + await git(['push', '-u', 'origin', 'main'], working); + + const service = new GitService(); + const branchStatus = await service.createBranch(working, 'feature/release-flow'); + assert.equal(branchStatus.branch.head, 'feature/release-flow'); + await fs.writeFile(path.join(working, 'release.txt'), 'release cockpit\n'); + const committed = await service.commit(working, 'Add release flow', ['release.txt']); + assert.equal(committed.status.branch.ahead, 0, 'unpublished branches have no upstream-based ahead count'); + const pushed = await service.push(working); + assert.equal(pushed.status.branch.upstream, 'origin/feature/release-flow'); + await service.verifyCommitOnRemoteBranch(working, committed.sha, 'feature/release-flow'); + + await fs.appendFile(path.join(working, 'release.txt'), 'local draft\n'); + await fs.writeFile(path.join(working, 'untracked.txt'), 'draft\n'); + const stashed = await service.stash(working, 'Draft release work'); + assert.equal(stashed.status.clean, true); + assert.equal(stashed.stashes.length, 1); + const restored = await service.popStash(working, stashed.stashes[0].ref); + assert.equal(restored.status.counts.changed, 2); +}); diff --git a/tests/gitea-actions.test.mjs b/tests/gitea-actions.test.mjs new file mode 100644 index 0000000..38162e3 --- /dev/null +++ b/tests/gitea-actions.test.mjs @@ -0,0 +1,343 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import giteaModule from '../src/main/gitea-service.cjs'; + +const { GiteaService } = giteaModule; + +function makeStore() { + return { data: { gitea: { baseUrl: 'https://gitea.example.test' } }, getToken: () => 'demo-token' }; +} + +test('normalizes run payloads from different Actions API shapes', () => { + const service = new GiteaService(makeStore()); + const run = service.normalizeRun({ + task_id: 42, + index: 7, + workflow_name: 'Deploy', + status: 'success', + commit: { sha: 'a'.repeat(40) }, + ref: 'refs/heads/main', + workflow_file: '.gitea/workflows/deploy.yml', + start_time: '2026-07-24T12:00:00Z' + }); + assert.equal(run.id, 42); + assert.equal(run.runNumber, 7); + assert.equal(run.conclusion, 'success'); + assert.equal(run.headSha, 'a'.repeat(40)); + assert.equal(run.headBranch, 'refs/heads/main'); +}); + +test('retries Actions runs without optional filters when a server rejects them', async () => { + const service = new GiteaService(makeStore()); + const calls = []; + service.request = async (pathname) => { + calls.push(pathname); + if (calls.length === 1) { + const error = new Error('Unsupported query'); + error.status = 422; + throw error; + } + return { data: { workflow_runs: [{ id: 11, run_number: 11, status: 'queued', head_sha: 'b'.repeat(40), head_branch: 'main' }] } }; + }; + const result = await service.listWorkflowRuns({ owner: 'jens', repo: 'app', sha: 'b'.repeat(40), branch: 'main' }); + assert.equal(calls.length, 2); + assert.match(calls[0], /head_sha=/); + assert.doesNotMatch(calls[1], /head_sha=/); + assert.equal(result.source, 'runs'); + assert.equal(result.runs[0].runNumber, 11); +}); + +test('falls back to legacy Actions tasks endpoint when runs is unavailable', async () => { + const service = new GiteaService(makeStore()); + const calls = []; + service.request = async (pathname) => { + calls.push(pathname); + if (pathname.includes('/runs?')) { + const error = new Error('Not found'); + error.status = 404; + throw error; + } + return { data: [{ task_id: 9, index: 3, status: 'running', commit_sha: 'c'.repeat(40), branch: 'main' }] }; + }; + const result = await service.listWorkflowRuns({ owner: 'jens', repo: 'app' }); + assert.equal(result.source, 'tasks'); + assert.equal(result.runs[0].id, 9); + assert.ok(calls.some((pathname) => pathname.includes('/tasks?'))); +}); + +test('selects the newest matching workflow run', async () => { + const service = new GiteaService(makeStore()); + service.listWorkflowRuns = async () => ({ source: 'runs', runs: [ + { id: 1, headSha: 'd'.repeat(40), headBranch: 'main', workflowPath: 'deploy.yml', createdAt: '2026-07-24T10:00:00Z' }, + { id: 2, headSha: 'd'.repeat(40), headBranch: 'main', workflowPath: '.gitea/workflows/deploy.yml', createdAt: '2026-07-24T11:00:00Z' }, + { id: 3, headSha: 'e'.repeat(40), headBranch: 'main', workflowPath: 'deploy.yml', createdAt: '2026-07-24T12:00:00Z' } + ] }); + const found = await service.findWorkflowRun({ owner: 'jens', repo: 'app', sha: 'd'.repeat(40), branch: 'main', workflowFile: 'deploy.yml' }); + assert.equal(found.run.id, 2); + const excludingNewest = await service.findWorkflowRun({ owner: 'jens', repo: 'app', sha: 'd'.repeat(40), branch: 'main', workflowFile: 'deploy.yml', excludeRunIds: [2] }); + assert.equal(excludingNewest.run.id, 1); +}); + +test('checks repository workflow files through the contents API', async () => { + const service = new GiteaService(makeStore()); + const calls = []; + service.request = async (pathname) => { calls.push(pathname); return { data: { type: 'file' } }; }; + assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/deploy.yml', ref: 'main' }), true); + assert.match(calls[0], /contents\/\.gitea\/workflows\/deploy\.yml\?ref=main/); + + service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; }; + assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false); +}); + +test('creates controlled pull requests and reads branch protection', async () => { + const service = new GiteaService(makeStore()); + const calls = []; + service.request = async (pathname, options = {}) => { + calls.push({ pathname, options }); + if (pathname.includes('/branches/main')) return { data: { name: 'main', protected: true } }; + if (pathname.endsWith('/branch_protections')) return { data: [{ branch_name: 'main', required_approvals: 2, require_signed_commits: true }] }; + return { data: { number: 12, html_url: 'https://gitea.test/owner/app/pulls/12' } }; + }; + const protection = await service.getBranchProtection('owner', 'app', 'main'); + assert.equal(protection.protected, true); + assert.equal(protection.requiredApprovals, 2); + const pull = await service.createPullRequest({ owner: 'owner', repo: 'app', head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' }); + assert.equal(pull.number, 12); + const create = calls.find((call) => call.options.method === 'POST'); + assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' }); + await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/); +}); + +test('resolves release attachment metadata before downloading the actual asset', async () => { + const service = new GiteaService(makeStore()); + let metadataPath = ''; + let requested = ''; + service.request = async (pathname) => { + metadataPath = pathname; + return { + data: { + id: 412, + browser_download_url: 'https://gitea.example.test/attachments/release.exe', + }, + }; + }; + service.downloadAuthenticated = async (pathname) => { + requested = pathname; + return Buffer.from('asset'); + }; + const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412); + assert.equal(asset.toString(), 'asset'); + assert.equal( + metadataPath, + '/repos/Jens/ForgeFlow/releases/107/assets/412', + ); + assert.equal( + requested, + 'https://gitea.example.test/attachments/release.exe', + ); + await assert.rejects( + () => service.downloadReleaseAsset('Jens', 'ForgeFlow', null, 412), + /invalid release ID/, + ); +}); + +test('uses a release-provided browser download URL without requesting metadata again', async () => { + const service = new GiteaService(makeStore()); + service.request = async () => { throw new Error('metadata lookup should not run'); }; + let requested = ''; + service.downloadAuthenticated = async (pathname) => { + requested = pathname; + return Buffer.from('asset'); + }; + const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, { + downloadUrl: 'https://gitea.example.test/attachments/direct.exe', + }); + assert.equal(asset.toString(), 'asset'); + assert.equal(requested, 'https://gitea.example.test/attachments/direct.exe'); +}); + +test('rewrites Gitea internal HTTP release URLs to the configured public origin', async () => { + const service = new GiteaService(makeStore()); + let requested = ''; + service.downloadAuthenticated = async (pathname) => { + requested = pathname; + return Buffer.from('asset'); + }; + await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, { + downloadUrl: 'http://192.168.56.10:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe', + }); + assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe'); +}); + +test('creates conservative default branch protection rules', async () => { + const service = new GiteaService(makeStore()); + let request = null; + service.request = async (pathname, options) => { + request = { pathname, options }; + return { data: { rule_name: 'main' } }; + }; + const result = await service.createBranchProtection('jens', 'app', 'main'); + assert.equal(result.rule_name, 'main'); + assert.equal(request.options.method, 'POST'); + assert.equal(request.options.body.enable_push, false); + assert.equal(request.options.body.enable_force_push, false); + assert.equal(request.options.body.rule_name, 'main'); +}); + +test('creates repository-scoped read-only deploy keys and reuses only safe matches', async () => { + const service = new GiteaService(makeStore()); + const publicKey = `ssh-ed25519 ${Buffer.from('public-key-material').toString('base64')} forgeflow:test`; + const requests = []; + service.request = async (pathname, options = {}) => { + requests.push({ pathname, options }); + if (!options.method) return { data: [] }; + return { data: { id: 41, key: publicKey, read_only: true } }; + }; + const created = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }); + assert.equal(created.created, true); + assert.equal(requests[1].options.body.read_only, true); + + service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: true }] }); + const reused = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }); + assert.equal(reused.created, false); + + service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: false }] }); + await assert.rejects( + () => service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }), + (error) => error.code === 'DEPLOY_KEY_NOT_READ_ONLY', + ); +}); + +test('request sends scoped credentials, parses response types and redacts rejected secrets', async (context) => { + const originalFetch = globalThis.fetch; + context.after(() => { globalThis.fetch = originalFetch; }); + const calls = []; + const diagnostics = { debug: async (...args) => calls.push(['debug', ...args]), warning: async (...args) => calls.push(['warning', ...args]) }; + const service = new GiteaService(makeStore(), diagnostics); + globalThis.fetch = async (url, options) => { + calls.push([url, options]); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'x-test': 'yes' } }); + }; + const json = await service.request('/user', { method: 'POST', body: { hello: 'world' }, headers: { 'X-Extra': 'value' } }); + assert.deepEqual(json.data, { ok: true }); + assert.equal(calls[0][1].headers.Authorization, 'token demo-token'); + assert.equal(calls[0][1].headers['Content-Type'], 'application/json'); + assert.equal(calls[0][1].headers['X-Extra'], 'value'); + + globalThis.fetch = async () => new Response('plain', { status: 200 }); + assert.equal((await service.request('/plain', { responseType: 'text', auth: false })).data, 'plain'); + globalThis.fetch = async () => new Response(Uint8Array.from([1, 2, 3]), { status: 200 }); + assert.deepEqual((await service.request('/binary', { responseType: 'buffer' })).data, Buffer.from([1, 2, 3])); + globalThis.fetch = async () => new Response(null, { status: 204 }); + assert.equal((await service.request('/empty')).data, null); + + globalThis.fetch = async () => new Response(JSON.stringify({ message: 'bad demo-token' }), { status: 403, statusText: 'Forbidden' }); + await assert.rejects(service.request('/denied'), (error) => error.status === 403 && !error.message.includes('demo-token')); + globalThis.fetch = async () => new Response('not found', { status: 404, statusText: 'Not Found' }); + await assert.rejects(service.request('/missing'), (error) => error.status === 404 && error.payload === 'not found'); +}); + +test('request rejects absent credentials and wraps network failures', async (context) => { + const originalFetch = globalThis.fetch; + context.after(() => { globalThis.fetch = originalFetch; }); + const warnings = []; + const service = new GiteaService({ data: { gitea: { baseUrl: 'https://gitea.example.test' } }, getToken: () => '' }, { warning: async (...args) => warnings.push(args) }); + await assert.rejects(service.request('/user'), /no Gitea access token/i); + globalThis.fetch = async () => { const error = new Error('connect ECONNREFUSED'); error.code = 'ECONNREFUSED'; throw error; }; + await assert.rejects(service.request('/version', { auth: false }), (error) => error.code === 'ECONNREFUSED' && /could not reach/i.test(error.message)); + assert.equal(warnings[0][0], 'gitea.request.failed'); +}); + +test('repository pagination, connection validation and simple endpoints preserve API data', async () => { + const service = new GiteaService(makeStore()); + let pages = 0; + service.request = async (pathname) => { + if (pathname === '/user') return { data: { login: 'jens' } }; + if (pathname === '/version') throw Object.assign(new Error('unsupported'), { status: 404 }); + if (pathname.includes('/user/repos')) { pages += 1; return { data: pages === 1 ? Array.from({ length: 50 }, (_, id) => ({ id })) : [{ id: 51 }] }; } + if (pathname.includes('/branches/')) return { data: { name: 'main' } }; + return { data: { id: 1 } }; + }; + const validated = await service.validateConnection('https://gitea.example.test/', 'token'); + assert.equal(validated.repositoryCount, 50); + assert.equal(validated.version, null); + pages = 0; + assert.equal((await service.listRepositories()).length, 51); + assert.equal((await service.getRepository('owner space', 'repo/name')).id, 1); + assert.equal((await service.getBranch('owner', 'repo', 'feature/test')).name, 'main'); +}); + +test('branch protection tolerates unsupported APIs but propagates server failures', async () => { + const service = new GiteaService(makeStore()); + service.getBranch = async () => ({ protected: false }); + service.request = async () => { throw Object.assign(new Error('unsupported'), { status: 404 }); }; + const absent = await service.getBranchProtection('owner', 'repo', 'main'); + assert.equal(absent.protected, false); + assert.equal(absent.enablePush, null); + service.request = async () => { throw Object.assign(new Error('down'), { status: 500 }); }; + await assert.rejects(service.getBranchProtection('owner', 'repo', 'main'), /down/); +}); + +test('file, release, pull request and deploy-key helpers validate malformed API inputs', async () => { + const service = new GiteaService(makeStore()); + service.request = async () => ({ data: [] }); + assert.deepEqual(await service.listDeployKeys('owner', 'repo'), []); + assert.deepEqual(await service.listPullRequests({ owner: 'owner', repo: 'repo', limit: 500 }), []); + await assert.rejects(service.ensureReadOnlyDeployKey({ owner: 'owner', repo: 'repo', publicKey: 'invalid' }), /valid SSH public key/i); + await assert.rejects(service.createReadOnlyDeployKey({ owner: 'owner', repo: 'repo', publicKey: 'invalid' }), /valid SSH public key/i); + await assert.rejects(service.deleteDeployKey('owner', 'repo', 0), /valid deploy-key ID/i); + await assert.rejects(service.createPullRequest({ owner: 'owner', repo: 'repo', head: 'a', base: 'b', title: '' }), /1-255/); + await assert.rejects(service.createPullRequest({ owner: 'owner', repo: 'repo', head: 'a', base: 'b', title: 'x'.repeat(256) }), /1-255/); + await assert.rejects(service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'folder' }), /not a file/i); + + service.request = async () => ({ data: { encoding: 'base64', content: Buffer.from('hello').toString('base64') } }); + assert.equal((await service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' })).decoded, 'hello'); + service.request = async () => ({ data: { content: 'plain' } }); + assert.equal((await service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' })).decoded, 'plain'); + service.request = async () => ({ data: { encoding: 'none' } }); + await assert.rejects(service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' }), /readable content/i); + + for (const method of ['getLatestRelease', 'getReleaseByTag']) { + service.request = async () => { throw Object.assign(new Error('missing'), { status: 404 }); }; + assert.equal(await service[method]('owner', 'repo', 'v1'), null); + service.request = async () => { throw Object.assign(new Error('server'), { status: 500 }); }; + await assert.rejects(service[method]('owner', 'repo', 'v1'), /server/); + } +}); + +test('authenticated downloads keep tokens same-origin and enforce secure redirects', async (context) => { + const originalFetch = globalThis.fetch; + context.after(() => { globalThis.fetch = originalFetch; }); + const service = new GiteaService(makeStore()); + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url: String(url), options }); + if (calls.length === 1) return new Response(null, { status: 302, headers: { location: 'https://cdn.example.test/release.exe' } }); + return new Response('asset', { status: 200 }); + }; + assert.equal((await service.downloadAuthenticated('/attachments/release.exe')).toString(), 'asset'); + assert.equal(calls[0].options.headers.Authorization, 'token demo-token'); + assert.equal(calls[1].options.headers.Authorization, undefined); + + globalThis.fetch = async () => new Response(null, { status: 302, headers: { location: 'http://cdn.example.test/file' } }); + await assert.rejects(service.downloadAuthenticated('/file'), /insecure cross-origin/i); + globalThis.fetch = async () => new Response(null, { status: 302 }); + await assert.rejects(service.downloadAuthenticated('/file'), /did not contain a destination/i); + globalThis.fetch = async () => new Response('missing', { status: 404 }); + await assert.rejects(service.downloadAuthenticated('/file'), /HTTP 404/i); + + let redirects = 0; + globalThis.fetch = async () => new Response(null, { status: 302, headers: { location: `/redirect-${redirects += 1}` } }); + await assert.rejects(service.downloadAuthenticated('/file'), /redirect limit/i); +}); + +test('release downloads and workflow dispatch reject inconsistent evidence', async () => { + const service = new GiteaService(makeStore()); + await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 0), /invalid release asset ID/i); + service.request = async () => ({ data: { id: 2, browser_download_url: 'https://gitea.example/file' } }); + await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 3), /different release asset/i); + service.request = async () => ({ data: { id: 3, browser_download_url: '' } }); + await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 3), /did not provide/i); + service.request = async () => ({ status: 202, data: null }); + assert.deepEqual(await service.dispatchWorkflow({ owner: 'owner', repo: 'repo', workflowFile: 'deploy.yml', ref: 'main' }), { accepted: false, status: 202 }); +}); diff --git a/tests/inventory-classifier.test.mjs b/tests/inventory-classifier.test.mjs new file mode 100644 index 0000000..6c0d5bc --- /dev/null +++ b/tests/inventory-classifier.test.mjs @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { classifyInventory } = require("../src/main/inventory-classifier.cjs"); +const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("../src/main/deployment-identity.cjs"); +const { InventoryReviewService } = require("../src/main/inventory-review-service.cjs"); + +function workload(id, options = {}) { + return { + workloadId: id, serverId: options.serverId || "unraid", displayName: options.name || id, + status: options.status || (options.link ? "linked" : "suggested"), link: options.link || null, + compose: { project: options.project || id, workingDir: options.root || `/mnt/user/appdata/${id}`, configFiles: options.files || [`/mnt/user/appdata/${id}/compose.yml`], services: ["app"] }, + containers: options.noContainers ? [] : [{ id: `container-${id}`, name: id, running: options.running !== false }], + runtime: { running: options.running !== false, health: options.health || "healthy" }, + metadata: { sourceRepository: options.remote ?? "git@gitea.test:Jens/Portfolio.git", liveRevision: options.sha || "a".repeat(40), branch: options.branch || "main" }, + candidates: options.candidates || [{ repositoryFullName: "Jens/Portfolio", score: 100, exact: true }], + remoteFolderCandidate: id, + }; +} + +test("deployment identity is canonical across SSH and HTTPS remotes", () => { + const ssh = deploymentIdentity({ workload: workload("one") }); + const https = deploymentIdentity({ workload: workload("two", { remote: "https://gitea.test/Jens/Portfolio.git" }) }); + assert.equal(ssh.repository, https.repository); + assert.equal(deploymentAuthorityKey(ssh), deploymentAuthorityKey({ ...https, serverId: ssh.serverId, environment: ssh.environment })); +}); + +test("running duplicate is authoritative and historical folder is never linked", () => { + const active = workload("portfolio-current", { link: { profileId: "profile", repositoryFullName: "Jens/Portfolio" } }); + const historical = workload("portfolio-old", { running: false, root: "/mnt/user/appdata/portfolio/releases/old", link: { profileId: "profile-old", repositoryFullName: "Jens/Portfolio" } }); + const result = classifyInventory([historical, active], [{ id: "profile", environment: "production" }, { id: "profile-old", environment: "production" }]); + assert.equal(result.find((item) => item.workloadId === "portfolio-current").authoritative, true); + assert.equal(result.find((item) => item.workloadId === "portfolio-old").classification.type, "backup"); + assert.equal(result.find((item) => item.workloadId === "portfolio-old").shadowedLink.profileId, "profile-old"); + assert.equal(result.find((item) => item.workloadId === "portfolio-old").link, null); +}); + +for (const [label, item, expected] of [ + ["backup Compose folder", workload("backup", { root: "/mnt/user/appdata/portfolio-backup", running: false }), "backup"], + ["release directory", workload("release", { root: "/mnt/user/appdata/portfolio/releases/a1", running: false }), "release-folder"], + ["staging workload", workload("stage", { root: "/mnt/user/appdata/portfolio-staging" }), "staging"], + ["candidate runtime", workload("candidate", { name: "portfolio-candidate-039" }), "temporary-runtime"], + ["stopped legitimate app", workload("stopped", { running: false }), "stopped-application"], + ["Compose without container", workload("historical", { noContainers: true, running: false }), "historical-compose"], + ["external container without Git provenance", workload("external", { remote: "", candidates: [], status: "unmatched" }), "external-container"], + ["external container with inaccessible repository provenance", workload("external-git", { remote: "https://github.com/vendor/image.git", candidates: [], status: "unmatched" }), "external-container"], + ["system container", workload("infra", { name: "watchtower", remote: "", candidates: [] }), "system-container"], + ["ambiguous exact matches", workload("ambiguous", { status: "ambiguous", candidates: [{ repositoryFullName: "Jens/A", score: 100, exact: true }, { repositoryFullName: "Jens/B", score: 100, exact: true }] }), "ambiguous"], + ["profile whose server workload disappeared", { ...workload("stale", { running: false, noContainers: true, link: { profileId: "profile-stale", repositoryFullName: "Jens/Portfolio" } }), metadata: { sourceRepository: "git@gitea.test:Jens/Portfolio.git", branch: "main", staleLink: true } }, "stale-link"], +]) test(`inventory classifies ${label}`, () => { + assert.equal(classifyInventory([item])[0].classification.type, expected); +}); + +test("multi-instance environments remain separate authority groups", () => { + const a = workload("instance-a", { link: { profileId: "a", repositoryFullName: "Jens/Portfolio" } }); + const b = workload("instance-b", { link: { profileId: "b", repositoryFullName: "Jens/Portfolio" } }); + const result = classifyInventory([a, b], [{ id: "a", environment: "production" }, { id: "b", environment: "staging" }]); + assert.equal(result.filter((item) => item.classification.type === "duplicate").length, 0); +}); + +test("stored review decision becomes stale when remote evidence changes", () => { + const original = classifyInventory([workload("review")])[0]; + const decision = { workloadId: original.workloadId, evidenceHash: original.evidenceHash, action: "ignore", reason: "Known external workload" }; + const unchanged = classifyInventory([workload("review")], [], [decision])[0]; + const changed = classifyInventory([workload("review", { remote: "git@gitea.test:Jens/Renamed.git" })], [], [decision])[0]; + assert.equal(unchanged.reviewDecision.action, "ignore"); + assert.equal(changed.reviewDecision, null); + assert.equal(changed.reviewDecisionStale, true); +}); + +test("review decisions drive classification and explicit authority", () => { + const primary = classifyInventory([workload("primary")])[0]; + const secondary = classifyInventory([workload("secondary")])[0]; + const decisions = [ + { workloadId: primary.workloadId, evidenceHash: primary.evidenceHash, action: "mark-historical", reason: "Retained rollback definition" }, + { workloadId: secondary.workloadId, evidenceHash: secondary.evidenceHash, action: "select-authoritative", reason: "Verified production instance" }, + ]; + const result = classifyInventory([workload("primary"), workload("secondary")], [], decisions); + assert.equal(result.find((item) => item.workloadId === "primary").classification.type, "historical-compose"); + assert.equal(result.find((item) => item.workloadId === "secondary").authoritative, true); +}); + +test("ignore and monitor-only decisions stay evidence-bound", () => { + const ignored = classifyInventory([workload("ignored")])[0]; + const monitoredSource = workload("monitored", { remote: "git@gitea.test:Jens/Monitored.git", candidates: [{ repositoryFullName: "Jens/Monitored", score: 100, exact: true }] }); + const monitored = classifyInventory([monitoredSource])[0]; + const result = classifyInventory([workload("ignored"), monitoredSource], [], [ + { workloadId: ignored.workloadId, evidenceHash: ignored.evidenceHash, action: "ignore", reason: "Managed by another platform" }, + { workloadId: monitored.workloadId, evidenceHash: monitored.evidenceHash, action: "monitor-only", reason: "Visibility without deployment ownership" }, + ]); + assert.equal(result.find((item) => item.workloadId === "ignored").classification.type, "manually-excluded"); + assert.equal(result.find((item) => item.workloadId === "monitored").classification.type, "monitor-only"); +}); + +test("review service requires reason, exact plan and recovery snapshot", async () => { + const decisions = []; + const store = { getInventoryReviewDecisions: () => decisions, createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }), saveInventoryReviewDecision: async (_server, decision) => { decisions.push(decision); return decision; } }; + const service = new InventoryReviewService({ store }); + const item = classifyInventory([workload("review")])[0]; + assert.throws(() => service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "no" }), (error) => error.code === "INVENTORY_REVIEW_REASON_REQUIRED"); + const plan = service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "Managed outside ForgeFlow" }); + await assert.rejects(service.apply({ plan }), (error) => error.code === "INVENTORY_REVIEW_PLAN_REQUIRED"); + const result = await service.apply({ plan, expectedPlanId: plan.id }); + assert.equal(result.snapshot.filePath, "snapshot.json"); + assert.equal(decisions[0].evidenceHash, item.evidenceHash); +}); + +test("large inventory classification is deterministic and bounded", () => { + const input = Array.from({ length: 1200 }, (_, index) => workload(`app-${index}`, { remote: `git@gitea.test:Jens/App-${index}.git`, candidates: [{ repositoryFullName: `Jens/App-${index}`, score: 100, exact: true }] })); + const started = Date.now(); + const result = classifyInventory(input); + assert.equal(result.length, 1200); + assert.ok(Date.now() - started < 2000); + assert.equal(new Set(result.map((item) => item.evidenceHash)).size, 1200); +}); + +test("evidence hash changes for runtime, Compose and candidate changes", () => { + const identity = deploymentIdentity({ workload: workload("hash") }); + const one = deploymentEvidenceHash(identity, { running: true, files: ["compose.yml"] }); + const two = deploymentEvidenceHash(identity, { running: false, files: ["compose.yml"] }); + assert.notEqual(one, two); +}); diff --git a/tests/ipc-contract.test.mjs b/tests/ipc-contract.test.mjs new file mode 100644 index 0000000..d1034b2 --- /dev/null +++ b/tests/ipc-contract.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +async function rendererSource() { + return (await Promise.all(["app.js", "diff-view.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n"); +} + +test("every preload invoke channel has a registered IPC handler", async () => { + const preload = await readFile( + new URL("../preload.cjs", import.meta.url), + "utf8", + ); + const ipc = (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n"); + const invokes = [...preload.matchAll(/invoke\(\s*['"]([^'"]+)['"]/g)].map( + (match) => match[1], + ); + const handlers = new Set( + [...ipc.matchAll(/register\(\s*['"]([^'"]+)['"]/g)].map( + (match) => match[1], + ), + ); + assert.ok(invokes.length > 40, "expected the complete renderer API surface"); + assert.deepEqual( + invokes.filter((channel) => !handlers.has(channel)), + [], + ); +}); + +test("every renderer bridge call is exposed by the preload contract", async () => { + const renderer = await rendererSource(); + const preload = await readFile( + new URL("../preload.cjs", import.meta.url), + "utf8", + ); + const calls = new Set( + [...renderer.matchAll(/window\.forgeflow\.([A-Za-z0-9_]+)\s*\(/g)].map( + (match) => match[1], + ), + ); + const exposed = new Set( + [...preload.matchAll(/^\s+([A-Za-z0-9_]+):/gm)].map((match) => match[1]), + ); + assert.ok(calls.size > 40, "expected the complete renderer bridge surface"); + assert.deepEqual( + [...calls].filter((method) => !exposed.has(method)), + [], + ); +}); diff --git a/tests/log-redaction.test.mjs b/tests/log-redaction.test.mjs new file mode 100644 index 0000000..c695027 --- /dev/null +++ b/tests/log-redaction.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import redaction from '../src/main/log-redaction.cjs'; + +const { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure } = redaction; + +test('redacts runtime credentials, structured secrets, private keys and URL credentials', () => { + const token = ['gitea', 'TEST', 'ONLY', 'SecretToken123456'].join('_'); + const input = [ + `Authorization: Bearer ${token}`, + `https://${['jens', 'p4ssw0rd'].join(':')}@gitea.example.test/api?access_token=${token}`, + 'client_secret=another-secret-value', + ['-----BEGIN', 'PRIVATE KEY-----\nsecret-key-material\n-----END PRIVATE KEY-----'].join(' ') + ].join('\n'); + const output = redactSecrets(input, [token]); + assert.doesNotMatch(output, /ThisIsARealisticSecret|p4ssw0rd|another-secret-value|secret-key-material/); + assert.match(output, /REDACTED/); +}); + +test('sanitizes nested sensitive keys and aliases user paths', () => { + const value = { + accessToken: 'do-not-keep', + nested: { password: 'do-not-keep-either', path: 'C:\\Users\\example-user\\Projects\\ForgeFlow' }, + home: '/home/jens/projects/forgeflow' + }; + const sanitized = sanitizeForDiagnostics(value, { homeDir: '/home/jens', cwd: '/work/ForgeFlow' }); + assert.equal(sanitized.accessToken, '[REDACTED]'); + assert.equal(sanitized.nested.password, '[REDACTED]'); + assert.doesNotMatch(JSON.stringify(sanitized), /do-not-keep|Users\\Jens|\/home\/jens/); + assert.match(JSON.stringify(sanitized), //); +}); + +test('strict privacy mode replaces stable identifiers deterministically', () => { + const first = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens', host: '192.168.10.20', basePath: '/mnt/user/appdata' }, { strictIdentifiers: true }); + const second = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens', host: '192.168.10.20', basePath: '/mnt/user/appdata' }, { strictIdentifiers: true }); + assert.equal(first.fullName, second.fullName); + assert.equal(first.login, second.login); + assert.notEqual(first.fullName, 'jens/private-project'); + assert.match(first.fullName, /^fullname-[a-f0-9]{12}$/); + assert.notEqual(first.host, '192.168.10.20'); + assert.notEqual(first.basePath, '/mnt/user/appdata'); + assert.equal(stableAlias('same', 'repo'), stableAlias('same', 'repo')); +}); + +test('strict privacy redacts private addresses, infrastructure URLs and server paths in log text', () => { + const result = redactPrivateInfrastructure('host 192.168.10.20 url https://internal.example.test/status path /mnt/user/appdata/example'); + assert.doesNotMatch(result, /192\.168\.10\.20|internal\.example\.test|\/mnt\/user\/appdata/); +}); + +test('path aliasing handles slash variants', () => { + const result = pathAlias('C:\\Users\\example-user\\src and C:/Users/example-user/src', { homeDir: 'C:\\Users\\example-user', cwd: 'D:\\ForgeFlow' }); + assert.doesNotMatch(result, /Users[\\/]Jens/); + assert.match(result, //); +}); diff --git a/tests/partial-staging.test.mjs b/tests/partial-staging.test.mjs new file mode 100644 index 0000000..57fc1a8 --- /dev/null +++ b/tests/partial-staging.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import gitModule from '../src/main/git-service.cjs'; + +const exec = promisify(execFile); +const git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' }); +const { GitService, parseUnifiedDiff } = gitModule; + +test('unified diff parser separates selectable hunks', () => { + const parsed = parseUnifiedDiff('diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new\n@@ -10 +10 @@\n-x\n+y\n'); + assert.equal(parsed.hunks.length, 2); + assert.equal(parsed.hunks[0].additions, 1); + assert.equal(parsed.hunks[1].deletions, 1); +}); + +test('stages only selected hunks using a server-generated patch', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-hunks-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + await git(['init'], root); + await git(['config', 'user.name', 'ForgeFlow Test'], root); + await git(['config', 'user.email', 'forgeflow@example.invalid'], root); + const original = [...Array(20)].map((_, index) => `line ${index + 1}`).join('\n') + '\n'; + await fs.writeFile(path.join(root, 'file.txt'), original); + await git(['add', '.'], root); await git(['commit', '-m', 'Initial'], root); + const lines = original.trimEnd().split('\n'); lines[0] = 'first changed'; lines[19] = 'last changed'; + await fs.writeFile(path.join(root, 'file.txt'), `${lines.join('\n')}\n`); + const service = new GitService(); + const hunks = await service.diffHunks(root, 'file.txt'); + assert.equal(hunks.hunks.length, 2); + await service.stageHunks(root, 'file.txt', [0]); + const staged = (await git(['diff', '--cached'], root)).stdout; + const unstaged = (await git(['diff'], root)).stdout; + assert.match(staged, /first changed/); assert.doesNotMatch(staged, /last changed/); + assert.match(unstaged, /last changed/); assert.doesNotMatch(unstaged, /first changed/); + await service.commitStaged(root, 'Commit reviewed hunk'); + const afterCommit = (await git(['diff'], root)).stdout; + assert.match(afterCommit, /last changed/); + assert.doesNotMatch(afterCommit, /first changed/); +}); diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs new file mode 100644 index 0000000..f31af66 --- /dev/null +++ b/tests/preflight.test.mjs @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import preflightModule from '../src/main/preflight-service.cjs'; + +const { PreflightService, summarize, check } = preflightModule; + +test('only required failed checks block readiness', () => { + const summary = summarize([ + check('required-pass', 'Required pass', 'pass', 'ok', { required: true }), + check('optional-warning', 'Optional warning', 'warning', 'notice'), + check('optional-fail', 'Optional fail', 'fail', 'not blocking'), + check('required-fail', 'Required fail', 'fail', 'blocked', { required: true }) + ]); + assert.equal(summary.ready, false); + assert.deepEqual(summary.blocking, ['required-fail']); + assert.equal(summary.counts.warning, 1); +}); + +test('system preflight can pass before credentials are entered', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-system-')); + t.after(() => rm(root, { recursive: true, force: true })); + const service = new PreflightService({ + store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' }, + git: { isAvailable: async () => ({ available: true, version: 'git version test' }) }, + gitea: {}, deployments: {}, + diagnostics: { logDirectory: path.join(root, 'diagnostics'), info: async () => {} }, + userDataPath: path.join(root, 'data'), + secureStorageAvailable: () => true + }); + service.gitIdentity = async () => ({ name: 'Jens', email: 'jens@example.test' }); + const result = await service.runSystem({ roots: [root] }); + assert.equal(result.summary.ready, true); + assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'warning'); + assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'pass'); +}); + +test('deployment preflight verifies exact Git, workflow, Actions and server prerequisites', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-deploy-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true }); + await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n'); + await writeFile(path.join(root, '.gitea', 'workflows', 'rollback.yml'), 'name: rollback\n'); + const sha = 'a'.repeat(40); + const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: 'https://app.example.test/status', healthcheckUrl: 'https://app.example.test/health' }; + const service = new PreflightService({ + store: { getDeploymentProfile: () => profile }, + git: { + status: async () => ({ root, head: sha, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }), + verifyCommitOnRemoteBranch: async () => true + }, + gitea: { repositoryFileExists: async () => true, listWorkflowRuns: async () => ({ runs: [] }) }, + deployments: { + readStatusEndpoint: async () => ({ configured: true, reachable: true, ok: true, liveSha: sha, status: 200 }), + checkHealth: async () => ({ configured: true, healthy: true, status: 200, latencyMs: 12 }) + }, + diagnostics: { info: async () => {} }, userDataPath: root + }); + const result = await service.runDeployment({ repository: { fullName: 'jens/app', localPath: root }, profileId: profile.id }); + assert.equal(result.summary.ready, true); + assert.equal(result.checks.filter((item) => item.status === 'fail').length, 0); + assert.equal(result.head, sha); +}); + +test('system preflight reports unavailable Git, storage, roots and rejected Gitea credentials', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-failures-')); + t.after(() => rm(root, { recursive: true, force: true })); + const ordinaryFile = path.join(root, 'not-a-directory'); + await writeFile(ordinaryFile, 'file'); + const events = []; + const service = new PreflightService({ + store: { data: { gitea: { baseUrl: 'https://stored.test' } }, getToken: () => 'stored-token' }, + git: { isAvailable: async () => ({ available: false, error: 'git missing' }) }, + gitea: { validateConnection: async () => { throw new Error('token rejected'); } }, + deployments: {}, diagnostics: { logDirectory: path.join(root, 'logs'), info: async (...args) => events.push(args) }, + userDataPath: path.join(root, 'data'), secureStorageAvailable: () => false + }); + service.writableDirectory = async (directory) => { + if (directory.endsWith('data')) throw new Error('read only'); + return true; + }; + const result = await service.runSystem({ roots: [ordinaryFile, path.join(root, 'missing'), ordinaryFile, ''] }); + assert.equal(result.checks.find((item) => item.id === 'git.available').status, 'fail'); + assert.equal(result.checks.find((item) => item.id === 'storage.userdata').status, 'fail'); + assert.equal(result.checks.find((item) => item.id === 'storage.diagnostics').status, 'pass'); + assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'warning'); + assert.equal(result.checks.find((item) => item.id === 'workspace.root.0').status, 'fail'); + assert.equal(result.checks.find((item) => item.id === 'workspace.root.1').status, 'fail'); + assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'fail'); + assert.equal(result.summary.ready, false); + assert.equal(events[0][0], 'preflight.system.completed'); +}); + +test('system preflight warns on incomplete Git identity and accepts unknown Gitea version', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-identity-')); + t.after(() => rm(root, { recursive: true, force: true })); + const service = new PreflightService({ + store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' }, + git: { isAvailable: async () => ({ available: true, version: 'git' }) }, + gitea: { validateConnection: async () => ({ version: null, user: null, repositoryCount: 0 }) }, deployments: {}, + diagnostics: { logDirectory: path.join(root, 'logs'), info: async () => {} }, userDataPath: path.join(root, 'data') + }); + service.gitIdentity = async () => ({ name: '', email: '' }); + const result = await service.runSystem({ baseUrl: 'https://gitea.test', token: 'token', roots: [] }); + assert.equal(result.checks.find((item) => item.id === 'git.identity').status, 'warning'); + assert.match(result.checks.find((item) => item.id === 'gitea.connection').detail, /unknown version.*user/i); + assert.equal(result.checks.find((item) => item.id === 'gitea.repositories').status, 'pass'); + assert.equal(result.checks.find((item) => item.id === 'workspace.roots').status, 'warning'); + + service.gitIdentity = async () => { throw new Error('identity lookup failed'); }; + const second = await service.runSystem(); + assert.match(second.checks.find((item) => item.id === 'git.identity').detail, /lookup failed/i); +}); + +test('deployment preflight fails fast for invalid identity, profile and missing local link', async () => { + const diagnostics = []; + const service = new PreflightService({ + store: { getDeploymentProfile: (_name, id) => id === 'known' ? { id: 'known', name: 'Production' } : null }, + git: {}, gitea: {}, deployments: {}, diagnostics: { info: async (...args) => diagnostics.push(args) }, userDataPath: '' + }); + await assert.rejects(service.runDeployment({ repository: null, profileId: 'known' }), /identity is required/i); + await assert.rejects(service.runDeployment({ repository: { fullName: 'owner/app' }, profileId: 'missing' }), /profile not found/i); + const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: '' }, profileId: 'known' }); + assert.deepEqual(result.summary.blocking, ['repository.linked']); + assert.equal(diagnostics[0][0], 'preflight.deployment.completed'); +}); + +test('deployment preflight preserves actionable evidence across Git, workflow and endpoint failures', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-degraded-')); + t.after(() => rm(root, { recursive: true, force: true })); + const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml' }; + const service = new PreflightService({ + store: { getDeploymentProfile: () => profile }, + git: { + status: async () => ({ root, head: 'b'.repeat(40), clean: false, counts: { changed: 4 }, branch: { head: '', upstream: '', ahead: 2, behind: 3 } }), + verifyCommitOnRemoteBranch: async () => { throw new Error('commit not published'); } + }, + gitea: { repositoryFileExists: async () => false, listWorkflowRuns: async () => { throw new Error('Actions disabled'); } }, + deployments: {}, diagnostics: { info: async () => {} }, userDataPath: root + }); + const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id }); + for (const id of ['git.branch', 'git.clean', 'git.upstream', 'git.sync', 'git.remote-sha', 'workflow.deploy.local', 'workflow.deploy.remote', 'gitea.actions', 'server.status.configured']) { + assert.equal(result.checks.find((item) => item.id === id).status, 'fail', id); + } + assert.equal(result.checks.find((item) => item.id === 'workflow.rollback.local').status, 'warning'); + assert.equal(result.checks.find((item) => item.id === 'server.health').status, 'warning'); + assert.equal(result.head, 'b'.repeat(40)); +}); + +test('deployment preflight distinguishes unreachable and mismatched status evidence', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-status-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true }); + await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n'); + const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app/status', healthcheckUrl: 'https://app/health' }; + let status = { reachable: false, ok: false, status: 503, error: '' }; + const service = new PreflightService({ + store: { getDeploymentProfile: () => profile }, + git: { status: async () => { throw new Error('checkout corrupt'); } }, + gitea: { repositoryFileExists: async () => { throw new Error('Gitea offline'); } }, + deployments: { readStatusEndpoint: async () => status, checkHealth: async () => ({ healthy: false, status: 500, error: '' }) }, + diagnostics: { info: async () => {} }, userDataPath: root + }); + const unreachable = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id }); + assert.match(unreachable.checks.find((item) => item.id === 'server.status.reachable').detail, /HTTP 503/i); + assert.match(unreachable.checks.find((item) => item.id === 'server.health').detail, /HTTP 500/i); + assert.match(unreachable.checks.find((item) => item.id === 'git.repository').detail, /checkout corrupt/i); + + status = { reachable: true, ok: true, repository: 'other/app', environment: 'staging', liveSha: null }; + const mismatch = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id }); + const identity = mismatch.checks.find((item) => item.id === 'server.status.identity'); + assert.equal(identity.status, 'fail'); + assert.equal(identity.required, true); + assert.match(mismatch.checks.find((item) => item.id === 'server.status.reachable').detail, /no live SHA/i); +}); diff --git a/tests/process-error-policy.test.mjs b/tests/process-error-policy.test.mjs new file mode 100644 index 0000000..748db35 --- /dev/null +++ b/tests/process-error-policy.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import policyModule from '../src/main/process-error-policy.cjs'; + +const { installOutputPipeGuards, isBrokenPipeError } = policyModule; + +test('broken output pipes are recognized without treating unrelated failures as EPIPE', () => { + assert.equal(isBrokenPipeError(Object.assign(new Error('closed'), { code: 'EPIPE' })), true); + assert.equal(isBrokenPipeError(Object.assign(new Error('denied'), { code: 'EACCES' })), false); + assert.equal(isBrokenPipeError(null), false); +}); + +test('output pipe guard absorbs EPIPE and can be cleanly removed', () => { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const observed = []; + const remove = installOutputPipeGuards({ stdout, stderr, onBrokenPipe: (error) => observed.push(error.code) }); + + stdout.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' })); + stderr.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' })); + assert.deepEqual(observed, ['EPIPE', 'EPIPE']); + remove(); + assert.equal(stdout.listenerCount('error'), 0); + assert.equal(stderr.listenerCount('error'), 0); +}); diff --git a/tests/production-acceptance.test.mjs b/tests/production-acceptance.test.mjs new file mode 100644 index 0000000..3da0c71 --- /dev/null +++ b/tests/production-acceptance.test.mjs @@ -0,0 +1,129 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFile, rm, writeFile } from "node:fs/promises"; + +const require = createRequire(import.meta.url); +const { ProductionAcceptanceHarness } = require("../src/main/production-acceptance-harness.cjs"); + +async function fixture(t) { + const harness = await ProductionAcceptanceHarness.create(); + t.after(() => harness.cleanup()); + return harness; +} + +test("production harness proves clean install, portable start and configuration migration", async (t) => { + const harness = await fixture(t); + assert.equal((await harness.install("0.10.0", "portable")).mode, "portable"); + const migration = await harness.migrate("1.0.0-rc.1"); + assert.equal(migration.previousVersion, "0.10.0"); + assert.equal(JSON.parse(await readFile(harness.paths.config, "utf8")).schemaVersion, 13); + assert.equal(JSON.parse(await readFile(migration.backup, "utf8")).schemaVersion, 12); +}); + +test("authentication fixtures cover token rotation, password, SSH keys and changed host keys", async (t) => { + const harness = await fixture(t); + assert.equal(harness.rotateToken().tokenVersion, 2); + assert.equal(harness.authenticate("password").authenticated, true); + assert.equal(harness.authenticate("ssh-key", { hostFingerprint: "SHA256:fixture-host" }).authenticated, true); + assert.throws(() => harness.authenticate("ssh-key", { hostFingerprint: "SHA256:changed" }), /fingerprint changed/); +}); + +test("new repositories deploy by server pull and Direct Copy at the exact Gitea SHA", async (t) => { + const harness = await fixture(t); + await harness.install(); + harness.authenticate("ssh-key", { hostFingerprint: harness.state.hostFingerprint }); + const serverSha = await harness.createCommit(); + assert.equal((await harness.deploy(harness.plan(serverSha, "server-git"))).status, "success"); + const copySha = await harness.createCommit("fix: direct copy fixture"); + assert.equal((await harness.deploy(harness.plan(copySha, "push-bundle"))).status, "success"); + assert.equal(harness.state.liveSha, copySha); +}); + +test("existing deployments are adopted, externally updated and reconciled without replacement", async (t) => { + const harness = await fixture(t); + assert.deepEqual(harness.adoptExisting(), { linked: true, liveSha: harness.initialSha, preserved: true }); + const sha = await harness.createCommit(); + assert.equal(harness.externalUpdate(sha).liveSha, sha); + assert.equal(harness.state.healthy, true); +}); + +test("deploy key rotation, revocation, restore and writable-key rejection fail closed", async (t) => { + const harness = await fixture(t); + harness.authenticate("password"); + assert.equal(harness.rotateDeployKey().rotated, true); + assert.equal(harness.revokeDeployKey().deploymentBlocked, true); + assert.equal(harness.restoreDeployKey().restored, true); + harness.setKeyAccess(false); + assert.throws(() => harness.rotateDeployKey(), /writable/); + await assert.rejects(() => harness.deploy(harness.plan(harness.initialSha)), /Writable deploy key/); +}); + +test("unhealthy activation rolls back only to the exact recorded previous SHA", async (t) => { + const harness = await fixture(t); + harness.authenticate("password"); + harness.adoptExisting(); + const sha = await harness.createCommit(); + assert.equal((await harness.deploy(harness.plan(sha), "unhealthy")).status, "failed"); + assert.throws(() => harness.rollback("0".repeat(40)), /exact recorded/); + const rollback = harness.rollback(harness.initialSha); + assert.equal(rollback.status, "rolled-back"); + assert.equal(rollback.liveSha, harness.initialSha); +}); + +test("network failures distinguish fetch from partial activation and preserve recovery", async (t) => { + const harness = await fixture(t); + harness.authenticate("password"); + const sha = await harness.createCommit(); + let outcome = await harness.deploy(harness.plan(sha), "fetch-network"); + assert.deepEqual(outcome.failure, { message: "Network interrupted during fetch", partial: false }); + outcome = await harness.deploy(harness.plan(sha), "activation-network"); + assert.equal(outcome.failure.partial, true); + assert.ok(harness.state.recovery); +}); + +test("application shutdown is recoverable and stale plans cannot mutate state", async (t) => { + const harness = await fixture(t); + harness.authenticate("password"); + const plan = harness.plan(harness.initialSha); + harness.state.liveSha = "1".repeat(40); + await assert.rejects(() => harness.deploy(plan), /Stale reconciliation plan/); + const current = harness.plan(harness.initialSha); + assert.equal((await harness.deploy(current, "shutdown")).status, "interrupted"); + assert.equal(harness.recover().status, "failed"); +}); + +test("corrupt configuration is recoverable from the migration backup", async (t) => { + const harness = await fixture(t); + await harness.install(); + await harness.migrate(); + const backup = `${harness.paths.config}.backup`; + await writeFile(harness.paths.config, "{broken", "utf8"); + await assert.rejects(() => readFile(harness.paths.config, "utf8").then(JSON.parse)); + await writeFile(harness.paths.config, await readFile(backup)); + assert.equal(JSON.parse(await readFile(harness.paths.config, "utf8")).version, "0.10.0"); +}); + +test("release verification rejects checksum failures, missing assets and drafts without requiring paid signing", async (t) => { + const harness = await fixture(t); + await harness.publishRelease("1.0.0-ok"); + assert.equal((await harness.verifyRelease("1.0.0-ok")).verified, true); + for (const [version, options, error] of [ + ["1.0.0-checksum", { badChecksum: true }, /checksum/], + ["1.0.0-missing", { missingAsset: true }, /asset is missing/], + ["1.0.0-draft", { draft: true }, /draft release/], + ]) { + await harness.publishRelease(version, options); + await assert.rejects(() => harness.verifyRelease(version), error); + } +}); + +test("large and partial inventory fixtures expose duplicates without touching production data", async (t) => { + const harness = await fixture(t); + const inventory = harness.inventory(24, true); + assert.equal(inventory.workloads.length, 24); + assert.equal(inventory.workloads.filter((item) => item.classification === "duplicate").length, 1); + assert.equal(inventory.partial, true); + assert.match(inventory.warnings[0], /unavailable/); + await rm(harness.paths.server, { recursive: true, force: true }); +}); diff --git a/tests/renderer-workflow.test.mjs b/tests/renderer-workflow.test.mjs new file mode 100644 index 0000000..c1c6e41 --- /dev/null +++ b/tests/renderer-workflow.test.mjs @@ -0,0 +1,308 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const rendererFiles = ["app.js", "diff-view.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"]; +async function rendererSource() { + return (await Promise.all(rendererFiles.map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n"); +} +async function ipcSource() { + return (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n"); +} + +test("desktop shell serializes ForgeFlow to one configuration writer", async () => { + const main = await readFile(new URL("../main.cjs", import.meta.url), "utf8"); + assert.match(main, /requestSingleInstanceLock\(\)/); + assert.match(main, /second-instance/); + assert.match(main, /showMainWindow\(\)/); +}); + +test("changed file list has an independently scrollable bounded layout", async () => { + const css = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match( + css, + /\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/, + ); + assert.match( + css, + /\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/, + ); + assert.match( + css, + /\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/, + ); +}); + +test("commit workflow explains every disabled prerequisite", async () => { + const renderer = await rendererSource(); + assert.match(renderer, /Commit message required/); + assert.match(renderer, /Enter a commit message to enable commit and push/); + assert.match(renderer, /ForgeFlow stages the selected files automatically/); + assert.match(renderer, /data-action="commit-push"/); + assert.match(renderer, /Commit staged hunks/); +}); + +test("ITWorx branding is integrated into titlebar and setup", async () => { + const renderer = await rendererSource(); + assert.match(renderer, /itworx-mark\.png/); + assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/); +}); + +test("all modal content stays inside the viewport with a persistent action footer", async () => { + const css = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match( + css, + /\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/, + ); + assert.match( + css, + /\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/, + ); + assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/); +}); + +test("settings provides one-click normalization for legacy Gitea origins", async () => { + const renderer = await rendererSource(); + assert.match(renderer, /data-action="normalize-origins"/); + assert.match(renderer, /Normalize all origins/); +}); + +test("Git mutations are serialized per repository and expose repair actions", async () => { + const ipc = await ipcSource(); + const renderer = await rendererSource(); + assert.match(ipc, /repositoryMutations = new Map/); + assert.match(ipc, /withRepositoryMutation/); + assert.match(ipc, /GIT_LOCKS_RECENT/); + assert.match(ipc, /setTimeout\(resolve, 2_500\)/); + assert.match(renderer, /data-action="repair-git-locks"/); + assert.match(renderer, /Repository troubleshooting/); + assert.match(renderer, /data-action="repair-origin"/); + assert.match(renderer, /Open guided repository repair/); +}); + +test("SSH secrets are captured before the loading render clears password inputs", async () => { + const renderer = await rendererSource(); + const passwordCapture = renderer.search( + /const password = document\.querySelector\(["']#server-password["']\)/, + ); + const loading = renderer.search( + /setLoading\(true, ["']Saving encrypted SSH configuration/, + ); + assert.ok(passwordCapture >= 0 && loading > passwordCapture); +}); + +test("SSH deployments are polled in the background and Portfolio casing is preserved", async () => { + const renderer = await rendererSource(); + assert.match(renderer, /function startOperationPolling\(\)/); + assert.match(renderer, /startOperationPolling\(\);/); + assert.match(renderer, /Visible container name/); + assert.match(renderer, /Compose services to verify/); +}); + +test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => { + const renderer = await rendererSource(); + assert.match(renderer, /Built-in high-contrast ITWorx mark/); + assert.match(renderer, /profile-icon-mode/); + assert.match(renderer, /Repair DockerMan integration/); + assert.match(renderer, /reconcile-deployment/); +}); + +test("repository troubleshooting offers personalized synchronization repair actions", async () => { + const renderer = await rendererSource(); + const ipc = await ipcSource(); + assert.match(renderer, /repair-repository-sync/); + assert.match(renderer, /safety branch/); + assert.match(ipc, /repository:repair-sync/); +}); + +test("Gitea workspace sync is preview-driven, recoverable and never deletes ignored runtime data", async () => { + const renderer = await rendererSource(); + const preload = await readFile(new URL("../preload.cjs", import.meta.url), "utf8"); + const ipc = await ipcSource(); + assert.match(renderer, /Gitea workspace sync/); + assert.match(renderer, /preview-workspace-sync/); + assert.match(renderer, /confirm-workspace-sync/); + assert.match(renderer, /Ignored runtime files remain in place/); + assert.match(renderer, /recovery branch/); + assert.match(renderer, /named Git stash/); + assert.match(renderer, /Gitea fetch interval/); + assert.match(preload, /previewWorkspaceSync/); + assert.match(preload, /applyWorkspaceSync/); + assert.match(ipc, /repository:workspace-sync-preview/); + assert.match(ipc, /repository:workspace-sync-apply/); +}); + +test("demo bridge implements the complete Git recovery flow", async () => { + const source = await readFile( + new URL("../src/renderer/mock-repository-bridge.js", import.meta.url), + "utf8", + ); + for (const method of [ + "gitRecoveryStatus", + "reconcileRepository", + "repairGitLocks", + "repairRepositorySync", + ]) { + assert.match(source, new RegExp(`async ${method}\\(`)); + } +}); + +test("Git tools rows retain their content height inside the scrollable tab", async () => { + const styles = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match( + styles, + /\.git-tools-grid\s*\{[^}]*grid-auto-rows:\s*max-content/s, + ); +}); + +test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => { + const renderer = await rendererSource(); + const preload = await readFile( + new URL("../preload.cjs", import.meta.url), + "utf8", + ); + for (const phrase of [ + "Stage hunks", + "Conflict guide", + "Create pull request", + "Open pull requests", + "load-pull-requests", + "Check branch protection", + "Encrypted configuration backup", + "Deployment policy", + "Operational audit log", + ]) + assert.match(renderer, new RegExp(phrase, "i")); + for (const method of [ + "stageHunks", + "resolveConflict", + "createPullRequest", + "branchProtection", + "openEditor", + "openTerminal", + "exportConfigurationBackup", + "listAuditEvents", + ]) + assert.match(preload, new RegExp(`${method}:`)); +}); + +test("one-click troubleshooting excludes destructive or publishing Git actions", async () => { + const renderer = await rendererSource(); + const ipc = await readFile( + new URL("../src/main/ipc.cjs", import.meta.url), + "utf8", + ); + assert.match(ipc, /action:\s*["']abort-operation["'],\s*safe:\s*false/); + assert.match(ipc, /action:\s*["']push["'],\s*safe:\s*false/); + assert.match(ipc, /const stale = lock\.ageMs >= 10_000/); + assert.match( + ipc, + /\[\s*["']fast-forward["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/, + ); + assert.doesNotMatch( + ipc, + /\[\s*["']fast-forward["'],\s*["']push["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/, + ); + assert.match( + renderer, + /trouble\?\.issues\?\.some\(\(item\) => item\.repairable && item\.safe\)/, + ); +}); + +test("premium repository workspace groups variable context above a stable tab row", async () => { + const renderer = await rendererSource(); + const styles = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match( + styles, + /\.repo-workspace\s*\{[^}]*grid-template-rows:\s*auto auto 39px minmax\(0, 1fr\)/s, + ); + assert.match(renderer, /
/); + assert.match(styles, /\.tabs\s*\{[^}]*overflow-x:\s*auto/s); + assert.match(styles, /\.tab\s*\{[^}]*white-space:\s*nowrap/s); + assert.match(styles, /prefers-reduced-motion/); + assert.match(styles, /ForgeFlow 0\.8 premium visual system/); +}); + +test("help center explains core workflows and supports contextual searchable guidance", async () => { + const renderer = await rendererSource(); + assert.match(renderer, /function renderHelp\(\)/); + assert.match(renderer, /id: "workspace-sync"/); + assert.match(renderer, /id: "deployment-linking"/); + assert.match(renderer, /id: "deploy-keys"/); + assert.match(renderer, /id: "git-validator"/); + assert.match(renderer, /id="help-search"/); + assert.match(renderer, /data-action="open-context-help" data-topic="workspace-sync"/); + assert.match(renderer, /ui\.currentView === "help"/); +}); + +test("interactive project illustrations are semantic, responsive and motion-safe", async () => { + const renderer = await rendererSource(); + const styles = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match(renderer, /function projectIllustration/); + assert.match(renderer, /data-project-illustration/); + assert.match(renderer, /document\.addEventListener\("pointermove"/); + assert.match(styles, /\.project-illustration/); + assert.match(styles, /@keyframes signal-travel/); + assert.match(styles, /prefers-reduced-motion/); + assert.match(styles, /transform: none !important/); +}); + +test("the diff canvas uses a contextual and motion-safe code illustration", async () => { + const renderer = await rendererSource(); + const styles = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match(renderer, /function diffAtmosphere/); + assert.match(renderer, /data-diff-atmosphere/); + assert.match(renderer, /--diff-tilt-x/); + assert.match(styles, /\.diff-atmosphere/); + assert.match(styles, /@keyframes code-packet-travel/); + assert.match(styles, /prefers-reduced-motion/); +}); + +test("Git Validator exposes scored best-practice checks and bounded repairs", async () => { + const renderer = await rendererSource(); + const styles = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + const preload = await readFile( + new URL("../preload.cjs", import.meta.url), + "utf8", + ); + assert.match(renderer, /function renderGitValidator/); + assert.match(renderer, /gitValidatorPreviewRepair/); + assert.match(renderer, /git-validator-suppress/); + assert.match(renderer, /git-validator-policy/); + assert.match(styles, /\.validator-score/); + assert.match(styles, /@container \(max-width: 900px\)/); + assert.match(preload, /gitValidatorScan/); + assert.match(preload, /gitValidatorRepair/); + assert.match(preload, /gitValidatorExport/); +}); + +test("renderer guards accessible names, labels and uncertain inventory evidence", async () => { + const renderer = await rendererSource(); + const styles = await readFile(new URL("../src/renderer/styles.css", import.meta.url), "utf8"); + assert.match(renderer, /button\.icon-button:not\(\[aria-label\]\)/); + assert.match(renderer, /\.field > label:not\(\[for\]\)/); + assert.match(renderer, /topCandidate\.confidence \|\| topCandidate\.status \|\| "review required"/); + assert.match(styles, /\.action-panel-body \.panel-callout > h2/); + assert.match(styles, /@media \(max-height: 760px\)/); +}); diff --git a/tests/repository-matching.test.mjs b/tests/repository-matching.test.mjs new file mode 100644 index 0000000..b382412 --- /dev/null +++ b/tests/repository-matching.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import matching from '../src/shared/repository-match.cjs'; + +const { normalizeRemoteUrl, matchRemoteToRepository } = matching; +const repositories = [{ full_name: 'jens/forgeflow', name: 'forgeflow', owner: { login: 'jens' } }]; + +test('normalizes HTTPS remotes', () => { + assert.deepEqual(normalizeRemoteUrl('https://gitea.internal/jens/forgeflow.git'), { host: 'gitea.internal', path: 'jens/forgeflow' }); +}); + +test('normalizes SCP-style SSH remotes', () => { + assert.deepEqual(normalizeRemoteUrl('git@gitea.internal:jens/forgeflow.git'), { host: 'gitea.internal', path: 'jens/forgeflow' }); +}); + +test('matches local remote to Gitea full name', () => { + assert.equal(matchRemoteToRepository('git@gitea.internal:jens/forgeflow.git', repositories)?.full_name, 'jens/forgeflow'); +}); diff --git a/tests/repository-monitor.test.mjs b/tests/repository-monitor.test.mjs new file mode 100644 index 0000000..cf99abc --- /dev/null +++ b/tests/repository-monitor.test.mjs @@ -0,0 +1,152 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import monitorModule from '../src/main/repository-monitor.cjs'; + +const { RepositoryMonitor } = monitorModule; + +test('repository monitor establishes a baseline and emits only on later changes', async () => { + let revision = 1; + const changes = []; + const git = { + status: async (localPath) => ({ localPath, revision }), + statusFingerprint: (status) => String(status.revision) + }; + const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } }; + const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) }); + monitor.setPaths(['/repo']); + await monitor.tick(); + assert.equal(changes.length, 0); + revision = 2; + await monitor.tick(); + assert.equal(changes.length, 1); + assert.equal(changes[0].reason, 'working-tree-changed'); + monitor.pause('/repo'); + revision = 3; + await monitor.tick(); + assert.equal(changes.length, 1); + monitor.resume('/repo'); + await monitor.tick(); + assert.equal(changes.length, 2); +}); +test('repository monitor checks multiple repositories concurrently with a bounded worker pool', async () => { + let active = 0; + let peak = 0; + const git = { + status: async (localPath) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 15)); + active -= 1; + return { localPath, revision: 1 }; + }, + statusFingerprint: (status) => String(status.revision) + }; + const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } }; + const monitor = new RepositoryMonitor({ store, git }); + monitor.setPaths(Array.from({ length: 10 }, (_, index) => `/repo-${index}`)); + await monitor.tick(); + assert.equal(peak, 4); + assert.equal(active, 0); + assert.equal(monitor.fingerprints.size, 10); +}); + +test('a watched repository is read on filesystem activity instead of on every interval', async (context) => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const os = await import('node:os'); + const path = await import('node:path'); + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-watch-')); + context.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, '.git'), { recursive: true }); + + let revision = 1; + const reads = []; + const changes = []; + const git = { + status: async (localPath) => { reads.push(localPath); return { localPath, revision }; }, + statusFingerprint: (status) => String(status.revision) + }; + const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } }; + const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) }); + context.after(() => monitor.stop()); + + monitor.restart(); + monitor.setPaths([root]); + if (!monitor.watchers.has(root)) { + context.skip('this platform does not support recursive directory watching'); + return; + } + + await monitor.tick(); + assert.equal(reads.length, 1, 'the baseline is established once'); + + // Without filesystem activity the interval must not spawn another read. + await monitor.tick(); + assert.equal(reads.length, 1); + + revision = 2; + await writeFile(path.join(root, 'feature.txt'), 'changed\n'); + // Exercise the monitor's filesystem-activity boundary deterministically. + // Native fs.watch delivery is platform/overlay specific and is covered by + // the product's safety interval rather than by this unit test. + monitor.noteFilesystemChange(root); + // The watcher debounce and the per-repository cooldown both apply here. + const deadline = Date.now() + 5_000; + while (changes.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + assert.ok(reads.length > 1, 'filesystem activity triggers a read'); + assert.equal(changes.length, 1); + assert.equal(changes[0].reason, 'working-tree-changed'); + + const readsAfterChange = reads.length; + await new Promise((resolve) => setTimeout(resolve, 800)); + assert.equal(reads.length, readsAfterChange, 'a quiet repository is not read again'); + + monitor.stop(); + assert.equal(monitor.watchers.size, 0, 'stopping releases every watcher'); +}); + +test('background Gitea awareness fetches read-only remote state with bounded concurrency', async () => { + let active = 0; + let peak = 0; + const changes = []; + const git = { + fetch: async (localPath) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 15)); + active -= 1; + return { status: { localPath, revision: 2, branch: { head: 'main', ahead: 0, behind: 1 }, counts: {} } }; + }, + statusFingerprint: (status) => String(status.revision), + }; + const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 1 } } }; + const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) }); + const paths = Array.from({ length: 6 }, (_, index) => `/repo-${index}`); + monitor.setPaths(paths); + for (const localPath of paths) { + monitor.fingerprints.set(localPath, '1'); + monitor.lastFetchedAt.set(localPath, Date.now() - 61_000); + } + + await monitor.fetchRemoteUpdates(); + assert.equal(peak, 2); + assert.equal(active, 0); + assert.equal(changes.length, paths.length); + assert.ok(changes.every((change) => change.reason === 'remote-state-changed')); +}); + +test('a zero remote fetch interval disables background network access', async () => { + let fetches = 0; + const git = { + fetch: async () => { fetches += 1; return { status: { revision: 2 } }; }, + statusFingerprint: (status) => String(status.revision), + }; + const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 0 } } }; + const monitor = new RepositoryMonitor({ store, git }); + monitor.setPaths(['/repo']); + monitor.lastFetchedAt.set('/repo', 0); + await monitor.fetchRemoteUpdates(Date.now() + 24 * 60 * 60_000); + assert.equal(fetches, 0); +}); diff --git a/tests/repository-service.test.mjs b/tests/repository-service.test.mjs new file mode 100644 index 0000000..5e7622a --- /dev/null +++ b/tests/repository-service.test.mjs @@ -0,0 +1,290 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, symlink } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import repositoryModule from '../src/main/repository-service.cjs'; + +const { RepositoryService } = repositoryModule; + +function status(head = 'a'.repeat(40)) { + return { + head, + shortHead: head.slice(0, 7), + clean: true, + counts: { changed: 0, conflicts: 0 }, + branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } + }; +} + +const remote = { + id: 1, + name: 'Portfolio', + full_name: 'Jens/Portfolio', + owner: { login: 'Jens' }, + private: true, + default_branch: 'main', + html_url: 'https://gitea.example/Jens/Portfolio', + clone_url: 'https://gitea.example/Jens/Portfolio.git', + ssh_url: 'git@gitea.example:Jens/Portfolio.git' +}; + +function service() { + return new RepositoryService({ data: { preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] } }, {}, {}); +} + +test('a synchronized commit is deployable when the server is unknown or older', () => { + const current = status(); + const unknown = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [ + { id: 'prod', branch: 'main', state: { liveSha: null, healthy: null } } + ]); + assert.equal(unknown.readyToDeploy, true); + + const older = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [ + { id: 'prod', branch: 'main', state: { liveSha: 'b'.repeat(40), healthy: true } } + ]); + assert.equal(older.readyToDeploy, true); +}); + +test('a healthy commit already live on the server is not offered for deployment again', () => { + const current = status(); + const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [ + { id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: true } } + ]); + assert.equal(repository.readyToDeploy, false); +}); + +test('an unhealthy live commit remains eligible for a controlled redeploy', () => { + const current = status(); + const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [ + { id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: false } } + ]); + assert.equal(repository.readyToDeploy, true); +}); + +test('repository discovery is bounded, skips generated trees and ignores inaccessible roots', async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-repositories-')); + context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))); + await mkdir(path.join(root, 'group', 'app', '.git'), { recursive: true }); + await mkdir(path.join(root, 'node_modules', 'ignored', '.git'), { recursive: true }); + await mkdir(path.join(root, 'too', 'deep', 'repository', '.git'), { recursive: true }); + try { await symlink(path.join(root, 'group'), path.join(root, 'linked'), 'junction'); } catch {} + + const instance = service(); + const found = await instance.discoverInRoot(root, 2); + assert.deepEqual(found, [await import('node:fs/promises').then(({ realpath }) => realpath(path.join(root, 'group', 'app')))]); + assert.deepEqual(await instance.discoverInRoot(path.join(root, 'missing')), []); + const all = await instance.discoverAll([root, root, '', null]); + assert.equal(all.length, 2); + assert.equal(new Set(all).size, 2); + assert.ok(all.includes(found[0])); +}); + +test('a repository reached through a directory junction is discovered once', async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-junction-')); + context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))); + const elsewhere = path.join(root, 'elsewhere', 'service'); + await mkdir(path.join(elsewhere, '.git'), { recursive: true }); + await mkdir(path.join(root, 'workspace'), { recursive: true }); + try { + await symlink(elsewhere, path.join(root, 'workspace', 'linked-service'), 'junction'); + } catch { + context.skip('this platform does not allow creating directory links'); + return; + } + + const { realpath } = await import('node:fs/promises'); + const found = await service().discoverInRoot(path.join(root, 'workspace'), 3); + assert.deepEqual(found, [await realpath(elsewhere)]); +}); + +test('local descriptors preserve Git failures and watch paths are defensive copies', async () => { + const instance = new RepositoryService({ data: {} }, { + status: async (localPath) => { + if (localPath.endsWith('bad')) throw new Error('not a repository'); + return { ...status(), root: `${localPath}/canonical`, remoteUrl: remote.clone_url }; + } + }, {}); + const descriptors = await instance.getLocalDescriptors(['good', 'bad']); + assert.equal(descriptors[0].localPath, 'good/canonical'); + assert.equal(descriptors[1].error, 'not a repository'); + instance.lastKnownLocalPaths = ['one']; + const watched = instance.getWatchPaths(); + watched.push('two'); + assert.deepEqual(instance.getWatchPaths(), ['one']); +}); + +test('refresh links explicit and remote-matched repositories and retains unmatched locals', async () => { + const diagnostics = []; + const store = { + data: { + gitea: { baseUrl: 'https://gitea.example' }, + workspaceRoots: ['root'], + repositoryMappings: { 'jens/portfolio': 'C:/explicit' }, + preferences: { preferredCloneProtocol: 'https' }, + favorites: ['jens/portfolio'] + }, + getToken: () => 'token', + getDeploymentProfiles: (name) => name === remote.full_name ? [{ id: 'prod', branch: 'main' }] : [], + getDeploymentState: () => ({ liveSha: null, healthy: null }) + }; + const secondRemote = { ...remote, id: 2, name: 'Other', full_name: 'Jens/Other', clone_url: 'https://gitea.example/Jens/Other.git' }; + const instance = new RepositoryService(store, { + status: async (localPath) => ({ + ...status(localPath.includes('unmatched') ? 'b'.repeat(40) : 'a'.repeat(40)), + root: localPath, + remoteUrl: localPath.includes('matched') ? secondRemote.clone_url : remote.clone_url + }) + }, { listRepositories: async () => [remote, secondRemote] }, { debug: async (...args) => diagnostics.push(args) }); + instance.discoverAll = async () => ['C:/matched', 'C:/unmatched']; + + const repositories = await instance.refresh(); + const explicit = repositories.find((item) => item.fullName === remote.full_name); + const matched = repositories.find((item) => item.fullName === secondRemote.full_name); + const unmatched = repositories.find((item) => item.linkState === 'unmatched-local'); + assert.equal(explicit.localPath, 'C:/explicit'); + assert.equal(explicit.favorite, true); + assert.equal(explicit.preferredCloneUrl, remote.clone_url); + assert.equal(matched.localPath, 'C:/matched'); + assert.equal(unmatched.localPath, 'C:/unmatched'); + assert.deepEqual(instance.getWatchPaths().sort(), ['C:/explicit', 'C:/matched', 'C:/unmatched'].sort()); + assert.equal(diagnostics[0][0], 'repositories.refresh.completed'); +}); + +test('resolving one repository reads only that repository, not the whole workspace', async () => { + const scanned = []; + const store = { + data: { + gitea: { baseUrl: 'https://gitea.example' }, + workspaceRoots: ['root'], + repositoryMappings: { 'jens/portfolio': 'C:/explicit' }, + preferences: { preferredCloneProtocol: 'https' }, + favorites: [] + }, + getToken: () => 'token', + getDeploymentProfiles: () => [{ id: 'prod', branch: 'main' }], + getDeploymentState: () => ({ liveSha: null, healthy: null }) + }; + const instance = new RepositoryService(store, { + status: async (localPath) => { + scanned.push(localPath); + return { ...status(), root: localPath, remoteUrl: remote.clone_url }; + } + }, { listRepositories: async () => [remote, { ...remote, id: 2, full_name: 'Jens/Other', name: 'Other' }] }); + instance.discoverAll = async () => ['C:/explicit', 'C:/other', 'C:/third']; + + await instance.refresh(); + const duringRefresh = scanned.length; + assert.equal(duringRefresh, 3); + + scanned.length = 0; + const resolved = await instance.resolveByFullName(remote.full_name); + assert.equal(resolved.fullName, remote.full_name); + assert.equal(resolved.localPath, 'C:/explicit'); + assert.equal(resolved.deploymentProfiles[0].id, 'prod'); + assert.deepEqual(scanned, ['C:/explicit']); + + assert.equal(await instance.resolveByFullName(''), null); +}); + +test('resolving an unknown repository still falls back to a full refresh', async () => { + const store = { + data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'https' }, favorites: [] }, + getToken: () => 'token', + getDeploymentProfiles: () => [], + getDeploymentState: () => null + }; + const instance = new RepositoryService(store, { + status: async (localPath) => ({ ...status(), root: localPath, remoteUrl: '' }) + }, { listRepositories: async () => [remote] }); + instance.discoverAll = async () => ['C:/loose-checkout']; + + const local = await instance.resolveByFullName('loose-checkout'); + assert.equal(local.linkState, 'unmatched-local'); + assert.equal(await instance.resolveByFullName('Jens/Missing'), null); +}); + +test('refresh remains local-only without configured Gitea credentials', async () => { + const store = { + data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] }, + getToken: () => '', getDeploymentProfiles: () => [], getDeploymentState: () => null + }; + const instance = new RepositoryService(store, {}, { listRepositories: async () => { throw new Error('must not call'); } }); + instance.discoverAll = async () => []; + assert.deepEqual(await instance.refresh(), []); +}); + +test('refresh uses last-known Gitea repositories after a transient remote failure', async () => { + const warnings = []; + let remoteAvailable = true; + const store = { + data: { + gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, + preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] + }, + getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null + }; + const instance = new RepositoryService(store, {}, { + listRepositories: async () => { + if (!remoteAvailable) throw new Error('Gitea timed out'); + return [remote]; + } + }, { debug: async () => {}, warning: async (...args) => warnings.push(args) }); + instance.discoverAll = async () => []; + + const fresh = await instance.refresh(); + remoteAvailable = false; + const degraded = await instance.refresh({ force: true }); + + assert.equal(fresh[0].remoteStale, false); + assert.equal(degraded[0].fullName, remote.full_name); + assert.equal(degraded[0].remoteStale, true); + assert.equal(degraded[0].remoteRefreshError, 'Gitea timed out'); + assert.ok(degraded[0].remoteLastRefreshedAt); + assert.equal(warnings[0][0], 'repositories.remote-refresh.degraded'); +}); + +test('initial Gitea failure remains visible when no safe cache exists', async () => { + const store = { + data: { gitea: { baseUrl: 'https://gitea.example' } }, + getToken: () => 'token' + }; + const instance = new RepositoryService(store, {}, { + listRepositories: async () => { throw new Error('Gitea unavailable'); } + }); + await assert.rejects(() => instance.refresh(), /Gitea unavailable/); +}); + +test('refresh coalesces concurrent work and briefly reuses remote and discovery results', async () => { + let remoteCalls = 0; + let discoveryCalls = 0; + const store = { + data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] }, + getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null + }; + const instance = new RepositoryService(store, {}, { listRepositories: async () => { remoteCalls += 1; await new Promise((resolve) => setTimeout(resolve, 10)); return [remote]; } }); + instance.discoverAll = async () => { discoveryCalls += 1; return []; }; + + const [first, second] = await Promise.all([instance.refresh(), instance.refresh()]); + assert.deepEqual(first, second); + await instance.refresh(); + assert.equal(remoteCalls, 1); + assert.equal(discoveryCalls, 1); + await instance.refresh({ force: true }); + assert.equal(remoteCalls, 2); + assert.equal(discoveryCalls, 2); +}); + +test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => { + const instance = service(); + const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []); + assert.equal(conflicted.attentionReason, 'Merge conflict'); + assert.equal(conflicted.readyToDeploy, false); + const behind = instance.decorate(remote, { localPath: 'repo', status: { ...status(), branch: { ...status().branch, behind: 1 } } }, []); + assert.equal(behind.attentionReason, '1 commit behind remote'); + const broken = instance.decorate(remote, { localPath: 'repo', status: null, error: 'broken checkout' }, []); + assert.equal(broken.attentionReason, 'broken checkout'); + const remoteOnly = instance.decorate({ ...remote, ssh_url: '', clone_url: '' }, null, []); + assert.equal(remoteOnly.linkState, 'remote-only'); + assert.equal(remoteOnly.preferredCloneUrl, ''); +}); diff --git a/tests/security-validation.test.mjs b/tests/security-validation.test.mjs new file mode 100644 index 0000000..0f816f8 --- /dev/null +++ b/tests/security-validation.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import validation from '../src/shared/validation.cjs'; +import redaction from '../src/main/log-redaction.cjs'; + +const { + normalizeBaseUrl, + assertRepositoryRelativePath, + assertRepositoryRelativePaths, + assertFullCommitSha, + assertWorkflowFile, + assertWorkflowFileName, + assertBranchName, + assertEnvironmentName, + assertHttpUrl, + assertCloneRemote +} = validation; +const { redactSecrets } = redaction; + +test('rejects credentials embedded in service URLs', () => { + assert.throws(() => normalizeBaseUrl(`https://${['jens', 'secret'].join(':')}@gitea.example.test`), /credentials/i); + assert.throws(() => normalizeBaseUrl('http://gitea.example.test'), /must use HTTPS/i); + assert.equal(normalizeBaseUrl('http://127.0.0.1:3000/'), 'http://127.0.0.1:3000'); + assert.throws(() => assertHttpUrl(`https://${['user', 'secret'].join(':')}@app.example.test/health`), /credentials/i); +}); + +test('accepts repository-relative paths but blocks escapes and absolute paths', () => { + assert.equal(assertRepositoryRelativePath('./src/main.ts'), 'src/main.ts'); + assert.deepEqual(assertRepositoryRelativePaths(['src/main.ts', 'src/main.ts', 'docs/readme.md']), ['src/main.ts', 'docs/readme.md']); + assert.throws(() => assertRepositoryRelativePath('../secrets.txt'), /escape/i); + assert.throws(() => assertRepositoryRelativePath('/etc/passwd'), /absolute/i); + assert.throws(() => assertRepositoryRelativePath('C:\\Windows\\win.ini'), /absolute/i); +}); + +test('validates full commit SHAs and workflow filenames', () => { + const sha = 'A'.repeat(40); + assert.equal(assertFullCommitSha(sha), 'a'.repeat(40)); + assert.equal(assertWorkflowFile('.gitea/workflows/deploy.yml'), '.gitea/workflows/deploy.yml'); + assert.throws(() => assertFullCommitSha('abc1234'), /full commit SHA/i); + assert.throws(() => assertWorkflowFile('../deploy.yml'), /escape/i); + assert.throws(() => assertWorkflowFile('deploy.sh'), /YAML/i); + assert.equal(assertWorkflowFileName('deploy.yml'), 'deploy.yml'); + assert.throws(() => assertWorkflowFileName('.gitea/workflows/deploy.yml'), /filename/i); +}); + +test('allows supported Git remotes and rejects unsafe protocols/passwords', () => { + assert.equal(assertCloneRemote('git@gitea.example.test:jens/app.git'), 'git@gitea.example.test:jens/app.git'); + assert.equal(assertCloneRemote('ssh://git@gitea.example.test/jens/app.git'), 'ssh://git@gitea.example.test/jens/app.git'); + assert.throws(() => assertCloneRemote('file:///tmp/repo.git'), /unsupported/i); + assert.throws(() => assertCloneRemote(`https://${['jens', 'secret'].join(':')}@gitea.example.test/jens/app.git`), /password/i); +}); + +test('redacts known tokens, authorization headers, query tokens and URL passwords', () => { + const token = 'super-secret-token'; + const source = `Authorization: token ${token}\nhttps://gitea.test/api?access_token=${token}\nhttps://${['jens', 'password'].join(':')}@gitea.test\n${token}`; + const result = redactSecrets(source, [token]); + assert.doesNotMatch(result, /super-secret-token|password/); + assert.match(result, /\[REDACTED\]/); +}); + + +test('validates deployment branch and environment identifiers', () => { + assert.equal(assertBranchName('release/staging'), 'release/staging'); + assert.equal(assertEnvironmentName('Production-EU'), 'production-eu'); + assert.throws(() => assertBranchName('-dangerous'), /invalid/i); + assert.throws(() => assertBranchName('main..backup'), /invalid/i); + assert.throws(() => assertEnvironmentName('production eu'), /environment/i); +}); diff --git a/tests/semver.test.mjs b/tests/semver.test.mjs new file mode 100644 index 0000000..d544e60 --- /dev/null +++ b/tests/semver.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { parseVersion, compareVersions, isNewerVersion } = require('../src/shared/semver.cjs'); + +test('semantic versions are compared without lexical mistakes', () => { + assert.equal(parseVersion('v0.4.0').minor, 4); + assert.equal(compareVersions('0.10.0', '0.9.9'), 1); + assert.equal(compareVersions('1.0.0', '1.0.0'), 0); + assert.equal(isNewerVersion('0.4.1', '0.4.0'), true); + assert.equal(isNewerVersion('0.4.0-beta.1', '0.4.0'), false); +}); diff --git a/tests/server-inventory-branches.test.mjs b/tests/server-inventory-branches.test.mjs new file mode 100644 index 0000000..b272782 --- /dev/null +++ b/tests/server-inventory-branches.test.mjs @@ -0,0 +1,150 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + parseServerInventory, buildWorkloadInventory, inventoryContainerMatch, remoteIdentity, + stableWorkloadId, profileMatchesWorkload, sanitizeLegacyContainer, safeRelativeToBase, + canonicalServerAppdataPath, deploymentRootCandidate, +} = require('../src/main/server-inventory.cjs'); + +const b64 = (value) => Buffer.from(String(value)).toString('base64'); + +test('inventory parser handles every evidence record and ignores malformed payloads', () => { + const legacy = { Id: 'legacy', Name: '/App', Config: { Image: 'app:1', Labels: { 'com.docker.compose.project': 'app' } }, State: { Running: true, Status: 'running', Health: { Status: 'healthy' } }, Mounts: null }; + const safe = { id: 'safe', name: '/Safe', running: false, labels: null, mounts: null, ports: null, networks: null }; + const projects = [{ Name: 'app', Status: 'running(1)', ConfigFiles: '/mnt/user/appdata/App/compose.yml,/mnt/user/appdata/App/extra.yml' }, { name: '', configFiles: [] }]; + const output = [ + 'noise', '__FORGEFLOW_INVENTORY__', + `H\ttrue\tfalse\ttrue\ttrue\tfalse\ttrue\t${b64('Compose v2')}\t${b64('Linux')}`, + `R\t${b64('/mnt/user/appdata/App')}\t${b64('git@gitea.test:Owner/App.git')}\t${'a'.repeat(40)}\t${b64('main')}`, + `C\t${b64(JSON.stringify([legacy, null]))}`, + `C\t${b64(JSON.stringify(safe))}`, + `C\t${b64('{bad json')}`, + `D\t${b64('App')}\t${b64('/templates/App.xml')}\t${b64('http://app')}\t${b64('/icon.png')}\t${b64('/bin/bash')}\t${b64('app:1')}\t${b64('bridge')}`, + `P\t${b64(JSON.stringify(projects))}`, + `P\t${b64(JSON.stringify({ name: 'single', status: 'exited', config_files: ['single.yml'] }))}`, + `Y\t${b64('/mnt/user/appdata/App/')}\t${b64('compose.yml\ncompose.prod.yml\n')}\t${b64('app')}\t${b64('web\nworker')}\t${b64('app:1')}\ttrue\t${b64('')}`, + `W\t${b64('partial docker inspect failure')}`, + 'UNKNOWN\tignored', + ].join('\n'); + const parsed = parseServerInventory(output); + assert.deepEqual(parsed.capabilities, { docker: true, compose: false, git: true, tar: true, checksum: false, baseWritable: true, composeVersion: 'Compose v2', platform: 'Linux' }); + assert.equal(parsed.checkouts.length, 1); + assert.equal(parsed.containers.length, 2); + assert.equal(parsed.containers[0].health, 'healthy'); + assert.deepEqual(parsed.containers[1].labels, {}); + assert.equal(parsed.dockerMan[0].templatePath, '/templates/App.xml'); + assert.equal(parsed.composeProjects.length, 2); + assert.deepEqual(parsed.composeProjects[0].configFiles, ['/mnt/user/appdata/App/compose.yml', '/mnt/user/appdata/App/extra.yml']); + assert.deepEqual(parsed.composeDefinitions[0].services, ['web', 'worker']); + assert.deepEqual(parsed.warnings, ['partial docker inspect failure']); + assert.throws(() => parseServerInventory('ordinary output'), /did not return/i); +}); + +test('server path normalization keeps deployments inside canonical appdata', () => { + assert.equal(safeRelativeToBase('/mnt/user/appdata/', '/mnt/user/appdata/App/'), 'App'); + for (const value of ['', '/mnt/user/appdata', '/mnt/user/appdata/../etc', '/other/App']) assert.equal(safeRelativeToBase('/mnt/user/appdata', value), ''); + assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/cache/appdata/App'), '/mnt/user/appdata/App'); + assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/disk2/appdata/App/data'), '/mnt/user/appdata/App/data'); + assert.equal(canonicalServerAppdataPath('', '/mnt/user/appdata/App'), '/mnt/user/appdata/App'); + assert.equal(canonicalServerAppdataPath('/custom', '/outside/path'), '/outside/path'); + assert.equal(canonicalServerAppdataPath('/custom', ''), ''); + assert.equal(deploymentRootCandidate('App/source-pre-abcdef1/source'), 'App'); + assert.equal(deploymentRootCandidate('App/.forgeflow/incoming'), 'App'); +}); + +test('profile matching requires the same server and accepts each stable identity form', () => { + const workload = { serverId: 'server', workloadId: 'workload', selector: { kind: 'compose', composeProject: 'app' }, compose: { project: 'app', workingDir: '/apps/App' }, remoteFolderCandidate: 'App', containers: [{ name: 'app-web' }] }; + assert.equal(profileMatchesWorkload(null, workload), false); + assert.equal(profileMatchesWorkload({ provider: 'gitea-actions', serverId: 'server' }, workload), false); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'other' }, workload), false); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { workloadId: 'workload' } }, workload), true); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { selector: workload.selector } }, workload), true); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app' }, workload), true); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', composeWorkingDir: '/other' }, workload), false); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', remoteFolder: 'App' }, workload), true); + assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', containerName: 'app-web' }, workload), true); + assert.equal(stableWorkloadId('server', workload.selector), stableWorkloadId('server', workload.selector)); +}); + +test('container matching prioritizes working directory, mounts, provenance and stable names', () => { + const checkout = { root: '/apps/App', remote: 'git@gitea.test:Owner/App.git' }; + const repository = { name: 'App' }; + const base = { running: true, labels: {}, mounts: [], name: 'different' }; + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project.working_dir': '/apps/App/' } }), 100); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, mounts: [{ Source: '/apps/App/data' }] }), 90); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'org.opencontainers.image.source': 'https://gitea.test/Owner/App' } }), 85); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project': 'app' } }), 70); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, name: '/APP' }), 60); + assert.equal(inventoryContainerMatch(checkout, repository, { ...base, running: false }), 0); + assert.equal(inventoryContainerMatch(checkout, repository, base), 0); + assert.equal(remoteIdentity(''), ''); +}); + +test('workload builder merges runtime, Compose file and DockerMan evidence without backups', () => { + const labels = { + 'com.docker.compose.project': 'app', + 'com.docker.compose.project.working_dir': '/mnt/cache/appdata/App', + 'com.docker.compose.project.config_files': '/mnt/cache/appdata/App/compose.yml', + 'com.docker.compose.service': 'web', + 'tech.itworx.forgeflow.repository': 'git@gitea.test:Owner/App.git', + 'tech.itworx.forgeflow.commit': 'b'.repeat(40), + 'tech.itworx.forgeflow.branch': 'main', + }; + const inventory = { + containers: [ + { id: 'web', name: 'app-web', image: 'registry/app:1', imageId: 'image', running: true, status: 'running', health: 'unhealthy', labels, ports: { '8080/tcp': null, '3000/udp': [{ HostIp: '0.0.0.0', HostPort: '3000' }] }, mounts: [{ Type: 'bind', Source: '/mnt/disk1/appdata/App/data', Destination: '/data', RW: false }], networks: { frontend: {} }, restartPolicy: 'always' }, + { id: 'worker', name: 'app-worker', image: 'registry/worker:1', imageId: 'worker', running: false, status: 'exited', health: null, labels: { ...labels, 'com.docker.compose.service': 'worker' }, ports: {}, mounts: [], networks: {}, restartPolicy: '' }, + ], + checkouts: [{ root: '/mnt/user/appdata/App', remote: 'git@gitea.test:Owner/App.git', liveSha: 'c'.repeat(40), branch: 'release' }], + composeProjects: [{ name: 'app', status: 'running', configFiles: [] }, { name: 'headless', status: 'exited', configFiles: ['/mnt/user/appdata/Headless/compose.yml'] }], + composeDefinitions: [ + { workingDir: '/mnt/user/appdata/App', configFiles: ['/mnt/user/appdata/App/compose.yml'], projectName: 'app', services: ['web', 'worker'], images: ['registry/app:1'], valid: true, error: '' }, + { workingDir: '/mnt/user/appdata/Backup/.forgeflow/releases/one', configFiles: ['compose.yml'], projectName: 'backup', services: [], images: [], valid: true }, + { workingDir: '/mnt/user/appdata/Standalone', configFiles: ['/mnt/user/appdata/Standalone/compose.yml'], projectName: '', services: ['api'], images: ['standalone:1'], valid: false, error: 'invalid compose' }, + ], + dockerMan: [ + { name: 'app-web', templatePath: '/templates/app.xml', webUiUrl: 'http://app', iconUrl: '/app.png', shell: '/bin/bash', repository: 'registry/app:1', network: 'frontend' }, + { name: 'template-only', templatePath: '/templates/template.xml', webUiUrl: '', iconUrl: '', shell: '', repository: 'template:1', network: 'bridge' }, + ], warnings: [], capabilities: {}, + }; + const repository = { fullName: 'Owner/App', name: 'App', cloneUrl: 'https://gitea.test/Owner/App.git' }; + const workloads = buildWorkloadInventory({ inventory, server: { id: 'server', name: 'Unraid', basePath: '/mnt/user/appdata' }, repositories: [repository], profiles: [{ id: 'profile', provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', repositoryFullName: 'Owner/App', adoptedFromServer: true }] }); + assert.equal(workloads.some((workload) => workload.displayName === 'backup'), false); + const app = workloads.find((workload) => workload.displayName === 'app'); + assert.equal(app.status, 'linked'); + assert.equal(app.runtime.running, true); + assert.equal(app.runtime.allRunning, false); + assert.equal(app.runtime.health, 'unhealthy'); + assert.equal(app.runtime.ports.length, 2); + assert.equal(app.containers[0].mounts[0].readOnly, true); + assert.equal(app.remoteFolderCandidate, 'App'); + assert.equal(app.candidates[0].exact, true); + assert.equal(app.link.source, 'automatic'); + const standalone = workloads.find((workload) => workload.displayName === 'Standalone'); + assert.equal(standalone.metadata.composeDefinitionValid, false); + assert.equal(standalone.metadata.composeDefinitionError, 'invalid compose'); + const template = workloads.find((workload) => workload.displayName === 'template-only'); + assert.equal(template.kind, 'dockerman-container'); + assert.equal(template.runtime.running, false); + assert.equal(template.metadata.shell, '/bin/sh'); +}); + +test('legacy container sanitizer applies safe defaults to partial Docker inspect data', () => { + assert.deepEqual(sanitizeLegacyContainer(null), { + id: '', name: '', image: '', imageId: '', running: false, status: '', health: null, + labels: { + 'com.docker.compose.project': '', 'com.docker.compose.project.working_dir': '', 'com.docker.compose.project.config_files': '', 'com.docker.compose.service': '', + 'org.opencontainers.image.source': '', 'org.opencontainers.image.revision': '', 'tech.itworx.forgeflow.repository': '', 'tech.itworx.forgeflow.commit': '', + 'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '', + }, ports: {}, mounts: [], networks: {}, restartPolicy: '', + }); + assert.equal(sanitizeLegacyContainer({ + Id: 'no-healthcheck', + Name: '/NoHealthcheck', + State: { Running: true, Status: 'running' }, + Config: { Image: 'example/no-healthcheck:latest', Labels: null }, + }).health, null); +}); diff --git a/tests/shell-verification.test.mjs b/tests/shell-verification.test.mjs new file mode 100644 index 0000000..60a37b2 --- /dev/null +++ b/tests/shell-verification.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, copyFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import shellVerification from '../src/shared/shell-verification.cjs'; + +const { bashSyntaxCheckInvocation, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification; + +test('Shell validation refuses absolute and escaping script paths', () => { + assert.throws(() => normalizeRelativePosixPath('C:\\Projects\\ForgeFlow\\script.sh'), /must be relative/); + assert.throws(() => normalizeRelativePosixPath('/tmp/script.sh'), /must be relative/); + assert.throws(() => normalizeRelativePosixPath('../script.sh'), /may not escape/); +}); + +test('Bash syntax validation works from a project root containing spaces', async (t) => { + if (spawnSync('bash', ['--version'], { encoding: 'utf8' }).status !== 0) { + t.skip('Bash is not available in this environment.'); + return; + } + const tempBase = await mkdtemp(path.join(os.tmpdir(), 'forge flow verify ')); + try { + const relativeDirectory = path.join(tempBase, 'examples', 'server'); + await mkdir(relativeDirectory, { recursive: true }); + await copyFile(new URL('../examples/server/forgeflow-deploy', import.meta.url), path.join(relativeDirectory, 'forgeflow-deploy')); + const invocation = bashSyntaxCheckInvocation(tempBase); + assert.equal(invocation.options.cwd, tempBase); + assert.deepEqual(invocation.args, ['-n']); + assert.equal(invocation.options.input.includes('\r'), false); + const result = spawnSync(invocation.command, invocation.args, invocation.options); + assert.equal(result.status, 0, result.stderr); + } finally { + try { + await rm(tempBase, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100 + }); + } catch (error) { + // Git Bash on Windows can retain a short-lived working-directory handle + // after bash -n exits. Do not fail a successful syntax test solely because + // Windows delayed releasing that temporary directory. + if (!['EBUSY', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error; + } + } +}); + +test('Bash syntax validation from text does not depend on a Windows working directory', () => { + const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n'); + assert.equal(invocation.command, 'bash'); + assert.deepEqual(invocation.args, ['-n']); + assert.equal(invocation.options.cwd, undefined); + assert.match(invocation.options.input, /set -euo pipefail/); +}); + +test('Bash syntax validation from text detects malformed scripts', (t) => { + if (spawnSync('bash', ['--version'], { encoding: 'utf8' }).status !== 0) { + t.skip('Bash is not available in this environment.'); + return; + } + const invocation = bashSyntaxCheckFromTextInvocation('if true; then\n echo missing fi\n'); + const result = spawnSync(invocation.command, invocation.args, invocation.options); + assert.notEqual(result.status, 0); +}); + +test('portable server-script validation does not require a local Bash executable', () => { + const script = `#!/usr/bin/env bash +set -Eeuo pipefail +readonly CONFIG_FILE="/etc/forgeflow/targets.conf" +echo "Target configuration must be owned by root" +APP_DIR=/tmp/app +SHA=0123456789012345678901234567890123456789 +COMPOSE_FILE=docker-compose.yml +write_status() { :; } +exec 9>/tmp/test.lock +flock -n 9 +git -C "$APP_DIR" fetch origin main +git -C "$APP_DIR" reset --hard "$SHA" +docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans +write_status "healthy" +write_status "unhealthy" +`; + assert.equal(validateShellScriptStructure(script), true); +}); + +test('portable server-script validation refuses missing deployment safety markers', () => { + assert.throws(() => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'), /missing required safety marker/); +}); + +test('Windows publication never depends on an external Bash shim', () => { + assert.equal(shouldRunExternalBash('win32'), false); + assert.equal(shouldRunExternalBash('linux'), true); + assert.equal(shouldRunExternalBash('darwin'), true); +}); diff --git a/tests/ssh-connection-pool.test.mjs b/tests/ssh-connection-pool.test.mjs new file mode 100644 index 0000000..6c2a902 --- /dev/null +++ b/tests/ssh-connection-pool.test.mjs @@ -0,0 +1,255 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const ssh2Path = require.resolve("ssh2"); +const realSsh2 = require("ssh2"); + +// SshService resolves ssh2 lazily and after an await, so the replacement has to +// stay in place until the whole operation settles. +async function withFakeSsh2(Client, operation) { + require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: { ...realSsh2, Client } }; + try { + return await operation(); + } finally { + require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: realSsh2 }; + } +} + +const { SshService } = require("../src/main/ssh-service.cjs"); + +function makeStore(overrides = {}) { + const server = { + id: "unraid", + host: "tower", + port: 22, + username: "root", + authType: "password", + basePath: "/mnt/user/appdata", + hostFingerprint: "SHA256:trusted", + ...overrides, + }; + return { + server, + getServer: () => server, + getServerCredentials: () => ({ password: "secret", passphrase: "" }), + }; +} + +// A client that reports what the pool does to it: how often it connected, how +// many channels it opened, and whether it was closed. +function fakeClientFactory({ execBehaviour = () => ({ ok: true }) } = {}) { + const state = { connects: 0, execs: 0, ends: 0, instances: [] }; + class FakeClient extends EventEmitter { + constructor() { + super(); + this.ended = false; + state.instances.push(this); + } + connect(options) { + state.connects += 1; + options.hostVerifier(Buffer.from("host key")); + setImmediate(() => this.emit("ready")); + } + exec(command, callback) { + state.execs += 1; + const outcome = execBehaviour(state.execs, this); + if (outcome.channelError) { + setImmediate(() => callback(outcome.channelError)); + return; + } + const stream = new EventEmitter(); + stream.stderr = new EventEmitter(); + // A channel that closes without an exit status reports null, which is how + // a connection lost mid-command surfaces. That is not the same as 0. + const closeCode = Object.hasOwn(outcome, "exitCode") ? outcome.exitCode : 0; + setImmediate(() => { + stream.emit("data", Buffer.from(outcome.stdout ?? "ok")); + stream.emit("close", closeCode, null); + }); + callback(null, stream); + } + end() { + if (this.ended) return; + this.ended = true; + state.ends += 1; + setImmediate(() => this.emit("close")); + } + } + return { FakeClient, state }; +} + +function service(store, options = {}) { + return new SshService({ store, diagnostics: null, ...options }); +} + +const run = withFakeSsh2; + +test("a sequence of commands to one server shares a single connection", async () => { + const { FakeClient, state } = fakeClientFactory(); + const store = makeStore(); + const ssh = service(store); + + for (let index = 0; index < 5; index += 1) { + await run(FakeClient, () => ssh.exec("unraid", `echo ${index}`)); + } + + assert.equal(state.execs, 5); + assert.equal(state.connects, 1, "five commands, one handshake"); + ssh.closeAll(); +}); + +test("concurrent commands share the connection and it survives until the last one finishes", async () => { + const { FakeClient, state } = fakeClientFactory(); + const ssh = service(makeStore()); + + await run(FakeClient, () => Promise.all([ + ssh.exec("unraid", "one"), + ssh.exec("unraid", "two"), + ssh.exec("unraid", "three"), + ])); + + assert.equal(state.connects, 1); + assert.equal(state.execs, 3); + assert.equal(state.ends, 0, "the shared connection is not closed while it is idle in the pool"); + ssh.closeAll(); + assert.equal(state.ends, 1); +}); + +test("a connection that died while pooled is replaced and the command runs once", async () => { + const { FakeClient, state } = fakeClientFactory({ + execBehaviour: (call, client) => (call === 2 && !client.reopened + ? { channelError: Object.assign(new Error("channel open failure"), { code: "ERR_CHANNEL" }) } + : { ok: true }), + }); + const ssh = service(makeStore()); + + await run(FakeClient, () => ssh.exec("unraid", "first")); + const result = await run(FakeClient, () => ssh.exec("unraid", "second")); + + assert.equal(result.stdout, "ok"); + assert.equal(state.connects, 2, "the stale connection is replaced"); + assert.equal(state.execs, 3, "the failed attempt never reached the server, so it is retried once"); + ssh.closeAll(); +}); + +test("a command that reached the server is never retried, not even on a reused connection", async () => { + let deployAttempts = 0; + const { FakeClient, state } = fakeClientFactory({ + execBehaviour: (call) => { + if (call === 1) return { ok: true }; + deployAttempts += 1; + return { exitCode: 1, stdout: "docker compose failed" }; + }, + }); + const ssh = service(makeStore()); + + // The first command establishes the pooled connection, so the deployment below + // runs on a reused one - the case where a retry would be tempting. + await run(FakeClient, () => ssh.exec("unraid", "true")); + await assert.rejects(() => run(FakeClient, () => ssh.exec("unraid", "docker compose up -d")), (error) => { + assert.equal(error.code, "SSH_COMMAND_FAILED"); + return true; + }); + + assert.equal(deployAttempts, 1, "a deployment command is never repeated by the pool"); + assert.equal(state.connects, 1); + ssh.closeAll(); +}); + +test("a connection lost while a command was running is not retried either", async () => { + let attempts = 0; + const { FakeClient, state } = fakeClientFactory({ + execBehaviour: (call) => { + if (call === 1) return { ok: true }; + attempts += 1; + // The stream opened, so the server may already be acting on this command. + return { exitCode: null, stdout: "" }; + }, + }); + const ssh = service(makeStore()); + + await run(FakeClient, () => ssh.exec("unraid", "true")); + await assert.rejects(() => run(FakeClient, () => ssh.exec("unraid", "docker compose up -d")), (error) => { + assert.equal(error.code, "SSH_COMMAND_FAILED"); + return true; + }); + + assert.equal(attempts, 1); + assert.equal(state.connects, 1); + ssh.closeAll(); +}); + +test("a first connection that cannot be established is reported without a retry", async () => { + class RefusingClient extends EventEmitter { + connect() { + setImmediate(() => this.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" }))); + } + end() {} + } + const ssh = service(makeStore()); + + await assert.rejects(() => run(RefusingClient, () => ssh.exec("unraid", "true")), /SSH connection failed/); + assert.equal(ssh.sessions.size, 0, "a failed connection is not pooled"); +}); + +test("a trust-on-first-use connection is never pooled or reused", async () => { + const { FakeClient, state } = fakeClientFactory(); + const ssh = service(makeStore({ hostFingerprint: "" })); + + await run(FakeClient, () => ssh.test("unraid", { trustOnFirstUse: true })); + await run(FakeClient, () => ssh.test("unraid", { trustOnFirstUse: true })); + + assert.equal(state.connects, 2, "an unverified connection is opened fresh every time"); + assert.equal(ssh.sessions.size, 0); + assert.equal(state.ends, 2, "and closed immediately afterwards"); +}); + +test("changing the server identity or credentials invalidates the pooled connection", async () => { + const { FakeClient, state } = fakeClientFactory(); + const store = makeStore(); + const ssh = service(store); + + await run(FakeClient, () => ssh.exec("unraid", "before")); + assert.equal(state.connects, 1); + + store.server.hostFingerprint = "SHA256:rotated"; + await run(FakeClient, () => ssh.exec("unraid", "after")); + assert.equal(state.connects, 2, "the previous connection is not reused across an identity change"); + ssh.closeAll(); +}); + +test("an idle connection is closed after its lifetime and reopened on demand", async () => { + const { FakeClient, state } = fakeClientFactory(); + const ssh = service(makeStore(), { idleConnectionMs: 40 }); + + await run(FakeClient, () => ssh.exec("unraid", "one")); + assert.equal(state.ends, 0); + + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.equal(state.ends, 1, "the idle connection is released"); + assert.equal(ssh.sessions.size, 0); + + await run(FakeClient, () => ssh.exec("unraid", "two")); + assert.equal(state.connects, 2); + ssh.closeAll(); +}); + +test("an error on an idle pooled connection is absorbed instead of terminating the process", async () => { + const { FakeClient, state } = fakeClientFactory(); + const ssh = service(makeStore()); + + await run(FakeClient, () => ssh.exec("unraid", "one")); + const pooled = state.instances.at(-1); + + pooled.emit("error", new Error("read ECONNRESET")); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(ssh.sessions.size, 0, "the dead connection leaves the pool"); + await run(FakeClient, () => ssh.exec("unraid", "two")); + assert.equal(state.connects, 2); + ssh.closeAll(); +}); diff --git a/tests/ssh-connection.test.mjs b/tests/ssh-connection.test.mjs new file mode 100644 index 0000000..d4cc084 --- /dev/null +++ b/tests/ssh-connection.test.mjs @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +// SshService resolves ssh2 lazily, so replacing the cached module is enough to +// drive a real connection lifecycle without a server. +const ssh2Path = require.resolve("ssh2"); +const realSsh2 = require("ssh2"); + +function withFakeSsh2(Client, run) { + require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: { ...realSsh2, Client } }; + try { + return run(); + } finally { + require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: realSsh2 }; + } +} + +const { SshService } = require("../src/main/ssh-service.cjs"); + +function store(server = {}) { + return { + getServer: () => ({ id: "unraid", host: "tower", port: 22, username: "root", authType: "password", basePath: "/mnt/user/appdata", hostFingerprint: "SHA256:trusted", ...server }), + getServerCredentials: () => ({ password: "secret", passphrase: "" }), + }; +} + +test("a connection that fails twice rejects once and never terminates the process", async () => { + class DoubleFailingClient extends EventEmitter { + connect() { + setImmediate(() => this.emit("error", Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }))); + } + end() { + // The socket resets shortly after teardown. An unhandled 'error' event on + // an EventEmitter takes the whole main process down. + setImmediate(() => this.emit("error", new Error("read ECONNRESET"))); + } + } + + const service = withFakeSsh2(DoubleFailingClient, () => new SshService({ store: store(), diagnostics: null })); + await assert.rejects( + () => withFakeSsh2(DoubleFailingClient, () => service.exec("unraid", "true")), + (error) => { + assert.equal(error.code, "ECONNREFUSED"); + assert.match(error.message, /SSH connection failed/); + return true; + }, + ); + + // Give the delayed teardown error time to land while the test is still running. + await new Promise((resolve) => setTimeout(resolve, 50)); +}); + +test("a host key that does not match the trusted fingerprint is reported as an identity change", async () => { + class MismatchingClient extends EventEmitter { + connect(options) { + options.hostVerifier(Buffer.from("a different host key")); + setImmediate(() => this.emit("error", new Error("handshake failed"))); + } + end() {} + } + + const service = withFakeSsh2(MismatchingClient, () => new SshService({ store: store(), diagnostics: null })); + await assert.rejects( + () => withFakeSsh2(MismatchingClient, () => service.exec("unraid", "true")), + (error) => { + assert.equal(error.code, "SSH_HOST_KEY_MISMATCH"); + assert.match(error.message, /SSH host identity changed/); + assert.equal(error.expectedFingerprint, "SHA256:trusted"); + assert.ok(error.observedFingerprint.startsWith("SHA256:")); + return true; + }, + ); +}); + +test("running a command requires a trusted host fingerprint", async () => { + const service = new SshService({ store: store({ hostFingerprint: "" }), diagnostics: null }); + await assert.rejects(() => service.exec("unraid", "true"), (error) => { + assert.equal(error.code, "SSH_HOST_NOT_TRUSTED"); + return true; + }); + await assert.rejects(() => service.uploadBuffer("unraid", "/mnt/user/appdata/x", "data"), (error) => { + assert.equal(error.code, "SSH_HOST_NOT_TRUSTED"); + return true; + }); +}); + +test("a remote upload path may not escape into an arbitrary location", () => { + const service = new SshService({ store: store(), diagnostics: null }); + assert.equal(service.ensureUploadTarget("/mnt/user/appdata/app/file.tar"), "/mnt/user/appdata/app/file.tar"); + assert.equal(service.ensureUploadTarget("\\mnt\\user\\appdata\\app"), "/mnt/user/appdata/app"); + for (const value of ["relative/path", "/mnt/../etc/passwd", "/mnt/user/../../etc", "", null]) { + assert.throws(() => service.ensureUploadTarget(value), /absolute safe Unix path/); + } +}); diff --git a/tests/ssh-service.test.mjs b/tests/ssh-service.test.mjs new file mode 100644 index 0000000..c6774f7 --- /dev/null +++ b/tests/ssh-service.test.mjs @@ -0,0 +1,157 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; +import { mkdtemp, writeFile, mkdir } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const { SshService, parseCapabilityOutput, shellQuote, fingerprintKey } = require("../src/main/ssh-service.cjs"); + +test("SSH capability parsing keeps Git optional and reports deployment prerequisites separately", () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + const parsed = parseCapabilityOutput(`noise\n__FORGEFLOW_SERVER_TEST__\nplatform=${b64("Linux Unraid")}\ndocker=true\ndockerReady=true\ncompose=true\ncomposeVersion=${b64("Docker Compose version v2.40.0")}\ngit=false\ntar=true\nchecksum=true\nbaseWritable=true\n`); + assert.equal(parsed.dockerReady, true); + assert.equal(parsed.compose, true); + assert.equal(parsed.git, false); + assert.equal(parsed.tar, true); + assert.equal(parsed.checksum, true); + assert.equal(parsed.baseWritable, true); +}); + +test("SSH execution rejects truncated output instead of using an incomplete inventory", async () => { + const service = new SshService({ store: {}, diagnostics: null }); + const stream = new EventEmitter(); + stream.stderr = new EventEmitter(); + const client = { + exec(_command, callback) { + callback(null, stream); + queueMicrotask(() => { + stream.emit("data", Buffer.from("x".repeat(64))); + stream.emit("close", 0, null); + }); + }, + }; + await assert.rejects( + service.execClient(client, "inventory", { maxOutput: 16, timeout: 1_000 }), + (error) => error?.code === "SSH_OUTPUT_TRUNCATED" && /incomplete result/.test(error.message), + ); +}); + +test("SSH helpers quote shell values, fingerprint keys and parse absent capability markers", () => { + assert.equal(shellQuote("it's safe"), "'it'\\''s safe'"); + assert.equal(fingerprintKey(Buffer.from('key')), fingerprintKey('key')); + assert.match(fingerprintKey('key'), /^SHA256:/); + assert.deepEqual(parseCapabilityOutput('plain server banner'), { + platform: 'plain server banner', docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false + }); +}); + +test("server validation handles password and missing or non-file private keys", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-ssh-')); + context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))); + const service = new SshService({ store: {}, diagnostics: null }); + assert.deepEqual(await service.validateServerConfiguration({ authType: 'password' }), { valid: true, method: 'password' }); + await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: '' }), /select a private key/i); + await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: path.join(root, 'missing') }), (error) => error.code === 'SSH_PRIVATE_KEY_NOT_FOUND'); + await mkdir(path.join(root, 'directory')); + await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: path.join(root, 'directory') }), (error) => error.code === 'SSH_PRIVATE_KEY_NOT_FOUND'); +}); + +test("connection options enforce host identity and support password credentials", async () => { + const key = Buffer.from('server-key'); + const fingerprint = fingerprintKey(key); + const store = { getServerCredentials: () => ({ password: 'secret' }) }; + const service = new SshService({ store, diagnostics: null }); + const trusted = await service.connectionOptions({ id: 'one', host: 'server', port: 2222, username: 'root', authType: 'password', hostFingerprint: fingerprint }); + assert.equal(trusted.options.password, 'secret'); + assert.equal(trusted.options.port, 2222); + assert.equal(trusted.options.hostVerifier(key), true); + assert.equal(trusted.getObservedFingerprint(), fingerprint); + assert.equal(trusted.options.hostVerifier(Buffer.from('changed')), false); + const firstUse = await service.connectionOptions({ id: 'one', host: 'server', username: 'root', authType: 'password' }, { trustOnFirstUse: true }); + assert.equal(firstUse.options.port, 22); + assert.equal(firstUse.options.hostVerifier(key), true); + const previewBound = await service.connectionOptions( + { id: 'one', host: 'server', username: 'root', authType: 'password' }, + { expectedFingerprint: fingerprint }, + ); + assert.equal(previewBound.options.hostVerifier(key), true); + assert.equal(previewBound.options.hostVerifier(Buffer.from('changed')), false); +}); + +test("SSH host fingerprint preview rejects the handshake before credentials are requested", async () => { + const key = Buffer.from("untrusted-server-key"); + let connectedOptions = null; + class ProbeClient extends EventEmitter { + connect(options) { + connectedOptions = options; + assert.equal(options.hostVerifier(key), false); + queueMicrotask(() => this.emit("error", Object.assign(new Error("host rejected"), { code: "HOST_VERIFIER_REJECTED" }))); + } + end() {} + } + const store = { + getServer: () => ({ id: "server", name: "Unraid", host: "192.0.2.10", port: 2222, username: "root", authType: "password" }), + getServerCredentials: () => { throw new Error("credentials must not be read during a fingerprint preview"); }, + }; + const service = new SshService({ store, diagnostics: null, clientFactory: () => ProbeClient }); + const result = await service.probeHostFingerprint("server"); + assert.equal(result.fingerprint, fingerprintKey(key)); + assert.deepEqual(result.server, { id: "server", name: "Unraid", host: "192.0.2.10", port: 2222 }); + assert.equal("password" in connectedOptions, false); + assert.equal("privateKey" in connectedOptions, false); +}); + +test("connection options report unreadable private keys without leaking credentials", async () => { + const service = new SshService({ store: { getServerCredentials: () => ({ passphrase: 'secret' }) }, diagnostics: null }); + await assert.rejects( + service.connectionOptions({ id: 'key', host: 'server', username: 'root', authType: 'privateKey', privateKeyPath: 'Z:/missing/key' }), + (error) => error.code === 'SSH_PRIVATE_KEY_READ_FAILED' && !error.message.includes('secret') + ); +}); + +test("SSH execution distinguishes startup errors, command failures, success and timeout", async () => { + const service = new SshService({ store: {}, diagnostics: null }); + const clientFor = (start) => ({ exec(_command, callback) { start(callback); } }); + await assert.rejects(service.execClient(clientFor((callback) => callback(new Error('exec unavailable'))), 'x'), /exec unavailable/); + + const commandClient = clientFor((callback) => { + const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream); + queueMicrotask(() => { stream.stderr.emit('data', Buffer.from('permission denied')); stream.emit('close', 23, 'TERM'); }); + }); + await assert.rejects(service.execClient(commandClient, 'x'), (error) => error.code === 'SSH_COMMAND_FAILED' && error.exitCode === 23 && error.signal === 'TERM'); + + const successClient = clientFor((callback) => { + const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream); + queueMicrotask(() => { stream.emit('data', Buffer.from('ok')); stream.stderr.emit('data', Buffer.from('warning')); stream.emit('close', 0, null); }); + }); + assert.deepEqual(await service.execClient(successClient, 'x'), { stdout: 'ok', stderr: 'warning', exitCode: 0, truncated: false }); + + const hangingClient = clientFor((callback) => { const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream); }); + await assert.rejects(service.execClient(hangingClient, 'x', { timeout: 5 }), /timed out/i); +}); + +test("upload and execution reject unsafe paths and untrusted hosts", async () => { + const service = new SshService({ store: { getServer: () => ({ id: 'one' }) }, diagnostics: null }); + assert.equal(service.ensureUploadTarget('\\srv\\apps\\file'), '/srv/apps/file'); + for (const target of ['', 'relative/file', '/srv/../secret', `/srv/${String.fromCharCode(0)}bad`]) { + assert.throws(() => service.ensureUploadTarget(target), /absolute safe Unix path/i); + } + await assert.rejects(service.withSftp('one', '/srv/file', () => {}), (error) => error.code === 'SSH_HOST_NOT_TRUSTED'); + await assert.rejects(service.exec('one', 'true'), (error) => error.code === 'SSH_HOST_NOT_TRUSTED'); +}); + +test("uploadFile rejects directories before connecting", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-upload-')); + context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))); + const service = new SshService({ store: {}, diagnostics: null }); + await assert.rejects(service.uploadFile('one', root, '/srv/file'), /not a file/i); + const file = path.join(root, 'file'); + await writeFile(file, 'content'); + service.withSftp = async (_id, remotePath, action) => action({ fastPut(_local, _target, options, callback) { options.step(7, 7, 7); callback(null); } }, remotePath); + let progress = null; + assert.deepEqual(await service.uploadFile('one', file, '/srv/file', { onProgress: (value) => { progress = value; } }), { remotePath: '/srv/file', size: 7 }); + assert.deepEqual(progress, { transferred: 7, total: 7 }); +}); diff --git a/tests/tool-invocation.test.mjs b/tests/tool-invocation.test.mjs new file mode 100644 index 0000000..1108036 --- /dev/null +++ b/tests/tool-invocation.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import toolInvocation from '../src/shared/tool-invocation.cjs'; +import processRunner from '../src/main/process-runner.cjs'; + +const { npmProbeCandidates } = toolInvocation; +const { run } = processRunner; + +test('uses npm CLI through Node when doctor is launched by npm on Windows', () => { + const candidates = npmProbeCandidates({ + platform: 'win32', + execPath: 'C:\\Program Files\\nodejs\\node.exe', + env: { + npm_execpath: 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', + npm_node_execpath: 'C:\\Program Files\\nodejs\\node.exe', + ComSpec: 'C:\\Windows\\System32\\cmd.exe' + } + }); + assert.deepEqual(candidates[0], { + file: 'C:\\Program Files\\nodejs\\node.exe', + args: ['C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', '--version'], + source: 'npm_execpath' + }); +}); + +test('falls back to cmd.exe for npm command shims on Windows', () => { + const candidates = npmProbeCandidates({ + platform: 'win32', + execPath: 'C:\\Program Files\\nodejs\\node.exe', + env: { ComSpec: 'C:\\Windows\\System32\\cmd.exe' } + }); + assert.deepEqual(candidates, [{ + file: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'npm --version'], + source: 'windows-command-shim' + }]); +}); + +test('uses npm directly on non-Windows systems', () => { + assert.deepEqual(npmProbeCandidates({ platform: 'linux', env: {}, execPath: '/usr/bin/node' }), [ + { file: 'npm', args: ['--version'], source: 'path' } + ]); +}); + + +test('process runner accepts stdin for Git pathspec transport', async () => { + const result = await run(process.execPath, ['-e', 'process.stdin.pipe(process.stdout)'], { input: 'a\0b\0' }); + assert.equal(result.stdout, 'a\0b\0'); +}); diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs new file mode 100644 index 0000000..bffd6eb --- /dev/null +++ b/tests/unraid-deployment.test.mjs @@ -0,0 +1,1541 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; +const require = createRequire(import.meta.url); +const { + UnraidDeploymentService, + safeRemoteFolder, + safeRelativeRemoteFile, + parseInspection, + dockerIgnoreHasPath, + checksSummary, + parseServerInventory, + inventoryContainerMatch, + remoteIdentity, + xmlEscape, + bash, +} = require("../src/main/unraid-deployment-service.cjs"); +const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs"); +const { buildWorkloadInventory, deploymentRootCandidate } = require("../src/main/server-inventory.cjs"); + +async function unraidSource() { + const files = ["unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs", "unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs"]; + return (await Promise.all(files.map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n"); +} + +test("Unraid remote paths cannot escape appdata project folder", () => { + assert.equal(safeRemoteFolder("lumaops"), "lumaops"); + assert.throws(() => safeRemoteFolder("../lumaops")); + assert.equal( + safeRelativeRemoteFile("deploy/docker-compose.yml"), + "deploy/docker-compose.yml", + ); + assert.throws(() => safeRelativeRemoteFile("../../etc/passwd")); +}); + +test("server release directories resolve to the stable deployment root", () => { + assert.equal(deploymentRootCandidate("infrabinder/releases/f8b0dd8"), "infrabinder"); + assert.equal(deploymentRootCandidate("portfolio/.forgeflow/releases/abc/source"), "portfolio"); + assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy"); +}); + +test("inventory refresh preserves a repository deployment root above its Compose working directory", () => { + const service = new UnraidDeploymentService({}); + const existing = { + id: "profile", provider: "ssh-unraid", serverId: "server", environment: "production", + branch: "main", deploymentMode: "server-git", remoteFolder: "App/source", + composeWorkingDir: "/mnt/user/appdata/App/source/ops", composeProject: "app", + composeFiles: ["ops/compose.yml"], composeServices: ["web"], containerName: "app-web", + workloadIdentity: { linkSource: "automatic-compose" }, preservePaths: [".env"], + }; + const workload = { + workloadId: "workload", kind: "compose-project", displayName: "app", + remoteFolderCandidate: "App/source/ops", selector: { kind: "compose-project", composeProject: "app" }, + compose: { + project: "app", workingDir: "/mnt/user/appdata/App/source/ops", + configFiles: ["/mnt/user/appdata/App/source/ops/compose.yml"], services: ["web"], + }, + containers: [{ name: "app-web", running: true, service: "web", mounts: [], ports: [] }], + metadata: {}, dockerMan: null, + }; + const refreshed = service.refreshedProfileFromWorkload( + { fullName: "Owner/App", name: "App", defaultBranch: "main", sshUrl: "git@gitea.test:Owner/App.git" }, + { id: "server", name: "Server", basePath: "/mnt/user/appdata" }, + workload, + existing, + ); + assert.equal(refreshed.remoteFolder, "App/source"); + assert.equal(refreshed.composeWorkingDir, "/mnt/user/appdata/App/source/ops"); + assert.deepEqual(refreshed.composeFiles, ["ops/compose.yml"]); +}); + +test("server inspection key-value payload is decoded safely", () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + const parsed = parseInspection( + `noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=${b64(" M docker-compose.yml\n")}\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`, + ); + assert.equal(parsed.rootGit, true); + assert.deepEqual(parsed.composeFiles, ["docker-compose.yml"]); + assert.deepEqual(parsed.nestedGit, ["source"]); + assert.equal(parsed.trackedChanges.length, 1); + assert.match(parsed.dockerignoreContent, /\.git/); + assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]); +}); + +test("server pull provisions a pinned repository-scoped key and records access metadata", async () => { + const publicKey = `ssh-ed25519 ${Buffer.from("server-public-key").toString("base64")} forgeflow:test`; + const profile = { + id: "profile-1", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio", + branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"], composeServices: ["portfolio"], + }; + let saved = null; + let deployKeyRequest = null; + let calls = 0; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }), + saveDeploymentProfile: async (_fullName, value) => { saved = value; return value; }, + }, + ssh: { exec: async () => { + calls += 1; + if (calls === 1) return { stdout: `__FORGEFLOW_DEPLOY_KEY__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }; + return { stdout: `${"a".repeat(40)}\trefs/heads/main\n` }; + } }, + gitea: { ensureReadOnlyDeployKey: async (request) => { deployKeyRequest = request; return { id: 17, created: true }; } }, + }); + const result = await service.configureServerGitAccess({ + repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.example.test:Jens/Portfolio.git" }, + profileId: profile.id, + }); + assert.equal(deployKeyRequest.owner, "Jens"); + assert.equal(deployKeyRequest.repo, "Portfolio"); + assert.equal(saved.deploymentMode, "server-git"); + assert.equal(saved.serverGitAccess.hostFingerprint, "SHA256:host"); + assert.equal(result.remoteSha, "a".repeat(40)); +}); + +test("server pull prefers the linked checkout origin over stale detected SSH endpoints", () => { + const service = new UnraidDeploymentService({}); + const repository = { + fullName: "Jens/Portfolio", + localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" }, + sshUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git", + preferredCloneUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git", + }; + const profile = { cloneUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git" }; + + assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git"); + assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 }); +}); + +test("write-access inspection parses the remote permission report through the access module", async () => { + const encoded = (value) => Buffer.from(value).toString("base64"); + const profile = { id: "profile", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", composeFiles: ["compose.yml"] }; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "server", basePath: "/mnt/user/appdata" }), + }, + ssh: { exec: async () => ({ stdout: `__FORGEFLOW_PERMISSIONS__\nI\t${encoded("deploy")}\t1000\t1000\t${encoded("users")}\tfalse\tfalse\nP\t${encoded("project-root")}\t${encoded("Project folder")}\t${encoded("/mnt/user/appdata/App")}\tdirectory\ttrue\ttrue\ttrue\ttrue\ttrue\ttrue\t${encoded("deploy")}\t${encoded("users")}\t2775\t${encoded("/mnt/user/appdata/App")}\t${encoded("Read/write probe passed.")}\n` }) }, + }); + const report = await service.inspectWriteAccess({ repository: { fullName: "Owner/App" }, profileId: "profile" }); + assert.equal(report.ready, true); + assert.equal(report.identity.user, "deploy"); + assert.equal(report.targets[0].effectiveWritable, true); +}); + +test("server pull verification proves a repository-scoped read-only key and exact commit parity", async () => { + const sha = "c".repeat(40); + const profile = { + id: "profile-verify", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio", + environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"], + serverGitAccess: { deployKeyId: 17, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" }, + }; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }), + getDeploymentState: () => ({ liveSha: sha, containerRunning: true, healthy: true }), + }, + ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) }, + gitea: { + getBranch: async () => ({ commit: { id: sha } }), + listDeployKeys: async () => [{ id: 17, read_only: true }], + }, + }); + service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: sha }); + const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id }); + assert.equal(report.readiness, "Ready"); + assert.equal(report.ready, true); + assert.equal(report.checks.find((check) => check.id === "deploy-key-scope").status, "pass"); +}); + +test("server pull remains deploy-ready when only live runtime evidence is incomplete", async () => { + const sha = "c".repeat(40); + const profile = { + id: "profile-runtime-incomplete", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio", + environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"], + serverGitAccess: { deployKeyId: 17, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" }, + }; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }), + getDeploymentState: () => ({ containerRunning: true, healthy: null }), + }, + ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) }, + gitea: { + getBranch: async () => ({ commit: { id: sha } }), + listDeployKeys: async () => [{ id: 17, read_only: true }], + }, + }); + service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: null }); + const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id }); + assert.equal(report.deployReady, true); + assert.equal(report.ready, true); + assert.equal(report.readiness, "Deploy-ready; runtime verification incomplete"); + assert.deepEqual(report.deploymentBlockers, []); +}); + +test("server pull verification blocks a writable Gitea deploy key", async () => { + const sha = "d".repeat(40); + const profile = { + id: "profile-writable", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio", + environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"], + serverGitAccess: { deployKeyId: 18, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" }, + }; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }), + getDeploymentState: () => ({ liveSha: sha, containerRunning: true, healthy: true }), + }, + ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) }, + gitea: { getBranch: async () => ({ commit: { id: sha } }), listDeployKeys: async () => [{ id: 18, read_only: false }] }, + }); + service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: sha }); + const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id }); + assert.equal(report.readiness, "Access failed"); + assert.equal(report.ready, false); +}); + +test("server workload inventory links running containers to exact Gitea checkouts", () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + const inspect = JSON.stringify([ + { + Name: "/Portfolio", + State: { Running: true, Health: { Status: "healthy" } }, + Config: { + Labels: { + "com.docker.compose.project.working_dir": + "/mnt/user/appdata/Portfolio", + }, + }, + Mounts: [], + }, + ]); + const inventory = parseServerInventory( + `noise\n__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${"a".repeat(40)}\t${b64("main")}\nC\t${b64(inspect)}\n`, + ); + assert.equal(inventory.checkouts.length, 1); + assert.equal(inventory.containers.length, 1); + assert.equal( + remoteIdentity("git@gitea.itworx.tech:Jens/Portfolio.git"), + remoteIdentity("https://gitea.itworx.tech/Jens/Portfolio"), + ); + assert.equal( + inventoryContainerMatch( + inventory.checkouts[0], + { name: "Portfolio" }, + inventory.containers[0], + ), + 100, + ); +}); + +test("low-level inventory scan is read-only and user discovery auto-links exact provenance", async () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + const sha = "b".repeat(40); + const container = { + Name: "/Portfolio", + State: { Running: true, Health: { Status: "healthy" } }, + Config: { + Labels: { + "com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio", + "com.docker.compose.service": "portfolio", + }, + }, + Mounts: [], + NetworkSettings: { Ports: { "3000/tcp": [{ HostPort: "8080" }] } }, + }; + const profiles = []; + const states = new Map(); + const service = new UnraidDeploymentService({ + store: { + getServer: () => ({ + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }), + getDeploymentProfiles: () => profiles, + saveDeploymentProfile: async (_fullName, profile) => { + profiles.push(profile); + return profile; + }, + saveDeploymentState: async (id, state) => { + states.set(id, state); + return state; + }, + }, + ssh: { + exec: async () => ({ + stdout: `__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${sha}\t${b64("main")}\nC\t${b64(JSON.stringify([container]))}\n`, + }), + }, + gitea: { getBranch: async () => ({ commit: { id: sha } }) }, + }); + const repositories = [ + { + fullName: "Jens/Portfolio", + name: "Portfolio", + defaultBranch: "main", + cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git", + sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git", + }, + ]; + const readOnlyDiscovery = await service.scanServerInventory("unraid", repositories); + assert.equal(readOnlyDiscovery.adopted, 0); + assert.equal(readOnlyDiscovery.verified, 0); + assert.equal(profiles.length, 0); + assert.equal(states.size, 0); + + const discovery = await service.discoverServerWorkloads("unraid", repositories); + assert.equal(discovery.adopted, 1); + assert.equal(discovery.verified, 1); + assert.equal(profiles[0].containerName, "Portfolio"); + assert.equal(profiles[0].adoptedFromServer, true); + assert.equal(states.get(profiles[0].id).matchesGitea, true); + assert.deepEqual(discovery.refreshedProfileIds, [profiles[0].id]); +}); + +test("a stale deployment link cannot block adoption of its running replacement", async () => { + const repository = { + fullName: "Jens/DevRunBook", + name: "DevRunBook", + defaultBranch: "main", + sshUrl: "git@gitea.test:Jens/DevRunBook.git", + }; + const stale = { + workloadId: "old-devrunbook", + status: "stale", + classification: { type: "stale-link" }, + runtime: { running: false, health: "missing" }, + link: { profileId: "old-profile", repositoryFullName: repository.fullName }, + candidates: [{ repositoryFullName: repository.fullName, score: 100, exact: true }], + }; + const replacement = { + workloadId: "devrunbook-runtime", + serverId: "unraid", + displayName: "DevRunBook", + status: "suggested", + classification: { type: "active-application" }, + runtime: { running: true, health: "healthy" }, + link: null, + candidates: [{ + repositoryFullName: repository.fullName, + score: 85, + exact: false, + identityExact: true, + }], + compose: { + project: "devrunbook", + workingDir: "/mnt/user/appdata/DevRunBook", + configFiles: ["/mnt/user/appdata/DevRunBook/compose.yml"], + services: ["app"], + }, + containers: [{ name: "DevRunBook", running: true, mounts: [], ports: [] }], + metadata: { branch: "main" }, + remoteFolderCandidate: "DevRunBook", + }; + const saved = []; + const service = new UnraidDeploymentService({ + store: { + data: { + deploymentProfiles: { + [repository.fullName]: [{ + id: "old-profile", + provider: "ssh-unraid", + serverId: "unraid", + workloadIdentity: { workloadId: stale.workloadId, linkSource: "automatic" }, + }], + }, + }, + createRecoverySnapshot: async () => ({}), + saveDeploymentProfile: async (_fullName, profile) => { + saved.push(profile); + return profile; + }, + saveDeploymentState: async () => ({}), + }, + }); + service.collectServerInventory = async () => ({ + server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }, + inventory: { capabilities: {}, warnings: [] }, + workloads: [stale, replacement], + }); + + const plan = service.reconciliationPlan( + { id: "unraid" }, + [stale, replacement], + [repository], + { autoLink: true }, + ); + assert.deepEqual(plan.additions.map((item) => item.workloadId), [replacement.workloadId]); + + const result = await service.scanServerInventory("unraid", [repository], { autoLink: true }); + assert.equal(result.adopted, 1); + assert.equal(replacement.link.repositoryFullName, repository.fullName); + assert.equal(saved.length, 1); +}); + + + +test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => { + const workloads = buildWorkloadInventory({ + inventory: { + checkouts: [], + dockerMan: [ + { + name: "omniroute", + templatePath: "/boot/config/plugins/dockerMan/templates-user/my-omniroute.xml", + webUiUrl: "http://[IP]:[PORT:20128]/", + iconUrl: "", + shell: "sh", + repository: "diegosouzapw/omniroute:latest", + network: "bridge", + }, + ], + containers: [ + { + id: "container-1", + name: "omniroute", + image: "ghcr.io/diegosouzapw/omniroute:latest", + imageId: "sha256:image", + running: false, + status: "exited", + health: null, + labels: {}, + ports: { "3000/tcp": [{ HostPort: "20128", HostIp: "0.0.0.0" }] }, + mounts: [ + { + Type: "bind", + Source: "/mnt/user/appdata/OmniRoute/config", + Destination: "/app/config", + RW: true, + }, + ], + networks: { bridge: {} }, + restartPolicy: "unless-stopped", + }, + ], + warnings: [], + capabilities: { docker: true, compose: true }, + }, + server: { + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }, + repositories: [ + { + fullName: "Jens/OmniRoute", + name: "OmniRoute", + cloneUrl: "https://gitea.itworx.tech/Jens/OmniRoute.git", + }, + ], + profiles: [], + }); + assert.equal(workloads.length, 1); + assert.equal(workloads[0].runtime.running, false); + assert.equal(workloads[0].kind, "dockerman-container"); + assert.equal(workloads[0].remoteFolderCandidate, "OmniRoute"); + assert.equal(workloads[0].status, "suggested"); + assert.equal(workloads[0].candidates[0].exact, false); + assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i); +}); + +test("server inventory batches Docker inspect and retains a disappearing-container fallback", async () => { + const source = await unraidSource(); + assert.match(source, /docker inspect "\\\$\{container_ids\[@\]\}"/); + assert.match(source, /for container_id in "\\\$\{container_ids\[@\]\}"/); +}); + +test("server inventory avoids a second Compose process for static image definitions", async () => { + const source = await unraidSource(); + assert.match(source, /has_override=false/); + assert.match(source, /\[ -z "\$images" \].*config --images/); +}); + +test("server inventory groups multi-service Compose projects and preserves their identity", () => { + const baseContainer = { + image: "example/app:latest", + imageId: "sha256:image", + running: true, + status: "running", + health: null, + ports: {}, + mounts: [], + networks: { appnet: {} }, + restartPolicy: "unless-stopped", + }; + const labels = { + "com.docker.compose.project": "forgeflow", + "com.docker.compose.project.working_dir": "/mnt/user/appdata/ForgeFlow", + "com.docker.compose.project.config_files": "/mnt/user/appdata/ForgeFlow/compose.yml,/mnt/user/appdata/ForgeFlow/compose.prod.yml", + }; + const workloads = buildWorkloadInventory({ + inventory: { + checkouts: [], + dockerMan: [], + warnings: [], + capabilities: {}, + containers: [ + { + ...baseContainer, + id: "web", + name: "forgeflow-web-1", + labels: { ...labels, "com.docker.compose.service": "web" }, + }, + { + ...baseContainer, + id: "worker", + name: "forgeflow-worker-1", + labels: { ...labels, "com.docker.compose.service": "worker" }, + }, + ], + }, + server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }, + repositories: [], + profiles: [], + }); + assert.equal(workloads.length, 1); + assert.deepEqual(workloads[0].compose.services.sort(), ["web", "worker"]); + assert.deepEqual(workloads[0].compose.configFiles, [ + "/mnt/user/appdata/ForgeFlow/compose.yml", + "/mnt/user/appdata/ForgeFlow/compose.prod.yml", + ]); + assert.equal(workloads[0].compose.project, "forgeflow"); + assert.equal(workloads[0].remoteFolderCandidate, "ForgeFlow"); +}); + + + +test("manual workload linking does not claim Gitea parity for unrelated provenance", async () => { + let savedState = null; + const service = new UnraidDeploymentService({ + store: { + saveDeploymentState: async (_id, state) => { + savedState = state; + return state; + }, + }, + ssh: {}, + git: {}, + diagnostics: null, + }); + await service.saveWorkloadState( + { + id: "profile", + containerName: "app", + remoteFolder: "app", + cloneUrl: "https://gitea.itworx.tech/Jens/Expected.git", + }, + { + workloadId: "workload", + observedAt: new Date().toISOString(), + metadata: { + sourceRepository: "https://gitea.itworx.tech/Jens/Other.git", + liveRevision: "a".repeat(40), + }, + runtime: { running: true, health: "healthy" }, + containers: [{ name: "app", running: true, health: "healthy" }], + compose: { project: "app" }, + }, + { basePath: "/mnt/user/appdata" }, + ); + assert.equal(savedState.liveSha, "a".repeat(40)); + assert.equal(savedState.matchesGitea, false); + assert.equal(savedState.giteaSha, null); +}); + +test("inventory provenance alone never claims Gitea commit parity", async () => { + let savedState = null; + const service = new UnraidDeploymentService({ + store: { getDeploymentState: () => ({}), saveDeploymentState: async (_id, state) => { savedState = state; return state; } }, + }); + await service.saveWorkloadState( + { id: "profile", remoteFolder: "app", cloneUrl: "git@gitea.test:Owner/App.git" }, + { + workloadId: "workload", observedAt: new Date().toISOString(), + metadata: { sourceRepository: "git@gitea.test:Owner/App.git", liveRevision: "a".repeat(40) }, + runtime: { running: true, health: "healthy" }, containers: [{ name: "app", running: true, health: "healthy" }], compose: {}, + }, + { basePath: "/mnt/user/appdata" }, + ); + assert.equal(savedState.matchesGitea, false); + assert.equal(savedState.giteaSha, null); +}); + +test("a stopped workload cannot become healthy through a reused healthcheck port", async () => { + let savedState = null; + const service = new UnraidDeploymentService({ + store: { getDeploymentState: () => ({}), saveDeploymentState: async (_id, state) => { savedState = state; return state; } }, + }); + await service.saveWorkloadState( + { id: "profile", remoteFolder: "app", healthcheckUrl: "http://server.test/health" }, + { workloadId: "workload", metadata: {}, runtime: { running: false, health: "unverified" }, containers: [{ name: "app", running: false }], compose: {} }, + { basePath: "/mnt/user/appdata" }, + { health: { configured: true, healthy: true, status: 200 } }, + ); + assert.equal(savedState.containerRunning, false); + assert.equal(savedState.healthy, false); + assert.equal(savedState.runtimeVerification, "stopped"); +}); + +test("Docker ignore checks identify exact runtime and Git context exclusions", () => { + const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n"; + assert.equal(dockerIgnoreHasPath(rules, ".git"), true); + assert.equal(dockerIgnoreHasPath(rules, "data"), true); + assert.equal(dockerIgnoreHasPath(rules, "logs"), true); + assert.equal(dockerIgnoreHasPath(rules, "source"), false); +}); + +test("server inspection detects preserved runtime paths and missing Docker context exclusions", async () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + let receivedCommand = ""; + const store = { + getDeploymentProfile: () => ({ + id: "production", + provider: "ssh-unraid", + serverId: "unraid", + remoteFolder: "lumaops", + preservePaths: ["data", "logs"], + }), + getServer: () => ({ + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }), + }; + const ssh = { + exec: async (_serverId, command) => { + receivedCommand = command; + return { + stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`, + stderr: "", + exitCode: 0, + }; + }, + }; + const service = new UnraidDeploymentService({ + store, + ssh, + git: {}, + diagnostics: null, + }); + const inspection = await service.inspect({ + repository: { fullName: "Jens/LumaOps", name: "LumaOps" }, + profileId: "production", + }); + + assert.match(receivedCommand, /base64 -d \| bash$/); + assert.equal(inspection.remotePath, "/mnt/user/appdata/lumaops"); + assert.equal(inspection.dockerignoreGitExcluded, true); + assert.deepEqual(inspection.existingPreservePaths.sort(), ["data", "logs"]); + assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), [ + "logs", + "source", + ]); +}); + +test("preflight summary blocks only failed checks", () => { + const result = checksSummary([ + { id: "a", status: "pass" }, + { id: "b", status: "warning" }, + { id: "c", status: "fail" }, + ]); + assert.equal(result.ready, false); + assert.deepEqual(result.blocking, ["c"]); +}); + +test("SSH helpers produce pinned fingerprints and quoted commands", () => { + assert.match(fingerprintKey(Buffer.from("host-key")), /^SHA256:/); + assert.equal(shellQuote("a'b"), "'a'\\''b'"); + const wrapped = bash("git fetch origin main"); + assert.match(wrapped, /base64 -d \| bash$/); + assert.equal(wrapped.includes("\n"), false); + const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + assert.match(decoded, /GIT_TERMINAL_PROMPT=0/); + assert.match(decoded, /BatchMode=yes/); + assert.match(decoded, /forgeflow_compose\(\)/); + assert.match(decoded, /docker compose "\$@"/); + assert.match(decoded, /docker-compose "\$@"/); + assert.match(decoded, /git fetch origin main/); +}); + +test("SSH rollback refuses any SHA other than the exact recorded previous deployment", async () => { + const previousSha = "a".repeat(40); + const store = { + getDeploymentProfile: () => ({ + id: "production", + provider: "ssh-unraid", + serverId: "unraid", + remoteFolder: "lumaops", + composeFile: "docker-compose.yml", + branch: "main", + environment: "production", + }), + getServer: () => ({ id: "unraid", basePath: "/mnt/user/appdata" }), + getDeploymentState: () => ({ liveSha: "b".repeat(40), previousSha }), + }; + const service = new UnraidDeploymentService({ + store, + ssh: {}, + git: {}, + diagnostics: null, + }); + await assert.rejects( + service.rollback({ + repository: { + fullName: "Jens/LumaOps", + name: "LumaOps", + localPath: "/tmp/lumaops", + }, + profileId: "production", + targetSha: "c".repeat(40), + }), + (error) => error.code === "ROLLBACK_TARGET_NOT_PREVIOUS_SHA", + ); +}); + +test("successful SSH rollback records the formerly live SHA as the new rollback target", async () => { + const previousSha = "a".repeat(40); + const liveSha = "b".repeat(40); + const savedStates = []; + const operations = []; + const store = { + getDeploymentProfile: () => ({ + id: "production", + provider: "ssh-unraid", + serverId: "unraid", + remoteFolder: "lumaops", + composeFile: "docker-compose.yml", + branch: "main", + environment: "production", + healthcheckUrl: "", + iconMode: "none", + }), + getServer: () => ({ + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }), + getDeploymentState: () => ({ liveSha, previousSha }), + addOperation: async (operation) => { + operations.push(operation); + return operation; + }, + saveDeploymentState: async (_profileId, state) => { + savedStates.push(state); + return state; + }, + }; + const git = { verifyCommitOnRemoteBranch: async () => true }; + const ssh = { exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }) }; + const service = new UnraidDeploymentService({ + store, + ssh, + git, + diagnostics: null, + }); + service.inspect = async () => ({ + rootGit: true, + trackedChanges: [], + head: liveSha, + }); + service.checkHealth = async () => ({ + configured: false, + healthy: null, + status: null, + latencyMs: null, + }); + service.executePushBundle = async () => ({ stdout: "rollback activated", stderr: "", exitCode: 0 }); + const result = await service.rollback({ + repository: { + fullName: "Jens/LumaOps", + name: "LumaOps", + localPath: "/tmp/lumaops", + }, + profileId: "production", + targetSha: previousSha, + }); + assert.equal(result.status, "rolled-back"); + assert.equal(savedStates.at(-1).liveSha, previousSha); + assert.equal(savedStates.at(-1).previousSha, liveSha); + assert.equal(operations.at(-1).previousSha, liveSha); +}); + +test("generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name", () => { + const service = new UnraidDeploymentService({ + store: {}, + ssh: {}, + git: {}, + diagnostics: null, + }); + const compose = service.generatedCompose( + { composeService: "Portfolio", hostPort: 5150, containerPort: 80 }, + { name: "Portfolio" }, + ); + assert.match(compose, / portfolio:/); + assert.match(compose, /image: forgeflow\/portfolio:production/); + assert.match(compose, /container_name: Portfolio/); +}); + +test("SSH deployment dispatch returns a running operation while the remote build continues in background", async () => { + const sha = "d".repeat(40); + const operations = []; + let resolveRemote; + const store = { + getDeploymentProfile: () => ({ + id: "production", + provider: "ssh-unraid", + serverId: "unraid", + remoteFolder: "Portfolio", + cloneUrl: "forgeflow-gitea:Jens/Portfolio.git", + composeFile: "docker-compose.yml", + branch: "main", + environment: "production", + healthcheckUrl: "", + iconMode: "none", + }), + getServer: () => ({ + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }), + addOperation: async (operation) => { + operations.push(structuredClone(operation)); + return structuredClone(operation); + }, + saveDeploymentState: async () => ({}), + }; + const ssh = { + exec: async () => + new Promise((resolve) => { + resolveRemote = resolve; + }), + }; + const service = new UnraidDeploymentService({ + store, + ssh, + git: {}, + diagnostics: null, + }); + service.preflight = async () => ({ + summary: { ready: true, blocking: [] }, + inspection: { head: null }, + }); + service.checkHealth = async () => ({ + configured: false, + healthy: null, + status: null, + latencyMs: null, + }); + service.executePushBundle = async () => new Promise((resolve) => { resolveRemote = resolve; }); + + const operation = await service.deploy({ + repository: { fullName: "Jens/Portfolio", name: "Portfolio" }, + profileId: "production", + sha, + }); + assert.equal(operation.status, "running"); + assert.match(operation.logs.join("\n"), /background/i); + + resolveRemote({ + stdout: "Container Portfolio started\n", + stderr: "", + exitCode: 0, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(operations.at(-1).status, "success"); +}); + +test("Unraid preflight verifies repository access before a deployment can start", async () => { + const source = await unraidSource(); + assert.match(source, /server-git-access/); + assert.match(source, /git ls-remote --exit-code/); + assert.match(source, /Unraid .* Gitea read access/); +}); + +test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => { + const service = new UnraidDeploymentService({ + store: {}, + ssh: {}, + git: {}, + diagnostics: null, + }); + const metadata = service.metadataCompose( + { + composeService: "portfolio", + containerName: "Portfolio", + remoteFolder: "Portfolio", + environment: "production", + hostPort: 5150, + webUiUrl: "http://192.168.56.10:5150/admin", + dockerShell: "/bin/sh", + }, + { name: "Portfolio" }, + "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", + ); + assert.match(metadata, / portfolio:/); + assert.doesNotMatch(metadata, /image: forgeflow\/portfolio:production/); + assert.doesNotMatch(metadata, /container_name: Portfolio/); + assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/); + assert.match( + metadata, + /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/, + ); + assert.match( + metadata, + /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/, + ); +}); + +test("DockerMan integration writes a persistent template fallback and invalidates cached metadata", () => { + const service = new UnraidDeploymentService({ + store: {}, + ssh: {}, + git: {}, + diagnostics: null, + }); + const profile = { + composeService: "portfolio", + containerName: "Portfolio", + remoteFolder: "Portfolio", + environment: "production", + hostPort: 5150, + webUiUrl: "http://192.168.56.10:5150/", + dockerShell: "/bin/sh", + manageDockerMan: true, + generatedCompose: true, + }; + const repository = { name: "Portfolio" }; + const icon = + "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png"; + const template = service.dockerManTemplate(profile, repository, icon); + const refresh = service.dockerManRefreshScript(profile, repository, icon); + assert.match(template, /Portfolio<\/Name>/); + assert.match( + template, + /forgeflow\/portfolio:production<\/Repository>/, + ); + assert.match(template, /http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/); + assert.match( + template, + /file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/, + ); + assert.match(refresh, /templates-user\/my-Portfolio\.xml/); + assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/); + assert.match( + refresh, + /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/, + ); + assert.doesNotMatch(refresh, /dockerManRefreshScript/); + assert.equal(xmlEscape('A&B<"x">'), "A&B<"x">"); +}); + + + +test("adopted DockerMan templates are never rewritten", () => { + const service = new UnraidDeploymentService({ + store: {}, + ssh: {}, + git: {}, + diagnostics: null, + }); + const refresh = service.dockerManRefreshScript( + { + composeService: "omniroute", + containerName: "omniroute", + remoteFolder: "OmniRoute", + environment: "production", + manageDockerMan: true, + generatedCompose: false, + adoptedFromServer: true, + }, + { name: "OmniRoute" }, + "", + ); + assert.match(refresh, /left the existing DockerMan template unchanged/); + assert.doesNotMatch(refresh, /templates-user/); +}); + +test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => { + const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); + const os = await import("node:os"); + const path = await import("node:path"); + const sourcePath = await mkdtemp(path.join(os.tmpdir(), "forgeflow-icon-")); + t.after(() => rm(sourcePath, { recursive: true, force: true })); + const asset = path.join( + sourcePath, + "src", + "renderer", + "assets", + "itworx-mark.png", + ); + await mkdir(path.dirname(asset), { recursive: true }); + await writeFile(asset, Buffer.from([137, 80, 78, 71])); + const uploads = []; + const service = new UnraidDeploymentService({ + store: {}, + git: {}, + diagnostics: null, + sourcePath, + ssh: { + uploadFile: async (...args) => { + uploads.push(args); + return {}; + }, + }, + }); + const icon = await service.prepareIcon( + { iconMode: "builtin", remoteFolder: "Portfolio" }, + { name: "Portfolio" }, + { id: "unraid" }, + ); + assert.equal( + icon, + "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", + ); + assert.equal(uploads.length, 1); + assert.equal(uploads[0][0], "unraid"); + assert.equal(uploads[0][1], asset); + assert.equal( + uploads[0][2], + "/boot/config/plugins/dockerMan/images/Portfolio-icon.png", + ); +}); + +test("stuck SSH deployment is reconciled to success when exact SHA and container health are live", async () => { + const sha = "f".repeat(40); + const saved = []; + const operation = { + id: "op-1", + type: "deployment", + provider: "ssh-unraid", + action: "deploy", + repository: "Jens/Portfolio", + profileId: "production", + sha, + status: "running", + logs: [], + }; + const store = { + getOperation: () => operation, + addOperation: async (next) => { + saved.push(next); + return next; + }, + }; + const service = new UnraidDeploymentService({ + store, + ssh: {}, + git: {}, + diagnostics: null, + }); + service.refreshProfileState = async () => ({ + liveSha: sha, + containerRunning: true, + healthy: true, + }); + const result = await service.refreshOperation("op-1"); + assert.equal(result.status, "success"); + assert.match(result.logs.at(-1), /reconciled/i); + assert.equal(saved.at(-1).status, "success"); +}); + +test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => { + const source = await unraidSource(); + assert.match(source, /\/var\/lib\/docker\/unraid\/images/); + assert.match(source, /dynamix\.docker\.manager\/images/); + assert.match(source, /-icon\.png/); + assert.match(source, /cp \${shellQuote\(localIconPath\)}/); + assert.match(source, /never adds destructive recreation or orphan-removal flags/); +}); + +test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => { + const requested = "a".repeat(40); + const live = "b".repeat(40); + const operation = { + id: "op-superseded", + type: "deployment", + provider: "ssh-unraid", + action: "deploy", + repository: "Jens/Portfolio", + profileId: "production", + sha: requested, + status: "running", + logs: [], + }; + const saved = []; + const service = new UnraidDeploymentService({ + store: { + getOperation: () => operation, + addOperation: async (next) => { + saved.push(next); + return next; + }, + }, + ssh: {}, + git: {}, + diagnostics: null, + }); + service.refreshProfileState = async () => ({ + liveSha: live, + containerRunning: true, + healthy: true, + }); + const result = await service.refreshOperation(operation.id); + assert.equal(result.status, "cancelled"); + assert.match(result.error, /Superseded/); + assert.equal(saved.at(-1).status, "cancelled"); +}); + +test("existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth", () => { + const { + deriveDetectedProfile, + } = require("../src/main/unraid-deployment-service.cjs"); + const result = deriveDetectedProfile({ + repository: { + name: "blockpilot-autonomous", + defaultBranch: "main", + sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git", + }, + server: { id: "unraid", host: "192.168.56.10" }, + remoteFolder: "blockpilot-autonomous", + remotePath: "/mnt/user/appdata/blockpilot-autonomous", + payload: { + head: "a".repeat(40), + branch: "main", + remote: "ssh://git@gitea/Jens/blockpilot-autonomous.git", + composeFiles: ["compose.yml"], + compose: { services: { app: { image: "blockpilot:test" } } }, + containers: [ + { + Name: "/blockpilot", + State: { Running: true }, + Config: { + Image: "blockpilot:test", + Env: ["TOKEN=secret", "MODE=prod"], + Labels: { + "com.docker.compose.service": "app", + "com.docker.compose.project": "blockpilot", + }, + }, + HostConfig: { RestartPolicy: { Name: "unless-stopped" } }, + NetworkSettings: { + Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "1223" }] }, + Networks: { bridge: {} }, + }, + Mounts: [ + { + Type: "bind", + Source: "/mnt/user/appdata/blockpilot-autonomous/data", + Destination: "/data", + RW: true, + }, + ], + }, + ], + dockerManXml: + "blockpilothttp://[IP]:[PORT:1223]/https://example.test/icon.png/bin/bash", + }, + }); + assert.equal(result.profile.hostPort, 1223); + assert.equal(result.profile.containerPort, 8080); + assert.equal(result.profile.containerName, "blockpilot"); + assert.equal(result.profile.composeService, "app"); + assert.equal(result.profile.webUiUrl, "http://[IP]:[PORT:1223]/"); + assert.equal(result.profile.iconUrl, "https://example.test/icon.png"); + assert.equal(result.profile.dockerShell, "/bin/bash"); + assert.deepEqual(result.profile.detectedMetadata.envNames, ["TOKEN", "MODE"]); + assert.ok(result.profile.preservePaths.includes("data")); + assert.equal(result.provenance.hostPort.origin, "docker-inspect"); +}); + +test("a previously failed deployment is corrected when its exact commit is healthy on Unraid", async () => { + const sha = "e".repeat(40); + const failed = { + id: "failed-1", + type: "deployment", + provider: "ssh-unraid", + action: "deploy", + profileId: "production", + sha, + status: "failed", + logs: [], + }; + const operations = [failed]; + const service = new UnraidDeploymentService({ + store: { + data: { operations }, + getOperation: (id) => operations.find((item) => item.id === id), + addOperation: async (next) => { + operations.splice( + operations.findIndex((item) => item.id === next.id), + 1, + next, + ); + return next; + }, + }, + ssh: {}, + git: {}, + diagnostics: null, + }); + await service.reconcileRecordedOperations("production", { + liveSha: sha, + matchesGitea: true, + containerRunning: true, + healthy: true, + }); + assert.equal(operations[0].status, "success"); + assert.match(operations[0].logs.at(-1), /reconciled/i); +}); + +test("a failed deployment is marked superseded when Gitea and Unraid agree on a newer commit", async () => { + const liveSha = "d".repeat(40); + const operations = [ + { + id: "failed-2", + type: "deployment", + provider: "ssh-unraid", + profileId: "production", + sha: "c".repeat(40), + status: "failed", + logs: [], + }, + ]; + const service = new UnraidDeploymentService({ + store: { + data: { operations }, + getOperation: (id) => operations.find((item) => item.id === id), + addOperation: async (next) => { + operations.splice( + operations.findIndex((item) => item.id === next.id), + 1, + next, + ); + return next; + }, + }, + ssh: {}, + git: {}, + diagnostics: null, + }); + await service.reconcileRecordedOperations("production", { + liveSha, + matchesGitea: true, + containerRunning: true, + healthy: true, + }); + assert.equal(operations[0].status, "cancelled"); + assert.match(operations[0].error, /Superseded/); +}); + +test("linked Compose deployments retain the existing project, files and service set", () => { + const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); + const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" }; + const profile = { + composeProject: "omniroute-production", + composeFiles: ["compose.yml", "compose.unraid.yml"], + composeServices: ["api", "worker"], + generatedCompose: false, + }; + const invocation = service.composeInvocation(profile, repository); + assert.match(invocation, /-p 'omniroute-production'/); + assert.ok(invocation.indexOf("-f 'compose.yml'") < invocation.indexOf("-f 'compose.unraid.yml'")); + assert.doesNotMatch(invocation, /compose\.metadata\.yml/); + assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]); +}); + +test("inventory scan uses only configured roots and reports partial find failures", () => { + const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); + const script = service.inventoryScript({ + basePath: "/mnt/user/appdata", + scanRoots: ["/mnt/user/appdata", "/mnt/cache/custom apps"], + scanExcludes: ["archive-*", "scratch"], + }); + assert.match(script, /add_scan_root '\/mnt\/user\/appdata'/); + assert.match(script, /add_scan_root '\/mnt\/cache\/custom apps'/); + assert.match(script, /-name 'archive-\*'/); + assert.match(script, /-name 'scratch'/); + assert.match(script, /Inventory scan partially failed/); + assert.match(script, /2>"\$scan_error" \|\| true/); + assert.match(script, /docker inspect "\$container_id"/); + assert.doesNotMatch(script, /docker inspect --format/); + assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/); +}); + +test("push bundle activation validates Compose and services before promoting current SHA", () => { + const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); + const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" }; + const profile = { + environment: "production", + deploymentMode: "push-bundle", + composeProject: "omniroute", + composeFiles: ["compose.yml", "compose.unraid.yml"], + composeServices: ["api", "worker"], + preservePaths: ["data", "config"], + generatedCompose: false, + adoptedFromServer: true, + manageDockerMan: false, + }; + const script = service.pushBundleScript({ + repository, + profile, + remotePath: "/mnt/user/appdata/OmniRoute", + targetSha: "a".repeat(40), + requestId: "request-1", + remotePart: "/mnt/user/appdata/.forgeflow/incoming/request-1.tar.part", + digest: "b".repeat(64), + metadata: "services:\n api:\n labels: {}\n worker:\n labels: {}\n", + generated: "", + iconReference: "", + }); + const configIndex = script.indexOf("config >/dev/null"); + const buildIndex = script.indexOf("build"); + const upIndex = script.indexOf("up -d --no-build", buildIndex); + const serviceCheckIndex = script.indexOf("Compose service $service did not create a container"); + const promoteIndex = script.indexOf('current-sha.pending'); + assert.ok(configIndex >= 0 && configIndex < buildIndex); + assert.ok(buildIndex < upIndex); + assert.ok(upIndex < serviceCheckIndex); + assert.ok(serviceCheckIndex < promoteIndex); + assert.match(script, /mmin \+120/); + assert.match(script, /grep -E '\(\^\/\|\(\^\|\/\)\\\.\\\.\(\/\|\$\)\)'/); + assert.match(script, /kill -0 "\$lock_pid"/); + assert.match(script, /is_preserved "\$rel" && continue/); + assert.doesNotMatch(script, /git clone|git -C "\$root" fetch/); + assert.match(script, /ForgeFlow left the existing DockerMan template unchanged/); +}); + +test("generated push-bundle command passes Bash syntax validation", { skip: process.platform === "win32" }, async () => { + const { spawnSync } = await import("node:child_process"); + const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); + const repository = { name: "Demo", fullName: "Jens/Demo" }; + const profile = { + environment: "production", + deploymentMode: "push-bundle", + composeProject: "demo", + composeFiles: ["compose.yml"], + composeServices: ["app"], + preservePaths: ["data"], + generatedCompose: false, + adoptedFromServer: true, + manageDockerMan: false, + }; + const generated = service.pushBundleScript({ + repository, + profile, + remotePath: "/mnt/user/appdata/Demo", + targetSha: "d".repeat(40), + requestId: "syntax-test", + remotePart: "/mnt/user/appdata/Demo/.forgeflow/incoming/syntax-test.tar.part", + digest: "e".repeat(64), + metadata: "services:\n app:\n labels: {}\n", + generated: "", + iconReference: "", + }); + const wrapped = bash(generated); + const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; + const script = Buffer.from(encoded, "base64").toString("utf8"); + const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test("push bundle preflight does not require Git or Gitea credentials on Unraid", async (context) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const localPath = await mkdtemp(join(tmpdir(), "forgeflow-push-preflight-")); + context.after(() => rm(localPath, { recursive: true, force: true })); + await writeFile(join(localPath, "compose.yml"), "services:\n app:\n image: example/app:latest\n"); + const sha = "c".repeat(40); + const profile = { + id: "production", + name: "Production", + environment: "production", + provider: "ssh-unraid", + branch: "main", + serverId: "unraid", + remoteFolder: "OmniRoute", + deploymentMode: "push-bundle", + composeFile: "compose.yml", + composeFiles: ["compose.yml"], + composeService: "app", + composeServices: ["app"], + iconMode: "none", + generatedCompose: false, + preservePaths: ["data"], + }; + let remoteGitProbeCount = 0; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ + id: "unraid", + name: "Unraid", + host: "192.168.56.10", + port: 22, + username: "root", + basePath: "/mnt/user/appdata", + hostFingerprint: "SHA256:test", + }), + }, + git: { + status: async () => ({ + root: localPath, + head: sha, + clean: true, + counts: { changed: 0 }, + branch: { head: "main", upstream: "origin/main", ahead: 0, behind: 0 }, + }), + verifyCommitOnRemoteBranch: async () => true, + }, + ssh: { + test: async () => ({ + capabilities: { + docker: true, + dockerReady: true, + compose: true, + composeVersion: "Docker Compose version v2", + git: false, + tar: true, + checksum: true, + baseWritable: true, + }, + }), + exec: async (_serverId, command) => { + if (String(command).includes("git ls-remote")) remoteGitProbeCount += 1; + const encodedFiles = Buffer.from("").toString("base64"); + return { + stdout: `__FORGEFLOW_KV__\nexists=false\nrootGit=false\nhead=\nbranch=\nremote=\ntrackedChanges=\ncomposeFiles=${encodedFiles}\nnestedGit=\ndockerfile=false\ndockerignoreContent=\nexistingPreservePaths=\n`, + }; + }, + }, + sourcePath: new URL("..", import.meta.url).pathname, + }); + service.inspectWriteAccess = async () => ({ ready: true, blocking: [], targets: [], identity: { user: "root" } }); + const result = await service.preflight({ + repository: { + fullName: "Jens/OmniRoute", + name: "OmniRoute", + localPath, + localStatus: { head: sha }, + }, + profileId: "production", + sha, + }); + assert.equal(remoteGitProbeCount, 0); + assert.equal(result.checks.find((item) => item.id === "transfer-path")?.status, "pass"); + assert.match(result.checks.find((item) => item.id === "transfer-path")?.detail || "", /No Gitea credential/); + assert.equal(result.checks.some((item) => item.id === "server-git-command"), false); + assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail"))); +}); + +test("preflight explains monitor-only and degraded server evidence without hiding warnings", async () => { + const sha = "d".repeat(40); + const profile = { + id: "observed", provider: "ssh-unraid", environment: "production", branch: "main", serverId: "unraid", + remoteFolder: "Observed", deploymentMode: "monitor-only", composeFiles: ["compose.yml"], + composeService: "app", iconMode: "url", iconUrl: "", preservePaths: ["data"] + }; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid", host: "server", port: 22, username: "deploy", basePath: "/mnt/apps", hostFingerprint: "" }) + }, + git: {}, + ssh: { test: async () => { throw new Error("host unavailable"); } }, + gitea: {}, + sourcePath: process.cwd() + }); + service.inspectWriteAccess = async () => { throw new Error("permission probe failed"); }; + service.inspect = async () => ({ + exists: true, rootGit: true, head: "e".repeat(40), trackedChanges: ["compose.yml"], nestedGit: ["vendor/repo"], + dockerfile: true, dockerignore: false, dockerignoreGitExcluded: false, dockerContextExclusionsMissing: ["data"], + existingPreservePaths: ["data"], composeFiles: [] + }); + const result = await service.preflight({ repository: { fullName: "Jens/Observed", name: "Observed", localPath: null }, profileId: profile.id, sha }); + const byId = (id) => result.checks.find((check) => check.id === id); + assert.equal(byId("deployment-mode").status, "fail"); + assert.equal(byId("local-repository").status, "fail"); + assert.equal(byId("ssh").status, "fail"); + assert.equal(byId("host-key").status, "fail"); + assert.equal(byId("project-write-access").status, "fail"); + assert.equal(byId("tracked-changes").status, "warning"); + assert.equal(byId("nested-git").status, "warning"); + assert.equal(byId("dockerignore").status, "warning"); + assert.equal(byId("dockerignore-runtime").status, "warning"); + assert.equal(byId("dockerman-icon").status, "fail"); + assert.equal(byId("dockerman-webui").status, "warning"); + assert.equal(result.summary.ready, false); +}); + +test("server-pull preflight resolves Gitea SHA and reports every degraded capability and write target", async () => { + const sha = "f".repeat(40); + const profile = { + id: "server-pull", provider: "ssh-unraid", environment: "staging", branch: "release", serverId: "unraid", remoteFolder: "App", + deploymentMode: "server-git", composeFiles: ["compose.yml", "compose.prod.yml"], composeService: "web", + iconMode: "none", preservePaths: [] + }; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid", host: "server", port: 22, username: "deploy", basePath: "/mnt/apps", hostFingerprint: "SHA256:trusted" }) + }, + git: { status: async () => ({ root: "/local", clean: false, counts: { changed: 3 }, branch: { head: "main" } }) }, + ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: false, compose: false, git: false, tar: true, checksum: false, baseWritable: false } }) }, + gitea: { + getBranch: async () => ({ commit: { sha } }), + repositoryFileExists: async ({ filePath, ref }) => ref === sha && filePath === "compose.yml", + }, + sourcePath: process.cwd() + }); + service.probeServerGitAccess = async () => ({ ready: false, error: "deploy key missing", remoteSha: null }); + service.inspectWriteAccess = async () => ({ + ready: false, identity: { user: "deploy" }, blocking: [{ path: "/mnt/apps/App" }], + targets: [{ id: "root", label: "Project root", path: "/mnt/apps/App", required: true, effectiveWritable: false, owner: "root", group: "root", mode: "0755", detail: "not writable" }] + }); + service.inspect = async () => ({ exists: false, rootGit: false, trackedChanges: [], nestedGit: [], dockerfile: false, dockerignore: false, dockerignoreGitExcluded: false, dockerContextExclusionsMissing: [], existingPreservePaths: [], composeFiles: [] }); + const result = await service.preflight({ repository: { fullName: "Jens/App", name: "App", localPath: "/local" }, profileId: profile.id }); + const byId = (id) => result.checks.find((check) => check.id === id); + assert.equal(result.sha, sha); + assert.equal(byId("local-branch").status, "warning"); + assert.equal(byId("local-clean").status, "warning"); + assert.equal(byId("gitea-deployment-files").status, "fail"); + assert.match(byId("gitea-deployment-files").detail, /compose\.prod\.yml/); + assert.equal(byId("docker-runtime").status, "fail"); + assert.match(byId("docker-runtime").detail, /cannot query/i); + assert.equal(byId("compose-command").status, "fail"); + assert.equal(byId("bundle-tools").status, "fail"); + assert.equal(byId("server-base-writable").status, "fail"); + assert.equal(byId("server-git-access").repairAction, "configure-server-git-access"); + assert.equal(byId("write-path:root").status, "fail"); + assert.equal(byId("remote-folder").status, "pass"); + assert.equal(byId("dockerman-icon").status, "warning"); +}); diff --git a/tests/update-service.test.mjs b/tests/update-service.test.mjs new file mode 100644 index 0000000..7ea358e --- /dev/null +++ b/tests/update-service.test.mjs @@ -0,0 +1,799 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { EventEmitter } from "node:events"; +import { createHash, generateKeyPairSync, sign } from "node:crypto"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +const require = createRequire(import.meta.url); +const execFileAsync = promisify(execFile); +const { + UpdateService, + verifyReleaseManifest, + waitForUpdaterStarted, + windowsUpdaterSpawnOptions, +} = require("../src/main/update-service.cjs"); + +function createSignedReleaseFixture({ + version, + remoteSha, + assetName, + binary, +}) { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const sha256 = createHash("sha256").update(binary).digest("hex"); + const manifest = { + schemaVersion: 1, + product: "ForgeFlow", + version, + tag: `v${version}`, + commit: remoteSha, + buildId: "test-build", + signature: { algorithm: "Ed25519", keyId: "SHA256:test" }, + artifacts: [{ name: assetName, bytes: binary.length, sha256 }], + }; + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + const signatureBytes = Buffer.from( + `${sign(null, manifestBytes, privateKey).toString("base64")}\n`, + ); + return { publicKey, sha256, manifestBytes, signatureBytes }; +} + +test("Windows updater uses a hidden non-detached PowerShell child", () => { + assert.deepEqual(windowsUpdaterSpawnOptions("C:\\updates"), { + detached: false, + stdio: "ignore", + windowsHide: true, + cwd: "C:\\updates", + }); +}); + +test("update check pins version to an exact branch commit", async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-")); + const saved = []; + const store = { + data: { + gitea: { baseUrl: "https://gitea.example.test" }, + updates: { + owner: "Jens", + repo: "ForgeFlow", + branch: "main", + autoCheck: true, + }, + }, + async save() { + saved.push(true); + }, + }; + const calls = []; + const gitea = { + async getBranch(owner, repo, branch) { + calls.push(["branch", owner, repo, branch]); + return { commit: { id: "a".repeat(40) } }; + }, + async getRepositoryFile(input) { + calls.push(["file", input]); + return { + decoded: JSON.stringify({ name: "forgeflow", version: "0.4.1" }), + }; + }, + }; + const service = new UpdateService({ + store, + gitea, + diagnostics: null, + appInfo: { version: "0.4.0", packaged: false }, + sourcePath: temp, + userDataPath: temp, + }); + const result = await service.check(); + assert.equal(result.available, true); + assert.equal(result.remoteSha, "a".repeat(40)); + assert.equal(calls[1][1].ref, "a".repeat(40)); + assert.equal(saved.length, 1); + await rm(temp, { recursive: true, force: true }); +}); + +test("update repository parts reject path injection", async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-")); + const service = new UpdateService({ + store: { + data: { + updates: { owner: "../Jens", repo: "ForgeFlow", branch: "main" }, + }, + save: async () => {}, + }, + gitea: {}, + diagnostics: null, + appInfo: { version: "0.4.0", packaged: false }, + sourcePath: temp, + userDataPath: temp, + }); + await assert.rejects(() => service.check(), /unsupported characters/); + await rm(temp, { recursive: true, force: true }); +}); + +test("source updater refuses an unsigned archive before launching a helper", async () => { + const temp = await mkdtemp( + path.join(os.tmpdir(), "forgeflow-update-handshake-"), + ); + const source = path.join(temp, "source"); + const scripts = path.join(source, "scripts"); + const archive = path.join(temp, "update.zip"); + await mkdir(scripts, { recursive: true }); + await writeFile( + path.join(scripts, "apply-source-update.ps1"), + "# test helper", + ); + await writeFile(archive, "PK fake archive"); + + let capturedArgs = null; + const spawnProcess = (_command, args) => { + capturedArgs = args; + const child = new EventEmitter(); + child.pid = 4321; + child.unref = () => {}; + queueMicrotask(() => child.emit("spawn")); + const statusIndex = args.indexOf("-StatusPath"); + const statusPath = args[statusIndex + 1]; + const updateIdIndex = args.indexOf("-UpdateId"); + const updateId = args[updateIdIndex + 1]; + setTimeout( + () => + writeFile( + statusPath, + JSON.stringify({ + state: "started", + expectedVersion: "0.5.3", + updateId, + }), + ), + 30, + ); + return child; + }; + + const service = new UpdateService({ + store: { + data: { updates: {}, gitea: { baseUrl: "https://example.test" } }, + save: async () => {}, + }, + gitea: {}, + diagnostics: null, + appInfo: { version: "0.5.2", packaged: false }, + sourcePath: source, + userDataPath: temp, + platform: "win32", + spawnProcess, + powershellPath: + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + handshakeTimeoutMs: 1000, + handshakePollMs: 10, + }); + service.staged = { + archivePath: archive, + remoteVersion: "0.5.3", + remoteSha: "a".repeat(40), + sha256: "b".repeat(64), + }; + await assert.rejects( + service.apply(), + (error) => error.code === "SIGNED_SOURCE_UPDATE_REQUIRED", + ); + assert.equal(capturedArgs, null); + await rm(temp, { recursive: true, force: true }); +}); + +test("source updater leaves ForgeFlow open when no STARTED marker arrives", async () => { + const temp = await mkdtemp( + path.join(os.tmpdir(), "forgeflow-update-timeout-"), + ); + const statusPath = path.join(temp, "status.json"); + await writeFile(statusPath, JSON.stringify({ state: "launching" })); + await assert.rejects( + () => + waitForUpdaterStarted(statusPath, { + timeoutMs: 80, + pollMs: 10, + childState: { exited: false, error: null }, + }), + (error) => error.code === "UPDATE_HELPER_START_TIMEOUT", + ); + await rm(temp, { recursive: true, force: true }); +}); + +test("completed source update result is returned once and acknowledged", async () => { + const temp = await mkdtemp( + path.join(os.tmpdir(), "forgeflow-update-result-"), + ); + const updates = path.join(temp, "updates"); + await mkdir(updates, { recursive: true }); + const statusPath = path.join(updates, "apply-test.status.json"); + await writeFile( + statusPath, + JSON.stringify({ + state: "success", + expectedVersion: "0.5.3", + installedVersion: "0.5.3", + restartLaunched: false, + message: "installed", + logPath: "C:\\log.txt", + updatedAt: new Date().toISOString(), + }), + ); + const service = new UpdateService({ + store: { data: { updates: {} }, save: async () => {} }, + gitea: {}, + diagnostics: null, + appInfo: { version: "0.5.3", packaged: false }, + sourcePath: temp, + userDataPath: temp, + }); + const first = await service.consumeLatestResult(); + const second = await service.consumeLatestResult(); + assert.equal(first.state, "success"); + assert.equal(first.restartLaunched, false); + assert.equal(second, null); + const persisted = JSON.parse(await readFile(statusPath, "utf8")); + assert.ok(persisted.acknowledgedAt); + await rm(temp, { recursive: true, force: true }); +}); + +test("PowerShell update helper writes lifecycle status before waiting for ForgeFlow exit", async () => { + const script = await readFile( + new URL("../scripts/apply-source-update.ps1", import.meta.url), + "utf8", + ); + assert.match(script, /\[string\]\$StatusPath/); + assert.match(script, /Write-UpdateState -State "started"/); + assert.match(script, /Write-UpdateState -State "success"/); + assert.match(script, /Write-UpdateState -State "rolled-back"/); + assert.match(script, /UTF8Encoding\(\$false\)/); + assert.match(script, /WriteAllText/); +}); + +test("PowerShell update helper starts with param and has no BOM or stray leading slash", async () => { + const bytes = await readFile( + new URL("../scripts/apply-source-update.ps1", import.meta.url), + ); + assert.notDeepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf]); + const text = bytes.toString("utf8"); + assert.match(text.trimStart(), /^param\(/); + assert.doesNotMatch(text.trimStart(), /^\\/); + assert.match(text, /node_modules\\electron\\dist\\electron\.exe/); + assert.match(text, /npm ci --no-audit --no-fund/); + assert.match(text, /package-lock\.json/); + assert.doesNotMatch(text, /Get-Command npm\.cmd/); + assert.match(text, /Integrated source update refuses to overwrite a Git working tree/); + assert.ok( + text.indexOf("Handshake-only verification completed successfully") < + text.indexOf("Integrated source update refuses to overwrite a Git working tree"), + ); + assert.ok( + text.indexOf("$actualHash = Get-Sha256") < + text.indexOf("Source update preflight passed"), + ); + assert.ok( + text.indexOf('Write-UpdateState -State "success"') < + text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"), + ); +}); + +test("release publisher verifies Gitea and bootstraps the installed updater service and helper", async () => { + const script = await readFile( + new URL("../Publish-ForgeFlow-Release.ps1", import.meta.url), + "utf8", + ); + assert.match(script, /npm install --no-audit --no-fund/); + assert.match(script, /package-lock\.json/); + assert.match(script, /non-reproducible update/); + assert.match(script, /npm run check/); + assert.match(script, /npm run dist:win/); + assert.match(script, /npm run release:binary/); + assert.match(script, /SkipBinaryRelease/); + assert.match(script, /ForgeFlow-Setup-\$version-win-x64\.exe/); + assert.match(script, /ForgeFlow-Portable-\$version-win-x64\.exe/); + assert.match(script, /git ls-remote origin/); + assert.match(script, /publishedCommit -ne \$localCommit/); + assert.match(script, /scripts\\apply-source-update\.ps1/); + assert.match(script, /src\\main\\update-service\.cjs/); + assert.match(script, /expectedUpdateId/); + assert.match(script, /readLogTail/); + assert.match(script, /HandshakeOnly/); + assert.match(script, /handshakeResult\.state -ne "started"/); + assert.match(script, /Windows-tested/); + assert.match(script, /without changing its version/); + assert.doesNotMatch(script, /Copy-Item[^\n]+package\.json/); +}); + +test("one-click Windows release wrapper invokes the atomic publisher", async () => { + const script = await readFile( + new URL("../PUBLISH-AND-ENABLE-UPDATE.cmd", import.meta.url), + "utf8", + ); + assert.match(script, /ExecutionPolicy Bypass/); + assert.match(script, /Publish-ForgeFlow-Release\.ps1/); + assert.match(script, /older ForgeFlow updater can now install/); + assert.match(script, /exit \/b %forgeflowExitCode%/); +}); + +test("missing binary release recovery script builds the exact Gitea commit and uploads all assets", async () => { + const script = await readFile( + new URL("../Publish-Missing-Binary-Release.ps1", import.meta.url), + "utf8", + ); + assert.match(script.trimStart(), /^param\(/); + assert.match(script, /git clone --branch \$Branch --single-branch/); + assert.match(script, /git -C \$clone ls-remote origin/); + assert.match(script, /npm ci --no-audit --no-fund/); + assert.match(script, /npm run check/); + assert.match(script, /npm run dist:win/); + assert.match(script, /npm run release:binary/); + assert.match(script, /FORGEFLOW_USER_DATA/); + assert.match(script, /ForgeFlow-Setup-\$version-win-x64\.exe/); + assert.match(script, /ForgeFlow-Portable-\$version-win-x64\.exe/); +}); + +test("binary publisher derives repository coordinates from ForgeFlow settings", async () => { + const script = await readFile( + new URL("../scripts/publish-binary-release.cjs", import.meta.url), + "utf8", + ); + assert.match(script, /config\.updates\?\.owner/); + assert.match(script, /config\.updates\?\.repo/); + assert.match(script, /config\.updates\?\.branch/); + assert.match(script, /encodeURIComponent\(owner\)/); + assert.match(script, /encodeURIComponent\(repo\)/); + assert.doesNotMatch(script, /\/repos\/Jens\/ForgeFlow\/releases/); +}); + + +test("packaged updater passes Gitea browser download URLs to the asset downloader", async () => { + const source = await readFile( + new URL("../src/main/update-service.cjs", import.meta.url), + "utf8", + ); + assert.match(source, /downloadUrl: asset\.browser_download_url/); + assert.match(source, /downloadUrl: checksumAsset\.browser_download_url/); + assert.match(source, /downloadUrl: manifestAsset\.browser_download_url/); + assert.match(source, /downloadUrl: signatureAsset\.browser_download_url/); + assert.match(source, /RELEASE_ASSET_METADATA_RECEIVED/); +}); +test("PowerShell helper replaces an existing launching status with a Windows-safe file API", async () => { + const script = await readFile( + new URL("../scripts/apply-source-update.ps1", import.meta.url), + "utf8", + ); + assert.match( + script, + /System\.IO\.File\]::Replace\(\$temporary, \$StatusPath, \$backup\)/, + ); + assert.match( + script, + /System\.IO\.File\]::Copy\(\$temporary, \$StatusPath, \$true\)/, + ); + assert.doesNotMatch( + script, + /Move-Item -LiteralPath \$temporary -Destination \$StatusPath -Force/, + ); + assert.match(script, /\[switch\]\$HandshakeOnly/); + assert.match(script, /Handshake-only verification completed successfully/); +}); + +test("binary helper confirms startup through real Windows PowerShell", { skip: process.platform !== "win32" }, async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-handshake-")); + const statusPath = path.join(temp, "status.json"); + const logPath = path.join(temp, "helper.log"); + await writeFile(statusPath, JSON.stringify({ state: "launching", updateId: "binary-handshake" })); + const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url)); + const { stdout, stderr } = await execFileAsync(powershell, [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath, + "-BinaryPath", path.join(temp, "unused.exe"), "-ExpectedSha256", "0".repeat(64), + "-ExpectedVersion", "9.9.9", "-CurrentExecutable", path.join(temp, "unused-current.exe"), + "-Portable", "False", "-ParentPid", "999999", "-LogPath", logPath, + "-StatusPath", statusPath, "-UpdateId", "binary-handshake", "-HandshakeOnly" + ], { windowsHide: true }); + assert.equal(stdout, ""); + assert.equal(stderr, ""); + const status = JSON.parse(await readFile(statusPath, "utf8")); + assert.equal(status.updateId, "binary-handshake"); + assert.equal(status.state, "started"); + assert.match(await readFile(logPath, "utf8"), /Handshake-only verification completed successfully/); + await rm(temp, { recursive: true, force: true }); +}); + +test("binary helper confirms startup through the production Node spawn options", { skip: process.platform !== "win32" }, async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-node-spawn-")); + const statusPath = path.join(temp, "status.json"); + const logPath = path.join(temp, "helper.log"); + const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url)); + const updateId = "binary-node-spawn"; + await writeFile(statusPath, JSON.stringify({ state: "launching", updateId })); + const child = spawn(powershell, [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath, + "-BinaryPath", path.join(temp, "unused.exe"), "-ExpectedSha256", "0".repeat(64), + "-ExpectedVersion", "9.9.9", "-CurrentExecutable", path.join(temp, "unused-current.exe"), + "-Portable", "False", "-ParentPid", String(process.pid), "-LogPath", logPath, + "-StatusPath", statusPath, "-UpdateId", updateId, "-HandshakeOnly", + ], windowsUpdaterSpawnOptions(temp)); + const childState = { exited: false, code: null, error: null }; + child.once("error", (error) => { childState.error = error; }); + child.once("exit", (code) => { childState.exited = true; childState.code = code; }); + const status = await waitForUpdaterStarted(statusPath, { + timeoutMs: 5000, + pollMs: 25, + childState, + expectedUpdateId: updateId, + logPath, + }); + assert.equal(status.state, "started"); + let log = ""; + for (let attempt = 0; attempt < 40 && !log.includes("Handshake-only verification completed successfully"); attempt += 1) { + await delay(25); + log = await readFile(logPath, "utf8").catch(() => ""); + } + assert.match(log, /Handshake-only verification completed successfully/); + if (child.exitCode === null) { + await new Promise((resolve, reject) => { + child.once("exit", resolve); + child.once("error", reject); + }); + } + await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); +}); + +test("binary helper verifies SHA-256 without PowerShell module autoloading", { skip: process.platform !== "win32" }, async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-dotnet-sha-")); + const binaryPath = path.join(temp, "update.exe"); + const currentPath = path.join(temp, "current.exe"); + const statusPath = path.join(temp, "status.json"); + const logPath = path.join(temp, "helper.log"); + const bytes = Buffer.from("verified update bytes"); + await writeFile(binaryPath, bytes); + await writeFile(currentPath, "current"); + const expectedSha256 = createHash("sha256").update(bytes).digest("hex"); + const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url)); + const { stderr } = await execFileAsync(powershell, [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath, + "-BinaryPath", binaryPath, "-ExpectedSha256", expectedSha256, "-ExpectedVersion", "9.9.9", + "-CurrentExecutable", currentPath, "-Portable", "False", "-ParentPid", String(process.pid), + "-LogPath", logPath, "-StatusPath", statusPath, "-UpdateId", "dotnet-sha", "-VerifyOnly", + ], { windowsHide: true, env: { ...process.env, PSModulePath: "" } }); + assert.equal(stderr, ""); + assert.match(await readFile(logPath, "utf8"), /Verification-only SHA-256 check completed successfully/); + await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); +}); +test("early helper exit reports the helper log instead of only an exit code", async () => { + const temp = await mkdtemp( + path.join(os.tmpdir(), "forgeflow-update-log-tail-"), + ); + const statusPath = path.join(temp, "status.json"); + const logPath = path.join(temp, "apply.log"); + await writeFile( + statusPath, + JSON.stringify({ state: "launching", updateId: "request-1" }), + ); + await writeFile(logPath, "first line\nactual helper failure\n"); + await assert.rejects( + () => + waitForUpdaterStarted(statusPath, { + timeoutMs: 100, + pollMs: 5, + childState: { exited: true, code: 0, error: null }, + expectedUpdateId: "request-1", + logPath, + }), + (error) => + error.code === "UPDATE_HELPER_EXITED_EARLY" && + /actual helper failure/.test(error.message), + ); + await rm(temp, { recursive: true, force: true }); +}); + +test("updater handshake rejects a stale status from another update request", async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-id-")); + const statusPath = path.join(temp, "status.json"); + await writeFile( + statusPath, + JSON.stringify({ state: "started", updateId: "old-request" }), + ); + await assert.rejects( + () => + waitForUpdaterStarted(statusPath, { + timeoutMs: 50, + pollMs: 5, + childState: { exited: false, code: null, error: null }, + expectedUpdateId: "new-request", + }), + (error) => error.code === "UPDATE_HELPER_START_TIMEOUT", + ); + await rm(temp, { recursive: true, force: true }); +}); + +test("packaged updater downloads only a publisher-signed Windows asset", async () => { + const temp = await mkdtemp( + path.join(os.tmpdir(), "forgeflow-binary-update-"), + ); + const binary = Buffer.alloc(1_100_000, 0x5a); + binary[0] = 0x4d; + binary[1] = 0x5a; + const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe"; + const remoteSha = "a".repeat(40); + const signed = createSignedReleaseFixture({ + version: "0.8.2", + remoteSha, + assetName, + binary, + }); + const manifestName = "ForgeFlow-0.8.2-release-manifest.json"; + const gitea = { + async getReleaseByTag(_owner, _repo, tag) { + if (tag !== "v0.8.2") return null; + return { + id: 82, + tag_name: tag, + draft: false, + prerelease: false, + assets: [ + { + id: 41, + name: assetName, + browser_download_url: "http://wrong-origin.test/setup", + }, + { + name: `${assetName}.sha256`, + id: 42, + browser_download_url: "http://wrong-origin.test/checksum", + }, + { + name: manifestName, + id: 43, + browser_download_url: "http://wrong-origin.test/manifest", + }, + { + name: `${manifestName}.sig`, + id: 44, + browser_download_url: "http://wrong-origin.test/signature", + }, + ], + }; + }, + async downloadReleaseAsset(_owner, _repo, releaseId, assetId, options) { + assert.equal(releaseId, 82); + const downloads = { + 41: ["http://wrong-origin.test/setup", binary], + 42: [ + "http://wrong-origin.test/checksum", + Buffer.from(`${signed.sha256} ${assetName}\n`), + ], + 43: ["http://wrong-origin.test/manifest", signed.manifestBytes], + 44: ["http://wrong-origin.test/signature", signed.signatureBytes], + }; + assert.equal(options.downloadUrl, downloads[assetId][0]); + return downloads[assetId][1]; + }, + }; + const service = new UpdateService({ + store: { + data: { gitea: { baseUrl: "https://gitea.test" } }, + save: async () => {}, + }, + gitea, + diagnostics: null, + appInfo: { + version: "0.8.1", + packaged: true, + executablePath: "C:\\ForgeFlow\\ForgeFlow.exe", + }, + sourcePath: temp, + userDataPath: temp, + platform: "win32", + updatePublicKey: signed.publicKey, + }); + const result = await service.downloadPackaged({ + owner: "Jens", + repo: "ForgeFlow", + remoteVersion: "0.8.2", + remoteSha, + }); + assert.equal(result.downloaded, true); + assert.equal(result.sha256, signed.sha256); + assert.equal(result.publisherKeyId, "SHA256:test"); + assert.equal(result.portable, false); + assert.equal((await readFile(result.binaryPath)).length, binary.length); + await rm(temp, { recursive: true, force: true }); +}); + +test("packaged updater rejects a binary whose checksum does not match", async () => { + const temp = await mkdtemp( + path.join(os.tmpdir(), "forgeflow-binary-mismatch-"), + ); + const binary = Buffer.alloc(1_100_000, 0x5a); + binary[0] = 0x4d; + binary[1] = 0x5a; + const assetName = "ForgeFlow-Portable-0.8.2-win-x64.exe"; + const remoteSha = "b".repeat(40); + const signed = createSignedReleaseFixture({ + version: "0.8.2", + remoteSha, + assetName, + binary, + }); + const manifestName = "ForgeFlow-0.8.2-release-manifest.json"; + const service = new UpdateService({ + store: { data: { gitea: {} }, save: async () => {} }, + gitea: { + async getReleaseByTag() { + return { + id: 83, + tag_name: "v0.8.2", + assets: [ + { + id: 51, + name: assetName, + browser_download_url: "http://wrong-origin.test/portable", + }, + { + id: 52, + name: `${assetName}.sha256`, + browser_download_url: "http://wrong-origin.test/checksum", + }, + { id: 53, name: manifestName }, + { id: 54, name: `${manifestName}.sig` }, + ], + }; + }, + async downloadReleaseAsset(_owner, _repo, releaseId, assetId) { + assert.equal(releaseId, 83); + if (assetId === 51) return binary; + if (assetId === 52) + return Buffer.from(`${"0".repeat(64)} ${assetName}`); + if (assetId === 53) return signed.manifestBytes; + return signed.signatureBytes; + }, + }, + diagnostics: null, + appInfo: { + version: "0.8.1", + packaged: true, + portableExecutablePath: "C:\\ForgeFlow-Portable.exe", + }, + sourcePath: temp, + userDataPath: temp, + platform: "win32", + updatePublicKey: signed.publicKey, + }); + await assert.rejects( + () => + service.downloadPackaged({ + owner: "Jens", + repo: "ForgeFlow", + remoteVersion: "0.8.2", + remoteSha, + }), + /does not match the signed publisher manifest/, + ); + await rm(temp, { recursive: true, force: true }); +}); + +test("release manifest verification rejects a different publisher key", () => { + const binary = Buffer.alloc(1_100_000, 0x5a); + const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe"; + const fixture = createSignedReleaseFixture({ + version: "0.8.2", + remoteSha: "c".repeat(40), + assetName, + binary, + }); + const otherKey = generateKeyPairSync("ed25519").publicKey; + assert.throws( + () => + verifyReleaseManifest({ + manifestBytes: fixture.manifestBytes, + signatureBytes: fixture.signatureBytes, + publicKey: otherKey, + update: { + remoteVersion: "0.8.2", + remoteSha: "c".repeat(40), + }, + assetName, + }), + (error) => error.code === "RELEASE_SIGNATURE_INVALID", + ); +}); + +test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => { + const [pkgSource, signatureSource, checksumSource, manifestSigner, releaseWorkflow] = await Promise.all([ + readFile(new URL("../package.json", import.meta.url), "utf8"), + readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"), + readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"), + readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"), + readFile(new URL("../.gitea/workflows/release.yml", import.meta.url), "utf8"), + ]); + assert.match(pkgSource, /verify-release-signatures\.mjs/); + assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/); + assert.match(signatureSource, /FORGEFLOW_EXPECTED_PUBLISHER/); + assert.match(signatureSource, /TimestampSubject/); + assert.match(signatureSource, /Signed release verification failed/); + assert.match(signatureSource, /Authenticode inspection unavailable/); + assert.match(checksumSource, /provenance\.json/); + assert.match(checksumSource, /sbom\.cdx\.json/); + assert.match(checksumSource, /CycloneDX/); + assert.match(checksumSource, /publisherManifestSignature/); + assert.match(manifestSigner, /Ed25519/); + assert.match(manifestSigner, /release-manifest\.json/); + assert.match(pkgSource, /sign-release-manifest\.mjs/); + assert.doesNotMatch(releaseWorkflow, /checkout@v\d|setup-node@v\d/); + assert.match(releaseWorkflow, /checkout@[a-f0-9]{40}/); + assert.match(releaseWorkflow, /setup-node@[a-f0-9]{40}/); + assert.ok( + releaseWorkflow.indexOf("Validate version bump and build release artifacts") < + releaseWorkflow.indexOf("FORGEFLOW_RELEASE_SIGNING_KEY_PEM"), + "signing secrets must not be present during dependency installation and quality checks", + ); + assert.match(releaseWorkflow, /finally \{/); + assert.match(releaseWorkflow, /Remove-Item -LiteralPath \$privateKeyPath -Force/); + const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8"); + assert.match(publisher, /draft: true/); + assert.match(publisher, /requiredAssets/); + assert.match(publisher, /Release remains draft because required assets are missing/); + assert.match(publisher, /sbom\.cdx\.json/); +}); + +test("the supported Windows build uses free offline Ed25519 publisher signing", async () => { + const [pkg, keySetup, manifestSigner, publicKey] = await Promise.all([ + readFile(new URL("../package.json", import.meta.url), "utf8"), + readFile(new URL("../scripts/setup-update-signing-key.mjs", import.meta.url), "utf8"), + readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"), + readFile(new URL("../build/update-signing-public.pem", import.meta.url), "utf8"), + ]); + assert.doesNotMatch(pkg, /dist:win:signed/); + assert.match(pkg, /dist:win/); + assert.match(pkg, /signing:setup/); + assert.match(keySetup, /release-signing-private\.pem/); + assert.match(manifestSigner, /sign\(null, manifestBytes, privateKey\)/); + assert.match(publicKey, /BEGIN PUBLIC KEY/); + assert.doesNotMatch(publicKey, /PRIVATE KEY/); +}); + +test("binary update helper verifies, waits, applies and records restart state", async () => { + const helper = await readFile( + new URL("../scripts/apply-binary-update.ps1", import.meta.url), + "utf8", + ); + for (const marker of [ + "Security.Cryptography.SHA256", + "Wait-Process", + 'Write-UpdateState -State "started"', + 'Write-UpdateState -State "waiting-for-exit"', + 'Write-UpdateState -State "applying"', + 'Write-UpdateState -State "success"', + 'Start-Process -FilePath $BinaryPath -ArgumentList "/S"', + "Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable", + ]) { + assert.ok( + helper.includes(marker), + `missing binary updater marker: ${marker}`, + ); + } + assert.doesNotMatch(helper, /Get-FileHash/); + assert.match(helper, /function Start-ForgeFlowAndVerify/); + assert.match(helper, /Start-Sleep -Milliseconds 1500/); + assert.match(helper, /Updated portable executable failed its restart probe/); + assert.ok( + helper.indexOf("$actualSha256 = Get-Sha256") < + helper.indexOf("Binary preflight passed"), + ); + assert.ok( + helper.indexOf("Binary preflight passed") < + helper.indexOf('Write-UpdateState -State "waiting-for-exit"'), + ); +}); diff --git a/tests/validation.test.mjs b/tests/validation.test.mjs new file mode 100644 index 0000000..e1c64fb --- /dev/null +++ b/tests/validation.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import validation from '../src/shared/validation.cjs'; + +const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest, assertHttpUrl } = validation; + +test('normalizes Gitea base URL', () => { + assert.equal(normalizeBaseUrl('https://gitea.example.com/'), 'https://gitea.example.com'); +}); + +test('rejects blank commit messages', () => { + assert.throws(() => assertCommitMessage(' '), /commit message/i); +}); + +test('requires exact SHA and workflow profile', () => { + assert.throws(() => assertDeploymentRequest({ branch: 'main', workflowFile: 'deploy.yml' }, 'nope'), /commit SHA/i); +}); + +test('accepts Unraid DockerMan WebUI placeholders only when explicitly enabled', () => { + assert.equal(assertHttpUrl('http://[IP]:[PORT:1223]/', { allowUnraidTemplate: true }), 'http://[IP]:[PORT:1223]/'); + assert.throws(() => assertHttpUrl('http://[IP]:[PORT:1223]/')); +}); diff --git a/tests/zip-writer.test.mjs b/tests/zip-writer.test.mjs new file mode 100644 index 0000000..37464b4 --- /dev/null +++ b/tests/zip-writer.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import zlib from 'node:zlib'; +import zipModule from '../src/shared/zip-writer.cjs'; + +const { createZip, crc32 } = zipModule; + +function unzipLocalEntries(buffer) { + const entries = new Map(); + let offset = 0; + while (offset + 4 <= buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { + const method = buffer.readUInt16LE(offset + 8); + const expectedCrc = buffer.readUInt32LE(offset + 14); + const compressedSize = buffer.readUInt32LE(offset + 18); + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + nameLength + extraLength; + const name = buffer.subarray(nameStart, nameStart + nameLength).toString('utf8'); + const compressed = buffer.subarray(dataStart, dataStart + compressedSize); + const data = method === 8 ? zlib.inflateRawSync(compressed) : compressed; + assert.equal(crc32(data), expectedCrc); + entries.set(name, data); + offset = dataStart + compressedSize; + } + return entries; +} + +test('creates a valid deflated ZIP with UTF-8 entry names and CRCs', () => { + const archive = createZip([ + { name: 'manifest.json', data: '{"ok":true}\n' }, + { name: 'logs/diagnostics.jsonl', data: Buffer.from('hello diagnostics\n') }, + { name: 'unicode/één.txt', data: 'veilig' } + ]); + assert.equal(archive.readUInt32LE(0), 0x04034b50); + assert.equal(archive.readUInt32LE(archive.length - 22), 0x06054b50); + const entries = unzipLocalEntries(archive); + assert.equal(entries.size, 3); + assert.equal(entries.get('manifest.json').toString(), '{"ok":true}\n'); + assert.equal(entries.get('unicode/één.txt').toString(), 'veilig'); +}); diff --git a/update-windows.ps1 b/update-windows.ps1 new file mode 100644 index 0000000..04b884c --- /dev/null +++ b/update-windows.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +Set-Location $PSScriptRoot + +function Assert-Command { + param([Parameter(Mandatory = $true)][string]$Name) + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required command '$Name' was not found on PATH." } +} +function Invoke-Step { + param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][scriptblock]$Action) + Write-Host "`n$Title" -ForegroundColor Yellow + & $Action + if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE." } +} + +$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json +$version = [string]$package.version +if ($package.name -ne "forgeflow" -or [string]::IsNullOrWhiteSpace($version)) { throw "This folder is not a valid ForgeFlow source release." } + +Write-Host "ForgeFlow v$version source update" -ForegroundColor Cyan +Write-Host "Your Gitea token, repository mappings and deployment profiles are stored outside this source folder and are not reset." -ForegroundColor DarkGray +Assert-Command node +Assert-Command npm +Assert-Command git + +Invoke-Step "Installing exact project dependencies..." { + if (-not (Test-Path ".\package-lock.json")) { throw "package-lock.json is required for a reproducible ForgeFlow update." } + npm ci --no-audit --no-fund +} +Invoke-Step "Running the environment doctor..." { npm run doctor } +Invoke-Step "Running source verification and automated tests..." { npm run check } + +Write-Host "`nForgeFlow v$version is ready." -ForegroundColor Green +Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green +npm start

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 literal 0 HcmV?d00001 diff --git a/src/renderer/assets/itworx-wordmark-light.png b/src/renderer/assets/itworx-wordmark-light.png new file mode 100644 index 0000000000000000000000000000000000000000..cb4dcb8715a2142025ac9511b45eadaba14ea321 GIT binary patch literal 75240 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