-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinit.ts
More file actions
252 lines (217 loc) · 7.37 KB
/
Copy pathinit.ts
File metadata and controls
252 lines (217 loc) · 7.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import { mkdir, writeFile, readFile, appendFile } from 'node:fs/promises';
import { join, basename } from 'node:path';
import { homedir } from 'node:os';
import type { Command } from 'commander';
import { stringify } from 'yaml';
import { detectTools, SUPPORTED_TOOLS } from '../utils/detect-tools.js';
import * as ui from '../utils/ui.js';
import type { ToolId } from '../utils/detect-tools.js';
import { fileExists } from '../utils/fs.js';
import {
selectPrompt,
multiselectPrompt,
confirmPrompt,
introPrompt,
outroPrompt,
notePrompt,
spinnerTask,
isInteractiveSession,
} from '../utils/prompt.js';
export interface InitOptions {
tools?: string;
mode?: 'copy' | 'link';
global?: boolean;
yes?: boolean;
preset?: string;
}
import { BUILTIN_SCOPES } from '../core/schema.js';
function buildRuleFileContent(scope: string): string {
return `# .dwf/rules/${scope}.yml
scope: ${scope}
rules: []
# Example:
# - id: my-rule
# severity: error
# content: |
# Describe your rule here.
`;
}
function parseToolsFlag(raw: string): ToolId[] {
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
for (const id of ids) {
if (!SUPPORTED_TOOLS.includes(id as ToolId)) {
throw new Error(`Unknown tool "${id}". Supported: ${SUPPORTED_TOOLS.join(', ')}`);
}
}
return ids as ToolId[];
}
async function resolveTools(options: InitOptions, cwd: string): Promise<ToolId[]> {
const detected = await detectTools(cwd);
const detectedIds = detected.filter((t) => t.detected).map((t) => t.id);
if (options.tools) {
return parseToolsFlag(options.tools);
}
if (options.yes) {
return detectedIds.length > 0 ? detectedIds : ['claude'];
}
for (;;) {
const selected = await multiselectPrompt<ToolId>({
message: 'Which tools to configure?',
options: SUPPORTED_TOOLS.map((id) => ({
label: id,
value: id,
hint: detectedIds.includes(id) ? 'detected' : undefined,
})),
initialValues: detectedIds,
});
if (selected.length > 0) {
return selected;
}
ui.warn('Select at least one tool');
}
}
async function resolveMode(options: InitOptions): Promise<'copy' | 'link'> {
if (options.mode) {
if (options.mode !== 'copy' && options.mode !== 'link') {
throw new Error(`Unknown mode "${options.mode as string}". Supported: copy, link`);
}
return options.mode;
}
if (options.yes) {
return 'copy';
}
const mode = await selectPrompt<'copy' | 'link'>({
message: 'Output mode',
options: [
{ label: 'copy', value: 'copy' as const, hint: 'Embed rules directly in tool config files' },
{ label: 'link', value: 'link' as const, hint: 'Symlink tool config files to .dwf/ output' },
],
});
return mode;
}
async function appendToGitignore(cwd: string): Promise<void> {
const gitignorePath = join(cwd, '.gitignore');
const entry = '.dwf/.cache/';
if (await fileExists(gitignorePath)) {
const content = await readFile(gitignorePath, 'utf-8');
if (content.includes(entry)) return;
const suffix = content.endsWith('\n') ? '' : '\n';
await appendFile(gitignorePath, `${suffix}${entry}\n`);
} else {
await writeFile(gitignorePath, `${entry}\n`, 'utf-8');
}
}
type InitScope = 'project' | 'global';
async function resolveInitScope(options: InitOptions): Promise<InitScope> {
if (options.global) {
return 'global';
}
if (options.yes) {
return 'project';
}
return selectPrompt<InitScope>({
message: 'Where do you want to set up devw?',
options: [
{ label: 'This project (.dwf/)', value: 'project' as const },
{ label: 'Global (~/.dwf/)', value: 'global' as const },
],
});
}
export async function runInit(options: InitOptions): Promise<void> {
const cwd = process.cwd();
if (isInteractiveSession() && !options.yes) {
introPrompt('Initialize dev-workflows');
}
let scope: InitScope;
let tools: ToolId[];
let mode: 'copy' | 'link';
try {
scope = await resolveInitScope(options);
const toolDetectRoot = scope === 'global' ? homedir() : cwd;
tools = await resolveTools(options, toolDetectRoot);
mode = await resolveMode(options);
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') return;
ui.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
return;
}
const rootDir = scope === 'global' ? homedir() : cwd;
const dwfDir = join(rootDir, '.dwf');
const dwfPath = scope === 'global' ? '~/.dwf/' : '.dwf/';
const alreadyExists = await fileExists(dwfDir);
if (isInteractiveSession() && !options.yes) {
const willCreate = ['config.yml', ...BUILTIN_SCOPES.map((s) => `rules/${s}.yml`)];
const noteLines = [
`Location: ${dwfPath}`,
`Tools: ${tools.join(', ')}`,
`Mode: ${mode}`,
`Will create: ${willCreate.join(', ')}`,
...(alreadyExists ? ['⚠ Already exists — config will be overwritten, rules preserved'] : []),
].join('\n');
notePrompt(noteLines, 'Summary');
const confirmed = await confirmPrompt({
message: alreadyExists ? 'Overwrite and initialize?' : 'Initialize?',
defaultValue: true,
});
if (!confirmed) {
outroPrompt('Init cancelled.');
return;
}
}
// For -y + alreadyExists: show warn so the existing e2e test keeps passing
if (options.yes && alreadyExists) {
const locationHint = scope === 'global' ? '~/.dwf/ already exists.' : '.dwf/ already exists in this directory.';
ui.warn(locationHint);
}
const rulesDir = join(dwfDir, 'rules');
const projectName = scope === 'global' ? 'global' : basename(cwd);
const config = {
version: '0.2',
project: { name: projectName },
tools,
mode,
global: true,
blocks: [] as string[],
};
const configContent = `# Dev Workflows configuration\n${stringify(config)}`;
await spinnerTask({
label: 'Setting up .dwf/ workspace…',
task: async () => {
await mkdir(rulesDir, { recursive: true });
await mkdir(join(dwfDir, 'assets'), { recursive: true });
await writeFile(join(dwfDir, 'config.yml'), configContent, 'utf-8');
for (const s of BUILTIN_SCOPES) {
await writeFile(join(rulesDir, `${s}.yml`), buildRuleFileContent(s), 'utf-8');
}
},
});
if (scope !== 'global') {
await appendToGitignore(cwd);
}
// Success summary
ui.newline();
ui.success(`Initialized ${dwfPath} — ${tools.join(', ')} (${mode} mode)`);
outroPrompt('Run "devw add" to browse and install rules.');
if (options.preset) {
ui.newline();
ui.info(`Installing preset: ${options.preset}...`);
const { installPreset } = await import('./add.js');
const { runCompileFromAdd } = await import('./compile.js');
const anyAdded = await installPreset(cwd, options.preset, { force: true });
if (anyAdded) {
await runCompileFromAdd();
}
}
}
export function registerInitCommand(program: Command): void {
program
.command('init')
.description('Initialize .dwf/ in this project or globally')
.option('--tools <tools>', 'Comma-separated list of tools (claude,cursor,gemini)')
.option('--mode <mode>', 'Output mode: copy or link')
.option('--global', 'Initialize global config in ~/.dwf/')
.option('--preset <preset>', 'Install a preset after initialization (e.g., spec-driven)')
.option('-y, --yes', 'Accept all defaults')
.action((options: InitOptions) => runInit(options));
}