syndes 0.1.0 → 0.3.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 +53 -0
- package/analytics/metrics/rework.mjs +8 -2
- package/analytics/metrics/volume.mjs +19 -6
- package/analytics/team.mjs +245 -0
- package/bin/cli.mjs +413 -1
- package/collect/projects.mjs +18 -2
- package/dashboard/api/team.mjs +248 -0
- package/dashboard/router.mjs +7 -0
- package/dashboard/server.mjs +11 -2
- package/dashboard/web/api.js +8 -0
- package/dashboard/web/app.css +45 -1
- package/dashboard/web/app.js +1 -0
- package/dashboard/web/charts.js +191 -0
- package/dashboard/web/ui.js +3 -1
- package/dashboard/web/views/team.js +496 -0
- package/package.json +8 -4
- package/runtime/config.mjs +17 -0
- package/runtime/identity.mjs +96 -0
- package/runtime/paths.mjs +12 -0
- package/runtime/worker.mjs +35 -1
- package/src/briefing.mjs +14 -1
- package/src/doctor.mjs +30 -0
- package/src/preflight.mjs +174 -0
- package/src/term.mjs +42 -3
- package/sync/index.mjs +336 -0
- package/sync/invite.mjs +70 -0
- package/sync/run-sync.mjs +21 -0
- package/sync/share.mjs +143 -0
- package/sync/transport.mjs +203 -0
package/sync/index.mjs
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pool: publish our shelf, read everyone else's.
|
|
3
|
+
*
|
|
4
|
+
* This module is the whole of "sharing", and it is deliberately small because
|
|
5
|
+
* the hard parts were designed away rather than solved:
|
|
6
|
+
*
|
|
7
|
+
* • Nothing is merged. Peer data is never appended to our chain — it is read
|
|
8
|
+
* from the pool and displayed beside ours. The ledger keeps exactly one
|
|
9
|
+
* writer, forever, which is the invariant everything else here rests on.
|
|
10
|
+
* • Nothing conflicts. A device writes only its own shelf (see transport.mjs).
|
|
11
|
+
* • Nothing sensitive travels. The unit of sharing is a sanitised rollup, not
|
|
12
|
+
* a record (see share.mjs).
|
|
13
|
+
*
|
|
14
|
+
* A pull failure is not an error state. The pool is a cache of other people's
|
|
15
|
+
* numbers; when it cannot be reached, the last copy on disk is still true as of
|
|
16
|
+
* its own timestamp, and the UI says how old it is rather than going blank.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { teamDir, teamDeviceDir, teamStateFile, rollupFile, packageRoot } from '../runtime/paths.mjs';
|
|
23
|
+
import { loadConfig, rawConfig, saveConfig, setPath, readJson } from '../runtime/config.mjs';
|
|
24
|
+
import { identity } from '../runtime/identity.mjs';
|
|
25
|
+
import { loadRollup, coveredDays } from '../analytics/rollup.mjs';
|
|
26
|
+
import { localDay, shiftDay } from '../analytics/ranges.mjs';
|
|
27
|
+
import { sanitise, manifestFor } from './share.mjs';
|
|
28
|
+
import { encode as encodeInvite } from './invite.mjs';
|
|
29
|
+
import {
|
|
30
|
+
transportFor, listDevices, readJsonFile, writeJsonFile, removeFile, git,
|
|
31
|
+
} from './transport.mjs';
|
|
32
|
+
import { rmSync } from 'node:fs';
|
|
33
|
+
import { writeFileSync, renameSync } from 'node:fs';
|
|
34
|
+
import { dirname } from 'node:path';
|
|
35
|
+
import { debug } from '../runtime/log.mjs';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where our own shelf lives inside the pool.
|
|
39
|
+
*
|
|
40
|
+
* Takes the root rather than assuming the default working copy: a folder pool
|
|
41
|
+
* IS the shared folder, and publishing into teamDir instead would write a
|
|
42
|
+
* perfectly correct shelf somewhere nobody is reading.
|
|
43
|
+
*/
|
|
44
|
+
function shelfOf(root, deviceId) {
|
|
45
|
+
return teamDeviceDir(root, deviceId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function teamConfig() {
|
|
49
|
+
return loadConfig().team ?? {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isEnabled() {
|
|
53
|
+
const team = teamConfig();
|
|
54
|
+
return Boolean(team.enabled && team.transport);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Setup ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Join or create a pool.
|
|
61
|
+
*
|
|
62
|
+
* @param {{transport: 'git'|'folder', repo: string, scope?: string}} options
|
|
63
|
+
*/
|
|
64
|
+
export function join_(options) {
|
|
65
|
+
const transport = transportFor(options.transport);
|
|
66
|
+
if (!transport) throw new Error(`unknown transport: ${options.transport}`);
|
|
67
|
+
if (!options.repo) throw new Error('a repository url or folder path is required');
|
|
68
|
+
|
|
69
|
+
const root = options.transport === 'folder' ? options.repo : teamDir;
|
|
70
|
+
mkdirSync(root, { recursive: true });
|
|
71
|
+
|
|
72
|
+
const started = transport.init(root, { repo: options.repo });
|
|
73
|
+
if (!started.ok) throw new Error(started.error ?? 'could not reach the pool');
|
|
74
|
+
|
|
75
|
+
const next = rawConfig();
|
|
76
|
+
setPath(next, 'team.enabled', true);
|
|
77
|
+
setPath(next, 'team.transport', options.transport);
|
|
78
|
+
setPath(next, 'team.repo', options.repo);
|
|
79
|
+
if (options.scope) setPath(next, 'team.scope', options.scope);
|
|
80
|
+
saveConfig(next);
|
|
81
|
+
|
|
82
|
+
return { root, transport: transport.id, describe: transport.describe(root) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function leave() {
|
|
86
|
+
const next = rawConfig();
|
|
87
|
+
setPath(next, 'team.enabled', false);
|
|
88
|
+
saveConfig(next);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The working copy for the configured transport. A folder pool IS the folder. */
|
|
93
|
+
export function poolRoot() {
|
|
94
|
+
const team = teamConfig();
|
|
95
|
+
return team.transport === 'folder' && team.repo ? team.repo : teamDir;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Publishing ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Write our sanitised rollups onto our own shelf.
|
|
102
|
+
*
|
|
103
|
+
* Rewrites every day in the window rather than only today: a day that was still
|
|
104
|
+
* open at the last publish has since been finalised, and a stale copy of it in
|
|
105
|
+
* the pool would quietly under-report the person who left their laptop early.
|
|
106
|
+
*
|
|
107
|
+
* @returns {{written: number, removed: number, days: string[]}}
|
|
108
|
+
*/
|
|
109
|
+
export function publish({ root = poolRoot() } = {}) {
|
|
110
|
+
const team = teamConfig();
|
|
111
|
+
const me = identity();
|
|
112
|
+
const scope = team.scope ?? 'summary';
|
|
113
|
+
const keepDays = team.shareDays ?? 45;
|
|
114
|
+
|
|
115
|
+
const shelf = shelfOf(root, me.deviceId);
|
|
116
|
+
const rollups = join(shelf, 'rollup');
|
|
117
|
+
mkdirSync(rollups, { recursive: true });
|
|
118
|
+
|
|
119
|
+
const oldest = shiftDay(localDay(Date.now()), -(keepDays - 1));
|
|
120
|
+
const days = coveredDays().filter((day) => day >= oldest);
|
|
121
|
+
|
|
122
|
+
let written = 0;
|
|
123
|
+
const published = [];
|
|
124
|
+
for (const day of days) {
|
|
125
|
+
const rollup = loadRollup(day);
|
|
126
|
+
if (!rollup) continue;
|
|
127
|
+
const clean = sanitise(rollup, scope);
|
|
128
|
+
if (!clean) continue;
|
|
129
|
+
writeJsonFile(join(rollups, `${day}.json`), clean);
|
|
130
|
+
published.push(day);
|
|
131
|
+
written += 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Retire days that have aged out of the window, so a shelf is bounded rather
|
|
135
|
+
// than growing for as long as somebody keeps the tool installed.
|
|
136
|
+
let removed = 0;
|
|
137
|
+
try {
|
|
138
|
+
for (const name of readdirSync(rollups)) {
|
|
139
|
+
const day = name.replace(/\.json$/, '');
|
|
140
|
+
if (name.endsWith('.json') && day < oldest) { removeFile(join(rollups, name)); removed += 1; }
|
|
141
|
+
}
|
|
142
|
+
} catch { /* nothing published yet */ }
|
|
143
|
+
|
|
144
|
+
writeJsonFile(join(shelf, 'meta.json'), manifestFor(me, {
|
|
145
|
+
scope, version: version(), days: published.length,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
return { written, removed, days: published };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Reading the pool ────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every shelf in the pool, ours included.
|
|
155
|
+
*
|
|
156
|
+
* @returns {{deviceId, name, host, os, scope, lastSeen, isMe, rollups: Map<string,object>}[]}
|
|
157
|
+
*/
|
|
158
|
+
export function readPool({ root = poolRoot(), days = null } = {}) {
|
|
159
|
+
const me = identity();
|
|
160
|
+
const wanted = days ? new Set(days) : null;
|
|
161
|
+
const peers = [];
|
|
162
|
+
|
|
163
|
+
for (const deviceId of listDevices(root)) {
|
|
164
|
+
const shelf = teamDeviceDir(root, deviceId);
|
|
165
|
+
const meta = readJsonFile(join(shelf, 'meta.json')) ?? {};
|
|
166
|
+
const rollups = new Map();
|
|
167
|
+
|
|
168
|
+
let names = [];
|
|
169
|
+
try { names = readdirSync(join(shelf, 'rollup')); } catch { names = []; }
|
|
170
|
+
|
|
171
|
+
for (const name of names) {
|
|
172
|
+
if (!name.endsWith('.json')) continue;
|
|
173
|
+
const day = name.slice(0, -5);
|
|
174
|
+
if (wanted && !wanted.has(day)) continue;
|
|
175
|
+
const rollup = readJsonFile(join(shelf, 'rollup', name));
|
|
176
|
+
if (rollup) rollups.set(day, rollup);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
peers.push({
|
|
180
|
+
deviceId,
|
|
181
|
+
name: meta.name ?? deviceId.slice(0, 6),
|
|
182
|
+
host: meta.host ?? null,
|
|
183
|
+
os: meta.os ?? null,
|
|
184
|
+
scope: meta.scope ?? 'summary',
|
|
185
|
+
lastSeen: meta.lastSeen ?? null,
|
|
186
|
+
isMe: deviceId === me.deviceId,
|
|
187
|
+
rollups,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Ours first, then most recently seen. A list that reorders itself as people
|
|
192
|
+
// work is unreadable; anchoring self at the top keeps it stable.
|
|
193
|
+
peers.sort((a, b) => Number(b.isMe) - Number(a.isMe) || (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
|
|
194
|
+
return peers;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* An invite for this pool, for the owner to send.
|
|
199
|
+
*
|
|
200
|
+
* Carries the repository address and a label — never a credential. Who may join
|
|
201
|
+
* is decided by the repository's collaborator list, which is the only place that
|
|
202
|
+
* decision can be revoked from.
|
|
203
|
+
*/
|
|
204
|
+
export function inviteCode({ from = null } = {}) {
|
|
205
|
+
const team = teamConfig();
|
|
206
|
+
if (!team.enabled || !team.repo) throw new Error('sharing is off — join or create a pool first');
|
|
207
|
+
return encodeInvite({
|
|
208
|
+
repo: team.repo,
|
|
209
|
+
transport: team.transport ?? 'git',
|
|
210
|
+
pool: poolName(),
|
|
211
|
+
from: from ?? identity().name,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A readable label for the pool, taken from the repository name. */
|
|
216
|
+
export function poolName() {
|
|
217
|
+
const repo = teamConfig().repo ?? '';
|
|
218
|
+
const last = repo.replace(/\.git$/, '').split(/[/\\:]/).filter(Boolean).pop();
|
|
219
|
+
return last || 'pool';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Remove someone's shelf from the pool.
|
|
224
|
+
*
|
|
225
|
+
* This is the ONE operation that deliberately writes outside our own directory,
|
|
226
|
+
* and it is an explicit administrative act rather than part of any sync. Git
|
|
227
|
+
* permissions are per-repository, so anyone with write access could already do
|
|
228
|
+
* this by hand; having a command for it means it is done correctly — the folder
|
|
229
|
+
* removed, the change pushed — instead of half-done.
|
|
230
|
+
*
|
|
231
|
+
* It removes them from view, not from history. Earlier commits still hold the
|
|
232
|
+
* data and anyone who pulled before still has a copy. The caller is expected to
|
|
233
|
+
* have said so before calling this.
|
|
234
|
+
*
|
|
235
|
+
* @returns {{removed: boolean, name: string|null, pushed: boolean, error: string|null}}
|
|
236
|
+
*/
|
|
237
|
+
export function removeDevice(target, { push = true } = {}) {
|
|
238
|
+
const root = poolRoot();
|
|
239
|
+
const me = identity();
|
|
240
|
+
|
|
241
|
+
const peers = readPool({ root });
|
|
242
|
+
const match = peers.find((peer) => peer.deviceId === target)
|
|
243
|
+
?? peers.find((peer) => peer.name?.toLowerCase() === String(target).toLowerCase());
|
|
244
|
+
|
|
245
|
+
if (!match) return { removed: false, name: null, pushed: false, error: `no machine called "${target}" in the pool` };
|
|
246
|
+
if (match.deviceId === me.deviceId) {
|
|
247
|
+
// Deleting your own shelf while still publishing would simply re-create it
|
|
248
|
+
// on the next sync, which looks like the command silently failed.
|
|
249
|
+
return { removed: false, name: match.name, pushed: false, error: 'that is this machine — use `syndes team leave` instead' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const shelf = teamDeviceDir(root, match.deviceId);
|
|
253
|
+
const relative = `devices/${match.deviceId}`;
|
|
254
|
+
|
|
255
|
+
if ((teamConfig().transport ?? 'git') !== 'git') {
|
|
256
|
+
try { rmSync(shelf, { recursive: true, force: true }); } catch (error) { return { removed: false, name: match.name, pushed: false, error: error.message }; }
|
|
257
|
+
return { removed: true, name: match.name, pushed: true, error: null };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const removed = git(root, ['rm', '-r', '--quiet', '--', relative]);
|
|
261
|
+
if (!removed.ok) return { removed: false, name: match.name, pushed: false, error: removed.error };
|
|
262
|
+
|
|
263
|
+
const committed = git(root, ['commit', '-m', `syndes: remove ${match.name} from the pool`, '--', relative]);
|
|
264
|
+
if (!committed.ok) return { removed: true, name: match.name, pushed: false, error: committed.error };
|
|
265
|
+
|
|
266
|
+
if (!push) return { removed: true, name: match.name, pushed: false, error: null };
|
|
267
|
+
|
|
268
|
+
const transport = transportFor('git');
|
|
269
|
+
transport.pull(root);
|
|
270
|
+
const pushed = git(root, ['push', 'origin', 'HEAD'], 25_000);
|
|
271
|
+
return { removed: true, name: match.name, pushed: pushed.ok, error: pushed.ok ? null : pushed.error };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── One cycle ───────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Pull, publish, push. The whole exchange, and the only thing the poller calls.
|
|
278
|
+
*
|
|
279
|
+
* Pull comes first so our push rebases onto whatever arrived while we were away;
|
|
280
|
+
* publish comes second so the thing we push is current rather than one cycle
|
|
281
|
+
* behind, which is what makes the dashboard feel live rather than lagged.
|
|
282
|
+
*/
|
|
283
|
+
export function syncOnce({ push = true } = {}) {
|
|
284
|
+
if (!isEnabled()) return { ok: false, reason: 'sharing is off', at: Date.now() };
|
|
285
|
+
|
|
286
|
+
const team = teamConfig();
|
|
287
|
+
const transport = transportFor(team.transport);
|
|
288
|
+
const root = poolRoot();
|
|
289
|
+
if (!transport) return { ok: false, reason: 'unknown transport', at: Date.now() };
|
|
290
|
+
|
|
291
|
+
const pulled = transport.pull(root, { repo: team.repo });
|
|
292
|
+
const published = publish({ root });
|
|
293
|
+
|
|
294
|
+
let pushed = { ok: true, pushed: false, error: null };
|
|
295
|
+
if (push) {
|
|
296
|
+
pushed = transport.push(root, {
|
|
297
|
+
deviceId: identity().deviceId,
|
|
298
|
+
message: `syndes: ${identity().name} through ${localDay(Date.now())}`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const state = {
|
|
303
|
+
ok: pulled.ok && pushed.ok,
|
|
304
|
+
at: Date.now(),
|
|
305
|
+
pulled: pulled.ok,
|
|
306
|
+
pushed: pushed.pushed,
|
|
307
|
+
published: published.written,
|
|
308
|
+
error: pulled.error ?? pushed.error ?? null,
|
|
309
|
+
};
|
|
310
|
+
saveState(state);
|
|
311
|
+
debug('team sync', state);
|
|
312
|
+
return state;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function loadState() {
|
|
316
|
+
return readJson(teamStateFile).data ?? { ok: null, at: null, error: null };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function saveState(state) {
|
|
320
|
+
try {
|
|
321
|
+
mkdirSync(dirname(teamStateFile), { recursive: true });
|
|
322
|
+
const staging = `${teamStateFile}.tmp`;
|
|
323
|
+
writeFileSync(staging, `${JSON.stringify(state)}\n`);
|
|
324
|
+
renameSync(staging, teamStateFile);
|
|
325
|
+
} catch { /* the sync happened whether or not we could note it */ }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function version() {
|
|
329
|
+
try {
|
|
330
|
+
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export { join_ as joinPool, sanitise };
|
package/sync/invite.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invites: one string that carries everything a new machine needs to join.
|
|
3
|
+
*
|
|
4
|
+
* The alternative is a paragraph of instructions — "install this, then run that,
|
|
5
|
+
* paste this URL, use your own GitHub" — which is where onboarding goes to die.
|
|
6
|
+
* An invite collapses it into a line the inviter copies and the joiner pastes.
|
|
7
|
+
*
|
|
8
|
+
* ── An invite is NOT a credential ──────────────────────────────────────────
|
|
9
|
+
* It carries a repository address and a label, nothing else. Access is decided
|
|
10
|
+
* entirely by whether GitHub lets that person in, which the repository owner
|
|
11
|
+
* controls with collaborator permissions and can revoke. Encoding a token here
|
|
12
|
+
* would mean the inviter's own credentials travelling through a chat app, which
|
|
13
|
+
* is exactly the practice this tool should not be teaching. So the invite is
|
|
14
|
+
* base64 for tidiness, not for secrecy, and it is safe to say so out loud.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PREFIX = 'syndes1_';
|
|
18
|
+
const VERSION = 1;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{repo: string, transport: 'git'|'folder', pool?: string, from?: string}} details
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function encode({ repo, transport = 'git', pool = null, from = null }) {
|
|
25
|
+
if (!repo) throw new Error('an invite needs a repository');
|
|
26
|
+
const body = { v: VERSION, r: repo, t: transport };
|
|
27
|
+
if (pool) body.p = pool.slice(0, 60);
|
|
28
|
+
if (from) body.f = from.slice(0, 40);
|
|
29
|
+
return PREFIX + Buffer.from(JSON.stringify(body), 'utf8').toString('base64url');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decode, tolerating what people actually paste — surrounding quotes, a stray
|
|
34
|
+
* backtick from a chat app, the whole command line rather than just the code.
|
|
35
|
+
*
|
|
36
|
+
* @returns {{repo, transport, pool, from}|null} null when it is not an invite
|
|
37
|
+
*/
|
|
38
|
+
export function decode(input) {
|
|
39
|
+
if (typeof input !== 'string') return null;
|
|
40
|
+
|
|
41
|
+
let text = input.trim().replace(/^[`'"]+|[`'"]+$/g, '');
|
|
42
|
+
const found = /syndes1_[A-Za-z0-9_-]+/.exec(text);
|
|
43
|
+
if (found) text = found[0];
|
|
44
|
+
if (!text.startsWith(PREFIX)) return null;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const body = JSON.parse(Buffer.from(text.slice(PREFIX.length), 'base64url').toString('utf8'));
|
|
48
|
+
if (body.v !== VERSION || typeof body.r !== 'string' || !body.r) return null;
|
|
49
|
+
return {
|
|
50
|
+
repo: body.r,
|
|
51
|
+
transport: body.t === 'folder' ? 'folder' : 'git',
|
|
52
|
+
pool: typeof body.p === 'string' ? body.p : null,
|
|
53
|
+
from: typeof body.f === 'string' ? body.f : null,
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The line an inviter sends. Two steps, because the install must be global. */
|
|
61
|
+
export function commandFor(code) {
|
|
62
|
+
return `npm install -g syndes && syndes join ${code}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True for anything that looks like an invite, so `join` can accept a raw URL too. */
|
|
66
|
+
export function looksLikeInvite(input) {
|
|
67
|
+
return typeof input === 'string' && input.includes(PREFIX);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { PREFIX };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One sync cycle, in its own process.
|
|
4
|
+
*
|
|
5
|
+
* git talks to a network and the transport calls it synchronously, which is
|
|
6
|
+
* correct for a CLI and unacceptable inside the dashboard's event loop — a
|
|
7
|
+
* twenty-five second timeout on a slow remote would freeze every other request
|
|
8
|
+
* on the server, including the one drawing the page that asked for the sync.
|
|
9
|
+
*
|
|
10
|
+
* So the poller spawns this instead of calling syncOnce directly. The child
|
|
11
|
+
* blocks; the server does not.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { syncOnce } from './index.mjs';
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const result = syncOnce({ push: !process.argv.includes('--no-push') });
|
|
18
|
+
process.stdout.write(JSON.stringify(result));
|
|
19
|
+
} catch (error) {
|
|
20
|
+
process.stdout.write(JSON.stringify({ ok: false, at: Date.now(), error: String(error?.message ?? error) }));
|
|
21
|
+
}
|
package/sync/share.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What leaves the machine, and what never does.
|
|
3
|
+
*
|
|
4
|
+
* The local ledger holds prompts, file paths and shell commands. A shared pool
|
|
5
|
+
* is, by definition, somewhere other people can read — so the unit of sharing is
|
|
6
|
+
* NOT the ledger. It is the daily rollup, with the identifying fields removed
|
|
7
|
+
* before it is written.
|
|
8
|
+
*
|
|
9
|
+
* That choice is deliberate on three counts:
|
|
10
|
+
* • A rollup is already a count of things, not a copy of them. There is no
|
|
11
|
+
* prompt text in it to leak, because none was ever folded in.
|
|
12
|
+
* • It is a few kilobytes a day, so a poll every thirty seconds is free.
|
|
13
|
+
* • It carries every number the team view needs, so nothing is lost by
|
|
14
|
+
* refusing to ship the records themselves.
|
|
15
|
+
*
|
|
16
|
+
* Scopes:
|
|
17
|
+
* summary (default) counts, tokens, time, hours. No paths, no project ids,
|
|
18
|
+
* no per-tool breakdown.
|
|
19
|
+
* detailed adds project ids and the tool mix, for teams who have agreed to
|
|
20
|
+
* it. Still never prompt text, a file path, a command, or a session
|
|
21
|
+
* id — those are refused at every scope, because a scope is a dial
|
|
22
|
+
* for detail and not a way to opt out of the promise.
|
|
23
|
+
*
|
|
24
|
+
* Sanitisation is SUBTRACTIVE ONLY. A key is dropped, never rewritten, so a
|
|
25
|
+
* reader can always tell "zero" from "not shared". Where a count has to survive
|
|
26
|
+
* an id being removed it is added under a new `…Count` name rather than
|
|
27
|
+
* overwriting the field it came from.
|
|
28
|
+
*
|
|
29
|
+
* Paths may name a nested key with `*` for "every entry", because the leak this
|
|
30
|
+
* module exists to prevent hid one level down: volume.bySource carries a
|
|
31
|
+
* per-agent breakdown, and each of its buckets carried the session ids.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Refused at every scope. Nothing on this list is ever shared. */
|
|
35
|
+
const NEVER = {
|
|
36
|
+
volume: ['files', 'sessions', 'bySource.*.sessions'],
|
|
37
|
+
tools: ['targets', 'commands'],
|
|
38
|
+
rework: ['churn', 'storms'],
|
|
39
|
+
friction: ['byTarget'],
|
|
40
|
+
context: ['sessionsWithCompact'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Dropped as well at `summary` — useful detail, but it describes your work. */
|
|
44
|
+
const SUMMARY_ONLY = {
|
|
45
|
+
volume: ['projects', 'bySource'],
|
|
46
|
+
tools: ['byTool'],
|
|
47
|
+
friction: ['byTool'],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} rollup from analytics/rollup.mjs buildDay()
|
|
52
|
+
* @param {'summary'|'detailed'} scope
|
|
53
|
+
* @returns {object|null} a rollup of the same shape, with fields removed
|
|
54
|
+
*/
|
|
55
|
+
export function sanitise(rollup, scope = 'summary') {
|
|
56
|
+
if (!rollup?.metrics) return null;
|
|
57
|
+
|
|
58
|
+
const metrics = {};
|
|
59
|
+
for (const [name, metric] of Object.entries(rollup.metrics)) {
|
|
60
|
+
const paths = scope === 'detailed'
|
|
61
|
+
? (NEVER[name] ?? [])
|
|
62
|
+
: [...(NEVER[name] ?? []), ...(SUMMARY_ONLY[name] ?? [])];
|
|
63
|
+
metrics[name] = strip(metric, paths);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Counts survive their ids. "Four sessions" is what the team view divides by
|
|
67
|
+
// and it says nothing about which four.
|
|
68
|
+
if (rollup.metrics.volume) {
|
|
69
|
+
metrics.volume.sessionCount = (rollup.metrics.volume.sessions ?? []).length;
|
|
70
|
+
metrics.volume.projectCount = (rollup.metrics.volume.projects ?? []).length;
|
|
71
|
+
metrics.volume.fileCount = (rollup.metrics.volume.files ?? []).length;
|
|
72
|
+
}
|
|
73
|
+
if (rollup.metrics.context) {
|
|
74
|
+
metrics.context.sessionsWithCompactCount = (rollup.metrics.context.sessionsWithCompact ?? []).length;
|
|
75
|
+
}
|
|
76
|
+
if (rollup.metrics.rework) {
|
|
77
|
+
// The storms themselves name a command and never travel; the count does,
|
|
78
|
+
// because the score reads it and a missing one reads as "no storms".
|
|
79
|
+
metrics.rework.stormCount = (rollup.metrics.rework.storms ?? []).length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
day: rollup.day,
|
|
84
|
+
version: rollup.version,
|
|
85
|
+
records: rollup.records,
|
|
86
|
+
builtAt: rollup.builtAt,
|
|
87
|
+
final: rollup.final,
|
|
88
|
+
scope,
|
|
89
|
+
metrics,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Remove each path, copying only as deep as a removal actually reaches. */
|
|
94
|
+
function strip(object, paths) {
|
|
95
|
+
if (!object || typeof object !== 'object') return object;
|
|
96
|
+
let out = { ...object };
|
|
97
|
+
for (const path of paths) out = drop(out, path.split('.'));
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function drop(node, segments) {
|
|
102
|
+
if (!node || typeof node !== 'object') return node;
|
|
103
|
+
const [head, ...rest] = segments;
|
|
104
|
+
|
|
105
|
+
if (!rest.length) {
|
|
106
|
+
if (head === '*') return node;
|
|
107
|
+
const out = { ...node };
|
|
108
|
+
delete out[head];
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (head === '*') {
|
|
113
|
+
const out = Array.isArray(node) ? [...node] : { ...node };
|
|
114
|
+
for (const key of Object.keys(out)) out[key] = drop(out[key], rest);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!(head in node)) return node;
|
|
119
|
+
return { ...node, [head]: drop(node[head], rest) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The card a device leaves beside its rollups.
|
|
124
|
+
*
|
|
125
|
+
* Written on every publish rather than once at enrolment, so `lastSeen` is
|
|
126
|
+
* meaningful: a shelf that has stopped being updated is visibly stale rather
|
|
127
|
+
* than silently indistinguishable from a quiet day.
|
|
128
|
+
*/
|
|
129
|
+
export function manifestFor(identity, { scope, version, days }) {
|
|
130
|
+
return {
|
|
131
|
+
deviceId: identity.deviceId,
|
|
132
|
+
name: identity.name,
|
|
133
|
+
host: identity.host,
|
|
134
|
+
os: identity.os,
|
|
135
|
+
scope,
|
|
136
|
+
version,
|
|
137
|
+
days,
|
|
138
|
+
firstSeen: identity.createdAt,
|
|
139
|
+
lastSeen: Date.now(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { NEVER, SUMMARY_ONLY };
|