syndes 0.2.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 CHANGED
@@ -77,12 +77,31 @@ other people also use.
77
77
 
78
78
  ## Sharing an account
79
79
 
80
+ `syndes install` asks. Or set it up later:
81
+
80
82
  ```bash
81
83
  syndes team join git@github.com:you/usage.git # a private repo you both push to
82
84
  syndes team join ~/Dropbox/usage --folder # or no server at all
83
- syndes team name "Ada"
84
85
  ```
85
86
 
87
+ **Adding someone is one line.** `syndes team invite` — or the Invite button on
88
+ the Team page — prints a command you send them:
89
+
90
+ ```
91
+ npm install -g syndes && syndes join syndes1_eyJ2Ijox…
92
+ ```
93
+
94
+ That installs SynDes, works out why git cannot reach the repo, offers to sign
95
+ them in, joins them and starts publishing. The invite carries an address, never
96
+ a credential: who may join is the repository's collaborator list, and only that.
97
+
98
+ | | |
99
+ | --- | --- |
100
+ | `syndes team invite` | a line to send someone |
101
+ | `syndes team status` | who used how much |
102
+ | `syndes team remove <name>` | take a machine out of the pool |
103
+ | `syndes team doctor` | what is stopping git reaching the repo |
104
+
86
105
  Each machine writes only its own folder and reads everyone's, so there is nothing
87
106
  to merge and nothing to run. Open the dashboard and the **Team** page is live
88
107
  while you watch it.
@@ -90,6 +109,11 @@ while you watch it.
90
109
  What leaves your machine: daily counts, tokens, and the hours you worked. What
91
110
  never does: your prompts, your file paths, your commands — at any setting.
92
111
 
112
+ **A pool is one room.** Everyone in it sees everyone else — names, hours and
113
+ volumes — and can read the raw files on GitHub without SynDes. Add only people
114
+ who should all see each other. Removing someone takes them out of the view;
115
+ git keeps the old commits, so it is not an erasure.
116
+
93
117
  This is cooperative measurement, not enforcement. It sees Claude Code on machines
94
118
  where SynDes is installed, and anyone can stop publishing. Note that consumer
95
119
  plans are licensed to one person; per-person logins are both the supported path
package/bin/cli.mjs CHANGED
@@ -42,7 +42,9 @@ import { openUrl } from '../src/open.mjs';
42
42
  import { pendingCount } from '../runtime/spool.mjs';
43
43
  import { detectAll, adapterFor, ADAPTERS } from '../adapters/index.mjs';
44
44
  import { pollTails, resetCursors } from '../collect/tail.mjs';
45
- import { joinPool, leave as leavePool, syncOnce, readPool, isEnabled, teamConfig, loadState, poolRoot } from '../sync/index.mjs';
45
+ import { joinPool, leave as leavePool, syncOnce, readPool, isEnabled, teamConfig, loadState, poolRoot, inviteCode, removeDevice, poolName } from '../sync/index.mjs';
46
+ import { decode as decodeInvite, commandFor } from '../sync/invite.mjs';
47
+ import { diagnose as preflight, repair as repairAuth, ghInstallHint } from '../src/preflight.mjs';
46
48
  import { identity, setName, initialsOf } from '../runtime/identity.mjs';
47
49
  import { team as teamView } from '../analytics/team.mjs';
48
50
 
