Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"bin/arashi.bat",
"bin/arashi.ps1",
"scripts/postinstall.js",
"schema/config.schema.json",
"README.md",
"LICENSE"
],
Expand All @@ -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": {
Expand All @@ -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": {
Expand Down
75 changes: 75 additions & 0 deletions schema/config.schema.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
23 changes: 7 additions & 16 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 17 additions & 41 deletions src/commands/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<PromptOutcome<boolean>>;
promptInput?: (message: string, defaultValue?: string) => Promise<PromptOutcome<string>>;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -122,7 +121,6 @@ export async function executeClone(
confirm,
askInput,
askSelect,
readDefaultBranch,
deleteDirectory,
});

Expand Down Expand Up @@ -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) {
Expand All @@ -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(", ")}`,
);
}

Expand All @@ -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,
});

Expand Down Expand Up @@ -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;
}
Expand All @@ -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}`);
Expand Down Expand Up @@ -357,7 +346,6 @@ async function reconcileUnmanagedRepositories(options: {
confirm: (message: string, defaultValue?: boolean) => Promise<PromptOutcome<boolean>>;
askInput: (message: string, defaultValue?: string) => Promise<PromptOutcome<string>>;
askSelect: <T>(message: string, choices: Choice<T>[]) => Promise<PromptOutcome<T>>;
readDefaultBranch: (repoPath: string) => Promise<string>;
deleteDirectory: (path: string) => Promise<void>;
}): Promise<{ cancelled: boolean; updatedConfig: boolean }> {
if (options.unmanagedRepositories.length === 0) {
Expand Down Expand Up @@ -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.`);
}
Expand Down
Loading
Loading