wikimemory 0.2.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -0
  3. package/dist/npm-cli/scripts/cli-options.js +29 -0
  4. package/dist/npm-cli/scripts/cli.js +79 -0
  5. package/dist/npm-cli/scripts/client-tools.js +57 -0
  6. package/dist/npm-cli/scripts/deployment-record.js +72 -0
  7. package/dist/npm-cli/scripts/dev.js +49 -0
  8. package/dist/npm-cli/scripts/lifecycle-runtime.js +38 -0
  9. package/dist/npm-cli/scripts/package-root.js +15 -0
  10. package/dist/npm-cli/scripts/passkeys.js +197 -0
  11. package/dist/npm-cli/scripts/setup.js +523 -0
  12. package/dist/npm-cli/scripts/status.js +49 -0
  13. package/dist/npm-cli/scripts/uninstall.js +220 -0
  14. package/dist/npm-cli/scripts/upgrade.js +301 -0
  15. package/dist/npm-cli/src/version.js +2 -0
  16. package/dist/web/assets/index-CjnFBnXp.css +1 -0
  17. package/dist/web/assets/index-buhGyrxi.js +72 -0
  18. package/dist/web/index.html +14 -0
  19. package/migrations/0001_initial.sql +297 -0
  20. package/migrations/0002_passkeys.sql +30 -0
  21. package/migrations/0003_passkey_management.sql +38 -0
  22. package/migrations/0004_credential_bound_registration_tokens.sql +18 -0
  23. package/package.json +89 -0
  24. package/release-manifest.json +22 -0
  25. package/skills/wikimemory-ingest/SKILL.md +24 -0
  26. package/skills/wikimemory-ingest/agents/openai.yaml +4 -0
  27. package/skills/wikimemory-install/SKILL.md +81 -0
  28. package/skills/wikimemory-install/agents/openai.yaml +4 -0
  29. package/skills/wikimemory-lint/SKILL.md +18 -0
  30. package/skills/wikimemory-lint/agents/openai.yaml +4 -0
  31. package/skills/wikimemory-recall/SKILL.md +20 -0
  32. package/skills/wikimemory-recall/agents/openai.yaml +4 -0
  33. package/src/auth/local.ts +131 -0
  34. package/src/auth/passkey-api.ts +100 -0
  35. package/src/auth/passkey-management.ts +150 -0
  36. package/src/auth/passkey.ts +908 -0
  37. package/src/auth/props.ts +96 -0
  38. package/src/auth/resource.ts +36 -0
  39. package/src/domain/crypto.ts +27 -0
  40. package/src/domain/errors.ts +32 -0
  41. package/src/domain/export-service.ts +363 -0
  42. package/src/domain/guards.ts +9 -0
  43. package/src/domain/memory-service.ts +1092 -0
  44. package/src/domain/secret-scanner.ts +45 -0
  45. package/src/domain/text-chunk.ts +24 -0
  46. package/src/domain/types.ts +169 -0
  47. package/src/env.ts +20 -0
  48. package/src/index.ts +138 -0
  49. package/src/mcp/schemas.ts +117 -0
  50. package/src/mcp/server.ts +506 -0
  51. package/src/version.ts +2 -0
  52. package/src/web/app.ts +299 -0
