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 +25 -1
- package/bin/cli.mjs +190 -3
- package/dashboard/api/team.mjs +29 -1
- package/dashboard/web/api.js +2 -0
- package/dashboard/web/app.css +18 -0
- package/dashboard/web/ui.js +2 -0
- package/dashboard/web/views/team.js +96 -2
- package/package.json +1 -1
- package/src/preflight.mjs +174 -0
- package/sync/index.mjs +80 -1
- package/sync/invite.mjs +70 -0
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('
|
|
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'],
|
package/dashboard/api/team.mjs
CHANGED
|
@@ -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 } };
|
package/dashboard/web/api.js
CHANGED
|
@@ -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)}`,
|
package/dashboard/web/app.css
CHANGED
|
@@ -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) {
|
package/dashboard/web/ui.js
CHANGED
|
@@ -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.
|
|
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
|
/**
|
package/sync/invite.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invites: one string that carries everything a new machine needs to join.
|
|
3
|
+
*
|
|
4
|
+
* The alternative is a paragraph of instructions — "install this, then run that,
|
|
5
|
+
* paste this URL, use your own GitHub" — which is where onboarding goes to die.
|
|
6
|
+
* An invite collapses it into a line the inviter copies and the joiner pastes.
|
|
7
|
+
*
|
|
8
|
+
* ── An invite is NOT a credential ──────────────────────────────────────────
|
|
9
|
+
* It carries a repository address and a label, nothing else. Access is decided
|
|
10
|
+
* entirely by whether GitHub lets that person in, which the repository owner
|
|
11
|
+
* controls with collaborator permissions and can revoke. Encoding a token here
|
|
12
|
+
* would mean the inviter's own credentials travelling through a chat app, which
|
|
13
|
+
* is exactly the practice this tool should not be teaching. So the invite is
|
|
14
|
+
* base64 for tidiness, not for secrecy, and it is safe to say so out loud.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PREFIX = 'syndes1_';
|
|
18
|
+
const VERSION = 1;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{repo: string, transport: 'git'|'folder', pool?: string, from?: string}} details
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function encode({ repo, transport = 'git', pool = null, from = null }) {
|
|
25
|
+
if (!repo) throw new Error('an invite needs a repository');
|
|
26
|
+
const body = { v: VERSION, r: repo, t: transport };
|
|
27
|
+
if (pool) body.p = pool.slice(0, 60);
|
|
28
|
+
if (from) body.f = from.slice(0, 40);
|
|
29
|
+
return PREFIX + Buffer.from(JSON.stringify(body), 'utf8').toString('base64url');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decode, tolerating what people actually paste — surrounding quotes, a stray
|
|
34
|
+
* backtick from a chat app, the whole command line rather than just the code.
|
|
35
|
+
*
|
|
36
|
+
* @returns {{repo, transport, pool, from}|null} null when it is not an invite
|
|
37
|
+
*/
|
|
38
|
+
export function decode(input) {
|
|
39
|
+
if (typeof input !== 'string') return null;
|
|
40
|
+
|
|
41
|
+
let text = input.trim().replace(/^[`'"]+|[`'"]+$/g, '');
|
|
42
|
+
const found = /syndes1_[A-Za-z0-9_-]+/.exec(text);
|
|
43
|
+
if (found) text = found[0];
|
|
44
|
+
if (!text.startsWith(PREFIX)) return null;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const body = JSON.parse(Buffer.from(text.slice(PREFIX.length), 'base64url').toString('utf8'));
|
|
48
|
+
if (body.v !== VERSION || typeof body.r !== 'string' || !body.r) return null;
|
|
49
|
+
return {
|
|
50
|
+
repo: body.r,
|
|
51
|
+
transport: body.t === 'folder' ? 'folder' : 'git',
|
|
52
|
+
pool: typeof body.p === 'string' ? body.p : null,
|
|
53
|
+
from: typeof body.f === 'string' ? body.f : null,
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The line an inviter sends. Two steps, because the install must be global. */
|
|
61
|
+
export function commandFor(code) {
|
|
62
|
+
return `npm install -g syndes && syndes join ${code}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True for anything that looks like an invite, so `join` can accept a raw URL too. */
|
|
66
|
+
export function looksLikeInvite(input) {
|
|
67
|
+
return typeof input === 'string' && input.includes(PREFIX);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { PREFIX };
|