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 +25 -1
- package/bin/cli.mjs +201 -4
- package/dashboard/api/team.mjs +29 -1
- package/dashboard/auth.mjs +35 -0
- package/dashboard/server.mjs +42 -6
- package/dashboard/web/api.js +2 -0
- package/dashboard/web/app.css +66 -0
- package/dashboard/web/ui.js +2 -0
- package/dashboard/web/views/team.js +169 -50
- 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,
|
|
@@ -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
|
-
|
|
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('
|
|
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'],
|
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/auth.mjs
CHANGED
|
@@ -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 };
|
package/dashboard/server.mjs
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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) {
|
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,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) {
|
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', {}, [
|
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
28
|
-
|
|
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.
|
|
84
|
-
h('div.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
206
|
-
h('
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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.
|
|
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
|
/**
|
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 };
|