drybase 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/bin/drybase.js +2 -0
- package/package.json +45 -0
- package/src/commands/history.js +38 -0
- package/src/commands/init.js +171 -0
- package/src/commands/onboard.js +185 -0
- package/src/commands/rollback.js +71 -0
- package/src/commands/start.js +51 -0
- package/src/commands/status.js +33 -0
- package/src/commands/stop.js +33 -0
- package/src/commands/sync.js +35 -0
- package/src/commands/validate.js +34 -0
- package/src/commands/watch.js +33 -0
- package/src/core/analyzer.js +157 -0
- package/src/core/git.js +81 -0
- package/src/core/github.js +91 -0
- package/src/core/syncer.js +320 -0
- package/src/core/watcher.js +92 -0
- package/src/index.js +117 -0
- package/src/utils/config.js +141 -0
- package/src/utils/differ.js +76 -0
- package/src/utils/llm-adapter.js +60 -0
- package/src/utils/logger.js +45 -0
- package/src/utils/state.js +79 -0
- package/src/utils/test-runner.js +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tristan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# DryBase
|
|
2
|
+
|
|
3
|
+
CLI tool that monitors file changes in a "base" repository and automatically syncs those changes to multiple target repositories — eliminating the need for npm/composer packages for shared code.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g drybase
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. Initialize config in your base repo
|
|
15
|
+
cd /path/to/base-repo
|
|
16
|
+
drybase init
|
|
17
|
+
|
|
18
|
+
# 2. Validate configuration
|
|
19
|
+
drybase validate
|
|
20
|
+
|
|
21
|
+
# 3. Preview what would sync
|
|
22
|
+
drybase sync --dry-run
|
|
23
|
+
|
|
24
|
+
# 4. Run a one-time sync
|
|
25
|
+
drybase sync
|
|
26
|
+
|
|
27
|
+
# 5. Watch for changes (foreground)
|
|
28
|
+
drybase watch
|
|
29
|
+
|
|
30
|
+
# 6. Or run as a daemon
|
|
31
|
+
drybase start
|
|
32
|
+
drybase stop
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
| Command | Description |
|
|
38
|
+
|---------|-------------|
|
|
39
|
+
| `drybase init` | Interactive config creation |
|
|
40
|
+
| `drybase onboard [path]` | LLM-powered project analysis |
|
|
41
|
+
| `drybase validate` | Validate configuration |
|
|
42
|
+
| `drybase sync [--repo] [--dry-run] [--force]` | One-time sync |
|
|
43
|
+
| `drybase watch` | Watch and sync in foreground |
|
|
44
|
+
| `drybase start` / `stop` | Daemon lifecycle |
|
|
45
|
+
| `drybase status` | Show per-repo sync status |
|
|
46
|
+
| `drybase history [--repo] [--limit]` | Show sync history |
|
|
47
|
+
| `drybase rollback <repo> [--yes]` | Revert last sync |
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Create `drybase.json` in your base repository root:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"baseRepo": {
|
|
56
|
+
"path": ".",
|
|
57
|
+
"watchPaths": ["src/shared", "config/base"]
|
|
58
|
+
},
|
|
59
|
+
"github": {
|
|
60
|
+
"token": "env:GITHUB_TOKEN"
|
|
61
|
+
},
|
|
62
|
+
"targetRepos": [
|
|
63
|
+
{
|
|
64
|
+
"name": "owner/repo",
|
|
65
|
+
"localPath": "/path/to/repo",
|
|
66
|
+
"syncPath": "src/base",
|
|
67
|
+
"syncMode": "auto-if-tests-pass",
|
|
68
|
+
"branch": "main"
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"options": {
|
|
72
|
+
"commitMessageTemplate": "Sync base code: {{files}}",
|
|
73
|
+
"branchPrefix": "sync-bot",
|
|
74
|
+
"autoMergeOnPass": true,
|
|
75
|
+
"runTests": true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Sync Modes
|
|
81
|
+
|
|
82
|
+
- **`direct`** — Commit directly to target branch (fastest, riskiest)
|
|
83
|
+
- **`auto-if-tests-pass`** — Create sync branch, run tests, auto-merge if green
|
|
84
|
+
- **`pr-always`** — Always create a PR for manual review
|
|
85
|
+
|
|
86
|
+
## LLM Providers
|
|
87
|
+
|
|
88
|
+
The `onboard` command supports multiple LLM providers via Vercel AI SDK:
|
|
89
|
+
|
|
90
|
+
- Anthropic (Claude)
|
|
91
|
+
- OpenAI (GPT-4)
|
|
92
|
+
- Google (Gemini)
|
|
93
|
+
- Mistral
|
|
94
|
+
- Ollama (local models)
|
|
95
|
+
- Any OpenAI-compatible API
|
|
96
|
+
|
|
97
|
+
## Environment Variables
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
GITHUB_TOKEN=ghp_... # Required for GitHub operations
|
|
101
|
+
ANTHROPIC_API_KEY=sk-ant-... # For Claude
|
|
102
|
+
OPENAI_API_KEY=sk-... # For GPT
|
|
103
|
+
DRYBASE_DEBUG=1 # Enable debug logging
|
|
104
|
+
DRYBASE_CONFIG=/path/to/conf # Custom config path
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
package/bin/drybase.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "drybase",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool that monitors file changes in a base repository and syncs to multiple target repositories",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"drybase": "./bin/drybase.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"prepublishOnly": "npm test"
|
|
19
|
+
},
|
|
20
|
+
"keywords": ["cli", "sync", "git", "monorepo", "dry", "shared-code", "file-sync"],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/tristandewit/drybase.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/tristandewit/drybase#readme",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@ai-sdk/anthropic": "^1",
|
|
32
|
+
"@ai-sdk/openai": "^1",
|
|
33
|
+
"@octokit/rest": "^22",
|
|
34
|
+
"ai": "^4",
|
|
35
|
+
"chalk": "^5",
|
|
36
|
+
"chokidar": "^4",
|
|
37
|
+
"commander": "^14",
|
|
38
|
+
"dotenv": "^16",
|
|
39
|
+
"inquirer": "^12",
|
|
40
|
+
"simple-git": "^3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"vitest": "^3"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { loadConfig } from '../utils/config.js';
|
|
2
|
+
import { loadState } from '../utils/state.js';
|
|
3
|
+
import * as logger from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
export async function historyCommand(options) {
|
|
6
|
+
const config = await loadConfig();
|
|
7
|
+
if (!config) {
|
|
8
|
+
logger.error('No drybase.json found. Run "drybase init" to create one.');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const state = await loadState(config._configDir);
|
|
13
|
+
let history = state.syncHistory || [];
|
|
14
|
+
|
|
15
|
+
if (options.repo) {
|
|
16
|
+
history = history.filter((h) => h.repo === options.repo);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const limit = parseInt(options.limit, 10) || 20;
|
|
20
|
+
history = history.slice(0, limit);
|
|
21
|
+
|
|
22
|
+
if (!history.length) {
|
|
23
|
+
logger.info('No sync history found.');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
logger.info(`Sync History (last ${history.length})\n`);
|
|
28
|
+
|
|
29
|
+
const rows = history.map((entry) => ({
|
|
30
|
+
ID: entry.id,
|
|
31
|
+
Repo: entry.repo,
|
|
32
|
+
Time: new Date(entry.timestamp).toLocaleString(),
|
|
33
|
+
Files: entry.files?.length || 0,
|
|
34
|
+
Status: entry.status,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
logger.table(rows);
|
|
38
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import * as logger from '../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
export async function initCommand() {
|
|
8
|
+
// Detect git root
|
|
9
|
+
let gitRoot = null;
|
|
10
|
+
try {
|
|
11
|
+
gitRoot = execSync('git rev-parse --show-toplevel', {
|
|
12
|
+
encoding: 'utf8',
|
|
13
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
14
|
+
}).trim();
|
|
15
|
+
logger.success(`Detected git repository root: ${gitRoot}`);
|
|
16
|
+
} catch {
|
|
17
|
+
logger.warn('Not in a git repository. Config will be created in current directory.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const configDir = gitRoot || process.cwd();
|
|
21
|
+
const configPath = path.join(configDir, 'drybase.json');
|
|
22
|
+
|
|
23
|
+
// Check if config already exists
|
|
24
|
+
try {
|
|
25
|
+
await fs.access(configPath);
|
|
26
|
+
const { overwrite } = await inquirer.prompt([
|
|
27
|
+
{
|
|
28
|
+
type: 'confirm',
|
|
29
|
+
name: 'overwrite',
|
|
30
|
+
message: 'drybase.json already exists. Overwrite?',
|
|
31
|
+
default: false,
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
if (!overwrite) {
|
|
35
|
+
logger.info('Cancelled.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Doesn't exist — good
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Base repo path
|
|
43
|
+
const { basePath } = await inquirer.prompt([
|
|
44
|
+
{
|
|
45
|
+
type: 'list',
|
|
46
|
+
name: 'basePath',
|
|
47
|
+
message: 'Where is your base code?',
|
|
48
|
+
choices: [
|
|
49
|
+
{ name: `Current directory (${configDir})`, value: configDir },
|
|
50
|
+
{ name: 'Specify custom path', value: '__custom__' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
let resolvedBasePath = basePath;
|
|
56
|
+
if (basePath === '__custom__') {
|
|
57
|
+
const { customPath } = await inquirer.prompt([
|
|
58
|
+
{ type: 'input', name: 'customPath', message: 'Enter base repo path:' },
|
|
59
|
+
]);
|
|
60
|
+
resolvedBasePath = path.resolve(customPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Watch paths
|
|
64
|
+
const { watchPathsInput } = await inquirer.prompt([
|
|
65
|
+
{
|
|
66
|
+
type: 'input',
|
|
67
|
+
name: 'watchPathsInput',
|
|
68
|
+
message: 'Which folders contain base code to sync? (comma-separated)',
|
|
69
|
+
default: 'src/shared',
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
const watchPaths = watchPathsInput.split(',').map((p) => p.trim()).filter(Boolean);
|
|
73
|
+
|
|
74
|
+
// GitHub token
|
|
75
|
+
const { tokenSource } = await inquirer.prompt([
|
|
76
|
+
{
|
|
77
|
+
type: 'list',
|
|
78
|
+
name: 'tokenSource',
|
|
79
|
+
message: 'GitHub token configuration:',
|
|
80
|
+
choices: [
|
|
81
|
+
{ name: 'Use environment variable (GITHUB_TOKEN)', value: 'env:GITHUB_TOKEN' },
|
|
82
|
+
{ name: 'Enter token directly', value: '__direct__' },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
let token = tokenSource;
|
|
88
|
+
if (tokenSource === '__direct__') {
|
|
89
|
+
const { directToken } = await inquirer.prompt([
|
|
90
|
+
{ type: 'password', name: 'directToken', message: 'GitHub personal access token:' },
|
|
91
|
+
]);
|
|
92
|
+
token = directToken;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Target repos
|
|
96
|
+
const targetRepos = [];
|
|
97
|
+
let addMore = true;
|
|
98
|
+
while (addMore) {
|
|
99
|
+
const answers = await inquirer.prompt([
|
|
100
|
+
{ type: 'input', name: 'name', message: 'Target repo (owner/name):' },
|
|
101
|
+
{ type: 'input', name: 'localPath', message: 'Local path to target repo:' },
|
|
102
|
+
{ type: 'input', name: 'syncPath', message: 'Sync destination path in target:', default: 'src/base' },
|
|
103
|
+
{
|
|
104
|
+
type: 'list',
|
|
105
|
+
name: 'syncMode',
|
|
106
|
+
message: 'Sync mode:',
|
|
107
|
+
choices: ['auto-if-tests-pass', 'pr-always', 'direct'],
|
|
108
|
+
},
|
|
109
|
+
{ type: 'input', name: 'branch', message: 'Target branch:', default: 'main' },
|
|
110
|
+
]);
|
|
111
|
+
targetRepos.push(answers);
|
|
112
|
+
|
|
113
|
+
const { more } = await inquirer.prompt([
|
|
114
|
+
{ type: 'confirm', name: 'more', message: 'Add another target repo?', default: false },
|
|
115
|
+
]);
|
|
116
|
+
addMore = more;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// LLM provider
|
|
120
|
+
const { llmProvider } = await inquirer.prompt([
|
|
121
|
+
{
|
|
122
|
+
type: 'list',
|
|
123
|
+
name: 'llmProvider',
|
|
124
|
+
message: 'LLM provider for onboard command:',
|
|
125
|
+
choices: [
|
|
126
|
+
{ name: 'Anthropic (Claude)', value: 'anthropic' },
|
|
127
|
+
{ name: 'OpenAI (GPT-4)', value: 'openai' },
|
|
128
|
+
{ name: 'Google (Gemini)', value: 'google' },
|
|
129
|
+
{ name: 'Mistral', value: 'mistral' },
|
|
130
|
+
{ name: 'Ollama (Local)', value: 'ollama' },
|
|
131
|
+
{ name: 'Skip LLM setup', value: null },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
// Build config
|
|
137
|
+
const config = {
|
|
138
|
+
baseRepo: {
|
|
139
|
+
path: path.relative(configDir, resolvedBasePath) || '.',
|
|
140
|
+
watchPaths,
|
|
141
|
+
},
|
|
142
|
+
github: { token },
|
|
143
|
+
targetRepos,
|
|
144
|
+
options: {
|
|
145
|
+
commitMessageTemplate: 'Sync base code: {{files}}',
|
|
146
|
+
branchPrefix: 'sync-bot',
|
|
147
|
+
autoMergeOnPass: true,
|
|
148
|
+
runTests: true,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (llmProvider) {
|
|
153
|
+
const modelDefaults = {
|
|
154
|
+
anthropic: { model: 'claude-sonnet-4-20250514', apiKey: 'env:ANTHROPIC_API_KEY' },
|
|
155
|
+
openai: { model: 'gpt-4-turbo', apiKey: 'env:OPENAI_API_KEY' },
|
|
156
|
+
google: { model: 'gemini-1.5-pro', apiKey: 'env:GOOGLE_API_KEY' },
|
|
157
|
+
mistral: { model: 'mistral-large-latest', apiKey: 'env:MISTRAL_API_KEY' },
|
|
158
|
+
ollama: { model: 'llama3.1', baseURL: 'http://localhost:11434' },
|
|
159
|
+
};
|
|
160
|
+
config.llm = { provider: llmProvider, ...modelDefaults[llmProvider] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
164
|
+
logger.success(`Created drybase.json in ${configDir}`);
|
|
165
|
+
|
|
166
|
+
logger.info('\nNext steps:');
|
|
167
|
+
logger.dim(' 1. Review and edit drybase.json');
|
|
168
|
+
logger.dim(' 2. Run: drybase validate');
|
|
169
|
+
logger.dim(' 3. Run: drybase onboard (to auto-detect base files)');
|
|
170
|
+
logger.dim(' or: drybase watch (to start syncing)');
|
|
171
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { loadConfig, findConfigFile } from '../utils/config.js';
|
|
5
|
+
import { analyzeProject, scanProject } from '../core/analyzer.js';
|
|
6
|
+
import * as logger from '../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
export async function onboardCommand(projectPath, options) {
|
|
9
|
+
const resolvedPath = path.resolve(projectPath);
|
|
10
|
+
|
|
11
|
+
// Check project exists
|
|
12
|
+
try {
|
|
13
|
+
await fs.access(resolvedPath);
|
|
14
|
+
} catch {
|
|
15
|
+
logger.error(`Project path not found: ${resolvedPath}`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Try to load existing config for LLM settings
|
|
20
|
+
let config;
|
|
21
|
+
try {
|
|
22
|
+
config = await loadConfig();
|
|
23
|
+
} catch {
|
|
24
|
+
// No config yet — that's fine for onboard
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (options.llm === false) {
|
|
28
|
+
logger.info('Skipping LLM analysis (--no-llm). Manual mode.');
|
|
29
|
+
await manualOnboard(resolvedPath);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!config?.llm) {
|
|
34
|
+
logger.warn('No LLM provider configured. Falling back to manual mode.');
|
|
35
|
+
logger.dim('Tip: Run "drybase init" first, or add an "llm" section to drybase.json');
|
|
36
|
+
await manualOnboard(resolvedPath);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let analysis;
|
|
41
|
+
try {
|
|
42
|
+
analysis = await analyzeProject(resolvedPath, config.llm);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
logger.error(`LLM analysis failed: ${err.message}`);
|
|
45
|
+
logger.warn('Falling back to manual mode.');
|
|
46
|
+
await manualOnboard(resolvedPath);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!analysis) {
|
|
51
|
+
await manualOnboard(resolvedPath);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Display results
|
|
56
|
+
const { baseFiles, recommendedWatchPaths } = analysis;
|
|
57
|
+
|
|
58
|
+
if (!baseFiles?.length) {
|
|
59
|
+
logger.info('No base files detected. Try manual mode with --no-llm.');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
logger.info(`\nFound ${baseFiles.length} potential base file(s):\n`);
|
|
64
|
+
|
|
65
|
+
const icons = { high: '✔', medium: '?', low: '○' };
|
|
66
|
+
for (const file of baseFiles) {
|
|
67
|
+
const icon = icons[file.confidence] || '?';
|
|
68
|
+
logger.info(` ${icon} ${file.path} (confidence: ${file.confidence})`);
|
|
69
|
+
logger.dim(` ${file.reason}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!options.interactive) {
|
|
73
|
+
logger.info('\nRecommended watch paths:');
|
|
74
|
+
for (const wp of recommendedWatchPaths || []) {
|
|
75
|
+
logger.dim(` ${wp}`);
|
|
76
|
+
}
|
|
77
|
+
logger.info('\nRun with --interactive to accept/reject suggestions and generate config.');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Interactive mode: accept/reject each suggestion
|
|
82
|
+
const accepted = [];
|
|
83
|
+
for (const file of baseFiles) {
|
|
84
|
+
const { include } = await inquirer.prompt([
|
|
85
|
+
{
|
|
86
|
+
type: 'confirm',
|
|
87
|
+
name: 'include',
|
|
88
|
+
message: `Include ${file.path}? (${file.confidence} confidence: ${file.reason})`,
|
|
89
|
+
default: file.confidence !== 'low',
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
if (include) accepted.push(file);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!accepted.length) {
|
|
96
|
+
logger.warn('No files selected. Onboard cancelled.');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Determine watch paths from accepted files
|
|
101
|
+
const watchPathSet = new Set();
|
|
102
|
+
for (const file of accepted) {
|
|
103
|
+
const dir = path.dirname(file.path);
|
|
104
|
+
// Use the top-level meaningful directory
|
|
105
|
+
const parts = dir.split(path.sep);
|
|
106
|
+
if (parts.length >= 2) {
|
|
107
|
+
watchPathSet.add(parts.slice(0, 2).join('/'));
|
|
108
|
+
} else {
|
|
109
|
+
watchPathSet.add(dir);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const watchPaths = [...watchPathSet];
|
|
113
|
+
|
|
114
|
+
logger.info(`\nWatch paths: ${watchPaths.join(', ')}`);
|
|
115
|
+
|
|
116
|
+
// Generate config
|
|
117
|
+
const { save } = await inquirer.prompt([
|
|
118
|
+
{
|
|
119
|
+
type: 'confirm',
|
|
120
|
+
name: 'save',
|
|
121
|
+
message: 'Save as drybase.json?',
|
|
122
|
+
default: true,
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
if (!save) {
|
|
127
|
+
logger.info('Cancelled.');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const existingConfig = config || {};
|
|
132
|
+
const newConfig = {
|
|
133
|
+
...existingConfig,
|
|
134
|
+
baseRepo: {
|
|
135
|
+
...existingConfig.baseRepo,
|
|
136
|
+
path: existingConfig.baseRepo?.path || '.',
|
|
137
|
+
watchPaths,
|
|
138
|
+
},
|
|
139
|
+
_metadata: {
|
|
140
|
+
onboardedAt: new Date().toISOString(),
|
|
141
|
+
llmProvider: config?.llm?.provider,
|
|
142
|
+
llmModel: config?.llm?.model,
|
|
143
|
+
filesAnalyzed: baseFiles.length,
|
|
144
|
+
suggestionsAccepted: accepted.length,
|
|
145
|
+
suggestionsRejected: baseFiles.length - accepted.length,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Remove internal fields
|
|
150
|
+
delete newConfig._configPath;
|
|
151
|
+
delete newConfig._configDir;
|
|
152
|
+
|
|
153
|
+
const configPath = (await findConfigFile()) || path.join(resolvedPath, 'drybase.json');
|
|
154
|
+
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2) + '\n');
|
|
155
|
+
logger.success(`Updated ${configPath}`);
|
|
156
|
+
logger.dim(` Accepted ${accepted.length}/${baseFiles.length} suggestions`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function manualOnboard(projectPath) {
|
|
160
|
+
logger.info('Scanning project structure...');
|
|
161
|
+
const tree = await scanProject(projectPath);
|
|
162
|
+
const dirs = [...new Set(tree.filter((f) => f.type === 'dir').map((f) => f.path))].sort();
|
|
163
|
+
|
|
164
|
+
if (!dirs.length) {
|
|
165
|
+
logger.warn('No directories found in project.');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { selectedDirs } = await inquirer.prompt([
|
|
170
|
+
{
|
|
171
|
+
type: 'checkbox',
|
|
172
|
+
name: 'selectedDirs',
|
|
173
|
+
message: 'Select directories containing base code to sync:',
|
|
174
|
+
choices: dirs.map((d) => ({ name: d, value: d })),
|
|
175
|
+
},
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
if (!selectedDirs.length) {
|
|
179
|
+
logger.warn('No directories selected.');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
logger.info(`Selected watch paths: ${selectedDirs.join(', ')}`);
|
|
184
|
+
logger.info('Add these to your drybase.json under "baseRepo.watchPaths"');
|
|
185
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import { loadConfig } from '../utils/config.js';
|
|
3
|
+
import { loadState, saveState, getLastSync, markRolledBack } from '../utils/state.js';
|
|
4
|
+
import * as git from '../core/git.js';
|
|
5
|
+
import * as logger from '../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
export async function rollbackCommand(repoName, options) {
|
|
8
|
+
const config = await loadConfig();
|
|
9
|
+
if (!config) {
|
|
10
|
+
logger.error('No drybase.json found. Run "drybase init" to create one.');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const state = await loadState(config._configDir);
|
|
15
|
+
const lastSync = getLastSync(state, repoName);
|
|
16
|
+
|
|
17
|
+
if (!lastSync) {
|
|
18
|
+
logger.error(`No sync history found for "${repoName}".`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (lastSync.status === 'rolled-back') {
|
|
23
|
+
logger.warn(`Last sync for "${repoName}" was already rolled back.`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!lastSync.commitSha) {
|
|
28
|
+
logger.error(`No commit SHA found for last sync to "${repoName}". Cannot rollback.`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const targetRepo = config.targetRepos.find((r) => r.name === repoName);
|
|
33
|
+
if (!targetRepo) {
|
|
34
|
+
logger.error(`Repo "${repoName}" not found in config.`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
logger.info(`Last sync to ${repoName}:`);
|
|
39
|
+
logger.dim(` Timestamp: ${lastSync.timestamp}`);
|
|
40
|
+
logger.dim(` Commit: ${lastSync.commitSha}`);
|
|
41
|
+
logger.dim(` Files: ${Object.keys(lastSync.files).join(', ')}`);
|
|
42
|
+
|
|
43
|
+
if (!options.yes) {
|
|
44
|
+
const { confirm } = await inquirer.prompt([
|
|
45
|
+
{
|
|
46
|
+
type: 'confirm',
|
|
47
|
+
name: 'confirm',
|
|
48
|
+
message: `Revert commit ${lastSync.commitSha.slice(0, 8)} in ${repoName}?`,
|
|
49
|
+
default: false,
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
if (!confirm) {
|
|
53
|
+
logger.info('Cancelled.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const revertSha = await git.revertCommit(targetRepo.localPath, lastSync.commitSha);
|
|
60
|
+
await git.pushBranch(targetRepo.localPath, targetRepo.branch || 'main');
|
|
61
|
+
|
|
62
|
+
const newState = markRolledBack(state, repoName);
|
|
63
|
+
await saveState(config._configDir, newState);
|
|
64
|
+
|
|
65
|
+
logger.success(`Rolled back sync to ${repoName} (revert commit: ${revertSha})`);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger.error(`Rollback failed: ${err.message}`);
|
|
68
|
+
logger.debug(err.stack);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import * as logger from '../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
const DAEMON_DIR = path.join(os.homedir(), '.drybase');
|
|
9
|
+
const PID_FILE = path.join(DAEMON_DIR, 'daemon.pid');
|
|
10
|
+
const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
|
|
11
|
+
|
|
12
|
+
export async function startCommand() {
|
|
13
|
+
await fs.mkdir(DAEMON_DIR, { recursive: true });
|
|
14
|
+
|
|
15
|
+
// Check if already running
|
|
16
|
+
try {
|
|
17
|
+
const pid = parseInt(await fs.readFile(PID_FILE, 'utf8'), 10);
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0); // signal 0 = check if alive
|
|
20
|
+
logger.warn(`Daemon already running (PID ${pid}). Use "drybase stop" first.`);
|
|
21
|
+
return;
|
|
22
|
+
} catch {
|
|
23
|
+
// Process not found — stale PID file, clean up
|
|
24
|
+
await fs.unlink(PID_FILE);
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// No PID file — good
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Spawn drybase watch as detached process
|
|
31
|
+
const binPath = path.resolve(
|
|
32
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
33
|
+
'../../bin/drybase.js'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const logStream = await fs.open(LOG_FILE, 'a');
|
|
37
|
+
|
|
38
|
+
const child = spawn(process.execPath, [binPath, 'watch'], {
|
|
39
|
+
detached: true,
|
|
40
|
+
stdio: ['ignore', logStream.fd, logStream.fd],
|
|
41
|
+
env: { ...process.env },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
child.unref();
|
|
45
|
+
await fs.writeFile(PID_FILE, String(child.pid));
|
|
46
|
+
await logStream.close();
|
|
47
|
+
|
|
48
|
+
logger.success(`Daemon started (PID ${child.pid})`);
|
|
49
|
+
logger.dim(` Log file: ${LOG_FILE}`);
|
|
50
|
+
logger.dim(` PID file: ${PID_FILE}`);
|
|
51
|
+
}
|