@bitpub/cli 2.0.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/src/config.js ADDED
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+
7
+ const BITPUB_DIR = path.join(os.homedir(), '.bitpub');
8
+ const CONFIG_FILE = path.join(BITPUB_DIR, 'config.json');
9
+
10
+ /**
11
+ * Default cloud tenant. `bitpub init` provisions an identity here unless
12
+ * overridden with --url. Override via env for development:
13
+ * BITPUB_CLOUD_URL=http://localhost:8080 bitpub init
14
+ */
15
+ const DEFAULT_CLOUD_URL = process.env.BITPUB_CLOUD_URL || 'https://bitpub.io';
16
+
17
+ function ensureDir() {
18
+ if (!fs.existsSync(BITPUB_DIR)) {
19
+ fs.mkdirSync(BITPUB_DIR, { recursive: true });
20
+ }
21
+ }
22
+
23
+ function readConfig() {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function writeConfig(config) {
32
+ ensureDir();
33
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
34
+ }
35
+
36
+ /**
37
+ * Identity is valid if api_url is present and at least one of these is true:
38
+ * - private identity: api_key + owner (provisioned by `bitpub init`)
39
+ * - group identity: group_key + domain (set by `bitpub auth login`)
40
+ * - legacy single-key: api_key + domain (old config shape, still supported)
41
+ */
42
+ function isConfigured(config) {
43
+ if (!config || !config.api_url) return false;
44
+ const hasPrivate = Boolean(config.api_key && config.owner);
45
+ const hasGroup = Boolean(config.group_key && config.domain);
46
+ const hasLegacy = Boolean(config.api_key && config.domain);
47
+ return hasPrivate || hasGroup || hasLegacy;
48
+ }
49
+
50
+ /**
51
+ * Require a valid config or exit with a helpful message that points at the
52
+ * agent-first onboarding path first, then the BYOC path.
53
+ */
54
+ function requireConfig() {
55
+ const config = readConfig();
56
+ if (!isConfigured(config)) {
57
+ console.error('No BitPub identity configured. Set one up:');
58
+ console.error(' bitpub init # auto-provision against the hosted tenant');
59
+ console.error(' bitpub auth login --key <K> --domain <D> # enterprise / BYOC');
60
+ process.exit(1);
61
+ }
62
+ return config;
63
+ }
64
+
65
+ /**
66
+ * Stable author identifier used in slice metadata. Falls back gracefully
67
+ * across config shapes so freshly-provisioned (owner-only) and BYOC
68
+ * (domain-only) identities both produce a meaningful author_id.
69
+ */
70
+ function authorIdFor(config) {
71
+ if (config.owner) return `agent_${config.owner}`;
72
+ if (config.domain) return `agent_${config.domain.replace(/[^a-zA-Z0-9]/g, '_')}`;
73
+ return 'agent_anonymous';
74
+ }
75
+
76
+ module.exports = {
77
+ readConfig,
78
+ writeConfig,
79
+ requireConfig,
80
+ isConfigured,
81
+ authorIdFor,
82
+ BITPUB_DIR,
83
+ CONFIG_FILE,
84
+ DEFAULT_CLOUD_URL,
85
+ };
package/src/crypto.js ADDED
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const SALT = Buffer.from('bitpub-private-v1');
6
+ const INFO = Buffer.from('content-encryption');
7
+ const PREFIX = 'bitpub-encrypted:v1:';
8
+
9
+ function deriveKey(apiKey) {
10
+ return crypto.hkdfSync('sha256', apiKey, SALT, INFO, 32);
11
+ }
12
+
13
+ function encrypt(content, apiKey) {
14
+ const key = deriveKey(apiKey);
15
+ const iv = crypto.randomBytes(12);
16
+ const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(key), iv);
17
+ const encrypted = Buffer.concat([cipher.update(content, 'utf8'), cipher.final()]);
18
+ const tag = cipher.getAuthTag();
19
+ const combined = Buffer.concat([encrypted, tag]);
20
+ return PREFIX + iv.toString('base64') + ':' + combined.toString('base64');
21
+ }
22
+
23
+ function decrypt(encryptedStr, apiKey) {
24
+ if (!encryptedStr.startsWith(PREFIX)) return encryptedStr;
25
+ const parts = encryptedStr.slice(PREFIX.length).split(':', 1);
26
+ const rest = encryptedStr.slice(PREFIX.length + parts[0].length + 1);
27
+ const iv = Buffer.from(parts[0], 'base64');
28
+ const data = Buffer.from(rest, 'base64');
29
+ const tag = data.slice(-16);
30
+ const ciphertext = data.slice(0, -16);
31
+ const key = deriveKey(apiKey);
32
+ const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), iv);
33
+ decipher.setAuthTag(tag);
34
+ return decipher.update(ciphertext, null, 'utf8') + decipher.final('utf8');
35
+ }
36
+
37
+ function isEncrypted(str) {
38
+ return typeof str === 'string' && str.startsWith(PREFIX);
39
+ }
40
+
41
+ function isPrivateHcu(hcu) {
42
+ return typeof hcu === 'string' && hcu.startsWith('bitpub://private:');
43
+ }
44
+
45
+ /**
46
+ * Decrypt payload.content for any private-scope slices in an array.
47
+ * Mutates the slice objects in place. Returns the array for chaining.
48
+ */
49
+ function decryptSlices(slices, apiKey) {
50
+ for (const slice of slices) {
51
+ if (!isPrivateHcu(slice.hcu)) continue;
52
+ const payload = typeof slice.payload === 'string' ? JSON.parse(slice.payload) : slice.payload;
53
+ if (isEncrypted(payload.content)) {
54
+ payload.content = decrypt(payload.content, apiKey);
55
+ slice.payload = payload;
56
+ }
57
+ }
58
+ return slices;
59
+ }
60
+
61
+ module.exports = { encrypt, decrypt, isEncrypted, isPrivateHcu, decryptSlices };
@@ -0,0 +1,373 @@
1
+ 'use strict';
2
+
3
+ const Database = require('better-sqlite3');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const fs = require('fs');
7
+
8
+ const DB_PATH = path.join(os.homedir(), '.bitpub', 'cache.db');
9
+
10
+ // All cache tables live here. CREATE TABLE IF NOT EXISTS is idempotent
11
+ // and fast (~0.1ms), so we run it on every connection rather than relying
12
+ // on `bitpub init`. This means a CLI that's been installed since before
13
+ // a new table was added (e.g. local_trash for soft delete) self-migrates
14
+ // the first time any cache function is called — no `bitpub init` rerun
15
+ // required.
16
+ const SCHEMA_DDL = `
17
+ CREATE TABLE IF NOT EXISTS local_slices (
18
+ hcu TEXT PRIMARY KEY,
19
+ payload TEXT NOT NULL,
20
+ metadata TEXT NOT NULL,
21
+ version INTEGER DEFAULT 1,
22
+ last_synced TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS synced_namespaces (
26
+ pattern TEXT PRIMARY KEY,
27
+ last_synced TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
28
+ slice_count INTEGER DEFAULT 0
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS local_trash (
32
+ hcu TEXT PRIMARY KEY,
33
+ payload TEXT NOT NULL,
34
+ metadata TEXT NOT NULL,
35
+ version INTEGER NOT NULL,
36
+ deleted_at TEXT NOT NULL,
37
+ trashed_locally_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
38
+ );
39
+ `;
40
+
41
+ function getDb() {
42
+ const dir = path.dirname(DB_PATH);
43
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
44
+ const db = new Database(DB_PATH);
45
+ // Self-heal schema on every connection — handles upgrades from CLI
46
+ // versions that predated newer tables. Idempotent, microseconds.
47
+ db.exec(SCHEMA_DDL);
48
+ return db;
49
+ }
50
+
51
+ // ── Schema ────────────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Explicit initialization is now redundant — `getDb()` runs the same DDL
55
+ * on every connection. This function is kept for backward compatibility
56
+ * with code that calls it directly during `bitpub init`.
57
+ *
58
+ * Two tables matter for deletion:
59
+ *
60
+ * local_slices — active state ONLY. No deleted_at column. The hot
61
+ * read path (`bitpub read`) is a pure-local query
62
+ * against this table; tombstones never leak in.
63
+ *
64
+ * local_trash — personal undo buffer for private slices THIS machine
65
+ * dropped. Stores the prior payload + version so an
66
+ * offline `bitpub trash restore` can attempt a server
67
+ * write with --expect-version. Group drops are NOT
68
+ * stored here (group is shared state; restoration goes
69
+ * through the server). Cleaned up on sync if remotely
70
+ * restored or superseded; TTL-purged after 30 days.
71
+ */
72
+ function initCache() {
73
+ const db = getDb();
74
+ db.close();
75
+ }
76
+
77
+ // ── Writes ────────────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Upsert a single slice returned from the remote API into the local cache.
81
+ * Accepts either the full DTO (from /push response) or a raw DB row.
82
+ */
83
+ function upsertSlice(slice) {
84
+ const db = getDb();
85
+ const stmt = db.prepare(`
86
+ INSERT INTO local_slices (hcu, payload, metadata, version, last_synced)
87
+ VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
88
+ ON CONFLICT (hcu) DO UPDATE SET
89
+ payload = excluded.payload,
90
+ metadata = excluded.metadata,
91
+ version = excluded.version,
92
+ last_synced = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
93
+ `);
94
+ stmt.run(
95
+ slice.hcu,
96
+ typeof slice.payload === 'string' ? slice.payload : JSON.stringify(slice.payload),
97
+ typeof slice.metadata === 'string' ? slice.metadata : JSON.stringify(slice.metadata),
98
+ slice.metadata?.version ?? 1
99
+ );
100
+ db.close();
101
+ }
102
+
103
+ /**
104
+ * Record that a namespace pattern was explicitly fetched.
105
+ */
106
+ function recordNamespaceSync(pattern, count) {
107
+ const db = getDb();
108
+ db.prepare(`
109
+ INSERT INTO synced_namespaces (pattern, last_synced, slice_count)
110
+ VALUES (?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
111
+ ON CONFLICT (pattern) DO UPDATE SET
112
+ last_synced = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
113
+ slice_count = excluded.slice_count
114
+ `).run(pattern, count);
115
+ db.close();
116
+ }
117
+
118
+ // ── Reads ─────────────────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Query local cache using HCU glob patterns.
122
+ * exact → exact match
123
+ * .../path/* → direct children (one level deep)
124
+ * .../path/** → all descendants (recursive)
125
+ */
126
+ function querySlices(hcuPattern) {
127
+ const db = getDb();
128
+ const pattern = hcuPattern.replace(/\/$/, '');
129
+ let rows;
130
+
131
+ if (pattern.endsWith('/**')) {
132
+ const base = pattern.slice(0, -3);
133
+ // Match the exact base OR anything underneath it
134
+ rows = db.prepare(`
135
+ SELECT * FROM local_slices
136
+ WHERE hcu = ? OR hcu LIKE ?
137
+ ORDER BY hcu
138
+ `).all(base, base + '/%');
139
+ } else if (pattern.endsWith('/*')) {
140
+ const base = pattern.slice(0, -2);
141
+ // Direct children: one slash deeper, no further slashes
142
+ rows = db.prepare(`
143
+ SELECT * FROM local_slices
144
+ WHERE hcu LIKE ?
145
+ AND SUBSTR(hcu, LENGTH(?) + 2) NOT LIKE '%/%'
146
+ ORDER BY hcu
147
+ `).all(base + '/%', base);
148
+ } else {
149
+ rows = db.prepare('SELECT * FROM local_slices WHERE hcu = ?').all(pattern);
150
+ }
151
+
152
+ db.close();
153
+ return rows;
154
+ }
155
+
156
+ /**
157
+ * Find slices whose address ends with the given path-tail. Used for
158
+ * "did you mean" recovery on a `load` miss: the user typed a short name
159
+ * that didn't resolve in their active workspace, but a slice with the
160
+ * same trailing path may still exist elsewhere in their cache (e.g. in
161
+ * Transcripts/, Memory/, a sibling Workspace, etc.).
162
+ *
163
+ * Matching is path-suffix only — `notes` matches `.../foo/notes` but not
164
+ * `.../barnotes`. Multi-segment tails work too: `Transcripts/Raw/2026-04-30`
165
+ * matches any address ending in that exact path.
166
+ *
167
+ * SQL wildcards `%` and `_` in the input are escaped so user-supplied
168
+ * names can't accidentally widen the search.
169
+ *
170
+ * @param {string} tail e.g. "notes" or "Transcripts/Raw/2026-04-30-..."
171
+ * @param {string} [scopePattern] optional bitpub:// pattern to constrain the
172
+ * search (e.g. `bitpub://private:owner/**`)
173
+ * @returns {Array} matching local_slices rows
174
+ */
175
+ function findSlicesByName(tail, scopePattern) {
176
+ if (typeof tail !== 'string' || tail.length === 0) return [];
177
+ const db = getDb();
178
+
179
+ const normalizedTail = '/' + tail.replace(/^\/+|\/+$/g, '');
180
+ const escape = (s) => s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
181
+ const tailLike = '%' + escape(normalizedTail);
182
+
183
+ let rows;
184
+ if (scopePattern) {
185
+ const scopeBase = scopePattern.replace(/\/(\*\*|\*)$/, '').replace(/\/$/, '');
186
+ const scopeLike = escape(scopeBase) + '/%';
187
+ rows = db.prepare(`
188
+ SELECT * FROM local_slices
189
+ WHERE (hcu = ? OR hcu LIKE ? ESCAPE '\\')
190
+ AND hcu LIKE ? ESCAPE '\\'
191
+ ORDER BY hcu
192
+ `).all(scopeBase, scopeLike, tailLike);
193
+ } else {
194
+ rows = db.prepare(
195
+ `SELECT * FROM local_slices WHERE hcu LIKE ? ESCAPE '\\' ORDER BY hcu`
196
+ ).all(tailLike);
197
+ }
198
+
199
+ db.close();
200
+ return rows;
201
+ }
202
+
203
+ /**
204
+ * Return all synced namespace records for the `status` command.
205
+ */
206
+ function getSyncedNamespaces() {
207
+ const db = getDb();
208
+ const rows = db.prepare(
209
+ 'SELECT pattern, last_synced, slice_count FROM synced_namespaces ORDER BY last_synced DESC'
210
+ ).all();
211
+ db.close();
212
+ return rows;
213
+ }
214
+
215
+ /**
216
+ * Return aggregate stats for the `status` command.
217
+ */
218
+ function getCacheStats() {
219
+ const db = getDb();
220
+ const row = db.prepare(`
221
+ SELECT COUNT(*) as total_slices,
222
+ MAX(last_synced) as latest_sync,
223
+ MIN(last_synced) as oldest_sync
224
+ FROM local_slices
225
+ `).get();
226
+ db.close();
227
+ return row;
228
+ }
229
+
230
+ // ── Deletion helpers ──────────────────────────────────────────────────────────
231
+ //
232
+ // Drops mutate two tables atomically depending on scope:
233
+ // - private + this-machine drop → row leaves local_slices, lands in local_trash
234
+ // - group drop → row leaves local_slices, no trash entry
235
+ // - sync-discovered drop (any) → row leaves local_slices, no trash entry
236
+ //
237
+ // The local_trash entry carries the version at delete-time so a restore push
238
+ // can pass --expect-version and let the server reject any state drift.
239
+
240
+ const TRASH_TTL_DAYS = 30;
241
+
242
+ /**
243
+ * Remove a slice from the active local cache. Used for any drop the local
244
+ * cache observes — both ones initiated here and ones learned about via
245
+ * sync (SSE `deleted` hint, fetch returning no row, etc.).
246
+ */
247
+ function evictSlice(hcu) {
248
+ const db = getDb();
249
+ db.prepare('DELETE FROM local_slices WHERE hcu = ?').run(hcu);
250
+ db.close();
251
+ }
252
+
253
+ /**
254
+ * Move a slice from local_slices into local_trash, recording the version
255
+ * and tombstone timestamp from the server response so a future restore
256
+ * can use --expect-version safely.
257
+ *
258
+ * Only call this for private slices the local user actively dropped; group
259
+ * drops should go through `evictSlice` instead.
260
+ */
261
+ function trashSlice(slice, deletedAt) {
262
+ const db = getDb();
263
+ const tx = db.transaction(() => {
264
+ db.prepare('DELETE FROM local_slices WHERE hcu = ?').run(slice.hcu);
265
+ db.prepare(`
266
+ INSERT INTO local_trash (hcu, payload, metadata, version, deleted_at, trashed_locally_at)
267
+ VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
268
+ ON CONFLICT (hcu) DO UPDATE SET
269
+ payload = excluded.payload,
270
+ metadata = excluded.metadata,
271
+ version = excluded.version,
272
+ deleted_at = excluded.deleted_at,
273
+ trashed_locally_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
274
+ `).run(
275
+ slice.hcu,
276
+ typeof slice.payload === 'string' ? slice.payload : JSON.stringify(slice.payload),
277
+ typeof slice.metadata === 'string' ? slice.metadata : JSON.stringify(slice.metadata),
278
+ slice.metadata?.version ?? 1,
279
+ deletedAt || slice.deleted_at || new Date().toISOString()
280
+ );
281
+ });
282
+ tx();
283
+ db.close();
284
+ }
285
+
286
+ /**
287
+ * Look up a single trash entry. Returns null if not in trash.
288
+ */
289
+ function getTrashEntry(hcu) {
290
+ const db = getDb();
291
+ const row = db.prepare('SELECT * FROM local_trash WHERE hcu = ?').get(hcu);
292
+ db.close();
293
+ return row || null;
294
+ }
295
+
296
+ /**
297
+ * List trash entries, optionally constrained to an HCU prefix (e.g. the
298
+ * active workspace's namespace). Newest-first.
299
+ */
300
+ function listTrash(prefix) {
301
+ const db = getDb();
302
+ let rows;
303
+ if (prefix) {
304
+ rows = db.prepare(`
305
+ SELECT * FROM local_trash
306
+ WHERE hcu = ? OR hcu LIKE ?
307
+ ORDER BY trashed_locally_at DESC
308
+ `).all(prefix.replace(/\/$/, ''), prefix.replace(/\/$/, '') + '/%');
309
+ } else {
310
+ rows = db.prepare('SELECT * FROM local_trash ORDER BY trashed_locally_at DESC').all();
311
+ }
312
+ db.close();
313
+ return rows;
314
+ }
315
+
316
+ /**
317
+ * Remove an entry from local_trash without touching the server. Used after
318
+ * a successful restore (the slice is back in local_slices via upsert), or
319
+ * by `bitpub trash empty`.
320
+ */
321
+ function removeFromTrash(hcu) {
322
+ const db = getDb();
323
+ db.prepare('DELETE FROM local_trash WHERE hcu = ?').run(hcu);
324
+ db.close();
325
+ }
326
+
327
+ /**
328
+ * Empty all trash entries, optionally constrained to a prefix.
329
+ * Returns the number of rows removed.
330
+ */
331
+ function emptyTrash(prefix) {
332
+ const db = getDb();
333
+ let result;
334
+ if (prefix) {
335
+ const base = prefix.replace(/\/$/, '');
336
+ result = db.prepare('DELETE FROM local_trash WHERE hcu = ? OR hcu LIKE ?').run(base, base + '/%');
337
+ } else {
338
+ result = db.prepare('DELETE FROM local_trash').run();
339
+ }
340
+ db.close();
341
+ return result.changes;
342
+ }
343
+
344
+ /**
345
+ * TTL-purge stale trash entries. Trash entries older than TRASH_TTL_DAYS
346
+ * are removed. Returns the count of removed rows.
347
+ */
348
+ function purgeExpiredTrash() {
349
+ const db = getDb();
350
+ const cutoff = new Date(Date.now() - TRASH_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString();
351
+ const result = db.prepare('DELETE FROM local_trash WHERE trashed_locally_at < ?').run(cutoff);
352
+ db.close();
353
+ return result.changes;
354
+ }
355
+
356
+ module.exports = {
357
+ initCache,
358
+ upsertSlice,
359
+ recordNamespaceSync,
360
+ querySlices,
361
+ findSlicesByName,
362
+ getSyncedNamespaces,
363
+ getCacheStats,
364
+ // Deletion helpers
365
+ evictSlice,
366
+ trashSlice,
367
+ getTrashEntry,
368
+ listTrash,
369
+ removeFromTrash,
370
+ emptyTrash,
371
+ purgeExpiredTrash,
372
+ TRASH_TTL_DAYS,
373
+ };