syndes 0.1.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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/adapters/claude-code.mjs +59 -0
  4. package/adapters/codex.mjs +256 -0
  5. package/adapters/index.mjs +92 -0
  6. package/analytics/index.mjs +189 -0
  7. package/analytics/metrics/context.mjs +95 -0
  8. package/analytics/metrics/cost.mjs +83 -0
  9. package/analytics/metrics/friction.mjs +86 -0
  10. package/analytics/metrics/prompts.mjs +93 -0
  11. package/analytics/metrics/rework.mjs +113 -0
  12. package/analytics/metrics/time.mjs +104 -0
  13. package/analytics/metrics/tokens.mjs +88 -0
  14. package/analytics/metrics/tools.mjs +118 -0
  15. package/analytics/metrics/volume.mjs +98 -0
  16. package/analytics/ranges.mjs +98 -0
  17. package/analytics/rollup.mjs +151 -0
  18. package/analytics/score.mjs +194 -0
  19. package/bin/cli.mjs +596 -0
  20. package/bin/postinstall.mjs +44 -0
  21. package/collect/classify.mjs +226 -0
  22. package/collect/git.mjs +78 -0
  23. package/collect/projects.mjs +82 -0
  24. package/collect/redact.mjs +85 -0
  25. package/collect/sessions.mjs +119 -0
  26. package/collect/tail.mjs +126 -0
  27. package/collect/tools.mjs +121 -0
  28. package/collect/transcript.mjs +128 -0
  29. package/dashboard/api/index.mjs +296 -0
  30. package/dashboard/auth.mjs +235 -0
  31. package/dashboard/router.mjs +55 -0
  32. package/dashboard/security.mjs +95 -0
  33. package/dashboard/server.mjs +156 -0
  34. package/dashboard/static.mjs +47 -0
  35. package/dashboard/web/SynDes.icns +0 -0
  36. package/dashboard/web/api.js +80 -0
  37. package/dashboard/web/app.css +532 -0
  38. package/dashboard/web/app.js +261 -0
  39. package/dashboard/web/charts.js +273 -0
  40. package/dashboard/web/index.html +23 -0
  41. package/dashboard/web/logo.png +0 -0
  42. package/dashboard/web/ui.js +434 -0
  43. package/dashboard/web/views/habits.js +166 -0
  44. package/dashboard/web/views/ledger.js +164 -0
  45. package/dashboard/web/views/overview.js +214 -0
  46. package/dashboard/web/views/sessions.js +133 -0
  47. package/dashboard/web/views/settings.js +180 -0
  48. package/ledger/append.mjs +126 -0
  49. package/ledger/chain.mjs +53 -0
  50. package/ledger/keys.mjs +72 -0
  51. package/ledger/read.mjs +77 -0
  52. package/ledger/retention.mjs +104 -0
  53. package/ledger/schema.mjs +96 -0
  54. package/ledger/segments.mjs +109 -0
  55. package/ledger/verify.mjs +174 -0
  56. package/notify/index.mjs +67 -0
  57. package/notify/linux.mjs +41 -0
  58. package/notify/mac.mjs +44 -0
  59. package/notify/terminal.mjs +15 -0
  60. package/notify/windows.mjs +61 -0
  61. package/package.json +66 -0
  62. package/practices/budget.mjs +97 -0
  63. package/practices/catalog.mjs +64 -0
  64. package/practices/deliver.mjs +101 -0
  65. package/practices/engine.mjs +107 -0
  66. package/practices/rules/batch-tool-calls.mjs +15 -0
  67. package/practices/rules/context-hygiene.mjs +17 -0
  68. package/practices/rules/delegate-wide-search.mjs +15 -0
  69. package/practices/rules/index.mjs +28 -0
  70. package/practices/rules/permission-friction.mjs +16 -0
  71. package/practices/rules/project-memory.mjs +27 -0
  72. package/practices/rules/prompt-specificity.mjs +15 -0
  73. package/practices/rules/read-before-edit.mjs +16 -0
  74. package/practices/rules/retry-storm.mjs +22 -0
  75. package/practices/rules/session-sprawl.mjs +15 -0
  76. package/practices/rules/verify-after-change.mjs +16 -0
  77. package/runtime/config.mjs +116 -0
  78. package/runtime/hook.mjs +154 -0
  79. package/runtime/jsonl.mjs +104 -0
  80. package/runtime/lock.mjs +98 -0
  81. package/runtime/log.mjs +37 -0
  82. package/runtime/paths.mjs +116 -0
  83. package/runtime/platform.mjs +74 -0
  84. package/runtime/spool.mjs +92 -0
  85. package/runtime/worker.mjs +275 -0
  86. package/src/briefing.mjs +94 -0
  87. package/src/doctor.mjs +153 -0
  88. package/src/export.mjs +68 -0
  89. package/src/install.mjs +95 -0
  90. package/src/open.mjs +23 -0
  91. package/src/report.mjs +120 -0
  92. package/src/settings.mjs +173 -0
  93. package/src/status.mjs +61 -0
  94. package/src/systemauth.mjs +179 -0
  95. package/src/term.mjs +272 -0
  96. package/src/uninstall.mjs +43 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Install: copy to a stable home, mint the chain key, open the ledger, wire hooks.
