@workerstack/repo 0.0.0-stage → 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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +0 -3
- package/agents/global-settings/antigravity.json +3 -0
- package/agents/global-settings/claude.json +6 -0
- package/agents/global-settings/codex.toml +2 -0
- package/agents/global-settings/cursor.json +3 -0
- package/agents/global-settings/grok.toml +36 -0
- package/agents/global-settings/manifest.json +36 -0
- package/agents/grok/hooks/orchestrate-parent.json +60 -0
- package/agents/grok/hooks/rtk.json +16 -0
- package/agents/grok/hooks/rulesync.json +3 -0
- package/agents/grok/roles/scout.toml +5 -0
- package/agents/hooks.jsonc +67 -0
- package/agents/mcp-policy.jsonc +16 -0
- package/agents/rules/overview.md +21 -0
- package/agents/settings/claude.json +5 -0
- package/agents/skills/ask/SKILL.md +13 -0
- package/agents/skills/auto/SKILL.md +13 -0
- package/agents/skills/away/SKILL.md +19 -0
- package/agents/skills/blast-radius/SKILL.md +54 -0
- package/agents/skills/bro/SKILL.md +9 -0
- package/agents/skills/browser/SKILL.md +16 -0
- package/agents/skills/clasp/SKILL.md +11 -0
- package/agents/skills/commit/SKILL.md +35 -0
- package/agents/skills/commit-and-push/SKILL.md +9 -0
- package/agents/skills/commit-push/SKILL.md +59 -0
- package/agents/skills/commit-push-deploy/SKILL.md +74 -0
- package/agents/skills/commit-push-release/SKILL.md +75 -0
- package/agents/skills/correct/SKILL.md +39 -0
- package/agents/skills/do/SKILL.md +21 -0
- package/agents/skills/explain/SKILL.md +13 -0
- package/agents/skills/google-workspace/SKILL.md +12 -0
- package/agents/skills/handoff/SKILL.md +81 -0
- package/agents/skills/implement-plan/SKILL.md +29 -0
- package/agents/skills/implement-roadmap/SKILL.md +27 -0
- package/agents/skills/jev/SKILL.md +17 -0
- package/agents/skills/listen/SKILL.md +15 -0
- package/agents/skills/pickup/SKILL.md +58 -0
- package/agents/skills/principle-attack-the-premise/SKILL.md +24 -0
- package/agents/skills/principle-boundary-discipline/SKILL.md +38 -0
- package/agents/skills/principle-build-the-lever/SKILL.md +28 -0
- package/agents/skills/principle-encode-lessons-in-structure/SKILL.md +15 -0
- package/agents/skills/principle-exhaust-the-design-space/SKILL.md +12 -0
- package/agents/skills/principle-experience-first/SKILL.md +22 -0
- package/agents/skills/principle-explain-the-number/SKILL.md +21 -0
- package/agents/skills/principle-fix-root-causes/SKILL.md +24 -0
- package/agents/skills/principle-foundational-thinking/SKILL.md +25 -0
- package/agents/skills/principle-guard-the-context-window/SKILL.md +16 -0
- package/agents/skills/principle-laziness-protocol/SKILL.md +13 -0
- package/agents/skills/principle-make-operations-idempotent/SKILL.md +25 -0
- package/agents/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +18 -0
- package/agents/skills/principle-minimize-reader-load/SKILL.md +22 -0
- package/agents/skills/principle-model-the-domain/SKILL.md +26 -0
- package/agents/skills/principle-never-block-on-the-human/SKILL.md +17 -0
- package/agents/skills/principle-outcome-oriented-execution/SKILL.md +25 -0
- package/agents/skills/principle-prove-it-works/SKILL.md +25 -0
- package/agents/skills/principle-redesign-from-first-principles/SKILL.md +18 -0
- package/agents/skills/principle-separate-before-serializing-shared-state/SKILL.md +20 -0
- package/agents/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
- package/agents/skills/principle-subtract-before-you-add/SKILL.md +21 -0
- package/agents/skills/principle-test-behavior-not-implementation/SKILL.md +24 -0
- package/agents/skills/principle-type-system-discipline/SKILL.md +17 -0
- package/agents/skills/review-plan/SKILL.md +28 -0
- package/agents/skills/review-roadmap/SKILL.md +28 -0
- package/agents/skills/show-me/SKILL.md +21 -0
- package/agents/skills/speak/SKILL.md +15 -0
- package/agents/skills/su/SKILL.md +11 -0
- package/agents/skills/teach/SKILL.md +25 -0
- package/agents/skills/tmp/SKILL.md +54 -0
- package/agents/skills/typescript-best-practices/SKILL.md +32 -0
- package/agents/skills/typescript-best-practices/references/patterns.md +328 -0
- package/agents/skills/unslop/SKILL.md +62 -0
- package/agents/skills/write-plan/SKILL.md +33 -0
- package/agents/skills/write-roadmap/SKILL.md +66 -0
- package/agents/subagents/implementer-deep.md +57 -0
- package/agents/subagents/implementer-fast.md +57 -0
- package/agents/subagents/orchestrator.md +43 -0
- package/agents/subagents/researcher.md +47 -0
- package/agents/subagents/scout.md +44 -0
- package/bin/repo.ts +4 -0
- package/cli/actionlint-default.test.ts +38 -0
- package/cli/actionlint-download.test.ts +162 -0
- package/cli/actionlint-real.test.ts +33 -0
- package/cli/actionlint.constants.ts +43 -0
- package/cli/actionlint.test.ts +108 -0
- package/cli/actionlint.ts +178 -0
- package/cli/bin.test.ts +54 -0
- package/cli/bin.ts +40 -0
- package/cli/check-changelog.test.ts +40 -0
- package/cli/check-changelog.ts +12 -0
- package/cli/check-comments.test.ts +157 -0
- package/cli/check-comments.ts +123 -0
- package/cli/check-env-modules.test.ts +85 -0
- package/cli/check-env-modules.ts +56 -0
- package/cli/check-magic.test.ts +139 -0
- package/cli/check-magic.ts +120 -0
- package/cli/check-manifests.constants.ts +4 -0
- package/cli/check-manifests.test.ts +210 -0
- package/cli/check-manifests.ts +71 -0
- package/cli/check-nightly.test.ts +103 -0
- package/cli/check-nightly.ts +37 -0
- package/cli/check-ratchets-missing.test.ts +46 -0
- package/cli/check-ratchets-mutation.test.ts +49 -0
- package/cli/check-ratchets.constants.ts +20 -0
- package/cli/check-ratchets.test.ts +190 -0
- package/cli/check-ratchets.ts +193 -0
- package/cli/check-rejections.test.ts +220 -0
- package/cli/check-rejections.ts +153 -0
- package/cli/check-reports.ts +37 -0
- package/cli/check-tsconfig.test.ts +507 -0
- package/cli/check-tsconfig.ts +205 -0
- package/cli/commands.test.ts +114 -0
- package/cli/commands.ts +218 -0
- package/cli/commit-changelog.ts +27 -0
- package/cli/commit-msg-changelog.test.ts +93 -0
- package/cli/commit-msg-range.test.ts +126 -0
- package/cli/commit-msg-red-proof.test.ts +147 -0
- package/cli/commit-msg-repo.test.ts +153 -0
- package/cli/commit-msg.test.ts +101 -0
- package/cli/commit-msg.ts +194 -0
- package/cli/commit-range.constants.ts +17 -0
- package/cli/commit-range.ts +57 -0
- package/cli/config-knip.test.ts +70 -0
- package/cli/config-nightly.test.ts +84 -0
- package/cli/config-tdd.test.ts +46 -0
- package/cli/config.test.ts +576 -0
- package/cli/config.ts +261 -0
- package/cli/conventional.ts +9 -0
- package/cli/coverage-config.ts +62 -0
- package/cli/coverage.constants.ts +6 -0
- package/cli/coverage.test.ts +199 -0
- package/cli/coverage.ts +127 -0
- package/cli/env.constants.ts +10 -0
- package/cli/env.test.ts +56 -0
- package/cli/env.ts +72 -0
- package/cli/fixtures/base-config.ts +13 -0
- package/cli/fixtures/gate-world.ts +48 -0
- package/cli/fixtures/knip-run.ts +38 -0
- package/cli/fixtures/mutate-fakes.ts +137 -0
- package/cli/fixtures/planted-repo.ts +50 -0
- package/cli/fixtures/publish-world.ts +71 -0
- package/cli/fixtures/red-proof-repo.ts +72 -0
- package/cli/fixtures/release-context.ts +15 -0
- package/cli/fixtures/release-world.ts +111 -0
- package/cli/fixtures/stub-bin.ts +40 -0
- package/cli/guard-diagnostics.test.ts +157 -0
- package/cli/guard-diagnostics.ts +195 -0
- package/cli/init.test.ts +487 -0
- package/cli/init.ts +275 -0
- package/cli/knip-check-ignores.test.ts +106 -0
- package/cli/knip-check-real.test.ts +65 -0
- package/cli/knip-check.constants.ts +46 -0
- package/cli/knip-check.test.ts +114 -0
- package/cli/knip-check.ts +169 -0
- package/cli/knip-schema.ts +67 -0
- package/cli/known-failures.constants.ts +22 -0
- package/cli/known-failures.test.ts +138 -0
- package/cli/known-failures.ts +131 -0
- package/cli/lint/as-needs-reason.ts +41 -0
- package/cli/lint/boundaries.ts +73 -0
- package/cli/lint/class-file.ts +39 -0
- package/cli/lint/config-file.ts +32 -0
- package/cli/lint/entry-file.ts +54 -0
- package/cli/lint/env-in-config.ts +66 -0
- package/cli/lint/filename-matches-export.ts +63 -0
- package/cli/lint/index-file.ts +22 -0
- package/cli/lint/index.ts +47 -0
- package/cli/lint/is-reason.ts +6 -0
- package/cli/lint/manifest.ts +46 -0
- package/cli/lint/names.ts +59 -0
- package/cli/lint/no-commented-out-code.ts +105 -0
- package/cli/lint/no-mock-of-subject.ts +64 -0
- package/cli/lint/no-module-side-effects.ts +94 -0
- package/cli/lint/no-module-state.ts +58 -0
- package/cli/lint/no-side-effect-import.ts +17 -0
- package/cli/lint/no-v8-ignore.ts +18 -0
- package/cli/lint/script-rules.ts +9 -0
- package/cli/lint/skip-on-platform.constants.ts +8 -0
- package/cli/lint/skip-on-platform.ts +118 -0
- package/cli/lint/todo-needs-id.ts +63 -0
- package/cli/lint/types-file.ts +28 -0
- package/cli/lint-schema.ts +32 -0
- package/cli/magic/fixtures/magic-repo.ts +64 -0
- package/cli/magic/magic-hit.ts +2 -0
- package/cli/magic/magic-nodes.ts +44 -0
- package/cli/magic/magic-numbers.test.ts +99 -0
- package/cli/magic/magic-numbers.ts +117 -0
- package/cli/magic/magic-strings.test.ts +38 -0
- package/cli/magic/magic-strings.ts +81 -0
- package/cli/main.test.ts +853 -0
- package/cli/main.ts +292 -0
- package/cli/mutate-args.ts +79 -0
- package/cli/mutate-check.test.ts +147 -0
- package/cli/mutate-deps.ts +42 -0
- package/cli/mutate-edges.test.ts +150 -0
- package/cli/mutate-files.test.ts +100 -0
- package/cli/mutate-files.ts +31 -0
- package/cli/mutate-floors.test.ts +91 -0
- package/cli/mutate-floors.ts +104 -0
- package/cli/mutate-real-deps.ts +57 -0
- package/cli/mutate-real.test.ts +96 -0
- package/cli/mutate-report.ts +96 -0
- package/cli/mutate-run.ts +128 -0
- package/cli/mutate-shards.ts +64 -0
- package/cli/mutate.constants.ts +67 -0
- package/cli/mutate.test.ts +182 -0
- package/cli/mutate.ts +64 -0
- package/cli/nested-vitest-flags.ts +8 -0
- package/cli/nightly-config.constants.ts +14 -0
- package/cli/nightly-config.ts +62 -0
- package/cli/prompt.test.ts +202 -0
- package/cli/prompt.ts +1 -0
- package/cli/red-proof-edges.test.ts +78 -0
- package/cli/red-proof-failures.test.ts +82 -0
- package/cli/red-proof-tree.test.ts +157 -0
- package/cli/red-proof-verdict.test.ts +44 -0
- package/cli/red-proof.constants.ts +14 -0
- package/cli/red-proof.ts +202 -0
- package/cli/release/changelog-parse.ts +83 -0
- package/cli/release/changelog.test.ts +141 -0
- package/cli/release/changelog.ts +106 -0
- package/cli/release/gate-refusals.test.ts +85 -0
- package/cli/release/gate.test.ts +63 -0
- package/cli/release/gate.ts +70 -0
- package/cli/release/git.ts +66 -0
- package/cli/release/github.test.ts +128 -0
- package/cli/release/github.ts +46 -0
- package/cli/release/list-dir.test.ts +11 -0
- package/cli/release/list-dir.ts +6 -0
- package/cli/release/main.test.ts +48 -0
- package/cli/release/main.ts +51 -0
- package/cli/release/manifest.test.ts +24 -0
- package/cli/release/manifest.ts +34 -0
- package/cli/release/pack.test.ts +107 -0
- package/cli/release/pack.ts +76 -0
- package/cli/release/plan.ts +127 -0
- package/cli/release/publish-run.test.ts +98 -0
- package/cli/release/publish.test.ts +127 -0
- package/cli/release/publish.ts +125 -0
- package/cli/release/refuse.ts +5 -0
- package/cli/release/release-messages.constants.ts +96 -0
- package/cli/release/release-refusals.test.ts +149 -0
- package/cli/release/release.constants.ts +128 -0
- package/cli/release/release.test.ts +199 -0
- package/cli/release/release.ts +145 -0
- package/cli/release/version.test.ts +118 -0
- package/cli/release/version.ts +69 -0
- package/cli/release/wait.test.ts +101 -0
- package/cli/release/wait.ts +95 -0
- package/cli/script-diagnostics.test.ts +76 -0
- package/cli/script-diagnostics.ts +62 -0
- package/cli/scripts/board.ts +519 -0
- package/cli/scripts/browser.ts +994 -0
- package/cli/scripts/change-set.ts +105 -0
- package/cli/scripts/check-globals.ts +199 -0
- package/cli/scripts/ci-skips.constants.ts +17 -0
- package/cli/scripts/ci-skips.ts +118 -0
- package/cli/scripts/ci-wait-run.ts +28 -0
- package/cli/scripts/ci-wait.constants.ts +44 -0
- package/cli/scripts/ci-wait.ts +175 -0
- package/cli/scripts/ci.constants.ts +15 -0
- package/cli/scripts/ci.ts +208 -0
- package/cli/scripts/clean.ts +363 -0
- package/cli/scripts/comments-step.ts +15 -0
- package/cli/scripts/doctor.ts +828 -0
- package/cli/scripts/emit-agent-shell-tools.ts +91 -0
- package/cli/scripts/emit-mcp-policy.ts +402 -0
- package/cli/scripts/folder-map.ts +353 -0
- package/cli/scripts/gate-fat-tools.ts +1095 -0
- package/cli/scripts/gitignore-guard.ts +338 -0
- package/cli/scripts/google-workspace.ts +779 -0
- package/cli/scripts/hook-command.ts +72 -0
- package/cli/scripts/hook-input.ts +178 -0
- package/cli/scripts/hook-io.constants.ts +5 -0
- package/cli/scripts/hook-io.ts +46 -0
- package/cli/scripts/hooks-union.ts +469 -0
- package/cli/scripts/interactive.ts +30 -0
- package/cli/scripts/jev.ts +420 -0
- package/cli/scripts/job.ts +934 -0
- package/cli/scripts/jsonc.ts +9 -0
- package/cli/scripts/lessons.ts +564 -0
- package/cli/scripts/lint-baseline.ts +233 -0
- package/cli/scripts/lint-main.ts +24 -0
- package/cli/scripts/lint-skips.ts +139 -0
- package/cli/scripts/lint.constants.ts +6 -0
- package/cli/scripts/lint.ts +142 -0
- package/cli/scripts/listen.ts +962 -0
- package/cli/scripts/managed-text.ts +47 -0
- package/cli/scripts/orchestrate-parent.ts +1155 -0
- package/cli/scripts/parse-need.ts +81 -0
- package/cli/scripts/persist-handoff.ts +228 -0
- package/cli/scripts/persist-implementation.ts +276 -0
- package/cli/scripts/persist-plan.ts +888 -0
- package/cli/scripts/persist-roadmap.ts +478 -0
- package/cli/scripts/persist-tmp.ts +177 -0
- package/cli/scripts/persist.ts +54 -0
- package/cli/scripts/phase-files.ts +121 -0
- package/cli/scripts/prompt.ts +38 -0
- package/cli/scripts/purge-global-mcps.ts +313 -0
- package/cli/scripts/rejections-step.ts +37 -0
- package/cli/scripts/rtk-grok.ts +102 -0
- package/cli/scripts/scrub-spawn-prompt.ts +103 -0
- package/cli/scripts/scrub-text-lib.ts +58 -0
- package/cli/scripts/session-map.ts +69 -0
- package/cli/scripts/setup.ts +862 -0
- package/cli/scripts/skill-conflicts.ts +82 -0
- package/cli/scripts/speak.ts +370 -0
- package/cli/scripts/strategy.ts +168 -0
- package/cli/scripts/sync-global-settings.ts +316 -0
- package/cli/scripts/turn-steps.ts +249 -0
- package/cli/scripts/turn.ts +157 -0
- package/cli/scripts/typecheck-step.ts +43 -0
- package/cli/scripts/vscode-tasks.ts +175 -0
- package/cli/scripts/workspace-root.ts +160 -0
- package/cli/scripts/worktree.ts +806 -0
- package/cli/spawn.test.ts +34 -0
- package/cli/spawn.ts +27 -0
- package/cli/staged-change.constants.ts +11 -0
- package/cli/staged-change.ts +79 -0
- package/cli/stryker-preload.test.ts +50 -0
- package/cli/stryker-preload.ts +9 -0
- package/cli/sync/agents/frontmatter.test.ts +197 -0
- package/cli/sync/agents/frontmatter.ts +140 -0
- package/cli/sync/agents/hooks.test.ts +517 -0
- package/cli/sync/agents/hooks.ts +401 -0
- package/cli/sync/agents/rules.test.ts +229 -0
- package/cli/sync/agents/rules.ts +84 -0
- package/cli/sync/agents/sources.test.ts +375 -0
- package/cli/sync/agents/sources.ts +244 -0
- package/cli/sync/agents/subagents.test.ts +149 -0
- package/cli/sync/agents/subagents.ts +158 -0
- package/cli/sync/agents-text.test.ts +31 -0
- package/cli/sync/agents-text.ts +36 -0
- package/cli/sync/fixtures/copied.README.md +35 -0
- package/cli/sync/fixtures/copied.tgz +0 -0
- package/cli/sync/fixtures/maps/golden/INDEX.md +22 -0
- package/cli/sync/fixtures/maps-ws.tgz +0 -0
- package/cli/sync/git-files.test.ts +340 -0
- package/cli/sync/git-files.ts +197 -0
- package/cli/sync/git-hooks.test.ts +94 -0
- package/cli/sync/golden.test.ts +15 -0
- package/cli/sync/hooks-mode.test.ts +99 -0
- package/cli/sync/hosts.ts +28 -0
- package/cli/sync/knip-config.constants.ts +4 -0
- package/cli/sync/knip-config.test.ts +147 -0
- package/cli/sync/knip-config.ts +108 -0
- package/cli/sync/manifest.test.ts +78 -0
- package/cli/sync/manifest.ts +60 -0
- package/cli/sync/maps.test.ts +304 -0
- package/cli/sync/maps.ts +297 -0
- package/cli/sync/migrate.test.ts +2000 -0
- package/cli/sync/migrate.ts +1104 -0
- package/cli/sync/nightly-jobs.test.ts +63 -0
- package/cli/sync/nightly.constants.ts +48 -0
- package/cli/sync/nightly.test.ts +100 -0
- package/cli/sync/nightly.ts +75 -0
- package/cli/sync/outputs.test.ts +351 -0
- package/cli/sync/outputs.ts +168 -0
- package/cli/sync/oxlint-config.test.ts +178 -0
- package/cli/sync/oxlint-config.ts +168 -0
- package/cli/sync/release-jobs.test.ts +29 -0
- package/cli/sync/release-reports.test.ts +31 -0
- package/cli/sync/release-workflow.constants.ts +61 -0
- package/cli/sync/release-workflow.test.ts +192 -0
- package/cli/sync/release-workflow.ts +86 -0
- package/cli/sync/release.constants.ts +93 -0
- package/cli/sync/release.test.ts +187 -0
- package/cli/sync/release.ts +112 -0
- package/cli/sync/repo-dir.test.ts +371 -0
- package/cli/sync/repo-dir.ts +205 -0
- package/cli/sync/standards.constants.ts +8 -0
- package/cli/sync/standards.test.ts +206 -0
- package/cli/sync/standards.ts +179 -0
- package/cli/sync/stock-history.json +2796 -0
- package/cli/sync/stock-history.test.ts +66 -0
- package/cli/sync/stock-history.ts +84 -0
- package/cli/sync/stock.test.ts +47 -0
- package/cli/sync/stock.ts +24 -0
- package/cli/sync/trees.test.ts +621 -0
- package/cli/sync/trees.ts +280 -0
- package/cli/sync/validate.test.ts +299 -0
- package/cli/sync/validate.ts +402 -0
- package/cli/sync/workspace.test.ts +180 -0
- package/cli/sync/workspace.ts +138 -0
- package/cli/sync/write.test.ts +341 -0
- package/cli/sync/write.ts +231 -0
- package/cli/sync.test.ts +1495 -0
- package/cli/sync.ts +360 -0
- package/cli/tdd-scope.test.ts +36 -0
- package/cli/tdd-scope.ts +41 -0
- package/cli/test-files.ts +16 -0
- package/cli/ts-program.ts +69 -0
- package/cli/tsconfig.constants.ts +8 -0
- package/cli/workerstack-config.types.ts +21 -0
- package/dist/cli/env.d.ts +10 -0
- package/dist/cli/env.js +64 -0
- package/dist/presets/temp-root-env.d.ts +10 -0
- package/dist/presets/temp-root-env.js +20 -0
- package/dist/presets/temp-root.constants.d.ts +6 -0
- package/dist/presets/temp-root.constants.js +6 -0
- package/dist/presets/temp-root.d.ts +2 -0
- package/dist/presets/temp-root.js +60 -0
- package/dist/presets/vitest.constants.d.ts +16 -0
- package/dist/presets/vitest.constants.js +16 -0
- package/dist/presets/vitest.d.ts +11 -0
- package/dist/presets/vitest.js +67 -0
- package/dist/presets/workers-plugin.d.ts +10 -0
- package/dist/presets/workers-plugin.js +54 -0
- package/package.json +141 -4
- package/references/INDEX.md +100 -0
- package/references/browser.md +66 -0
- package/references/cascade.md +15 -0
- package/references/clasp.md +21 -0
- package/references/cloudflare.md +36 -0
- package/references/google-workspace.md +79 -0
- package/references/hooks.md +103 -0
- package/references/implementation-format.md +119 -0
- package/references/jev.md +56 -0
- package/references/lessons.md +73 -0
- package/references/listen.md +80 -0
- package/references/map.md +107 -0
- package/references/output.md +47 -0
- package/references/plan-format.md +264 -0
- package/references/review-loop.md +48 -0
- package/references/roadmap-format.md +104 -0
- package/references/rtk.md +27 -0
- package/references/scope.md +76 -0
- package/references/setup.md +42 -0
- package/references/speak.md +59 -0
- package/tsconfig/base.json +28 -0
- package/tsconfig/bun.d.ts +1 -0
- package/tsconfig/bun.json +8 -0
- package/tsconfig/reset.d.ts +1 -0
- package/tsconfig/strict.json +12 -0
- package/tsconfig/web.json +7 -0
- package/tsconfig/worker.d.ts +1 -0
- package/tsconfig/worker.json +8 -0
|
@@ -0,0 +1,2000 @@
|
|
|
1
|
+
import { DEFAULT_COVERAGE } from "../coverage-config.ts";
|
|
2
|
+
import { DEFAULT_NIGHTLY } from "../nightly-config.ts";
|
|
3
|
+
import { BASE_CONFIG } from "../fixtures/base-config.ts";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import * as tar from "tar";
|
|
10
|
+
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
|
11
|
+
import { BUN } from "../../dev/test-paths.ts";
|
|
12
|
+
import { HOSTS, loadConfig, type ResolvedConfig } from "../config.ts";
|
|
13
|
+
import { computeOutputs, runSync } from "../sync.ts";
|
|
14
|
+
import { untrackedEntries } from "./git-files.ts";
|
|
15
|
+
import { HOST_TREES } from "./hosts.ts";
|
|
16
|
+
import { readWorkspace } from "./workspace.ts";
|
|
17
|
+
import { HOOK_COMMANDS_KEY, hashText } from "./stock-history.ts";
|
|
18
|
+
import {
|
|
19
|
+
COPIED_MARKER,
|
|
20
|
+
applyMigration,
|
|
21
|
+
conflictLines,
|
|
22
|
+
generatedFiles,
|
|
23
|
+
planAdoption,
|
|
24
|
+
planMigration,
|
|
25
|
+
planRuleUpgrade,
|
|
26
|
+
trackedRels,
|
|
27
|
+
type MigrateDeps,
|
|
28
|
+
type MigrationStep,
|
|
29
|
+
} from "./migrate.ts";
|
|
30
|
+
|
|
31
|
+
const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
32
|
+
const FIXTURE_TGZ = path.join(SELF_ROOT, "cli", "sync", "fixtures", "copied.tgz");
|
|
33
|
+
/** The packed copied install, extracted once; a packed fixture keeps its .claude/skills out of the repo's own tree. */
|
|
34
|
+
const FIXTURE = fs.mkdtempSync(path.join(os.tmpdir(), "workerstack-copied-fixture-"));
|
|
35
|
+
tar.x({ file: FIXTURE_TGZ, cwd: FIXTURE, sync: true });
|
|
36
|
+
// the packed config lists packs that no longer exist; the migration under test starts from a config that loads
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
path.join(FIXTURE, "workerstack.config.ts"),
|
|
39
|
+
'import { defineConfig } from "@workerstack/repo"\n\nexport default defineConfig({\n packs: ["agents", "docs"],\n})\n',
|
|
40
|
+
);
|
|
41
|
+
const CONFIG: ResolvedConfig = {
|
|
42
|
+
packs: ["agents"],
|
|
43
|
+
agents: { hosts: HOSTS, skills: [] },
|
|
44
|
+
...BASE_CONFIG,
|
|
45
|
+
};
|
|
46
|
+
const LINES = [
|
|
47
|
+
"repo sync: moved .rulesync/rules/team.md to .repo/rules/team.md",
|
|
48
|
+
"repo sync: moved .rulesync/subagents/reviewer.md to .repo/subagents/reviewer.md",
|
|
49
|
+
"repo sync: moved .agents/skills/deploy to .repo/skills/deploy",
|
|
50
|
+
'repo sync: moved your sections "Our hooks" of .rulesync/reference/hooks.md to references/hooks.md',
|
|
51
|
+
"repo sync: moved .rulesync/reference/deploy.md to references/deploy.md",
|
|
52
|
+
"repo sync: moved your AGENTS.md text to .repo/rules/agents.md",
|
|
53
|
+
"repo sync: moved your .claude/settings.json keys permissions.allow to .repo/settings/claude.json",
|
|
54
|
+
"repo sync: moved .rulesync/lessons/ (1) to .repo/lessons/",
|
|
55
|
+
"repo sync: moved your .rulesync/hooks.jsonc to .repo/hooks.jsonc (it now replaces the stock hooks)",
|
|
56
|
+
"repo sync: moved your .rulesync/mcp-policy.jsonc to .repo/mcp-policy.jsonc",
|
|
57
|
+
'repo sync: moved packs ["listen"] from .rulesync/repo.features.jsonc to agents.skills in workerstack.config.ts',
|
|
58
|
+
];
|
|
59
|
+
const UNTRACKED = "AGENTS.md, .mcp.json, .agents/, .claude/, .codex/, .cursor/, .grok/";
|
|
60
|
+
const FINAL = `repo sync: untracked ${UNTRACKED} (now generated) and removed the copied .rulesync/; commit the result`;
|
|
61
|
+
const FINAL_NO_GIT = "repo sync: removed the copied .rulesync/";
|
|
62
|
+
|
|
63
|
+
const temps: string[] = [];
|
|
64
|
+
|
|
65
|
+
afterAll(() => fs.rmSync(FIXTURE, { recursive: true, force: true }));
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function tempDir(): string {
|
|
72
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workerstack-migrate-"));
|
|
73
|
+
temps.push(dir);
|
|
74
|
+
return dir;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function write(root: string, rel: string, text: string): void {
|
|
78
|
+
const file = path.join(root, rel);
|
|
79
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
fs.writeFileSync(file, text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function read(root: string, rel: string): string {
|
|
84
|
+
return fs.readFileSync(path.join(root, rel), "utf8");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function exists(root: string, rel: string): boolean {
|
|
88
|
+
return (
|
|
89
|
+
fs.existsSync(path.join(root, rel)) || fs.lstatSync(path.join(root, rel), { throwIfNoEntry: false }) !== undefined
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function git(root: string, ...args: string[]): string {
|
|
94
|
+
const result = spawnSync(
|
|
95
|
+
"git",
|
|
96
|
+
["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args],
|
|
97
|
+
{ cwd: root, encoding: "utf8" },
|
|
98
|
+
);
|
|
99
|
+
if (result.status !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr}`);
|
|
100
|
+
return result.stdout;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The recorded copied install in a temp dir: the fixture, this repo linked as the stock package, one commit. */
|
|
104
|
+
function copied(options: { git?: boolean } = {}): string {
|
|
105
|
+
const root = tempDir();
|
|
106
|
+
fs.cpSync(FIXTURE, root, { recursive: true, verbatimSymlinks: true });
|
|
107
|
+
fs.mkdirSync(path.join(root, "node_modules", "@workerstack"), { recursive: true });
|
|
108
|
+
fs.symlinkSync(SELF_ROOT, path.join(root, "node_modules", "@workerstack", "repo"), "dir");
|
|
109
|
+
if (options.git !== false) {
|
|
110
|
+
git(root, "init", "-q");
|
|
111
|
+
fs.appendFileSync(path.join(root, ".git", "info", "exclude"), "node_modules/\n");
|
|
112
|
+
git(root, "add", "-A");
|
|
113
|
+
git(root, "commit", "-q", "-m", "copied install");
|
|
114
|
+
}
|
|
115
|
+
return root;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function stockOf(root: string): string {
|
|
119
|
+
return path.join(root, "node_modules", "@workerstack", "repo");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every file and link under `root` outside node_modules and .git, with its text or link target. */
|
|
123
|
+
function snapshot(root: string): Record<string, string> {
|
|
124
|
+
const out: Record<string, string> = {};
|
|
125
|
+
const walk = (rel: string): void => {
|
|
126
|
+
for (const entry of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
127
|
+
const next = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
128
|
+
if (rel === "" && (entry.name === "node_modules" || entry.name === ".git")) continue;
|
|
129
|
+
if (entry.isSymbolicLink()) out[next] = `-> ${fs.readlinkSync(path.join(root, next))}`;
|
|
130
|
+
else if (entry.isDirectory()) walk(next);
|
|
131
|
+
else out[next] = fs.readFileSync(path.join(root, next), "utf8");
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
walk("");
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What sync generates for `root` before it migrates: the stock's own generated text, as runSync hands it over. */
|
|
139
|
+
async function generatedOf(root: string): Promise<[Map<string, string>, string[]]> {
|
|
140
|
+
const config = await loadConfig(root);
|
|
141
|
+
const workspace = readWorkspace(root);
|
|
142
|
+
if (!config.ok || !workspace.ok) throw new Error("the copied install does not load");
|
|
143
|
+
const planned = computeOutputs(root, config.config, workspace.workspace);
|
|
144
|
+
if (!planned.ok) throw new Error(planned.errors.join("\n"));
|
|
145
|
+
return [generatedFiles(planned.outputs), untrackedEntries(config.config.agents.hosts, planned.outputs)];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let fixtureFiles: Promise<[Map<string, string>, string[]]> | undefined;
|
|
149
|
+
/** The generated files and ignore entries of the copied fixture, the same for every copy of it; rendered once. */
|
|
150
|
+
function fixturePlanned(): Promise<[Map<string, string>, string[]]> {
|
|
151
|
+
fixtureFiles ??= generatedOf(copied({ git: false }));
|
|
152
|
+
return fixtureFiles;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function migrate(root: string, deps: MigrateDeps = {}): Promise<string[]> {
|
|
156
|
+
return applyMigration(root, planMigration(root, stockOf(root), ...(await fixturePlanned()), deps), deps);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** repo sync in `root`; `full` runs every generator, otherwise none, so only the migration and the checks run. */
|
|
160
|
+
function sync(root: string, args: string[], full = false) {
|
|
161
|
+
const out: string[] = [];
|
|
162
|
+
const err: string[] = [];
|
|
163
|
+
const io = { cwd: root, stdout: (s: string) => out.push(s), stderr: (s: string) => err.push(s) };
|
|
164
|
+
return runSync(args, io, full ? {} : { generators: [], post: [] }).then((code) => ({ code, out, err }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
describe("planMigration on a copied install", () => {
|
|
168
|
+
it("is empty without the copied-install marker", async () => {
|
|
169
|
+
const root = copied({ git: false });
|
|
170
|
+
fs.rmSync(path.join(root, COPIED_MARKER));
|
|
171
|
+
expect(planMigration(root, stockOf(root), ...(await fixturePlanned()))).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("names the moves in the order of Usage, then one untrack and one removal", async () => {
|
|
175
|
+
const root = copied();
|
|
176
|
+
const steps = planMigration(root, stockOf(root), ...(await fixturePlanned()));
|
|
177
|
+
const notes = steps.flatMap((step) => ("note" in step ? [`repo sync: ${step.note}`] : []));
|
|
178
|
+
expect(notes).toEqual(LINES);
|
|
179
|
+
expect(steps.map((step) => step.kind).slice(-2)).toEqual(["untrack", "remove"]);
|
|
180
|
+
expect(steps.find((step) => step.kind === "untrack")).toEqual({
|
|
181
|
+
kind: "untrack",
|
|
182
|
+
paths: UNTRACKED.split(", "),
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("plans the same steps for a root with a trailing separator", async () => {
|
|
187
|
+
const root = copied();
|
|
188
|
+
expect(planMigration(`${root}${path.sep}`, stockOf(root), ...(await fixturePlanned()))).toEqual(
|
|
189
|
+
planMigration(root, stockOf(root), ...(await fixturePlanned())),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("applyMigration on a copied install", () => {
|
|
195
|
+
it("moves each file, prints each line and leaves only generated paths behind", async () => {
|
|
196
|
+
const root = copied();
|
|
197
|
+
const lines = await migrate(root);
|
|
198
|
+
expect(lines).toEqual([...LINES, FINAL]);
|
|
199
|
+
|
|
200
|
+
expect(read(root, ".repo/rules/team.md")).toBe(read(FIXTURE, ".rulesync/rules/team.md"));
|
|
201
|
+
expect(read(root, ".repo/subagents/reviewer.md")).toBe(read(FIXTURE, ".rulesync/subagents/reviewer.md"));
|
|
202
|
+
expect(read(root, ".repo/skills/deploy/SKILL.md")).toBe(read(FIXTURE, ".agents/skills/deploy/SKILL.md"));
|
|
203
|
+
expect(read(root, "references/deploy.md")).toBe("# Deploy\n\nHow we deploy: `make deploy`.\n");
|
|
204
|
+
expect(read(root, "references/hooks.md")).toBe(
|
|
205
|
+
"# Hooks\n\n## Our hooks\n\nWe also run `make lint` from a pre-push hook.\n",
|
|
206
|
+
);
|
|
207
|
+
expect(read(root, ".repo/rules/agents.md")).toBe(
|
|
208
|
+
"---\ndescription: Carried over from your earlier setup\n---\nOur team notes: ship on Fridays only with a rollback plan.\n",
|
|
209
|
+
);
|
|
210
|
+
expect(JSON.parse(read(root, ".repo/settings/claude.json"))).toEqual({
|
|
211
|
+
permissions: { allow: ["Bash(make lint:*)"] },
|
|
212
|
+
});
|
|
213
|
+
expect(read(root, ".repo/lessons/x.md")).toBe(read(FIXTURE, ".rulesync/lessons/x.md"));
|
|
214
|
+
expect(exists(root, ".repo/lessons/INDEX.md")).toBe(false);
|
|
215
|
+
expect(read(root, ".repo/hooks.jsonc")).toBe(read(FIXTURE, ".rulesync/hooks.jsonc"));
|
|
216
|
+
expect(read(root, ".repo/mcp-policy.jsonc")).toBe(read(FIXTURE, ".rulesync/mcp-policy.jsonc"));
|
|
217
|
+
expect(read(root, "workerstack.config.ts")).toBe(
|
|
218
|
+
'import { defineConfig } from "@workerstack/repo"\n\nexport default defineConfig({\n agents: { skills: ["listen"] },\n packs: ["agents", "docs"],\n})\n',
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// stock-only copies are gone: the whole copied tree and every stock skill copy
|
|
222
|
+
expect(exists(root, ".rulesync")).toBe(false);
|
|
223
|
+
expect(exists(root, ".agents/skills/ask")).toBe(false);
|
|
224
|
+
expect(exists(root, ".agents/skills/deploy")).toBe(false);
|
|
225
|
+
// untracked, not deleted: generated trees stay for sync to overwrite; the two files whose own text moved are gone
|
|
226
|
+
expect(exists(root, ".claude/agents/scout.md")).toBe(true);
|
|
227
|
+
expect(exists(root, "AGENTS.md")).toBe(false);
|
|
228
|
+
expect(exists(root, ".claude/settings.json")).toBe(false);
|
|
229
|
+
const tracked = git(root, "ls-files").split("\n");
|
|
230
|
+
for (const dir of [".agents/", ".claude/", ".codex/", ".cursor/", ".grok/", "AGENTS.md", ".mcp.json"]) {
|
|
231
|
+
expect(tracked.filter((file) => file.startsWith(dir) || file === dir)).toEqual([]);
|
|
232
|
+
}
|
|
233
|
+
expect(tracked).toContain(".vscode/tasks.json");
|
|
234
|
+
expect(tracked).toContain("package.json");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("leaves a config that still loads, with the pack in agents.skills", async () => {
|
|
238
|
+
const root = copied();
|
|
239
|
+
await migrate(root);
|
|
240
|
+
const loaded = await loadConfig(root);
|
|
241
|
+
expect(loaded).toEqual({
|
|
242
|
+
ok: true,
|
|
243
|
+
config: {
|
|
244
|
+
packs: ["agents", "docs"],
|
|
245
|
+
agents: { hosts: HOSTS, skills: ["listen"] },
|
|
246
|
+
commit: { maxChangedLines: 1000 },
|
|
247
|
+
lint: { skip: [] },
|
|
248
|
+
knip: { ignore: [] },
|
|
249
|
+
coverage: DEFAULT_COVERAGE,
|
|
250
|
+
nightly: DEFAULT_NIGHTLY,
|
|
251
|
+
tdd: false,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("plans nothing on a second run", async () => {
|
|
257
|
+
const root = copied();
|
|
258
|
+
await migrate(root);
|
|
259
|
+
const before = snapshot(root);
|
|
260
|
+
expect(planMigration(root, stockOf(root), ...(await fixturePlanned()))).toEqual([]);
|
|
261
|
+
expect(await migrate(root)).toEqual([]);
|
|
262
|
+
expect(snapshot(root)).toEqual(before);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("skips the untrack step outside git and still removes the copy", async () => {
|
|
266
|
+
const root = copied({ git: false });
|
|
267
|
+
const steps = planMigration(root, stockOf(root), ...(await fixturePlanned()));
|
|
268
|
+
expect(steps.map((step) => step.kind)).not.toContain("untrack");
|
|
269
|
+
expect(await applyMigration(root, steps)).toEqual([...LINES, FINAL_NO_GIT]);
|
|
270
|
+
expect(exists(root, ".rulesync")).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("a destination that already exists", () => {
|
|
275
|
+
it("stops with one line naming both paths and changes nothing", async () => {
|
|
276
|
+
const root = copied();
|
|
277
|
+
write(root, ".repo/rules/team.md", "other text\n");
|
|
278
|
+
const before = snapshot(root);
|
|
279
|
+
const steps = planMigration(root, stockOf(root), ...(await fixturePlanned()));
|
|
280
|
+
expect(conflictLines(steps)).toEqual([
|
|
281
|
+
"repo sync: cannot move .rulesync/rules/team.md: .repo/rules/team.md already exists and differs; merge them by hand, then run `bun repo sync`",
|
|
282
|
+
]);
|
|
283
|
+
expect(await applyMigration(root, steps)).toEqual(conflictLines(steps));
|
|
284
|
+
expect(snapshot(root)).toEqual(before);
|
|
285
|
+
expect(git(root, "ls-files", "AGENTS.md")).toBe("AGENTS.md\n");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("through runSync exits 1 with that line on stderr and writes nothing", async () => {
|
|
289
|
+
const root = copied();
|
|
290
|
+
write(root, ".repo/rules/team.md", "other text\n");
|
|
291
|
+
const before = snapshot(root);
|
|
292
|
+
const result = await sync(root, []);
|
|
293
|
+
expect(result.code).toBe(1);
|
|
294
|
+
expect(result.err).toEqual([
|
|
295
|
+
"repo sync: cannot move .rulesync/rules/team.md: .repo/rules/team.md already exists and differs; merge them by hand, then run `bun repo sync`\n",
|
|
296
|
+
]);
|
|
297
|
+
expect(snapshot(root)).toEqual(before);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("goes on when the destination holds the same text", async () => {
|
|
301
|
+
const root = copied();
|
|
302
|
+
write(root, ".repo/rules/team.md", read(root, ".rulesync/rules/team.md"));
|
|
303
|
+
const lines = await migrate(root);
|
|
304
|
+
expect(lines[0]).toBe(LINES[0]);
|
|
305
|
+
expect(exists(root, ".rulesync")).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("repo sync around the migration", () => {
|
|
310
|
+
it("--check prints the pending count, exits 1 and writes nothing", async () => {
|
|
311
|
+
const root = copied();
|
|
312
|
+
const before = snapshot(root);
|
|
313
|
+
const count = planMigration(root, stockOf(root), ...(await fixturePlanned())).length;
|
|
314
|
+
const result = await sync(root, ["--check"], true);
|
|
315
|
+
expect(result.code).toBe(1);
|
|
316
|
+
expect(result.err).toEqual([`repo sync: ${count} migration steps pending; run \`bun repo sync\`\n`]);
|
|
317
|
+
expect(result.out).toEqual([]);
|
|
318
|
+
expect(snapshot(root)).toEqual(before);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("runs the migration first and prints its lines before the sync summary", async () => {
|
|
322
|
+
const root = copied();
|
|
323
|
+
const result = await sync(root, [], true);
|
|
324
|
+
expect(result.code).toBe(0);
|
|
325
|
+
expect(result.out.slice(0, LINES.length + 1)).toEqual([...LINES, FINAL].map((line) => `${line}\n`));
|
|
326
|
+
expect(exists(root, ".rulesync")).toBe(false);
|
|
327
|
+
expect(await sync(root, ["--check"], true)).toMatchObject({ code: 0, out: ["repo sync: up to date\n"] });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("a full sync after the migration carries the consumer's own content into the trees", async () => {
|
|
331
|
+
const root = copied();
|
|
332
|
+
const own = JSON.parse(read(root, ".claude/settings.json")) as { hooks: Record<string, unknown[]> };
|
|
333
|
+
own.hooks.PostToolUse = [
|
|
334
|
+
...(own.hooks.PostToolUse ?? []),
|
|
335
|
+
{ matcher: "Write", hooks: [{ type: "command", command: "make post-write", timeout: 5 }] },
|
|
336
|
+
];
|
|
337
|
+
write(root, ".claude/settings.json", JSON.stringify(own, null, 2));
|
|
338
|
+
const result = await sync(root, [], true);
|
|
339
|
+
expect(result.err).toEqual([]);
|
|
340
|
+
expect(result.code).toBe(0);
|
|
341
|
+
const agents = read(root, "AGENTS.md");
|
|
342
|
+
expect(agents).toContain("Team rule: run `make lint` before every push.");
|
|
343
|
+
expect(agents).toContain("Our team notes: ship on Fridays only with a rollback plan.");
|
|
344
|
+
const settings = JSON.parse(read(root, ".claude/settings.json")) as {
|
|
345
|
+
permissions: { allow: string[] };
|
|
346
|
+
hooks: { PostToolUse: { matcher: string }[] };
|
|
347
|
+
};
|
|
348
|
+
expect(settings.permissions.allow).toContain("Bash(make lint:*)");
|
|
349
|
+
expect(settings.hooks.PostToolUse.map((group) => group.matcher)).toEqual(expect.arrayContaining(["Edit", "Write"]));
|
|
350
|
+
expect(JSON.stringify(settings.hooks.PostToolUse)).toContain("make post-write");
|
|
351
|
+
expect(read(root, ".mcp.json")).toContain("docs-mcp");
|
|
352
|
+
expect(exists(root, ".claude/agents/reviewer.md")).toBe(true);
|
|
353
|
+
expect(fs.readlinkSync(path.join(root, ".claude/skills/deploy"))).toContain("deploy");
|
|
354
|
+
// a new process reads the migrated config, which this one cached before the migration
|
|
355
|
+
for (const args of [[], ["--check"]]) {
|
|
356
|
+
const again = spawnSync(BUN, [path.join(SELF_ROOT, "bin", "repo.ts"), "sync", ...args], {
|
|
357
|
+
cwd: root,
|
|
358
|
+
encoding: "utf8",
|
|
359
|
+
});
|
|
360
|
+
expect({ status: again.status, out: again.stdout }).toEqual({ status: 0, out: "repo sync: up to date\n" });
|
|
361
|
+
}
|
|
362
|
+
}, 180_000);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("a failure after the migration", () => {
|
|
366
|
+
it("leaves no source to adopt again: the next sync succeeds with the AGENTS.md line once", async () => {
|
|
367
|
+
const root = copied();
|
|
368
|
+
// fails only once the migration removed the copy, so the migration itself runs
|
|
369
|
+
const failing = {
|
|
370
|
+
generators: [
|
|
371
|
+
(ctx: { read: (rel: string) => string | null }) => ({
|
|
372
|
+
outputs: [],
|
|
373
|
+
errors: ctx.read(COPIED_MARKER) === null ? ["forced failure"] : [],
|
|
374
|
+
}),
|
|
375
|
+
],
|
|
376
|
+
};
|
|
377
|
+
const err: string[] = [];
|
|
378
|
+
const code = await runSync([], { cwd: root, stdout: () => {}, stderr: (s) => err.push(s) }, failing);
|
|
379
|
+
expect(code).toBe(1);
|
|
380
|
+
expect(err).toEqual(["repo sync: forced failure\n", "repo sync: nothing written\n"]);
|
|
381
|
+
expect(exists(root, ".rulesync")).toBe(false);
|
|
382
|
+
expect(exists(root, "AGENTS.md")).toBe(false);
|
|
383
|
+
|
|
384
|
+
const second = await sync(root, [], true);
|
|
385
|
+
expect(second.err).toEqual([]);
|
|
386
|
+
expect(second.code).toBe(0);
|
|
387
|
+
const line = "Our team notes: ship on Fridays only with a rollback plan.";
|
|
388
|
+
expect(read(root, "AGENTS.md").split(line)).toHaveLength(2);
|
|
389
|
+
expect(JSON.parse(read(root, ".claude/settings.json"))).toMatchObject({
|
|
390
|
+
permissions: { allow: expect.arrayContaining(["Bash(make lint:*)"]) },
|
|
391
|
+
});
|
|
392
|
+
}, 180_000);
|
|
393
|
+
|
|
394
|
+
it("treats an overlay that already holds a stale AGENTS.md text as adopted, not as a conflict", async () => {
|
|
395
|
+
const root = copied();
|
|
396
|
+
await migrate(root);
|
|
397
|
+
write(
|
|
398
|
+
root,
|
|
399
|
+
"AGENTS.md",
|
|
400
|
+
"<!-- repo:begin -->\nstock\n<!-- repo:end -->\nOur team notes: ship on Fridays only with a rollback plan.\n",
|
|
401
|
+
);
|
|
402
|
+
write(root, ".claude/settings.json", JSON.stringify({ permissions: { allow: ["Bash(make lint:*)"] } }));
|
|
403
|
+
const result = await sync(root, [], true);
|
|
404
|
+
expect(result.err).toEqual([]);
|
|
405
|
+
expect(result.code).toBe(0);
|
|
406
|
+
expect(read(root, "AGENTS.md").split("ship on Fridays")).toHaveLength(2);
|
|
407
|
+
}, 180_000);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe("a crash between steps", () => {
|
|
411
|
+
it("is finished by the next run, whichever step it follows, with the marker removed last", async () => {
|
|
412
|
+
const reference = copied();
|
|
413
|
+
const planned = planMigration(reference, stockOf(reference), ...(await fixturePlanned()));
|
|
414
|
+
await migrate(reference);
|
|
415
|
+
const expected = snapshot(reference);
|
|
416
|
+
const expectedTracked = git(reference, "ls-files");
|
|
417
|
+
|
|
418
|
+
for (let crashAfter = 0; crashAfter < planned.length; crashAfter++) {
|
|
419
|
+
const root = copied();
|
|
420
|
+
const deps: MigrateDeps = {
|
|
421
|
+
afterStep: (index) => {
|
|
422
|
+
if (index === crashAfter) throw new Error("crash");
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
await expect(migrate(root, deps)).rejects.toThrow("crash");
|
|
426
|
+
expect(exists(root, COPIED_MARKER), `marker after step ${crashAfter} (${planned[crashAfter]!.kind})`).toBe(
|
|
427
|
+
crashAfter < planned.length - 1,
|
|
428
|
+
);
|
|
429
|
+
if (crashAfter < planned.length - 1) {
|
|
430
|
+
await migrate(root);
|
|
431
|
+
}
|
|
432
|
+
expect(snapshot(root), `after a crash behind step ${crashAfter}`).toEqual(expected);
|
|
433
|
+
expect(git(root, "ls-files")).toBe(expectedTracked);
|
|
434
|
+
}
|
|
435
|
+
}, 180_000);
|
|
436
|
+
|
|
437
|
+
it("replans only the removal when .rulesync/ was half removed", async () => {
|
|
438
|
+
const reference = copied();
|
|
439
|
+
await migrate(reference);
|
|
440
|
+
|
|
441
|
+
const root = copied();
|
|
442
|
+
const steps = planMigration(root, stockOf(root), ...(await fixturePlanned()));
|
|
443
|
+
await applyMigration(root, steps.slice(0, -1));
|
|
444
|
+
fs.rmSync(path.join(root, ".rulesync", "scripts", "browser.ts"));
|
|
445
|
+
fs.rmSync(path.join(root, ".rulesync", "rules"), { recursive: true });
|
|
446
|
+
const rest = planMigration(root, stockOf(root), ...(await fixturePlanned()));
|
|
447
|
+
expect(rest.map((step) => step.kind)).toEqual(["remove"]);
|
|
448
|
+
await applyMigration(root, rest);
|
|
449
|
+
expect(snapshot(root)).toEqual(snapshot(reference));
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe("Windows spellings", () => {
|
|
454
|
+
it("reads tracked paths with backslashes or a drive prefix as root-relative posix paths", () => {
|
|
455
|
+
expect(
|
|
456
|
+
trackedRels(
|
|
457
|
+
[
|
|
458
|
+
"AGENTS.md",
|
|
459
|
+
".claude\\settings.json",
|
|
460
|
+
"C:\\proj\\.agents\\skills\\ask\\SKILL.md",
|
|
461
|
+
"c:/PROJ/.grok/config.toml",
|
|
462
|
+
"D:\\elsewhere\\x",
|
|
463
|
+
"",
|
|
464
|
+
],
|
|
465
|
+
"C:\\proj",
|
|
466
|
+
),
|
|
467
|
+
).toEqual(["AGENTS.md", ".claude/settings.json", ".agents/skills/ask/SKILL.md", ".grok/config.toml"]);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("plans the same untrack paths from git output in either spelling", async () => {
|
|
471
|
+
const root = copied();
|
|
472
|
+
const posix = planMigration(root, stockOf(root), ...(await fixturePlanned()));
|
|
473
|
+
const spelled = planMigration(root, stockOf(root), ...(await fixturePlanned()), {
|
|
474
|
+
tracked: () => [
|
|
475
|
+
"AGENTS.md",
|
|
476
|
+
".mcp.json",
|
|
477
|
+
`${root.replaceAll("/", "\\")}\\.agents\\skills\\ask\\SKILL.md`,
|
|
478
|
+
".claude\\settings.json",
|
|
479
|
+
".codex\\config.toml",
|
|
480
|
+
".cursor\\hooks.json",
|
|
481
|
+
".grok\\config.toml",
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
expect(spelled).toEqual(posix);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const MARKER_FILE = { [COPIED_MARKER]: "// copied script\n" };
|
|
489
|
+
const DEFINE = "const defineConfig = (config: object) => config\n";
|
|
490
|
+
const SIMPLE_CONFIG = `${DEFINE}export default defineConfig({\n packs: ["agents"],\n})\n`;
|
|
491
|
+
/** A stock package as it is packed: sources under `agents/`, scripts under `cli/scripts/`, no generated trees. */
|
|
492
|
+
const STOCK_FILES: Record<string, string> = {
|
|
493
|
+
"package.json": '{ "name": "@workerstack/repo" }\n',
|
|
494
|
+
"agents/rules/overview.md": "---\nroot: true\n---\n# Overview\n\n## Stock\n\nbody\n",
|
|
495
|
+
"agents/rules/plain.md": "---\nroot: false\n---\nno heading and no sections\n",
|
|
496
|
+
"agents/subagents/scout.md": "scout\n",
|
|
497
|
+
"agents/hooks.jsonc": "{}\n",
|
|
498
|
+
"agents/mcp-policy.jsonc": "{}\n",
|
|
499
|
+
"agents/global-settings/claude.json": "{}\n",
|
|
500
|
+
"agents/skills/ask/SKILL.md": "ask\n",
|
|
501
|
+
"references/hooks.md": "---\ntags: [hooks]\n---\n# Hooks\n\n## Stock\n\nbody\n",
|
|
502
|
+
"references/bare.md": "bare\n\n## Stock\n\nbody\n",
|
|
503
|
+
"references/cascade.md": "# Cascade\n\n## Stock\n\nbody\n",
|
|
504
|
+
};
|
|
505
|
+
/** What sync generates from that stock, which the package does not ship. */
|
|
506
|
+
const GENERATED: Record<string, string> = {
|
|
507
|
+
".claude/settings.json": '{ "permissions": { "allow": ["A"] }, "env": { "X": "1" } }\n',
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/** The ignore entries sync derives for `config` when every host generated a file in each dir it owns. */
|
|
511
|
+
function ignoredOf(config: ResolvedConfig): string[] {
|
|
512
|
+
const paths = [
|
|
513
|
+
"AGENTS.md",
|
|
514
|
+
".mcp.json",
|
|
515
|
+
...config.agents.hosts.flatMap((host) => HOST_TREES[host].owns.map((dir) => `${dir}x`)),
|
|
516
|
+
];
|
|
517
|
+
const outputs = paths.map((rel) => ({
|
|
518
|
+
kind: "file" as const,
|
|
519
|
+
path: rel,
|
|
520
|
+
content: "",
|
|
521
|
+
tracked: false,
|
|
522
|
+
managed: "whole" as const,
|
|
523
|
+
}));
|
|
524
|
+
return untrackedEntries(config.agents.hosts, outputs);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
type Mini = { root: string; stock: string; generated: Map<string, string> };
|
|
528
|
+
|
|
529
|
+
/** A small copied install over a fake stock package, so each rule can be tried alone. */
|
|
530
|
+
function mini(
|
|
531
|
+
files: Record<string, string>,
|
|
532
|
+
options: { stock?: Record<string, string>; generated?: Record<string, string>; git?: boolean } = {},
|
|
533
|
+
): Mini {
|
|
534
|
+
const dir = tempDir();
|
|
535
|
+
const stock = path.join(dir, "stock");
|
|
536
|
+
const root = path.join(dir, "root");
|
|
537
|
+
for (const [rel, text] of Object.entries({ ...STOCK_FILES, ...options.stock })) write(stock, rel, text);
|
|
538
|
+
for (const [rel, text] of Object.entries({ ...MARKER_FILE, "workerstack.config.ts": SIMPLE_CONFIG, ...files })) {
|
|
539
|
+
write(root, rel, text);
|
|
540
|
+
}
|
|
541
|
+
if (options.git === true) {
|
|
542
|
+
git(root, "init", "-q");
|
|
543
|
+
git(root, "add", "-A");
|
|
544
|
+
git(root, "commit", "-q", "-m", "copied install");
|
|
545
|
+
}
|
|
546
|
+
return { root, stock, generated: new Map(Object.entries({ ...GENERATED, ...options.generated })) };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function planOf(m: Mini, config: ResolvedConfig = CONFIG, deps: MigrateDeps = {}): MigrationStep[] {
|
|
550
|
+
return planMigration(m.root, m.stock, m.generated, ignoredOf(config), deps);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The lines of each step that prints one, in order. */
|
|
554
|
+
function notes(steps: readonly MigrationStep[]): string[] {
|
|
555
|
+
return steps.flatMap((step) => ("note" in step ? [step.note] : []));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function contents(steps: readonly MigrationStep[]): string[] {
|
|
559
|
+
return steps.flatMap((step) => (step.kind === "write" ? [step.content] : []));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const yours = (rel: string) =>
|
|
563
|
+
`repo sync: ${rel} is yours and nothing reads it; move it out of .rulesync (to references/ if it explains something) or delete it, then run \`bun repo sync\``;
|
|
564
|
+
const REMOVAL: MigrationStep = { kind: "remove", paths: [".rulesync"] };
|
|
565
|
+
const clash = (from: string, to: string) =>
|
|
566
|
+
`repo sync: cannot move ${from}: ${to} already exists and differs; merge them by hand, then run \`bun repo sync\``;
|
|
567
|
+
|
|
568
|
+
describe("a stock package as it is packed", () => {
|
|
569
|
+
const CONSUMER_STOCK = path.join(SELF_ROOT, "cli", "sync", "fixtures", "consumer-stock");
|
|
570
|
+
const RULE = "agents/rules/overview.md";
|
|
571
|
+
const SETTINGS = '{ "permissions": { "deny": ["Agent(Explore)"] } }\n';
|
|
572
|
+
const MCP = '{ "mcpServers": { "stock": { "command": "s" } } }\n';
|
|
573
|
+
const generated = new Map([
|
|
574
|
+
[".claude/settings.json", SETTINGS],
|
|
575
|
+
[".mcp.json", MCP],
|
|
576
|
+
]);
|
|
577
|
+
const none: MigrateDeps = { history: {}, tracked: () => null };
|
|
578
|
+
const RULE_FRONTMATTER = "---\ndescription: Carried over from your earlier setup\n---\n";
|
|
579
|
+
|
|
580
|
+
/** A copied install over the frozen stock snapshot: `agents/` and `cli/scripts/`, no `.rulesync/`, no generated trees. */
|
|
581
|
+
function packed(files: Record<string, string>): Mini {
|
|
582
|
+
const dir = tempDir();
|
|
583
|
+
const stock = path.join(dir, "stock");
|
|
584
|
+
const root = path.join(dir, "root");
|
|
585
|
+
fs.cpSync(CONSUMER_STOCK, stock, { recursive: true });
|
|
586
|
+
write(stock, "package.json", '{ "name": "@workerstack/repo" }\n');
|
|
587
|
+
write(stock, "agents/skills/ask/SKILL.md", "ask\n");
|
|
588
|
+
for (const [rel, text] of Object.entries({ ...MARKER_FILE, "workerstack.config.ts": SIMPLE_CONFIG, ...files }))
|
|
589
|
+
write(root, rel, text);
|
|
590
|
+
return { root, stock, generated };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
it("splits the extra sections of an edited stock rule into a -local file", () => {
|
|
594
|
+
const own = `${read(CONSUMER_STOCK, RULE)}\n## Ours\n\nship on Fridays\n`;
|
|
595
|
+
expect(planOf(packed({ ".rulesync/rules/overview.md": own }), CONFIG, none)).toEqual([
|
|
596
|
+
{
|
|
597
|
+
kind: "write",
|
|
598
|
+
path: ".repo/rules/overview-local.md",
|
|
599
|
+
content: `${RULE_FRONTMATTER}## Ours\n\nship on Fridays\n`,
|
|
600
|
+
note: 'moved your sections "Ours" of .rulesync/rules/overview.md to .repo/rules/overview-local.md',
|
|
601
|
+
},
|
|
602
|
+
REMOVAL,
|
|
603
|
+
]);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("moves an own key of .claude/settings.json to .repo/settings/claude.json", () => {
|
|
607
|
+
const own = JSON.stringify({ permissions: { deny: ["Agent(Explore)"] }, model: "opus" });
|
|
608
|
+
expect(planOf(packed({ ".claude/settings.json": own }), CONFIG, none)).toEqual([
|
|
609
|
+
{
|
|
610
|
+
kind: "write",
|
|
611
|
+
path: ".repo/settings/claude.json",
|
|
612
|
+
content: `${JSON.stringify({ model: "opus" }, null, 2)}\n`,
|
|
613
|
+
note: "moved your .claude/settings.json keys model to .repo/settings/claude.json",
|
|
614
|
+
},
|
|
615
|
+
{ kind: "remove", paths: [".claude/settings.json", ".rulesync"] },
|
|
616
|
+
]);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("removes an unedited copy of a stock skill instead of moving it", () => {
|
|
620
|
+
expect(planOf(packed({ ".agents/skills/ask/SKILL.md": "ask\n" }), CONFIG, none)).toEqual([
|
|
621
|
+
{ kind: "remove", paths: [".agents/skills/ask", ".rulesync"] },
|
|
622
|
+
]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("leaves an unedited real copy of a stock skill under .claude/skills alone, and moves one with an extra file", () => {
|
|
626
|
+
const unedited = packed({ ".claude/skills/ask/SKILL.md": "ask\n" });
|
|
627
|
+
expect(planAdoption(unedited.root, unedited.stock, CONFIG, [], [], none)).toEqual([]);
|
|
628
|
+
const edited = packed({ ".claude/skills/ask/SKILL.md": "ask\n", ".claude/skills/ask/notes.md": "mine\n" });
|
|
629
|
+
expect(planAdoption(edited.root, edited.stock, CONFIG, [], [], none)).toEqual([
|
|
630
|
+
{
|
|
631
|
+
kind: "move",
|
|
632
|
+
from: ".claude/skills/ask",
|
|
633
|
+
to: ".repo/skills/ask",
|
|
634
|
+
note: "moved .claude/skills/ask to .repo/skills/ask",
|
|
635
|
+
mark: {
|
|
636
|
+
path: ".claude/skills/ask/SKILL.md",
|
|
637
|
+
content: "---\noverrides: ask\n---\nask\n",
|
|
638
|
+
note: "marked .repo/skills/ask/SKILL.md with overrides: ask (it replaces the stock skill)",
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
]);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("keeps an own MCP server of .mcp.json in .repo/mcp.json, and not the stock one", () => {
|
|
645
|
+
const m = packed({
|
|
646
|
+
".mcp.json": JSON.stringify({ mcpServers: { stock: { command: "s" }, docs: { command: "d" } } }),
|
|
647
|
+
});
|
|
648
|
+
const outputs = [...generated].map(([rel, content]) => ({
|
|
649
|
+
kind: "file" as const,
|
|
650
|
+
path: rel,
|
|
651
|
+
content,
|
|
652
|
+
tracked: false,
|
|
653
|
+
managed: "whole" as const,
|
|
654
|
+
}));
|
|
655
|
+
expect(planAdoption(m.root, m.stock, CONFIG, outputs, [], none)).toEqual([
|
|
656
|
+
{
|
|
657
|
+
kind: "write",
|
|
658
|
+
path: ".repo/mcp.json",
|
|
659
|
+
content: `${JSON.stringify({ mcpServers: { docs: { command: "d" } } }, null, 2)}\n`,
|
|
660
|
+
note: "moved your .mcp.json keys mcpServers.docs to .repo/mcp.json",
|
|
661
|
+
},
|
|
662
|
+
]);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
describe("planMigration without a usable stock", () => {
|
|
667
|
+
it("plans nothing while the stock package is not installed", () => {
|
|
668
|
+
const m = mini({});
|
|
669
|
+
fs.rmSync(path.join(m.stock, "package.json"));
|
|
670
|
+
expect(planOf(m)).toEqual([]);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("plans only the removal for an install that holds nothing of its own", () => {
|
|
674
|
+
expect(planOf(mini({}))).toEqual([REMOVAL]);
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
describe("rules", () => {
|
|
679
|
+
it("moves a rule the stock lacks, and keeps stock rules live by writing extra sections to a -local sibling", () => {
|
|
680
|
+
const m = mini({
|
|
681
|
+
".rulesync/rules/team.md": "own\n",
|
|
682
|
+
".rulesync/rules/overview.md":
|
|
683
|
+
"---\nroot: true\n---\n# Mine\n\n## Stock\n\nedited body\n\n## Ours\n\nlocal text\n\n## Also\n\nmore\n",
|
|
684
|
+
".rulesync/rules/plain.md": "---\nroot: false\n---\n## Extra\n\nhere\n",
|
|
685
|
+
});
|
|
686
|
+
expect(planOf(m).slice(0, 3)).toEqual([
|
|
687
|
+
{
|
|
688
|
+
kind: "write",
|
|
689
|
+
path: ".repo/rules/overview-local.md",
|
|
690
|
+
content:
|
|
691
|
+
"---\ndescription: Carried over from your earlier setup\n---\n# Overview\n\n## Ours\n\nlocal text\n\n## Also\n\nmore\n",
|
|
692
|
+
note: 'moved your sections "Ours", "Also" of .rulesync/rules/overview.md to .repo/rules/overview-local.md',
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
kind: "write",
|
|
696
|
+
path: ".repo/rules/plain-local.md",
|
|
697
|
+
content: "---\ndescription: Carried over from your earlier setup\n---\n## Extra\n\nhere\n",
|
|
698
|
+
note: 'moved your sections "Extra" of .rulesync/rules/plain.md to .repo/rules/plain-local.md',
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
kind: "move",
|
|
702
|
+
from: ".rulesync/rules/team.md",
|
|
703
|
+
to: ".repo/rules/team.md",
|
|
704
|
+
note: "moved .rulesync/rules/team.md to .repo/rules/team.md",
|
|
705
|
+
},
|
|
706
|
+
]);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("leaves a stock rule with no extra sections to the removal, and a -local sibling already written alone", () => {
|
|
710
|
+
const m = mini({
|
|
711
|
+
".rulesync/rules/overview.md": "---\nroot: true\n---\n# Overview\n\n## Stock\n\nbody\n",
|
|
712
|
+
".rulesync/rules/plain.md": "---\nroot: false\n---\n## Extra\n\nhere\n",
|
|
713
|
+
".repo/rules/plain-local.md": "---\ndescription: Carried over from your earlier setup\n---\n## Extra\n\nhere\n",
|
|
714
|
+
});
|
|
715
|
+
expect(planOf(m)).toEqual([REMOVAL]);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("stops on a -local sibling that holds other text", () => {
|
|
719
|
+
const m = mini({
|
|
720
|
+
".rulesync/rules/plain.md": "---\nroot: false\n---\n## Extra\n\nhere\n",
|
|
721
|
+
".repo/rules/plain-local.md": "other\n",
|
|
722
|
+
});
|
|
723
|
+
expect(conflictLines(planOf(m))).toEqual([clash(".rulesync/rules/plain.md", ".repo/rules/plain-local.md")]);
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
describe("subagents and skills", () => {
|
|
728
|
+
it("moves an edited stock subagent whole as an override, and leaves an identical one to the removal", () => {
|
|
729
|
+
const m = mini({
|
|
730
|
+
".rulesync/subagents/scout.md": "edited scout\n",
|
|
731
|
+
".rulesync/subagents/orchestrator.md": "own\n",
|
|
732
|
+
});
|
|
733
|
+
expect(notes(planOf(m))).toEqual([
|
|
734
|
+
"moved .rulesync/subagents/orchestrator.md to .repo/subagents/orchestrator.md",
|
|
735
|
+
"moved your edited .rulesync/subagents/scout.md to .repo/subagents/scout.md (it now replaces the stock subagent)",
|
|
736
|
+
]);
|
|
737
|
+
expect(planOf(mini({ ".rulesync/subagents/scout.md": "scout\r\n" }))).toEqual([REMOVAL]);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("moves an edited stock skill whole, a skill the stock lacks, and removes the identical copies", () => {
|
|
741
|
+
const m = mini(
|
|
742
|
+
{
|
|
743
|
+
".agents/skills/ask/SKILL.md": "edited\n",
|
|
744
|
+
".agents/skills/deploy/SKILL.md": "deploy\n",
|
|
745
|
+
".agents/skills/bro/SKILL.md": "bro\n",
|
|
746
|
+
".agents/skills/README.md": "not a skill\n",
|
|
747
|
+
},
|
|
748
|
+
{ stock: { "agents/skills/bro/SKILL.md": "bro\n" } },
|
|
749
|
+
);
|
|
750
|
+
expect(planOf(m)).toEqual([
|
|
751
|
+
{
|
|
752
|
+
kind: "move",
|
|
753
|
+
from: ".agents/skills/ask",
|
|
754
|
+
to: ".repo/skills/ask",
|
|
755
|
+
note: "moved your edited .agents/skills/ask to .repo/skills/ask (it now replaces the stock skill)",
|
|
756
|
+
mark: {
|
|
757
|
+
path: ".agents/skills/ask/SKILL.md",
|
|
758
|
+
content: "---\noverrides: ask\n---\nedited\n",
|
|
759
|
+
note: "marked .repo/skills/ask/SKILL.md with overrides: ask (it replaces the stock skill)",
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
kind: "move",
|
|
764
|
+
from: ".agents/skills/deploy",
|
|
765
|
+
to: ".repo/skills/deploy",
|
|
766
|
+
note: "moved .agents/skills/deploy to .repo/skills/deploy",
|
|
767
|
+
},
|
|
768
|
+
{ kind: "remove", paths: [".agents/skills/bro", ".rulesync"] },
|
|
769
|
+
]);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("treats a skill whose file list differs as edited", () => {
|
|
773
|
+
const m = mini({ ".agents/skills/ask/SKILL.md": "ask\n", ".agents/skills/ask/extra.md": "extra\n" });
|
|
774
|
+
expect(planOf(m)[0]).toMatchObject({ kind: "move", from: ".agents/skills/ask" });
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe("references", () => {
|
|
779
|
+
it("drops old generated files and stock copies, writes extra sections, moves everything else", () => {
|
|
780
|
+
const m = mini({
|
|
781
|
+
".rulesync/reference/law.md": "# Law\n",
|
|
782
|
+
".rulesync/reference/INDEX.md": "generated\n",
|
|
783
|
+
".rulesync/reference/cascade.md": "# Cascade\n\n## Stock\n\nbody\n",
|
|
784
|
+
".rulesync/reference/hooks.md": "# Hooks\n\n## Stock\n\nbody\n\n## A\n\n1\n\n## B\n\n2\n",
|
|
785
|
+
".rulesync/reference/bare.md": "bare\n\n## Stock\n\nbody\n\n## C\n\nthree\n",
|
|
786
|
+
".rulesync/reference/deploy.md": "# Deploy\n",
|
|
787
|
+
".rulesync/reference/notes.txt": "not markdown\n",
|
|
788
|
+
".rulesync/reference/nested/more.md": "# More\n",
|
|
789
|
+
});
|
|
790
|
+
const steps = planOf(m);
|
|
791
|
+
expect(notes(steps)).toEqual([
|
|
792
|
+
'moved your sections "C" of .rulesync/reference/bare.md to references/bare.md',
|
|
793
|
+
'moved your sections "A", "B" of .rulesync/reference/hooks.md to references/hooks.md',
|
|
794
|
+
"moved .rulesync/reference/deploy.md to references/deploy.md",
|
|
795
|
+
"moved .rulesync/reference/nested to references/nested",
|
|
796
|
+
"moved .rulesync/reference/notes.txt to references/notes.txt",
|
|
797
|
+
]);
|
|
798
|
+
expect(contents(steps)).toEqual(["# bare.md\n\n## C\n\nthree\n", "# Hooks\n\n## A\n\n1\n\n## B\n\n2\n"]);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("takes the heading from the consumer's copy when the stock has none, then from the file name", () => {
|
|
802
|
+
const m = mini(
|
|
803
|
+
{
|
|
804
|
+
".rulesync/reference/a.md": "# Mine\n\n## S\n\nx\n\n## Own\n\ny\n",
|
|
805
|
+
".rulesync/reference/b.md": "text\n\n## S\n\nx\n\n## Own\n\ny\n",
|
|
806
|
+
},
|
|
807
|
+
{ stock: { "references/a.md": "stock\n\n## S\n\nx\n", "references/b.md": "stock\n\n## S\n\nx\n" } },
|
|
808
|
+
);
|
|
809
|
+
expect(contents(planOf(m))).toEqual(["# Mine\n\n## Own\n\ny\n", "# b.md\n\n## Own\n\ny\n"]);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("appends extra sections after text already at the destination, and does nothing when they are there", () => {
|
|
813
|
+
const own = ".rulesync/reference/hooks.md";
|
|
814
|
+
const text = "# Hooks\n\n## Stock\n\nbody\n\n## Ours\n\nmine\n";
|
|
815
|
+
const appended = mini({ [own]: text, "references/hooks.md": "# Hooks\n\nconsumer text\n\n" });
|
|
816
|
+
expect(contents(planOf(appended))).toEqual(["# Hooks\n\nconsumer text\n\n## Ours\n\nmine\n"]);
|
|
817
|
+
const there = mini({ [own]: text, "references/hooks.md": "# Hooks\n\n## Ours\n\nmine\n" });
|
|
818
|
+
expect(planOf(there)).toEqual([REMOVAL]);
|
|
819
|
+
const same = mini({ [own]: STOCK_FILES["references/hooks.md"]! });
|
|
820
|
+
expect(planOf(same)).toEqual([REMOVAL]);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("stops on a moved file whose destination holds other text, and on a dir against a file", () => {
|
|
824
|
+
const m = mini({
|
|
825
|
+
".rulesync/reference/deploy.md": "# Deploy\n",
|
|
826
|
+
"references/deploy.md": "# Other\n",
|
|
827
|
+
".rulesync/reference/guide/one.md": "1\n",
|
|
828
|
+
"references/guide": "a file\n",
|
|
829
|
+
});
|
|
830
|
+
expect(conflictLines(planOf(m))).toEqual([
|
|
831
|
+
clash(".rulesync/reference/deploy.md", "references/deploy.md"),
|
|
832
|
+
clash(".rulesync/reference/guide", "references/guide"),
|
|
833
|
+
]);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
describe("AGENTS.md text and .claude/settings.json keys", () => {
|
|
838
|
+
const block = "<!-- repo:begin -->\n\nstock\n\n<!-- repo:end -->\n";
|
|
839
|
+
|
|
840
|
+
it("plans nothing for a missing AGENTS.md, one with only the guarded block, or text already moved", () => {
|
|
841
|
+
expect(planOf(mini({}))).toEqual([REMOVAL]);
|
|
842
|
+
expect(planOf(mini({ "AGENTS.md": block }))).toEqual([REMOVAL]);
|
|
843
|
+
const moved = mini({
|
|
844
|
+
"AGENTS.md": `${block}\nours\n`,
|
|
845
|
+
".repo/rules/agents.md": "---\ndescription: Carried over from your earlier setup\n---\nours\n",
|
|
846
|
+
});
|
|
847
|
+
expect(planOf(moved)).toEqual([{ kind: "remove", paths: ["AGENTS.md", ".rulesync"] }]);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it("takes a settings overlay as done only when it holds every key and list item, and stops otherwise", () => {
|
|
851
|
+
const settings = JSON.stringify({ model: "opus", permissions: { allow: ["Zed"] } });
|
|
852
|
+
const plan = (overlay: string) =>
|
|
853
|
+
conflictLines(planOf(mini({ ".claude/settings.json": settings, ".repo/settings/claude.json": overlay })));
|
|
854
|
+
expect(plan(JSON.stringify({ model: "opus", other: 1, permissions: { allow: ["Zed", "Y"] } }))).toEqual([]);
|
|
855
|
+
for (const overlay of [
|
|
856
|
+
JSON.stringify({ model: "sonnet", permissions: { allow: ["Zed"] } }),
|
|
857
|
+
JSON.stringify({ model: "opus", permissions: { allow: ["Y"] } }),
|
|
858
|
+
JSON.stringify({ model: "opus", permissions: [] }),
|
|
859
|
+
JSON.stringify({ model: "opus" }),
|
|
860
|
+
"{",
|
|
861
|
+
]) {
|
|
862
|
+
expect(plan(overlay), overlay).toEqual([clash(".claude/settings.json", ".repo/settings/claude.json")]);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it("takes an overlay that already holds the text as done, not as a conflict", () => {
|
|
867
|
+
const moved = mini({
|
|
868
|
+
"AGENTS.md": `${block}\nours\n`,
|
|
869
|
+
".repo/rules/agents.md": "---\ndescription: Carried over from your earlier setup\n---\nbefore\n\nours\n\nafter\n",
|
|
870
|
+
".claude/settings.json": JSON.stringify({ permissions: { allow: ["A"] } }),
|
|
871
|
+
".repo/settings/claude.json": JSON.stringify({ permissions: { allow: ["A", "B"], deny: ["D"] }, model: "x" }),
|
|
872
|
+
});
|
|
873
|
+
expect(conflictLines(planOf(moved))).toEqual([]);
|
|
874
|
+
expect(planOf(moved).map((step) => step.kind)).toEqual(["remove"]);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it("stops when .repo/rules/agents.md holds other text", () => {
|
|
878
|
+
const m = mini({ "AGENTS.md": `${block}\nours\n`, ".repo/rules/agents.md": "other\n" });
|
|
879
|
+
expect(conflictLines(planOf(m))).toEqual([clash("AGENTS.md", ".repo/rules/agents.md")]);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("moves list items, new keys and changed values, and the hooks apart", () => {
|
|
883
|
+
const m = mini({
|
|
884
|
+
".claude/settings.json": JSON.stringify({
|
|
885
|
+
permissions: { allow: ["A", "B"], deny: ["D"] },
|
|
886
|
+
env: { X: "2", Y: "1" },
|
|
887
|
+
model: "opus",
|
|
888
|
+
}),
|
|
889
|
+
});
|
|
890
|
+
const [step] = planOf(m);
|
|
891
|
+
expect(step).toMatchObject({
|
|
892
|
+
kind: "write",
|
|
893
|
+
path: ".repo/settings/claude.json",
|
|
894
|
+
note: "moved your .claude/settings.json keys permissions.allow, permissions.deny, env.X, env.Y, model to .repo/settings/claude.json",
|
|
895
|
+
});
|
|
896
|
+
expect(JSON.parse(contents([step!])[0]!)).toEqual({
|
|
897
|
+
permissions: { allow: ["B"], deny: ["D"] },
|
|
898
|
+
env: { X: "2", Y: "1" },
|
|
899
|
+
model: "opus",
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("plans nothing for settings the stock already has, a missing file, an unreadable stock file or none generated", () => {
|
|
904
|
+
expect(planOf(mini({ ".claude/settings.json": GENERATED[".claude/settings.json"]! }))).toEqual([REMOVAL]);
|
|
905
|
+
expect(planOf(mini({}))).toEqual([REMOVAL]);
|
|
906
|
+
const unreadable = mini(
|
|
907
|
+
{ ".claude/settings.json": '{ "env": { "Z": "1" } }' },
|
|
908
|
+
{ generated: { ".claude/settings.json": "{" } },
|
|
909
|
+
);
|
|
910
|
+
expect(planOf(unreadable)).toEqual([REMOVAL]);
|
|
911
|
+
// a host left out of the config: sync generates no settings to compare with, so the file is left alone
|
|
912
|
+
const ungenerated = mini({ ".claude/settings.json": '{ "env": { "Z": "1" } }' });
|
|
913
|
+
ungenerated.generated.delete(".claude/settings.json");
|
|
914
|
+
expect(planOf(ungenerated)).toEqual([REMOVAL]);
|
|
915
|
+
expect(planOf(mini({ ".claude/settings.json": "[]" }))).toEqual([REMOVAL]);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("asks for the keys by hand when settings.json is not JSON", async () => {
|
|
919
|
+
const m = mini({ ".claude/settings.json": "{" });
|
|
920
|
+
const steps = planOf(m);
|
|
921
|
+
expect(steps[0]).toEqual({
|
|
922
|
+
kind: "note",
|
|
923
|
+
note: ".claude/settings.json is not valid JSON; copy any keys of yours into .repo/settings/claude.json",
|
|
924
|
+
});
|
|
925
|
+
expect((await applyMigration(m.root, steps))[0]).toBe(
|
|
926
|
+
"repo sync: .claude/settings.json is not valid JSON; copy any keys of yours into .repo/settings/claude.json",
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("stops when the settings overlay holds other keys", () => {
|
|
931
|
+
const m = mini({ ".claude/settings.json": '{ "model": "opus" }', ".repo/settings/claude.json": "{}\n" });
|
|
932
|
+
expect(conflictLines(planOf(m))).toEqual([clash(".claude/settings.json", ".repo/settings/claude.json")]);
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
describe("lessons and overrides", () => {
|
|
937
|
+
it("moves lessons into an existing .repo/lessons, leaves INDEX.md, and drops an identical file", async () => {
|
|
938
|
+
const m = mini({
|
|
939
|
+
".rulesync/lessons/a.md": "a\n",
|
|
940
|
+
".rulesync/lessons/b.md": "b\n",
|
|
941
|
+
".rulesync/lessons/INDEX.md": "old index\n",
|
|
942
|
+
".rulesync/lessons/sub/c.md": "c\n",
|
|
943
|
+
".repo/lessons/sub/d.md": "d\n",
|
|
944
|
+
".repo/lessons/b.md": "b\n",
|
|
945
|
+
".repo/lessons/INDEX.md": "new index\n",
|
|
946
|
+
});
|
|
947
|
+
const steps = planOf(m);
|
|
948
|
+
expect(notes(steps)).toEqual(["moved .rulesync/lessons/ (3) to .repo/lessons/"]);
|
|
949
|
+
await applyMigration(m.root, steps);
|
|
950
|
+
expect(read(m.root, ".repo/lessons/a.md")).toBe("a\n");
|
|
951
|
+
expect(read(m.root, ".repo/lessons/sub/c.md")).toBe("c\n");
|
|
952
|
+
expect(read(m.root, ".repo/lessons/sub/d.md")).toBe("d\n");
|
|
953
|
+
expect(read(m.root, ".repo/lessons/INDEX.md")).toBe("new index\n");
|
|
954
|
+
expect(exists(m.root, ".rulesync")).toBe(false);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it("plans nothing for a lessons dir that holds only its index, and stops on a differing lesson", () => {
|
|
958
|
+
expect(planOf(mini({ ".rulesync/lessons/INDEX.md": "x\n" }))).toEqual([REMOVAL]);
|
|
959
|
+
const m = mini({ ".rulesync/lessons/a.md": "a\n", ".repo/lessons/a.md": "other\n" });
|
|
960
|
+
expect(conflictLines(planOf(m))).toEqual([clash(".rulesync/lessons", ".repo/lessons")]);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it("moves permissions.json and each global-settings file that differs from stock, and no identical file", () => {
|
|
964
|
+
const m = mini(
|
|
965
|
+
{
|
|
966
|
+
".rulesync/permissions.json": '{ "deny": [] }\n',
|
|
967
|
+
".rulesync/global-settings/claude.json": "{}\n",
|
|
968
|
+
".rulesync/global-settings/codex.toml": "x = 1\n",
|
|
969
|
+
".rulesync/global-settings/grok.toml": "y = 1\n",
|
|
970
|
+
".rulesync/hooks.jsonc": "{}\r\n",
|
|
971
|
+
".rulesync/mcp-policy.jsonc": "{}\n",
|
|
972
|
+
},
|
|
973
|
+
{ stock: { "agents/global-settings/grok.toml": "y = 2\n" } },
|
|
974
|
+
);
|
|
975
|
+
expect(notes(planOf(m))).toEqual([
|
|
976
|
+
"moved your .rulesync/permissions.json to .repo/permissions.json",
|
|
977
|
+
"moved your .rulesync/global-settings/codex.toml to .repo/global-settings/codex.toml",
|
|
978
|
+
"moved your .rulesync/global-settings/grok.toml to .repo/global-settings/grok.toml (it now replaces the stock one)",
|
|
979
|
+
]);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it("stops on any other entry of the copied dir and changes nothing", async () => {
|
|
983
|
+
const m = mini({ ".rulesync/custom.md": "mine\n", ".rulesync/repo.features.jsonc": "{}\n" });
|
|
984
|
+
expect(conflictLines(planOf(m))).toEqual([yours(".rulesync/custom.md")]);
|
|
985
|
+
expect(await applyMigration(m.root, planOf(m))).toEqual([yours(".rulesync/custom.md")]);
|
|
986
|
+
expect(fs.existsSync(path.join(m.root, ".repo/custom.md"))).toBe(false);
|
|
987
|
+
expect(read(m.root, ".rulesync/custom.md")).toBe("mine\n");
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
describe("packs", () => {
|
|
992
|
+
const packs = (text: string) => ({ ".rulesync/repo.features.jsonc": text });
|
|
993
|
+
const MOVED =
|
|
994
|
+
'moved packs ["speak", "clasp"] from .rulesync/repo.features.jsonc to agents.skills in workerstack.config.ts';
|
|
995
|
+
const ASK =
|
|
996
|
+
'could not add the packs ["listen"] of .rulesync/repo.features.jsonc to workerstack.config.ts; add agents: { skills: ["listen"] } there';
|
|
997
|
+
|
|
998
|
+
it("inserts agents.skills for each known pack once, in the config's line endings", async () => {
|
|
999
|
+
const m = mini(packs('// packs\n{ "packs": ["speak", "bogus", "speak", "clasp"] }'));
|
|
1000
|
+
write(m.root, "workerstack.config.ts", SIMPLE_CONFIG.replaceAll("\n", "\r\n"));
|
|
1001
|
+
const steps = planOf(m);
|
|
1002
|
+
expect(notes(steps)).toEqual([MOVED]);
|
|
1003
|
+
expect(await applyMigration(m.root, steps)).toContain(`repo sync: ${MOVED}`);
|
|
1004
|
+
expect(read(m.root, "workerstack.config.ts")).toBe(
|
|
1005
|
+
`${DEFINE}export default defineConfig({\n agents: { skills: ["speak", "clasp"] },\n packs: ["agents"],\n})\n`.replaceAll(
|
|
1006
|
+
"\n",
|
|
1007
|
+
"\r\n",
|
|
1008
|
+
),
|
|
1009
|
+
);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("adds agents to a packs list that lacks it, so the moved agents.skills stays valid", async () => {
|
|
1013
|
+
const m = mini(packs('{ "packs": ["speak"] }'));
|
|
1014
|
+
write(m.root, "workerstack.config.ts", `${DEFINE}export default defineConfig({\n packs: ["docs"],\n})\n`);
|
|
1015
|
+
await applyMigration(m.root, planOf(m));
|
|
1016
|
+
const text = read(m.root, "workerstack.config.ts");
|
|
1017
|
+
expect(text).toContain('agents: { skills: ["speak"] }');
|
|
1018
|
+
expect(text).toMatch(/packs: \[[^\]]*"docs"[^\]]*\]/);
|
|
1019
|
+
expect(text).toMatch(/packs: \[[^\]]*"agents"[^\]]*\]/);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("puts agents alone into an empty packs list", async () => {
|
|
1023
|
+
const m = mini(packs('{ "packs": ["speak"] }'));
|
|
1024
|
+
write(m.root, "workerstack.config.ts", `${DEFINE}export default defineConfig({ packs: [] })\n`);
|
|
1025
|
+
await applyMigration(m.root, planOf(m));
|
|
1026
|
+
expect(read(m.root, "workerstack.config.ts")).toBe(
|
|
1027
|
+
`${DEFINE}export default defineConfig({\n agents: { skills: ["speak"] }, packs: ["agents"] })\n`,
|
|
1028
|
+
);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("plans nothing for no packs, a features file that is not JSON, or packs the config already lists", () => {
|
|
1032
|
+
expect(planOf(mini(packs('{ "packs": [] }')))).toEqual([REMOVAL]);
|
|
1033
|
+
expect(planOf(mini(packs("{")))).toEqual([REMOVAL]);
|
|
1034
|
+
expect(planOf(mini(packs('{ "packs": "listen" }')))).toEqual([REMOVAL]);
|
|
1035
|
+
const listed = mini(packs('{ "packs": ["listen"] }'));
|
|
1036
|
+
write(
|
|
1037
|
+
listed.root,
|
|
1038
|
+
"workerstack.config.ts",
|
|
1039
|
+
`${DEFINE}export default defineConfig({ agents: { skills: ['listen'] } })\n`,
|
|
1040
|
+
);
|
|
1041
|
+
expect(planOf(listed)).toEqual([REMOVAL]);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it("asks the user to add the key when the config has an agents key without the pack, or no defineConfig call", () => {
|
|
1045
|
+
const own = mini(packs('{ "packs": ["listen"] }'));
|
|
1046
|
+
write(
|
|
1047
|
+
own.root,
|
|
1048
|
+
"workerstack.config.ts",
|
|
1049
|
+
`${DEFINE}export default defineConfig({ "agents": { hosts: ["claude"] } })\n`,
|
|
1050
|
+
);
|
|
1051
|
+
expect(planOf(own)[0]).toEqual({ kind: "note", note: ASK });
|
|
1052
|
+
const plain = mini(packs('{ "packs": ["listen"] }'));
|
|
1053
|
+
write(plain.root, "workerstack.config.ts", "export default {}\n");
|
|
1054
|
+
expect(planOf(plain)[0]).toEqual({ kind: "note", note: ASK });
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it("puts the config back and says so when the inserted key does not take effect", async () => {
|
|
1058
|
+
const m = mini(packs('{ "packs": ["listen"] }'));
|
|
1059
|
+
const before = `${DEFINE}// call defineConfig({ later\nexport default defineConfig(\n { packs: ["agents"] },\n)\n`;
|
|
1060
|
+
write(m.root, "workerstack.config.ts", before);
|
|
1061
|
+
const steps = planOf(m);
|
|
1062
|
+
expect(steps[0]).toMatchObject({ kind: "write", path: "workerstack.config.ts" });
|
|
1063
|
+
expect(await applyMigration(m.root, steps)).toContain(
|
|
1064
|
+
'repo sync: could not add agents.skills ["listen"] to workerstack.config.ts; add agents: { skills: ["listen"] } there',
|
|
1065
|
+
);
|
|
1066
|
+
expect(read(m.root, "workerstack.config.ts")).toBe(before);
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
describe("untracking", () => {
|
|
1071
|
+
it("lists only the generated paths of the enabled hosts that git tracks", () => {
|
|
1072
|
+
const m = mini(
|
|
1073
|
+
{
|
|
1074
|
+
"AGENTS.md": "x\n",
|
|
1075
|
+
".mcp.json": "{}\n",
|
|
1076
|
+
".claude/settings.json": "{}\n",
|
|
1077
|
+
".cursor/hooks.json": "{}\n",
|
|
1078
|
+
".vscode/tasks.json": "{}\n",
|
|
1079
|
+
},
|
|
1080
|
+
{ git: true },
|
|
1081
|
+
);
|
|
1082
|
+
const claudeOnly: ResolvedConfig = {
|
|
1083
|
+
packs: ["agents"],
|
|
1084
|
+
agents: { hosts: ["claude"], skills: [] },
|
|
1085
|
+
...BASE_CONFIG,
|
|
1086
|
+
};
|
|
1087
|
+
expect(planOf(m, claudeOnly).find((step) => step.kind === "untrack")).toEqual({
|
|
1088
|
+
kind: "untrack",
|
|
1089
|
+
paths: ["AGENTS.md", ".mcp.json", ".claude/"],
|
|
1090
|
+
});
|
|
1091
|
+
expect(planOf(m).find((step) => step.kind === "untrack")).toEqual({
|
|
1092
|
+
kind: "untrack",
|
|
1093
|
+
paths: ["AGENTS.md", ".mcp.json", ".claude/", ".cursor/"],
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it("plans no untrack when nothing generated is tracked, or when git cannot answer", () => {
|
|
1098
|
+
expect(planOf(mini({ "package.json": "{}\n" }, { git: true }))).toEqual([REMOVAL]);
|
|
1099
|
+
const broken = mini({ ".mcp.json": "{}\n" });
|
|
1100
|
+
fs.mkdirSync(path.join(broken.root, ".git"));
|
|
1101
|
+
expect(planOf(broken)).toEqual([REMOVAL]);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("stops with the git message when the index cannot be changed, and leaves the copy for the next run", async () => {
|
|
1105
|
+
const m = mini({ "AGENTS.md": "x\n" }, { git: true });
|
|
1106
|
+
fs.writeFileSync(path.join(m.root, ".git", "index.lock"), "");
|
|
1107
|
+
await expect(applyMigration(m.root, planOf(m))).rejects.toThrow(/git rm --cached failed: .*index\.lock/s);
|
|
1108
|
+
expect(exists(m.root, COPIED_MARKER)).toBe(true);
|
|
1109
|
+
fs.rmSync(path.join(m.root, ".git", "index.lock"));
|
|
1110
|
+
expect(await applyMigration(m.root, planOf(m))).toEqual([
|
|
1111
|
+
"repo sync: untracked AGENTS.md (now generated) and removed the copied .rulesync/; commit the result",
|
|
1112
|
+
]);
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
describe("repo sync and the migration", () => {
|
|
1117
|
+
/** A consumer whose only copied-install trace is the marker, with this repo linked as the stock package. */
|
|
1118
|
+
function bare(manifest: string): string {
|
|
1119
|
+
const root = tempDir();
|
|
1120
|
+
write(root, "workerstack.config.ts", "export default {}\n");
|
|
1121
|
+
write(root, "package.json", manifest);
|
|
1122
|
+
write(root, COPIED_MARKER, "// copied script\n");
|
|
1123
|
+
fs.mkdirSync(path.join(root, "node_modules", "@workerstack"), { recursive: true });
|
|
1124
|
+
fs.symlinkSync(SELF_ROOT, path.join(root, "node_modules", "@workerstack", "repo"), "dir");
|
|
1125
|
+
return root;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
it("--check says 1 migration step for a single pending step", async () => {
|
|
1129
|
+
const result = await sync(bare('{ "name": "x" }\n'), ["--check"]);
|
|
1130
|
+
expect(result).toMatchObject({
|
|
1131
|
+
code: 1,
|
|
1132
|
+
err: ["repo sync: 1 migration step pending; run `bun repo sync`\n"],
|
|
1133
|
+
out: [],
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it("reports a conflict as exit 0 under --postinstall, and a failed untrack as an error", async () => {
|
|
1138
|
+
const conflict = copied();
|
|
1139
|
+
write(conflict, ".repo/rules/team.md", "other\n");
|
|
1140
|
+
const result = await sync(conflict, ["--postinstall"]);
|
|
1141
|
+
expect(result.code).toBe(0);
|
|
1142
|
+
expect(result.err).toHaveLength(1);
|
|
1143
|
+
|
|
1144
|
+
const locked = copied();
|
|
1145
|
+
fs.writeFileSync(path.join(locked, ".git", "index.lock"), "");
|
|
1146
|
+
const failed = await sync(locked, [], true);
|
|
1147
|
+
expect(failed.code).toBe(1);
|
|
1148
|
+
expect(failed.err).toEqual([expect.stringMatching(/^repo sync: git rm --cached failed: /)]);
|
|
1149
|
+
expect(exists(locked, COPIED_MARKER)).toBe(true);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it("reports a config that no longer loads after the migration", async () => {
|
|
1153
|
+
const root = copied();
|
|
1154
|
+
let loads = 0;
|
|
1155
|
+
const load: typeof loadConfig = async (dir) => {
|
|
1156
|
+
loads += 1;
|
|
1157
|
+
return loads === 1 ? loadConfig(dir) : { ok: false, errors: ["workerstack.config.ts: copy refused"] };
|
|
1158
|
+
};
|
|
1159
|
+
const err: string[] = [];
|
|
1160
|
+
const code = await runSync(
|
|
1161
|
+
[],
|
|
1162
|
+
{ cwd: root, stdout: () => {}, stderr: (s) => err.push(s) },
|
|
1163
|
+
{ generators: [], post: [], load },
|
|
1164
|
+
);
|
|
1165
|
+
expect(code).toBe(1);
|
|
1166
|
+
expect(err.join("")).toContain("copy refused");
|
|
1167
|
+
expect(exists(root, COPIED_MARKER)).toBe(false);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it("never migrates this repo, whatever files it holds", async () => {
|
|
1171
|
+
const root = bare('{ "name": "@workerstack/repo" }\n');
|
|
1172
|
+
const result = await sync(root, ["--check"]);
|
|
1173
|
+
expect(result.err.join("")).not.toContain("migration");
|
|
1174
|
+
expect(exists(root, COPIED_MARKER)).toBe(true);
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
describe("copies of older stock versions", () => {
|
|
1179
|
+
const OLD_RULE = "---\nroot: false\n---\n# Overview\n\n## Retired section\n\nold stock text\n";
|
|
1180
|
+
const OLD_HOOKS = '{ "old": true }\n';
|
|
1181
|
+
const hist = (entries: Record<string, string[]>): MigrateDeps => ({
|
|
1182
|
+
history: Object.fromEntries(Object.entries(entries).map(([rel, texts]) => [rel, texts.map(hashText)])),
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
it("removes a copy equal to an older stock version instead of moving it, whatever its line endings", () => {
|
|
1186
|
+
const m = mini({
|
|
1187
|
+
".rulesync/rules/overview.md": OLD_RULE,
|
|
1188
|
+
".rulesync/rules/retired.md": "a rule stock dropped\r\n",
|
|
1189
|
+
".rulesync/subagents/gone.md": "a subagent stock dropped\n",
|
|
1190
|
+
".rulesync/hooks.jsonc": OLD_HOOKS,
|
|
1191
|
+
".rulesync/global-settings/old.toml": "x = 1\n",
|
|
1192
|
+
".rulesync/reference/hooks.md": "# Hooks\n\n## Retired\n\nold\n",
|
|
1193
|
+
".rulesync/reference/skills-guide.md": "# Guide\n",
|
|
1194
|
+
".agents/skills/ask/SKILL.md": "old ask\n",
|
|
1195
|
+
".agents/skills/gone/SKILL.md": "old gone\n",
|
|
1196
|
+
".claude/settings.json": '{ "env": { "OLD": "1" } }\n',
|
|
1197
|
+
"AGENTS.md": "old whole agents file\n",
|
|
1198
|
+
});
|
|
1199
|
+
const deps = hist({
|
|
1200
|
+
".rulesync/rules/overview.md": [OLD_RULE],
|
|
1201
|
+
".rulesync/rules/retired.md": ["a rule stock dropped\n"],
|
|
1202
|
+
".rulesync/subagents/gone.md": ["a subagent stock dropped\n"],
|
|
1203
|
+
".rulesync/hooks.jsonc": [OLD_HOOKS],
|
|
1204
|
+
".rulesync/global-settings/old.toml": ["x = 1\n"],
|
|
1205
|
+
"references/hooks.md": ["# Hooks\n\n## Retired\n\nold\n"],
|
|
1206
|
+
".rulesync/reference/skills-guide.md": ["# Guide\n"],
|
|
1207
|
+
".agents/skills/ask/SKILL.md": ["old ask\n"],
|
|
1208
|
+
".agents/skills/gone/SKILL.md": ["old gone\n"],
|
|
1209
|
+
".claude/settings.json": ['{ "env": { "OLD": "1" } }\n'],
|
|
1210
|
+
"AGENTS.md": ["old whole agents file\n"],
|
|
1211
|
+
});
|
|
1212
|
+
expect(planOf(m, CONFIG, deps)).toEqual([
|
|
1213
|
+
{ kind: "remove", paths: [".agents/skills/ask", ".agents/skills/gone", ".rulesync"] },
|
|
1214
|
+
]);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
it("still moves an edit of an older version, and a file the history does not know", () => {
|
|
1218
|
+
const m = mini({
|
|
1219
|
+
".rulesync/rules/overview.md": `${OLD_RULE}\n## Ours\n\nmine\n`,
|
|
1220
|
+
".rulesync/hooks.jsonc": '{ "old": true, "mine": 1 }\n',
|
|
1221
|
+
".agents/skills/ask/SKILL.md": "old ask\n",
|
|
1222
|
+
".agents/skills/ask/extra.md": "my addition\n",
|
|
1223
|
+
});
|
|
1224
|
+
const deps = hist({
|
|
1225
|
+
".rulesync/rules/overview.md": [OLD_RULE],
|
|
1226
|
+
".rulesync/hooks.jsonc": [OLD_HOOKS],
|
|
1227
|
+
".agents/skills/ask/SKILL.md": ["old ask\n"],
|
|
1228
|
+
});
|
|
1229
|
+
expect(notes(planOf(m, CONFIG, deps))).toEqual([
|
|
1230
|
+
'moved your sections "Retired section", "Ours" of .rulesync/rules/overview.md to .repo/rules/overview-local.md',
|
|
1231
|
+
"moved your edited .agents/skills/ask to .repo/skills/ask (it now replaces the stock skill)",
|
|
1232
|
+
"moved your .rulesync/hooks.jsonc to .repo/hooks.jsonc (it now replaces the stock hooks)",
|
|
1233
|
+
]);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
it("uses the shipped table: the recorded install's stock copies are all unedited", async () => {
|
|
1237
|
+
const root = copied();
|
|
1238
|
+
const moved = notes(planMigration(root, stockOf(root), ...(await fixturePlanned())));
|
|
1239
|
+
expect(moved).toEqual(LINES.map((line) => line.replace("repo sync: ", "")));
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
describe("no unknown text is deleted", () => {
|
|
1244
|
+
const none: MigrateDeps = { history: {} };
|
|
1245
|
+
|
|
1246
|
+
it("moves a stock-named rule edited inside its sections whole, as an override", async () => {
|
|
1247
|
+
const m = mini({ ".rulesync/rules/overview.md": "---\nroot: true\n---\n# Overview\n\n## Stock\n\nedited body\n" });
|
|
1248
|
+
const steps = planOf(m, CONFIG, none);
|
|
1249
|
+
expect(notes(steps)).toEqual([
|
|
1250
|
+
"moved your edited .rulesync/rules/overview.md to .repo/rules/overview.md (it now replaces the stock rule)",
|
|
1251
|
+
]);
|
|
1252
|
+
expect(await applyMigration(m.root, steps)).toContain(
|
|
1253
|
+
"repo sync: moved your edited .rulesync/rules/overview.md to .repo/rules/overview.md (it now replaces the stock rule)",
|
|
1254
|
+
);
|
|
1255
|
+
expect(read(m.root, ".repo/rules/overview.md")).toContain("edited body");
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it("moves a stock-named reference edited inside its sections to references/", () => {
|
|
1259
|
+
const m = mini({ ".rulesync/reference/cascade.md": "# Cascade\n\n## Stock\n\nedited\n" });
|
|
1260
|
+
expect(notes(planOf(m, CONFIG, none))).toEqual([
|
|
1261
|
+
"moved your edited .rulesync/reference/cascade.md to references/cascade.md",
|
|
1262
|
+
]);
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it("stops on scripts, the pipeline file and settings other than claude.json that match no stock version, and drops those that do", () => {
|
|
1266
|
+
const m = mini(
|
|
1267
|
+
{
|
|
1268
|
+
".rulesync/scripts/mine.ts": "export {}\n",
|
|
1269
|
+
".rulesync/scripts/deep/tool.ts": "export {}\n",
|
|
1270
|
+
".rulesync/scripts/stock.ts": "stock script\n",
|
|
1271
|
+
".rulesync/repo.pipeline.jsonc": '{ "mine": 1 }\n',
|
|
1272
|
+
".rulesync/settings/claude.json": "{}\n",
|
|
1273
|
+
".rulesync/settings/codex.json": "{}\n",
|
|
1274
|
+
},
|
|
1275
|
+
{ stock: { "cli/scripts/stock.ts": "stock script\n" } },
|
|
1276
|
+
);
|
|
1277
|
+
const steps = planOf(m, CONFIG, none);
|
|
1278
|
+
expect(conflictLines(steps)).toEqual([
|
|
1279
|
+
yours(".rulesync/scripts/deep/tool.ts"),
|
|
1280
|
+
yours(".rulesync/scripts/mine.ts"),
|
|
1281
|
+
yours(".rulesync/settings/codex.json"),
|
|
1282
|
+
yours(".rulesync/repo.pipeline.jsonc"),
|
|
1283
|
+
]);
|
|
1284
|
+
// An edited settings/claude.json is the overlay's own file and keeps moving there.
|
|
1285
|
+
expect(notes(steps)).toEqual(["moved your .rulesync/settings/claude.json to .repo/settings/claude.json"]);
|
|
1286
|
+
const old = hashText("old script\n");
|
|
1287
|
+
const known = mini(
|
|
1288
|
+
{ ".rulesync/scripts/old.ts": "old script\n", ".rulesync/repo.pipeline.jsonc": "{}\n" },
|
|
1289
|
+
{
|
|
1290
|
+
stock: { "agents/repo.pipeline.jsonc": "{}\n" },
|
|
1291
|
+
},
|
|
1292
|
+
);
|
|
1293
|
+
expect(planOf(known, CONFIG, { history: { ".rulesync/scripts/old.ts": [old] } })).toEqual([REMOVAL]);
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
it("stops on any other entry of the copied dir, one line per entry, and leaves reference and the features file to their own handling", () => {
|
|
1297
|
+
const m = mini({
|
|
1298
|
+
".rulesync/notes.md": "mine\n",
|
|
1299
|
+
".rulesync/misc/a.txt": "a\n",
|
|
1300
|
+
".rulesync/repo.features.jsonc": "{}\n",
|
|
1301
|
+
".rulesync/reference/deploy.md": "# Deploy\n",
|
|
1302
|
+
});
|
|
1303
|
+
const steps = planOf(m, CONFIG, none);
|
|
1304
|
+
expect(conflictLines(steps)).toEqual([yours(".rulesync/misc"), yours(".rulesync/notes.md")]);
|
|
1305
|
+
expect(notes(steps)).toEqual(["moved .rulesync/reference/deploy.md to references/deploy.md"]);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
it("moves a folder found where a rule, subagent or global setting should be", () => {
|
|
1309
|
+
const m = mini({
|
|
1310
|
+
".rulesync/rules/folder/a.md": "a\n",
|
|
1311
|
+
".rulesync/subagents/folder/b.md": "b\n",
|
|
1312
|
+
".rulesync/global-settings/folder/c.json": "{}\n",
|
|
1313
|
+
});
|
|
1314
|
+
expect(notes(planOf(m, CONFIG, none))).toEqual([
|
|
1315
|
+
"moved .rulesync/rules/folder to .repo/rules/folder",
|
|
1316
|
+
"moved .rulesync/subagents/folder to .repo/subagents/folder",
|
|
1317
|
+
"moved your .rulesync/global-settings/folder to .repo/global-settings/folder",
|
|
1318
|
+
]);
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it("refuses to overwrite a differing file when a move meets one written earlier in the run", async () => {
|
|
1322
|
+
const m = mini({ ".rulesync/settings/claude.json": '{ "a": 1 }\n', ".repo/settings/claude.json": '{ "b": 2 }\n' });
|
|
1323
|
+
await expect(
|
|
1324
|
+
applyMigration(m.root, [
|
|
1325
|
+
{ kind: "move", from: ".rulesync/settings/claude.json", to: ".repo/settings/claude.json", note: "x" },
|
|
1326
|
+
]),
|
|
1327
|
+
).rejects.toThrow(/already exists and differs/);
|
|
1328
|
+
expect(read(m.root, ".rulesync/settings/claude.json")).toBe('{ "a": 1 }\n');
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
describe("hooks in .claude/settings.json", () => {
|
|
1333
|
+
const stockGroup = { matcher: "Bash", hooks: [{ type: "command", command: "stock-cmd", timeout: 5 }] };
|
|
1334
|
+
const stockSettings = JSON.stringify({ hooks: { PreToolUse: [stockGroup] } });
|
|
1335
|
+
const opts = { generated: { ".claude/settings.json": stockSettings } };
|
|
1336
|
+
const TO = ".repo/settings/claude.json";
|
|
1337
|
+
|
|
1338
|
+
it("carries hook groups and commands that no stock version ran, under the same event", async () => {
|
|
1339
|
+
const mine = { matcher: "Edit", hooks: [{ type: "command", command: "mine" }] };
|
|
1340
|
+
const mixed = {
|
|
1341
|
+
matcher: "Bash",
|
|
1342
|
+
hooks: [
|
|
1343
|
+
{ type: "command", command: "stock-cmd", timeout: 5 },
|
|
1344
|
+
{ type: "command", command: "mine-too" },
|
|
1345
|
+
],
|
|
1346
|
+
};
|
|
1347
|
+
const m = mini(
|
|
1348
|
+
{
|
|
1349
|
+
".claude/settings.json": JSON.stringify({
|
|
1350
|
+
permissions: { allow: ["X"] },
|
|
1351
|
+
hooks: {
|
|
1352
|
+
PreToolUse: [stockGroup, mixed],
|
|
1353
|
+
PostToolUse: [mine],
|
|
1354
|
+
Stop: [{ matcher: "*", hooks: [{ type: "command", command: "old stock" }] }],
|
|
1355
|
+
},
|
|
1356
|
+
}),
|
|
1357
|
+
},
|
|
1358
|
+
opts,
|
|
1359
|
+
);
|
|
1360
|
+
const deps: MigrateDeps = { history: { [HOOK_COMMANDS_KEY]: [hashText("old stock")] } };
|
|
1361
|
+
const steps = planOf(m, CONFIG, deps);
|
|
1362
|
+
expect(notes(steps).slice(0, 1)).toEqual([
|
|
1363
|
+
`moved your .claude/settings.json keys permissions to ${TO}\nmoved your .claude/settings.json hooks PreToolUse (1) to ${TO}\nmoved your .claude/settings.json hooks PostToolUse (1) to ${TO}`,
|
|
1364
|
+
]);
|
|
1365
|
+
expect(JSON.parse(contents(steps)[0]!)).toEqual({
|
|
1366
|
+
permissions: { allow: ["X"] },
|
|
1367
|
+
hooks: {
|
|
1368
|
+
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "mine-too" }] }],
|
|
1369
|
+
PostToolUse: [mine],
|
|
1370
|
+
},
|
|
1371
|
+
});
|
|
1372
|
+
expect((await applyMigration(m.root, steps)).slice(0, 3)).toEqual([
|
|
1373
|
+
`repo sync: moved your .claude/settings.json keys permissions to ${TO}`,
|
|
1374
|
+
`repo sync: moved your .claude/settings.json hooks PreToolUse (1) to ${TO}`,
|
|
1375
|
+
`repo sync: moved your .claude/settings.json hooks PostToolUse (1) to ${TO}`,
|
|
1376
|
+
]);
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
it("carries only hooks when nothing else is new, and plans nothing when every command is stock", () => {
|
|
1380
|
+
const own = { type: "command", command: "mine" };
|
|
1381
|
+
const only = mini(
|
|
1382
|
+
{ ".claude/settings.json": JSON.stringify({ hooks: { PostToolUse: [{ matcher: "x", hooks: [own] }] } }) },
|
|
1383
|
+
opts,
|
|
1384
|
+
);
|
|
1385
|
+
expect(notes(planOf(only, CONFIG, { history: {} }))).toEqual([
|
|
1386
|
+
`moved your .claude/settings.json hooks PostToolUse (1) to ${TO}`,
|
|
1387
|
+
]);
|
|
1388
|
+
const stockOnly = mini({ ".claude/settings.json": JSON.stringify({ hooks: { PreToolUse: [stockGroup] } }) }, opts);
|
|
1389
|
+
expect(planOf(stockOnly, CONFIG, { history: {} })).toEqual([REMOVAL]);
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
it("carries a group or an event value it cannot read as it is, and a hook with no command", () => {
|
|
1393
|
+
const m = mini(
|
|
1394
|
+
{
|
|
1395
|
+
".claude/settings.json": JSON.stringify({
|
|
1396
|
+
hooks: { A: ["text"], B: "odd", C: [{ matcher: "m", hooks: [{ type: "prompt" }] }], D: [{ matcher: "m" }] },
|
|
1397
|
+
}),
|
|
1398
|
+
},
|
|
1399
|
+
opts,
|
|
1400
|
+
);
|
|
1401
|
+
expect(JSON.parse(contents(planOf(m, CONFIG, { history: {} }))[0]!)).toEqual({
|
|
1402
|
+
hooks: { A: ["text"], B: ["odd"], C: [{ matcher: "m", hooks: [{ type: "prompt" }] }], D: [{ matcher: "m" }] },
|
|
1403
|
+
});
|
|
1404
|
+
const notObject = mini({ ".claude/settings.json": JSON.stringify({ hooks: "nope", env: { Z: "1" } }) }, opts);
|
|
1405
|
+
expect(notes(planOf(notObject, CONFIG, { history: {} }))).toEqual([
|
|
1406
|
+
`moved your .claude/settings.json keys env to ${TO}`,
|
|
1407
|
+
]);
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
describe("adopting what a consumer already has where sync generates", () => {
|
|
1412
|
+
const RULE = "# Team rules\n\nAlways run the linter.\n";
|
|
1413
|
+
const STOP = (rel: string) =>
|
|
1414
|
+
`repo sync: ${rel} is yours and would be ignored; move it under .repo/ (see node_modules/@workerstack/repo/references/map.md) or delete it, then run \`bun repo sync\`\n`;
|
|
1415
|
+
|
|
1416
|
+
/** A consumer that never ran the copying init: a config, the stock linked in, and `files` committed. */
|
|
1417
|
+
function plain(files: Record<string, string>, options: { git?: boolean } = {}): string {
|
|
1418
|
+
const root = tempDir();
|
|
1419
|
+
write(root, "workerstack.config.ts", "export default {}\n");
|
|
1420
|
+
write(root, "package.json", JSON.stringify({ name: "fixture" }));
|
|
1421
|
+
for (const [rel, text] of Object.entries(files)) write(root, rel, text);
|
|
1422
|
+
fs.mkdirSync(path.join(root, "node_modules", "@workerstack"), { recursive: true });
|
|
1423
|
+
fs.symlinkSync(SELF_ROOT, path.join(root, "node_modules", "@workerstack", "repo"), "dir");
|
|
1424
|
+
if (options.git !== false) {
|
|
1425
|
+
git(root, "init", "-q");
|
|
1426
|
+
fs.appendFileSync(path.join(root, ".git", "info", "exclude"), "node_modules/\n");
|
|
1427
|
+
git(root, "add", "-A");
|
|
1428
|
+
git(root, "commit", "-q", "-m", "own files");
|
|
1429
|
+
}
|
|
1430
|
+
return root;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const tracked = (root: string): string[] => git(root, "ls-files").split("\n").filter(Boolean);
|
|
1434
|
+
|
|
1435
|
+
it("takes a hand-written tracked AGENTS.md into .repo/rules/agents.md, the generated AGENTS.md and out of the index", async () => {
|
|
1436
|
+
const root = plain({ "AGENTS.md": RULE });
|
|
1437
|
+
const result = await sync(root, [], true);
|
|
1438
|
+
expect(result.err).toEqual([]);
|
|
1439
|
+
expect(result.code).toBe(0);
|
|
1440
|
+
expect(result.out).toContain("repo sync: adopted your AGENTS.md as .repo/rules/agents.md\n");
|
|
1441
|
+
expect(result.out).toContain("repo sync: untracked AGENTS.md (now generated); commit the result\n");
|
|
1442
|
+
expect(read(root, ".repo/rules/agents.md")).toBe(
|
|
1443
|
+
`---\ndescription: Carried over from your earlier setup\n---\n${RULE.trim()}\n`,
|
|
1444
|
+
);
|
|
1445
|
+
expect(read(root, "AGENTS.md")).toContain("Always run the linter.");
|
|
1446
|
+
expect(tracked(root)).not.toContain("AGENTS.md");
|
|
1447
|
+
}, 120_000);
|
|
1448
|
+
|
|
1449
|
+
it("adopts nothing a previous sync wrote: an edited generated file is overwritten, not adopted", async () => {
|
|
1450
|
+
const root = plain({});
|
|
1451
|
+
expect((await sync(root, [], true)).code).toBe(0);
|
|
1452
|
+
write(root, "AGENTS.md", `${read(root, "AGENTS.md")}\nstray edit\n`);
|
|
1453
|
+
expect((await sync(root, [], true)).code).toBe(0);
|
|
1454
|
+
expect(exists(root, ".repo/rules/agents.md")).toBe(false);
|
|
1455
|
+
expect(read(root, "AGENTS.md")).not.toContain("stray edit");
|
|
1456
|
+
}, 120_000);
|
|
1457
|
+
|
|
1458
|
+
it("adopts nothing that equals what sync would generate, even with the manifest gone", async () => {
|
|
1459
|
+
const root = plain({});
|
|
1460
|
+
expect((await sync(root, [], true)).code).toBe(0);
|
|
1461
|
+
fs.rmSync(path.join(root, ".repo", "tmp", "sync.json"));
|
|
1462
|
+
const result = await sync(root, [], true);
|
|
1463
|
+
expect(result.code).toBe(0);
|
|
1464
|
+
expect(result.out.join("")).not.toContain("adopted");
|
|
1465
|
+
expect(exists(root, ".repo/rules")).toBe(false);
|
|
1466
|
+
}, 120_000);
|
|
1467
|
+
|
|
1468
|
+
it("takes the keys and hooks of a .claude/settings.json it does not generate into .repo/settings/claude.json", async () => {
|
|
1469
|
+
const mine = {
|
|
1470
|
+
permissions: { allow: ["Bash(make lint:*)"] },
|
|
1471
|
+
hooks: { PostToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "make post-write" }] }] },
|
|
1472
|
+
};
|
|
1473
|
+
const root = plain({ ".claude/settings.json": JSON.stringify(mine) });
|
|
1474
|
+
const result = await sync(root, [], true);
|
|
1475
|
+
expect(result.err).toEqual([]);
|
|
1476
|
+
expect(result.out).toContain(
|
|
1477
|
+
"repo sync: moved your .claude/settings.json keys permissions.allow to .repo/settings/claude.json\n",
|
|
1478
|
+
);
|
|
1479
|
+
expect(JSON.parse(read(root, ".repo/settings/claude.json"))).toEqual(mine);
|
|
1480
|
+
const settings = JSON.parse(read(root, ".claude/settings.json")) as { permissions: { allow: string[] } };
|
|
1481
|
+
expect(settings.permissions.allow).toContain("Bash(make lint:*)");
|
|
1482
|
+
expect(read(root, ".claude/settings.json")).toContain("make post-write");
|
|
1483
|
+
expect(tracked(root).filter((rel) => rel.startsWith(".claude/"))).toEqual([]);
|
|
1484
|
+
}, 120_000);
|
|
1485
|
+
|
|
1486
|
+
it("stops on a .claude/settings.json or .mcp.json that is not a JSON object, changing nothing", async () => {
|
|
1487
|
+
for (const rel of [".claude/settings.json", ".mcp.json"]) {
|
|
1488
|
+
for (const text of ["{", "[]"]) {
|
|
1489
|
+
const root = plain({ [rel]: text });
|
|
1490
|
+
const before = snapshot(root);
|
|
1491
|
+
const result = await sync(root, [], true);
|
|
1492
|
+
expect(result.code).toBe(1);
|
|
1493
|
+
expect(result.err).toEqual([STOP(rel)]);
|
|
1494
|
+
expect(snapshot(root)).toEqual(before);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}, 240_000);
|
|
1498
|
+
|
|
1499
|
+
it("takes the servers of a .mcp.json it does not generate into .repo/mcp.json, merged back into the generated one", async () => {
|
|
1500
|
+
const server = { command: "npx", args: ["-y", "docs-mcp"] };
|
|
1501
|
+
const root = plain({ ".mcp.json": JSON.stringify({ mcpServers: { docs: server } }) });
|
|
1502
|
+
const result = await sync(root, [], true);
|
|
1503
|
+
expect(result.err).toEqual([]);
|
|
1504
|
+
expect(result.out).toContain("repo sync: moved your .mcp.json keys mcpServers.docs to .repo/mcp.json\n");
|
|
1505
|
+
expect(JSON.parse(read(root, ".repo/mcp.json"))).toEqual({ mcpServers: { docs: server } });
|
|
1506
|
+
expect((JSON.parse(read(root, ".mcp.json")) as { mcpServers: Record<string, unknown> }).mcpServers.docs).toEqual(
|
|
1507
|
+
server,
|
|
1508
|
+
);
|
|
1509
|
+
expect(tracked(root)).not.toContain(".mcp.json");
|
|
1510
|
+
}, 120_000);
|
|
1511
|
+
|
|
1512
|
+
it("moves a real skill dir under .agents/skills or .claude/skills to .repo/skills and links it", async () => {
|
|
1513
|
+
const root = plain({
|
|
1514
|
+
".claude/skills/deploy/SKILL.md": "---\nname: deploy\ndescription: ship\n---\nShip it.\n",
|
|
1515
|
+
".agents/skills/lint/SKILL.md": "---\nname: lint\ndescription: lint\n---\nLint it.\n",
|
|
1516
|
+
});
|
|
1517
|
+
const result = await sync(root, [], true);
|
|
1518
|
+
expect(result.err).toEqual([]);
|
|
1519
|
+
expect(result.out).toContain("repo sync: moved .claude/skills/deploy to .repo/skills/deploy\n");
|
|
1520
|
+
expect(result.out).toContain("repo sync: moved .agents/skills/lint to .repo/skills/lint\n");
|
|
1521
|
+
expect(read(root, ".repo/skills/deploy/SKILL.md")).toContain("Ship it.");
|
|
1522
|
+
expect(fs.lstatSync(path.join(root, ".claude/skills/deploy")).isSymbolicLink()).toBe(true);
|
|
1523
|
+
expect(fs.lstatSync(path.join(root, ".agents/skills/lint")).isSymbolicLink()).toBe(true);
|
|
1524
|
+
expect(tracked(root).filter((rel) => rel.startsWith(".claude/") || rel.startsWith(".agents/"))).toEqual([]);
|
|
1525
|
+
}, 120_000);
|
|
1526
|
+
|
|
1527
|
+
it("leaves a real skill dir that is an unedited stock copy to be replaced by its link", async () => {
|
|
1528
|
+
const root = plain({});
|
|
1529
|
+
fs.cpSync(path.join(SELF_ROOT, ".agents", "skills", "ask"), path.join(root, ".claude", "skills", "ask"), {
|
|
1530
|
+
recursive: true,
|
|
1531
|
+
});
|
|
1532
|
+
const result = await sync(root, [], true);
|
|
1533
|
+
expect(result.code).toBe(0);
|
|
1534
|
+
expect(exists(root, ".repo/skills")).toBe(false);
|
|
1535
|
+
expect(fs.lstatSync(path.join(root, ".claude/skills/ask")).isSymbolicLink()).toBe(true);
|
|
1536
|
+
}, 120_000);
|
|
1537
|
+
|
|
1538
|
+
it("stops, changing nothing, on a file sync would overwrite with other text", async () => {
|
|
1539
|
+
const root = plain({});
|
|
1540
|
+
write(root, ".claude/agents/scout.md", "my own scout\n");
|
|
1541
|
+
const before = snapshot(root);
|
|
1542
|
+
const result = await sync(root, [], true);
|
|
1543
|
+
expect(result.code).toBe(1);
|
|
1544
|
+
expect(result.err).toEqual([STOP(".claude/agents/scout.md")]);
|
|
1545
|
+
expect(snapshot(root)).toEqual(before);
|
|
1546
|
+
}, 120_000);
|
|
1547
|
+
|
|
1548
|
+
it("stops on a tracked file under a host dir that it cannot adopt, one line per file, and only those", async () => {
|
|
1549
|
+
const root = plain({
|
|
1550
|
+
".claude/commands/b.md": "b\n",
|
|
1551
|
+
".claude/commands/a.md": "a\n",
|
|
1552
|
+
".cursor/rules/x.mdc": "x\n",
|
|
1553
|
+
});
|
|
1554
|
+
write(root, ".claude/commands/untracked.md", "u\n");
|
|
1555
|
+
const before = snapshot(root);
|
|
1556
|
+
const result = await sync(root, [], true);
|
|
1557
|
+
expect(result.code).toBe(1);
|
|
1558
|
+
expect(result.err).toEqual([
|
|
1559
|
+
STOP(".claude/commands/a.md"),
|
|
1560
|
+
STOP(".claude/commands/b.md"),
|
|
1561
|
+
STOP(".cursor/rules/x.mdc"),
|
|
1562
|
+
]);
|
|
1563
|
+
expect(snapshot(root)).toEqual(before);
|
|
1564
|
+
expect(tracked(root)).toContain(".claude/commands/a.md");
|
|
1565
|
+
}, 120_000);
|
|
1566
|
+
|
|
1567
|
+
it("leaves an untracked file it does not generate alone", async () => {
|
|
1568
|
+
const root = plain({});
|
|
1569
|
+
write(root, ".claude/settings.local.json", "{}\n");
|
|
1570
|
+
const result = await sync(root, [], true);
|
|
1571
|
+
expect(result.code).toBe(0);
|
|
1572
|
+
expect(read(root, ".claude/settings.local.json")).toBe("{}\n");
|
|
1573
|
+
}, 120_000);
|
|
1574
|
+
|
|
1575
|
+
it("reports a stop as exit 0 under --postinstall", async () => {
|
|
1576
|
+
const root = plain({ ".claude/commands/a.md": "a\n" });
|
|
1577
|
+
const result = await sync(root, ["--postinstall"], true);
|
|
1578
|
+
expect(result.code).toBe(0);
|
|
1579
|
+
expect(result.err).toEqual([STOP(".claude/commands/a.md")]);
|
|
1580
|
+
}, 120_000);
|
|
1581
|
+
|
|
1582
|
+
it("--check prints the pending count, exits 1 and writes nothing", async () => {
|
|
1583
|
+
const root = plain({ "AGENTS.md": RULE });
|
|
1584
|
+
const before = snapshot(root);
|
|
1585
|
+
const result = await sync(root, ["--check"], true);
|
|
1586
|
+
expect(result.code).toBe(1);
|
|
1587
|
+
expect(result.err).toEqual(["repo sync: 2 adoption steps pending; run `bun repo sync`\n"]);
|
|
1588
|
+
expect(snapshot(root)).toEqual(before);
|
|
1589
|
+
const one = plain({ "AGENTS.md": RULE }, { git: false });
|
|
1590
|
+
expect((await sync(one, ["--check"], true)).err).toEqual([
|
|
1591
|
+
"repo sync: 1 adoption step pending; run `bun repo sync`\n",
|
|
1592
|
+
]);
|
|
1593
|
+
}, 120_000);
|
|
1594
|
+
|
|
1595
|
+
it("stops when .repo/rules/agents.md already holds other text", async () => {
|
|
1596
|
+
const root = plain({
|
|
1597
|
+
"AGENTS.md": RULE,
|
|
1598
|
+
".repo/rules/agents.md": "---\ndescription: Carried over from your earlier setup\n---\nother\n",
|
|
1599
|
+
});
|
|
1600
|
+
const before = snapshot(root);
|
|
1601
|
+
const result = await sync(root, [], true);
|
|
1602
|
+
expect(result.code).toBe(1);
|
|
1603
|
+
expect(result.err).toEqual([
|
|
1604
|
+
"repo sync: cannot move AGENTS.md: .repo/rules/agents.md already exists and differs; merge them by hand, then run `bun repo sync`\n",
|
|
1605
|
+
]);
|
|
1606
|
+
expect(snapshot(root)).toEqual(before);
|
|
1607
|
+
}, 120_000);
|
|
1608
|
+
|
|
1609
|
+
it("adopts outside git and does not untrack", async () => {
|
|
1610
|
+
const root = plain({ "AGENTS.md": RULE }, { git: false });
|
|
1611
|
+
const result = await sync(root, [], true);
|
|
1612
|
+
expect(result.code).toBe(0);
|
|
1613
|
+
expect(result.out.join("")).not.toContain("untracked");
|
|
1614
|
+
expect(read(root, ".repo/rules/agents.md")).toContain("Always run the linter.");
|
|
1615
|
+
}, 120_000);
|
|
1616
|
+
|
|
1617
|
+
it("stops on a real file where a skill link belongs", async () => {
|
|
1618
|
+
const root = plain({});
|
|
1619
|
+
write(root, ".claude/skills/ask", "not a skill dir\n");
|
|
1620
|
+
const result = await sync(root, [], true);
|
|
1621
|
+
expect(result.code).toBe(1);
|
|
1622
|
+
expect(result.err).toEqual([STOP(".claude/skills/ask")]);
|
|
1623
|
+
}, 120_000);
|
|
1624
|
+
|
|
1625
|
+
it("reports a move that fails while it runs, and a generator error the adoption causes", async () => {
|
|
1626
|
+
const both = plain({
|
|
1627
|
+
".agents/skills/x/SKILL.md": "---\nname: x\n---\none\n",
|
|
1628
|
+
".claude/skills/x/SKILL.md": "---\nname: x\n---\ntwo\n",
|
|
1629
|
+
});
|
|
1630
|
+
const clash = await sync(both, [], true);
|
|
1631
|
+
expect(clash.code).toBe(1);
|
|
1632
|
+
expect(clash.err.join("")).toContain("already exists and differs");
|
|
1633
|
+
|
|
1634
|
+
const root = plain({ "AGENTS.md": RULE }, { git: false });
|
|
1635
|
+
const generator = (ctx: { read: (rel: string) => string | null }) =>
|
|
1636
|
+
ctx.read(".repo/rules/agents.md") === null
|
|
1637
|
+
? {
|
|
1638
|
+
outputs: [
|
|
1639
|
+
{
|
|
1640
|
+
kind: "file" as const,
|
|
1641
|
+
path: "AGENTS.md",
|
|
1642
|
+
content: "generated\n",
|
|
1643
|
+
tracked: false,
|
|
1644
|
+
managed: "whole" as const,
|
|
1645
|
+
},
|
|
1646
|
+
],
|
|
1647
|
+
errors: [],
|
|
1648
|
+
}
|
|
1649
|
+
: { outputs: [], errors: ["generator saw the adopted rule"] };
|
|
1650
|
+
const out: string[] = [];
|
|
1651
|
+
const err: string[] = [];
|
|
1652
|
+
const code = await runSync(
|
|
1653
|
+
[],
|
|
1654
|
+
{ cwd: root, stdout: (s) => out.push(s), stderr: (s) => err.push(s) },
|
|
1655
|
+
{ generators: [generator] },
|
|
1656
|
+
);
|
|
1657
|
+
expect(code).toBe(1);
|
|
1658
|
+
expect(err).toEqual(["repo sync: generator saw the adopted rule\n", "repo sync: nothing written\n"]);
|
|
1659
|
+
expect(read(root, ".repo/rules/agents.md")).toContain("Always run the linter.");
|
|
1660
|
+
}, 120_000);
|
|
1661
|
+
|
|
1662
|
+
describe("planAdoption on its own", () => {
|
|
1663
|
+
const file = (rel: string, content: string) => ({
|
|
1664
|
+
kind: "file" as const,
|
|
1665
|
+
path: rel,
|
|
1666
|
+
content,
|
|
1667
|
+
tracked: false,
|
|
1668
|
+
managed: "whole" as const,
|
|
1669
|
+
});
|
|
1670
|
+
const plan = (root: string, outputs: ReturnType<typeof file>[], deps: MigrateDeps = {}) =>
|
|
1671
|
+
planAdoption(root, stockOf(root), CONFIG, outputs, [], deps);
|
|
1672
|
+
|
|
1673
|
+
it("plans nothing for a whitespace-only AGENTS.md", () => {
|
|
1674
|
+
const root = plain({ "AGENTS.md": " \n" }, { git: false });
|
|
1675
|
+
expect(plan(root, [file("AGENTS.md", "generated\n")])).toEqual([]);
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
it("plans nothing for .mcp.json servers the generated one has, or when sync generates no JSON there", () => {
|
|
1679
|
+
const root = plain({ ".mcp.json": '{"mcpServers":{"a":{}}}' }, { git: false });
|
|
1680
|
+
expect(plan(root, [file(".mcp.json", '{"mcpServers":{"a":{},"b":{}}}')])).toEqual([]);
|
|
1681
|
+
expect(plan(root, [file(".mcp.json", "generated\n")])).toEqual([]);
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
it("skips a tracked file that is gone, a tracked link, and a tracked copy of a past stock file", () => {
|
|
1685
|
+
const root = plain({ ".claude/agents/gone.md": "x\n", ".claude/agents/old.md": "old\n" });
|
|
1686
|
+
fs.symlinkSync("../../elsewhere", path.join(root, ".claude", "linked"), "dir");
|
|
1687
|
+
git(root, "add", "-A");
|
|
1688
|
+
fs.rmSync(path.join(root, ".claude/agents/gone.md"));
|
|
1689
|
+
const history = { ".claude/agents/old.md": [hashText("old\n")] };
|
|
1690
|
+
expect(plan(root, [], { history })).toEqual([]);
|
|
1691
|
+
});
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
it("never adopts in this repo", async () => {
|
|
1695
|
+
const self = plain({ "AGENTS.md": RULE }, { git: false });
|
|
1696
|
+
write(self, "package.json", JSON.stringify({ name: "@workerstack/repo" }));
|
|
1697
|
+
expect((await sync(self, ["--check"], true)).err.join("")).not.toContain("adoption");
|
|
1698
|
+
}, 180_000);
|
|
1699
|
+
|
|
1700
|
+
it("keeps the adoption and writes nothing else when its result is invalid", async () => {
|
|
1701
|
+
const root = plain({ "AGENTS.md": RULE }, { git: false });
|
|
1702
|
+
const agents = {
|
|
1703
|
+
kind: "file" as const,
|
|
1704
|
+
path: "AGENTS.md",
|
|
1705
|
+
content: "# Generated\n",
|
|
1706
|
+
tracked: false,
|
|
1707
|
+
managed: "whole" as const,
|
|
1708
|
+
};
|
|
1709
|
+
const deps = settingsOnceMade(".repo/rules/agents.md");
|
|
1710
|
+
const result = await run(root, { generators: [...deps.generators, () => ({ outputs: [agents], errors: [] })] });
|
|
1711
|
+
expect(result).toEqual({ code: 1, err: [SCHEMA_LINE, "repo sync: nothing written\n"] });
|
|
1712
|
+
expect(exists(root, ".repo/rules/agents.md")).toBe(true);
|
|
1713
|
+
expect(exists(root, SETTINGS)).toBe(false);
|
|
1714
|
+
});
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
const SETTINGS = ".claude/settings.json";
|
|
1718
|
+
const BAD = '{"hooks":{"PreToolUse":"x"}}\n';
|
|
1719
|
+
const SCHEMA_LINE =
|
|
1720
|
+
"repo sync: .claude/settings.json: hooks.PreToolUse: Invalid input: expected array, received string\n";
|
|
1721
|
+
/** Plans a valid settings file until `made` exists, then an invalid one. */
|
|
1722
|
+
const settingsOnceMade = (made: string) => ({
|
|
1723
|
+
generators: [
|
|
1724
|
+
(ctx: { read(rel: string): string | null }) => ({
|
|
1725
|
+
outputs: [
|
|
1726
|
+
{
|
|
1727
|
+
kind: "file" as const,
|
|
1728
|
+
path: SETTINGS,
|
|
1729
|
+
content: ctx.read(made) === null ? "{}\n" : BAD,
|
|
1730
|
+
tracked: false,
|
|
1731
|
+
managed: "whole" as const,
|
|
1732
|
+
},
|
|
1733
|
+
],
|
|
1734
|
+
errors: [],
|
|
1735
|
+
}),
|
|
1736
|
+
],
|
|
1737
|
+
});
|
|
1738
|
+
const run = async (root: string, deps: Parameters<typeof runSync>[2]) => {
|
|
1739
|
+
const err: string[] = [];
|
|
1740
|
+
const code = await runSync([], { cwd: root, stdout: () => {}, stderr: (s) => err.push(s) }, deps);
|
|
1741
|
+
return { code, err };
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1744
|
+
describe("repo sync validates what the migration and the adoption change", () => {
|
|
1745
|
+
it("keeps the migration and writes nothing else when its result is invalid", async () => {
|
|
1746
|
+
const root = copied({ git: false });
|
|
1747
|
+
const result = await run(root, settingsOnceMade(".repo/rules/team.md"));
|
|
1748
|
+
expect(result).toEqual({ code: 1, err: [SCHEMA_LINE, "repo sync: nothing written\n"] });
|
|
1749
|
+
expect(exists(root, ".rulesync")).toBe(false);
|
|
1750
|
+
expect(exists(root, ".repo/rules/team.md")).toBe(true);
|
|
1751
|
+
expect(exists(root, SETTINGS)).toBe(false);
|
|
1752
|
+
expect(exists(root, ".repo/tmp/sync.json")).toBe(false);
|
|
1753
|
+
});
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
describe("planRuleUpgrade", () => {
|
|
1757
|
+
const STOCK = {
|
|
1758
|
+
"package.json": '{ "name": "@workerstack/repo" }\n',
|
|
1759
|
+
"agents/rules/overview.md": "---\ndescription: Stock\n---\nstock\n",
|
|
1760
|
+
"agents/rules/deep/style.md": "---\ndescription: Stock\n---\nstock\n",
|
|
1761
|
+
"agents/subagents/scout.md": "---\nname: scout\n---\nstock\n",
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
/** A consumer holding `files` beside a fake stock package. */
|
|
1765
|
+
function upgradeRoot(files: Record<string, string>): { root: string; stock: string } {
|
|
1766
|
+
const dir = tempDir();
|
|
1767
|
+
const stock = path.join(dir, "stock");
|
|
1768
|
+
const root = path.join(dir, "root");
|
|
1769
|
+
for (const [rel, text] of Object.entries(STOCK)) write(stock, rel, text);
|
|
1770
|
+
fs.mkdirSync(root, { recursive: true });
|
|
1771
|
+
for (const [rel, text] of Object.entries(files)) write(root, rel, text);
|
|
1772
|
+
return { root, stock };
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
const plan = (files: Record<string, string>) => {
|
|
1776
|
+
const { root, stock } = upgradeRoot(files);
|
|
1777
|
+
return { root, stock, steps: planRuleUpgrade(root, stock) };
|
|
1778
|
+
};
|
|
1779
|
+
|
|
1780
|
+
it("rewrites globs to paths, drops root and targets, and takes the description from the first heading", () => {
|
|
1781
|
+
const { steps } = plan({
|
|
1782
|
+
".repo/rules/testing.md":
|
|
1783
|
+
'---\nroot: false\ntargets: ["*"]\nglobs:\n - src/**\n - lib/**\n---\n# Testing rules\n\nBody.\n',
|
|
1784
|
+
});
|
|
1785
|
+
expect(steps).toEqual([
|
|
1786
|
+
{
|
|
1787
|
+
kind: "write",
|
|
1788
|
+
path: ".repo/rules/testing.md",
|
|
1789
|
+
content: "---\ndescription: Testing rules\npaths:\n - src/**\n - lib/**\n---\n# Testing rules\n\nBody.\n",
|
|
1790
|
+
note: "rewrote .repo/rules/testing.md frontmatter (description added, globs → paths, root and targets dropped)",
|
|
1791
|
+
},
|
|
1792
|
+
]);
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
it("falls back to the rule name for the description and reads comma-separated globs", () => {
|
|
1796
|
+
const { steps } = plan({ ".repo/rules/notes.md": '---\nglobs: "a/**, b/**"\n---\nNo heading.\n' });
|
|
1797
|
+
expect(contents(steps)).toEqual(["---\ndescription: notes\npaths:\n - a/**\n - b/**\n---\nNo heading.\n"]);
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
it.each([['["**/*"]'], ['["*"]'], ['["src/**", "**/*"]']])("turns globs %s into no paths", (globs) => {
|
|
1801
|
+
const { steps } = plan({ ".repo/rules/all.md": `---\ndescription: Keep me\nglobs: ${globs}\n---\nBody.\n` });
|
|
1802
|
+
expect(contents(steps)).toEqual(["---\ndescription: Keep me\n---\nBody.\n"]);
|
|
1803
|
+
expect(notes(steps)).toEqual(["rewrote .repo/rules/all.md frontmatter (globs dropped)"]);
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
it("keeps the other keys and the body byte for byte", () => {
|
|
1807
|
+
const { steps } = plan({
|
|
1808
|
+
".repo/rules/x.md": "---\n# a note\nroot: false\ndescription: D\nextra: 'v'\n---\n# H\n\n---\ntext \n",
|
|
1809
|
+
});
|
|
1810
|
+
expect(contents(steps)).toEqual(["---\n# a note\ndescription: D\nextra: 'v'\n---\n# H\n\n---\ntext \n"]);
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
it("marks an old-format rule that bears a stock name, case-folded and nested, in the same step", () => {
|
|
1814
|
+
const { steps } = plan({
|
|
1815
|
+
".repo/rules/Overview.md": "---\nroot: true\nglobs: ['**/*']\n---\n# Mine\n",
|
|
1816
|
+
".repo/rules/deep/style.md": "---\nroot: false\n---\nBody\n",
|
|
1817
|
+
});
|
|
1818
|
+
expect(steps).toEqual([
|
|
1819
|
+
{
|
|
1820
|
+
kind: "write",
|
|
1821
|
+
path: ".repo/rules/Overview.md",
|
|
1822
|
+
content: "---\ndescription: Mine\noverrides: Overview\n---\n# Mine\n",
|
|
1823
|
+
note: "rewrote .repo/rules/Overview.md frontmatter (description added, root and globs dropped)\nmarked .repo/rules/Overview.md with overrides: Overview (it replaces the stock rule)",
|
|
1824
|
+
},
|
|
1825
|
+
{
|
|
1826
|
+
kind: "write",
|
|
1827
|
+
path: ".repo/rules/deep/style.md",
|
|
1828
|
+
content: "---\ndescription: style\noverrides: deep/style\n---\nBody\n",
|
|
1829
|
+
note: "rewrote .repo/rules/deep/style.md frontmatter (description added, root dropped)\nmarked .repo/rules/deep/style.md with overrides: deep/style (it replaces the stock rule)",
|
|
1830
|
+
},
|
|
1831
|
+
]);
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
it("never marks a new-format file, and leaves a new-format rule that lacks a description alone", () => {
|
|
1835
|
+
const { steps } = plan({
|
|
1836
|
+
".repo/rules/overview.md": "---\ndescription: Mine\n---\nBody\n",
|
|
1837
|
+
".repo/rules/style.md": "---\npaths: [a]\n---\nBody\n",
|
|
1838
|
+
".repo/subagents/scout.md": "---\nname: scout\ndescription: mine\n---\nBody\n",
|
|
1839
|
+
});
|
|
1840
|
+
expect(steps).toEqual([]);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
it("does not mark an old-format file that bears no stock name", () => {
|
|
1844
|
+
const { steps } = plan({ ".repo/rules/team.md": "---\nroot: false\ndescription: Team\n---\nBody\n" });
|
|
1845
|
+
expect(contents(steps)).toEqual(["---\ndescription: Team\n---\nBody\n"]);
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
it("keeps an overrides that is already there and never doubles it", () => {
|
|
1849
|
+
const { steps } = plan({
|
|
1850
|
+
".repo/rules/overview.md": "---\nroot: true\ndescription: D\noverrides: overview\n---\nB\n",
|
|
1851
|
+
});
|
|
1852
|
+
expect(contents(steps)).toEqual(["---\ndescription: D\noverrides: overview\n---\nB\n"]);
|
|
1853
|
+
expect(notes(steps)).toEqual(["rewrote .repo/rules/overview.md frontmatter (root dropped)"]);
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
it("marks an old-format subagent that bears a stock name and keeps its targets", () => {
|
|
1857
|
+
const { steps } = plan({
|
|
1858
|
+
".repo/subagents/scout.md": "---\nname: scout\ntargets:\n - claudecode\n---\nBody\n",
|
|
1859
|
+
".repo/subagents/mine.md": "---\nname: mine\ntargets:\n - claudecode\n---\nBody\n",
|
|
1860
|
+
});
|
|
1861
|
+
expect(steps).toEqual([
|
|
1862
|
+
{
|
|
1863
|
+
kind: "write",
|
|
1864
|
+
path: ".repo/subagents/scout.md",
|
|
1865
|
+
content: "---\noverrides: scout\nname: scout\ntargets:\n - claudecode\n---\nBody\n",
|
|
1866
|
+
note: "marked .repo/subagents/scout.md with overrides: scout (it replaces the stock subagent)",
|
|
1867
|
+
},
|
|
1868
|
+
]);
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
it("drops root and globs from a subagent", () => {
|
|
1872
|
+
const { steps } = plan({ ".repo/subagents/mine.md": "---\nname: mine\nroot: false\nglobs: [a]\n---\nBody\n" });
|
|
1873
|
+
expect(contents(steps)).toEqual(["---\nname: mine\n---\nBody\n"]);
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
it("keeps CRLF line endings when it rewrites", () => {
|
|
1877
|
+
const { steps } = plan({ ".repo/rules/crlf.md": "---\r\nroot: false\r\ndescription: D\r\n---\r\nBody\r\n" });
|
|
1878
|
+
expect(contents(steps)).toEqual(["---\r\ndescription: D\r\n---\r\nBody\r\n"]);
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
it("skips a file whose frontmatter does not parse, for the loader to report", () => {
|
|
1882
|
+
expect(plan({ ".repo/rules/a.md": "no fence\n", ".repo/rules/b.md": "---\nroot: [\n---\n" }).steps).toEqual([]);
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
it("plans nothing without .repo, and rewrites without a stock package", () => {
|
|
1886
|
+
const { root } = upgradeRoot({});
|
|
1887
|
+
expect(planRuleUpgrade(root, path.join(root, "missing"))).toEqual([]);
|
|
1888
|
+
write(root, ".repo/rules/a.md", "---\nroot: false\n---\nBody\n");
|
|
1889
|
+
expect(contents(planRuleUpgrade(root, path.join(root, "missing")))).toEqual(["---\ndescription: a\n---\nBody\n"]);
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
it("plans nothing on a second run", async () => {
|
|
1893
|
+
const { root, stock, steps } = plan({
|
|
1894
|
+
".repo/rules/overview.md": "---\nroot: true\nglobs: ['**/*']\n---\n# Mine\n",
|
|
1895
|
+
".repo/rules/testing.md": "---\nroot: false\nglobs: [src/**]\n---\nBody\n",
|
|
1896
|
+
".repo/subagents/scout.md": "---\nname: scout\ntargets: [claudecode]\n---\nBody\n",
|
|
1897
|
+
});
|
|
1898
|
+
expect(steps).toHaveLength(3);
|
|
1899
|
+
const lines = await applyMigration(root, steps);
|
|
1900
|
+
expect(lines).toHaveLength(4);
|
|
1901
|
+
expect(lines.every((line) => line.startsWith("repo sync: "))).toBe(true);
|
|
1902
|
+
expect(planRuleUpgrade(root, stock)).toEqual([]);
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
describe("migration and adoption mark what they move onto a stock name", () => {
|
|
1907
|
+
it("marks a moved stock rule, subagent and skill, and nothing else", async () => {
|
|
1908
|
+
const m = mini(
|
|
1909
|
+
{
|
|
1910
|
+
".rulesync/rules/plain.md": "---\nroot: false\n---\nedited plain\n",
|
|
1911
|
+
".rulesync/rules/team.md": "own\n",
|
|
1912
|
+
".rulesync/subagents/scout.md": "---\nname: scout\n---\nedited scout\n",
|
|
1913
|
+
".rulesync/subagents/mine.md": "own\n",
|
|
1914
|
+
".agents/skills/ask/SKILL.md": "---\nname: ask\n---\nedited\n",
|
|
1915
|
+
".agents/skills/deploy/SKILL.md": "deploy\n",
|
|
1916
|
+
},
|
|
1917
|
+
{ stock: { "agents/rules/plain.md": "---\nroot: false\n---\nstock plain\n" } },
|
|
1918
|
+
);
|
|
1919
|
+
const lines = await applyMigration(m.root, planOf(m));
|
|
1920
|
+
expect(lines).toContain(
|
|
1921
|
+
"repo sync: moved your edited .rulesync/rules/plain.md to .repo/rules/plain.md (it now replaces the stock rule)",
|
|
1922
|
+
);
|
|
1923
|
+
expect(lines).toContain(
|
|
1924
|
+
"repo sync: marked .repo/rules/plain.md with overrides: plain (it replaces the stock rule)",
|
|
1925
|
+
);
|
|
1926
|
+
expect(lines).toContain(
|
|
1927
|
+
"repo sync: marked .repo/subagents/scout.md with overrides: scout (it replaces the stock subagent)",
|
|
1928
|
+
);
|
|
1929
|
+
expect(lines).toContain(
|
|
1930
|
+
"repo sync: marked .repo/skills/ask/SKILL.md with overrides: ask (it replaces the stock skill)",
|
|
1931
|
+
);
|
|
1932
|
+
expect(lines.filter((line) => line.includes("marked"))).toHaveLength(3);
|
|
1933
|
+
expect(read(m.root, ".repo/rules/plain.md")).toBe("---\noverrides: plain\nroot: false\n---\nedited plain\n");
|
|
1934
|
+
expect(read(m.root, ".repo/subagents/scout.md")).toBe("---\noverrides: scout\nname: scout\n---\nedited scout\n");
|
|
1935
|
+
expect(read(m.root, ".repo/skills/ask/SKILL.md")).toBe("---\noverrides: ask\nname: ask\n---\nedited\n");
|
|
1936
|
+
expect(read(m.root, ".repo/rules/team.md")).toBe("own\n");
|
|
1937
|
+
expect(read(m.root, ".repo/subagents/mine.md")).toBe("own\n");
|
|
1938
|
+
expect(read(m.root, ".repo/skills/deploy/SKILL.md")).toBe("deploy\n");
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
it("frames a file with no frontmatter", async () => {
|
|
1942
|
+
const m = mini({ ".agents/skills/ask/SKILL.md": "edited\n" });
|
|
1943
|
+
await applyMigration(m.root, planOf(m));
|
|
1944
|
+
expect(read(m.root, ".repo/skills/ask/SKILL.md")).toBe("---\noverrides: ask\n---\nedited\n");
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
it("does not double a mark that a stopped run already made", async () => {
|
|
1948
|
+
const m = mini({ ".agents/skills/ask/SKILL.md": "---\noverrides: ask\n---\nedited\n" });
|
|
1949
|
+
const lines = await applyMigration(m.root, planOf(m));
|
|
1950
|
+
expect(read(m.root, ".repo/skills/ask/SKILL.md")).toBe("---\noverrides: ask\n---\nedited\n");
|
|
1951
|
+
expect(lines.filter((line) => line.includes("marked"))).toEqual([]);
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
it("moves a stock rule whose frontmatter does not parse without marking it", async () => {
|
|
1955
|
+
const broken = "---\n: [\n---\nedited plain\n";
|
|
1956
|
+
const m = mini(
|
|
1957
|
+
{ ".rulesync/rules/plain.md": broken },
|
|
1958
|
+
{ stock: { "agents/rules/plain.md": "---\nroot: false\n---\nstock plain\n" } },
|
|
1959
|
+
);
|
|
1960
|
+
const lines = await applyMigration(m.root, planOf(m));
|
|
1961
|
+
expect(lines.filter((line) => line.includes("marked"))).toEqual([]);
|
|
1962
|
+
expect(read(m.root, ".repo/rules/plain.md")).toBe(broken);
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
it("moves an edited stock skill that has no SKILL.md without a mark", async () => {
|
|
1966
|
+
const m = mini({ ".agents/skills/ask/extra.md": "mine\n" });
|
|
1967
|
+
const lines = await applyMigration(m.root, planOf(m));
|
|
1968
|
+
expect(lines.filter((line) => line.includes("marked"))).toEqual([]);
|
|
1969
|
+
expect(read(m.root, ".repo/skills/ask/extra.md")).toBe("mine\n");
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
it("keeps CRLF line endings when it marks, and frames a frontmatter that never closes", async () => {
|
|
1973
|
+
const m = mini(
|
|
1974
|
+
{
|
|
1975
|
+
".rulesync/rules/plain.md": "---\r\nroot: false\r\n---\r\nedited\r\n",
|
|
1976
|
+
".rulesync/subagents/scout.md": "---\nname: scout\nunclosed\n",
|
|
1977
|
+
},
|
|
1978
|
+
{ stock: { "agents/rules/plain.md": "---\nroot: false\n---\nstock plain\n" } },
|
|
1979
|
+
);
|
|
1980
|
+
await applyMigration(m.root, planOf(m));
|
|
1981
|
+
expect(read(m.root, ".repo/rules/plain.md")).toBe("---\r\noverrides: plain\r\nroot: false\r\n---\r\nedited\r\n");
|
|
1982
|
+
expect(read(m.root, ".repo/subagents/scout.md")).toBe("---\noverrides: scout\n---\n---\nname: scout\nunclosed\n");
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1985
|
+
it("marks an adopted skill that bears a stock name, so the first sync passes", async () => {
|
|
1986
|
+
const root = tempDir();
|
|
1987
|
+
write(root, "workerstack.config.ts", "export default {}\n");
|
|
1988
|
+
write(root, "package.json", JSON.stringify({ name: "fixture" }));
|
|
1989
|
+
write(root, ".claude/skills/ask/SKILL.md", "---\nname: ask\ndescription: mine\n---\nmy ask\n");
|
|
1990
|
+
fs.mkdirSync(path.join(root, "node_modules", "@workerstack"), { recursive: true });
|
|
1991
|
+
fs.symlinkSync(SELF_ROOT, path.join(root, "node_modules", "@workerstack", "repo"), "dir");
|
|
1992
|
+
const result = await sync(root, [], true);
|
|
1993
|
+
expect(result.err).toEqual([]);
|
|
1994
|
+
expect(result.code).toBe(0);
|
|
1995
|
+
expect(result.out.join("")).toContain(
|
|
1996
|
+
"repo sync: marked .repo/skills/ask/SKILL.md with overrides: ask (it replaces the stock skill)\n",
|
|
1997
|
+
);
|
|
1998
|
+
expect(read(root, ".repo/skills/ask/SKILL.md")).toContain("overrides: ask");
|
|
1999
|
+
}, 120_000);
|
|
2000
|
+
});
|