thevoidforge 21.0.0 → 21.0.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/dist/wizard/danger-room.config.json +5 -0
- package/dist/wizard/ui/app.js +1231 -0
- package/dist/wizard/ui/danger-room-prophecy.js +217 -0
- package/dist/wizard/ui/danger-room.html +626 -0
- package/dist/wizard/ui/danger-room.js +880 -0
- package/dist/wizard/ui/deploy.html +177 -0
- package/dist/wizard/ui/deploy.js +582 -0
- package/dist/wizard/ui/favicon.svg +11 -0
- package/dist/wizard/ui/index.html +394 -0
- package/dist/wizard/ui/lobby.html +228 -0
- package/dist/wizard/ui/lobby.js +783 -0
- package/dist/wizard/ui/login.html +110 -0
- package/dist/wizard/ui/login.js +184 -0
- package/dist/wizard/ui/rollback.js +107 -0
- package/dist/wizard/ui/styles.css +1029 -0
- package/dist/wizard/ui/tower.html +171 -0
- package/dist/wizard/ui/tower.js +444 -0
- package/dist/wizard/ui/war-room-prophecy.js +217 -0
- package/dist/wizard/ui/war-room.html +219 -0
- package/dist/wizard/ui/war-room.js +285 -0
- package/package.json +2 -2
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoidForge Wizard — Vanilla JS Step Machine
|
|
3
|
+
* Gandalf — Setup Wizard (Three-Act Flow, v7.1)
|
|
4
|
+
* Act 1: 1=Vault, 2=API Key
|
|
5
|
+
* Act 2: 3=Project, 4=PRD, 4b=Credentials
|
|
6
|
+
* Act 3: 5=Operations Menu, 6=Review, 7=Create/Done
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
(function () {
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const TOTAL_STEPS = 7;
|
|
13
|
+
let currentStep = 1;
|
|
14
|
+
|
|
15
|
+
// State
|
|
16
|
+
const state = {
|
|
17
|
+
anthropicKeyStored: false,
|
|
18
|
+
cloudProviders: {}, // { aws: true, vercel: false, ... }
|
|
19
|
+
projectName: '',
|
|
20
|
+
projectDir: '',
|
|
21
|
+
projectDesc: '',
|
|
22
|
+
projectDomain: '',
|
|
23
|
+
projectHostname: '',
|
|
24
|
+
prdMode: 'generate', // 'generate' | 'paste' | 'skip'
|
|
25
|
+
prdContent: '',
|
|
26
|
+
generatedPrd: '',
|
|
27
|
+
deployTarget: '',
|
|
28
|
+
createdDir: '',
|
|
29
|
+
envGroups: [], // PRD-driven env credential groups
|
|
30
|
+
envCredentials: {}, // { VAR_NAME: 'value', ... }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// DOM refs
|
|
34
|
+
const $ = (sel) => document.querySelector(sel);
|
|
35
|
+
const $$ = (sel) => document.querySelectorAll(sel);
|
|
36
|
+
|
|
37
|
+
const progressBar = $('#progress-bar');
|
|
38
|
+
const stepLabel = $('#step-label');
|
|
39
|
+
const btnBack = $('#btn-back');
|
|
40
|
+
const btnNext = $('#btn-next');
|
|
41
|
+
|
|
42
|
+
// --- Navigation ---
|
|
43
|
+
|
|
44
|
+
function showStep(step) {
|
|
45
|
+
// ENCHANT-R2-012 + CROSS-R4-016: Determine direction for animation
|
|
46
|
+
const goingBack = (typeof step === 'number' && typeof currentStep === 'number' && step < currentStep) ||
|
|
47
|
+
(step === 4 && currentStep === '4b') ||
|
|
48
|
+
(step === '4b' && currentStep === 5);
|
|
49
|
+
$$('.step').forEach((el) => {
|
|
50
|
+
el.classList.add('hidden');
|
|
51
|
+
el.classList.remove('entering-backward');
|
|
52
|
+
});
|
|
53
|
+
const target = $(`#step-${step}`);
|
|
54
|
+
if (target) {
|
|
55
|
+
if (goingBack) target.classList.add('entering-backward');
|
|
56
|
+
target.classList.remove('hidden');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
currentStep = step;
|
|
60
|
+
|
|
61
|
+
// Act-based progress: Act 1 (steps 1-2), Act 2 (steps 3-4b), Act 3 (steps 5-7)
|
|
62
|
+
const hasEnvStep = state.envGroups.length > 0;
|
|
63
|
+
const visibleSteps = hasEnvStep ? [1, 2, 3, 4, '4b', 5, 6, 7] : [1, 2, 3, 4, 5, 6, 7];
|
|
64
|
+
const currentIdx = visibleSteps.indexOf(step);
|
|
65
|
+
const totalVisible = visibleSteps.length;
|
|
66
|
+
const displayNum = currentIdx >= 0 ? currentIdx + 1 : (typeof step === 'number' ? step : 5);
|
|
67
|
+
|
|
68
|
+
const pct = Math.round((displayNum / totalVisible) * 100);
|
|
69
|
+
progressBar.style.width = `${pct}%`;
|
|
70
|
+
progressBar.setAttribute('aria-valuenow', String(pct));
|
|
71
|
+
|
|
72
|
+
// Show act labels instead of step numbers
|
|
73
|
+
let actLabel = 'Act 1 — Secure Your Forge';
|
|
74
|
+
if (step >= 3 && step !== 5 && step !== 6 && step !== 7) actLabel = 'Act 2 — Describe Your Vision';
|
|
75
|
+
if (step === '4b') actLabel = 'Act 2 — Describe Your Vision';
|
|
76
|
+
if (step >= 5) actLabel = 'Act 3 — Equip Your Project';
|
|
77
|
+
if (step === 6) actLabel = 'Review';
|
|
78
|
+
if (step === 7) actLabel = 'Creating';
|
|
79
|
+
stepLabel.textContent = actLabel;
|
|
80
|
+
|
|
81
|
+
btnBack.disabled = step === 1;
|
|
82
|
+
|
|
83
|
+
if (step === 6) {
|
|
84
|
+
btnNext.textContent = 'Create Project';
|
|
85
|
+
} else if (step === 7) {
|
|
86
|
+
btnNext.style.display = 'none';
|
|
87
|
+
btnBack.style.display = 'none';
|
|
88
|
+
} else if (step === '4b') {
|
|
89
|
+
// Step 4b has its own Store/Skip buttons, hide main nav Next
|
|
90
|
+
btnNext.style.display = 'none';
|
|
91
|
+
btnBack.style.display = '';
|
|
92
|
+
} else {
|
|
93
|
+
btnNext.textContent = 'Next';
|
|
94
|
+
btnNext.style.display = '';
|
|
95
|
+
btnBack.style.display = '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Focus first input
|
|
99
|
+
const firstInput = target?.querySelector('input, textarea, select');
|
|
100
|
+
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function syncState() {
|
|
104
|
+
if (currentStep === 3) {
|
|
105
|
+
state.projectName = $('#project-name').value.trim();
|
|
106
|
+
state.projectDir = $('#project-dir').value.trim();
|
|
107
|
+
state.projectDesc = $('#project-desc').value.trim();
|
|
108
|
+
}
|
|
109
|
+
if (currentStep === 5) {
|
|
110
|
+
// Domain/hostname are now in the operations menu
|
|
111
|
+
state.projectDomain = $('#project-domain')?.value.trim() || '';
|
|
112
|
+
state.projectHostname = $('#project-hostname')?.value.trim() || '';
|
|
113
|
+
}
|
|
114
|
+
if (currentStep === 4) {
|
|
115
|
+
if (state.prdMode === 'paste') {
|
|
116
|
+
state.prdContent = $('#prd-paste').value.trim();
|
|
117
|
+
} else if (state.prdMode === 'generate' && state.generatedPrd) {
|
|
118
|
+
state.prdContent = state.generatedPrd;
|
|
119
|
+
} else {
|
|
120
|
+
state.prdContent = '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Deploy target is set directly by card clicks — no sync needed
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function canAdvance() {
|
|
127
|
+
switch (currentStep) {
|
|
128
|
+
case 1: return true; // Vault unlock handled by button, not Next
|
|
129
|
+
case 2: return true; // API key has skip option
|
|
130
|
+
case 3: return state.projectName.trim() !== '' && state.projectDir.trim() !== '';
|
|
131
|
+
case 4: return true; // PRD optional
|
|
132
|
+
case 5: return true; // Operations menu — all optional
|
|
133
|
+
case 6: return true;
|
|
134
|
+
default: return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function nextStep() {
|
|
139
|
+
syncState();
|
|
140
|
+
if (!canAdvance()) {
|
|
141
|
+
showValidationErrors();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
clearValidationErrors();
|
|
145
|
+
|
|
146
|
+
// After PRD step (4), check for env requirements then go to operations menu
|
|
147
|
+
if (currentStep === 4) {
|
|
148
|
+
const prdText = state.prdContent || state.generatedPrd || '';
|
|
149
|
+
if (prdText) {
|
|
150
|
+
const envGroups = await loadEnvRequirements(prdText);
|
|
151
|
+
state.envGroups = envGroups;
|
|
152
|
+
if (envGroups.length > 0) {
|
|
153
|
+
renderEnvCredentials(envGroups);
|
|
154
|
+
showStep('4b');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// No env requirements — go to operations menu
|
|
159
|
+
await loadDeployTargets();
|
|
160
|
+
loadCloudProviders();
|
|
161
|
+
showStep(5);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// After 4b, proceed to operations menu
|
|
166
|
+
if (currentStep === '4b') {
|
|
167
|
+
await loadDeployTargets();
|
|
168
|
+
loadCloudProviders();
|
|
169
|
+
showStep(5);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (currentStep === 6) {
|
|
174
|
+
createProject();
|
|
175
|
+
showStep(7);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Blueprint auto-detection: when moving from Step 3 → Step 4,
|
|
180
|
+
// check if docs/PRD.md already exists in the project directory
|
|
181
|
+
if (currentStep === 3 && state.projectDir) {
|
|
182
|
+
try {
|
|
183
|
+
const detectRes = await fetch('/api/blueprint/detect');
|
|
184
|
+
const detectData = await detectRes.json();
|
|
185
|
+
if (detectData.detected) {
|
|
186
|
+
const blueprintBanner = $('#blueprint-detection');
|
|
187
|
+
if (blueprintBanner) {
|
|
188
|
+
$('#blueprint-project-name').textContent = detectData.name || 'your project';
|
|
189
|
+
blueprintBanner.classList.remove('hidden');
|
|
190
|
+
// User clicks "Use my blueprint" → redirect to /blueprint flow
|
|
191
|
+
// User clicks "Start fresh" → continue to Step 4 normally
|
|
192
|
+
return; // Pause navigation until user decides
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch { /* detection failed — proceed normally */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (currentStep < TOTAL_STEPS) {
|
|
199
|
+
const nextStepNum = currentStep + 1;
|
|
200
|
+
if (nextStepNum === 5) await loadDeployTargets();
|
|
201
|
+
if (nextStepNum === 6) populateReview();
|
|
202
|
+
showStep(nextStepNum);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function prevStep() {
|
|
207
|
+
if (currentStep === 1) return;
|
|
208
|
+
if (currentStep === '4b') { showStep(4); return; }
|
|
209
|
+
if (currentStep === 5 && state.envGroups.length > 0) { showStep('4b'); return; }
|
|
210
|
+
if (currentStep === 5) { showStep(4); return; }
|
|
211
|
+
if (currentStep === 6) { showStep(5); return; }
|
|
212
|
+
showStep(currentStep - 1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
btnNext.addEventListener('click', nextStep);
|
|
216
|
+
btnBack.addEventListener('click', prevStep);
|
|
217
|
+
|
|
218
|
+
// Keyboard: Enter triggers contextual action
|
|
219
|
+
document.addEventListener('keydown', (e) => {
|
|
220
|
+
if (e.key !== 'Enter') return;
|
|
221
|
+
if (e.target.tagName === 'TEXTAREA') return;
|
|
222
|
+
// Don't intercept Enter on buttons, links, or selects — let native behavior handle them
|
|
223
|
+
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'SELECT') return;
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
|
|
226
|
+
if (currentStep === 1) {
|
|
227
|
+
if (vaultPasswordInput.value) unlockVaultBtn.click();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (currentStep === 2) {
|
|
231
|
+
if (keyInput.value.trim()) validateKeyBtn.click();
|
|
232
|
+
else nextStep(); // skip
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (currentStep === '4b') return; // Step 4b has its own Store/Skip buttons
|
|
237
|
+
|
|
238
|
+
if (currentStep === 4) {
|
|
239
|
+
const generateTab = $('#tab-generate');
|
|
240
|
+
const ideaField = $('#prd-idea');
|
|
241
|
+
if (generateTab.classList.contains('active') && ideaField.value.trim() && !state.generatedPrd) {
|
|
242
|
+
generatePrdBtn.click();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
nextStep();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// =============================================
|
|
251
|
+
// Step 1: Vault + API Key
|
|
252
|
+
// =============================================
|
|
253
|
+
|
|
254
|
+
const vaultPasswordInput = $('#vault-password');
|
|
255
|
+
const vaultStatus = $('#vault-status');
|
|
256
|
+
const unlockVaultBtn = $('#unlock-vault');
|
|
257
|
+
const toggleVaultBtn = $('#toggle-vault-visibility');
|
|
258
|
+
const vaultCard = $('#vault-card');
|
|
259
|
+
const apikeyCard = $('#apikey-card');
|
|
260
|
+
|
|
261
|
+
const keyInput = $('#anthropic-key');
|
|
262
|
+
const keyStatus = $('#key-status');
|
|
263
|
+
const validateKeyBtn = $('#validate-key');
|
|
264
|
+
const toggleKeyBtn = $('#toggle-key-visibility');
|
|
265
|
+
|
|
266
|
+
toggleVaultBtn.addEventListener('click', () => {
|
|
267
|
+
const isPassword = vaultPasswordInput.type === 'password';
|
|
268
|
+
vaultPasswordInput.type = isPassword ? 'text' : 'password';
|
|
269
|
+
toggleVaultBtn.textContent = isPassword ? 'Hide' : 'Show';
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
toggleKeyBtn.addEventListener('click', () => {
|
|
273
|
+
const isPassword = keyInput.type === 'password';
|
|
274
|
+
keyInput.type = isPassword ? 'text' : 'password';
|
|
275
|
+
toggleKeyBtn.textContent = isPassword ? 'Hide' : 'Show';
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Check vault state on load
|
|
279
|
+
fetch('/api/credentials/status')
|
|
280
|
+
.then((r) => r.json())
|
|
281
|
+
.then((data) => {
|
|
282
|
+
if (data.vaultPath) $('#vault-path').textContent = data.vaultPath;
|
|
283
|
+
if (data.vaultExists) {
|
|
284
|
+
$('#vault-password-label').textContent = 'Vault Password';
|
|
285
|
+
vaultPasswordInput.placeholder = 'Enter your vault password';
|
|
286
|
+
$('#vault-hint').textContent = 'Enter the password you used to create this vault.';
|
|
287
|
+
}
|
|
288
|
+
if (data.unlocked && data.anthropic) {
|
|
289
|
+
state.anthropicKeyStored = true;
|
|
290
|
+
vaultCard.classList.add('hidden');
|
|
291
|
+
apikeyCard.classList.remove('hidden');
|
|
292
|
+
showStatus(keyStatus, 'success', 'API key already stored in vault');
|
|
293
|
+
keyInput.placeholder = 'Key already stored — enter a new one to replace';
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
.catch(() => {});
|
|
297
|
+
|
|
298
|
+
unlockVaultBtn.addEventListener('click', async () => {
|
|
299
|
+
const password = vaultPasswordInput.value;
|
|
300
|
+
if (!password) { showStatus(vaultStatus, 'error', 'Please enter a password'); return; }
|
|
301
|
+
if (password.length < 8) { showStatus(vaultStatus, 'error', 'Password must be at least 8 characters'); return; }
|
|
302
|
+
|
|
303
|
+
showStatus(vaultStatus, 'loading', 'Unlocking...');
|
|
304
|
+
unlockVaultBtn.disabled = true;
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const res = await fetch('/api/credentials/unlock', {
|
|
308
|
+
method: 'POST',
|
|
309
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
310
|
+
body: JSON.stringify({ password }),
|
|
311
|
+
});
|
|
312
|
+
const data = await res.json();
|
|
313
|
+
|
|
314
|
+
if (res.ok && data.unlocked) {
|
|
315
|
+
// ENCHANT-R2-003 + CROSS-R4-017: Forge-lit pulse on vault unlock (force replay)
|
|
316
|
+
const vc = document.getElementById('vault-card');
|
|
317
|
+
if (vc) { vc.classList.remove('forge-lit'); void vc.offsetWidth; vc.classList.add('forge-lit'); }
|
|
318
|
+
if (data.anthropic) {
|
|
319
|
+
state.anthropicKeyStored = true;
|
|
320
|
+
showStatus(vaultStatus, 'success', 'Vault unlocked — API key found');
|
|
321
|
+
setTimeout(() => showStep(3), 900);
|
|
322
|
+
} else {
|
|
323
|
+
showStatus(vaultStatus, 'success', 'Vault unlocked');
|
|
324
|
+
setTimeout(() => showStep(2), 900);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
showStatus(vaultStatus, 'error', data.error || 'Failed to unlock');
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
showStatus(vaultStatus, 'error', 'Connection error: ' + err.message);
|
|
331
|
+
} finally {
|
|
332
|
+
unlockVaultBtn.disabled = false;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
validateKeyBtn.addEventListener('click', async () => {
|
|
337
|
+
const apiKey = keyInput.value.trim();
|
|
338
|
+
if (!apiKey) { showStatus(keyStatus, 'error', 'Please enter your API key'); return; }
|
|
339
|
+
|
|
340
|
+
showStatus(keyStatus, 'loading', 'Validating...');
|
|
341
|
+
validateKeyBtn.disabled = true;
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const res = await fetch('/api/credentials/anthropic', {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
347
|
+
body: JSON.stringify({ apiKey }),
|
|
348
|
+
});
|
|
349
|
+
const data = await res.json();
|
|
350
|
+
|
|
351
|
+
if (res.ok && data.stored) {
|
|
352
|
+
showStatus(keyStatus, 'success', 'Key validated and stored in vault');
|
|
353
|
+
state.anthropicKeyStored = true;
|
|
354
|
+
setTimeout(() => showStep(3), 600);
|
|
355
|
+
} else {
|
|
356
|
+
showStatus(keyStatus, 'error', data.error || 'Validation failed');
|
|
357
|
+
}
|
|
358
|
+
} catch (err) {
|
|
359
|
+
showStatus(keyStatus, 'error', 'Connection error: ' + err.message);
|
|
360
|
+
} finally {
|
|
361
|
+
validateKeyBtn.disabled = false;
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// =============================================
|
|
366
|
+
// Step 2: API Key + Skip
|
|
367
|
+
// =============================================
|
|
368
|
+
|
|
369
|
+
let providersLoaded = false;
|
|
370
|
+
|
|
371
|
+
/** Make a div act as a keyboard-accessible button */
|
|
372
|
+
function activateOnKeyboard(el, handler) {
|
|
373
|
+
el.addEventListener('click', handler);
|
|
374
|
+
el.addEventListener('keydown', (e) => {
|
|
375
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
handler(e);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Skip API key button
|
|
383
|
+
$('#skip-key')?.addEventListener('click', () => {
|
|
384
|
+
showStep(3);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
async function loadCloudProviders() {
|
|
388
|
+
if (providersLoaded) return;
|
|
389
|
+
providersLoaded = true;
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const [provRes, statusRes] = await Promise.all([
|
|
393
|
+
fetch('/api/cloud/providers').then((r) => r.json()),
|
|
394
|
+
fetch('/api/cloud/status').then((r) => r.json()),
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
const container = $('#cloud-providers-list');
|
|
398
|
+
container.innerHTML = '';
|
|
399
|
+
|
|
400
|
+
for (const provider of provRes.providers) {
|
|
401
|
+
const configured = statusRes.status[provider.id] || false;
|
|
402
|
+
state.cloudProviders[provider.id] = configured;
|
|
403
|
+
|
|
404
|
+
const card = document.createElement('div');
|
|
405
|
+
card.className = 'provider-card' + (configured ? ' configured' : '');
|
|
406
|
+
card.dataset.provider = provider.id;
|
|
407
|
+
|
|
408
|
+
const badge = configured
|
|
409
|
+
? '<span class="provider-badge connected">Connected</span>'
|
|
410
|
+
: '<span class="provider-badge not-connected">Not connected</span>';
|
|
411
|
+
|
|
412
|
+
let fieldsHtml = '';
|
|
413
|
+
for (const field of provider.fields) {
|
|
414
|
+
fieldsHtml += `
|
|
415
|
+
<div class="field">
|
|
416
|
+
<label for="cloud-${field.key}">${field.label}</label>
|
|
417
|
+
<input type="${field.secret ? 'password' : 'text'}" id="cloud-${field.key}"
|
|
418
|
+
data-field-key="${field.key}" placeholder="${field.placeholder}" autocomplete="off">
|
|
419
|
+
</div>`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
card.innerHTML = `
|
|
423
|
+
<div class="provider-header" data-toggle="${provider.id}" role="button" tabindex="0" aria-expanded="false">
|
|
424
|
+
<div class="provider-header-left">
|
|
425
|
+
<span class="provider-chevron">▶</span>
|
|
426
|
+
<div>
|
|
427
|
+
<div class="provider-name">${provider.name} <button class="provider-help-btn" data-help="${provider.id}" type="button" title="How to get credentials">?</button></div>
|
|
428
|
+
<div class="provider-desc">${provider.description}</div>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
${badge}
|
|
432
|
+
</div>
|
|
433
|
+
<div class="provider-body hidden" id="body-${provider.id}">
|
|
434
|
+
<div class="provider-help hidden" id="help-${provider.id}">
|
|
435
|
+
<button class="provider-help-close" data-close-help="${provider.id}" type="button" title="Close">×</button>
|
|
436
|
+
${provider.help}
|
|
437
|
+
<a class="help-link" href="${provider.credentialUrl}" target="_blank" rel="noopener">Open ${provider.name} Credentials Page →</a>
|
|
438
|
+
</div>
|
|
439
|
+
<div class="provider-fields" id="fields-${provider.id}">
|
|
440
|
+
${fieldsHtml}
|
|
441
|
+
<div class="btn-row">
|
|
442
|
+
<button class="btn btn-primary btn-small" data-validate="${provider.id}" type="button">
|
|
443
|
+
${configured ? 'Update' : 'Connect'}
|
|
444
|
+
</button>
|
|
445
|
+
${configured ? `<button class="btn btn-secondary btn-small" data-remove="${provider.id}" type="button">Remove</button>` : ''}
|
|
446
|
+
</div>
|
|
447
|
+
<div class="status-row" id="cloud-status-${provider.id}"></div>
|
|
448
|
+
</div>
|
|
449
|
+
</div>`;
|
|
450
|
+
|
|
451
|
+
container.appendChild(card);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Toggle accordion on header click or keyboard (Enter/Space)
|
|
455
|
+
function toggleAccordion(toggle) {
|
|
456
|
+
const id = toggle.dataset.toggle;
|
|
457
|
+
const card = toggle.closest('.provider-card');
|
|
458
|
+
const body = $(`#body-${id}`);
|
|
459
|
+
const isOpen = !body.classList.contains('hidden');
|
|
460
|
+
body.classList.toggle('hidden');
|
|
461
|
+
card.classList.toggle('open', !isOpen);
|
|
462
|
+
toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
container.addEventListener('click', (e) => {
|
|
466
|
+
if (e.target.closest('[data-help]')) return;
|
|
467
|
+
if (e.target.closest('[data-close-help]')) return;
|
|
468
|
+
const toggle = e.target.closest('[data-toggle]');
|
|
469
|
+
if (toggle) toggleAccordion(toggle);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
container.addEventListener('keydown', (e) => {
|
|
473
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
474
|
+
if (e.target.closest('[data-help]')) return;
|
|
475
|
+
if (e.target.closest('[data-close-help]')) return;
|
|
476
|
+
const toggle = e.target.closest('[data-toggle]');
|
|
477
|
+
if (toggle) {
|
|
478
|
+
e.preventDefault();
|
|
479
|
+
toggleAccordion(toggle);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Help button toggles
|
|
484
|
+
container.addEventListener('click', (e) => {
|
|
485
|
+
const helpBtn = e.target.closest('[data-help]');
|
|
486
|
+
if (helpBtn) {
|
|
487
|
+
e.stopPropagation();
|
|
488
|
+
const providerId = helpBtn.dataset.help;
|
|
489
|
+
// Expand the accordion if it's collapsed so the help panel is visible
|
|
490
|
+
const body = $(`#body-${providerId}`);
|
|
491
|
+
const card = helpBtn.closest('.provider-card');
|
|
492
|
+
if (body.classList.contains('hidden')) {
|
|
493
|
+
body.classList.remove('hidden');
|
|
494
|
+
card.classList.add('open');
|
|
495
|
+
}
|
|
496
|
+
$(`#help-${providerId}`).classList.toggle('hidden');
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const closeBtn = e.target.closest('[data-close-help]');
|
|
500
|
+
if (closeBtn) {
|
|
501
|
+
e.stopPropagation();
|
|
502
|
+
$(`#help-${closeBtn.dataset.closeHelp}`).classList.add('hidden');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Validate buttons
|
|
508
|
+
container.addEventListener('click', async (e) => {
|
|
509
|
+
const validateBtn = e.target.closest('[data-validate]');
|
|
510
|
+
if (!validateBtn) return;
|
|
511
|
+
|
|
512
|
+
const providerId = validateBtn.dataset.validate;
|
|
513
|
+
const statusEl = $(`#cloud-status-${providerId}`);
|
|
514
|
+
const card = validateBtn.closest('.provider-card');
|
|
515
|
+
|
|
516
|
+
const credentials = {};
|
|
517
|
+
card.querySelectorAll('[data-field-key]').forEach((input) => {
|
|
518
|
+
if (input.value.trim()) credentials[input.dataset.fieldKey] = input.value.trim();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const provider = provRes.providers.find((p) => p.id === providerId);
|
|
522
|
+
const missing = provider.fields.filter((f) => !f.optional && !credentials[f.key]);
|
|
523
|
+
if (missing.length > 0) {
|
|
524
|
+
showStatus(statusEl, 'error', `Missing: ${missing.map((f) => f.label).join(', ')}`);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
showStatus(statusEl, 'loading', 'Validating...');
|
|
529
|
+
validateBtn.disabled = true;
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const res = await fetch('/api/cloud/validate', {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
535
|
+
body: JSON.stringify({ provider: providerId, credentials }),
|
|
536
|
+
});
|
|
537
|
+
const data = await res.json();
|
|
538
|
+
|
|
539
|
+
if (res.ok && data.stored) {
|
|
540
|
+
const identity = data.identity ? ` (${data.identity})` : '';
|
|
541
|
+
showStatus(statusEl, 'success', `Connected${identity}`);
|
|
542
|
+
state.cloudProviders[providerId] = true;
|
|
543
|
+
card.classList.add('configured');
|
|
544
|
+
card.querySelector('.provider-badge').className = 'provider-badge connected';
|
|
545
|
+
card.querySelector('.provider-badge').textContent = 'Connected';
|
|
546
|
+
} else {
|
|
547
|
+
showStatus(statusEl, 'error', data.error || 'Validation failed');
|
|
548
|
+
}
|
|
549
|
+
} catch (err) {
|
|
550
|
+
showStatus(statusEl, 'error', 'Connection error: ' + err.message);
|
|
551
|
+
} finally {
|
|
552
|
+
validateBtn.disabled = false;
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Remove buttons
|
|
557
|
+
container.addEventListener('click', async (e) => {
|
|
558
|
+
const removeBtn = e.target.closest('[data-remove]');
|
|
559
|
+
if (!removeBtn) return;
|
|
560
|
+
|
|
561
|
+
const providerId = removeBtn.dataset.remove;
|
|
562
|
+
const statusEl = $(`#cloud-status-${providerId}`);
|
|
563
|
+
const card = removeBtn.closest('.provider-card');
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
await fetch('/api/cloud/remove', {
|
|
567
|
+
method: 'POST',
|
|
568
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
569
|
+
body: JSON.stringify({ provider: providerId }),
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
state.cloudProviders[providerId] = false;
|
|
573
|
+
card.classList.remove('configured');
|
|
574
|
+
card.querySelector('.provider-badge').className = 'provider-badge not-connected';
|
|
575
|
+
card.querySelector('.provider-badge').textContent = 'Not connected';
|
|
576
|
+
showStatus(statusEl, 'info', 'Credentials removed');
|
|
577
|
+
removeBtn.remove();
|
|
578
|
+
} catch (err) {
|
|
579
|
+
showStatus(statusEl, 'error', 'Failed to remove: ' + err.message);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
} catch (err) {
|
|
584
|
+
$('#cloud-providers-list').innerHTML = `<div class="status-row error">Failed to load providers: ${escapeHtml(err.message)}</div>`;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Step 4b needs to re-show nav buttons properly
|
|
589
|
+
const origShowStep = showStep;
|
|
590
|
+
showStep = function (step) {
|
|
591
|
+
if (step === '4b') {
|
|
592
|
+
btnNext.style.display = 'none';
|
|
593
|
+
btnBack.style.display = '';
|
|
594
|
+
btnBack.disabled = false;
|
|
595
|
+
}
|
|
596
|
+
origShowStep(step);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// =============================================
|
|
600
|
+
// Step 3: Project Setup
|
|
601
|
+
// =============================================
|
|
602
|
+
|
|
603
|
+
const projectNameInput = $('#project-name');
|
|
604
|
+
const projectDirInput = $('#project-dir');
|
|
605
|
+
|
|
606
|
+
projectNameInput.addEventListener('input', () => {
|
|
607
|
+
const name = projectNameInput.value.trim();
|
|
608
|
+
if (name && !projectDirInput.dataset.manual) {
|
|
609
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9\-_\s]/g, '').replace(/\s+/g, '-');
|
|
610
|
+
fetch('/api/project/defaults')
|
|
611
|
+
.then((r) => r.json())
|
|
612
|
+
.then((data) => {
|
|
613
|
+
if (!projectDirInput.dataset.manual) {
|
|
614
|
+
projectDirInput.value = data.baseDir + '/' + slug;
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
.catch(() => {});
|
|
618
|
+
}
|
|
619
|
+
state.projectName = name;
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
projectDirInput.addEventListener('input', () => {
|
|
623
|
+
projectDirInput.dataset.manual = 'true';
|
|
624
|
+
state.projectDir = projectDirInput.value.trim();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// =============================================
|
|
628
|
+
// Step 4: PRD
|
|
629
|
+
// =============================================
|
|
630
|
+
|
|
631
|
+
$$('.tab').forEach((tab) => {
|
|
632
|
+
tab.addEventListener('click', () => {
|
|
633
|
+
$$('.tab').forEach((t) => {
|
|
634
|
+
t.classList.remove('active');
|
|
635
|
+
t.setAttribute('aria-selected', 'false');
|
|
636
|
+
});
|
|
637
|
+
$$('.tab-panel').forEach((p) => p.classList.remove('active'));
|
|
638
|
+
tab.classList.add('active');
|
|
639
|
+
tab.setAttribute('aria-selected', 'true');
|
|
640
|
+
const panel = $(`#tab-${tab.dataset.tab}`);
|
|
641
|
+
if (panel) panel.classList.add('active');
|
|
642
|
+
state.prdMode = tab.dataset.tab;
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const validatePrdBtn = $('#validate-prd');
|
|
647
|
+
const prdStatus = $('#prd-status');
|
|
648
|
+
|
|
649
|
+
validatePrdBtn.addEventListener('click', async () => {
|
|
650
|
+
const content = $('#prd-paste').value.trim();
|
|
651
|
+
if (!content) { showStatus(prdStatus, 'error', 'Paste your PRD content first'); return; }
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
const res = await fetch('/api/prd/validate', {
|
|
655
|
+
method: 'POST',
|
|
656
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
657
|
+
body: JSON.stringify({ content }),
|
|
658
|
+
});
|
|
659
|
+
const data = await res.json();
|
|
660
|
+
|
|
661
|
+
if (data.valid) {
|
|
662
|
+
showStatus(prdStatus, 'success', `Valid frontmatter: ${data.frontmatter.name || 'unnamed'} (${data.frontmatter.type || 'no type'})`);
|
|
663
|
+
} else {
|
|
664
|
+
showStatus(prdStatus, 'error', data.errors.join(', '));
|
|
665
|
+
}
|
|
666
|
+
} catch (err) {
|
|
667
|
+
showStatus(prdStatus, 'error', 'Validation error: ' + err.message);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
let cachedPrompt = null;
|
|
672
|
+
$('#copy-prd-prompt').addEventListener('click', async () => {
|
|
673
|
+
const promptCopyStatus = $('#prompt-copy-status');
|
|
674
|
+
try {
|
|
675
|
+
if (!cachedPrompt) {
|
|
676
|
+
showStatus(promptCopyStatus, 'loading', 'Loading prompt...');
|
|
677
|
+
const res = await fetch('/api/prd/prompt');
|
|
678
|
+
const data = await res.json();
|
|
679
|
+
if (!res.ok) throw new Error(data.error || 'Failed to load prompt');
|
|
680
|
+
cachedPrompt = data.prompt;
|
|
681
|
+
}
|
|
682
|
+
await copyToClipboard(cachedPrompt);
|
|
683
|
+
showStatus(promptCopyStatus, 'success', 'Prompt copied — paste it into your AI of choice, add your idea, then paste the result above');
|
|
684
|
+
} catch (err) {
|
|
685
|
+
showStatus(promptCopyStatus, 'error', 'Failed to copy: ' + err.message);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const generatePrdBtn = $('#generate-prd');
|
|
690
|
+
const generationOutput = $('#generation-output');
|
|
691
|
+
const generatedContent = $('#generated-prd-content');
|
|
692
|
+
|
|
693
|
+
generatePrdBtn.addEventListener('click', async () => {
|
|
694
|
+
const idea = $('#prd-idea').value.trim();
|
|
695
|
+
if (!idea) {
|
|
696
|
+
showStatus($('#generate-status'), 'error', 'Describe your idea first');
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
$('#generate-status').className = 'status-row';
|
|
700
|
+
|
|
701
|
+
generatePrdBtn.disabled = true;
|
|
702
|
+
generatePrdBtn.textContent = 'Generating...';
|
|
703
|
+
generationOutput.classList.remove('hidden');
|
|
704
|
+
generatedContent.textContent = '';
|
|
705
|
+
// ENCHANT-R2-006: Add streaming cursor
|
|
706
|
+
generatedContent.classList.add('streaming');
|
|
707
|
+
state.generatedPrd = '';
|
|
708
|
+
|
|
709
|
+
try {
|
|
710
|
+
const res = await fetch('/api/prd/generate', {
|
|
711
|
+
method: 'POST',
|
|
712
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
713
|
+
body: JSON.stringify({
|
|
714
|
+
idea,
|
|
715
|
+
name: state.projectName,
|
|
716
|
+
framework: $('#pref-framework').value,
|
|
717
|
+
database: $('#pref-database').value,
|
|
718
|
+
deploy: $('#pref-deploy').value,
|
|
719
|
+
}),
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const reader = res.body.getReader();
|
|
723
|
+
const decoder = new TextDecoder();
|
|
724
|
+
let buffer = '';
|
|
725
|
+
let wasTruncated = false;
|
|
726
|
+
|
|
727
|
+
while (true) {
|
|
728
|
+
const { done, value } = await reader.read();
|
|
729
|
+
if (done) break;
|
|
730
|
+
|
|
731
|
+
buffer += decoder.decode(value, { stream: true });
|
|
732
|
+
const lines = buffer.split('\n');
|
|
733
|
+
buffer = lines.pop() || '';
|
|
734
|
+
|
|
735
|
+
for (const line of lines) {
|
|
736
|
+
if (!line.startsWith('data: ')) continue;
|
|
737
|
+
const data = line.slice(6);
|
|
738
|
+
if (data === '[DONE]') continue;
|
|
739
|
+
|
|
740
|
+
try {
|
|
741
|
+
const parsed = JSON.parse(data);
|
|
742
|
+
if (parsed.text) {
|
|
743
|
+
state.generatedPrd += parsed.text;
|
|
744
|
+
generatedContent.textContent = state.generatedPrd;
|
|
745
|
+
generatedContent.scrollTop = generatedContent.scrollHeight;
|
|
746
|
+
}
|
|
747
|
+
if (parsed.truncated) {
|
|
748
|
+
wasTruncated = true;
|
|
749
|
+
}
|
|
750
|
+
if (parsed.error) {
|
|
751
|
+
generatedContent.textContent += '\n\nError: ' + parsed.error;
|
|
752
|
+
}
|
|
753
|
+
} catch { /* skip */ }
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (wasTruncated) {
|
|
758
|
+
showStatus($('#generate-status'), 'error',
|
|
759
|
+
'PRD was truncated — the output hit the model token limit. Try a shorter idea description or generate again with fewer details.');
|
|
760
|
+
}
|
|
761
|
+
} catch (err) {
|
|
762
|
+
generatedContent.textContent += '\n\nConnection error: ' + err.message;
|
|
763
|
+
} finally {
|
|
764
|
+
// ENCHANT-R2-006: Remove streaming cursor when done
|
|
765
|
+
generatedContent.classList.remove('streaming');
|
|
766
|
+
generatePrdBtn.disabled = false;
|
|
767
|
+
generatePrdBtn.textContent = 'Generate PRD with Claude';
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
$('#copy-generated').addEventListener('click', () => {
|
|
772
|
+
if (!state.generatedPrd) return;
|
|
773
|
+
copyToClipboard(state.generatedPrd).then(() => {
|
|
774
|
+
$('#copy-generated').textContent = 'Copied!';
|
|
775
|
+
setTimeout(() => { $('#copy-generated').textContent = 'Copy'; }, 2000);
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// =============================================
|
|
780
|
+
// Step 4b: PRD-Driven Credentials
|
|
781
|
+
// =============================================
|
|
782
|
+
|
|
783
|
+
async function loadEnvRequirements(prdText) {
|
|
784
|
+
try {
|
|
785
|
+
const res = await fetch('/api/prd/env-requirements', {
|
|
786
|
+
method: 'POST',
|
|
787
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
788
|
+
body: JSON.stringify({ content: prdText }),
|
|
789
|
+
});
|
|
790
|
+
const data = await res.json();
|
|
791
|
+
return data.groups || [];
|
|
792
|
+
} catch {
|
|
793
|
+
return [];
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function renderEnvCredentials(groups) {
|
|
798
|
+
const container = $('#env-credentials-list');
|
|
799
|
+
const emptyEl = $('#env-credentials-empty');
|
|
800
|
+
container.innerHTML = '';
|
|
801
|
+
|
|
802
|
+
if (groups.length === 0) {
|
|
803
|
+
emptyEl.classList.remove('hidden');
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
emptyEl.classList.add('hidden');
|
|
807
|
+
|
|
808
|
+
for (const group of groups) {
|
|
809
|
+
const section = document.createElement('div');
|
|
810
|
+
section.className = 'card';
|
|
811
|
+
section.style.marginBottom = '12px';
|
|
812
|
+
|
|
813
|
+
let fieldsHtml = '';
|
|
814
|
+
for (const field of group.fields) {
|
|
815
|
+
fieldsHtml += `
|
|
816
|
+
<div class="field">
|
|
817
|
+
<label for="env-${field.key}">${escapeHtml(field.label)}</label>
|
|
818
|
+
<input type="${field.secret ? 'password' : 'text'}" id="env-${field.key}"
|
|
819
|
+
data-env-key="${field.key}" placeholder="${escapeHtml(field.placeholder)}" autocomplete="off">
|
|
820
|
+
</div>`;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
section.innerHTML = `
|
|
824
|
+
<h3>${escapeHtml(group.name)}</h3>
|
|
825
|
+
${fieldsHtml}`;
|
|
826
|
+
container.appendChild(section);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Store All button
|
|
831
|
+
$('#store-env-credentials')?.addEventListener('click', async () => {
|
|
832
|
+
const statusEl = $('#env-store-status');
|
|
833
|
+
const inputs = $$('[data-env-key]');
|
|
834
|
+
const credentials = {};
|
|
835
|
+
let count = 0;
|
|
836
|
+
|
|
837
|
+
inputs.forEach((input) => {
|
|
838
|
+
if (input.value.trim()) {
|
|
839
|
+
credentials[input.dataset.envKey] = input.value.trim();
|
|
840
|
+
count++;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
if (count === 0) {
|
|
845
|
+
showStatus(statusEl, 'info', 'No credentials entered — skipping.');
|
|
846
|
+
proceedFromEnvStep();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
showStatus(statusEl, 'loading', `Storing ${count} credentials in vault...`);
|
|
851
|
+
try {
|
|
852
|
+
const res = await fetch('/api/credentials/env-batch', {
|
|
853
|
+
method: 'POST',
|
|
854
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
855
|
+
body: JSON.stringify({ credentials }),
|
|
856
|
+
});
|
|
857
|
+
const data = await res.json();
|
|
858
|
+
|
|
859
|
+
if (res.ok && data.stored) {
|
|
860
|
+
state.envCredentials = credentials;
|
|
861
|
+
showStatus(statusEl, 'success', `${count} credentials stored in vault`);
|
|
862
|
+
setTimeout(proceedFromEnvStep, 600);
|
|
863
|
+
} else {
|
|
864
|
+
showStatus(statusEl, 'error', data.error || 'Failed to store credentials');
|
|
865
|
+
}
|
|
866
|
+
} catch (err) {
|
|
867
|
+
showStatus(statusEl, 'error', 'Connection error: ' + err.message);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Skip button
|
|
872
|
+
$('#skip-env-credentials')?.addEventListener('click', () => {
|
|
873
|
+
proceedFromEnvStep();
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
function proceedFromEnvStep() {
|
|
877
|
+
Promise.all([loadDeployTargets(), loadCloudProviders()]).then(() => showStep(5));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// =============================================
|
|
881
|
+
// Step 5: Deploy Target
|
|
882
|
+
// =============================================
|
|
883
|
+
|
|
884
|
+
async function loadDeployTargets() {
|
|
885
|
+
try {
|
|
886
|
+
const res = await fetch('/api/cloud/deploy-targets');
|
|
887
|
+
const data = await res.json();
|
|
888
|
+
|
|
889
|
+
const container = $('#deploy-targets-list');
|
|
890
|
+
const noteEl = $('#deploy-note');
|
|
891
|
+
container.innerHTML = '';
|
|
892
|
+
|
|
893
|
+
if (!state.deployTarget) {
|
|
894
|
+
state.deployTarget = 'docker';
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const availableTargets = data.targets.filter((t) => t.available);
|
|
898
|
+
const onlyDocker = availableTargets.length === 1 && availableTargets[0].id === 'docker';
|
|
899
|
+
|
|
900
|
+
if (onlyDocker) {
|
|
901
|
+
state.deployTarget = 'docker';
|
|
902
|
+
noteEl.innerHTML = '<div class="status-row info" style="margin-top: 16px;">No cloud providers configured, so <strong>Docker (local)</strong> is preselected. You can add cloud credentials later by re-running the wizard.</div>';
|
|
903
|
+
} else {
|
|
904
|
+
noteEl.innerHTML = '';
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
for (const target of data.targets) {
|
|
908
|
+
const card = document.createElement('div');
|
|
909
|
+
card.className = 'deploy-card' + (target.available ? '' : ' unavailable');
|
|
910
|
+
if (state.deployTarget === target.id) card.classList.add('selected');
|
|
911
|
+
card.dataset.target = target.id;
|
|
912
|
+
card.setAttribute('role', 'radio');
|
|
913
|
+
card.setAttribute('aria-checked', state.deployTarget === target.id ? 'true' : 'false');
|
|
914
|
+
if (target.available) {
|
|
915
|
+
card.tabIndex = 0;
|
|
916
|
+
} else {
|
|
917
|
+
card.setAttribute('aria-disabled', 'true');
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const badge = target.available
|
|
921
|
+
? '<span class="provider-badge connected">Ready</span>'
|
|
922
|
+
: `<span class="provider-badge not-connected">Needs ${target.provider || 'setup'}</span>`;
|
|
923
|
+
|
|
924
|
+
card.innerHTML = `
|
|
925
|
+
<div class="deploy-card-header">
|
|
926
|
+
<span class="deploy-card-name">${target.name}</span>
|
|
927
|
+
${badge}
|
|
928
|
+
</div>
|
|
929
|
+
<div class="deploy-card-desc">${target.description}</div>`;
|
|
930
|
+
|
|
931
|
+
const selectTarget = () => {
|
|
932
|
+
if (!target.available) return;
|
|
933
|
+
$$('.deploy-card').forEach((c) => {
|
|
934
|
+
c.classList.remove('selected');
|
|
935
|
+
c.setAttribute('aria-checked', 'false');
|
|
936
|
+
});
|
|
937
|
+
card.classList.add('selected');
|
|
938
|
+
card.setAttribute('aria-checked', 'true');
|
|
939
|
+
state.deployTarget = target.id;
|
|
940
|
+
};
|
|
941
|
+
activateOnKeyboard(card, selectTarget);
|
|
942
|
+
|
|
943
|
+
container.appendChild(card);
|
|
944
|
+
}
|
|
945
|
+
} catch (err) {
|
|
946
|
+
$('#deploy-targets-list').innerHTML = `<div class="status-row error">Failed to load targets: ${escapeHtml(err.message)}</div>`;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// =============================================
|
|
951
|
+
// Step 6: Review
|
|
952
|
+
// =============================================
|
|
953
|
+
|
|
954
|
+
function populateReview() {
|
|
955
|
+
$('#review-name').textContent = state.projectName;
|
|
956
|
+
$('#review-dir').textContent = state.projectDir;
|
|
957
|
+
$('#review-desc').textContent = state.projectDesc || '(not set)';
|
|
958
|
+
$('#review-domain').textContent = state.projectDomain || '(not set)';
|
|
959
|
+
$('#review-hostname').textContent = state.projectHostname || '(not set)';
|
|
960
|
+
const deployNames = { vps: 'VPS (AWS EC2)', vercel: 'Vercel', railway: 'Railway', cloudflare: 'Cloudflare Workers/Pages', static: 'Static (S3 + CloudFront)', docker: 'Docker (local)' };
|
|
961
|
+
$('#review-deploy').textContent = deployNames[state.deployTarget] || state.deployTarget || 'Docker (local)';
|
|
962
|
+
|
|
963
|
+
if (state.prdMode === 'paste' && state.prdContent) {
|
|
964
|
+
$('#review-prd').textContent = 'Custom PRD (pasted)';
|
|
965
|
+
} else if (state.prdMode === 'generate' && state.generatedPrd) {
|
|
966
|
+
$('#review-prd').textContent = 'Generated by Claude';
|
|
967
|
+
} else {
|
|
968
|
+
$('#review-prd').textContent = 'Default template (edit later)';
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Show env credentials count if any were stored
|
|
972
|
+
const envCount = Object.keys(state.envCredentials).length;
|
|
973
|
+
const envRow = $('#review-env-credentials');
|
|
974
|
+
if (envRow) {
|
|
975
|
+
envRow.textContent = envCount > 0 ? `${envCount} keys stored in vault` : 'None (add to .env later)';
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// =============================================
|
|
980
|
+
// Step 7: Create
|
|
981
|
+
// =============================================
|
|
982
|
+
|
|
983
|
+
async function createProject() {
|
|
984
|
+
const creatingState = $('#creating-state');
|
|
985
|
+
const statusText = $('#create-status-text');
|
|
986
|
+
|
|
987
|
+
creatingState.classList.remove('hidden');
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
statusText.textContent = 'Creating project files...';
|
|
991
|
+
let prd = state.prdContent || undefined;
|
|
992
|
+
|
|
993
|
+
const res = await fetch('/api/project/create', {
|
|
994
|
+
method: 'POST',
|
|
995
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
996
|
+
body: JSON.stringify({
|
|
997
|
+
name: state.projectName,
|
|
998
|
+
directory: state.projectDir,
|
|
999
|
+
description: state.projectDesc || undefined,
|
|
1000
|
+
domain: state.projectDomain || undefined,
|
|
1001
|
+
hostname: state.projectHostname || undefined,
|
|
1002
|
+
deploy: state.deployTarget || undefined,
|
|
1003
|
+
prd,
|
|
1004
|
+
}),
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
const data = await res.json();
|
|
1008
|
+
|
|
1009
|
+
if (res.ok && data.created) {
|
|
1010
|
+
state.createdDir = data.directory;
|
|
1011
|
+
// Show done state
|
|
1012
|
+
creatingState.classList.add('hidden');
|
|
1013
|
+
$('#done-state').classList.remove('hidden');
|
|
1014
|
+
$('#done-details').innerHTML = `
|
|
1015
|
+
<p><strong>${escapeHtml(state.projectName)}</strong></p>
|
|
1016
|
+
<p style="color: var(--text-dim); font-family: var(--mono); font-size: 13px;">${escapeHtml(data.directory)}</p>
|
|
1017
|
+
<p style="color: var(--text-dim); margin-top: 8px;">${data.files.length} files created</p>
|
|
1018
|
+
`;
|
|
1019
|
+
setTimeout(() => { const h = $('#step-7-heading'); if (h) h.focus(); }, 100);
|
|
1020
|
+
} else {
|
|
1021
|
+
showCreateError('Error: ' + (data.error || 'Unknown error'));
|
|
1022
|
+
}
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
showCreateError('Error: ' + err.message);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function showCreateError(message) {
|
|
1029
|
+
const creatingState = $('#creating-state');
|
|
1030
|
+
const statusText = $('#create-status-text');
|
|
1031
|
+
creatingState.querySelector('.spinner')?.classList.add('hidden');
|
|
1032
|
+
statusText.textContent = message;
|
|
1033
|
+
statusText.style.color = 'var(--error)';
|
|
1034
|
+
// Show back + retry buttons so the user isn't trapped
|
|
1035
|
+
btnBack.style.display = '';
|
|
1036
|
+
btnBack.disabled = false;
|
|
1037
|
+
btnNext.style.display = '';
|
|
1038
|
+
btnNext.textContent = 'Retry';
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
$('#open-tower')?.addEventListener('click', () => {
|
|
1042
|
+
if (state.createdDir) {
|
|
1043
|
+
const name = encodeURIComponent(state.projectName);
|
|
1044
|
+
const dir = encodeURIComponent(state.createdDir);
|
|
1045
|
+
window.location.href = `/tower.html?name=${name}&dir=${dir}`;
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
$('#open-terminal')?.addEventListener('click', () => {
|
|
1050
|
+
if (state.createdDir) {
|
|
1051
|
+
const cmd = `cd "${state.createdDir}" && claude`;
|
|
1052
|
+
copyToClipboard(cmd).then(() => {
|
|
1053
|
+
alert(`Copied to clipboard:\n\n${cmd}\n\nPaste this in your terminal.`);
|
|
1054
|
+
}).catch(() => {
|
|
1055
|
+
alert(`Run this in your terminal:\n\n${cmd}`);
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
$('#open-finder')?.addEventListener('click', () => {
|
|
1061
|
+
if (state.createdDir) {
|
|
1062
|
+
copyToClipboard(state.createdDir).then(() => {
|
|
1063
|
+
alert(`Path copied. Open Finder and press Cmd+Shift+G, then paste:\n\n${state.createdDir}`);
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// Provisioning moved to Haku (deploy wizard) — launch with `npm run deploy`
|
|
1069
|
+
|
|
1070
|
+
// =============================================
|
|
1071
|
+
// Step 5: Operations Menu — Card Expand/Collapse
|
|
1072
|
+
// =============================================
|
|
1073
|
+
|
|
1074
|
+
$$('.ops-card-header').forEach((header) => {
|
|
1075
|
+
function toggle() {
|
|
1076
|
+
const card = header.closest('.ops-card');
|
|
1077
|
+
const body = card.querySelector('.ops-card-body');
|
|
1078
|
+
const isOpen = !body.classList.contains('hidden');
|
|
1079
|
+
body.classList.toggle('hidden');
|
|
1080
|
+
card.classList.toggle('expanded', !isOpen);
|
|
1081
|
+
header.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
|
1082
|
+
if (!isOpen) {
|
|
1083
|
+
const firstInput = body.querySelector('input, select');
|
|
1084
|
+
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
header.addEventListener('click', toggle);
|
|
1088
|
+
header.addEventListener('keydown', (e) => {
|
|
1089
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// Skip All button
|
|
1094
|
+
$('#ops-skip-all')?.addEventListener('click', () => {
|
|
1095
|
+
populateReview();
|
|
1096
|
+
showStep(6);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
// Update header when project name is typed (Éowyn's enchantment)
|
|
1100
|
+
projectNameInput.addEventListener('input', () => {
|
|
1101
|
+
const name = projectNameInput.value.trim();
|
|
1102
|
+
const logo = $('.logo');
|
|
1103
|
+
if (name) {
|
|
1104
|
+
logo.textContent = 'Gandalf — ' + name;
|
|
1105
|
+
} else {
|
|
1106
|
+
logo.textContent = 'Gandalf — VoidForge Setup';
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
// =============================================
|
|
1111
|
+
// Utilities
|
|
1112
|
+
// =============================================
|
|
1113
|
+
|
|
1114
|
+
function showValidationErrors() {
|
|
1115
|
+
if (currentStep === 3) {
|
|
1116
|
+
const nameInput = $('#project-name');
|
|
1117
|
+
const dirInput = $('#project-dir');
|
|
1118
|
+
if (!state.projectName) nameInput.style.borderColor = 'var(--error)';
|
|
1119
|
+
if (!state.projectDir) dirInput.style.borderColor = 'var(--error)';
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function clearValidationErrors() {
|
|
1124
|
+
const nameInput = $('#project-name');
|
|
1125
|
+
const dirInput = $('#project-dir');
|
|
1126
|
+
if (nameInput) nameInput.style.borderColor = '';
|
|
1127
|
+
if (dirInput) dirInput.style.borderColor = '';
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function escapeHtml(str) {
|
|
1131
|
+
const div = document.createElement('div');
|
|
1132
|
+
div.appendChild(document.createTextNode(str));
|
|
1133
|
+
return div.innerHTML;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function showStatus(el, type, message) {
|
|
1137
|
+
el.className = 'status-row ' + type;
|
|
1138
|
+
el.textContent = message;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function copyToClipboard(text) {
|
|
1142
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1143
|
+
return navigator.clipboard.writeText(text);
|
|
1144
|
+
}
|
|
1145
|
+
return new Promise((resolve, reject) => {
|
|
1146
|
+
const ta = document.createElement('textarea');
|
|
1147
|
+
ta.value = text;
|
|
1148
|
+
ta.style.position = 'fixed';
|
|
1149
|
+
ta.style.opacity = '0';
|
|
1150
|
+
document.body.appendChild(ta);
|
|
1151
|
+
ta.select();
|
|
1152
|
+
try {
|
|
1153
|
+
document.execCommand('copy');
|
|
1154
|
+
resolve();
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
reject(e);
|
|
1157
|
+
} finally {
|
|
1158
|
+
document.body.removeChild(ta);
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Blueprint detection handlers
|
|
1164
|
+
const btnUseBlueprint = $('#btn-use-blueprint');
|
|
1165
|
+
const btnStartFresh = $('#btn-start-fresh');
|
|
1166
|
+
|
|
1167
|
+
if (btnUseBlueprint) {
|
|
1168
|
+
btnUseBlueprint.addEventListener('click', async () => {
|
|
1169
|
+
// User chose blueprint path — validate and show results
|
|
1170
|
+
const blueprintBanner = $('#blueprint-detection');
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
const res = await fetch('/api/blueprint/validate');
|
|
1174
|
+
const data = await res.json();
|
|
1175
|
+
|
|
1176
|
+
if (data.valid) {
|
|
1177
|
+
// Show validation success in the banner itself
|
|
1178
|
+
if (blueprintBanner) {
|
|
1179
|
+
blueprintBanner.innerHTML = `
|
|
1180
|
+
<h2 style="margin: 0 0 0.5rem 0; color: var(--success, #4ade80);">Blueprint Validated</h2>
|
|
1181
|
+
<p style="margin: 0 0 0.5rem 0;">${data.summary || 'PRD is valid and ready to build.'}</p>
|
|
1182
|
+
<p style="margin: 0 0 1rem 0; opacity: 0.8;">Run <code>/blueprint</code> in Claude Code to provision and start building.</p>
|
|
1183
|
+
<button type="button" onclick="this.closest('#blueprint-detection').classList.add('hidden'); document.querySelector('#step-4').classList.remove('hidden');"
|
|
1184
|
+
style="padding: 0.5rem 1rem; background: transparent; color: var(--text, #ccc); border: 1px solid var(--border, #333); border-radius: 6px; cursor: pointer;">
|
|
1185
|
+
Continue to wizard instead
|
|
1186
|
+
</button>
|
|
1187
|
+
`;
|
|
1188
|
+
}
|
|
1189
|
+
} else {
|
|
1190
|
+
// Show errors but don't dead-end — let user fix or continue
|
|
1191
|
+
if (blueprintBanner) {
|
|
1192
|
+
const errors = data.frontmatterErrors?.join('<br>') || 'Unknown validation error';
|
|
1193
|
+
blueprintBanner.innerHTML = `
|
|
1194
|
+
<h2 style="margin: 0 0 0.5rem 0; color: var(--warning, #fbbf24);">Blueprint Has Issues</h2>
|
|
1195
|
+
<p style="margin: 0 0 0.5rem 0; font-size: 0.9rem;">${errors}</p>
|
|
1196
|
+
<p style="margin: 0 0 1rem 0; opacity: 0.8;">Fix these in docs/PRD.md, or continue with the wizard interview.</p>
|
|
1197
|
+
<button type="button" onclick="this.closest('#blueprint-detection').classList.add('hidden'); document.querySelector('#step-4').classList.remove('hidden');"
|
|
1198
|
+
style="padding: 0.5rem 1rem; background: transparent; color: var(--text, #ccc); border: 1px solid var(--border, #333); border-radius: 6px; cursor: pointer;">
|
|
1199
|
+
Continue to wizard
|
|
1200
|
+
</button>
|
|
1201
|
+
`;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
} catch {
|
|
1205
|
+
// API unavailable — still show useful guidance, never dead-end
|
|
1206
|
+
if (blueprintBanner) {
|
|
1207
|
+
blueprintBanner.innerHTML = `
|
|
1208
|
+
<h2 style="margin: 0 0 0.5rem 0; color: var(--accent, #5b5bf7);">Blueprint Path Available</h2>
|
|
1209
|
+
<p style="margin: 0 0 1rem 0;">Run <code>/blueprint</code> in Claude Code to validate your PRD and provision infrastructure.</p>
|
|
1210
|
+
<button type="button" onclick="this.closest('#blueprint-detection').classList.add('hidden'); document.querySelector('#step-4').classList.remove('hidden');"
|
|
1211
|
+
style="padding: 0.5rem 1rem; background: transparent; color: var(--text, #ccc); border: 1px solid var(--border, #333); border-radius: 6px; cursor: pointer;">
|
|
1212
|
+
Continue to wizard
|
|
1213
|
+
</button>
|
|
1214
|
+
`;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (btnStartFresh) {
|
|
1221
|
+
btnStartFresh.addEventListener('click', () => {
|
|
1222
|
+
const blueprintBanner = $('#blueprint-detection');
|
|
1223
|
+
if (blueprintBanner) blueprintBanner.classList.add('hidden');
|
|
1224
|
+
// Continue to Step 4 (PRD interview) normally
|
|
1225
|
+
showStep(4);
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Init
|
|
1230
|
+
showStep(1);
|
|
1231
|
+
})();
|