nubos-pilot 0.5.9 → 0.6.1

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/bin/install.js CHANGED
@@ -683,6 +683,10 @@ async function main() {
683
683
  const doctor = require('./np-tools/doctor.cjs');
684
684
  return await doctor.run(rest.slice(1), { cwd, stdout: process.stdout });
685
685
  }
686
+ case 'install-hooks':
687
+ return await runInstallHooks({ cwd, args: rest.slice(1) });
688
+ case 'uninstall-hooks':
689
+ return await runUninstallHooks({ cwd, args: rest.slice(1) });
686
690
  default:
687
691
  process.stderr.write(
688
692
  red + 'Unbekanntes Subcommand: ' + sub + reset + '\n',
@@ -692,6 +696,70 @@ async function main() {
692
696
  }
693
697
  }
694
698
 
699
+ function _parseHookFlags(args) {
700
+ const flags = { scope: null, which: 'both', force: false, dryRun: false };
701
+ for (let i = 0; i < args.length; i++) {
702
+ const a = args[i];
703
+ if (a === '--scope' || a === '-s') { flags.scope = args[++i] || null; continue; }
704
+ if (a.startsWith('--scope=')) { flags.scope = a.slice('--scope='.length); continue; }
705
+ if (a === '--statusline-only') { flags.which = 'statusline'; continue; }
706
+ if (a === '--ctx-monitor-only') { flags.which = 'ctx-monitor'; continue; }
707
+ if (a === '--force' || a === '-f') { flags.force = true; continue; }
708
+ if (a === '--dry-run') { flags.dryRun = true; continue; }
709
+ }
710
+ if (flags.scope && !VALID_SCOPES.includes(flags.scope)) {
711
+ throw new NubosPilotError('invalid-flag',
712
+ '--scope must be one of: ' + VALID_SCOPES.join(', '),
713
+ { flag: '--scope', got: flags.scope });
714
+ }
715
+ return flags;
716
+ }
717
+
718
+ async function runInstallHooks(opts) {
719
+ const o = opts || {};
720
+ const projectRoot = o.projectRoot || o.cwd || process.cwd();
721
+ const flags = _parseHookFlags(o.args || []);
722
+ const scope = flags.scope || _readExistingScope(projectRoot) || 'local';
723
+ const claudeHooks = require('../lib/install/claude-hooks.cjs');
724
+ const res = claudeHooks.installClaudeHooks({
725
+ projectRoot, scope, which: flags.which, force: flags.force, dryRun: flags.dryRun,
726
+ });
727
+ if (res.dryRun) {
728
+ process.stdout.write(JSON.stringify({ dryRun: true, path: res.path, results: res.results }, null, 2) + '\n');
729
+ return res;
730
+ }
731
+ console.error(green + '✓ Claude Code hooks geschrieben → ' + res.path + reset);
732
+ if (res.results.statusline) {
733
+ console.error(dim + ' statusline: ' + res.results.statusline.action
734
+ + (res.results.statusline.existingCommand ? ' (existing: ' + res.results.statusline.existingCommand + ')' : '')
735
+ + reset);
736
+ }
737
+ if (res.results.ctxMonitor) {
738
+ console.error(dim + ' ctx-monitor: ' + res.results.ctxMonitor.action + reset);
739
+ }
740
+ if (res.results.statusline && res.results.statusline.action === 'skipped-existing') {
741
+ console.error(yellow + ' [statusline] existing non-nubos statusLine preserved. Pass --force to overwrite.' + reset);
742
+ }
743
+ return res;
744
+ }
745
+
746
+ async function runUninstallHooks(opts) {
747
+ const o = opts || {};
748
+ const projectRoot = o.projectRoot || o.cwd || process.cwd();
749
+ const flags = _parseHookFlags(o.args || []);
750
+ const scope = flags.scope || _readExistingScope(projectRoot) || 'local';
751
+ const claudeHooks = require('../lib/install/claude-hooks.cjs');
752
+ const res = claudeHooks.uninstallClaudeHooks({ projectRoot, scope, dryRun: flags.dryRun });
753
+ if (res.dryRun) {
754
+ process.stdout.write(JSON.stringify({ dryRun: true, path: res.path, results: res.results }, null, 2) + '\n');
755
+ return res;
756
+ }
757
+ console.error(green + '✓ Claude Code hooks entfernt ← ' + res.path + reset);
758
+ console.error(dim + ' statusline: ' + res.results.statusline.action + reset);
759
+ console.error(dim + ' ctx-monitor: ' + res.results.ctxMonitor.action + reset);
760
+ return res;
761
+ }
762
+
695
763
  if (require.main === module) {
696
764
  main().catch((err) => {
697
765
  if (err && err.code) {
@@ -9,6 +9,7 @@ const COMMANDS = [
9
9
  { name: 'plan-milestone', category: 'Planning', description: 'Plan a milestone: scaffolds slices + tasks' },
10
10
  { name: 'new-project', category: 'Planning', description: 'Greenfield project init (PROJECT.md + REQUIREMENTS.md + M001 milestone)' },
11
11
  { name: 'new-milestone', category: 'Planning', description: 'Append a new milestone (M<NNN>) to an existing project' },
12
+ { name: 'propose-milestones', category: 'Planning', description: 'Re-plan all not-yet-done milestones: AI proposes add/update/remove from PROJECT.md + REQUIREMENTS.md' },
12
13
  { name: 'agent-skills', category: 'Planning', description: 'Print agent_skills config for a given subagent' },
13
14
 
14
15
  { name: 'execute-milestone', category: 'Execution', description: 'Wave-based milestone execution — slice by slice, tasks parallel within a slice' },
@@ -83,7 +83,7 @@ test('DP-1: run(["3"]) on valid milestone returns JSON payload with expected sha
83
83
  }
84
84
  });
85
85
 
86
- test('DP-1b: CLAUDECODE=1 sets text_mode=true with runtime source', () => {
86
+ test('DP-1b: CLAUDECODE=1 no longer flips text_mode (Claude Code uses AskUserQuestion)', () => {
87
87
  const restore = _clearClaudeEnv();
88
88
  try {
89
89
  process.env.CLAUDECODE = '1';
@@ -92,8 +92,8 @@ test('DP-1b: CLAUDECODE=1 sets text_mode=true with runtime source', () => {
92
92
  const cap = _captureStdout();
93
93
  subcmd.run(['3'], { cwd: sandbox, stdout: cap.stub });
94
94
  const payload = JSON.parse(cap.get().trim());
95
- assert.equal(payload.text_mode, true);
96
- assert.equal(payload.text_mode_source, 'runtime');
95
+ assert.equal(payload.text_mode, false);
96
+ assert.equal(payload.text_mode_source, 'default');
97
97
  } finally {
98
98
  restore();
99
99
  }
@@ -0,0 +1,461 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const YAML = require('yaml');
6
+
7
+ const {
8
+ NubosPilotError,
9
+ atomicWriteFileSync,
10
+ withFileLock,
11
+ } = require('../../lib/core.cjs');
12
+ const layout = require('../../lib/layout.cjs');
13
+ const { readState } = require('../../lib/state.cjs');
14
+ const textMode = require('../../lib/text-mode.cjs');
15
+
16
+ const TBD_RE = /<!--\s*TBD[^>]*-->/gi;
17
+ const DONE_STATUSES = new Set(['done', 'complete', 'completed']);
18
+
19
+ function _emit(stdout, payload) {
20
+ stdout.write(JSON.stringify(payload, null, 2));
21
+ }
22
+
23
+ function _guardInitialized(root) {
24
+ const projectMd = path.join(root, '.nubos-pilot', 'PROJECT.md');
25
+ if (!fs.existsSync(projectMd)) {
26
+ throw new NubosPilotError(
27
+ 'project-not-initialized',
28
+ 'PROJECT.md not found — run np:new-project first',
29
+ { hint: 'Run np:new-project first', path: projectMd },
30
+ );
31
+ }
32
+ }
33
+
34
+ function _readRoadmap(root) {
35
+ const p = path.join(root, '.nubos-pilot', 'roadmap.yaml');
36
+ if (!fs.existsSync(p)) {
37
+ throw new NubosPilotError(
38
+ 'roadmap-missing',
39
+ 'roadmap.yaml not found',
40
+ { path: p },
41
+ );
42
+ }
43
+ const raw = fs.readFileSync(p, 'utf-8');
44
+ let doc;
45
+ try { doc = YAML.parse(raw); } catch (err) {
46
+ throw new NubosPilotError(
47
+ 'roadmap-parse-error',
48
+ 'roadmap.yaml invalid YAML',
49
+ { path: p, cause: err && err.message },
50
+ );
51
+ }
52
+ if (!doc || !Array.isArray(doc.milestones)) {
53
+ throw new NubosPilotError(
54
+ 'roadmap-parse-error',
55
+ 'roadmap.yaml missing milestones array',
56
+ { path: p },
57
+ );
58
+ }
59
+ return { doc, path: p };
60
+ }
61
+
62
+ function _classifyMilestone(m, root, stateMilestoneId) {
63
+ if (!m || m.id === 'backlog') return null;
64
+ const status = typeof m.status === 'string' ? m.status : 'pending';
65
+ const isDone = DONE_STATUSES.has(status);
66
+ const slices = Array.isArray(m.slices) ? m.slices : [];
67
+ const hasSlices = slices.length > 0;
68
+
69
+ const mNumMatch = typeof m.id === 'string' ? m.id.match(/^M(\d+)$/) : null;
70
+ const mNum = mNumMatch ? Number(mNumMatch[1]) : (typeof m.number === 'number' ? m.number : null);
71
+
72
+ let contextSummary = null;
73
+ let contextHasContent = false;
74
+ if (mNum != null) {
75
+ const ctxPath = layout.milestoneContextPath(mNum, root);
76
+ if (fs.existsSync(ctxPath)) {
77
+ const raw = fs.readFileSync(ctxPath, 'utf-8');
78
+ const tbdSections = (raw.match(TBD_RE) || []).length;
79
+ const contentSections = (raw.match(/^<[a-z_]+>$/gm) || []).length;
80
+ contextHasContent = contentSections > 0 && tbdSections === 0;
81
+ contextSummary = {
82
+ path: ctxPath,
83
+ byte_size: raw.length,
84
+ tbd_sections: tbdSections,
85
+ content_sections: contentSections,
86
+ has_content: contextHasContent,
87
+ };
88
+ }
89
+ }
90
+
91
+ const isActive = stateMilestoneId && m.id === stateMilestoneId;
92
+
93
+ let classification;
94
+ if (isDone) classification = 'completed';
95
+ else if (hasSlices || isActive) classification = 'active';
96
+ else if (contextHasContent) classification = 'discussed';
97
+ else classification = 'empty';
98
+
99
+ return {
100
+ id: m.id,
101
+ number: mNum,
102
+ name: m.name || '',
103
+ goal: typeof m.goal === 'string' ? m.goal : '',
104
+ status,
105
+ classification,
106
+ slice_count: slices.length,
107
+ context: contextSummary,
108
+ touchable: classification === 'empty',
109
+ modification_requires_confirm: classification === 'active' || classification === 'discussed',
110
+ };
111
+ }
112
+
113
+ function _nextMilestoneNumber(doc) {
114
+ let maxNum = 0;
115
+ for (const m of doc.milestones || []) {
116
+ if (!m) continue;
117
+ if (m.id === 'backlog') continue;
118
+ if (typeof m.number === 'number' && Number.isInteger(m.number) && m.number > maxNum) {
119
+ maxNum = m.number;
120
+ }
121
+ if (typeof m.id === 'string') {
122
+ const mm = m.id.match(/^M(\d+)$/);
123
+ if (mm) {
124
+ const n = Number(mm[1]);
125
+ if (Number.isInteger(n) && n > maxNum) maxNum = n;
126
+ }
127
+ }
128
+ }
129
+ return maxNum + 1;
130
+ }
131
+
132
+ function _interviewPayload(cwd) {
133
+ const root = path.resolve(cwd);
134
+ _guardInitialized(root);
135
+ const { doc } = _readRoadmap(root);
136
+
137
+ let stateMilestoneId = null;
138
+ try {
139
+ const st = readState(root);
140
+ stateMilestoneId = st && st.frontmatter && st.frontmatter.milestone || null;
141
+ } catch {
142
+ stateMilestoneId = null;
143
+ }
144
+
145
+ const classified = [];
146
+ for (const m of doc.milestones) {
147
+ const row = _classifyMilestone(m, root, stateMilestoneId);
148
+ if (row) classified.push(row);
149
+ }
150
+
151
+ const projectMd = fs.readFileSync(path.join(root, '.nubos-pilot', 'PROJECT.md'), 'utf-8');
152
+ const reqPath = path.join(root, '.nubos-pilot', 'REQUIREMENTS.md');
153
+ const reqMd = fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf-8') : '';
154
+
155
+ const projectHasTbd = /_TBD — filled by \/np:discuss-project\._/.test(projectMd);
156
+
157
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
158
+
159
+ return {
160
+ _workflow: 'propose-milestones',
161
+ mode: 'interview',
162
+ text_mode: tmDetail.enabled,
163
+ text_mode_source: tmDetail.source,
164
+ project_md_path: path.join(root, '.nubos-pilot', 'PROJECT.md'),
165
+ requirements_md_path: reqPath,
166
+ project_md: projectMd,
167
+ requirements_md: reqMd,
168
+ project_has_tbd: projectHasTbd,
169
+ current_state_milestone: stateMilestoneId,
170
+ milestones: classified,
171
+ next_milestone_number: _nextMilestoneNumber(doc),
172
+ guidance: {
173
+ completed: 'Untouchable — never modify or remove; displayed for context only.',
174
+ active: 'Has slices or is the current state pointer — modifications require explicit per-item confirm.',
175
+ discussed: 'Has non-TBD CONTEXT.md content — modifications require explicit per-item confirm.',
176
+ empty: 'No slices, CONTEXT.md still TBD — freely modifiable or removable.',
177
+ },
178
+ };
179
+ }
180
+
181
+ function _validateOperation(op, idx) {
182
+ if (!op || typeof op !== 'object') {
183
+ throw new NubosPilotError(
184
+ 'invalid-operation',
185
+ 'operation ' + idx + ' is not an object',
186
+ { index: idx },
187
+ );
188
+ }
189
+ const type = op.type;
190
+ if (!['add', 'update', 'remove'].includes(type)) {
191
+ throw new NubosPilotError(
192
+ 'invalid-operation-type',
193
+ 'operation ' + idx + ' has unknown type: ' + String(type),
194
+ { index: idx, type },
195
+ );
196
+ }
197
+ if (type === 'add') {
198
+ if (typeof op.milestone_name !== 'string' || op.milestone_name.trim() === '') {
199
+ throw new NubosPilotError('answers-missing-field', 'op[' + idx + '].milestone_name required', { index: idx });
200
+ }
201
+ if (typeof op.milestone_goal !== 'string' || op.milestone_goal.trim() === '') {
202
+ throw new NubosPilotError('answers-missing-field', 'op[' + idx + '].milestone_goal required', { index: idx });
203
+ }
204
+ } else {
205
+ if (typeof op.milestone_id !== 'string' || !/^M\d+$/.test(op.milestone_id)) {
206
+ throw new NubosPilotError('answers-missing-field', 'op[' + idx + '].milestone_id required (format M<NNN>)', { index: idx });
207
+ }
208
+ if (type === 'update') {
209
+ const hasName = typeof op.new_name === 'string' && op.new_name.trim() !== '';
210
+ const hasGoal = typeof op.new_goal === 'string' && op.new_goal.trim() !== '';
211
+ if (!hasName && !hasGoal) {
212
+ throw new NubosPilotError('answers-missing-field', 'op[' + idx + '] update needs new_name or new_goal', { index: idx });
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ function _findMilestone(doc, id) {
219
+ return doc.milestones.find((m) => m && m.id === id);
220
+ }
221
+
222
+ function _assertTouchable(m, opType, confirmForceModify) {
223
+ const status = typeof m.status === 'string' ? m.status : 'pending';
224
+ if (DONE_STATUSES.has(status)) {
225
+ throw new NubosPilotError(
226
+ 'milestone-completed-untouchable',
227
+ 'Milestone ' + m.id + ' is completed (status=' + status + ') — cannot ' + opType,
228
+ { id: m.id, status },
229
+ );
230
+ }
231
+ const slices = Array.isArray(m.slices) ? m.slices : [];
232
+ if (slices.length > 0 && !confirmForceModify) {
233
+ throw new NubosPilotError(
234
+ 'milestone-has-slices',
235
+ 'Milestone ' + m.id + ' has ' + slices.length + ' slice(s); ' + opType + ' requires confirm_force_modify=true',
236
+ { id: m.id, slice_count: slices.length },
237
+ );
238
+ }
239
+ }
240
+
241
+ function _applyAdd(doc, op) {
242
+ const mNum = _nextMilestoneNumber(doc);
243
+ const id = layout.mId(mNum);
244
+ doc.milestones.push({
245
+ id,
246
+ number: mNum,
247
+ name: op.milestone_name,
248
+ goal: op.milestone_goal,
249
+ status: 'pending',
250
+ requirements: [],
251
+ success_criteria: [],
252
+ slices: [],
253
+ });
254
+ return { type: 'add', id, number: mNum, name: op.milestone_name };
255
+ }
256
+
257
+ function _applyUpdate(doc, op) {
258
+ const m = _findMilestone(doc, op.milestone_id);
259
+ if (!m) {
260
+ throw new NubosPilotError('milestone-not-found', 'milestone ' + op.milestone_id + ' not found', { id: op.milestone_id });
261
+ }
262
+ _assertTouchable(m, 'update', op.confirm_force_modify === true);
263
+ const changed = {};
264
+ if (typeof op.new_name === 'string' && op.new_name.trim() !== '') {
265
+ changed.from_name = m.name;
266
+ m.name = op.new_name;
267
+ changed.to_name = m.name;
268
+ }
269
+ if (typeof op.new_goal === 'string' && op.new_goal.trim() !== '') {
270
+ changed.from_goal = m.goal;
271
+ m.goal = op.new_goal;
272
+ changed.to_goal = m.goal;
273
+ }
274
+ return { type: 'update', id: m.id, changed };
275
+ }
276
+
277
+ function _applyRemove(doc, op, root) {
278
+ const idx = doc.milestones.findIndex((m) => m && m.id === op.milestone_id);
279
+ if (idx < 0) {
280
+ throw new NubosPilotError('milestone-not-found', 'milestone ' + op.milestone_id + ' not found', { id: op.milestone_id });
281
+ }
282
+ const m = doc.milestones[idx];
283
+ _assertTouchable(m, 'remove', op.confirm_force_modify === true);
284
+ doc.milestones.splice(idx, 1);
285
+
286
+ let archivedTo = null;
287
+ const mNumMatch = m.id.match(/^M(\d+)$/);
288
+ if (mNumMatch) {
289
+ const mNum = Number(mNumMatch[1]);
290
+ const srcDir = layout.milestoneDir(mNum, root);
291
+ if (fs.existsSync(srcDir)) {
292
+ const archRoot = path.join(root, '.nubos-pilot', 'archive', 'milestones');
293
+ fs.mkdirSync(archRoot, { recursive: true });
294
+ const stamp = new Date().toISOString().slice(0, 10);
295
+ const target = path.join(archRoot, m.id + '-' + stamp);
296
+ fs.renameSync(srcDir, target);
297
+ archivedTo = target;
298
+ }
299
+ }
300
+ return { type: 'remove', id: m.id, archived_to: archivedTo };
301
+ }
302
+
303
+ function _apply(answersPath, cwd, stdout) {
304
+ let raw;
305
+ try { raw = fs.readFileSync(answersPath, 'utf-8'); } catch (err) {
306
+ throw new NubosPilotError(
307
+ 'answers-not-readable',
308
+ 'answers file not readable: ' + answersPath,
309
+ { path: answersPath, cause: err && err.code },
310
+ );
311
+ }
312
+ let answers;
313
+ try { answers = JSON.parse(raw); } catch (err) {
314
+ throw new NubosPilotError(
315
+ 'answers-parse-error',
316
+ 'answers file is not valid JSON',
317
+ { path: answersPath, cause: err && err.message },
318
+ );
319
+ }
320
+ if (!answers || !Array.isArray(answers.operations)) {
321
+ throw new NubosPilotError(
322
+ 'answers-missing-field',
323
+ 'answers.operations must be an array',
324
+ {},
325
+ );
326
+ }
327
+ answers.operations.forEach(_validateOperation);
328
+
329
+ const root = path.resolve(cwd);
330
+ _guardInitialized(root);
331
+
332
+ const roadmapPath = path.join(root, '.nubos-pilot', 'roadmap.yaml');
333
+ const results = withFileLock(roadmapPath, () => {
334
+ const rawYaml = fs.readFileSync(roadmapPath, 'utf-8');
335
+ let doc;
336
+ try { doc = YAML.parse(rawYaml); } catch (err) {
337
+ throw new NubosPilotError('roadmap-parse-error', 'roadmap.yaml invalid YAML', { path: roadmapPath, cause: err && err.message });
338
+ }
339
+ if (!doc || !Array.isArray(doc.milestones)) {
340
+ throw new NubosPilotError('roadmap-parse-error', 'roadmap.yaml missing milestones array', { path: roadmapPath });
341
+ }
342
+
343
+ const out = [];
344
+ for (const op of answers.operations) {
345
+ if (op.type === 'add') out.push(_applyAdd(doc, op));
346
+ else if (op.type === 'update') out.push(_applyUpdate(doc, op));
347
+ else if (op.type === 'remove') out.push(_applyRemove(doc, op, root));
348
+ }
349
+
350
+ atomicWriteFileSync(roadmapPath, YAML.stringify(doc, { indent: 2 }));
351
+
352
+ for (const result of out) {
353
+ if (result.type === 'add') {
354
+ _writeMilestoneArtefacts(root, result.number, result.name, doc);
355
+ }
356
+ }
357
+
358
+ return out;
359
+ });
360
+
361
+ _emit(stdout, {
362
+ mode: 'apply',
363
+ results,
364
+ });
365
+ }
366
+
367
+ function _writeMilestoneArtefacts(root, mNum, name, doc) {
368
+ const { _render, _loadTemplate } = _lazyRenderer();
369
+ const m = doc.milestones.find((x) => x && x.id === layout.mId(mNum));
370
+ const goal = m && m.goal || '';
371
+ layout.createMilestoneDir(mNum, root);
372
+ const mIdStr = layout.mId(mNum);
373
+ const createdDate = new Date().toISOString().slice(0, 10);
374
+ const ctxVars = {
375
+ milestone_id: mIdStr,
376
+ milestone_name: name,
377
+ created_date: createdDate,
378
+ goal_text: goal,
379
+ decisions_text: '<!-- TBD: locked decisions from /np:discuss-phase -->',
380
+ deferred_text: '<!-- TBD: deferred ideas -->',
381
+ domain_text: '<!-- TBD: domain boundary -->',
382
+ canonical_refs_text: '<!-- TBD: canonical references -->',
383
+ };
384
+ const roadmapVars = {
385
+ milestone_id: mIdStr,
386
+ milestone_name: name,
387
+ created_date: createdDate,
388
+ slices_text: '<!-- TBD: slices will be appended by /np:plan-phase ' + mNum + ' -->',
389
+ };
390
+ const metaVars = {
391
+ milestone_id: mIdStr,
392
+ milestone_name: JSON.stringify(name).slice(1, -1),
393
+ status: 'pending',
394
+ created_date: createdDate,
395
+ goal_text_escaped: JSON.stringify(goal).slice(1, -1),
396
+ requirements_json: '[]',
397
+ success_criteria_json: '[]',
398
+ slice_count: 0,
399
+ task_count: 0,
400
+ };
401
+ _writeFile(layout.milestoneContextPath(mNum, root), _render(_loadTemplate('CONTEXT.md'), ctxVars, 'milestone/CONTEXT.md'));
402
+ _writeFile(layout.milestoneRoadmapPath(mNum, root), _render(_loadTemplate('ROADMAP.md'), roadmapVars, 'milestone/ROADMAP.md'));
403
+ _writeFile(layout.milestoneMetaPath(mNum, root), _render(_loadTemplate('META.json'), metaVars, 'milestone/META.json'));
404
+ }
405
+
406
+ function _writeFile(target, content) {
407
+ if (path.basename(target) === 'PROJECT.md') {
408
+ throw new NubosPilotError(
409
+ 'propose-milestones-forbidden-write',
410
+ 'propose-milestones is never allowed to write PROJECT.md (D-29)',
411
+ { path: target },
412
+ );
413
+ }
414
+ atomicWriteFileSync(target, content);
415
+ }
416
+
417
+ function _lazyRenderer() {
418
+ const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates', 'milestone');
419
+ const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
420
+ function _render(raw, vars, templateName) {
421
+ return raw.replace(PLACEHOLDER_RE, (_match, key) => {
422
+ if (!(key in vars)) {
423
+ throw new NubosPilotError(
424
+ 'template-unresolved-var',
425
+ 'Undefined placeholder {{' + key + '}} in template "' + templateName + '"',
426
+ { template: templateName, variable: key, available: Object.keys(vars) },
427
+ );
428
+ }
429
+ return String(vars[key]);
430
+ });
431
+ }
432
+ function _loadTemplate(name) {
433
+ return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf-8');
434
+ }
435
+ return { _render, _loadTemplate };
436
+ }
437
+
438
+ function run(args, ctx) {
439
+ const context = ctx || {};
440
+ const cwd = context.cwd || process.cwd();
441
+ const stdout = context.stdout || process.stdout;
442
+ const argv = args || [];
443
+
444
+ const applyIdx = argv.indexOf('--apply');
445
+ if (applyIdx >= 0) {
446
+ const answersPath = argv[applyIdx + 1];
447
+ if (!answersPath) {
448
+ throw new NubosPilotError(
449
+ 'missing-apply-path',
450
+ '--apply requires a path to the answers JSON file',
451
+ { args: argv.slice() },
452
+ );
453
+ }
454
+ _apply(answersPath, cwd, stdout);
455
+ return;
456
+ }
457
+
458
+ _emit(stdout, _interviewPayload(cwd));
459
+ }
460
+
461
+ module.exports = { run, _interviewPayload };