syndes 0.1.0 → 0.2.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/src/doctor.mjs CHANGED
@@ -24,6 +24,8 @@ import { isHeld } from '../runtime/lock.mjs';
24
24
  import { describe as describeAuth } from '../dashboard/auth.mjs';
25
25
  import { probe } from '../notify/index.mjs';
26
26
  import { readJson, loadConfig } from '../runtime/config.mjs';
27
+ import { isEnabled as sharingOn, teamConfig, loadState as teamState, readPool, poolRoot } from '../sync/index.mjs';
28
+ import { identity } from '../runtime/identity.mjs';
27
29
  import { configFile } from '../runtime/paths.mjs';
28
30
 
29
31
  function versionOf(file) {
@@ -137,6 +139,34 @@ export async function diagnose({ deep = false } = {}) {
137
139
  checks.push(note('system unlock', `${lock.system.method} is available here — syndes lock system`));
138
140
  }
139
141
 
142
+ // ── Sharing ───────────────────────────────────────────────────────────────
143
+ if (!sharingOn()) {
144
+ checks.push(note('sharing', 'off — this machine reports only to itself'));
145
+ } else {
146
+ const state = teamState();
147
+ const peers = readPool();
148
+ const mine = peers.find((peer) => peer.deviceId === identity().deviceId);
149
+
150
+ checks.push(state.ok === false
151
+ ? fail('pool', `last exchange failed: ${String(state.error ?? 'unknown').slice(0, 80)}`, 'syndes team sync')
152
+ : pass('pool', `${teamConfig().transport} · ${displayPath(poolRoot())}`));
153
+
154
+ checks.push(mine
155
+ ? pass('your shelf', `${identity().name} · ${mine.rollups.size} day(s) published`)
156
+ : fail('your shelf', 'nothing of yours is in the pool yet', 'syndes team sync'));
157
+
158
+ // A peer whose shelf has gone quiet is the failure people misread as "they
159
+ // stopped working". Naming it here is cheaper than them guessing.
160
+ const stale = peers.filter((peer) => !peer.isMe && (!peer.lastSeen || Date.now() - peer.lastSeen > 6 * 3_600_000));
161
+ checks.push(stale.length
162
+ ? note('peers', `${peers.length - 1} other machine(s), ${stale.length} not synced in 6h: ${stale.map((p) => p.name).join(', ')}`)
163
+ : pass('peers', `${peers.length - 1} other machine(s), all current`));
164
+
165
+ checks.push(note('sharing scope', teamConfig().scope === 'detailed'
166
+ ? 'detailed — counts, tokens, hours, project ids and tool mix. Never prompt text.'
167
+ : 'summary — counts, tokens and hours only. No paths, no project ids, no prompt text.'));
168
+ }
169
+
140
170
  const config = readJson(configFile);
141
171
  if (config.exists && config.error) {
142
172
  checks.push(fail('config', `syndes.json does not parse: ${config.error}`, 'fix or delete it; defaults will be used'));
package/src/term.mjs CHANGED
@@ -207,10 +207,50 @@ export function beat(ms = 90) {
207
207
  return new Promise((resolve) => { setTimeout(resolve, ms); });
208
208
  }
209
209
 
210
+ /**
211
+ * Ask, and always come back with something.
212
+ *
213
+ * Two ways a question never gets answered, both of which used to hang forever:
214
+ *
215
+ * • There is no terminal. Every caller of this function is interactive by
216
+ * nature, so with no tty there is nobody to ask — and reading whatever
217
+ * happens to be on a pipe as though a person typed it is worse than not
218
+ * asking, particularly for the PIN prompt. This follows the rule the rest
219
+ * of the codebase already keeps: a probe that cannot answer returns nothing
220
+ * and the caller degrades, rather than "unsupported" and "broken" looking
221
+ * alike. It is also the only DETERMINISTIC answer available. When stdin is
222
+ * /dev/null — which is exactly what npm hands a postinstall — Node emits no
223
+ * `end` and no `close` on it at all, so nothing short of a timeout would
224
+ * ever notice, and a timeout in a prompt is a guess about how fast someone
225
+ * types.
226
+ *
227
+ * • The user pressed ctrl-D at a real prompt. readline's callback never
228
+ * fires, so `close` is what settles it.
229
+ *
230
+ * Left unhandled, either one leaves the promise pending and Node exits mid
231
+ * install with "unsettled top-level await", which reads as a crash with no cause.
232
+ * An unanswered question resolves empty, and every caller already treats empty
233
+ * as "no answer given".
234
+ */
210
235
  export function ask(question, { silent = false } = {}) {
211
236
  return new Promise((resolve) => {
237
+ if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
238
+ resolve('');
239
+ return;
240
+ }
241
+
212
242
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
213
243
 
244
+ let settled = false;
245
+ const done = (answer) => {
246
+ if (settled) return;
247
+ settled = true;
248
+ try { rl.close(); } catch { /* already closing */ }
249
+ resolve(answer);
250
+ };
251
+ rl.on('close', () => done(''));
252
+ rl.on('error', () => done(''));
253
+
214
254
  if (silent) {
215
255
  // Echo off by hand: readline has no password mode, and a password printed
216
256
  // into a terminal that may be scrolled back or recorded is unacceptable.
@@ -220,13 +260,12 @@ export function ask(question, { silent = false } = {}) {
220
260
  rl.question('', (answer) => {
221
261
  rl.input.off('data', redraw);
222
262
  rl.output.write('\n');
223
- rl.close();
224
- resolve(answer);
263
+ done(answer);
225
264
  });
226
265
  return;
227
266
  }
228
267
 
229
- rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
268
+ rl.question(question, (answer) => done(answer.trim()));
230
269
  });
231
270
  }
232
271
 
package/sync/index.mjs ADDED
@@ -0,0 +1,257 @@
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 {
29
+ transportFor, listDevices, readJsonFile, writeJsonFile, removeFile,
30
+ } from './transport.mjs';
31
+ import { writeFileSync, renameSync } from 'node:fs';
32
+ import { dirname } from 'node:path';
33
+ import { debug } from '../runtime/log.mjs';
34
+
35
+ /**
36
+ * Where our own shelf lives inside the pool.
37
+ *
38
+ * Takes the root rather than assuming the default working copy: a folder pool
39
+ * IS the shared folder, and publishing into teamDir instead would write a
40
+ * perfectly correct shelf somewhere nobody is reading.
41
+ */
42
+ function shelfOf(root, deviceId) {
43
+ return teamDeviceDir(root, deviceId);
44
+ }
45
+
46
+ export function teamConfig() {
47
+ return loadConfig().team ?? {};
48
+ }
49
+
50
+ export function isEnabled() {
51
+ const team = teamConfig();
52
+ return Boolean(team.enabled && team.transport);
53
+ }
54
+
55
+ // ── Setup ───────────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Join or create a pool.
59
+ *
60
+ * @param {{transport: 'git'|'folder', repo: string, scope?: string}} options
61
+ */
62
+ export function join_(options) {
63
+ const transport = transportFor(options.transport);
64
+ if (!transport) throw new Error(`unknown transport: ${options.transport}`);
65
+ if (!options.repo) throw new Error('a repository url or folder path is required');
66
+
67
+ const root = options.transport === 'folder' ? options.repo : teamDir;
68
+ mkdirSync(root, { recursive: true });
69
+
70
+ const started = transport.init(root, { repo: options.repo });
71
+ if (!started.ok) throw new Error(started.error ?? 'could not reach the pool');
72
+
73
+ const next = rawConfig();
74
+ setPath(next, 'team.enabled', true);
75
+ setPath(next, 'team.transport', options.transport);
76
+ setPath(next, 'team.repo', options.repo);
77
+ if (options.scope) setPath(next, 'team.scope', options.scope);
78
+ saveConfig(next);
79
+
80
+ return { root, transport: transport.id, describe: transport.describe(root) };
81
+ }
82
+
83
+ export function leave() {
84
+ const next = rawConfig();
85
+ setPath(next, 'team.enabled', false);
86
+ saveConfig(next);
87
+ return true;
88
+ }
89
+
90
+ /** The working copy for the configured transport. A folder pool IS the folder. */
91
+ export function poolRoot() {
92
+ const team = teamConfig();
93
+ return team.transport === 'folder' && team.repo ? team.repo : teamDir;
94
+ }
95
+
96
+ // ── Publishing ──────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Write our sanitised rollups onto our own shelf.
100
+ *
101
+ * Rewrites every day in the window rather than only today: a day that was still
102
+ * open at the last publish has since been finalised, and a stale copy of it in
103
+ * the pool would quietly under-report the person who left their laptop early.
104
+ *
105
+ * @returns {{written: number, removed: number, days: string[]}}
106
+ */
107
+ export function publish({ root = poolRoot() } = {}) {
108
+ const team = teamConfig();
109
+ const me = identity();
110
+ const scope = team.scope ?? 'summary';
111
+ const keepDays = team.shareDays ?? 45;
112
+
113
+ const shelf = shelfOf(root, me.deviceId);
114
+ const rollups = join(shelf, 'rollup');
115
+ mkdirSync(rollups, { recursive: true });
116
+
117
+ const oldest = shiftDay(localDay(Date.now()), -(keepDays - 1));
118
+ const days = coveredDays().filter((day) => day >= oldest);
119
+
120
+ let written = 0;
121
+ const published = [];
122
+ for (const day of days) {
123
+ const rollup = loadRollup(day);
124
+ if (!rollup) continue;
125
+ const clean = sanitise(rollup, scope);
126
+ if (!clean) continue;
127
+ writeJsonFile(join(rollups, `${day}.json`), clean);
128
+ published.push(day);
129
+ written += 1;
130
+ }
131
+
132
+ // Retire days that have aged out of the window, so a shelf is bounded rather
133
+ // than growing for as long as somebody keeps the tool installed.
134
+ let removed = 0;
135
+ try {
136
+ for (const name of readdirSync(rollups)) {
137
+ const day = name.replace(/\.json$/, '');
138
+ if (name.endsWith('.json') && day < oldest) { removeFile(join(rollups, name)); removed += 1; }
139
+ }
140
+ } catch { /* nothing published yet */ }
141
+
142
+ writeJsonFile(join(shelf, 'meta.json'), manifestFor(me, {
143
+ scope, version: version(), days: published.length,
144
+ }));
145
+
146
+ return { written, removed, days: published };
147
+ }
148
+
149
+ // ── Reading the pool ────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Every shelf in the pool, ours included.
153
+ *
154
+ * @returns {{deviceId, name, host, os, scope, lastSeen, isMe, rollups: Map<string,object>}[]}
155
+ */
156
+ export function readPool({ root = poolRoot(), days = null } = {}) {
157
+ const me = identity();
158
+ const wanted = days ? new Set(days) : null;
159
+ const peers = [];
160
+
161
+ for (const deviceId of listDevices(root)) {
162
+ const shelf = teamDeviceDir(root, deviceId);
163
+ const meta = readJsonFile(join(shelf, 'meta.json')) ?? {};
164
+ const rollups = new Map();
165
+
166
+ let names = [];
167
+ try { names = readdirSync(join(shelf, 'rollup')); } catch { names = []; }
168
+
169
+ for (const name of names) {
170
+ if (!name.endsWith('.json')) continue;
171
+ const day = name.slice(0, -5);
172
+ if (wanted && !wanted.has(day)) continue;
173
+ const rollup = readJsonFile(join(shelf, 'rollup', name));
174
+ if (rollup) rollups.set(day, rollup);
175
+ }
176
+
177
+ peers.push({
178
+ deviceId,
179
+ name: meta.name ?? deviceId.slice(0, 6),
180
+ host: meta.host ?? null,
181
+ os: meta.os ?? null,
182
+ scope: meta.scope ?? 'summary',
183
+ lastSeen: meta.lastSeen ?? null,
184
+ isMe: deviceId === me.deviceId,
185
+ rollups,
186
+ });
187
+ }
188
+
189
+ // Ours first, then most recently seen. A list that reorders itself as people
190
+ // work is unreadable; anchoring self at the top keeps it stable.
191
+ peers.sort((a, b) => Number(b.isMe) - Number(a.isMe) || (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
192
+ return peers;
193
+ }
194
+
195
+ // ── One cycle ───────────────────────────────────────────────────────────────
196
+
197
+ /**
198
+ * Pull, publish, push. The whole exchange, and the only thing the poller calls.
199
+ *
200
+ * Pull comes first so our push rebases onto whatever arrived while we were away;
201
+ * publish comes second so the thing we push is current rather than one cycle
202
+ * behind, which is what makes the dashboard feel live rather than lagged.
203
+ */
204
+ export function syncOnce({ push = true } = {}) {
205
+ if (!isEnabled()) return { ok: false, reason: 'sharing is off', at: Date.now() };
206
+
207
+ const team = teamConfig();
208
+ const transport = transportFor(team.transport);
209
+ const root = poolRoot();
210
+ if (!transport) return { ok: false, reason: 'unknown transport', at: Date.now() };
211
+
212
+ const pulled = transport.pull(root, { repo: team.repo });
213
+ const published = publish({ root });
214
+
215
+ let pushed = { ok: true, pushed: false, error: null };
216
+ if (push) {
217
+ pushed = transport.push(root, {
218
+ deviceId: identity().deviceId,
219
+ message: `syndes: ${identity().name} through ${localDay(Date.now())}`,
220
+ });
221
+ }
222
+
223
+ const state = {
224
+ ok: pulled.ok && pushed.ok,
225
+ at: Date.now(),
226
+ pulled: pulled.ok,
227
+ pushed: pushed.pushed,
228
+ published: published.written,
229
+ error: pulled.error ?? pushed.error ?? null,
230
+ };
231
+ saveState(state);
232
+ debug('team sync', state);
233
+ return state;
234
+ }
235
+
236
+ export function loadState() {
237
+ return readJson(teamStateFile).data ?? { ok: null, at: null, error: null };
238
+ }
239
+
240
+ function saveState(state) {
241
+ try {
242
+ mkdirSync(dirname(teamStateFile), { recursive: true });
243
+ const staging = `${teamStateFile}.tmp`;
244
+ writeFileSync(staging, `${JSON.stringify(state)}\n`);
245
+ renameSync(staging, teamStateFile);
246
+ } catch { /* the sync happened whether or not we could note it */ }
247
+ }
248
+
249
+ function version() {
250
+ try {
251
+ return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ export { join_ as joinPool, sanitise };
@@ -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 };