@ssm123ssm/vault 0.1.10 → 0.1.11
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/index.js +1 -1
- package/package.json +1 -1
- package/preview-agent.js +862 -0
package/index.js
CHANGED
package/package.json
CHANGED
package/preview-agent.js
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const fsSync = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
const crypto = require("crypto");
|
|
8
|
+
const prompts = require("prompts");
|
|
9
|
+
const WebSocket = require("ws");
|
|
10
|
+
const sodium = require("libsodium-wrappers");
|
|
11
|
+
const http = require("http");
|
|
12
|
+
const { spawn } = require("child_process");
|
|
13
|
+
|
|
14
|
+
const CONFIG_DIR = path.join(os.homedir(), ".preview");
|
|
15
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "agent.json");
|
|
16
|
+
|
|
17
|
+
const MIME_TYPES = {
|
|
18
|
+
".html": "text/html",
|
|
19
|
+
".htm": "text/html",
|
|
20
|
+
".pdf": "application/pdf",
|
|
21
|
+
".csv": "text/csv",
|
|
22
|
+
".json": "application/json",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".jpg": "image/jpeg",
|
|
25
|
+
".jpeg": "image/jpeg",
|
|
26
|
+
".svg": "image/svg+xml",
|
|
27
|
+
".txt": "text/plain",
|
|
28
|
+
".md": "text/markdown",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function getMimeType(filename) {
|
|
32
|
+
const ext = path.extname(filename).toLowerCase();
|
|
33
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toBase64(buffer) {
|
|
37
|
+
return Buffer.from(buffer).toString("base64");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deriveKey(passphrase, salt) {
|
|
41
|
+
return crypto.pbkdf2Sync(passphrase, salt, 250000, 32, "sha256");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encryptBuffer(key, payload) {
|
|
45
|
+
const iv = crypto.randomBytes(12);
|
|
46
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
47
|
+
const ciphertext = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
48
|
+
const tag = cipher.getAuthTag();
|
|
49
|
+
return { iv, ciphertext: Buffer.concat([ciphertext, tag]) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function walkDir(rootDir) {
|
|
53
|
+
const entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
54
|
+
const files = [];
|
|
55
|
+
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (entry.name.startsWith(".")) continue;
|
|
58
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
files.push(...(await walkDir(fullPath)));
|
|
61
|
+
} else {
|
|
62
|
+
files.push(fullPath);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loadConfig() {
|
|
70
|
+
try {
|
|
71
|
+
const raw = await fs.readFile(CONFIG_PATH, "utf8");
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
if (parsed?.projects) {
|
|
74
|
+
for (const [key, value] of Object.entries(parsed.projects)) {
|
|
75
|
+
if (value && typeof value.source === "string") {
|
|
76
|
+
parsed.projects[key].source = sanitizePath(value.source);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
} catch (_error) {
|
|
82
|
+
return { appUrl: null, agentId: null, sessionToken: null, projects: {} };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function saveConfig(config) {
|
|
87
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
88
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toWsUrl(appUrl) {
|
|
92
|
+
if (!appUrl) return null;
|
|
93
|
+
if (appUrl.startsWith("ws://") || appUrl.startsWith("wss://")) {
|
|
94
|
+
return appUrl.endsWith("/api/agents/socket")
|
|
95
|
+
? appUrl
|
|
96
|
+
: appUrl.replace(/\/$/, "") + "/api/agents/socket";
|
|
97
|
+
}
|
|
98
|
+
return appUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/agents/socket";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeWsUrl(url, fallbackAppUrl) {
|
|
102
|
+
if (!url) return toWsUrl(fallbackAppUrl);
|
|
103
|
+
if (url.startsWith("ws://") || url.startsWith("wss://")) return toWsUrl(url);
|
|
104
|
+
return toWsUrl(url);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sanitizePath(input) {
|
|
108
|
+
const trimmed = input.trim();
|
|
109
|
+
if (
|
|
110
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
111
|
+
(trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
|
112
|
+
) {
|
|
113
|
+
return trimmed.slice(1, -1);
|
|
114
|
+
}
|
|
115
|
+
return trimmed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function listDirectories(dir) {
|
|
119
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
120
|
+
return entries
|
|
121
|
+
.filter((entry) => entry.isDirectory())
|
|
122
|
+
.map((entry) => entry.name)
|
|
123
|
+
.sort((a, b) => a.localeCompare(b));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function selectDirectory(startDir) {
|
|
127
|
+
let current = path.resolve(startDir || process.cwd());
|
|
128
|
+
while (true) {
|
|
129
|
+
let choices = [];
|
|
130
|
+
try {
|
|
131
|
+
const dirs = await listDirectories(current);
|
|
132
|
+
choices = [
|
|
133
|
+
{ title: "Use this directory", value: "__select__" },
|
|
134
|
+
{ title: "..", value: "__up__" },
|
|
135
|
+
...dirs.map((name) => ({
|
|
136
|
+
title: name + path.sep,
|
|
137
|
+
value: path.join(current, name),
|
|
138
|
+
})),
|
|
139
|
+
];
|
|
140
|
+
} catch (_error) {
|
|
141
|
+
choices = [
|
|
142
|
+
{ title: "Use this directory", value: "__select__" },
|
|
143
|
+
{ title: "..", value: "__up__" },
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const response = await prompts({
|
|
148
|
+
type: "select",
|
|
149
|
+
name: "next",
|
|
150
|
+
message: `Select directory (${current})`,
|
|
151
|
+
choices,
|
|
152
|
+
initial: 0,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!response.next) return current;
|
|
156
|
+
if (response.next === "__select__") return current;
|
|
157
|
+
if (response.next === "__up__") {
|
|
158
|
+
const parent = path.dirname(current);
|
|
159
|
+
if (parent === current) return current;
|
|
160
|
+
current = parent;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
current = response.next;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function selectDirectoryViaServer() {
|
|
168
|
+
const homeDir = os.homedir();
|
|
169
|
+
const port = Number(process.env.PREVIEW_AGENT_UI_PORT || 5595);
|
|
170
|
+
|
|
171
|
+
let resolveSelection;
|
|
172
|
+
let rejectSelection;
|
|
173
|
+
const selectionPromise = new Promise((resolve, reject) => {
|
|
174
|
+
resolveSelection = resolve;
|
|
175
|
+
rejectSelection = reject;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const server = http.createServer(async (req, res) => {
|
|
179
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
180
|
+
|
|
181
|
+
if (url.pathname === "/api/list") {
|
|
182
|
+
const targetPath = url.searchParams.get("path") || homeDir;
|
|
183
|
+
try {
|
|
184
|
+
const entries = await listDirectories(targetPath);
|
|
185
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
186
|
+
res.end(
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
path: targetPath,
|
|
189
|
+
entries: entries.map((name) => ({
|
|
190
|
+
name,
|
|
191
|
+
path: path.join(targetPath, name),
|
|
192
|
+
})),
|
|
193
|
+
parent: path.dirname(targetPath),
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
} catch (_error) {
|
|
197
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
198
|
+
res.end(JSON.stringify({ error: "Unable to list directory." }));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (url.pathname === "/api/select" && req.method === "POST") {
|
|
204
|
+
let body = "";
|
|
205
|
+
req.on("data", (chunk) => {
|
|
206
|
+
body += chunk.toString();
|
|
207
|
+
});
|
|
208
|
+
req.on("end", () => {
|
|
209
|
+
try {
|
|
210
|
+
const payload = JSON.parse(body || "{}");
|
|
211
|
+
const selected = payload.path;
|
|
212
|
+
if (!selected) {
|
|
213
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
214
|
+
res.end(JSON.stringify({ error: "Missing path." }));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
218
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
219
|
+
resolveSelection(selected);
|
|
220
|
+
server.close();
|
|
221
|
+
} catch (_error) {
|
|
222
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
223
|
+
res.end(JSON.stringify({ error: "Invalid payload." }));
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (url.pathname === "/") {
|
|
230
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
231
|
+
res.end(`<!doctype html>
|
|
232
|
+
<html>
|
|
233
|
+
<head>
|
|
234
|
+
<meta charset="utf-8" />
|
|
235
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
236
|
+
<title>Preview Agent - Select Folder</title>
|
|
237
|
+
<style>
|
|
238
|
+
body { font-family: ui-sans-serif, system-ui; background:#0f1117; color:#f3f1eb; margin:0; }
|
|
239
|
+
.wrap { max-width: 720px; margin: 0 auto; padding: 24px; }
|
|
240
|
+
.card { background:#151a24; border:1px solid rgba(255,255,255,0.08); border-radius:16px; padding:16px; }
|
|
241
|
+
.path { font-family: ui-monospace, SFMono-Regular; font-size: 12px; }
|
|
242
|
+
.list { margin-top:12px; display:grid; gap:8px; }
|
|
243
|
+
button { background:#222837; color:#f3f1eb; border:1px solid rgba(255,255,255,0.12); border-radius:12px; padding:8px 12px; cursor:pointer; }
|
|
244
|
+
button:hover { border-color:#f97316; }
|
|
245
|
+
.primary { background:#f97316; color:#1a1410; font-weight:600; }
|
|
246
|
+
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
247
|
+
</style>
|
|
248
|
+
</head>
|
|
249
|
+
<body>
|
|
250
|
+
<div class="wrap">
|
|
251
|
+
<h2>Select a folder for this project</h2>
|
|
252
|
+
<div class="card">
|
|
253
|
+
<div class="row">
|
|
254
|
+
<button id="upBtn">Up</button>
|
|
255
|
+
<button id="useBtn" class="primary">Use this folder</button>
|
|
256
|
+
</div>
|
|
257
|
+
<div class="path" id="path"></div>
|
|
258
|
+
<div class="list" id="list"></div>
|
|
259
|
+
</div>
|
|
260
|
+
<p style="font-size:12px;opacity:0.7;margin-top:12px;">Leave this window open until selection is confirmed.</p>
|
|
261
|
+
</div>
|
|
262
|
+
<script>
|
|
263
|
+
let currentPath = "";
|
|
264
|
+
const pathEl = document.getElementById("path");
|
|
265
|
+
const listEl = document.getElementById("list");
|
|
266
|
+
const upBtn = document.getElementById("upBtn");
|
|
267
|
+
const useBtn = document.getElementById("useBtn");
|
|
268
|
+
|
|
269
|
+
async function load(path) {
|
|
270
|
+
const res = await fetch("/api/list?path=" + encodeURIComponent(path || ""));
|
|
271
|
+
const data = await res.json();
|
|
272
|
+
if (!res.ok) return;
|
|
273
|
+
currentPath = data.path;
|
|
274
|
+
pathEl.textContent = currentPath;
|
|
275
|
+
listEl.innerHTML = "";
|
|
276
|
+
data.entries.forEach((entry) => {
|
|
277
|
+
const btn = document.createElement("button");
|
|
278
|
+
btn.textContent = entry.name + "/";
|
|
279
|
+
btn.onclick = () => load(entry.path);
|
|
280
|
+
listEl.appendChild(btn);
|
|
281
|
+
});
|
|
282
|
+
upBtn.onclick = () => load(data.parent);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
useBtn.onclick = async () => {
|
|
286
|
+
await fetch("/api/select", {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: { "Content-Type": "application/json" },
|
|
289
|
+
body: JSON.stringify({ path: currentPath }),
|
|
290
|
+
});
|
|
291
|
+
useBtn.textContent = "Selected";
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
load("");
|
|
295
|
+
</script>
|
|
296
|
+
</body>
|
|
297
|
+
</html>`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
res.writeHead(404);
|
|
302
|
+
res.end();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
server.listen(port);
|
|
306
|
+
console.log(`Open http://localhost:${port} to select a folder.`);
|
|
307
|
+
|
|
308
|
+
const timeout = setTimeout(() => {
|
|
309
|
+
rejectSelection(new Error("Folder selection timed out."));
|
|
310
|
+
server.close();
|
|
311
|
+
}, 5 * 60 * 1000);
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const selected = await selectionPromise;
|
|
315
|
+
clearTimeout(timeout);
|
|
316
|
+
return selected;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
clearTimeout(timeout);
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getArgValue(flag) {
|
|
324
|
+
const index = process.argv.indexOf(flag);
|
|
325
|
+
if (index === -1) return null;
|
|
326
|
+
const value = process.argv[index + 1];
|
|
327
|
+
if (!value || value.startsWith("--")) return null;
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function hasFlag(flag) {
|
|
332
|
+
return process.argv.includes(flag);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function ensureAgentKeypair(config) {
|
|
336
|
+
await sodium.ready;
|
|
337
|
+
const existing = config.agentKeyPair;
|
|
338
|
+
if (existing?.publicKey && existing?.privateKey) {
|
|
339
|
+
try {
|
|
340
|
+
const decoded = Buffer.from(existing.publicKey, "base64").toString("utf8");
|
|
341
|
+
if (decoded.includes("BEGIN")) {
|
|
342
|
+
// Legacy PEM keypair; regenerate in libsodium format.
|
|
343
|
+
config.agentKeyPair = null;
|
|
344
|
+
} else {
|
|
345
|
+
return existing;
|
|
346
|
+
}
|
|
347
|
+
} catch (_error) {
|
|
348
|
+
// fall through to regenerate
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const keyPair = sodium.crypto_box_keypair();
|
|
352
|
+
const pair = {
|
|
353
|
+
publicKey: sodium.to_base64(keyPair.publicKey, sodium.base64_variants.ORIGINAL),
|
|
354
|
+
privateKey: sodium.to_base64(keyPair.privateKey, sodium.base64_variants.ORIGINAL),
|
|
355
|
+
};
|
|
356
|
+
config.agentKeyPair = pair;
|
|
357
|
+
return pair;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function linkAgent({ silent = false } = {}) {
|
|
361
|
+
const config = await loadConfig();
|
|
362
|
+
const codeFromArg = getArgValue("--code") || null;
|
|
363
|
+
const appUrlFromArg = getArgValue("--app-url") || null;
|
|
364
|
+
const keyPair = await ensureAgentKeypair(config);
|
|
365
|
+
const defaultAppUrl =
|
|
366
|
+
appUrlFromArg ||
|
|
367
|
+
config.appUrl ||
|
|
368
|
+
process.env.PREVIEW_APP_URL ||
|
|
369
|
+
"http://localhost:3000";
|
|
370
|
+
|
|
371
|
+
let response = {
|
|
372
|
+
appUrl: defaultAppUrl,
|
|
373
|
+
code: codeFromArg || "",
|
|
374
|
+
name: os.hostname(),
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (!silent) {
|
|
378
|
+
response = await prompts([
|
|
379
|
+
{
|
|
380
|
+
type: "text",
|
|
381
|
+
name: "appUrl",
|
|
382
|
+
message: "Dashboard URL",
|
|
383
|
+
initial: defaultAppUrl,
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
type: "text",
|
|
387
|
+
name: "code",
|
|
388
|
+
message: "Link code",
|
|
389
|
+
initial: codeFromArg || undefined,
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
type: "text",
|
|
393
|
+
name: "name",
|
|
394
|
+
message: "Agent name",
|
|
395
|
+
initial: os.hostname(),
|
|
396
|
+
},
|
|
397
|
+
]);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!response.code) {
|
|
401
|
+
console.error("Missing link code.");
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const appUrl = response.appUrl.replace(/\/$/, "");
|
|
406
|
+
const claim = await fetch(`${appUrl}/api/agents/claim`, {
|
|
407
|
+
method: "POST",
|
|
408
|
+
headers: { "Content-Type": "application/json" },
|
|
409
|
+
body: JSON.stringify({
|
|
410
|
+
code: response.code,
|
|
411
|
+
name: response.name || os.hostname(),
|
|
412
|
+
publicKey: keyPair.publicKey,
|
|
413
|
+
}),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
if (!claim.ok) {
|
|
417
|
+
const data = await claim.json().catch(() => ({}));
|
|
418
|
+
console.error(data?.error || "Unable to link agent.");
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const data = await claim.json();
|
|
423
|
+
const wsUrlFromEnv = process.env.PREVIEW_AGENT_WS_URL || null;
|
|
424
|
+
const wsUrlFromServer = data.wsUrl || null;
|
|
425
|
+
const updated = {
|
|
426
|
+
...config,
|
|
427
|
+
appUrl,
|
|
428
|
+
agentId: data.agentId,
|
|
429
|
+
sessionToken: data.sessionToken,
|
|
430
|
+
agentName: response.name || config.agentName || os.hostname(),
|
|
431
|
+
agentKeyPair: keyPair,
|
|
432
|
+
wsUrl: normalizeWsUrl(wsUrlFromEnv || wsUrlFromServer, appUrl),
|
|
433
|
+
};
|
|
434
|
+
await saveConfig(updated);
|
|
435
|
+
if (!silent) {
|
|
436
|
+
console.log(`Linked agent ${data.agentId}.`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function addProject() {
|
|
441
|
+
const config = await loadConfig();
|
|
442
|
+
const response = await prompts([
|
|
443
|
+
{
|
|
444
|
+
type: "text",
|
|
445
|
+
name: "projectId",
|
|
446
|
+
message: "Project ID",
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
type: "toggle",
|
|
450
|
+
name: "browse",
|
|
451
|
+
message: "Select source directory",
|
|
452
|
+
initial: true,
|
|
453
|
+
active: "browse",
|
|
454
|
+
inactive: "type",
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
type: (prev) => (prev ? null : "text"),
|
|
458
|
+
name: "source",
|
|
459
|
+
message: "Local source directory",
|
|
460
|
+
initial: ".",
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
type: "password",
|
|
464
|
+
name: "passphrase",
|
|
465
|
+
message: "Project passphrase",
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
type: "text",
|
|
469
|
+
name: "projectName",
|
|
470
|
+
message: "Project name (optional)",
|
|
471
|
+
},
|
|
472
|
+
]);
|
|
473
|
+
|
|
474
|
+
if (!response.projectId || !response.passphrase) {
|
|
475
|
+
console.error("Project ID and passphrase are required.");
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const sourceDir = response.browse
|
|
480
|
+
? await selectDirectory(response.source || process.cwd())
|
|
481
|
+
: sanitizePath(response.source || ".");
|
|
482
|
+
|
|
483
|
+
const projectConfig = {
|
|
484
|
+
source: sourceDir,
|
|
485
|
+
passphrase: response.passphrase,
|
|
486
|
+
projectName: response.projectName || null,
|
|
487
|
+
};
|
|
488
|
+
config.projects = config.projects || {};
|
|
489
|
+
config.projects[response.projectId] = projectConfig;
|
|
490
|
+
await saveConfig(config);
|
|
491
|
+
console.log(`Project ${response.projectId} saved.`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function uploadProject(projectId, commandPayload, config) {
|
|
495
|
+
const projectConfig = config.projects?.[projectId];
|
|
496
|
+
if (!projectConfig) {
|
|
497
|
+
throw new Error(`Project ${projectId} not configured.`);
|
|
498
|
+
}
|
|
499
|
+
if (!commandPayload?.uploadToken) {
|
|
500
|
+
throw new Error("Missing upload token.");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const appUrl = (config.appUrl || "").replace(/\/$/, "");
|
|
504
|
+
const syncBase = `${appUrl}/api/sync`;
|
|
505
|
+
const sourceDir = path.resolve(process.cwd(), projectConfig.source);
|
|
506
|
+
const { filePaths, fingerprint, fileCount, totalBytes } =
|
|
507
|
+
await computeProjectFingerprint(sourceDir);
|
|
508
|
+
if (filePaths.length === 0) {
|
|
509
|
+
throw new Error("No files found to upload.");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const salt = crypto.randomBytes(16);
|
|
513
|
+
const key = deriveKey(projectConfig.passphrase, salt);
|
|
514
|
+
|
|
515
|
+
const manifestFiles = [];
|
|
516
|
+
for (const filePath of filePaths) {
|
|
517
|
+
const payload = await fs.readFile(filePath);
|
|
518
|
+
const hash = crypto.createHash("sha256").update(payload).digest("hex");
|
|
519
|
+
const relativeName = path.relative(sourceDir, filePath);
|
|
520
|
+
const baseName = path.basename(relativeName);
|
|
521
|
+
const fileId = crypto.randomBytes(9).toString("hex");
|
|
522
|
+
const stableHash = crypto.createHash("sha256").update(relativeName).digest("hex");
|
|
523
|
+
const { iv, ciphertext } = encryptBuffer(key, payload);
|
|
524
|
+
|
|
525
|
+
const uploadResponse = await fetch(`${syncBase}/file`, {
|
|
526
|
+
method: "POST",
|
|
527
|
+
headers: {
|
|
528
|
+
"Content-Type": "application/octet-stream",
|
|
529
|
+
"x-preview-project": projectId,
|
|
530
|
+
"x-preview-file": fileId,
|
|
531
|
+
"x-preview-agent-token": commandPayload.uploadToken,
|
|
532
|
+
},
|
|
533
|
+
body: ciphertext,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
if (!uploadResponse.ok) {
|
|
537
|
+
throw new Error(`Upload failed for ${relativeName}.`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const stats = await fs.stat(filePath);
|
|
541
|
+
|
|
542
|
+
manifestFiles.push({
|
|
543
|
+
id: fileId,
|
|
544
|
+
name: baseName,
|
|
545
|
+
path: relativeName,
|
|
546
|
+
size: stats.size,
|
|
547
|
+
type: getMimeType(filePath),
|
|
548
|
+
updatedAt: new Date().toISOString(),
|
|
549
|
+
iv: toBase64(iv),
|
|
550
|
+
hash: `sha256:${hash}`,
|
|
551
|
+
stableId: `sha256:${stableHash}`,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const manifest = {
|
|
556
|
+
version: 1,
|
|
557
|
+
projectId,
|
|
558
|
+
projectName: projectConfig.projectName || null,
|
|
559
|
+
createdAt: new Date().toISOString(),
|
|
560
|
+
files: manifestFiles,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const manifestEncrypted = encryptBuffer(
|
|
564
|
+
key,
|
|
565
|
+
Buffer.from(JSON.stringify(manifest))
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const manifestPayload = {
|
|
569
|
+
iv: toBase64(manifestEncrypted.iv),
|
|
570
|
+
ciphertext: toBase64(manifestEncrypted.ciphertext),
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const projectPayload = {
|
|
574
|
+
projectId,
|
|
575
|
+
projectName: projectConfig.projectName || null,
|
|
576
|
+
salt: toBase64(salt),
|
|
577
|
+
createdAt: new Date().toISOString(),
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const commitResponse = await fetch(`${syncBase}/commit`, {
|
|
581
|
+
method: "POST",
|
|
582
|
+
headers: { "Content-Type": "application/json" },
|
|
583
|
+
body: JSON.stringify({
|
|
584
|
+
projectId,
|
|
585
|
+
agentToken: commandPayload.uploadToken,
|
|
586
|
+
manifest: manifestPayload,
|
|
587
|
+
project: projectPayload,
|
|
588
|
+
}),
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
if (!commitResponse.ok) {
|
|
592
|
+
throw new Error("Failed to commit manifest.");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
projectConfig.lastFingerprint = fingerprint;
|
|
596
|
+
projectConfig.lastFileCount = fileCount;
|
|
597
|
+
projectConfig.lastTotalBytes = totalBytes;
|
|
598
|
+
await saveConfig(config);
|
|
599
|
+
|
|
600
|
+
return { files: manifestFiles.length };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function computeProjectFingerprint(sourceDir) {
|
|
604
|
+
const filePaths = await walkDir(sourceDir);
|
|
605
|
+
const hash = crypto.createHash("sha256");
|
|
606
|
+
let totalBytes = 0;
|
|
607
|
+
for (const filePath of filePaths) {
|
|
608
|
+
const stats = await fs.stat(filePath);
|
|
609
|
+
const relativeName = path.relative(sourceDir, filePath);
|
|
610
|
+
hash.update(relativeName);
|
|
611
|
+
hash.update(String(stats.size));
|
|
612
|
+
hash.update(String(stats.mtimeMs));
|
|
613
|
+
totalBytes += stats.size;
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
filePaths,
|
|
617
|
+
fingerprint: hash.digest("hex"),
|
|
618
|
+
fileCount: filePaths.length,
|
|
619
|
+
totalBytes,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function decryptAgentSecret(encrypted, keyPair) {
|
|
624
|
+
await sodium.ready;
|
|
625
|
+
const cipher = sodium.from_base64(encrypted, sodium.base64_variants.ORIGINAL);
|
|
626
|
+
const priv = sodium.from_base64(keyPair.privateKey, sodium.base64_variants.ORIGINAL);
|
|
627
|
+
const pub = sodium.from_base64(keyPair.publicKey, sodium.base64_variants.ORIGINAL);
|
|
628
|
+
const decrypted = sodium.crypto_box_seal_open(cipher, pub, priv);
|
|
629
|
+
return sodium.to_string(decrypted);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function reportAgentStatus(config) {
|
|
633
|
+
const appUrl = (config.appUrl || "").replace(/\/$/, "");
|
|
634
|
+
const statusUrl = `${appUrl}/api/agents/status`;
|
|
635
|
+
const projectsPayload = [];
|
|
636
|
+
|
|
637
|
+
for (const [projectId, projectConfig] of Object.entries(config.projects || {})) {
|
|
638
|
+
const sourceDir = path.resolve(process.cwd(), projectConfig.source);
|
|
639
|
+
const scan = await computeProjectFingerprint(sourceDir).catch(() => null);
|
|
640
|
+
if (!scan) continue;
|
|
641
|
+
const pending = scan.fingerprint !== projectConfig.lastFingerprint;
|
|
642
|
+
projectsPayload.push({
|
|
643
|
+
projectId,
|
|
644
|
+
fingerprint: scan.fingerprint,
|
|
645
|
+
fileCount: scan.fileCount,
|
|
646
|
+
totalBytes: scan.totalBytes,
|
|
647
|
+
scannedAt: new Date().toISOString(),
|
|
648
|
+
pending,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (projectsPayload.length === 0) return;
|
|
653
|
+
|
|
654
|
+
await fetch(statusUrl, {
|
|
655
|
+
method: "POST",
|
|
656
|
+
headers: {
|
|
657
|
+
"Content-Type": "application/json",
|
|
658
|
+
Authorization: `Bearer ${config.sessionToken}`,
|
|
659
|
+
},
|
|
660
|
+
body: JSON.stringify({ projects: projectsPayload }),
|
|
661
|
+
}).catch(() => null);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function runDaemon() {
|
|
665
|
+
let ws = null;
|
|
666
|
+
let reconnectMs = 2000;
|
|
667
|
+
console.log("Preview agent starting...");
|
|
668
|
+
const daemonMode = hasFlag("--daemon") || hasFlag("--daemon-child");
|
|
669
|
+
|
|
670
|
+
if (hasFlag("--daemon") && !hasFlag("--daemon-child")) {
|
|
671
|
+
const args = process.argv.slice(2).filter((arg) => arg !== "--daemon");
|
|
672
|
+
const logPath = path.join(CONFIG_DIR, "agent.log");
|
|
673
|
+
fsSync.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
674
|
+
const outFd = fsSync.openSync(logPath, "a");
|
|
675
|
+
const child = spawn(process.execPath, [__filename, ...args, "--daemon-child"], {
|
|
676
|
+
detached: true,
|
|
677
|
+
stdio: ["ignore", outFd, outFd],
|
|
678
|
+
});
|
|
679
|
+
child.unref();
|
|
680
|
+
console.log(`Daemon started in background. Logs: ${logPath}`);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const connect = async () => {
|
|
685
|
+
const config = await loadConfig();
|
|
686
|
+
if (!config.sessionToken || !config.appUrl) {
|
|
687
|
+
console.error("Agent not linked. Run preview-agent link first.");
|
|
688
|
+
process.exit(1);
|
|
689
|
+
}
|
|
690
|
+
const agentLabel = config.agentName
|
|
691
|
+
? `${config.agentName} (${config.agentId || "unknown"})`
|
|
692
|
+
: config.agentId || "unknown";
|
|
693
|
+
|
|
694
|
+
const wsUrl = normalizeWsUrl(
|
|
695
|
+
process.env.PREVIEW_AGENT_WS_URL || config.wsUrl,
|
|
696
|
+
config.appUrl
|
|
697
|
+
);
|
|
698
|
+
if (!wsUrl) {
|
|
699
|
+
console.error("Missing wsUrl.");
|
|
700
|
+
process.exit(1);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
console.log(`Connecting to ${wsUrl}...`);
|
|
704
|
+
ws = new WebSocket(wsUrl);
|
|
705
|
+
|
|
706
|
+
ws.on("open", () => {
|
|
707
|
+
reconnectMs = 2000;
|
|
708
|
+
console.log(`WebSocket connected. Authenticating as ${agentLabel}...`);
|
|
709
|
+
ws.send(
|
|
710
|
+
JSON.stringify({
|
|
711
|
+
type: "auth",
|
|
712
|
+
token: config.sessionToken,
|
|
713
|
+
})
|
|
714
|
+
);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
ws.on("message", async (raw) => {
|
|
718
|
+
let data = null;
|
|
719
|
+
try {
|
|
720
|
+
data = JSON.parse(raw.toString());
|
|
721
|
+
} catch (_error) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (data?.type === "auth_ok") {
|
|
726
|
+
console.log(`Authenticated as agent ${agentLabel}.`);
|
|
727
|
+
reportAgentStatus(config).catch(() => null);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (data?.type === "auth_error") {
|
|
732
|
+
console.error(`Auth failed: ${data.error || "unknown error"}`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (data?.type === "command" && data?.command) {
|
|
737
|
+
const command = data.command;
|
|
738
|
+
console.log(
|
|
739
|
+
`[${agentLabel}] Received command ${command.id} (${command.type}).`
|
|
740
|
+
);
|
|
741
|
+
ws.send(JSON.stringify({ type: "command:ack", commandId: command.id }));
|
|
742
|
+
try {
|
|
743
|
+
if (command.type === "SYNC") {
|
|
744
|
+
const result = await uploadProject(
|
|
745
|
+
command.projectId,
|
|
746
|
+
command.payload || {},
|
|
747
|
+
config
|
|
748
|
+
);
|
|
749
|
+
console.log(
|
|
750
|
+
`[${agentLabel}] Sync completed for ${command.projectId} (${result.files} files).`
|
|
751
|
+
);
|
|
752
|
+
ws.send(
|
|
753
|
+
JSON.stringify({
|
|
754
|
+
type: "command:result",
|
|
755
|
+
commandId: command.id,
|
|
756
|
+
status: "SUCCESS",
|
|
757
|
+
details: result,
|
|
758
|
+
})
|
|
759
|
+
);
|
|
760
|
+
} else if (command.type === "CONFIG") {
|
|
761
|
+
const config = await loadConfig();
|
|
762
|
+
const keyPair = await ensureAgentKeypair(config);
|
|
763
|
+
const encryptedPassphrase = command.payload?.encryptedPassphrase;
|
|
764
|
+
if (!encryptedPassphrase) {
|
|
765
|
+
throw new Error("Missing encrypted passphrase.");
|
|
766
|
+
}
|
|
767
|
+
const passphrase = await decryptAgentSecret(
|
|
768
|
+
encryptedPassphrase,
|
|
769
|
+
keyPair
|
|
770
|
+
);
|
|
771
|
+
const projectName = command.payload?.projectName || null;
|
|
772
|
+
const sourceDir = daemonMode
|
|
773
|
+
? await selectDirectoryViaServer()
|
|
774
|
+
: await selectDirectory(process.cwd());
|
|
775
|
+
config.projects = config.projects || {};
|
|
776
|
+
config.projects[command.projectId] = {
|
|
777
|
+
source: sourceDir,
|
|
778
|
+
passphrase,
|
|
779
|
+
projectName,
|
|
780
|
+
};
|
|
781
|
+
await saveConfig(config);
|
|
782
|
+
ws.send(
|
|
783
|
+
JSON.stringify({
|
|
784
|
+
type: "command:result",
|
|
785
|
+
commandId: command.id,
|
|
786
|
+
status: "SUCCESS",
|
|
787
|
+
details: { configured: true },
|
|
788
|
+
})
|
|
789
|
+
);
|
|
790
|
+
} else if (command.type === "REVOKE") {
|
|
791
|
+
console.log(`[${agentLabel}] Agent revoked. Shutting down.`);
|
|
792
|
+
ws.send(
|
|
793
|
+
JSON.stringify({
|
|
794
|
+
type: "command:result",
|
|
795
|
+
commandId: command.id,
|
|
796
|
+
status: "SUCCESS",
|
|
797
|
+
details: { revoked: true },
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
ws.close();
|
|
801
|
+
process.exit(0);
|
|
802
|
+
}
|
|
803
|
+
} catch (error) {
|
|
804
|
+
console.error(
|
|
805
|
+
`[${agentLabel}] Sync failed for ${command.projectId}: ${error.message || error}`
|
|
806
|
+
);
|
|
807
|
+
ws.send(
|
|
808
|
+
JSON.stringify({
|
|
809
|
+
type: "command:result",
|
|
810
|
+
commandId: command.id,
|
|
811
|
+
status: "FAILED",
|
|
812
|
+
details: { error: error.message || "Sync failed." },
|
|
813
|
+
})
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
ws.on("close", () => {
|
|
820
|
+
console.log("WebSocket disconnected. Reconnecting...");
|
|
821
|
+
setTimeout(connect, reconnectMs);
|
|
822
|
+
reconnectMs = Math.min(reconnectMs * 1.6, 15000);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
ws.on("error", (err) => {
|
|
826
|
+
console.error(`WebSocket error: ${err.message || err}`);
|
|
827
|
+
ws.close();
|
|
828
|
+
});
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
connect().catch((error) => {
|
|
832
|
+
console.error(error.message || error);
|
|
833
|
+
process.exit(1);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
setInterval(async () => {
|
|
837
|
+
const config = await loadConfig();
|
|
838
|
+
if (config.sessionToken && config.appUrl) {
|
|
839
|
+
reportAgentStatus(config).catch(() => null);
|
|
840
|
+
}
|
|
841
|
+
}, 20000);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const command = process.argv[2];
|
|
845
|
+
if (command === "link") {
|
|
846
|
+
linkAgent({ silent: false });
|
|
847
|
+
} else if (command === "add") {
|
|
848
|
+
addProject();
|
|
849
|
+
} else if (command === "run") {
|
|
850
|
+
if (hasFlag("--link")) {
|
|
851
|
+
linkAgent({ silent: true })
|
|
852
|
+
.then(() => runDaemon())
|
|
853
|
+
.catch((error) => {
|
|
854
|
+
console.error(error.message || error);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
});
|
|
857
|
+
} else {
|
|
858
|
+
runDaemon();
|
|
859
|
+
}
|
|
860
|
+
} else {
|
|
861
|
+
console.log("Usage: preview-agent <link|add|run>");
|
|
862
|
+
}
|