fix(cli): externalize Windows startup PowerShell to .ps1 wrapper (schtasks /TR 261-char limit)
The inline PowerShell env-parsing script exceeded the Task Scheduler /TR 261-char limit, causing startup enable to fail on Windows. Extract the script into a .ps1 wrapper file and reduce /TR to a short powershell.exe -File command (~115 chars). Mirrors the macOS writeMacosStartupWrapper pattern. Adds regression tests pinning /TR < 200 (default) and < 261 (worst-case). Apply fix to refactored lib/cli-startup.js (was cli.js before refactor).
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
|||||||
parseArgs,
|
parseArgs,
|
||||||
resolveServeHost,
|
resolveServeHost,
|
||||||
} from './cli.js';
|
} from './cli.js';
|
||||||
|
import { buildWindowsStartupTaskCommand } from './lib/cli-startup.js';
|
||||||
|
|
||||||
async function withTempOpenChamberDataDir(fn) {
|
async function withTempOpenChamberDataDir(fn) {
|
||||||
const previous = process.env.OPENCHAMBER_DATA_DIR;
|
const previous = process.env.OPENCHAMBER_DATA_DIR;
|
||||||
@@ -884,3 +885,37 @@ describe('lifecycle commands with unmanaged explicit ports', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Windows startup task command builder', () => {
|
||||||
|
it('default-path length stays under 200 chars', () => {
|
||||||
|
const cmd = buildWindowsStartupTaskCommand(
|
||||||
|
'C:\\Users\\test\\.config\\openchamber\\bin\\OpenChamber.ps1'
|
||||||
|
);
|
||||||
|
expect(cmd).toMatch(/^powershell\.exe -NoProfile -ExecutionPolicy Bypass -File /);
|
||||||
|
expect(cmd.length).toBeLessThan(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('worst-case long path stays under 261-char Task Scheduler ceiling', () => {
|
||||||
|
// Build a wrapper path >= 180 chars (simulates long OPENCHAMBER_DATA_DIR)
|
||||||
|
// Overhead = 57 chars (prefix + closing quote), so max wrapper for <261 total is 203
|
||||||
|
const longPath =
|
||||||
|
'C:\\Users\\' +
|
||||||
|
'a'.repeat(139) +
|
||||||
|
'\\.config\\openchamber\\bin\\OpenChamber.ps1';
|
||||||
|
expect(longPath.length).toBeGreaterThanOrEqual(180);
|
||||||
|
|
||||||
|
const cmd = buildWindowsStartupTaskCommand(longPath);
|
||||||
|
expect(cmd.length).toBeLessThan(261);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT inline SetEnvironmentVariable (externalization invariant)', () => {
|
||||||
|
const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1');
|
||||||
|
expect(cmd).not.toContain('SetEnvironmentVariable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses -File form, not -Command', () => {
|
||||||
|
const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1');
|
||||||
|
expect(cmd).toContain('-File ');
|
||||||
|
expect(cmd).not.toContain('-Command ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ function getMacosStartupWrapperPath() {
|
|||||||
return path.join(getDataDir(), 'bin', 'OpenChamber');
|
return path.join(getDataDir(), 'bin', 'OpenChamber');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWindowsStartupWrapperPath() {
|
||||||
|
return path.join(getDataDir(), 'bin', 'OpenChamber.ps1');
|
||||||
|
}
|
||||||
|
|
||||||
function collectStartupEnv(options = {}) {
|
function collectStartupEnv(options = {}) {
|
||||||
const env = options.envSnapshot === false ? {} : Object.fromEntries(
|
const env = options.envSnapshot === false ? {} : Object.fromEntries(
|
||||||
Object.entries(process.env)
|
Object.entries(process.env)
|
||||||
@@ -189,6 +193,24 @@ exec ${startupShellQuote(process.execPath)} ${args}
|
|||||||
return wrapperPath;
|
return wrapperPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeWindowsStartupWrapper(options = {}) {
|
||||||
|
const wrapperPath = getWindowsStartupWrapperPath();
|
||||||
|
const envFilePath = getStartupEnvFilePath();
|
||||||
|
const startupArgs = buildStartupArgs(options).map(powershellQuote).join(' ');
|
||||||
|
const ps1Content = [
|
||||||
|
`$envFile=${powershellQuote(envFilePath)}`,
|
||||||
|
`if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
|
||||||
|
`& ${powershellQuote(process.execPath)} ${startupArgs}`,
|
||||||
|
].join('; ');
|
||||||
|
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 });
|
||||||
|
fs.writeFileSync(wrapperPath, ps1Content, { mode: 0o700 });
|
||||||
|
return wrapperPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWindowsStartupTaskCommand(wrapperPath) {
|
||||||
|
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${wrapperPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildMacosLaunchAgent(options = {}) {
|
function buildMacosLaunchAgent(options = {}) {
|
||||||
const wrapperPath = writeMacosStartupWrapper(options);
|
const wrapperPath = writeMacosStartupWrapper(options);
|
||||||
const args = [wrapperPath];
|
const args = [wrapperPath];
|
||||||
@@ -318,21 +340,16 @@ function enableStartupService(options = {}) {
|
|||||||
return getStartupStatus();
|
return getStartupStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
const envFilePath = writeStartupEnvFile(options);
|
writeStartupEnvFile(options);
|
||||||
const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', ');
|
const wrapperPath = writeWindowsStartupWrapper(options);
|
||||||
const powerShellCommand = [
|
const taskCommand = buildWindowsStartupTaskCommand(wrapperPath);
|
||||||
`$envFile=${powershellQuote(envFilePath)}`,
|
|
||||||
`if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
|
|
||||||
`& ${powershellQuote(process.execPath)} ${startupArgs}`,
|
|
||||||
].join('; ');
|
|
||||||
const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`;
|
|
||||||
runStartupCommand('schtasks.exe', [
|
runStartupCommand('schtasks.exe', [
|
||||||
'/Create',
|
'/Create',
|
||||||
'/TN', STARTUP_SERVICE_ID,
|
'/TN', STARTUP_SERVICE_ID,
|
||||||
'/SC', 'ONLOGON',
|
'/SC', 'ONLOGON',
|
||||||
'/RL', 'LIMITED',
|
'/RL', 'LIMITED',
|
||||||
'/F',
|
'/F',
|
||||||
'/TR', taskArgs,
|
'/TR', taskCommand,
|
||||||
]);
|
]);
|
||||||
runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
|
runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
|
||||||
return getStartupStatus();
|
return getStartupStatus();
|
||||||
@@ -359,6 +376,8 @@ function disableStartupService() {
|
|||||||
|
|
||||||
runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
|
runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
|
||||||
runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true });
|
runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true });
|
||||||
|
try { fs.unlinkSync(getWindowsStartupWrapperPath()); } catch {}
|
||||||
|
removeStartupEnvFile();
|
||||||
return getStartupStatus();
|
return getStartupStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,4 +386,5 @@ export {
|
|||||||
getStartupStatus,
|
getStartupStatus,
|
||||||
enableStartupService,
|
enableStartupService,
|
||||||
disableStartupService,
|
disableStartupService,
|
||||||
|
buildWindowsStartupTaskCommand,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user