diff --git a/apps/cli/src/commands/service.test.ts b/apps/cli/src/commands/service.test.ts index 5df318e..1c4dfe4 100644 --- a/apps/cli/src/commands/service.test.ts +++ b/apps/cli/src/commands/service.test.ts @@ -41,6 +41,7 @@ import { resolveExecutableSiblingPackageJson, renderLaunchdPlist, renderServiceWrapper, + renderWindowsLauncher, renderSystemdTimer, runServiceAutoUpdate, scheduleDescription, @@ -596,10 +597,24 @@ describe("native scheduler templates", () => { "/MO", "5", "/TR", - '"C:\\Users\\alex\\AppData\\Roaming\\tokenmaxxing/service-sync.cmd"', + 'wscript.exe //nologo //b "C:\\Users\\alex\\AppData\\Roaming\\tokenmaxxing/service-sync-launcher.vbs"', "/F", ]); }); + + it("renders a hidden VBScript launcher for the sync wrapper", () => { + const launcher = renderWindowsLauncher( + "C:\\Users\\alex\\AppData\\Roaming\\tokenmaxxing/service-sync.cmd", + ); + + // window style 0 = hidden, bWaitOnReturn = True so the wrapper's exit code propagates. + expect(launcher).toContain( + 'shell.Run("""C:\\Users\\alex\\AppData\\Roaming\\tokenmaxxing/service-sync.cmd""", 0, True)', + ); + expect(launcher).toContain("WScript.Quit exitCode"); + // Must never open a console window: no direct cmd/console invocation. + expect(launcher).not.toContain("cmd.exe"); + }); }); describe("legacyServiceWrapperPaths", () => { @@ -1972,7 +1987,7 @@ describe("serviceInstallProgram", () => { installedAt: "2026-06-16T12:00:00.000Z", runnerTarget: "darwin-arm64", runnerVersion: "0.4.17", - templateVersion: 5, + templateVersion: 6, }); expect(written[0]?.metadata).not.toHaveProperty("autoUpdate"); expect(state.logs).toContain("Automatic sync installed"); diff --git a/apps/cli/src/commands/service.ts b/apps/cli/src/commands/service.ts index fe8c9ed..21cb5c3 100644 --- a/apps/cli/src/commands/service.ts +++ b/apps/cli/src/commands/service.ts @@ -58,12 +58,13 @@ const gunzipPromise = promisify(gunzip); const require = createRequire(import.meta.url); const SERVICE_LABEL = "sh.tokenmaxxing.sync"; -const SERVICE_TEMPLATE_VERSION = 5; +const SERVICE_TEMPLATE_VERSION = 6; const SYSTEMD_NAME = "tokenmaxxing-sync"; const WINDOWS_TASK_NAME = "tokenmaxxing-sync"; const POSIX_WRAPPER_NAME = "tokenmaxxing.sh"; const LEGACY_POSIX_WRAPPER_NAME = "service-sync.sh"; const WINDOWS_WRAPPER_NAME = "service-sync.cmd"; +const WINDOWS_LAUNCHER_NAME = "service-sync-launcher.vbs"; const PACKAGE_NAME = "@851-labs/tokenmaxxing"; const SERVICE_RUNNER_DIR_NAME = "service-runners"; const SERVICE_RUNNER_POINTER_NAME = "service-runner-current"; @@ -3301,6 +3302,10 @@ function windowsTaskNames(): string[] { return [windowsTaskName(), ...LEGACY_SCHEDULE_TIMES.map((time) => legacyWindowsTaskName(time))]; } +function windowsLauncherPath(paths: ServicePaths): string { + return join(paths.configDir, WINDOWS_LAUNCHER_NAME); +} + function renderLaunchdStartInterval(): string { return String(SERVICE_INTERVAL_SECONDS); } @@ -3851,6 +3856,21 @@ exit /b %ERRORLEVEL%\r `; } +// schtasks can only register a task that runs in the interactive session, so pointing the task +// action straight at the .cmd wrapper (a console program) makes a console window flash on every +// run — every 5 minutes — stealing focus from full-screen apps and games. wscript.exe is a +// GUI-subsystem host, so running the wrapper through this tiny .vbs with window style 0 performs +// the sync with no window at all, while still propagating the wrapper's exit code to Task Scheduler. +function renderWindowsLauncher(wrapperPath: string): string { + const command = vbsQuote(`"${wrapperPath}"`); + return `' Auto-generated by tokenmaxxing. Runs the sync wrapper without a console window.\r +Dim shell, exitCode\r +Set shell = CreateObject("WScript.Shell")\r +exitCode = shell.Run(${command}, 0, True)\r +WScript.Quit exitCode\r +`; +} + function renderWindowsLogRotation(logPath: string): string { const quotedLogPath = cmdQuote(logPath); const moves = Array.from({ length: SERVICE_LOG_ROTATIONS - 1 }, (_, index) => { @@ -3929,7 +3949,9 @@ function writeServiceFiles( await rm(legacyWrapperPath, { force: true }); } await writeFileAtomic(paths.wrapperPath, wrapper); - if (paths.backend !== "windows-task-scheduler") { + if (paths.backend === "windows-task-scheduler") { + await writeFileAtomic(windowsLauncherPath(paths), renderWindowsLauncher(paths.wrapperPath)); + } else { await chmod(paths.wrapperPath, 0o755); } await writeFileAtomic(paths.metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); @@ -3950,6 +3972,9 @@ function removeServiceFiles(paths: ServicePaths): Effect.Effect { return Effect.tryPromise({ try: async () => { await rm(paths.wrapperPath, { force: true }); + if (paths.backend === "windows-task-scheduler") { + await rm(windowsLauncherPath(paths), { force: true }); + } for (const legacyWrapperPath of legacyServiceWrapperPaths(paths)) { await rm(legacyWrapperPath, { force: true }); } @@ -4017,7 +4042,7 @@ function windowsTaskCreateArgs(paths: ServicePaths): string[] { "/MO", String(SERVICE_INTERVAL_MINUTES), "/TR", - cmdQuote(paths.wrapperPath), + `wscript.exe //nologo //b ${cmdQuote(windowsLauncherPath(paths))}`, "/F", ]; } @@ -4393,6 +4418,11 @@ function cmdQuote(value: string): string { return `"${value.replaceAll('"', '\\"')}"`; } +// VBScript string literals are double-quote delimited and escape an embedded quote by doubling it. +function vbsQuote(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} + function escapeCmdSetValue(value: string): string { return value.replaceAll('"', '\\"'); } @@ -4466,6 +4496,7 @@ export { verifyNpmIntegrity, windowsTaskNames, windowsTaskCreateArgs, + renderWindowsLauncher, ServiceCommandNotFoundError, ServiceEnvTokenError, ServiceEphemeralCommandError,