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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/index.js';
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
+ }