nova-spec 1.0.1 → 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/AGENTS.md +13 -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 +61 -0
- package/lib/installer.js +330 -108
- package/lib/jira.js +138 -0
- package/lib/migrate-config.js +59 -0
- package/lib/sync.js +201 -100
- package/novaspec/agents/context-loader.md +1 -1
- package/novaspec/commands/nova-sync.md +2 -2
- 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 +13 -6
- 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,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 {
|
|
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
|
|
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,95 +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 / .opencode settings
|
|
135
204
|
if (runtime === 'claude' || runtime === 'both') {
|
|
136
|
-
|
|
205
|
+
const linkDir = scope === 'global' ? destDir : path.join(destDir, '.claude');
|
|
206
|
+
createSymlinks(linkDir, destNovaspec);
|
|
207
|
+
writeClaudeSettings(linkDir);
|
|
137
208
|
}
|
|
138
209
|
if (runtime === 'opencode' || runtime === 'both') {
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
217
|
}
|
|
142
218
|
|
|
143
|
-
// .gitignore
|
|
144
219
|
ensureGitignore(destDir);
|
|
145
220
|
}
|
|
146
221
|
|
|
147
|
-
function
|
|
148
|
-
const dir
|
|
149
|
-
|
|
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 });
|
|
150
308
|
for (const name of ['commands', 'skills', 'agents']) {
|
|
151
|
-
const link = path.join(
|
|
152
|
-
const target = path.
|
|
309
|
+
const link = path.join(linkDir, name);
|
|
310
|
+
const target = path.relative(linkDir, path.join(novaspecDir, name));
|
|
153
311
|
fs.rmSync(link, { recursive: true, force: true });
|
|
154
|
-
|
|
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
|
+
}
|
|
155
328
|
}
|
|
156
329
|
}
|
|
157
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
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function writeClaudeSettings(claudeDir) {
|
|
348
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
349
|
+
const novaHook = { type: 'command', command: buildHookCommand(), timeout: 30 };
|
|
350
|
+
|
|
351
|
+
let settings = {};
|
|
352
|
+
if (fs.existsSync(settingsPath)) {
|
|
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
|
+
}
|
|
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');
|
|
374
|
+
}
|
|
375
|
+
|
|
158
376
|
function writeOpenCodeSettings(opencodeDir) {
|
|
159
377
|
const settingsPath = path.join(opencodeDir, 'settings.local.json');
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
);
|
|
166
390
|
}
|
|
167
391
|
|
|
168
|
-
function writeConfig(destDir, { jiraConfig, baseBranch }) {
|
|
392
|
+
function writeConfig(destDir, { ticketSystem, jiraConfig, baseBranch, forgeType }) {
|
|
169
393
|
const configPath = path.join(destDir, 'novaspec', 'config.yml');
|
|
170
|
-
if (fs.existsSync(configPath)) return;
|
|
394
|
+
if (fs.existsSync(configPath)) return;
|
|
171
395
|
|
|
396
|
+
const yamlString = (s) => JSON.stringify(s ?? '');
|
|
172
397
|
const content = [
|
|
173
398
|
'# nova-spec — project configuration',
|
|
174
399
|
'# This file is gitignored — do not push it to the repo.',
|
|
@@ -184,15 +409,23 @@ function writeConfig(destDir, { jiraConfig, baseBranch }) {
|
|
|
184
409
|
' chore: chore',
|
|
185
410
|
' architecture: arch',
|
|
186
411
|
' ticket_case: upper',
|
|
187
|
-
` base: ${baseBranch}`,
|
|
412
|
+
` base: ${yamlString(baseBranch)}`,
|
|
413
|
+
'',
|
|
414
|
+
'forge:',
|
|
415
|
+
` type: ${forgeType || 'auto'}`,
|
|
416
|
+
' cli: auto',
|
|
417
|
+
'',
|
|
418
|
+
`ticket_system: ${ticketSystem || 'jira'}`,
|
|
188
419
|
'',
|
|
189
420
|
'jira:',
|
|
190
|
-
` skill:
|
|
191
|
-
` url: ${jiraConfig.url}`,
|
|
192
|
-
` project: ${jiraConfig.project}`,
|
|
193
|
-
` email: ${jiraConfig.email}`,
|
|
194
|
-
` token: ${jiraConfig.token}`,
|
|
195
|
-
` 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)}`,
|
|
196
429
|
].join('\n') + '\n';
|
|
197
430
|
|
|
198
431
|
fs.writeFileSync(configPath, content);
|
|
@@ -207,35 +440,24 @@ function ensureGitignore(destDir) {
|
|
|
207
440
|
if (content.includes(marker)) return;
|
|
208
441
|
}
|
|
209
442
|
|
|
210
|
-
|
|
443
|
+
// Project-install gitignore: only the personal/secret bits. Templates,
|
|
444
|
+
// commands, skills, agents are committed so the team shares customizations.
|
|
445
|
+
const lines = [
|
|
211
446
|
'',
|
|
212
|
-
|
|
447
|
+
marker,
|
|
213
448
|
'novaspec/config.yml',
|
|
214
|
-
|
|
449
|
+
`novaspec/${MANIFEST_FILE}`,
|
|
215
450
|
'.env',
|
|
216
451
|
'notes.md',
|
|
452
|
+
'.claude/settings.local.json',
|
|
217
453
|
'.opencode/settings.local.json',
|
|
218
454
|
'.opencode/node_modules/',
|
|
219
455
|
'.DS_Store',
|
|
220
456
|
'# /nova-spec',
|
|
221
457
|
'',
|
|
222
|
-
]
|
|
458
|
+
];
|
|
223
459
|
|
|
224
|
-
fs.appendFileSync(gitignorePath,
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function copyDir(src, dest) {
|
|
228
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
229
|
-
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
230
|
-
const srcPath = path.join(src, entry.name);
|
|
231
|
-
const destPath = path.join(dest, entry.name);
|
|
232
|
-
if (entry.name === 'config.yml') continue; // never overwrite user config
|
|
233
|
-
if (entry.isDirectory()) {
|
|
234
|
-
copyDir(srcPath, destPath);
|
|
235
|
-
} else {
|
|
236
|
-
fs.copyFileSync(srcPath, destPath);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
460
|
+
fs.appendFileSync(gitignorePath, lines.join('\n'));
|
|
239
461
|
}
|
|
240
462
|
|
|
241
463
|
module.exports = { init };
|
package/lib/jira.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
function runCli() {
|
|
83
|
+
// CLI mode: node lib/jira.js <command> [args...]
|
|
84
|
+
const [, , cmd, ...args] = process.argv;
|
|
85
|
+
const { JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env;
|
|
86
|
+
|
|
87
|
+
if (!JIRA_URL || !JIRA_EMAIL || !JIRA_API_TOKEN) {
|
|
88
|
+
console.error('Missing env vars: JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN');
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const baseArgs = { url: JIRA_URL, email: JIRA_EMAIL, token: JIRA_API_TOKEN };
|
|
93
|
+
let promise;
|
|
94
|
+
|
|
95
|
+
switch (cmd) {
|
|
96
|
+
case 'get':
|
|
97
|
+
if (!args[0]) {
|
|
98
|
+
console.error('Usage: nova-jira get <TICKET>');
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
|
101
|
+
promise = getIssueAsync({ ...baseArgs, ticket: args[0] });
|
|
102
|
+
break;
|
|
103
|
+
case 'transitions':
|
|
104
|
+
if (!args[0]) {
|
|
105
|
+
console.error('Usage: nova-jira transitions <TICKET>');
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
promise = listTransitionsAsync({ ...baseArgs, ticket: args[0] });
|
|
109
|
+
break;
|
|
110
|
+
case 'transition':
|
|
111
|
+
if (!args[0] || !args[1]) {
|
|
112
|
+
console.error('Usage: nova-jira transition <TICKET> <TRANSITION_ID>');
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
promise = transitionAsync({ ...baseArgs, ticket: args[0], transitionId: args[1] });
|
|
116
|
+
break;
|
|
117
|
+
default:
|
|
118
|
+
console.error('Commands: get <TICKET> | transitions <TICKET> | transition <TICKET> <ID>');
|
|
119
|
+
process.exit(2);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
promise
|
|
123
|
+
.then((r) => {
|
|
124
|
+
if (r != null) console.log(JSON.stringify(r, null, 2));
|
|
125
|
+
})
|
|
126
|
+
.catch((err) => {
|
|
127
|
+
console.error(err.message);
|
|
128
|
+
process.exit(err.status === 401 ? 401 : err.status === 404 ? 404 : 1);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (require.main === module) runCli();
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
getIssueAsync,
|
|
136
|
+
listTransitionsAsync,
|
|
137
|
+
transitionAsync,
|
|
138
|
+
};
|