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 +3 -0
- package/dist/cli/daemon-installer.js +46 -2
- package/dist/cli/daemon-installer.test.js +83 -1
- package/dist/config.js +1 -0
- package/dist/config.test.js +14 -0
- package/dist/discord/message-coordinator.js +2 -0
- package/dist/discord/restart-command.js +14 -14
- package/dist/discord/restart-command.test.js +92 -3
- package/dist/discord/update-command.js +3 -3
- package/dist/discord/update-command.test.js +26 -1
- package/dist/index.js +1 -0
- package/dist/npm-managed.js +7 -22
- package/dist/npm-managed.test.js +12 -32
- package/package.json +1 -1
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,
|
package/dist/config.test.js
CHANGED
|
@@ -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',
|
|
37
|
-
logsCmd: ['journalctl', ['--user', '-u',
|
|
38
|
-
checkActiveCmd: ['systemctl', ['--user', 'status',
|
|
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',
|
|
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
|
|
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
|
-
?
|
|
118
|
-
:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
package/dist/npm-managed.js
CHANGED
|
@@ -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:
|
|
33
|
-
*
|
|
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
|
|
37
|
-
|
|
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.
|
package/dist/npm-managed.test.js
CHANGED
|
@@ -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
|
|
23
|
+
let mockExistsSync;
|
|
21
24
|
beforeEach(async () => {
|
|
22
|
-
const mod = await import('
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
39
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
expect(await isNpmManaged()).toBe(
|
|
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
|
// ---------------------------------------------------------------------------
|