@profullstack/nichedb 0.10.0 → 0.12.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +197 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/nichedb",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI and MCP bridge for NicheDB: browse collections, manage sources and feeds, search items, from any deployment",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -9,12 +9,13 @@
9
9
  * `bin/` shim executes it.
10
10
  */
11
11
 
12
+ import { spawnSync } from 'node:child_process';
12
13
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
- import { homedir } from 'node:os';
14
+ import { homedir, tmpdir } from 'node:os';
14
15
  import { join } from 'node:path';
15
16
  import { createInterface } from 'node:readline';
16
17
 
17
- export const VERSION = '0.10.0';
18
+ export const VERSION = '0.12.0';
18
19
  const DEFAULT_API = process.env.NICHEDB_API ?? 'https://nichedb.dev';
19
20
  const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'nichedb');
20
21
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
@@ -36,6 +37,12 @@ export const COMMANDS = [
36
37
  summary: 'Sources with status, last run and item counts.',
37
38
  options: ['--collection <slug>', '--json'],
38
39
  },
40
+ {
41
+ name: 'submit',
42
+ usage: 'submit <url> [--collection <slug>] [--note …] [--email …]',
43
+ summary: 'Suggest a feed for the index. No key needed; an admin reviews it.',
44
+ options: ['--collection <slug>', '--note <text>', '--email <address>', '--json'],
45
+ },
39
46
  {
40
47
  name: 'source',
41
48
  usage: 'source <slug>',
@@ -171,6 +178,38 @@ export const COMMANDS = [
171
178
  summary: 'Print the RSS URL (or the feed itself with --fetch).',
172
179
  options: ['--fetch'],
173
180
  },
181
+ {
182
+ name: 'profiles',
183
+ usage: 'profiles [<query>] [--since <iso>] [--mine]',
184
+ summary: 'People with an OpenProfile.md: search them, or list the ones your key owns.',
185
+ options: ['--since <iso>', '--mine', '--limit', '--json', '--urls'],
186
+ },
187
+ {
188
+ name: 'profile',
189
+ usage: 'profile <id|slug-id|handle>',
190
+ summary: 'One person’s OpenProfile.md, as served. --json for the parsed view.',
191
+ options: ['--json'],
192
+ },
193
+ {
194
+ name: 'profile claim',
195
+ usage: 'profile claim <ref> [--email <address>]',
196
+ summary:
197
+ 'Claim a profile as yours (key required): proven by your email or a link back from your site.',
198
+ options: ['--email (admins: claim it for someone)'],
199
+ },
200
+ {
201
+ name: 'profile edit',
202
+ usage: 'profile edit <ref> [--file openprofile.md] [--handle …] [--public|--private]',
203
+ summary:
204
+ 'Edit a profile you own (key required). Opens $EDITOR on the file when no --file is given.',
205
+ options: ['--file <path>', '--handle <handle>', '--public', '--private', '--json'],
206
+ },
207
+ {
208
+ name: 'profile handle',
209
+ usage: 'profile handle <ref> <handle>',
210
+ summary: 'Take a handle: your URL becomes /c/profiles/<handle> (key required).',
211
+ options: [],
212
+ },
174
213
  {
175
214
  name: 'login',
176
215
  usage: 'login [--api <url>] [--key <ndb_…>]',
@@ -259,13 +298,51 @@ export function makeClient({ api, key, fetchImpl = fetch }) {
259
298
  if (!res.ok) throw new Error(data?.error ?? `${res.status} from ${path}`);
260
299
  return data;
261
300
  }
301
+ /** A body that is not JSON: a whole file, sent as its own media type. */
302
+ async function send(method, path, text, contentType) {
303
+ const res = await fetchImpl(`${base}${path}`, {
304
+ method,
305
+ headers: {
306
+ accept: 'application/json',
307
+ 'user-agent': `nichedb-cli/${VERSION}`,
308
+ 'content-type': contentType,
309
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
310
+ },
311
+ body: text,
312
+ });
313
+ const raw = await res.text();
314
+ let data;
315
+ try {
316
+ data = JSON.parse(raw);
317
+ } catch {
318
+ data = { raw };
319
+ }
320
+ if (!res.ok) throw new Error(data?.error ?? `${res.status} from ${path}`);
321
+ return data;
322
+ }
323
+ /** A document, not JSON: an openprofile.md as served. */
324
+ async function text(path) {
325
+ const res = await fetchImpl(`${base}${path}`, {
326
+ headers: {
327
+ accept: 'text/markdown, text/plain',
328
+ 'user-agent': `nichedb-cli/${VERSION}`,
329
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
330
+ },
331
+ });
332
+ const body = await res.text();
333
+ if (!res.ok) throw new Error(`${res.status} from ${path}`);
334
+ return body;
335
+ }
262
336
  return {
263
337
  base,
264
338
  key,
265
339
  get: (p) => call('GET', p),
266
340
  post: (p, b) => call('POST', p, b ?? {}),
267
341
  patch: (p, b) => call('PATCH', p, b),
342
+ put: (p, b) => call('PUT', p, b),
268
343
  del: (p) => call('DELETE', p),
344
+ send,
345
+ text,
269
346
  };
270
347
  }
271
348
 
@@ -398,6 +475,29 @@ export async function run(
398
475
  }
399
476
  return 0;
400
477
  }
