@telecrypt-io/storage 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 +109 -0
- package/README.md +53 -0
- package/dist/TeleCryptIOStorage.d.ts +185 -0
- package/dist/TeleCryptIOStorage.js +323 -0
- package/dist/cli/cryptoSnapshot.d.ts +37 -0
- package/dist/cli/cryptoSnapshot.js +187 -0
- package/dist/cli/errors.d.ts +1 -0
- package/dist/cli/errors.js +3 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +415 -0
- package/dist/cli/output.d.ts +16 -0
- package/dist/cli/output.js +24 -0
- package/dist/cli/poll.d.ts +1 -0
- package/dist/cli/poll.js +3 -0
- package/dist/cli/profile.d.ts +20 -0
- package/dist/cli/profile.js +46 -0
- package/dist/cli/storage.d.ts +36 -0
- package/dist/cli/storage.js +99 -0
- package/dist/core/errors.d.ts +8 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/operations.d.ts +35 -0
- package/dist/core/operations.js +120 -0
- package/dist/core/poll.d.ts +14 -0
- package/dist/core/poll.js +24 -0
- package/dist/core/types.d.ts +50 -0
- package/dist/core/types.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
interface IndexSpec {
|
|
2
|
+
name: string;
|
|
3
|
+
keyPath: string | string[];
|
|
4
|
+
unique: boolean;
|
|
5
|
+
multiEntry: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface StoreSpec {
|
|
8
|
+
name: string;
|
|
9
|
+
keyPath: string | string[] | null;
|
|
10
|
+
autoIncrement: boolean;
|
|
11
|
+
indexes: IndexSpec[];
|
|
12
|
+
}
|
|
13
|
+
interface StoreRecord {
|
|
14
|
+
key?: unknown;
|
|
15
|
+
value: unknown;
|
|
16
|
+
}
|
|
17
|
+
interface DbSnapshot {
|
|
18
|
+
name: string;
|
|
19
|
+
version: number;
|
|
20
|
+
stores: StoreSpec[];
|
|
21
|
+
records: Record<string, StoreRecord[]>;
|
|
22
|
+
}
|
|
23
|
+
export interface CryptoSnapshot {
|
|
24
|
+
dbs: DbSnapshot[];
|
|
25
|
+
}
|
|
26
|
+
/** Dumps every IndexedDB database currently visible to this process. */
|
|
27
|
+
export declare function exportIndexedDB(): Promise<CryptoSnapshot>;
|
|
28
|
+
/** Recreates every database/store/index/record from a snapshot into the
|
|
29
|
+
* current (assumed empty) IndexedDB factory. */
|
|
30
|
+
export declare function importIndexedDB(snapshot: CryptoSnapshot): Promise<void>;
|
|
31
|
+
export declare function loadSnapshotFromDisk(path: string): CryptoSnapshot | null;
|
|
32
|
+
export declare function saveSnapshotToDisk(path: string, snapshot: CryptoSnapshot): void;
|
|
33
|
+
/** Loads the on-disk snapshot (if any) into the current process's fake-indexeddb. */
|
|
34
|
+
export declare function restoreCryptoStore(path: string): Promise<void>;
|
|
35
|
+
/** Dumps the current process's fake-indexeddb to disk for the next process. */
|
|
36
|
+
export declare function persistCryptoStore(path: string): Promise<void>;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process crypto persistence (THE central challenge — see docs/CLI_SPEC.md).
|
|
3
|
+
*
|
|
4
|
+
* A CLI runs each command as a separate OS process. `TeleCryptIOStorage.create()`
|
|
5
|
+
* initialises rust-crypto with `useIndexedDB: true`, but in Node the only
|
|
6
|
+
* IndexedDB available is `fake-indexeddb`, which is a pure in-memory
|
|
7
|
+
* implementation — it evaporates when the process exits. Left alone, every
|
|
8
|
+
* CLI invocation would start with an empty crypto store and be unable to
|
|
9
|
+
* decrypt anything a previous invocation wrote (including its own device's
|
|
10
|
+
* megolm sessions), which also breaks cross-user sharing: userB's upload
|
|
11
|
+
* process sends userA an olm-encrypted room key addressed to userA's device
|
|
12
|
+
* identity; if that identity is regenerated every run, the key is
|
|
13
|
+
* undecryptable and userA can never read userB's file no matter what backup
|
|
14
|
+
* scheme is layered on top.
|
|
15
|
+
*
|
|
16
|
+
* Fix: snapshot fake-indexeddb's databases to disk after each command and
|
|
17
|
+
* reload them before the next one runs, keyed to the profile directory. This
|
|
18
|
+
* is Option 1 from the spec (disk-persistent crypto store) implemented via
|
|
19
|
+
* generic export/import over the *public* IndexedDB API (databases(), cursors,
|
|
20
|
+
* transactions) rather than poking fake-indexeddb's internals — so it isn't
|
|
21
|
+
* coupled to fake-indexeddb's private representation and would keep working
|
|
22
|
+
* against any spec-compliant IndexedDB implementation. Runtime crypto
|
|
23
|
+
* behaviour is otherwise unchanged from what the library's own tests already
|
|
24
|
+
* exercise (keys.test.ts, tree.test.ts 4.6 both run rust-crypto on
|
|
25
|
+
* fake-indexeddb).
|
|
26
|
+
*
|
|
27
|
+
* Binary values (megolm session keys etc.) don't survive JSON, so the
|
|
28
|
+
* snapshot is serialised with Node's structured-clone-capable `node:v8`
|
|
29
|
+
* (de)serialize rather than JSON.
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from "node:fs";
|
|
32
|
+
import * as v8 from "node:v8";
|
|
33
|
+
function getIndexedDB() {
|
|
34
|
+
const idb = globalThis.indexedDB;
|
|
35
|
+
if (!idb) {
|
|
36
|
+
throw new Error("cryptoSnapshot: globalThis.indexedDB is not set — import 'fake-indexeddb/auto' before calling exportIndexedDB/importIndexedDB");
|
|
37
|
+
}
|
|
38
|
+
return idb;
|
|
39
|
+
}
|
|
40
|
+
function promisifyRequest(req) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
req.onsuccess = () => resolve(req.result);
|
|
43
|
+
req.onerror = () => reject(req.error);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function promisifyTxDone(tx) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
tx.oncomplete = () => resolve();
|
|
49
|
+
tx.onerror = () => reject(tx.error);
|
|
50
|
+
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function readAllRecords(store) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const out = [];
|
|
56
|
+
const outOfLine = store.keyPath === null;
|
|
57
|
+
const req = store.openCursor();
|
|
58
|
+
req.onerror = () => reject(req.error);
|
|
59
|
+
req.onsuccess = () => {
|
|
60
|
+
const cursor = req.result;
|
|
61
|
+
if (cursor) {
|
|
62
|
+
out.push(outOfLine
|
|
63
|
+
? { key: cursor.primaryKey, value: cursor.value }
|
|
64
|
+
: { value: cursor.value });
|
|
65
|
+
cursor.continue();
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
resolve(out);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Dumps every IndexedDB database currently visible to this process. */
|
|
74
|
+
export async function exportIndexedDB() {
|
|
75
|
+
const idb = getIndexedDB();
|
|
76
|
+
const infos = await idb.databases();
|
|
77
|
+
const dbs = [];
|
|
78
|
+
for (const info of infos) {
|
|
79
|
+
if (!info.name)
|
|
80
|
+
continue;
|
|
81
|
+
const db = await promisifyRequest(idb.open(info.name, info.version));
|
|
82
|
+
try {
|
|
83
|
+
const storeNames = Array.from(db.objectStoreNames);
|
|
84
|
+
const stores = [];
|
|
85
|
+
const records = {};
|
|
86
|
+
if (storeNames.length > 0) {
|
|
87
|
+
const tx = db.transaction(storeNames, "readonly");
|
|
88
|
+
for (const storeName of storeNames) {
|
|
89
|
+
const store = tx.objectStore(storeName);
|
|
90
|
+
const indexes = Array.from(store.indexNames).map((iname) => {
|
|
91
|
+
const idx = store.index(iname);
|
|
92
|
+
return {
|
|
93
|
+
name: idx.name,
|
|
94
|
+
keyPath: idx.keyPath,
|
|
95
|
+
unique: idx.unique,
|
|
96
|
+
multiEntry: idx.multiEntry,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
stores.push({
|
|
100
|
+
name: storeName,
|
|
101
|
+
keyPath: store.keyPath,
|
|
102
|
+
autoIncrement: store.autoIncrement,
|
|
103
|
+
indexes,
|
|
104
|
+
});
|
|
105
|
+
records[storeName] = await readAllRecords(store);
|
|
106
|
+
}
|
|
107
|
+
await promisifyTxDone(tx);
|
|
108
|
+
}
|
|
109
|
+
dbs.push({ name: info.name, version: db.version, stores, records });
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
db.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { dbs };
|
|
116
|
+
}
|
|
117
|
+
/** Recreates every database/store/index/record from a snapshot into the
|
|
118
|
+
* current (assumed empty) IndexedDB factory. */
|
|
119
|
+
export async function importIndexedDB(snapshot) {
|
|
120
|
+
const idb = getIndexedDB();
|
|
121
|
+
for (const dbSnap of snapshot.dbs) {
|
|
122
|
+
const db = await new Promise((resolve, reject) => {
|
|
123
|
+
const req = idb.open(dbSnap.name, Math.max(dbSnap.version, 1));
|
|
124
|
+
req.onupgradeneeded = () => {
|
|
125
|
+
const database = req.result;
|
|
126
|
+
for (const store of dbSnap.stores) {
|
|
127
|
+
const os = database.createObjectStore(store.name, {
|
|
128
|
+
keyPath: store.keyPath ?? undefined,
|
|
129
|
+
autoIncrement: store.autoIncrement,
|
|
130
|
+
});
|
|
131
|
+
for (const idx of store.indexes) {
|
|
132
|
+
os.createIndex(idx.name, idx.keyPath, {
|
|
133
|
+
unique: idx.unique,
|
|
134
|
+
multiEntry: idx.multiEntry,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
req.onsuccess = () => resolve(req.result);
|
|
140
|
+
req.onerror = () => reject(req.error);
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
const storeNames = dbSnap.stores.map((s) => s.name);
|
|
144
|
+
if (storeNames.length > 0) {
|
|
145
|
+
const tx = db.transaction(storeNames, "readwrite");
|
|
146
|
+
for (const store of dbSnap.stores) {
|
|
147
|
+
const os = tx.objectStore(store.name);
|
|
148
|
+
for (const rec of dbSnap.records[store.name] ?? []) {
|
|
149
|
+
if (store.keyPath === null) {
|
|
150
|
+
os.put(rec.value, rec.key);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
os.put(rec.value);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await promisifyTxDone(tx);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
db.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export function loadSnapshotFromDisk(path) {
|
|
166
|
+
if (!fs.existsSync(path))
|
|
167
|
+
return null;
|
|
168
|
+
const buf = fs.readFileSync(path);
|
|
169
|
+
if (buf.length === 0)
|
|
170
|
+
return null;
|
|
171
|
+
return v8.deserialize(buf);
|
|
172
|
+
}
|
|
173
|
+
export function saveSnapshotToDisk(path, snapshot) {
|
|
174
|
+
fs.writeFileSync(path, v8.serialize(snapshot), { mode: 0o600 });
|
|
175
|
+
}
|
|
176
|
+
/** Loads the on-disk snapshot (if any) into the current process's fake-indexeddb. */
|
|
177
|
+
export async function restoreCryptoStore(path) {
|
|
178
|
+
const snapshot = loadSnapshotFromDisk(path);
|
|
179
|
+
if (snapshot) {
|
|
180
|
+
await importIndexedDB(snapshot);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Dumps the current process's fake-indexeddb to disk for the next process. */
|
|
184
|
+
export async function persistCryptoStore(path) {
|
|
185
|
+
const snapshot = await exportIndexedDB();
|
|
186
|
+
saveSnapshotToDisk(path, snapshot);
|
|
187
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CliError } from "../core/errors.js";
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "fake-indexeddb/auto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { createClient } from "matrix-js-sdk";
|
|
7
|
+
import { clearProfile, readSession, writeSession } from "./profile.js";
|
|
8
|
+
import { initStorageForNewSession, openStorage, waitForBackupSettled } from "./storage.js";
|
|
9
|
+
import { CliError } from "./errors.js";
|
|
10
|
+
import { runAction } from "./output.js";
|
|
11
|
+
import * as core from "../core/operations.js";
|
|
12
|
+
// matrix-js-sdk (loglevel) and the rust-crypto WASM tracing layer write
|
|
13
|
+
// verbose logs straight to console.log/debug/info/trace (stdout by default)
|
|
14
|
+
// AND console.warn/error (stderr by default) — e.g. push-rule setup notices
|
|
15
|
+
// and background-request warnings fire on totally successful runs. Left
|
|
16
|
+
// alone, that corrupts BOTH halves of the CLI's output contract: stdout must
|
|
17
|
+
// be exactly one line (human text or --json payload), and stderr under
|
|
18
|
+
// --json must be exactly one `{"error": "..."}` line for a test (or script)
|
|
19
|
+
// to parse. Silence all of them here, before TeleCryptIOStorage.create() ever
|
|
20
|
+
// triggers rust-crypto initialisation; the CLI's own output always goes
|
|
21
|
+
// through process.stdout.write/process.stderr.write directly (see
|
|
22
|
+
// output.ts), never console.*, so this can't swallow anything we emit
|
|
23
|
+
// ourselves. Set TELECRYPT_IO_STORAGE_DEBUG=1 to see the SDK's logs again
|
|
24
|
+
// (all routed to stderr, labelled) when troubleshooting.
|
|
25
|
+
if (!process.env.TELECRYPT_IO_STORAGE_DEBUG) {
|
|
26
|
+
console.log = () => { };
|
|
27
|
+
console.debug = () => { };
|
|
28
|
+
console.info = () => { };
|
|
29
|
+
console.trace = () => { };
|
|
30
|
+
console.warn = () => { };
|
|
31
|
+
console.error = () => { };
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const toStderr = (label) => (...args) => process.stderr.write(`[${label}] ${args.map(String).join(" ")}\n`);
|
|
35
|
+
console.log = toStderr("log");
|
|
36
|
+
console.debug = toStderr("debug");
|
|
37
|
+
console.info = toStderr("info");
|
|
38
|
+
console.trace = toStderr("trace");
|
|
39
|
+
console.warn = toStderr("warn");
|
|
40
|
+
console.error = toStderr("error");
|
|
41
|
+
}
|
|
42
|
+
const EXT_MIMETYPES = {
|
|
43
|
+
".txt": "text/plain",
|
|
44
|
+
".json": "application/json",
|
|
45
|
+
".png": "image/png",
|
|
46
|
+
".jpg": "image/jpeg",
|
|
47
|
+
".jpeg": "image/jpeg",
|
|
48
|
+
".pdf": "application/pdf",
|
|
49
|
+
".md": "text/markdown",
|
|
50
|
+
};
|
|
51
|
+
function guessMimetype(filePath) {
|
|
52
|
+
return EXT_MIMETYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
53
|
+
}
|
|
54
|
+
const program = new Command();
|
|
55
|
+
program
|
|
56
|
+
.name("telecrypt-io")
|
|
57
|
+
.description("TeleCrypt.io CLI")
|
|
58
|
+
.option("--json", "machine-readable JSON output")
|
|
59
|
+
.showHelpAfterError();
|
|
60
|
+
const storage = program
|
|
61
|
+
.command("storage")
|
|
62
|
+
.description("End-to-end encrypted file storage on Matrix");
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Session
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
storage
|
|
67
|
+
.command("login")
|
|
68
|
+
.description("Log in and persist the session + crypto store to the profile")
|
|
69
|
+
.requiredOption("--homeserver <url>", "Matrix homeserver base URL")
|
|
70
|
+
.requiredOption("--user <localpart>", "Username (localpart or full MXID)")
|
|
71
|
+
.requiredOption("--password <pw>", "Password")
|
|
72
|
+
.action(async (opts, command) => {
|
|
73
|
+
await runAction(command, async () => {
|
|
74
|
+
const client = createClient({ baseUrl: opts.homeserver });
|
|
75
|
+
let res;
|
|
76
|
+
try {
|
|
77
|
+
res = await client.loginWithPassword(opts.user, opts.password);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new CliError(`login failed: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
const session = {
|
|
83
|
+
homeserver: opts.homeserver,
|
|
84
|
+
userId: res.user_id,
|
|
85
|
+
deviceId: res.device_id,
|
|
86
|
+
accessToken: res.access_token,
|
|
87
|
+
};
|
|
88
|
+
writeSession(session);
|
|
89
|
+
// Establishes this device's crypto identity and does a first sync
|
|
90
|
+
// (proves connectivity end-to-end), and writes the initial crypto
|
|
91
|
+
// store snapshot so later commands have something to load.
|
|
92
|
+
const opened = await initStorageForNewSession(session);
|
|
93
|
+
await opened.close();
|
|
94
|
+
return {
|
|
95
|
+
json: { userId: session.userId, deviceId: session.deviceId, homeserver: session.homeserver },
|
|
96
|
+
text: `Logged in as ${session.userId} (device ${session.deviceId})`,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
storage
|
|
101
|
+
.command("register")
|
|
102
|
+
.description("Register a new account (dev/test convenience), then log in")
|
|
103
|
+
.requiredOption("--homeserver <url>", "Matrix homeserver base URL")
|
|
104
|
+
.requiredOption("--user <localpart>", "Username (localpart)")
|
|
105
|
+
.requiredOption("--password <pw>", "Password")
|
|
106
|
+
.action(async (opts, command) => {
|
|
107
|
+
await runAction(command, async () => {
|
|
108
|
+
const res = await fetch(`${opts.homeserver}/_matrix/client/v3/register`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
username: opts.user,
|
|
113
|
+
password: opts.password,
|
|
114
|
+
auth: { type: "m.login.dummy" },
|
|
115
|
+
inhibit_login: false,
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
const body = await res.text();
|
|
120
|
+
throw new CliError(`registration failed (${res.status}): ${body}`);
|
|
121
|
+
}
|
|
122
|
+
const data = (await res.json());
|
|
123
|
+
const session = {
|
|
124
|
+
homeserver: opts.homeserver,
|
|
125
|
+
userId: data.user_id,
|
|
126
|
+
deviceId: data.device_id,
|
|
127
|
+
accessToken: data.access_token,
|
|
128
|
+
};
|
|
129
|
+
writeSession(session);
|
|
130
|
+
const opened = await initStorageForNewSession(session);
|
|
131
|
+
await opened.close();
|
|
132
|
+
return {
|
|
133
|
+
json: { userId: session.userId, deviceId: session.deviceId, homeserver: session.homeserver },
|
|
134
|
+
text: `Registered and logged in as ${session.userId} (device ${session.deviceId})`,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
storage
|
|
139
|
+
.command("whoami")
|
|
140
|
+
.description("Print the current session identity")
|
|
141
|
+
.action(async (_opts, command) => {
|
|
142
|
+
await runAction(command, async () => {
|
|
143
|
+
const session = readSession();
|
|
144
|
+
if (!session)
|
|
145
|
+
throw new CliError("not logged in");
|
|
146
|
+
return {
|
|
147
|
+
json: { userId: session.userId, deviceId: session.deviceId, homeserver: session.homeserver },
|
|
148
|
+
text: `${session.userId} (device ${session.deviceId}) @ ${session.homeserver}`,
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
storage
|
|
153
|
+
.command("logout")
|
|
154
|
+
.description("Clear the local profile (session + crypto store)")
|
|
155
|
+
.action(async (_opts, command) => {
|
|
156
|
+
await runAction(command, async () => {
|
|
157
|
+
const session = readSession();
|
|
158
|
+
if (session) {
|
|
159
|
+
// Best-effort server-side logout; local profile is cleared either way.
|
|
160
|
+
await fetch(`${session.homeserver}/_matrix/client/v3/logout`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { Authorization: `Bearer ${session.accessToken}` },
|
|
163
|
+
}).catch(() => undefined);
|
|
164
|
+
}
|
|
165
|
+
clearProfile();
|
|
166
|
+
return { json: { loggedOut: true }, text: "Logged out." };
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Recovery (Layer 2)
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
const recovery = storage.command("recovery").description("Server-side key backup / recovery");
|
|
173
|
+
recovery
|
|
174
|
+
.command("setup")
|
|
175
|
+
.description("Set up recovery (cross-signing + key backup) and print the Recovery Key")
|
|
176
|
+
.action(async (_opts, command) => {
|
|
177
|
+
await runAction(command, async () => {
|
|
178
|
+
const opened = await openStorage();
|
|
179
|
+
try {
|
|
180
|
+
const result = await core.setupRecovery(opened.storage);
|
|
181
|
+
// Give any already-known megolm sessions a chance to actually reach
|
|
182
|
+
// the server backup before this short-lived process exits — see
|
|
183
|
+
// waitForBackupSettled's doc comment.
|
|
184
|
+
await waitForBackupSettled(opened.storage);
|
|
185
|
+
return {
|
|
186
|
+
json: { ...result },
|
|
187
|
+
text: [
|
|
188
|
+
"Recovery Key (SAVE THIS — it is the only way to recover your files on a new device):",
|
|
189
|
+
"",
|
|
190
|
+
result.recoveryKey,
|
|
191
|
+
].join("\n"),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await opened.close();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
recovery
|
|
200
|
+
.command("restore <recoveryKey>")
|
|
201
|
+
.description("Restore keys on this device from a Recovery Key")
|
|
202
|
+
.action(async (recoveryKey, _opts, command) => {
|
|
203
|
+
await runAction(command, async () => {
|
|
204
|
+
const opened = await openStorage();
|
|
205
|
+
try {
|
|
206
|
+
const result = await core.restoreRecovery(opened.storage, recoveryKey);
|
|
207
|
+
return {
|
|
208
|
+
json: { ...result },
|
|
209
|
+
text: `Restored ${result.imported}/${result.total} keys.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
await opened.close();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Folders
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
const folder = storage.command("folder").description("Shared folder operations");
|
|
221
|
+
folder
|
|
222
|
+
.command("create <name>")
|
|
223
|
+
.description("Create a new shared folder")
|
|
224
|
+
.action(async (name, _opts, command) => {
|
|
225
|
+
await runAction(command, async () => {
|
|
226
|
+
const opened = await openStorage();
|
|
227
|
+
try {
|
|
228
|
+
const result = await core.createFolder(opened.storage, name);
|
|
229
|
+
return {
|
|
230
|
+
json: { folderId: result.id, name: result.name },
|
|
231
|
+
text: `Created folder "${result.name}" (${result.id})`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await opened.close();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
folder
|
|
240
|
+
.command("list")
|
|
241
|
+
.description("List folders visible to the current user")
|
|
242
|
+
.action(async (_opts, command) => {
|
|
243
|
+
await runAction(command, async () => {
|
|
244
|
+
const opened = await openStorage();
|
|
245
|
+
try {
|
|
246
|
+
const folders = await core.listFolders(opened.storage);
|
|
247
|
+
return {
|
|
248
|
+
json: { folders },
|
|
249
|
+
text: folders.length === 0
|
|
250
|
+
? "(no folders)"
|
|
251
|
+
: folders.map((f) => `${f.id}\t${f.name}`).join("\n"),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await opened.close();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
folder
|
|
260
|
+
.command("join <folderId>")
|
|
261
|
+
.description("Accept a pending folder invitation (join the room)")
|
|
262
|
+
.action(async (folderId, _opts, command) => {
|
|
263
|
+
await runAction(command, async () => {
|
|
264
|
+
const opened = await openStorage();
|
|
265
|
+
try {
|
|
266
|
+
const result = await core.joinFolder(opened.storage, folderId);
|
|
267
|
+
return { json: { ...result }, text: `Joined folder ${result.folderId}` };
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await opened.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
folder
|
|
275
|
+
.command("share <folderId> <userId>")
|
|
276
|
+
.description("Invite a participant to a shared folder at a given role")
|
|
277
|
+
.option("--role <role>", "viewer or editor", "viewer")
|
|
278
|
+
.action(async (folderId, userId, opts, command) => {
|
|
279
|
+
await runAction(command, async () => {
|
|
280
|
+
// Validated here too (before openStorage/login is even attempted) so
|
|
281
|
+
// a bad --role fails fast exactly as before; core.shareFolder repeats
|
|
282
|
+
// the same check so it's still safe to call standalone (e.g. from a
|
|
283
|
+
// future UI) without this CLI-side pre-check.
|
|
284
|
+
if (opts.role !== "viewer" && opts.role !== "editor") {
|
|
285
|
+
throw new CliError(`invalid --role "${opts.role}" (must be viewer or editor)`);
|
|
286
|
+
}
|
|
287
|
+
const opened = await openStorage();
|
|
288
|
+
try {
|
|
289
|
+
const result = await core.shareFolder(opened.storage, folderId, userId, opts.role);
|
|
290
|
+
return {
|
|
291
|
+
json: { ...result },
|
|
292
|
+
text: `Invited ${result.userId} to ${result.folderId} as ${result.role}`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
await opened.close();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
folder
|
|
301
|
+
.command("members <folderId>")
|
|
302
|
+
.description("List participants and their roles")
|
|
303
|
+
.action(async (folderId, _opts, command) => {
|
|
304
|
+
await runAction(command, async () => {
|
|
305
|
+
const opened = await openStorage();
|
|
306
|
+
try {
|
|
307
|
+
const members = await core.listMembers(opened.storage, folderId);
|
|
308
|
+
return {
|
|
309
|
+
json: { members },
|
|
310
|
+
text: members.map((m) => `${m.userId}\t${m.role}\t${m.membership}`).join("\n"),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await opened.close();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
folder
|
|
319
|
+
.command("unshare <folderId> <userId>")
|
|
320
|
+
.description("Remove a participant from a shared folder")
|
|
321
|
+
.action(async (folderId, userId, _opts, command) => {
|
|
322
|
+
await runAction(command, async () => {
|
|
323
|
+
const opened = await openStorage();
|
|
324
|
+
try {
|
|
325
|
+
const result = await core.unshareFolder(opened.storage, folderId, userId);
|
|
326
|
+
return { json: { ...result }, text: `Removed ${result.userId} from ${result.folderId}` };
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
await opened.close();
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Files
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
const file = storage.command("file").description("File operations within a folder");
|
|
337
|
+
file
|
|
338
|
+
.command("upload <folderId> <path>")
|
|
339
|
+
.description("Encrypt and upload a local file into a folder")
|
|
340
|
+
.option("--name <name>", "Name to store the file as (default: basename of path)")
|
|
341
|
+
.action(async (folderId, filePath, opts, command) => {
|
|
342
|
+
await runAction(command, async () => {
|
|
343
|
+
if (!fs.existsSync(filePath)) {
|
|
344
|
+
throw new CliError(`file not found: ${filePath}`);
|
|
345
|
+
}
|
|
346
|
+
const opened = await openStorage();
|
|
347
|
+
try {
|
|
348
|
+
const name = opts.name ?? path.basename(filePath);
|
|
349
|
+
const data = fs.readFileSync(filePath);
|
|
350
|
+
const result = await core.uploadFile(opened.storage, folderId, name, data, guessMimetype(filePath));
|
|
351
|
+
// If recovery/backup is already active for this account, give the
|
|
352
|
+
// new session's key a chance to actually reach the server backup
|
|
353
|
+
// before this short-lived process exits.
|
|
354
|
+
await waitForBackupSettled(opened.storage);
|
|
355
|
+
return {
|
|
356
|
+
json: { fileId: result.id, name: result.name },
|
|
357
|
+
text: `Uploaded "${result.name}" as ${result.id}`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
await opened.close();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
file
|
|
366
|
+
.command("list <folderId>")
|
|
367
|
+
.description("List files in a folder")
|
|
368
|
+
.action(async (folderId, _opts, command) => {
|
|
369
|
+
await runAction(command, async () => {
|
|
370
|
+
const opened = await openStorage();
|
|
371
|
+
try {
|
|
372
|
+
const files = await core.listFiles(opened.storage, folderId);
|
|
373
|
+
return {
|
|
374
|
+
json: { files },
|
|
375
|
+
text: files.length === 0 ? "(no files)" : files.map((f) => `${f.id}\t${f.name}`).join("\n"),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
await opened.close();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
file
|
|
384
|
+
.command("download <folderId> <fileId> <destPath>")
|
|
385
|
+
.description("Download and decrypt a file to a local path")
|
|
386
|
+
.action(async (folderId, fileId, destPath, _opts, command) => {
|
|
387
|
+
await runAction(command, async () => {
|
|
388
|
+
const opened = await openStorage();
|
|
389
|
+
try {
|
|
390
|
+
const result = await core.downloadFile(opened.storage, folderId, fileId);
|
|
391
|
+
fs.writeFileSync(destPath, Buffer.from(result.bytes));
|
|
392
|
+
return {
|
|
393
|
+
json: { path: destPath, bytes: result.bytes.byteLength, mimetype: result.mimetype },
|
|
394
|
+
text: `Downloaded ${result.bytes.byteLength} bytes to ${destPath}`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
await opened.close();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
program
|
|
404
|
+
.parseAsync(process.argv)
|
|
405
|
+
.catch((err) => {
|
|
406
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
407
|
+
process.exitCode = 1;
|
|
408
|
+
})
|
|
409
|
+
.finally(() => {
|
|
410
|
+
// Every action closes its own storage (stopClient()), but Node's global
|
|
411
|
+
// fetch (undici) can keep a keep-alive socket open past that, leaving
|
|
412
|
+
// the process hanging instead of exiting on its own. Exit explicitly
|
|
413
|
+
// once the command has fully finished and printed its output.
|
|
414
|
+
process.exit(process.exitCode ?? 0);
|
|
415
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
export interface CommandResult {
|
|
3
|
+
/** Machine-readable payload for --json. */
|
|
4
|
+
json: Record<string, unknown>;
|
|
5
|
+
/** Human-readable text for the default (non --json) output. */
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Runs a command action: executes `fn`, prints its result (JSON or human
|
|
10
|
+
* text) to stdout on success, or a clean `{ "error": "..." }` (JSON mode) /
|
|
11
|
+
* `Error: ...` (text mode) to stderr on failure — never a raw stack trace.
|
|
12
|
+
* Sets process.exitCode accordingly (0 on success, 1 on failure) rather than
|
|
13
|
+
* calling process.exit() directly, so callers can do any final cleanup
|
|
14
|
+
* first.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runAction(command: Command, fn: () => Promise<CommandResult>): Promise<void>;
|