nova-spec 1.0.2 → 1.0.5
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/AGENTS.md +32 -3
- package/CLAUDE.md +3 -0
- package/INSTALL.md +144 -0
- package/PHILOSOPHY.md +149 -0
- package/README.md +27 -12
- package/lib/cli.js +153 -1
- package/lib/forge.js +63 -0
- package/lib/installer.js +322 -131
- package/lib/jira.js +86 -0
- package/lib/migrate-config.js +59 -0
- package/lib/sync.js +180 -118
- package/novaspec/agents/context-loader.md +1 -1
- package/novaspec/guardrails/nova-installed.sh +21 -0
- package/novaspec/guardrails/proposal-closed.sh +49 -0
- package/novaspec/guardrails/review-checks.sh +115 -0
- package/novaspec/templates/ticket-summary.md +14 -0
- package/package.json +12 -4
- package/novaspec/.nova-manifest.json +0 -30
- package/novaspec/config.yml +0 -23
- package/novaspec/custom/agents/.gitkeep +0 -0
- package/novaspec/custom/commands/.gitkeep +0 -0
- package/novaspec/custom/skills/.gitkeep +0 -0
package/lib/installer.js
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { select, input, confirm } = require('@inquirer/prompts');
|
|
6
|
-
const {
|
|
6
|
+
const {
|
|
7
|
+
generateManifest,
|
|
8
|
+
buildHookCommand,
|
|
9
|
+
HOOK_MARKER,
|
|
10
|
+
MANIFEST_FILE,
|
|
11
|
+
} = require('./sync.js');
|
|
12
|
+
const { detectForge } = require('./forge.js');
|
|
13
|
+
const { listTransitionsAsync } = require('./jira.js');
|
|
7
14
|
|
|
8
15
|
const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
9
16
|
const NOVASPEC_SRC = path.join(PACKAGE_ROOT, 'novaspec');
|
|
@@ -13,7 +20,6 @@ const CLAUDE_MD_SRC = path.join(PACKAGE_ROOT, 'CLAUDE.md');
|
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
73
|
-
console.log(` → Runtime:
|
|
74
|
-
console.log(` →
|
|
75
|
-
console.log(` →
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
190
|
+
copyTree(NOVASPEC_SRC, destNovaspec, { exclude: ['config.yml', MANIFEST_FILE] });
|
|
105
191
|
|
|
106
|
-
|
|
107
|
-
if (configBackup) {
|
|
108
|
-
fs.writeFileSync(destNovaspecConfig, configBackup);
|
|
109
|
-
}
|
|
192
|
+
if (configBackup) fs.writeFileSync(destConfigPath, configBackup);
|
|
110
193
|
|
|
111
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
149
|
-
const dir
|
|
150
|
-
|
|
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(
|
|
153
|
-
const target = path.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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;
|
|
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:
|
|
221
|
-
` url: ${jiraConfig.url}`,
|
|
222
|
-
` project: ${jiraConfig.project}`,
|
|
223
|
-
` email: ${jiraConfig.email}`,
|
|
224
|
-
` token: ${jiraConfig.token}`,
|
|
225
|
-
` 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
|
-
|
|
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
|
-
|
|
447
|
+
marker,
|
|
243
448
|
'novaspec/config.yml',
|
|
244
|
-
|
|
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
|
-
]
|
|
458
|
+
];
|
|
254
459
|
|
|
255
|
-
fs.appendFileSync(gitignorePath,
|
|
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 };
|
package/lib/jira.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const { URL } = require('url');
|
|
5
|
+
|
|
6
|
+
function basicAuth(email, token) {
|
|
7
|
+
return Buffer.from(`${email}:${token}`).toString('base64');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function request({ url, method = 'GET', email, token, body = null }) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = new URL(url);
|
|
15
|
+
} catch (_) {
|
|
16
|
+
return reject(new Error(`Invalid URL: ${url}`));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const options = {
|
|
20
|
+
method,
|
|
21
|
+
hostname: parsed.hostname,
|
|
22
|
+
port: parsed.port || 443,
|
|
23
|
+
path: parsed.pathname + parsed.search,
|
|
24
|
+
headers: {
|
|
25
|
+
Authorization: `Basic ${basicAuth(email, token)}`,
|
|
26
|
+
Accept: 'application/json',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (body) {
|
|
31
|
+
options.headers['Content-Type'] = 'application/json';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const req = https.request(options, (res) => {
|
|
35
|
+
let data = '';
|
|
36
|
+
res.setEncoding('utf8');
|
|
37
|
+
res.on('data', (chunk) => (data += chunk));
|
|
38
|
+
res.on('end', () => {
|
|
39
|
+
const status = res.statusCode || 0;
|
|
40
|
+
if (status >= 400) {
|
|
41
|
+
const error = new Error(`Jira HTTP ${status}: ${data || res.statusMessage}`);
|
|
42
|
+
error.status = status;
|
|
43
|
+
return reject(error);
|
|
44
|
+
}
|
|
45
|
+
if (!data) return resolve(null);
|
|
46
|
+
try {
|
|
47
|
+
resolve(JSON.parse(data));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
reject(new Error(`Invalid JSON from Jira: ${err.message}`));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
req.on('error', (err) => reject(err));
|
|
55
|
+
if (body) req.write(JSON.stringify(body));
|
|
56
|
+
req.end();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getIssueAsync({ url, email, token, ticket }) {
|
|
61
|
+
const endpoint = `${url.replace(/\/$/, '')}/rest/api/3/issue/${encodeURIComponent(ticket)}`;
|
|
62
|
+
return request({ url: endpoint, email, token });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function listTransitionsAsync({ url, email, token, ticket }) {
|
|
66
|
+
const endpoint = `${url.replace(/\/$/, '')}/rest/api/3/issue/${encodeURIComponent(ticket)}/transitions`;
|
|
67
|
+
const response = await request({ url: endpoint, email, token });
|
|
68
|
+
return (response && response.transitions) || [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function transitionAsync({ url, email, token, ticket, transitionId }) {
|
|
72
|
+
const endpoint = `${url.replace(/\/$/, '')}/rest/api/3/issue/${encodeURIComponent(ticket)}/transitions`;
|
|
73
|
+
return request({
|
|
74
|
+
url: endpoint,
|
|
75
|
+
method: 'POST',
|
|
76
|
+
email,
|
|
77
|
+
token,
|
|
78
|
+
body: { transition: { id: String(transitionId) } },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
getIssueAsync,
|
|
84
|
+
listTransitionsAsync,
|
|
85
|
+
transitionAsync,
|
|
86
|
+
};
|