Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c21616e72 | ||
|
|
f866b12fbf | ||
|
|
f7d6bc374f |
@@ -4,7 +4,7 @@
|
||||
|
||||
ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unraid-servers gebruikt. Je ziet in één werkruimte wat lokaal gewijzigd is, wat op Gitea staat en welke exacte commit op de server draait. ForgeFlow begeleidt je daarna veilig door review, commit, push, deployment en verificatie.
|
||||
|
||||
> Huidige release: **0.10.6** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||
> Huidige release: **0.10.9** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||
|
||||

|
||||
|
||||
|
||||
+23
-20
@@ -1,4 +1,4 @@
|
||||
ForgeFlow 0.10.6 source manifest
|
||||
ForgeFlow 0.10.9 source manifest
|
||||
SHA-256 BYTES PATH
|
||||
(The manifest excludes itself, dependencies and generated release artifacts.)
|
||||
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
|
||||
@@ -39,6 +39,9 @@ f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581
|
||||
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
|
||||
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md
|
||||
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md
|
||||
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md
|
||||
4be29ad0cb7ebcf5625172b8d2bd7a67cdc6d64d3a94e2c3f0656cdfd42dcb7a 642 docs/RELEASE_NOTES_0.10.8.md
|
||||
fb64517aa64d3ecfe8b51b09e198c2c9fbba96d0cd24a87301c7f6dea3076095 961 docs/RELEASE_NOTES_0.10.9.md
|
||||
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
|
||||
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
|
||||
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
|
||||
@@ -98,23 +101,23 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||
1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs
|
||||
970c2afb09f2b9d1e97a5a92c0bd824c054b72298c5a5d234396d5e34de065ae 13870 main.cjs
|
||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||
32b8a6a210910623eb078956a2f06a33b2649d60333a567e620d46c6ed4bcc0d 179806 package-lock.json
|
||||
681fb8a118b372ad9b62f8940d7c921eb20267c4eaf8a99431370f8d64d39992 5550 package.json
|
||||
f79efd5414d35bbcd1476956db1feaab43c3a8f6d22325ef1020a93c34f8e2ee 179806 package-lock.json
|
||||
2b9c81b45209f775ced9af95b43e5a32e3bce9369406881c1aba1fdbf93ee402 5664 package.json
|
||||
2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs
|
||||
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs
|
||||
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
||||
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
|
||||
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
|
||||
51dff94dcdf3ae86ff1d544bb52eeab2ab51421a51b35c5dbe8f7c9f8d23c50a 10313 README.md
|
||||
b1a5837a2087cc9a160f1e60c7cdfd750668314ddfdb42ce11a4368151e8d048 10313 README.md
|
||||
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json
|
||||
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md
|
||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||
8fe616851e6a089a59fd134905465019c33d26f6408ebd0613fff587b6c589e0 4400 scripts/apply-binary-update.ps1
|
||||
706b49e001837c91134044d3af132d58fb14955245bc1d5c36b413eb65867002 10486 scripts/apply-source-update.ps1
|
||||
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
|
||||
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
|
||||
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
|
||||
4490bed84761f76e1fd87ee3117fe53e82e60b7760329c97d68772d6d820ebae 8521 scripts/audit-installed-deployments.cjs
|
||||
47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
|
||||
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
||||
0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs
|
||||
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
||||
@@ -123,7 +126,7 @@ b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102
|
||||
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
|
||||
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
||||
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
|
||||
85134984297c90a5de812c075d7f75eaa5242d5ee33713c6a4a2fe9703f82be9 19210 scripts/verify.mjs
|
||||
b35a8701bba54338cce22c0afc3934f5c9747bcfedd85475ccde2c1b7352743d 20272 scripts/verify.mjs
|
||||
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
|
||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||
@@ -152,18 +155,18 @@ fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745
|
||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
||||
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
|
||||
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
|
||||
90504e27bfabcd2f927ba29930fad9bb65520fdf871a2a9e49cbcefcb837d84c 25492 src/main/unraid-access-methods.cjs
|
||||
560084e3f10062f8f21b62f1cc50050350558e9505ab96ed235c6ab377da2546 25774 src/main/unraid-access-methods.cjs
|
||||
2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs
|
||||
803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs
|
||||
2e63fdc0be0bf4d8e5c3d7d45ff5811d786b0821a08b82f511f1301696e12c9d 17157 src/main/unraid-deployment-service.cjs
|
||||
f0861356c1ff4ca361c7004c1f7d935858f51cc74335a7ad2136021f2e612220 36016 src/main/unraid-inventory-methods.cjs
|
||||
9e682411f73450f595b5cc4dfb28939c6c58b9547d0a91e287c3efb4955e8840 26299 src/main/unraid-preflight-methods.cjs
|
||||
0f95a26b7206603c4ea12d3ad4733b8d5730ca18a344ffad49e2a3ffe46a0699 37780 src/main/unraid-inventory-methods.cjs
|
||||
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
|
||||
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
|
||||
d4b3a07eeca687a49544a94ea574f7c6f7e0bc3aa9311b614d5244a468ca4a1b 11307 src/main/unraid-state-methods.cjs
|
||||
a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 src/main/update-service.cjs
|
||||
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
|
||||
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
|
||||
0f2070aa3b5c404aedf643837dfd7c5d547e8f45b2e9c97e4cfcf11951555474 17705 src/renderer/actions/deployment-profile.js
|
||||
33e65457e44708cecbe859b8c8a0c51b4ab33219c3b7a9f9dad884089f07d367 17904 src/renderer/actions/deployment-profile.js
|
||||
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
|
||||
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
|
||||
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
|
||||
@@ -178,10 +181,10 @@ eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924
|
||||
a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html
|
||||
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
||||
5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js
|
||||
b30f44e8e4285e40e7ef87474aeb34467a40dbedc9023a080deaa204ce84bbeb 20032 src/renderer/mock-repository-bridge.js
|
||||
4ace18f1756da5f9ec3e4cf40a141cf3268e796b1fd9d788bd3ddab3652c7c04 20032 src/renderer/mock-repository-bridge.js
|
||||
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
||||
1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css
|
||||
5230c238acb87381303ec30026906cf8a7163c33380859d351b268adfc26b4f4 100196 src/renderer/views.js
|
||||
4fbd3d7af3279a02c1da508758f6c7d9ca8de3d82f869be8439030ec81e8d941 101429 src/renderer/views.js
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
||||
@@ -195,7 +198,7 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436
|
||||
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
||||
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
|
||||
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
||||
b46c9624c20bae767e50b4ab85e6b0ea6a9ee4a5c5c30f55554217f25134f1e7 11218 tests/browser/forgeflow.spec.mjs
|
||||
9346a48e2328fc617b2b94caa876a1d60f0098896538224c04b8b4e4c2c023a4 11566 tests/browser/forgeflow.spec.mjs
|
||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
|
||||
ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
|
||||
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
|
||||
@@ -216,18 +219,18 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392
|
||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
||||
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
||||
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
||||
629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs
|
||||
d4980aa7d2d364793f38771011383348be472d5447b6ee88a4d9e5f3a3b3ffa9 9911 tests/renderer-workflow.test.mjs
|
||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
||||
62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs
|
||||
ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs
|
||||
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
|
||||
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
||||
e631e9ca49a5bac7075860aac2ff4d377a32a78377b70e06ecf833f0f192fd5f 11552 tests/server-inventory-branches.test.mjs
|
||||
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
||||
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||
4182b61e395aff310b9a964c973a43c3566df0b44c454054c3abdd9459e86e3b 49134 tests/unraid-deployment.test.mjs
|
||||
9c58c600d925db6a3ad526e661010c05619e24d61c9d19ec0e207a09ae4cb93a 23981 tests/update-service.test.mjs
|
||||
1c5b40fb0a915ab0448bb9d2394f626c7b12142d91c23393a2d7537b3ea411ab 50853 tests/unraid-deployment.test.mjs
|
||||
bf95071f8d8bec6dcf2c889f1f9d83be1b56178dc0d2be84da555848b6319cbc 25650 tests/update-service.test.mjs
|
||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -41,6 +41,10 @@ let diagnostics;
|
||||
let configStore;
|
||||
let tray;
|
||||
let quitCleanupStarted = false;
|
||||
const ownsSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!ownsSingleInstanceLock) app.quit();
|
||||
else app.on("second-instance", () => showMainWindow());
|
||||
|
||||
function broadcast(channel, payload) {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
@@ -171,6 +175,7 @@ function createWindow() {
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
if (!ownsSingleInstanceLock) return;
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.10.6",
|
||||
"version": "0.10.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.10.6",
|
||||
"version": "0.10.9",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.10.6",
|
||||
"version": "0.10.9",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -111,6 +111,9 @@
|
||||
"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/CURRENT_STATE.md",
|
||||
"docs/MUTATION_MODEL.md",
|
||||
"docs/RELEASING.md",
|
||||
|
||||
@@ -8,7 +8,8 @@ param(
|
||||
[Parameter(Mandatory = $true)][string]$LogPath,
|
||||
[Parameter(Mandatory = $true)][string]$StatusPath,
|
||||
[Parameter(Mandatory = $true)][string]$UpdateId,
|
||||
[switch]$HandshakeOnly
|
||||
[switch]$HandshakeOnly,
|
||||
[switch]$VerifyOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -51,6 +52,16 @@ function Write-UpdateState {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||
@@ -59,9 +70,13 @@ try {
|
||||
Write-Log "Handshake-only verification completed successfully."
|
||||
exit 0
|
||||
}
|
||||
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$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." }
|
||||
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 {
|
||||
|
||||
@@ -71,6 +71,17 @@ function Write-UpdateState {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -120,7 +131,7 @@ try {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
$actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$actualHash = Get-Sha256 -Path $ArchivePath
|
||||
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
||||
|
||||
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
||||
|
||||
@@ -117,7 +117,18 @@ app.whenReady().then(async () => {
|
||||
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, readiness: item.readiness || item.action || null, remoteSha: item.remoteSha || item.branchSha || null, liveSha: item.liveSha || null, error: item.error || null })),
|
||||
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));
|
||||
|
||||
+17
-2
@@ -86,6 +86,9 @@ const required = [
|
||||
"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/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
@@ -124,9 +127,9 @@ for (const file of required) await access(path.join(root, file));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (packageJson.version !== "0.10.6")
|
||||
if (packageJson.version !== "0.10.9")
|
||||
throw new Error(
|
||||
`Expected package version 0.10.6, got ${packageJson.version}.`,
|
||||
`Expected package version 0.10.9, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
@@ -464,6 +467,18 @@ const release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md
|
||||
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 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}`);
|
||||
|
||||
@@ -212,10 +212,19 @@ function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIden
|
||||
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 = failed ? (checks.some((item) => item.id.includes("access") || item.id.includes("key")) ? "Access failed" : checks.some((item) => item.id === "runtime" || item.id === "health") ? "Runtime unhealthy" : "Configuration required") : incomplete ? (branchSha && liveSha && branchSha !== liveSha ? "Commit mismatch" : "Verification incomplete") : "Ready";
|
||||
return { readiness, ready: readiness === "Ready" || readiness === "Commit mismatch", checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
|
||||
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) {
|
||||
|
||||
@@ -36,7 +36,11 @@ function createUnraidInventoryMethods({
|
||||
disappeared=0
|
||||
while IFS= read -r container_id; do
|
||||
[ -n "$container_id" ] || continue
|
||||
if inspect=$(docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"running":{{json .State.Running}},"status":{{json .State.Status}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}null{{end}},"labels":{{json .Config.Labels}},"ports":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}},"restartPolicy":{{json .HostConfig.RestartPolicy.Name}}}' "$container_id" 2>/dev/null); then
|
||||
# Use Docker's own JSON document instead of a Go template. Accessing an
|
||||
# absent .State.Health map key makes the formatted Docker inspect fail for
|
||||
# every container without a healthcheck, which previously made those
|
||||
# containers look as if they disappeared during the scan.
|
||||
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))
|
||||
@@ -384,15 +388,41 @@ function createUnraidInventoryMethods({
|
||||
};
|
||||
}
|
||||
|
||||
async scanServerInventory(serverId, repositories) {
|
||||
async scanServerInventory(serverId, repositories, { autoLink = false } = {}) {
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
const response = this.inventoryResponse(server, inventory, workloads);
|
||||
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.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,
|
||||
readOnly: true,
|
||||
adopted,
|
||||
adoptedLinks,
|
||||
readOnly: !autoLink,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -428,6 +458,7 @@ function createUnraidInventoryMethods({
|
||||
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,
|
||||
@@ -559,7 +590,7 @@ function createUnraidInventoryMethods({
|
||||
}
|
||||
|
||||
async discoverServerWorkloads(serverId, repositories) {
|
||||
return this.scanServerInventory(serverId, repositories);
|
||||
return this.scanServerInventory(serverId, repositories, { autoLink: true });
|
||||
}
|
||||
|
||||
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
|
||||
|
||||
@@ -283,6 +283,35 @@ function createUnraidPreflightMethods({
|
||||
}
|
||||
}
|
||||
|
||||
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 || {};
|
||||
|
||||
@@ -337,8 +337,13 @@ async function handleDeploymentProfileActions(event, target, action, repository)
|
||||
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
|
||||
ui.serverGitVerifications[profileId] = result;
|
||||
render();
|
||||
const failures = result.checks.filter((check) => check.status === "fail");
|
||||
showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning");
|
||||
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 {
|
||||
|
||||
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.10.6-demo",
|
||||
appVersion: "0.10.9-demo",
|
||||
platform: "win32",
|
||||
state: clone(state),
|
||||
git: { available: true, version: "git version 2.47.3" },
|
||||
|
||||
+13
-3
@@ -31,6 +31,9 @@ function renderTitlebar() {
|
||||
|
||||
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('<span class="mini-badge danger" title="Conflicts">!</span>');
|
||||
@@ -50,10 +53,14 @@ function renderRepositoryRow(repository) {
|
||||
badges.push(
|
||||
'<span class="mini-badge success" title="Ready to deploy">↗</span>',
|
||||
);
|
||||
if (profiles.length)
|
||||
badges.push(
|
||||
`<span class="mini-badge ${runningWorkloads.length ? "success" : "warning"} deployment-badge" title="${attr(`${profiles.length} server deployment${profiles.length === 1 ? "" : "s"} linked${runningWorkloads.length ? ` · ${runningWorkloads.length} running` : ""}`)}">S${profiles.length}</span>`,
|
||||
);
|
||||
if (!repository.localPath)
|
||||
badges.push('<span class="mini-badge" title="No local folder">—</span>');
|
||||
const branch = status?.branch.head || repository.defaultBranch || "remote";
|
||||
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}">
|
||||
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}" data-deployment-count="${profiles.length}">
|
||||
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
|
||||
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span>• ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
|
||||
<span class="repo-badges">${badges.join("")}</span>
|
||||
@@ -365,7 +372,7 @@ function renderProfileCard(repository, profile, compact = false) {
|
||||
const serverAccessAction = isSsh && mode === "server-git"
|
||||
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
|
||||
: "";
|
||||
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
||||
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong>${mode === "server-git" ? `<span>Server pull</span><strong class="${verification ? verification.deployReady ? "text-success" : "text-warning" : ""}">${escapeHtml(verification?.readiness || "Verify before deployment")}</strong>` : ""}<span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
||||
}
|
||||
function renderRepositoryDeployments(repository) {
|
||||
const profiles = repository.deploymentProfiles || [];
|
||||
@@ -432,6 +439,7 @@ function renderRepositoryWorkspace(repository) {
|
||||
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"
|
||||
@@ -452,6 +460,8 @@ function renderRepositoryWorkspace(repository) {
|
||||
? "danger"
|
||||
: serverState.healthy === true
|
||||
? "success"
|
||||
: profileWorkload?.runtime?.running
|
||||
? "success"
|
||||
: "";
|
||||
const content = (
|
||||
{
|
||||
@@ -474,7 +484,7 @@ function renderRepositoryWorkspace(repository) {
|
||||
: "";
|
||||
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
|
||||
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
|
||||
<div class="release-rail">${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) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
|
||||
<div class="release-rail">${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)}</div>
|
||||
${deploymentLinks}
|
||||
<nav class="tabs">${[
|
||||
["changes", "Changes"],
|
||||
|
||||
@@ -115,8 +115,12 @@ test("repository changes, Git tools and Git Validator complete their primary flo
|
||||
|
||||
test("every long application surface retains a working vertical scroll owner", async ({ page }) => {
|
||||
for (const view of ["overview", "deployments", "diagnostics", "settings"]) {
|
||||
await page.locator(`.nav-button[data-view="${view}"]`).click();
|
||||
await assertScrollableWhenOverflowing(page, ".main-canvas");
|
||||
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"]) {
|
||||
@@ -142,6 +146,8 @@ test("deployment inventory supports dense workloads without ambiguous blank card
|
||||
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");
|
||||
|
||||
@@ -10,6 +10,13 @@ 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),
|
||||
|
||||
@@ -141,4 +141,10 @@ test('legacy container sanitizer applies safe defaults to partial Docker inspect
|
||||
'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);
|
||||
});
|
||||
|
||||
@@ -112,6 +112,33 @@ test("server pull verification proves a repository-scoped read-only key and exac
|
||||
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 = {
|
||||
@@ -168,7 +195,7 @@ test("server workload inventory links running containers to exact Gitea checkout
|
||||
);
|
||||
});
|
||||
|
||||
test("server discovery is read-only and explicit reconciliation adopts a verified deployment", async () => {
|
||||
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 = {
|
||||
@@ -218,17 +245,15 @@ test("server discovery is read-only and explicit reconciliation adopts a verifie
|
||||
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
||||
},
|
||||
];
|
||||
const discovery = await service.discoverServerWorkloads("unraid", repositories);
|
||||
assert.equal(discovery.adopted, 0);
|
||||
assert.equal(discovery.verified, 0);
|
||||
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 preview = await service.planServerInventoryReconciliation("unraid", repositories, { autoLink: true });
|
||||
assert.equal(preview.plan.summary.additions, 1);
|
||||
const result = await service.reconcileServerInventory("unraid", repositories, { autoLink: true, expectedPlanId: preview.plan.id });
|
||||
assert.equal(result.adopted, 1);
|
||||
assert.equal(result.verified, 1);
|
||||
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);
|
||||
@@ -1073,6 +1098,8 @@ test("inventory scan uses only configured roots and reports partial find failure
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -1296,7 +1323,10 @@ test("server-pull preflight resolves Gitea SHA and reports every degraded capabi
|
||||
},
|
||||
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 } }) },
|
||||
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 });
|
||||
@@ -1310,6 +1340,8 @@ test("server-pull preflight resolves Gitea SHA and reports every degraded capabi
|
||||
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");
|
||||
|
||||
@@ -410,6 +410,29 @@ test("binary helper confirms startup through the production Node spawn options",
|
||||
}
|
||||
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-"),
|
||||
@@ -626,7 +649,7 @@ test("binary update helper verifies, waits, applies and records restart state",
|
||||
"utf8",
|
||||
);
|
||||
for (const marker of [
|
||||
"Get-FileHash",
|
||||
"Security.Cryptography.SHA256",
|
||||
"Wait-Process",
|
||||
'Write-UpdateState -State "started"',
|
||||
'Write-UpdateState -State "waiting-for-exit"',
|
||||
@@ -640,4 +663,5 @@ test("binary update helper verifies, waits, applies and records restart state",
|
||||
`missing binary updater marker: ${marker}`,
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(helper, /Get-FileHash/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user