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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh Vanderberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # Wikimemory
2
+
3
+ Wikimemory is a personal, remotely hosted memory service for Claude, Codex, and
4
+ other MCP clients. It preserves research, project state, technical decisions, and
5
+ reusable context in an auditable revision store that the owner can browse and
6
+ search from the web.
7
+
8
+ It includes remote HTTP MCP with OAuth/PKCE, passkey identity, append-only revision
9
+ storage, full-text search, a React browse/search/history UI, multi-passkey controls,
10
+ sanitized exports, and version-matched client skills. The server does not call an
11
+ LLM; Claude or Codex performs synthesis while Wikimemory provides deterministic,
12
+ auditable storage and retrieval.
13
+
14
+ ## Quick start
15
+
16
+ Requirements: Node.js 22+, npm, a Cloudflare account, and a passkey-capable browser
17
+ or password manager. No Google Cloud project or OAuth application is required.
18
+
19
+ ```sh
20
+ npx wrangler login
21
+ npx wikimemory install
22
+ ```
23
+
24
+ The installer previews the exact Cloudflare account, Worker, D1 database, and KV
25
+ namespace before creating anything. Open its one-time URL to register the owner
26
+ passkey, then connect a client and install its memory skills:
27
+
28
+ ```sh
29
+ npx wikimemory connect codex
30
+ npx wikimemory skills install codex
31
+
32
+ # or
33
+ npx wikimemory connect claude
34
+ npx wikimemory skills install claude
35
+ ```
36
+
37
+ Use the same remote MCP from Claude web or mobile by adding the printed HTTPS `/mcp`
38
+ URL as a custom connector. See the complete [installation guide](docs/installation.md)
39
+ for recovery, passkey management, upgrades, and safe uninstall.
40
+
41
+ ## Local development without a checkout
42
+
43
+ ```sh
44
+ npx wikimemory dev
45
+ ```
46
+
47
+ This starts the packaged Worker, React app, D1, and KV through local Wrangler
48
+ emulation. State persists under `./.wikimemory/dev`; production passkey identity is
49
+ replaced with a clearly marked fake local owner while OAuth, PKCE, scopes, tokens,
50
+ and MCP transport remain real.
51
+
52
+ ## Lifecycle commands
53
+
54
+ ```sh
55
+ npx wikimemory status
56
+ npx wikimemory upgrade
57
+ npx wikimemory recover
58
+ npx wikimemory passkeys list
59
+ npx wikimemory uninstall # preview only
60
+ ```
61
+
62
+ Parallel installations use `--deployment NAME`. Non-secret lifecycle state lives
63
+ under `~/.config/wikimemory/deployments/NAME/`. Uninstall requires an explicit apply
64
+ step and exact Worker-name confirmation because deleting D1 permanently destroys the
65
+ stored memory.
66
+
67
+ ## V1 in one picture
68
+
69
+ ```text
70
+ Claude / Codex ---- Streamable HTTP MCP ----+
71
+ |
72
+ Browser ------------ HTTPS web app ---------+--> Cloudflare Worker --> D1
73
+ |
74
+ Passkey ------------ owner authentication --+
75
+ ```
76
+
77
+ Cloudflare is the recommended V1 host because Workers, D1, and KV all have free
78
+ tier allowances large enough for typical personal use; the
79
+ [paid Workers plan](https://developers.cloudflare.com/workers/platform/pricing/)
80
+ starts at $5/month if those limits are exceeded. Memory is protected by access control and
81
+ Cloudflare-managed encryption, but it is **not end-to-end encrypted from Cloudflare**.
82
+ Do not store credentials or material you are unwilling to entrust to the host.
83
+
84
+ ## Design documents
85
+
86
+ - [V1 product specification](docs/v1-spec.md)
87
+ - [Architecture](docs/architecture.md)
88
+ - [Security and threat model](docs/security.md)
89
+ - [Data model](docs/data-model.md)
90
+ - [MCP contract](docs/mcp-contract.md)
91
+ - [Local development and testing](docs/testing.md)
92
+ - [Installation and client connection](docs/installation.md)
93
+ - [Export formats and privacy](docs/export-format.md)
94
+ - [Pasteable agent instructions](docs/manual-agent-instructions.md)
95
+ - [Implementation plan](docs/implementation-plan.md)
96
+ - [Independent design review and dispositions](docs/design-review-2026-07-18.md)
97
+
98
+ ## V1 boundaries
99
+
100
+ V1 is a personal, bring-your-own-Cloudflare deployment with one workspace and
101
+ passkey owner authentication. It includes a React web application with explicit
102
+ owner administration, remote MCP,
103
+ local emulation, portable export, agent skills, and an installation skill.
104
+
105
+ Multi-tenant SaaS, vector search, attachments, built-in chat, offline operation,
106
+ manual document editing, scheduled backups, monitoring infrastructure, custom
107
+ domains, and permanent staging infrastructure are not V1 features.
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,29 @@
1
+ import { deploymentPaths } from "./deployment-record.js";
2
+ export function deploymentArguments(args) {
3
+ let deployment = "wikimemory";
4
+ const remaining = [];
5
+ for (let index = 0; index < args.length; index += 1) {
6
+ const argument = args[index];
7
+ if (argument === "--deployment") {
8
+ const value = args[index + 1];
9
+ if (value === undefined || value.startsWith("--"))
10
+ throw new Error("--deployment requires a value");
11
+ deploymentPaths(value);
12
+ deployment = value;
13
+ index += 1;
14
+ }
15
+ else if (argument !== undefined)
16
+ remaining.push(argument);
17
+ }
18
+ return { deployment, remaining };
19
+ }
20
+ export function installArguments(deployment, args) {
21
+ const result = [...args];
22
+ if (!result.includes("--worker-name"))
23
+ result.push("--worker-name", deployment);
24
+ if (!result.includes("--database-name"))
25
+ result.push("--database-name", deployment);
26
+ if (!result.includes("--kv-name"))
27
+ result.push("--kv-name", `${deployment}-oauth`);
28
+ return result;
29
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir } from "node:fs/promises";
3
+ import process from "node:process";
4
+ import { WIKIMEMORY_VERSION } from "../src/version.js";
5
+ import { deploymentArguments, installArguments } from "./cli-options.js";
6
+ import { deploymentPaths } from "./deployment-record.js";
7
+ import { packageRoot } from "./package-root.js";
8
+ const [command, ...args] = process.argv.slice(2);
9
+ function usage() {
10
+ return `Wikimemory ${WIKIMEMORY_VERSION}\n\nUsage:\n wikimemory install [--deployment NAME] [installer options]\n wikimemory recover [--deployment NAME]\n wikimemory dev [wrangler dev options]\n wikimemory status [--deployment NAME]\n wikimemory upgrade [--deployment NAME] [--yes]\n wikimemory passkeys [--deployment NAME] list|add|revoke\n wikimemory connect [--deployment NAME] codex|claude\n wikimemory skills install codex|claude\n wikimemory uninstall [--deployment NAME] [--apply]\n wikimemory --version`;
11
+ }
12
+ async function main() {
13
+ if (command === undefined || command === "--help" || command === "help") {
14
+ console.log(usage());
15
+ return;
16
+ }
17
+ if (command === "--version" || command === "version") {
18
+ console.log(WIKIMEMORY_VERSION);
19
+ return;
20
+ }
21
+ const root = packageRoot();
22
+ if (command === "dev") {
23
+ const { runDev } = await import("./dev.js");
24
+ await runDev(root, args);
25
+ return;
26
+ }
27
+ if (command === "skills") {
28
+ if (args[0] !== "install" || args.length !== 2)
29
+ throw new Error("Usage: wikimemory skills install codex|claude");
30
+ const { installSkills } = await import("./client-tools.js");
31
+ await installSkills(root, args[1]);
32
+ return;
33
+ }
34
+ const parsed = deploymentArguments(args);
35
+ const paths = deploymentPaths(parsed.deployment);
36
+ if (command === "install" || command === "recover") {
37
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
38
+ process.env["WIKIMEMORY_PACKAGE_ROOT"] = root;
39
+ process.env["WIKIMEMORY_STATE_DIR"] = paths.directory;
40
+ process.env["WIKIMEMORY_PACKAGED"] = "1";
41
+ const { runSetup } = await import("./setup.js");
42
+ const setupArguments = installArguments(parsed.deployment, parsed.remaining);
43
+ await runSetup(command === "recover" ? ["--recover", ...setupArguments] : setupArguments);
44
+ }
45
+ else if (command === "upgrade") {
46
+ const { runUpgrade } = await import("./upgrade.js");
47
+ await runUpgrade(["--deployment", parsed.deployment, ...parsed.remaining]);
48
+ }
49
+ else if (command === "status") {
50
+ if (parsed.remaining.length !== 0)
51
+ throw new Error("Usage: wikimemory status [--deployment NAME]");
52
+ const { runStatus } = await import("./status.js");
53
+ await runStatus(parsed.deployment);
54
+ }
55
+ else if (command === "passkeys") {
56
+ process.env["WIKIMEMORY_STATE_DIR"] = paths.directory;
57
+ process.env["WIKIMEMORY_PACKAGED"] = "1";
58
+ const { runPasskeys } = await import("./passkeys.js");
59
+ await runPasskeys(parsed.remaining);
60
+ }
61
+ else if (command === "connect") {
62
+ if (parsed.remaining.length !== 1)
63
+ throw new Error("Usage: wikimemory connect [--deployment NAME] codex|claude");
64
+ const { connectClient } = await import("./client-tools.js");
65
+ await connectClient(parsed.deployment, parsed.remaining[0]);
66
+ }
67
+ else if (command === "uninstall") {
68
+ process.env["WIKIMEMORY_STATE_DIR"] = paths.directory;
69
+ process.env["WIKIMEMORY_PACKAGED"] = "1";
70
+ const { runUninstall } = await import("./uninstall.js");
71
+ await runUninstall(parsed.remaining);
72
+ }
73
+ else
74
+ throw new Error(`Unknown command: ${command}`);
75
+ }
76
+ main().catch((error) => {
77
+ console.error(`Wikimemory failed: ${error instanceof Error ? error.message : "Unknown error"}`);
78
+ process.exitCode = 1;
79
+ });
@@ -0,0 +1,57 @@
1
+ import { spawn } from "node:child_process";
2
+ import { cp, mkdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { deploymentRecordPath, readDeploymentRecord } from "./deployment-record.js";
6
+ function client(value) {
7
+ if (value === "codex" || value === "claude")
8
+ return value;
9
+ throw new Error("Client must be codex or claude");
10
+ }
11
+ async function run(command, args) {
12
+ await new Promise((resolve, reject) => {
13
+ const child = spawn(command, args, { stdio: "inherit" });
14
+ child.on("error", reject);
15
+ child.on("close", (code) => {
16
+ if (code === 0)
17
+ resolve();
18
+ else
19
+ reject(new Error(`${command} exited with status ${code ?? "unknown"}`));
20
+ });
21
+ });
22
+ }
23
+ export async function connectClient(deployment, selected) {
24
+ const target = client(selected);
25
+ const record = await readDeploymentRecord(deploymentRecordPath(deployment));
26
+ const endpoint = `${record.origin}/mcp`;
27
+ if (target === "codex") {
28
+ await run("codex", ["mcp", "add", deployment, "--url", endpoint]);
29
+ await run("codex", ["mcp", "login", deployment, "--scopes", "memory:read,memory:write"]);
30
+ }
31
+ else {
32
+ await run("claude", [
33
+ "mcp",
34
+ "add",
35
+ "--transport",
36
+ "http",
37
+ "--scope",
38
+ "user",
39
+ deployment,
40
+ endpoint
41
+ ]);
42
+ await run("claude", ["mcp", "login", deployment]);
43
+ }
44
+ }
45
+ export async function installSkills(packageRoot, selected) {
46
+ const target = client(selected);
47
+ const destinationRoot = join(homedir(), target === "codex" ? ".codex" : ".claude", "skills");
48
+ await mkdir(destinationRoot, { recursive: true, mode: 0o700 });
49
+ const names = ["wikimemory-recall", "wikimemory-ingest", "wikimemory-lint", "wikimemory-install"];
50
+ for (const name of names) {
51
+ await cp(join(packageRoot, "skills", name), join(destinationRoot, name), {
52
+ recursive: true,
53
+ force: true
54
+ });
55
+ }
56
+ console.log(`Installed ${names.length} Wikimemory skills for ${target}. Restart the client.`);
57
+ }
@@ -0,0 +1,72 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { z } from "zod";
5
+ const DEPLOYMENT_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/u;
6
+ export const DEPLOYMENT_RECORD_SCHEMA = z
7
+ .object({
8
+ formatVersion: z.literal(1),
9
+ accountId: z.string().min(1),
10
+ workerName: z.string().regex(DEPLOYMENT_NAME),
11
+ databaseName: z.string().min(1),
12
+ databaseId: z.string().min(1),
13
+ kvName: z.string().min(1),
14
+ kvId: z.string().min(1),
15
+ origin: z.url().refine((value) => value.startsWith("https://")),
16
+ installedVersion: z.string().regex(/^\d+\.\d+\.\d+$/u)
17
+ })
18
+ .strict();
19
+ const PRODUCTION_CONFIG_SCHEMA = z.object({
20
+ name: z.string(),
21
+ account_id: z.string(),
22
+ vars: z.object({ APP_BASE_URL: z.url() }),
23
+ d1_databases: z.array(z.object({
24
+ binding: z.literal("DB"),
25
+ database_name: z.string(),
26
+ database_id: z.string()
27
+ })),
28
+ kv_namespaces: z.array(z.object({ binding: z.literal("OAUTH_KV"), id: z.string() }))
29
+ });
30
+ export function deploymentRecordPath(deployment = "wikimemory") {
31
+ return deploymentPaths(deployment).record;
32
+ }
33
+ export function deploymentPaths(deployment = "wikimemory") {
34
+ if (!DEPLOYMENT_NAME.test(deployment))
35
+ throw new Error("Invalid deployment name");
36
+ const configRoot = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
37
+ const directory = join(configRoot, "wikimemory", "deployments", deployment);
38
+ return {
39
+ directory,
40
+ record: join(directory, "deployment.json"),
41
+ config: join(directory, "wrangler.jsonc"),
42
+ installProgress: join(directory, "install-progress.json"),
43
+ uninstallProgress: join(directory, "uninstall-progress.json"),
44
+ passkeyClient: join(directory, "passkey-client.json")
45
+ };
46
+ }
47
+ export function deploymentRecordFromConfig(configText, installedVersion, kvName) {
48
+ const config = PRODUCTION_CONFIG_SCHEMA.parse(JSON.parse(configText));
49
+ const database = config.d1_databases.at(0);
50
+ const namespace = config.kv_namespaces.at(0);
51
+ if (database === undefined || namespace === undefined)
52
+ throw new Error("Production config is missing Wikimemory storage bindings");
53
+ return DEPLOYMENT_RECORD_SCHEMA.parse({
54
+ formatVersion: 1,
55
+ accountId: config.account_id,
56
+ workerName: config.name,
57
+ databaseName: database.database_name,
58
+ databaseId: database.database_id,
59
+ kvName,
60
+ kvId: namespace.id,
61
+ origin: config.vars.APP_BASE_URL,
62
+ installedVersion
63
+ });
64
+ }
65
+ export async function readDeploymentRecord(path) {
66
+ return DEPLOYMENT_RECORD_SCHEMA.parse(JSON.parse(await readFile(path, "utf8")));
67
+ }
68
+ export async function writeDeploymentRecord(record, path = deploymentRecordPath(record.workerName)) {
69
+ const valid = DEPLOYMENT_RECORD_SCHEMA.parse(record);
70
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
71
+ await writeFile(path, `${JSON.stringify(valid, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
72
+ }
@@ -0,0 +1,49 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ export function localConfig(packageRoot) {
5
+ return `${JSON.stringify({
6
+ $schema: join(packageRoot, "node_modules", "wrangler", "config-schema.json"),
7
+ name: "wikimemory-local",
8
+ main: join(packageRoot, "src", "index.ts"),
9
+ compatibility_date: "2026-07-18",
10
+ compatibility_flags: ["nodejs_compat"],
11
+ assets: {
12
+ directory: join(packageRoot, "dist", "web"),
13
+ binding: "ASSETS",
14
+ not_found_handling: "single-page-application"
15
+ },
16
+ vars: { APP_ENV: "local" },
17
+ d1_databases: [
18
+ {
19
+ binding: "DB",
20
+ database_name: "wikimemory",
21
+ database_id: "00000000-0000-0000-0000-000000000001",
22
+ migrations_dir: join(packageRoot, "migrations")
23
+ }
24
+ ],
25
+ kv_namespaces: [{ binding: "OAUTH_KV", id: "00000000000000000000000000000001" }]
26
+ }, null, 2)}\n`;
27
+ }
28
+ async function runWrangler(args, inherited = false) {
29
+ await new Promise((resolvePromise, reject) => {
30
+ const child = spawn("npx", ["wrangler", ...args], {
31
+ stdio: inherited ? "inherit" : ["ignore", "inherit", "inherit"]
32
+ });
33
+ child.on("error", reject);
34
+ child.on("close", (code) => {
35
+ if (code === 0)
36
+ resolvePromise();
37
+ else
38
+ reject(new Error(`Wrangler exited with status ${code ?? "unknown"}`));
39
+ });
40
+ });
41
+ }
42
+ export async function runDev(packageRoot, args) {
43
+ const stateDirectory = resolve(".wikimemory", "dev");
44
+ await mkdir(stateDirectory, { recursive: true });
45
+ const configPath = join(stateDirectory, "wrangler.jsonc");
46
+ await writeFile(configPath, localConfig(packageRoot), "utf8");
47
+ await runWrangler(["d1", "migrations", "apply", "wikimemory", "--local", "--config", configPath]);
48
+ await runWrangler(["dev", "--config", configPath, ...args], true);
49
+ }
@@ -0,0 +1,38 @@
1
+ import { join } from "node:path";
2
+ import { deploymentRecordPath } from "./deployment-record.js";
3
+ const packaged = process.env["WIKIMEMORY_PACKAGED"] === "1";
4
+ const stateRoot = process.env["WIKIMEMORY_STATE_DIR"] ?? ".";
5
+ export const packageRoot = process.env["WIKIMEMORY_PACKAGE_ROOT"] ?? ".";
6
+ export const setupRuntime = {
7
+ packaged,
8
+ executable: packaged ? "wikimemory install" : "npm run setup --",
9
+ config: join(stateRoot, packaged ? "wrangler.jsonc" : "wrangler.production.jsonc"),
10
+ progress: join(stateRoot, packaged ? "install-progress.json" : ".wikimemory-installer.json")
11
+ };
12
+ export const uninstallRuntime = {
13
+ packaged,
14
+ executable: packaged ? "wikimemory uninstall" : "npm run uninstall --",
15
+ config: setupRuntime.config,
16
+ installProgress: setupRuntime.progress,
17
+ uninstallProgress: join(stateRoot, packaged ? "uninstall-progress.json" : ".wikimemory-uninstall.json"),
18
+ record: join(stateRoot, "deployment.json")
19
+ };
20
+ export const passkeyRuntime = {
21
+ config: setupRuntime.config,
22
+ client: join(stateRoot, packaged ? "passkey-client.json" : ".wikimemory-cli.json")
23
+ };
24
+ export function installedRecordPath(workerName) {
25
+ return packaged ? join(stateRoot, "deployment.json") : deploymentRecordPath(workerName);
26
+ }
27
+ export async function removePackagedDeploymentRecord() {
28
+ if (!packaged)
29
+ return;
30
+ const { unlink } = await import("node:fs/promises");
31
+ try {
32
+ await unlink(uninstallRuntime.record);
33
+ }
34
+ catch (error) {
35
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
36
+ throw error;
37
+ }
38
+ }
@@ -0,0 +1,15 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export function packageRoot(moduleUrl = import.meta.url) {
5
+ let candidate = dirname(fileURLToPath(moduleUrl));
6
+ for (let depth = 0; depth < 6; depth += 1) {
7
+ if (existsSync(join(candidate, "release-manifest.json")))
8
+ return candidate;
9
+ const parent = dirname(candidate);
10
+ if (parent === candidate)
11
+ break;
12
+ candidate = parent;
13
+ }
14
+ throw new Error("Could not locate the Wikimemory package root");
15
+ }
@@ -0,0 +1,197 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:http";
5
+ import process from "node:process";
6
+ import { pathToFileURL } from "node:url";
7
+ import { z } from "zod";
8
+ import { passkeyRuntime } from "./lifecycle-runtime.js";
9
+ import { configuredOrigin } from "./setup.js";
10
+ const PRODUCTION_CONFIG = passkeyRuntime.config;
11
+ const CLI_STATE = passkeyRuntime.client;
12
+ const CALLBACK = "http://127.0.0.1:45831/callback";
13
+ const CLIENT_SCHEMA = z.object({ clientId: z.string().min(1) });
14
+ const DCR_SCHEMA = z.object({ client_id: z.string().min(1) });
15
+ const TOKEN_SCHEMA = z.object({ access_token: z.string().min(1), token_type: z.string() });
16
+ const PASSKEY_SCHEMA = z.object({
17
+ credentialRef: z.string(),
18
+ label: z.string(),
19
+ deviceType: z.enum(["singleDevice", "multiDevice"]),
20
+ backedUp: z.boolean(),
21
+ createdAt: z.string(),
22
+ lastUsedAt: z.string().nullable()
23
+ });
24
+ const LIST_SCHEMA = z.object({ passkeys: z.array(PASSKEY_SCHEMA) });
25
+ const ADD_SCHEMA = z.object({ registrationUrl: z.url(), expiresAt: z.string() });
26
+ const REVOKE_SCHEMA = z.object({
27
+ revoked: z.string(),
28
+ sessionCleanupComplete: z.boolean()
29
+ });
30
+ function command(args) {
31
+ if (args[0] === "list" && args.length === 1)
32
+ return { kind: "list" };
33
+ if (args[0] === "add" && args[1] === "--name" && args[2] !== undefined && args.length === 3)
34
+ return { kind: "add", label: args[2] };
35
+ if (args[0] === "revoke" &&
36
+ args[1] !== undefined &&
37
+ args.length === 2 &&
38
+ /^[a-f0-9]{64}$/u.test(args[1]))
39
+ return { kind: "revoke", credentialRef: args[1] };
40
+ const executable = passkeyRuntime.config.endsWith("wrangler.jsonc")
41
+ ? "wikimemory passkeys"
42
+ : "npm run passkeys --";
43
+ throw new Error(`Usage: ${executable} list\n ${executable} add --name "Backup key"\n ${executable} revoke CREDENTIAL_REF`);
44
+ }
45
+ async function clientId(origin) {
46
+ try {
47
+ return CLIENT_SCHEMA.parse(JSON.parse(await readFile(CLI_STATE, "utf8"))).clientId;
48
+ }
49
+ catch (error) {
50
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
51
+ throw error;
52
+ }
53
+ const response = await fetch(`${origin}/oauth/register`, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json" },
56
+ body: JSON.stringify({
57
+ client_name: "Wikimemory owner CLI",
58
+ redirect_uris: [CALLBACK],
59
+ grant_types: ["authorization_code"],
60
+ response_types: ["code"],
61
+ token_endpoint_auth_method: "none"
62
+ })
63
+ });
64
+ if (!response.ok)
65
+ throw new Error(`OAuth client registration failed with HTTP ${response.status}`);
66
+ const registered = DCR_SCHEMA.parse(await response.json());
67
+ await writeFile(CLI_STATE, `${JSON.stringify({ clientId: registered.client_id }, null, 2)}\n`, {
68
+ encoding: "utf8",
69
+ flag: "wx"
70
+ });
71
+ return registered.client_id;
72
+ }
73
+ function openBrowser(url) {
74
+ const platform = process.platform;
75
+ const executable = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
76
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
77
+ const child = spawn(executable, args, { detached: true, stdio: "ignore" });
78
+ child.unref();
79
+ }
80
+ async function authorizationCode(authorizeUrl, expectedState) {
81
+ return await new Promise((resolve, reject) => {
82
+ const server = createServer((request, response) => {
83
+ const url = new URL(request.url ?? "/", CALLBACK);
84
+ if (url.pathname !== "/callback") {
85
+ response.writeHead(404).end("Not found");
86
+ return;
87
+ }
88
+ const state = url.searchParams.get("state");
89
+ const code = url.searchParams.get("code");
90
+ const error = url.searchParams.get("error");
91
+ if (state !== expectedState || code === null || error !== null) {
92
+ response
93
+ .writeHead(400, { "content-type": "text/plain; charset=utf-8" })
94
+ .end("Wikimemory CLI authorization failed. Return to the terminal.");
95
+ server.close();
96
+ reject(new Error(error ?? "OAuth callback validation failed"));
97
+ return;
98
+ }
99
+ response
100
+ .writeHead(200, {
101
+ "content-type": "text/plain; charset=utf-8",
102
+ "cache-control": "no-store"
103
+ })
104
+ .end("Wikimemory CLI authorized. You can close this tab.");
105
+ server.close();
106
+ resolve(code);
107
+ });
108
+ server.on("error", reject);
109
+ server.listen(45831, "127.0.0.1", () => {
110
+ openBrowser(authorizeUrl);
111
+ });
112
+ });
113
+ }
114
+ async function accessToken(origin, client) {
115
+ const verifier = randomBytes(32).toString("base64url");
116
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
117
+ const state = randomBytes(24).toString("base64url");
118
+ const resource = `${origin}/mcp`;
119
+ const authorize = new URL(`${origin}/authorize`);
120
+ authorize.search = new URLSearchParams({
121
+ response_type: "code",
122
+ client_id: client,
123
+ redirect_uri: CALLBACK,
124
+ scope: "memory:admin",
125
+ state,
126
+ code_challenge: challenge,
127
+ code_challenge_method: "S256",
128
+ resource
129
+ }).toString();
130
+ console.log("Opening your browser for passkey authorization…");
131
+ const code = await authorizationCode(authorize.toString(), state);
132
+ const response = await fetch(`${origin}/oauth/token`, {
133
+ method: "POST",
134
+ headers: { "content-type": "application/x-www-form-urlencoded" },
135
+ body: new URLSearchParams({
136
+ grant_type: "authorization_code",
137
+ client_id: client,
138
+ code,
139
+ redirect_uri: CALLBACK,
140
+ code_verifier: verifier,
141
+ resource
142
+ })
143
+ });
144
+ if (!response.ok)
145
+ throw new Error(`OAuth token exchange failed with HTTP ${response.status}`);
146
+ return TOKEN_SCHEMA.parse(await response.json()).access_token;
147
+ }
148
+ export async function runPasskeys(args) {
149
+ const selected = command(args);
150
+ const origin = configuredOrigin(await readFile(PRODUCTION_CONFIG, "utf8"));
151
+ if (origin === null || origin === "https://bootstrap.invalid")
152
+ throw new Error("A deployed wrangler.production.jsonc is required");
153
+ const token = await accessToken(origin, await clientId(origin));
154
+ const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
155
+ if (selected.kind === "list") {
156
+ const response = await fetch(`${origin}/api/passkeys`, { headers });
157
+ if (!response.ok)
158
+ throw new Error(`Passkey listing failed with HTTP ${response.status}`);
159
+ const result = LIST_SCHEMA.parse(await response.json());
160
+ for (const passkey of result.passkeys) {
161
+ console.log(`${passkey.credentialRef} ${passkey.label}\n ${passkey.deviceType}, ${passkey.backedUp ? "backed up" : "not backed up"}, created ${passkey.createdAt}${passkey.lastUsedAt === null ? "" : `, last used ${passkey.lastUsedAt}`}`);
162
+ }
163
+ }
164
+ else if (selected.kind === "add") {
165
+ const response = await fetch(`${origin}/api/passkeys`, {
166
+ method: "POST",
167
+ headers,
168
+ body: JSON.stringify({ label: selected.label })
169
+ });
170
+ if (!response.ok)
171
+ throw new Error(`Passkey registration failed with HTTP ${response.status}`);
172
+ const result = ADD_SCHEMA.parse(await response.json());
173
+ console.log(`Opening the one-use registration page (expires ${result.expiresAt})…`);
174
+ openBrowser(result.registrationUrl);
175
+ }
176
+ else {
177
+ const response = await fetch(`${origin}/api/passkeys`, {
178
+ method: "DELETE",
179
+ headers,
180
+ body: JSON.stringify({ credentialRef: selected.credentialRef })
181
+ });
182
+ if (!response.ok)
183
+ throw new Error(`Passkey revocation failed with HTTP ${response.status}: ${await response.text()}`);
184
+ const result = REVOKE_SCHEMA.parse(await response.json());
185
+ console.log(`Revoked ${result.revoked}.`);
186
+ console.log(result.sessionCleanupComplete
187
+ ? "Sessions created by that credential were also removed."
188
+ : "Session cleanup could not be confirmed, but those sessions are blocked because the passkey no longer exists.");
189
+ }
190
+ }
191
+ const entrypoint = process.argv[1];
192
+ if (entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href) {
193
+ runPasskeys(process.argv.slice(2)).catch((error) => {
194
+ console.error(`Passkey command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
195
+ process.exitCode = 1;
196
+ });
197
+ }