discoclaw 0.2.0 → 0.2.2

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.
package/.env.example.full CHANGED
@@ -318,6 +318,9 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
318
318
  # managers, or running under a different user). If unset, the command is
319
319
  # auto-selected: systemctl --user on Linux, launchctl on macOS.
320
320
  #DC_RESTART_CMD=
321
+ # Sets the systemd unit / launchd label name used by !restart and !update apply.
322
+ # Default: discoclaw. Ignored when DC_RESTART_CMD is set (that overrides the entire command).
323
+ #DISCOCLAW_SERVICE_NAME=discoclaw
321
324
  # Global cap on parallel runtime invocations across all Discord sessions (0 = unlimited).
322
325
  #DISCOCLAW_MAX_CONCURRENT_INVOCATIONS=3
323
326
  # Max depth for chained action follow-ups (e.g. defer → action → response). 0 = disabled.
@@ -119,8 +119,38 @@ export function renderLaunchdPlist(packageRoot, cwd, envVars, serviceName = 'dis
119
119
  lines.push('');
120
120
  return lines.join('\n');
121
121
  }
122
+ // ── Env helpers ────────────────────────────────────────────────────────────
123
+ /**
124
+ * Ensures DISCOCLAW_SERVICE_NAME in the .env file reflects serviceName.
125
+ * - Default ('discoclaw'): removes any existing DISCOCLAW_SERVICE_NAME line.
126
+ * - Non-default: replaces existing line in-place, or appends a new one.
127
+ * Only writes if content actually changed.
128
+ */
129
+ export function ensureServiceNameInEnv(envPath, serviceName) {
130
+ const content = fs.readFileSync(envPath, 'utf8');
131
+ const KEY = 'DISCOCLAW_SERVICE_NAME';
132
+ const lineRegex = /^DISCOCLAW_SERVICE_NAME=.*$/m;
133
+ let newContent;
134
+ if (serviceName === 'discoclaw') {
135
+ if (!lineRegex.test(content))
136
+ return;
137
+ newContent = content.replace(/^DISCOCLAW_SERVICE_NAME=.*\n?/m, '');
138
+ }
139
+ else {
140
+ if (lineRegex.test(content)) {
141
+ newContent = content.replace(lineRegex, `${KEY}=${serviceName}`);
142
+ }
143
+ else {
144
+ const sep = content.endsWith('\n') ? '' : '\n';
145
+ newContent = `${content}${sep}${KEY}=${serviceName}\n`;
146
+ }
147
+ }
148
+ if (newContent !== content) {
149
+ fs.writeFileSync(envPath, newContent, 'utf8');
150
+ }
151
+ }
122
152
  // ── Platform installers ────────────────────────────────────────────────────