@@ -52,7 +54,7 @@ const COMMANDS = {
52
54
  install: cmdInstall, uninstall: cmdUninstall, doctor: cmdDoctor,
53
55
  status: cmdStatus, report: cmdReport, dashboard: cmdDashboard, ui: cmdDashboard,
54
56
  lock: cmdLock, verify: cmdVerify, sessions: cmdSessions,
55
- sources: cmdSources, watch: cmdWatch, team: cmdTeam,
57
+ sources: cmdSources, watch: cmdWatch, team: cmdTeam, join: cmdJoin,
56
58
  projects: cmdProjects, practices: cmdPractices, habits: cmdPractices, coach: cmdCoach,
57
59
  ledger: cmdLedger, export: cmdExport, rebuild: cmdRebuild, drain: cmdDrain,
58
60
  archive: cmdArchive, prune: cmdPrune, config: cmdConfig,
@@ -592,6 +594,130 @@ async function cmdWatch(args) {
592
594
  process.on('SIGINT', () => { clearInterval(timer); write(); process.exit(0); });
593
595
  }
594
596
 
597
+ /**
598
+ * `syndes join <invite>` — everything a newcomer has to do, in one command.
599
+ *
600
+ * The person receiving an invite has not read any of this and should not have
601
+ * to. So this installs if it is not installed, works out why git cannot reach
602
+ * the repository, offers to fix that, joins, and publishes — reporting each
603
+ * step rather than failing with one line about credentials.
604
+ */
605
+ async function cmdJoin(args) {
606
+ const raw = args.find((arg) => !arg.startsWith('--'));
607
+ if (!raw) throw new Error('usage: syndes join <invite-code> (the line whoever invited you sent)');
608
+
609
+ const invite = decodeInvite(raw);
610
+ if (!invite) {
611
+ throw new Error('that does not look like an invite — paste the whole line you were sent, or use: syndes team join <repo-url>');
612
+ }
613
+
614
+ banner();
615
+ write(` Joining ${bold(invite.pool ?? 'the pool')}${invite.from ? grey(`, invited by ${invite.from}`) : ''}`);
616
+ write(grey(` ${invite.repo}`));
617
+ write();
618
+
619
+ if (!isInstalledHere()) {
620
+ const step = spinner('Setting up SynDes on this machine');
621
+ await beat();
622
+ await install({});
623
+ step.succeed(displayPath(installDir));
624
+ } else {
625
+ write(` ${grey(DOT)} ${grey('already installed')}`);
626
+ }
627
+
628
+ if (!(await ensureReachable(invite.repo, args))) return;
629
+
630
+ let who = args.find((arg) => arg.startsWith('--name='))?.split('=').slice(1).join('=');
631
+ if (!who && process.stdin.isTTY) {
632
+ write();
633
+ who = (await ask(` Your name, as the others will see it [${identity().name}]: `)).trim();
634
+ }
635
+ if (who) setName(who);
636
+
637
+ write();
638
+ const step = spinner('Joining the pool');
639
+ await beat();
640
+ try {
641
+ joinPool({ transport: invite.transport, repo: invite.repo });
642
+ } catch (error) {
643
+ step.fail(error.message);
644
+ process.exitCode = 1;
645
+ return;
646
+ }
647
+ step.succeed(poolName());
648
+
649
+ const share = spinner('Publishing this machine');
650
+ await beat();
651
+ const state = syncOnce();
652
+ if (state.ok) share.succeed(`${state.published} day(s) shared as ${identity().name}`);
653
+ else share.fail(state.error ?? 'could not publish');
654
+
655
+ const others = readPool().filter((peer) => !peer.isMe);
656
+ write();
657
+ write(` ${OK} you are in${others.length ? grey(` · also here: ${others.map((peer) => peer.name).join(', ')}`) : ''}`);
658
+ write(grey(` ${cyan('syndes dashboard')} then open Team`));
659
+ write();
660
+ }
661
+
662
+ function isInstalledHere() {
663
+ try {
664
+ return (adapterFor('claude-code').status?.().wired ?? 0) > 0;
665
+ } catch {
666
+ return false;
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Check git can reach the repository, and fix it if we can.
672
+ *
673
+ * The confusing case this exists for: accepting a collaborator invite in a
674
+ * browser authorises the ACCOUNT, while git on this machine still cannot prove
675
+ * who it is — and GitHub answers that with "not found" rather than "not you".
676
+ */
677
+ async function ensureReachable(repo, args = []) {
678
+ const step = spinner('Checking access to the repository');
679
+ await beat();
680
+ let report = preflight(repo);
681
+
682
+ if (report.ok) {
683
+ // Only claim an account when a reach check actually ran and passed with it.
684
+ // A folder pool, or a local path, never touches GitHub — saying "as <user>"
685
+ // there would credit an authentication that was not tested.
686
+ const tested = report.checks.some((check) => check.name === 'repository' && check.ok);
687
+ step.succeed(tested && report.gh.account ? `as ${report.gh.account}` : tested ? 'reachable' : 'no sign-in needed');
688
+ return true;
689
+ }
690
+ step.fail(report.blocking.detail);
691
+
692
+ if (report.gh.installed && process.stdin.isTTY && !args.includes('--no-fix')) {
693
+ write();
694
+ write(grey(' This is usually a sign-in, not a missing repository.'));
695
+ if (await confirm(' Sign in to GitHub now?', true)) {
696
+ write();
697
+ const fixed = repairAuth({ interactive: true });
698
+ write();
699
+ if (fixed.ran.length) write(grey(` ran: ${fixed.ran.join(', ')}`));
700
+ const retry = spinner('Checking access again');
701
+ await beat();
702
+ report = preflight(repo);
703
+ if (report.ok) {
704
+ retry.succeed(report.gh.account ? `as ${report.gh.account}` : 'reachable');
705
+ return true;
706
+ }
707
+
708
+ retry.fail(report.blocking.detail);
709
+ }
710
+ }
711
+
712
+ write();
713
+ write(` ${cyan('→')} ${report.blocking.fix}`);
714
+ if (!report.gh.installed) write(` ${cyan('→')} the GitHub CLI makes this one command: ${ghInstallHint()}`);
715
+ write(grey(' then run this again — nothing is lost, recording has already started'));
716
+ write();
717
+ process.exitCode = 1;
718
+ return false;
719
+ }
720
+
595
721
  /**
596
722
  * `syndes team …` — the shared-account pool.
597
723
  *
@@ -659,6 +785,63 @@ async function cmdTeam(args) {
659
785
  return;
660
786
  }
661
787
 
788
+ if (verb === 'invite') {
789
+ if (!isEnabled()) throw new Error('sharing is off — syndes team join <repo> first');
790
+ const code = inviteCode();
791
+ write();
792
+ write(` ${bold('Send this line')} ${grey('· they paste it into a terminal, and that is all')}`);
793
+ write();
794
+ write(` ${cyan(commandFor(code))}`);
795
+ write();
796
+ write(grey(' It installs SynDes, checks their GitHub access, offers to fix it,'));
797
+ write(grey(' and joins them to this pool.'));
798
+ write();
799
+ write(grey(` ${WARN} first add them as a collaborator on ${teamConfig().repo}`));
800
+ write(grey(' The invite is not a key — access is the repository\'s collaborator list.'));
801
+ write(grey(' Everyone in the pool can see everyone else in it.'));
802
+ write();
803
+ return;
804
+ }
805
+
806
+ if (verb === 'remove') {
807
+ const target = rest.filter((arg) => !arg.startsWith('--')).join(' ');
808
+ if (!target) throw new Error('usage: syndes team remove <name>');
809
+ if (!isEnabled()) throw new Error('sharing is off');
810
+
811
+ const peers = readPool();
812
+ const match = peers.find((p) => p.deviceId === target || p.name?.toLowerCase() === target.toLowerCase());
813
+ if (!match) throw new Error(`no machine called "${target}" — ${peers.map((p) => p.name).join(', ')}`);
814
+
815
+ write();
816
+ write(` ${WARN} removing ${bold(match.name)} takes their ${match.rollups.size} published day(s) out of the pool`);
817
+ write(grey(' Their own ledger is untouched. Past totals will change for everyone.'));
818
+ write(grey(' Git keeps the old commits, so this removes them from view, not from history.'));
819
+ write(grey(` To stop them adding more, remove their GitHub access as well.`));
820
+ write();
821
+ if (process.stdin.isTTY && !rest.includes('--yes') && !(await confirm(` Remove ${match.name}?`))) return write('cancelled');
822
+
823
+ const step = spinner(`Removing ${match.name}`);
824
+ await beat();
825
+ const result = removeDevice(match.deviceId);
826
+ if (!result.removed) { step.fail(result.error); process.exitCode = 1; return; }
827
+ if (result.pushed) step.succeed('removed and pushed');
828
+ else step.fail(`removed locally, but the push failed: ${result.error ?? 'unknown'}`);
829
+ return;
830
+ }
831
+
832
+ if (verb === 'doctor') {
833
+ const report = preflight(teamConfig().repo ?? null);
834
+ write();
835
+ for (const check of report.checks) {
836
+ const mark = check.ok === true ? OK : check.ok === false ? FAIL : grey(DOT);
837
+ write(` ${mark} ${check.name.padEnd(12)} ${check.ok === false ? check.detail : grey(check.detail)}`);
838
+ if (check.fix) write(` ${cyan('→')} ${check.fix}`);
839
+ }
840
+ write();
841
+ if (!report.ok) process.exitCode = 1;
842
+ return;
843
+ }
844
+
662
845
  if (verb === 'sync') {
663
846
  if (!isEnabled()) return write(grey('sharing is off — syndes team join <repo>'));
664
847
  const spin = spinner('Exchanging with the pool');
@@ -706,6 +889,8 @@ async function cmdTeam(args) {
706
889
  write();
707
890
  write(` ${grey('overlap')} ${percent(data.grid.overlapRate)} ${grey(`of working hours had more than one of you in them`)}`);
708
891
  write();
892
+ write(grey(` ${cyan('syndes team invite')} to add someone ${DOT} ${cyan('syndes team remove <name>')} ${DOT} ${cyan('syndes team doctor')}`));
893
+ write();
709
894
  }
710
895
 
711
896
  /** `syndes lock [open|system|pin]` */
@@ -801,7 +986,9 @@ function usage_() {
801
986
  [cyan('export --format=csv'), 'all of it, back out'],
802
987
  [cyan('sources'), 'which coding agents SynDes can see'],
803
988
  [cyan('watch [--once]'), 'follow agents that have no hook API'],
804
- [cyan('team [join|status]'), 'share one account and see the split'],
989
+ [cyan('join <invite>'), 'accept an invite — installs and sets everything up'],
990
+ [cyan('team invite'), 'a line to send someone so they can join'],
991
+ [cyan('team [status|remove]'), 'share one account and see the split'],
805
992
  [cyan('lock [mode]'), 'how the dashboard unlocks: open, system or pin'],
806
993
  [cyan('doctor [--full]'), 'what is wired and what actually works'],
807
994
  [cyan('off [30m] / on'), 'pause and resume tracking'],
@@ -15,7 +15,9 @@ import { execFile } from 'node:child_process';
15
15
  import { promisify } from 'node:util';
16
16
  import { join } from 'node:path';
17
17
  import { team } from '../../analytics/team.mjs';
18
- import { joinPool, leave, publish, isEnabled, teamConfig } from '../../sync/index.mjs';
18
+ import { joinPool, leave, publish, isEnabled, teamConfig, inviteCode, removeDevice, poolName } from '../../sync/index.mjs';
19
+ import { commandFor } from '../../sync/invite.mjs';
20
+ import { diagnose as preflight } from '../../src/preflight.mjs';
19
21
  import { setName } from '../../runtime/identity.mjs';
20
22
  import { rawConfig, saveConfig, setPath, resetCache } from '../../runtime/config.mjs';
21
23
  import { packageRoot } from '../../runtime/paths.mjs';
@@ -97,6 +99,32 @@ export async function teamAction({ body }) {
97
99
  return { data: { ok: true, pollSeconds: seconds } };
98
100
  }
99
101
 
102
+ if (action === 'invite') {
103
+ const code = inviteCode();
104
+ return {
105
+ data: {
106
+ ok: true,
107
+ code,
108
+ command: commandFor(code),
109
+ pool: poolName(),
110
+ repo: teamConfig().repo,
111
+ },
112
+ };
113
+ }
114
+
115
+ if (action === 'remove') {
116
+ // Removing someone is the one write outside our own shelf, so it is
117
+ // deliberately a separate action from anything the sync loop performs.
118
+ const result = removeDevice(String(body.deviceId ?? body.name ?? ''));
119
+ if (!result.removed) return { status: 400, data: { error: result.error ?? 'could not remove' } };
120
+ await syncNow();
121
+ return { data: { ok: true, name: result.name, pushed: result.pushed, error: result.error } };
122
+ }
123
+
124
+ if (action === 'check') {
125
+ return { data: preflight(teamConfig().repo ?? null) };
126
+ }
127
+
100
128
  if (action === 'sync') {
101
129
  const state = await syncNow();
102
130
  return { data: { ok: true, sync: state } };
@@ -72,6 +72,8 @@ export const api = {
72
72
 
73
73
  team: (range) => get(`/api/team?range=${encodeURIComponent(range)}`),
74
74
  teamAction: (action, payload = {}) => post('/api/team', { action, ...payload }),
75
+ teamInvite: () => post('/api/team', { action: 'invite' }),
76
+ teamRemove: (deviceId) => post('/api/team', { action: 'remove', deviceId }),
75
77
  // Not fetched through request(): an event stream is a live connection, not a
76
78
  // response with a body, so the view owns it and closes it itself.
77
79
  teamStreamUrl: (range) => `/api/team/stream?range=${encodeURIComponent(range)}`,
@@ -540,6 +540,24 @@ a { color: inherit; text-decoration: none; }
540
540
  .field::placeholder { color: var(--ink-3); }
541
541
  .field:focus-visible { outline: 2px solid var(--lime); outline-offset: 2px; }
542
542
 
543
+ /* The invite line. Monospace and selectable — somebody will always copy it by
544
+ hand rather than trust the button, and a wrapped command must still be one
545
+ correct command when it is pasted. */
546
+ .invite {
547
+ display: block;
548
+ font-family: var(--mono);
549
+ font-size: 11px;
550
+ line-height: 1.6;
551
+ color: var(--lime);
552
+ background: var(--surface-3);
553
+ border-radius: var(--r-chip);
554
+ padding: var(--s3) var(--s4);
555
+ word-break: break-all;
556
+ user-select: all;
557
+ max-height: 140px;
558
+ overflow-y: auto;
559
+ }
560
+
543
561
  /* ── Responsive ───────────────────────────────────────────────────────── */
544
562
 
545
563
  @media (max-width: 1240px) {
@@ -344,6 +344,7 @@ export function modal({
344
344
  title,
345
345
  note = null,
346
346
  input = null, // { placeholder, type, inputmode, maxlength, validate }
347
+ body = null, // extra nodes, rendered above the field
347
348
  confirmLabel = 'Confirm',
348
349
  cancelLabel = 'Cancel',
349
350
  danger = false,
@@ -376,6 +377,7 @@ export function modal({
376
377
  const panel = h('div.modal', { role: 'dialog', 'aria-modal': 'true', 'aria-label': title }, [
377
378
  h('div.modal__title', { text: title }),
378
379
  note ? h('div.modal__note', { text: note }) : null,
380
+ ...[].concat(body ?? []).filter(Boolean),
379
381
  field,
380
382
  field ? error : null,
381
383
  h('div.modal__actions', {}, [
@@ -222,18 +222,26 @@ function peopleCard(data, outlet, range) {
222
222
  String(peer.totals.sessions),
223
223
  duration(peer.totals.activeMs),
224
224
  peer.score === null ? DASH : String(peer.score),
225
+ peer.isMe
226
+ ? h('span', { style: 'color:var(--ink-3)', text: DASH })
227
+ : h('button.btn.btn--ghost.btn--sm', {
228
+ text: 'Remove',
229
+ title: `Remove ${peer.name} from the pool`,
230
+ onclick: () => remove(peer, outlet, range),
231
+ }),
225
232
  ]);
226
233
 
227
234
  const actions = h('div', { style: 'display:flex;gap:8px' }, [
235
+ h('button.btn.btn--lime', { text: 'Invite', onclick: () => invite() }),
228
236
  h('button.btn.btn--ghost', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
229
237
  h('button.btn.btn--ghost', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
230
238
  ]);
231
239
 
232
240
  return card('People', [
233
241
  table(
234
- ['Person', 'Share', 'Tokens', 'Sessions', 'Active', 'Score'],
242
+ ['Person', 'Share', 'Tokens', 'Sessions', 'Active', 'Score', ''],
235
243
  rows,
236
- { align: ['left', 'left', 'right', 'right', 'right', 'right'] },
244
+ { align: ['left', 'left', 'right', 'right', 'right', 'right', 'right'] },
237
245
  ),
238
246
  h('span.card__note', {
239
247
  text: 'A stale shelf has not synced in six hours — that is a machine that stopped reporting, not a quiet day.',
@@ -247,6 +255,92 @@ function peopleCard(data, outlet, range) {
247
255
 
248
256
  // ── Actions ─────────────────────────────────────────────────────────────────
249
257
 
258
+ /**
259
+ * Invite someone.
260
+ *
261
+ * The output is one line they paste into a terminal — not a URL and a list of
262
+ * steps. Everything they would otherwise have to be told (install it, sign in
263
+ * to GitHub, paste the repo, pick a name) is what `syndes join` then does for
264
+ * them, so the thing being copied here is the whole onboarding.
265
+ *
266
+ * The two facts that are easy to get wrong are stated in the dialog rather than
267
+ * left for someone to discover: the invite is not a key, and a pool is one room
268
+ * where everybody can see everybody.
269
+ */
270
+ async function invite() {
271
+ let details;
272
+ try {
273
+ details = await api.teamInvite();
274
+ } catch (error) {
275
+ await modal({ title: 'Could not make an invite', note: error.body?.error ?? error.message, confirmLabel: 'Close', cancelLabel: 'Dismiss' });
276
+ return;
277
+ }
278
+
279
+ const box = h('code.invite', { text: details.command });
280
+ const copy = h('button.btn.btn--lime', { type: 'button', text: 'Copy the line' });
281
+ copy.addEventListener('click', async () => {
282
+ try {
283
+ await navigator.clipboard.writeText(details.command);
284
+ copy.textContent = 'Copied';
285
+ } catch {
286
+ // Clipboard access can be refused; selecting the text still works.
287
+ copy.textContent = 'Select it and copy';
288
+ }
289
+ setTimeout(() => { copy.textContent = 'Copy the line'; }, 2200);
290
+ });
291
+
292
+ await modal({
293
+ title: `Invite someone to ${details.pool}`,
294
+ note: 'Send them this line. It installs SynDes, checks their GitHub access, offers to fix it, and joins them.',
295
+ body: [
296
+ box,
297
+ h('div', { style: 'display:flex;justify-content:center' }, [copy]),
298
+ h('div.modal__note', { style: 'text-align:left;margin-top:4px' }, [
299
+ h('div', { text: `1. Add them as a collaborator on ${details.repo}` }),
300
+ h('div', { text: '2. They accept the invite on GitHub' }),
301
+ h('div', { text: '3. They paste the line above' }),
302
+ ]),
303
+ h('div.modal__note', { style: 'text-align:left;color:var(--orange)' }, [
304
+ h('div', { text: 'Everyone in a pool can see everyone else in it — names, hours and volumes.' }),
305
+ h('div', { text: 'The line is not a key. Access is the repository\'s collaborator list, and only that.' }),
306
+ ]),
307
+ ],
308
+ confirmLabel: 'Done',
309
+ cancelLabel: 'Close',
310
+ });
311
+ }
312
+
313
+ async function remove(peer, outlet, range) {
314
+ const ok = await modal({
315
+ title: `Remove ${peer.name}?`,
316
+ note: `This takes their published days out of the pool for everyone, so past totals will change. Their own ledger is untouched.`,
317
+ body: [
318
+ h('div.modal__note', { style: 'text-align:left' }, [
319
+ h('div', { text: 'Git keeps the old commits, so this removes them from view, not from history.' }),
320
+ h('div', { text: 'To stop them publishing more, remove their access to the repository as well.' }),
321
+ ]),
322
+ ],
323
+ confirmLabel: `Remove ${peer.name}`,
324
+ cancelLabel: 'Keep them',
325
+ danger: true,
326
+ });
327
+ if (!ok) return;
328
+
329
+ try {
330
+ const result = await api.teamRemove(peer.deviceId);
331
+ if (!result.pushed) {
332
+ await modal({
333
+ title: 'Removed here, but not pushed',
334
+ note: result.error ?? 'The change is local until a push succeeds. Try Sync now.',
335
+ confirmLabel: 'Close', cancelLabel: 'Dismiss',
336
+ });
337
+ }
338
+ } catch (error) {
339
+ await modal({ title: 'Could not remove', note: error.body?.error ?? error.message, confirmLabel: 'Close', cancelLabel: 'Dismiss' });
340
+ }
341
+ await refresh(outlet, range);
342
+ }
343
+
250
344
  async function rename(data, outlet, range) {
251
345
  const name = await modal({
252
346
  title: 'What should this machine be called?',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syndes",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "SynDes — a tamper-evident ledger of everything you do in Claude Code, an efficiency score built from it, and a local dashboard that shows you how you actually work. Splits one shared account between the people on it. macOS, Windows and Linux. Zero dependencies.",
5
5
  "keywords": [
6
6
  "syndes",
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Can this machine actually reach the pool — and if not, what fixes it.
3
+ *
4
+ * Joining a shared repository fails for a handful of dull reasons that all
5
+ * surface as the same unhelpful git error, and one of them is genuinely
6
+ * confusing: accepting a collaborator invite in a browser grants the ACCOUNT
7
+ * access, while git on the machine still has no idea who you are. GitHub then
8
+ * returns 404 rather than admit a private repository exists, so the honest
9
+ * message "you are not signed in" arrives disguised as "no such repo".
10
+ *
11
+ * So every check here answers three things: what was tested, what happened, and
12
+ * the exact next command. A check that cannot answer returns `null` rather than
13
+ * guessing — same rule the platform probes follow.
14
+ *
15
+ * Nothing is installed without being asked. Software installs itself on a
16
+ * developer's machine only when they say so; the most this does unattended is
17
+ * teach git to use credentials the user already has.
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process';
21
+ import { which, isMac, isWindows, platformName } from '../runtime/platform.mjs';
22
+ import { debug } from '../runtime/log.mjs';
23
+
24
+ const TIMEOUT_MS = 20_000;
25
+
26
+ function run(binary, args, { timeout = TIMEOUT_MS, quiet = true } = {}) {
27
+ try {
28
+ const out = execFileSync(binary, args, {
29
+ encoding: 'utf8',
30
+ timeout,
31
+ stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
32
+ windowsHide: true,
33
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' },
34
+ });
35
+ return { ok: true, out: (out ?? '').trim(), error: null };
36
+ } catch (error) {
37
+ const stderr = String(error.stderr ?? '').trim();
38
+ return { ok: false, out: '', error: (stderr || error.message).split('\n').slice(-2).join(' ').slice(0, 240) };
39
+ }
40
+ }
41
+
42
+ /** How this platform installs the GitHub CLI. */
43
+ export function ghInstallHint() {
44
+ if (isMac) return which('brew') ? 'brew install gh' : 'see https://cli.github.com (or: brew install gh)';
45
+ if (isWindows) return 'winget install --id GitHub.cli';
46
+ return 'see https://cli.github.com — most distros package it as `gh`';
47
+ }
48
+
49
+ export function gitInstallHint() {
50
+ if (isMac) return 'xcode-select --install';
51
+ if (isWindows) return 'winget install --id Git.Git';
52
+ return 'your package manager, e.g. apt install git';
53
+ }
54
+
55
+ /** @returns {{installed: boolean, authed: boolean, account: string|null, gitConfigured: boolean}} */
56
+ export function ghState() {
57
+ const gh = which('gh');
58
+ if (!gh) return { installed: false, authed: false, account: null, gitConfigured: false };
59
+
60
+ const status = run(gh, ['auth', 'status']);
61
+ const account = /Logged in to \S+ account (\S+)/.exec(`${status.out}\n${status.error ?? ''}`)?.[1] ?? null;
62
+
63
+ // `gh auth setup-git` writes a credential helper entry; without it a gh login
64
+ // authenticates the gh command and nothing else, which is the trap.
65
+ const helper = run(which('git') ?? 'git', ['config', '--get-regexp', 'credential.*helper']);
66
+ return {
67
+ installed: true,
68
+ authed: Boolean(account),
69
+ account,
70
+ gitConfigured: /gh auth git-credential|gh\b/.test(helper.out),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Everything that must be true before a join can work.
76
+ *
77
+ * @param {string|null} repo the remote to test, or null to skip the reach check
78
+ * @returns {{ok: boolean, checks: object[], blocking: object|null}}
79
+ */
80
+ export function diagnose(repo = null) {
81
+ const checks = [];
82
+ const git = which('git');
83
+
84
+ checks.push(git
85
+ ? { name: 'git', ok: true, detail: git }
86
+ : { name: 'git', ok: false, detail: `not installed on this ${platformName}`, fix: gitInstallHint() });
87
+
88
+ const gh = ghState();
89
+ checks.push(gh.installed
90
+ ? { name: 'gh', ok: true, detail: gh.authed ? `signed in as ${gh.account}` : 'installed, not signed in' }
91
+ : { name: 'gh', ok: null, detail: 'not installed — optional, but it is the easiest way to authenticate', fix: ghInstallHint() });
92
+
93
+ if (git && repo && !repo.startsWith('/') && !repo.startsWith('~')) {
94
+ const reach = run(git, ['ls-remote', '--heads', repo]);
95
+ if (reach.ok) {
96
+ checks.push({ name: 'repository', ok: true, detail: 'reachable, and git can authenticate' });
97
+ } else {
98
+ checks.push({
99
+ name: 'repository',
100
+ ok: false,
101
+ detail: explain(reach.error, gh),
102
+ fix: remedy(gh, repo),
103
+ raw: reach.error,
104
+ });
105
+ }
106
+ }
107
+
108
+ const blocking = checks.find((check) => check.ok === false) ?? null;
109
+ return { ok: !blocking, checks, blocking, gh };
110
+ }
111
+
112
+ /**
113
+ * Turn git's message into the actual problem.
114
+ *
115
+ * "Repository not found" on a private repo almost never means the repo is
116
+ * missing — it means GitHub will not confirm it exists to someone it cannot
117
+ * identify. Repeating git's wording would send the user to check the URL, which
118
+ * is the one thing that is usually right.
119
+ */
120
+ function explain(error, gh) {
121
+ const text = String(error ?? '');
122
+ if (/could not read Username|Authentication failed|terminal prompts disabled/i.test(text)) {
123
+ return gh.authed
124
+ ? `git has no credentials for this remote, though gh is signed in as ${gh.account}`
125
+ : 'git has no credentials for this remote';
126
+ }
127
+ if (/not found|does not exist/i.test(text)) {
128
+ return gh.authed
129
+ ? `not visible to ${gh.account} — either the invite is not accepted, or git is not using that account`
130
+ : 'private repositories are invisible until git can prove who you are';
131
+ }
132
+ if (/Permission denied \(publickey\)/i.test(text)) return 'no SSH key on this machine that GitHub recognises';
133
+ if (/Could not resolve host|network/i.test(text)) return 'cannot reach the network';
134
+ return text.slice(0, 160);
135
+ }
136
+
137
+ function remedy(gh, repo) {
138
+ const ssh = repo.startsWith('git@') || repo.startsWith('ssh://');
139
+ if (ssh) return 'add an SSH key to your GitHub account, or use the https:// url instead';
140
+ if (!gh.installed) return `${ghInstallHint()}, then: gh auth login && gh auth setup-git`;
141
+ if (!gh.authed) return 'gh auth login';
142
+ if (!gh.gitConfigured) return 'gh auth setup-git';
143
+ return 'check the collaborator invite has been accepted for this account';
144
+ }
145
+
146
+ /**
147
+ * Fix what can be fixed without installing anything.
148
+ *
149
+ * `gh auth login` is interactive by design — it opens a browser and asks the
150
+ * person to confirm — so it is only attempted when there is a terminal to run
151
+ * it in, and it inherits stdio so the user sees and answers it themselves.
152
+ *
153
+ * @returns {{ran: string[], ok: boolean}}
154
+ */
155
+ export function repair({ interactive = Boolean(process.stdin.isTTY) } = {}) {
156
+ const gh = ghState();
157
+ const ran = [];
158
+ if (!gh.installed) return { ran, ok: false };
159
+
160
+ const binary = which('gh');
161
+ if (!gh.authed) {
162
+ if (!interactive) return { ran, ok: false };
163
+ ran.push('gh auth login');
164
+ const result = run(binary, ['auth', 'login'], { quiet: false, timeout: 300_000 });
165
+ if (!result.ok) return { ran, ok: false };
166
+ }
167
+
168
+ if (!ghState().gitConfigured) {
169
+ ran.push('gh auth setup-git');
170
+ const result = run(binary, ['auth', 'setup-git']);
171
+ if (!result.ok) { debug('gh auth setup-git failed', result.error); return { ran, ok: false }; }
172
+ }
173
+ return { ran, ok: ghState().authed };
174
+ }
package/sync/index.mjs CHANGED
@@ -25,9 +25,11 @@ import { identity } from '../runtime/identity.mjs';
25
25
  import { loadRollup, coveredDays } from '../analytics/rollup.mjs';
26
26
  import { localDay, shiftDay } from '../analytics/ranges.mjs';
27
27
  import { sanitise, manifestFor } from './share.mjs';
28
+ import { encode as encodeInvite } from './invite.mjs';
28
29
  import {
29
- transportFor, listDevices, readJsonFile, writeJsonFile, removeFile,
30
+ transportFor, listDevices, readJsonFile, writeJsonFile, removeFile, git,
30
31
  } from './transport.mjs';
32
+ import { rmSync } from 'node:fs';
31
33
  import { writeFileSync, renameSync } from 'node:fs';
32
34
  import { dirname } from 'node:path';
33
35
  import { debug } from '../runtime/log.mjs';
@@ -192,6 +194,83 @@ export function readPool({ root = poolRoot(), days = null } = {}) {
192
194
  return peers;
193
195
  }
194
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
+
195
274
  // ── One cycle ───────────────────────────────────────────────────────────────
196
275
 
197
276
  /**
@@ -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 };