discoclaw 0.1.0
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/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon installer for discoclaw.
|
|
3
|
+
* Invoked by `discoclaw install-daemon`.
|
|
4
|
+
* On Linux: writes a systemd user unit and enables it via systemctl.
|
|
5
|
+
* On macOS: writes a launchd plist and loads it via launchctl.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import * as readline from 'node:readline/promises';
|
|
13
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
14
|
+
// ── Package resolution ─────────────────────────────────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the package root directory.
|
|
17
|
+
* At runtime this file lives at dist/cli/daemon-installer.js,
|
|
18
|
+
* so package root is two directories up.
|
|
19
|
+
*/
|
|
20
|
+
export function resolvePackageRoot() {
|
|
21
|
+
const selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
return path.resolve(selfDir, '..', '..');
|
|
23
|
+
}
|
|
24
|
+
// ── Rendering helpers ──────────────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Parses a .env file into key-value pairs.
|
|
27
|
+
* Skips blank lines and comment lines.
|
|
28
|
+
*/
|
|
29
|
+
export function parseEnvFile(content) {
|
|
30
|
+
const result = {};
|
|
31
|
+
for (const line of content.split('\n')) {
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
34
|
+
continue;
|
|
35
|
+
const eqIdx = trimmed.indexOf('=');
|
|
36
|
+
if (eqIdx < 0)
|
|
37
|
+
continue;
|
|
38
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
39
|
+
const val = trimmed.slice(eqIdx + 1);
|
|
40
|
+
if (key)
|
|
41
|
+
result[key] = val;
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Renders a systemd user unit file for discoclaw.
|
|
47
|
+
* Uses EnvironmentFile for .env loading (native systemd feature).
|
|
48
|
+
*/
|
|
49
|
+
export function renderSystemdUnit(packageRoot, cwd) {
|
|
50
|
+
const entryPoint = path.join(packageRoot, 'dist', 'index.js');
|
|
51
|
+
return [
|
|
52
|
+
'[Unit]',
|
|
53
|
+
'Description=DiscoClaw — personal AI orchestrator',
|
|
54
|
+
'After=network.target',
|
|
55
|
+
'',
|
|
56
|
+
'[Service]',
|
|
57
|
+
'Type=simple',
|
|
58
|
+
`ExecStart=/usr/bin/node ${entryPoint}`,
|
|
59
|
+
`WorkingDirectory=${cwd}`,
|
|
60
|
+
`EnvironmentFile=${path.join(cwd, '.env')}`,
|
|
61
|
+
'Restart=on-failure',
|
|
62
|
+
'RestartSec=5',
|
|
63
|
+
'',
|
|
64
|
+
'[Install]',
|
|
65
|
+
'WantedBy=default.target',
|
|
66
|
+
'',
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Renders a launchd plist for discoclaw.
|
|
71
|
+
* Since launchd has no EnvironmentFile equivalent, env vars are baked in
|
|
72
|
+
* from the parsed .env at install time.
|
|
73
|
+
*/
|
|
74
|
+
export function renderLaunchdPlist(packageRoot, cwd, envVars) {
|
|
75
|
+
const entryPoint = path.join(packageRoot, 'dist', 'index.js');
|
|
76
|
+
const envEntries = Object.entries(envVars)
|
|
77
|
+
.map(([k, v]) => `\t\t<key>${k}</key>\n\t\t<string>${v}</string>`)
|
|
78
|
+
.join('\n');
|
|
79
|
+
const lines = [
|
|
80
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
81
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
82
|
+
'<plist version="1.0">',
|
|
83
|
+
'<dict>',
|
|
84
|
+
'\t<key>Label</key>',
|
|
85
|
+
'\t<string>com.discoclaw.agent</string>',
|
|
86
|
+
'\t<key>ProgramArguments</key>',
|
|
87
|
+
'\t<array>',
|
|
88
|
+
'\t\t<string>/usr/bin/node</string>',
|
|
89
|
+
`\t\t<string>${entryPoint}</string>`,
|
|
90
|
+
'\t</array>',
|
|
91
|
+
'\t<key>WorkingDirectory</key>',
|
|
92
|
+
`\t<string>${cwd}</string>`,
|
|
93
|
+
'\t<key>EnvironmentVariables</key>',
|
|
94
|
+
'\t<dict>',
|
|
95
|
+
];
|
|
96
|
+
if (envEntries)
|
|
97
|
+
lines.push(envEntries);
|
|
98
|
+
lines.push('\t</dict>');
|
|
99
|
+
lines.push('\t<key>RunAtLoad</key>');
|
|
100
|
+
lines.push('\t<true/>');
|
|
101
|
+
lines.push('\t<key>KeepAlive</key>');
|
|
102
|
+
lines.push('\t<true/>');
|
|
103
|
+
lines.push('</dict>');
|
|
104
|
+
lines.push('</plist>');
|
|
105
|
+
lines.push('');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
// ── Platform installers ────────────────────────────────────────────────────
|
|
109
|
+
async function installSystemd(packageRoot, cwd, ask) {
|
|
110
|
+
const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
111
|
+
const servicePath = path.join(serviceDir, 'discoclaw.service');
|
|
112
|
+
if (fs.existsSync(servicePath)) {
|
|
113
|
+
const answer = await ask(`Service file already exists at ${servicePath}. Overwrite? [y/N] `);
|
|
114
|
+
if (answer.trim().toLowerCase() !== 'y') {
|
|
115
|
+
console.log('Aborted.\n');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const unit = renderSystemdUnit(packageRoot, cwd);
|
|
120
|
+
fs.mkdirSync(serviceDir, { recursive: true });
|
|
121
|
+
fs.writeFileSync(servicePath, unit, 'utf8');
|
|
122
|
+
console.log(`Wrote ${servicePath}\n`);
|
|
123
|
+
console.log('Running systemctl --user daemon-reload...');
|
|
124
|
+
try {
|
|
125
|
+
execFileSync('systemctl', ['--user', 'daemon-reload']);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
console.error(`systemctl daemon-reload failed: ${err.message}\n`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
console.log('Enabling and starting discoclaw service...');
|
|
132
|
+
try {
|
|
133
|
+
execFileSync('systemctl', ['--user', 'enable', '--now', 'discoclaw']);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.error(`systemctl enable/start failed: ${err.message}\n`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
console.log('DiscoClaw daemon installed and started.\n');
|
|
140
|
+
console.log('Useful commands:');
|
|
141
|
+
console.log(' journalctl --user -u discoclaw.service -f # tail logs');
|
|
142
|
+
console.log(' systemctl --user status discoclaw # check status');
|
|
143
|
+
console.log(' systemctl --user stop discoclaw # stop the service');
|
|
144
|
+
console.log('');
|
|
145
|
+
}
|
|
146
|
+
async function installLaunchd(packageRoot, cwd, envPath, ask) {
|
|
147
|
+
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
148
|
+
const plistPath = path.join(agentsDir, 'com.discoclaw.agent.plist');
|
|
149
|
+
if (fs.existsSync(plistPath)) {
|
|
150
|
+
const answer = await ask(`Plist already exists at ${plistPath}. Overwrite? [y/N] `);
|
|
151
|
+
if (answer.trim().toLowerCase() !== 'y') {
|
|
152
|
+
console.log('Aborted.\n');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
157
|
+
const envVars = parseEnvFile(envContent);
|
|
158
|
+
const plist = renderLaunchdPlist(packageRoot, cwd, envVars);
|
|
159
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
160
|
+
fs.writeFileSync(plistPath, plist, 'utf8');
|
|
161
|
+
console.log(`Wrote ${plistPath}\n`);
|
|
162
|
+
console.log('Note: On macOS, .env changes require re-running `discoclaw install-daemon`.\n' +
|
|
163
|
+
' Environment variables are baked into the plist at install time.\n');
|
|
164
|
+
const uid = process.getuid();
|
|
165
|
+
const target = `gui/${uid}/com.discoclaw.agent`;
|
|
166
|
+
// Idempotent: bootout first (ignore failure if agent is not currently loaded)
|
|
167
|
+
try {
|
|
168
|
+
execFileSync('launchctl', ['bootout', target]);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Not currently loaded — that's fine
|
|
172
|
+
}
|
|
173
|
+
console.log('Running launchctl bootstrap...');
|
|
174
|
+
try {
|
|
175
|
+
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath]);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
console.error(`launchctl bootstrap failed: ${err.message}\n`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
console.log('DiscoClaw daemon installed and started.\n');
|
|
182
|
+
console.log('Useful commands:');
|
|
183
|
+
console.log(" log stream --predicate 'process == \"node\"' # tail logs");
|
|
184
|
+
console.log(' launchctl list com.discoclaw.agent # check status');
|
|
185
|
+
console.log(` launchctl bootout ${target} # stop and unload`);
|
|
186
|
+
console.log('');
|
|
187
|
+
}
|
|
188
|
+
// ── Main entrypoint ────────────────────────────────────────────────────────
|
|
189
|
+
export async function runDaemonInstaller() {
|
|
190
|
+
if (!input.isTTY) {
|
|
191
|
+
console.error('discoclaw install-daemon requires an interactive terminal.\n');
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
const platform = process.platform;
|
|
195
|
+
if (platform !== 'linux' && platform !== 'darwin') {
|
|
196
|
+
console.error(`Unsupported platform: ${platform}. Only Linux (systemd) and macOS (launchd) are supported.\n`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const cwd = process.cwd();
|
|
200
|
+
const envPath = path.join(cwd, '.env');
|
|
201
|
+
if (!fs.existsSync(envPath)) {
|
|
202
|
+
console.error(`No .env found in ${cwd}.\nRun \`discoclaw init\` first to set up your configuration.\n`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
const packageRoot = resolvePackageRoot();
|
|
206
|
+
const entryPoint = path.join(packageRoot, 'dist', 'index.js');
|
|
207
|
+
if (!fs.existsSync(entryPoint)) {
|
|
208
|
+
console.error(`Cannot find the bot runtime at ${entryPoint}.\n` +
|
|
209
|
+
'Make sure the package is properly installed (try running `npm install -g discoclaw` again).\n');
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
const rl = readline.createInterface({ input, output });
|
|
213
|
+
const ask = (prompt) => rl.question(prompt);
|
|
214
|
+
try {
|
|
215
|
+
if (platform === 'linux') {
|
|
216
|
+
await installSystemd(packageRoot, cwd, ask);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
await installLaunchd(packageRoot, cwd, envPath, ask);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
rl.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
vi.mock('node:fs', () => ({
|
|
3
|
+
default: {
|
|
4
|
+
existsSync: vi.fn(),
|
|
5
|
+
mkdirSync: vi.fn(),
|
|
6
|
+
writeFileSync: vi.fn(),
|
|
7
|
+
readFileSync: vi.fn(),
|
|
8
|
+
},
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('node:child_process', () => ({
|
|
11
|
+
execFileSync: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('node:readline/promises', () => ({
|
|
14
|
+
createInterface: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { createInterface } from 'node:readline/promises';
|
|
19
|
+
import { parseEnvFile, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
|
|
20
|
+
// ── Test helpers ───────────────────────────────────────────────────────────
|
|
21
|
+
function makeReadline(answers = []) {
|
|
22
|
+
return {
|
|
23
|
+
question: vi.fn(async () => answers.shift() ?? ''),
|
|
24
|
+
close: vi.fn(),
|
|
25
|
+
on: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const PACKAGE_ROOT = '/opt/discoclaw';
|
|
29
|
+
const CWD = '/home/user/bot';
|
|
30
|
+
const SAMPLE_ENV = 'DISCORD_TOKEN=abc123\nDISCORD_ALLOW_USER_IDS=111\n# comment\n\nFOO=bar=baz\n';
|
|
31
|
+
// ── parseEnvFile ───────────────────────────────────────────────────────────
|
|
32
|
+
describe('parseEnvFile', () => {
|
|
33
|
+
it('parses key=value pairs', () => {
|
|
34
|
+
const result = parseEnvFile('FOO=bar\nBAZ=qux\n');
|
|
35
|
+
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' });
|
|
36
|
+
});
|
|
37
|
+
it('skips blank lines and comment lines', () => {
|
|
38
|
+
const result = parseEnvFile('# comment\n\nFOO=bar\n');
|
|
39
|
+
expect(result).toEqual({ FOO: 'bar' });
|
|
40
|
+
});
|
|
41
|
+
it('preserves values that contain = characters', () => {
|
|
42
|
+
const result = parseEnvFile('FOO=bar=baz\n');
|
|
43
|
+
expect(result).toEqual({ FOO: 'bar=baz' });
|
|
44
|
+
});
|
|
45
|
+
it('returns empty object for empty input', () => {
|
|
46
|
+
expect(parseEnvFile('')).toEqual({});
|
|
47
|
+
expect(parseEnvFile('# just comments\n')).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
// ── renderSystemdUnit ──────────────────────────────────────────────────────
|
|
51
|
+
describe('renderSystemdUnit', () => {
|
|
52
|
+
it('produces correct ExecStart with /usr/bin/node and dist/index.js', () => {
|
|
53
|
+
const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
|
|
54
|
+
expect(unit).toContain(`ExecStart=/usr/bin/node ${PACKAGE_ROOT}/dist/index.js`);
|
|
55
|
+
});
|
|
56
|
+
it('sets WorkingDirectory to the provided cwd', () => {
|
|
57
|
+
const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
|
|
58
|
+
expect(unit).toContain(`WorkingDirectory=${CWD}`);
|
|
59
|
+
});
|
|
60
|
+
it('sets EnvironmentFile to <cwd>/.env', () => {
|
|
61
|
+
const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
|
|
62
|
+
expect(unit).toContain(`EnvironmentFile=${CWD}/.env`);
|
|
63
|
+
});
|
|
64
|
+
it('includes standard unit and install sections', () => {
|
|
65
|
+
const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
|
|
66
|
+
expect(unit).toContain('[Unit]');
|
|
67
|
+
expect(unit).toContain('[Service]');
|
|
68
|
+
expect(unit).toContain('[Install]');
|
|
69
|
+
expect(unit).toContain('Restart=on-failure');
|
|
70
|
+
expect(unit).toContain('WantedBy=default.target');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// ── renderLaunchdPlist ─────────────────────────────────────────────────────
|
|
74
|
+
describe('renderLaunchdPlist', () => {
|
|
75
|
+
const envVars = { DISCORD_TOKEN: 'tok', FOO: 'bar' };
|
|
76
|
+
it('produces valid XML plist with correct label', () => {
|
|
77
|
+
const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
|
|
78
|
+
expect(plist).toContain('<?xml version="1.0"');
|
|
79
|
+
expect(plist).toContain('<string>com.discoclaw.agent</string>');
|
|
80
|
+
});
|
|
81
|
+
it('sets ProgramArguments to /usr/bin/node and dist/index.js', () => {
|
|
82
|
+
const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
|
|
83
|
+
expect(plist).toContain('<string>/usr/bin/node</string>');
|
|
84
|
+
expect(plist).toContain(`<string>${PACKAGE_ROOT}/dist/index.js</string>`);
|
|
85
|
+
});
|
|
86
|
+
it('sets WorkingDirectory to the provided cwd', () => {
|
|
87
|
+
const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
|
|
88
|
+
expect(plist).toContain(`<string>${CWD}</string>`);
|
|
89
|
+
});
|
|
90
|
+
it('emits EnvironmentVariables entries for each parsed .env key', () => {
|
|
91
|
+
const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
|
|
92
|
+
expect(plist).toContain('<key>DISCORD_TOKEN</key>');
|
|
93
|
+
expect(plist).toContain('<string>tok</string>');
|
|
94
|
+
expect(plist).toContain('<key>FOO</key>');
|
|
95
|
+
expect(plist).toContain('<string>bar</string>');
|
|
96
|
+
});
|
|
97
|
+
it('includes RunAtLoad and KeepAlive', () => {
|
|
98
|
+
const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
|
|
99
|
+
expect(plist).toContain('<key>RunAtLoad</key>');
|
|
100
|
+
expect(plist).toContain('<key>KeepAlive</key>');
|
|
101
|
+
expect(plist).toContain('<true/>');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// ── runDaemonInstaller ─────────────────────────────────────────────────────
|
|
105
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
106
|
+
const originalPlatform = process.platform;
|
|
107
|
+
describe('runDaemonInstaller', () => {
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
vi.clearAllMocks();
|
|
110
|
+
process.stdin.isTTY = true;
|
|
111
|
+
// Default: .env and dist/index.js exist; service file does not
|
|
112
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
113
|
+
const s = String(p);
|
|
114
|
+
if (s.endsWith('.env'))
|
|
115
|
+
return true;
|
|
116
|
+
if (s.endsWith('dist/index.js'))
|
|
117
|
+
return true;
|
|
118
|
+
return false;
|
|
119
|
+
});
|
|
120
|
+
vi.mocked(fs.readFileSync).mockReturnValue(SAMPLE_ENV);
|
|
121
|
+
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
122
|
+
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
123
|
+
vi.mocked(execFileSync).mockReturnValue(Buffer.alloc(0));
|
|
124
|
+
vi.mocked(createInterface).mockReturnValue(makeReadline());
|
|
125
|
+
// Default to linux for most tests
|
|
126
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
127
|
+
});
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
process.stdin.isTTY = originalIsTTY;
|
|
130
|
+
Object.defineProperty(process, 'platform', {
|
|
131
|
+
value: originalPlatform,
|
|
132
|
+
configurable: true,
|
|
133
|
+
});
|
|
134
|
+
vi.restoreAllMocks();
|
|
135
|
+
});
|
|
136
|
+
// ── Guard checks ──────────────────────────────────────────────────────────
|
|
137
|
+
it('errors when stdin is not a TTY', async () => {
|
|
138
|
+
process.stdin.isTTY = false;
|
|
139
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
140
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
141
|
+
throw new Error(`exit:${code ?? 0}`);
|
|
142
|
+
}));
|
|
143
|
+
await expect(runDaemonInstaller()).rejects.toThrow('exit:1');
|
|
144
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('requires an interactive terminal'));
|
|
145
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
146
|
+
});
|
|
147
|
+
it('errors when .env is missing in cwd', async () => {
|
|
148
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
149
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
150
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
151
|
+
throw new Error(`exit:${code ?? 0}`);
|
|
152
|
+
}));
|
|
153
|
+
await expect(runDaemonInstaller()).rejects.toThrow('exit:1');
|
|
154
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('discoclaw init'));
|
|
155
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
156
|
+
});
|
|
157
|
+
it('errors when dist/index.js does not exist at package root', async () => {
|
|
158
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
159
|
+
const s = String(p);
|
|
160
|
+
if (s.endsWith('.env'))
|
|
161
|
+
return true;
|
|
162
|
+
return false; // dist/index.js missing
|
|
163
|
+
});
|
|
164
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
165
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
166
|
+
throw new Error(`exit:${code ?? 0}`);
|
|
167
|
+
}));
|
|
168
|
+
await expect(runDaemonInstaller()).rejects.toThrow('exit:1');
|
|
169
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('dist/index.js'));
|
|
170
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
171
|
+
});
|
|
172
|
+
it('errors on unsupported platforms', async () => {
|
|
173
|
+
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
174
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
175
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
176
|
+
throw new Error(`exit:${code ?? 0}`);
|
|
177
|
+
}));
|
|
178
|
+
await expect(runDaemonInstaller()).rejects.toThrow('exit:1');
|
|
179
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Unsupported platform'));
|
|
180
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
181
|
+
});
|
|
182
|
+
// ── Platform routing ──────────────────────────────────────────────────────
|
|
183
|
+
it('linux: calls systemctl daemon-reload then enable --now', async () => {
|
|
184
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
185
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
186
|
+
await runDaemonInstaller();
|
|
187
|
+
const calls = vi.mocked(execFileSync).mock.calls;
|
|
188
|
+
expect(calls).toContainEqual(['systemctl', ['--user', 'daemon-reload']]);
|
|
189
|
+
expect(calls).toContainEqual(['systemctl', ['--user', 'enable', '--now', 'discoclaw']]);
|
|
190
|
+
expect(calls.some(([cmd]) => cmd === 'launchctl')).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
it('darwin: calls launchctl bootout then bootstrap', async () => {
|
|
193
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
194
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
195
|
+
await runDaemonInstaller();
|
|
196
|
+
const calls = vi.mocked(execFileSync).mock.calls;
|
|
197
|
+
const launchctlCalls = calls.filter(([cmd]) => cmd === 'launchctl');
|
|
198
|
+
expect(launchctlCalls.length).toBeGreaterThanOrEqual(2);
|
|
199
|
+
const [bootoutArgs] = launchctlCalls[0].slice(1);
|
|
200
|
+
const [bootstrapArgs] = launchctlCalls[1].slice(1);
|
|
201
|
+
expect(bootoutArgs[0]).toBe('bootout');
|
|
202
|
+
expect(bootstrapArgs[0]).toBe('bootstrap');
|
|
203
|
+
expect(calls.some(([cmd]) => cmd === 'systemctl')).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
// ── Failure handling ──────────────────────────────────────────────────────
|
|
206
|
+
it('systemctl enable failure produces a clear error message', async () => {
|
|
207
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
208
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
209
|
+
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
|
210
|
+
const argsArr = args;
|
|
211
|
+
if (cmd === 'systemctl' && argsArr.includes('enable')) {
|
|
212
|
+
throw new Error('Failed to enable unit');
|
|
213
|
+
}
|
|
214
|
+
return Buffer.alloc(0);
|
|
215
|
+
});
|
|
216
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
217
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
218
|
+
throw new Error(`exit:${code ?? 0}`);
|
|
219
|
+
}));
|
|
220
|
+
await expect(runDaemonInstaller()).rejects.toThrow('exit:1');
|
|
221
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('systemctl enable/start failed'));
|
|
222
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
223
|
+
});
|
|
224
|
+
it('launchctl bootout failure is ignored; bootstrap failure produces a clear error', async () => {
|
|
225
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
226
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
227
|
+
let bootoutCalled = false;
|
|
228
|
+
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
|
229
|
+
const argsArr = args;
|
|
230
|
+
if (cmd === 'launchctl' && argsArr[0] === 'bootout') {
|
|
231
|
+
bootoutCalled = true;
|
|
232
|
+
// bootout "succeeds" (no throw) — idempotent unload
|
|
233
|
+
return Buffer.alloc(0);
|
|
234
|
+
}
|
|
235
|
+
if (cmd === 'launchctl' && argsArr[0] === 'bootstrap') {
|
|
236
|
+
throw new Error('bootstrap: service already exists');
|
|
237
|
+
}
|
|
238
|
+
return Buffer.alloc(0);
|
|
239
|
+
});
|
|
240
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
241
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
242
|
+
throw new Error(`exit:${code ?? 0}`);
|
|
243
|
+
}));
|
|
244
|
+
await expect(runDaemonInstaller()).rejects.toThrow('exit:1');
|
|
245
|
+
expect(bootoutCalled).toBe(true);
|
|
246
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('launchctl bootstrap failed'));
|
|
247
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
248
|
+
});
|
|
249
|
+
// ── Overwrite prompts ─────────────────────────────────────────────────────
|
|
250
|
+
it('linux: prompts before overwriting an existing service file', async () => {
|
|
251
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
252
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
253
|
+
// Service file exists
|
|
254
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
255
|
+
const s = String(p);
|
|
256
|
+
if (s.endsWith('.env'))
|
|
257
|
+
return true;
|
|
258
|
+
if (s.endsWith('dist/index.js'))
|
|
259
|
+
return true;
|
|
260
|
+
if (s.endsWith('discoclaw.service'))
|
|
261
|
+
return true;
|
|
262
|
+
return false;
|
|
263
|
+
});
|
|
264
|
+
const rl = makeReadline(['y']); // answer 'y' to overwrite prompt
|
|
265
|
+
vi.mocked(createInterface).mockReturnValue(rl);
|
|
266
|
+
await runDaemonInstaller();
|
|
267
|
+
expect(rl.question).toHaveBeenCalledWith(expect.stringContaining('Overwrite?'));
|
|
268
|
+
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
269
|
+
});
|
|
270
|
+
it('linux: aborts if user declines to overwrite existing service file', async () => {
|
|
271
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
272
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
273
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
274
|
+
const s = String(p);
|
|
275
|
+
if (s.endsWith('.env'))
|
|
276
|
+
return true;
|
|
277
|
+
if (s.endsWith('dist/index.js'))
|
|
278
|
+
return true;
|
|
279
|
+
if (s.endsWith('discoclaw.service'))
|
|
280
|
+
return true;
|
|
281
|
+
return false;
|
|
282
|
+
});
|
|
283
|
+
const rl = makeReadline(['n']); // decline overwrite
|
|
284
|
+
vi.mocked(createInterface).mockReturnValue(rl);
|
|
285
|
+
await runDaemonInstaller();
|
|
286
|
+
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
287
|
+
expect(execFileSync).not.toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* discoclaw CLI entrypoint.
|
|
4
|
+
* Usage: discoclaw <command> [options]
|
|
5
|
+
*/
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { runInitWizard } from './init-wizard.js';
|
|
8
|
+
import { runDaemonInstaller } from './daemon-installer.js';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { version } = require('../../package.json');
|
|
11
|
+
const [, , command] = process.argv;
|
|
12
|
+
switch (command) {
|
|
13
|
+
case 'init':
|
|
14
|
+
await runInitWizard();
|
|
15
|
+
break;
|
|
16
|
+
case 'install-daemon':
|
|
17
|
+
await runDaemonInstaller();
|
|
18
|
+
break;
|
|
19
|
+
case '--version':
|
|
20
|
+
case '-v':
|
|
21
|
+
console.log(version);
|
|
22
|
+
break;
|
|
23
|
+
case '--help':
|
|
24
|
+
case '-h':
|
|
25
|
+
case undefined:
|
|
26
|
+
printHelp(version);
|
|
27
|
+
break;
|
|
28
|
+
default:
|
|
29
|
+
console.error(`Unknown command: ${command}\n`);
|
|
30
|
+
printHelp(version);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
function printHelp(ver) {
|
|
34
|
+
console.log(`discoclaw v${ver} — Personal AI orchestrator\n` +
|
|
35
|
+
`\nUsage: discoclaw <command>\n` +
|
|
36
|
+
`\nCommands:\n` +
|
|
37
|
+
` init Interactive setup wizard — creates .env and workspace/\n` +
|
|
38
|
+
` install-daemon Register discoclaw as a persistent background service\n` +
|
|
39
|
+
`\nOptions:\n` +
|
|
40
|
+
` -v, --version Print version\n` +
|
|
41
|
+
` -h, --help Print this help\n`);
|
|
42
|
+
}
|