@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,255 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `bitpub save <name> [content]` — write a slice.
5
+ *
6
+ * Accepts three input forms (same as load/find/delete):
7
+ * - short name "notes" → resolves through the active project
8
+ * - alias ref "@team/Eng/notes" → expands against ~/.bitpub/aliases.json
9
+ * - full URL "bitpub://group:..." → used verbatim
10
+ *
11
+ * Lazy + silent setup:
12
+ * If the user has no identity yet AND the cwd isn't $HOME, save runs
13
+ * `ensureIdentity` + `ensureWorkspace` automatically before the actual
14
+ * save. Identity provisioning still prints a brief block (it's a
15
+ * one-time event), but anchoring the folder is silent — the first
16
+ * save in any folder simply produces a `✓ Saved → …` line plus the
17
+ * suggestion block, exactly like every subsequent save.
18
+ *
19
+ * Default = private. The CLI never silently writes to a group address;
20
+ * the only way to land in `bitpub://group:...` is to ask for it
21
+ * explicitly with a fully-qualified URL or a `@team` alias.
22
+ *
23
+ * After a successful default-path save, suggest alternative places the
24
+ * slice could live (long-term memory, drafts, team scope) so the agent
25
+ * can offer the human a one-click "move it elsewhere" follow-up
26
+ * without inventing those addresses itself.
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const os = require('os');
31
+ const path = require('path');
32
+ const { readConfig, isConfigured, authorIdFor, DEFAULT_CLOUD_URL } = require('../config');
33
+ const { createApiClient } = require('../api');
34
+ const { upsertSlice } = require('../db/cache');
35
+ const { encrypt, decryptSlices, isPrivateHcu } = require('../crypto');
36
+ const { resolveHcu } = require('../workspace');
37
+ const { readAliases } = require('../aliases');
38
+
39
+ function readStdin() {
40
+ return new Promise((resolve, reject) => {
41
+ if (process.stdin.isTTY) return resolve('');
42
+ let data = '';
43
+ process.stdin.setEncoding('utf-8');
44
+ process.stdin.on('data', chunk => { data += chunk; });
45
+ process.stdin.on('end', () => resolve(data));
46
+ process.stdin.on('error', reject);
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Ensure we have an identity before any save touches the wire. Lazy:
52
+ * only kicks in when there's no config yet, and only when the user is
53
+ * inside a project folder (never in $HOME — that'd anchor the home
54
+ * directory, which is almost never what they want).
55
+ *
56
+ * Anchoring is silent: the first save in a new folder reads exactly
57
+ * the same as the hundredth (one save line, one suggestion block).
58
+ * Identity provisioning still prints (it's a one-time event that
59
+ * matters: the user just got a new cloud identity).
60
+ *
61
+ * Returns the resolved config or exits with a helpful error.
62
+ */
63
+ async function ensureConfigOrSetup() {
64
+ let config = readConfig();
65
+ if (isConfigured(config)) {
66
+ // Even if identity is configured, make sure the cwd has an anchor
67
+ // so saves land in a stable project address. Silent.
68
+ if (config.owner) {
69
+ const cwd = process.cwd();
70
+ const home = path.resolve(os.homedir());
71
+ if (path.resolve(cwd) !== home) {
72
+ const { ensureWorkspace } = require('./setup');
73
+ ensureWorkspace({ owner: config.owner, quiet: true });
74
+ }
75
+ }
76
+ return config;
77
+ }
78
+
79
+ const cwd = process.cwd();
80
+ const home = path.resolve(os.homedir());
81
+ if (path.resolve(cwd) === home) {
82
+ console.error('No BitPub identity configured, and you\'re in $HOME.');
83
+ console.error('Run `bitpub setup` from a project folder to get started.');
84
+ process.exit(1);
85
+ }
86
+
87
+ // Defer the require until we actually need to provision — avoids
88
+ // pulling in axios on every save when the user is already configured.
89
+ const { ensureIdentity, ensureWorkspace } = require('./setup');
90
+ try {
91
+ config = await ensureIdentity({ url: DEFAULT_CLOUD_URL, quiet: false });
92
+ if (config?.owner) ensureWorkspace({ owner: config.owner, quiet: true });
93
+ } catch (err) {
94
+ console.error(`\n✗ Could not set up BitPub: ${err.message}`);
95
+ console.error(' Try running `bitpub setup --url <URL>` manually with a reachable backend.');
96
+ process.exit(1);
97
+ }
98
+ return config;
99
+ }
100
+
101
+ /**
102
+ * Format the suggestions block printed after a default-path save.
103
+ *
104
+ * Goal: surface a short list of *other private addresses this slice
105
+ * could plausibly live at*, without prescribing anything. The agent
106
+ * reads this output and can offer the user "want me to move it?" —
107
+ * the human sees concrete addresses, not abstract guidance.
108
+ *
109
+ * We suggest three canonical organizational namespaces in the user's
110
+ * private scope (Memory, Inbox, Drafts) plus any aliases they've
111
+ * defined (@memory, @inbox, @team, etc.). All suggestions are
112
+ * addresses the user can write to with a follow-up save.
113
+ *
114
+ * No invented addresses: if the user has no aliases defined and no
115
+ * private owner, we return an empty list rather than guessing.
116
+ */
117
+ function suggestionLines(name, savedAddress, config) {
118
+ const lines = [];
119
+ const seen = new Set([savedAddress]);
120
+ const aliases = readAliases();
121
+ const tail = name.replace(/^\/+/, '');
122
+
123
+ // Canonical private organizational folders. These are predictable
124
+ // paths under the user's own scope — the slice would live next to
125
+ // whatever else they've saved to /Memory/, /Inbox/, /Drafts/.
126
+ if (config.owner) {
127
+ const privateBase = `bitpub://private:${config.owner}`;
128
+ const places = [
129
+ ['/Memory/', 'long-term memory'],
130
+ ['/Inbox/', 'quick capture / triage'],
131
+ ['/Drafts/', 'short-lived working drafts'],
132
+ ];
133
+ for (const [prefix, desc] of places) {
134
+ const addr = privateBase + prefix + tail;
135
+ if (seen.has(addr)) continue;
136
+ seen.add(addr);
137
+ // Display the path-only form (drops the noisy bitpub://private:<owner>
138
+ // prefix). The agent can re-qualify it when calling save.
139
+ lines.push(` ${prefix}${tail}`.padEnd(38) + `# ${desc}`);
140
+ }
141
+ }
142
+
143
+ // User-defined aliases — these often point at custom places that
144
+ // matter more than the canonical ones above.
145
+ for (const [aliasName, aliasAddress] of Object.entries(aliases || {})) {
146
+ const addr = aliasAddress.replace(/\/?$/, '/') + tail;
147
+ if (seen.has(addr)) continue;
148
+ seen.add(addr);
149
+ const desc = aliasAddress.includes('group:') ? 'shared with your team' : 'alias';
150
+ lines.push(` @${aliasName}/${tail}`.padEnd(38) + `# ${desc}`);
151
+ }
152
+
153
+ return lines;
154
+ }
155
+
156
+ module.exports = function registerSave(program) {
157
+ program
158
+ .command('save <name> [content]')
159
+ .description('Save a slice. Defaults to this project (private + encrypted); pass a full URL or @alias to write elsewhere.')
160
+ .option('--file <path>', 'Read content from a file')
161
+ .option('--stdin', 'Read content from stdin (default if no content/file given)')
162
+ .option('--tags <string>', 'Comma-separated tags')
163
+ .option('--append', 'Append to existing slice instead of overwriting')
164
+ .option('--expect-version <number>', 'Reject if current version differs (optimistic concurrency)', parseInt)
165
+ .option('--force', 'Overwrite a tombstoned slice (un-delete + new content in one step)')
166
+ .option('--format <string>', 'Content format', 'text/markdown')
167
+ .action(async (name, content, opts) => {
168
+ const config = await ensureConfigOrSetup();
169
+
170
+ let resolved;
171
+ try {
172
+ resolved = resolveHcu(name, config);
173
+ } catch (err) {
174
+ console.error(err.message);
175
+ process.exit(1);
176
+ }
177
+ const { hcu: address, source, workspace } = resolved;
178
+
179
+ let finalContent = content;
180
+ if (opts.file) {
181
+ finalContent = fs.readFileSync(opts.file, 'utf-8');
182
+ } else if (opts.stdin || !finalContent) {
183
+ finalContent = await readStdin();
184
+ }
185
+ if (!finalContent) {
186
+ console.error('No content provided. Pass it as an argument, --file <path>, or pipe via stdin.');
187
+ process.exit(1);
188
+ }
189
+
190
+ const tagList = opts.tags
191
+ ? opts.tags.split(',').map(t => t.trim()).filter(Boolean)
192
+ : [];
193
+
194
+ const api = createApiClient(config);
195
+ const payloadContent = isPrivateHcu(address)
196
+ ? encrypt(finalContent, config.api_key)
197
+ : finalContent;
198
+
199
+ const body = {
200
+ hcu: address,
201
+ metadata: {
202
+ author_id: authorIdFor(config),
203
+ timestamp: new Date().toISOString(),
204
+ tags: tagList,
205
+ },
206
+ payload: { format: opts.format, content: payloadContent },
207
+ };
208
+
209
+ try {
210
+ const result = await api.push(body, {
211
+ append: !!opts.append,
212
+ expectVersion: opts.expectVersion,
213
+ force: !!opts.force,
214
+ });
215
+ decryptSlices([result.slice], config.api_key);
216
+ upsertSlice(result.slice);
217
+ console.log(`✓ Saved → ${address} (v${result.slice.metadata.version})`);
218
+
219
+ // Suggestions only fire on default-path saves: when the user
220
+ // gave us a short name resolved against the active project (or
221
+ // a session-default fallback). Explicit URLs and aliases are
222
+ // already deliberate — no second-guessing.
223
+ if (source === 'workspace' || source === 'session-default') {
224
+ const suggestions = suggestionLines(name, address, config);
225
+ if (suggestions.length > 0) {
226
+ console.log('');
227
+ console.log(' Other places this could live:');
228
+ for (const line of suggestions) console.log(line);
229
+ console.log('');
230
+ console.log(` Move with: bitpub save <one-of-those> --file - (or save again to that address)`);
231
+ }
232
+ }
233
+ } catch (err) {
234
+ if (err.response?.status === 409) {
235
+ const data = err.response.data;
236
+ if (data?.deleted_at) {
237
+ console.error(`Save blocked: "${name}" was deleted (${data.deleted_at}).`);
238
+ console.error('');
239
+ console.error(' Restore the prior content: bitpub delete "' + name + '" --undo');
240
+ console.error(' Overwrite with new content: bitpub save "' + name + '" "..." --force');
241
+ } else {
242
+ console.error(
243
+ `Version conflict: expected v${data.expected_version} but server has v${data.actual_version}.`
244
+ );
245
+ console.error(` Run: bitpub sync ${address} to refresh, then retry.`);
246
+ }
247
+ } else {
248
+ console.error(`Save failed: ${err.message}`);
249
+ }
250
+ process.exit(1);
251
+ }
252
+ });
253
+ };
254
+
255
+ module.exports.suggestionLines = suggestionLines;
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const { requireConfig } = require('../config');
4
+ const { createApiClient } = require('../api');
5
+ const { upsertSlice } = require('../db/cache');
6
+ const axios = require('axios');
7
+ const cheerio = require('cheerio');
8
+ const TurndownService = require('turndown');
9
+ const { URL } = require('url');
10
+
11
+ module.exports = function registerSeed(program) {
12
+ program
13
+ .command('seed')
14
+ .description('Fetch top pages from a URL and push them as context slices (bootstraps the knowledge base)')
15
+ .requiredOption('--url <string>', 'Base URL to seed from (e.g. https://example.com)')
16
+ .requiredOption('--address <string>', 'Target namespace address (e.g. bitpub://group:domain.com/Public)')
17
+ .option('--pages <number>', 'Max pages to fetch', '5')
18
+ .action(async ({ url: baseUrl, address, pages }) => {
19
+ const config = requireConfig();
20
+ const api = createApiClient(config);
21
+ const maxPages = Math.min(parseInt(pages, 10) || 5, 20);
22
+ const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
23
+
24
+ console.log(`Seeding from ${baseUrl} into ${address} (up to ${maxPages} pages)…\n`);
25
+
26
+ const urls = await discoverUrls(baseUrl, maxPages);
27
+
28
+ let seeded = 0;
29
+ for (const pageUrl of urls) {
30
+ try {
31
+ const { title, markdown } = await fetchAsMarkdown(pageUrl, td);
32
+ const slug = urlToSlug(pageUrl, baseUrl);
33
+ const targetAddress = `${address.replace(/\/$/, '')}/${slug}`;
34
+
35
+ const body = {
36
+ hcu: targetAddress,
37
+ metadata: {
38
+ author_id: 'seed_bot',
39
+ timestamp: new Date().toISOString(),
40
+ tags: ['seeded', 'web'],
41
+ },
42
+ payload: {
43
+ format: 'text/markdown',
44
+ content: `# ${title}\n\n> Source: ${pageUrl}\n\n${markdown}`,
45
+ },
46
+ };
47
+
48
+ const result = await api.push(body, false);
49
+ upsertSlice(result.slice);
50
+ console.log(` ✓ ${targetAddress}`);
51
+ seeded++;
52
+ } catch (err) {
53
+ console.error(` ✗ ${pageUrl}: ${err.message}`);
54
+ }
55
+ }
56
+
57
+ console.log(`\nSeeded ${seeded} / ${urls.length} pages into ${address}`);
58
+ });
59
+ };
60
+
61
+ // ── Helpers ───────────────────────────────────────────────────────────────────
62
+
63
+ async function discoverUrls(baseUrl, maxPages) {
64
+ const base = new URL(baseUrl);
65
+ const seen = new Set([normalizeUrl(baseUrl)]);
66
+ const queue = [normalizeUrl(baseUrl)];
67
+ const result = [];
68
+
69
+ while (queue.length > 0 && result.length < maxPages) {
70
+ const current = queue.shift();
71
+ result.push(current);
72
+
73
+ try {
74
+ const res = await axios.get(current, { timeout: 10_000 });
75
+ const $ = cheerio.load(res.data);
76
+
77
+ $('a[href]').each((_, el) => {
78
+ try {
79
+ const href = $(el).attr('href');
80
+ const resolved = new URL(href, current);
81
+ const norm = normalizeUrl(resolved.href);
82
+
83
+ if (
84
+ resolved.hostname === base.hostname &&
85
+ !seen.has(norm) &&
86
+ !norm.match(/\.(pdf|jpg|jpeg|png|gif|svg|zip|css|js|xml|json)(\?.*)?$/i)
87
+ ) {
88
+ seen.add(norm);
89
+ queue.push(norm);
90
+ }
91
+ } catch {
92
+ // Invalid URL — skip
93
+ }
94
+ });
95
+ } catch {
96
+ // Failed to fetch page for link extraction — still include it in results
97
+ }
98
+ }
99
+
100
+ return result;
101
+ }
102
+
103
+ async function fetchAsMarkdown(url, td) {
104
+ const res = await axios.get(url, {
105
+ timeout: 15_000,
106
+ headers: { 'User-Agent': 'BitPub-Seed/1.0 (context indexer)' },
107
+ });
108
+
109
+ const $ = cheerio.load(res.data);
110
+
111
+ // Title
112
+ const title =
113
+ $('meta[property="og:title"]').attr('content') ||
114
+ $('title').text() ||
115
+ url;
116
+
117
+ // Remove nav/footer/script noise before converting
118
+ $('script, style, nav, footer, header, .nav, .footer, .header, [role="navigation"]').remove();
119
+
120
+ // Prefer main content area if available
121
+ const bodyEl =
122
+ $('main').length ? $('main') :
123
+ $('article').length ? $('article') :
124
+ $('[role="main"]').length ? $('[role="main"]') :
125
+ $('body');
126
+
127
+ const markdown = td.turndown(bodyEl.html() || '');
128
+
129
+ return { title: title.trim(), markdown };
130
+ }
131
+
132
+ function urlToSlug(pageUrl, baseUrl) {
133
+ const base = new URL(baseUrl);
134
+ const page = new URL(pageUrl);
135
+
136
+ let slug = page.pathname
137
+ .replace(/^\//, '') // strip leading slash
138
+ .replace(/\/$/, '') // strip trailing slash
139
+ .replace(/\//g, '_') // slashes → underscores
140
+ .replace(/[^a-zA-Z0-9_-]/g, '_') // sanitize
141
+ .replace(/_+/g, '_') // collapse runs
142
+ .replace(/^_|_$/g, ''); // trim
143
+
144
+ return slug || `${base.hostname.replace(/\./g, '_')}_home`;
145
+ }
146
+
147
+ function normalizeUrl(url) {
148
+ const u = new URL(url);
149
+ u.hash = ''; // ignore fragments
150
+ u.search = ''; // ignore query strings for deduplication
151
+ return u.href.replace(/\/$/, '');
152
+ }