123
- async function installSystemd(packageRoot, cwd, serviceName, ask) {
153
+ async function installSystemd(packageRoot, cwd, envPath, serviceName, ask) {
124
154
  const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
125
155
  const servicePath = path.join(serviceDir, `${serviceName}.service`);
126
156
  if (fs.existsSync(servicePath)) {
@@ -130,6 +160,13 @@ async function installSystemd(packageRoot, cwd, serviceName, ask) {
130
160
  return;
131
161
  }
132
162
  }
163
+ try {
164
+ ensureServiceNameInEnv(envPath, serviceName);
165
+ }
166
+ catch (err) {
167
+ console.error(`Failed to update .env: ${err.message}\n`);
168
+ process.exit(1);
169
+ }
133
170
  const unit = renderSystemdUnit(packageRoot, cwd);
134
171
  fs.mkdirSync(serviceDir, { recursive: true });
135
172
  fs.writeFileSync(servicePath, unit, 'utf8');
@@ -168,6 +205,13 @@ async function installLaunchd(packageRoot, cwd, envPath, serviceName, ask) {
168
205
  return;
169
206
  }
170
207
  }
208
+ try {
209
+ ensureServiceNameInEnv(envPath, serviceName);
210
+ }
211
+ catch (err) {
212
+ console.error(`Failed to update .env: ${err.message}\n`);
213
+ process.exit(1);
214
+ }
171
215
  const envContent = fs.readFileSync(envPath, 'utf8');
172
216
  const envVars = parseEnvFile(envContent);
173
217
  const plist = renderLaunchdPlist(packageRoot, cwd, envVars, serviceName);
@@ -229,7 +273,7 @@ export async function runDaemonInstaller() {
229
273
  const ask = (prompt) => rl.question(prompt);
230
274
  try {
231
275
  if (platform === 'linux') {
232
- await installSystemd(packageRoot, cwd, serviceName, ask);
276
+ await installSystemd(packageRoot, cwd, envPath, serviceName, ask);
233
277
  }
234
278
  else {
235
279
  await installLaunchd(packageRoot, cwd, envPath, serviceName, ask);
@@ -16,7 +16,7 @@ vi.mock('node:readline/promises', () => ({
16
16
  import fs from 'node:fs';
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { createInterface } from 'node:readline/promises';
19
- import { parseEnvFile, parseServiceName, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
19
+ import { ensureServiceNameInEnv, parseEnvFile, parseServiceName, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
20
20
  // ── Test helpers ───────────────────────────────────────────────────────────
21
21
  function makeReadline(answers = []) {
22
22
  return {
@@ -121,6 +121,43 @@ describe('renderLaunchdPlist', () => {
121
121
  expect(plist).toContain('<true/>');
122
122
  });
123
123
  });
124
+ // ── ensureServiceNameInEnv ─────────────────────────────────────────────────
125
+ describe('ensureServiceNameInEnv', () => {
126
+ const ENV_PATH = '/home/user/bot/.env';
127
+ beforeEach(() => {
128
+ vi.clearAllMocks();
129
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
130
+ });
131
+ it('default name, no existing line — content is unchanged', () => {
132
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\n');
133
+ ensureServiceNameInEnv(ENV_PATH, 'discoclaw');
134
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
135
+ });
136
+ it('custom name, no existing line — appends DISCOCLAW_SERVICE_NAME=custom-name', () => {
137
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\n');
138
+ ensureServiceNameInEnv(ENV_PATH, 'custom-name');
139
+ expect(fs.writeFileSync).toHaveBeenCalledWith(ENV_PATH, 'DISCORD_TOKEN=abc\nDISCOCLAW_SERVICE_NAME=custom-name\n', 'utf8');
140
+ });
141
+ it('custom name, existing stale line — replaces old value in-place, no duplicate', () => {
142
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\nDISCOCLAW_SERVICE_NAME=old-name\nFOO=bar\n');
143
+ ensureServiceNameInEnv(ENV_PATH, 'new-name');
144
+ const written = vi.mocked(fs.writeFileSync).mock.calls[0][1];
145
+ expect(written).toContain('DISCOCLAW_SERVICE_NAME=new-name');
146
+ expect(written).not.toContain('DISCOCLAW_SERVICE_NAME=old-name');
147
+ expect((written.match(/DISCOCLAW_SERVICE_NAME=/g) ?? []).length).toBe(1);
148
+ });
149
+ it('default name, existing stale line — removes DISCOCLAW_SERVICE_NAME line', () => {
150
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\nDISCOCLAW_SERVICE_NAME=old-name\n');
151
+ ensureServiceNameInEnv(ENV_PATH, 'discoclaw');
152
+ const written = vi.mocked(fs.writeFileSync).mock.calls[0][1];
153
+ expect(written).not.toContain('DISCOCLAW_SERVICE_NAME');
154
+ });
155
+ it('custom name matches existing line — no file write (no-op)', () => {
156
+ vi.mocked(fs.readFileSync).mockReturnValue('DISCORD_TOKEN=abc\nDISCOCLAW_SERVICE_NAME=custom-name\n');
157
+ ensureServiceNameInEnv(ENV_PATH, 'custom-name');
158
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
159
+ });
160
+ });
124
161
  // ── runDaemonInstaller ─────────────────────────────────────────────────────
125
162
  const originalIsTTY = process.stdin.isTTY;
126
163
  const originalPlatform = process.platform;
@@ -306,4 +343,49 @@ describe('runDaemonInstaller', () => {
306
343
  expect(fs.writeFileSync).not.toHaveBeenCalled();
307
344
  expect(execFileSync).not.toHaveBeenCalled();
308
345
  });
346
+ it('linux: does not mutate .env when overwrite is declined with custom --service-name', async () => {
347
+ vi.spyOn(console, 'log').mockImplementation(() => { });
348
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
349
+ const originalArgv = process.argv;
350
+ process.argv = ['node', 'discoclaw', 'install-daemon', '--service-name', 'custom'];
351
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
352
+ const s = String(p);
353
+ if (s.endsWith('.env'))
354
+ return true;
355
+ if (s.endsWith('dist/index.js'))
356
+ return true;
357
+ if (s.endsWith('custom.service'))
358
+ return true;
359
+ return false;
360
+ });
361
+ const rl = makeReadline(['n']); // decline overwrite
362
+ vi.mocked(createInterface).mockReturnValue(rl);
363
+ try {
364
+ await runDaemonInstaller();
365
+ }
366
+ finally {
367
+ process.argv = originalArgv;
368
+ }
369
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
370
+ });
371
+ it('linux: writes DISCOCLAW_SERVICE_NAME=custom to .env before service file when --service-name custom is passed', async () => {
372
+ vi.spyOn(console, 'log').mockImplementation(() => { });
373
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
374
+ const originalArgv = process.argv;
375
+ process.argv = ['node', 'discoclaw', 'install-daemon', '--service-name', 'custom'];
376
+ // .env has no DISCOCLAW_SERVICE_NAME yet
377
+ vi.mocked(fs.readFileSync).mockReturnValue(SAMPLE_ENV);
378
+ try {
379
+ await runDaemonInstaller();
380
+ }
381
+ finally {
382
+ process.argv = originalArgv;
383
+ }
384
+ const calls = vi.mocked(fs.writeFileSync).mock.calls;
385
+ const envWriteIdx = calls.findIndex(([p]) => String(p).endsWith('.env'));
386
+ const serviceWriteIdx = calls.findIndex(([p]) => String(p).endsWith('custom.service'));
387
+ expect(envWriteIdx).toBeGreaterThanOrEqual(0);
388
+ expect(calls[envWriteIdx][1]).toContain('DISCOCLAW_SERVICE_NAME=custom');
389
+ expect(serviceWriteIdx).toBeGreaterThan(envWriteIdx);
390
+ });
309
391
  });
package/dist/config.js CHANGED
@@ -406,6 +406,7 @@ export function parseConfig(env) {
406
406
  botActivity: parseTrimmedString(env, 'DISCOCLAW_BOT_ACTIVITY'),
407
407
  botActivityType: parseEnum(env, 'DISCOCLAW_BOT_ACTIVITY_TYPE', ['Playing', 'Listening', 'Watching', 'Competing', 'Custom'], 'Playing'),
408
408
  botAvatar: parseAvatarPath(env, 'DISCOCLAW_BOT_AVATAR'),
409
+ serviceName: parseTrimmedString(env, 'DISCOCLAW_SERVICE_NAME') ?? 'discoclaw',
409
410
  },
410
411
  warnings,
411
412
  infos,
@@ -27,6 +27,7 @@ describe('parseConfig', () => {
27
27
  expect(config.tasksSyncFailureRetryDelayMs).toBe(30_000);
28
28
  expect(config.tasksSyncDeferredRetryDelayMs).toBe(30_000);
29
29
  expect(config.outputFormat).toBe('text');
30
+ expect(config.serviceName).toBe('discoclaw');
30
31
  expect(warnings.some((w) => w.includes('category flags are ignored'))).toBe(false);
31
32
  expect(infos.some((i) => i.includes('category flags are ignored'))).toBe(false);
32
33
  });
@@ -693,4 +694,17 @@ describe('parseConfig', () => {
693
694
  const { config } = parseConfig(env({ DISCOCLAW_WEBHOOK_CONFIG: '/etc/discoclaw/webhooks.json' }));
694
695
  expect(config.webhookConfigPath).toBe('/etc/discoclaw/webhooks.json');
695
696
  });
697
+ // --- serviceName ---
698
+ it('defaults serviceName to "discoclaw"', () => {
699
+ const { config } = parseConfig(env());
700
+ expect(config.serviceName).toBe('discoclaw');
701
+ });
702
+ it('parses DISCOCLAW_SERVICE_NAME when set', () => {
703
+ const { config } = parseConfig(env({ DISCOCLAW_SERVICE_NAME: 'discoclaw-dev' }));
704
+ expect(config.serviceName).toBe('discoclaw-dev');
705
+ });
706
+ it('returns default serviceName when DISCOCLAW_SERVICE_NAME is whitespace-only', () => {
707
+ const { config } = parseConfig(env({ DISCOCLAW_SERVICE_NAME: ' ' }));
708
+ expect(config.serviceName).toBe('discoclaw');
709
+ });
696
710
  });
@@ -494,6 +494,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
494
494
  dataDir: params.dataDir,
495
495
  userId: msg.author.id,
496
496
  activeForge: getActiveOrchestrator()?.activePlanId,
497
+ serviceName: params.serviceName,
497
498
  });
498
499
  await msg.reply({ content: result.reply, allowedMentions: NO_MENTIONS });
499
500
  // Deferred action (e.g., restart) runs after the reply is sent.
@@ -509,6 +510,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
509
510
  projectCwd: params.projectCwd,
510
511
  dataDir: params.dataDir,
511
512
  restartCmd: params.updateRestartCmd,
513
+ serviceName: params.serviceName,
512
514
  });