3
+ *
4
+ * The copy matters. Pointing settings.json at wherever this is running from
5
+ * means pointing it at the npm cache under `npx` — which npm prunes — and a hook
6
+ * whose script has vanished is a broken session on every tool call.
7
+ *
8
+ * Order matters too: key and directories first, then the genesis record, then
9
+ * the hooks. Wiring hooks before the ledger can accept records means the first
10
+ * tool call after install writes into nothing.
11
+ *
12
+ * Idempotent. Re-running repairs drift rather than duplicating anything.
13
+ */
14
+
15
+ import { cpSync, existsSync, rmSync, readFileSync } from 'node:fs';
16
+ import { join, resolve, sep } from 'node:path';
17
+ import {
18
+ packageRoot, installDir, installedHookScript, isInstalledCopy,
19
+ ensureDataDirs, displayPath,
20
+ } from '../runtime/paths.mjs';
21
+ import { ensureKey } from '../ledger/keys.mjs';
22
+ import { append } from '../ledger/append.mjs';
23
+ import { draft, KIND } from '../ledger/schema.mjs';
24
+ import { height } from '../ledger/read.mjs';
25
+ import { installHooks, hookCommand, HOOK_EVENTS } from './settings.mjs';
26
+ import { isSupportedPlatform, platformName } from '../runtime/platform.mjs';
27
+
28
+ /** Never shipped into the install: tests, VCS, and anything npm would not publish. */
29
+ const SKIP = new Set(['node_modules', '.git', 'test', 'coverage', '.github', '.DS_Store']);
30
+
31
+ export async function install({ repair = false } = {}) {
32
+ const steps = [];
33
+
34
+ if (!isSupportedPlatform) {
35
+ throw new Error(`${platformName} is not supported — macOS, Windows and Linux only`);
36
+ }
37
+
38
+ // 1. The stable home.
39
+ if (isInstalledCopy) {
40
+ steps.push({ step: 'copy', skipped: true, detail: 'already running from the install directory' });
41
+ } else {
42
+ copyPackage();
43
+ steps.push({ step: 'copy', detail: displayPath(installDir) });
44
+ }
45
+
46
+ // 2. Storage and the key, before anything tries to write a record.
47
+ ensureDataDirs();
48
+ const { keyId, created } = ensureKey();
49
+ steps.push({ step: 'key', detail: created ? `minted ${keyId}` : `existing ${keyId}` });
50
+
51
+ // 3. Open the chain. The install itself is the first thing it records.
52
+ const before = height();
53
+ await append([draft(KIND.INSTALL, {
54
+ data: {
55
+ version: version(),
56
+ node: process.version,
57
+ platform: process.platform,
58
+ events: HOOK_EVENTS,
59
+ repair,
60
+ },
61
+ ...(before.seq < 0 ? { key: keyId } : {}),
62
+ })]);
63
+ steps.push({ step: 'ledger', detail: before.seq < 0 ? 'chain opened' : `chain at ${height().seq}` });
64
+
65
+ // 4. Hooks last.
66
+ const { backup, events } = installHooks(hookCommand(installedHookScript));
67
+ steps.push({ step: 'hooks', detail: `${events.length} events`, backup });
68
+
69
+ return { steps, installDir, events, keyId };
70
+ }
71
+
72
+ function copyPackage() {
73
+ // Remove first: cpSync merges, so an upgrade that deletes a file would leave
74
+ // the old one behind to be imported by a stale path somewhere.
75
+ if (existsSync(installDir)) rmSync(installDir, { recursive: true, force: true });
76
+
77
+ cpSync(packageRoot, installDir, {
78
+ recursive: true,
79
+ dereference: true,
80
+ filter: (source) => {
81
+ const relative = resolve(source).slice(resolve(packageRoot).length + 1);
82
+ if (!relative) return true;
83
+ const [top] = relative.split(sep);
84
+ return !SKIP.has(top);
85
+ },
86
+ });
87
+ }
88
+
89
+ function version() {
90
+ try {
91
+ return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
package/src/open.mjs ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Open a URL in the default browser.
3
+ *
4
+ * Returns false rather than throwing when there is no browser — the caller then
5
+ * prints the URL, which is the actually useful outcome on a headless box.
6
+ */
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import { openArgs } from '../runtime/platform.mjs';
10
+ import { debug } from '../runtime/log.mjs';
11
+
12
+ export function openUrl(url) {
13
+ const target = openArgs(url);
14
+ if (!target) return false;
15
+
16
+ try {
17
+ spawn(target.command, target.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
18
+ return true;
19
+ } catch (error) {
20
+ debug('open failed', error.message);
21
+ return false;
22
+ }
23
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The terminal digest — day, week, month.
3
+ *
4
+ * Same numbers as the dashboard by construction, because both call
5
+ * analytics/index.mjs. Nothing here computes a metric.
6
+ */
7
+
8
+ import {
9
+ bold, grey, cyan, green, red, yellow, write, rule, table, bar, sparkline,
10
+ compact, usd, duration, percent, width, DOT,
11
+ } from './term.mjs';
12
+ import { overview } from '../analytics/index.mjs';
13
+ import { evaluate } from '../practices/engine.mjs';
14
+ import { nameFor } from '../collect/projects.mjs';
15
+
16
+ export async function report(spec = '7d', { practices = true } = {}) {
17
+ const data = await overview(spec);
18
+ const { headline, previousHeadline: before, score, metrics, cost } = data;
19
+
20
+ write();
21
+ write(`${bold('syndes')} ${grey(DOT)} ${data.range.label}`);
22
+ write(rule(width()));
23
+
24
+ // ── The score ─────────────────────────────────────────────────────────────
25
+ if (score.total === null) {
26
+ write(`${grey('not enough recorded yet to score — keep going')}`);
27
+ } else {
28
+ const delta = data.scoreDelta;
29
+ const arrow = delta === null ? '' : delta > 0 ? green(`+${delta}`) : delta < 0 ? red(String(delta)) : grey('0');
30
+ write(`${bold('score')} ${bar(score.total / 100, 28)} ${bold(String(score.total))}${grey('/100')} ${arrow} ${grey(`(${score.confidence} confidence)`)}`);
31
+ write();
32
+ for (const pillar of score.pillars) {
33
+ const value = pillar.score === null ? grey(' —') : String(pillar.score).padStart(3);
34
+ write(` ${value} ${bar((pillar.score ?? 0) / 100, 14, tint(pillar.score))} ${pillar.label}`);
35
+ }
36
+ }
37
+
38
+ // ── Headline ──────────────────────────────────────────────────────────────
39
+ write();
40
+ write(table([
41
+ [grey('sessions'), String(headline.sessions), delta(headline.sessions, before.sessions)],
42
+ [grey('prompts'), String(headline.prompts), delta(headline.prompts, before.prompts)],
43
+ [grey('tool calls'), compact(headline.toolCalls), delta(headline.toolCalls, before.toolCalls)],
44
+ [grey('active time'), duration(headline.activeMs), grey(`of ${duration(headline.wallMs)} open`)],
45
+ [grey('tokens'), compact(headline.tokens), grey(`${percent(headline.cacheHitRate)} from cache`)],
46
+ [grey('cost'), usd(headline.usd) + (cost.estimated ? grey('*') : ''), delta(headline.usd, before.usd, usd)],
47
+ [grey('files changed'), String(headline.files), headline.commits ? grey(`${headline.commits} commits`) : ''],
48
+ [grey('interruptions'), String(headline.blocks), headline.compacts ? grey(`${headline.compacts} compactions`) : ''],
49
+ ], { align: ['left', 'right', 'left'] }).map((line) => ` ${line}`).join('\n'));
50
+
51
+ if (cost.estimated) write(grey(` * priced from the ${cost.tableVersion} table by model family`));
52
+
53
+ // ── Trend ─────────────────────────────────────────────────────────────────
54
+ if (data.series.length > 1) {
55
+ write();
56
+ write(` ${grey('activity')} ${cyan(sparkline(data.series.map((day) => day.toolCalls)))} ${grey(`${data.series[0].day} to ${data.series.at(-1).day}`)}`);
57
+ write(` ${grey('by hour ')} ${cyan(sparkline(data.hours))} ${grey('00 to 23')}`);
58
+ }
59
+
60
+ // ── Where the time went ───────────────────────────────────────────────────
61
+ const families = Object.entries(metrics.tools.byFamily ?? {}).sort((a, b) => b[1] - a[1]).slice(0, 6);
62
+ if (families.length) {
63
+ write();
64
+ write(` ${bold('tools')}`);
65
+ const total = families.reduce((sum, [, count]) => sum + count, 0);
66
+ write(table(families.map(([family, count]) => [
67
+ grey(family), bar(count / total, 16), String(count),
68
+ ]), { align: ['left', 'left', 'right'] }).map((line) => ` ${line}`).join('\n'));
69
+ }
70
+
71
+ // ── What to fix ───────────────────────────────────────────────────────────
72
+ if (practices) {
73
+ const findings = await evaluate();
74
+ write();
75
+ if (!findings.length) {
76
+ write(` ${green('nothing flagged')} ${grey('— practice rules found no pattern worth changing')}`);
77
+ } else {
78
+ write(` ${bold('worth changing')}`);
79
+ for (const finding of findings.slice(0, 3)) {
80
+ write(` ${severity(finding.severity)} ${bold(finding.title)}${finding.muted ? grey(' (muted)') : ''}`);
81
+ write(` ${grey(finding.evidence)}`);
82
+ write(` ${cyan('→')} ${finding.fix}`);
83
+ }
84
+ if (findings.length > 3) write(grey(` …and ${findings.length - 3} more — syndes practices`));
85
+ }
86
+ }
87
+
88
+ write();
89
+ return data;
90
+ }
91
+
92
+ function tint(score) {
93
+ if (score === null) return grey;
94
+ if (score >= 75) return green;
95
+ if (score >= 50) return yellow;
96
+ return red;
97
+ }
98
+
99
+ function severity(level) {
100
+ return level >= 3 ? red('!!') : level === 2 ? yellow(' !') : grey(' ·');
101
+ }
102
+
103
+ function delta(now, then, format = compact) {
104
+ if (!then) return '';
105
+ const change = now - then;
106
+ if (!change) return grey('=');
107
+ const text = `${change > 0 ? '+' : ''}${format(change)}`;
108
+ return change > 0 ? green(text) : red(text);
109
+ }
110
+
111
+ /** Per-project breakdown, printed by `syndes projects`. */
112
+ export function projectTable(projects) {
113
+ return table([
114
+ [grey('project'), grey('sessions'), grey('prompts'), grey('tools'), grey('tokens')],
115
+ ...projects.slice(0, 15).map((project) => [
116
+ nameFor(project.id), String(project.sessions), String(project.prompts),
117
+ compact(project.toolCalls), compact(project.tokens),
118
+ ]),
119
+ ], { align: ['left', 'right', 'right', 'right', 'right'] });
120
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Surgery on <claudeDir>/settings.json. This file is shared.
3
+ *
4
+ * The user has permissions, a status line, MCP servers and quite possibly other
5
+ * hook-based tools in it — on this author's machine claude-noti and claude-tts
6
+ * already own Stop and SessionStart hooks. So the rules are:
7
+ *
8
+ * • Merge into what is there; never rewrite wholesale.
9
+ * • Never touch a hook that is not ours, on any event, including shared ones.
10
+ * • Back it up before every write.
11
+ * • Refuse to write at all if the existing file does not parse, rather than
12
+ * overwrite something a human hand-edited and left a trailing comma in.
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
16
+ import { dirname } from 'node:path';
17
+ import { settingsFile, installedHookScript } from '../runtime/paths.mjs';
18
+
19
+ /**
20
+ * Every event syndes listens to.
21
+ *
22
+ * PreToolUse and PostToolUse are the expensive pair — they fire on every tool
23
+ * call — and they are wired anyway, because "every single move" is the product.
24
+ * That choice is what buys runtime/hook.mjs its budget, and it is the reason
25
+ * that file has no local imports. See DESIGN.md §1.
26
+ */
27
+ export const HOOK_EVENTS = [
28
+ 'SessionStart',
29
+ 'UserPromptSubmit',
30
+ 'PreToolUse',
31
+ 'PostToolUse',
32
+ 'PermissionRequest',
33
+ 'Notification',
34
+ 'PreCompact',
35
+ 'Stop',
36
+ 'SubagentStop',
37
+ 'SessionEnd',
38
+ ];
39
+
40
+ /** Generous for a hook that does one small write; short enough to never hang a session. */
41
+ const HOOK_TIMEOUT_SECONDS = 5;
42
+
43
+ /** @returns {{data: object|null, error: string|null, exists: boolean}} */
44
+ export function readSettings() {
45
+ if (!existsSync(settingsFile)) return { data: {}, error: null, exists: false };
46
+ try {
47
+ return { data: JSON.parse(readFileSync(settingsFile, 'utf8')), error: null, exists: true };
48
+ } catch (error) {
49
+ return { data: null, error: error.message, exists: true };
50
+ }
51
+ }
52
+
53
+ export function backupSettings() {
54
+ if (!existsSync(settingsFile)) return null;
55
+ const backup = `${settingsFile}.syndes-backup`;
56
+ try {
57
+ copyFileSync(settingsFile, backup);
58
+ return backup;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function writeSettings(data) {
65
+ const backup = backupSettings();
66
+ mkdirSync(dirname(settingsFile), { recursive: true });
67
+ writeFileSync(settingsFile, `${JSON.stringify(data, null, 2)}\n`);
68
+ return backup;
69
+ }
70
+
71
+ /**
72
+ * The command Claude Code runs.
73
+ *
74
+ * process.execPath rather than the bare word `node`: the PATH inside a hook is
75
+ * not the user's shell PATH, and a hook that cannot find node fails on every
76
+ * event. The cost of that choice is that switching Node versions with nvm can
77
+ * leave the path dangling — which is exactly what `doctor` checks for, and what
78
+ * `install --repair` fixes.
79
+ *
80
+ * Both parts are quoted so a path containing spaces survives cmd.exe.
81
+ */
82
+ export function hookCommand(script = installedHookScript, node = process.execPath) {
83
+ return `"${node}" "${script}"`;
84
+ }
85
+
86
+ /** True when a hook entry is one of ours and nobody else's. */
87
+ function isOurs(hook) {
88
+ const command = typeof hook?.command === 'string' ? hook.command : '';
89
+ return command.includes('syndes') && command.includes('hook.mjs');
90
+ }
91
+
92
+ function stripOurs(groups) {
93
+ return (groups ?? [])
94
+ .map((group) => ({ ...group, hooks: (group.hooks ?? []).filter((hook) => !isOurs(hook)) }))
95
+ .filter((group) => group.hooks.length > 0);
96
+ }
97
+
98
+ /**
99
+ * Wire our hook into every event we listen to, leaving everyone else's alone.
100
+ *
101
+ * @returns {{backup: string|null, events: string[]}}
102
+ */
103
+ export function installHooks(command, events = HOOK_EVENTS) {
104
+ const { data, error } = readSettings();
105
+ if (error) throw new Error(`${settingsFile} is not valid JSON (${error}) — fix it and re-run`);
106
+
107
+ const hooks = { ...(data.hooks ?? {}) };
108
+ const wanted = new Set(events);
109
+
110
+ for (const event of new Set([...HOOK_EVENTS, ...events])) {
111
+ const groups = stripOurs(hooks[event]);
112
+ if (wanted.has(event)) {
113
+ groups.push({ hooks: [{ type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS }] });
114
+ }
115
+ if (groups.length) hooks[event] = groups;
116
+ else delete hooks[event];
117
+ }
118
+
119
+ const backup = writeSettings({ ...data, hooks });
120
+ return { backup, events: [...events] };
121
+ }
122
+
123
+ /** Remove every syndes hook. Everyone else's survives untouched. */
124
+ export function uninstallHooks() {
125
+ const { data, error, exists } = readSettings();
126
+ if (!exists) return { backup: null, removed: [] };
127
+ if (error) throw new Error(`${settingsFile} is not valid JSON (${error}) — fix it and re-run`);
128
+
129
+ const hooks = { ...(data.hooks ?? {}) };
130
+ const removed = [];
131
+
132
+ for (const event of Object.keys(hooks)) {
133
+ const before = countOurs(hooks[event]);
134
+ if (!before) continue;
135
+ const groups = stripOurs(hooks[event]);
136
+ if (groups.length) hooks[event] = groups;
137
+ else delete hooks[event];
138
+ removed.push(event);
139
+ }
140
+ if (!removed.length) return { backup: null, removed };
141
+
142
+ const backup = writeSettings({ ...data, hooks });
143
+ return { backup, removed };
144
+ }
145
+
146
+ function countOurs(groups) {
147
+ return (groups ?? []).reduce((sum, group) => sum + (group.hooks ?? []).filter(isOurs).length, 0);
148
+ }
149
+
150
+ /** Which events currently carry one of our hooks, and where they point. */
151
+ export function installedEvents() {
152
+ const { data, error } = readSettings();
153
+ if (error || !data) return { events: [], commands: [], error };
154
+
155
+ const events = [];
156
+ const commands = new Set();
157
+ for (const [event, groups] of Object.entries(data.hooks ?? {})) {
158
+ for (const group of groups ?? []) {
159
+ for (const hook of group.hooks ?? []) {
160
+ if (!isOurs(hook)) continue;
161
+ if (!events.includes(event)) events.push(event);
162
+ commands.add(hook.command);
163
+ }
164
+ }
165
+ }
166
+ return { events, commands: [...commands], error: null };
167
+ }
168
+
169
+ export function isInstalled() {
170
+ return installedEvents().events.length > 0;
171
+ }
172
+
173
+ export { isOurs };
package/src/status.mjs ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One screen: today, the score, the streak, and whether the machine is healthy.
3
+ * What `syndes` with no arguments prints.
4
+ */
5
+
6
+ import { overview } from '../analytics/index.mjs';
7
+ import { rangeFor, localDay, shiftDay } from '../analytics/ranges.mjs';
8
+ import { rollupsFor } from '../analytics/rollup.mjs';
9
+ import { height } from '../ledger/read.mjs';
10
+ import { pendingCount } from '../runtime/spool.mjs';
11
+ import { isInstalled } from './settings.mjs';
12
+ import { mode as authMode } from '../dashboard/auth.mjs';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { pauseFile } from '../runtime/paths.mjs';
15
+
16
+ export async function statusData(spec = '7d') {
17
+ const data = await overview(spec);
18
+ const today = await overview('today');
19
+
20
+ return {
21
+ ...data,
22
+ today: today.headline,
23
+ todayScore: today.score,
24
+ streak: await streak(),
25
+ installed: isInstalled(),
26
+ lock: authMode(),
27
+ pending: pendingCount(),
28
+ ledger: height(),
29
+ paused: pausedState(),
30
+ };
31
+ }
32
+
33
+ /** Consecutive local days, ending today, with any activity. */
34
+ export async function streak(max = 365) {
35
+ let day = localDay(Date.now());
36
+ let count = 0;
37
+
38
+ for (let index = 0; index < max; index += 1) {
39
+ const [rollup] = await rollupsFor(rangeFor(day));
40
+ if (!rollup?.records) {
41
+ // Today not having started yet must not break a streak that is still alive.
42
+ if (index === 0) { day = shiftDay(day, -1); continue; }
43
+ break;
44
+ }
45
+ count += 1;
46
+ day = shiftDay(day, -1);
47
+ }
48
+ return count;
49
+ }
50
+
51
+ export function pausedState() {
52
+ if (!existsSync(pauseFile)) return null;
53
+ try {
54
+ const raw = readFileSync(pauseFile, 'utf8').trim();
55
+ if (!raw) return { until: null, indefinite: true };
56
+ const until = Number(JSON.parse(raw).until);
57
+ return Date.now() < until ? { until, indefinite: false } : null;
58
+ } catch {
59
+ return { until: null, indefinite: true };
60
+ }
61
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * System authentication — the OS decides, not us.
3
+ *
4
+ * The best password is the one the user has already set up. On a Mac with Touch
5
+ * ID that means a fingerprint; everywhere else we say so plainly instead of
6
+ * pretending, because a lock that silently does nothing is worse than no lock.
7
+ *
8
+ * macOS: LocalAuthentication via a tiny Swift helper compiled on first use.
9
+ * swiftc ships with the Xcode command line tools, which most developers
10
+ * already have; when it is absent this reports unavailable and the caller
11
+ * falls back rather than failing.
12
+ *
13
+ * Windows / Linux: no dependency-free way to reach Windows Hello or polkit from
14
+ * Node exists, so `available()` returns false and the caller uses a PIN.
15
+ * Claiming otherwise would be the exact failure this codebase avoids.
16
+ */
17
+
18
+ import { execFileSync, execFile } from 'node:child_process';
19
+ import { writeFileSync, existsSync, mkdirSync, chmodSync, copyFileSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { promisify } from 'node:util';
23
+ import { dataDir } from '../runtime/paths.mjs';
24
+ import { isMac, which } from '../runtime/platform.mjs';
25
+ import { debug } from '../runtime/log.mjs';
26
+
27
+ const run = promisify(execFile);
28
+ /**
29
+ * The helper is a real .app bundle, not a bare binary.
30
+ *
31
+ * LocalAuthentication names the requesting process in its dialog. A loose
32
+ * executable called `touchid` produced "touchid is trying to…" beside the
33
+ * generic exec icon, which tells the user nothing about who is asking and looks
34
+ * like something that escaped from a terminal. A bundle with CFBundleName and
35
+ * an icon makes the prompt say SynDes and show the SynDes mark.
36
+ */
37
+ const HELPER_DIR = join(dataDir, 'bin');
38
+ const BUNDLE = join(HELPER_DIR, 'SynDes.app');
39
+ const HELPER = join(BUNDLE, 'Contents', 'MacOS', 'SynDes');
40
+ const ICON_SOURCE = join(dirname(fileURLToPath(import.meta.url)), '..', 'dashboard', 'web', 'SynDes.icns');
41
+
42
+ const SWIFT_SOURCE = `
43
+ import LocalAuthentication
44
+ import Foundation
45
+
46
+ let context = LAContext()
47
+ context.localizedFallbackTitle = ""
48
+ var error: NSError?
49
+
50
+ // deviceOwnerAuthentication (not ...WithBiometrics) lets the system fall back to
51
+ // the login password, which is what the user expects when a finger does not read.
52
+ guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
53
+ FileHandle.standardError.write("unavailable\\n".data(using: .utf8)!)
54
+ exit(2)
55
+ }
56
+
57
+ let reason = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "open your dashboard"
58
+ let semaphore = DispatchSemaphore(value: 0)
59
+ var ok = false
60
+
61
+ context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
62
+ ok = success
63
+ semaphore.signal()
64
+ }
65
+ semaphore.wait()
66
+ exit(ok ? 0 : 1)
67
+ `;
68
+
69
+ let cachedAvailability = null;
70
+
71
+ /** @returns {{available: boolean, reason: string, method: string|null}} */
72
+ export function available() {
73
+ if (cachedAvailability) return cachedAvailability;
74
+ cachedAvailability = probe();
75
+ return cachedAvailability;
76
+ }
77
+
78
+ function probe() {
79
+ if (!isMac) {
80
+ return {
81
+ available: false,
82
+ method: null,
83
+ reason: 'system unlock is macOS-only for now — no dependency-free way to reach Windows Hello or polkit from Node',
84
+ };
85
+ }
86
+
87
+ if (existsSync(HELPER)) return { available: true, method: 'Touch ID', reason: 'ready' };
88
+
89
+ if (!which('swiftc')) {
90
+ return {
91
+ available: false,
92
+ method: null,
93
+ reason: 'swiftc not found — install the Xcode command line tools (xcode-select --install) to enable Touch ID',
94
+ };
95
+ }
96
+
97
+ // Enrolment is a separate question from capability: a Mac can support Touch ID
98
+ // with no finger registered, and evaluatePolicy would then fall back to the
99
+ // login password, which is still a legitimate system unlock.
100
+ return { available: true, method: 'Touch ID', reason: 'ready (helper will be built on first use)' };
101
+ }
102
+
103
+ const INFO_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
104
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
105
+ <plist version="1.0"><dict>
106
+ <key>CFBundleName</key><string>SynDes</string>
107
+ <key>CFBundleDisplayName</key><string>SynDes</string>
108
+ <key>CFBundleExecutable</key><string>SynDes</string>
109
+ <key>CFBundleIdentifier</key><string>dev.syndes.unlock</string>
110
+ <key>CFBundleIconFile</key><string>SynDes</string>
111
+ <key>CFBundlePackageType</key><string>APPL</string>
112
+ <key>CFBundleShortVersionString</key><string>1.0</string>
113
+ <key>LSUIElement</key><true/>
114
+ <key>NSHumanReadableCopyright</key><string>Local unlock helper for the SynDes dashboard.</string>
115
+ </dict></plist>
116
+ `;
117
+
118
+ /** Build the helper bundle. Cached; only ever runs once per install. */
119
+ function build() {
120
+ if (existsSync(HELPER)) return true;
121
+ const swiftc = which('swiftc');
122
+ if (!swiftc) return false;
123
+
124
+ try {
125
+ const contents = join(BUNDLE, 'Contents');
126
+ mkdirSync(join(contents, 'MacOS'), { recursive: true });
127
+ mkdirSync(join(contents, 'Resources'), { recursive: true });
128
+
129
+ writeFileSync(join(contents, 'Info.plist'), INFO_PLIST);
130
+ if (existsSync(ICON_SOURCE)) copyFileSync(ICON_SOURCE, join(contents, 'Resources', 'SynDes.icns'));
131
+
132
+ const source = join(HELPER_DIR, 'unlock.swift');
133
+ writeFileSync(source, SWIFT_SOURCE);
134
+ execFileSync(swiftc, ['-O', '-o', HELPER, source], { timeout: 90_000, stdio: 'ignore' });
135
+ chmodSync(HELPER, 0o700);
136
+ return true;
137
+ } catch (error) {
138
+ debug('touch id helper build failed', error.message);
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Build the helper without prompting.
145
+ *
146
+ * Called when the user chooses system unlock, so the swiftc compile happens
147
+ * once at configure time rather than adding ninety seconds to the first unlock
148
+ * — which would look like the dashboard had hung.
149
+ *
150
+ * @returns {{ok: boolean, reason: string|null, bundle: string}}
151
+ */
152
+ export function prepare() {
153
+ const probe = available();
154
+ if (!probe.available) return { ok: false, reason: probe.reason, bundle: BUNDLE };
155
+ return build()
156
+ ? { ok: true, reason: null, bundle: BUNDLE }
157
+ : { ok: false, reason: 'could not build the unlock helper', bundle: BUNDLE };
158
+ }
159
+
160
+ /**
161
+ * Prompt the user. Resolves true only on a real system confirmation.
162
+ *
163
+ * @param {string} reason shown in the system dialog
164
+ */
165
+ export async function authenticate(reason = 'open your dashboard') {
166
+ if (!available().available) return { ok: false, reason: available().reason };
167
+ if (!build()) return { ok: false, reason: 'could not build the Touch ID helper' };
168
+
169
+ try {
170
+ await run(HELPER, [reason], { timeout: 60_000 });
171
+ return { ok: true, reason: null };
172
+ } catch (error) {
173
+ // exit 1 = the user cancelled or failed; exit 2 = policy unavailable.
174
+ if (error.code === 2) return { ok: false, reason: 'no Touch ID or login password is configured' };
175
+ return { ok: false, reason: 'authentication cancelled' };
176
+ }
177
+ }
178
+
179
+ export { HELPER };