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,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Haku — VoidForge Deploy Wizard
|
|
3
|
+
* Steps: 1=Unlock+Project, 2=Confirm, 3=Provision, 4=Done
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const TOTAL_STEPS = 4;
|
|
10
|
+
let currentStep = 1;
|
|
11
|
+
|
|
12
|
+
const state = {
|
|
13
|
+
projectDir: '',
|
|
14
|
+
projectName: '',
|
|
15
|
+
deployTarget: '',
|
|
16
|
+
framework: '',
|
|
17
|
+
database: 'none',
|
|
18
|
+
cache: 'none',
|
|
19
|
+
instanceType: 't3.micro',
|
|
20
|
+
hostname: '',
|
|
21
|
+
registerDomain: false,
|
|
22
|
+
provisionResult: null,
|
|
23
|
+
provisionRunId: '',
|
|
24
|
+
deployCmd: '',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const $ = (sel) => document.querySelector(sel);
|
|
28
|
+
const $$ = (sel) => document.querySelectorAll(sel);
|
|
29
|
+
|
|
30
|
+
const progressBar = $('#progress-bar');
|
|
31
|
+
const stepLabel = $('#step-label');
|
|
32
|
+
|
|
33
|
+
function showStep(step) {
|
|
34
|
+
$$('.step').forEach((el) => el.classList.add('hidden'));
|
|
35
|
+
const target = $(`#step-${step}`);
|
|
36
|
+
if (target) target.classList.remove('hidden');
|
|
37
|
+
currentStep = step;
|
|
38
|
+
|
|
39
|
+
const pct = Math.round((step / TOTAL_STEPS) * 100);
|
|
40
|
+
progressBar.style.width = `${pct}%`;
|
|
41
|
+
progressBar.setAttribute('aria-valuenow', String(pct));
|
|
42
|
+
stepLabel.textContent = `Step ${step} of ${TOTAL_STEPS}`;
|
|
43
|
+
|
|
44
|
+
const firstInput = target?.querySelector('input, textarea, select');
|
|
45
|
+
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Step 1: Unlock + Scan Project ---
|
|
49
|
+
|
|
50
|
+
const vaultPasswordInput = $('#vault-password');
|
|
51
|
+
const vaultStatus = $('#vault-status');
|
|
52
|
+
const unlockVaultBtn = $('#unlock-vault');
|
|
53
|
+
const projectCard = $('#project-card');
|
|
54
|
+
|
|
55
|
+
$('#toggle-vault-visibility').addEventListener('click', () => {
|
|
56
|
+
const isPassword = vaultPasswordInput.type === 'password';
|
|
57
|
+
vaultPasswordInput.type = isPassword ? 'text' : 'password';
|
|
58
|
+
$('#toggle-vault-visibility').textContent = isPassword ? 'Hide' : 'Show';
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Check vault state on load — skip unlock if already open
|
|
62
|
+
fetch('/api/credentials/status')
|
|
63
|
+
.then((r) => r.json())
|
|
64
|
+
.then((data) => {
|
|
65
|
+
if (data.unlocked) {
|
|
66
|
+
showStatus(vaultStatus, 'success', 'Vault already unlocked');
|
|
67
|
+
unlockVaultBtn.style.display = 'none';
|
|
68
|
+
vaultPasswordInput.style.display = 'none';
|
|
69
|
+
$('#toggle-vault-visibility').style.display = 'none';
|
|
70
|
+
projectCard.classList.remove('hidden');
|
|
71
|
+
$('#project-dir').focus();
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
.catch(() => {});
|
|
75
|
+
|
|
76
|
+
unlockVaultBtn.addEventListener('click', async () => {
|
|
77
|
+
const password = vaultPasswordInput.value;
|
|
78
|
+
if (!password) { showStatus(vaultStatus, 'error', 'Please enter your password'); return; }
|
|
79
|
+
|
|
80
|
+
showStatus(vaultStatus, 'loading', 'Unlocking...');
|
|
81
|
+
unlockVaultBtn.disabled = true;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch('/api/credentials/unlock', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
87
|
+
body: JSON.stringify({ password }),
|
|
88
|
+
});
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
|
|
91
|
+
if (res.ok && data.unlocked) {
|
|
92
|
+
showStatus(vaultStatus, 'success', 'Vault unlocked');
|
|
93
|
+
projectCard.classList.remove('hidden');
|
|
94
|
+
$('#project-dir').focus();
|
|
95
|
+
} else {
|
|
96
|
+
showStatus(vaultStatus, 'error', data.error || 'Failed to unlock');
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
showStatus(vaultStatus, 'error', 'Connection error: ' + err.message);
|
|
100
|
+
} finally {
|
|
101
|
+
unlockVaultBtn.disabled = false;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Scan project
|
|
106
|
+
const projectDirInput = $('#project-dir');
|
|
107
|
+
const projectStatus = $('#project-status');
|
|
108
|
+
const scanProjectBtn = $('#scan-project');
|
|
109
|
+
|
|
110
|
+
scanProjectBtn.addEventListener('click', async () => {
|
|
111
|
+
const dir = projectDirInput.value.trim();
|
|
112
|
+
if (!dir) { showStatus(projectStatus, 'error', 'Enter your project directory'); return; }
|
|
113
|
+
|
|
114
|
+
showStatus(projectStatus, 'loading', 'Scanning project...');
|
|
115
|
+
scanProjectBtn.disabled = true;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch('/api/deploy/scan', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
121
|
+
body: JSON.stringify({ directory: dir }),
|
|
122
|
+
});
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
|
|
125
|
+
if (res.ok && data.valid) {
|
|
126
|
+
state.projectDir = dir;
|
|
127
|
+
state.projectName = data.name;
|
|
128
|
+
state.deployTarget = data.deploy || 'docker';
|
|
129
|
+
state.framework = data.framework || '';
|
|
130
|
+
state.database = data.database || 'none';
|
|
131
|
+
state.cache = data.cache || 'none';
|
|
132
|
+
state.instanceType = data.instanceType || 't3.micro';
|
|
133
|
+
state.hostname = data.hostname || '';
|
|
134
|
+
|
|
135
|
+
showStatus(projectStatus, 'success', `Found: ${data.name} (${data.deploy || 'docker'})`);
|
|
136
|
+
// Show continue button instead of auto-advancing
|
|
137
|
+
scanProjectBtn.textContent = 'Continue';
|
|
138
|
+
scanProjectBtn.onclick = () => goToStep2();
|
|
139
|
+
} else {
|
|
140
|
+
showStatus(projectStatus, 'error', data.error || 'Not a valid VoidForge project');
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
showStatus(projectStatus, 'error', 'Scan failed: ' + err.message);
|
|
144
|
+
} finally {
|
|
145
|
+
scanProjectBtn.disabled = false;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// --- Step 2: Review & Configure ---
|
|
150
|
+
|
|
151
|
+
const DEPLOY_DESCRIPTIONS = {
|
|
152
|
+
docker: 'This will generate a Dockerfile, docker-compose.yml, and .dockerignore. No cloud resources will be created.',
|
|
153
|
+
vps: 'This will create AWS resources: EC2 instance, security group, SSH key pair. These resources will incur AWS charges.',
|
|
154
|
+
vercel: 'This will create a project on your Vercel account. Free tier covers most hobby projects.',
|
|
155
|
+
railway: 'This will create a project on your Railway account with optional database/Redis services.',
|
|
156
|
+
cloudflare: 'This will create a Cloudflare Pages project, optionally with a D1 database. Pages has a generous free tier.',
|
|
157
|
+
static: 'This will create an S3 bucket configured for static website hosting. Minimal AWS charges.',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function goToStep2() {
|
|
161
|
+
// Populate step 2 summary
|
|
162
|
+
$('#summary-name').textContent = state.projectName;
|
|
163
|
+
$('#summary-framework').textContent = state.framework || 'auto-detect';
|
|
164
|
+
$('#summary-database').textContent = state.database || 'none';
|
|
165
|
+
|
|
166
|
+
// Set deploy target dropdown
|
|
167
|
+
const deploySelect = $('#summary-deploy-select');
|
|
168
|
+
deploySelect.value = state.deployTarget;
|
|
169
|
+
updateDeployDescription();
|
|
170
|
+
|
|
171
|
+
// Set hostname and instance type
|
|
172
|
+
$('#summary-hostname').value = state.hostname;
|
|
173
|
+
$('#summary-instance-type').value = state.instanceType;
|
|
174
|
+
|
|
175
|
+
showStep(2);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function updateDeployDescription() {
|
|
179
|
+
const target = $('#summary-deploy-select').value;
|
|
180
|
+
state.deployTarget = target;
|
|
181
|
+
$('#provision-confirm-desc').textContent = DEPLOY_DESCRIPTIONS[target] || 'This will provision your deploy target.';
|
|
182
|
+
|
|
183
|
+
// Show/hide instance type selector for VPS target
|
|
184
|
+
const instanceRow = $('#instance-type-row');
|
|
185
|
+
if (target === 'vps') {
|
|
186
|
+
instanceRow.classList.remove('hidden');
|
|
187
|
+
} else {
|
|
188
|
+
instanceRow.classList.add('hidden');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Show/hide domain registration checkbox (needs hostname + Cloudflare credentials)
|
|
192
|
+
updateRegisterDomainVisibility();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function updateRegisterDomainVisibility() {
|
|
196
|
+
const registerRow = $('#register-domain-row');
|
|
197
|
+
const hostname = $('#summary-hostname').value.trim();
|
|
198
|
+
// Basic domain format check: must contain a dot and look like a valid domain
|
|
199
|
+
const isValidDomain = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(hostname);
|
|
200
|
+
// Show registration option when a valid domain is entered and target is not Docker
|
|
201
|
+
if (hostname && isValidDomain && state.deployTarget !== 'docker') {
|
|
202
|
+
registerRow.classList.remove('hidden');
|
|
203
|
+
registerRow.classList.add('highlight');
|
|
204
|
+
setTimeout(() => registerRow.classList.remove('highlight'), 600);
|
|
205
|
+
const labelSpan = $('#summary-register-domain').nextElementSibling;
|
|
206
|
+
if (labelSpan) {
|
|
207
|
+
labelSpan.textContent = `Register ${hostname} via Cloudflare Registrar (~$10-15/year, non-refundable)`;
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
registerRow.classList.add('hidden');
|
|
211
|
+
$('#summary-register-domain').checked = false;
|
|
212
|
+
state.registerDomain = false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
$('#summary-deploy-select').addEventListener('change', updateDeployDescription);
|
|
217
|
+
|
|
218
|
+
$('#summary-hostname').addEventListener('input', () => {
|
|
219
|
+
state.hostname = $('#summary-hostname').value.trim();
|
|
220
|
+
updateRegisterDomainVisibility();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
$('#summary-register-domain').addEventListener('change', () => {
|
|
224
|
+
state.registerDomain = $('#summary-register-domain').checked;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
$('#summary-instance-type').addEventListener('change', () => {
|
|
228
|
+
state.instanceType = $('#summary-instance-type').value;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Back to step 1
|
|
232
|
+
$('#back-to-project').addEventListener('click', () => {
|
|
233
|
+
scanProjectBtn.textContent = 'Scan Project';
|
|
234
|
+
scanProjectBtn.onclick = null;
|
|
235
|
+
showStep(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
$('#start-provision').addEventListener('click', () => {
|
|
239
|
+
if (state.registerDomain) {
|
|
240
|
+
const hostname = state.hostname || 'this domain';
|
|
241
|
+
const confirmed = confirm(
|
|
242
|
+
`You are about to purchase "${hostname}" via Cloudflare Registrar.\n\n` +
|
|
243
|
+
`Cost: ~$10-15/year\n` +
|
|
244
|
+
`This is non-refundable and cannot be undone.\n\n` +
|
|
245
|
+
`Continue?`
|
|
246
|
+
);
|
|
247
|
+
if (!confirmed) return;
|
|
248
|
+
}
|
|
249
|
+
showStep(3);
|
|
250
|
+
runProvisioning();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// --- Step 3: Provisioning ---
|
|
254
|
+
|
|
255
|
+
const provisionLog = $('#provision-log');
|
|
256
|
+
const provisionDoneActions = $('#provision-done-actions');
|
|
257
|
+
const provisionErrorActions = $('#provision-error-actions');
|
|
258
|
+
const provisionSrStatus = $('#provision-sr-status');
|
|
259
|
+
|
|
260
|
+
const STATUS_ICONS = {
|
|
261
|
+
started: '\u25CF',
|
|
262
|
+
done: '\u2713',
|
|
263
|
+
error: '\u2717',
|
|
264
|
+
skipped: '\u2014',
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
let provisionEventCount = 0;
|
|
268
|
+
let hasNonFatalErrors = false;
|
|
269
|
+
|
|
270
|
+
function addProvisionEvent(event) {
|
|
271
|
+
if (event.status === 'error' && event.step && (event.step.startsWith('registrar') || event.step.startsWith('dns'))) {
|
|
272
|
+
hasNonFatalErrors = true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const emptyEl = $('#provision-empty');
|
|
276
|
+
if (emptyEl) emptyEl.remove();
|
|
277
|
+
|
|
278
|
+
let stepEl = provisionLog.querySelector(`[data-step="${event.step}"]`);
|
|
279
|
+
if (!stepEl) {
|
|
280
|
+
stepEl = document.createElement('div');
|
|
281
|
+
stepEl.dataset.step = event.step;
|
|
282
|
+
provisionLog.appendChild(stepEl);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
stepEl.innerHTML = `
|
|
286
|
+
<div class="provision-step">
|
|
287
|
+
<span class="provision-icon ${event.status}">${STATUS_ICONS[event.status] || ''}</span>
|
|
288
|
+
<span class="provision-message">${escapeHtml(event.message)}</span>
|
|
289
|
+
</div>
|
|
290
|
+
${event.detail ? `<div class="provision-detail">${escapeHtml(event.detail)}</div>` : ''}`;
|
|
291
|
+
|
|
292
|
+
provisionLog.scrollTop = provisionLog.scrollHeight;
|
|
293
|
+
if (event.status === 'done') provisionEventCount++;
|
|
294
|
+
provisionSrStatus.textContent = `${provisionEventCount} steps completed`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function runProvisioning() {
|
|
298
|
+
provisionEventCount = 0;
|
|
299
|
+
hasNonFatalErrors = false;
|
|
300
|
+
|
|
301
|
+
const deployNames = { vps: 'AWS VPS', vercel: 'Vercel', railway: 'Railway', cloudflare: 'Cloudflare', static: 'Static S3', docker: 'Docker' };
|
|
302
|
+
$('#provision-log-subtitle').textContent = `Provisioning ${deployNames[state.deployTarget] || state.deployTarget}...`;
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch('/api/provision/start', {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
308
|
+
body: JSON.stringify({
|
|
309
|
+
projectDir: state.projectDir,
|
|
310
|
+
projectName: state.projectName,
|
|
311
|
+
deployTarget: state.deployTarget,
|
|
312
|
+
framework: state.framework,
|
|
313
|
+
database: state.database,
|
|
314
|
+
cache: state.cache,
|
|
315
|
+
instanceType: state.instanceType,
|
|
316
|
+
hostname: state.hostname || undefined,
|
|
317
|
+
registerDomain: state.registerDomain ? true : undefined,
|
|
318
|
+
}),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const contentType = res.headers.get('Content-Type') || '';
|
|
322
|
+
if (!contentType.includes('text/event-stream')) {
|
|
323
|
+
const data = await res.json();
|
|
324
|
+
addProvisionEvent({ step: 'error', status: 'error', message: data.error || 'Provisioning failed' });
|
|
325
|
+
provisionErrorActions.classList.remove('hidden');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const reader = res.body.getReader();
|
|
330
|
+
const decoder = new TextDecoder();
|
|
331
|
+
let buffer = '';
|
|
332
|
+
|
|
333
|
+
while (true) {
|
|
334
|
+
const { done, value } = await reader.read();
|
|
335
|
+
if (done) break;
|
|
336
|
+
|
|
337
|
+
buffer += decoder.decode(value, { stream: true });
|
|
338
|
+
const lines = buffer.split('\n');
|
|
339
|
+
buffer = lines.pop() || '';
|
|
340
|
+
|
|
341
|
+
for (const line of lines) {
|
|
342
|
+
if (!line.startsWith('data: ')) continue;
|
|
343
|
+
const data = line.slice(6);
|
|
344
|
+
if (data === '[DONE]') continue;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const event = JSON.parse(data);
|
|
348
|
+
if (event.result) state.provisionResult = event.result;
|
|
349
|
+
if (event.runId) state.provisionRunId = event.runId;
|
|
350
|
+
addProvisionEvent(event);
|
|
351
|
+
} catch { /* skip */ }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (state.provisionResult && state.provisionResult.success) {
|
|
356
|
+
provisionDoneActions.classList.remove('hidden');
|
|
357
|
+
if (hasNonFatalErrors) {
|
|
358
|
+
$('#provision-log-subtitle').textContent = 'Infrastructure provisioned. Some optional steps had issues \u2014 see log above.';
|
|
359
|
+
provisionSrStatus.textContent = 'Infrastructure provisioned with warnings. Some optional steps failed. Press Continue.';
|
|
360
|
+
} else {
|
|
361
|
+
provisionSrStatus.textContent = 'Provisioning complete. Press Continue.';
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
provisionErrorActions.classList.remove('hidden');
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
addProvisionEvent({ step: 'connection', status: 'error', message: 'Connection error: ' + err.message });
|
|
368
|
+
provisionErrorActions.classList.remove('hidden');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
$('#provision-next').addEventListener('click', () => goToDone());
|
|
373
|
+
|
|
374
|
+
$('#provision-cleanup').addEventListener('click', async () => {
|
|
375
|
+
addProvisionEvent({ step: 'cleanup', status: 'started', message: 'Cleaning up resources...' });
|
|
376
|
+
try {
|
|
377
|
+
const res = await fetch('/api/provision/cleanup', {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
380
|
+
body: JSON.stringify({ runId: state.provisionRunId }),
|
|
381
|
+
});
|
|
382
|
+
const data = await res.json();
|
|
383
|
+
addProvisionEvent({ step: 'cleanup', status: data.cleaned ? 'done' : 'error', message: data.message || data.error || 'Unknown' });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
addProvisionEvent({ step: 'cleanup', status: 'error', message: 'Cleanup error: ' + err.message });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
$('#provision-continue').addEventListener('click', () => goToDone());
|
|
390
|
+
|
|
391
|
+
// --- Step 4: Done ---
|
|
392
|
+
|
|
393
|
+
function goToDone() {
|
|
394
|
+
showStep(4);
|
|
395
|
+
populateDone();
|
|
396
|
+
setTimeout(() => { const h = $('#step-4-heading'); if (h) h.focus(); }, 100);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function populateDone() {
|
|
400
|
+
const result = state.provisionResult;
|
|
401
|
+
|
|
402
|
+
$('#done-details').innerHTML = `
|
|
403
|
+
<p><strong>${escapeHtml(state.projectName)}</strong></p>
|
|
404
|
+
<p style="color: var(--text-dim); font-family: var(--mono); font-size: 13px;">${escapeHtml(state.projectDir)}</p>
|
|
405
|
+
`;
|
|
406
|
+
|
|
407
|
+
// Infra details
|
|
408
|
+
const infraCard = $('#infra-details-card');
|
|
409
|
+
const infraDetails = $('#infra-details');
|
|
410
|
+
|
|
411
|
+
const labelMap = {
|
|
412
|
+
'SSH_KEY_PATH': 'SSH Key', 'SSH_HOST': 'Server IP', 'SSH_USER': 'SSH User',
|
|
413
|
+
'DB_ENGINE': 'Database', 'DB_PORT': 'DB Port', 'DB_INSTANCE_ID': 'DB Instance',
|
|
414
|
+
'DB_USERNAME': 'DB Username', 'DB_PASSWORD': 'DB Password',
|
|
415
|
+
'REDIS_CLUSTER_ID': 'Redis Cluster',
|
|
416
|
+
'VERCEL_PROJECT_ID': 'Vercel Project ID', 'VERCEL_PROJECT_NAME': 'Project Name',
|
|
417
|
+
'RAILWAY_PROJECT_ID': 'Railway Project ID', 'RAILWAY_PROJECT_NAME': 'Project Name',
|
|
418
|
+
'RAILWAY_DB_PLUGIN': 'Database Service',
|
|
419
|
+
'CF_ACCOUNT_ID': 'Cloudflare Account', 'CF_PROJECT_NAME': 'Project Name',
|
|
420
|
+
'CF_PROJECT_URL': 'Site URL', 'CF_D1_DATABASE_ID': 'D1 Database ID',
|
|
421
|
+
'CF_D1_DATABASE_NAME': 'D1 Database',
|
|
422
|
+
'S3_BUCKET': 'S3 Bucket', 'S3_WEBSITE_URL': 'Website URL',
|
|
423
|
+
'DB_HOST': 'Database Host', 'REDIS_HOST': 'Redis Host', 'REDIS_PORT': 'Redis Port',
|
|
424
|
+
'REGISTRAR_DOMAIN': 'Registered Domain', 'REGISTRAR_EXPIRY': 'Domain Expiry',
|
|
425
|
+
'DNS_HOSTNAME': 'Domain', 'DNS_ZONE_ID': 'DNS Zone ID',
|
|
426
|
+
'VERCEL_DOMAIN': 'Custom Domain', 'RAILWAY_DOMAIN': 'Custom Domain',
|
|
427
|
+
'CF_CUSTOM_DOMAIN': 'Custom Domain',
|
|
428
|
+
'DEPLOY_URL': 'Live URL',
|
|
429
|
+
'GITHUB_REPO_URL': 'GitHub Repository',
|
|
430
|
+
'GITHUB_OWNER': 'GitHub Owner',
|
|
431
|
+
'GITHUB_REPO_NAME': 'GitHub Repo',
|
|
432
|
+
};
|
|
433
|
+
const sensitiveKeys = ['DB_PASSWORD'];
|
|
434
|
+
const urlKeys = ['CF_PROJECT_URL', 'S3_WEBSITE_URL', 'DEPLOY_URL', 'GITHUB_REPO_URL'];
|
|
435
|
+
|
|
436
|
+
if (result?.success && result.outputs && Object.keys(result.outputs).length > 0) {
|
|
437
|
+
infraCard.classList.remove('hidden');
|
|
438
|
+
let html = '';
|
|
439
|
+
|
|
440
|
+
const displayOrder = [
|
|
441
|
+
'DEPLOY_URL', 'GITHUB_REPO_URL',
|
|
442
|
+
'SSH_KEY_PATH', 'SSH_HOST', 'SSH_USER',
|
|
443
|
+
'DB_ENGINE', 'DB_HOST', 'DB_PORT', 'DB_INSTANCE_ID', 'DB_USERNAME', 'DB_PASSWORD',
|
|
444
|
+
'REDIS_HOST', 'REDIS_PORT', 'REDIS_CLUSTER_ID',
|
|
445
|
+
'VERCEL_PROJECT_ID', 'VERCEL_PROJECT_NAME', 'VERCEL_DOMAIN',
|
|
446
|
+
'RAILWAY_PROJECT_ID', 'RAILWAY_PROJECT_NAME', 'RAILWAY_DB_PLUGIN', 'RAILWAY_DOMAIN',
|
|
447
|
+
'CF_ACCOUNT_ID', 'CF_PROJECT_NAME', 'CF_PROJECT_URL', 'CF_D1_DATABASE_ID', 'CF_D1_DATABASE_NAME', 'CF_CUSTOM_DOMAIN',
|
|
448
|
+
'S3_BUCKET', 'S3_WEBSITE_URL',
|
|
449
|
+
'REGISTRAR_DOMAIN', 'REGISTRAR_EXPIRY',
|
|
450
|
+
'DNS_HOSTNAME', 'DNS_ZONE_ID',
|
|
451
|
+
];
|
|
452
|
+
const outputKeys = Object.keys(result.outputs);
|
|
453
|
+
const orderedKeys = displayOrder.filter((k) => outputKeys.includes(k));
|
|
454
|
+
const remainingKeys = outputKeys.filter((k) => !displayOrder.includes(k));
|
|
455
|
+
const sortedKeys = [...orderedKeys, ...remainingKeys];
|
|
456
|
+
|
|
457
|
+
for (const key of sortedKeys) {
|
|
458
|
+
const value = result.outputs[key];
|
|
459
|
+
const label = labelMap[key] || key.replace(/_/g, ' ');
|
|
460
|
+
const isSensitive = sensitiveKeys.includes(key);
|
|
461
|
+
const isUrl = urlKeys.includes(key);
|
|
462
|
+
let displayValue;
|
|
463
|
+
if (isSensitive) {
|
|
464
|
+
displayValue = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022';
|
|
465
|
+
} else if (isUrl) {
|
|
466
|
+
displayValue = `<a href="${escapeHtml(value)}" target="_blank" rel="noopener">${escapeHtml(value)}</a>`;
|
|
467
|
+
} else if (key === 'REGISTRAR_EXPIRY' && value) {
|
|
468
|
+
try {
|
|
469
|
+
displayValue = new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(new Date(value));
|
|
470
|
+
} catch { displayValue = escapeHtml(value); }
|
|
471
|
+
} else {
|
|
472
|
+
displayValue = escapeHtml(value);
|
|
473
|
+
}
|
|
474
|
+
html += `<div class="infra-item"><span class="infra-label">${escapeHtml(label)}</span><span class="infra-value">${displayValue}</span></div>`;
|
|
475
|
+
}
|
|
476
|
+
if (result.files && result.files.length > 0) {
|
|
477
|
+
html += `<div class="infra-item"><span class="infra-label">Generated files</span><span class="infra-value">${result.files.length} files</span></div>`;
|
|
478
|
+
}
|
|
479
|
+
infraDetails.innerHTML = html;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Next steps per target
|
|
483
|
+
const nextSteps = $('#next-steps-list');
|
|
484
|
+
let stepsHtml = '';
|
|
485
|
+
let deployCmd = '';
|
|
486
|
+
|
|
487
|
+
if (result && result.success && result.outputs['SSH_HOST']) {
|
|
488
|
+
deployCmd = `cd "${state.projectDir}" && ./infra/deploy.sh`;
|
|
489
|
+
stepsHtml += `<li>SSH into your server: <code>ssh -i .ssh/deploy-key.pem ec2-user@${escapeHtml(result.outputs['SSH_HOST'])}</code></li>`;
|
|
490
|
+
stepsHtml += '<li>Run <code>infra/provision.sh</code> on the server to install dependencies</li>';
|
|
491
|
+
stepsHtml += `<li>Deploy: <code>./infra/deploy.sh</code></li>`;
|
|
492
|
+
} else if (state.deployTarget === 'vercel') {
|
|
493
|
+
deployCmd = `cd "${state.projectDir}" && npx vercel deploy`;
|
|
494
|
+
stepsHtml += '<li>Link: <code>npx vercel link</code></li>';
|
|
495
|
+
stepsHtml += '<li>Deploy: <code>npx vercel deploy</code></li>';
|
|
496
|
+
} else if (state.deployTarget === 'railway') {
|
|
497
|
+
const rid = result?.outputs?.['RAILWAY_PROJECT_ID'] || '';
|
|
498
|
+
deployCmd = `cd "${state.projectDir}" && railway up`;
|
|
499
|
+
if (rid) stepsHtml += `<li>Link: <code>railway link ${escapeHtml(rid)}</code></li>`;
|
|
500
|
+
stepsHtml += '<li>Deploy: <code>railway up</code></li>';
|
|
501
|
+
} else if (state.deployTarget === 'cloudflare') {
|
|
502
|
+
deployCmd = `cd "${state.projectDir}" && npx wrangler pages deploy ./dist`;
|
|
503
|
+
stepsHtml += '<li>Deploy: <code>npx wrangler pages deploy ./dist</code></li>';
|
|
504
|
+
if (result?.outputs?.['CF_PROJECT_URL']) {
|
|
505
|
+
stepsHtml += `<li>Visit: <a href="${escapeHtml(result.outputs['CF_PROJECT_URL'])}" target="_blank" rel="noopener">${escapeHtml(result.outputs['CF_PROJECT_URL'])}</a></li>`;
|
|
506
|
+
}
|
|
507
|
+
} else if (state.deployTarget === 'static') {
|
|
508
|
+
deployCmd = `cd "${state.projectDir}" && ./infra/deploy-s3.sh`;
|
|
509
|
+
stepsHtml += '<li>Deploy: <code>./infra/deploy-s3.sh</code></li>';
|
|
510
|
+
if (result?.outputs?.['S3_WEBSITE_URL']) {
|
|
511
|
+
stepsHtml += `<li>Visit: <a href="${escapeHtml(result.outputs['S3_WEBSITE_URL'])}" target="_blank" rel="noopener">${escapeHtml(result.outputs['S3_WEBSITE_URL'])}</a></li>`;
|
|
512
|
+
}
|
|
513
|
+
} else if (state.deployTarget === 'docker') {
|
|
514
|
+
deployCmd = `cd "${state.projectDir}" && docker compose up -d`;
|
|
515
|
+
stepsHtml += '<li>Run: <code>docker compose up -d</code></li>';
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
nextSteps.innerHTML = stepsHtml;
|
|
519
|
+
|
|
520
|
+
// Store deploy command for the copy button
|
|
521
|
+
state.deployCmd = deployCmd;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Copy deploy command button — bound once, reads from state
|
|
525
|
+
$('#copy-deploy-cmd').addEventListener('click', () => {
|
|
526
|
+
if (state.deployCmd) {
|
|
527
|
+
copyToClipboard(state.deployCmd).then(() => {
|
|
528
|
+
$('#copy-deploy-cmd').textContent = 'Copied!';
|
|
529
|
+
setTimeout(() => { $('#copy-deploy-cmd').textContent = 'Copy Deploy Command'; }, 2000);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// --- Utilities ---
|
|
535
|
+
|
|
536
|
+
function escapeHtml(str) {
|
|
537
|
+
const div = document.createElement('div');
|
|
538
|
+
div.appendChild(document.createTextNode(str));
|
|
539
|
+
return div.innerHTML;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function showStatus(el, type, message) {
|
|
543
|
+
el.className = 'status-row ' + type;
|
|
544
|
+
el.textContent = message;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function copyToClipboard(text) {
|
|
548
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
549
|
+
return navigator.clipboard.writeText(text);
|
|
550
|
+
}
|
|
551
|
+
return new Promise((resolve, reject) => {
|
|
552
|
+
const ta = document.createElement('textarea');
|
|
553
|
+
ta.value = text;
|
|
554
|
+
ta.style.position = 'fixed';
|
|
555
|
+
ta.style.opacity = '0';
|
|
556
|
+
document.body.appendChild(ta);
|
|
557
|
+
ta.select();
|
|
558
|
+
try { document.execCommand('copy'); resolve(); }
|
|
559
|
+
catch (e) { reject(e); }
|
|
560
|
+
finally { document.body.removeChild(ta); }
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Keyboard: Enter triggers contextual action
|
|
565
|
+
document.addEventListener('keydown', (e) => {
|
|
566
|
+
if (e.key !== 'Enter') return;
|
|
567
|
+
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON') return;
|
|
568
|
+
e.preventDefault();
|
|
569
|
+
|
|
570
|
+
if (currentStep === 1) {
|
|
571
|
+
if (!projectCard.classList.contains('hidden') && projectDirInput.value.trim()) {
|
|
572
|
+
scanProjectBtn.click();
|
|
573
|
+
} else if (vaultPasswordInput.value) {
|
|
574
|
+
unlockVaultBtn.click();
|
|
575
|
+
}
|
|
576
|
+
} else if (currentStep === 2) {
|
|
577
|
+
$('#start-provision').click();
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
showStep(1);
|
|
582
|
+
})();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" style="stop-color:#ff6b35"/>
|
|
5
|
+
<stop offset="50%" style="stop-color:#ffd700"/>
|
|
6
|
+
<stop offset="100%" style="stop-color:#00d4ff"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<rect width="32" height="32" rx="6" fill="#0a0a0f"/>
|
|
10
|
+
<text x="16" y="24" text-anchor="middle" font-family="Impact,sans-serif" font-size="22" font-weight="bold" fill="url(#g)">V</text>
|
|
11
|
+
</svg>
|