513
515
  await msg.reply({ content: result.reply, allowedMentions: NO_MENTIONS });
514
516
  // Deferred action (e.g., restart after apply) runs after the reply is sent.
@@ -30,21 +30,21 @@ function mapExitCode(err) {
30
30
  return 0;
31
31
  return typeof err.code === 'number' ? err.code : null;
32
32
  }
33
- function getPlatformCommands() {
33
+ function getPlatformCommands(serviceName = 'discoclaw') {
34
34
  if (process.platform === 'linux') {
35
35
  return {
36
- statusCmd: ['systemctl', ['--user', 'status', 'discoclaw']],
37
- logsCmd: ['journalctl', ['--user', '-u', 'discoclaw', '--no-pager', '-n', '30']],
38
- checkActiveCmd: ['systemctl', ['--user', 'status', 'discoclaw']],
36
+ statusCmd: ['systemctl', ['--user', 'status', serviceName]],
37
+ logsCmd: ['journalctl', ['--user', '-u', serviceName, '--no-pager', '-n', '30']],
38
+ checkActiveCmd: ['systemctl', ['--user', 'status', serviceName]],
39
39
  isActive: (result) => result.stdout.includes('active (running)'),
40
- restartCmd: () => ['systemctl', ['--user', 'restart', 'discoclaw']],
40
+ restartCmd: () => ['systemctl', ['--user', 'restart', serviceName]],
41
41
  };
42
42
  }
43
43
  if (process.platform === 'darwin') {
44
44
  const uid = process.getuid?.() ?? 501;
45
- const plistPath = `${os.homedir()}/Library/LaunchAgents/com.discoclaw.agent.plist`;
45
+ const label = `com.discoclaw.${serviceName}`;
46
+ const plistPath = `${os.homedir()}/Library/LaunchAgents/${label}.plist`;
46
47
  const domain = `gui/${uid}`;
47
- const label = 'com.discoclaw.agent';
48
48
  return {
49
49
  statusCmd: ['launchctl', ['list', label]],
50
50
  logsCmd: ['log', ['show', '--predicate', 'process == "node"', '--last', '5m', '--style', 'compact']],
@@ -62,18 +62,18 @@ function getPlatformCommands() {
62
62
  * platform, assuming the service is already running. Falls back to systemctl
63
63
  * on unsupported platforms.
64
64
  */
65
- export function getRestartCmdArgs() {
66
- const pc = getPlatformCommands();
65
+ export function getRestartCmdArgs(serviceName) {
66
+ const pc = getPlatformCommands(serviceName);
67
67
  if (pc)
68
68
  return pc.restartCmd(true);
69
- return ['systemctl', ['--user', 'restart', 'discoclaw']];
69
+ return ['systemctl', ['--user', 'restart', serviceName ?? 'discoclaw']];
70
70
  }
71
71
  export async function handleRestartCommand(cmd, opts) {
72
72
  // Support both legacy (log) and new (opts bag) signatures.
73
73
  const resolved = opts && typeof opts === 'object' && 'info' in opts
74
74
  ? { log: opts }
75
75
  : opts ?? {};
76
- const { log, dataDir, userId, activeForge } = resolved;
76
+ const { log, dataDir, userId, activeForge, serviceName = 'discoclaw' } = resolved;
77
77
  try {
78
78
  if (cmd.action === 'help') {
79
79
  return {
@@ -86,7 +86,7 @@ export async function handleRestartCommand(cmd, opts) {
86
86
  ].join('\n'),
87
87
  };
88
88
  }
89
- const pc = getPlatformCommands();
89
+ const pc = getPlatformCommands(serviceName);
90
90
  if (!pc) {
91
91
  return {
92
92
  reply: `!restart is not supported on this platform (${process.platform}). Only Linux (systemd) and macOS (launchd) are supported.`,
@@ -114,8 +114,8 @@ export async function handleRestartCommand(cmd, opts) {
114
114
  // invokes *after* sending the reply to Discord.
115
115
  return {
116
116
  reply: wasActive
117
- ? 'Restarting discoclaw... back in a moment.'
118
- : 'Starting discoclaw...',
117
+ ? `Restarting ${serviceName}... back in a moment.`
118
+ : `Starting ${serviceName}...`,
119
119
  deferred: () => {
120
120
  // Write shutdown context right before triggering restart so it
121
121
  // doesn't linger if the deferred never fires or restart fails.
@@ -126,7 +126,7 @@ describe('handleRestartCommand - macOS', () => {
126
126
  const statusCall = calls.find((c) => c[0] === 'launchctl');
127
127
  expect(statusCall).toBeDefined();
128
128
  expect(statusCall[1]).toContain('list');
129
- expect(statusCall[1]).toContain('com.discoclaw.agent');
129
+ expect(statusCall[1]).toContain('com.discoclaw.discoclaw');
130
130
  // Must NOT call systemctl
131
131
  expect(calls.every((c) => c[0] !== 'systemctl')).toBe(true);
132
132
  });
@@ -154,7 +154,7 @@ describe('handleRestartCommand - macOS', () => {
154
154
  const kickstartCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('kickstart'));
155
155
  expect(kickstartCall).toBeDefined();
156
156
  expect(kickstartCall[1]).toContain('-k');
157
- expect(kickstartCall[1]).toContain('gui/501/com.discoclaw.agent');
157
+ expect(kickstartCall[1]).toContain('gui/501/com.discoclaw.discoclaw');
158
158
  });
159
159
  it('restart uses launchctl bootstrap in deferred when service is inactive', async () => {
160
160
  const { execFile } = await import('node:child_process');
@@ -171,7 +171,96 @@ describe('handleRestartCommand - macOS', () => {
171
171
  const bootstrapCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('bootstrap'));
172
172
  expect(bootstrapCall).toBeDefined();
173
173
  expect(bootstrapCall[1]).toContain('gui/501');
174
- expect(bootstrapCall[1]).toContain('/Users/testuser/Library/LaunchAgents/com.discoclaw.agent.plist');
174
+ expect(bootstrapCall[1]).toContain('/Users/testuser/Library/LaunchAgents/com.discoclaw.discoclaw.plist');
175
+ });
176
+ });
177
+ describe('handleRestartCommand - custom serviceName (Linux)', () => {
178
+ beforeEach(() => {
179
+ vi.clearAllMocks();
180
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
181
+ });
182
+ afterEach(() => {
183
+ Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
184
+ });
185
+ it('default serviceName still targets discoclaw in systemctl args', async () => {
186
+ const { execFile } = await import('node:child_process');
187
+ await handleRestartCommand({ action: 'status' });
188
+ const calls = execFile.mock.calls;
189
+ const statusCall = calls.find((c) => c[0] === 'systemctl');
190
+ expect(statusCall[1]).toContain('discoclaw');
191
+ });
192
+ it('default restart reply still says "Restarting discoclaw..."', async () => {
193
+ const result = await handleRestartCommand({ action: 'restart' });
194
+ expect(result.reply).toBe('Restarting discoclaw... back in a moment.');
195
+ });
196
+ it('custom serviceName threads into systemctl status args', async () => {
197
+ const { execFile } = await import('node:child_process');
198
+ await handleRestartCommand({ action: 'status' }, { serviceName: 'discoclaw-beta' });
199
+ const calls = execFile.mock.calls;
200
+ const statusCall = calls.find((c) => c[0] === 'systemctl' && c[1].includes('status'));
201
+ expect(statusCall[1]).toContain('discoclaw-beta');
202
+ expect(statusCall[1]).not.toContain('discoclaw-beta'.replace('discoclaw-beta', 'discoclaw'));
203
+ });
204
+ it('custom serviceName threads into journalctl logs args', async () => {
205
+ const { execFile } = await import('node:child_process');
206
+ await handleRestartCommand({ action: 'logs' }, { serviceName: 'discoclaw-beta' });
207
+ const calls = execFile.mock.calls;
208
+ const logsCall = calls.find((c) => c[0] === 'journalctl');
209
+ expect(logsCall).toBeDefined();
210
+ expect(logsCall[1]).toContain('discoclaw-beta');
211
+ });
212
+ it('custom serviceName threads into systemctl restart args and reply string', async () => {
213
+ const { execFile } = await import('node:child_process');
214
+ const result = await handleRestartCommand({ action: 'restart' }, { serviceName: 'discoclaw-beta' });
215
+ expect(result.reply).toBe('Restarting discoclaw-beta... back in a moment.');
216
+ result.deferred();
217
+ const calls = execFile.mock.calls;
218
+ const restartCall = calls.find((c) => c[0] === 'systemctl' && c[1].includes('restart'));
219
+ expect(restartCall).toBeDefined();
220
+ expect(restartCall[1]).toContain('discoclaw-beta');
221
+ });
222
+ });
223
+ describe('handleRestartCommand - custom serviceName (macOS)', () => {
224
+ beforeEach(() => {
225
+ vi.clearAllMocks();
226
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
227
+ vi.spyOn(process, 'getuid').mockReturnValue(501);
228
+ });
229
+ afterEach(() => {
230
+ Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
231
+ vi.restoreAllMocks();
232
+ });
233
+ it('custom serviceName threads into launchctl label and plist path', async () => {
234
+ const { execFile } = await import('node:child_process');
235
+ const result = await handleRestartCommand({ action: 'restart' }, { serviceName: 'discoclaw-beta' });
236
+ expect(result.reply).toBe('Restarting discoclaw-beta... back in a moment.');
237
+ result.deferred();
238
+ const calls = execFile.mock.calls;
239
+ const kickstartCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('kickstart'));
240
+ expect(kickstartCall).toBeDefined();
241
+ expect(kickstartCall[1]).toContain('gui/501/com.discoclaw.discoclaw-beta');
242
+ });
243
+ it('custom serviceName uses correct plist path on bootstrap', async () => {
244
+ const { execFile } = await import('node:child_process');
245
+ execFile.mockImplementationOnce((cmd, args, opts, cb) => {
246
+ const err = Object.assign(new Error('not loaded'), { code: 1 });
247
+ cb(err, '', '');
248
+ });
249
+ const result = await handleRestartCommand({ action: 'restart' }, { serviceName: 'discoclaw-beta' });
250
+ expect(result.reply).toBe('Starting discoclaw-beta...');
251
+ result.deferred();
252
+ const calls = execFile.mock.calls;
253
+ const bootstrapCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('bootstrap'));
254
+ expect(bootstrapCall).toBeDefined();
255
+ expect(bootstrapCall[1]).toContain('/Users/testuser/Library/LaunchAgents/com.discoclaw.discoclaw-beta.plist');
256
+ });
257
+ it('custom serviceName threads into launchctl list (status)', async () => {
258
+ const { execFile } = await import('node:child_process');
259
+ await handleRestartCommand({ action: 'status' }, { serviceName: 'discoclaw-beta' });
260
+ const calls = execFile.mock.calls;
261
+ const listCall = calls.find((c) => c[0] === 'launchctl' && c[1].includes('list'));
262
+ expect(listCall).toBeDefined();
263
+ expect(listCall[1]).toContain('com.discoclaw.discoclaw-beta');
175
264
  });
176
265
  });
177
266
  describe('handleRestartCommand - unsupported platform', () => {
@@ -35,7 +35,7 @@ function mapExitCode(err) {
35
35
  }
36
36
  const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
37
37
  export async function handleUpdateCommand(cmd, opts = {}) {
38
- const { log, dataDir, userId, restartCmd, projectCwd, onProgress } = opts;
38
+ const { log, dataDir, userId, restartCmd, projectCwd, onProgress, serviceName } = opts;
39
39
  const progress = (msg) => {
40
40
  onProgress?.(msg);
41
41
  log?.info({}, `update-command: ${msg}`);
@@ -123,7 +123,7 @@ export async function handleUpdateCommand(cmd, opts = {}) {
123
123
  });
124
124
  }
125
125
  else {
126
- const [restartBin, restartArgList] = getRestartCmdArgs();
126
+ const [restartBin, restartArgList] = getRestartCmdArgs(serviceName);
127
127
  execFile(restartBin, restartArgList, (err) => {
128
128
  if (err)
129
129
  log?.error({ err }, 'update-command: restart failed');
@@ -180,7 +180,7 @@ export async function handleUpdateCommand(cmd, opts = {}) {
180
180
  });
181
181
  }
182
182
  else {
183
- const [restartBin, restartArgList] = getRestartCmdArgs();
183
+ const [restartBin, restartArgList] = getRestartCmdArgs(serviceName);
184
184
  execFile(restartBin, restartArgList, (err) => {
185
185
  if (err)
186
186
  log?.error({ err }, 'update-command: restart failed');
@@ -249,7 +249,7 @@ describe('handleUpdateCommand: apply', () => {
249
249
  const restartCall = calls.find(([cmd, args]) => cmd === 'launchctl' && args.includes('kickstart'));
250
250
  expect(restartCall).toBeDefined();
251
251
  expect(restartCall[1]).toContain('-k');
252
- expect(restartCall[1].some((a) => a.includes('com.discoclaw.agent'))).toBe(true);
252
+ expect(restartCall[1].some((a) => a.includes('com.discoclaw.discoclaw'))).toBe(true);
253
253
  }
254
254
  finally {
255
255
  Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
@@ -266,6 +266,17 @@ describe('handleUpdateCommand: apply', () => {
266
266
  expect(shCall).toBeDefined();
267
267
  expect(shCall[1]).toEqual(['-c', 'sudo systemctl restart discoclaw']);
268
268
  });
269
+ it('deferred passes custom serviceName to getRestartCmdArgs (git-managed)', async () => {
270
+ const { execFile } = await import('node:child_process');
271
+ const mock = mockAllSuccess();
272
+ execFile.mockImplementation(mock);
273
+ const result = await handleUpdateCommand({ action: 'apply' }, { serviceName: 'discoclaw-beta' });
274
+ result.deferred();
275
+ const calls = execFile.mock.calls;
276
+ const restartCall = calls.find(([cmd, args]) => cmd === 'systemctl' && args.includes('restart'));
277
+ expect(restartCall).toBeDefined();
278
+ expect(restartCall[1]).toEqual(['--user', 'restart', 'discoclaw-beta']);
279
+ });
269
280
  it('calls onProgress callback for each step', async () => {
270
281
  const { execFile } = await import('node:child_process');
271
282
  execFile.mockImplementation(mockAllSuccess());
@@ -340,4 +351,18 @@ describe('handleUpdateCommand: npm-managed mode', () => {
340
351
  expect(result.reply).toContain('failed');
341
352
  expect(result.deferred).toBeUndefined();
342
353
  });
354
+ it('deferred passes custom serviceName to getRestartCmdArgs (npm-managed)', async () => {
355
+ const { execFile } = await import('node:child_process');
356
+ execFile.mockImplementation((cmd, args, optsOrCb, maybeCb) => {
357
+ const cb = typeof optsOrCb === 'function' ? optsOrCb : maybeCb;
358
+ if (cb)
359
+ cb(null, '', '');
360
+ });
361
+ const result = await handleUpdateCommand({ action: 'apply' }, { serviceName: 'discoclaw-beta' });
362
+ result.deferred();
363
+ const calls = execFile.mock.calls;
364
+ const restartCall = calls.find(([cmd, args]) => cmd === 'systemctl' && args.includes('restart'));
365
+ expect(restartCall).toBeDefined();
366
+ expect(restartCall[1]).toEqual(['--user', 'restart', 'discoclaw-beta']);
367
+ });
343
368
  });
package/dist/index.js CHANGED
@@ -586,6 +586,7 @@ const botParams = {
586
586
  workspaceCwd,
587
587
  projectCwd: projectRoot,
588
588
  updateRestartCmd: process.env.DC_RESTART_CMD,
589
+ serviceName: cfg.serviceName,
589
590
  groupsDir,
590
591
  useGroupDirCwd,
591
592
  runtimeModel,
@@ -1,5 +1,8 @@
1
1
  import { execa } from 'execa';
2
+ import { existsSync } from 'node:fs';
2
3
  import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
3
6
  const _require = createRequire(import.meta.url);
4
7
  /**
5
8
  * Returns the version string from the nearest package.json.
@@ -14,32 +17,14 @@ export function getLocalVersion() {
14
17
  return 'unknown';
15
18
  }
16
19
  }
17
- /**
18
- * Returns the npm global node_modules root directory, or null on failure.
19
- */
20
- async function getNpmGlobalRoot() {
21
- try {
22
- const result = await execa('npm', ['root', '-g']);
23
- const root = result.stdout.trim();
24
- return root || null;
25
- }
26
- catch {
27
- return null;
28
- }
29
- }
30
20
  /**
31
21
  * Returns true when the running process was installed via `npm install -g`.
32
- * Detection: checks whether process.argv[1] is rooted under the npm global
33
- * node_modules directory returned by `npm root -g`.
22
+ * Detection: source installs have a `.git` directory at the package root;
23
+ * npm-published packages do not (`.git` is excluded from the `files` array).
34
24
  */
35
25
  export async function isNpmManaged() {
36
- const globalRoot = await getNpmGlobalRoot();
37
- if (!globalRoot)
38
- return false;
39
- const script = process.argv[1];
40
- if (!script)
41
- return false;
42
- return script.startsWith(globalRoot);
26
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ return !existsSync(path.join(packageRoot, '.git'));
43
28
  }
44
29
  /**
45
30
  * Fetches the latest published version of discoclaw from the npm registry.
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  vi.mock('execa', () => ({
3
3
  execa: vi.fn(),
4
4
  }));
5
+ vi.mock('node:fs', () => ({
6
+ existsSync: vi.fn(),
7
+ }));
5
8
  import { getLocalVersion, getLatestNpmVersion, isNpmManaged, npmGlobalUpgrade } from './npm-managed.js';
6
9
  // ---------------------------------------------------------------------------
7
10
  // getLocalVersion
@@ -17,42 +20,19 @@ describe('getLocalVersion', () => {
17
20
  // isNpmManaged
18
21
  // ---------------------------------------------------------------------------
19
22
  describe('isNpmManaged', () => {
20
- let mockExeca;
23
+ let mockExistsSync;
21
24
  beforeEach(async () => {
22
- const mod = await import('execa');
23
- mockExeca = mod.execa;
24
- mockExeca.mockReset();
25
- });
26
- it('returns true when process.argv[1] is under the npm global root', async () => {
27
- mockExeca.mockResolvedValueOnce({ stdout: '/usr/local/lib/node_modules\n' });
28
- const orig = process.argv[1];
29
- process.argv[1] = '/usr/local/lib/node_modules/discoclaw/dist/cli/index.js';
30
- try {
31
- expect(await isNpmManaged()).toBe(true);
32
- expect(mockExeca).toHaveBeenCalledWith('npm', ['root', '-g']);
33
- }
34
- finally {
35
- process.argv[1] = orig;
36
- }
25
+ const mod = await import('node:fs');
26
+ mockExistsSync = mod.existsSync;
27
+ mockExistsSync.mockReset();
37
28
  });
38
- it('returns false when process.argv[1] is outside the npm global root', async () => {
39
- mockExeca.mockResolvedValueOnce({ stdout: '/usr/local/lib/node_modules\n' });
40
- const orig = process.argv[1];
41
- process.argv[1] = '/home/user/code/discoclaw/src/index.ts';
42
- try {
43
- expect(await isNpmManaged()).toBe(false);
44
- }
45
- finally {
46
- process.argv[1] = orig;
47
- }
48
- });
49
- it('returns false when npm root -g fails', async () => {
50
- mockExeca.mockRejectedValueOnce(new Error('npm not found'));
29
+ it('returns false (source install) when .git exists at the package root', async () => {
30
+ mockExistsSync.mockReturnValue(true);
51
31
  expect(await isNpmManaged()).toBe(false);
52
32
  });
53
- it('returns false when npm root -g returns empty output', async () => {
54
- mockExeca.mockResolvedValueOnce({ stdout: ' ' });
55
- expect(await isNpmManaged()).toBe(false);
33
+ it('returns true (npm-managed) when .git does not exist at the package root', async () => {
34
+ mockExistsSync.mockReturnValue(false);
35
+ expect(await isNpmManaged()).toBe(true);
56
36
  });
57
37
  });
58
38
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Minimal Discord bridge routing messages to AI runtimes",
5
5
  "license": "MIT",
6
6
  "repository": {