kubun 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.
- package/README.md +22 -234
- package/bin/dev.js +12 -4
- package/bin/run.js +11 -2
- package/lib/account.d.ts +2 -0
- package/lib/account.js +5 -0
- package/lib/client.d.ts +2 -0
- package/{dist → lib}/client.js +4 -1
- package/lib/commands/account.d.ts +2 -0
- package/lib/commands/account.js +24 -0
- package/lib/commands/graph.d.ts +2 -0
- package/lib/commands/graph.js +76 -0
- package/lib/commands/graphql.d.ts +2 -0
- package/lib/commands/graphql.js +33 -0
- package/lib/commands/hub.d.ts +2 -0
- package/lib/commands/hub.js +105 -0
- package/lib/commands/mcp.d.ts +2 -0
- package/lib/commands/mcp.js +21 -0
- package/lib/commands/model.d.ts +2 -0
- package/lib/commands/model.js +71 -0
- package/lib/commands/peer.d.ts +2 -0
- package/lib/commands/peer.js +275 -0
- package/lib/commands/serve.d.ts +2 -0
- package/lib/commands/serve.js +86 -0
- package/lib/engine.d.ts +74 -0
- package/lib/engine.js +154 -0
- package/lib/fs.d.ts +4 -0
- package/{dist → lib}/fs.js +6 -2
- package/lib/hub.d.ts +38 -0
- package/lib/hub.js +109 -0
- package/lib/identity.d.ts +19 -0
- package/lib/identity.js +31 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +19 -0
- package/lib/options.d.ts +19 -0
- package/lib/options.js +47 -0
- package/lib/program.d.ts +2 -0
- package/lib/program.js +32 -0
- package/lib/ui.d.ts +5 -0
- package/lib/ui.js +29 -0
- package/package.json +47 -50
- package/bin/dev.cmd +0 -3
- package/bin/run.cmd +0 -3
- package/dist/account.js +0 -5
- package/dist/commands/account/generate.js +0 -10
- package/dist/commands/account/id.js +0 -18
- package/dist/commands/graph/deploy.js +0 -32
- package/dist/commands/graph/mutate.js +0 -32
- package/dist/commands/graph/query.js +0 -32
- package/dist/commands/graphql/schema.js +0 -41
- package/dist/commands/mcp.js +0 -33
- package/dist/commands/model/cluster.js +0 -35
- package/dist/commands/model/create.js +0 -69
- package/dist/commands/serve.js +0 -126
- package/dist/index.js +0 -1
- package/oclif.manifest.json +0 -533
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ClusterBuilder } from '@kubun/protocol';
|
|
2
|
+
import { Command, Option } from 'commander';
|
|
3
|
+
import { writeJSON } from '../fs.js';
|
|
4
|
+
import { renderNotice } from '../ui.js';
|
|
5
|
+
function parseJSON(label, value) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(value);
|
|
8
|
+
} catch (cause) {
|
|
9
|
+
throw new Error(`${label} is not valid JSON: ${value}`, {
|
|
10
|
+
cause
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function createClusterCommand() {
|
|
15
|
+
const cmd = new Command('cluster').description('Create a documents cluster model').requiredOption('-m, --model <json...>', 'document model as JSON string (repeatable)').option('-o, --output <file>', 'output file for the cluster');
|
|
16
|
+
cmd.action(async (opts)=>{
|
|
17
|
+
const builder = new ClusterBuilder();
|
|
18
|
+
builder.addAll(opts.model.map((model)=>parseJSON('--model', model)));
|
|
19
|
+
const cluster = builder.build();
|
|
20
|
+
if (opts.output == null) {
|
|
21
|
+
renderNotice('success', 'Cluster model created');
|
|
22
|
+
console.log(JSON.stringify(cluster));
|
|
23
|
+
} else {
|
|
24
|
+
await writeJSON(opts.output, cluster);
|
|
25
|
+
renderNotice('success', `Cluster model written to ${opts.output}`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return cmd;
|
|
29
|
+
}
|
|
30
|
+
function createCreateCommand() {
|
|
31
|
+
const cmd = new Command('create').description('Create a document model').argument('<name>', 'document model name').argument('<schema>', 'document schema as JSON string').addOption(new Option('-b, --behavior <behavior>', 'behavior of the document').choices([
|
|
32
|
+
'default',
|
|
33
|
+
'interface',
|
|
34
|
+
'unique'
|
|
35
|
+
]).default('default')).option('-c, --cluster', 'create a cluster model instead of a document model').option('-o, --output <file>', 'output file').option('-u, --unique-field <field...>', 'unique field of the document when behavior is "unique" (repeatable)');
|
|
36
|
+
cmd.action(async (name, schema, opts)=>{
|
|
37
|
+
const model = {
|
|
38
|
+
name,
|
|
39
|
+
behavior: opts.behavior,
|
|
40
|
+
schema: parseJSON('<schema>', schema)
|
|
41
|
+
};
|
|
42
|
+
if (opts.behavior === 'unique') {
|
|
43
|
+
model.uniqueFields = opts.uniqueField;
|
|
44
|
+
}
|
|
45
|
+
const builder = new ClusterBuilder();
|
|
46
|
+
builder.add(model);
|
|
47
|
+
if (opts.cluster) {
|
|
48
|
+
const cluster = builder.build();
|
|
49
|
+
if (opts.output == null) {
|
|
50
|
+
renderNotice('success', 'Cluster model created');
|
|
51
|
+
console.log(JSON.stringify(cluster));
|
|
52
|
+
} else {
|
|
53
|
+
await writeJSON(opts.output, cluster);
|
|
54
|
+
renderNotice('success', `Cluster model written to ${opts.output}`);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const document = builder.cluster[0];
|
|
59
|
+
if (opts.output == null) {
|
|
60
|
+
renderNotice('success', 'Document model created');
|
|
61
|
+
console.log(JSON.stringify(document));
|
|
62
|
+
} else {
|
|
63
|
+
await writeJSON(opts.output, document);
|
|
64
|
+
renderNotice('success', `Document model written to ${opts.output}`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return cmd;
|
|
68
|
+
}
|
|
69
|
+
export function createModelCommand() {
|
|
70
|
+
return new Command('model').description('Model commands').addCommand(createClusterCommand()).addCommand(createCreateCommand());
|
|
71
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getKubunLogger } from '@kubun/logger';
|
|
2
|
+
import { configureSync, getConsoleSink } from '@logtape/logtape';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { createAdapter, startNode } from '../engine.js';
|
|
5
|
+
import { resolveIdentity } from '../identity.js';
|
|
6
|
+
import { withAllowedOrigin, withLogLevel, withPort, withPrivateKey } from '../options.js';
|
|
7
|
+
import { renderNotice, withSpinner } from '../ui.js';
|
|
8
|
+
export function createServeCommand() {
|
|
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');
|
|
10
|
+
withAllowedOrigin(cmd);
|
|
11
|
+
withLogLevel(cmd);
|
|
12
|
+
withPort(cmd);
|
|
13
|
+
withPrivateKey(cmd);
|
|
14
|
+
cmd.action(async (opts)=>{
|
|
15
|
+
if (opts.id != null && opts.privateKey != null) {
|
|
16
|
+
throw new Error('--id and --private-key are mutually exclusive');
|
|
17
|
+
}
|
|
18
|
+
if (opts.autoAcceptPeers != null && !opts.p2p) {
|
|
19
|
+
throw new Error('--auto-accept-peers requires --p2p');
|
|
20
|
+
}
|
|
21
|
+
if (!opts.http && !opts.p2p) {
|
|
22
|
+
throw new Error('--no-http requires --p2p: without either there is nothing to reach');
|
|
23
|
+
}
|
|
24
|
+
configureSync({
|
|
25
|
+
reset: true,
|
|
26
|
+
sinks: {
|
|
27
|
+
console: getConsoleSink()
|
|
28
|
+
},
|
|
29
|
+
loggers: [
|
|
30
|
+
{
|
|
31
|
+
category: [
|
|
32
|
+
'logtape',
|
|
33
|
+
'meta'
|
|
34
|
+
],
|
|
35
|
+
lowestLevel: 'error',
|
|
36
|
+
sinks: [
|
|
37
|
+
'console'
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
category: [
|
|
42
|
+
'kubun'
|
|
43
|
+
],
|
|
44
|
+
lowestLevel: opts.logLevel ?? 'warning',
|
|
45
|
+
sinks: [
|
|
46
|
+
'console'
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
});
|
|
51
|
+
const logger = getKubunLogger('cli');
|
|
52
|
+
const identity = resolveIdentity(opts, logger);
|
|
53
|
+
const autoAcceptPeers = opts.autoAcceptPeers ? opts.autoAcceptPeers.split(',').map((s)=>s.trim()) : undefined;
|
|
54
|
+
const port = opts.port == null ? undefined : typeof opts.port === 'number' ? opts.port : Number.parseInt(opts.port, 10);
|
|
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
|
|
61
|
+
} : undefined,
|
|
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
|
+
});
|
|
74
|
+
});
|
|
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}`);
|
|
76
|
+
if (opts.p2p && autoAcceptPeers) {
|
|
77
|
+
process.stderr.write(` Auto-accept peers: ${autoAcceptPeers.length} DID(s)\n`);
|
|
78
|
+
}
|
|
79
|
+
await new Promise((resolve)=>{
|
|
80
|
+
process.once('SIGINT', resolve);
|
|
81
|
+
process.once('SIGTERM', resolve);
|
|
82
|
+
});
|
|
83
|
+
await engine.dispose();
|
|
84
|
+
});
|
|
85
|
+
return cmd;
|
|
86
|
+
}
|
package/lib/engine.d.ts
ADDED
|
@@ -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/fs.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function resolvePath(value: string): string;
|
|
2
|
+
export declare function readJSON<T = unknown>(path: string): Promise<T>;
|
|
3
|
+
export declare function writeFile(path: string, value: string): Promise<void>;
|
|
4
|
+
export declare function writeJSON(path: string, value: unknown, format?: boolean): Promise<void>;
|