nova-spec 1.0.2 → 1.0.4

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/lib/installer.js CHANGED
@@ -3,17 +3,23 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { select, input, confirm } = require('@inquirer/prompts');
6
- const { generateManifest } = require('./sync.js');
6
+ const {
7
+ generateManifest,
8
+ buildHookCommand,
9
+ HOOK_MARKER,
10
+ } = require('./sync.js');
11
+ const { detectForge } = require('./forge.js');
12
+ const { listTransitionsAsync } = require('./jira.js');
7
13
 
8
14
  const PACKAGE_ROOT = path.join(__dirname, '..');
9
15
  const NOVASPEC_SRC = path.join(PACKAGE_ROOT, 'novaspec');
10
16
  const AGENTS_SRC = path.join(PACKAGE_ROOT, 'AGENTS.md');
11
17
  const CLAUDE_MD_SRC = path.join(PACKAGE_ROOT, 'CLAUDE.md');
18
+ const MANIFEST_FILE = '.nova-manifest.json';
12
19
 
13
20
  async function init() {
14
21
  console.log('\n nova-spec installer\n ───────────────────\n');
15
22
 
16
- // 1. Scope: global or project
17
23
  const scope = await select({
18
24
  message: 'Where do you want to install nova-spec?',
19
25
  choices: [
@@ -23,9 +29,13 @@ async function init() {
23
29
  ],
24
30
  });
25
31
 
26
- const destDir = scope === 'global'
27
- ? path.join(process.env.HOME || process.env.USERPROFILE, '.claude')
28
- : process.cwd();
32
+ const home = process.env.HOME || process.env.USERPROFILE;
33
+ if (scope === 'global' && !home) {
34
+ console.error(' ✗ HOME / USERPROFILE not set; cannot resolve global install path.');
35
+ process.exit(1);
36
+ }
37
+
38
+ const destDir = scope === 'global' ? path.join(home, '.claude') : process.cwd();
29
39
 
30
40
  if (scope === 'update') {
31
41
  const { sync } = require('./sync.js');
@@ -33,7 +43,6 @@ async function init() {
33
43
  return;
34
44
  }
35
45
 
36
- // 2. Runtime
37
46
  const runtime = await select({
38
47
  message: 'Which AI runtime do you use?',
39
48
  choices: [
@@ -43,36 +52,41 @@ async function init() {
43
52
  ],
44
53
  });
45
54
 
46
- // 3. Jira
47
- const useJira = await confirm({ message: 'Do you use Jira?', default: true });
55
+ if (scope === 'global' && runtime !== 'claude') {
56
+ console.warn('\n Global install currently only supports Claude Code.');
57
+ console.warn(' For OpenCode, use a project install.\n');
58
+ }
48
59
 
49
- let jiraConfig = { skill: '', url: '', project: '', email: '', token: '${JIRA_API_TOKEN}', done_transition_id: '41' };
60
+ const ticketSystem = await select({
61
+ message: 'Ticket system:',
62
+ choices: [
63
+ { name: 'Jira', value: 'jira' },
64
+ { name: 'None (paste tickets manually)', value: 'none' },
65
+ ],
66
+ });
50
67
 
51
- if (useJira) {
52
- jiraConfig.skill = 'jira-integration';
53
- jiraConfig.url = await input({
54
- message: 'Jira URL:',
55
- default: 'https://your-workspace.atlassian.net',
56
- });
57
- jiraConfig.project = await input({ message: 'Jira project key:', default: 'PROJ' });
58
- jiraConfig.email = await input({ message: 'Your Jira email:' });
59
- jiraConfig.done_transition_id = await input({
60
- message: 'Jira "Done" transition ID (find it via GET /rest/api/3/issue/<TICKET>/transitions):',
61
- default: '41',
62
- });
63
- console.log('\n Tip: set JIRA_API_TOKEN in your environment.');
64
- console.log(' Get your token at: https://id.atlassian.com/manage-profile/security/api-tokens\n');
65
- }
68
+ const jiraConfig = await collectJiraConfig(ticketSystem === 'jira');
66
69
 
67
- // 4. Branch config
68
70
  const baseBranch = await input({ message: 'Base branch:', default: 'main' });
69
71
 
70
- // 5. Confirm
72
+ const detectedForge = scope === 'project' ? detectForge(destDir) : null;
73
+ const forgeType = await select({
74
+ message: detectedForge ? `Forge (detected: ${detectedForge}):` : 'Forge:',
75
+ choices: [
76
+ { name: 'Auto-detect from git remote', value: 'auto' },
77
+ { name: 'GitHub (gh)', value: 'github' },
78
+ { name: 'GitLab (glab)', value: 'gitlab' },
79
+ { name: 'None / manual', value: 'none' },
80
+ ],
81
+ default: detectedForge || 'auto',
82
+ });
83
+
71
84
  console.log('\n Summary:');
72
- console.log(` → Scope: ${scope === 'global' ? 'Global (~/.claude)' : 'Project (' + destDir + ')'}`);
73
- console.log(` → Runtime: ${runtime}`);
74
- console.log(` → Jira: ${useJira ? jiraConfig.url + ' / ' + jiraConfig.project : 'disabled'}`);
75
- console.log(` → Branch: ${baseBranch}\n`);
85
+ console.log(` → Scope: ${scope === 'global' ? 'Global (' + destDir + ')' : 'Project (' + destDir + ')'}`);
86
+ console.log(` → Runtime: ${runtime}`);
87
+ console.log(` → Tickets: ${ticketSystem === 'jira' ? jiraConfig.url + ' / ' + jiraConfig.project : 'manual'}`);
88
+ console.log(` → Forge: ${forgeType}`);
89
+ console.log(` → Branch: ${baseBranch}\n`);
76
90
 
77
91
  const ok = await confirm({ message: 'Install with these settings?', default: true });
78
92
  if (!ok) {
@@ -80,125 +94,306 @@ async function init() {
80
94
  return;
81
95
  }
82
96
 
83
- // 6. Install
84
97
  installFiles(destDir, runtime, scope);
85
- writeConfig(destDir, { jiraConfig, baseBranch });
86
- generateManifest(path.join(destDir, 'novaspec'));
98
+ writeConfig(destDir, { ticketSystem, jiraConfig, baseBranch, forgeType });
99
+
100
+ // Manifest reflects what we just shipped from the package.
101
+ const manifest = generateManifest(PACKAGE_ROOT);
102
+ fs.writeFileSync(
103
+ path.join(destDir, 'novaspec', MANIFEST_FILE),
104
+ JSON.stringify(manifest, null, 2) + '\n',
105
+ );
87
106
 
88
107
  console.log('\n ✓ nova-spec installed!\n');
89
108
  console.log(' Next step: open Claude Code or OpenCode in this directory and run:');
90
109
  console.log(' /nova-start TICKET-123\n');
91
110
  }
92
111
 
112
+ async function collectJiraConfig(useJira) {
113
+ const config = {
114
+ skill: '',
115
+ url: '',
116
+ project: '',
117
+ email: '',
118
+ token: '${JIRA_API_TOKEN}',
119
+ done_transition_id: '41',
120
+ };
121
+
122
+ if (!useJira) return config;
123
+
124
+ config.skill = 'jira-integration';
125
+ config.url = await input({
126
+ message: 'Jira URL:',
127
+ default: 'https://your-workspace.atlassian.net',
128
+ });
129
+ config.project = await input({ message: 'Jira project key:', default: 'PROJ' });
130
+ config.email = await input({ message: 'Your Jira email:' });
131
+
132
+ console.log('\n Tip: set JIRA_API_TOKEN in your environment.');
133
+ console.log(' Get your token at: https://id.atlassian.com/manage-profile/security/api-tokens\n');
134
+
135
+ const token = process.env.JIRA_API_TOKEN;
136
+ if (token && config.email && config.url) {
137
+ const validate = await confirm({
138
+ message: 'Validate Jira "Done" transition by listing transitions of an existing ticket?',
139
+ default: true,
140
+ });
141
+ if (validate) {
142
+ const sampleKey = await input({
143
+ message: `Sample ticket key (e.g. ${config.project}-1):`,
144
+ default: `${config.project}-1`,
145
+ });
146
+ try {
147
+ const transitions = await listTransitionsAsync({
148
+ url: config.url,
149
+ email: config.email,
150
+ token,
151
+ ticket: sampleKey,
152
+ });
153
+ if (transitions.length === 0) {
154
+ console.warn(' ⚠ No transitions returned. Falling back to manual entry.');
155
+ } else {
156
+ const choice = await select({
157
+ message: 'Which transition closes a ticket as Done?',
158
+ choices: transitions.map((t) => ({
159
+ name: `${t.name} (id: ${t.id})`,
160
+ value: t.id,
161
+ })),
162
+ });
163
+ config.done_transition_id = choice;
164
+ return config;
165
+ }
166
+ } catch (err) {
167
+ console.warn(` ⚠ Could not validate via API: ${err.message}`);
168
+ console.warn(' Falling back to manual entry.\n');
169
+ }
170
+ }
171
+ }
172
+
173
+ config.done_transition_id = await input({
174
+ message: 'Jira "Done" transition ID:',
175
+ default: '41',
176
+ });
177
+
178
+ return config;
179
+ }
180
+
93
181
  function installFiles(destDir, runtime, scope) {
94
- // Copy novaspec/
95
182
  const destNovaspec = path.join(destDir, 'novaspec');
96
- const destNovaspecConfig = path.join(destNovaspec, 'config.yml');
97
183
 
98
- // Backup existing config.yml
99
- let configBackup = null;
100
- if (fs.existsSync(destNovaspecConfig)) {
101
- configBackup = fs.readFileSync(destNovaspecConfig, 'utf8');
102
- }
184
+ // Backup config.yml in case copyTree ever changes
185
+ const destConfigPath = path.join(destNovaspec, 'config.yml');
186
+ const configBackup = fs.existsSync(destConfigPath)
187
+ ? fs.readFileSync(destConfigPath, 'utf8')
188
+ : null;
103
189
 
104
- copyDir(NOVASPEC_SRC, destNovaspec);
190
+ copyTree(NOVASPEC_SRC, destNovaspec, { exclude: ['config.yml', MANIFEST_FILE] });
105
191
 
106
- // Restore config.yml if it existed (user's config wins)
107
- if (configBackup) {
108
- fs.writeFileSync(destNovaspecConfig, configBackup);
109
- }
192
+ if (configBackup) fs.writeFileSync(destConfigPath, configBackup);
110
193
 
111
- // Copy AGENTS.md and CLAUDE.md
194
+ // Framework files at top level
112
195
  if (fs.existsSync(AGENTS_SRC)) fs.copyFileSync(AGENTS_SRC, path.join(destDir, 'AGENTS.md'));
113
- if (fs.existsSync(CLAUDE_MD_SRC)) fs.copyFileSync(CLAUDE_MD_SRC, path.join(destDir, 'CLAUDE.md'));
196
+ if (fs.existsSync(CLAUDE_MD_SRC) && !fs.existsSync(path.join(destDir, 'CLAUDE.md'))) {
197
+ fs.copyFileSync(CLAUDE_MD_SRC, path.join(destDir, 'CLAUDE.md'));
198
+ }
114
199
 
115
- // Create context/ structure (only for project scope)
116
200
  if (scope === 'project') {
117
- for (const dir of [
118
- 'context/decisions/archived',
119
- 'context/gotchas',
120
- 'context/services',
121
- 'context/changes/active',
122
- 'context/changes/archive',
123
- ]) {
124
- fs.mkdirSync(path.join(destDir, dir), { recursive: true });
125
- }
126
- const gitkeep = path.join(destDir, 'context/changes/active/.gitkeep');
127
- if (!fs.existsSync(gitkeep)) fs.writeFileSync(gitkeep, '');
128
-
129
- // notes.md
130
- const notes = path.join(destDir, 'notes.md');
131
- if (!fs.existsSync(notes)) fs.writeFileSync(notes, '');
201
+ scaffoldContext(destDir);
132
202
  }
133
203
 
134
- // Runtime symlinks / settings
135
204
  if (runtime === 'claude' || runtime === 'both') {
136
- createSymlinks(destDir, '.claude');
137
- writeClaudeSettings(path.join(destDir, '.claude'));
205
+ const linkDir = scope === 'global' ? destDir : path.join(destDir, '.claude');
206
+ createSymlinks(linkDir, destNovaspec);
207
+ writeClaudeSettings(linkDir);
138
208
  }
139
209
  if (runtime === 'opencode' || runtime === 'both') {
140
- createSymlinks(destDir, '.opencode');
141
- writeOpenCodeSettings(path.join(destDir, '.opencode'));
210
+ if (scope === 'global') {
211
+ console.warn(' ⚠ Skipping OpenCode global setup (use project install instead).');
212
+ } else {
213
+ const linkDir = path.join(destDir, '.opencode');
214
+ createSymlinks(linkDir, destNovaspec);
215
+ writeOpenCodeSettings(linkDir);
216
+ }
142
217
  }
143
218
 
144
- // .gitignore
145
219
  ensureGitignore(destDir);
146
220
  }
147
221
 
148
- function createSymlinks(destDir, dotDir) {
149
- const dir = path.join(destDir, dotDir);
150
- fs.mkdirSync(dir, { recursive: true });
222
+ function scaffoldContext(destDir) {
223
+ for (const dir of [
224
+ 'context/decisions/archived',
225
+ 'context/gotchas',
226
+ 'context/services',
227
+ 'context/changes/active',
228
+ 'context/changes/archive',
229
+ ]) {
230
+ fs.mkdirSync(path.join(destDir, dir), { recursive: true });
231
+ }
232
+ const gitkeep = path.join(destDir, 'context/changes/active/.gitkeep');
233
+ if (!fs.existsSync(gitkeep)) fs.writeFileSync(gitkeep, '');
234
+
235
+ const notes = path.join(destDir, 'notes.md');
236
+ if (!fs.existsSync(notes)) fs.writeFileSync(notes, '');
237
+
238
+ // Top-level scaffolding files explaining their purpose
239
+ writeIfMissing(path.join(destDir, 'context/stack.md'), STACK_TEMPLATE);
240
+ writeIfMissing(path.join(destDir, 'context/conventions.md'), CONVENTIONS_TEMPLATE);
241
+ }
242
+
243
+ function writeIfMissing(filePath, content) {
244
+ if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, content);
245
+ }
246
+
247
+ const STACK_TEMPLATE = `<!--
248
+ context/stack.md — describe the technology stack of this project.
249
+
250
+ Loaded by nova-spec at the start of every ticket so the agent knows what
251
+ language, frameworks, and key dependencies you use. Keep it short and factual:
252
+ versions matter, philosophy doesn't.
253
+
254
+ Edit freely. Update it whenever you upgrade a major dependency.
255
+ -->
256
+
257
+ # Stack
258
+
259
+ ## Language & runtime
260
+ - e.g. Node.js 20.x / Ruby 3.3 / Python 3.12
261
+
262
+ ## Framework
263
+ - e.g. Next.js 14 (App Router) / Rails 7.1 / FastAPI 0.110
264
+
265
+ ## Key dependencies
266
+ - e.g. PostgreSQL 16, Redis 7, Sidekiq 7
267
+ - e.g. tailwindcss, prisma, vitest
268
+
269
+ ## Infrastructure
270
+ - e.g. AWS (ECS, RDS), GitHub Actions, Cloudflare Workers
271
+ `;
272
+
273
+ const CONVENTIONS_TEMPLATE = `<!--
274
+ context/conventions.md — house rules and patterns for this codebase.
275
+
276
+ Loaded by nova-spec at the start of every ticket so the agent writes code
277
+ that matches your team's style without you having to repeat yourself. List
278
+ things that are NOT obvious from reading the code.
279
+
280
+ Edit freely. One line per rule is fine.
281
+ -->
282
+
283
+ # Conventions
284
+
285
+ ## Code style
286
+ - e.g. 2-space indent, single quotes, trailing commas
287
+ - e.g. functional components only; no class components
288
+ - e.g. no default exports
289
+
290
+ ## Patterns we follow
291
+ - e.g. service layer for all DB access; controllers stay thin
292
+ - e.g. errors as values, not thrown (Result/Either)
293
+ - e.g. one component per file
294
+
295
+ ## Patterns we avoid
296
+ - e.g. no global mutable state
297
+ - e.g. no \`any\` in TypeScript
298
+ - e.g. no inline styles
299
+
300
+ ## Tests
301
+ - e.g. characterization tests before refactoring
302
+ - e.g. one assertion per test
303
+ - e.g. fixtures in \`__fixtures__/\`, not inline
304
+ `;
305
+
306
+ function createSymlinks(linkDir, novaspecDir) {
307
+ fs.mkdirSync(linkDir, { recursive: true });
151
308
  for (const name of ['commands', 'skills', 'agents']) {
152
- const link = path.join(dir, name);
153
- const target = path.join('..', 'novaspec', name);
309
+ const link = path.join(linkDir, name);
310
+ const target = path.relative(linkDir, path.join(novaspecDir, name));
154
311
  fs.rmSync(link, { recursive: true, force: true });
155
- fs.symlinkSync(target, link);
312
+ const symlinkType = process.platform === 'win32' ? 'junction' : null;
313
+ try {
314
+ if (symlinkType) {
315
+ fs.symlinkSync(target, link, symlinkType);
316
+ } else {
317
+ fs.symlinkSync(target, link);
318
+ }
319
+ } catch (err) {
320
+ if (err.code === 'EPERM' && process.platform === 'win32') {
321
+ console.warn(` ⚠ Could not symlink ${name} (Windows needs Developer Mode).`);
322
+ console.warn(` Falling back to copy. Re-run sync to refresh.`);
323
+ copyTree(path.join(novaspecDir, name), link);
324
+ } else {
325
+ throw err;
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ function copyTree(src, dest, { exclude = [] } = {}) {
332
+ fs.mkdirSync(dest, { recursive: true });
333
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
334
+ if (exclude.includes(entry.name)) continue;
335
+ if (entry.isSymbolicLink()) continue;
336
+ if (entry.name === 'node_modules') continue;
337
+ const srcPath = path.join(src, entry.name);
338
+ const destPath = path.join(dest, entry.name);
339
+ if (entry.isDirectory()) {
340
+ copyTree(srcPath, destPath, { exclude });
341
+ } else {
342
+ fs.copyFileSync(srcPath, destPath);
343
+ }
156
344
  }
157
345
  }
158
346
 
159
347
  function writeClaudeSettings(claudeDir) {
160
348
  const settingsPath = path.join(claudeDir, 'settings.local.json');
161
- const hook = {
162
- hooks: {
163
- SessionStart: [
164
- {
165
- hooks: [
166
- {
167
- type: 'command',
168
- command: 'npx nova-spec@latest sync 2>/dev/null || true',
169
- timeout: 30,
170
- },
171
- ],
172
- },
173
- ],
174
- },
175
- };
349
+ const novaHook = { type: 'command', command: buildHookCommand(), timeout: 30 };
176
350
 
351
+ let settings = {};
177
352
  if (fs.existsSync(settingsPath)) {
178
- const existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
179
- if (existing.hooks?.SessionStart) return; // already configured
180
- existing.hooks = existing.hooks || {};
181
- existing.hooks.SessionStart = hook.hooks.SessionStart;
182
- fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n');
183
- } else {
184
- fs.writeFileSync(settingsPath, JSON.stringify(hook, null, 2) + '\n');
353
+ try {
354
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
355
+ } catch (err) {
356
+ console.warn(` ⚠ Could not parse ${settingsPath}: ${err.message}`);
357
+ console.warn(' Skipping hook setup. Fix the JSON and run /nova-sync.');
358
+ return;
359
+ }
185
360
  }
361
+
362
+ settings.hooks = settings.hooks || {};
363
+ settings.hooks.SessionStart = settings.hooks.SessionStart || [];
364
+
365
+ const alreadyHasNovaHook = settings.hooks.SessionStart.some((g) =>
366
+ (g.hooks || []).some((h) => h?.command?.includes(HOOK_MARKER)),
367
+ );
368
+
369
+ if (!alreadyHasNovaHook) {
370
+ settings.hooks.SessionStart.push({ hooks: [novaHook] });
371
+ }
372
+
373
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
186
374
  }
187
375
 
188
376
  function writeOpenCodeSettings(opencodeDir) {
189
377
  const settingsPath = path.join(opencodeDir, 'settings.local.json');
190
- if (!fs.existsSync(settingsPath)) {
191
- fs.writeFileSync(settingsPath, JSON.stringify({
192
- $schema: 'https://opencode.ai/config.json',
193
- permission: { skill: { '*': 'allow' } },
194
- }, null, 2) + '\n');
195
- }
378
+ if (fs.existsSync(settingsPath)) return;
379
+ fs.writeFileSync(
380
+ settingsPath,
381
+ JSON.stringify(
382
+ {
383
+ $schema: 'https://opencode.ai/config.json',
384
+ permission: { skill: { '*': 'allow' } },
385
+ },
386
+ null,
387
+ 2,
388
+ ) + '\n',
389
+ );
196
390
  }
197
391
 
198
- function writeConfig(destDir, { jiraConfig, baseBranch }) {
392
+ function writeConfig(destDir, { ticketSystem, jiraConfig, baseBranch, forgeType }) {
199
393
  const configPath = path.join(destDir, 'novaspec', 'config.yml');
200
- if (fs.existsSync(configPath)) return; // already restored from backup
394
+ if (fs.existsSync(configPath)) return;
201
395
 
396
+ const yamlString = (s) => JSON.stringify(s ?? '');
202
397
  const content = [
203
398
  '# nova-spec — project configuration',
204
399
  '# This file is gitignored — do not push it to the repo.',
@@ -214,15 +409,23 @@ function writeConfig(destDir, { jiraConfig, baseBranch }) {
214
409
  ' chore: chore',
215
410
  ' architecture: arch',
216
411
  ' ticket_case: upper',
217
- ` base: ${baseBranch}`,
412
+ ` base: ${yamlString(baseBranch)}`,
413
+ '',
414
+ 'forge:',
415
+ ` type: ${forgeType || 'auto'}`,
416
+ ' cli: auto',
417
+ '',
418
+ `ticket_system: ${ticketSystem || 'jira'}`,
218
419
  '',
219
420
  'jira:',
220
- ` skill: "${jiraConfig.skill}"`,
221
- ` url: ${jiraConfig.url}`,
222
- ` project: ${jiraConfig.project}`,
223
- ` email: ${jiraConfig.email}`,
224
- ` token: ${jiraConfig.token}`,
225
- ` done_transition_id: "${jiraConfig.done_transition_id}"`,
421
+ ` skill: ${yamlString(jiraConfig.skill)}`,
422
+ ` url: ${yamlString(jiraConfig.url)}`,
423
+ ` project: ${yamlString(jiraConfig.project)}`,
424
+ ` email: ${yamlString(jiraConfig.email)}`,
425
+ ` token: ${yamlString(jiraConfig.token)}`,
426
+ ` done_transition_id: ${yamlString(jiraConfig.done_transition_id)}`,
427
+ ' transitions:',
428
+ ` done: ${yamlString(jiraConfig.done_transition_id)}`,
226
429
  ].join('\n') + '\n';
227
430
 
228
431
  fs.writeFileSync(configPath, content);
@@ -237,11 +440,13 @@ function ensureGitignore(destDir) {
237
440
  if (content.includes(marker)) return;
238
441
  }
239
442
 
240
- const block = [
443
+ // Project-install gitignore: only the personal/secret bits. Templates,
444
+ // commands, skills, agents are committed so the team shares customizations.
445
+ const lines = [
241
446
  '',
242
- '# nova-spec (local)',
447
+ marker,
243
448
  'novaspec/config.yml',
244
- 'novaspec/custom/',
449
+ `novaspec/${MANIFEST_FILE}`,
245
450
  '.env',
246
451
  'notes.md',
247
452
  '.claude/settings.local.json',
@@ -250,23 +455,9 @@ function ensureGitignore(destDir) {
250
455
  '.DS_Store',
251
456
  '# /nova-spec',
252
457
  '',
253
- ].join('\n');
458
+ ];
254
459
 
255
- fs.appendFileSync(gitignorePath, block);
256
- }
257
-
258
- function copyDir(src, dest) {
259
- fs.mkdirSync(dest, { recursive: true });
260
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
261
- const srcPath = path.join(src, entry.name);
262
- const destPath = path.join(dest, entry.name);
263
- if (entry.name === 'config.yml') continue; // never overwrite user config
264
- if (entry.isDirectory()) {
265
- copyDir(srcPath, destPath);
266
- } else {
267
- fs.copyFileSync(srcPath, destPath);
268
- }
269
- }
460
+ fs.appendFileSync(gitignorePath, lines.join('\n'));
270
461
  }
271
462
 
272
463
  module.exports = { init };