@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.
@@ -0,0 +1,377 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Folder anchors — the small piece of state that ties a folder on disk
5
+ * to a stable private address, so the same short name ("notes") always
6
+ * resolves to the same slice when the user is sitting in that folder.
7
+ *
8
+ * ~/projects/billing-svc → bitpub://private:agent_xyz/Projects/billing-svc/
9
+ *
10
+ * Anchors live in ~/.bitpub/config.json under `folderAnchors`. This is a
11
+ * deliberate change from earlier releases, which stored a `.bitpub/workspace.json`
12
+ * marker inside each project folder:
13
+ *
14
+ * - No clutter inside git checkouts.
15
+ * - One file to back up / sync / inspect; the agent already has it.
16
+ * - Anchors survive `git clean -fdx`, branch resets, fresh clones.
17
+ *
18
+ * Schema (one entry per anchored folder):
19
+ *
20
+ * {
21
+ * "folderAnchors": {
22
+ * "/Users/alice/projects/q3-launch": {
23
+ * "id": "ws_q3-launch_abc12345",
24
+ * "namespace": "bitpub://private:agent_xyz/Projects/q3-launch/",
25
+ * "label": "q3-launch",
26
+ * "created_at": "2026-05-11T...Z",
27
+ * "group_link": null
28
+ * }
29
+ * }
30
+ * }
31
+ *
32
+ * Resolution rules:
33
+ * 1. Walk up from cwd. If a key in `folderAnchors` matches, use its
34
+ * `namespace`. Stop at $HOME — we never anchor $HOME.
35
+ * 2. On the same walk, find any legacy `.bitpub/workspace.json` (or
36
+ * ancient `.tollbit/workspace.json`) marker, migrate it into
37
+ * folderAnchors, delete the marker file, and return.
38
+ * 3. Otherwise fall back to `bitpub://private:<owner>/Sessions/<YYYY-MM-DD>/`,
39
+ * which captures ad-hoc work outside any project folder.
40
+ *
41
+ * Group access bypasses all of this: any `bitpub://group:...` URL goes
42
+ * through unchanged. Anchors only short-circuit *unqualified* names.
43
+ *
44
+ * NOTE on the term "workspace" in this file:
45
+ * The user-facing word is now "project" everywhere — in CLI output, in
46
+ * the browser, in agent docs. Internally we still call it a "workspace"
47
+ * because that's what the public function names have been since v1
48
+ * and renaming them is a separate, much larger refactor.
49
+ */
50
+
51
+ const path = require('path');
52
+ const fs = require('fs');
53
+ const os = require('os');
54
+ const crypto = require('crypto');
55
+ const { isAliasRef, expandAlias } = require('./aliases');
56
+ const { readConfig, writeConfig } = require('./config');
57
+
58
+ const LEGACY_MARKER_DIRNAME = '.bitpub';
59
+ const LEGACY_TOLLBIT_DIRNAME = '.tollbit';
60
+ const LEGACY_MARKER_FILE = 'workspace.json';
61
+
62
+ const PROJECTS_PATH_SEGMENT = 'Projects';
63
+
64
+ function legacyMarkerPath(rootDir) {
65
+ return path.join(rootDir, LEGACY_MARKER_DIRNAME, LEGACY_MARKER_FILE);
66
+ }
67
+
68
+ function olderTollbitMarkerPath(rootDir) {
69
+ return path.join(rootDir, LEGACY_TOLLBIT_DIRNAME, LEGACY_MARKER_FILE);
70
+ }
71
+
72
+ /**
73
+ * Canonicalize an absolute path: resolve symlinks via realpathSync so
74
+ * `/tmp/foo` and `/private/tmp/foo` (the same folder on macOS) compare
75
+ * equal as anchor keys. Falls back to `path.resolve` for paths that
76
+ * don't yet exist (e.g. a folder that will be created in this call).
77
+ *
78
+ * This matters because `process.cwd()` reports the post-symlink path
79
+ * while `mkdir`/`mkdtempSync` may have returned the pre-symlink one —
80
+ * without this normalization the same folder could end up with two
81
+ * separate anchors in the config.
82
+ */
83
+ function canonicalPath(p) {
84
+ const resolved = path.resolve(p);
85
+ try { return fs.realpathSync(resolved); }
86
+ catch { return resolved; }
87
+ }
88
+
89
+ function _normalizeNamespaceString(ns) {
90
+ if (typeof ns !== 'string') return ns;
91
+ return ns.replace(/^tollbit:\/\//, 'bitpub://');
92
+ }
93
+
94
+ function _normalizeMarker(m) {
95
+ if (!m || typeof m !== 'object') return null;
96
+ if (typeof m.namespace !== 'string') return null;
97
+ if (m.namespace.startsWith('tollbit://')) {
98
+ return { ...m, namespace: _normalizeNamespaceString(m.namespace) };
99
+ }
100
+ return m;
101
+ }
102
+
103
+ // ── folderAnchors map in config.json ────────────────────────────────────────
104
+
105
+ function readAnchors() {
106
+ const config = readConfig() || {};
107
+ return config.folderAnchors && typeof config.folderAnchors === 'object'
108
+ ? config.folderAnchors
109
+ : {};
110
+ }
111
+
112
+ function _writeAnchors(map) {
113
+ const config = readConfig() || {};
114
+ config.folderAnchors = map;
115
+ writeConfig(config);
116
+ }
117
+
118
+ function setAnchor(rootDir, marker) {
119
+ const map = readAnchors();
120
+ map[canonicalPath(rootDir)] = marker;
121
+ _writeAnchors(map);
122
+ }
123
+
124
+ function deleteAnchor(rootDir) {
125
+ const map = readAnchors();
126
+ const key = canonicalPath(rootDir);
127
+ if (!(key in map)) return false;
128
+ delete map[key];
129
+ _writeAnchors(map);
130
+ return true;
131
+ }
132
+
133
+ // ── one-time migration: in-project marker file → folderAnchors map ──────────
134
+
135
+ function _safeReadMarkerFile(file) {
136
+ try {
137
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
138
+ if (raw && typeof raw.namespace === 'string') return raw;
139
+ } catch { /* corrupt — ignore */ }
140
+ return null;
141
+ }
142
+
143
+ function _migrateLegacyMarker(rootDir, marker, sourcePath) {
144
+ const normalized = { ...marker, namespace: _normalizeNamespaceString(marker.namespace) };
145
+ setAnchor(rootDir, normalized);
146
+
147
+ // Best-effort cleanup. If we can't delete the marker file (read-only fs,
148
+ // permissions, etc.) the worst case is that subsequent runs re-migrate
149
+ // it — idempotent and harmless.
150
+ try {
151
+ fs.unlinkSync(sourcePath);
152
+ const dir = path.dirname(sourcePath);
153
+ try {
154
+ const remaining = fs.readdirSync(dir);
155
+ if (remaining.length === 0) fs.rmdirSync(dir);
156
+ } catch { /* dir not empty — leave it */ }
157
+ } catch { /* couldn't delete — leave the marker, anchor still works */ }
158
+
159
+ if (process.env.BITPUB_QUIET !== '1') {
160
+ const relMarker = path.relative(rootDir, sourcePath) || sourcePath;
161
+ console.error(
162
+ `[bitpub] moved folder anchor: ${relMarker} → ~/.bitpub/config.json (no more in-project clutter)`
163
+ );
164
+ }
165
+
166
+ return normalized;
167
+ }
168
+
169
+ // ── walk up from cwd looking for an anchor ──────────────────────────────────
170
+
171
+ /**
172
+ * Walk up from `start` looking for either a configured anchor (in
173
+ * ~/.bitpub/config.json) or a legacy marker file (which gets migrated on
174
+ * the fly). Returns `{ root, marker, legacy }` or `null`.
175
+ *
176
+ * Deliberately bails at $HOME: a top-level home anchor would silently
177
+ * capture every shell session, which is almost never what the user wants.
178
+ */
179
+ function findWorkspace(start = process.cwd()) {
180
+ const home = canonicalPath(os.homedir());
181
+ const anchors = readAnchors();
182
+ let dir = canonicalPath(start);
183
+
184
+ while (true) {
185
+ if (Object.prototype.hasOwnProperty.call(anchors, dir)) {
186
+ const marker = _normalizeMarker(anchors[dir]);
187
+ if (marker) return { root: dir, marker, legacy: false };
188
+ }
189
+
190
+ const legacy1 = legacyMarkerPath(dir);
191
+ if (fs.existsSync(legacy1)) {
192
+ const parsed = _safeReadMarkerFile(legacy1);
193
+ if (parsed) {
194
+ const migrated = _migrateLegacyMarker(dir, parsed, legacy1);
195
+ return { root: dir, marker: migrated, legacy: true };
196
+ }
197
+ }
198
+
199
+ const legacy2 = olderTollbitMarkerPath(dir);
200
+ if (fs.existsSync(legacy2)) {
201
+ const parsed = _safeReadMarkerFile(legacy2);
202
+ if (parsed) {
203
+ const migrated = _migrateLegacyMarker(dir, parsed, legacy2);
204
+ return { root: dir, marker: migrated, legacy: true };
205
+ }
206
+ }
207
+
208
+ if (dir === home) return null;
209
+ const parent = path.dirname(dir);
210
+ if (parent === dir) return null;
211
+ dir = parent;
212
+ }
213
+ }
214
+
215
+ // ── creating a new anchor ───────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Slugify a folder name into something safe to embed in a URL path:
219
+ * lowercase, hyphen-separated, ASCII only.
220
+ */
221
+ function slugify(name) {
222
+ const slug = String(name || '')
223
+ .toLowerCase()
224
+ .replace(/[^a-z0-9]+/g, '-')
225
+ .replace(/^-+|-+$/g, '')
226
+ .slice(0, 64);
227
+ return slug || 'project';
228
+ }
229
+
230
+ function generateWorkspaceId(slug) {
231
+ const rand = crypto.randomBytes(4).toString('hex');
232
+ return `ws_${slug.slice(0, 24)}_${rand}`;
233
+ }
234
+
235
+ /**
236
+ * Create or return an anchor for `rootDir`. Idempotent unless `force`
237
+ * is set: if there's already an anchor for this folder (in
238
+ * folderAnchors OR a legacy marker we'd auto-migrate), we return it
239
+ * unchanged.
240
+ *
241
+ * The returned shape is intentionally the same as before the move from
242
+ * marker files: `{ root, marker, created }`. Callers don't need to
243
+ * know where the anchor is stored.
244
+ */
245
+ function createWorkspace(rootDir, ownerId, { force = false, label } = {}) {
246
+ if (!ownerId) throw new Error('createWorkspace: ownerId is required');
247
+
248
+ const home = canonicalPath(os.homedir());
249
+ const resolvedRoot = canonicalPath(rootDir);
250
+ if (resolvedRoot === home) {
251
+ throw new Error(
252
+ 'Refusing to anchor $HOME as a project. Pick a project folder instead.'
253
+ );
254
+ }
255
+
256
+ if (!force) {
257
+ // findWorkspace will surface either a configured anchor or migrate a
258
+ // legacy marker at this exact dir, so the caller gets the existing
259
+ // state without duplicates.
260
+ const existing = findWorkspace(resolvedRoot);
261
+ if (existing && existing.root === resolvedRoot) {
262
+ return { root: resolvedRoot, marker: existing.marker, created: false };
263
+ }
264
+ }
265
+
266
+ const displayLabel = label || path.basename(resolvedRoot);
267
+ const slug = slugify(displayLabel);
268
+ const id = generateWorkspaceId(slug);
269
+ const namespace = `bitpub://private:${ownerId}/${PROJECTS_PATH_SEGMENT}/${slug}/`;
270
+
271
+ const marker = {
272
+ id,
273
+ namespace,
274
+ label: displayLabel,
275
+ created_at: new Date().toISOString(),
276
+ group_link: null,
277
+ };
278
+
279
+ setAnchor(resolvedRoot, marker);
280
+ return { root: resolvedRoot, marker, created: true };
281
+ }
282
+
283
+ // ── computed namespaces / hcu resolution ───────────────────────────────────
284
+
285
+ /**
286
+ * Compute the namespace for short-name resolution given current cwd.
287
+ * Returns `{ namespace, source, workspace }` where `source` is:
288
+ * - 'workspace' : a project anchor was found (`workspace` set)
289
+ * - 'session-default' : fell back to per-day private session bucket
290
+ * Returns `null` if no owner is configured and no anchor was found.
291
+ */
292
+ function activeNamespace(config, cwd = process.cwd()) {
293
+ const ws = findWorkspace(cwd);
294
+ if (ws) {
295
+ return { namespace: ws.marker.namespace, source: 'workspace', workspace: ws };
296
+ }
297
+
298
+ if (!config || !config.owner) return null;
299
+
300
+ const today = new Date().toISOString().slice(0, 10);
301
+ const namespace = `bitpub://private:${config.owner}/Sessions/${today}/`;
302
+ return { namespace, source: 'session-default', workspace: null };
303
+ }
304
+
305
+ /**
306
+ * Turn a short name like "notes" into a fully-qualified address. Resolution
307
+ * rules, in order:
308
+ *
309
+ * 1. `@alias/...` → expand against ~/.bitpub/aliases.json
310
+ * 2. `bitpub://...` → use verbatim (no rewriting, ever)
311
+ * 3. `/Memory/notes` → private-root: prefix with bitpub://private:<owner>
312
+ * (this is what `save` suggestions point at, so
313
+ * the agent can paste them back without thinking)
314
+ * 4. `notes` → project-relative, resolved through the active anchor
315
+ *
316
+ * Rule 3 exists specifically so the suggestions block in `save`'s output
317
+ * (`/Memory/notes`, `/Inbox/notes`, ...) can be copy-pasted into a follow-up
318
+ * `bitpub save /Memory/notes` without re-qualifying to a full URL.
319
+ */
320
+ function resolveHcu(nameOrHcu, config, cwd = process.cwd()) {
321
+ if (typeof nameOrHcu !== 'string' || nameOrHcu.length === 0) {
322
+ throw new Error('resolveHcu: name is required');
323
+ }
324
+
325
+ if (isAliasRef(nameOrHcu)) {
326
+ return { hcu: expandAlias(nameOrHcu), source: 'alias', workspace: null };
327
+ }
328
+
329
+ if (nameOrHcu.startsWith('bitpub://')) {
330
+ return { hcu: nameOrHcu, source: 'explicit', workspace: null };
331
+ }
332
+
333
+ if (nameOrHcu.startsWith('/')) {
334
+ if (!config || !config.owner) {
335
+ throw new Error(
336
+ 'Cannot resolve "' + nameOrHcu + '": absolute paths require a private identity. ' +
337
+ 'Run `bitpub setup` first, or pass a full bitpub:// URL.'
338
+ );
339
+ }
340
+ const trimmed = nameOrHcu.replace(/^\/+/, '');
341
+ return {
342
+ hcu: `bitpub://private:${config.owner}/${trimmed}`,
343
+ source: 'private-root',
344
+ workspace: null,
345
+ };
346
+ }
347
+
348
+ const active = activeNamespace(config, cwd);
349
+ if (!active) {
350
+ throw new Error(
351
+ 'Cannot resolve "' + nameOrHcu + '": no project anchored here and no identity configured. ' +
352
+ 'Run `bitpub setup` first.'
353
+ );
354
+ }
355
+
356
+ const trimmed = nameOrHcu.replace(/^\/+/, '');
357
+ return {
358
+ hcu: active.namespace + trimmed,
359
+ source: active.source,
360
+ workspace: active.workspace,
361
+ };
362
+ }
363
+
364
+ module.exports = {
365
+ // Public API
366
+ findWorkspace,
367
+ createWorkspace,
368
+ activeNamespace,
369
+ resolveHcu,
370
+ slugify,
371
+ canonicalPath,
372
+ PROJECTS_PATH_SEGMENT,
373
+ // Anchor map helpers (used by setup, tests, future admin commands)
374
+ readAnchors,
375
+ setAnchor,
376
+ deleteAnchor,
377
+ };