kubun 0.11.0 → 0.12.1

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.
@@ -9,8 +9,10 @@ function parseVariables(value) {
9
9
  }
10
10
  try {
11
11
  return JSON.parse(value);
12
- } catch {
13
- throw new Error(`--variables is not valid JSON: ${value}`);
12
+ } catch (cause) {
13
+ throw new Error(`--variables is not valid JSON: ${value}`, {
14
+ cause
15
+ });
14
16
  }
15
17
  }
16
18
  function createMutateCommand() {
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createHubCommand(): Command;
@@ -0,0 +1,105 @@
1
+ import { getKubunLogger } from '@kubun/logger';
2
+ import { configureSync, getConsoleSink } from '@logtape/logtape';
3
+ import { Command } from 'commander';
4
+ import { startHub } from '../hub.js';
5
+ import { withAllowedOrigin, withLogLevel, withPort, withPrivateKey } from '../options.js';
6
+ import { renderNotice, withSpinner } from '../ui.js';
7
+ /**
8
+ * Resolve who the relay accepts. Exactly one of the two flags is required: a
9
+ * hub started with neither would run refusing every DID, which looks like a
10
+ * broken hub rather than a missing decision.
11
+ */ function resolveHubAuth(opts) {
12
+ if (opts.acceptDid != null && opts.acceptAll) {
13
+ throw new Error('--accept-did and --accept-all are mutually exclusive');
14
+ }
15
+ if (opts.acceptDid == null && !opts.acceptAll) {
16
+ throw new Error('one of --accept-did or --accept-all is required');
17
+ }
18
+ if (opts.acceptAll) {
19
+ return {
20
+ mode: 'open'
21
+ };
22
+ }
23
+ const allowedDIDs = (opts.acceptDid ?? '').split(',').map((did)=>did.trim()).filter(Boolean);
24
+ if (allowedDIDs.length === 0) {
25
+ throw new Error('--accept-did lists no DID');
26
+ }
27
+ // A wildcard in the list would match no issuer and quietly deny everyone, so
28
+ // the operator asking for open is sent to the flag that means it.
29
+ if (allowedDIDs.includes('*')) {
30
+ throw new Error('--accept-did takes DIDs, not a wildcard. Use --accept-all to accept any DID.');
31
+ }
32
+ return {
33
+ mode: 'allowlist',
34
+ allowedDIDs
35
+ };
36
+ }
37
+ export function createHubCommand() {
38
+ const cmd = new Command('hub').description('Start a blind relay hub for groups this process is not a member of').option('--accept-did <dids>', 'comma-separated DIDs allowed to use the relay').option('--accept-all', 'accept every signed DID (development only)').option('--db <path>', 'path to the hub database');
39
+ withAllowedOrigin(cmd);
40
+ withLogLevel(cmd);
41
+ withPort(cmd);
42
+ withPrivateKey(cmd);
43
+ cmd.action(async (opts)=>{
44
+ const auth = resolveHubAuth(opts);
45
+ configureSync({
46
+ reset: true,
47
+ sinks: {
48
+ console: getConsoleSink()
49
+ },
50
+ loggers: [
51
+ {
52
+ category: [
53
+ 'logtape',
54
+ 'meta'
55
+ ],
56
+ lowestLevel: 'error',
57
+ sinks: [
58
+ 'console'
59
+ ]
60
+ },
61
+ {
62
+ category: [
63
+ 'kubun'
64
+ ],
65
+ lowestLevel: opts.logLevel ?? 'warning',
66
+ sinks: [
67
+ 'console'
68
+ ]
69
+ }
70
+ ]
71
+ });
72
+ if (auth.mode === 'open') {
73
+ const logger = getKubunLogger('cli');
74
+ logger.warn('================================================================');
75
+ logger.warn(' RELAY AUTH IS OPEN — --accept-all');
76
+ logger.warn(' Any signed token from any DID will be accepted.');
77
+ logger.warn(' Never use this setting in production.');
78
+ logger.warn('================================================================');
79
+ }
80
+ const port = opts.port == null ? undefined : typeof opts.port === 'number' ? opts.port : Number.parseInt(opts.port, 10);
81
+ const hub = await withSpinner('Starting the hub...', ()=>startHub({
82
+ auth,
83
+ ...opts.db == null ? {} : {
84
+ db: opts.db
85
+ },
86
+ ...opts.privateKey == null ? {} : {
87
+ privateKey: opts.privateKey
88
+ },
89
+ ...opts.allowedOrigin == null ? {} : {
90
+ allowedOrigin: opts.allowedOrigin
91
+ },
92
+ ...port == null ? {} : {
93
+ port
94
+ }
95
+ }));
96
+ const accepted = auth.mode === 'open' ? 'any signed DID' : `${auth.allowedDIDs?.length ?? 0} DID(s)`;
97
+ renderNotice('success', `Hub relay listening at ${hub.url}\n DID: ${hub.did}\n Accepting: ${accepted}`);
98
+ await new Promise((resolve)=>{
99
+ process.once('SIGINT', resolve);
100
+ process.once('SIGTERM', resolve);
101
+ });
102
+ await hub.dispose();
103
+ });
104
+ return cmd;
105
+ }
@@ -5,8 +5,10 @@ import { renderNotice } from '../ui.js';
5
5
  function parseJSON(label, value) {
6
6
  try {
7
7
  return JSON.parse(value);
8
- } catch {
9
- throw new Error(`${label} is not valid JSON: ${value}`);
8
+ } catch (cause) {
9
+ throw new Error(`${label} is not valid JSON: ${value}`, {
10
+ cause
11
+ });
10
12
  }
11
13
  }
12
14
  function createClusterCommand() {
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createPeerCommand(): Command;
@@ -0,0 +1,275 @@
1
+ import { getKubunLogger } from '@kubun/logger';
2
+ import { Command } from 'commander';
3
+ import { announceSelf, buildEngine, createAdapter, deployPeerGraph } from '../engine.js';
4
+ import { resolveIdentity } from '../identity.js';
5
+ import { withLogLevel, withPrivateKey } from '../options.js';
6
+ import { renderNotice } from '../ui.js';
7
+ const PREPARE_JOIN_REQUEST = 'mutation { prepareJoinRequest { joinRequest } }';
8
+ const COMPLETE_JOIN = `mutation($invitePayload: String!, $send: ShareInput, $receive: ShareReceiveInput) {
9
+ completeJoin(invitePayload: $invitePayload, send: $send, receive: $receive) { group { id name } }
10
+ }`;
11
+ const CREATE_GROUP = `mutation($name: String!, $url: URL!) {
12
+ createGroup(input: { name: $name, hubs: [{ url: $url }] }) { group { id name } }
13
+ }`;
14
+ const ADMIT = `mutation($groupID: ID!, $joinRequest: String!, $send: ShareInput, $receive: ShareReceiveInput) {
15
+ admitJoinRequest(groupID: $groupID, joinRequest: $joinRequest, send: $send, receive: $receive) {
16
+ peerDID
17
+ invitePayload
18
+ }
19
+ }`;
20
+ const GROUPS = '{ groups { id name } }';
21
+ const PEER_DEVICES = `query($groupID: ID!) {
22
+ peerDevices(groupID: $groupID) { peerDID label availability }
23
+ }`;
24
+ const SYNC_PEER = `mutation($groupID: ID!, $peerDID: ID!) {
25
+ syncPeer(groupID: $groupID, peerDID: $peerDID) {
26
+ messagesSent
27
+ messagesReceived
28
+ divergentBuckets
29
+ }
30
+ }`;
31
+ /** `--send a,b` as the `ShareInput` the admission mutations take, or null for none. */ function shareInput(models) {
32
+ if (models == null) return null;
33
+ const list = models.split(',').map((entry)=>entry.trim()).filter(Boolean);
34
+ return {
35
+ models: list
36
+ };
37
+ }
38
+ /**
39
+ * Run one p2p mutation against a local device and stop.
40
+ *
41
+ * Both halves of the offline exchange are one-shot, but they must land in the
42
+ * SAME database: `peer request` writes the key material `peer join` consumes,
43
+ * so a default in-memory adapter would make the pair silently unusable.
44
+ */ async function withPeerDevice(opts, run) {
45
+ if (opts.db == null) {
46
+ throw new Error('--db is required: the join exchange spans two commands and must persist');
47
+ }
48
+ const logger = getKubunLogger('cli');
49
+ const { engine } = buildEngine({
50
+ identity: resolveIdentity({
51
+ ...opts,
52
+ p2p: true
53
+ }, logger),
54
+ adapter: createAdapter(opts.db),
55
+ p2p: {}
56
+ });
57
+ const execute = async (kind, id, text, variables)=>{
58
+ const params = {
59
+ id,
60
+ text,
61
+ ...variables == null ? {} : {
62
+ variables
63
+ }
64
+ };
65
+ const result = kind === 'mutate' ? await engine.mutateGraph(params) : await engine.queryGraph(params);
66
+ if (result.errors != null) {
67
+ throw new Error(result.errors.map((error)=>error.message).join('; '));
68
+ }
69
+ if (result.data == null) {
70
+ throw new Error(`${kind} returned no data`);
71
+ }
72
+ return result.data;
73
+ };
74
+ try {
75
+ const id = await deployPeerGraph(engine);
76
+ return await run({
77
+ mutate: (text, variables)=>execute('mutate', id, text, variables),
78
+ query: (text, variables)=>execute('query', id, text, variables),
79
+ engine,
80
+ hubReady: async ()=>{
81
+ const api = await engine.getAPI('p2p');
82
+ await api.hubReady;
83
+ return api;
84
+ }
85
+ });
86
+ } finally{
87
+ await engine.dispose();
88
+ }
89
+ }
90
+ function withPeerOptions(cmd) {
91
+ cmd.option('--db <path>', 'path to the local SQLite database');
92
+ withLogLevel(cmd);
93
+ withPrivateKey(cmd, {
94
+ required: true
95
+ });
96
+ return cmd;
97
+ }
98
+ export function createPeerCommand() {
99
+ const cmd = new Command('peer').description('Pair devices, advertise this one, and sync with them');
100
+ const request = new Command('request').description('Print a join request for another device to admit');
101
+ withPeerOptions(request);
102
+ request.action(async (opts)=>{
103
+ const data = await withPeerDevice(opts, async ({ mutate })=>{
104
+ return await mutate(PREPARE_JOIN_REQUEST);
105
+ });
106
+ // The payload goes to stdout alone so it can be piped; everything else this
107
+ // command says goes to stderr.
108
+ process.stdout.write(`${data.prepareJoinRequest.joinRequest}\n`);
109
+ });
110
+ const join = new Command('join').description('Complete a join from an invite payload');
111
+ join.requiredOption('--invite <payload>', 'invite payload minted by the admitting device');
112
+ // Each device declares its OWN half in the call it already makes. Without
113
+ // `--send` this device grants nothing back, so the admitter can never pull
114
+ // what this one authors and the pair syncs one way while looking bidirectional.
115
+ join.option('--send <models>', 'comma-separated model ids this device shares back');
116
+ join.option('--receive', 'activate the seeded catalog, so this device pulls the share scope');
117
+ withPeerOptions(join);
118
+ join.action(async (opts)=>{
119
+ const data = await withPeerDevice(opts, async ({ mutate })=>{
120
+ return await mutate(COMPLETE_JOIN, {
121
+ invitePayload: opts.invite,
122
+ send: shareInput(opts.send),
123
+ receive: opts.receive === true ? {
124
+ activate: true
125
+ } : null
126
+ });
127
+ });
128
+ renderNotice('success', `Joined ${data.completeJoin.group.name}\n Group: ${data.completeJoin.group.id}`);
129
+ });
130
+ const createGroup = new Command('create-group').description('Create a group on this device and bind it to a hub');
131
+ createGroup.requiredOption('--name <name>', 'group name');
132
+ createGroup.requiredOption('--hub <url>', 'hub relay URL the group publishes through');
133
+ withPeerOptions(createGroup);
134
+ createGroup.action(async (opts)=>{
135
+ const data = await withPeerDevice(opts, async ({ mutate, hubReady })=>{
136
+ // Before the group's first commit, not after: the creation commit rides
137
+ // the relay, and a one-shot process that dials before its relay exists
138
+ // reports an empty group rather than an unreachable one.
139
+ await hubReady();
140
+ return await mutate(CREATE_GROUP, {
141
+ name: opts.name,
142
+ url: opts.hub
143
+ });
144
+ });
145
+ renderNotice('success', `Created ${data.createGroup.group.name}\n Group: ${data.createGroup.group.id}`);
146
+ });
147
+ const admit = new Command('admit').description('Admit a join request into a group and print the invite payload');
148
+ admit.requiredOption('--group <id>', 'group to admit into');
149
+ admit.requiredOption('--request <payload>', 'join request printed by `peer request`');
150
+ admit.option('--send <models>', 'comma-separated model ids to share with the admitted device');
151
+ admit.option('--receive', 'activate the created catalog, so this device pulls the share scope');
152
+ withPeerOptions(admit);
153
+ admit.action(async (opts)=>{
154
+ const data = await withPeerDevice(opts, async ({ mutate, hubReady })=>{
155
+ // An admission drives an MLS commit whose fan-out to the group's EXISTING
156
+ // members rides the relay, and `withPeerDevice` disposes the engine the
157
+ // moment this returns. Without the wait, a group with a third member is a
158
+ // plausible silently-dropped commit.
159
+ await hubReady();
160
+ return await mutate(ADMIT, {
161
+ groupID: opts.group,
162
+ joinRequest: opts.request,
163
+ send: shareInput(opts.send),
164
+ receive: opts.receive === true ? {
165
+ activate: true
166
+ } : null
167
+ });
168
+ });
169
+ process.stderr.write(` Admitted: ${data.admitJoinRequest.peerDID}\n`);
170
+ // The payload alone on stdout, as `peer request` does, so it can be piped.
171
+ process.stdout.write(`${data.admitJoinRequest.invitePayload}\n`);
172
+ });
173
+ const profile = new Command('profile').description('Declare what this device is, so co-members can see it at all');
174
+ profile.requiredOption('--label <label>', 'display name shown to co-members');
175
+ profile.option('--availability <tier>', 'always-on | interactive | mobile (default: always-on)', 'always-on');
176
+ withPeerOptions(profile);
177
+ profile.action(async (opts)=>{
178
+ // Commander enforces `--label`, so this only fires if the option is ever
179
+ // made optional — a cast would go on compiling and announce `undefined`.
180
+ const label = opts.label;
181
+ if (label == null) {
182
+ throw new Error('--label is required');
183
+ }
184
+ const availability = opts.availability ?? 'always-on';
185
+ if (availability !== 'always-on' && availability !== 'interactive' && availability !== 'mobile') {
186
+ throw new Error(`--availability must be always-on, interactive or mobile, got ${availability}`);
187
+ }
188
+ await withPeerDevice(opts, async ({ engine, hubReady })=>{
189
+ // The announce publishes on each joined group's lane, so the relay has to
190
+ // be up first or this device advertises only to its own store.
191
+ await hubReady();
192
+ await announceSelf(engine, {
193
+ label,
194
+ availability
195
+ });
196
+ });
197
+ renderNotice('success', `Announced as ${label} (${availability})`);
198
+ });
199
+ const list = new Command('list').description("This group's other devices, and who answered");
200
+ list.option('--group <id>', 'group to list (default: every joined group)');
201
+ withPeerOptions(list);
202
+ list.action(async (opts)=>{
203
+ const rows = await withPeerDevice(opts, async ({ query, engine, hubReady })=>{
204
+ const api = await hubReady();
205
+ // Every announce seeds this device's OWN row, and a device never answers
206
+ // its own gather — so leaving it in prints this machine as `silent`, which
207
+ // is both useless and wrong.
208
+ const selfDID = engine.identity.id;
209
+ // Named from the store even when `--group` picks one, so the column reads
210
+ // as a name in both modes rather than as an id in one of them.
211
+ const joined = (await query(GROUPS)).groups;
212
+ const groups = opts.group == null ? joined : joined.filter((group)=>group.id === opts.group);
213
+ if (opts.group != null && groups.length === 0) {
214
+ throw new Error(`this device has not joined group ${opts.group}`);
215
+ }
216
+ const out = [];
217
+ for (const group of groups){
218
+ // Announce then gather, as the app's device list does: a device that has
219
+ // gone quiet should hear this one is here in the same round trip it is
220
+ // asked to answer.
221
+ const answered = await api.refreshPeerPresence(group.id);
222
+ const live = new Set(answered.map((peer)=>peer.peerDID));
223
+ const devices = await query(PEER_DEVICES, {
224
+ groupID: group.id
225
+ });
226
+ for (const device of devices.peerDevices){
227
+ if (device.peerDID === selfDID) continue;
228
+ out.push({
229
+ group: group.name,
230
+ did: device.peerDID,
231
+ label: `${device.label} (${device.availability})`,
232
+ live: live.has(device.peerDID)
233
+ });
234
+ }
235
+ }
236
+ return out;
237
+ });
238
+ if (rows.length === 0) {
239
+ // Never "no devices": a device that has not announced is invisible here,
240
+ // which is a different fact from not existing.
241
+ renderNotice('info', 'No devices have answered yet');
242
+ return;
243
+ }
244
+ for (const row of rows){
245
+ // "answered" and "silent", never "online"/"offline": the gather reports who
246
+ // replied inside its window, and asleep is indistinguishable from gone.
247
+ process.stdout.write(`${row.live ? 'answered' : 'silent '} ${row.label} ${row.did} [${row.group}]\n`);
248
+ }
249
+ });
250
+ const sync = new Command('sync').description('Catch up with one named device now');
251
+ sync.argument('<peerDID>', 'the device to sync with');
252
+ sync.requiredOption('--group <id>', 'group whose hub tunnel routes the session');
253
+ withPeerOptions(sync);
254
+ sync.action(async (peerDID, opts)=>{
255
+ const data = await withPeerDevice(opts, async ({ mutate, hubReady })=>{
256
+ await hubReady();
257
+ return await mutate(SYNC_PEER, {
258
+ groupID: opts.group,
259
+ peerDID
260
+ });
261
+ });
262
+ const synced = data.syncPeer;
263
+ // All-zero is a real outcome, not a failure: a device with no ACTIVE catalog
264
+ // resolving to a concrete owner short-circuits before choosing a direction.
265
+ renderNotice('success', `Synced with ${peerDID}\n Messages: ${synced.messagesReceived} in, ${synced.messagesSent} out\n Divergent buckets: ${synced.divergentBuckets}`);
266
+ });
267
+ cmd.addCommand(request);
268
+ cmd.addCommand(join);
269
+ cmd.addCommand(createGroup);
270
+ cmd.addCommand(admit);
271
+ cmd.addCommand(profile);
272
+ cmd.addCommand(list);
273
+ cmd.addCommand(sync);
274
+ return cmd;
275
+ }
@@ -1,18 +1,12 @@
1
- import { NodeSQLiteAdapter } from '@kubun/db-node-sqlite';
2
- import { PostgresAdapter } from '@kubun/db-postgres';
3
- import { KubunEngine } from '@kubun/engine';
4
1
  import { getKubunLogger } from '@kubun/logger';
5
- import { createHTTPPlugin } from '@kubun/plugin-http';
6
- import { createP2PPlugin } from '@kubun/plugin-p2p';
7
- import { createRPCPlugin } from '@kubun/plugin-rpc';
8
2
  import { configureSync, getConsoleSink } from '@logtape/logtape';
9
3
  import { Command } from 'commander';
10
- import { resolvePath } from '../fs.js';
4
+ import { createAdapter, startNode } from '../engine.js';
11
5
  import { resolveIdentity } from '../identity.js';
12
6
  import { withAllowedOrigin, withLogLevel, withPort, withPrivateKey } from '../options.js';
13
7
  import { renderNotice, withSpinner } from '../ui.js';
14
8
  export function createServeCommand() {
15
- const cmd = new Command('serve').description('Start a local Kubun server').option('--auto-accept-peers <dids>', 'comma-separated DIDs to auto-accept for peer join flows').option('--db <path>', 'path to the local SQLite database').option('--id <id>', 'server ID').option('--p2p', 'enable P2P mode with sync and graph protocols over HTTP');
9
+ const cmd = new Command('serve').description('Start a local Kubun server').option('--auto-accept-peers <dids>', 'comma-separated DIDs to auto-accept for peer join flows').option('--db <path>', 'path to the local SQLite database').option('--id <id>', 'server ID').option('--label <label>', 'display name advertised to co-members (default: the host name)').option('--no-http', 'run as a hub-only peer, binding no HTTP listener (requires --p2p)').option('--p2p', 'enable P2P mode with sync and graph protocols');
16
10
  withAllowedOrigin(cmd);
17
11
  withLogLevel(cmd);
18
12
  withPort(cmd);
@@ -24,6 +18,9 @@ export function createServeCommand() {
24
18
  if (opts.autoAcceptPeers != null && !opts.p2p) {
25
19
  throw new Error('--auto-accept-peers requires --p2p');
26
20
  }
21
+ if (!opts.http && !opts.p2p) {
22
+ throw new Error('--no-http requires --p2p: without either there is nothing to reach');
23
+ }
27
24
  configureSync({
28
25
  reset: true,
29
26
  sinks: {
@@ -53,56 +50,29 @@ export function createServeCommand() {
53
50
  });
54
51
  const logger = getKubunLogger('cli');
55
52
  const identity = resolveIdentity(opts, logger);
56
- const adapter = opts.db == null || opts.db === ':memory:' ? new NodeSQLiteAdapter({
57
- database: ':memory:'
58
- }) : opts.db.startsWith('postgres://') || opts.db.startsWith('postgresql://') ? new PostgresAdapter({
59
- url: opts.db
60
- }) : new NodeSQLiteAdapter({
61
- database: resolvePath(opts.db)
62
- });
63
53
  const autoAcceptPeers = opts.autoAcceptPeers ? opts.autoAcceptPeers.split(',').map((s)=>s.trim()) : undefined;
64
- const rpcAccessRules = autoAcceptPeers != null ? {
65
- '*': {
66
- allow: [
67
- identity.id,
68
- ...autoAcceptPeers
69
- ]
70
- }
71
- } : undefined;
72
54
  const port = opts.port == null ? undefined : typeof opts.port === 'number' ? opts.port : Number.parseInt(opts.port, 10);
73
- const plugins = [
74
- createRPCPlugin({
75
- ...rpcAccessRules != null ? {
76
- accessRules: rpcAccessRules
55
+ const { engine, url } = await withSpinner('Starting the server...', async ()=>{
56
+ return await startNode({
57
+ identity,
58
+ adapter: createAdapter(opts.db),
59
+ ...opts.label != null ? {
60
+ label: opts.label
77
61
  } : undefined,
78
- allowDelegatedMutations: opts.p2p
79
- }),
80
- createHTTPPlugin({
81
- port,
82
- allowedOrigin: opts.allowedOrigin
83
- })
84
- ];
85
- if (opts.p2p) {
86
- plugins.push(createP2PPlugin({
87
- http: true,
88
- autoAcceptPeers
89
- }));
90
- }
91
- const engine = new KubunEngine({
92
- db: adapter,
93
- identity,
94
- plugins
95
- });
96
- const http = await withSpinner('Starting the server...', async ()=>{
97
- const api = await engine.getAPI('http');
98
- await api.listening;
99
- if (opts.p2p) {
100
- const p2p = await engine.getAPI('p2p');
101
- await p2p.syncReady;
102
- }
103
- return api;
62
+ ...opts.http ? {
63
+ http: {
64
+ port,
65
+ allowedOrigin: opts.allowedOrigin
66
+ }
67
+ } : undefined,
68
+ ...opts.p2p ? {
69
+ p2p: autoAcceptPeers == null ? {} : {
70
+ autoAcceptPeers
71
+ }
72
+ } : undefined
73
+ });
104
74
  });
105
- renderNotice('success', `HTTP server listening at ${http.getURL()}\n DID: ${identity.id}`);
75
+ renderNotice('success', url == null ? `Hub-only peer running, no HTTP listener\n DID: ${identity.id}` : `HTTP server listening at ${url}\n DID: ${identity.id}`);
106
76
  if (opts.p2p && autoAcceptPeers) {
107
77
  process.stderr.write(` Auto-accept peers: ${autoAcceptPeers.length} DID(s)\n`);
108
78
  }
@@ -0,0 +1,74 @@
1
+ import type { Identity, OwnIdentity } from '@kokuin/token';
2
+ import { KubunDB } from '@kubun/db';
3
+ import { NodeSQLiteAdapter } from '@kubun/db-node-sqlite';
4
+ import { PostgresAdapter } from '@kubun/db-postgres';
5
+ import { KubunEngine } from '@kubun/engine';
6
+ /**
7
+ * The graph a peer device serves its own p2p surface from.
8
+ *
9
+ * Deployed with no clusters: a headless peer learns a model from whichever
10
+ * device already has it (the negotiate response carries the cluster), so
11
+ * requiring the operator to supply model definitions up front would be asking
12
+ * for something sync provides.
13
+ */
14
+ export declare const PEER_GRAPH_ID = "kubun-peer";
15
+ /** The adapters a CLI device can run on, kept concrete so `@kubun/db-adapter` stays out of the manifest. */
16
+ export type PeerAdapter = NodeSQLiteAdapter | PostgresAdapter;
17
+ export declare function createAdapter(db?: string): PeerAdapter;
18
+ export type BuildEngineParams = {
19
+ identity: Identity | OwnIdentity;
20
+ adapter: PeerAdapter;
21
+ /** Omit to bind no HTTP listener at all — the hub-only participant. */
22
+ http?: {
23
+ port?: number;
24
+ allowedOrigin?: string;
25
+ };
26
+ p2p?: {
27
+ autoAcceptPeers?: Array<string>;
28
+ };
29
+ };
30
+ /**
31
+ * The engine every command builds, with or without an HTTP listener.
32
+ *
33
+ * With `http` omitted the p2p plugin also drops its HTTP sync transport, so the
34
+ * device is reachable only through a group's hub tunnel. That is the whole
35
+ * difference between the two topologies: hub relay and tunnel dialling are
36
+ * always wired, and the hub client factory defaults to HTTP on its own.
37
+ */
38
+ export declare function buildEngine(params: BuildEngineParams): {
39
+ engine: KubunEngine;
40
+ db: KubunDB;
41
+ };
42
+ /** Deploy the peer graph, which is what carries the p2p mutations. */
43
+ export declare function deployPeerGraph(engine: KubunEngine): Promise<string>;
44
+ /**
45
+ * Advertise this device to its co-members.
46
+ *
47
+ * Presence is opt-in — a device with no local profile publishes nothing and
48
+ * never appears in another device's projection, so without this call a headless
49
+ * peer is running and unselectable.
50
+ */
51
+ export declare function announceSelf(engine: KubunEngine, params: {
52
+ label: string;
53
+ availability: 'always-on' | 'interactive' | 'mobile';
54
+ }): Promise<void>;
55
+ export type StartNodeParams = BuildEngineParams & {
56
+ label?: string;
57
+ };
58
+ export type StartedNode = {
59
+ engine: KubunEngine;
60
+ /** The device's stores, so a caller can read what the engine wrote. */
61
+ db: KubunDB;
62
+ /** The peer graph's id, or null when p2p is off and none was deployed. */
63
+ deployID: string | null;
64
+ /** Null for a hub-only peer, which binds no listener. */
65
+ url: string | null;
66
+ };
67
+ /**
68
+ * Bring a device up: the engine, its peer graph, and its advertisement.
69
+ *
70
+ * `serve` is a thin wrapper over this so a test can start the same device
71
+ * without a process to signal — the alternative is a test that re-types the
72
+ * startup and then agrees with itself.
73
+ */
74
+ export declare function startNode(params: StartNodeParams): Promise<StartedNode>;
package/lib/engine.js ADDED
@@ -0,0 +1,154 @@
1
+ import { hostname } from 'node:os';
2
+ import { KubunDB } from '@kubun/db';
3
+ import { NodeSQLiteAdapter } from '@kubun/db-node-sqlite';
4
+ import { PostgresAdapter } from '@kubun/db-postgres';
5
+ import { KubunEngine } from '@kubun/engine';
6
+ import { createHTTPPlugin } from '@kubun/plugin-http';
7
+ import { createP2PPlugin, MERKLE_SYNC_PROTOCOL } from '@kubun/plugin-p2p';
8
+ import { createRPCPlugin } from '@kubun/plugin-rpc';
9
+ import { resolvePath } from './fs.js';
10
+ /**
11
+ * The graph a peer device serves its own p2p surface from.
12
+ *
13
+ * Deployed with no clusters: a headless peer learns a model from whichever
14
+ * device already has it (the negotiate response carries the cluster), so
15
+ * requiring the operator to supply model definitions up front would be asking
16
+ * for something sync provides.
17
+ */ export const PEER_GRAPH_ID = 'kubun-peer';
18
+ export function createAdapter(db) {
19
+ if (db == null || db === ':memory:') {
20
+ return new NodeSQLiteAdapter({
21
+ database: ':memory:'
22
+ });
23
+ }
24
+ if (db.startsWith('postgres://') || db.startsWith('postgresql://')) {
25
+ return new PostgresAdapter({
26
+ url: db
27
+ });
28
+ }
29
+ return new NodeSQLiteAdapter({
30
+ database: resolvePath(db)
31
+ });
32
+ }
33
+ /**
34
+ * The engine every command builds, with or without an HTTP listener.
35
+ *
36
+ * With `http` omitted the p2p plugin also drops its HTTP sync transport, so the
37
+ * device is reachable only through a group's hub tunnel. That is the whole
38
+ * difference between the two topologies: hub relay and tunnel dialling are
39
+ * always wired, and the hub client factory defaults to HTTP on its own.
40
+ */ export function buildEngine(params) {
41
+ const { adapter, identity, http, p2p } = params;
42
+ const plugins = [
43
+ createRPCPlugin({
44
+ ...p2p?.autoAcceptPeers != null ? {
45
+ accessRules: {
46
+ '*': {
47
+ allow: [
48
+ identity.id,
49
+ ...p2p.autoAcceptPeers
50
+ ]
51
+ }
52
+ }
53
+ } : undefined,
54
+ allowDelegatedMutations: p2p != null
55
+ })
56
+ ];
57
+ if (http != null) {
58
+ plugins.push(createHTTPPlugin({
59
+ port: http.port,
60
+ allowedOrigin: http.allowedOrigin
61
+ }));
62
+ }
63
+ if (p2p != null) {
64
+ plugins.push(createP2PPlugin({
65
+ ...http != null ? {
66
+ http: true
67
+ } : undefined,
68
+ ...p2p.autoAcceptPeers != null ? {
69
+ autoAcceptPeers: p2p.autoAcceptPeers
70
+ } : undefined
71
+ }));
72
+ }
73
+ const db = new KubunDB({
74
+ adapter
75
+ });
76
+ return {
77
+ engine: new KubunEngine({
78
+ db,
79
+ identity,
80
+ plugins
81
+ }),
82
+ db
83
+ };
84
+ }
85
+ /** Deploy the peer graph, which is what carries the p2p mutations. */ export async function deployPeerGraph(engine) {
86
+ const deployed = await engine.deployGraph({
87
+ id: PEER_GRAPH_ID,
88
+ name: 'Kubun peer',
89
+ clusters: [],
90
+ plugins: {
91
+ p2p: {}
92
+ }
93
+ });
94
+ return deployed.id;
95
+ }
96
+ /**
97
+ * Advertise this device to its co-members.
98
+ *
99
+ * Presence is opt-in — a device with no local profile publishes nothing and
100
+ * never appears in another device's projection, so without this call a headless
101
+ * peer is running and unselectable.
102
+ */ export async function announceSelf(engine, params) {
103
+ const p2p = await engine.getAPI('p2p');
104
+ await p2p.setLocalPeerProfile({
105
+ label: params.label,
106
+ availability: params.availability,
107
+ capabilities: [
108
+ {
109
+ protocol: MERKLE_SYNC_PROTOCOL,
110
+ version: 1,
111
+ transports: null
112
+ }
113
+ ]
114
+ });
115
+ }
116
+ /**
117
+ * Bring a device up: the engine, its peer graph, and its advertisement.
118
+ *
119
+ * `serve` is a thin wrapper over this so a test can start the same device
120
+ * without a process to signal — the alternative is a test that re-types the
121
+ * startup and then agrees with itself.
122
+ */ export async function startNode(params) {
123
+ const { engine, db } = buildEngine(params);
124
+ let deployID = null;
125
+ if (params.p2p != null) {
126
+ const p2p = await engine.getAPI('p2p');
127
+ await p2p.syncReady;
128
+ // The p2p mutations live on a deployed graph, and a hub-only peer has no
129
+ // client to deploy one for it.
130
+ deployID = await deployPeerGraph(engine);
131
+ await announceSelf(engine, {
132
+ label: params.label ?? hostname(),
133
+ // A process meant to be left running is exactly what selection should
134
+ // prefer, and this is the only place that claim can be made honestly.
135
+ availability: params.http == null ? 'always-on' : 'interactive'
136
+ });
137
+ }
138
+ if (params.http == null) {
139
+ return {
140
+ engine,
141
+ db,
142
+ deployID,
143
+ url: null
144
+ };
145
+ }
146
+ const api = await engine.getAPI('http');
147
+ await api.listening;
148
+ return {
149
+ engine,
150
+ db,
151
+ deployID,
152
+ url: api.getURL()
153
+ };
154
+ }
package/lib/hub.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { type HubAuthConfig, type Relay, type RelayObservers } from '@kubun/hub';
2
+ export type StartHubParams = {
3
+ /**
4
+ * Who may use the relay. Required: an unset `auth` would leave `createRelay`
5
+ * with no access rules at all, which refuses every DID without saying so.
6
+ */
7
+ auth: HubAuthConfig;
8
+ /** SQLite path, `:memory:`, or a postgres URL. Defaults to `:memory:`. */
9
+ db?: string;
10
+ /**
11
+ * Base64 private key for the hub's signing identity. Absent generates an
12
+ * ephemeral one — every client holding the old DID then has to rediscover it
13
+ * from `/info`, which is why `resolveIdentity` warns about it.
14
+ */
15
+ privateKey?: string;
16
+ allowedOrigin?: string;
17
+ /** Bind port. Absent probes for a free one, preferring {@link DEFAULT_HUB_PORT}. */
18
+ port?: number;
19
+ /** Relay diagnostics — auth refusals, stores, fetches. */
20
+ observers?: RelayObservers;
21
+ };
22
+ export type StartedHub = {
23
+ /** The relay endpoint a device binds a group to. */
24
+ url: string;
25
+ /** The hub's DID — the audience of every token it accepts, served at `/info`. */
26
+ did: string;
27
+ relay: Relay;
28
+ dispose: () => Promise<void>;
29
+ };
30
+ /**
31
+ * Serve a blind relay hub over HTTP.
32
+ *
33
+ * The hub holds no group state and is a member of nothing: it stores encrypted
34
+ * envelopes and serves topic subscriptions for groups it cannot read. Its only
35
+ * authentication is `auth`, checked against the issuer of each signed Enkaku
36
+ * envelope — there is nothing to check at the HTTP layer.
37
+ */
38
+ export declare function startHub(params: StartHubParams): Promise<StartedHub>;
package/lib/hub.js ADDED
@@ -0,0 +1,109 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { NodeSQLiteAdapter } from '@kubun/db-node-sqlite';
3
+ import { PostgresAdapter } from '@kubun/db-postgres';
4
+ import { createRelay } from '@kubun/hub';
5
+ import { getKubunLogger } from '@kubun/logger';
6
+ import { getPort } from '@tejika/env';
7
+ import { resolvePath } from './fs.js';
8
+ import { resolveIdentity } from './identity.js';
9
+ /**
10
+ * Where the relay's Enkaku transport is mounted. The relay is path-agnostic —
11
+ * it is one transport behind a `fetch` — so this prefix is ours to pick, and it
12
+ * is the URL a device binds a group to.
13
+ */ const RELAY_PATH = '/relay';
14
+ /**
15
+ * The name `/info` advertises the relay mount under, mirroring how a device's
16
+ * HTTP surface advertises its own protocols. A device binds the mount path
17
+ * directly, so this is discovery for a reader that has only the origin.
18
+ */ const RELAY_PROTOCOL_NAME = 'relay';
19
+ /** Preferred port when none is given; a taken one yields another free port. */ const DEFAULT_HUB_PORT = 4321;
20
+ /**
21
+ * Serve a blind relay hub over HTTP.
22
+ *
23
+ * The hub holds no group state and is a member of nothing: it stores encrypted
24
+ * envelopes and serves topic subscriptions for groups it cannot read. Its only
25
+ * authentication is `auth`, checked against the issuer of each signed Enkaku
26
+ * envelope — there is nothing to check at the HTTP layer.
27
+ */ export async function startHub(params) {
28
+ const logger = getKubunLogger('cli');
29
+ // No `--id` counterpart here: the relay signs its own response tokens, so an
30
+ // identity that cannot sign is not a hub identity.
31
+ const identity = resolveIdentity({
32
+ privateKey: params.privateKey
33
+ }, logger);
34
+ const adapter = params.db == null || params.db === ':memory:' ? new NodeSQLiteAdapter({
35
+ database: ':memory:'
36
+ }) : params.db.startsWith('postgres://') || params.db.startsWith('postgresql://') ? new PostgresAdapter({
37
+ url: params.db
38
+ }) : new NodeSQLiteAdapter({
39
+ database: resolvePath(params.db)
40
+ });
41
+ const relay = await createRelay({
42
+ db: adapter,
43
+ identity,
44
+ auth: params.auth,
45
+ retention: {
46
+ olderThanSeconds: 30 * 24 * 60 * 60
47
+ },
48
+ ...params.allowedOrigin == null ? {} : {
49
+ allowedOrigin: params.allowedOrigin
50
+ },
51
+ ...params.observers == null ? {} : {
52
+ observers: params.observers
53
+ }
54
+ });
55
+ // OPTIONS is served alongside POST: the relay answers the CORS preflight
56
+ // itself, and a POST-only mount leaves a browser client unable to reach it.
57
+ const handler = async (request)=>{
58
+ const { pathname } = new URL(request.url);
59
+ if (pathname === RELAY_PATH || pathname.startsWith(`${RELAY_PATH}/`)) {
60
+ return request.method === 'POST' || request.method === 'OPTIONS' ? await relay.fetch(request) : new Response(null, {
61
+ status: 405
62
+ });
63
+ }
64
+ if (request.method === 'GET' && pathname === '/health') {
65
+ return Response.json({
66
+ status: 'ok'
67
+ });
68
+ }
69
+ // Discovery, in the shape a device already reads from another device's
70
+ // `/info`: the DID every token addressed to this hub must name, plus where
71
+ // the relay is mounted. A hub advertising a DID it cannot sign with is
72
+ // simply unreachable — its own server refuses the audience — so nothing
73
+ // here has to be verified by the reader.
74
+ if (request.method === 'GET' && pathname === '/info') {
75
+ return Response.json({
76
+ did: identity.id,
77
+ protocols: {
78
+ [RELAY_PROTOCOL_NAME]: RELAY_PATH
79
+ }
80
+ });
81
+ }
82
+ return new Response(null, {
83
+ status: 404
84
+ });
85
+ };
86
+ const port = params.port ?? await getPort('kubun', {
87
+ default: DEFAULT_HUB_PORT
88
+ });
89
+ const httpServer = await new Promise((resolve)=>{
90
+ const server = serve({
91
+ fetch: handler,
92
+ port
93
+ }, ()=>resolve(server));
94
+ });
95
+ return {
96
+ url: `http://localhost:${port}${RELAY_PATH}`,
97
+ did: identity.id,
98
+ relay,
99
+ dispose: async ()=>{
100
+ await new Promise((resolve, reject)=>{
101
+ httpServer.close((error)=>{
102
+ if (error == null) resolve();
103
+ else reject(error);
104
+ });
105
+ });
106
+ await relay.dispose();
107
+ }
108
+ };
109
+ }
package/lib/identity.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import type { Identity, OwnIdentity } from '@kokuin/token';
1
+ import type { DIDString, Identity, OwnIdentity } from '@kokuin/token';
2
2
  type Logger = {
3
3
  warn: (message: string, properties?: Record<string, unknown>) => void;
4
4
  };
5
5
  export type IdentityOptions = {
6
6
  privateKey?: string;
7
- id?: string;
7
+ id?: DIDString;
8
8
  p2p?: boolean;
9
9
  };
10
10
  /**
package/lib/program.js CHANGED
@@ -5,8 +5,10 @@ import { buildProgram as tejikaBuildProgram } from '@tejika/cli';
5
5
  import { createAccountCommand } from './commands/account.js';
6
6
  import { createGraphCommand } from './commands/graph.js';
7
7
  import { createGraphQLCommand } from './commands/graphql.js';
8
+ import { createHubCommand } from './commands/hub.js';
8
9
  import { createMCPCommand } from './commands/mcp.js';
9
10
  import { createModelCommand } from './commands/model.js';
11
+ import { createPeerCommand } from './commands/peer.js';
10
12
  import { createServeCommand } from './commands/serve.js';
11
13
  const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json');
12
14
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
@@ -18,8 +20,10 @@ export function buildProgram() {
18
20
  createAccountCommand(),
19
21
  createGraphCommand(),
20
22
  createGraphQLCommand(),
23
+ createHubCommand(),
21
24
  createModelCommand(),
22
25
  createMCPCommand(),
26
+ createPeerCommand(),
23
27
  createServeCommand()
24
28
  ]
25
29
  });
package/lib/ui.js CHANGED
@@ -8,7 +8,13 @@ const renderToStderr = {
8
8
  const { unmount } = render(/*#__PURE__*/ _jsx(SystemNotice, {
9
9
  variant: variant,
10
10
  text: text
11
- }), renderToStderr);
11
+ }), {
12
+ ...renderToStderr,
13
+ // Nothing to protect from interleaved logs: this mounts and unmounts in one
14
+ // turn. Patching the global console for that costs a process-wide side
15
+ // effect, and throws outright wherever `console` is already a stand-in.
16
+ patchConsole: false
17
+ });
12
18
  unmount();
13
19
  }
14
20
  /** Show a live spinner on stderr for the duration of `fn`, then unmount. */ export async function withSpinner(label, fn) {
package/package.json CHANGED
@@ -1,59 +1,68 @@
1
1
  {
2
2
  "name": "kubun",
3
- "version": "0.11.0",
4
- "license": "see LICENSE.md",
3
+ "version": "0.12.1",
5
4
  "description": "Kubun CLI",
6
5
  "keywords": [],
6
+ "license": "see LICENSE.md",
7
7
  "type": "module",
8
- "engines": {
9
- "node": ">=22.0.0"
10
- },
11
8
  "main": "lib/index.js",
12
9
  "types": "lib/index.d.ts",
10
+ "bin": {
11
+ "kubun": "./bin/run.js"
12
+ },
13
13
  "files": [
14
14
  "/bin",
15
15
  "/lib"
16
16
  ],
17
- "bin": {
18
- "kubun": "./bin/run.js"
19
- },
20
17
  "dependencies": {
21
- "@kokuin/token": "^0.1.1",
22
- "@logtape/logtape": "^2.2.3",
23
- "@mokei/context-server": "^0.8.0",
24
- "@sozai/codec": "^0.1.0",
25
- "@tejika/cli": "^0.1.0",
26
- "@tejika/env": "^0.1.0",
27
- "@tejika/ui": "^0.1.0",
18
+ "@hono/node-server": "^2.1.0",
19
+ "@kokuin/token": "^0.4.0",
20
+ "@logtape/logtape": "^2.3.0",
21
+ "@mokei/context-server": "^0.12.0",
22
+ "@sozai/codec": "^0.4.0",
23
+ "@tejika/cli": "^0.4.1",
24
+ "@tejika/env": "^0.5.0",
25
+ "@tejika/ui": "^0.4.1",
28
26
  "commander": "^15.0.0",
29
27
  "graphql": "^16.14.2",
30
- "ink": "^7.1.0",
28
+ "ink": "^7.1.1",
31
29
  "react": "19.2.3",
32
- "@kubun/client": "^0.11.0",
33
- "@kubun/engine": "^0.11.0",
34
- "@kubun/db-node-sqlite": "^0.11.0",
35
- "@kubun/http-client": "^0.11.0",
36
- "@kubun/mcp": "^0.11.0",
37
- "@kubun/graphql": "^0.11.0",
38
- "@kubun/db-postgres": "^0.11.0",
39
- "@kubun/logger": "^0.11.0",
40
- "@kubun/plugin-p2p": "^0.11.0",
41
- "@kubun/plugin-rpc": "^0.11.0",
42
- "@kubun/plugin-http": "^0.11.0",
43
- "@kubun/protocol": "^0.11.0"
30
+ "@kubun/client": "^0.12.1",
31
+ "@kubun/db-postgres": "^0.12.1",
32
+ "@kubun/db": "^0.12.1",
33
+ "@kubun/db-node-sqlite": "^0.12.1",
34
+ "@kubun/engine": "^0.12.1",
35
+ "@kubun/graphql": "^0.13.0",
36
+ "@kubun/http-client": "^0.12.1",
37
+ "@kubun/logger": "^0.12.0",
38
+ "@kubun/mcp": "^0.12.1",
39
+ "@kubun/plugin-http": "^0.12.1",
40
+ "@kubun/hub": "^0.12.1",
41
+ "@kubun/plugin-p2p": "^0.12.1",
42
+ "@kubun/protocol": "^0.13.0",
43
+ "@kubun/plugin-rpc": "^0.12.1"
44
44
  },
45
45
  "devDependencies": {
46
- "ink-testing-library": "^4.0.0"
46
+ "ink-testing-library": "^4.0.0",
47
+ "@kubun/id": "^0.12.0",
48
+ "@kubun/store-graph": "^0.13.0",
49
+ "@kubun/store-p2p": "^0.12.1"
50
+ },
51
+ "engines": {
52
+ "node": ">=22.0.0"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
47
56
  },
48
57
  "scripts": {
49
- "kubun": "./bin/dev.js",
58
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
50
59
  "build:clean": "del lib",
51
60
  "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
52
61
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
53
62
  "build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck --declarationMap false",
54
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
55
- "test:types": "tsc --noEmit --skipLibCheck",
56
- "test:unit": "vitest run",
57
- "test": "pnpm run test:types && pnpm run test:unit"
63
+ "kubun": "./bin/dev.js",
64
+ "test": "pnpm run test:types && pnpm run test:unit",
65
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
66
+ "test:unit": "vitest run"
58
67
  }
59
68
  }