diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c60ae2..c3f055b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Validate generated config schema + run: bun run schema:check + - name: Validate README documentation link target run: | if grep -qF "arashi-docs.netlify.app" README.md; then diff --git a/README.md b/README.md index ef27572..44b52db 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,24 @@ If you prefer the term `delete`, create a shell alias: alias arashi-delete='arashi remove -f' ``` +## Configuration Schema + +Arashi publishes a JSON Schema for `.arashi/config.json` so editors can validate and autocomplete your config. + +- Stable URL: `https://unpkg.com/arashi/schema/config.schema.json` +- Version-pinned URL: `https://unpkg.com/arashi@1.7.0/schema/config.schema.json` + +Example config header: + +```json +{ + "$schema": "https://unpkg.com/arashi/schema/config.schema.json", + "version": "1.0.0", + "reposDir": "./repos", + "repos": {} +} +``` + ## skills.sh Integration Arashi also ships a dedicated `skills.sh` integration package for guided installation, workflow examples, and troubleshooting. @@ -202,6 +220,7 @@ Arashi also ships a dedicated `skills.sh` integration package for guided install ## Documentation - Installation details: [`docs/INSTALLATION.md`](./docs/INSTALLATION.md) +- Configuration details: [`docs/configuration.md`](./docs/configuration.md) - Clone command details: [`docs/commands/clone.md`](./docs/commands/clone.md) - Hook behavior: [`docs/hooks.md`](./docs/hooks.md) - Setup command details: [`docs/commands/setup.md`](./docs/commands/setup.md) diff --git a/bun.lock b/bun.lock index 783f8d9..ec7261a 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "oxfmt": "0.28.0", "oxlint": "1.43.0", "semantic-release": "^24.2.0", + "ts-json-schema-generator": "2.4.0", "typescript": "^5.9.3", }, }, @@ -64,6 +65,8 @@ "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], @@ -148,6 +151,8 @@ "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -170,10 +175,14 @@ "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], + "balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="], + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], @@ -278,6 +287,8 @@ "find-versions": ["find-versions@6.0.0", "", { "dependencies": { "semver-regex": "^4.0.5", "super-regex": "^1.0.0" } }, "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "from2": ["from2@2.3.0", "", { "dependencies": { "inherits": "^2.0.1", "readable-stream": "^2.0.0" } }, "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g=="], "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], @@ -292,6 +303,8 @@ "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], @@ -352,6 +365,8 @@ "issue-parser": ["issue-parser@7.0.1", "", { "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.uniqby": "^4.7.0" } }, "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "java-properties": ["java-properties@1.0.2", "", {}, "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -362,6 +377,8 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -412,8 +429,12 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], @@ -430,6 +451,8 @@ "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="], "npm": ["npm@10.9.4", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^8.0.1", "@npmcli/config": "^9.0.0", "@npmcli/fs": "^4.0.0", "@npmcli/map-workspaces": "^4.0.2", "@npmcli/package-json": "^6.2.0", "@npmcli/promise-spawn": "^8.0.2", "@npmcli/redact": "^3.2.2", "@npmcli/run-script": "^9.1.0", "@sigstore/tuf": "^3.1.1", "abbrev": "^3.0.1", "archy": "~1.0.0", "cacache": "^19.0.1", "chalk": "^5.4.1", "ci-info": "^4.2.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^10.4.5", "graceful-fs": "^4.2.11", "hosted-git-info": "^8.1.0", "ini": "^5.0.0", "init-package-json": "^7.0.2", "is-cidr": "^5.1.1", "json-parse-even-better-errors": "^4.0.0", "libnpmaccess": "^9.0.0", "libnpmdiff": "^7.0.1", "libnpmexec": "^9.0.1", "libnpmfund": "^6.0.1", "libnpmhook": "^11.0.0", "libnpmorg": "^7.0.0", "libnpmpack": "^8.0.1", "libnpmpublish": "^10.0.1", "libnpmsearch": "^8.0.0", "libnpmteam": "^7.0.0", "libnpmversion": "^7.0.0", "make-fetch-happen": "^14.0.3", "minimatch": "^9.0.5", "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", "node-gyp": "^11.2.0", "nopt": "^8.1.0", "normalize-package-data": "^7.0.0", "npm-audit-report": "^6.0.0", "npm-install-checks": "^7.1.1", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-profile": "^11.0.1", "npm-registry-fetch": "^18.0.2", "npm-user-validate": "^3.0.0", "p-map": "^7.0.3", "pacote": "^19.0.1", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", "read": "^4.1.0", "semver": "^7.7.2", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", "supports-color": "^9.4.0", "tar": "^6.2.1", "text-table": "~0.2.0", "tiny-relative-date": "^1.3.0", "treeverse": "^3.0.0", "validate-npm-package-name": "^6.0.1", "which": "^5.0.0", "write-file-atomic": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA=="], @@ -466,6 +489,8 @@ "p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], @@ -480,6 +505,8 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -518,6 +545,8 @@ "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semantic-release": ["semantic-release@24.2.9", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^11.0.0", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^8.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA=="], @@ -598,6 +627,10 @@ "traverse": ["traverse@0.6.8", "", {}, "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA=="], + "ts-json-schema-generator": ["ts-json-schema-generator@2.4.0", "", { "dependencies": { "@types/json-schema": "^7.0.15", "commander": "^13.1.0", "glob": "^11.0.1", "json5": "^2.2.3", "normalize-path": "^3.0.0", "safe-stable-stringify": "^2.5.0", "tslib": "^2.8.1", "typescript": "^5.8.2" }, "bin": { "ts-json-schema-generator": "bin/ts-json-schema-generator.js" } }, "sha512-HbmNsgs58CfdJq0gpteRTxPXG26zumezOs+SB9tgky6MpqiFgQwieCn2MW70+sxpHouZ/w9LW0V6L4ZQO4y1Ug=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1054,6 +1087,8 @@ "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1076,6 +1111,8 @@ "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "ts-json-schema-generator/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..2e03c7d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,31 @@ +# Configuration + +Arashi stores workspace settings in `.arashi/config.json`. + +To enable JSON validation and editor autocomplete, include a `$schema` property: + +```json +{ + "$schema": "https://unpkg.com/arashi/schema/config.schema.json", + "version": "1.0.0", + "reposDir": "./repos", + "repos": {} +} +``` + +## Schema URLs + +- Stable schema URL: `https://unpkg.com/arashi/schema/config.schema.json` +- Version-pinned schema URL: `https://unpkg.com/arashi@1.7.0/schema/config.schema.json` + +Use the stable URL for normal workflows, and the version-pinned URL when you want schema behavior to stay fixed for a specific release. + +## Canonical Key Format + +Newly written config files use camelCase keys: + +- `reposDir` +- `repos` +- `gitUrl` + +Legacy snake_case keys are still accepted when loading existing workspaces, and Arashi rewrites them to canonical camelCase when the config is saved. diff --git a/package.json b/package.json index 6ae2923..41c78e0 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bin/arashi.bat", "bin/arashi.ps1", "scripts/postinstall.js", + "schema/config.schema.json", "README.md", "LICENSE" ], @@ -57,10 +58,14 @@ "lint": "oxlint -D no-explicit-any .", "lint:fix": "oxlint --fix -D no-explicit-any .", "lint:ci": "oxlint --format github -D no-explicit-any .", + "schema:generate": "ts-json-schema-generator --tsconfig tsconfig.schema.json --path src/lib/config.ts --type Config --expose export --jsDoc extended --out schema/config.schema.json", + "schema:publish": "bun run schema:generate && oxfmt --config .oxfmtrc.json --write schema/config.schema.json", + "schema:check": "bun run schema:publish && git diff --exit-code -- schema/config.schema.json", "format": "oxfmt --config .oxfmtrc.json --write .", "format:check": "oxfmt --config .oxfmtrc.json --check .", "quality:changed": "bun run scripts/quality/changed-files-quality.ts", "postinstall": "node scripts/postinstall.js", + "prepublishOnly": "bun run schema:publish", "prepare": "husky" }, "devDependencies": { @@ -80,6 +85,7 @@ "oxfmt": "0.28.0", "oxlint": "1.43.0", "semantic-release": "^24.2.0", + "ts-json-schema-generator": "2.4.0", "typescript": "^5.9.3" }, "lint-staged": { diff --git a/schema/config.schema.json b/schema/config.schema.json new file mode 100644 index 0000000..43b7724 --- /dev/null +++ b/schema/config.schema.json @@ -0,0 +1,75 @@ +{ + "$ref": "#/definitions/Config", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Config": { + "additionalProperties": false, + "description": "Root configuration object for Arashi", + "properties": { + "$schema": { + "description": "JSON Schema URL for editor validation/autocomplete", + "type": "string" + }, + "hooks": { + "additionalProperties": false, + "description": "Optional workspace-level hooks settings", + "properties": { + "timeout": { + "description": "Timeout in milliseconds for long-running operations", + "type": "number" + } + }, + "type": "object" + }, + "repos": { + "additionalProperties": { + "$ref": "#/definitions/RepoConfig" + }, + "description": "Map of repository names to their configurations", + "type": "object" + }, + "reposDir": { + "description": "Directory where repositories are located", + "type": "string" + }, + "sync": { + "additionalProperties": false, + "description": "Optional sync command settings", + "properties": { + "timeoutSeconds": { + "description": "Sync timeout in seconds", + "type": "number" + } + }, + "type": "object" + }, + "version": { + "$ref": "#/definitions/ConfigVersion", + "description": "Configuration schema version for migrations" + } + }, + "required": ["version", "reposDir", "repos"], + "type": "object" + }, + "ConfigVersion": { + "const": "1.0.0", + "type": "string" + }, + "RepoConfig": { + "additionalProperties": false, + "description": "Configuration for a single repository", + "properties": { + "gitUrl": { + "description": "Canonical git URL for cloning the repository", + "type": "string" + }, + "path": { + "description": "Path to the repository (relative or absolute)", + "type": "string" + } + }, + "required": ["path"], + "type": "object" + } + } +} diff --git a/src/commands/add.ts b/src/commands/add.ts index 36ecf55..4d4fd88 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -350,20 +350,20 @@ async function executeAdd( // Step 4: Check for duplicate name const config = await loadConfig(workspaceRoot); - if (config.discovered_repos[repositoryName]) { + if (config.repos[repositoryName]) { throw new AddCommandError( - `Repository name "${repositoryName}" already exists at ${config.discovered_repos[repositoryName].path}`, + `Repository name "${repositoryName}" already exists at ${config.repos[repositoryName].path}`, AddCommandErrorCode.DUPLICATE_NAME, { name: repositoryName, - existingPath: config.discovered_repos[repositoryName].path, + existingPath: config.repos[repositoryName].path, gitUrl, }, ); } // Step 5: Prepare clone destination - const reposDir = join(workspaceRoot, config.repos_dir); + const reposDir = join(workspaceRoot, config.reposDir); const clonePath = join(reposDir, repositoryName); // Step 6: Clone repository @@ -409,20 +409,11 @@ async function executeAdd( const s5 = spinner("Updating configuration...").start(); try { const repoConfig: RepoConfig = { - path: join(".", config.repos_dir, repositoryName), - git_url: urlInfo.url, - default_branch: defaultBranch, - is_bare: false, - worktrees: [], + path: join(".", config.reposDir, repositoryName), + gitUrl: urlInfo.url, }; - if (setupScript) { - repoConfig.hooks = { - setup: join(".", config.repos_dir, repositoryName, basename(setupScript)), - }; - } - - config.discovered_repos[repositoryName] = repoConfig; + config.repos[repositoryName] = repoConfig; await saveConfig(workspaceRoot, config); s5.succeed("Configuration updated"); } catch (error) { diff --git a/src/commands/clone.ts b/src/commands/clone.ts index 3509bf4..6908813 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -3,11 +3,12 @@ import { join } from "path"; import { findWorkspaceRoot, loadConfig, + normalizeConfig, saveConfig, type Config, repairRepositoryGitUrls, } from "../lib/config.ts"; -import { clone as cloneRepository, exec, getDefaultBranch } from "../lib/git.ts"; +import { clone as cloneRepository, exec } from "../lib/git.ts"; import { removeDir } from "../lib/filesystem.ts"; import { applyCloneProtocol, @@ -44,7 +45,6 @@ interface CloneCommandDependencies { repairRepositoryGitUrls?: typeof repairRepositoryGitUrls; discoverCloneRepositories?: typeof discoverCloneRepositories; cloneRepository?: typeof cloneRepository; - getDefaultBranch?: typeof getDefaultBranch; removeDir?: typeof removeDir; promptConfirm?: (message: string, defaultValue?: boolean) => Promise>; promptInput?: (message: string, defaultValue?: string) => Promise>; @@ -85,7 +85,6 @@ export async function executeClone( const repairGitUrls = deps.repairRepositoryGitUrls ?? repairRepositoryGitUrls; const discoverRepositories = deps.discoverCloneRepositories ?? discoverCloneRepositories; const runClone = deps.cloneRepository ?? cloneRepository; - const readDefaultBranch = deps.getDefaultBranch ?? getDefaultBranch; const deleteDirectory = deps.removeDir ?? removeDir; const confirm = deps.promptConfirm ?? promptConfirm; const askInput = deps.promptInput ?? promptInput; @@ -97,7 +96,7 @@ export async function executeClone( ); const workspaceRoot = deps.workspaceRoot ?? (await resolveWorkspaceRoot()); - const config = await readConfig(workspaceRoot); + const config = normalizeConfig(await readConfig(workspaceRoot)); const repairResult = await repairGitUrls(workspaceRoot, config); let configUpdated = repairResult.updated; @@ -122,7 +121,6 @@ export async function executeClone( confirm, askInput, askSelect, - readDefaultBranch, deleteDirectory, }); @@ -153,10 +151,10 @@ export async function executeClone( const missingWithUrls = discovery.configuredMissing.filter( (repository) => - typeof repository.config.git_url === "string" && repository.config.git_url.length > 0, + typeof repository.config.gitUrl === "string" && repository.config.gitUrl.length > 0, ); const missingWithoutUrls = discovery.configuredMissing.filter( - (repository) => !repository.config.git_url, + (repository) => !repository.config.gitUrl, ); if (interactive && missingWithoutUrls.length > 0) { @@ -178,18 +176,18 @@ export async function executeClone( continue; } - repository.config.git_url = value; + repository.config.gitUrl = value; missingWithUrls.push(repository); configUpdated = true; } } const unresolvedMissingWithoutUrls = missingWithoutUrls - .filter((repository) => !repository.config.git_url) + .filter((repository) => !repository.config.gitUrl) .map((repository) => repository.name); if (unresolvedMissingWithoutUrls.length > 0) { logger.warn( - `Skipping repositories without configured git_url: ${unresolvedMissingWithoutUrls.join(", ")}`, + `Skipping repositories without configured gitUrl: ${unresolvedMissingWithoutUrls.join(", ")}`, ); } @@ -199,7 +197,7 @@ export async function executeClone( const preferredProtocol = await resolveProtocolPreference({ interactive, - urls: Object.values(config.discovered_repos).map((repo) => repo.git_url), + urls: Object.values(config.repos).map((repo) => repo.gitUrl), askSelect, }); @@ -252,11 +250,11 @@ export async function executeClone( .filter((name) => !selectedRepositories.some((repository) => repository.name === name)); for (const repository of selectedRepositories) { - const rawGitUrl = repository.config.git_url; + const rawGitUrl = repository.config.gitUrl; if (!rawGitUrl) { failed.push({ name: repository.name, - reason: "Missing git_url in configuration", + reason: "Missing gitUrl in configuration", }); continue; } @@ -271,19 +269,10 @@ export async function executeClone( cloneSpinner.succeed(`Cloned ${repository.name}`); cloned.push(repository.name); - if (repository.config.git_url !== cloneUrl) { - repository.config.git_url = cloneUrl; + if (repository.config.gitUrl !== cloneUrl) { + repository.config.gitUrl = cloneUrl; configUpdated = true; } - - if (!repository.config.default_branch) { - try { - repository.config.default_branch = await readDefaultBranch(repository.path); - configUpdated = true; - } catch { - // Best effort: keep clone success even if default branch detection fails - } - } } catch (error) { const reason = error instanceof Error ? error.message : String(error); cloneSpinner.fail(`Failed to clone ${repository.name}`); @@ -357,7 +346,6 @@ async function reconcileUnmanagedRepositories(options: { confirm: (message: string, defaultValue?: boolean) => Promise>; askInput: (message: string, defaultValue?: string) => Promise>; askSelect: (message: string, choices: Choice[]) => Promise>; - readDefaultBranch: (repoPath: string) => Promise; deleteDirectory: (path: string) => Promise; }): Promise<{ cancelled: boolean; updatedConfig: boolean }> { if (options.unmanagedRepositories.length === 0) { @@ -438,24 +426,12 @@ async function reconcileUnmanagedRepositories(options: { gitUrl = value; } - let defaultBranch: string | undefined; - try { - defaultBranch = await options.readDefaultBranch(unmanagedRepository.path); - } catch { - defaultBranch = undefined; - } - - const repoConfig: Config["discovered_repos"][string] = { - path: join(".", options.config.repos_dir, unmanagedRepository.name), - git_url: gitUrl, - is_bare: false, - worktrees: [], + const repoConfig: Config["repos"][string] = { + path: join(".", options.config.reposDir, unmanagedRepository.name), + gitUrl, }; - if (defaultBranch) { - repoConfig.default_branch = defaultBranch; - } - options.config.discovered_repos[unmanagedRepository.name] = repoConfig; + options.config.repos[unmanagedRepository.name] = repoConfig; updatedConfig = true; logger.info(`Added ${unmanagedRepository.name} to configuration.`); } diff --git a/src/commands/create.ts b/src/commands/create.ts index db45988..c41b411 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -199,10 +199,10 @@ export async function executeCreate( const arashiConfig = loadedConfig.config; - // 2. Discover repositories (child repos in repos_dir) - // Convert repos_dir to absolute path since it may be relative (e.g., "./repos") + // 2. Discover repositories (child repos in reposDir) + // Convert reposDir to absolute path since it may be relative (e.g., "./repos") const currentDir = context.executionPath; - const reposDirAbsolute = resolve(currentDir, arashiConfig.repos_dir); + const reposDirAbsolute = resolve(currentDir, arashiConfig.reposDir); const discoveryResult = await discoverRepositories(reposDirAbsolute); // 3. Include the meta-repo itself in the repository list diff --git a/src/commands/init.ts b/src/commands/init.ts index 1197b44..ae45e1d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -28,9 +28,6 @@ interface InitOptions { /** Skip automatic repository discovery */ noDiscover?: boolean; - /** Enable or disable automatic setup hook execution */ - autoSetup?: boolean; - /** Dry run - show what would be done without making changes */ dryRun?: boolean; @@ -334,7 +331,7 @@ exit 0 content: `#!/usr/bin/env bash # Setup Hook Example # -# This hook runs during repository initialization (if auto_setup is enabled). +# This hook runs during repository initialization. # Use it to perform one-time setup tasks for newly discovered repositories. # # Environment variables: @@ -689,8 +686,6 @@ async function executeInit(options: InitOptions): Promise { } discoveredRepos[repo.name] = { path: repo.path, - default_branch: repo.defaultBranch, - worktrees: [], }; } } catch (error) { @@ -708,10 +703,10 @@ async function executeInit(options: InitOptions): Promise { // 10. Generate and write config const arashiConfig: config.Config = { + $schema: config.DEFAULT_CONFIG_SCHEMA_URL, version: "1.0.0", - repos_dir: reposDir, - auto_setup: options.autoSetup !== undefined ? options.autoSetup : true, - discovered_repos: discoveredRepos, + reposDir: reposDir, + repos: discoveredRepos, }; const configPath = config.getConfigPath(cwd); @@ -887,18 +882,14 @@ export function createCommand(): Command { .option("--repos-dir ", "Custom location for managed repositories", "./repos") .option("--force", "Overwrite existing configuration if present") .option("--no-discover", "Skip automatic repository discovery") - .option("--auto-setup", "Enable automatic setup hook execution (default: true)") - .option("--no-auto-setup", "Disable automatic setup hook execution") .option("--dry-run", "Show what would be done without making changes") .option("--verbose", "Show detailed information during initialization") - .action(async (options: InitOptions & { discover?: boolean; autoSetup?: boolean }) => { + .action(async (options: InitOptions & { discover?: boolean }) => { // Commander converts --no-discover to discover: false - // Commander converts --no-auto-setup to autoSetup: false const normalizedOptions: InitOptions = { reposDir: options.reposDir, force: options.force, noDiscover: options.discover === false, // --no-discover sets discover: false - autoSetup: options.autoSetup !== false, // --no-auto-setup sets autoSetup: false dryRun: options.dryRun, verbose: options.verbose, }; diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 5a9cc86..6a61ca1 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -105,9 +105,9 @@ export async function executeRemove( { error: error instanceof Error ? error.message : String(error) }, ); } - const reposDirName = basename(config.repos_dir); - const childRepoNames = new Set(Object.keys(config.discovered_repos)); - const repositories = buildRepositoryTargets(workspaceRoot, config.discovered_repos); + const reposDirName = basename(config.reposDir); + const childRepoNames = new Set(Object.keys(config.repos)); + const repositories = buildRepositoryTargets(workspaceRoot, config.repos); if (repositories.length === 0) { throw new RemoveCommandError( @@ -118,7 +118,7 @@ export async function executeRemove( const prompt = promptHandlers || { confirm: promptConfirm, multiSelect: promptMultiSelect }; const allowNonInteractive = Boolean(promptHandlers); - const defaultBranches = await getDefaultBranchMap(workspaceRoot, config.discovered_repos); + const defaultBranches = await getDefaultBranchMap(workspaceRoot, config.repos); const usedPathMode = { value: false }; const pathWorktrees: WorktreeEntry[] = []; let targetBranches: string[] = []; @@ -542,19 +542,15 @@ function expandSelectedWorktrees( async function getDefaultBranchMap( workspaceRoot: string, - repos: Record, + repos: Record, ): Promise> { const map: Record = {}; const mainName = basename(workspaceRoot); map[mainName] = await resolveDefaultBranch(workspaceRoot); for (const [name, repo] of Object.entries(repos)) { - if (repo.default_branch) { - map[name] = repo.default_branch; - } else { - const repoPath = resolve(workspaceRoot, repo.path); - map[name] = await resolveDefaultBranch(repoPath); - } + const repoPath = resolve(workspaceRoot, repo.path); + map[name] = await resolveDefaultBranch(repoPath); } return map; diff --git a/src/commands/status.ts b/src/commands/status.ts index 5326fd3..97a3afc 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -294,8 +294,8 @@ export async function checkAllRepos( { name: "Main Repository", path: workspaceRoot }, ]; - // Add all discovered repos (resolve relative paths to absolute) - for (const [name, repoConfig] of Object.entries(config.discovered_repos)) { + // Add all configured repos (resolve relative paths to absolute) + for (const [name, repoConfig] of Object.entries(config.repos)) { const absolutePath = resolve(workspaceRoot, repoConfig.path); reposToCheck.push({ name, path: absolutePath }); } diff --git a/src/commands/switch.ts b/src/commands/switch.ts index 912db52..9c00f90 100644 --- a/src/commands/switch.ts +++ b/src/commands/switch.ts @@ -128,7 +128,7 @@ export async function executeSwitch( if (scope === "all") { scopedCandidates = await augmentAllCandidates(scopedCandidates, { workspaceRoot, - reposDir: workspace.config?.repos_dir ?? "./repos", + reposDir: workspace.config?.reposDir ?? "./repos", repositories: workspace.repositories, }); } diff --git a/src/commands/sync.ts b/src/commands/sync.ts index e94d259..faf5d11 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -36,7 +36,7 @@ export async function executeSync(options: SyncCommandOptions): Promise 0) { throw new Error(`Repositories not found: ${missing.join(", ")}`); } diff --git a/src/core/list.ts b/src/core/list.ts index 0883fb9..7ae641f 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -26,7 +26,7 @@ import chalk from "chalk"; // ============================================================================ /** - * Find parent repository if current directory is a child repo within repos_dir + * Find parent repository if current directory is a child repo within reposDir * * When running arashi list from within a child repository (e.g., repos/my-app), * this function searches upward to find the parent worktree that contains the @@ -35,7 +35,7 @@ import chalk from "chalk"; * * **Detection Logic:** * 1. Walk up directory tree looking for .arashi/config.json - * 2. If found, check if current directory is within that config's repos_dir + * 2. If found, check if current directory is within that config's reposDir * 3. If yes, return the parent repo path (containing .arashi) * 4. If no, return null (we're in the main/parent repo) * @@ -68,19 +68,19 @@ async function findParentRepo(currentPath: string): Promise { // Check if config exists await access(configPath, constants.R_OK); - // Found a config - load it to check repos_dir + // Found a config - load it to check reposDir try { const cfg = await config.loadConfig(searchPath); - const reposDirAbs = resolve(searchPath, cfg.repos_dir); + const reposDirAbs = resolve(searchPath, cfg.reposDir); - // Check if current path is within repos_dir + // Check if current path is within reposDir const rel = relative(reposDirAbs, currentPath); if (rel && !rel.startsWith("..") && !isAbsolute(rel)) { - // We're inside repos_dir of this config - this is the parent repo + // We're inside reposDir of this config - this is the parent repo return searchPath; } - // We found a config but we're not in its repos_dir + // We found a config but we're not in its reposDir // This means we're already at the parent level return null; } catch { @@ -117,7 +117,7 @@ async function findParentRepo(currentPath: string): Promise { * - Configuration is optional (warns if `.arashi/config.json` is missing) * - Shows progress spinner in verbose mode (suppressed in JSON mode) * - Gracefully handles errors with clear messages - * - If run from within repos_dir, automatically switches to parent repo + * - If run from within reposDir, automatically switches to parent repo * * @param options - Command options controlling output format and behavior * @param options.verbose - If true, discovers nested sub-repositories (slower) @@ -189,7 +189,7 @@ export async function listCommand(options?: ListCommandOptions): Promise { throw new NotInRepositoryError(cwd); } - // Try to find parent repo if we're in a child repo within repos_dir + // Try to find parent repo if we're in a child repo within reposDir const parentRepo = await findParentRepo(cwd); if (parentRepo) { // We're in a child repo - use parent repo for listing worktrees diff --git a/src/core/worktree.ts b/src/core/worktree.ts index ea26111..c0b13fb 100644 --- a/src/core/worktree.ts +++ b/src/core/worktree.ts @@ -558,13 +558,15 @@ export async function detectRepositoryType( } // Check if this is a child repository - // A child repo must be directly inside a repos_dir/ folder, and that repos_dir + // A child repo must be directly inside a reposDir/ folder, and that reposDir // must be inside a meta-repo (has .arashi/config.json) if (config) { - const reposDir = basename(config.repos_dir); + const reposDir = basename( + config.reposDir ?? (config as { repos_dir?: string }).repos_dir ?? "./repos", + ); const pathParts = repo.path.split(sep); - // Check if the immediate parent directory is the repos_dir + // Check if the immediate parent directory is the reposDir const parentDir = pathParts[pathParts.length - 2]; if (parentDir === reposDir) { // Check if grandparent has .arashi/config.json (is a meta-repo) @@ -1116,9 +1118,8 @@ export async function createCoordinatedWorktrees( config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: false, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, }; } } diff --git a/src/lib/clone-discovery.ts b/src/lib/clone-discovery.ts index ee0de65..261b506 100644 --- a/src/lib/clone-discovery.ts +++ b/src/lib/clone-discovery.ts @@ -34,7 +34,7 @@ export async function discoverCloneRepositories( const configuredMissing: ConfiguredRepositoryState[] = []; const configuredNames = new Set(); - for (const [name, repoConfig] of Object.entries(config.discovered_repos)) { + for (const [name, repoConfig] of Object.entries(config.repos)) { configuredNames.add(name); const repoPath = resolve(workspaceRoot, repoConfig.path); const repoExists = await pathExists(repoPath); @@ -53,7 +53,7 @@ export async function discoverCloneRepositories( } const unmanagedLocal: UnmanagedRepositoryState[] = []; - const reposRoot = resolve(workspaceRoot, config.repos_dir); + const reposRoot = resolve(workspaceRoot, config.reposDir); const reposRootExists = await pathExists(reposRoot); if (reposRootExists) { const entries = await readdir(reposRoot, { withFileTypes: true }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 5f76a70..8c89839 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -15,18 +15,6 @@ import { exec, readTrackedFileFromDefaultBranch } from "./git.ts"; // Data Types // ============================================================================ -/** - * Hook configuration for lifecycle events - */ -export interface HookConfig { - /** Path to script executed before worktree creation */ - pre_create?: string; - /** Path to script executed after worktree creation */ - post_create?: string; - /** Path to script executed during repository setup */ - setup?: string; -} - /** * Information about a single git worktree */ @@ -36,7 +24,7 @@ export interface WorktreeInfo { /** Filesystem path to the worktree */ path: string; /** ISO 8601 timestamp when worktree was created */ - created_at: string; + createdAt: string; /** Optional user-defined metadata */ metadata?: Record; } @@ -48,36 +36,38 @@ export interface RepoConfig { /** Path to the repository (relative or absolute) */ path: string; /** Canonical git URL for cloning the repository */ - git_url?: string; - /** Name of the default branch (auto-detected if omitted) */ - default_branch?: string; - /** Whether the repository is bare (auto-detected if omitted) */ - is_bare?: boolean; - /** List of active worktrees for this repository */ - worktrees?: WorktreeInfo[]; - /** Custom hook configuration for this repository */ - hooks?: HookConfig; + gitUrl?: string; } +export const CURRENT_CONFIG_VERSION = "1.0.0" as const; +export type ConfigVersion = typeof CURRENT_CONFIG_VERSION; + /** * Root configuration object for Arashi */ export interface Config { + /** JSON Schema URL for editor validation/autocomplete */ + $schema?: string; /** Configuration schema version for migrations */ - version: string; + version: ConfigVersion; /** Directory where repositories are located */ - repos_dir: string; - /** Whether to automatically run setup hooks */ - auto_setup: boolean; + reposDir: string; /** Optional workspace-level hooks settings */ hooks?: { /** Timeout in milliseconds for long-running operations */ timeout?: number; }; + /** Optional sync command settings */ + sync?: { + /** Sync timeout in seconds */ + timeoutSeconds?: number; + }; /** Map of repository names to their configurations */ - discovered_repos: Record; + repos: Record; } +export const DEFAULT_CONFIG_SCHEMA_URL = "https://unpkg.com/arashi/schema/config.schema.json"; + type ConfigErrorContext = { errors: string[]; [key: string]: unknown; @@ -93,8 +83,6 @@ export interface WorkspaceRepository { path: string; /** Canonical git URL from configuration, if available */ gitUrl?: string; - /** Default branch from config, if present */ - defaultBranch?: string; } export type ConfigSourceType = "local-file" | "repository-content"; @@ -172,6 +160,20 @@ export class ConfigValidationError extends ConfigError { } } +/** + * Error thrown when configuration version is not supported by this CLI release. + */ +export class UnsupportedConfigVersionError extends ConfigError { + constructor(version: string, supportedVersion: ConfigVersion) { + super( + `Unsupported configuration version "${version}". This version of arashi supports "${supportedVersion}".`, + undefined, + { version, supportedVersion }, + ); + this.name = "UnsupportedConfigVersionError"; + } +} + // ============================================================================ // Helper Functions // ============================================================================ @@ -263,10 +265,10 @@ export async function findWorkspaceRoot(startPath: string = process.cwd()): Prom * Generate default configuration * * Creates a minimal valid configuration with sensible defaults: - * - version: "1.0.0" - * - repos_dir: "./repos" - * - auto_setup: true - * - discovered_repos: {} + * - $schema: "https://unpkg.com/arashi/schema/config.schema.json" + * - version: CURRENT_CONFIG_VERSION + * - reposDir: "./repos" + * - repos: {} * * @returns Default configuration object * @@ -278,10 +280,10 @@ export async function findWorkspaceRoot(startPath: string = process.cwd()): Prom */ export function generateDefaultConfig(): Config { return { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + $schema: DEFAULT_CONFIG_SCHEMA_URL, + version: CURRENT_CONFIG_VERSION, + reposDir: "./repos", + repos: {}, }; } @@ -289,210 +291,319 @@ export function generateDefaultConfig(): Config { // Validation Functions // ============================================================================ -/** - * Validate configuration structure and required fields - * - * Checks: - * - All required fields present (version, repos_dir, auto_setup, discovered_repos) - * - Field types are correct - * - Nested structures valid (RepoConfig, WorktreeInfo, HookConfig) - * - * Does NOT check: - * - File system paths exist - * - Git repository validity - * - Hook script permissions - * - * @param config - Configuration object to validate - * @throws {ConfigValidationError} If validation fails with specific error details - * - * @example - * ```typescript - * try { - * validateConfig(loadedData); - * } catch (error) { - * if (error instanceof ConfigValidationError) { - * console.error('Validation errors:', error.context.errors); - * } - * } - * ``` - */ -export function validateConfig(config: unknown): asserts config is Config { - const errors: string[] = []; - const cfg = config as Record; +const ROOT_ALLOWED_KEYS = new Set([ + "$schema", + "version", + "reposDir", + "repos_dir", + "repos", + "discoveredRepos", + "discovered_repos", + "hooks", + "sync", +]); + +const ROOT_HOOKS_ALLOWED_KEYS = new Set(["timeout"]); +const ROOT_SYNC_ALLOWED_KEYS = new Set(["timeoutSeconds", "timeout_seconds"]); +const VERSION_ALIASES = new Map([["1", CURRENT_CONFIG_VERSION]]); + +const REPO_ALLOWED_KEYS = new Set([ + "path", + "gitUrl", + "git_url", + "defaultBranch", + "default_branch", + "isBare", + "is_bare", + "worktrees", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} - // Validate root level fields - if (typeof config !== "object" || config === null) { - throw new ConfigValidationError(["Config must be an object"]); +function getFirstDefined(...values: Array): T | undefined { + for (const value of values) { + if (value !== undefined) { + return value; + } } + return undefined; +} - if (typeof cfg.version !== "string" || cfg.version === "") { - errors.push("version: must be a non-empty string"); +function validateNoUnknownKeys( + value: Record, + allowedKeys: Set, + prefix: string, + errors: string[], +): void { + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + const label = prefix ? `${prefix}.${key}` : key; + errors.push(`${label}: unknown property`); + } } +} - if (typeof cfg.repos_dir !== "string" || cfg.repos_dir === "") { - errors.push("repos_dir: must be a non-empty string"); +function resolveConfigVersion( + rawVersion: unknown, + errors: string[], +): { + version: ConfigVersion; + migratedFromVersion?: string; +} { + if (typeof rawVersion !== "string" || rawVersion.trim() === "") { + errors.push("version: must be a non-empty string"); + return { version: CURRENT_CONFIG_VERSION }; } - if (typeof cfg.auto_setup !== "boolean") { - errors.push("auto_setup: must be a boolean"); - } + const version = rawVersion.trim(); + const canonicalVersion = VERSION_ALIASES.get(version) ?? version; - if ( - typeof cfg.discovered_repos !== "object" || - cfg.discovered_repos === null || - Array.isArray(cfg.discovered_repos) - ) { - errors.push("discovered_repos: must be an object"); - } else { - // Validate each repository configuration - for (const [repoName, repoConfig] of Object.entries( - cfg.discovered_repos as Record, - )) { - validateRepoConfig(repoName, repoConfig, errors); - } + if (canonicalVersion !== CURRENT_CONFIG_VERSION) { + throw new UnsupportedConfigVersionError(version, CURRENT_CONFIG_VERSION); } - if (errors.length > 0) { - throw new ConfigValidationError(errors); + if (canonicalVersion !== version) { + return { + version: canonicalVersion, + migratedFromVersion: version, + }; } + + return { + version: CURRENT_CONFIG_VERSION, + }; } -/** - * Validate a single repository configuration - * - * @param repoName - Name of the repository (for error messages) - * @param repoConfig - Repository configuration to validate - * @param errors - Array to accumulate validation errors - */ -function validateRepoConfig(repoName: string, repoConfig: unknown, errors: string[]): void { - const prefix = `discovered_repos.${repoName}`; - const repo = repoConfig as Record; +function normalizeRepoConfig( + repoName: string, + value: unknown, + errors: string[], +): RepoConfig | null { + const prefix = `repos.${repoName}`; - if (typeof repoConfig !== "object" || repoConfig === null) { + if (!isRecord(value)) { errors.push(`${prefix}: must be an object`); - return; + return null; } - // Required field: path - if (typeof repo.path !== "string" || repo.path === "") { + validateNoUnknownKeys(value, REPO_ALLOWED_KEYS, prefix, errors); + + const path = value.path; + const gitUrl = getFirstDefined( + value.gitUrl as string | undefined, + value.git_url as string | undefined, + ); + + if (typeof path !== "string" || path.trim() === "") { errors.push(`${prefix}.path: must be a non-empty string`); + return null; } - // Optional field: default_branch - if (repo.default_branch !== undefined) { - if (typeof repo.default_branch !== "string" || repo.default_branch === "") { - errors.push(`${prefix}.default_branch: must be a non-empty string if present`); + const normalized: RepoConfig = { path }; + + if (gitUrl !== undefined) { + if (typeof gitUrl !== "string" || gitUrl.trim() === "") { + errors.push(`${prefix}.gitUrl: must be a non-empty string if present`); + } else { + normalized.gitUrl = gitUrl; } } - // Optional field: git_url - if (repo.git_url !== undefined) { - if (typeof repo.git_url !== "string" || repo.git_url.trim() === "") { - errors.push(`${prefix}.git_url: must be a non-empty string if present`); - } + return normalized; +} + +function normalizeWorkspaceHooks( + value: unknown, + prefix: string, + errors: string[], +): Config["hooks"] | undefined { + if (value === undefined) { + return undefined; } - // Optional field: is_bare - if (repo.is_bare !== undefined) { - if (typeof repo.is_bare !== "boolean") { - errors.push(`${prefix}.is_bare: must be a boolean if present`); - } + if (!isRecord(value)) { + errors.push(`${prefix}: must be an object if present`); + return undefined; } - // Optional field: worktrees - if (repo.worktrees !== undefined) { - if (!Array.isArray(repo.worktrees)) { - errors.push(`${prefix}.worktrees: must be an array if present`); - } else { - repo.worktrees.forEach((worktree, index: number) => { - validateWorktreeInfo(`${prefix}.worktrees[${index}]`, worktree, errors); - }); - } + validateNoUnknownKeys(value, ROOT_HOOKS_ALLOWED_KEYS, prefix, errors); + + const timeout = value.timeout; + if (timeout === undefined) { + return undefined; } - // Optional field: hooks - if (repo.hooks !== undefined) { - validateHookConfig(`${prefix}.hooks`, repo.hooks, errors); + if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) { + errors.push(`${prefix}.timeout: must be a positive number if present`); + return undefined; } + + return { timeout }; } -/** - * Validate a single worktree configuration - * - * @param prefix - Path prefix for error messages - * @param worktree - Worktree info to validate - * @param errors - Array to accumulate validation errors - */ -function validateWorktreeInfo(prefix: string, worktree: unknown, errors: string[]): void { - const wt = worktree as Record; - if (typeof worktree !== "object" || worktree === null) { - errors.push(`${prefix}: must be an object`); - return; +function normalizeSyncConfig( + value: unknown, + prefix: string, + errors: string[], +): Config["sync"] | undefined { + if (value === undefined) { + return undefined; } - // Required field: branch - if (typeof wt.branch !== "string" || wt.branch === "") { - errors.push(`${prefix}.branch: must be a non-empty string`); + if (!isRecord(value)) { + errors.push(`${prefix}: must be an object if present`); + return undefined; } - // Required field: path - if (typeof wt.path !== "string" || wt.path === "") { - errors.push(`${prefix}.path: must be a non-empty string`); - } + validateNoUnknownKeys(value, ROOT_SYNC_ALLOWED_KEYS, prefix, errors); - // Required field: created_at - if (typeof wt.created_at !== "string" || wt.created_at === "") { - errors.push(`${prefix}.created_at: must be a non-empty string`); - } else { - // Validate ISO 8601 format - const date = new Date(wt.created_at); - if (isNaN(date.getTime())) { - errors.push(`${prefix}.created_at: must be a valid ISO 8601 date string`); - } + const timeoutSeconds = getFirstDefined( + value.timeoutSeconds as number | undefined, + value.timeout_seconds as number | undefined, + ); + + if (timeoutSeconds === undefined) { + return undefined; } - // Optional field: metadata - if (wt.metadata !== undefined) { - if (typeof wt.metadata !== "object" || wt.metadata === null || Array.isArray(wt.metadata)) { - errors.push(`${prefix}.metadata: must be an object if present`); - } + if ( + typeof timeoutSeconds !== "number" || + !Number.isFinite(timeoutSeconds) || + timeoutSeconds < 0 + ) { + errors.push(`${prefix}.timeoutSeconds: must be a non-negative number if present`); + return undefined; } + + return { + timeoutSeconds, + }; } /** - * Validate hook configuration + * Normalize legacy/snake_case config keys to canonical camelCase format. * - * @param prefix - Path prefix for error messages - * @param hooks - Hook config to validate - * @param errors - Array to accumulate validation errors + * Accepted legacy aliases: + * - repos_dir -> reposDir + * - discovered_repos / discoveredRepos -> repos + * - git_url -> gitUrl + * + * Legacy repository metadata keys (`defaultBranch`, `isBare`, `worktrees`) are + * accepted for backward compatibility but intentionally dropped from the + * normalized result. */ -function validateHookConfig(prefix: string, hooks: unknown, errors: string[]): void { - const hookConfig = hooks as Record; - if (typeof hooks !== "object" || hooks === null) { - errors.push(`${prefix}: must be an object`); - return; +function normalizeConfigInternal(config: unknown): { + config: Config; + migratedFromVersion?: string; +} { + const errors: string[] = []; + + if (!isRecord(config)) { + throw new ConfigValidationError(["Config must be an object"]); } - // Optional field: pre_create - if (hookConfig.pre_create !== undefined) { - if (typeof hookConfig.pre_create !== "string" || hookConfig.pre_create === "") { - errors.push(`${prefix}.pre_create: must be a non-empty string if present`); - } + validateNoUnknownKeys(config, ROOT_ALLOWED_KEYS, "", errors); + + const schema = config.$schema; + const versionInfo = resolveConfigVersion(config.version, errors); + const reposDir = getFirstDefined( + config.reposDir as string | undefined, + config.repos_dir as string | undefined, + ); + const reposRaw = getFirstDefined( + config.repos as Record | undefined, + config.discoveredRepos as Record | undefined, + config.discovered_repos as Record | undefined, + ); + const hooks = normalizeWorkspaceHooks(config.hooks, "hooks", errors); + const sync = normalizeSyncConfig(config.sync, "sync", errors); + + if (schema !== undefined && (typeof schema !== "string" || schema.trim() === "")) { + errors.push("$schema: must be a non-empty string if present"); } - // Optional field: post_create - if (hookConfig.post_create !== undefined) { - if (typeof hookConfig.post_create !== "string" || hookConfig.post_create === "") { - errors.push(`${prefix}.post_create: must be a non-empty string if present`); - } + if (typeof reposDir !== "string" || reposDir.trim() === "") { + errors.push("reposDir: must be a non-empty string"); } - // Optional field: setup - if (hookConfig.setup !== undefined) { - if (typeof hookConfig.setup !== "string" || hookConfig.setup === "") { - errors.push(`${prefix}.setup: must be a non-empty string if present`); + const normalizedRepos: Record = {}; + if (!isRecord(reposRaw)) { + errors.push("repos: must be an object"); + } else { + for (const [repoName, repoConfig] of Object.entries(reposRaw)) { + const normalized = normalizeRepoConfig(repoName, repoConfig, errors); + if (normalized) { + normalizedRepos[repoName] = normalized; + } } } + + if (errors.length > 0) { + throw new ConfigValidationError(errors); + } + + const normalizedVersion = versionInfo.version; + const normalizedReposDir = reposDir as string; + + const normalizedConfig: Config = { + version: normalizedVersion, + reposDir: normalizedReposDir, + repos: normalizedRepos, + }; + + if (typeof schema === "string") { + normalizedConfig.$schema = schema; + } + + if (hooks) { + normalizedConfig.hooks = hooks; + } + + if (sync) { + normalizedConfig.sync = sync; + } + + return { + config: normalizedConfig, + migratedFromVersion: versionInfo.migratedFromVersion, + }; +} + +export function normalizeConfig(config: unknown): Config { + return normalizeConfigInternal(config).config; +} + +/** + * Validate configuration structure and required fields + * + * Checks: + * - All required fields present (version, reposDir, repos) + * - Field types are correct + * - Nested structures valid (RepoConfig and workspace-level hooks/sync objects) + * + * Does NOT check: + * - File system paths exist + * - Git repository validity + * - Hook script permissions + * + * @param config - Configuration object to validate + * @throws {ConfigValidationError} If validation fails with specific error details + * + * @example + * ```typescript + * try { + * validateConfig(loadedData); + * } catch (error) { + * if (error instanceof ConfigValidationError) { + * console.error('Validation errors:', error.context.errors); + * } + * } + * ``` + */ +export function validateConfig(config: unknown): asserts config is Config { + normalizeConfig(config); } // ============================================================================ @@ -511,7 +622,7 @@ function validateHookConfig(prefix: string, hooks: unknown, errors: string[]): v * @example * ```typescript * const config = await loadConfig('/path/to/repo'); - * console.log(config.repos_dir); // "./repos" + * console.log(config.reposDir); // "./repos" * ``` */ export async function loadConfig(repoPath: string): Promise { @@ -541,10 +652,13 @@ export async function loadConfig(repoPath: string): Promise { throw new ConfigParseError(configPath, error as Error); } - // Validate structure - validateConfig(data); + const normalized = normalizeConfigInternal(data); + + if (normalized.migratedFromVersion) { + await saveConfig(repoPath, normalized.config); + } - return data; + return normalized.config; } function parseAndValidateConfig(text: string, configPath: string): Config { @@ -555,8 +669,7 @@ function parseAndValidateConfig(text: string, configPath: string): Config { throw new ConfigParseError(configPath, error as Error); } - validateConfig(data); - return data; + return normalizeConfigInternal(data).config; } /** @@ -606,6 +719,43 @@ export async function loadConfigWithFallback( } } +function normalizePersistedRepoConfig(repoConfig: RepoConfig): RepoConfig { + const normalized: RepoConfig = { + path: repoConfig.path, + }; + + if (repoConfig.gitUrl && repoConfig.gitUrl.trim().length > 0) { + normalized.gitUrl = repoConfig.gitUrl; + } + + return normalized; +} + +function normalizePersistedConfig(config: Config): Config { + const repos: Record = {}; + + for (const [name, repoConfig] of Object.entries(config.repos)) { + repos[name] = normalizePersistedRepoConfig(repoConfig); + } + + const persisted: Config = { + $schema: config.$schema ?? DEFAULT_CONFIG_SCHEMA_URL, + version: config.version, + reposDir: config.reposDir, + repos, + }; + + if (config.hooks) { + persisted.hooks = config.hooks; + } + + if (config.sync) { + persisted.sync = config.sync; + } + + return persisted; +} + /** * Save configuration to .arashi/config.json * @@ -619,7 +769,7 @@ export async function loadConfigWithFallback( * @example * ```typescript * const config = await loadConfig('/path/to/repo'); - * config.auto_setup = false; + * config.reposDir = "./repos"; * await saveConfig('/path/to/repo', config); * ``` */ @@ -628,11 +778,14 @@ export async function saveConfig(repoPath: string, config: Config): Promise const config = await loadConfig(repoPath); // Remove repository (idempotent - no error if doesn't exist) - delete config.discovered_repos[name]; + delete config.repos[name]; // Save updated configuration await saveConfig(repoPath, config); @@ -721,12 +873,11 @@ export async function loadWorkspaceRepositories( path: resolve(workspaceRoot), }); - for (const [name, repoConfig] of Object.entries(config.discovered_repos)) { + for (const [name, repoConfig] of Object.entries(config.repos)) { repositories.push({ name, path: resolve(workspaceRoot, repoConfig.path), - gitUrl: repoConfig.git_url, - defaultBranch: repoConfig.default_branch, + gitUrl: repoConfig.gitUrl, }); } @@ -743,7 +894,7 @@ export interface GitUrlRepairResult { * Attempt to fill missing repository git URLs from local clone remotes. * * This provides backward-compatible repair behavior for existing workspaces - * where `discovered_repos` entries were created before `git_url` tracking. + * where `repos` entries were created before `gitUrl` tracking. */ export async function repairRepositoryGitUrls( workspaceRoot: string, @@ -752,15 +903,15 @@ export async function repairRepositoryGitUrls( const repaired: string[] = []; const unresolved: string[] = []; - for (const [name, repoConfig] of Object.entries(config.discovered_repos)) { - if (repoConfig.git_url && repoConfig.git_url.trim().length > 0) { + for (const [name, repoConfig] of Object.entries(config.repos)) { + if (repoConfig.gitUrl && repoConfig.gitUrl.trim().length > 0) { continue; } const repoPath = resolve(workspaceRoot, repoConfig.path); const gitUrl = await resolveOriginRemoteUrl(repoPath); if (gitUrl) { - repoConfig.git_url = gitUrl; + repoConfig.gitUrl = gitUrl; repaired.push(name); } else { unresolved.push(name); diff --git a/src/types.ts b/src/types.ts index 1a26676..1844eb5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,27 +2,26 @@ export interface ArashiConfig { version: string; - repos_dir: string; + reposDir: string; worktree_strategy: "same_branch"; - auto_setup: boolean; - discovered_repos: { + repos: { [repoName: string]: RepoConfig; }; } export interface RepoConfig { path: string; - default_branch: string; + defaultBranch: string; remote: string; has_setup_script: boolean; - git_url?: string; + gitUrl?: string; } export interface WorktreeInfo { branch: string; path: string; head: string; - is_bare: boolean; + isBare: boolean; repos: { [repoName: string]: { path: string; diff --git a/tests/fixtures/extra-fields.json b/tests/fixtures/extra-fields.json index 53ba159..37de139 100644 --- a/tests/fixtures/extra-fields.json +++ b/tests/fixtures/extra-fields.json @@ -1,7 +1,6 @@ { "version": "1.0.0", "repos_dir": "./repos", - "auto_setup": true, "discovered_repos": {}, "future_feature": "some value", "custom_metadata": { diff --git a/tests/fixtures/invalid-json.json b/tests/fixtures/invalid-json.json index 9f97a13..5cb2f79 100644 --- a/tests/fixtures/invalid-json.json +++ b/tests/fixtures/invalid-json.json @@ -1,5 +1,4 @@ { "version": "1.0.0", "repos_dir": "./repos", - "auto_setup": true, "discovered_repos": { diff --git a/tests/fixtures/missing-repos-dir.json b/tests/fixtures/missing-repos-dir.json index 8b76924..ed5d2c3 100644 --- a/tests/fixtures/missing-repos-dir.json +++ b/tests/fixtures/missing-repos-dir.json @@ -1,5 +1,4 @@ { "version": "1.0.0", - "auto_setup": true, "discovered_repos": {} } diff --git a/tests/fixtures/missing-version.json b/tests/fixtures/missing-version.json index 567f311..ed7f5a8 100644 --- a/tests/fixtures/missing-version.json +++ b/tests/fixtures/missing-version.json @@ -1,5 +1,4 @@ { "repos_dir": "./repos", - "auto_setup": true, "discovered_repos": {} } diff --git a/tests/fixtures/valid-config.json b/tests/fixtures/valid-config.json index c52da56..041ea57 100644 --- a/tests/fixtures/valid-config.json +++ b/tests/fixtures/valid-config.json @@ -1,7 +1,6 @@ { "version": "1.0.0", "repos_dir": "./repos", - "auto_setup": true, "discovered_repos": { "example-repo": { "path": "./repos/example-repo", @@ -16,10 +15,7 @@ "jira_ticket": "PROJ-123" } } - ], - "hooks": { - "post_create": "./.arashi/hooks/post-create.sh" - } + ] } } } diff --git a/tests/helpers/create-bare-create-workspace.ts b/tests/helpers/create-bare-create-workspace.ts index 4f9e982..484d4c0 100644 --- a/tests/helpers/create-bare-create-workspace.ts +++ b/tests/helpers/create-bare-create-workspace.ts @@ -44,9 +44,8 @@ export async function createBareCreateWorkspace( JSON.stringify( { version: "1.0.0", - repos_dir: configReposDir, - auto_setup: true, - discovered_repos: {}, + reposDir: configReposDir, + repos: {}, }, null, 2, diff --git a/tests/helpers/create-child-hook-workspace.ts b/tests/helpers/create-child-hook-workspace.ts index bcb075b..29a95db 100644 --- a/tests/helpers/create-child-hook-workspace.ts +++ b/tests/helpers/create-child-hook-workspace.ts @@ -40,10 +40,8 @@ export async function createChildHookWorkspace( await initGitRepo(workspacePath, "main"); - const discoveredRepos: Record< - string, - { path: string; default_branch: string; is_bare: boolean } - > = {}; + const discoveredRepos: Record = + {}; const childRepoPaths: Record = {}; for (const repoName of childRepoNames) { @@ -57,8 +55,8 @@ export async function createChildHookWorkspace( childRepoPaths[repoName] = repoPath; discoveredRepos[repoName] = { path: `./repos/${repoName}`, - default_branch: "main", - is_bare: false, + defaultBranch: "main", + isBare: false, }; } @@ -68,12 +66,11 @@ export async function createChildHookWorkspace( JSON.stringify( { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", hooks: { timeout: options.hookTimeoutMs ?? 1000, }, - discovered_repos: discoveredRepos, + repos: discoveredRepos, }, null, 2, diff --git a/tests/helpers/remove-test-workspace.ts b/tests/helpers/remove-test-workspace.ts index 1f026ee..49c2bc8 100644 --- a/tests/helpers/remove-test-workspace.ts +++ b/tests/helpers/remove-test-workspace.ts @@ -37,15 +37,14 @@ export async function createRemoveWorkspace( const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: Object.fromEntries( + reposDir: "./repos", + repos: Object.fromEntries( repos.map((repo) => [ repo.name, { path: `./repos/${repo.name}`, - default_branch: "main", - is_bare: false, + defaultBranch: "main", + isBare: false, worktrees: [], }, ]), diff --git a/tests/integration/add-duplicate-guidance.test.ts b/tests/integration/add-duplicate-guidance.test.ts index ca0e301..126783a 100644 --- a/tests/integration/add-duplicate-guidance.test.ts +++ b/tests/integration/add-duplicate-guidance.test.ts @@ -13,12 +13,11 @@ describe("add command duplicate guidance", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "arashi-docs": { path: "./repos/arashi-docs", - git_url: "git@github.com:corwinm/arashi-docs.git", + gitUrl: "git@github.com:corwinm/arashi-docs.git", }, }, }; diff --git a/tests/integration/clone.test.ts b/tests/integration/clone.test.ts index 47a5b2b..7acf798 100644 --- a/tests/integration/clone.test.ts +++ b/tests/integration/clone.test.ts @@ -22,12 +22,11 @@ describe("clone command", () => { const config: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "repo-a": { path: "./repos/repo-a", - git_url: "git@github.com:team/repo-a.git", + gitUrl: "git@github.com:team/repo-a.git", }, }, }; @@ -49,16 +48,15 @@ describe("clone command", () => { test("supports interactive selection of missing repositories", async () => { const config: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "repo-a": { path: "./repos/repo-a", - git_url: "git@github.com:team/repo-a.git", + gitUrl: "git@github.com:team/repo-a.git", }, "repo-b": { path: "./repos/repo-b", - git_url: "git@github.com:team/repo-b.git", + gitUrl: "git@github.com:team/repo-b.git", }, }, }; @@ -90,16 +88,15 @@ describe("clone command", () => { test("clones all missing repositories with --all", async () => { const config: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "repo-a": { path: "./repos/repo-a", - git_url: "https://github.com/team/repo-a.git", + gitUrl: "https://github.com/team/repo-a.git", }, "repo-b": { path: "./repos/repo-b", - git_url: "https://github.com/team/repo-b.git", + gitUrl: "https://github.com/team/repo-b.git", }, }, }; @@ -127,16 +124,15 @@ describe("clone command", () => { test("continues cloning after partial failures", async () => { const config: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "repo-a": { path: "./repos/repo-a", - git_url: "git@github.com:team/repo-a.git", + gitUrl: "git@github.com:team/repo-a.git", }, "repo-b": { path: "./repos/repo-b", - git_url: "git@github.com:team/repo-b.git", + gitUrl: "git@github.com:team/repo-b.git", }, }, }; diff --git a/tests/integration/config-integration.test.ts b/tests/integration/config-integration.test.ts index 1a75603..bfc27e4 100644 --- a/tests/integration/config-integration.test.ts +++ b/tests/integration/config-integration.test.ts @@ -17,6 +17,7 @@ import { ConfigNotFoundError, ConfigParseError, ConfigValidationError, + UnsupportedConfigVersionError, ConfigError, type Config, } from "../../src/lib/config"; @@ -91,7 +92,7 @@ describe("saveConfig", () => { // Check for 2-space indentation expect(content).toContain(' "version"'); - expect(content).toContain(' "repos_dir"'); + expect(content).toContain(' "reposDir"'); expect(content).not.toContain(' "version"'); // Not 4 spaces // Verify it's valid JSON @@ -114,48 +115,38 @@ describe("saveConfig", () => { await saveConfig(testDir, config1); const config2 = generateDefaultConfig(); - config2.auto_setup = false; + config2.reposDir = "./custom-repos"; await saveConfig(testDir, config2); const loaded = await loadConfig(testDir); - expect(loaded.auto_setup).toBe(false); + expect(loaded.reposDir).toBe("./custom-repos"); }); - test("preserves complex nested structures", async () => { - const config: Config = { + test("drops deprecated repository metadata while preserving canonical fields", async () => { + const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "test-repo": { path: "./repos/test-repo", - default_branch: "main", - is_bare: false, + defaultBranch: "main", + isBare: false, worktrees: [ { branch: "feature-123", path: "./repos/test-repo.worktrees/feature-123", - created_at: "2026-02-03T10:30:00Z", - metadata: { - jira: "PROJ-123", - owner: "alice", - }, + createdAt: "2026-02-03T10:30:00Z", }, ], - hooks: { - post_create: "./.arashi/hooks/post-create.sh", - }, }, }, }; - await saveConfig(testDir, config); + await saveConfig(testDir, config as unknown as Config); const loaded = await loadConfig(testDir); - expect(loaded).toEqual(config); - expect(loaded.discovered_repos["test-repo"].worktrees?.[0].metadata).toEqual({ - jira: "PROJ-123", - owner: "alice", + expect(loaded.repos["test-repo"]).toEqual({ + path: "./repos/test-repo", }); }); }); @@ -179,6 +170,44 @@ describe("loadConfig", () => { expect(loaded).toEqual(config); }); + test("migrates version alias to canonical version and persists", async () => { + const configPath = getConfigPath(testDir); + await mkdir(join(testDir, ".arashi"), { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { + version: "1", + reposDir: "./repos", + repos: {}, + }, + null, + 2, + ), + ); + + const loaded = await loadConfig(testDir); + expect(loaded.version).toBe("1.0.0"); + + const persisted = JSON.parse(await Bun.file(configPath).text()) as { version: string }; + expect(persisted.version).toBe("1.0.0"); + }); + + test("throws unsupported version error for future config versions", async () => { + const configPath = getConfigPath(testDir); + await mkdir(join(testDir, ".arashi"), { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ + version: "2.0.0", + reposDir: "./repos", + repos: {}, + }), + ); + + await expect(loadConfig(testDir)).rejects.toThrow(UnsupportedConfigVersionError); + }); + test("throws ConfigNotFoundError when file does not exist", async () => { await expect(loadConfig(testDir)).rejects.toThrow(ConfigNotFoundError); }); @@ -226,9 +255,8 @@ describe("loadConfig", () => { await writeFile( configPath, JSON.stringify({ - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, // Missing version }), ); @@ -243,9 +271,8 @@ describe("loadConfig", () => { configPath, JSON.stringify({ version: "", // Invalid - auto_setup: "true", // Wrong type - discovered_repos: {}, - // Missing repos_dir + repos: {}, + // Missing reposDir }), ); @@ -260,22 +287,19 @@ describe("loadConfig", () => { } }); - test("loads configuration with extra fields (forward compatibility)", async () => { + test("rejects configuration with unknown root fields", async () => { const configPath = getConfigPath(testDir); await mkdir(join(testDir, ".arashi"), { recursive: true }); const configWithExtras = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, future_feature: "some value", custom_data: { team: "backend" }, }; await writeFile(configPath, JSON.stringify(configWithExtras, null, 2)); - const loaded = await loadConfig(testDir); - expect(loaded.version).toBe("1.0.0"); - expect((loaded as { future_feature?: unknown }).future_feature).toBe("some value"); + await expect(loadConfig(testDir)).rejects.toThrow(ConfigValidationError); }); }); @@ -295,13 +319,12 @@ describe("addRepo", () => { test("adds repository to configuration", async () => { await addRepo(testDir, "my-app", { path: "./repos/my-app", - default_branch: "main", }); const config = await loadConfig(testDir); - expect(config.discovered_repos["my-app"]).toBeDefined(); - expect(config.discovered_repos["my-app"].path).toBe("./repos/my-app"); - expect(config.discovered_repos["my-app"].default_branch).toBe("main"); + expect(config.repos["my-app"]).toBeDefined(); + expect(config.repos["my-app"].path).toBe("./repos/my-app"); + expect(config.repos["my-app"].gitUrl).toBeUndefined(); }); test("adds repository with minimal fields", async () => { @@ -310,25 +333,21 @@ describe("addRepo", () => { }); const config = await loadConfig(testDir); - expect(config.discovered_repos["simple-repo"]).toBeDefined(); - expect(config.discovered_repos["simple-repo"].path).toBe("./repos/simple"); - expect(config.discovered_repos["simple-repo"].default_branch).toBeUndefined(); + expect(config.repos["simple-repo"]).toBeDefined(); + expect(config.repos["simple-repo"].path).toBe("./repos/simple"); + expect(config.repos["simple-repo"].gitUrl).toBeUndefined(); }); test("adds repository with complete configuration", async () => { await addRepo(testDir, "full-repo", { path: "./repos/full", - default_branch: "develop", - is_bare: true, - hooks: { - post_create: "./hooks/post.sh", - }, + gitUrl: "git@github.com:team/full.git", }); const config = await loadConfig(testDir); - const repo = config.discovered_repos["full-repo"]; - expect(repo.is_bare).toBe(true); - expect(repo.hooks?.post_create).toBe("./hooks/post.sh"); + const repo = config.repos["full-repo"]; + expect(repo.path).toBe("./repos/full"); + expect(repo.gitUrl).toBe("git@github.com:team/full.git"); }); test("throws error when repository name already exists", async () => { @@ -360,10 +379,10 @@ describe("addRepo", () => { await addRepo(testDir, "repo3", { path: "./repos/repo3" }); const config = await loadConfig(testDir); - expect(Object.keys(config.discovered_repos)).toHaveLength(3); - expect(config.discovered_repos["repo1"]).toBeDefined(); - expect(config.discovered_repos["repo2"]).toBeDefined(); - expect(config.discovered_repos["repo3"]).toBeDefined(); + expect(Object.keys(config.repos)).toHaveLength(3); + expect(config.repos["repo1"]).toBeDefined(); + expect(config.repos["repo2"]).toBeDefined(); + expect(config.repos["repo3"]).toBeDefined(); }); test("preserves existing repositories when adding new one", async () => { @@ -371,8 +390,8 @@ describe("addRepo", () => { await addRepo(testDir, "second", { path: "./repos/second" }); const config = await loadConfig(testDir); - expect(config.discovered_repos["first"]).toBeDefined(); - expect(config.discovered_repos["second"]).toBeDefined(); + expect(config.repos["first"]).toBeDefined(); + expect(config.repos["second"]).toBeDefined(); }); }); @@ -394,7 +413,7 @@ describe("removeRepo", () => { await removeRepo(testDir, "to-remove"); const config = await loadConfig(testDir); - expect(config.discovered_repos["to-remove"]).toBeUndefined(); + expect(config.repos["to-remove"]).toBeUndefined(); }); test("succeeds silently when repository does not exist (idempotent)", async () => { @@ -402,7 +421,7 @@ describe("removeRepo", () => { await removeRepo(testDir, "non-existent"); const config = await loadConfig(testDir); - expect(config.discovered_repos["non-existent"]).toBeUndefined(); + expect(config.repos["non-existent"]).toBeUndefined(); }); test("preserves other repositories when removing one", async () => { @@ -413,9 +432,9 @@ describe("removeRepo", () => { await removeRepo(testDir, "remove"); const config = await loadConfig(testDir); - expect(config.discovered_repos["keep1"]).toBeDefined(); - expect(config.discovered_repos["keep2"]).toBeDefined(); - expect(config.discovered_repos["remove"]).toBeUndefined(); + expect(config.repos["keep1"]).toBeDefined(); + expect(config.repos["keep2"]).toBeDefined(); + expect(config.repos["remove"]).toBeUndefined(); }); test("can remove and re-add repository", async () => { @@ -424,7 +443,7 @@ describe("removeRepo", () => { await addRepo(testDir, "repo", { path: "./repos/path2" }); const config = await loadConfig(testDir); - expect(config.discovered_repos["repo"].path).toBe("./repos/path2"); + expect(config.repos["repo"].path).toBe("./repos/path2"); }); }); @@ -442,31 +461,14 @@ describe("round-trip tests", () => { test("save and load preserves all data", async () => { const original: Config = { version: "1.0.0", - repos_dir: "/absolute/path/to/repos", - auto_setup: false, - discovered_repos: { + reposDir: "/absolute/path/to/repos", + repos: { repo1: { path: "./repos/repo1", - default_branch: "develop", - is_bare: true, + gitUrl: "git@github.com:team/repo1.git", }, repo2: { path: "./repos/repo2", - worktrees: [ - { - branch: "feature-auth", - path: "./worktrees/feature-auth", - created_at: "2026-02-03T15:45:30Z", - metadata: { - ticket: "JIRA-456", - priority: "high", - }, - }, - ], - hooks: { - pre_create: "./hooks/pre.sh", - post_create: "./hooks/post.sh", - }, }, }, }; @@ -474,7 +476,7 @@ describe("round-trip tests", () => { await saveConfig(testDir, original); const loaded = await loadConfig(testDir); - expect(loaded).toEqual(original); + expect(loaded).toMatchObject(original); }); test("multiple save-load cycles preserve data", async () => { @@ -482,26 +484,25 @@ describe("round-trip tests", () => { await saveConfig(testDir, config); config = await loadConfig(testDir); - config.auto_setup = false; + config.reposDir = "./repos-custom"; await saveConfig(testDir, config); config = await loadConfig(testDir); await addRepo(testDir, "test", { path: "./test" }); config = await loadConfig(testDir); - expect(config.auto_setup).toBe(false); - expect(config.discovered_repos["test"]).toBeDefined(); + expect(config.reposDir).toBe("./repos-custom"); + expect(config.repos["test"]).toBeDefined(); }); - test("persists repository git_url fields across save/load", async () => { + test("persists repository gitUrl fields across save/load", async () => { const config: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "repo-with-url": { path: "./repos/repo-with-url", - git_url: "git@github.com:team/repo-with-url.git", + gitUrl: "git@github.com:team/repo-with-url.git", }, }, }; @@ -509,9 +510,7 @@ describe("round-trip tests", () => { await saveConfig(testDir, config); const loaded = await loadConfig(testDir); - expect(loaded.discovered_repos["repo-with-url"]?.git_url).toBe( - "git@github.com:team/repo-with-url.git", - ); + expect(loaded.repos["repo-with-url"]?.gitUrl).toBe("git@github.com:team/repo-with-url.git"); }); test("preserves JSON formatting across save-load cycles", async () => { @@ -554,7 +553,7 @@ describe("end-to-end workflow", () => { // Load and verify const loaded = await loadConfig(testDir); expect(loaded.version).toBe("1.0.0"); - expect(loaded.repos_dir).toBe("./repos"); + expect(loaded.reposDir).toBe("./repos"); }); test("complete repository management workflow", async () => { @@ -564,25 +563,23 @@ describe("end-to-end workflow", () => { // Add repositories await addRepo(testDir, "frontend", { path: "./repos/frontend", - default_branch: "main", }); await addRepo(testDir, "backend", { path: "./repos/backend", - default_branch: "develop", }); // Verify both exist let config = await loadConfig(testDir); - expect(Object.keys(config.discovered_repos)).toHaveLength(2); + expect(Object.keys(config.repos)).toHaveLength(2); // Remove one await removeRepo(testDir, "frontend"); // Verify only one remains config = await loadConfig(testDir); - expect(Object.keys(config.discovered_repos)).toHaveLength(1); - expect(config.discovered_repos["backend"]).toBeDefined(); + expect(Object.keys(config.repos)).toHaveLength(1); + expect(config.repos["backend"]).toBeDefined(); }); test("modify configuration settings workflow", async () => { @@ -591,14 +588,12 @@ describe("end-to-end workflow", () => { // Load and modify let config = await loadConfig(testDir); - config.repos_dir = "/custom/path"; - config.auto_setup = false; + config.reposDir = "/custom/path"; await saveConfig(testDir, config); // Verify changes persisted config = await loadConfig(testDir); - expect(config.repos_dir).toBe("/custom/path"); - expect(config.auto_setup).toBe(false); + expect(config.reposDir).toBe("/custom/path"); }); }); @@ -613,7 +608,7 @@ describe("repairRepositoryGitUrls", () => { await rm(testDir, { recursive: true, force: true }); }); - test("repairs missing git_url from local origin remote", async () => { + test("repairs missing gitUrl from local origin remote", async () => { const repoPath = join(testDir, "repos", "child-repo"); await mkdir(repoPath, { recursive: true }); @@ -622,9 +617,8 @@ describe("repairRepositoryGitUrls", () => { const config: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "child-repo": { path: "./repos/child-repo", }, @@ -635,8 +629,6 @@ describe("repairRepositoryGitUrls", () => { expect(result.updated).toBe(true); expect(result.repaired).toEqual(["child-repo"]); - expect(config.discovered_repos["child-repo"].git_url).toBe( - "git@github.com:team/child-repo.git", - ); + expect(config.repos["child-repo"].gitUrl).toBe("git@github.com:team/child-repo.git"); }); }); diff --git a/tests/integration/init.test.ts b/tests/integration/init.test.ts index b150a55..802bf96 100644 --- a/tests/integration/init.test.ts +++ b/tests/integration/init.test.ts @@ -107,9 +107,8 @@ describe("init command - success cases", () => { // Verify config content const loadedConfig = await config.loadConfig(testDir); expect(loadedConfig.version).toBe("1.0.0"); - expect(loadedConfig.repos_dir).toBe("./repos"); - expect(loadedConfig.auto_setup).toBe(true); - expect(loadedConfig.discovered_repos).toEqual({}); + expect(loadedConfig.reposDir).toBe("./repos"); + expect(loadedConfig.repos).toEqual({}); // Verify hooks directory created const hooksDir = join(testDir, ".arashi", "hooks"); @@ -142,7 +141,7 @@ describe("init command - success cases", () => { // Verify config uses custom path const loadedConfig = await config.loadConfig(testDir); - expect(loadedConfig.repos_dir).toBe("./custom-repos"); + expect(loadedConfig.reposDir).toBe("./custom-repos"); // Verify .gitignore updated with custom path const gitignoreContent = await filesystem.readTextFile(join(testDir, ".gitignore")); @@ -161,17 +160,7 @@ describe("init command - success cases", () => { // Verify no repositories discovered in config const loadedConfig = await config.loadConfig(testDir); - expect(Object.keys(loadedConfig.discovered_repos)).toHaveLength(0); - }); - - test("init with --no-auto-setup disables auto setup", async () => { - const result = await runInitCommand(testDir, ["--no-auto-setup"]); - - expect(result.exitCode).toBe(0); - - // Verify auto_setup is false - const loadedConfig = await config.loadConfig(testDir); - expect(loadedConfig.auto_setup).toBe(false); + expect(Object.keys(loadedConfig.repos)).toHaveLength(0); }); test("init with --force overwrites existing configuration", async () => { @@ -180,7 +169,7 @@ describe("init command - success cases", () => { // Modify config let loadedConfig = await config.loadConfig(testDir); - loadedConfig.auto_setup = false; + loadedConfig.reposDir = "./custom-repos"; await config.saveConfig(testDir, loadedConfig); // Reinitialize with --force @@ -192,7 +181,7 @@ describe("init command - success cases", () => { // Verify config reset to defaults loadedConfig = await config.loadConfig(testDir); - expect(loadedConfig.auto_setup).toBe(true); + expect(loadedConfig.reposDir).toBe("./repos"); // Verify backup created const backupFiles = await Array.fromAsync( @@ -392,9 +381,9 @@ describe("init command - repository discovery", () => { // Verify discovered repos in config const loadedConfig = await config.loadConfig(testDir); - expect(Object.keys(loadedConfig.discovered_repos)).toHaveLength(2); - expect(loadedConfig.discovered_repos["repo1"]).toBeDefined(); - expect(loadedConfig.discovered_repos["repo2"]).toBeDefined(); + expect(Object.keys(loadedConfig.repos)).toHaveLength(2); + expect(loadedConfig.repos["repo1"]).toBeDefined(); + expect(loadedConfig.repos["repo2"]).toBeDefined(); }); test("handles empty repos directory", async () => { @@ -407,7 +396,7 @@ describe("init command - repository discovery", () => { expect(result.stdout).toContain("Discovered 0 repositories"); const loadedConfig = await config.loadConfig(testDir); - expect(Object.keys(loadedConfig.discovered_repos)).toHaveLength(0); + expect(Object.keys(loadedConfig.repos)).toHaveLength(0); }); }); @@ -483,7 +472,7 @@ describe("init command - edge cases", () => { // Verify config stores absolute path const loadedConfig = await config.loadConfig(testDir); - expect(loadedConfig.repos_dir).toBe(absolutePath); + expect(loadedConfig.reposDir).toBe(absolutePath); await cleanup(testDir); }); @@ -589,8 +578,7 @@ describe("init command - dry-run mode", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Configuration preview:"); expect(result.stdout).toContain('"version": "1.0.0"'); - expect(result.stdout).toContain('"repos_dir": "./repos"'); - expect(result.stdout).toContain('"auto_setup": true'); + expect(result.stdout).toContain('"reposDir": "./repos"'); }); test("--dry-run works with --repos-dir option", async () => { @@ -598,20 +586,12 @@ describe("init command - dry-run mode", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("[DRY RUN]"); - expect(result.stdout).toContain('"repos_dir": "./custom"'); + expect(result.stdout).toContain('"reposDir": "./custom"'); // Verify custom directory NOT created expect(await filesystem.fileExists(join(testDir, "custom"))).toBe(false); }); - test("--dry-run works with --no-auto-setup option", async () => { - const result = await runInitCommand(testDir, ["--dry-run", "--no-auto-setup"]); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("[DRY RUN]"); - expect(result.stdout).toContain('"auto_setup": false'); - }); - test("--dry-run works with --no-discover option", async () => { // Create a repo in repos directory await mkdir(join(testDir, "repos", "test-repo", ".git"), { recursive: true }); @@ -807,14 +787,12 @@ describe("init command - dry-run and verbose together", () => { "--verbose", "--repos-dir", "./custom", - "--no-auto-setup", ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("[DRY RUN]"); expect(result.stdout).toContain("[VERBOSE]"); - expect(result.stdout).toContain('"repos_dir": "./custom"'); - expect(result.stdout).toContain('"auto_setup": false'); + expect(result.stdout).toContain('"reposDir": "./custom"'); // Verify nothing created (except the custom directory we created for the test) expect(await filesystem.fileExists(join(testDir, ".arashi"))).toBe(false); diff --git a/tests/integration/list-integration.test.ts b/tests/integration/list-integration.test.ts index d048f88..ef1f38e 100644 --- a/tests/integration/list-integration.test.ts +++ b/tests/integration/list-integration.test.ts @@ -54,9 +54,8 @@ async function createTempGitRepo(): Promise { await mkdir(join(testDir, ".arashi"), { recursive: true }); const testConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, hooks: { timeout: 300, }, @@ -712,9 +711,8 @@ describe("list command - edge cases", () => { await mkdir(join(wtPath, ".arashi"), { recursive: true }); const testConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, hooks: { timeout: 300 }, discovery: { max_depth: 3 }, }; diff --git a/tests/integration/nested-worktree-paths.test.ts b/tests/integration/nested-worktree-paths.test.ts index 1c51864..b97dd9e 100644 --- a/tests/integration/nested-worktree-paths.test.ts +++ b/tests/integration/nested-worktree-paths.test.ts @@ -46,10 +46,9 @@ describe("Nested Worktree Paths Integration", () => { join(metaRepoPath, ".arashi", "config.json"), JSON.stringify({ version: "1.0.0", - repos_dir: "./repos", - auto_setup: false, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }), ); @@ -89,10 +88,9 @@ describe("Nested Worktree Paths Integration", () => { join(metaRepoPath, ".arashi", "config.json"), JSON.stringify({ version: "1.0.0", - repos_dir: "./repos", - auto_setup: false, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }), ); diff --git a/tests/integration/pull.test.ts b/tests/integration/pull.test.ts index 534d736..b0294b0 100644 --- a/tests/integration/pull.test.ts +++ b/tests/integration/pull.test.ts @@ -78,13 +78,12 @@ async function createWorkspaceWithRepo( await mkdir(configDir, { recursive: true }); const config: Record = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "repo-a": { path: "./repos/repo-a", - default_branch: "main", - is_bare: false, + defaultBranch: "main", + isBare: false, worktrees: [], }, }, diff --git a/tests/integration/setup.test.ts b/tests/integration/setup.test.ts index 49e75af..d189263 100644 --- a/tests/integration/setup.test.ts +++ b/tests/integration/setup.test.ts @@ -45,28 +45,26 @@ async function createWorkspace( const config: { version: string; - repos_dir: string; - auto_setup: boolean; + reposDir: string; hooks?: { timeout: number }; - discovered_repos: Record< + repos: Record< string, - { path: string; default_branch: string; is_bare: boolean; worktrees: never[] } + { path: string; defaultBranch: string; isBare: boolean; worktrees: never[] } >; } = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "repo-a": { path: "./repos/repo-a", - default_branch: "main", - is_bare: false, + defaultBranch: "main", + isBare: false, worktrees: [], }, "repo-b": { path: "./repos/repo-b", - default_branch: "main", - is_bare: false, + defaultBranch: "main", + isBare: false, worktrees: [], }, }, diff --git a/tests/integration/sync.test.ts b/tests/integration/sync.test.ts index 4cfb9b4..7a73d16 100644 --- a/tests/integration/sync.test.ts +++ b/tests/integration/sync.test.ts @@ -9,7 +9,7 @@ import { import { executeSync } from "../../src/commands/sync.ts"; type SyncConfig = { - discovered_repos: Record>; + repos: Record>; sync?: { timeoutSeconds?: number; }; @@ -152,10 +152,10 @@ describe("sync command - integration", () => { await updateConfig(workspace.rootPath, (config) => { return { ...config, - discovered_repos: { - ...config.discovered_repos, + repos: { + ...config.repos, "repo-b": { - ...config.discovered_repos["repo-b"], + ...config.repos["repo-b"], path: "./repos/missing-repo", }, }, diff --git a/tests/unit/config.bare-context.test.ts b/tests/unit/config.bare-context.test.ts index 7193ba8..2643cf5 100644 --- a/tests/unit/config.bare-context.test.ts +++ b/tests/unit/config.bare-context.test.ts @@ -23,7 +23,7 @@ describe("config resolution in bare contexts", () => { const loaded = await loadConfigWithFallback(workspace.worktreePath); expect(loaded.source).toBe("local-file"); - expect(loaded.config.repos_dir).toBe("./repos"); + expect(loaded.config.reposDir).toBe("./repos"); }); test("falls back to repository content for bare invocation", async () => { @@ -34,7 +34,7 @@ describe("config resolution in bare contexts", () => { }); expect(loaded.source).toBe("repository-content"); - expect(loaded.config.repos_dir).toBe("./repos"); + expect(loaded.config.reposDir).toBe("./repos"); }); test("throws clear error when config does not exist", async () => { diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index d3b7c4f..212844c 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -8,8 +8,10 @@ import { describe, test, expect } from "bun:test"; import { getConfigPath, generateDefaultConfig, + normalizeConfig, validateConfig, ConfigValidationError, + UnsupportedConfigVersionError, type Config, } from "../../src/lib/config"; import { join } from "path"; @@ -40,9 +42,8 @@ describe("generateDefaultConfig", () => { const config = generateDefaultConfig(); expect(config.version).toBe("1.0.0"); - expect(config.repos_dir).toBe("./repos"); - expect(config.auto_setup).toBe(true); - expect(config.discovered_repos).toEqual({}); + expect(config.reposDir).toBe("./repos"); + expect(config.repos).toEqual({}); }); test("returns a new object each time", () => { @@ -58,13 +59,12 @@ describe("validateConfig - root level", () => { test("accepts valid complete configuration", () => { const validConfig: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "example-repo": { path: "./repos/example-repo", - default_branch: "main", - is_bare: false, + defaultBranch: "main", + isBare: false, }, }, }; @@ -75,9 +75,8 @@ describe("validateConfig - root level", () => { test("accepts minimal valid configuration", () => { const minimalConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, }; expect(() => validateConfig(minimalConfig)).not.toThrow(); @@ -96,54 +95,39 @@ describe("validateConfig - root level", () => { test("catches missing version field", () => { const config = { - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, }; expect(() => validateConfig(config)).toThrow(ConfigValidationError); expect(() => validateConfig(config)).toThrow("version"); }); - test("catches missing repos_dir field", () => { + test("catches missing reposDir field", () => { const config = { version: "1.0.0", - auto_setup: true, - discovered_repos: {}, + repos: {}, }; expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("repos_dir"); + expect(() => validateConfig(config)).toThrow("reposDir"); }); - test("catches missing auto_setup field", () => { + test("catches missing repos field", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - discovered_repos: {}, + reposDir: "./repos", }; expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("auto_setup"); - }); - - test("catches missing discovered_repos field", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - }; - - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("discovered_repos"); + expect(() => validateConfig(config)).toThrow("repos"); }); test("catches invalid field types", () => { const config = { version: 1.0, // Should be string - repos_dir: "./repos", - auto_setup: "true", // Should be boolean - discovered_repos: [], // Should be object + reposDir: "./repos", + repos: [], // Should be object }; try { @@ -153,17 +137,15 @@ describe("validateConfig - root level", () => { expect(error).toBeInstanceOf(ConfigValidationError); const err = error as ConfigValidationError; expect(err.context.errors).toContain("version: must be a non-empty string"); - expect(err.context.errors).toContain("auto_setup: must be a boolean"); - expect(err.context.errors).toContain("discovered_repos: must be an object"); + expect(err.context.errors).toContain("repos: must be an object"); } }); test("catches empty string values", () => { const config = { version: "", // Empty string not allowed - repos_dir: "", - auto_setup: true, - discovered_repos: {}, + reposDir: "", + repos: {}, }; try { @@ -176,171 +158,51 @@ describe("validateConfig - root level", () => { } }); - test("preserves unknown fields (forward compatibility)", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, - future_feature: "some value", - custom_metadata: { team: "backend" }, - }; - - expect(() => validateConfig(config)).not.toThrow(); - }); -}); - -describe("validateConfig - RepoConfig validation", () => { - test("accepts valid repository configuration", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - default_branch: "main", - is_bare: false, - }, - }, - }; - - expect(() => validateConfig(config)).not.toThrow(); - }); - - test("accepts repository with minimal fields", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - }, - }, - }; - - expect(() => validateConfig(config)).not.toThrow(); - }); - - test("accepts repository git_url when present", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - git_url: "git@github.com:team/my-repo.git", - }, - }, - }; - - expect(() => validateConfig(config)).not.toThrow(); - }); - - test("catches invalid git_url type", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - git_url: 123, - }, - }, - }; - - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("git_url"); - }); - - test("catches missing path field in repository", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - default_branch: "main", - }, - }, - }; - - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("my-repo"); - expect(() => validateConfig(config)).toThrow("path"); - }); - - test("catches invalid default_branch type", () => { + test("rejects unsupported config version", () => { const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - default_branch: 123, // Should be string - }, - }, + version: "2.0.0", + reposDir: "./repos", + repos: {}, }; - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("default_branch"); + expect(() => validateConfig(config)).toThrow(UnsupportedConfigVersionError); + expect(() => validateConfig(config)).toThrow("Unsupported configuration version"); }); - test("catches invalid is_bare type", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - is_bare: "false", // Should be boolean - }, - }, - }; + test("normalizes supported version alias", () => { + const normalized = normalizeConfig({ + version: "1", + reposDir: "./repos", + repos: {}, + }); - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("is_bare"); + expect(normalized.version).toBe("1.0.0"); }); - test("catches non-array worktrees", () => { + test("rejects unknown root fields", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - worktrees: "not-an-array", - }, - }, + reposDir: "./repos", + repos: {}, + future_feature: "some value", + custom_metadata: { team: "backend" }, }; expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("worktrees"); + expect(() => validateConfig(config)).toThrow("unknown property"); }); }); -describe("validateConfig - WorktreeInfo validation", () => { - test("accepts valid worktree configuration", () => { +describe("validateConfig - RepoConfig validation", () => { + test("accepts valid repository configuration", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "my-repo": { path: "./repos/my-repo", - worktrees: [ - { - branch: "feature-123", - path: "./repos/my-repo.worktrees/feature-123", - created_at: "2026-02-03T10:30:00Z", - }, - ], + defaultBranch: "main", + isBare: false, }, }, }; @@ -348,25 +210,13 @@ describe("validateConfig - WorktreeInfo validation", () => { expect(() => validateConfig(config)).not.toThrow(); }); - test("accepts worktree with metadata", () => { + test("accepts repository with minimal fields", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "my-repo": { path: "./repos/my-repo", - worktrees: [ - { - branch: "feature-123", - path: "./repos/my-repo.worktrees/feature-123", - created_at: "2026-02-03T10:30:00Z", - metadata: { - jira_ticket: "PROJ-123", - owner: "alice", - }, - }, - ], }, }, }; @@ -374,153 +224,63 @@ describe("validateConfig - WorktreeInfo validation", () => { expect(() => validateConfig(config)).not.toThrow(); }); - test("catches missing branch field", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - worktrees: [ - { - path: "./repos/my-repo.worktrees/feature-123", - created_at: "2026-02-03T10:30:00Z", - }, - ], - }, - }, - }; - - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("branch"); - }); - - test("catches missing path field in worktree", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - worktrees: [ - { - branch: "feature-123", - created_at: "2026-02-03T10:30:00Z", - }, - ], - }, - }, - }; - - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("path"); - }); - - test("catches missing created_at field", () => { + test("accepts repository gitUrl when present", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "my-repo": { path: "./repos/my-repo", - worktrees: [ - { - branch: "feature-123", - path: "./repos/my-repo.worktrees/feature-123", - }, - ], + gitUrl: "git@github.com:team/my-repo.git", }, }, }; - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("created_at"); + expect(() => validateConfig(config)).not.toThrow(); }); - test("catches invalid ISO 8601 date", () => { + test("catches invalid gitUrl type", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "my-repo": { path: "./repos/my-repo", - worktrees: [ - { - branch: "feature-123", - path: "./repos/my-repo.worktrees/feature-123", - created_at: "not-a-date", - }, - ], + gitUrl: 123, }, }, }; expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("created_at"); - expect(() => validateConfig(config)).toThrow("ISO 8601"); + expect(() => validateConfig(config)).toThrow("gitUrl"); }); - test("catches invalid metadata type", () => { + test("catches missing path field in repository", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "my-repo": { - path: "./repos/my-repo", - worktrees: [ - { - branch: "feature-123", - path: "./repos/my-repo.worktrees/feature-123", - created_at: "2026-02-03T10:30:00Z", - metadata: "not-an-object", - }, - ], + defaultBranch: "main", }, }, }; expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("metadata"); - }); -}); - -describe("validateConfig - HookConfig validation", () => { - test("accepts valid hook configuration", () => { - const config = { - version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "my-repo": { - path: "./repos/my-repo", - hooks: { - pre_create: "./.arashi/hooks/pre-create.sh", - post_create: "./.arashi/hooks/post-create.sh", - setup: "./.arashi/hooks/setup.sh", - }, - }, - }, - }; - - expect(() => validateConfig(config)).not.toThrow(); + expect(() => validateConfig(config)).toThrow("my-repo"); + expect(() => validateConfig(config)).toThrow("path"); }); - test("accepts partial hook configuration", () => { + test("accepts deprecated repository metadata keys during migration", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "my-repo": { path: "./repos/my-repo", - hooks: { - post_create: "./.arashi/hooks/post-create.sh", - }, + defaultBranch: "main", + isBare: false, + worktrees: [], }, }, }; @@ -528,42 +288,39 @@ describe("validateConfig - HookConfig validation", () => { expect(() => validateConfig(config)).not.toThrow(); }); - test("catches invalid pre_create type", () => { - const config = { + test("drops deprecated repository metadata keys during normalization", () => { + const normalized = normalizeConfig({ version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "my-repo": { path: "./repos/my-repo", - hooks: { - pre_create: 123, - }, + defaultBranch: "main", + isBare: false, + worktrees: [], }, }, - }; + }); - expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("pre_create"); + expect(normalized.repos["my-repo"]).toEqual({ + path: "./repos/my-repo", + }); }); - test("catches empty hook paths", () => { + test("catches unknown repository properties", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { + reposDir: "./repos", + repos: { "my-repo": { path: "./repos/my-repo", - hooks: { - post_create: "", - }, + customField: true, }, }, }; expect(() => validateConfig(config)).toThrow(ConfigValidationError); - expect(() => validateConfig(config)).toThrow("post_create"); + expect(() => validateConfig(config)).toThrow("unknown property"); }); }); @@ -571,12 +328,11 @@ describe("validateConfig - error messages", () => { test("provides multiple errors in single validation", () => { const config = { version: "", // Invalid - repos_dir: "./repos", - auto_setup: "not-a-boolean", // Invalid - discovered_repos: { + reposDir: "./repos", + repos: { "bad-repo": { // Missing path - default_branch: 123, // Invalid type + customField: true, // Unknown property }, }, }; @@ -594,9 +350,8 @@ describe("validateConfig - error messages", () => { test("error message includes helpful context", () => { const config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, }; delete (config as { version?: string }).version; diff --git a/tests/unit/lib/clone-discovery.test.ts b/tests/unit/lib/clone-discovery.test.ts index eb01379..78f410a 100644 --- a/tests/unit/lib/clone-discovery.test.ts +++ b/tests/unit/lib/clone-discovery.test.ts @@ -26,11 +26,10 @@ describe("clone-discovery", () => { const config: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: { - "repo-a": { path: "./repos/repo-a", git_url: "git@github.com:team/repo-a.git" }, - "repo-b": { path: "./repos/repo-b", git_url: "git@github.com:team/repo-b.git" }, + reposDir: "./repos", + repos: { + "repo-a": { path: "./repos/repo-a", gitUrl: "git@github.com:team/repo-a.git" }, + "repo-b": { path: "./repos/repo-b", gitUrl: "git@github.com:team/repo-b.git" }, }, }; @@ -40,16 +39,15 @@ describe("clone-discovery", () => { expect(result.configuredMissing.map((repo) => repo.name)).toEqual(["repo-b"]); }); - test("detects unmanaged local repositories under repos_dir", async () => { + test("detects unmanaged local repositories under reposDir", async () => { const unmanagedPath = join(workspaceRoot, "repos", "extra-repo"); await mkdir(unmanagedPath, { recursive: true }); await writeFile(join(unmanagedPath, ".git"), "gitdir: ./.git/worktrees/main\n"); const config: Config = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, - discovered_repos: {}, + reposDir: "./repos", + repos: {}, }; const result = await discoverCloneRepositories(workspaceRoot, config); diff --git a/tests/unit/repository-type-detection.test.ts b/tests/unit/repository-type-detection.test.ts index b909cdb..4185baa 100644 --- a/tests/unit/repository-type-detection.test.ts +++ b/tests/unit/repository-type-detection.test.ts @@ -67,10 +67,9 @@ describe("detectRepositoryType", () => { const config: ArashiConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }; const result = await detectRepositoryType(repo, config); diff --git a/tests/unit/worktree-path-calculation.test.ts b/tests/unit/worktree-path-calculation.test.ts index 82fefd8..4b39abf 100644 --- a/tests/unit/worktree-path-calculation.test.ts +++ b/tests/unit/worktree-path-calculation.test.ts @@ -85,10 +85,9 @@ describe("calculateWorktreePath", () => { const config: ArashiConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }; const result = await calculateWorktreePath(repo, "feature-123", config); @@ -119,10 +118,9 @@ describe("calculateWorktreePath", () => { const config: ArashiConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }; // Test various branch names @@ -157,10 +155,9 @@ describe("calculateWorktreePath", () => { const config: ArashiConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }; const result = await calculateWorktreePath(repo, "feature-123", config); @@ -185,10 +182,9 @@ describe("calculateWorktreePath", () => { const config: ArashiConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }; const result = await calculateWorktreePath(repo, "bugfix-789", config); @@ -208,7 +204,7 @@ describe("calculateWorktreePath", () => { await mkdir(join(metaRepoPath, ".arashi"), { recursive: true }); await writeFile( join(metaRepoPath, ".arashi", "config.json"), - JSON.stringify({ version: "1.0.0", repos_dir: "./repos" }), + JSON.stringify({ version: "1.0.0", reposDir: "./repos" }), ); // Create child repo @@ -224,10 +220,9 @@ describe("calculateWorktreePath", () => { const config: ArashiConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }; const result = await calculateWorktreePath(childRepo, "feature-123", config); @@ -246,7 +241,7 @@ describe("calculateWorktreePath", () => { await mkdir(join(bareMetaRepoPath, ".arashi"), { recursive: true }); await writeFile( join(bareMetaRepoPath, ".arashi", "config.json"), - JSON.stringify({ version: "1.0.0", repos_dir: "./repos" }), + JSON.stringify({ version: "1.0.0", reposDir: "./repos" }), ); // Create child repo inside bare parent @@ -262,10 +257,9 @@ describe("calculateWorktreePath", () => { const config: ArashiConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }; const result = await calculateWorktreePath(childRepo, "feature-123", config); @@ -284,7 +278,7 @@ describe("calculateWorktreePath", () => { await mkdir(join(bareMetaRepoPath, ".arashi"), { recursive: true }); await writeFile( join(bareMetaRepoPath, ".arashi", "config.json"), - JSON.stringify({ version: "1.0.0", repos_dir: "./repos" }), + JSON.stringify({ version: "1.0.0", reposDir: "./repos" }), ); // Create multiple child repos @@ -304,10 +298,9 @@ describe("calculateWorktreePath", () => { const config: ArashiConfig = { version: "1.0.0", - repos_dir: "./repos", - auto_setup: true, + reposDir: "./repos", worktree_strategy: "same_branch", - discovered_repos: {}, + repos: {}, }; // All children should nest inside branch-name-only parent worktree diff --git a/tsconfig.schema.json b/tsconfig.schema.json new file mode 100644 index 0000000..6f103c5 --- /dev/null +++ b/tsconfig.schema.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/lib/config.ts"], + "exclude": ["node_modules", "dist", "tests"] +}