@diffin/cli 0.0.0-stage → 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.
@@ -0,0 +1,45 @@
1
+ // The computer this CLI runs on, as a hash the server can compare and nothing more.
2
+ //
3
+ // Many Google accounts are cheap; one computer is not. The server links the accounts that run from one machine and
4
+ // lets them spend as one person, and refuses a third account on the same machine (`COMPUTER_ACCOUNTS`). What is sent is
5
+ // the salted HMAC of the platform id the OS already keeps for the machine — macOS IOPlatformUUID, Linux machine-id,
6
+ // Windows MachineGuid — never the id itself, no MAC address, no host name. It names the computer to the server about
7
+ // as much as a random token would, except that reinstalling the CLI does not change it.
8
+ //
9
+ // This is a value the machine's owner can change (a VM, an edited CLI): it raises the price of a sign-in farm, it does
10
+ // not make one impossible. A CLI that cannot read one sends none, and the server gives such a run its lowest budget.
11
+ // The random device id (`state.mjs` deviceId) keeps its own roles — a backfill once per source per device.
12
+ import { execFileSync } from 'node:child_process';
13
+ import { readFileSync } from 'node:fs';
14
+ import { createHmac } from 'node:crypto';
15
+
16
+ const SALT = 'diffin-machine-v1';
17
+ let memo;
18
+
19
+ function platformId() {
20
+ try {
21
+ if (process.platform === 'darwin') {
22
+ const out = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
23
+ const m = out.match(/"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]{36})"/);
24
+ return m ? m[1].toUpperCase() : null;
25
+ }
26
+ if (process.platform === 'linux') {
27
+ for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) { try { const v = readFileSync(f, 'utf8').trim(); if (/^[0-9a-f]{32}$/.test(v)) return v; } catch { /* next */ } }
28
+ return null;
29
+ }
30
+ if (process.platform === 'win32') {
31
+ const out = execFileSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
32
+ const m = out.match(/MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]{36})/);
33
+ return m ? m[1].toLowerCase() : null;
34
+ }
35
+ } catch { /* no id to read */ }
36
+ return null;
37
+ }
38
+
39
+ /** the computer's hash — 64 hex characters — or null when this machine's id cannot be read */
40
+ export function machineHash() {
41
+ if (memo !== undefined) return memo;
42
+ const id = platformId();
43
+ memo = id ? createHmac('sha256', SALT).update(`${process.platform}:${id}`).digest('hex') : null;
44
+ return memo;
45
+ }
package/lib/net.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // HTTP helpers shared by every command. The transient-error set is the predecessor's,
2
+ // learned the hard way: a large upload leaves the socket idle while the body is
3
+ // prepared, and it gets reset — the same batch failed with write EPIPE one run and
4
+ // succeeded the next. Retrying is safe because the server is idempotent per session.
5
+ import { dim, fail, serverMessage, warn } from './ui.mjs';
6
+
7
+ const TRANSIENT = new Set([
8
+ 'EPIPE', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN',
9
+ 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
10
+ ]);
11
+
12
+ // the production API (decided 2026-10-11, docs/deploy.md); DIFFIN_SERVER points a machine elsewhere — the local stack
13
+ // is `DIFFIN_SERVER=http://localhost:3000`
14
+ export const SERVER = serverUrl(process.env.DIFFIN_SERVER ?? 'https://api.diffin.work');
15
+
16
+ // Every request carries the person's credential in a header, so the transport must
17
+ // be TLS everywhere except this machine. A mistyped http://api… would otherwise send
18
+ // a token that never expires in the clear and keep working, which is the worst kind of failure.
19
+ export function serverUrl(raw) {
20
+ let u;
21
+ try { u = new URL(raw); } catch { throw new Error(`DIFFIN_SERVER 가 URL 이 아닙니다: ${raw}`); }
22
+ const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1';
23
+ if (u.protocol !== 'https:' && !local) {
24
+ throw new Error(`DIFFIN_SERVER 는 https 여야 합니다 (localhost 제외): ${raw}`);
25
+ }
26
+ return u.origin;
27
+ }
28
+
29
+ // A run's files sit on ONE server machine's disk, and only that machine's worker reads
30
+ // them. The server names the machine when it opens (or resumes) a run; every request that
31
+ // belongs to the run — each file, `complete`, a discard — carries it, and the platform's
32
+ // proxy delivers the request to exactly that machine (`fly-force-instance-id`). Creating a
33
+ // run is NOT pinned: any machine may answer, and the one that does becomes the run's.
34
+ // A local server calls itself `local`, and nothing is sent.
35
+ let runMachine = null;
36
+ export function pinRun(machine) {
37
+ const m = String(machine ?? '');
38
+ runMachine = m !== 'local' && /^[A-Za-z0-9_-]{4,64}$/.test(m) ? m : null;
39
+ }
40
+ export const runAuth = (token, extra = {}) => ({ authorization: `Bearer ${token}`, ...(runMachine ? { 'fly-force-instance-id': runMachine } : {}), ...extra });
41
+
42
+ /**
43
+ * makeInit is a function so a retry builds a fresh body (a consumed stream can't replay).
44
+ *
45
+ * A 429 is WAITED OUT, not reported. The server says how long in `Retry-After`, and a rate limit is transient by
46
+ * definition — it exists to slow a client down, not to refuse its work. Measured 2026-09-23 on a real backfill: one
47
+ * of the three sessions in the window held 95 files (its own transcript and 94 subagents), so the upload burst spent
48
+ * the minute's budget, the first 429 ended that session, and the run was left incomplete with "요청이 너무 잦습니다"
49
+ * on screen. The same lesson the model client already carries — "retries live at the HTTP client, where 429 and 5xx
50
+ * are retryable and retry-after is honoured" — had never reached the CLI's.
51
+ *
52
+ * `Retry-After` is trusted but bounded: a header is someone else's number, and a person waiting at a terminal should
53
+ * not be held for a minute by a typo.
54
+ *
55
+ * Only a 429 that CARRIES the header is waited out. The server's rate limiter always sends one; its decisions — five
56
+ * hours between captures, a day's count, a day's spend — are 429s too and send none, because waiting does not change
57
+ * them. Retried, a refused capture printed "서버가 잠시 기다려 달라고 합니다" twice and held the agent's turn for three
58
+ * seconds before saying what the first answer had already said — and with five hours between captures that refusal
59
+ * is the common reply, not the rare one.
60
+ */
61
+ const RETRY_AFTER_MAX_SEC = 30;
62
+
63
+ export async function fetchRetry(url, makeInit, tries = 3) {
64
+ for (let attempt = 1; ; attempt++) {
65
+ let res;
66
+ try {
67
+ res = await fetch(url, makeInit());
68
+ } catch (e) {
69
+ const code = e?.cause?.code ?? e?.code ?? '';
70
+ if (attempt >= tries || !TRANSIENT.has(code)) throw e;
71
+ warn(`연결이 끊겨 다시 시도합니다 (${attempt}/${tries - 1}) ${dim(code)}`);
72
+ await new Promise((r) => setTimeout(r, 500 * attempt));
73
+ continue;
74
+ }
75
+ if (res.status !== 429 || attempt >= tries) return res;
76
+ const asked = Number(res.headers.get('retry-after'));
77
+ if (!(Number.isFinite(asked) && asked > 0)) return res; // a decision, not a rate limit: the caller says so
78
+ const waitSec = Math.min(asked, RETRY_AFTER_MAX_SEC);
79
+ warn(`서버가 잠시 기다려 달라고 합니다 — ${waitSec}초 뒤 다시 시도합니다 (${attempt}/${tries - 1})`);
80
+ await new Promise((r) => setTimeout(r, waitSec * 1000));
81
+ }
82
+ }
83
+
84
+ export function reportFail(status, body, ctx) {
85
+ const text = typeof body === 'string' ? body : JSON.stringify(body ?? {});
86
+ fail(`${ctx} 실패 — ${serverMessage(status, text)}`, process.env.DIFFIN_DEBUG ? `${status} ${text.slice(0, 300)}` : undefined);
87
+ }
@@ -0,0 +1,74 @@
1
+ // `npx @diffin/cli onboard <setup-token>` — the one line the web page shows. Login, the `diffin` command on PATH, install
2
+ // the skill, run the first backfill, and stay until the server has a result. Each step
3
+ // is one line as it finishes; the card at the end is the result and what happens next.
4
+ // A failure stops there and names the single command to run again: this one. The setup token is single-use, so a
5
+ // second run on a machine that is already signed in goes on without one, and the backfill picks up the run it left
6
+ // open (the server hands it back). There is no separate backfill command any more (2026-10-07): a person met it only
7
+ // in a failure message, as a second command to learn at the worst moment.
8
+ import { login } from './login.mjs';
9
+ import { init } from './init.mjs';
10
+ import { backfill } from './backfill.mjs';
11
+ import { readCreds } from './creds.mjs';
12
+ import { agentById } from './agents.mjs';
13
+ import { version } from './status.mjs';
14
+ import { ensureCommand, PACKAGE } from './command.mjs';
15
+ import { accent, banner, card, dim, fail, out, stepDone, stepFail, stepWarn } from './ui.mjs';
16
+
17
+ const n = (x) => Number(x ?? 0).toLocaleString('ko-KR');
18
+
19
+ export async function onboard(argv = []) {
20
+ const rest = argv.filter((a) => a.startsWith('--') || ['--project', '--exclude'].includes(argv[argv.indexOf(a) - 1]));
21
+ banner(version());
22
+ const given = argv.filter((a) => !rest.includes(a));
23
+ const signedIn = !given.length && readCreds()?.token;
24
+ const who = signedIn ? { email: readCreds()?.email ?? '' } : await login(given, { quiet: true });
25
+ if (!who) return;
26
+ stepDone('로그인', who.email, signedIn ? dim('이미 연결돼 있습니다') : '');
27
+
28
+ // the command the agent will run: npx left nothing on PATH (command.mjs)
29
+ const cmd = ensureCommand(version());
30
+ if (cmd.state === 'failed') stepWarn('diffin 명령', '설치하지 못했습니다', dim(`터미널에서 npm install -g ${PACKAGE()} 를 실행하세요 · ${cmd.error}`));
31
+ else stepDone('diffin 명령', cmd.version, dim(cmd.state === 'installed' ? '설치했습니다 · 에이전트가 기록할 때 씁니다' : '이미 있습니다'));
32
+
33
+ const installed = init([], { quiet: true });
34
+ if (process.exitCode) { stepFail('에이전트 연결', '기록할 수 있는 에이전트가 이 기기에 없습니다'); return; }
35
+ // the agents whose sessions can be recorded are the ones connected; one that only received the skill is named apart
36
+ const names = (ok) => [...new Set(installed.map((r) => agentById(r.agent)).filter((a) => a && Boolean(a.capture) === ok).map((a) => a.name))];
37
+ const later = names(false);
38
+ stepDone('에이전트 연결', names(true).join(' · ') || '없음', later.length ? dim(`${later.join(' · ')} 는 아직 지원하지 않습니다`) : '');
39
+
40
+ // The window is the SERVER's: it comes from the account's entitlement and this
41
+ // command never previews — every source's line prints the range the server gave.
42
+ const runs = await backfill(rest);
43
+ out();
44
+ // the journey has already said it stopped, and how to go on
45
+ if (process.exitCode) { if (!runs?.journey) fail('지난 작업을 다 올리지 못했습니다.', 'npx @diffin/cli onboard 를 다시 실행하면 이어서 올립니다.'); return; }
46
+
47
+ // The app's address comes from the server (`FRONT_URL`, returned on connect) and nowhere else. A default here was
48
+ // `https://diffin.work/app`, which is the string the CLI's own rule names: it is wrong on every local run, and it
49
+ // is printed exactly when the server did NOT say — which is when it is least likely to be right.
50
+ const app = readCreds()?.appUrl ?? null;
51
+ const done = (runs ?? []).filter((r) => r.outcome === 'done');
52
+ const waiting = (runs ?? []).some((r) => r.outcome === 'waiting' || r.outcome === 'handed');
53
+ // a number is printed only where the server said it
54
+ const atoms = done.reduce((t, r) => t + Number(r.view?.progress?.atoms ?? 0), 0);
55
+ const goals = done.reduce((t, r) => t + Number(r.view?.progress?.goals ?? 0), 0);
56
+ const result = waiting ? '서버가 기록을 마저 정리하고 있습니다. 몇 분 뒤 앱에서 볼 수 있습니다.'
57
+ : atoms ? `기록 ${n(atoms)}개가 작업 ${n(goals)}개로 정리됐습니다.`
58
+ : '새로 올릴 작업은 없었습니다.';
59
+ card([
60
+ '연결됐습니다',
61
+ '',
62
+ // the journey's last frame already says what came of the run
63
+ ...(runs?.journey ? [] : [result]),
64
+ ...(app ? [`${accent('→')} ${app}`] : []),
65
+ '',
66
+ '이제부터 에이전트가 작업을 diffin 에 자동으로 기록합니다.',
67
+ '따로 할 일은 없습니다. 하루에 몇 번, 일이 일단락되면 올라갑니다.',
68
+ dim('에이전트의 다음 세션부터 적용됩니다.'),
69
+ '',
70
+ `기록 멈추기 diffin pause`,
71
+ `폴더 제외 diffin exclude <폴더>`,
72
+ `상태 보기 diffin status`,
73
+ ]);
74
+ }