syndes 0.2.0 → 0.3.3

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,
@@ -526,7 +528,17 @@ async function cmdDashboard(args) {
526
528
 
527
529
  if (loadConfig().dashboard.openOnStart && !args.includes('--no-open')) openUrl(url);
528
530
 
529
- const stop = async () => { await close(); process.exit(0); };
531
+ // A second ctrl-c means "I do not believe you, leave now" — so the first
532
+ // press shuts down politely and the second one does not wait for it.
533
+ let stopping = false;
534
+ const stop = async () => {
535
+ if (stopping) process.exit(0);
536
+ stopping = true;
537
+ write();
538
+ write(grey(' stopping…'));
539
+ await close();
540
+ process.exit(0);
541
+ };
530
542
  process.on('SIGINT', stop);
531
543
  process.on('SIGTERM', stop);
532
544
  }
@@ -592,6 +604,130 @@ async function cmdWatch(args) {
592
604
  process.on('SIGINT', () => { clearInterval(timer); write(); process.exit(0); });
593
605
  }
594
606
 
607
+ /**
608
+ * `syndes join <invite>` — everything a newcomer has to do, in one command.
609
+ *
610
+ * The person receiving an invite has not read any of this and should not have
611
+ * to. So this installs if it is not installed, works out why git cannot reach
612
+ * the repository, offers to fix that, joins, and publishes — reporting each
613
+ * step rather than failing with one line about credentials.
614
+ */
615
+ async function cmdJoin(args) {
616
+ const raw = args.find((arg) => !arg.startsWith('--'));
617
+ if (!raw) throw new Error('usage: syndes join <invite-code> (the line whoever invited you sent)');
618
+
619
+ const invite = decodeInvite(raw);
620
+ if (!invite) {
621
+ throw new Error('that does not look like an invite — paste the whole line you were sent, or use: syndes team join <repo-url>');
622
+ }
623
+
624
+ banner();
625
+ write(` Joining ${bold(invite.pool ?? 'the pool')}${invite.from ? grey(`, invited by ${invite.from}`) : ''}`);
626
+ write(grey(` ${invite.repo}`));
627
+ write();
628
+
629
+ if (!isInstalledHere()) {
630
+ const step = spinner('Setting up SynDes on this machine');
631
+ await beat();
632
+ await install({});
633
+ step.succeed(displayPath(installDir));
634
+ } else {
635
+ write(` ${grey(DOT)} ${grey('already installed')}`);
636
+ }
637
+
638
+ if (!(await ensureReachable(invite.repo, args))) return;
639
+
640
+ let who = args.find((arg) => arg.startsWith('--name='))?.split('=').slice(1).join('=');
641
+ if (!who && process.stdin.isTTY) {
642
+ write();
643
+ who = (await ask(` Your name, as the others will see it [${identity().name}]: `)).trim();
644
+ }
645
+ if (who) setName(who);
646
+
647
+ write();
648
+ const step = spinner('Joining the pool');
649
+ await beat();
650
+ try {
651
+ joinPool({ transport: invite.transport, repo: invite.repo });
652
+ } catch (error) {
653
+ step.fail(error.message);
654
+ process.exitCode = 1;
655
+ return;
656
+ }
657
+ step.succeed(poolName());
658
+
659
+ const share = spinner('Publishing this machine');
660
+ await beat();
661
+ const state = syncOnce();
662
+ if (state.ok) share.succeed(`${state.published} day(s) shared as ${identity().name}`);
663
+ else share.fail(state.error ?? 'could not publish');
664
+
665
+ const others = readPool().filter((peer) => !peer.isMe);
666
+ write();
667
+ write(` ${OK} you are in${others.length ? grey(` · also here: ${others.map((peer) => peer.name).join(', ')}`) : ''}`);
668
+ write(grey(` ${cyan('syndes dashboard')} then open Team`));
669
+ write();
670
+ }
671
+
672
+ function isInstalledHere() {
673
+ try {
674
+ return (adapterFor('claude-code').status?.().wired ?? 0) > 0;
675
+ } catch {
676
+ return false;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Check git can reach the repository, and fix it if we can.
682
+ *
683
+ * The confusing case this exists for: accepting a collaborator invite in a
684
+ * browser authorises the ACCOUNT, while git on this machine still cannot prove
685
+ * who it is — and GitHub answers that with "not found" rather than "not you".
686
+ */
687
+ async function ensureReachable(repo, args = []) {
688
+ const step = spinner('Checking access to the repository');
689
+ await beat();
690
+ let report = preflight(repo);
691
+
692
+ if (report.ok) {
693
+ // Only claim an account when a reach check actually ran and passed with it.
694
+ // A folder pool, or a local path, never touches GitHub — saying "as <user>"
695
+ // there would credit an authentication that was not tested.
696
+ const tested = report.checks.some((check) => check.name === 'repository' && check.ok);
697
+ step.succeed(tested && report.gh.account ? `as ${report.gh.account}` : tested ? 'reachable' : 'no sign-in needed');
698
+ return true;
699
+ }
700
+ step.fail(report.blocking.detail);
701
+
702
+ if (report.gh.installed && process.stdin.isTTY && !args.includes('--no-fix')) {
703
+ write();
704
+ write(grey(' This is usually a sign-in, not a missing repository.'));
705
+ if (await confirm(' Sign in to GitHub now?', true)) {
706
+ write();
707
+ const fixed = repairAuth({ interactive: true });
708
+ write();
709
+ if (fixed.ran.length) write(grey(` ran: ${fixed.ran.join(', ')}`));
710
+ const retry = spinner('Checking access again');
711
+ await beat();
712
+ report = preflight(repo);
713
+ if (report.ok) {
714
+ retry.succeed(report.gh.account ? `as ${report.gh.account}` : 'reachable');
715
+ return true;
716
+ }
717
+
718
+ retry.fail(report.blocking.detail);
719
+ }
720
+ }
721
+
722
+ write();
723
+ write(` ${cyan('→')} ${report.blocking.fix}`);
724
+ if (!report.gh.installed) write(` ${cyan('→')} the GitHub CLI makes this one command: ${ghInstallHint()}`);
725
+ write(grey(' then run this again — nothing is lost, recording has already started'));
726
+ write();
727
+ process.exitCode = 1;
728
+ return false;
729
+ }
730
+
595
731
  /**
596
732
  * `syndes team …` — the shared-account pool.
597
733
  *
@@ -659,6 +795,63 @@ async function cmdTeam(args) {
659
795
  return;
660
796
  }
661
797
 
798
+ if (verb === 'invite') {
799
+ if (!isEnabled()) throw new Error('sharing is off — syndes team join <repo> first');
800
+ const code = inviteCode();
801
+ write();
802
+ write(` ${bold('Send this line')} ${grey('· they paste it into a terminal, and that is all')}`);
803
+ write();
804
+ write(` ${cyan(commandFor(code))}`);
805
+ write();
806
+ write(grey(' It installs SynDes, checks their GitHub access, offers to fix it,'));
807
+ write(grey(' and joins them to this pool.'));
808
+ write();
809
+ write(grey(` ${WARN} first add them as a collaborator on ${teamConfig().repo}`));
810
+ write(grey(' The invite is not a key — access is the repository\'s collaborator list.'));
811
+ write(grey(' Everyone in the pool can see everyone else in it.'));
812
+ write();
813
+ return;
814
+ }
815
+
816
+ if (verb === 'remove') {
817
+ const target = rest.filter((arg) => !arg.startsWith('--')).join(' ');
818
+ if (!target) throw new Error('usage: syndes team remove <name>');
819
+ if (!isEnabled()) throw new Error('sharing is off');
820
+
821
+ const peers = readPool();
822
+ const match = peers.find((p) => p.deviceId === target || p.name?.toLowerCase() === target.toLowerCase());
823
+ if (!match) throw new Error(`no machine called "${target}" — ${peers.map((p) => p.name).join(', ')}`);
824
+
825
+ write();
826
+ write(` ${WARN} removing ${bold(match.name)} takes their ${match.rollups.size} published day(s) out of the pool`);
827
+ write(grey(' Their own ledger is untouched. Past totals will change for everyone.'));
828
+ write(grey(' Git keeps the old commits, so this removes them from view, not from history.'));
829
+ write(grey(` To stop them adding more, remove their GitHub access as well.`));
830
+ write();
831
+ if (process.stdin.isTTY && !rest.includes('--yes') && !(await confirm(` Remove ${match.name}?`))) return write('cancelled');
832
+
833
+ const step = spinner(`Removing ${match.name}`);
834
+ await beat();
835
+ const result = removeDevice(match.deviceId);
836
+ if (!result.removed) { step.fail(result.error); process.exitCode = 1; return; }
837
+ if (result.pushed) step.succeed('removed and pushed');
838
+ else step.fail(`removed locally, but the push failed: ${result.error ?? 'unknown'}`);
839
+ return;
840
+ }
841
+
842
+ if (verb === 'doctor') {
843
+ const report = preflight(teamConfig().repo ?? null);
844
+ write();
845
+ for (const check of report.checks) {
846
+ const mark = check.ok === true ? OK : check.ok === false ? FAIL : grey(DOT);
847
+ write(` ${mark} ${check.name.padEnd(12)} ${check.ok === false ? check.detail : grey(check.detail)}`);
848
+ if (check.fix) write(` ${cyan('→')} ${check.fix}`);
849
+ }
850
+ write();
851
+ if (!report.ok) process.exitCode = 1;
852
+ return;
853
+ }
854
+
662
855
  if (verb === 'sync') {
663
856
  if (!isEnabled()) return write(grey('sharing is off — syndes team join <repo>'));
664
857
  const spin = spinner('Exchanging with the pool');
@@ -706,6 +899,8 @@ async function cmdTeam(args) {
706
899
  write();
707
900
  write(` ${grey('overlap')} ${percent(data.grid.overlapRate)} ${grey(`of working hours had more than one of you in them`)}`);
708
901
  write();
902
+ write(grey(` ${cyan('syndes team invite')} to add someone ${DOT} ${cyan('syndes team remove <name>')} ${DOT} ${cyan('syndes team doctor')}`));
903
+ write();
709
904
  }
710
905
 
711
906
  /** `syndes lock [open|system|pin]` */
@@ -801,7 +996,9 @@ function usage_() {
801
996
  [cyan('export --format=csv'), 'all of it, back out'],
802
997
  [cyan('sources'), 'which coding agents SynDes can see'],
803
998
  [cyan('watch [--once]'), 'follow agents that have no hook API'],
804
- [cyan('team [join|status]'), 'share one account and see the split'],
999
+ [cyan('join <invite>'), 'accept an invite — installs and sets everything up'],
1000
+ [cyan('team invite'), 'a line to send someone so they can join'],
1001
+ [cyan('team [status|remove]'), 'share one account and see the split'],
805
1002
  [cyan('lock [mode]'), 'how the dashboard unlocks: open, system or pin'],
806
1003
  [cyan('doctor [--full]'), 'what is wired and what actually works'],
807
1004
  [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 } };
@@ -32,6 +32,21 @@ export const MODES = ['open', 'system', 'pin'];
32
32
  /** Lives only in this process: a launch token dies with the server that made it. */
33
33
  let launchToken = null;
34
34
 
35
+ /**
36
+ * This run of the server.
37
+ *
38
+ * A lock means "ask me", and the answer has to be asked again each time the
39
+ * dashboard starts. Without this the session cookie was checked only against
40
+ * auth.json, which survives a restart — so turning on Touch ID or a PIN asked
41
+ * once, and for the next twelve hours anybody who opened the dashboard walked
42
+ * straight in. The lock looked set and was not enforced, which is worse than no
43
+ * lock, because the user believes it.
44
+ *
45
+ * Bound only in `system` and `pin` modes. `open` has no lock to enforce and a
46
+ * session that outlives the process there is a convenience, not a bypass.
47
+ */
48
+ let instance = randomBytes(9).toString('base64url');
49
+
35
50
  // ── Stored state ───────────────────────────────────────────────────────────
36
51
 
37
52
  export function loadAuth() {
@@ -174,6 +189,8 @@ export function systemAvailable() {
174
189
  export function issueToken(now = Date.now()) {
175
190
  const auth = ensureAuth();
176
191
  const payload = { exp: now + SESSION_MS, epoch: auth.sessionEpoch ?? 0, nonce: randomBytes(9).toString('base64url') };
192
+ // Stamp the run that issued it, so a restart asks again.
193
+ if ((auth.mode ?? 'open') !== 'open') payload.inst = instance;
177
194
  const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
178
195
  return `${body}.${sign(body, auth.secret)}`;
179
196
  }
@@ -195,6 +212,12 @@ export function verifyToken(token) {
195
212
 
196
213
  if (Date.now() > (payload.exp ?? 0)) return { ok: false, reason: 'expired' };
197
214
  if ((payload.epoch ?? -1) !== (auth.sessionEpoch ?? 0)) return { ok: false, reason: 'revoked' };
215
+
216
+ // Under a lock, a session belongs to the run that issued it. A cookie from a
217
+ // previous run — or one minted while the door was open — does not get in.
218
+ if ((auth.mode ?? 'open') !== 'open' && payload.inst !== instance) {
219
+ return { ok: false, reason: 'the dashboard was restarted' };
220
+ }
198
221
  return { ok: true, reason: null };
199
222
  }
200
223
 
@@ -232,4 +255,16 @@ function safeEqual(a, b) {
232
255
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
233
256
  }
234
257
 
258
+ /**
259
+ * Forget every session this run issued, as a restart would.
260
+ *
261
+ * Exists for the tests: a restart is otherwise only reproducible by spawning a
262
+ * process, and the property being checked — that a lock is re-asked — deserves
263
+ * a direct test as well as an end-to-end one.
264
+ */
265
+ export function resetInstance() {
266
+ instance = randomBytes(9).toString('base64url');
267
+ return instance;
268
+ }
269
+
235
270
  export { SESSION_MS, ensureAuth };
@@ -42,6 +42,19 @@ export async function startServer({ port, idleTimeoutMinutes } = {}) {
42
42
  send(res, 500, { error: 'internal error' });
43
43
  }); });
44
44
 
45
+ // Every open socket, so shutdown can hang them up.
46
+ //
47
+ // server.close() stops accepting new connections and then waits for the
48
+ // existing ones to finish — which for a browser keep-alive is "eventually"
49
+ // and for an event stream is "never". Without this, ctrl-c printed ^C and
50
+ // hung, and every further press queued another close callback until Node
51
+ // warned about a listener leak at eleven.
52
+ const sockets = new Set();
53
+ server.on('connection', (socket) => {
54
+ sockets.add(socket);
55
+ socket.on('close', () => sockets.delete(socket));
56
+ });
57
+
45
58
  let idleTimer = null;
46
59
  const touch = () => {
47
60
  if (!idleMs) return;
@@ -68,13 +81,36 @@ export async function startServer({ port, idleTimeoutMinutes } = {}) {
68
81
  const token = mintLaunchToken();
69
82
  const base = `http://${HOST}:${server.address().port}`;
70
83
  const url = authMode() === 'open' ? `${base}/?t=${token}` : base;
71
- return {
72
- url,
73
- base,
74
- port: server.address().port,
75
- server,
76
- close: () => new Promise((resolve) => { clearTimeout(idleTimer); server.close(resolve); }),
84
+
85
+ /**
86
+ * Stop listening and hang up.
87
+ *
88
+ * Idempotent: calling it twice must not register a second close callback,
89
+ * because the thing that calls it twice is somebody pressing ctrl-c again
90
+ * when the first press appeared to do nothing.
91
+ */
92
+ let closing = null;
93
+ const close = () => {
94
+ if (closing) return closing;
95
+ closing = new Promise((resolve) => {
96
+ clearTimeout(idleTimer);
97
+ let done = false;
98
+ const finish = () => { if (!done) { done = true; resolve(); } };
99
+
100
+ server.close(finish);
101
+ // Then end what is already open, oldest first — an event stream and a
102
+ // keep-alive both sit here forever otherwise.
103
+ for (const socket of sockets) socket.destroy();
104
+ sockets.clear();
105
+
106
+ // A socket wedged in a kernel buffer must not hold the process open.
107
+ const failsafe = setTimeout(finish, 2000);
108
+ failsafe.unref?.();
109
+ });
110
+ return closing;
77
111
  };
112
+
113
+ return { url, base, port: server.address().port, server, close, sockets };
78
114
  }
79
115
 
80
116
  async function handle(req, res, port) {
@@ -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,72 @@ 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
+ /* A grid that reflows on available width rather than on a breakpoint. Cards
544
+ claim a column while one fits and wrap when it does not, so the same markup
545
+ suits a laptop and a wide monitor without a media query choosing for it. */
546
+ .autogrid {
547
+ display: grid;
548
+ gap: var(--s5);
549
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
550
+ align-items: stretch;
551
+ }
552
+ .autogrid--wide { grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
553
+ .team { align-content: start; }
554
+
555
+ /* ── People ───────────────────────────────────────────────────────────── */
556
+
557
+ /* Rows, not a table. Stacking within a person rather than across them is what
558
+ lets a narrow card drop a line instead of truncating a name. */
559
+ .people { display: grid; }
560
+ .person {
561
+ display: grid;
562
+ grid-template-columns: auto minmax(0, 1fr) auto;
563
+ gap: var(--s3);
564
+ align-items: start;
565
+ padding: var(--s4) 0;
566
+ border-top: 1px solid var(--line);
567
+ }
568
+ .person:first-child { padding-top: var(--s2); border-top: 0; }
569
+ .person > .avatar { margin-top: 2px; }
570
+
571
+ .person__head { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
572
+ .person__name {
573
+ font-weight: 650;
574
+ min-width: 0;
575
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
576
+ }
577
+ .person__tag { font-size: 11px; color: var(--ink-3); }
578
+ /* Pushed right, and never wrapped away from the name it belongs to. */
579
+ .person__share { margin-left: auto; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
580
+
581
+ .person__stats { display: flex; flex-wrap: wrap; gap: 2px var(--s4); font-size: 12px; color: var(--ink-3); }
582
+ .person__stat b { color: var(--ink); font-weight: 650; font-variant-numeric: tabular-nums; }
583
+
584
+ @media (max-width: 520px) {
585
+ /* Below this the remove control cannot sit beside the row without squeezing
586
+ the name, so it moves under it rather than off the edge. */
587
+ .person { grid-template-columns: auto minmax(0, 1fr); }
588
+ .person > .btn { grid-column: 2; justify-self: start; }
589
+ }
590
+
591
+ /* The invite line. Monospace and selectable — somebody will always copy it by
592
+ hand rather than trust the button, and a wrapped command must still be one
593
+ correct command when it is pasted. */
594
+ .invite {
595
+ display: block;
596
+ font-family: var(--mono);
597
+ font-size: 11px;
598
+ line-height: 1.6;
599
+ color: var(--lime);
600
+ background: var(--surface-3);
601
+ border-radius: var(--r-chip);
602
+ padding: var(--s3) var(--s4);
603
+ word-break: break-all;
604
+ user-select: all;
605
+ max-height: 140px;
606
+ overflow-y: auto;
607
+ }
608
+
543
609
  /* ── Responsive ───────────────────────────────────────────────────────── */
544
610
 
545
611
  @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', {}, [
@@ -6,7 +6,7 @@
6
6
  * 2. What is the pool doing? totals, and whether the numbers are live
7
7
  * 3. Were we in each other's way? the contention grid — the one thing
8
8
  * neither person can see from their own machine
9
- * 4. How did each day break down? the daily split, and the people table
9
+ * 4. How did each day break down? the daily split, and who is in the pool
10
10
  *
11
11
  * The page is live while it is open: it holds one event stream, and the server
12
12
  * polls the pool only while that stream exists. When the view is replaced the
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { api } from '../api.js';
18
18
  import {
19
- h, card, figure, trend, chip, dot, meter, legend, empty, table, modal,
19
+ h, card, figure, trend, chip, dot, meter, legend, empty, modal,
20
20
  compact, usd, duration, percent, when, DASH,
21
21
  } from '../ui.js';
22
22
  import { dotMatrix, laneSplit, splitBar } from '../charts.js';
@@ -24,13 +24,14 @@ import { dotMatrix, laneSplit, splitBar } from '../charts.js';
24
24
  export async function render({ range }) {
25
25
  const data = await api.team(range);
26
26
 
27
- const outlet = h('div.content--fit', {
28
- style: 'display:grid;grid-template-rows:auto minmax(0,1fr);gap:var(--s4);min-height:0',
29
- });
27
+ // Not `content--fit`. That pins the page to the viewport and divides what is
28
+ // left between two rows, which is right for a dense screen and wrong here:
29
+ // with one person in the pool the cards have little in them, and forcing them
30
+ // to fill a tall window leaves most of it empty. Letting the page size to its
31
+ // content and scroll adapts to any window instead of assuming one.
32
+ const outlet = h('div.content.team', {});
30
33
 
31
34
  if (!data.enabled) {
32
- outlet.style.display = 'grid';
33
- outlet.style.gridTemplateRows = 'minmax(0,1fr)';
34
35
  outlet.appendChild(setup(range, outlet));
35
36
  return outlet;
36
37
  }
@@ -79,12 +80,12 @@ function connect(outlet, range) {
79
80
  function paint(outlet, data, range, { live = false } = {}) {
80
81
  const me = data.peers.find((peer) => peer.isMe) ?? null;
81
82
 
83
+ // One auto-fitting grid rather than two fixed rows. Cards claim a column when
84
+ // there is room for one and wrap when there is not, so the same markup works
85
+ // on a laptop and on a wide monitor without a breakpoint deciding for it.
82
86
  outlet.replaceChildren(
83
- h('div.grid.g-3', {}, [shareCard(data, me), poolCard(data, live), contentionCard(data)]),
84
- h('div.grid.fill', { style: 'grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)' }, [
85
- splitCard(data),
86
- peopleCard(data, outlet, range),
87
- ]),
87
+ h('div.autogrid', {}, [shareCard(data, me), poolCard(data, live), contentionCard(data)]),
88
+ h('div.autogrid.autogrid--wide', {}, [splitCard(data), peopleCard(data, outlet, range)]),
88
89
  );
89
90
  }
90
91
 
@@ -106,9 +107,13 @@ function shareCard(data, me) {
106
107
  }) : null,
107
108
  }),
108
109
  h('div', { style: 'display:grid;gap:4px' }, [
109
- h('span.card__note', { text: `Even split would be ${percent(fair)}` }),
110
+ // Meaningless while you are the only one here — "an even split would be
111
+ // 100%" is true and says nothing.
112
+ data.peers.length > 1 ? h('span.card__note', { text: `Even split would be ${percent(fair)}` }) : null,
110
113
  h('span.card__note', {
111
- text: `${compact(me?.totals.tokens ?? 0)} of ${compact(data.totals.tokens)} tokens`,
114
+ text: data.peers.length > 1
115
+ ? `${compact(me?.totals.tokens ?? 0)} of ${compact(data.totals.tokens)} tokens`
116
+ : `${compact(data.totals.tokens)} tokens · nobody else has joined yet`,
112
117
  }),
113
118
  ]),
114
119
  ]),
@@ -169,7 +174,9 @@ function contentionCard(data) {
169
174
  return card('When each of you worked', [
170
175
  h('div', { style: 'display:flex;align-items:baseline;gap:var(--s3)' }, [
171
176
  figure('of working hours overlapped', percent(grid.overlapRate), { small: true }),
172
- overlapping ? chip(`${grid.overlapBands} bands`, 'orange') : chip('no overlap', 'lime'),
177
+ overlapping
178
+ ? chip(`${grid.overlapBands} band${grid.overlapBands === 1 ? '' : 's'}`, 'orange')
179
+ : chip('no overlap', 'lime'),
173
180
  ]),
174
181
  dotMatrix(grid),
175
182
  legend([
@@ -201,52 +208,164 @@ function splitCard(data) {
201
208
  }
202
209
 
203
210
  // 4b. The people
211
+ /**
212
+ * One block per person, not a table.
213
+ *
214
+ * This was a seven-column table, and it did not survive contact with a real
215
+ * card: the name truncated to "Gurupra…", "3h 21m" wrapped and clipped, and the
216
+ * remove control was pushed off the right edge behind a scrollbar. A table needs
217
+ * a width it can rely on, and a card in a reflowing grid cannot promise one.
218
+ *
219
+ * Rows fix that by stacking within each person rather than across them — the
220
+ * name gets the width, the numbers wrap as a group, and nothing is ever cut off.
221
+ */
204
222
  function peopleCard(data, outlet, range) {
205
- const rows = data.peers.map((peer) => [
206
- h('div', { style: 'display:flex;align-items:center;gap:9px;min-width:0' }, [
207
- h('span.avatar', { text: peer.initials, title: peer.host ?? peer.deviceId }),
208
- h('div', { style: 'display:grid;min-width:0' }, [
209
- h('span', {
210
- style: 'font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
211
- text: peer.name,
212
- }),
213
- h('span.card__note', { text: peer.isMe ? 'this machine' : (peer.host ?? DASH) }),
214
- ]),
215
- peer.stale ? chip('stale', 'orange') : null,
223
+ const actions = h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap' }, [
224
+ h('button.btn.btn--lime.btn--sm', { text: 'Invite', onclick: () => invite() }),
225
+ h('button.btn.btn--ghost.btn--sm', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
226
+ h('button.btn.btn--ghost.btn--sm', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
227
+ ]);
228
+
229
+ return card('People', [
230
+ h('div.people', {}, data.peers.map((peer) => personRow(peer, outlet, range))),
231
+
232
+ data.peers.length === 1
233
+ ? h('span.card__note', { text: 'Only this machine so far. Invite gives you a line to send someone.' })
234
+ : h('span.card__note', { text: 'A stale shelf has not synced in six hours — a machine that stopped reporting, not a quiet day.' }),
235
+
236
+ h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap;padding-top:var(--s2)' }, [
237
+ h('button.btn.btn--ghost.btn--sm', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
238
+ h('button.btn.btn--ghost.btn--sm', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
216
239
  ]),
217
- h('div', { style: 'display:grid;gap:5px;min-width:90px' }, [
218
- h('span.tnum', { style: 'font-size:12px', text: percent(peer.share) }),
240
+ ], { actions });
241
+ }
242
+
243
+ function personRow(peer, outlet, range) {
244
+ return h('div.person', {}, [
245
+ h('span.avatar', { text: peer.initials, title: peer.host ?? peer.deviceId }),
246
+
247
+ h('div', { style: 'min-width:0;display:grid;gap:6px' }, [
248
+ h('div.person__head', {}, [
249
+ h('span.person__name', { text: peer.name, title: peer.name }),
250
+ peer.isMe ? h('span.person__tag', { text: 'this machine' }) : null,
251
+ peer.stale ? chip('stale', 'orange') : null,
252
+ h('span.person__share', { text: percent(peer.share) }),
253
+ ]),
254
+
219
255
  meter(peer.share, peer.isMe ? 'lime' : 'white'),
256
+
257
+ // Wraps as a group rather than being squeezed into columns, so a narrow
258
+ // card loses a line instead of losing a number.
259
+ h('div.person__stats', {}, [
260
+ stat(compact(peer.totals.tokens), 'tokens'),
261
+ stat(String(peer.totals.sessions), peer.totals.sessions === 1 ? 'session' : 'sessions'),
262
+ stat(duration(peer.totals.activeMs), 'active'),
263
+ stat(peer.score === null ? DASH : String(peer.score), 'score'),
264
+ ]),
220
265
  ]),
221
- compact(peer.totals.tokens),
222
- String(peer.totals.sessions),
223
- duration(peer.totals.activeMs),
224
- peer.score === null ? DASH : String(peer.score),
225
- ]);
226
266
 
227
- const actions = h('div', { style: 'display:flex;gap:8px' }, [
228
- h('button.btn.btn--ghost', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
229
- h('button.btn.btn--ghost', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
267
+ peer.isMe
268
+ ? null
269
+ : h('button.btn.btn--ghost.btn--sm', {
270
+ text: 'Remove',
271
+ title: `Remove ${peer.name} from the pool`,
272
+ onclick: () => remove(peer, outlet, range),
273
+ }),
230
274
  ]);
275
+ }
231
276
 
232
- return card('People', [
233
- table(
234
- ['Person', 'Share', 'Tokens', 'Sessions', 'Active', 'Score'],
235
- rows,
236
- { align: ['left', 'left', 'right', 'right', 'right', 'right'] },
237
- ),
238
- h('span.card__note', {
239
- text: 'A stale shelf has not synced in six hours — that is a machine that stopped reporting, not a quiet day.',
240
- }),
241
- h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap' }, [
242
- h('button.btn.btn--ghost', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
243
- h('button.btn.btn--ghost', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
244
- ]),
245
- ], { actions });
277
+ function stat(value, label) {
278
+ return h('span.person__stat', {}, [h('b', { text: value }), h('span', { text: ` ${label}` })]);
246
279
  }
247
280
 
248
281
  // ── Actions ─────────────────────────────────────────────────────────────────
249
282
 
283
+ /**
284
+ * Invite someone.
285
+ *
286
+ * The output is one line they paste into a terminal — not a URL and a list of
287
+ * steps. Everything they would otherwise have to be told (install it, sign in
288
+ * to GitHub, paste the repo, pick a name) is what `syndes join` then does for
289
+ * them, so the thing being copied here is the whole onboarding.
290
+ *
291
+ * The two facts that are easy to get wrong are stated in the dialog rather than
292
+ * left for someone to discover: the invite is not a key, and a pool is one room
293
+ * where everybody can see everybody.
294
+ */
295
+ async function invite() {
296
+ let details;
297
+ try {
298
+ details = await api.teamInvite();
299
+ } catch (error) {
300
+ await modal({ title: 'Could not make an invite', note: error.body?.error ?? error.message, confirmLabel: 'Close', cancelLabel: 'Dismiss' });
301
+ return;
302
+ }
303
+
304
+ const box = h('code.invite', { text: details.command });
305
+ const copy = h('button.btn.btn--lime', { type: 'button', text: 'Copy the line' });
306
+ copy.addEventListener('click', async () => {
307
+ try {
308
+ await navigator.clipboard.writeText(details.command);
309
+ copy.textContent = 'Copied';
310
+ } catch {
311
+ // Clipboard access can be refused; selecting the text still works.
312
+ copy.textContent = 'Select it and copy';
313
+ }
314
+ setTimeout(() => { copy.textContent = 'Copy the line'; }, 2200);
315
+ });
316
+
317
+ await modal({
318
+ title: `Invite someone to ${details.pool}`,
319
+ note: 'Send them this line. It installs SynDes, checks their GitHub access, offers to fix it, and joins them.',
320
+ body: [
321
+ box,
322
+ h('div', { style: 'display:flex;justify-content:center' }, [copy]),
323
+ h('div.modal__note', { style: 'text-align:left;margin-top:4px' }, [
324
+ h('div', { text: `1. Add them as a collaborator on ${details.repo}` }),
325
+ h('div', { text: '2. They accept the invite on GitHub' }),
326
+ h('div', { text: '3. They paste the line above' }),
327
+ ]),
328
+ h('div.modal__note', { style: 'text-align:left;color:var(--orange)' }, [
329
+ h('div', { text: 'Everyone in a pool can see everyone else in it — names, hours and volumes.' }),
330
+ h('div', { text: 'The line is not a key. Access is the repository\'s collaborator list, and only that.' }),
331
+ ]),
332
+ ],
333
+ confirmLabel: 'Done',
334
+ cancelLabel: 'Close',
335
+ });
336
+ }
337
+
338
+ async function remove(peer, outlet, range) {
339
+ const ok = await modal({
340
+ title: `Remove ${peer.name}?`,
341
+ note: `This takes their published days out of the pool for everyone, so past totals will change. Their own ledger is untouched.`,
342
+ body: [
343
+ h('div.modal__note', { style: 'text-align:left' }, [
344
+ h('div', { text: 'Git keeps the old commits, so this removes them from view, not from history.' }),
345
+ h('div', { text: 'To stop them publishing more, remove their access to the repository as well.' }),
346
+ ]),
347
+ ],
348
+ confirmLabel: `Remove ${peer.name}`,
349
+ cancelLabel: 'Keep them',
350
+ danger: true,
351
+ });
352
+ if (!ok) return;
353
+
354
+ try {
355
+ const result = await api.teamRemove(peer.deviceId);
356
+ if (!result.pushed) {
357
+ await modal({
358
+ title: 'Removed here, but not pushed',
359
+ note: result.error ?? 'The change is local until a push succeeds. Try Sync now.',
360
+ confirmLabel: 'Close', cancelLabel: 'Dismiss',
361
+ });
362
+ }
363
+ } catch (error) {
364
+ await modal({ title: 'Could not remove', note: error.body?.error ?? error.message, confirmLabel: 'Close', cancelLabel: 'Dismiss' });
365
+ }
366
+ await refresh(outlet, range);
367
+ }
368
+
250
369
  async function rename(data, outlet, range) {
251
370
  const name = await modal({
252
371
  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.3",
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 };