@ai-support-agent/cli 0.1.22-beta.4 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/auto-updater.js +1 -1
  2. package/dist/auto-updater.js.map +1 -1
  3. package/dist/cli/service/darwin-service.d.ts.map +1 -1
  4. package/dist/cli/service/darwin-service.js +11 -7
  5. package/dist/cli/service/darwin-service.js.map +1 -1
  6. package/dist/cli/service/win32-service.d.ts +59 -1
  7. package/dist/cli/service/win32-service.d.ts.map +1 -1
  8. package/dist/cli/service/win32-service.js +463 -95
  9. package/dist/cli/service/win32-service.js.map +1 -1
  10. package/dist/commands/chat-executor.js +1 -1
  11. package/dist/commands/chat-executor.js.map +1 -1
  12. package/dist/commands/e2e-test-executor.js +8 -8
  13. package/dist/commands/e2e-test-executor.js.map +1 -1
  14. package/dist/commands/stop-agent.js +1 -1
  15. package/dist/commands/stop-agent.js.map +1 -1
  16. package/dist/docker/docker-utils.d.ts.map +1 -1
  17. package/dist/docker/docker-utils.js +34 -6
  18. package/dist/docker/docker-utils.js.map +1 -1
  19. package/dist/docker/dockerfile-generator.d.ts.map +1 -1
  20. package/dist/docker/dockerfile-generator.js +8 -0
  21. package/dist/docker/dockerfile-generator.js.map +1 -1
  22. package/dist/docker/project-image-builder.d.ts.map +1 -1
  23. package/dist/docker/project-image-builder.js +2 -1
  24. package/dist/docker/project-image-builder.js.map +1 -1
  25. package/dist/locales/en.json +1 -0
  26. package/dist/locales/ja.json +1 -0
  27. package/dist/mcp/tools/browser.d.ts.map +1 -1
  28. package/dist/mcp/tools/browser.js +2 -1
  29. package/dist/mcp/tools/browser.js.map +1 -1
  30. package/dist/mcp/tools/e2e-test-step.d.ts.map +1 -1
  31. package/dist/mcp/tools/e2e-test-step.js +2 -1
  32. package/dist/mcp/tools/e2e-test-step.js.map +1 -1
  33. package/dist/project-agent.js +1 -1
  34. package/dist/project-agent.js.map +1 -1
  35. package/dist/project-worker.d.ts.map +1 -1
  36. package/dist/project-worker.js +2 -1
  37. package/dist/project-worker.js.map +1 -1
  38. package/dist/retry-strategy.d.ts.map +1 -1
  39. package/dist/retry-strategy.js +2 -1
  40. package/dist/retry-strategy.js.map +1 -1
  41. package/dist/utils/path-utils.d.ts +4 -0
  42. package/dist/utils/path-utils.d.ts.map +1 -1
  43. package/dist/utils/path-utils.js +14 -0
  44. package/dist/utils/path-utils.js.map +1 -1
  45. package/dist/utils.d.ts +17 -0
  46. package/dist/utils.d.ts.map +1 -1
  47. package/dist/utils.js +27 -1
  48. package/dist/utils.js.map +1 -1
  49. package/dist/vscode/vscode-server.js +1 -1
  50. package/dist/vscode/vscode-server.js.map +1 -1
  51. package/dist/ws-reconnect.js +1 -1
  52. package/dist/ws-reconnect.js.map +1 -1
  53. package/package.json +1 -1
@@ -33,32 +33,88 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.Win32ServiceStrategy = void 0;
36
+ exports.Win32ServiceStrategy = exports.getNodePath = exports.getCliEntryPoint = void 0;
37
+ exports.getProjectTaskName = getProjectTaskName;
38
+ exports.getAllProjectTasks = getAllProjectTasks;
39
+ exports.generateProjectTaskXml = generateProjectTaskXml;
40
+ exports.generateWin32WrapperScript = generateWin32WrapperScript;
41
+ exports.writeAndRegisterProjectTask = writeAndRegisterProjectTask;
37
42
  exports.generateTaskXml = generateTaskXml;
38
43
  const child_process_1 = require("child_process");
39
44
  const fs = __importStar(require("fs"));
40
45
  const os = __importStar(require("os"));
41
46
  const path = __importStar(require("path"));
42
47
  const constants_1 = require("../../constants");
48
+ const config_manager_1 = require("../../config-manager");
49
+ const docker_utils_1 = require("../../docker/docker-utils");
43
50
  const i18n_1 = require("../../i18n");
44
51
  const logger_1 = require("../../logger");
45
52
  const utils_1 = require("../../utils");
53
+ const path_utils_1 = require("../../utils/path-utils");
46
54
  const escape_xml_1 = require("./escape-xml");
47
55
  const node_paths_1 = require("./node-paths");
