wispy-cli 1.2.3 → 1.4.0
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/bin/wispy.mjs +219 -1
- package/core/deploy.mjs +51 -0
- package/core/engine.mjs +132 -0
- package/core/index.mjs +4 -0
- package/core/memory.mjs +10 -1
- package/core/migrate.mjs +357 -0
- package/core/server.mjs +152 -2
- package/core/session.mjs +8 -0
- package/core/skills.mjs +339 -0
- package/core/sync.mjs +682 -0
- package/core/user-model.mjs +302 -0
- package/lib/channels/email.mjs +187 -0
- package/lib/channels/index.mjs +66 -9
- package/lib/channels/signal.mjs +151 -0
- package/lib/channels/whatsapp.mjs +141 -0
- package/lib/wispy-repl.mjs +102 -24
- package/lib/wispy-tui.mjs +964 -380
- package/package.json +18 -2
package/core/migrate.mjs
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/migrate.mjs — Migration tool for importing from OpenClaw
|
|
3
|
+
*
|
|
4
|
+
* Helps users switch from OpenClaw to Wispy by importing:
|
|
5
|
+
* - Memories (MEMORY.md → ~/.wispy/memory/)
|
|
6
|
+
* - Workspace files (SOUL.md, USER.md, IDENTITY.md → user-model)
|
|
7
|
+
* - Cron jobs (openclaw cron format → wispy cron format)
|
|
8
|
+
* - Channel configs (telegram token, etc.)
|
|
9
|
+
*
|
|
10
|
+
* SAFE: Read-only from OpenClaw. Copies to Wispy, never modifies OpenClaw.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, writeFile, mkdir, readdir, access, stat } from "node:fs/promises";
|
|
14
|
+
import { join, basename } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
const OPENCLAW_DIR = join(homedir(), ".openclaw");
|
|
18
|
+
const OPENCLAW_WORKSPACE = join(OPENCLAW_DIR, "workspace");
|
|
19
|
+
|
|
20
|
+
export class OpenClawMigrator {
|
|
21
|
+
constructor(wispyDir) {
|
|
22
|
+
this.wispyDir = wispyDir;
|
|
23
|
+
this.report = {
|
|
24
|
+
detected: false,
|
|
25
|
+
memories: [],
|
|
26
|
+
userModel: [],
|
|
27
|
+
cronJobs: [],
|
|
28
|
+
channels: [],
|
|
29
|
+
skipped: [],
|
|
30
|
+
errors: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Detection ─────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
async detect() {
|
|
37
|
+
try {
|
|
38
|
+
await access(OPENCLAW_DIR);
|
|
39
|
+
this.report.detected = true;
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
this.report.detected = false;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Main migration entry ──────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run full migration.
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {boolean} opts.dryRun — if true, only report what would be migrated
|
|
53
|
+
* @param {boolean} opts.memoryOnly — if true, only migrate memories
|
|
54
|
+
*/
|
|
55
|
+
async migrate(opts = {}) {
|
|
56
|
+
const { dryRun = false, memoryOnly = false } = opts;
|
|
57
|
+
|
|
58
|
+
const detected = await this.detect();
|
|
59
|
+
if (!detected) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
error: `OpenClaw not found at ${OPENCLAW_DIR}`,
|
|
63
|
+
report: this.report,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Migrate memories
|
|
68
|
+
await this._migrateMemories(dryRun);
|
|
69
|
+
|
|
70
|
+
if (!memoryOnly) {
|
|
71
|
+
await this._migrateWorkspaceFiles(dryRun);
|
|
72
|
+
await this._migrateCronJobs(dryRun);
|
|
73
|
+
await this._migrateChannels(dryRun);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
dryRun,
|
|
79
|
+
report: this.report,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Memory migration ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async _migrateMemories(dryRun) {
|
|
86
|
+
const memoryDir = join(this.wispyDir, "memory");
|
|
87
|
+
|
|
88
|
+
// Check for MEMORY.md in workspace root
|
|
89
|
+
const memoryMdPath = join(OPENCLAW_WORKSPACE, "MEMORY.md");
|
|
90
|
+
try {
|
|
91
|
+
const content = await readFile(memoryMdPath, "utf8");
|
|
92
|
+
if (content.trim()) {
|
|
93
|
+
const dest = join(memoryDir, "openclaw-import.md");
|
|
94
|
+
if (!dryRun) {
|
|
95
|
+
await mkdir(memoryDir, { recursive: true });
|
|
96
|
+
await writeFile(dest, `# Imported from OpenClaw MEMORY.md\n\n${content}`, "utf8");
|
|
97
|
+
}
|
|
98
|
+
this.report.memories.push({ source: memoryMdPath, dest, action: "copy" });
|
|
99
|
+
}
|
|
100
|
+
} catch { /* No MEMORY.md — skip */ }
|
|
101
|
+
|
|
102
|
+
// Check for memory/ subdirectory
|
|
103
|
+
const openclawMemoryDir = join(OPENCLAW_WORKSPACE, "memory");
|
|
104
|
+
try {
|
|
105
|
+
const files = await readdir(openclawMemoryDir);
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (!file.endsWith(".md") && !file.endsWith(".json")) continue;
|
|
108
|
+
const src = join(openclawMemoryDir, file);
|
|
109
|
+
const dest = join(memoryDir, `openclaw-${file}`);
|
|
110
|
+
try {
|
|
111
|
+
const content = await readFile(src, "utf8");
|
|
112
|
+
if (content.trim()) {
|
|
113
|
+
if (!dryRun) {
|
|
114
|
+
await mkdir(memoryDir, { recursive: true });
|
|
115
|
+
await writeFile(dest, content, "utf8");
|
|
116
|
+
}
|
|
117
|
+
this.report.memories.push({ source: src, dest, action: "copy" });
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this.report.errors.push(`Memory file ${file}: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch { /* No memory directory */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Workspace files → user model ──────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async _migrateWorkspaceFiles(dryRun) {
|
|
129
|
+
const filesToProcess = [
|
|
130
|
+
{ name: "USER.md", type: "user" },
|
|
131
|
+
{ name: "IDENTITY.md", type: "identity" },
|
|
132
|
+
{ name: "SOUL.md", type: "soul" },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
for (const { name, type } of filesToProcess) {
|
|
136
|
+
const src = join(OPENCLAW_WORKSPACE, name);
|
|
137
|
+
try {
|
|
138
|
+
const content = await readFile(src, "utf8");
|
|
139
|
+
if (!content.trim()) continue;
|
|
140
|
+
|
|
141
|
+
// Extract useful info and save to wispy memory
|
|
142
|
+
const dest = join(this.wispyDir, "memory", `openclaw-${name.toLowerCase()}`);
|
|
143
|
+
if (!dryRun) {
|
|
144
|
+
await mkdir(join(this.wispyDir, "memory"), { recursive: true });
|
|
145
|
+
await writeFile(dest, `# Imported from OpenClaw ${name}\n\n${content}`, "utf8");
|
|
146
|
+
}
|
|
147
|
+
this.report.userModel.push({ source: src, dest, type, action: "copy" });
|
|
148
|
+
|
|
149
|
+
// Parse USER.md for structured data
|
|
150
|
+
if (type === "user" && !dryRun) {
|
|
151
|
+
await this._importUserMd(content);
|
|
152
|
+
}
|
|
153
|
+
} catch { /* File doesn't exist, skip */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async _importUserMd(content) {
|
|
158
|
+
// Extract structured fields from USER.md
|
|
159
|
+
const userModelPath = join(this.wispyDir, "user-model.json");
|
|
160
|
+
|
|
161
|
+
let model = {};
|
|
162
|
+
try {
|
|
163
|
+
model = JSON.parse(await readFile(userModelPath, "utf8"));
|
|
164
|
+
} catch {
|
|
165
|
+
model = { basics: {}, preferences: {}, patterns: { peakHours: [], avgSessionLength: 0, commonTasks: [], totalSessions: 0, totalMessages: 0 } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Simple regex extraction
|
|
169
|
+
const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/);
|
|
170
|
+
if (nameMatch && !model.basics?.name) {
|
|
171
|
+
const name = nameMatch[1].replace(/\(.+\)/, "").trim();
|
|
172
|
+
model.basics = model.basics ?? {};
|
|
173
|
+
model.basics.name = name;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const tzMatch = content.match(/\*\*Timezone:\*\*\s*(.+)/);
|
|
177
|
+
if (tzMatch) {
|
|
178
|
+
model.basics = model.basics ?? {};
|
|
179
|
+
model.basics.timezone = tzMatch[1].trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Korean name usually indicates Korean language preference
|
|
183
|
+
if (content.includes("민서") || content.includes("Korean") || content.toLowerCase().includes("asia/seoul")) {
|
|
184
|
+
model.preferences = model.preferences ?? {};
|
|
185
|
+
if (!model.preferences.communicationLanguage) {
|
|
186
|
+
model.preferences.communicationLanguage = "Korean";
|
|
187
|
+
model.basics.language = "ko";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// OS hints
|
|
192
|
+
if (content.includes("MacBook") || content.includes("macOS")) {
|
|
193
|
+
model.basics = model.basics ?? {};
|
|
194
|
+
model.basics.os = "macOS";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
model.updatedAt = new Date().toISOString();
|
|
198
|
+
await mkdir(this.wispyDir, { recursive: true });
|
|
199
|
+
await writeFile(userModelPath, JSON.stringify(model, null, 2) + "\n", "utf8");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Cron job migration ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async _migrateCronJobs(dryRun) {
|
|
205
|
+
// OpenClaw cron config locations
|
|
206
|
+
const candidates = [
|
|
207
|
+
join(OPENCLAW_DIR, "cron.json"),
|
|
208
|
+
join(OPENCLAW_DIR, "config", "cron.json"),
|
|
209
|
+
join(OPENCLAW_WORKSPACE, "cron.json"),
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
for (const src of candidates) {
|
|
213
|
+
try {
|
|
214
|
+
const raw = await readFile(src, "utf8");
|
|
215
|
+
const openclawCron = JSON.parse(raw);
|
|
216
|
+
|
|
217
|
+
// Convert OpenClaw cron format to Wispy cron format
|
|
218
|
+
const wispyCronPath = join(this.wispyDir, "cron.json");
|
|
219
|
+
let wispyCron = [];
|
|
220
|
+
try {
|
|
221
|
+
wispyCron = JSON.parse(await readFile(wispyCronPath, "utf8"));
|
|
222
|
+
} catch { /* empty */ }
|
|
223
|
+
|
|
224
|
+
const converted = this._convertCronFormat(openclawCron);
|
|
225
|
+
const toAdd = converted.filter(j =>
|
|
226
|
+
!wispyCron.some(existing => existing.name === j.name)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (toAdd.length > 0) {
|
|
230
|
+
if (!dryRun) {
|
|
231
|
+
await mkdir(this.wispyDir, { recursive: true });
|
|
232
|
+
await writeFile(wispyCronPath, JSON.stringify([...wispyCron, ...toAdd], null, 2) + "\n", "utf8");
|
|
233
|
+
}
|
|
234
|
+
for (const job of toAdd) {
|
|
235
|
+
this.report.cronJobs.push({ source: src, job: job.name, action: "import" });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
break; // Only process first found
|
|
239
|
+
} catch { /* Not found or parse error */ }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_convertCronFormat(openclawCron) {
|
|
244
|
+
// OpenClaw cron is an array of job objects
|
|
245
|
+
const jobs = Array.isArray(openclawCron) ? openclawCron : (openclawCron.jobs ?? []);
|
|
246
|
+
return jobs.map(job => ({
|
|
247
|
+
name: job.name ?? job.id ?? `imported-${Date.now()}`,
|
|
248
|
+
schedule: job.schedule ?? job.cron ?? "0 9 * * *",
|
|
249
|
+
prompt: job.prompt ?? job.task ?? job.message ?? "Run imported cron job",
|
|
250
|
+
enabled: job.enabled !== false,
|
|
251
|
+
importedFrom: "openclaw",
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Channel config migration ──────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
async _migrateChannels(dryRun) {
|
|
258
|
+
// Look for OpenClaw channel configs
|
|
259
|
+
const configCandidates = [
|
|
260
|
+
join(OPENCLAW_DIR, "openclaw.json"),
|
|
261
|
+
join(OPENCLAW_DIR, "config.json"),
|
|
262
|
+
join(OPENCLAW_DIR, "config", "openclaw.json"),
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
for (const src of configCandidates) {
|
|
266
|
+
try {
|
|
267
|
+
const raw = await readFile(src, "utf8");
|
|
268
|
+
const config = JSON.parse(raw);
|
|
269
|
+
|
|
270
|
+
const channelsPath = join(this.wispyDir, "channels.json");
|
|
271
|
+
let wispyChannels = {};
|
|
272
|
+
try {
|
|
273
|
+
wispyChannels = JSON.parse(await readFile(channelsPath, "utf8"));
|
|
274
|
+
} catch { /* empty */ }
|
|
275
|
+
|
|
276
|
+
// Extract Telegram config
|
|
277
|
+
const telegramToken = config.telegram?.token
|
|
278
|
+
?? config.channels?.telegram?.token
|
|
279
|
+
?? config.plugins?.telegram?.token;
|
|
280
|
+
|
|
281
|
+
if (telegramToken && !wispyChannels.telegram) {
|
|
282
|
+
wispyChannels.telegram = { token: telegramToken };
|
|
283
|
+
this.report.channels.push({ channel: "telegram", action: "import" });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Extract Discord config
|
|
287
|
+
const discordToken = config.discord?.token
|
|
288
|
+
?? config.channels?.discord?.token;
|
|
289
|
+
|
|
290
|
+
if (discordToken && !wispyChannels.discord) {
|
|
291
|
+
wispyChannels.discord = { token: discordToken };
|
|
292
|
+
this.report.channels.push({ channel: "discord", action: "import" });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (this.report.channels.length > 0 && !dryRun) {
|
|
296
|
+
await mkdir(this.wispyDir, { recursive: true });
|
|
297
|
+
await writeFile(channelsPath, JSON.stringify(wispyChannels, null, 2) + "\n", "utf8");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
break; // Only process first found
|
|
301
|
+
} catch { /* Not found */ }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Report formatting ─────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
formatReport() {
|
|
308
|
+
const r = this.report;
|
|
309
|
+
const lines = [];
|
|
310
|
+
|
|
311
|
+
if (!r.detected) {
|
|
312
|
+
return `❌ OpenClaw not found at ${OPENCLAW_DIR}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
lines.push(`✅ OpenClaw detected at ${OPENCLAW_DIR}`);
|
|
316
|
+
lines.push("");
|
|
317
|
+
|
|
318
|
+
if (r.memories.length > 0) {
|
|
319
|
+
lines.push(`📝 Memories (${r.memories.length}):`);
|
|
320
|
+
for (const m of r.memories) {
|
|
321
|
+
lines.push(` ${m.action}: ${basename(m.source)} → ${basename(m.dest)}`);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
lines.push("📝 Memories: none found");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (r.userModel.length > 0) {
|
|
328
|
+
lines.push(`👤 User profile files (${r.userModel.length}):`);
|
|
329
|
+
for (const u of r.userModel) {
|
|
330
|
+
lines.push(` ${u.action}: ${u.source.replace(homedir(), "~")}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (r.cronJobs.length > 0) {
|
|
335
|
+
lines.push(`⏰ Cron jobs (${r.cronJobs.length}):`);
|
|
336
|
+
for (const j of r.cronJobs) {
|
|
337
|
+
lines.push(` import: ${j.job}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (r.channels.length > 0) {
|
|
342
|
+
lines.push(`📡 Channels (${r.channels.length}):`);
|
|
343
|
+
for (const c of r.channels) {
|
|
344
|
+
lines.push(` import: ${c.channel}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (r.errors.length > 0) {
|
|
349
|
+
lines.push(`⚠️ Errors (${r.errors.length}):`);
|
|
350
|
+
for (const e of r.errors) {
|
|
351
|
+
lines.push(` ${e}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return lines.join("\n");
|
|
356
|
+
}
|
|
357
|
+
}
|
package/core/server.mjs
CHANGED
|
@@ -7,11 +7,71 @@
|
|
|
7
7
|
|
|
8
8
|
import { createServer } from "node:http";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
11
|
-
import { randomBytes, createHmac } from "node:crypto";
|
|
10
|
+
import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
11
|
+
import { randomBytes, createHmac, createHash } from "node:crypto";
|
|
12
12
|
|
|
13
13
|
import { WISPY_DIR } from "./config.mjs";
|
|
14
14
|
|
|
15
|
+
// ── Sync helpers ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const SYNC_PATTERNS = [
|
|
18
|
+
(f) => f.startsWith("memory/") && f.endsWith(".md"),
|
|
19
|
+
(f) => f.startsWith("sessions/") && f.endsWith(".json"),
|
|
20
|
+
(f) => f === "cron/jobs.json",
|
|
21
|
+
(f) => /^workstreams\/[^/]+\/work\.md$/.test(f),
|
|
22
|
+
(f) => f === "permissions.json",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function isSyncable(relPath) {
|
|
26
|
+
return SYNC_PATTERNS.some(fn => fn(relPath));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sha256(content) {
|
|
30
|
+
return createHash("sha256").update(content).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPathSafe(relPath) {
|
|
34
|
+
// Reject absolute paths and path traversal
|
|
35
|
+
if (path.isAbsolute(relPath)) return false;
|
|
36
|
+
if (relPath.includes("..")) return false;
|
|
37
|
+
if (relPath.includes("\0")) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function scanSyncableFiles(baseDir) {
|
|
42
|
+
const entries = [];
|
|
43
|
+
await _scanDir(baseDir, baseDir, entries);
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function _scanDir(baseDir, dir, entries) {
|
|
48
|
+
let items;
|
|
49
|
+
try {
|
|
50
|
+
items = await readdir(dir, { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const fullPath = path.join(dir, item.name);
|
|
56
|
+
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, "/");
|
|
57
|
+
if (item.isDirectory()) {
|
|
58
|
+
if (["node_modules", ".git", ".npm"].includes(item.name)) continue;
|
|
59
|
+
await _scanDir(baseDir, fullPath, entries);
|
|
60
|
+
} else if (item.isFile() && isSyncable(relPath)) {
|
|
61
|
+
try {
|
|
62
|
+
const content = await readFile(fullPath);
|
|
63
|
+
const fileStat = await stat(fullPath);
|
|
64
|
+
entries.push({
|
|
65
|
+
path: relPath,
|
|
66
|
+
hash: sha256(content),
|
|
67
|
+
modifiedAt: fileStat.mtime.toISOString(),
|
|
68
|
+
size: content.length,
|
|
69
|
+
});
|
|
70
|
+
} catch { /* skip */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
15
75
|
const SERVER_CONFIG_FILE = path.join(WISPY_DIR, "server.json");
|
|
16
76
|
const DEFAULT_PORT = 18790;
|
|
17
77
|
const DEFAULT_HOST = "127.0.0.1";
|
|
@@ -335,6 +395,96 @@ export class WispyServer {
|
|
|
335
395
|
}
|
|
336
396
|
}
|
|
337
397
|
|
|
398
|
+
// ── Sync ────────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
// GET /api/sync/manifest — list all syncable files with hash + mtime
|
|
401
|
+
if (pathname === "/api/sync/manifest" && method === "GET") {
|
|
402
|
+
const files = await scanSyncableFiles(WISPY_DIR);
|
|
403
|
+
return jsonResponse(res, 200, { files });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// GET /api/sync/file?path=<relPath> — download a file
|
|
407
|
+
if (pathname === "/api/sync/file" && method === "GET") {
|
|
408
|
+
const relPath = url.searchParams.get("path");
|
|
409
|
+
if (!relPath) return errorResponse(res, 400, "path required");
|
|
410
|
+
if (!isPathSafe(relPath)) return errorResponse(res, 400, "invalid path");
|
|
411
|
+
if (!isSyncable(relPath)) return errorResponse(res, 400, "path not syncable");
|
|
412
|
+
|
|
413
|
+
const fullPath = path.join(WISPY_DIR, relPath);
|
|
414
|
+
try {
|
|
415
|
+
const content = await readFile(fullPath);
|
|
416
|
+
const fileStat = await stat(fullPath);
|
|
417
|
+
return jsonResponse(res, 200, {
|
|
418
|
+
path: relPath,
|
|
419
|
+
content: content.toString("base64"),
|
|
420
|
+
encoding: "base64",
|
|
421
|
+
modifiedAt: fileStat.mtime.toISOString(),
|
|
422
|
+
hash: sha256(content),
|
|
423
|
+
size: content.length,
|
|
424
|
+
});
|
|
425
|
+
} catch {
|
|
426
|
+
return errorResponse(res, 404, "File not found");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// POST /api/sync/file — upload a file
|
|
431
|
+
if (pathname === "/api/sync/file" && method === "POST") {
|
|
432
|
+
const body = await readBody(req);
|
|
433
|
+
const { path: relPath, content, encoding = "base64", modifiedAt } = body;
|
|
434
|
+
if (!relPath || content == null) return errorResponse(res, 400, "path and content required");
|
|
435
|
+
if (!isPathSafe(relPath)) return errorResponse(res, 400, "invalid path");
|
|
436
|
+
if (!isSyncable(relPath)) return errorResponse(res, 400, "path not syncable");
|
|
437
|
+
|
|
438
|
+
const fullPath = path.join(WISPY_DIR, relPath);
|
|
439
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
440
|
+
const buf = Buffer.from(content, encoding === "base64" ? "base64" : "utf8");
|
|
441
|
+
await writeFile(fullPath, buf);
|
|
442
|
+
if (modifiedAt) {
|
|
443
|
+
const mtime = new Date(modifiedAt);
|
|
444
|
+
const { utimes } = await import("node:fs/promises");
|
|
445
|
+
await utimes(fullPath, mtime, mtime).catch(() => {});
|
|
446
|
+
}
|
|
447
|
+
return jsonResponse(res, 200, { success: true, path: relPath, size: buf.length });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// POST /api/sync/bulk — upload multiple files at once
|
|
451
|
+
if (pathname === "/api/sync/bulk" && method === "POST") {
|
|
452
|
+
const body = await readBody(req);
|
|
453
|
+
const files = body.files ?? [];
|
|
454
|
+
if (!Array.isArray(files)) return errorResponse(res, 400, "files must be an array");
|
|
455
|
+
|
|
456
|
+
const results = [];
|
|
457
|
+
for (const file of files) {
|
|
458
|
+
const { path: relPath, content, encoding = "base64", modifiedAt } = file;
|
|
459
|
+
if (!relPath || content == null) {
|
|
460
|
+
results.push({ path: relPath, success: false, error: "missing path or content" });
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!isPathSafe(relPath) || !isSyncable(relPath)) {
|
|
464
|
+
results.push({ path: relPath, success: false, error: "invalid or non-syncable path" });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const fullPath = path.join(WISPY_DIR, relPath);
|
|
469
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
470
|
+
const buf = Buffer.from(content, encoding === "base64" ? "base64" : "utf8");
|
|
471
|
+
await writeFile(fullPath, buf);
|
|
472
|
+
if (modifiedAt) {
|
|
473
|
+
const mtime = new Date(modifiedAt);
|
|
474
|
+
const { utimes } = await import("node:fs/promises");
|
|
475
|
+
await utimes(fullPath, mtime, mtime).catch(() => {});
|
|
476
|
+
}
|
|
477
|
+
results.push({ path: relPath, success: true, size: buf.length });
|
|
478
|
+
} catch (err) {
|
|
479
|
+
results.push({ path: relPath, success: false, error: err.message });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const succeeded = results.filter(r => r.success).length;
|
|
484
|
+
const failed = results.filter(r => !r.success).length;
|
|
485
|
+
return jsonResponse(res, 200, { success: failed === 0, received: files.length, succeeded, failed, results });
|
|
486
|
+
}
|
|
487
|
+
|
|
338
488
|
// ── Audit ───────────────────────────────────────────────────────────────
|
|
339
489
|
if (pathname === "/api/audit" && method === "GET") {
|
|
340
490
|
const filter = {};
|
package/core/session.mjs
CHANGED
|
@@ -116,6 +116,7 @@ export class SessionManager {
|
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* Save a session to disk.
|
|
119
|
+
* If auto-sync is enabled, pushes the file to remote (non-blocking).
|
|
119
120
|
*/
|
|
120
121
|
async save(id) {
|
|
121
122
|
const session = this._sessions.get(id);
|
|
@@ -123,6 +124,13 @@ export class SessionManager {
|
|
|
123
124
|
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
124
125
|
const filePath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
125
126
|
await writeFile(filePath, JSON.stringify(session.toJSON(), null, 2) + "\n", "utf8");
|
|
127
|
+
|
|
128
|
+
// Auto-sync: push to remote if configured (fire-and-forget)
|
|
129
|
+
try {
|
|
130
|
+
const { getSyncManager } = await import("./sync.mjs");
|
|
131
|
+
const syncMgr = await getSyncManager();
|
|
132
|
+
if (syncMgr?.auto) syncMgr.pushFile(filePath);
|
|
133
|
+
} catch { /* sync is optional */ }
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
/**
|