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,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive init wizard for discoclaw.
|
|
3
|
+
* Invoked by `discoclaw init` to guide a first-time user through setup.
|
|
4
|
+
* Creates a .env file and scaffolds a workspace/ directory in the current
|
|
5
|
+
* working directory.
|
|
6
|
+
*/
|
|
7
|
+
import * as readline from 'node:readline/promises';
|
|
8
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { validateDiscordToken, validateSnowflake, validateSnowflakes } from '../validate.js';
|
|
13
|
+
import { ensureWorkspaceBootstrapFiles } from '../workspace-bootstrap.js';
|
|
14
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
15
|
+
function which(bin) {
|
|
16
|
+
try {
|
|
17
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
18
|
+
execFileSync(finder, [bin], { stdio: 'pipe' });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function backupFileName(now = new Date()) {
|
|
26
|
+
const ts = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '');
|
|
27
|
+
return `.env.backup.${ts}`;
|
|
28
|
+
}
|
|
29
|
+
export function buildEnvContent(vals, now = new Date()) {
|
|
30
|
+
const lines = [
|
|
31
|
+
'# Discoclaw — generated by discoclaw init',
|
|
32
|
+
`# Created: ${now.toISOString()}`,
|
|
33
|
+
'',
|
|
34
|
+
];
|
|
35
|
+
lines.push('# REQUIRED');
|
|
36
|
+
lines.push(`DISCORD_TOKEN=${vals.DISCORD_TOKEN ?? ''}`);
|
|
37
|
+
lines.push(`DISCORD_ALLOW_USER_IDS=${vals.DISCORD_ALLOW_USER_IDS ?? ''}`);
|
|
38
|
+
lines.push(`DISCOCLAW_TASKS_FORUM=${vals.DISCOCLAW_TASKS_FORUM ?? ''}`);
|
|
39
|
+
lines.push(`DISCOCLAW_CRON_FORUM=${vals.DISCOCLAW_CRON_FORUM ?? ''}`);
|
|
40
|
+
lines.push('');
|
|
41
|
+
if (vals.PRIMARY_RUNTIME) {
|
|
42
|
+
const providerSpecificKeys = [
|
|
43
|
+
'GEMINI_BIN',
|
|
44
|
+
'GEMINI_MODEL',
|
|
45
|
+
'OPENAI_API_KEY',
|
|
46
|
+
'CODEX_BIN',
|
|
47
|
+
'CODEX_MODEL',
|
|
48
|
+
'CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX',
|
|
49
|
+
'CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS',
|
|
50
|
+
'CLAUDE_OUTPUT_FORMAT',
|
|
51
|
+
'OPENROUTER_API_KEY',
|
|
52
|
+
'OPENROUTER_BASE_URL',
|
|
53
|
+
'OPENROUTER_MODEL',
|
|
54
|
+
];
|
|
55
|
+
lines.push('# PROVIDER');
|
|
56
|
+
lines.push(`PRIMARY_RUNTIME=${vals.PRIMARY_RUNTIME}`);
|
|
57
|
+
for (const k of providerSpecificKeys) {
|
|
58
|
+
if (vals[k])
|
|
59
|
+
lines.push(`${k}=${vals[k]}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push('');
|
|
62
|
+
if (vals.DISCORD_GUILD_ID) {
|
|
63
|
+
lines.push('# CORE');
|
|
64
|
+
lines.push(`DISCORD_GUILD_ID=${vals.DISCORD_GUILD_ID}`);
|
|
65
|
+
lines.push('');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const optionalKeys = ['DISCOCLAW_DISCORD_ACTIONS', 'DISCOCLAW_STATUS_CHANNEL'];
|
|
69
|
+
const hasOptional = optionalKeys.some((k) => vals[k]);
|
|
70
|
+
if (hasOptional) {
|
|
71
|
+
lines.push('# OPTIONAL');
|
|
72
|
+
for (const k of optionalKeys) {
|
|
73
|
+
if (vals[k])
|
|
74
|
+
lines.push(`${k}=${vals[k]}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
78
|
+
lines.push('# For all options, see .env.example.full');
|
|
79
|
+
lines.push('');
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
export function selectDefaultProvider(detected) {
|
|
83
|
+
if (detected.includes('claude'))
|
|
84
|
+
return '1';
|
|
85
|
+
if (detected.includes('gemini'))
|
|
86
|
+
return '2';
|
|
87
|
+
if (detected.includes('codex'))
|
|
88
|
+
return '4';
|
|
89
|
+
return '1';
|
|
90
|
+
}
|
|
91
|
+
// ── main wizard ───────────────────────────────────────────────────────────────
|
|
92
|
+
export async function runInitWizard() {
|
|
93
|
+
if (!input.isTTY) {
|
|
94
|
+
console.error('discoclaw init requires an interactive terminal.\n');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const cwd = process.cwd();
|
|
98
|
+
const envPath = path.join(cwd, '.env');
|
|
99
|
+
let rl = null;
|
|
100
|
+
let canceled = false;
|
|
101
|
+
let completed = false;
|
|
102
|
+
function cleanup() {
|
|
103
|
+
canceled = true;
|
|
104
|
+
try {
|
|
105
|
+
fs.unlinkSync(path.join(cwd, '.env.tmp'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
if (rl) {
|
|
111
|
+
const toClose = rl;
|
|
112
|
+
rl = null;
|
|
113
|
+
toClose.close();
|
|
114
|
+
}
|
|
115
|
+
console.log('\n\nSetup canceled.\n');
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
process.on('SIGINT', cleanup);
|
|
119
|
+
process.on('SIGTERM', cleanup);
|
|
120
|
+
rl = readline.createInterface({ input, output });
|
|
121
|
+
rl.on('close', () => {
|
|
122
|
+
if (!canceled && !completed)
|
|
123
|
+
cleanup();
|
|
124
|
+
});
|
|
125
|
+
async function ask(prompt) {
|
|
126
|
+
if (canceled || !rl)
|
|
127
|
+
return '';
|
|
128
|
+
return rl.question(prompt);
|
|
129
|
+
}
|
|
130
|
+
async function askValidated(prompt, validate) {
|
|
131
|
+
while (true) {
|
|
132
|
+
if (canceled)
|
|
133
|
+
return '';
|
|
134
|
+
const val = await ask(prompt);
|
|
135
|
+
const err = validate(val.trim());
|
|
136
|
+
if (!err)
|
|
137
|
+
return val.trim();
|
|
138
|
+
console.log(` Error: ${err}. Try again.\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function askOptional(prompt, validate) {
|
|
142
|
+
while (true) {
|
|
143
|
+
if (canceled)
|
|
144
|
+
return '';
|
|
145
|
+
const val = await ask(prompt);
|
|
146
|
+
if (!val.trim())
|
|
147
|
+
return '';
|
|
148
|
+
const err = validate(val.trim());
|
|
149
|
+
if (!err)
|
|
150
|
+
return val.trim();
|
|
151
|
+
console.log(` Error: ${err}. Try again.\n`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ── Welcome ──────────────────────────────────────────────────────────────
|
|
155
|
+
console.log(`\nDiscoclaw Init\n==============\n` +
|
|
156
|
+
`This wizard creates a .env file and workspace/ directory in:\n ${cwd}\n`);
|
|
157
|
+
// ── Discord bot guidance ──────────────────────────────────────────────────
|
|
158
|
+
console.log(`Discord Bot Setup\n-----------------\n` +
|
|
159
|
+
`If you haven't created a Discord bot yet, follow these steps:\n\n` +
|
|
160
|
+
` 1. Go to https://discord.com/developers/applications\n` +
|
|
161
|
+
` 2. Click "New Application" and give it a name (e.g. "DiscoClaw").\n` +
|
|
162
|
+
` 3. Open the "Bot" tab and click "Add Bot".\n` +
|
|
163
|
+
` 4. Enable "Message Content Intent" under Privileged Gateway Intents.\n` +
|
|
164
|
+
` 5. Click "Reset Token", copy it — you'll enter it below.\n` +
|
|
165
|
+
` 6. Invite your bot: Bot tab → OAuth2 → URL Generator\n` +
|
|
166
|
+
` Scopes: bot Permissions: Send Messages, Read Message History\n` +
|
|
167
|
+
` Open the generated URL and select your server.\n\n` +
|
|
168
|
+
`Already have a bot? Just press Enter.\n`);
|
|
169
|
+
await ask('Press Enter to continue... ');
|
|
170
|
+
// ── Check existing .env ───────────────────────────────────────────────────
|
|
171
|
+
if (fs.existsSync(envPath)) {
|
|
172
|
+
const existing = fs.readFileSync(envPath, 'utf8');
|
|
173
|
+
const tokenMatch = existing.match(/^DISCORD_TOKEN=(.*)$/m);
|
|
174
|
+
const idsMatch = existing.match(/^DISCORD_ALLOW_USER_IDS=(.*)$/m);
|
|
175
|
+
console.log('\nExisting .env detected:');
|
|
176
|
+
if (tokenMatch?.[1]) {
|
|
177
|
+
const t = tokenMatch[1].trim();
|
|
178
|
+
console.log(` DISCORD_TOKEN = ${t.slice(0, 8)}...(masked)`);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
console.log(' DISCORD_TOKEN = (not set)');
|
|
182
|
+
}
|
|
183
|
+
if (idsMatch?.[1]) {
|
|
184
|
+
const ids = idsMatch[1].trim().split(/[,\s]+/).filter(Boolean);
|
|
185
|
+
const masked = ids.map((id) => (id.length > 6 ? `${id.slice(0, 3)}...${id.slice(-3)}` : '***'));
|
|
186
|
+
console.log(` DISCORD_ALLOW_USER_IDS = ${masked.join(', ')}`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
console.log(' DISCORD_ALLOW_USER_IDS = (not set)');
|
|
190
|
+
}
|
|
191
|
+
console.log('');
|
|
192
|
+
const overwrite = await ask('Overwrite with fresh config? [y/N] ');
|
|
193
|
+
if (overwrite.toLowerCase() !== 'y') {
|
|
194
|
+
console.log('Run discoclaw init after removing .env to reconfigure.\n');
|
|
195
|
+
completed = true;
|
|
196
|
+
rl.close();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const bkName = backupFileName();
|
|
200
|
+
const backupPath = path.join(cwd, bkName);
|
|
201
|
+
fs.copyFileSync(envPath, backupPath);
|
|
202
|
+
console.log(` Backed up to ${bkName}\n`);
|
|
203
|
+
}
|
|
204
|
+
// ── Required values ───────────────────────────────────────────────────────
|
|
205
|
+
const values = {};
|
|
206
|
+
values.DISCORD_TOKEN = await askValidated('Discord bot token: ', (val) => {
|
|
207
|
+
const r = validateDiscordToken(val);
|
|
208
|
+
return r.valid ? null : (r.reason ?? 'Invalid token format');
|
|
209
|
+
});
|
|
210
|
+
values.DISCORD_ALLOW_USER_IDS = await askValidated('Allowed user IDs (comma-separated): ', (val) => {
|
|
211
|
+
if (!val.trim())
|
|
212
|
+
return 'At least one user ID is required';
|
|
213
|
+
const r = validateSnowflakes(val);
|
|
214
|
+
if (!r.valid && r.invalidIds.length > 0)
|
|
215
|
+
return `Invalid IDs: ${r.invalidIds.join(', ')}`;
|
|
216
|
+
if (!r.valid)
|
|
217
|
+
return 'At least one valid snowflake ID is required';
|
|
218
|
+
return null;
|
|
219
|
+
});
|
|
220
|
+
values.DISCOCLAW_TASKS_FORUM = await askValidated('Tasks forum channel ID (required): ', (val) => (validateSnowflake(val) ? null : 'Must be a 17-20 digit number'));
|
|
221
|
+
values.DISCOCLAW_CRON_FORUM = await askValidated('Automations forum channel ID (required): ', (val) => (validateSnowflake(val) ? null : 'Must be a 17-20 digit number'));
|
|
222
|
+
// ── Runtime detection ─────────────────────────────────────────────────────
|
|
223
|
+
const detected = [];
|
|
224
|
+
if (which('claude'))
|
|
225
|
+
detected.push('claude');
|
|
226
|
+
if (which('gemini'))
|
|
227
|
+
detected.push('gemini');
|
|
228
|
+
if (which('codex'))
|
|
229
|
+
detected.push('codex');
|
|
230
|
+
if (detected.length > 0) {
|
|
231
|
+
console.log(`\nDetected runtimes: ${detected.join(', ')}`);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
console.log('\nNo AI runtimes detected in PATH.');
|
|
235
|
+
console.log(' → Install Claude CLI: https://docs.anthropic.com/en/docs/claude-code');
|
|
236
|
+
}
|
|
237
|
+
// ── Provider selection ────────────────────────────────────────────────────
|
|
238
|
+
console.log('\nSelect your AI provider:');
|
|
239
|
+
console.log(' 1) Claude' + (detected.includes('claude') ? ' (detected)' : ''));
|
|
240
|
+
console.log(' 2) Gemini' + (detected.includes('gemini') ? ' (detected)' : ''));
|
|
241
|
+
console.log(' 3) OpenAI');
|
|
242
|
+
console.log(' 4) Codex' + (detected.includes('codex') ? ' (detected)' : ''));
|
|
243
|
+
console.log(' 5) OpenRouter');
|
|
244
|
+
const defaultProvider = selectDefaultProvider(detected);
|
|
245
|
+
const providerChoice = await askValidated(`Provider [1-5, default: ${defaultProvider}]: `, (val) => {
|
|
246
|
+
const effective = val || defaultProvider;
|
|
247
|
+
return ['1', '2', '3', '4', '5'].includes(effective) ? null : 'Enter 1, 2, 3, 4, or 5';
|
|
248
|
+
});
|
|
249
|
+
const finalChoice = providerChoice || defaultProvider;
|
|
250
|
+
if (finalChoice === '1') {
|
|
251
|
+
values.PRIMARY_RUNTIME = 'claude';
|
|
252
|
+
const skipPerms = await ask('Enable CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS? (required for headless operation) [Y/n] ');
|
|
253
|
+
if (skipPerms.toLowerCase() !== 'n') {
|
|
254
|
+
values.CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS = '1';
|
|
255
|
+
}
|
|
256
|
+
const streamJson = await ask('Use stream-json output format? (smoother streaming) [Y/n] ');
|
|
257
|
+
if (streamJson.toLowerCase() !== 'n') {
|
|
258
|
+
values.CLAUDE_OUTPUT_FORMAT = 'stream-json';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else if (finalChoice === '2') {
|
|
262
|
+
values.PRIMARY_RUNTIME = 'gemini';
|
|
263
|
+
console.log(' Note: auth is handled by the gemini binary itself (run `gemini` to authenticate).');
|
|
264
|
+
const gemBin = await askOptional('Gemini binary path [default: gemini]: ', () => null);
|
|
265
|
+
values.GEMINI_BIN = gemBin || 'gemini';
|
|
266
|
+
const gemModel = await askOptional('Gemini model [default: gemini-2.5-pro]: ', () => null);
|
|
267
|
+
values.GEMINI_MODEL = gemModel || 'gemini-2.5-pro';
|
|
268
|
+
}
|
|
269
|
+
else if (finalChoice === '3') {
|
|
270
|
+
values.PRIMARY_RUNTIME = 'openai';
|
|
271
|
+
console.log(' Note: the OpenAI adapter is HTTP-only.');
|
|
272
|
+
values.OPENAI_API_KEY = await askValidated('OpenAI API key: ', (val) => (val ? null : 'API key is required'));
|
|
273
|
+
}
|
|
274
|
+
else if (finalChoice === '4') {
|
|
275
|
+
values.PRIMARY_RUNTIME = 'codex';
|
|
276
|
+
const codexBin = await askOptional('Codex binary path [leave empty to use PATH]: ', () => null);
|
|
277
|
+
if (codexBin)
|
|
278
|
+
values.CODEX_BIN = codexBin;
|
|
279
|
+
const codexModel = await askOptional('Codex model [leave empty for default]: ', () => null);
|
|
280
|
+
if (codexModel)
|
|
281
|
+
values.CODEX_MODEL = codexModel;
|
|
282
|
+
const bypassApprovals = await ask('Enable CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX? [y/N] ');
|
|
283
|
+
if (bypassApprovals.toLowerCase() === 'y') {
|
|
284
|
+
values.CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX = '1';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else if (finalChoice === '5') {
|
|
288
|
+
values.PRIMARY_RUNTIME = 'openrouter';
|
|
289
|
+
console.log(' Note: the OpenRouter adapter is HTTP-only.');
|
|
290
|
+
values.OPENROUTER_API_KEY = await askValidated('OpenRouter API key: ', (val) => (val ? null : 'API key is required'));
|
|
291
|
+
const orBaseUrl = await askOptional('OpenRouter base URL [leave empty for default]: ', () => null);
|
|
292
|
+
if (orBaseUrl)
|
|
293
|
+
values.OPENROUTER_BASE_URL = orBaseUrl;
|
|
294
|
+
const orModel = await askOptional('OpenRouter model [default: anthropic/claude-sonnet-4]: ', () => null);
|
|
295
|
+
values.OPENROUTER_MODEL = orModel || 'anthropic/claude-sonnet-4';
|
|
296
|
+
}
|
|
297
|
+
// ── Recommended settings ──────────────────────────────────────────────────
|
|
298
|
+
const configRecommended = await ask('\nConfigure recommended settings? [Y/n] ');
|
|
299
|
+
if (configRecommended.toLowerCase() !== 'n') {
|
|
300
|
+
const guildId = await askOptional('Discord guild (server) ID [leave empty to skip]: ', (val) => {
|
|
301
|
+
if (!val)
|
|
302
|
+
return null;
|
|
303
|
+
return validateSnowflake(val) ? null : 'Must be a 17-20 digit number';
|
|
304
|
+
});
|
|
305
|
+
if (guildId)
|
|
306
|
+
values.DISCORD_GUILD_ID = guildId;
|
|
307
|
+
}
|
|
308
|
+
// ── Optional features ─────────────────────────────────────────────────────
|
|
309
|
+
const configOptional = await ask('\nConfigure optional features? [y/N] ');
|
|
310
|
+
if (configOptional.toLowerCase() === 'y') {
|
|
311
|
+
const actions = await ask('Enable Discord Actions? (lets the AI manage your server) [Y/n] ');
|
|
312
|
+
if (actions.toLowerCase() === 'n') {
|
|
313
|
+
values.DISCOCLAW_DISCORD_ACTIONS = '0';
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
values.DISCOCLAW_DISCORD_ACTIONS = '1';
|
|
317
|
+
}
|
|
318
|
+
const statusChannel = await askOptional('Status channel ID or name [leave empty to skip]: ', () => null);
|
|
319
|
+
if (statusChannel)
|
|
320
|
+
values.DISCOCLAW_STATUS_CHANNEL = statusChannel;
|
|
321
|
+
}
|
|
322
|
+
// ── Write .env ────────────────────────────────────────────────────────────
|
|
323
|
+
const envContent = buildEnvContent(values);
|
|
324
|
+
const tmpPath = path.join(cwd, '.env.tmp');
|
|
325
|
+
fs.writeFileSync(tmpPath, envContent, 'utf8');
|
|
326
|
+
fs.renameSync(tmpPath, envPath);
|
|
327
|
+
console.log('\n.env written successfully.\n');
|
|
328
|
+
// ── Scaffold workspace ────────────────────────────────────────────────────
|
|
329
|
+
const workspaceCwd = path.join(cwd, 'workspace');
|
|
330
|
+
console.log(`Scaffolding workspace at ${workspaceCwd}...`);
|
|
331
|
+
const scaffolded = await ensureWorkspaceBootstrapFiles(workspaceCwd);
|
|
332
|
+
if (scaffolded.length > 0) {
|
|
333
|
+
console.log(` Created: ${scaffolded.join(', ')}`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
console.log(' Workspace already set up — no files changed.');
|
|
337
|
+
}
|
|
338
|
+
console.log('');
|
|
339
|
+
// ── Next steps ────────────────────────────────────────────────────────────
|
|
340
|
+
let daemonHint;
|
|
341
|
+
if (process.platform === 'darwin') {
|
|
342
|
+
daemonHint = 'discoclaw install-daemon # sets up a launchd service';
|
|
343
|
+
}
|
|
344
|
+
else if (process.platform === 'win32') {
|
|
345
|
+
daemonHint = 'Run `discoclaw` directly or use a process manager (e.g. PM2).';
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
daemonHint = 'discoclaw install-daemon # sets up a systemd user service';
|
|
349
|
+
}
|
|
350
|
+
console.log('Configuration complete!\n');
|
|
351
|
+
console.log('Next steps:');
|
|
352
|
+
if (values.PRIMARY_RUNTIME === 'claude') {
|
|
353
|
+
console.log(` ${daemonHint}`);
|
|
354
|
+
}
|
|
355
|
+
else if (values.PRIMARY_RUNTIME === 'gemini') {
|
|
356
|
+
console.log(' 1. Authenticate: run `gemini` and follow the prompts.');
|
|
357
|
+
console.log(` 2. ${daemonHint}`);
|
|
358
|
+
}
|
|
359
|
+
else if (values.PRIMARY_RUNTIME === 'openai') {
|
|
360
|
+
console.log(' 1. Verify your OPENAI_API_KEY is correct.');
|
|
361
|
+
console.log(` 2. ${daemonHint}`);
|
|
362
|
+
}
|
|
363
|
+
else if (values.PRIMARY_RUNTIME === 'codex') {
|
|
364
|
+
console.log(' 1. Ensure the Codex binary is installed and accessible.');
|
|
365
|
+
console.log(` 2. ${daemonHint}`);
|
|
366
|
+
}
|
|
367
|
+
else if (values.PRIMARY_RUNTIME === 'openrouter') {
|
|
368
|
+
console.log(' 1. Verify your OPENROUTER_API_KEY is correct.');
|
|
369
|
+
console.log(` 2. ${daemonHint}`);
|
|
370
|
+
}
|
|
371
|
+
console.log('');
|
|
372
|
+
completed = true;
|
|
373
|
+
rl.close();
|
|
374
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
vi.mock('node:readline/promises', () => ({
|
|
6
|
+
createInterface: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('node:child_process', () => ({
|
|
9
|
+
execFileSync: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('../workspace-bootstrap.js', () => ({
|
|
12
|
+
ensureWorkspaceBootstrapFiles: vi.fn(async () => []),
|
|
13
|
+
}));
|
|
14
|
+
import { createInterface } from 'node:readline/promises';
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { ensureWorkspaceBootstrapFiles } from '../workspace-bootstrap.js';
|
|
17
|
+
import { backupFileName, buildEnvContent, runInitWizard, selectDefaultProvider } from './init-wizard.js';
|
|
18
|
+
const initialSigintListeners = new Set(process.listeners('SIGINT'));
|
|
19
|
+
const initialSigtermListeners = new Set(process.listeners('SIGTERM'));
|
|
20
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
21
|
+
function makeReadline(answers) {
|
|
22
|
+
let closeHandler;
|
|
23
|
+
return {
|
|
24
|
+
question: vi.fn(async () => answers.shift() ?? ''),
|
|
25
|
+
close: vi.fn(() => {
|
|
26
|
+
closeHandler?.();
|
|
27
|
+
}),
|
|
28
|
+
on: vi.fn((event, cb) => {
|
|
29
|
+
if (event === 'close')
|
|
30
|
+
closeHandler = cb;
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
describe('init wizard helpers', () => {
|
|
35
|
+
it('formats backup filenames from timestamps', () => {
|
|
36
|
+
const name = backupFileName(new Date('2026-02-21T18:45:12.999Z'));
|
|
37
|
+
expect(name).toBe('.env.backup.20260221T184512');
|
|
38
|
+
});
|
|
39
|
+
it('builds env content with provider/core/optional sections', () => {
|
|
40
|
+
const content = buildEnvContent({
|
|
41
|
+
DISCORD_TOKEN: 'a.b.c',
|
|
42
|
+
DISCORD_ALLOW_USER_IDS: '1000000000000000001',
|
|
43
|
+
DISCOCLAW_TASKS_FORUM: '1000000000000000002',
|
|
44
|
+
DISCOCLAW_CRON_FORUM: '1000000000000000003',
|
|
45
|
+
PRIMARY_RUNTIME: 'claude',
|
|
46
|
+
CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS: '1',
|
|
47
|
+
CLAUDE_OUTPUT_FORMAT: 'stream-json',
|
|
48
|
+
DISCORD_GUILD_ID: '1000000000000000004',
|
|
49
|
+
DISCOCLAW_DISCORD_ACTIONS: '1',
|
|
50
|
+
}, new Date('2026-02-21T00:00:00.000Z'));
|
|
51
|
+
expect(content).toContain('# REQUIRED');
|
|
52
|
+
expect(content).toContain('PRIMARY_RUNTIME=claude');
|
|
53
|
+
expect(content).toContain('CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1');
|
|
54
|
+
expect(content).toContain('# CORE');
|
|
55
|
+
expect(content).toContain('DISCORD_GUILD_ID=1000000000000000004');
|
|
56
|
+
expect(content).toContain('# OPTIONAL');
|
|
57
|
+
expect(content).toContain('DISCOCLAW_DISCORD_ACTIONS=1');
|
|
58
|
+
});
|
|
59
|
+
it('selects provider defaults in expected precedence order', () => {
|
|
60
|
+
expect(selectDefaultProvider(['codex'])).toBe('4');
|
|
61
|
+
expect(selectDefaultProvider(['gemini', 'codex'])).toBe('2');
|
|
62
|
+
expect(selectDefaultProvider(['claude', 'gemini', 'codex'])).toBe('1');
|
|
63
|
+
expect(selectDefaultProvider([])).toBe('1');
|
|
64
|
+
});
|
|
65
|
+
it('returns default provider 1 when no OpenRouter runtime is detected (HTTP-only, no binary)', () => {
|
|
66
|
+
// OpenRouter has no CLI binary so detection never adds it to the list
|
|
67
|
+
expect(selectDefaultProvider([])).toBe('1');
|
|
68
|
+
expect(selectDefaultProvider(['openrouter'])).toBe('1');
|
|
69
|
+
});
|
|
70
|
+
it('includes OpenRouter keys in generated env content', () => {
|
|
71
|
+
const content = buildEnvContent({
|
|
72
|
+
DISCORD_TOKEN: 'a.b.c',
|
|
73
|
+
DISCORD_ALLOW_USER_IDS: '1000000000000000001',
|
|
74
|
+
DISCOCLAW_TASKS_FORUM: '1000000000000000002',
|
|
75
|
+
DISCOCLAW_CRON_FORUM: '1000000000000000003',
|
|
76
|
+
PRIMARY_RUNTIME: 'openrouter',
|
|
77
|
+
OPENROUTER_API_KEY: 'sk-or-test-key',
|
|
78
|
+
OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
|
|
79
|
+
OPENROUTER_MODEL: 'anthropic/claude-sonnet-4',
|
|
80
|
+
}, new Date('2026-02-22T00:00:00.000Z'));
|
|
81
|
+
expect(content).toContain('PRIMARY_RUNTIME=openrouter');
|
|
82
|
+
expect(content).toContain('OPENROUTER_API_KEY=sk-or-test-key');
|
|
83
|
+
expect(content).toContain('OPENROUTER_BASE_URL=https://openrouter.ai/api/v1');
|
|
84
|
+
expect(content).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('runInitWizard', () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
process.stdin.isTTY = true;
|
|
91
|
+
});
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
process.stdin.isTTY = originalIsTTY;
|
|
94
|
+
for (const listener of process.listeners('SIGINT')) {
|
|
95
|
+
if (!initialSigintListeners.has(listener))
|
|
96
|
+
process.removeListener('SIGINT', listener);
|
|
97
|
+
}
|
|
98
|
+
for (const listener of process.listeners('SIGTERM')) {
|
|
99
|
+
if (!initialSigtermListeners.has(listener))
|
|
100
|
+
process.removeListener('SIGTERM', listener);
|
|
101
|
+
}
|
|
102
|
+
vi.restoreAllMocks();
|
|
103
|
+
});
|
|
104
|
+
it('rejects non-interactive terminals', async () => {
|
|
105
|
+
process.stdin.isTTY = false;
|
|
106
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
107
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
108
|
+
throw new Error(`exit:${code ?? 0}`);
|
|
109
|
+
}));
|
|
110
|
+
await expect(runInitWizard()).rejects.toThrow('exit:1');
|
|
111
|
+
expect(errSpy).toHaveBeenCalledWith('discoclaw init requires an interactive terminal.\n');
|
|
112
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
113
|
+
expect(vi.mocked(createInterface)).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
it('backs up an existing .env before overwrite and writes new config', async () => {
|
|
116
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
|
|
117
|
+
const previousCwd = process.cwd();
|
|
118
|
+
const oldEnv = 'DISCORD_TOKEN=old.token.value\nDISCORD_ALLOW_USER_IDS=111111111111111111\n';
|
|
119
|
+
const answers = [
|
|
120
|
+
'', // Press Enter to continue
|
|
121
|
+
'y', // Overwrite existing .env
|
|
122
|
+
'a.b.c', // DISCORD_TOKEN
|
|
123
|
+
'1000000000000000001', // DISCORD_ALLOW_USER_IDS
|
|
124
|
+
'1000000000000000002', // DISCOCLAW_TASKS_FORUM
|
|
125
|
+
'1000000000000000003', // DISCOCLAW_CRON_FORUM
|
|
126
|
+
'', // provider selection -> default (Claude)
|
|
127
|
+
'', // enable skip permissions
|
|
128
|
+
'', // enable stream-json
|
|
129
|
+
'n', // configure recommended settings
|
|
130
|
+
'n', // configure optional features
|
|
131
|
+
];
|
|
132
|
+
fs.writeFileSync(path.join(tmpDir, '.env'), oldEnv, 'utf8');
|
|
133
|
+
process.chdir(tmpDir);
|
|
134
|
+
vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
|
|
135
|
+
vi.mocked(execFileSync).mockImplementation(() => {
|
|
136
|
+
throw new Error('binary not found');
|
|
137
|
+
});
|
|
138
|
+
vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
|
|
139
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
140
|
+
try {
|
|
141
|
+
await runInitWizard();
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
process.chdir(previousCwd);
|
|
145
|
+
}
|
|
146
|
+
const backupFiles = fs.readdirSync(tmpDir).filter((name) => name.startsWith('.env.backup.'));
|
|
147
|
+
expect(backupFiles).toHaveLength(1);
|
|
148
|
+
expect(fs.readFileSync(path.join(tmpDir, backupFiles[0]), 'utf8')).toBe(oldEnv);
|
|
149
|
+
const newEnv = fs.readFileSync(path.join(tmpDir, '.env'), 'utf8');
|
|
150
|
+
expect(newEnv).toContain('DISCORD_TOKEN=a.b.c');
|
|
151
|
+
expect(newEnv).toContain('PRIMARY_RUNTIME=claude');
|
|
152
|
+
expect(newEnv).toContain('CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1');
|
|
153
|
+
expect(newEnv).toContain('CLAUDE_OUTPUT_FORMAT=stream-json');
|
|
154
|
+
expect(ensureWorkspaceBootstrapFiles).toHaveBeenCalledWith(path.join(tmpDir, 'workspace'));
|
|
155
|
+
});
|
|
156
|
+
it('writes openrouter config when provider 5 is selected', async () => {
|
|
157
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
|
|
158
|
+
const previousCwd = process.cwd();
|
|
159
|
+
const answers = [
|
|
160
|
+
'', // Press Enter to continue
|
|
161
|
+
// no existing .env
|
|
162
|
+
'a.b.c', // DISCORD_TOKEN
|
|
163
|
+
'1000000000000000001', // DISCORD_ALLOW_USER_IDS
|
|
164
|
+
'1000000000000000002', // DISCOCLAW_TASKS_FORUM
|
|
165
|
+
'1000000000000000003', // DISCOCLAW_CRON_FORUM
|
|
166
|
+
'5', // provider selection -> OpenRouter
|
|
167
|
+
'sk-or-test-key', // OPENROUTER_API_KEY
|
|
168
|
+
'', // OPENROUTER_BASE_URL (optional, skip)
|
|
169
|
+
'', // OPENROUTER_MODEL (optional, use default)
|
|
170
|
+
'n', // configure recommended settings
|
|
171
|
+
'n', // configure optional features
|
|
172
|
+
];
|
|
173
|
+
process.chdir(tmpDir);
|
|
174
|
+
vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
|
|
175
|
+
vi.mocked(execFileSync).mockImplementation(() => {
|
|
176
|
+
throw new Error('binary not found');
|
|
177
|
+
});
|
|
178
|
+
vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
|
|
179
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
180
|
+
try {
|
|
181
|
+
await runInitWizard();
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
process.chdir(previousCwd);
|
|
185
|
+
}
|
|
186
|
+
const newEnv = fs.readFileSync(path.join(tmpDir, '.env'), 'utf8');
|
|
187
|
+
expect(newEnv).toContain('PRIMARY_RUNTIME=openrouter');
|
|
188
|
+
expect(newEnv).toContain('OPENROUTER_API_KEY=sk-or-test-key');
|
|
189
|
+
expect(newEnv).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4');
|
|
190
|
+
});
|
|
191
|
+
});
|