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,238 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { OnboardingFlow } from './onboarding-flow.js';
|
|
3
|
+
function startFlow(displayName = 'TestUser') {
|
|
4
|
+
const flow = new OnboardingFlow();
|
|
5
|
+
const greeting = flow.start(displayName);
|
|
6
|
+
return { flow, greeting };
|
|
7
|
+
}
|
|
8
|
+
/** Run a flow to CONFIRM step. */
|
|
9
|
+
function flowToConfirm() {
|
|
10
|
+
const { flow } = startFlow();
|
|
11
|
+
flow.handleInput('David'); // NAME
|
|
12
|
+
flow.handleInput('America/New_York'); // TIMEZONE
|
|
13
|
+
flow.handleInput('yes'); // CHECKIN → CONFIRM
|
|
14
|
+
return flow;
|
|
15
|
+
}
|
|
16
|
+
describe('OnboardingFlow', () => {
|
|
17
|
+
it('start() returns greeting with first question', () => {
|
|
18
|
+
const { greeting } = startFlow('Dave');
|
|
19
|
+
expect(greeting.done).toBe(false);
|
|
20
|
+
expect(greeting.reply).toContain('Dave');
|
|
21
|
+
expect(greeting.reply).toContain('name');
|
|
22
|
+
});
|
|
23
|
+
it('updates lastActivityTimestamp on start and handleInput', () => {
|
|
24
|
+
const { flow } = startFlow();
|
|
25
|
+
const t1 = flow.lastActivityTimestamp;
|
|
26
|
+
expect(t1).toBeGreaterThan(0);
|
|
27
|
+
flow.handleInput('David');
|
|
28
|
+
expect(flow.lastActivityTimestamp).toBeGreaterThanOrEqual(t1);
|
|
29
|
+
});
|
|
30
|
+
// --- Happy path ---
|
|
31
|
+
it('full flow: name → timezone → check-in → confirm', () => {
|
|
32
|
+
const { flow } = startFlow();
|
|
33
|
+
let r = flow.handleInput('David');
|
|
34
|
+
expect(r.reply).toContain('David');
|
|
35
|
+
expect(r.done).toBe(false);
|
|
36
|
+
r = flow.handleInput('America/Chicago');
|
|
37
|
+
expect(r.reply).toContain('morning');
|
|
38
|
+
r = flow.handleInput('yes');
|
|
39
|
+
// Should show confirmation
|
|
40
|
+
expect(r.reply).toContain('David');
|
|
41
|
+
expect(r.reply).toContain('America/Chicago');
|
|
42
|
+
expect(r.reply).toContain('Morning check-in');
|
|
43
|
+
});
|
|
44
|
+
// --- Confirmation → write ---
|
|
45
|
+
it('confirmation with yes triggers WRITING', () => {
|
|
46
|
+
const flow = flowToConfirm();
|
|
47
|
+
const r = flow.handleInput('yes');
|
|
48
|
+
expect(r.done).toBe(false);
|
|
49
|
+
expect(r.writeResult).toBe('pending');
|
|
50
|
+
expect(r.reply).toContain('Writing');
|
|
51
|
+
});
|
|
52
|
+
it('confirmation accepts various affirmatives', () => {
|
|
53
|
+
for (const word of ['y', 'ok', 'confirm', 'looks good', 'YES']) {
|
|
54
|
+
const flow = flowToConfirm();
|
|
55
|
+
const r = flow.handleInput(word);
|
|
56
|
+
expect(r.writeResult).toBe('pending');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
// --- Editing a field ---
|
|
60
|
+
it('editing field 1 (name) at confirmation returns to confirm with updated value', () => {
|
|
61
|
+
const flow = flowToConfirm();
|
|
62
|
+
let r = flow.handleInput('1'); // Edit name
|
|
63
|
+
expect(r.reply).toContain('name');
|
|
64
|
+
r = flow.handleInput('NewName');
|
|
65
|
+
expect(r.reply).toContain('NewName');
|
|
66
|
+
expect(r.reply).toContain('yes');
|
|
67
|
+
});
|
|
68
|
+
it('editing field 2 (timezone) at confirmation returns to confirm with updated value', () => {
|
|
69
|
+
const flow = flowToConfirm();
|
|
70
|
+
let r = flow.handleInput('2'); // Edit timezone
|
|
71
|
+
expect(r.reply).toContain('timezone');
|
|
72
|
+
r = flow.handleInput('Europe/London');
|
|
73
|
+
expect(r.reply).toContain('Europe/London');
|
|
74
|
+
expect(r.reply).toContain('yes');
|
|
75
|
+
});
|
|
76
|
+
it('editing field 3 (check-in) at confirmation returns to confirm with updated value', () => {
|
|
77
|
+
const flow = flowToConfirm();
|
|
78
|
+
let r = flow.handleInput('3'); // Edit check-in
|
|
79
|
+
expect(r.reply).toContain('morning');
|
|
80
|
+
r = flow.handleInput('no');
|
|
81
|
+
expect(r.reply).toContain('No');
|
|
82
|
+
expect(r.reply).toContain('yes');
|
|
83
|
+
});
|
|
84
|
+
// --- WRITING step ---
|
|
85
|
+
it('message during WRITING returns hang-on message', () => {
|
|
86
|
+
const flow = flowToConfirm();
|
|
87
|
+
flow.handleInput('yes'); // → WRITING
|
|
88
|
+
const r = flow.handleInput('hello?');
|
|
89
|
+
expect(r.done).toBe(false);
|
|
90
|
+
expect(r.reply).toContain('still writing');
|
|
91
|
+
expect(r.writeResult).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
it('markWriteComplete transitions to DONE', () => {
|
|
94
|
+
const flow = flowToConfirm();
|
|
95
|
+
flow.handleInput('yes');
|
|
96
|
+
flow.markWriteComplete();
|
|
97
|
+
const r = flow.handleInput('anything');
|
|
98
|
+
expect(r.done).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
it('markWriteFailed transitions to WRITE_ERROR', () => {
|
|
101
|
+
const flow = flowToConfirm();
|
|
102
|
+
flow.handleInput('yes');
|
|
103
|
+
flow.markWriteFailed('disk full');
|
|
104
|
+
const r = flow.handleInput('retry');
|
|
105
|
+
expect(r.writeResult).toBe('pending');
|
|
106
|
+
});
|
|
107
|
+
// --- Write error recovery ---
|
|
108
|
+
it('retry from WRITE_ERROR returns writeResult pending', () => {
|
|
109
|
+
const flow = flowToConfirm();
|
|
110
|
+
flow.handleInput('yes');
|
|
111
|
+
flow.markWriteFailed('oops');
|
|
112
|
+
const r = flow.handleInput('yes'); // "yes" also works as retry
|
|
113
|
+
expect(r.writeResult).toBe('pending');
|
|
114
|
+
});
|
|
115
|
+
it('edit field from WRITE_ERROR then confirm', () => {
|
|
116
|
+
const flow = flowToConfirm();
|
|
117
|
+
flow.handleInput('yes');
|
|
118
|
+
flow.markWriteFailed('oops');
|
|
119
|
+
let r = flow.handleInput('1'); // Edit name
|
|
120
|
+
expect(r.reply).toContain('name');
|
|
121
|
+
r = flow.handleInput('FixedName');
|
|
122
|
+
expect(r.reply).toContain('FixedName'); // Confirmation
|
|
123
|
+
r = flow.handleInput('yes');
|
|
124
|
+
expect(r.writeResult).toBe('pending');
|
|
125
|
+
});
|
|
126
|
+
// --- Input validation ---
|
|
127
|
+
it('empty input on required field re-prompts', () => {
|
|
128
|
+
const { flow } = startFlow();
|
|
129
|
+
const r = flow.handleInput('');
|
|
130
|
+
expect(r.reply).toContain('need something');
|
|
131
|
+
});
|
|
132
|
+
it('over-length input is rejected', () => {
|
|
133
|
+
const { flow } = startFlow();
|
|
134
|
+
const r = flow.handleInput('a'.repeat(201));
|
|
135
|
+
expect(r.reply).toContain('200 characters');
|
|
136
|
+
});
|
|
137
|
+
it('placeholder input is rejected', () => {
|
|
138
|
+
const { flow } = startFlow();
|
|
139
|
+
const r = flow.handleInput('My name is {{BOT_NAME}}');
|
|
140
|
+
expect(r.reply).toContain('template placeholder');
|
|
141
|
+
});
|
|
142
|
+
it('invalid confirmation number is rejected', () => {
|
|
143
|
+
const flow = flowToConfirm();
|
|
144
|
+
const r = flow.handleInput('99');
|
|
145
|
+
expect(r.reply).toContain('pick a number');
|
|
146
|
+
});
|
|
147
|
+
// --- Timezone validation ---
|
|
148
|
+
it.each([
|
|
149
|
+
['PST', 'America/Los_Angeles'],
|
|
150
|
+
['PDT', 'America/Los_Angeles'],
|
|
151
|
+
['EST', 'America/New_York'],
|
|
152
|
+
['EDT', 'America/New_York'],
|
|
153
|
+
['CST', 'America/Chicago'],
|
|
154
|
+
['MST', 'America/Denver'],
|
|
155
|
+
['GMT', 'Etc/GMT'],
|
|
156
|
+
['UTC', 'UTC'],
|
|
157
|
+
['CET', 'Europe/Paris'],
|
|
158
|
+
['BST', 'Europe/London'],
|
|
159
|
+
['IST', 'Asia/Kolkata'],
|
|
160
|
+
['JST', 'Asia/Tokyo'],
|
|
161
|
+
])('timezone abbreviation %s maps to %s', (abbr, expected) => {
|
|
162
|
+
const { flow } = startFlow();
|
|
163
|
+
flow.handleInput('David');
|
|
164
|
+
flow.handleInput(abbr);
|
|
165
|
+
const vals = flow.getValues();
|
|
166
|
+
expect(vals.timezone).toBe(expected);
|
|
167
|
+
});
|
|
168
|
+
it('IANA timezone name is accepted as-is', () => {
|
|
169
|
+
const { flow } = startFlow();
|
|
170
|
+
flow.handleInput('David');
|
|
171
|
+
const r = flow.handleInput('Europe/Berlin');
|
|
172
|
+
expect(r.reply).toContain('morning'); // advanced to CHECKIN
|
|
173
|
+
expect(flow.getValues().timezone).toBe('Europe/Berlin');
|
|
174
|
+
});
|
|
175
|
+
it('invalid timezone re-prompts', () => {
|
|
176
|
+
const { flow } = startFlow();
|
|
177
|
+
flow.handleInput('David');
|
|
178
|
+
const r = flow.handleInput('NotATimezone');
|
|
179
|
+
expect(r.done).toBe(false);
|
|
180
|
+
expect(r.reply).toContain('recognize');
|
|
181
|
+
});
|
|
182
|
+
// --- Morning check-in parsing ---
|
|
183
|
+
it.each(['yes', 'y', 'yeah', 'yep', 'sure', 'ok', '1', 'true'])('checkin "%s" is treated as yes', (input) => {
|
|
184
|
+
const { flow } = startFlow();
|
|
185
|
+
flow.handleInput('David');
|
|
186
|
+
flow.handleInput('UTC');
|
|
187
|
+
flow.handleInput(input);
|
|
188
|
+
expect(flow.getValues().morningCheckin).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
it.each(['no', 'n', 'nope', 'nah', '0', 'false'])('checkin "%s" is treated as no', (input) => {
|
|
191
|
+
const { flow } = startFlow();
|
|
192
|
+
flow.handleInput('David');
|
|
193
|
+
flow.handleInput('UTC');
|
|
194
|
+
flow.handleInput(input);
|
|
195
|
+
expect(flow.getValues().morningCheckin).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
it('invalid check-in input re-prompts', () => {
|
|
198
|
+
const { flow } = startFlow();
|
|
199
|
+
flow.handleInput('David');
|
|
200
|
+
flow.handleInput('UTC');
|
|
201
|
+
const r = flow.handleInput('maybe');
|
|
202
|
+
expect(r.done).toBe(false);
|
|
203
|
+
expect(r.reply).toContain('yes or no');
|
|
204
|
+
});
|
|
205
|
+
// --- Getters ---
|
|
206
|
+
it('getValues returns collected values', () => {
|
|
207
|
+
const flow = flowToConfirm();
|
|
208
|
+
const vals = flow.getValues();
|
|
209
|
+
expect(vals.userName).toBe('David');
|
|
210
|
+
expect(vals.timezone).toBe('America/New_York');
|
|
211
|
+
expect(vals.morningCheckin).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
it('getValuesWithDefaults fills missing fields with defaults', () => {
|
|
214
|
+
const flow = new OnboardingFlow();
|
|
215
|
+
flow.start('Dave');
|
|
216
|
+
// No questions answered
|
|
217
|
+
const vals = flow.getValuesWithDefaults('Dave', 'America/Chicago');
|
|
218
|
+
expect(vals.userName).toBe('Dave');
|
|
219
|
+
expect(vals.timezone).toBe('America/Chicago');
|
|
220
|
+
expect(vals.morningCheckin).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
it('getValuesWithDefaults preserves answered fields', () => {
|
|
223
|
+
const { flow } = startFlow();
|
|
224
|
+
flow.handleInput('David'); // userName answered, timezone/checkin not yet
|
|
225
|
+
const vals = flow.getValuesWithDefaults('Dave', 'UTC');
|
|
226
|
+
expect(vals.userName).toBe('David'); // answered value kept
|
|
227
|
+
expect(vals.timezone).toBe('UTC'); // default used
|
|
228
|
+
expect(vals.morningCheckin).toBe(false); // default used
|
|
229
|
+
});
|
|
230
|
+
// --- Public API surface ---
|
|
231
|
+
it('public properties exist', () => {
|
|
232
|
+
const flow = new OnboardingFlow();
|
|
233
|
+
expect(typeof flow.lastActivityTimestamp).toBe('number');
|
|
234
|
+
expect(flow.channelMode).toBe('dm');
|
|
235
|
+
expect(flow.hasRedirected).toBe(false);
|
|
236
|
+
expect(flow.channelId).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding writer — generates workspace files from collected onboarding values.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { isOnboardingComplete } from '../workspace-bootstrap.js';
|
|
7
|
+
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g;
|
|
8
|
+
/**
|
|
9
|
+
* Generate IDENTITY.md content using the default bot name "Discoclaw".
|
|
10
|
+
* No template markers — ensures isOnboardingComplete() returns true.
|
|
11
|
+
*/
|
|
12
|
+
function generateIdentityContent() {
|
|
13
|
+
return [
|
|
14
|
+
`# IDENTITY.md - Who Am I?`,
|
|
15
|
+
``,
|
|
16
|
+
`- **Name:** Discoclaw`,
|
|
17
|
+
`- **Creature:** Familiar — a persistent presence that lives in the tools, remembers the work, and shows up ready.`,
|
|
18
|
+
`- **Vibe:** Direct, competent, dry. Concise when the moment calls for it, thorough when it matters.`,
|
|
19
|
+
`- **Emoji:** None`,
|
|
20
|
+
``,
|
|
21
|
+
`---`,
|
|
22
|
+
``,
|
|
23
|
+
`This isn't just metadata. It's the start of figuring out who you are.`,
|
|
24
|
+
].join('\n') + '\n';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Generate USER.md content from onboarding values.
|
|
28
|
+
*/
|
|
29
|
+
function generateUserContent(values) {
|
|
30
|
+
return [
|
|
31
|
+
`# USER.md - About Your Human`,
|
|
32
|
+
``,
|
|
33
|
+
`- **Name:** ${values.userName}`,
|
|
34
|
+
`- **What to call them:** ${values.userName}`,
|
|
35
|
+
`- **Timezone:** ${values.timezone}`,
|
|
36
|
+
`- **Morning check-in:** ${values.morningCheckin ? 'Yes' : 'No'}`,
|
|
37
|
+
``,
|
|
38
|
+
`---`,
|
|
39
|
+
``,
|
|
40
|
+
`The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference.`,
|
|
41
|
+
].join('\n') + '\n';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Write IDENTITY.md and USER.md from onboarding values.
|
|
45
|
+
* Always writes both files. On retry, overwrites any previously-written file.
|
|
46
|
+
*/
|
|
47
|
+
export async function writeWorkspaceFiles(values, workspaceCwd) {
|
|
48
|
+
const result = { written: [], errors: [], warnings: [] };
|
|
49
|
+
// Generate IDENTITY.md
|
|
50
|
+
const identityContent = generateIdentityContent();
|
|
51
|
+
const identityPath = path.join(workspaceCwd, 'IDENTITY.md');
|
|
52
|
+
try {
|
|
53
|
+
await fs.writeFile(identityPath, identityContent, 'utf-8');
|
|
54
|
+
result.written.push('IDENTITY.md');
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
result.errors.push(`Failed to write IDENTITY.md: ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
// Generate USER.md
|
|
60
|
+
const userContent = generateUserContent(values);
|
|
61
|
+
const userPath = path.join(workspaceCwd, 'USER.md');
|
|
62
|
+
try {
|
|
63
|
+
await fs.writeFile(userPath, userContent, 'utf-8');
|
|
64
|
+
result.written.push('USER.md');
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
result.errors.push(`Failed to write USER.md: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
// Check for unresolved placeholders in written files
|
|
70
|
+
for (const file of result.written) {
|
|
71
|
+
try {
|
|
72
|
+
const content = await fs.readFile(path.join(workspaceCwd, file), 'utf-8');
|
|
73
|
+
const remaining = content.match(PLACEHOLDER_RE);
|
|
74
|
+
if (remaining) {
|
|
75
|
+
result.warnings.push(`${file} has unresolved placeholders: ${remaining.join(', ')}. You can edit them manually later.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// File was written but can't be read back — unusual but not critical.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Post-write validation
|
|
83
|
+
if (result.errors.length === 0) {
|
|
84
|
+
const complete = await isOnboardingComplete(workspaceCwd);
|
|
85
|
+
if (!complete) {
|
|
86
|
+
result.errors.push('Post-write validation failed: isOnboardingComplete() returned false.');
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// Clean up first-run instructions now that onboarding is done.
|
|
90
|
+
const bootstrapPath = path.join(workspaceCwd, 'BOOTSTRAP.md');
|
|
91
|
+
try {
|
|
92
|
+
await fs.unlink(bootstrapPath);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (err.code !== 'ENOENT') {
|
|
96
|
+
throw new Error(`Failed to clean up BOOTSTRAP.md: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { writeWorkspaceFiles } from './onboarding-writer.js';
|
|
6
|
+
import { isOnboardingComplete } from '../workspace-bootstrap.js';
|
|
7
|
+
vi.mock('../workspace-bootstrap.js', async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal();
|
|
9
|
+
return { ...actual, isOnboardingComplete: vi.fn(actual.isOnboardingComplete) };
|
|
10
|
+
});
|
|
11
|
+
describe('writeWorkspaceFiles', () => {
|
|
12
|
+
const dirs = [];
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
for (const d of dirs)
|
|
15
|
+
await fs.rm(d, { recursive: true, force: true });
|
|
16
|
+
dirs.length = 0;
|
|
17
|
+
});
|
|
18
|
+
const baseValues = {
|
|
19
|
+
userName: 'David',
|
|
20
|
+
timezone: 'America/New_York',
|
|
21
|
+
morningCheckin: false,
|
|
22
|
+
};
|
|
23
|
+
it('writes IDENTITY.md and USER.md with correct content', async () => {
|
|
24
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
25
|
+
dirs.push(workspace);
|
|
26
|
+
const result = await writeWorkspaceFiles(baseValues, workspace);
|
|
27
|
+
expect(result.written).toContain('IDENTITY.md');
|
|
28
|
+
expect(result.written).toContain('USER.md');
|
|
29
|
+
expect(result.errors).toHaveLength(0);
|
|
30
|
+
const identity = await fs.readFile(path.join(workspace, 'IDENTITY.md'), 'utf-8');
|
|
31
|
+
expect(identity).toContain('Discoclaw');
|
|
32
|
+
expect(identity).not.toContain('*(pick something you like)*');
|
|
33
|
+
const user = await fs.readFile(path.join(workspace, 'USER.md'), 'utf-8');
|
|
34
|
+
expect(user).toContain('David');
|
|
35
|
+
expect(user).toContain('America/New_York');
|
|
36
|
+
expect(user).toContain('**Morning check-in:** No');
|
|
37
|
+
});
|
|
38
|
+
it('passes isOnboardingComplete after writing', async () => {
|
|
39
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
40
|
+
dirs.push(workspace);
|
|
41
|
+
await writeWorkspaceFiles(baseValues, workspace);
|
|
42
|
+
expect(await isOnboardingComplete(workspace)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it('produces no unresolved placeholders', async () => {
|
|
45
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
46
|
+
dirs.push(workspace);
|
|
47
|
+
const result = await writeWorkspaceFiles(baseValues, workspace);
|
|
48
|
+
expect(result.warnings).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
it('writes morning check-in preference correctly', async () => {
|
|
51
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
52
|
+
dirs.push(workspace);
|
|
53
|
+
const values = {
|
|
54
|
+
userName: 'Dave',
|
|
55
|
+
timezone: 'Europe/London',
|
|
56
|
+
morningCheckin: true,
|
|
57
|
+
};
|
|
58
|
+
const result = await writeWorkspaceFiles(values, workspace);
|
|
59
|
+
expect(result.errors).toHaveLength(0);
|
|
60
|
+
const user = await fs.readFile(path.join(workspace, 'USER.md'), 'utf-8');
|
|
61
|
+
expect(user).toContain('Europe/London');
|
|
62
|
+
expect(user).toContain('**Morning check-in:** Yes');
|
|
63
|
+
});
|
|
64
|
+
it('deletes BOOTSTRAP.md on successful onboarding', async () => {
|
|
65
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
66
|
+
dirs.push(workspace);
|
|
67
|
+
// Simulate a pre-existing BOOTSTRAP.md from scaffolding
|
|
68
|
+
await fs.writeFile(path.join(workspace, 'BOOTSTRAP.md'), '# First run\n', 'utf-8');
|
|
69
|
+
const result = await writeWorkspaceFiles(baseValues, workspace);
|
|
70
|
+
expect(result.errors).toHaveLength(0);
|
|
71
|
+
// BOOTSTRAP.md should be gone
|
|
72
|
+
await expect(fs.access(path.join(workspace, 'BOOTSTRAP.md'))).rejects.toThrow();
|
|
73
|
+
});
|
|
74
|
+
it('succeeds when BOOTSTRAP.md does not exist', async () => {
|
|
75
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
76
|
+
dirs.push(workspace);
|
|
77
|
+
// No BOOTSTRAP.md present — ENOENT should be silently swallowed
|
|
78
|
+
const result = await writeWorkspaceFiles(baseValues, workspace);
|
|
79
|
+
expect(result.errors).toHaveLength(0);
|
|
80
|
+
expect(result.written).toContain('IDENTITY.md');
|
|
81
|
+
expect(result.written).toContain('USER.md');
|
|
82
|
+
});
|
|
83
|
+
it('overwrites existing files on retry', async () => {
|
|
84
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
85
|
+
dirs.push(workspace);
|
|
86
|
+
// First write
|
|
87
|
+
await writeWorkspaceFiles(baseValues, workspace);
|
|
88
|
+
// Second write with different values
|
|
89
|
+
const newValues = {
|
|
90
|
+
userName: 'NewUser',
|
|
91
|
+
timezone: 'Asia/Tokyo',
|
|
92
|
+
morningCheckin: true,
|
|
93
|
+
};
|
|
94
|
+
const result = await writeWorkspaceFiles(newValues, workspace);
|
|
95
|
+
expect(result.errors).toHaveLength(0);
|
|
96
|
+
const user = await fs.readFile(path.join(workspace, 'USER.md'), 'utf-8');
|
|
97
|
+
expect(user).toContain('NewUser');
|
|
98
|
+
expect(user).toContain('Asia/Tokyo');
|
|
99
|
+
expect(user).not.toContain('America/New_York');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('writeWorkspaceFiles — BOOTSTRAP.md cleanup', () => {
|
|
103
|
+
const dirs = [];
|
|
104
|
+
let isOnboardingCompleteSpy;
|
|
105
|
+
afterEach(async () => {
|
|
106
|
+
vi.restoreAllMocks();
|
|
107
|
+
for (const d of dirs)
|
|
108
|
+
await fs.rm(d, { recursive: true, force: true });
|
|
109
|
+
dirs.length = 0;
|
|
110
|
+
});
|
|
111
|
+
const baseValues = {
|
|
112
|
+
userName: 'David',
|
|
113
|
+
timezone: 'America/New_York',
|
|
114
|
+
morningCheckin: false,
|
|
115
|
+
};
|
|
116
|
+
it('preserves BOOTSTRAP.md when post-write validation fails', async () => {
|
|
117
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
118
|
+
dirs.push(workspace);
|
|
119
|
+
// Place a BOOTSTRAP.md so we can verify it survives
|
|
120
|
+
const bootstrapPath = path.join(workspace, 'BOOTSTRAP.md');
|
|
121
|
+
await fs.writeFile(bootstrapPath, '# First run\n', 'utf-8');
|
|
122
|
+
// Force isOnboardingComplete to return false after files are written
|
|
123
|
+
const { isOnboardingComplete } = await import('../workspace-bootstrap.js');
|
|
124
|
+
isOnboardingCompleteSpy = vi.mocked(isOnboardingComplete).mockResolvedValue(false);
|
|
125
|
+
const result = await writeWorkspaceFiles(baseValues, workspace);
|
|
126
|
+
// Validation failed → errors array is non-empty
|
|
127
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
128
|
+
// BOOTSTRAP.md should still exist — it must not be deleted on failure
|
|
129
|
+
await expect(fs.access(bootstrapPath)).resolves.toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
it('re-throws non-ENOENT unlink errors', async () => {
|
|
132
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-writer-'));
|
|
133
|
+
dirs.push(workspace);
|
|
134
|
+
// Ensure isOnboardingComplete returns true so the cleanup branch runs
|
|
135
|
+
const { isOnboardingComplete } = await import('../workspace-bootstrap.js');
|
|
136
|
+
isOnboardingCompleteSpy = vi.mocked(isOnboardingComplete).mockResolvedValue(true);
|
|
137
|
+
// Make fs.unlink throw EPERM
|
|
138
|
+
const eperm = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
|
|
139
|
+
const unlinkSpy = vi.spyOn(fs, 'unlink').mockRejectedValue(eperm);
|
|
140
|
+
await expect(writeWorkspaceFiles(baseValues, workspace)).rejects.toThrow('Failed to clean up BOOTSTRAP.md');
|
|
141
|
+
unlinkSpy.mockRestore();
|
|
142
|
+
});
|
|
143
|
+
});
|
package/dist/pidlock.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
/** Lock dirs younger than this with missing/corrupt metadata are treated as initializing. */
|
|
5
|
+
const GRACE_PERIOD_MS = 2000;
|
|
6
|
+
const heldLocks = new Map();
|
|
7
|
+
function lockDirPath(lockPath) {
|
|
8
|
+
return `${lockPath}.lock`;
|
|
9
|
+
}
|
|
10
|
+
function generateToken() {
|
|
11
|
+
return crypto.randomBytes(16).toString('hex');
|
|
12
|
+
}
|
|
13
|
+
function isPidAlive(pid) {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
const code = err.code;
|
|
20
|
+
if (code === 'EPERM')
|
|
21
|
+
return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Read Linux process start time from /proc/{pid}/stat (field 22).
|
|
27
|
+
* Returns null on non-Linux or any read/parse failure.
|
|
28
|
+
*/
|
|
29
|
+
async function getProcessStartTime(pid) {
|
|
30
|
+
try {
|
|
31
|
+
const stat = await fs.readFile(`/proc/${pid}/stat`, 'utf-8');
|
|
32
|
+
const closeParenIdx = stat.lastIndexOf(')');
|
|
33
|
+
if (closeParenIdx === -1)
|
|
34
|
+
return null;
|
|
35
|
+
const fields = stat.slice(closeParenIdx + 2).split(' ');
|
|
36
|
+
const startTime = Number(fields[19]);
|
|
37
|
+
return Number.isFinite(startTime) ? startTime : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function readMeta(dirPath) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = await fs.readFile(path.join(dirPath, 'meta.json'), 'utf-8');
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
if (typeof parsed?.pid !== 'number' || typeof parsed?.token !== 'string')
|
|
48
|
+
return null;
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function writeMeta(dirPath, meta) {
|
|
56
|
+
const metaPath = path.join(dirPath, 'meta.json');
|
|
57
|
+
const tmpPath = `${metaPath}.tmp.${process.pid}`;
|
|
58
|
+
await fs.writeFile(tmpPath, JSON.stringify(meta) + '\n', 'utf-8');
|
|
59
|
+
await fs.rename(tmpPath, metaPath);
|
|
60
|
+
}
|
|
61
|
+
async function handleLegacyLockFile(lockPath) {
|
|
62
|
+
try {
|
|
63
|
+
const stat = await fs.stat(lockPath);
|
|
64
|
+
if (!stat.isFile())
|
|
65
|
+
return;
|
|
66
|
+
let pid = NaN;
|
|
67
|
+
try {
|
|
68
|
+
const content = await fs.readFile(lockPath, 'utf-8');
|
|
69
|
+
pid = Number(content.trim());
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Treat unreadable legacy files as stale.
|
|
73
|
+
}
|
|
74
|
+
if (Number.isFinite(pid) && pid > 0 && isPidAlive(pid)) {
|
|
75
|
+
throw new Error(`Another discoclaw instance is already running (PID ${pid}). ` +
|
|
76
|
+
`Legacy lock file: ${lockPath}`);
|
|
77
|
+
}
|
|
78
|
+
await fs.rm(lockPath, { force: true });
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (err.code === 'ENOENT')
|
|
82
|
+
return;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Acquire a PID lock directory. Throws if another live process holds the lock.
|
|
88
|
+
*/
|
|
89
|
+
export async function acquirePidLock(lockPath) {
|
|
90
|
+
await handleLegacyLockFile(lockPath);
|
|
91
|
+
const dirPath = lockDirPath(lockPath);
|
|
92
|
+
const token = generateToken();
|
|
93
|
+
const startTime = await getProcessStartTime(process.pid);
|
|
94
|
+
const meta = {
|
|
95
|
+
pid: process.pid,
|
|
96
|
+
token,
|
|
97
|
+
acquiredAt: new Date().toISOString(),
|
|
98
|
+
...(startTime != null ? { startTime } : {}),
|
|
99
|
+
};
|
|
100
|
+
// Attempt 1: atomic directory create.
|
|
101
|
+
try {
|
|
102
|
+
await fs.mkdir(dirPath);
|
|
103
|
+
await writeMeta(dirPath, meta);
|
|
104
|
+
heldLocks.set(dirPath, token);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (err.code !== 'EEXIST')
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
// Existing lock — determine if stale or held.
|
|
112
|
+
const existingMeta = await readMeta(dirPath);
|
|
113
|
+
if (!existingMeta) {
|
|
114
|
+
let dirAge = Infinity;
|
|
115
|
+
try {
|
|
116
|
+
const stat = await fs.stat(dirPath);
|
|
117
|
+
dirAge = Date.now() - stat.mtimeMs;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Can't stat: treat as stale.
|
|
121
|
+
}
|
|
122
|
+
if (dirAge < GRACE_PERIOD_MS) {
|
|
123
|
+
throw new Error(`PID lock initializing (dir age: ${Math.round(dirAge)}ms). Lock dir: ${dirPath}`);
|
|
124
|
+
}
|
|
125
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const alive = isPidAlive(existingMeta.pid);
|
|
129
|
+
if (alive) {
|
|
130
|
+
const existingStartTime = await getProcessStartTime(existingMeta.pid);
|
|
131
|
+
const metaHasStartTime = existingMeta.startTime != null;
|
|
132
|
+
const procHasStartTime = existingStartTime != null;
|
|
133
|
+
if (metaHasStartTime && procHasStartTime && existingMeta.startTime !== existingStartTime) {
|
|
134
|
+
// PID reuse — stale lock.
|
|
135
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
throw new Error(`Another discoclaw instance is already running (PID ${existingMeta.pid}). ` +
|
|
139
|
+
`Lock dir: ${dirPath}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Dead PID — stale lock.
|
|
144
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Attempt 2: retry after stale cleanup.
|
|
148
|
+
try {
|
|
149
|
+
await fs.mkdir(dirPath);
|
|
150
|
+
await writeMeta(dirPath, meta);
|
|
151
|
+
heldLocks.set(dirPath, token);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (err.code === 'EEXIST') {
|
|
155
|
+
throw new Error(`PID lock contention (lost race). Lock dir: ${dirPath}`);
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Release the PID lock directory, but only if it's owned by this process token.
|
|
162
|
+
*/
|
|
163
|
+
export async function releasePidLock(lockPath) {
|
|
164
|
+
const dirPath = lockDirPath(lockPath);
|
|
165
|
+
const heldToken = heldLocks.get(dirPath);
|
|
166
|
+
try {
|
|
167
|
+
const meta = await readMeta(dirPath);
|
|
168
|
+
if (!meta)
|
|
169
|
+
return;
|
|
170
|
+
if (heldToken) {
|
|
171
|
+
if (meta.token === heldToken) {
|
|
172
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Fallback path: best-effort cleanup for older call sites.
|
|
177
|
+
if (meta.pid === process.pid) {
|
|
178
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Lock dir already gone or unreadable — nothing to do.
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
heldLocks.delete(dirPath);
|
|
186
|
+
}
|
|
187
|
+
}
|