@memrosetta/cli 0.4.0 → 0.4.2
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/chunk-72IW6TAV.js +59 -0
- package/dist/chunk-MISLIVUL.js +70 -0
- package/dist/chunk-NU5ZJJXP.js +63 -0
- package/dist/chunk-SEPYQK3J.js +60 -0
- package/dist/clear-47OFIDME.js +39 -0
- package/dist/clear-5SZVGYBX.js +39 -0
- package/dist/compress-SEFTKZMU.js +33 -0
- package/dist/compress-YNY6YNFU.js +33 -0
- package/dist/count-AMSEVDWR.js +24 -0
- package/dist/count-Z67KBEMV.js +24 -0
- package/dist/feedback-QDOWDWHM.js +40 -0
- package/dist/feedback-XGBKFQXC.js +40 -0
- package/dist/get-NY5H3MUA.js +30 -0
- package/dist/hooks/on-prompt.js +2 -2
- package/dist/hooks/on-stop.js +2 -2
- package/dist/index.js +35 -18
- package/dist/ingest-GSJMWDV5.js +95 -0
- package/dist/ingest-TZEVA25F.js +95 -0
- package/dist/init-GRVRJ6RO.js +205 -0
- package/dist/init-LK4UQISR.js +205 -0
- package/dist/invalidate-BY5VNFSE.js +25 -0
- package/dist/maintain-SGM56XKE.js +37 -0
- package/dist/maintain-VX2VWB2L.js +37 -0
- package/dist/relate-L5464WV5.js +47 -0
- package/dist/relate-SGZLG7JU.js +47 -0
- package/dist/reset-CYY4KYAB.js +129 -0
- package/dist/search-BJ2YV5IS.js +48 -0
- package/dist/search-PT4POELX.js +48 -0
- package/dist/status-TVY32MZD.js +218 -0
- package/dist/store-2USP33HQ.js +91 -0
- package/dist/store-XCFYGYBE.js +91 -0
- package/dist/sync-643GTA5X.js +319 -0
- package/dist/sync-7TONPJBY.js +351 -0
- package/dist/sync-BPVMHW34.js +319 -0
- package/dist/sync-OZQLBYT2.js +317 -0
- package/dist/sync-WURX2HJZ.js +321 -0
- package/dist/working-memory-UYVEJJYW.js +53 -0
- package/dist/working-memory-VP6L2QV6.js +53 -0
- package/package.json +5 -4
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasFlag,
|
|
3
|
+
optionalOption,
|
|
4
|
+
requireOption
|
|
5
|
+
} from "./chunk-VZQURGWB.js";
|
|
6
|
+
import {
|
|
7
|
+
output,
|
|
8
|
+
outputError
|
|
9
|
+
} from "./chunk-ET6TNQOJ.js";
|
|
10
|
+
import {
|
|
11
|
+
getConfig,
|
|
12
|
+
getDefaultDbPath,
|
|
13
|
+
writeConfig
|
|
14
|
+
} from "./chunk-SEPYQK3J.js";
|
|
15
|
+
|
|
16
|
+
// src/commands/sync.ts
|
|
17
|
+
import { randomUUID } from "crypto";
|
|
18
|
+
function parseSubcommand(args) {
|
|
19
|
+
const first = args[0];
|
|
20
|
+
if (!first || first.startsWith("--")) return null;
|
|
21
|
+
if (first === "enable" || first === "disable" || first === "status" || first === "now" || first === "device-id") {
|
|
22
|
+
return first;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
async function readHiddenInput(prompt) {
|
|
27
|
+
const stdin = process.stdin;
|
|
28
|
+
const stdout = process.stdout;
|
|
29
|
+
if (!stdin.isTTY) {
|
|
30
|
+
throw new Error("Interactive input requires a TTY. Use --key-stdin to pipe the key instead.");
|
|
31
|
+
}
|
|
32
|
+
stdout.write(prompt);
|
|
33
|
+
const wasRaw = stdin.isRaw;
|
|
34
|
+
stdin.setRawMode(true);
|
|
35
|
+
stdin.resume();
|
|
36
|
+
stdin.setEncoding("utf-8");
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let buffer = "";
|
|
39
|
+
const onData = (chunk) => {
|
|
40
|
+
for (const ch of chunk) {
|
|
41
|
+
const code = ch.charCodeAt(0);
|
|
42
|
+
if (code === 13 || code === 10) {
|
|
43
|
+
stdin.removeListener("data", onData);
|
|
44
|
+
stdin.setRawMode(wasRaw);
|
|
45
|
+
stdin.pause();
|
|
46
|
+
stdout.write("\n");
|
|
47
|
+
resolve(buffer);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (code === 3) {
|
|
51
|
+
stdin.removeListener("data", onData);
|
|
52
|
+
stdin.setRawMode(wasRaw);
|
|
53
|
+
stdin.pause();
|
|
54
|
+
stdout.write("\n");
|
|
55
|
+
reject(new Error("Aborted"));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (code === 127 || code === 8) {
|
|
59
|
+
buffer = buffer.slice(0, -1);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
buffer += ch;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
stdin.on("data", onData);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async function readStdinKey() {
|
|
69
|
+
const chunks = [];
|
|
70
|
+
for await (const chunk of process.stdin) {
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
}
|
|
73
|
+
return Buffer.concat(chunks).toString("utf-8").trim();
|
|
74
|
+
}
|
|
75
|
+
async function testConnection(serverUrl, apiKey) {
|
|
76
|
+
const url = `${serverUrl.replace(/\/$/, "")}/sync/health`;
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url, {
|
|
79
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
86
|
+
throw new Error(`Sync server health check failed: ${msg}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function withSyncClient(dbPath, config, fn) {
|
|
90
|
+
const Database = (await import("better-sqlite3")).default;
|
|
91
|
+
const { SyncClient, ensureSyncSchema } = await import("@memrosetta/sync-client");
|
|
92
|
+
if (!config.syncServerUrl || !config.syncApiKey || !config.syncDeviceId) {
|
|
93
|
+
throw new Error("Sync is not configured. Run: memrosetta sync enable --server <url>");
|
|
94
|
+
}
|
|
95
|
+
const db = new Database(dbPath);
|
|
96
|
+
try {
|
|
97
|
+
ensureSyncSchema(db);
|
|
98
|
+
const client = new SyncClient(db, {
|
|
99
|
+
serverUrl: config.syncServerUrl,
|
|
100
|
+
apiKey: config.syncApiKey,
|
|
101
|
+
deviceId: config.syncDeviceId
|
|
102
|
+
});
|
|
103
|
+
return await fn(client, db);
|
|
104
|
+
} finally {
|
|
105
|
+
db.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function runEnable(options) {
|
|
109
|
+
const { args, format } = options;
|
|
110
|
+
let serverUrl;
|
|
111
|
+
try {
|
|
112
|
+
serverUrl = requireOption(args, "--server", "server URL");
|
|
113
|
+
} catch (err) {
|
|
114
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
let apiKey = optionalOption(args, "--key");
|
|
119
|
+
if (hasFlag(args, "--key-stdin")) {
|
|
120
|
+
apiKey = await readStdinKey();
|
|
121
|
+
}
|
|
122
|
+
if (!apiKey) {
|
|
123
|
+
try {
|
|
124
|
+
apiKey = await readHiddenInput("API key: ");
|
|
125
|
+
} catch (err) {
|
|
126
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!apiKey) {
|
|
132
|
+
outputError("API key is required", format);
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const skipTest = hasFlag(args, "--no-test");
|
|
137
|
+
if (!skipTest) {
|
|
138
|
+
try {
|
|
139
|
+
await testConnection(serverUrl, apiKey);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
outputError(
|
|
142
|
+
`${err instanceof Error ? err.message : String(err)}
|
|
143
|
+
Use --no-test to skip the health check.`,
|
|
144
|
+
format
|
|
145
|
+
);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const existing = getConfig();
|
|
151
|
+
const deviceId = existing.syncDeviceId ?? `device-${randomUUID().slice(0, 8)}`;
|
|
152
|
+
writeConfig({
|
|
153
|
+
...existing,
|
|
154
|
+
syncEnabled: true,
|
|
155
|
+
syncServerUrl: serverUrl,
|
|
156
|
+
syncApiKey: apiKey,
|
|
157
|
+
syncDeviceId: deviceId
|
|
158
|
+
});
|
|
159
|
+
if (format === "text") {
|
|
160
|
+
process.stdout.write("Sync enabled.\n");
|
|
161
|
+
process.stdout.write(` Server: ${serverUrl}
|
|
162
|
+
`);
|
|
163
|
+
process.stdout.write(` DeviceId: ${deviceId}
|
|
164
|
+
`);
|
|
165
|
+
if (skipTest) {
|
|
166
|
+
process.stdout.write(" (health check skipped)\n");
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
output({ enabled: true, serverUrl, deviceId, healthCheckSkipped: skipTest }, format);
|
|
171
|
+
}
|
|
172
|
+
function runDisable(options) {
|
|
173
|
+
const { format } = options;
|
|
174
|
+
const existing = getConfig();
|
|
175
|
+
writeConfig({
|
|
176
|
+
...existing,
|
|
177
|
+
syncEnabled: false
|
|
178
|
+
});
|
|
179
|
+
if (format === "text") {
|
|
180
|
+
process.stdout.write("Sync disabled. (server URL and API key preserved for re-enable)\n");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
output({ enabled: false }, format);
|
|
184
|
+
}
|
|
185
|
+
async function runStatus(options) {
|
|
186
|
+
const { format, db } = options;
|
|
187
|
+
const config = getConfig();
|
|
188
|
+
const dbPath = db ?? config.dbPath ?? getDefaultDbPath();
|
|
189
|
+
if (!config.syncEnabled) {
|
|
190
|
+
if (format === "text") {
|
|
191
|
+
process.stdout.write("Sync: disabled\n");
|
|
192
|
+
if (config.syncServerUrl) {
|
|
193
|
+
process.stdout.write(` Server: ${config.syncServerUrl}
|
|
194
|
+
`);
|
|
195
|
+
}
|
|
196
|
+
if (config.syncDeviceId) {
|
|
197
|
+
process.stdout.write(` DeviceId: ${config.syncDeviceId}
|
|
198
|
+
`);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
output(
|
|
203
|
+
{
|
|
204
|
+
enabled: false,
|
|
205
|
+
serverUrl: config.syncServerUrl ?? null,
|
|
206
|
+
deviceId: config.syncDeviceId ?? null
|
|
207
|
+
},
|
|
208
|
+
format
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const status = await withSyncClient(dbPath, config, async (client) => client.getStatus());
|
|
214
|
+
if (format === "text") {
|
|
215
|
+
process.stdout.write("Sync: enabled\n");
|
|
216
|
+
process.stdout.write(` Server: ${status.serverUrl}
|
|
217
|
+
`);
|
|
218
|
+
process.stdout.write(` DeviceId: ${status.deviceId}
|
|
219
|
+
`);
|
|
220
|
+
process.stdout.write(` Pending ops: ${status.pendingOps}
|
|
221
|
+
`);
|
|
222
|
+
process.stdout.write(` Current cursor: ${status.cursor}
|
|
223
|
+
`);
|
|
224
|
+
process.stdout.write(
|
|
225
|
+
` Last push: ${status.lastPush.successAt ?? "never"}` + (status.lastPush.attemptAt && status.lastPush.attemptAt !== status.lastPush.successAt ? ` (last attempt: ${status.lastPush.attemptAt})` : "") + "\n"
|
|
226
|
+
);
|
|
227
|
+
process.stdout.write(
|
|
228
|
+
` Last pull: ${status.lastPull.successAt ?? "never"}` + (status.lastPull.attemptAt && status.lastPull.attemptAt !== status.lastPull.successAt ? ` (last attempt: ${status.lastPull.attemptAt})` : "") + "\n"
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
output(status, format);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function runNow(options) {
|
|
239
|
+
const { args, format, db } = options;
|
|
240
|
+
const config = getConfig();
|
|
241
|
+
const dbPath = db ?? config.dbPath ?? getDefaultDbPath();
|
|
242
|
+
if (!config.syncEnabled) {
|
|
243
|
+
outputError("Sync is disabled. Run: memrosetta sync enable --server <url>", format);
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const pushOnly = hasFlag(args, "--push-only");
|
|
248
|
+
const pullOnly = hasFlag(args, "--pull-only");
|
|
249
|
+
try {
|
|
250
|
+
const result = await withSyncClient(dbPath, config, async (client) => {
|
|
251
|
+
let pushed = 0;
|
|
252
|
+
let pulled = 0;
|
|
253
|
+
if (!pullOnly) {
|
|
254
|
+
const pushResult = await client.push();
|
|
255
|
+
pushed = pushResult.pushed;
|
|
256
|
+
}
|
|
257
|
+
if (!pushOnly) {
|
|
258
|
+
pulled = await client.pull();
|
|
259
|
+
}
|
|
260
|
+
return { pushed, pulled };
|
|
261
|
+
});
|
|
262
|
+
if (format === "text") {
|
|
263
|
+
process.stdout.write(`Sync complete. pushed=${result.pushed} pulled=${result.pulled}
|
|
264
|
+
`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
output(result, format);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
270
|
+
process.exitCode = 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function runDeviceId(options) {
|
|
274
|
+
const { format } = options;
|
|
275
|
+
const config = getConfig();
|
|
276
|
+
if (!config.syncDeviceId) {
|
|
277
|
+
outputError("No deviceId set. Run: memrosetta sync enable --server <url>", format);
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (format === "text") {
|
|
282
|
+
process.stdout.write(`${config.syncDeviceId}
|
|
283
|
+
`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
output({ deviceId: config.syncDeviceId }, format);
|
|
287
|
+
}
|
|
288
|
+
async function run(options) {
|
|
289
|
+
const sub = parseSubcommand(options.args);
|
|
290
|
+
if (!sub) {
|
|
291
|
+
outputError(
|
|
292
|
+
"Usage: memrosetta sync <enable|disable|status|now|device-id>\n\n enable --server <url> [--key <key> | --key-stdin] [--no-test]\n disable\n status\n now [--push-only | --pull-only]\n device-id\n",
|
|
293
|
+
options.format
|
|
294
|
+
);
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const rest = { ...options, args: options.args.slice(1) };
|
|
299
|
+
switch (sub) {
|
|
300
|
+
case "enable":
|
|
301
|
+
await runEnable(rest);
|
|
302
|
+
return;
|
|
303
|
+
case "disable":
|
|
304
|
+
runDisable(rest);
|
|
305
|
+
return;
|
|
306
|
+
case "status":
|
|
307
|
+
await runStatus(rest);
|
|
308
|
+
return;
|
|
309
|
+
case "now":
|
|
310
|
+
await runNow(rest);
|
|
311
|
+
return;
|
|
312
|
+
case "device-id":
|
|
313
|
+
runDeviceId(rest);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export {
|
|
318
|
+
run
|
|
319
|
+
};
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasFlag,
|
|
3
|
+
optionalOption,
|
|
4
|
+
requireOption
|
|
5
|
+
} from "./chunk-NU5ZJJXP.js";
|
|
6
|
+
import {
|
|
7
|
+
output,
|
|
8
|
+
outputError
|
|
9
|
+
} from "./chunk-ET6TNQOJ.js";
|
|
10
|
+
import {
|
|
11
|
+
getConfig,
|
|
12
|
+
getDefaultDbPath,
|
|
13
|
+
writeConfig
|
|
14
|
+
} from "./chunk-SEPYQK3J.js";
|
|
15
|
+
|
|
16
|
+
// src/commands/sync.ts
|
|
17
|
+
import { randomUUID } from "crypto";
|
|
18
|
+
import { userInfo, platform } from "os";
|
|
19
|
+
import { createInterface } from "readline";
|
|
20
|
+
function parseSubcommand(args) {
|
|
21
|
+
const first = args[0];
|
|
22
|
+
if (!first || first.startsWith("--")) return null;
|
|
23
|
+
if (first === "enable" || first === "disable" || first === "status" || first === "now" || first === "device-id") {
|
|
24
|
+
return first;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
var CONTROL_CHAR_REGEX = /[\x00-\x1F\x7F]/;
|
|
29
|
+
function validateApiKey(key) {
|
|
30
|
+
const trimmed = key.trim();
|
|
31
|
+
if (trimmed.length === 0) {
|
|
32
|
+
throw new Error("API key is empty.");
|
|
33
|
+
}
|
|
34
|
+
if (CONTROL_CHAR_REGEX.test(trimmed)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"API key contains control characters. Your terminal likely does not support hidden input (e.g. Windows PowerShell). Pipe the key instead:\n echo <api-key> | memrosetta sync enable --server <url> --key-stdin"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return trimmed;
|
|
40
|
+
}
|
|
41
|
+
async function readHiddenInput(prompt) {
|
|
42
|
+
const stdin = process.stdin;
|
|
43
|
+
const stdout = process.stdout;
|
|
44
|
+
if (!stdin.isTTY) {
|
|
45
|
+
throw new Error("Interactive input requires a TTY. Use --key-stdin to pipe the key instead.");
|
|
46
|
+
}
|
|
47
|
+
stdout.write(prompt);
|
|
48
|
+
const originalWrite = stdout.write.bind(stdout);
|
|
49
|
+
let muted = true;
|
|
50
|
+
stdout.write = (chunk, ...rest) => {
|
|
51
|
+
if (!muted) {
|
|
52
|
+
return originalWrite(chunk, ...rest);
|
|
53
|
+
}
|
|
54
|
+
const str = typeof chunk === "string" ? chunk : chunk?.toString?.("utf-8") ?? "";
|
|
55
|
+
if (str === "\n" || str === "\r\n" || str === "\r") {
|
|
56
|
+
return originalWrite(chunk, ...rest);
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
const rl = createInterface({
|
|
61
|
+
input: stdin,
|
|
62
|
+
output: stdout,
|
|
63
|
+
terminal: true
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const answer = await new Promise((resolve, reject) => {
|
|
67
|
+
rl.once("close", () => {
|
|
68
|
+
reject(new Error("Aborted"));
|
|
69
|
+
});
|
|
70
|
+
rl.question("", (value) => {
|
|
71
|
+
resolve(value);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
return answer;
|
|
75
|
+
} finally {
|
|
76
|
+
muted = false;
|
|
77
|
+
stdout.write = originalWrite;
|
|
78
|
+
rl.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function readStdinKey() {
|
|
82
|
+
const chunks = [];
|
|
83
|
+
for await (const chunk of process.stdin) {
|
|
84
|
+
chunks.push(chunk);
|
|
85
|
+
}
|
|
86
|
+
return Buffer.concat(chunks).toString("utf-8").trim();
|
|
87
|
+
}
|
|
88
|
+
async function testConnection(serverUrl, apiKey) {
|
|
89
|
+
const url = `${serverUrl.replace(/\/$/, "")}/sync/health`;
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
99
|
+
throw new Error(`Sync server health check failed: ${msg}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function withSyncClient(dbPath, config, fn) {
|
|
103
|
+
const Database = (await import("better-sqlite3")).default;
|
|
104
|
+
const { SyncClient, ensureSyncSchema } = await import("@memrosetta/sync-client");
|
|
105
|
+
if (!config.syncServerUrl || !config.syncApiKey || !config.syncDeviceId) {
|
|
106
|
+
throw new Error("Sync is not configured. Run: memrosetta sync enable --server <url>");
|
|
107
|
+
}
|
|
108
|
+
if (CONTROL_CHAR_REGEX.test(config.syncApiKey)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
"Stored API key is invalid (contains control characters from a previous terminal input). Re-run with --key-stdin to fix it:\n echo <api-key> | memrosetta sync enable --server " + config.syncServerUrl + " --key-stdin"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const db = new Database(dbPath);
|
|
114
|
+
try {
|
|
115
|
+
ensureSyncSchema(db);
|
|
116
|
+
const client = new SyncClient(db, {
|
|
117
|
+
serverUrl: config.syncServerUrl,
|
|
118
|
+
apiKey: config.syncApiKey,
|
|
119
|
+
deviceId: config.syncDeviceId,
|
|
120
|
+
userId: userInfo().username
|
|
121
|
+
});
|
|
122
|
+
return await fn(client, db);
|
|
123
|
+
} finally {
|
|
124
|
+
db.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function runEnable(options) {
|
|
128
|
+
const { args, format } = options;
|
|
129
|
+
let serverUrl;
|
|
130
|
+
try {
|
|
131
|
+
serverUrl = requireOption(args, "--server", "server URL");
|
|
132
|
+
} catch (err) {
|
|
133
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let rawApiKey = optionalOption(args, "--key");
|
|
138
|
+
if (hasFlag(args, "--key-stdin")) {
|
|
139
|
+
rawApiKey = await readStdinKey();
|
|
140
|
+
}
|
|
141
|
+
if (!rawApiKey) {
|
|
142
|
+
if (platform() === "win32") {
|
|
143
|
+
process.stdout.write(
|
|
144
|
+
"Note: Windows terminals do not always mask pasted input. If characters appear or the key is rejected, pipe it via --key-stdin instead.\n"
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
rawApiKey = await readHiddenInput("API key: ");
|
|
149
|
+
} catch (err) {
|
|
150
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!rawApiKey) {
|
|
156
|
+
outputError("API key is required", format);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let apiKey;
|
|
161
|
+
try {
|
|
162
|
+
apiKey = validateApiKey(rawApiKey);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const skipTest = hasFlag(args, "--no-test");
|
|
169
|
+
if (!skipTest) {
|
|
170
|
+
try {
|
|
171
|
+
await testConnection(serverUrl, apiKey);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
outputError(
|
|
174
|
+
`${err instanceof Error ? err.message : String(err)}
|
|
175
|
+
Use --no-test to skip the health check.`,
|
|
176
|
+
format
|
|
177
|
+
);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const existing = getConfig();
|
|
183
|
+
const deviceId = existing.syncDeviceId ?? `device-${randomUUID().slice(0, 8)}`;
|
|
184
|
+
writeConfig({
|
|
185
|
+
...existing,
|
|
186
|
+
syncEnabled: true,
|
|
187
|
+
syncServerUrl: serverUrl,
|
|
188
|
+
syncApiKey: apiKey,
|
|
189
|
+
syncDeviceId: deviceId
|
|
190
|
+
});
|
|
191
|
+
if (format === "text") {
|
|
192
|
+
process.stdout.write("Sync enabled.\n");
|
|
193
|
+
process.stdout.write(` Server: ${serverUrl}
|
|
194
|
+
`);
|
|
195
|
+
process.stdout.write(` DeviceId: ${deviceId}
|
|
196
|
+
`);
|
|
197
|
+
if (skipTest) {
|
|
198
|
+
process.stdout.write(" (health check skipped)\n");
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
output({ enabled: true, serverUrl, deviceId, healthCheckSkipped: skipTest }, format);
|
|
203
|
+
}
|
|
204
|
+
function runDisable(options) {
|
|
205
|
+
const { format } = options;
|
|
206
|
+
const existing = getConfig();
|
|
207
|
+
writeConfig({
|
|
208
|
+
...existing,
|
|
209
|
+
syncEnabled: false
|
|
210
|
+
});
|
|
211
|
+
if (format === "text") {
|
|
212
|
+
process.stdout.write("Sync disabled. (server URL and API key preserved for re-enable)\n");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
output({ enabled: false }, format);
|
|
216
|
+
}
|
|
217
|
+
async function runStatus(options) {
|
|
218
|
+
const { format, db } = options;
|
|
219
|
+
const config = getConfig();
|
|
220
|
+
const dbPath = db ?? config.dbPath ?? getDefaultDbPath();
|
|
221
|
+
if (!config.syncEnabled) {
|
|
222
|
+
if (format === "text") {
|
|
223
|
+
process.stdout.write("Sync: disabled\n");
|
|
224
|
+
if (config.syncServerUrl) {
|
|
225
|
+
process.stdout.write(` Server: ${config.syncServerUrl}
|
|
226
|
+
`);
|
|
227
|
+
}
|
|
228
|
+
if (config.syncDeviceId) {
|
|
229
|
+
process.stdout.write(` DeviceId: ${config.syncDeviceId}
|
|
230
|
+
`);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
output(
|
|
235
|
+
{
|
|
236
|
+
enabled: false,
|
|
237
|
+
serverUrl: config.syncServerUrl ?? null,
|
|
238
|
+
deviceId: config.syncDeviceId ?? null
|
|
239
|
+
},
|
|
240
|
+
format
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const status = await withSyncClient(dbPath, config, async (client) => client.getStatus());
|
|
246
|
+
if (format === "text") {
|
|
247
|
+
process.stdout.write("Sync: enabled\n");
|
|
248
|
+
process.stdout.write(` Server: ${status.serverUrl}
|
|
249
|
+
`);
|
|
250
|
+
process.stdout.write(` DeviceId: ${status.deviceId}
|
|
251
|
+
`);
|
|
252
|
+
process.stdout.write(` Pending ops: ${status.pendingOps}
|
|
253
|
+
`);
|
|
254
|
+
process.stdout.write(` Current cursor: ${status.cursor}
|
|
255
|
+
`);
|
|
256
|
+
process.stdout.write(
|
|
257
|
+
` Last push: ${status.lastPush.successAt ?? "never"}` + (status.lastPush.attemptAt && status.lastPush.attemptAt !== status.lastPush.successAt ? ` (last attempt: ${status.lastPush.attemptAt})` : "") + "\n"
|
|
258
|
+
);
|
|
259
|
+
process.stdout.write(
|
|
260
|
+
` Last pull: ${status.lastPull.successAt ?? "never"}` + (status.lastPull.attemptAt && status.lastPull.attemptAt !== status.lastPull.successAt ? ` (last attempt: ${status.lastPull.attemptAt})` : "") + "\n"
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
output(status, format);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
267
|
+
process.exitCode = 1;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function runNow(options) {
|
|
271
|
+
const { args, format, db } = options;
|
|
272
|
+
const config = getConfig();
|
|
273
|
+
const dbPath = db ?? config.dbPath ?? getDefaultDbPath();
|
|
274
|
+
if (!config.syncEnabled) {
|
|
275
|
+
outputError("Sync is disabled. Run: memrosetta sync enable --server <url>", format);
|
|
276
|
+
process.exitCode = 1;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const pushOnly = hasFlag(args, "--push-only");
|
|
280
|
+
const pullOnly = hasFlag(args, "--pull-only");
|
|
281
|
+
try {
|
|
282
|
+
const result = await withSyncClient(dbPath, config, async (client) => {
|
|
283
|
+
let pushed = 0;
|
|
284
|
+
let pulled = 0;
|
|
285
|
+
if (!pullOnly) {
|
|
286
|
+
const pushResult = await client.push();
|
|
287
|
+
pushed = pushResult.pushed;
|
|
288
|
+
}
|
|
289
|
+
if (!pushOnly) {
|
|
290
|
+
pulled = await client.pull();
|
|
291
|
+
}
|
|
292
|
+
return { pushed, pulled };
|
|
293
|
+
});
|
|
294
|
+
if (format === "text") {
|
|
295
|
+
process.stdout.write(`Sync complete. pushed=${result.pushed} pulled=${result.pulled}
|
|
296
|
+
`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
output(result, format);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
outputError(err instanceof Error ? err.message : String(err), format);
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function runDeviceId(options) {
|
|
306
|
+
const { format } = options;
|
|
307
|
+
const config = getConfig();
|
|
308
|
+
if (!config.syncDeviceId) {
|
|
309
|
+
outputError("No deviceId set. Run: memrosetta sync enable --server <url>", format);
|
|
310
|
+
process.exitCode = 1;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (format === "text") {
|
|
314
|
+
process.stdout.write(`${config.syncDeviceId}
|
|
315
|
+
`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
output({ deviceId: config.syncDeviceId }, format);
|
|
319
|
+
}
|
|
320
|
+
async function run(options) {
|
|
321
|
+
const sub = parseSubcommand(options.args);
|
|
322
|
+
if (!sub) {
|
|
323
|
+
outputError(
|
|
324
|
+
"Usage: memrosetta sync <enable|disable|status|now|device-id>\n\n enable --server <url> [--key <key> | --key-stdin] [--no-test]\n disable\n status\n now [--push-only | --pull-only]\n device-id\n",
|
|
325
|
+
options.format
|
|
326
|
+
);
|
|
327
|
+
process.exitCode = 1;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const rest = { ...options, args: options.args.slice(1) };
|
|
331
|
+
switch (sub) {
|
|
332
|
+
case "enable":
|
|
333
|
+
await runEnable(rest);
|
|
334
|
+
return;
|
|
335
|
+
case "disable":
|
|
336
|
+
runDisable(rest);
|
|
337
|
+
return;
|
|
338
|
+
case "status":
|
|
339
|
+
await runStatus(rest);
|
|
340
|
+
return;
|
|
341
|
+
case "now":
|
|
342
|
+
await runNow(rest);
|
|
343
|
+
return;
|
|
344
|
+
case "device-id":
|
|
345
|
+
runDeviceId(rest);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
export {
|
|
350
|
+
run
|
|
351
|
+
};
|