48
- const TASK_NAME = 'AISupportAgent';
49
- function getLogDir() {
50
- const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
51
- return path.join(localAppData, 'ai-support-agent', 'logs');
56
+ Object.defineProperty(exports, "getCliEntryPoint", { enumerable: true, get: function () { return node_paths_1.getCliEntryPoint; } });
57
+ Object.defineProperty(exports, "getNodePath", { enumerable: true, get: function () { return node_paths_1.getNodePath; } });
58
+ const wrapper_helpers_1 = require("./wrapper-helpers");
59
+ const TASK_PREFIX = 'AISupportAgent';
60
+ const getLogDir = path_utils_1.getWin32LogDir;
61
+ /** Sanitize a code the same way buildContainerName does (lowercase, [a-z0-9-]). */
62
+ function sanitize(s) {
63
+ return s.toLowerCase().replace(/[^a-z0-9-]/g, '-');
52
64
  }
53
- function generateTaskXml(options) {
54
- const { nodePath, entryPoint, verbose, docker } = options;
55
- const args = [entryPoint, 'start'];
56
- if (!docker) {
57
- args.push(constants_1.CLI_FLAG_NO_DOCKER);
65
+ // ---------------------------------------------------------------------------
66
+ // Per-project task helpers
67
+ // ---------------------------------------------------------------------------
68
+ /**
69
+ * Returns the scheduled-task name for a given project.
70
+ * Format: AISupportAgent-{tenantCode}-{projectCode} (sanitized).
71
+ *
72
+ * The Windows Task Scheduler accepts backslashes as folder separators in task
73
+ * names, so any backslash in a code would silently create a nested task folder.
74
+ * sanitize() already strips it (along with other non `[a-z0-9-]` chars).
75
+ */
76
+ function getProjectTaskName(tenantCode, projectCode) {
77
+ return `${TASK_PREFIX}-${sanitize(tenantCode)}-${sanitize(projectCode)}`;
78
+ }
79
+ /**
80
+ * Lists all per-project scheduled tasks registered by this CLI.
81
+ *
82
+ * Queries Task Scheduler for tasks whose name starts with `${TASK_PREFIX}-`.
83
+ * Returns an empty list when schtasks is unavailable or no task matches.
84
+ */
85
+ function getAllProjectTasks() {
86
+ const prefix = `${TASK_PREFIX}-`;
87
+ const results = [];
88
+ try {
89
+ // /FO CSV /NH → bare rows; first column is "\TaskName" (leading backslash
90
+ // = root folder). Strip the leading backslash and surrounding quotes.
91
+ const output = (0, child_process_1.execSync)('schtasks /Query /FO CSV /NH', { stdio: 'pipe' }).toString();
92
+ for (const line of output.split(/\r?\n/)) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed)
95
+ continue;
96
+ const firstCol = trimmed.split('","')[0].replace(/^"/, '').replace(/"$/, '');
97
+ const name = firstCol.replace(/^\\/, '');
98
+ if (name.startsWith(prefix) && !results.some((r) => r.taskName === name)) {
99
+ results.push({ taskName: name });
100
+ }
101
+ }
58
102
  }
59
- if (verbose) {
60
- args.push(constants_1.CLI_FLAG_VERBOSE);
103
+ catch {
104
+ // schtasks unavailable / no tasks — return empty list
61
105
  }
106
+ return results;
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // Task XML + wrapper generation
110
+ // ---------------------------------------------------------------------------
111
+ /**
112
+ * Generate the scheduled-task XML that runs a per-project wrapper script.
113
+ * The action invokes cmd.exe on the generated run.cmd so the wrapper can set
114
+ * env vars and run `docker run` without leaking secrets onto the task's
115
+ * command line (which is world-readable via `schtasks /Query /XML`).
116
+ */
117
+ function generateProjectTaskXml(opts) {
62
118
  return `<?xml version="1.0" encoding="UTF-16"?>
63
119
  <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
64
120
  <Triggers>
@@ -78,140 +134,452 @@ function generateTaskXml(options) {
78
134
  </Settings>
79
135
  <Actions>
80
136
  <Exec>
81
- <Command>${(0, escape_xml_1.escapeXml)(nodePath)}</Command>
82
- <Arguments>${(0, escape_xml_1.escapeXml)(args.join(' '))}</Arguments>
137
+ <Command>cmd.exe</Command>
138
+ <Arguments>/c ${(0, escape_xml_1.escapeXml)(`"${opts.wrapperScriptPath}"`)}</Arguments>
83
139
  </Exec>
84
140
  </Actions>
85
141
  </Task>`;
86
142
  }
143
+ /** Convert localhost/127.0.0.1 to host.docker.internal for container use */
144
+ function toContainerApiUrl(apiUrl) {
145
+ return apiUrl.replace(/^(https?:\/\/)(localhost|127\.0\.0\.1)(:\d+)?/, (_, scheme, _host, port) => `${scheme}host.docker.internal${port ?? ''}`);
146
+ }
147
+ /**
148
+ * Windows batch (cmd.exe) cannot reliably escape every metacharacter the way a
149
+ * POSIX shell can with single quotes. Rather than risk a broken or injectable
150
+ * `set "VAR=value"` line, reject values containing characters that have special
151
+ * meaning to cmd.exe so the user fixes the bad value instead of silently
152
+ * generating a broken wrapper. Tokens / URLs / API keys never legitimately
153
+ * contain these. (No fallback — see the project "no fallback" rule.)
154
+ */
155
+ // Forbid cmd.exe metacharacters: % (var expansion), ! (delayed expansion),
156
+ // " (quote), CR/LF, ^ (escape/continuation), & (command separator), <>| (redir
157
+ // /pipe), and () (block grouping — harmless inside `set "..."` but rejected as
158
+ // defense in depth). A leading/trailing space is rejected separately below
159
+ // because `set " VAR=..."` would create a space-prefixed variable name that the
160
+ // `-e VAR` inherit line could never match.
161
+ const CMD_FORBIDDEN_RE = /[%!"\r\n^&<>|()]/;
162
+ function assertCmdSafe(value, field) {
163
+ if (CMD_FORBIDDEN_RE.test(value) || value !== value.trim()) {
164
+ throw new Error((0, i18n_1.t)('service.invalidWin32Value', { field }));
165
+ }
166
+ }
167
+ /**
168
+ * Generate a cmd.exe batch wrapper that runs docker for one project.
169
+ *
170
+ * Mirrors the Linux/Darwin wrappers: bind-mounts the per-project config dir and
171
+ * project dir, passes the token / api url / API keys as `-e` env vars, resolves
172
+ * the installed CLI version at runtime, auto-builds the image if missing, and
173
+ * removes any stale container before `docker run`.
174
+ */
175
+ function generateWin32WrapperScript(opts) {
176
+ const containerHome = '/home/node';
177
+ const containerConfigDir = `${containerHome}/.ai-support-agent`;
178
+ const homeDir = os.homedir();
179
+ const containerApiUrl = toContainerApiUrl(opts.apiUrl);
180
+ // Reject codes whose characters would break container naming / the dir map.
181
+ (0, wrapper_helpers_1.assertProjectCodeIsSafe)(opts.projectCode);
182
+ (0, wrapper_helpers_1.assertProjectCodeIsSafe)(opts.tenantCode);
183
+ // Reject secret/url values that cmd.exe cannot safely carry.
184
+ assertCmdSafe(opts.token, 'token');
185
+ assertCmdSafe(containerApiUrl, 'apiUrl');
186
+ if (opts.anthropicApiKey)
187
+ assertCmdSafe(opts.anthropicApiKey, 'anthropicApiKey');
188
+ if (opts.claudeCodeOauthToken)
189
+ assertCmdSafe(opts.claudeCodeOauthToken, 'claudeCodeOauthToken');
190
+ const containerProjectDir = `/workspace/projects/${opts.projectCode}`;
191
+ // `||` (not `??`) so an empty projectDir falls back to the default; an empty
192
+ // host path would emit `-v :/workspace/...:rw` which docker rejects.
193
+ const hostProjectDir = opts.projectDir || path.dirname(opts.projectConfigHostDir);
194
+ const containerName = `ai-${sanitize(opts.tenantCode)}-${sanitize(opts.projectCode)}`;
195
+ // docker run argument lines. Host paths are wrapped in double quotes so a
196
+ // space in the Windows home dir (e.g. C:\Users\First Last) does not split the
197
+ // -v argument. The values were validated above to contain no `"`.
198
+ const mountLines = [
199
+ ` -v "${homeDir}\\.claude:${containerHome}/.claude:rw" ^`,
200
+ ` -v "${opts.projectConfigHostDir}:${containerConfigDir}:rw" ^`,
201
+ ` -v "${homeDir}\\.claude.json:${containerHome}/.claude.json:rw" ^`,
202
+ ` -v "${hostProjectDir}:${containerProjectDir}:rw" ^`,
203
+ ];
204
+ const envLines = [
205
+ ` -e AI_SUPPORT_AGENT_IN_DOCKER=1 ^`,
206
+ ` -e HOME=${containerHome} ^`,
207
+ ` -e AI_SUPPORT_AGENT_CONFIG_DIR=${containerConfigDir} ^`,
208
+ // `-e NAME` (no value) tells docker to inherit the value from the wrapper's
209
+ // own environment, which we set via `set` below. This keeps secrets off the
210
+ // `docker run` argument vector.
211
+ ` -e AI_SUPPORT_AGENT_TOKEN ^`,
212
+ ` -e AI_SUPPORT_AGENT_API_URL ^`,
213
+ ` -e AI_SUPPORT_AGENT_PROJECT_DIR_MAP ^`,
214
+ ];
215
+ if (opts.anthropicApiKey)
216
+ envLines.push(` -e ANTHROPIC_API_KEY ^`);
217
+ if (opts.claudeCodeOauthToken)
218
+ envLines.push(` -e CLAUDE_CODE_OAUTH_TOKEN ^`);
219
+ const containerArgs = [
220
+ 'ai-support-agent', 'start', constants_1.CLI_FLAG_NO_DOCKER,
221
+ `--project ${opts.tenantCode}/${opts.projectCode}`,
222
+ ];
223
+ if (opts.verbose)
224
+ containerArgs.push(constants_1.CLI_FLAG_VERBOSE);
225
+ const setLines = [
226
+ `set "AI_SUPPORT_AGENT_TOKEN=${opts.token}"`,
227
+ `set "AI_SUPPORT_AGENT_API_URL=${containerApiUrl}"`,
228
+ `set "AI_SUPPORT_AGENT_PROJECT_DIR_MAP=${opts.projectCode}=${containerProjectDir}"`,
229
+ ];
230
+ if (opts.anthropicApiKey)
231
+ setLines.push(`set "ANTHROPIC_API_KEY=${opts.anthropicApiKey}"`);
232
+ if (opts.claudeCodeOauthToken)
233
+ setLines.push(`set "CLAUDE_CODE_OAUTH_TOKEN=${opts.claudeCodeOauthToken}"`);
234
+ const dockerRun = [
235
+ `docker run --rm -i --name "${containerName}" ^`,
236
+ mountLines.join('\r\n'),
237
+ envLines.join('\r\n'),
238
+ ` "%IMAGE_TAG%" ^`,
239
+ ` ${containerArgs.join(' ')}`,
240
+ ].join('\r\n');
241
+ // NOTE: `setlocal` scopes the `set` secrets to this wrapper invocation so
242
+ // they never leak into the parent environment. EnableExtensions is needed for
243
+ // the `if` blocks. We deliberately do NOT enable delayed expansion so a `!`
244
+ // in a value cannot trigger expansion (and `!` is rejected above anyway).
245
+ const lines = [
246
+ `@echo off`,
247
+ `setlocal EnableExtensions`,
248
+ ``,
249
+ `rem Resolve the installed CLI version at runtime so the image stays current after npm updates.`,
250
+ `rem usebackq lets the command be wrapped in backquotes so the inner node -p`,
251
+ `rem double quotes don't confuse for /f's parser, and a space in the npm root`,
252
+ `rem path (e.g. C:\\Program Files\\...) stays inside the JS string literal.`,
253
+ `for /f "usebackq delims=" %%R in (\`npm root -g 2^>nul\`) do set "_NPM_ROOT=%%R"`,
254
+ `if not defined _NPM_ROOT (`,
255
+ ` echo ERROR: Could not determine npm global root 1>&2`,
256
+ ` exit /b 1`,
257
+ `)`,
258
+ `for /f "usebackq delims=" %%V in (\`node -p "require('%_NPM_ROOT:\\=/%/@ai-support-agent/cli/package.json').version" 2^>nul\`) do set "_INSTALLED_VERSION=%%V"`,
259
+ `if not defined _INSTALLED_VERSION (`,
260
+ ` echo ERROR: Could not determine installed version of @ai-support-agent/cli 1>&2`,
261
+ ` exit /b 1`,
262
+ `)`,
263
+ `set "IMAGE_TAG=${opts.imageName}:%_INSTALLED_VERSION%"`,
264
+ ``,
265
+ `rem Auto-build the image if the required version does not exist locally`,
266
+ `docker image inspect "%IMAGE_TAG%" >nul 2>&1`,
267
+ `if errorlevel 1 (`,
268
+ ` echo Docker image %IMAGE_TAG% not found - building... 1>&2`,
269
+ ` call ai-support-agent docker-build`,
270
+ ` if errorlevel 1 (`,
271
+ ` echo ERROR: docker-build failed - cannot start container 1>&2`,
272
+ ` exit /b 1`,
273
+ ` )`,
274
+ `)`,
275
+ ``,
276
+ `rem Remove any stale container from a previous crash`,
277
+ `docker rm -f "${containerName}" >nul 2>&1`,
278
+ ``,
279
+ ...setLines,
280
+ ``,
281
+ dockerRun,
282
+ ``,
283
+ `endlocal`,
284
+ ];
285
+ return lines.join('\r\n') + '\r\n';
286
+ }
287
+ // ---------------------------------------------------------------------------
288
+ // Service file writing
289
+ // ---------------------------------------------------------------------------
290
+ /**
291
+ * Write the run.cmd wrapper and register/refresh the scheduled task for one
292
+ * project. Idempotent: deletes any existing task with the same name first so
293
+ * token/config updates take effect.
294
+ */
295
+ function writeAndRegisterProjectTask(project, options = {}) {
296
+ const { tenantCode, projectCode } = project;
297
+ (0, wrapper_helpers_1.assertProjectCodeIsSafe)(projectCode);
298
+ (0, wrapper_helpers_1.assertProjectCodeIsSafe)(tenantCode);
299
+ const projectKey = `${tenantCode}-${projectCode.toLowerCase()}`;
300
+ // mode 0o700 keeps the per-project metadata, services, and logs owner-only.
301
+ // The run.cmd wrapper written below contains the token in plaintext, so the
302
+ // service dir must not be world-readable. On Windows the POSIX mode bits are
303
+ // only partially honored, but we set them for parity with darwin/linux and so
304
+ // the wrapper is at least not group/other-writable on filesystems that map
305
+ // them (e.g. WSL/SMB shares).
306
+ const logDir = getLogDir();
307
+ const projectLogDir = (0, path_utils_1.getProjectLogDir)(logDir, projectKey);
308
+ if (!fs.existsSync(projectLogDir)) {
309
+ fs.mkdirSync(projectLogDir, { recursive: true, mode: 0o700 });
310
+ }
311
+ const servicesDir = (0, path_utils_1.getServicesDir)();
312
+ const projectServiceDir = (0, path_utils_1.getProjectServiceDir)(servicesDir, projectKey);
313
+ if (!fs.existsSync(projectServiceDir)) {
314
+ fs.mkdirSync(projectServiceDir, { recursive: true, mode: 0o700 });
315
+ }
316
+ const projectConfigHostDir = (0, path_utils_1.getProjectConfigHostDir)(tenantCode, projectCode);
317
+ if (!fs.existsSync(projectConfigHostDir)) {
318
+ fs.mkdirSync(projectConfigHostDir, { recursive: true, mode: 0o700 });
319
+ }
320
+ const validatedProjectDir = (0, wrapper_helpers_1.validateProjectDirForMount)(project.projectDir);
321
+ const wrapperScriptPath = (0, path_utils_1.getWin32WrapperScriptPath)(projectServiceDir);
322
+ const wrapperScript = generateWin32WrapperScript({
323
+ imageName: docker_utils_1.IMAGE_NAME,
324
+ tenantCode,
325
+ projectCode,
326
+ projectConfigHostDir,
327
+ projectDir: validatedProjectDir,
328
+ token: project.token,
329
+ apiUrl: project.apiUrl,
330
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY,
331
+ claudeCodeOauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN,
332
+ verbose: options.verbose,
333
+ });
334
+ // The wrapper holds the token in plaintext — write it owner-only.
335
+ fs.writeFileSync(wrapperScriptPath, wrapperScript, { encoding: 'utf-8', mode: 0o700 });
336
+ const taskName = getProjectTaskName(tenantCode, projectCode);
337
+ const xml = generateProjectTaskXml({ wrapperScriptPath });
338
+ const tmpXmlPath = path.join(os.tmpdir(), `${taskName}-task.xml`);
339
+ try {
340
+ fs.writeFileSync(tmpXmlPath, xml, 'utf-8');
341
+ // /F overwrites an existing task so token/config updates take effect.
342
+ (0, child_process_1.execSync)(`schtasks /Create /TN "${taskName}" /XML "${tmpXmlPath}" /F`, { stdio: 'pipe' });
343
+ }
344
+ finally {
345
+ if (fs.existsSync(tmpXmlPath)) {
346
+ fs.unlinkSync(tmpXmlPath);
347
+ }
348
+ }
349
+ }
87
350
  class Win32ServiceStrategy {
88
351
  install(options) {
89
- const logDir = getLogDir();
90
- const nodePath = (0, node_paths_1.getNodePath)();
352
+ const config = (0, config_manager_1.loadConfig)();
353
+ const projects = config ? (0, config_manager_1.getProjectList)(config) : [];
354
+ if (projects.length === 0) {
355
+ logger_1.logger.error((0, i18n_1.t)('service.noProjectsConfigured'));
356
+ return;
357
+ }
91
358
  const entryPoint = (0, node_paths_1.getCliEntryPoint)();
92
359
  if (!fs.existsSync(entryPoint)) {
93
360
  logger_1.logger.error((0, i18n_1.t)('service.entryPointNotFound', { path: entryPoint }));
94
361
  return;
95
362
  }
363
+ const logDir = getLogDir();
96
364
  if (!fs.existsSync(logDir)) {
97
365
  fs.mkdirSync(logDir, { recursive: true });
98
366
  }
99
- const xml = generateTaskXml({
100
- nodePath,
101
- entryPoint,
102
- logDir,
103
- verbose: options.verbose,
104
- docker: options.docker,
105
- });
106
- const tmpXmlPath = path.join(os.tmpdir(), `${TASK_NAME}-task.xml`);
107
- try {
108
- fs.writeFileSync(tmpXmlPath, xml, 'utf-8');
109
- (0, child_process_1.execSync)(`schtasks /Create /TN "${TASK_NAME}" /XML "${tmpXmlPath}" /F`, {
110
- stdio: 'pipe',
111
- });
112
- }
113
- catch (error) {
114
- const message = (0, utils_1.getErrorMessage)(error);
115
- logger_1.logger.error((0, i18n_1.t)('service.schtasksFailed', { message }));
116
- return;
117
- }
118
- finally {
119
- if (fs.existsSync(tmpXmlPath)) {
120
- fs.unlinkSync(tmpXmlPath);
367
+ // Detect sanitize() collisions where two valid codes map to the same task
368
+ // name. Shared helper guarantees identical semantics with linux/darwin.
369
+ const { collisions } = (0, wrapper_helpers_1.detectInstallCollisions)(projects, getProjectTaskName);
370
+ const reportedCollisionLabels = new Set();
371
+ let installedCount = 0;
372
+ let failedCount = 0;
373
+ for (const project of projects) {
374
+ const { projectCode } = project;
375
+ const fqn = `${project.tenantCode}/${projectCode}`;
376
+ const collision = collisions.get(fqn);
377
+ if (collision) {
378
+ const messageKey = collision.isDuplicate
379
+ ? 'service.projectDuplicateEntry'
380
+ : 'service.projectUnitNameCollision';
381
+ const dedupKey = `${collision.name}\x00${messageKey}`;
382
+ if (!reportedCollisionLabels.has(dedupKey)) {
383
+ logger_1.logger.error((0, i18n_1.t)(messageKey, {
384
+ projectCode,
385
+ unitName: collision.name,
386
+ others: collision.others.join(', '),
387
+ }));
388
+ reportedCollisionLabels.add(dedupKey);
389
+ }
390
+ failedCount += 1;
391
+ continue;
121
392
  }
393
+ try {
394
+ writeAndRegisterProjectTask(project, { verbose: options.verbose });
395
+ logger_1.logger.success((0, i18n_1.t)('service.projectInstalled', { projectCode, path: getProjectTaskName(project.tenantCode, projectCode) }));
396
+ installedCount += 1;
397
+ }
398
+ catch (error) {
399
+ const message = (0, utils_1.getErrorMessage)(error);
400
+ logger_1.logger.error((0, i18n_1.t)('service.projectInstallFailed', { projectCode, message }));
401
+ failedCount += 1;
402
+ }
403
+ }
404
+ if (installedCount > 0) {
405
+ logger_1.logger.info((0, i18n_1.t)('service.loadHintMulti'));
406
+ logger_1.logger.info((0, i18n_1.t)('service.logDir', { path: logDir }));
407
+ logger_1.logger.info((0, i18n_1.t)('service.noLogRotation'));
408
+ }
409
+ if (failedCount > 0) {
410
+ logger_1.logger.warn((0, i18n_1.t)('service.partialInstallSummary', {
411
+ failed: String(failedCount),
412
+ total: String(projects.length),
413
+ succeeded: String(installedCount),
414
+ }));
122
415
  }
123
- logger_1.logger.success((0, i18n_1.t)('service.installed.win32', { taskName: TASK_NAME }));
124
- logger_1.logger.info((0, i18n_1.t)('service.loadHint.win32', { taskName: TASK_NAME }));
125
- logger_1.logger.info((0, i18n_1.t)('service.logDir', { path: logDir }));
126
- logger_1.logger.info((0, i18n_1.t)('service.noLogRotation'));
127
416
  }
128
417
  uninstall() {
129
- try {
130
- (0, child_process_1.execSync)(`schtasks /Query /TN "${TASK_NAME}"`, { stdio: 'pipe' });
131
- }
132
- catch {
418
+ const projectTasks = getAllProjectTasks();
419
+ if (projectTasks.length === 0) {
133
420
  logger_1.logger.warn((0, i18n_1.t)('service.notInstalled.win32'));
134
421
  return;
135
422
  }
136
- logger_1.logger.info((0, i18n_1.t)('service.unloadHint.win32', { taskName: TASK_NAME }));
137
- try {
138
- (0, child_process_1.execSync)(`schtasks /Delete /TN "${TASK_NAME}" /F`, { stdio: 'pipe' });
423
+ let failed = false;
424
+ for (const { taskName } of projectTasks) {
425
+ try {
426
+ (0, child_process_1.execSync)(`schtasks /Delete /TN "${taskName}" /F`, { stdio: 'pipe' });
427
+ }
428
+ catch (error) {
429
+ const message = (0, utils_1.getErrorMessage)(error);
430
+ logger_1.logger.error((0, i18n_1.t)('service.schtasksFailed', { message }));
431
+ failed = true;
432
+ }
139
433
  }
140
- catch (error) {
141
- const message = (0, utils_1.getErrorMessage)(error);
142
- logger_1.logger.error((0, i18n_1.t)('service.schtasksFailed', { message }));
143
- return;
434
+ if (!failed) {
435
+ logger_1.logger.success((0, i18n_1.t)('service.uninstalled.win32'));
144
436
  }
145
- logger_1.logger.success((0, i18n_1.t)('service.uninstalled.win32'));
146
437
  }
147
438
  start() {
148
- try {
149
- (0, child_process_1.execSync)(`schtasks /Query /TN "${TASK_NAME}"`, { stdio: 'pipe' });
150
- }
151
- catch {
439
+ const projectTasks = getAllProjectTasks();
440
+ if (projectTasks.length === 0) {
152
441
  logger_1.logger.error((0, i18n_1.t)('service.notInstalled.win32'));
153
442
  return;
154
443
  }
155
- try {
156
- (0, child_process_1.execSync)(`schtasks /Run /TN "${TASK_NAME}"`, { stdio: 'pipe' });
157
- logger_1.logger.success((0, i18n_1.t)('service.started'));
444
+ let failed = false;
445
+ for (const { taskName } of projectTasks) {
446
+ try {
447
+ (0, child_process_1.execSync)(`schtasks /Run /TN "${taskName}"`, { stdio: 'pipe' });
448
+ }
449
+ catch (error) {
450
+ const message = (0, utils_1.getErrorMessage)(error);
451
+ logger_1.logger.error((0, i18n_1.t)('service.startFailed', { message }));
452
+ failed = true;
453
+ }
158
454
  }
159
- catch (error) {
160
- const message = (0, utils_1.getErrorMessage)(error);
161
- logger_1.logger.error((0, i18n_1.t)('service.startFailed', { message }));
455
+ if (!failed) {
456
+ logger_1.logger.success((0, i18n_1.t)('service.started'));
162
457
  }
163
458
  }
164
459
  stop() {
165
- try {
166
- (0, child_process_1.execSync)(`schtasks /Query /TN "${TASK_NAME}"`, { stdio: 'pipe' });
167
- }
168
- catch {
460
+ const projectTasks = getAllProjectTasks();
461
+ if (projectTasks.length === 0) {
169
462
  logger_1.logger.error((0, i18n_1.t)('service.notInstalled.win32'));
170
463
  return;
171
464
  }
172
- try {
173
- (0, child_process_1.execSync)(`schtasks /End /TN "${TASK_NAME}"`, { stdio: 'pipe' });
174
- logger_1.logger.success((0, i18n_1.t)('service.stopped'));
465
+ let failed = false;
466
+ for (const { taskName } of projectTasks) {
467
+ try {
468
+ (0, child_process_1.execSync)(`schtasks /End /TN "${taskName}"`, { stdio: 'pipe' });
469
+ }
470
+ catch (error) {
471
+ const message = (0, utils_1.getErrorMessage)(error);
472
+ logger_1.logger.error((0, i18n_1.t)('service.stopFailed', { message }));
473
+ failed = true;
474
+ }
175
475
  }
176
- catch (error) {
177
- const message = (0, utils_1.getErrorMessage)(error);
178
- logger_1.logger.error((0, i18n_1.t)('service.stopFailed', { message }));
476
+ if (!failed) {
477
+ logger_1.logger.success((0, i18n_1.t)('service.stopped'));
179
478
  }
180
479
  }
181
480
  restart() {
182
- try {
183
- (0, child_process_1.execSync)(`schtasks /Query /TN "${TASK_NAME}"`, { stdio: 'pipe' });
184
- }
185
- catch {
481
+ const projectTasks = getAllProjectTasks();
482
+ if (projectTasks.length === 0) {
186
483
  logger_1.logger.error((0, i18n_1.t)('service.notInstalled.win32'));
187
484
  return;
188
485
  }
189
- try {
190
- (0, child_process_1.execSync)(`schtasks /End /TN "${TASK_NAME}"`, { stdio: 'pipe' });
191
- }
192
- catch {
193
- // Task may not be running ignore
486
+ let failed = false;
487
+ for (const { taskName } of projectTasks) {
488
+ try {
489
+ try {
490
+ (0, child_process_1.execSync)(`schtasks /End /TN "${taskName}"`, { stdio: 'pipe' });
491
+ }
492
+ catch {
493
+ // Task may not be running — ignore
494
+ }
495
+ (0, child_process_1.execSync)(`schtasks /Run /TN "${taskName}"`, { stdio: 'pipe' });
496
+ }
497
+ catch (error) {
498
+ const message = (0, utils_1.getErrorMessage)(error);
499
+ logger_1.logger.error((0, i18n_1.t)('service.restartFailed', { message }));
500
+ failed = true;
501
+ }
194
502
  }
195
- try {
196
- (0, child_process_1.execSync)(`schtasks /Run /TN "${TASK_NAME}"`, { stdio: 'pipe' });
503
+ if (!failed) {
197
504
  logger_1.logger.success((0, i18n_1.t)('service.restarted'));
198
505
  }
199
- catch (error) {
200
- const message = (0, utils_1.getErrorMessage)(error);
201
- logger_1.logger.error((0, i18n_1.t)('service.restartFailed', { message }));
202
- }
203
506
  }
204
507
  status() {
508
+ const projectTasks = getAllProjectTasks();
205
509
  const logDir = getLogDir();
206
- try {
207
- const output = (0, child_process_1.execSync)(`schtasks /Query /TN "${TASK_NAME}" /FO CSV /NH`, { stdio: 'pipe' }).toString();
208
- const running = output.includes('Running');
209
- return { installed: true, running, logDir };
210
- }
211
- catch {
510
+ if (projectTasks.length === 0) {
212
511
  return { installed: false, running: false };
213
512
  }
513
+ // Build a reverse map from task name → original projectCode using config.
514
+ // sanitize() collapses `_` and other chars to `-`, so splitting the task
515
+ // name on `-` is lossy when tenant/project codes contain those characters
516
+ // (and the tenant/project boundary is ambiguous) — config is the source of
517
+ // truth. Mirrors the Linux strategy.
518
+ const config = (0, config_manager_1.loadConfig)();
519
+ const projectByTaskName = new Map();
520
+ if (config) {
521
+ for (const project of (0, config_manager_1.getProjectList)(config)) {
522
+ projectByTaskName.set(getProjectTaskName(project.tenantCode, project.projectCode), project.projectCode);
523
+ }
524
+ }
525
+ const projects = [];
526
+ let isAnyRunning = false;
527
+ for (const { taskName } of projectTasks) {
528
+ // Prefer the canonical projectCode from config. For orphaned tasks no
529
+ // longer in config, fall back to the task name with the brand prefix
530
+ // stripped so users still see something close to the original code.
531
+ const projectCode = projectByTaskName.get(taskName)
532
+ ?? (taskName.startsWith(`${TASK_PREFIX}-`) ? taskName.slice(TASK_PREFIX.length + 1) : taskName);
533
+ let running = false;
534
+ try {
535
+ const output = (0, child_process_1.execSync)(`schtasks /Query /TN "${taskName}" /FO CSV /NH`, { stdio: 'pipe' }).toString();
536
+ if (output.includes('Running')) {
537
+ running = true;
538
+ isAnyRunning = true;
539
+ }
540
+ }
541
+ catch {
542
+ // Not registered / query failed — running stays false
543
+ }
544
+ projects.push({ label: taskName, projectCode, running });
545
+ }
546
+ return { installed: true, running: isAnyRunning, logDir, projects };
214
547
  }
215
548
  }
216
549
  exports.Win32ServiceStrategy = Win32ServiceStrategy;
550
+ // Backward-compat: keep generateTaskXml exported for any external callers.
551
+ // Generates a single-task XML (legacy single-project form) — no longer used by
552
+ // the multi-project install path above.
553
+ function generateTaskXml(options) {
554
+ const { nodePath, entryPoint, verbose, docker } = options;
555
+ const args = [entryPoint, 'start'];
556
+ if (!docker)
557
+ args.push(constants_1.CLI_FLAG_NO_DOCKER);
558
+ if (verbose)
559
+ args.push(constants_1.CLI_FLAG_VERBOSE);
560
+ return `<?xml version="1.0" encoding="UTF-16"?>
561
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
562
+ <Triggers>
563
+ <LogonTrigger>
564
+ <Enabled>true</Enabled>
565
+ </LogonTrigger>
566
+ </Triggers>
567
+ <Settings>
568
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
569
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
570
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
571
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
572
+ <RestartOnFailure>
573
+ <Interval>PT1M</Interval>
574
+ <Count>999</Count>
575
+ </RestartOnFailure>
576
+ </Settings>
577
+ <Actions>
578
+ <Exec>
579
+ <Command>${(0, escape_xml_1.escapeXml)(nodePath)}</Command>
580
+ <Arguments>${(0, escape_xml_1.escapeXml)(args.join(' '))}</Arguments>
581
+ </Exec>
582
+ </Actions>
583
+ </Task>`;
584
+ }
217
585
  //# sourceMappingURL=win32-service.js.map