478
+ case 'submit': {
479
+ const [url] = rest;
480
+ if (!url) {
481
+ process.stderr.write(
482
+ 'usage: nichedb submit <url> [--collection <slug>] [--note …] [--email …]\n',
483
+ );
484
+ return 2;
485
+ }
486
+ const { submission, duplicate } = await client.post('/api/v1/submissions', {
487
+ url,
488
+ collection: flags.collection,
489
+ note: flags.note,
490
+ email: flags.email,
491
+ });
492
+ out(
493
+ json
494
+ ? JSON.stringify({ submission, duplicate }, null, 2)
495
+ : duplicate
496
+ ? `Already suggested and waiting for review: ${submission.feed_url}`
497
+ : `Suggested ${submission.feed_url}. An admin will review it.`,
498
+ );
499
+ return 0;
500
+ }
401
501
  case 'sources': {
402
502
  const qs = flags.collection ? `?collection=${encodeURIComponent(flags.collection)}` : '';
403
503
  const { sources } = await client.get(`/api/v1/sources${qs}`);
@@ -628,6 +728,101 @@ export async function run(
628
728
  out(await res.text());
629
729
  return 0;
630
730
  }
731
+ case 'profiles': {
732
+ const term = rest.join(' ');
733
+ const qs = new URLSearchParams();
734
+ if (term) qs.set('q', term);
735
+ if (flags.since) qs.set('since', flags.since);
736
+ if (flags.limit) qs.set('limit', flags.limit);
737
+ if (flags.mine) qs.set('mine', '1');
738
+ const { profiles } = await client.get(`/api/v1/profiles?${qs}`);
739
+ if (json) {
740
+ out(JSON.stringify(profiles, null, 2));
741
+ return 0;
742
+ }
743
+ for (const p of profiles) {
744
+ if (flags.urls) {
745
+ out(p.page);
746
+ continue;
747
+ }
748
+ out(`${pad(p.ref, 34)} ${pad(p.kind ?? '', 12)} ${p.name}${p.claimed ? ' (claimed)' : ''}`);
749
+ if (p.headline) out(` ${p.headline}`);
750
+ }
751
+ if (profiles.length === 0) out('(nobody yet)');
752
+ return 0;
753
+ }
754
+ case 'profile': {
755
+ const [sub, arg, extra] = rest;
756
+ if (sub === 'claim') {
757
+ if (!arg) throw new Error('profile claim <ref> [--email …]');
758
+ const r = await client.post(`/api/v1/profiles/${encodeURIComponent(arg)}/claim`, {
759
+ email: flags.email,
760
+ });
761
+ out(
762
+ json
763
+ ? JSON.stringify(r, null, 2)
764
+ : r.already
765
+ ? `Already yours: ${r.profile.page}`
766
+ : `Claimed by ${r.method}: ${r.profile.page}`,
767
+ );
768
+ return 0;
769
+ }
770
+ if (sub === 'handle') {
771
+ if (!arg || !extra) throw new Error('profile handle <ref> <handle>');
772
+ const { profile } = await client.put(`/api/v1/profiles/${encodeURIComponent(arg)}`, {
773
+ handle: extra,
774
+ });
775
+ out(json ? JSON.stringify(profile, null, 2) : `Now at ${profile.page}`);
776
+ return 0;
777
+ }
778
+ if (sub === 'edit') {
779
+ if (!arg)
780
+ throw new Error('profile edit <ref> [--file …] [--handle …] [--public|--private]');
781
+ const ref = encodeURIComponent(arg);
782
+ let markdown = null;
783
+ if (flags.file) markdown = await readFile(String(flags.file), 'utf8');
784
+ else if (!flags.handle && !flags.public && !flags.private) {
785
+ // No file and nothing else to set: the editor, on the file as served.
786
+ const current = await client.text(`/api/v1/profiles/${ref}/openprofile.md`);
787
+ const path = join(tmpdir(), `nichedb-profile-${Date.now()}.md`);
788
+ await writeFile(path, current, { mode: 0o600 });
789
+ const editor = process.env.VISUAL ?? process.env.EDITOR ?? 'vi';
790
+ const r = spawnSync(editor, [path], {
791
+ stdio: 'inherit',
792
+ shell: process.platform === 'win32',
793
+ });
794
+ if (r.status !== 0) throw new Error(`${editor} exited ${r.status}; nothing saved.`);
795
+ markdown = await readFile(path, 'utf8');
796
+ if (markdown === current) {
797
+ out('No change.');
798
+ return 0;
799
+ }
800
+ }
801
+ let answer = null;
802
+ if (markdown !== null)
803
+ answer = await client.send(
804
+ 'PUT',
805
+ `/api/v1/profiles/${ref}`,
806
+ markdown,
807
+ 'text/markdown; charset=utf-8',
808
+ );
809
+ const patch = {};
810
+ if (flags.handle) patch.handle = String(flags.handle);
811
+ if (flags.public) patch.public = true;
812
+ if (flags.private) patch.public = false;
813
+ if (Object.keys(patch).length) answer = await client.put(`/api/v1/profiles/${ref}`, patch);
814
+ out(json ? JSON.stringify(answer.profile, null, 2) : `Saved: ${answer.profile.page}`);
815
+ return 0;
816
+ }
817
+ if (!sub) throw new Error('profile <ref>, or profile claim|edit|handle');
818
+ if (json) {
819
+ const r = await client.get(`/api/v1/profiles/${encodeURIComponent(sub)}`);
820
+ out(JSON.stringify(r.profile, null, 2));
821
+ return 0;
822
+ }
823
+ out(await client.text(`/api/v1/profiles/${encodeURIComponent(sub)}/openprofile.md`));
824
+ return 0;
825
+ }
631
826
  case 'mcp':
632
827
  return serveMcp({ client, stdin, stdout });
633
828
  default: