forget-connect 0.1.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/LICENSE +202 -0
- package/README.md +110 -0
- package/bin/forget-connect.js +9 -0
- package/package.json +37 -0
- package/src/cli.js +484 -0
- package/src/core.js +680 -0
- package/src/doctor.js +456 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import readline from "node:readline/promises";
|
|
3
|
+
import { stdin, stdout } from "node:process";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MCP_URL,
|
|
6
|
+
ConfigError,
|
|
7
|
+
applyPlan,
|
|
8
|
+
buildPlan,
|
|
9
|
+
detectClients,
|
|
10
|
+
getClients,
|
|
11
|
+
inspectClients,
|
|
12
|
+
normalizeUrl,
|
|
13
|
+
redactUrlForDisplay,
|
|
14
|
+
scopedMcpUrl,
|
|
15
|
+
validateScopeId,
|
|
16
|
+
validateApiKey,
|
|
17
|
+
} from "./core.js";
|
|
18
|
+
import { doctorRemote } from "./doctor.js";
|
|
19
|
+
|
|
20
|
+
const CLIENT_IDS = new Set(["claude-code", "codex", "claude-desktop"]);
|
|
21
|
+
|
|
22
|
+
function help() {
|
|
23
|
+
return `forget-connect — connect your AI clients to Forget memory
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
forget-connect [connect] [options]
|
|
27
|
+
forget-connect status [options]
|
|
28
|
+
forget-connect doctor [options]
|
|
29
|
+
forget-connect disconnect [options]
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--client <ids> Comma-separated: claude-code,codex,claude-desktop,all
|
|
33
|
+
--url <url> MCP URL (default: ${DEFAULT_MCP_URL})
|
|
34
|
+
--user-id <id> Memory user scope (pair with --app-id)
|
|
35
|
+
--app-id <id> Project/app scope (pair with --user-id)
|
|
36
|
+
--no-auth Connect without a Bearer token (for a local server)
|
|
37
|
+
--no-rules Do not manage CLAUDE.md or AGENTS.md instruction blocks
|
|
38
|
+
--no-migrate-enacta Keep matching legacy config and rules blocks
|
|
39
|
+
--dry-run Show the files that would change without writing them
|
|
40
|
+
--timeout <seconds> Doctor network timeout (default: 10)
|
|
41
|
+
--json Print doctor results as JSON
|
|
42
|
+
-y, --yes Use detected clients (or all if none are detected)
|
|
43
|
+
-h, --help Show help
|
|
44
|
+
-v, --version Show version
|
|
45
|
+
|
|
46
|
+
Authentication:
|
|
47
|
+
Set FORGET_API_KEY, or run interactively and paste the key when prompted.
|
|
48
|
+
Keys are intentionally not accepted as command-line arguments.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
npx forget-connect
|
|
52
|
+
FORGET_API_KEY=... npx forget-connect --client claude-code,codex
|
|
53
|
+
FORGET_API_KEY=... npx forget-connect --user-id junghunkim --app-id Mem1
|
|
54
|
+
npx forget-connect --url http://localhost:8000/mcp --no-auth --client all
|
|
55
|
+
npx forget-connect status
|
|
56
|
+
npx forget-connect doctor --client codex
|
|
57
|
+
npx forget-connect disconnect --client codex
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function requireValue(argv, index, flag) {
|
|
62
|
+
const value = argv[index + 1];
|
|
63
|
+
if (!value || value.startsWith("-")) {
|
|
64
|
+
throw new ConfigError(`${flag} requires a value.`);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isHostedBaseUrl(value) {
|
|
70
|
+
const candidate = new URL(value);
|
|
71
|
+
const hosted = new URL(DEFAULT_MCP_URL);
|
|
72
|
+
const candidateHost = candidate.hostname.toLowerCase().replace(/\.+$/, "");
|
|
73
|
+
const hostedHost = hosted.hostname.toLowerCase().replace(/\.+$/, "");
|
|
74
|
+
if (candidateHost !== hostedHost) return false;
|
|
75
|
+
if (candidate.protocol !== hosted.protocol || candidate.port !== hosted.port) {
|
|
76
|
+
throw new ConfigError(
|
|
77
|
+
"The hosted Forget service requires HTTPS on its standard port.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
// Treat every URL on the managed service origin as hosted. Matching only a
|
|
81
|
+
// pathname prefix is bypassable with percent-encoded route segments that an
|
|
82
|
+
// HTTP server later decodes (for example /%6Dcp or /mcp%2Fproject).
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseArgs(argv, env = process.env) {
|
|
87
|
+
const options = {
|
|
88
|
+
action: "connect",
|
|
89
|
+
clientValues: [],
|
|
90
|
+
url: env.FORGET_MCP_URL || DEFAULT_MCP_URL,
|
|
91
|
+
userId: env.FORGET_USER_ID?.trim() || "",
|
|
92
|
+
appId: env.FORGET_APP_ID?.trim() || "",
|
|
93
|
+
auth: true,
|
|
94
|
+
rules: true,
|
|
95
|
+
migrateLegacy: true,
|
|
96
|
+
dryRun: false,
|
|
97
|
+
yes: false,
|
|
98
|
+
help: false,
|
|
99
|
+
version: false,
|
|
100
|
+
timeoutMs: 10000,
|
|
101
|
+
json: false,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
let actionSeen = false;
|
|
105
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
106
|
+
const arg = argv[index];
|
|
107
|
+
if (["connect", "disconnect", "status", "doctor"].includes(arg) && !actionSeen) {
|
|
108
|
+
options.action = arg;
|
|
109
|
+
actionSeen = true;
|
|
110
|
+
} else if (arg === "--client") {
|
|
111
|
+
options.clientValues.push(requireValue(argv, index, arg));
|
|
112
|
+
index += 1;
|
|
113
|
+
} else if (arg.startsWith("--client=")) {
|
|
114
|
+
options.clientValues.push(arg.slice("--client=".length));
|
|
115
|
+
} else if (arg === "--url") {
|
|
116
|
+
options.url = requireValue(argv, index, arg);
|
|
117
|
+
index += 1;
|
|
118
|
+
} else if (arg.startsWith("--url=")) {
|
|
119
|
+
options.url = arg.slice("--url=".length);
|
|
120
|
+
} else if (arg === "--user-id") {
|
|
121
|
+
options.userId = requireValue(argv, index, arg);
|
|
122
|
+
index += 1;
|
|
123
|
+
} else if (arg.startsWith("--user-id=")) {
|
|
124
|
+
options.userId = arg.slice("--user-id=".length);
|
|
125
|
+
} else if (arg === "--app-id") {
|
|
126
|
+
options.appId = requireValue(argv, index, arg);
|
|
127
|
+
index += 1;
|
|
128
|
+
} else if (arg.startsWith("--app-id=")) {
|
|
129
|
+
options.appId = arg.slice("--app-id=".length);
|
|
130
|
+
} else if (arg === "--no-auth") {
|
|
131
|
+
options.auth = false;
|
|
132
|
+
} else if (arg === "--no-rules") {
|
|
133
|
+
options.rules = false;
|
|
134
|
+
} else if (arg === "--no-migrate-enacta") {
|
|
135
|
+
options.migrateLegacy = false;
|
|
136
|
+
} else if (arg === "--dry-run") {
|
|
137
|
+
options.dryRun = true;
|
|
138
|
+
} else if (arg === "--timeout") {
|
|
139
|
+
const seconds = Number(requireValue(argv, index, arg));
|
|
140
|
+
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 60) {
|
|
141
|
+
throw new ConfigError("--timeout must be between 1 and 60 seconds.");
|
|
142
|
+
}
|
|
143
|
+
options.timeoutMs = Math.round(seconds * 1000);
|
|
144
|
+
index += 1;
|
|
145
|
+
} else if (arg.startsWith("--timeout=")) {
|
|
146
|
+
const seconds = Number(arg.slice("--timeout=".length));
|
|
147
|
+
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 60) {
|
|
148
|
+
throw new ConfigError("--timeout must be between 1 and 60 seconds.");
|
|
149
|
+
}
|
|
150
|
+
options.timeoutMs = Math.round(seconds * 1000);
|
|
151
|
+
} else if (arg === "--json") {
|
|
152
|
+
options.json = true;
|
|
153
|
+
} else if (arg === "--yes" || arg === "-y") {
|
|
154
|
+
options.yes = true;
|
|
155
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
156
|
+
options.help = true;
|
|
157
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
158
|
+
options.version = true;
|
|
159
|
+
} else {
|
|
160
|
+
throw new ConfigError(`Unknown argument: ${arg}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
options.baseUrl = normalizeUrl(options.url);
|
|
164
|
+
options.hosted = isHostedBaseUrl(options.baseUrl);
|
|
165
|
+
if (Boolean(options.userId) !== Boolean(options.appId)) {
|
|
166
|
+
throw new ConfigError("--user-id and --app-id must be provided together.");
|
|
167
|
+
}
|
|
168
|
+
options.scope = options.userId
|
|
169
|
+
? {
|
|
170
|
+
userId: validateScopeId(options.userId, "user_id"),
|
|
171
|
+
appId: validateScopeId(options.appId, "app_id"),
|
|
172
|
+
}
|
|
173
|
+
: null;
|
|
174
|
+
options.url = options.scope
|
|
175
|
+
? scopedMcpUrl(options.baseUrl, options.scope)
|
|
176
|
+
: options.baseUrl;
|
|
177
|
+
return options;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function requestedClientIds(values) {
|
|
181
|
+
const ids = values
|
|
182
|
+
.flatMap((value) => value.split(","))
|
|
183
|
+
.map((value) => value.trim())
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
if (ids.includes("all")) return ["claude-code", "codex", "claude-desktop"];
|
|
186
|
+
for (const id of ids) {
|
|
187
|
+
if (!CLIENT_IDS.has(id)) {
|
|
188
|
+
throw new ConfigError(`Unknown client: ${id}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return [...new Set(ids)];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function promptForClients(allClients, detected) {
|
|
195
|
+
const defaults = detected.length ? detected : allClients;
|
|
196
|
+
stdout.write("\nWhere should Forget be connected?\n");
|
|
197
|
+
allClients.forEach((client, index) => {
|
|
198
|
+
const detectedLabel = detected.includes(client) ? " (detected)" : "";
|
|
199
|
+
stdout.write(` ${index + 1}. ${client.name}${detectedLabel}\n`);
|
|
200
|
+
});
|
|
201
|
+
const defaultIndexes = defaults.map((client) => allClients.indexOf(client) + 1);
|
|
202
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
203
|
+
let answer;
|
|
204
|
+
try {
|
|
205
|
+
answer = await rl.question(
|
|
206
|
+
`Choose comma-separated numbers [${defaultIndexes.join(",")}]: `,
|
|
207
|
+
);
|
|
208
|
+
} finally {
|
|
209
|
+
rl.close();
|
|
210
|
+
}
|
|
211
|
+
const values = answer.trim() ? answer.split(",") : defaultIndexes.map(String);
|
|
212
|
+
const chosen = values.map((value) => {
|
|
213
|
+
const index = Number.parseInt(value.trim(), 10) - 1;
|
|
214
|
+
if (!Number.isInteger(index) || index < 0 || index >= allClients.length) {
|
|
215
|
+
throw new ConfigError(`Invalid client selection: ${value}`);
|
|
216
|
+
}
|
|
217
|
+
return allClients[index];
|
|
218
|
+
});
|
|
219
|
+
return [...new Set(chosen)];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function selectClients(options, allClients) {
|
|
223
|
+
const explicitIds = requestedClientIds(options.clientValues);
|
|
224
|
+
if (explicitIds.length) {
|
|
225
|
+
return explicitIds.map((id) => allClients.find((client) => client.id === id));
|
|
226
|
+
}
|
|
227
|
+
if (options.action === "status" || options.action === "disconnect") return allClients;
|
|
228
|
+
|
|
229
|
+
const detected = await detectClients(allClients);
|
|
230
|
+
if (options.action === "doctor") {
|
|
231
|
+
if (detected.length) return detected;
|
|
232
|
+
throw new ConfigError("No supported client installation was detected. Pass --client <ids> to diagnose an explicit target.");
|
|
233
|
+
}
|
|
234
|
+
if (options.yes) return detected.length ? detected : allClients;
|
|
235
|
+
if (stdin.isTTY && stdout.isTTY) return promptForClients(allClients, detected);
|
|
236
|
+
throw new ConfigError(
|
|
237
|
+
"No client was selected in a non-interactive shell. Pass --client <ids> or --yes.",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function promptHidden(question) {
|
|
242
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
|
|
243
|
+
throw new ConfigError("Set FORGET_API_KEY when running non-interactively.");
|
|
244
|
+
}
|
|
245
|
+
stdout.write(question);
|
|
246
|
+
const wasRaw = Boolean(stdin.isRaw);
|
|
247
|
+
stdin.setRawMode(true);
|
|
248
|
+
stdin.resume();
|
|
249
|
+
let value = "";
|
|
250
|
+
try {
|
|
251
|
+
return await new Promise((resolve, reject) => {
|
|
252
|
+
const onData = (chunk) => {
|
|
253
|
+
for (const byte of chunk) {
|
|
254
|
+
if (byte === 3) {
|
|
255
|
+
stdin.off("data", onData);
|
|
256
|
+
stdout.write("\n");
|
|
257
|
+
reject(new ConfigError("Cancelled."));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (byte === 10 || byte === 13) {
|
|
261
|
+
stdin.off("data", onData);
|
|
262
|
+
stdout.write("\n");
|
|
263
|
+
resolve(value);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (byte === 127 || byte === 8) {
|
|
267
|
+
value = value.slice(0, -1);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
value += Buffer.from([byte]).toString("utf8");
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
stdin.on("data", onData);
|
|
274
|
+
});
|
|
275
|
+
} finally {
|
|
276
|
+
stdin.setRawMode(wasRaw);
|
|
277
|
+
stdin.pause();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function apiKeyFor(options, env) {
|
|
282
|
+
if (!["connect", "doctor"].includes(options.action) || !options.auth || options.dryRun) return "";
|
|
283
|
+
const fromEnv = env.FORGET_API_KEY?.trim();
|
|
284
|
+
if (fromEnv) {
|
|
285
|
+
if (new URL(options.url).protocol !== "https:") {
|
|
286
|
+
throw new ConfigError(
|
|
287
|
+
"Bearer authentication requires HTTPS. Use --no-auth for a local HTTP server.",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return validateApiKey(fromEnv);
|
|
291
|
+
}
|
|
292
|
+
if (!options.hosted) return "";
|
|
293
|
+
const prompted = (await promptHidden("Paste your Forget API key: ")).trim();
|
|
294
|
+
if (!prompted) {
|
|
295
|
+
throw new ConfigError("An API key is required for the hosted Forget service.");
|
|
296
|
+
}
|
|
297
|
+
return validateApiKey(prompted);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function printStatus(statuses) {
|
|
301
|
+
for (const status of statuses) {
|
|
302
|
+
const config = status.config ? "connected" : "not connected";
|
|
303
|
+
const rules = status.rules === null
|
|
304
|
+
? ""
|
|
305
|
+
: status.rules
|
|
306
|
+
? ", rules installed"
|
|
307
|
+
: ", rules missing";
|
|
308
|
+
stdout.write(`${status.config ? "✓" : "○"} ${status.client.name}: ${config}${rules}\n`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function localDoctorResults(statuses, { requireRules = true } = {}) {
|
|
313
|
+
return statuses.map((status) => ({
|
|
314
|
+
id: status.client.id,
|
|
315
|
+
name: status.client.name,
|
|
316
|
+
config: status.config,
|
|
317
|
+
rules: status.rules,
|
|
318
|
+
transport_valid: status.transportValid,
|
|
319
|
+
url_matches: status.urlMatches,
|
|
320
|
+
auth_matches: status.authMatches,
|
|
321
|
+
rules_current: status.rulesCurrent,
|
|
322
|
+
ok: Boolean(
|
|
323
|
+
status.config
|
|
324
|
+
&& status.transportValid
|
|
325
|
+
&& status.urlMatches
|
|
326
|
+
&& status.authMatches
|
|
327
|
+
&& (
|
|
328
|
+
status.rules === null
|
|
329
|
+
|| !requireRules
|
|
330
|
+
|| (status.rules && status.rulesCurrent)
|
|
331
|
+
)
|
|
332
|
+
),
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function printDoctor(result) {
|
|
337
|
+
stdout.write("Forget connection doctor\n");
|
|
338
|
+
for (const client of result.clients) {
|
|
339
|
+
const details = [
|
|
340
|
+
client.config ? "config found" : "config missing",
|
|
341
|
+
client.transport_valid ? "transport valid" : "transport invalid",
|
|
342
|
+
client.url_matches ? "URL matches" : "URL mismatch",
|
|
343
|
+
client.auth_matches ? "auth matches" : "auth mismatch",
|
|
344
|
+
];
|
|
345
|
+
if (client.rules !== null) {
|
|
346
|
+
details.push(
|
|
347
|
+
client.rules_current
|
|
348
|
+
? "rules current"
|
|
349
|
+
: client.rules
|
|
350
|
+
? "rules stale"
|
|
351
|
+
: "rules missing",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
stdout.write(`${client.ok ? "✓" : "✗"} ${client.name}: ${details.join(", ")}\n`);
|
|
355
|
+
}
|
|
356
|
+
if (result.remote.skipped) {
|
|
357
|
+
stdout.write("○ MCP: not checked until local configuration is fixed\n");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const scopeProbe = result.remote.scope_probe;
|
|
361
|
+
if (scopeProbe?.required || scopeProbe?.requested) {
|
|
362
|
+
stdout.write(
|
|
363
|
+
`${scopeProbe.ok ? "✓" : "✗"} Scope: ${scopeProbe.ok ? "verified" : "not verified"}\n`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
stdout.write(
|
|
367
|
+
`${result.remote.ok ? "✓" : "✗"} MCP: ${result.remote.server_name} · protocol ${result.remote.protocol_version} · ${result.remote.tool_count} tools\n`,
|
|
368
|
+
);
|
|
369
|
+
if (result.remote.missing_tools.length) {
|
|
370
|
+
stdout.write(` missing tools: ${result.remote.missing_tools.join(", ")}\n`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function version() {
|
|
375
|
+
const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
|
|
376
|
+
return JSON.parse(raw).version;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
380
|
+
const options = parseArgs(argv, env);
|
|
381
|
+
if (options.help) {
|
|
382
|
+
stdout.write(help());
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (options.version) {
|
|
386
|
+
stdout.write(`${await version()}\n`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const allClients = getClients({ env });
|
|
391
|
+
const clients = await selectClients(options, allClients);
|
|
392
|
+
if (options.action === "status") {
|
|
393
|
+
printStatus(await inspectClients(clients));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const apiKey = await apiKeyFor(options, env);
|
|
398
|
+
if (options.action === "doctor") {
|
|
399
|
+
const local = localDoctorResults(
|
|
400
|
+
await inspectClients(clients, { url: options.url, apiKey }),
|
|
401
|
+
{ requireRules: options.rules },
|
|
402
|
+
);
|
|
403
|
+
const remote = local.every((client) => client.ok)
|
|
404
|
+
? await doctorRemote({
|
|
405
|
+
url: options.url,
|
|
406
|
+
apiKey,
|
|
407
|
+
timeoutMs: options.timeoutMs,
|
|
408
|
+
clientVersion: await version(),
|
|
409
|
+
expectedScope: options.scope,
|
|
410
|
+
requireScope: options.hosted,
|
|
411
|
+
})
|
|
412
|
+
: {
|
|
413
|
+
ok: false,
|
|
414
|
+
skipped: true,
|
|
415
|
+
reason: "local_configuration_mismatch",
|
|
416
|
+
server_name: "not checked",
|
|
417
|
+
server_version: "not checked",
|
|
418
|
+
protocol_version: "not checked",
|
|
419
|
+
session_negotiated: false,
|
|
420
|
+
tool_count: 0,
|
|
421
|
+
required_tools: [],
|
|
422
|
+
missing_tools: [],
|
|
423
|
+
scope_probe: {
|
|
424
|
+
requested: Boolean(options.scope),
|
|
425
|
+
required: options.hosted,
|
|
426
|
+
ok: false,
|
|
427
|
+
skipped: true,
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
const result = {
|
|
431
|
+
ok: local.every((client) => client.ok) && remote.ok,
|
|
432
|
+
url: redactUrlForDisplay(options.url),
|
|
433
|
+
scope: { configured: Boolean(options.scope) },
|
|
434
|
+
clients: local,
|
|
435
|
+
remote,
|
|
436
|
+
};
|
|
437
|
+
if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
|
|
438
|
+
else printDoctor(result);
|
|
439
|
+
if (!result.ok) throw new ConfigError("Doctor checks failed.");
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
const changes = await buildPlan(options.action, clients, {
|
|
443
|
+
url: options.url,
|
|
444
|
+
apiKey,
|
|
445
|
+
installInstructionRules: options.rules,
|
|
446
|
+
migrateLegacy: options.migrateLegacy,
|
|
447
|
+
legacyUrls: [...new Set([options.url, options.baseUrl])],
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (options.dryRun) {
|
|
451
|
+
if (!changes.length) {
|
|
452
|
+
stdout.write("No changes needed.\n");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
for (const change of changes) {
|
|
456
|
+
stdout.write(`Would update ${change.filePath} (${change.client.name} ${change.kind})\n`);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const result = await applyPlan(changes);
|
|
462
|
+
const verb = options.action === "connect" ? "Connected" : "Disconnected";
|
|
463
|
+
if (!result.changed.length) {
|
|
464
|
+
stdout.write("No changes needed.\n");
|
|
465
|
+
} else {
|
|
466
|
+
stdout.write(`${verb} ${clients.map((client) => client.name).join(", ")}.\n`);
|
|
467
|
+
for (const filePath of result.changed) stdout.write(` updated ${filePath}\n`);
|
|
468
|
+
for (const filePath of result.backups) stdout.write(` backup ${filePath}\n`);
|
|
469
|
+
}
|
|
470
|
+
if (options.action === "connect") {
|
|
471
|
+
if (options.hosted && !options.scope) {
|
|
472
|
+
stdout.write(
|
|
473
|
+
"\nWarning: hosted continuity scope is not configured. Reconnect with --user-id and --app-id before relying on cross-client recall.\n",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
const restarts = [...new Set(clients.map((client) => client.restart))];
|
|
477
|
+
stdout.write("\nNext:\n");
|
|
478
|
+
restarts.forEach((message) => stdout.write(` - ${message}\n`));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function main() {
|
|
483
|
+
await run();
|
|
484
|
+
}
|