@@ -0,0 +1,523 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import { access, mkdtemp, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import process from "node:process";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { pathToFileURL } from "node:url";
10
+ import { z } from "zod";
11
+ import { WIKIMEMORY_VERSION } from "../src/version.js";
12
+ import { deploymentRecordFromConfig, writeDeploymentRecord } from "./deployment-record.js";
13
+ import { installedRecordPath, packageRoot, setupRuntime } from "./lifecycle-runtime.js";
14
+ const PACKAGE_ROOT = packageRoot;
15
+ const CONFIG_PATH = setupRuntime.config;
16
+ const STATE_PATH = setupRuntime.progress;
17
+ const BOOTSTRAP_ORIGIN = "https://bootstrap.invalid";
18
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/u;
19
+ const WHOAMI_SCHEMA = z.discriminatedUnion("loggedIn", [
20
+ z.object({ loggedIn: z.literal(false) }),
21
+ z.object({
22
+ loggedIn: z.literal(true),
23
+ email: z.string().nullable().optional(),
24
+ accounts: z.array(z.object({ id: z.string().min(1), name: z.string().min(1) }))
25
+ })
26
+ ]);
27
+ const STATE_SCHEMA = z.object({
28
+ preflightComplete: z.literal(true),
29
+ accountId: z.string(),
30
+ workerName: z.string(),
31
+ databaseName: z.string(),
32
+ kvName: z.string()
33
+ });
34
+ const D1_QUERY_SCHEMA = z.array(z.object({ results: z.array(z.object({ name: z.string() })) }));
35
+ export function parseOptions(args) {
36
+ let recover = false;
37
+ let resume = false;
38
+ let yes = false;
39
+ let help = false;
40
+ let workerName = "wikimemory";
41
+ let databaseName = "wikimemory";
42
+ let kvName = "wikimemory-oauth";
43
+ let accountId = null;
44
+ for (let index = 0; index < args.length; index += 1) {
45
+ const argument = args[index];
46
+ if (argument === "--recover")
47
+ recover = true;
48
+ else if (argument === "--resume")
49
+ resume = true;
50
+ else if (argument === "--yes")
51
+ yes = true;
52
+ else if (argument === "--help")
53
+ help = true;
54
+ else if (argument === "--worker-name" ||
55
+ argument === "--database-name" ||
56
+ argument === "--kv-name" ||
57
+ argument === "--account-id") {
58
+ const value = args[index + 1];
59
+ if (value === undefined || value.startsWith("--"))
60
+ throw new Error(`${argument} requires a value`);
61
+ if (argument === "--account-id")
62
+ accountId = value;
63
+ else {
64
+ if (!NAME_PATTERN.test(value))
65
+ throw new Error(`${argument} must contain only lowercase letters, digits, and hyphens`);
66
+ if (argument === "--worker-name")
67
+ workerName = value;
68
+ else if (argument === "--database-name")
69
+ databaseName = value;
70
+ else
71
+ kvName = value;
72
+ }
73
+ index += 1;
74
+ }
75
+ else {
76
+ throw new Error(`Unknown option: ${argument ?? ""}`);
77
+ }
78
+ }
79
+ if (recover && resume)
80
+ throw new Error("--recover and --resume cannot be combined");
81
+ return { recover, resume, yes, help, workerName, databaseName, kvName, accountId };
82
+ }
83
+ function usage() {
84
+ return `Usage: ${setupRuntime.executable} [--account-id ID] [--yes] [--worker-name NAME] [--database-name NAME] [--kv-name NAME]\n ${setupRuntime.executable} --resume [--yes]\n ${setupRuntime.executable} --recover [--yes]`;
85
+ }
86
+ async function exists(path) {
87
+ try {
88
+ await access(path, constants.F_OK);
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ async function run(command, args, input, allowFailure = false) {
96
+ console.log(`\n> ${command} ${args.join(" ")}`);
97
+ const result = await new Promise((resolve, reject) => {
98
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
99
+ let stdout = "";
100
+ let stderr = "";
101
+ child.stdout.setEncoding("utf8");
102
+ child.stderr.setEncoding("utf8");
103
+ child.stdout.on("data", (chunk) => {
104
+ stdout += chunk;
105
+ process.stdout.write(chunk);
106
+ });
107
+ child.stderr.on("data", (chunk) => {
108
+ stderr += chunk;
109
+ process.stderr.write(chunk);
110
+ });
111
+ child.on("error", (error) => {
112
+ reject(error);
113
+ });
114
+ child.on("close", (code) => {
115
+ resolve({ stdout, stderr, exitCode: code ?? -1 });
116
+ });
117
+ child.stdin.end(input === undefined ? undefined : `${input}\n`);
118
+ });
119
+ if (result.exitCode !== 0 && !allowFailure)
120
+ throw new Error(`${command} exited with status ${result.exitCode}`);
121
+ return result;
122
+ }
123
+ async function runInteractive(command, args) {
124
+ console.log(`\n> ${command} ${args.join(" ")}`);
125
+ await new Promise((resolve, reject) => {
126
+ const child = spawn(command, args, { stdio: "inherit" });
127
+ child.on("error", (error) => {
128
+ reject(error);
129
+ });
130
+ child.on("close", (code) => {
131
+ if (code === 0)
132
+ resolve();
133
+ else
134
+ reject(new Error(`${command} exited with status ${code ?? "unknown"}`));
135
+ });
136
+ });
137
+ }
138
+ export function initialConfig(options, account) {
139
+ return `${JSON.stringify({
140
+ $schema: join(PACKAGE_ROOT, "node_modules", "wrangler", "config-schema.json"),
141
+ name: options.workerName,
142
+ account_id: account.id,
143
+ main: join(PACKAGE_ROOT, "src", "index.ts"),
144
+ compatibility_date: "2026-07-18",
145
+ compatibility_flags: ["nodejs_compat"],
146
+ assets: {
147
+ directory: join(PACKAGE_ROOT, "dist", "web"),
148
+ binding: "ASSETS",
149
+ not_found_handling: "single-page-application"
150
+ },
151
+ vars: { APP_ENV: "production", APP_BASE_URL: BOOTSTRAP_ORIGIN }
152
+ }, null, 2)}\n`;
153
+ }
154
+ export function withWebAssets(config) {
155
+ const parsed = z.record(z.string(), z.unknown()).parse(JSON.parse(config));
156
+ return `${JSON.stringify({
157
+ ...parsed,
158
+ assets: {
159
+ directory: join(PACKAGE_ROOT, "dist", "web"),
160
+ binding: "ASSETS",
161
+ not_found_handling: "single-page-application"
162
+ }
163
+ }, null, 2)}\n`;
164
+ }
165
+ export function deployedOrigin(output) {
166
+ const match = output.match(/https:\/\/[a-zA-Z0-9.-]+\.workers\.dev/u);
167
+ return match?.[0] ?? null;
168
+ }
169
+ export function configuredOrigin(config) {
170
+ const match = config.match(/"APP_BASE_URL"\s*:\s*"(https:\/\/[^"/]+)"/u);
171
+ return match?.[1] ?? null;
172
+ }
173
+ export function configValue(config, key) {
174
+ const match = config.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`, "u"));
175
+ return match?.[1] ?? null;
176
+ }
177
+ function hasBinding(config, binding) {
178
+ return new RegExp(`"binding"\\s*:\\s*"${binding}"`, "u").test(config);
179
+ }
180
+ export function bindingProperty(config, binding, property) {
181
+ const objects = config.match(/\{[^{}]*\}/gu) ?? [];
182
+ const object = objects.find((candidate) => new RegExp(`"binding"\\s*:\\s*"${binding}"`, "u").test(candidate));
183
+ return object === undefined ? null : configValue(object, property);
184
+ }
185
+ export function migrationBundle(sql, migrationName) {
186
+ const escapedName = migrationName.replaceAll("'", "''");
187
+ return `${sql.trimEnd()}\n\nINSERT INTO d1_migrations(name) VALUES ('${escapedName}');\n`;
188
+ }
189
+ export async function applyRemoteMigrations(databaseName) {
190
+ await run("npx", [
191
+ "wrangler",
192
+ "d1",
193
+ "execute",
194
+ databaseName,
195
+ "--remote",
196
+ "--config",
197
+ CONFIG_PATH,
198
+ "--command",
199
+ "CREATE TABLE IF NOT EXISTS d1_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL)"
200
+ ]);
201
+ const listed = await run("npx", [
202
+ "wrangler",
203
+ "d1",
204
+ "execute",
205
+ databaseName,
206
+ "--remote",
207
+ "--config",
208
+ CONFIG_PATH,
209
+ "--json",
210
+ "--command",
211
+ "SELECT name FROM d1_migrations ORDER BY id"
212
+ ]);
213
+ const applied = new Set(D1_QUERY_SCHEMA.parse(JSON.parse(listed.stdout)).flatMap((result) => result.results.map((row) => row.name)));
214
+ const migrations = (await readdir(join(PACKAGE_ROOT, "migrations")))
215
+ .filter((name) => /^\d+.*\.sql$/u.test(name))
216
+ .sort();
217
+ for (const migration of migrations) {
218
+ if (applied.has(migration))
219
+ continue;
220
+ const temporary = await mkdtemp(join(tmpdir(), "wikimemory-d1-migration-"));
221
+ if (!temporary.startsWith(`${tmpdir()}/wikimemory-d1-migration-`))
222
+ throw new Error("Unexpected migration temporary path");
223
+ try {
224
+ const bundlePath = join(temporary, migration);
225
+ await writeFile(bundlePath, migrationBundle(await readFile(join(PACKAGE_ROOT, "migrations", migration), "utf8"), migration), "utf8");
226
+ await run("npx", [
227
+ "wrangler",
228
+ "d1",
229
+ "execute",
230
+ databaseName,
231
+ "--remote",
232
+ "--config",
233
+ CONFIG_PATH,
234
+ "--file",
235
+ bundlePath
236
+ ]);
237
+ }
238
+ finally {
239
+ await rm(temporary, { recursive: true, force: true });
240
+ }
241
+ }
242
+ }
243
+ async function question(text) {
244
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
245
+ const answer = await prompt.question(text);
246
+ prompt.close();
247
+ return answer;
248
+ }
249
+ async function confirm(text, automatic) {
250
+ if (automatic)
251
+ return;
252
+ const answer = (await question(`${text} [y/N] `)).trim().toLowerCase();
253
+ if (answer !== "y" && answer !== "yes")
254
+ throw new Error("Cancelled");
255
+ }
256
+ async function selectAccount(options) {
257
+ const result = await run("npx", ["wrangler", "whoami", "--json"], undefined, true);
258
+ let whoami;
259
+ try {
260
+ whoami = WHOAMI_SCHEMA.parse(JSON.parse(result.stdout));
261
+ }
262
+ catch {
263
+ throw new Error("Wrangler authentication could not be determined. Run npx wrangler login first.");
264
+ }
265
+ if (!whoami.loggedIn)
266
+ throw new Error("Wrangler is not authenticated. Run npx wrangler login first.");
267
+ if (result.exitCode !== 0)
268
+ throw new Error("Wrangler could not read the authenticated Cloudflare identity.");
269
+ if (whoami.accounts.length === 0)
270
+ throw new Error("The authenticated Cloudflare identity has no accounts.");
271
+ let account;
272
+ if (options.accountId !== null)
273
+ account = whoami.accounts.find((candidate) => candidate.id === options.accountId);
274
+ else if (whoami.accounts.length === 1)
275
+ account = whoami.accounts[0];
276
+ else if (options.yes)
277
+ throw new Error("Multiple Cloudflare accounts are available; pass --account-id with the intended account ID.");
278
+ else {
279
+ console.log("\nCloudflare accounts:");
280
+ whoami.accounts.forEach((candidate, index) => {
281
+ console.log(` ${index + 1}. ${candidate.name} (${candidate.id})`);
282
+ });
283
+ const selected = Number.parseInt(await question("Select an account number: "), 10);
284
+ account = whoami.accounts[selected - 1];
285
+ }
286
+ if (account === undefined)
287
+ throw new Error("The requested Cloudflare account was not found.");
288
+ return { account, identity: whoami.email ?? "authenticated token" };
289
+ }
290
+ function bootstrapSecret() {
291
+ const raw = randomBytes(32).toString("base64url");
292
+ const hash = createHash("sha256").update(raw, "utf8").digest("hex");
293
+ return { raw, hash };
294
+ }
295
+ async function verifyEndpoint(origin, path, expectedStatus) {
296
+ const response = await fetch(`${origin}${path}`, { redirect: "error" });
297
+ if (!response.ok)
298
+ throw new Error(`Deployment ${path} check failed with HTTP ${response.status}`);
299
+ const parsed = z
300
+ .object({ status: z.literal(expectedStatus), service: z.literal("wikimemory") })
301
+ .safeParse(await response.json());
302
+ if (!parsed.success)
303
+ throw new Error(`Deployment ${path} returned an unexpected response.`);
304
+ }
305
+ export function handoff(origin, rawToken) {
306
+ const endpoint = `${origin}/mcp`;
307
+ return `Wikimemory is ready for owner setup.\n\nOpen this one-time URL on a device that can create a passkey:\n${origin}/setup#${encodeURIComponent(rawToken)}\n\nAfter setup, connect clients with:\n codex mcp add wikimemory --url ${endpoint}\n codex mcp login wikimemory --scopes memory:read,memory:write\n claude mcp add --transport http --scope user wikimemory ${endpoint}\n\nThe setup token was not written to disk. This is the only time it will be printed.`;
308
+ }
309
+ async function remoteWorkerExists(workerName) {
310
+ const result = await run("npx", ["wrangler", "deployments", "list", "--name", workerName, "--json", "--config", CONFIG_PATH], undefined, true);
311
+ return deploymentListIndicatesExisting(result);
312
+ }
313
+ export function deploymentListIndicatesExisting(result) {
314
+ if (result.exitCode === 0) {
315
+ const parsed = z.array(z.unknown()).safeParse(JSON.parse(result.stdout));
316
+ if (!parsed.success)
317
+ throw new Error("Wrangler returned an unexpected deployment-list response.");
318
+ return true;
319
+ }
320
+ const diagnostic = `${result.stdout}\n${result.stderr}`.toLowerCase();
321
+ if (diagnostic.includes("code: 10007") || diagnostic.includes("code: 10090"))
322
+ return false;
323
+ throw new Error("Could not verify whether the target Worker already exists.");
324
+ }
325
+ export function workersDevRegistrationRequired(result) {
326
+ const diagnostic = `${result.stdout}\n${result.stderr}`.toLowerCase();
327
+ return (diagnostic.includes("register a workers.dev subdomain") ||
328
+ diagnostic.includes("workers/onboarding"));
329
+ }
330
+ async function existingResourceNames() {
331
+ const [d1, kv] = await Promise.all([
332
+ run("npx", ["wrangler", "d1", "list", "--json", "--config", CONFIG_PATH]),
333
+ run("npx", ["wrangler", "kv", "namespace", "list", "--config", CONFIG_PATH])
334
+ ]);
335
+ const databases = z.array(z.looseObject({ name: z.string() })).parse(JSON.parse(d1.stdout));
336
+ const namespaces = z.array(z.looseObject({ title: z.string() })).parse(JSON.parse(kv.stdout));
337
+ return {
338
+ databases: new Set(databases.map((database) => database.name)),
339
+ namespaces: new Set(namespaces.map((namespace) => namespace.title))
340
+ };
341
+ }
342
+ async function ensureResources(options) {
343
+ let config = await readFile(CONFIG_PATH, "utf8");
344
+ if (!hasBinding(config, "DB")) {
345
+ await run("npx", [
346
+ "wrangler",
347
+ "d1",
348
+ "create",
349
+ options.databaseName,
350
+ "--update-config",
351
+ "--binding",
352
+ "DB",
353
+ "--config",
354
+ CONFIG_PATH
355
+ ]);
356
+ config = await readFile(CONFIG_PATH, "utf8");
357
+ }
358
+ if (!hasBinding(config, "OAUTH_KV")) {
359
+ await run("npx", [
360
+ "wrangler",
361
+ "kv",
362
+ "namespace",
363
+ "create",
364
+ options.kvName,
365
+ "--update-config",
366
+ "--binding",
367
+ "OAUTH_KV",
368
+ "--config",
369
+ CONFIG_PATH
370
+ ]);
371
+ }
372
+ }
373
+ async function finalizeDeployment(options) {
374
+ const existingConfig = await readFile(CONFIG_PATH, "utf8");
375
+ const assetConfig = withWebAssets(existingConfig);
376
+ if (assetConfig !== existingConfig)
377
+ await writeFile(CONFIG_PATH, assetConfig, "utf8");
378
+ if (!setupRuntime.packaged)
379
+ await run("npm", ["run", "build:web"]);
380
+ let config = await readFile(CONFIG_PATH, "utf8");
381
+ let origin = configuredOrigin(config);
382
+ if (origin === null)
383
+ throw new Error(`${CONFIG_PATH} has no valid APP_BASE_URL.`);
384
+ if (origin === BOOTSTRAP_ORIGIN) {
385
+ const deployArgs = ["wrangler", "deploy", "--strict", "--config", CONFIG_PATH];
386
+ let firstDeploy = await run("npx", deployArgs, undefined, true);
387
+ if (firstDeploy.exitCode !== 0 && workersDevRegistrationRequired(firstDeploy)) {
388
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
389
+ throw new Error("This Cloudflare account needs a workers.dev subdomain before its first Worker can be deployed. Rerun setup in an interactive terminal so Wrangler can register it.");
390
+ }
391
+ console.log("\nCloudflare needs a one-time account subdomain. Continuing interactively with Wrangler; choose an available workers.dev name when prompted.");
392
+ await runInteractive("npx", deployArgs);
393
+ firstDeploy = await run("npx", deployArgs, undefined, true);
394
+ }
395
+ if (firstDeploy.exitCode !== 0) {
396
+ throw new Error(`npx exited with status ${firstDeploy.exitCode}`);
397
+ }
398
+ origin = deployedOrigin(`${firstDeploy.stdout}\n${firstDeploy.stderr}`);
399
+ if (origin === null)
400
+ throw new Error(`Could not discover the workers.dev URL. Put the exact origin in APP_BASE_URL inside ${CONFIG_PATH}, then rerun with --resume.`);
401
+ config = await readFile(CONFIG_PATH, "utf8");
402
+ if (!config.includes(BOOTSTRAP_ORIGIN))
403
+ throw new Error(`Could not safely update APP_BASE_URL in ${CONFIG_PATH}.`);
404
+ await writeFile(CONFIG_PATH, config.replace(BOOTSTRAP_ORIGIN, origin), "utf8");
405
+ }
406
+ await ensureResources(options);
407
+ config = await readFile(CONFIG_PATH, "utf8");
408
+ const boundDatabaseName = bindingProperty(config, "DB", "database_name");
409
+ if (boundDatabaseName === null)
410
+ throw new Error(`${CONFIG_PATH} has a DB binding without a database_name.`);
411
+ await applyRemoteMigrations(boundDatabaseName);
412
+ const secret = bootstrapSecret();
413
+ await run("npx", ["wrangler", "deploy", "--strict", "--config", CONFIG_PATH]);
414
+ await run("npx", ["wrangler", "secret", "put", "SETUP_TOKEN_HASH", "--config", CONFIG_PATH], secret.hash);
415
+ await verifyEndpoint(origin, "/health", "ok");
416
+ await verifyEndpoint(origin, "/ready", "ready");
417
+ const finalConfig = await readFile(CONFIG_PATH, "utf8");
418
+ const deploymentRecord = deploymentRecordFromConfig(finalConfig, WIKIMEMORY_VERSION, options.kvName);
419
+ const recordPath = installedRecordPath(options.workerName);
420
+ await writeDeploymentRecord(deploymentRecord, recordPath);
421
+ console.log(`\nSaved deployment record: ${recordPath}`);
422
+ console.log(`\n${handoff(origin, secret.raw)}`);
423
+ }
424
+ async function freshDeployment(options) {
425
+ if (await exists(CONFIG_PATH))
426
+ throw new Error(`${CONFIG_PATH} already exists. Use --resume to finish provisioning or --recover to add a passkey.`);
427
+ const selected = await selectAccount(options);
428
+ console.log(`\nPlanned Cloudflare resources:\n Account: ${selected.account.name} (${selected.account.id})\n Worker: ${options.workerName}\n D1 database: ${options.databaseName}\n KV namespace: ${options.kvName}\n Identity: ${selected.identity}`);
429
+ await confirm("Create these resources and deploy Wikimemory?", options.yes);
430
+ await writeFile(CONFIG_PATH, initialConfig(options, selected.account), {
431
+ encoding: "utf8",
432
+ flag: "wx"
433
+ });
434
+ let workerExists;
435
+ let resources;
436
+ try {
437
+ [workerExists, resources] = await Promise.all([
438
+ remoteWorkerExists(options.workerName),
439
+ existingResourceNames()
440
+ ]);
441
+ }
442
+ catch (error) {
443
+ await unlink(CONFIG_PATH);
444
+ throw error;
445
+ }
446
+ const collisions = [
447
+ ...(workerExists ? [`Worker ${options.workerName}`] : []),
448
+ ...(resources.databases.has(options.databaseName)
449
+ ? [`D1 database ${options.databaseName}`]
450
+ : []),
451
+ ...(resources.namespaces.has(options.kvName) ? [`KV namespace ${options.kvName}`] : [])
452
+ ];
453
+ if (collisions.length > 0) {
454
+ await unlink(CONFIG_PATH);
455
+ throw new Error(`These remote names already exist: ${collisions.join(", ")}. No resources were created or changed; choose unused names.`);
456
+ }
457
+ await writeFile(STATE_PATH, `${JSON.stringify({
458
+ preflightComplete: true,
459
+ accountId: selected.account.id,
460
+ workerName: options.workerName,
461
+ databaseName: options.databaseName,
462
+ kvName: options.kvName
463
+ }, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
464
+ console.log(`\nCreated ${CONFIG_PATH}. It is ignored by git. If a later step fails, rerun with --resume.`);
465
+ await finalizeDeployment(options);
466
+ }
467
+ async function resumeDeployment(options) {
468
+ if (!(await exists(CONFIG_PATH)))
469
+ throw new Error(`${CONFIG_PATH} is required for --resume.`);
470
+ if (!(await exists(STATE_PATH)))
471
+ throw new Error(`${STATE_PATH} is missing, so successful collision preflight cannot be proven. Remove ${CONFIG_PATH} and start a fresh install.`);
472
+ const config = await readFile(CONFIG_PATH, "utf8");
473
+ const state = STATE_SCHEMA.parse(JSON.parse(await readFile(STATE_PATH, "utf8")));
474
+ const accountId = configValue(config, "account_id");
475
+ const workerName = configValue(config, "name");
476
+ if (accountId === null || workerName === null)
477
+ throw new Error(`${CONFIG_PATH} is missing account_id or name.`);
478
+ if (accountId !== state.accountId || workerName !== state.workerName)
479
+ throw new Error("Production config no longer matches the collision-checked installer state.");
480
+ const resumedOptions = {
481
+ ...options,
482
+ databaseName: state.databaseName,
483
+ kvName: state.kvName,
484
+ workerName: state.workerName
485
+ };
486
+ console.log(`\nResume target:\n Account ID: ${accountId}\n Worker: ${workerName}\n D1 database: ${state.databaseName}\n KV namespace: ${state.kvName}`);
487
+ await confirm("Resume provisioning and rotate the one-time setup credential?", options.yes);
488
+ await finalizeDeployment(resumedOptions);
489
+ }
490
+ async function recover(options) {
491
+ if (!(await exists(CONFIG_PATH)))
492
+ throw new Error(`${CONFIG_PATH} is required for recovery.`);
493
+ const config = await readFile(CONFIG_PATH, "utf8");
494
+ const origin = configuredOrigin(config);
495
+ if (origin === null || origin === BOOTSTRAP_ORIGIN)
496
+ throw new Error(`${CONFIG_PATH} does not contain a deployed APP_BASE_URL; use --resume first.`);
497
+ await confirm(`Rotate the one-time owner setup credential for ${origin}?`, options.yes);
498
+ const secret = bootstrapSecret();
499
+ await run("npx", ["wrangler", "secret", "put", "SETUP_TOKEN_HASH", "--config", CONFIG_PATH], secret.hash);
500
+ await verifyEndpoint(origin, "/health", "ok");
501
+ await verifyEndpoint(origin, "/ready", "ready");
502
+ console.log(`\n${handoff(origin, secret.raw)}`);
503
+ }
504
+ export async function runSetup(args) {
505
+ const options = parseOptions(args);
506
+ if (options.help) {
507
+ console.log(usage());
508
+ return;
509
+ }
510
+ if (options.recover)
511
+ await recover(options);
512
+ else if (options.resume)
513
+ await resumeDeployment(options);
514
+ else
515
+ await freshDeployment(options);
516
+ }
517
+ const entrypoint = process.argv[1];
518
+ if (entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href) {
519
+ runSetup(process.argv.slice(2)).catch((error) => {
520
+ console.error(`\nSetup failed: ${error instanceof Error ? error.message : "Unknown error"}`);
521
+ process.exitCode = 1;
522
+ });
523
+ }
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { deploymentRecordPath, readDeploymentRecord } from "./deployment-record.js";
3
+ const HEALTH_SCHEMA = z.object({
4
+ status: z.literal("ok"),
5
+ service: z.literal("wikimemory"),
6
+ version: z.string()
7
+ });
8
+ const READY_SCHEMA = z.object({
9
+ status: z.literal("ready"),
10
+ service: z.literal("wikimemory"),
11
+ version: z.string(),
12
+ schemaVersion: z.string()
13
+ });
14
+ const DISCOVERY_SCHEMA = z.object({ resource: z.url() });
15
+ export async function deploymentStatus(deployment) {
16
+ const record = await readDeploymentRecord(deploymentRecordPath(deployment));
17
+ const [healthResponse, readyResponse, discoveryResponse, appResponse] = await Promise.all([
18
+ fetch(`${record.origin}/health`, { redirect: "error" }),
19
+ fetch(`${record.origin}/ready`, { redirect: "error" }),
20
+ fetch(`${record.origin}/.well-known/oauth-protected-resource/mcp`, { redirect: "error" }),
21
+ fetch(`${record.origin}/app`, { redirect: "error" })
22
+ ]);
23
+ if (!healthResponse.ok || !readyResponse.ok || !discoveryResponse.ok || !appResponse.ok)
24
+ throw new Error("One or more Wikimemory deployment checks failed");
25
+ const health = HEALTH_SCHEMA.parse(await healthResponse.json());
26
+ const ready = READY_SCHEMA.parse(await readyResponse.json());
27
+ const discovery = DISCOVERY_SCHEMA.parse(await discoveryResponse.json());
28
+ if (health.version !== ready.version)
29
+ throw new Error("Health and schema versions disagree");
30
+ if (discovery.resource !== `${record.origin}/mcp`)
31
+ throw new Error("OAuth discovery reports an unexpected MCP resource");
32
+ if (!(await appResponse.text()).includes('<div id="root"></div>'))
33
+ throw new Error("React application shell verification failed");
34
+ return {
35
+ deployment,
36
+ accountId: record.accountId,
37
+ workerName: record.workerName,
38
+ database: `${record.databaseName} (${record.databaseId})`,
39
+ kvNamespace: `${record.kvName} (${record.kvId})`,
40
+ origin: record.origin,
41
+ recordedVersion: record.installedVersion,
42
+ runningVersion: health.version,
43
+ schemaVersion: ready.schemaVersion
44
+ };
45
+ }
46
+ export async function runStatus(deployment) {
47
+ const status = await deploymentStatus(deployment);
48
+ console.log(`Wikimemory deployment: ${status.deployment}\n Account: ${status.accountId}\n Worker: ${status.workerName}\n D1: ${status.database}\n KV: ${status.kvNamespace}\n Origin: ${status.origin}\n Recorded version: ${status.recordedVersion}\n Running version: ${status.runningVersion}\n Schema: ${status.schemaVersion}`);
49
+ }