From 293980aee00683aff1520c03d8b69aa5d0eb99a7 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Thu, 30 Apr 2026 19:30:40 +0200 Subject: [PATCH] fix(cli): build node ui during blue-green update --- RELEASE_PROCESS.md | 4 + docs/testing/AUTO_UPDATE_LOCAL_TESTING.md | 4 + packages/cli/src/cli.ts | 14 +- packages/cli/src/daemon/auto-update.ts | 62 +++- packages/cli/src/migration.ts | 252 +++++++++++++--- packages/cli/src/node-ui-static.ts | 102 +++++++ packages/cli/src/rollback-node-ui.ts | 103 +++++++ packages/cli/test/auto-update.test.ts | 156 +++++++++- packages/cli/test/migration.test.ts | 320 +++++++++++++++++++-- packages/cli/test/node-ui-static.test.ts | 36 +++ packages/cli/test/rollback-node-ui.test.ts | 123 ++++++++ 11 files changed, 1117 insertions(+), 59 deletions(-) create mode 100644 packages/cli/src/node-ui-static.ts create mode 100644 packages/cli/src/rollback-node-ui.ts create mode 100644 packages/cli/test/node-ui-static.test.ts create mode 100644 packages/cli/test/rollback-node-ui.test.ts diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index a63c65349..beb3d51c0 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -128,6 +128,8 @@ dkg update 9.0.0-beta.2 --allow-prerelease --no-verify-tag ## 7) Post-update verification +`build:runtime` is a runtime/package build. Git-based blue-green updates run the Node UI `build:ui` step separately inside the target release slot and require `packages/node-ui/dist-ui/index.html` before activation. + After each update: ```bash @@ -136,6 +138,8 @@ cat "$DKG_HOME/releases/active" cat "$DKG_HOME/.current-commit" cat "$DKG_HOME/.current-version" test ! -f "$DKG_HOME/.update-pending.json" && echo "pending state cleared" +SLOT="$(readlink -f "$DKG_HOME/releases/current")" +test -f "$SLOT/packages/node-ui/dist-ui/index.html" && echo "Node UI static bundle ready" ``` ## 8) Rollback diff --git a/docs/testing/AUTO_UPDATE_LOCAL_TESTING.md b/docs/testing/AUTO_UPDATE_LOCAL_TESTING.md index 1357fa0cf..3b9da5a6f 100644 --- a/docs/testing/AUTO_UPDATE_LOCAL_TESTING.md +++ b/docs/testing/AUTO_UPDATE_LOCAL_TESTING.md @@ -74,12 +74,16 @@ DKG_HOME="$DKG_HOME" dkg update 9.0.5 --no-verify-tag ## 5) Validate swap and metadata after each update +Git-based blue-green updates keep `build:runtime` runtime-focused, then build the Node UI static bundle in the target slot before swapping. + ```bash readlink "$DKG_HOME/releases/current" cat "$DKG_HOME/releases/active" cat "$DKG_HOME/.current-commit" cat "$DKG_HOME/.current-version" test ! -f "$DKG_HOME/.update-pending.json" && echo "pending state cleared" +SLOT="$(readlink -f "$DKG_HOME/releases/current")" +test -f "$SLOT/packages/node-ui/dist-ui/index.html" && echo "Node UI static bundle ready" ``` ## 6) Rollback test diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3d96016e3..3aa6ddefd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -53,6 +53,7 @@ import { DAEMON_EXIT_CODE_RESTART, } from './daemon.js'; import { migrateToBlueGreen } from './migration.js'; +import { ensureRollbackNodeUiBundle } from './rollback-node-ui.js'; import { registerIntegrationCommands } from './integrations/commands.js'; /** Commander action callbacks receive parsed .option() values with loose types. */ @@ -544,7 +545,10 @@ program // Keep blue-green slots initialized for both foreground and daemonized start. if (!process.env.DKG_NO_BLUE_GREEN) { - await migrateToBlueGreen((msg) => console.log(msg), { allowRemoteBootstrap: false }); + await migrateToBlueGreen((msg) => console.log(msg), { + allowRemoteBootstrap: false, + repairLiveNodeUi: true, + }); } if (opts.foreground) { @@ -2924,7 +2928,10 @@ program return; } - await migrateToBlueGreen((msg) => console.log(msg), { allowRemoteBootstrap: true }); + await migrateToBlueGreen((msg) => console.log(msg), { + allowRemoteBootstrap: true, + repairLiveNodeUi: false, + }); console.log('Checking for updates and applying...'); try { const updateStatus = await performUpdateWithStatus(au, (msg) => console.log(msg), { @@ -2988,6 +2995,9 @@ program console.error(`Slot ${target} has no build output. Run "dkg update" first to prepare it.`); process.exit(1); } + if (!ensureRollbackNodeUiBundle(targetDir, target)) { + process.exit(1); + } const pid = await readPid(); if (pid && isProcessRunning(pid)) { diff --git a/packages/cli/src/daemon/auto-update.ts b/packages/cli/src/daemon/auto-update.ts index 38941c689..20f2a496c 100644 --- a/packages/cli/src/daemon/auto-update.ts +++ b/packages/cli/src/daemon/auto-update.ts @@ -45,6 +45,16 @@ import { expectedBundledMarkItDownBuildMetadata, readCliPackageVersion, } from '../extraction/markitdown-bundle-metadata.js'; +import { + NODE_UI_PACKAGE_NAME_FALLBACKS, + nodeUiPackageJsonPath, + nodeUiPackageNamesFromCliPackageJson, + nodeUiPackageNameFromPackageJson, + nodeUiNpmStaticIndexPaths, + nodeUiStaticBuildCommand, + nodeUiStaticBuildLabel, + nodeUiStaticIndexPath, +} from '../node-ui-static.js'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -466,6 +476,22 @@ async function _performNpmUpdateInner( log(`Auto-update (npm): entry point missing after install. Aborting swap.`); return "failed"; } + let npmNodeUiPackageNames = NODE_UI_PACKAGE_NAME_FALLBACKS; + try { + npmNodeUiPackageNames = nodeUiPackageNamesFromCliPackageJson( + await readFile(join(npmPkgDir, "package.json"), "utf-8"), + ); + } catch { + // Older packages or damaged installs may not expose readable metadata. + } + const npmNodeUiIndexes = nodeUiNpmStaticIndexPaths(targetDir, npmNodeUiPackageNames); + if (!npmNodeUiIndexes.some((indexFile) => existsSync(indexFile))) { + log( + `Auto-update (npm): Node UI static bundle missing after install ` + + `(${npmNodeUiIndexes.join(', ')}). Aborting swap.`, + ); + return "failed"; + } let resolvedVersion = readCliPackageVersion(npmPkgDir); if (!resolvedVersion) { resolvedVersion = targetVersion; @@ -859,8 +885,9 @@ async function cleanGeneratedOutputs( const cliPkgDir = join(packagesDir, 'cli'); await rm(join(cliPkgDir, 'network'), { recursive: true, force: true }); await rm(join(cliPkgDir, 'project.json'), { force: true }); + await rm(dirname(nodeUiStaticIndexPath(targetDir)), { recursive: true, force: true }); log( - `Auto-update: cleared stale dist/ (${removedDist} pkgs) + tsconfig.tsbuildinfo (${removedTsBuildInfo} pkgs) + cli/network/ + cli/project.json before build (incremental caches preserved).`, + `Auto-update: cleared stale dist/ (${removedDist} pkgs) + tsconfig.tsbuildinfo (${removedTsBuildInfo} pkgs) + cli/network/ + cli/project.json + node-ui/dist-ui before build (incremental caches preserved).`, ); } catch (primaryErr: any) { log( @@ -1329,6 +1356,32 @@ async function _performUpdateInner( } } + let nodeUiPackageNames = NODE_UI_PACKAGE_NAME_FALLBACKS; + try { + nodeUiPackageNames = [nodeUiPackageNameFromPackageJson( + await readFile(nodeUiPackageJsonPath(targetDir), 'utf-8'), + )]; + } catch { + // Older/broken refs may still have a buildable UI; try known workspace names below. + } + for (let i = 0; i < nodeUiPackageNames.length; i++) { + const nodeUiPackageName = nodeUiPackageNames[i]; + try { + await runBuildStep(execAsync, nodeUiStaticBuildCommand(nodeUiPackageName), { + cwd: targetDir, + timeoutMs: timeouts.build, + label: nodeUiStaticBuildLabel(nodeUiPackageName), + log, + }); + break; + } catch (err) { + if (i === nodeUiPackageNames.length - 1) throw err; + log( + `Auto-update: ${nodeUiStaticBuildLabel(nodeUiPackageName)} failed; trying ${nodeUiStaticBuildLabel(nodeUiPackageNames[i + 1])}.`, + ); + } + } + log("Auto-update: staging MarkItDown binary for the inactive slot..."); try { await runBuildStep( @@ -1358,6 +1411,13 @@ async function _performUpdateInner( log(`Auto-update: build output missing (${entryFile}). Aborting swap.`); return "failed"; } + const nodeUiIndexFile = nodeUiStaticIndexPath(targetDir); + if (!existsSync(nodeUiIndexFile)) { + log( + `Auto-update: Node UI static bundle missing (${nodeUiIndexFile}). Aborting swap.`, + ); + return "failed"; + } const bundledMarkItDownAsset = currentBundledMarkItDownAssetName(); if (bundledMarkItDownAsset) { const bundledMarkItDownPath = join( diff --git a/packages/cli/src/migration.ts b/packages/cli/src/migration.ts index dc58ac105..64dd21bbb 100644 --- a/packages/cli/src/migration.ts +++ b/packages/cli/src/migration.ts @@ -1,8 +1,66 @@ -import { existsSync, lstatSync } from 'node:fs'; -import { mkdir, rm, readFile } from 'node:fs/promises'; +import { existsSync, lstatSync, readFileSync } from 'node:fs'; +import { mkdir, rm, readFile, readlink } from 'node:fs/promises'; import { join } from 'node:path'; import { execSync, execFileSync } from 'node:child_process'; import { releasesDir, repoDir, swapSlot, loadConfig, loadNetworkConfig, loadProjectConfig, gitCommandEnv, gitCommandArgs, slotReady } from './config.js'; +import { + isNodeUiGitLayoutSlot, + NODE_UI_PACKAGE_NAME_FALLBACKS, + nodeUiPackageJsonPath, + nodeUiPackageNamesFromCliPackageJson, + nodeUiPackageNameFromPackageJson, + nodeUiNpmStaticIndexPaths, + nodeUiStaticBuildCommand, + nodeUiStaticIndexPath, +} from './node-ui-static.js'; + +function npmCliPackageJsonPath(slotDir: string): string { + return join( + slotDir, + 'node_modules', + '@origintrail-official', + 'dkg', + 'package.json', + ); +} + +function nodeUiPackageNamesForNpmSlot(slotDir: string): string[] { + try { + return nodeUiPackageNamesFromCliPackageJson( + readFileSync(npmCliPackageJsonPath(slotDir), 'utf-8'), + ); + } catch { + return NODE_UI_PACKAGE_NAME_FALLBACKS; + } +} + +function npmNodeUiStaticIndexPaths(slotDir: string): string[] { + return nodeUiNpmStaticIndexPaths(slotDir, nodeUiPackageNamesForNpmSlot(slotDir)); +} + +function hasNpmNodeUiStaticBundle(slotDir: string): boolean { + return npmNodeUiStaticIndexPaths(slotDir).some((indexFile) => existsSync(indexFile)); +} + +function hasRequiredNodeUiStaticBundle(slotDir: string): boolean { + if (isNodeUiGitLayoutSlot(slotDir)) { + return existsSync(nodeUiStaticIndexPath(slotDir)); + } + return hasNpmNodeUiStaticBundle(slotDir); +} + +function assertNodeUiStaticBundle(slotDir: string): void { + const indexFile = nodeUiStaticIndexPath(slotDir); + if (!existsSync(indexFile)) { + throw new Error(`Node UI static bundle missing (${indexFile})`); + } +} + +function assertNpmNodeUiStaticBundle(slotDir: string): void { + if (!hasNpmNodeUiStaticBundle(slotDir)) { + throw new Error(`Node UI static bundle missing (${npmNodeUiStaticIndexPaths(slotDir).join(', ')})`); + } +} export const _migrationIo = { execSync: execSync as (...args: any[]) => any, @@ -10,6 +68,7 @@ export const _migrationIo = { repoDir: repoDir as () => string | null, loadConfig: loadConfig as () => Promise, loadNetworkConfig: loadNetworkConfig as () => Promise, + swapSlot: swapSlot as (slot: 'a' | 'b') => Promise, }; /** @@ -18,9 +77,10 @@ export const _migrationIo = { */ export async function migrateToBlueGreen( log: (msg: string) => void = console.log, - opts: { allowRemoteBootstrap?: boolean } = {}, + opts: { allowRemoteBootstrap?: boolean; repairLiveNodeUi?: boolean } = {}, ): Promise { - const { execSync, execFileSync, repoDir, loadConfig, loadNetworkConfig } = _migrationIo; + const { execSync, execFileSync, repoDir, loadConfig, loadNetworkConfig, swapSlot: swapActiveSlot } = _migrationIo; + const repairLiveNodeUi = opts.repairLiveNodeUi ?? true; const INSTALL_TIMEOUT_MS = 10 * 60_000; const BUILD_TIMEOUT_MS = 15 * 60_000; const rDir = releasesDir(); @@ -79,10 +139,136 @@ export async function migrateToBlueGreen( const slotA = join(rDir, 'a'); const slotB = join(rDir, 'b'); - if (hadCurrentLink && slotReady(slotA) && slotReady(slotB)) return; + if ( + hadCurrentLink && + slotReady(slotA) && + slotReady(slotB) && + hasRequiredNodeUiStaticBundle(slotA) && + hasRequiredNodeUiStaticBundle(slotB) + ) { + return; + } log('Migrating to blue-green release slots...'); + const runSlotCommand = (cmd: string, cwd: string, timeout: number): void => { + execSync(cmd, { + cwd, + encoding: 'utf-8', + stdio: 'pipe', + timeout, + }); + }; + + const resolveNodeUiPackageNames = (slotDir: string): string[] => { + try { + return [nodeUiPackageNameFromPackageJson( + readFileSync(nodeUiPackageJsonPath(slotDir), 'utf-8'), + )]; + } catch { + return NODE_UI_PACKAGE_NAME_FALLBACKS; + } + }; + + const runNodeUiStaticBuild = (slotDir: string): void => { + let lastError: unknown; + for (const packageName of resolveNodeUiPackageNames(slotDir)) { + try { + runSlotCommand( + nodeUiStaticBuildCommand(packageName), + slotDir, + BUILD_TIMEOUT_MS, + ); + return; + } catch (err) { + lastError = err; + } + } + throw lastError; + }; + + const buildRuntimeAndNodeUi = (slotDir: string): void => { + runSlotCommand('pnpm build:runtime', slotDir, BUILD_TIMEOUT_MS); + runNodeUiStaticBuild(slotDir); + assertNodeUiStaticBundle(slotDir); + }; + + const repairNodeUiStaticBundle = (slotDir: string): void => { + if (isNodeUiGitLayoutSlot(slotDir)) { + runNodeUiStaticBuild(slotDir); + assertNodeUiStaticBundle(slotDir); + return; + } + + assertNpmNodeUiStaticBundle(slotDir); + }; + + const activeSlotFromCurrent = async (): Promise<'a' | 'b' | null> => { + try { + const target = (await readlink(currentLink)).trim().split(/[\\/]/).pop(); + if (target === 'a' || target === 'b') return target; + } catch { + // Fall back to active metadata below. + } + try { + const activeRaw = (await readFile(join(rDir, 'active'), 'utf-8')).trim(); + if (activeRaw === 'a' || activeRaw === 'b') return activeRaw; + } catch { + return null; + } + return null; + }; + + const slotReadyWithNodeUi = (label: 'a' | 'b'): boolean => { + const slotDir = label === 'a' ? slotA : slotB; + return slotReady(slotDir) && hasRequiredNodeUiStaticBundle(slotDir); + }; + + const hasRestorableSlot = (): boolean => + slotReadyWithNodeUi('a') || slotReadyWithNodeUi('b'); + let noCurrentRepairError: unknown; + + const ensureNodeUiBundle = ( + slotDir: string, + label: 'a' | 'b', + liveSlot: 'a' | 'b' | null, + ): void => { + if (!slotReady(slotDir) || hasRequiredNodeUiStaticBundle(slotDir)) return; + if (liveSlot === label) { + if (!repairLiveNodeUi) { + log(` Slot ${label}: Node UI static bundle missing in active slot; leaving live slot untouched.`); + return; + } + log(` Slot ${label}: Node UI static bundle missing in active slot; rebuilding UI assets in place.`); + } else { + log(` Slot ${label}: Node UI static bundle missing; building UI assets.`); + } + try { + repairNodeUiStaticBundle(slotDir); + log(` Slot ${label}: Node UI static bundle built`); + } catch (err: any) { + if (liveSlot === label) { + log( + ` Slot ${label}: Node UI static bundle repair failed (${err?.message ?? String(err)}). ` + + 'Continuing startup with the existing Node UI fallback page.', + ); + return; + } + if (!liveSlot) { + noCurrentRepairError ??= err; + log( + ` Slot ${label}: Node UI static bundle repair failed (${err?.message ?? String(err)}). ` + + 'Trying remaining slots before selecting an initial slot.', + ); + return; + } + log( + ` Slot ${label}: Node UI static bundle repair failed (${err?.message ?? String(err)}). ` + + 'Leaving inactive slot for the next update to rebuild.', + ); + } + }; + const git = (args: string[], cwd?: string, repoUrl?: string): string => String(execFileSync('git', [...gitCommandArgs(repoUrl, config?.autoUpdate ?? network?.autoUpdate), ...args], { encoding: 'utf-8', @@ -99,18 +285,8 @@ export async function migrateToBlueGreen( } else { git(['clone', '--branch', sourceBranch, sourceRepo, slotA], undefined, sourceRepo); } - execSync('pnpm install --frozen-lockfile', { - cwd: slotA, - encoding: 'utf-8', - stdio: 'pipe', - timeout: INSTALL_TIMEOUT_MS, - }); - execSync('pnpm build:runtime', { - cwd: slotA, - encoding: 'utf-8', - stdio: 'pipe', - timeout: BUILD_TIMEOUT_MS, - }); + runSlotCommand('pnpm install --frozen-lockfile', slotA, INSTALL_TIMEOUT_MS); + buildRuntimeAndNodeUi(slotA); log(` Slot a: cloned and built from ${hasLocalRepo ? localRepo : sourceRepo}`); } @@ -127,18 +303,8 @@ export async function migrateToBlueGreen( if (!hasLocalRepo) { git(['checkout', sourceBranch], slotB); } - execSync('pnpm install --frozen-lockfile', { - cwd: slotB, - encoding: 'utf-8', - stdio: 'pipe', - timeout: INSTALL_TIMEOUT_MS, - }); - execSync('pnpm build:runtime', { - cwd: slotB, - encoding: 'utf-8', - stdio: 'pipe', - timeout: BUILD_TIMEOUT_MS, - }); + runSlotCommand('pnpm install --frozen-lockfile', slotB, INSTALL_TIMEOUT_MS); + buildRuntimeAndNodeUi(slotB); log(` Slot b: cloned and built`); } catch (err: any) { log(` Slot b: clone/build failed (${err.message}). Retrying clone only.`); @@ -157,17 +323,39 @@ export async function migrateToBlueGreen( } } + const liveSlot = hadCurrentLink ? await activeSlotFromCurrent() : null; + ensureNodeUiBundle(slotA, 'a', liveSlot); + ensureNodeUiBundle(slotB, 'b', liveSlot); + + if (hadCurrentLink && liveSlot && !slotReadyWithNodeUi(liveSlot)) { + if (!repairLiveNodeUi) { + log(`Migration complete: active slot ${liveSlot} is missing Node UI static bundle; live slot left untouched.`); + return; + } + log( + `Migration complete: active slot ${liveSlot} is missing Node UI static bundle; ` + + 'startup will continue with the existing Node UI fallback page.', + ); + return; + } + if (!hadCurrentLink) { - let initialSlot: 'a' | 'b' = 'a'; + let initialSlot: 'a' | 'b' | null = null; try { const activeRaw = (await readFile(join(rDir, 'active'), 'utf-8')).trim(); - if ((activeRaw === 'a' || activeRaw === 'b') && slotReady(join(rDir, activeRaw))) { + if ((activeRaw === 'a' || activeRaw === 'b') && slotReadyWithNodeUi(activeRaw)) { initialSlot = activeRaw; } } catch { // No prior active metadata; default to a. } - await swapSlot(initialSlot); + initialSlot ??= slotReadyWithNodeUi('a') ? 'a' : null; + initialSlot ??= slotReadyWithNodeUi('b') ? 'b' : null; + if (!initialSlot) { + if (noCurrentRepairError) throw noCurrentRepairError; + throw new Error('No blue-green slot has the required Node UI static bundle'); + } + await swapActiveSlot(initialSlot); log(`Migration complete: releases/current → ${initialSlot}`); return; } diff --git a/packages/cli/src/node-ui-static.ts b/packages/cli/src/node-ui-static.ts new file mode 100644 index 000000000..8305f0a78 --- /dev/null +++ b/packages/cli/src/node-ui-static.ts @@ -0,0 +1,102 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +export const DEFAULT_NODE_UI_PACKAGE_NAME = '@origintrail-official/dkg-node-ui'; +export const LEGACY_NODE_UI_PACKAGE_NAME = '@dkg/node-ui'; +export const NODE_UI_PACKAGE_NAME_FALLBACKS = [ + DEFAULT_NODE_UI_PACKAGE_NAME, + LEGACY_NODE_UI_PACKAGE_NAME, +]; + +export const NODE_UI_STATIC_BUILD_COMMAND = nodeUiStaticBuildCommand(); + +export const NODE_UI_STATIC_BUILD_LABEL = nodeUiStaticBuildLabel(); + +export function nodeUiPackageJsonPath(slotDir: string): string { + return join(slotDir, 'packages', 'node-ui', 'package.json'); +} + +export function nodeUiStaticIndexPath(slotDir: string): string { + return join(slotDir, 'packages', 'node-ui', 'dist-ui', 'index.html'); +} + +export function nodeUiStaticIndexPaths(slotDir: string): string[] { + return [ + nodeUiStaticIndexPath(slotDir), + ...nodeUiNpmStaticIndexPaths(slotDir), + ]; +} + +export function nodeUiNpmStaticIndexPaths( + slotDir: string, + packageNames = NODE_UI_PACKAGE_NAME_FALLBACKS, +): string[] { + return packageNames.flatMap((packageName) => { + const packagePath = packageName.split('/'); + return [ + join(slotDir, 'node_modules', ...packagePath, 'dist-ui', 'index.html'), + join( + slotDir, + 'node_modules', + '@origintrail-official', + 'dkg', + 'node_modules', + ...packagePath, + 'dist-ui', + 'index.html', + ), + ]; + }); +} + +export function isNodeUiGitLayoutSlot( + slotDir: string, + pathExists: (path: string) => boolean = existsSync, +): boolean { + return pathExists(nodeUiPackageJsonPath(slotDir)) + || pathExists(join(slotDir, 'packages', 'cli', 'dist', 'cli.js')); +} + +export function nodeUiStaticBuildCommand( + packageName = DEFAULT_NODE_UI_PACKAGE_NAME, +): string { + return `pnpm --filter ${packageName} run build:ui`; +} + +export function nodeUiStaticBuildLabel( + packageName = DEFAULT_NODE_UI_PACKAGE_NAME, +): string { + return `pnpm --filter ${packageName} build:ui`; +} + +export function nodeUiPackageNameFromPackageJson(raw: string): string { + try { + const name = String((JSON.parse(raw) as { name?: unknown }).name ?? '').trim(); + return name || DEFAULT_NODE_UI_PACKAGE_NAME; + } catch { + return DEFAULT_NODE_UI_PACKAGE_NAME; + } +} + +export function nodeUiPackageNamesFromCliPackageJson(raw: string): string[] { + try { + const parsed = JSON.parse(raw) as { + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + }; + const dependencySets = [ + parsed.dependencies, + parsed.optionalDependencies, + parsed.peerDependencies, + ]; + const declared = NODE_UI_PACKAGE_NAME_FALLBACKS.filter((packageName) => + dependencySets.some((deps) => + deps && Object.prototype.hasOwnProperty.call(deps, packageName), + ), + ); + return declared.length > 0 ? declared : NODE_UI_PACKAGE_NAME_FALLBACKS; + } catch { + return NODE_UI_PACKAGE_NAME_FALLBACKS; + } +} diff --git a/packages/cli/src/rollback-node-ui.ts b/packages/cli/src/rollback-node-ui.ts new file mode 100644 index 000000000..ffa3e2cdb --- /dev/null +++ b/packages/cli/src/rollback-node-ui.ts @@ -0,0 +1,103 @@ +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { toErrorMessage } from '@origintrail-official/dkg-core'; +import { + isNodeUiGitLayoutSlot, + NODE_UI_PACKAGE_NAME_FALLBACKS, + nodeUiPackageJsonPath, + nodeUiPackageNamesFromCliPackageJson, + nodeUiPackageNameFromPackageJson, + nodeUiNpmStaticIndexPaths, + nodeUiStaticBuildCommand, + nodeUiStaticBuildLabel, + nodeUiStaticIndexPath, +} from './node-ui-static.js'; + +const ROLLBACK_UI_BUILD_TIMEOUT_MS = 15 * 60_000; + +export interface RollbackNodeUiIo { + existsSync: typeof existsSync; + readFileSync: typeof readFileSync; + execSync: typeof execSync; + log: (message: string) => void; + error: (message: string) => void; +} + +const defaultIo: RollbackNodeUiIo = { + existsSync, + readFileSync, + execSync, + log: console.log, + error: console.error, +}; + +function nodeUiPackageNamesForRollback(slotDir: string, io: RollbackNodeUiIo): string[] { + try { + return [nodeUiPackageNameFromPackageJson( + io.readFileSync(nodeUiPackageJsonPath(slotDir), 'utf-8'), + )]; + } catch { + return NODE_UI_PACKAGE_NAME_FALLBACKS; + } +} + +function nodeUiPackageNamesForNpmSlot(slotDir: string, io: RollbackNodeUiIo): string[] { + try { + return nodeUiPackageNamesFromCliPackageJson( + io.readFileSync(join( + slotDir, + 'node_modules', + '@origintrail-official', + 'dkg', + 'package.json', + ), 'utf-8'), + ); + } catch { + return NODE_UI_PACKAGE_NAME_FALLBACKS; + } +} + +export function ensureRollbackNodeUiBundle( + slotDir: string, + target: 'a' | 'b', + io: RollbackNodeUiIo = defaultIo, +): boolean { + const gitIndex = nodeUiStaticIndexPath(slotDir); + const isGitSlot = isNodeUiGitLayoutSlot(slotDir, io.existsSync); + + if (!isGitSlot) { + const npmCandidateIndexes = nodeUiNpmStaticIndexPaths( + slotDir, + nodeUiPackageNamesForNpmSlot(slotDir, io), + ); + if (npmCandidateIndexes.some((indexFile) => io.existsSync(indexFile))) return true; + io.error(`Slot ${target} has no Node UI static bundle (${npmCandidateIndexes.join(', ')}). Run "dkg update" first to prepare it.`); + return false; + } + + if (io.existsSync(gitIndex)) return true; + io.log(`Slot ${target} has no Node UI static bundle; building UI assets before rollback...`); + let lastError: unknown; + const packageNames = nodeUiPackageNamesForRollback(slotDir, io); + for (const packageName of packageNames) { + try { + io.execSync(nodeUiStaticBuildCommand(packageName), { + cwd: slotDir, + encoding: 'utf-8', + stdio: 'pipe', + timeout: ROLLBACK_UI_BUILD_TIMEOUT_MS, + }); + if (io.existsSync(gitIndex)) return true; + lastError = new Error(`Node UI static bundle missing (${gitIndex})`); + } catch (err) { + lastError = err; + } + } + io.error( + `Rollback aborted: failed to build Node UI static bundle for slot ${target} ` + + `with ${packageNames.map(nodeUiStaticBuildLabel).join(', ')} ` + + `(${toErrorMessage(lastError)}).`, + ); + return false; +} diff --git a/packages/cli/test/auto-update.test.ts b/packages/cli/test/auto-update.test.ts index e2bd575f5..3030358a3 100644 --- a/packages/cli/test/auto-update.test.ts +++ b/packages/cli/test/auto-update.test.ts @@ -18,6 +18,8 @@ const MOCK_BUNDLER_SCRIPT = [ "export const MARKITDOWN_UPSTREAM_VERSION = '0.1.5';", "export const PYINSTALLER_VERSION = '6.19.0';", ].join('\n'); +const NODE_UI_BUILD_CMD = 'pnpm --filter @origintrail-official/dkg-node-ui run build:ui'; +const LEGACY_NODE_UI_BUILD_CMD = 'pnpm --filter @dkg/node-ui run build:ui'; let mockBundledCliPackageVersion = CLI_VERSION; let mockInstalledPackageVersion = '9.0.0-beta.4-dev.100.abc1234'; @@ -213,6 +215,30 @@ function makeFetchOk(sha: string) { }); } +function mockGitUpdateReadFile( + currentCommit = 'aaa111', + cliVersion = '9.0.0', + nodeUiPackageName = '@origintrail-official/dkg-node-ui', +) { + readFileImpl = async (path: any) => { + const p = normalizePathString(path); + if (p.endsWith('/packages/node-ui/package.json')) { + return JSON.stringify({ + name: nodeUiPackageName, + scripts: { 'build:ui': 'vite build' }, + }); + } + if (p.endsWith('/package.json') && !p.endsWith('/packages/cli/package.json')) { + return JSON.stringify({ scripts: { 'build:runtime': 'pnpm build:runtime' } }); + } + if (p.endsWith('/packages/cli/package.json')) { + return JSON.stringify({ version: cliVersion }); + } + if (p.endsWith('.update-pending.json')) throw new Error('ENOENT'); + return currentCommit; + }; +} + function getExecCalls() { return execCalls.map(c => ({ cmd: c.cmd, @@ -344,6 +370,7 @@ describe('blue-green checkForUpdate', () => { expect(gitCmds.some(c => c.file === 'git' && c.args[0] === 'checkout' && c.cwd === targetDir)).toBe(true); expect(allCmds.some(c => c.cmd.includes('pnpm install') && c.cwd === targetDir)).toBe(true); expect(allCmds.some(c => c.cmd.includes('pnpm build') && c.cwd === targetDir)).toBe(true); + expect(allCmds.some(c => c.cmd === NODE_UI_BUILD_CMD && c.cwd === targetDir)).toBe(true); expect(allCmds.some(c => c.cmd.includes('bundle-markitdown-binaries.mjs') && c.cwd === targetDir)).toBe(true); expect(allCmds.some(c => c.cmd.includes('--force') && c.cwd === targetDir)).toBe(false); expect(allCmds.some(c => c.cmd.includes('--best-effort') && c.cwd === targetDir)).toBe(true); @@ -353,6 +380,46 @@ describe('blue-green checkForUpdate', () => { expect(allCmds.every(c => c.cwd !== activeDir)).toBe(true); }); + it('runs the Node UI static build after the runtime build before swapping', async () => { + const current = 'aaa111'; + const latest = 'bbb224'; + mockGitUpdateReadFile(current); + makeFetchOk(latest); + + const result = await performUpdate(AU, () => {}); + expect(result).toBe(true); + + const targetDir = '/tmp/dkg-test/releases/b'; + const allCmds = getExecCalls().filter(c => c.cwd === targetDir).map(c => c.cmd); + const runtimeIdx = allCmds.indexOf('pnpm build:runtime'); + const uiIdx = allCmds.indexOf(NODE_UI_BUILD_CMD); + expect(runtimeIdx).toBeGreaterThanOrEqual(0); + expect(uiIdx).toBeGreaterThan(runtimeIdx); + expect(swapSlotCalls).toContain('b'); + expect(rmCalls.some(([p]) => + normalizePathString(p).endsWith('/packages/node-ui/dist-ui'), + )).toBe(true); + expect(existsSyncCalls.some(([p]) => + normalizePathString(p).endsWith('/packages/node-ui/dist-ui/index.html') + )).toBe(true); + }); + + it('uses the Node UI workspace package name from the target slot', async () => { + const current = 'aaa111'; + const latest = 'bbb225'; + mockGitUpdateReadFile(current, '9.0.0-beta.2', '@dkg/node-ui'); + makeFetchOk(latest); + + const result = await performUpdate(AU, () => {}); + expect(result).toBe(true); + + const targetDir = '/tmp/dkg-test/releases/b'; + const allCmds = getExecCalls().filter(c => c.cwd === targetDir).map(c => c.cmd); + expect(allCmds).toContain(LEGACY_NODE_UI_BUILD_CMD); + expect(allCmds).not.toContain(NODE_UI_BUILD_CMD); + expect(swapSlotCalls).toContain('b'); + }); + it('continues the update when MarkItDown staging fails inside the best-effort git-update step', async () => { const current = 'aaa111'; const latest = 'bbb223'; @@ -509,6 +576,40 @@ describe('blue-green checkForUpdate', () => { expect(logCalls.some(m => m.includes('build output missing'))).toBe(true); }); + it('aborts swap when the git Node UI static bundle is missing after build', async () => { + readFileImpl = async () => 'aaa111'; + makeFetchOk('newcommit'); + + existsSyncImpl = (p: any) => { + const path = normalizePathString(p); + if (path.endsWith('/packages/node-ui/dist-ui/index.html')) return false; + return true; + }; + + const logCalls: string[] = []; + const log = (msg: string) => { logCalls.push(msg); }; + const result = await performUpdate(AU, log); + expect(result).toBe(false); + expect(swapSlotCalls.length).toBe(0); + expect(logCalls.some(m => m.includes('Node UI static bundle missing'))).toBe(true); + }); + + it('does not swap when the Node UI static build fails', async () => { + readFileImpl = async () => 'aaa111'; + makeFetchOk('newcommit'); + execImpl = async (cmd: string) => { + if (cmd === NODE_UI_BUILD_CMD) throw new Error('vite exploded'); + return { stdout: '', stderr: '' }; + }; + + const logCalls: string[] = []; + const log = (msg: string) => { logCalls.push(msg); }; + const result = await performUpdate(AU, log); + expect(result).toBe(false); + expect(swapSlotCalls.length).toBe(0); + expect(logCalls.some(m => m.includes('build failed') && m.includes('vite exploded'))).toBe(true); + }); + it('continues the swap when the bundled MarkItDown binary is missing after build', async () => { readFileImpl = async () => 'aaa111'; makeFetchOk('newcommit'); @@ -978,6 +1079,55 @@ describe('performNpmUpdate', () => { expect(swapSlotCalls.length).toBe(0); }); + it('returns failed when npm Node UI static bundle is missing after install', async () => { + existsSyncImpl = (p: any) => { + const path = normalizePathString(p); + if (path.endsWith('/dist-ui/index.html')) return false; + return true; + }; + + const logCalls: string[] = []; + const log = (msg: string) => { logCalls.push(msg); }; + const result = await performNpmUpdate('9.0.0-beta.5', log); + + expect(result).toBe('failed'); + expect(swapSlotCalls.length).toBe(0); + expect(logCalls.some(m => m.includes('Node UI static bundle missing'))).toBe(true); + expect(existsSyncCalls.some(([path]) => + normalizePathString(path).endsWith('/packages/node-ui/dist-ui/index.html'), + )).toBe(false); + }); + + it('does not accept a legacy npm UI bundle for a current-package npm update', async () => { + readFileImpl = async (path: any) => { + const normalized = normalizePathString(path); + if (normalized.endsWith('.update-pending.json')) throw new Error('ENOENT'); + if (normalized.endsWith('/node_modules/@origintrail-official/dkg/package.json')) { + return JSON.stringify({ + version: '9.0.0-beta.5', + dependencies: { '@origintrail-official/dkg-node-ui': '9.0.0-beta.5' }, + }); + } + if (normalized.endsWith('package.json')) return JSON.stringify({ version: '9.0.0-beta.5' }); + throw new Error(`Unexpected readFile: ${normalized}`); + }; + existsSyncImpl = (p: any) => { + const path = normalizePathString(p); + if (path.endsWith('/node_modules/@origintrail-official/dkg/dist/cli.js')) return true; + if (path.includes('/@dkg/node-ui/dist-ui/index.html')) return true; + if (path.endsWith('/dist-ui/index.html')) return false; + return true; + }; + + const result = await performNpmUpdate('9.0.0-beta.5', () => {}); + + expect(result).toBe('failed'); + expect(swapSlotCalls.length).toBe(0); + expect(existsSyncCalls.some(([path]) => + normalizePathString(path).includes('/@dkg/node-ui/dist-ui/index.html'), + )).toBe(false); + }); + it('continues when the bundled MarkItDown binary is missing after install', async () => { mockInstalledPackageVersion = '9.0.0-beta.5'; existsSyncImpl = (p: any) => { @@ -1370,7 +1520,7 @@ describe('autoupdater hardening', () => { }); it('honours autoUpdate.buildTimeoutMs.contracts when contracts rebuild', async () => { - readFileImpl = async () => 'aaa111'; + mockGitUpdateReadFile(); makeFetchOk('bbb222'); let contractsTimeout: number | undefined; execImpl = async (cmd: string, opts?: any) => { @@ -1424,7 +1574,7 @@ describe('autoupdater hardening', () => { // change we run `hardhat clean` first to drop ghost outputs from // deleted contracts. Scoped to the same trigger as the rebuild so // no-change updates still benefit from the Hardhat compile cache. - readFileImpl = async () => 'aaa111'; + mockGitUpdateReadFile(); makeFetchOk('bbb222'); const order: string[] = []; execImpl = async (cmd: string) => { @@ -1443,7 +1593,7 @@ describe('autoupdater hardening', () => { }); it('contract-diff retries via `git fetch --depth=1` for the missing parent commit before giving up', async () => { - readFileImpl = async () => 'aaa111'; + mockGitUpdateReadFile(); makeFetchOk('bbb222'); let firstDiffSeen = false; let retryFetchArgs: string[] | null = null; diff --git a/packages/cli/test/migration.test.ts b/packages/cli/test/migration.test.ts index 9318013b7..020b7baab 100644 --- a/packages/cli/test/migration.test.ts +++ b/packages/cli/test/migration.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, mkdir, writeFile, readlink, readFile, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { _migrationIo, migrateToBlueGreen } from '../src/migration.js'; import { repoDir } from '../src/config.js'; @@ -11,21 +11,32 @@ let dkgHome: string; let execSyncCalls: { cmd: string; opts?: any }[] = []; let execFileSyncCalls: { binary: string; args: string[]; opts?: any }[] = []; +const NODE_UI_BUILD_CMD = 'pnpm --filter @origintrail-official/dkg-node-ui run build:ui'; +const LEGACY_NODE_UI_BUILD_CMD = 'pnpm --filter @dkg/node-ui run build:ui'; const origIo = { ..._migrationIo }; function installMocks() { _migrationIo.execSync = ((cmd: string, opts?: any) => { execSyncCalls.push({ cmd, opts }); + const cwd = opts?.cwd ? String(opts.cwd) : ''; + if (cwd && cmd === 'pnpm build:runtime') { + mkdirSync(join(cwd, 'packages', 'cli', 'dist'), { recursive: true }); + writeFileSync(join(cwd, 'packages', 'cli', 'dist', 'cli.js'), ''); + } + if (cwd && (cmd === NODE_UI_BUILD_CMD || cmd === LEGACY_NODE_UI_BUILD_CMD)) { + mkdirSync(join(cwd, 'packages', 'node-ui', 'dist-ui'), { recursive: true }); + writeFileSync(join(cwd, 'packages', 'node-ui', 'dist-ui', 'index.html'), ''); + } return 'https://github.com/test/repo.git'; }) as any; _migrationIo.execFileSync = ((binary: string, args: string[], opts?: any) => { execFileSyncCalls.push({ binary, args: [...args], opts }); - if (binary === 'git' && args[0] === 'clone') { + if (binary === 'git' && args.includes('clone')) { const target = args[args.length - 1]; if (target && !target.startsWith('git') && !target.startsWith('http')) { - try { mkdirSync(target, { recursive: true }); } catch {} + try { mkdirSync(join(target, '.git'), { recursive: true }); } catch {} } } if (binary === 'git' && args[0] === 'remote' && args[1] === 'get-url') { @@ -64,16 +75,57 @@ function makeLog(): { fn: (msg: string) => void; calls: string[] } { return { fn: (msg: string) => { calls.push(msg); }, calls }; } +async function writeSlotReady(slotDir: string, includeUi = true): Promise { + await mkdir(join(slotDir, '.git'), { recursive: true }); + await mkdir(join(slotDir, 'packages', 'cli', 'dist'), { recursive: true }); + await writeFile(join(slotDir, 'packages', 'cli', 'dist', 'cli.js'), ''); + if (includeUi) { + await mkdir(join(slotDir, 'packages', 'node-ui', 'dist-ui'), { recursive: true }); + await writeFile(join(slotDir, 'packages', 'node-ui', 'dist-ui', 'index.html'), ''); + } +} + +async function writeNpmSlotReady(slotDir: string, includeUi = true): Promise { + await mkdir(join(slotDir, 'node_modules', '@origintrail-official', 'dkg', 'dist'), { recursive: true }); + await writeFile(join(slotDir, 'package.json'), '{}'); + await writeFile(join(slotDir, 'node_modules', '@origintrail-official', 'dkg', 'dist', 'cli.js'), ''); + if (includeUi) { + await mkdir( + join( + slotDir, + 'node_modules', + '@origintrail-official', + 'dkg', + 'node_modules', + '@origintrail-official', + 'dkg-node-ui', + 'dist-ui', + ), + { recursive: true }, + ); + await writeFile( + join( + slotDir, + 'node_modules', + '@origintrail-official', + 'dkg', + 'node_modules', + '@origintrail-official', + 'dkg-node-ui', + 'dist-ui', + 'index.html', + ), + '', + ); + } +} + describe('migrateToBlueGreen', () => { it('skips migration when releases/current already exists', async () => { const rDir = join(dkgHome, 'releases'); await mkdir(rDir, { recursive: true }); - await mkdir(join(rDir, 'a', '.git'), { recursive: true }); - await mkdir(join(rDir, 'b', '.git'), { recursive: true }); - await mkdir(join(rDir, 'a', 'packages', 'cli', 'dist'), { recursive: true }); - await mkdir(join(rDir, 'b', 'packages', 'cli', 'dist'), { recursive: true }); - await writeFile(join(rDir, 'a', 'packages', 'cli', 'dist', 'cli.js'), ''); - await writeFile(join(rDir, 'b', 'packages', 'cli', 'dist', 'cli.js'), ''); + await writeSlotReady(join(rDir, 'a')); + await writeSlotReady(join(rDir, 'b')); await symlink('a', join(rDir, 'current')); const log = makeLog(); @@ -97,9 +149,7 @@ describe('migrateToBlueGreen', () => { it('restores current symlink from existing active metadata when valid', async () => { const rDir = join(dkgHome, 'releases'); - await mkdir(join(rDir, 'b', '.git'), { recursive: true }); - await mkdir(join(rDir, 'b', 'packages', 'cli', 'dist'), { recursive: true }); - await writeFile(join(rDir, 'b', 'packages', 'cli', 'dist', 'cli.js'), ''); + await writeSlotReady(join(rDir, 'b'), false); await writeFile(join(rDir, 'active'), 'b'); const log = makeLog(); @@ -217,6 +267,240 @@ describe('migrateToBlueGreen', () => { const allCmds = execSyncCalls.map(c => c.cmd); expect(allCmds.some(cmd => cmd.includes('pnpm install'))).toBe(true); expect(allCmds.some(cmd => cmd.includes('pnpm build'))).toBe(true); + expect(allCmds.some(cmd => cmd === NODE_UI_BUILD_CMD)).toBe(true); + const uiBuildIdx = allCmds.indexOf(NODE_UI_BUILD_CMD); + const runtimeBuildIdx = allCmds.indexOf('pnpm build:runtime'); + expect(uiBuildIdx).toBeGreaterThan(runtimeBuildIdx); + }); + + it('repairs inactive ready slots that are missing the Node UI static bundle', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a')); + await writeSlotReady(join(rDir, 'b'), false); + await writeFile(join(rDir, 'active'), 'a'); + await symlink('a', join(rDir, 'current')); + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + const uiBuilds = execSyncCalls.filter(c => c.cmd === NODE_UI_BUILD_CMD); + expect(uiBuilds.map(c => String(c.opts?.cwd))).toEqual([join(rDir, 'b')]); + expect(log.calls.some(m => m.includes('Node UI static bundle missing'))).toBe(true); + expect(execFileSyncCalls.some(c => c.binary === 'git' && c.args[0] === 'clone')).toBe(false); + }); + + it('does not run workspace UI build for npm-layout slots missing UI', async () => { + const rDir = join(dkgHome, 'releases'); + const slotB = join(rDir, 'b'); + await writeSlotReady(join(rDir, 'a')); + await writeNpmSlotReady(slotB, false); + await writeFile(join(rDir, 'active'), 'a'); + _migrationIo.swapSlot = (async () => undefined) as any; + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + const slotBCmds = execSyncCalls + .filter(c => String(c.opts?.cwd) === slotB) + .map(c => c.cmd); + expect(slotBCmds).not.toContain(NODE_UI_BUILD_CMD); + expect(slotBCmds).not.toContain(LEGACY_NODE_UI_BUILD_CMD); + expect(log.calls.some(m => m.includes('repair failed') && m.includes('Trying remaining slots'))).toBe(true); + }); + + it('does not select npm-layout slots that are missing UI', async () => { + const rDir = join(dkgHome, 'releases'); + await writeNpmSlotReady(join(rDir, 'a'), false); + await writeNpmSlotReady(join(rDir, 'b'), false); + await writeFile(join(rDir, 'active'), 'a'); + const swaps: Array<'a' | 'b'> = []; + _migrationIo.swapSlot = (async (slot: 'a' | 'b') => { + swaps.push(slot); + }) as any; + + const log = makeLog(); + await expect(migrateToBlueGreen(log.fn)).rejects.toThrow('Node UI static bundle missing'); + + expect(swaps).toEqual([]); + expect(execSyncCalls.length).toBe(0); + expect(log.calls.filter(m => m.includes('Trying remaining slots')).length).toBe(2); + }); + + it('uses the Node UI workspace package name from each slot during repair', async () => { + const rDir = join(dkgHome, 'releases'); + const slotB = join(rDir, 'b'); + await writeSlotReady(join(rDir, 'a')); + await writeSlotReady(slotB, false); + await mkdir(join(slotB, 'packages', 'node-ui'), { recursive: true }); + await writeFile( + join(slotB, 'packages', 'node-ui', 'package.json'), + JSON.stringify({ name: '@dkg/node-ui', scripts: { 'build:ui': 'vite build' } }), + ); + await writeFile(join(rDir, 'active'), 'a'); + await symlink('a', join(rDir, 'current')); + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + const slotBuilds = execSyncCalls + .filter(c => String(c.opts?.cwd) === slotB) + .map(c => c.cmd); + expect(slotBuilds).toContain(LEGACY_NODE_UI_BUILD_CMD); + expect(slotBuilds).not.toContain(NODE_UI_BUILD_CMD); + }); + + it('uses releases/current before stale active metadata when deciding the live slot', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a')); + await writeSlotReady(join(rDir, 'b'), false); + await writeFile(join(rDir, 'active'), 'b'); + await symlink('a', join(rDir, 'current')); + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + const uiBuilds = execSyncCalls.filter(c => c.cmd === NODE_UI_BUILD_CMD); + expect(uiBuilds.map(c => String(c.opts?.cwd))).toEqual([join(rDir, 'b')]); + }); + + it('repairs a live slot missing UI in place instead of failing over', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a'), false); + await writeSlotReady(join(rDir, 'b')); + await writeFile(join(rDir, 'active'), 'a'); + await symlink('a', join(rDir, 'current')); + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + expect(await readlink(join(rDir, 'current'))).toBe('a'); + const uiBuilds = execSyncCalls.filter(c => c.cmd === NODE_UI_BUILD_CMD); + expect(uiBuilds.map(c => String(c.opts?.cwd))).toEqual([join(rDir, 'a')]); + expect(log.calls.some(m => m.includes('active slot') && m.includes('rebuilding'))).toBe(true); + }); + + it('leaves live slot untouched when live UI repair is disabled', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a'), false); + await writeSlotReady(join(rDir, 'b')); + await writeFile(join(rDir, 'active'), 'a'); + await symlink('a', join(rDir, 'current')); + + const log = makeLog(); + await migrateToBlueGreen(log.fn, { repairLiveNodeUi: false }); + + expect(await readlink(join(rDir, 'current'))).toBe('a'); + const uiBuilds = execSyncCalls.filter(c => c.cmd === NODE_UI_BUILD_CMD); + expect(uiBuilds.map(c => String(c.opts?.cwd))).not.toContain(join(rDir, 'a')); + expect(log.calls.some(m => m.includes('active slot') && m.includes('untouched'))).toBe(true); + }); + + it('repairs live and standby UI when both ready slots are missing UI', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a'), false); + await writeSlotReady(join(rDir, 'b'), false); + await writeFile(join(rDir, 'active'), 'a'); + await symlink('a', join(rDir, 'current')); + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + expect(await readlink(join(rDir, 'current'))).toBe('a'); + const uiBuilds = execSyncCalls.filter(c => c.cmd === NODE_UI_BUILD_CMD); + expect(uiBuilds.map(c => String(c.opts?.cwd))).toEqual([join(rDir, 'a'), join(rDir, 'b')]); + }); + + it('does not block migration when inactive slot UI repair fails', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a')); + await writeSlotReady(join(rDir, 'b'), false); + await writeFile(join(rDir, 'active'), 'a'); + await symlink('a', join(rDir, 'current')); + _migrationIo.execSync = ((cmd: string, opts?: any) => { + execSyncCalls.push({ cmd, opts }); + if (cmd === NODE_UI_BUILD_CMD || cmd === LEGACY_NODE_UI_BUILD_CMD) throw new Error('vite exploded'); + return ''; + }) as any; + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + expect(log.calls.some(m => m.includes('repair failed') && m.includes('next update'))).toBe(true); + }); + + it('continues startup when live slot UI repair fails instead of rolling back', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a'), false); + await writeSlotReady(join(rDir, 'b')); + await writeFile(join(rDir, 'active'), 'a'); + await symlink('a', join(rDir, 'current')); + _migrationIo.execSync = ((cmd: string, opts?: any) => { + execSyncCalls.push({ cmd, opts }); + if (cmd === NODE_UI_BUILD_CMD || cmd === LEGACY_NODE_UI_BUILD_CMD) throw new Error('vite exploded'); + return ''; + }) as any; + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + expect(await readlink(join(rDir, 'current'))).toBe('a'); + expect(log.calls.some(m => m.includes('repair failed') && m.includes('fallback page'))).toBe(true); + }); + + it('restores current to a healthy UI slot when another ready slot repair fails', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a')); + await writeSlotReady(join(rDir, 'b'), false); + await writeFile(join(rDir, 'active'), 'b'); + _migrationIo.execSync = ((cmd: string, opts?: any) => { + execSyncCalls.push({ cmd, opts }); + if (cmd === NODE_UI_BUILD_CMD || cmd === LEGACY_NODE_UI_BUILD_CMD) throw new Error('vite exploded'); + return ''; + }) as any; + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + expect(await readlink(join(rDir, 'current'))).toBe('a'); + expect(log.calls.some(m => m.includes('repair failed') && m.includes('Trying remaining slots'))).toBe(true); + }); + + it('tries both slots before failing no-current UI repair', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a'), false); + await writeSlotReady(join(rDir, 'b'), false); + await writeFile(join(rDir, 'active'), 'a'); + _migrationIo.execSync = ((cmd: string, opts?: any) => { + execSyncCalls.push({ cmd, opts }); + const cwd = String(opts?.cwd ?? ''); + if (cmd === NODE_UI_BUILD_CMD || cmd === LEGACY_NODE_UI_BUILD_CMD) { + if (cwd.endsWith(`${join('releases', 'a')}`)) throw new Error('vite exploded'); + mkdirSync(join(cwd, 'packages', 'node-ui', 'dist-ui'), { recursive: true }); + writeFileSync(join(cwd, 'packages', 'node-ui', 'dist-ui', 'index.html'), ''); + } + return ''; + }) as any; + + const log = makeLog(); + await migrateToBlueGreen(log.fn); + + expect(await readlink(join(rDir, 'current'))).toBe('b'); + expect(log.calls.some(m => m.includes('Trying remaining slots'))).toBe(true); + }); + + it('fails initial migration when no slot can provide the Node UI static bundle', async () => { + const rDir = join(dkgHome, 'releases'); + await writeSlotReady(join(rDir, 'a'), false); + await writeSlotReady(join(rDir, 'b'), false); + _migrationIo.execSync = ((cmd: string, opts?: any) => { + execSyncCalls.push({ cmd, opts }); + if (cmd === NODE_UI_BUILD_CMD || cmd === LEGACY_NODE_UI_BUILD_CMD) throw new Error('vite exploded'); + return ''; + }) as any; + + const log = makeLog(); + await expect(migrateToBlueGreen(log.fn)).rejects.toThrow('vite exploded'); + expect(existsSync(join(rDir, 'current'))).toBe(false); }); it('migration slot B clone uses --dissociate to prevent repo corruption', async () => { @@ -263,9 +547,7 @@ describe('migrateToBlueGreen', () => { it('repairs incomplete slots even when current symlink exists', async () => { const rDir = join(dkgHome, 'releases'); - await mkdir(join(rDir, 'a', '.git'), { recursive: true }); - await mkdir(join(rDir, 'a', 'packages', 'cli', 'dist'), { recursive: true }); - await writeFile(join(rDir, 'a', 'packages', 'cli', 'dist', 'cli.js'), ''); + await writeSlotReady(join(rDir, 'a')); await mkdir(join(rDir, 'b'), { recursive: true }); // incomplete slot b await symlink('a', join(rDir, 'current')); @@ -281,12 +563,8 @@ describe('migrateToBlueGreen', () => { it('repairs non-symlink releases/current by recreating current symlink', async () => { const rDir = join(dkgHome, 'releases'); - await mkdir(join(rDir, 'a', '.git'), { recursive: true }); - await mkdir(join(rDir, 'b', '.git'), { recursive: true }); - await mkdir(join(rDir, 'a', 'packages', 'cli', 'dist'), { recursive: true }); - await mkdir(join(rDir, 'b', 'packages', 'cli', 'dist'), { recursive: true }); - await writeFile(join(rDir, 'a', 'packages', 'cli', 'dist', 'cli.js'), ''); - await writeFile(join(rDir, 'b', 'packages', 'cli', 'dist', 'cli.js'), ''); + await writeSlotReady(join(rDir, 'a')); + await writeSlotReady(join(rDir, 'b')); await writeFile(join(rDir, 'active'), 'b'); await mkdir(join(rDir, 'current'), { recursive: true }); // legacy broken state: directory instead of symlink diff --git a/packages/cli/test/node-ui-static.test.ts b/packages/cli/test/node-ui-static.test.ts new file mode 100644 index 000000000..5847d0151 --- /dev/null +++ b/packages/cli/test/node-ui-static.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { nodeUiNpmStaticIndexPaths, nodeUiStaticIndexPaths } from '../src/node-ui-static.js'; + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/'); +} + +describe('nodeUiStaticIndexPaths', () => { + it('includes git and npm slot layouts', () => { + const paths = nodeUiStaticIndexPaths('/tmp/dkg-test/releases/b').map(normalizePath); + + expect(paths).toContain('/tmp/dkg-test/releases/b/packages/node-ui/dist-ui/index.html'); + expect(paths).toContain('/tmp/dkg-test/releases/b/node_modules/@origintrail-official/dkg-node-ui/dist-ui/index.html'); + expect(paths).toContain('/tmp/dkg-test/releases/b/node_modules/@dkg/node-ui/dist-ui/index.html'); + expect(paths).toContain('/tmp/dkg-test/releases/b/node_modules/@origintrail-official/dkg/node_modules/@origintrail-official/dkg-node-ui/dist-ui/index.html'); + expect(paths).toContain('/tmp/dkg-test/releases/b/node_modules/@origintrail-official/dkg/node_modules/@dkg/node-ui/dist-ui/index.html'); + }); + + it('keeps npm candidates separate from the git workspace artifact', () => { + const paths = nodeUiNpmStaticIndexPaths('/tmp/dkg-test/releases/b').map(normalizePath); + + expect(paths).not.toContain('/tmp/dkg-test/releases/b/packages/node-ui/dist-ui/index.html'); + expect(paths).toContain('/tmp/dkg-test/releases/b/node_modules/@origintrail-official/dkg-node-ui/dist-ui/index.html'); + expect(paths).toContain('/tmp/dkg-test/releases/b/node_modules/@origintrail-official/dkg/node_modules/@origintrail-official/dkg-node-ui/dist-ui/index.html'); + }); + + it('can scope npm candidates to the expected UI package', () => { + const paths = nodeUiNpmStaticIndexPaths( + '/tmp/dkg-test/releases/b', + ['@origintrail-official/dkg-node-ui'], + ).map(normalizePath); + + expect(paths).toContain('/tmp/dkg-test/releases/b/node_modules/@origintrail-official/dkg-node-ui/dist-ui/index.html'); + expect(paths.some((path) => path.includes('/@dkg/node-ui/'))).toBe(false); + }); +}); diff --git a/packages/cli/test/rollback-node-ui.test.ts b/packages/cli/test/rollback-node-ui.test.ts new file mode 100644 index 000000000..5001f958e --- /dev/null +++ b/packages/cli/test/rollback-node-ui.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import type { ExecSyncOptionsWithStringEncoding } from 'node:child_process'; +import { ensureRollbackNodeUiBundle, type RollbackNodeUiIo } from '../src/rollback-node-ui.js'; + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/'); +} + +function makeIo(overrides: Partial): RollbackNodeUiIo { + return { + existsSync: () => false, + readFileSync: () => { + throw new Error('unexpected read'); + }, + execSync: () => '', + log: () => {}, + error: () => {}, + ...overrides, + }; +} + +describe('ensureRollbackNodeUiBundle', () => { + it('builds a missing git-layout Node UI bundle before rollback can activate the slot', () => { + const slotDir = join('tmp', 'releases', 'b'); + const gitIndex = join(slotDir, 'packages', 'node-ui', 'dist-ui', 'index.html'); + let built = false; + const commands: string[] = []; + const logs: string[] = []; + const errors: string[] = []; + const io = makeIo({ + existsSync: (path) => normalizePath(path).endsWith('/packages/cli/dist/cli.js') + || (built && path === gitIndex), + readFileSync: (path) => { + expect(normalizePath(path)).toContain('/packages/node-ui/package.json'); + return '{"name":"@origintrail-official/dkg-node-ui"}'; + }, + execSync: (command: string, options?: ExecSyncOptionsWithStringEncoding) => { + commands.push(command); + expect(options?.cwd).toBe(slotDir); + expect(options?.timeout).toBe(15 * 60_000); + built = true; + return ''; + }, + log: (message) => logs.push(message), + error: (message) => errors.push(message), + }); + + expect(ensureRollbackNodeUiBundle(slotDir, 'b', io)).toBe(true); + expect(commands).toEqual(['pnpm --filter @origintrail-official/dkg-node-ui run build:ui']); + expect(logs).toEqual(['Slot b has no Node UI static bundle; building UI assets before rollback...']); + expect(errors).toEqual([]); + }); + + it('fails a git-layout rollback when the UI build cannot produce index.html', () => { + const slotDir = join('tmp', 'releases', 'b'); + const commands: string[] = []; + const errors: string[] = []; + const io = makeIo({ + existsSync: (path) => normalizePath(path).endsWith('/packages/cli/dist/cli.js'), + readFileSync: () => '{"name":"@origintrail-official/dkg-node-ui"}', + execSync: (command: string) => { + commands.push(command); + throw new Error('vite exploded'); + }, + error: (message) => errors.push(message), + }); + + expect(ensureRollbackNodeUiBundle(slotDir, 'b', io)).toBe(false); + expect(commands).toEqual(['pnpm --filter @origintrail-official/dkg-node-ui run build:ui']); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('Rollback aborted: failed to build Node UI static bundle'); + expect(errors[0]).toContain('vite exploded'); + }); + + it('accepts an npm-layout rollback target that already contains packaged UI assets', () => { + const slotDir = join('tmp', 'releases', 'b'); + const npmIndex = join( + slotDir, + 'node_modules', + '@origintrail-official', + 'dkg-node-ui', + 'dist-ui', + 'index.html', + ); + const commands: string[] = []; + const io = makeIo({ + existsSync: (path) => path === npmIndex, + readFileSync: (path) => { + expect(normalizePath(path)).toContain('/node_modules/@origintrail-official/dkg/package.json'); + return '{"dependencies":{"@origintrail-official/dkg-node-ui":"10.0.0"}}'; + }, + execSync: (command: string) => { + commands.push(command); + return ''; + }, + }); + + expect(ensureRollbackNodeUiBundle(slotDir, 'b', io)).toBe(true); + expect(commands).toEqual([]); + }); + + it('fails an npm-layout rollback target that lacks packaged UI assets without attempting a repair build', () => { + const slotDir = join('tmp', 'releases', 'b'); + const commands: string[] = []; + const errors: string[] = []; + const io = makeIo({ + existsSync: () => false, + readFileSync: () => '{"dependencies":{"@origintrail-official/dkg-node-ui":"10.0.0"}}', + execSync: (command: string) => { + commands.push(command); + return ''; + }, + error: (message) => errors.push(message), + }); + + expect(ensureRollbackNodeUiBundle(slotDir, 'b', io)).toBe(false); + expect(commands).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('Slot b has no Node UI static bundle'); + expect(errors[0]).toContain('Run "dkg update" first'); + }); +});