@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.
- package/LICENSE +22 -0
- package/README.md +82 -2
- package/bin/diffin.mjs +69 -0
- package/lib/agents.mjs +56 -0
- package/lib/backfill.mjs +316 -0
- package/lib/capture.mjs +443 -0
- package/lib/command.mjs +44 -0
- package/lib/creds.mjs +35 -0
- package/lib/folders.mjs +76 -0
- package/lib/ignore.mjs +208 -0
- package/lib/init.mjs +114 -0
- package/lib/journey.mjs +269 -0
- package/lib/login.mjs +25 -0
- package/lib/machine.mjs +45 -0
- package/lib/net.mjs +87 -0
- package/lib/onboard.mjs +74 -0
- package/lib/scrub.mjs +513 -0
- package/lib/settings.mjs +46 -0
- package/lib/sources/antigravity.mjs +178 -0
- package/lib/sources/claude-code.mjs +107 -0
- package/lib/sources/codex.mjs +230 -0
- package/lib/sources/hermes.mjs +170 -0
- package/lib/sources/index.mjs +29 -0
- package/lib/sources/project-dir.mjs +13 -0
- package/lib/state.mjs +113 -0
- package/lib/status.mjs +71 -0
- package/lib/stream.mjs +67 -0
- package/lib/ui.mjs +220 -0
- package/lib/watch.mjs +164 -0
- package/package.json +21 -4
- package/templates/skills/capture/SKILL.md +107 -0
package/lib/ignore.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// `.diffin-ignore` — a file a person puts in a folder to say: nothing made here is recorded.
|
|
2
|
+
//
|
|
3
|
+
// It covers the folder AND everything under it, found the way git finds a .gitignore: from the session's folder up
|
|
4
|
+
// to the root. (It used to match the one folder exactly, so a file in ~/proj did nothing for a session opened in
|
|
5
|
+
// ~/proj/server — the person had said "not this project" and half of it went up.) And it holds for every command
|
|
6
|
+
// that sends: a capture and a BACKFILL alike. The backfill read only `--exclude`, so the first upload of a new
|
|
7
|
+
// agent, a retry, or `--extend` sent a folder the person had already marked.
|
|
8
|
+
//
|
|
9
|
+
// A session is held back when the folder it began in OR the folder it is in now is covered — a session keeps writing
|
|
10
|
+
// to the project dir it started in after the person cd's elsewhere, so the first folder alone would let a session
|
|
11
|
+
// that moved INTO an excluded folder through. A session that only PASSES THROUGH one is not held back here: its rows
|
|
12
|
+
// made there are cut one by one where every row is streamed out (`stream.mjs` `streamCutScrub`, `cli/test/ignore.mjs`),
|
|
13
|
+
// since each row says the folder it was made in. Not covered by either: a file of an excluded folder reached by its
|
|
14
|
+
// path from a session elsewhere — the row's folder is the session's, not the file's.
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, sep } from 'node:path';
|
|
17
|
+
import { readState, writeState } from './state.mjs';
|
|
18
|
+
|
|
19
|
+
/** how far back a capture of every session looks (capture.mjs), and what a later connect's folder list covers (backfill.mjs), for sessions that moved — a cheap
|
|
20
|
+
* mtime superset of the entitlement; the server's floors decide what is actually sent */
|
|
21
|
+
export const ALL_LOOKBACK_MS = 8 * 86_400_000;
|
|
22
|
+
|
|
23
|
+
const FILE = '.diffin-ignore';
|
|
24
|
+
const seen = new Map();
|
|
25
|
+
|
|
26
|
+
/** the folder that excludes `dir` — itself or an ancestor holding the file — or null */
|
|
27
|
+
export function ignoredBy(dir) {
|
|
28
|
+
if (!dir || typeof dir !== 'string') return null;
|
|
29
|
+
if (seen.has(dir)) return seen.get(dir);
|
|
30
|
+
// a folder the person switched off in the list (or with `diffin exclude`) is excluded the same way, it and all under it
|
|
31
|
+
const off = excludedFolders().find((x) => dir === x || dir.startsWith(x.endsWith(sep) ? x : x + sep)) ?? null;
|
|
32
|
+
if (off) { seen.set(dir, off); return off; }
|
|
33
|
+
let d = dir, found = null;
|
|
34
|
+
for (let i = 0; i < 64; i++) {
|
|
35
|
+
if (existsSync(join(d, FILE))) { found = d; break; }
|
|
36
|
+
const up = dirname(d);
|
|
37
|
+
if (up === d) break;
|
|
38
|
+
d = up;
|
|
39
|
+
}
|
|
40
|
+
seen.set(dir, found);
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Folders excluded without a file in them: the ones switched off in the first upload's list, or named with
|
|
46
|
+
* `diffin exclude`. Kept on this machine (state.json) rather than written into the person's project as a
|
|
47
|
+
* `.diffin-ignore` — a file appearing in a repository is a change to someone's work that they did not make.
|
|
48
|
+
*/
|
|
49
|
+
export function excludedFolders() {
|
|
50
|
+
const x = readState().excluded;
|
|
51
|
+
return Array.isArray(x) ? x.filter((f) => typeof f === 'string' && f) : [];
|
|
52
|
+
}
|
|
53
|
+
export function excludeFolders(folders) {
|
|
54
|
+
const s = readState();
|
|
55
|
+
s.excluded = [...new Set([...excludedFolders(), ...folders.filter(Boolean)])];
|
|
56
|
+
writeState(s); seen.clear();
|
|
57
|
+
}
|
|
58
|
+
export function includeFolder(folder) {
|
|
59
|
+
const s = readState();
|
|
60
|
+
const before = excludedFolders();
|
|
61
|
+
s.excluded = before.filter((f) => f !== folder);
|
|
62
|
+
if (!s.excluded.length) delete s.excluded;
|
|
63
|
+
writeState(s); seen.clear();
|
|
64
|
+
return before.length !== (s.excluded?.length ?? 0);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The ALLOW list (2026-10-11, `docs/folder-consent.md`): the folders the person switched ON at onboarding (or allowed
|
|
68
|
+
* since), kept here beside the excluded ones. Once a machine has one, a session in a folder under none of them is
|
|
69
|
+
* HELD — not sent, its floor untouched — until the person allows that folder (`diffin allow`, `diffin folders`, both on
|
|
70
|
+
* a terminal only). `null` means this machine has no list yet: nothing is held, and the first capture seeds the list
|
|
71
|
+
* from the folders it would send (an existing machine is converted without being asked). A denial beats an allowance.
|
|
72
|
+
*/
|
|
73
|
+
export function allowedFolders() {
|
|
74
|
+
const x = readState().allowed;
|
|
75
|
+
return Array.isArray(x) ? x.filter((f) => typeof f === 'string' && f) : null;
|
|
76
|
+
}
|
|
77
|
+
/** of a set of folders, the ones not under another of the set */
|
|
78
|
+
export function topFolders(folders) {
|
|
79
|
+
const u = [...new Set(folders.filter((f) => typeof f === 'string' && f))].sort();
|
|
80
|
+
return u.filter((d) => !u.some((x) => x !== d && d.startsWith(x.endsWith(sep) ? x : x + sep)));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Allow folders. `at` is the moment the PERSON allowed them (a terminal command, the app's decision): only the work after
|
|
84
|
+
* it goes up (`allowedSince`, sent as a capture session's `since`). The onboarding list and the seed pass no time — those
|
|
85
|
+
* folders' history is the backfill's and the watermark's, as before.
|
|
86
|
+
*/
|
|
87
|
+
export function allowFolders(folders, at) {
|
|
88
|
+
const s = readState();
|
|
89
|
+
const had = new Set(allowedFolders() ?? []);
|
|
90
|
+
s.allowed = topFolders([...had, ...folders]);
|
|
91
|
+
if (at) { s.allowedAt = { ...(s.allowedAt ?? {}) }; for (const f of folders) if (f && !had.has(f) && !allowedBy(f)) s.allowedAt[f] = new Date(at).toISOString(); }
|
|
92
|
+
writeState(s); seen.clear();
|
|
93
|
+
}
|
|
94
|
+
/** when the person allowed the folder covering `dir` — null for a folder of the onboarding list or the seed, or none */
|
|
95
|
+
export function allowedSince(dir) {
|
|
96
|
+
const a = allowedBy(dir); if (!a) return null;
|
|
97
|
+
const at = readState().allowedAt?.[a];
|
|
98
|
+
return typeof at === 'string' && !Number.isNaN(Date.parse(at)) ? at : null;
|
|
99
|
+
}
|
|
100
|
+
/** the allowed folder covering `dir`, or null (also null while the machine has no list) */
|
|
101
|
+
export function allowedBy(dir) {
|
|
102
|
+
const a = allowedFolders();
|
|
103
|
+
if (!a || !dir || typeof dir !== 'string') return null;
|
|
104
|
+
return a.find((x) => dir === x || dir.startsWith(x.endsWith(sep) ? x : x + sep)) ?? null;
|
|
105
|
+
}
|
|
106
|
+
/** a folder that is neither excluded nor allowed, on a machine that has an allow list: its sessions wait */
|
|
107
|
+
export function heldBy(dir) {
|
|
108
|
+
if (!dir || typeof dir !== 'string' || allowedFolders() === null) return false;
|
|
109
|
+
return !ignoredBy(dir) && !allowedBy(dir);
|
|
110
|
+
}
|
|
111
|
+
/** the folders to remember as allowed, from the list's answer: a folder that is on under one that is off (or under none) */
|
|
112
|
+
export function allowedFrom(tree, on) {
|
|
113
|
+
return tree.filter((it, i) => on[i] && (it.parent == null || !on[it.parent])).map((it) => it.dir);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** whether a `.diffin-ignore` FILE (not the list) is what excludes `dir` */
|
|
117
|
+
export function ignoreFileFor(dir) {
|
|
118
|
+
let d = dir;
|
|
119
|
+
for (let i = 0; i < 64 && d; i++) { if (existsSync(join(d, FILE))) return d; const up = dirname(d); if (up === d) break; d = up; }
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** the folder a session began in, as its rows say — the first row that names one (a few rows at most) */
|
|
124
|
+
export async function firstCwd(entry) {
|
|
125
|
+
if (entry.cwd) return entry.cwd;
|
|
126
|
+
let n = 0;
|
|
127
|
+
for await (const r of entry.files[0].rows()) {
|
|
128
|
+
if (r?.cwd) return String(r.cwd);
|
|
129
|
+
if (++n > 200) break;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** where a session began and where it is now, and whether either is excluded */
|
|
135
|
+
export async function sessionFolders(entry) {
|
|
136
|
+
const first = await firstCwd(entry).catch(() => null);
|
|
137
|
+
const last = (typeof entry.lastCwd === 'function' ? entry.lastCwd() : null) ?? first;
|
|
138
|
+
return { first, last, ignored: Boolean(ignoredBy(first) || ignoredBy(last)) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Where the work in a window was actually done: the folder of every turn the person made, counted. A session is not
|
|
143
|
+
* in one folder — each row says where it was made, and an agent moves about as it runs commands — so a list built from
|
|
144
|
+
* one folder per session was wrong whichever one it took (the last showed a repository's cli/ as a project of its own,
|
|
145
|
+
* the first a name the folder no longer has). This reads the rows. Costs one pass over the transcripts: measured
|
|
146
|
+
* 246,798 rows in 4.3 s on the owner's machine.
|
|
147
|
+
*/
|
|
148
|
+
export async function folderCensus(entries, { tsOf, fromMs = 0, toMs = Infinity }) {
|
|
149
|
+
const turns = new Map();
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
for await (const r of e.files[0].rows()) {
|
|
152
|
+
if (r?.type !== 'user' || r.isMeta) continue;
|
|
153
|
+
const t = tsOf(r);
|
|
154
|
+
if (t && (t < fromMs || t >= toMs)) continue;
|
|
155
|
+
const c = r.message?.content;
|
|
156
|
+
const said = typeof c === 'string' ? c.trim() : Array.isArray(c) && !c.some((b) => b?.type === 'tool_result') && c.some((b) => b?.type === 'text' && String(b.text ?? '').trim());
|
|
157
|
+
if (!said) continue;
|
|
158
|
+
const dir = String(r.cwd ?? e.cwd ?? '');
|
|
159
|
+
if (!dir || ignoredBy(dir)) continue;
|
|
160
|
+
turns.set(dir, (turns.get(dir) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return turns;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The census as a tree for the list: a folder sits under the nearest folder OF THE LIST that contains it, so the
|
|
168
|
+
* grouping is what the transcripts say and nothing is guessed (no looking for a repository's root). Items come in
|
|
169
|
+
* display order — a top folder, then everything under it — each with the turns made in it and under it.
|
|
170
|
+
*/
|
|
171
|
+
export function folderTree(turns) {
|
|
172
|
+
const dirs = [...turns.keys()].sort();
|
|
173
|
+
const under = (a, b) => a !== b && a.startsWith(b.endsWith(sep) ? b : b + sep);
|
|
174
|
+
const parentOf = (d) => dirs.filter((x) => under(d, x)).sort((a, b) => b.length - a.length)[0] ?? null;
|
|
175
|
+
const total = (d) => dirs.filter((x) => x === d || under(x, d)).reduce((t, x) => t + turns.get(x), 0);
|
|
176
|
+
const tops = dirs.filter((d) => !parentOf(d)).sort((a, b) => total(b) - total(a));
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const t of tops) {
|
|
179
|
+
out.push({ dir: t, parent: null, turns: total(t), own: turns.get(t) });
|
|
180
|
+
// `rel` is the path under the folder it sits beneath, so a nested line reads as a step down from the one above it
|
|
181
|
+
for (const d of dirs.filter((x) => under(x, t))) { const up = parentOf(d); out.push({ dir: d, parent: up, turns: total(d), own: turns.get(d), rel: d.slice(up.length + (up.endsWith(sep) ? 0 : 1)) }); }
|
|
182
|
+
}
|
|
183
|
+
return out.map((it) => ({ ...it, parent: it.parent == null ? null : out.findIndex((x) => x.dir === it.parent) }));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** the folders to remember as excluded, from the list's answer: a folder that is off under one that is on (or under
|
|
187
|
+
* none) — what lies under an excluded folder is already covered by it */
|
|
188
|
+
export function excludedFrom(tree, on) {
|
|
189
|
+
return tree.filter((it, i) => !on[i] && (it.parent == null || on[it.parent])).map((it) => it.dir);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Split a list of sessions into the ones that may be sent, how many a `.diffin-ignore` held back, and — with `hold` —
|
|
194
|
+
* the ones waiting for the person to allow their folder (`held`: the session and the folder it is in). A session is
|
|
195
|
+
* held when the folder it began in OR the one it is in now is held, as it is excluded by either.
|
|
196
|
+
*/
|
|
197
|
+
export async function withoutIgnored(entries, { hold = false } = {}) {
|
|
198
|
+
const kept = [], held = [];
|
|
199
|
+
let ignored = 0;
|
|
200
|
+
for (const e of entries) {
|
|
201
|
+
const f = await sessionFolders(e);
|
|
202
|
+
if (f.ignored) { ignored++; continue; }
|
|
203
|
+
const cwd = f.last ?? f.first ?? e.cwd;
|
|
204
|
+
if (hold && (heldBy(f.first) || heldBy(f.last))) { held.push({ entry: { ...e, cwd }, folder: heldBy(f.last) ? f.last : f.first }); continue; }
|
|
205
|
+
kept.push({ ...e, cwd, cwd0: f.first ?? cwd });
|
|
206
|
+
}
|
|
207
|
+
return { kept, ignored, held };
|
|
208
|
+
}
|
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `diffin init` — put the capture skill where every agent on this machine reads
|
|
2
|
+
// skills. Idempotent: an installed copy at the same or newer version is left alone,
|
|
3
|
+
// an older one is replaced. For Claude Code and Codex it also puts `diffin capture` on
|
|
4
|
+
// the agent's allowlist (settings.mjs): the send is the agent's own call, made without a
|
|
5
|
+
// prompt, because consent was given at install and scoped by folder. Nothing else is
|
|
6
|
+
// installed — the skill is the whole integration, the same for every agent.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { skillTargets } from './agents.mjs';
|
|
12
|
+
import { dim, fail, out, stepDone, stepWarn } from './ui.mjs';
|
|
13
|
+
import { installClaudeAllow, installCodexAllow } from './settings.mjs';
|
|
14
|
+
|
|
15
|
+
const SKILL = 'capture';
|
|
16
|
+
const BUNDLED = join(dirname(fileURLToPath(import.meta.url)), '..', 'templates', 'skills', SKILL, 'SKILL.md');
|
|
17
|
+
|
|
18
|
+
export function skillVersion(text) {
|
|
19
|
+
const m = String(text ?? '').match(/^\s*version:\s*"?([0-9]+(?:\.[0-9]+)*)"?\s*$/m);
|
|
20
|
+
return m ? m[1] : null;
|
|
21
|
+
}
|
|
22
|
+
const newer = (a, b) => { // a > b ?
|
|
23
|
+
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
|
|
24
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d > 0; }
|
|
25
|
+
return false;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** what the bundled skill's version is, and what is installed for the agent we run under */
|
|
29
|
+
export function installedVersion(dir) {
|
|
30
|
+
const p = join(dir, SKILL, 'SKILL.md');
|
|
31
|
+
return existsSync(p) ? skillVersion(readFileSync(p, 'utf8')) : null;
|
|
32
|
+
}
|
|
33
|
+
export const bundledVersion = () => skillVersion(readFileSync(BUNDLED, 'utf8'));
|
|
34
|
+
|
|
35
|
+
/** the bundled skill, with a description an agent renders well: Hermes shows 60 chars */
|
|
36
|
+
export function skillTextFor(agentId, text) {
|
|
37
|
+
if (agentId !== 'hermes') return text;
|
|
38
|
+
const desc = 'description: 기록해/capture, and whenever work just landed — merged, deployed, a document or request finished, a decision confirmed, the person wrapping up — records every session\'s work to diffin by running `diffin capture` alone, no confirmation. When unsure, run it — the command ends a call that comes too soon, without sending.';
|
|
39
|
+
return text.replace(/^description: .*$/m, desc);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function init(argv = [], { quiet = false, skillsOnly = false, closing = true } = {}) {
|
|
43
|
+
const text = readFileSync(BUNDLED, 'utf8');
|
|
44
|
+
const version = skillVersion(text);
|
|
45
|
+
const targets = skillTargets();
|
|
46
|
+
if (!targets.length) {
|
|
47
|
+
fail('스킬을 읽는 에이전트가 이 기기에 없습니다.', 'Claude Code, Codex, Antigravity, Hermes 가운데 하나를 설치한 뒤 diffin init 을 다시 실행하세요.');
|
|
48
|
+
process.exitCode = 1; return [];
|
|
49
|
+
}
|
|
50
|
+
const results = [];
|
|
51
|
+
for (const t of targets) {
|
|
52
|
+
const dir = join(t.dir, SKILL);
|
|
53
|
+
const have = installedVersion(t.dir);
|
|
54
|
+
let action;
|
|
55
|
+
if (have && !newer(version, have)) action = have === version ? 'kept' : 'kept (newer)';
|
|
56
|
+
else { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'SKILL.md'), skillTextFor(t.agent.id, text)); action = have ? `updated ${have} → ${version}` : `installed ${version}`; }
|
|
57
|
+
results.push({ agent: t.agent.id, dir, action });
|
|
58
|
+
const label = action === 'kept' ? `최신 ${dim(version)}` : action === 'kept (newer)' ? `유지 ${dim(have)}` : action.startsWith('updated') ? `갱신 ${dim(`${have} → ${version}`)}` : `설치 ${dim(version)}`;
|
|
59
|
+
if (!quiet) stepDone(t.agent.name, dir.replace(process.env.DIFFIN_HOME ?? homedir(), '~'), dim(label));
|
|
60
|
+
}
|
|
61
|
+
if (skillsOnly) return results;
|
|
62
|
+
// `diffin capture` on the allowlist is what lets the agent's own call run without a
|
|
63
|
+
// permission prompt (a "no" on that prompt ends its turn).
|
|
64
|
+
if (results.some((r) => r.agent === 'cc')) {
|
|
65
|
+
const a = installClaudeAllow();
|
|
66
|
+
if (!quiet && a.installed) stepDone('Claude Code', '~/.claude/settings.json', dim('허용 목록에 diffin capture 추가'));
|
|
67
|
+
else if (!quiet && a.reason === 'unreadable') stepWarn('Claude Code', '~/.claude/settings.json', dim('읽을 수 없어 허용 항목을 넣지 않았습니다'));
|
|
68
|
+
}
|
|
69
|
+
// Codex: the same entry in its rules file — there it is also what lets the command out of the sandbox (settings.mjs)
|
|
70
|
+
if (results.some((r) => r.agent === 'codex')) {
|
|
71
|
+
const a = installCodexAllow();
|
|
72
|
+
if (!quiet && a.installed) stepDone('Codex', '~/.codex/rules/default.rules', dim('허용 목록에 diffin capture 추가'));
|
|
73
|
+
else if (!quiet && a.reason === 'unreadable') stepWarn('Codex', '~/.codex/rules/default.rules', dim('읽을 수 없어 허용 항목을 넣지 않았습니다'));
|
|
74
|
+
}
|
|
75
|
+
if (!quiet) {
|
|
76
|
+
if (results.some((r) => !['cc', 'hermes', 'codex', 'antigravity', 'agents'].includes(r.agent))) out(` ${dim('기록은 아직 Claude Code, Codex, Antigravity, Hermes 세션만 지원합니다.')}`);
|
|
77
|
+
if (closing) {
|
|
78
|
+
out();
|
|
79
|
+
out(` ${dim('에이전트의 다음 세션부터, 에이전트가 작업을 diffin 에 자동으로 기록합니다. 따로 할 일은 없습니다.')}`);
|
|
80
|
+
out(` ${dim('잠시 멈추려면 diffin pause, 폴더를 빼려면 diffin exclude')}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Called by capture and backfill, so nobody has to remember `diffin init`. Three cases:
|
|
88
|
+
*
|
|
89
|
+
* An agent installed after onboarding: its skill dir has no capture yet. It gets
|
|
90
|
+
* the whole of init.
|
|
91
|
+
*
|
|
92
|
+
* A skill OLDER than the one this package carries: the package was updated and the
|
|
93
|
+
* skill was not. Nothing used to refresh it — the only notice was a dim line printed
|
|
94
|
+
* by `diffin capture`, a command the agent runs at the end of its turn, where nobody
|
|
95
|
+
* reads it — so an updated package kept instructing the agent by the old rules for
|
|
96
|
+
* good (measured 2026-10-03: the skill said an hour between captures ten hours after
|
|
97
|
+
* the server began refusing anything under five). A newer installed copy is left
|
|
98
|
+
* alone, as init leaves it.
|
|
99
|
+
*
|
|
100
|
+
* Quiet; one line per thing it touched.
|
|
101
|
+
*/
|
|
102
|
+
export function ensureInit() {
|
|
103
|
+
const targets = skillTargets();
|
|
104
|
+
const want = bundledVersion();
|
|
105
|
+
const missing = targets.filter((t) => !installedVersion(t.dir));
|
|
106
|
+
const stale = targets.map((t) => ({ t, have: installedVersion(t.dir) })).filter((x) => x.have && want && newer(want, x.have));
|
|
107
|
+
// Codex's rule is what lets its capture reach the server at all, so an agent that appeared later gets it here too
|
|
108
|
+
try { if (targets.some((t) => t.agent.id === 'codex') && installCodexAllow().changed) stepDone('Codex', '~/.codex/rules/default.rules', dim('허용 목록에 diffin capture 추가')); } catch { /* not worth an error */ }
|
|
109
|
+
if (!missing.length && !stale.length) return [];
|
|
110
|
+
const r = init([], { quiet: true, skillsOnly: !missing.length });
|
|
111
|
+
for (const t of missing) stepDone(t.agent.name, '새로 감지', dim('캡처 스킬 설치'));
|
|
112
|
+
for (const { t, have } of stale) stepDone(t.agent.name, `캡처 스킬 ${have} → ${want}`, dim('갱신'));
|
|
113
|
+
return r;
|
|
114
|
+
}
|
package/lib/journey.mjs
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// The onboarding's wait as one journey: 올리기 → 읽기 → 묶기 → 완성, one track for the whole of it, a headline and a line
|
|
2
|
+
// of what is happening, a character walking the track, and when it should be done. The same four stops and words as the
|
|
3
|
+
// app's screen, so a person who watched one recognises the other.
|
|
4
|
+
//
|
|
5
|
+
// Why one track. The run used to show each server stage's own count, so the bar filled to the end and emptied again at
|
|
6
|
+
// every stage (읽기 3/8, then 저장 0/8, then 묶기 0/1) and a stage that counts nothing showed no bar at all; the person
|
|
7
|
+
// could see that something moved, never how far in they were. The wait is 12–20 minutes on a first run of eleven or
|
|
8
|
+
// twelve days, and the moment it starts is the product's first impression.
|
|
9
|
+
//
|
|
10
|
+
// The stops are as long as the stages take. Measured on the run records (2026-10-07, wall time): a 554-record backfill
|
|
11
|
+
// read 28% · stitch 68% · finish 3%; captures of 125, 44 and 6 records 21–26% · 68–76% · 6–9%. The upload, the one part
|
|
12
|
+
// the CLI does itself, is short against them. So 10 · 22 · 60 · 8.
|
|
13
|
+
//
|
|
14
|
+
// Only on a terminal: a log or a pipe gets the step lines it always got (`backfill.mjs`, `watch.mjs`), one per result.
|
|
15
|
+
import { accent, bold, dim, green, red } from './ui.mjs';
|
|
16
|
+
|
|
17
|
+
export const WEIGHTS = { upload: 0.10, read: 0.22, stitch: 0.60, finish: 0.08 };
|
|
18
|
+
const ORDER = ['upload', 'read', 'stitch', 'finish'];
|
|
19
|
+
/** the stitch stage against the read stage, in wall time, and the finish against the read (the same runs as above:
|
|
20
|
+
* stitch/read 2.4 · 2.6 · 3.3 · 4.8, finish/read 0.1 · 0.2 · 0.4 · 0.5) — what the wait still holds once reading is timed */
|
|
21
|
+
export const STITCH_PER_READ = 2.7, FINISH_PER_READ = 0.3;
|
|
22
|
+
|
|
23
|
+
const STOPS = {
|
|
24
|
+
upload: { name: '올리기', title: '대화를 받고 있습니다', note: '다 받으면 바로 읽기 시작합니다.', doing: '대화 받는 중', item: '📦', plain: '[]' },
|
|
25
|
+
read: { name: '읽기', title: '대화를 읽고 있습니다', note: '대화마다 무슨 일을 했는지 찾아 기록으로 남깁니다.', doing: '한 일 찾는 중', item: '👀', plain: 'oo' },
|
|
26
|
+
stitch: { name: '묶기', title: '기록을 작업으로 묶고 있습니다', note: '비슷한 기록끼리 모아 하나의 작업으로 만듭니다. 가장 오래 걸리는 단계입니다.', doing: '작업으로 묶는 중', item: '🧵', plain: '~~' },
|
|
27
|
+
finish: { name: '완성', title: '마지막으로 다듬고 있습니다', note: '작업마다 직군을 붙이고 비슷한 작업끼리 프로젝트로 나눕니다.', doing: '기록 고르는 중', item: '✨', plain: '**' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** where a server run is on the track, from the progress the worker writes on the run record (`progress.stage`) */
|
|
31
|
+
export function serverPlace(view) {
|
|
32
|
+
const p = view?.progress ?? {};
|
|
33
|
+
const st = view?.status;
|
|
34
|
+
if (st === 'done' || st === 'empty' || p.stage === 'done') return { stage: 'finish', frac: 1, done: true };
|
|
35
|
+
const f = (x) => (p.total > 0 ? Math.min(1, Math.max(0, p.done / p.total)) : 0) * x;
|
|
36
|
+
switch (p.stage) {
|
|
37
|
+
case 'scan': return { stage: 'read', frac: 0.02 };
|
|
38
|
+
case 'map': return { stage: 'read', frac: 0.05 + f(0.75) };
|
|
39
|
+
case 'ship': return { stage: 'read', frac: 0.8 + f(0.2) };
|
|
40
|
+
case 'stitch': return { stage: 'stitch', frac: f(1), counted: p.total > 1 };
|
|
41
|
+
case 'domain': return { stage: 'finish', frac: 0.1 };
|
|
42
|
+
case 'relevance': return { stage: 'finish', frac: 0.45 };
|
|
43
|
+
case 'project': return { stage: 'finish', frac: 0.8 };
|
|
44
|
+
default: return { stage: 'read', frac: 0, waiting: true };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** a stage measured only by time: up to 90% by the time it is expected to take, then ever slower, never full */
|
|
49
|
+
export function eased(elapsed, expected) {
|
|
50
|
+
if (!(expected > 0)) return 0;
|
|
51
|
+
const x = elapsed / expected;
|
|
52
|
+
return x < 1 ? 0.9 * x : 0.9 + 0.09 * (1 - Math.exp(-(x - 1)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** the whole track's fill, 0–1, from a stage and how far into it */
|
|
56
|
+
export function overall(stage, frac) {
|
|
57
|
+
let t = 0;
|
|
58
|
+
for (const s of ORDER) { if (s === stage) return t + WEIGHTS[s] * Math.min(1, Math.max(0, frac)); t += WEIGHTS[s]; }
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** the stop and how far into it, from the track's fill — `overall` the other way round */
|
|
63
|
+
export function placeOf(fill) {
|
|
64
|
+
let t = 0;
|
|
65
|
+
for (const s of ORDER) { if (fill < t + WEIGHTS[s] || s === 'finish') return { stage: s, frac: Math.min(1, Math.max(0, (fill - t) / WEIGHTS[s])) }; t += WEIGHTS[s]; }
|
|
66
|
+
return { stage: 'finish', frac: 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** the least a run takes on the server whatever its size: the four small runs of 2026-10-07 (4, 6, 8 records)
|
|
70
|
+
* took 17, 20 and 25 s, nearly all of it fixed cost */
|
|
71
|
+
export const RUN_FLOOR_SEC = 20;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Where the server's half of the wait stands, from every run's place. The worker drives ONE run of a person at a
|
|
75
|
+
* time (a claim is per user, `pipeline/src/worker.ts`), so the runs are a queue, not a race: the fill is each run's
|
|
76
|
+
* own progress weighted by its size, and what is left is the run in hand plus every run still in line.
|
|
77
|
+
* Until 2026-10-07 the journey stood where the slowest run was — a queued run, at nothing, for as long as the first
|
|
78
|
+
* run took — so the track sat in 읽기 for seven minutes, the estimate read "약 47분", and then everything ran through
|
|
79
|
+
* in seconds once the queue reached the small runs.
|
|
80
|
+
*
|
|
81
|
+
* `places`: serverPlace(view) of each run, with `w` its size. `clocks[i]`: when run i was first seen at each stop
|
|
82
|
+
* (filled here). Returns { fill (0–1 of the server's part), waiting, left (seconds, or null) }.
|
|
83
|
+
*/
|
|
84
|
+
export function queue(places, clocks, now) {
|
|
85
|
+
const W = places.reduce((t, p) => t + (p.w ?? 1), 0) || 1;
|
|
86
|
+
let fill = 0, left = 0, known = true, spent = 0, spentW = 0;
|
|
87
|
+
const live = [];
|
|
88
|
+
places.forEach((p, i) => {
|
|
89
|
+
const c = (clocks[i] ??= {});
|
|
90
|
+
if (p.done) { if (c.read != null && c.end == null) c.end = now; } else if (!p.waiting) { c.read ??= now; c[p.stage] ??= now; }
|
|
91
|
+
let frac = p.frac;
|
|
92
|
+
if (!p.done && p.stage === 'stitch' && !p.counted && c.stitch != null) frac = Math.max(frac, eased((now - c.stitch) / 1000, Math.max(20, ((c.stitch - c.read) / 1000) * STITCH_PER_READ)));
|
|
93
|
+
const own = p.done ? 1 : p.waiting ? 0 : (overall(p.stage, frac) - WEIGHTS.upload) / (1 - WEIGHTS.upload);
|
|
94
|
+
fill += ((p.w ?? 1) / W) * own;
|
|
95
|
+
if (p.done) { if (c.read != null) { spent += (c.end - c.read) / 1000; spentW += p.w ?? 1; } return; }
|
|
96
|
+
if (p.waiting) return;
|
|
97
|
+
const r = remaining({ stage: p.stage, frac, now, clock: c });
|
|
98
|
+
if (r == null) known = false; else { left += r; spent += (now - c.read) / 1000 + r; spentW += p.w ?? 1; }
|
|
99
|
+
live.push(i);
|
|
100
|
+
});
|
|
101
|
+
// a run in line takes what the timed runs took per unit of size, never less than a run's fixed cost
|
|
102
|
+
const perW = spentW ? spent / spentW : null;
|
|
103
|
+
for (const p of places) if (!p.done && p.waiting) { if (perW == null) known = false; else left += Math.max(RUN_FLOOR_SEC, perW * (p.w ?? 1)); }
|
|
104
|
+
return { fill, waiting: !live.length && places.some((p) => !p.done), left: known ? left : null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** what is left, in seconds, or null while there is nothing to reckon from; `clock` holds when each stage began */
|
|
108
|
+
export function remaining({ stage, frac, now, clock }) {
|
|
109
|
+
const readStart = clock.read, stitchStart = clock.stitch, finishStart = clock.finish;
|
|
110
|
+
if (stage === 'upload' || readStart == null) return null;
|
|
111
|
+
if (stage === 'read') {
|
|
112
|
+
const spent = (now - readStart) / 1000;
|
|
113
|
+
if (frac < 0.15 || spent < 10) return null;
|
|
114
|
+
const total = spent / frac;
|
|
115
|
+
return total * (1 - frac) + total * (STITCH_PER_READ + FINISH_PER_READ);
|
|
116
|
+
}
|
|
117
|
+
const readWall = ((stitchStart ?? now) - readStart) / 1000;
|
|
118
|
+
if (stage === 'stitch') {
|
|
119
|
+
const expected = Math.max(20, readWall * STITCH_PER_READ);
|
|
120
|
+
const spent = (now - stitchStart) / 1000;
|
|
121
|
+
return Math.max(expected - spent, expected * 0.1) + readWall * FINISH_PER_READ;
|
|
122
|
+
}
|
|
123
|
+
const spent = (now - (finishStart ?? now)) / 1000;
|
|
124
|
+
return Math.max(readWall * FINISH_PER_READ - spent, 5);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** the number a person reads: it follows a falling estimate at once and a rising one only slowly */
|
|
128
|
+
export function settle(shown, computed, dtSec) {
|
|
129
|
+
if (computed == null) return shown;
|
|
130
|
+
if (shown == null || computed <= shown) return computed;
|
|
131
|
+
return shown + Math.min(computed - shown, dtSec * 0.25);
|
|
132
|
+
}
|
|
133
|
+
export const leftText = (sec) => (sec == null ? '예상 시간 계산 중' : sec < 60 ? '곧 끝납니다' : `약 ${Math.ceil(sec / 60)}분 남음`);
|
|
134
|
+
|
|
135
|
+
// terminal columns: a Hangul syllable or an emoji takes two
|
|
136
|
+
const wide = (cp) => (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0x1f300 && cp <= 0x1faff) || cp === 0x2728;
|
|
137
|
+
const BARE = /\x1b\[[0-9;]*m/g;
|
|
138
|
+
export const cols = (s) => [...String(s).replace(BARE, '').replace(/\ufe0f/g, '')].reduce((n, ch) => n + (wide(ch.codePointAt(0)) ? 2 : 1), 0);
|
|
139
|
+
const fit = (s, w) => { let out = '', n = 0; for (const ch of String(s)) { const c = wide(ch.codePointAt(0)) ? 2 : 1; if (n + c > w) break; out += ch; n += c; } return out; };
|
|
140
|
+
const wrap = (s, w) => { const lines = []; let cur = ''; for (const word of String(s).split(' ')) { const next = cur ? `${cur} ${word}` : word; if (cols(next) > w && cur) { lines.push(cur); cur = word; } else cur = next; } if (cur) lines.push(cur); return lines.map((l) => fit(l, w)); };
|
|
141
|
+
|
|
142
|
+
/** can this terminal draw emoji and the character's face? Not the old Windows console, not a dumb terminal */
|
|
143
|
+
export function canDraw(env = process.env, platform = process.platform) {
|
|
144
|
+
if (env.DIFFIN_PLAIN || env.TERM === 'dumb') return false;
|
|
145
|
+
if (platform === 'win32' && !env.WT_SESSION && !env.TERM_PROGRAM) return false;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const FACE = { open: '(•ᴗ•)', blink: '(-ᴗ-)', happy: '(^ᴗ^)', out: '(×_×)' };
|
|
150
|
+
const PLAIN_FACE = { open: '(o.o)', blink: '(-.-)', happy: '(^o^)', out: '(x_x)' };
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One frame of the journey, as lines. Pure — the clock, the size and the tick come in — so a test can hold it.
|
|
154
|
+
* `s`: { mode: 'run' | 'done' | 'stopped', stage, frac, sessions, elapsedSec, leftSec, waiting, result: { atoms, goals } }
|
|
155
|
+
*/
|
|
156
|
+
export function frame(s, { width = 80, tick = 0, emoji = true } = {}) {
|
|
157
|
+
const W = Math.max(28, Math.min(60, width - 4));
|
|
158
|
+
const face = emoji ? FACE : PLAIN_FACE;
|
|
159
|
+
const stop = STOPS[s.stage] ?? STOPS.upload;
|
|
160
|
+
const fill = s.mode === 'done' ? 1 : overall(s.stage, s.frac);
|
|
161
|
+
const lines = [];
|
|
162
|
+
const n = (x) => Number(x ?? 0).toLocaleString('ko-KR');
|
|
163
|
+
|
|
164
|
+
// the headline and what is happening
|
|
165
|
+
if (s.mode === 'done') {
|
|
166
|
+
lines.push(`${green('✓')} ${bold('정리가 끝났습니다')}`);
|
|
167
|
+
lines.push(...wrap(s.result?.atoms > 0 ? `기록 ${n(s.result.atoms)}개가 작업 ${n(s.result.goals)}개로 묶였습니다. 앞으로는 작업이 끝날 때마다 에이전트가 알아서 기록합니다.` : '새로 기록할 작업은 없었습니다. 앞으로는 작업이 끝날 때마다 에이전트가 알아서 기록합니다.', W));
|
|
168
|
+
} else if (s.mode === 'stopped') {
|
|
169
|
+
lines.push(bold('처리가 끝나지 않았습니다'));
|
|
170
|
+
lines.push(...wrap('이미 올라간 기록은 그대로 남아 있습니다. 터미널에서 아래 명령을 다시 실행하면 이어서 올라갑니다.', W));
|
|
171
|
+
lines.push('', ` ${dim('$')} npx @diffin/cli onboard`);
|
|
172
|
+
} else if (s.mode === 'later') {
|
|
173
|
+
lines.push(bold('서버가 마저 정리하고 있습니다'));
|
|
174
|
+
lines.push(...wrap('몇 분 뒤 앱에서 볼 수 있습니다. 창을 닫아도 계속됩니다.', W));
|
|
175
|
+
} else {
|
|
176
|
+
lines.push(`${bold(stop.title)}${emoji ? ` ${stop.item}` : ''}`);
|
|
177
|
+
lines.push(...wrap(stop.note, W));
|
|
178
|
+
}
|
|
179
|
+
lines.push('');
|
|
180
|
+
|
|
181
|
+
// the character above the track, where the track has reached; it carries the stage's thing and hops it now and then
|
|
182
|
+
const mood = s.mode === 'done' ? 'happy' : s.mode === 'stopped' ? 'out' : tick % 14 === 13 ? 'blink' : 'open';
|
|
183
|
+
const thing = s.mode === 'done' ? (emoji ? '🎉' : '\\o/') : s.mode === 'stopped' ? (emoji ? '💤' : 'zz') : emoji ? stop.item : stop.plain;
|
|
184
|
+
const at = Math.max(0, Math.min(W - 7, Math.round(fill * W) - 3));
|
|
185
|
+
const hop = s.mode === 'run' && Math.floor(tick / 3) % 2 === 1;
|
|
186
|
+
lines.push(' '.repeat(at + cols(face.open)) + (hop ? thing : ''));
|
|
187
|
+
lines.push(' '.repeat(at) + face[mood] + (hop ? '' : thing));
|
|
188
|
+
|
|
189
|
+
// the track: four lengths by how long each takes, a gap between, filled to where the run is
|
|
190
|
+
const gaps = ORDER.length - 1, span = W - gaps;
|
|
191
|
+
const min = 6;
|
|
192
|
+
let lens = ORDER.map((k) => Math.max(min, Math.round(WEIGHTS[k] * span)));
|
|
193
|
+
lens[2] += span - lens.reduce((a, b) => a + b, 0); // the longest stop takes the rounding
|
|
194
|
+
let filled = Math.round(fill * span), track = '', starts = [], pos = 0;
|
|
195
|
+
ORDER.forEach((k, i) => {
|
|
196
|
+
starts.push(pos);
|
|
197
|
+
const f = Math.max(0, Math.min(lens[i], filled)); filled -= lens[i];
|
|
198
|
+
track += accent('━'.repeat(f)) + dim('─'.repeat(lens[i] - f)) + (i < gaps ? ' ' : '');
|
|
199
|
+
pos += lens[i] + 1;
|
|
200
|
+
});
|
|
201
|
+
lines.push(track);
|
|
202
|
+
const here = s.mode === 'done' ? -1 : ORDER.indexOf(s.stage);
|
|
203
|
+
let labels = '', used = 0;
|
|
204
|
+
ORDER.forEach((k, i) => {
|
|
205
|
+
const name = STOPS[k].name; const pad = Math.max(0, starts[i] - used); labels += ' '.repeat(pad); used += pad;
|
|
206
|
+
labels += i === here ? bold(name) : i > here && here >= 0 ? dim(name) : name; used += cols(name);
|
|
207
|
+
if (i < gaps) { labels += ' '; used += 1; }
|
|
208
|
+
});
|
|
209
|
+
lines.push(labels, '');
|
|
210
|
+
|
|
211
|
+
// the status line: what is happening, how much, for how long, and how long still
|
|
212
|
+
// on a narrow terminal the parts go least needed first: how many conversations, then how long it has been
|
|
213
|
+
const parts = [s.sessions ? `대화 ${n(s.sessions)}개` : '', s.mode === 'done' ? `${since(s.elapsedSec)} 걸렸습니다` : s.mode === 'stopped' ? '' : `${since(s.elapsedSec)}째`, s.mode === 'run' ? leftText(s.leftSec) : ''].filter(Boolean);
|
|
214
|
+
const dot = s.mode === 'done' ? green('●') : s.mode === 'stopped' ? red('●') : accent('●');
|
|
215
|
+
const doing = s.mode === 'done' ? '정리 완료' : s.mode === 'stopped' ? '멈춤' : s.mode === 'later' ? '정리 중' : s.waiting ? '차례 기다리는 중' : stop.doing;
|
|
216
|
+
while (parts.length && cols(`● ${doing} ${parts.join(' · ')}`) > W) parts.shift();
|
|
217
|
+
lines.push(`${dot} ${bold(doing)} ${dim(parts.join(' · '))}`);
|
|
218
|
+
// the upload is the one part that stops with the window; the rest is the server's
|
|
219
|
+
if (s.mode === 'run' && s.stage !== 'upload') lines.push(dim('창을 닫아도 계속됩니다.'));
|
|
220
|
+
return lines.map((l) => ` ${l}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const since = (sec) => { const t = Math.max(0, Math.round(sec)); return t < 60 ? `${t}초` : `${Math.floor(t / 60)}분 ${String(t % 60).padStart(2, '0')}초`; };
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The live journey on stderr. `upload(done, total)` while files go up, `server(places)` with each run's place on every
|
|
227
|
+
* poll, then `end(mode, extra)` once — it leaves the last frame standing. `note(text)` prints a line above it (a skipped
|
|
228
|
+
* session, a failure) without tearing the drawing.
|
|
229
|
+
*/
|
|
230
|
+
export function journey({ sessions = 0, stream = process.stderr, emoji = canDraw() } = {}) {
|
|
231
|
+
const t0 = Date.now();
|
|
232
|
+
const clocks = [];
|
|
233
|
+
let st = { mode: 'run', stage: 'upload', frac: 0, sessions };
|
|
234
|
+
let shown = null, lastAt = t0, tick = 0, drawn = 0, ended = false, places = null, est = null;
|
|
235
|
+
const width = () => stream.columns || 80;
|
|
236
|
+
// every run's place, read again on each frame so a stitch that counts nothing still moves between polls
|
|
237
|
+
const place = (now) => {
|
|
238
|
+
const q = queue(places, clocks, now);
|
|
239
|
+
est = q.left;
|
|
240
|
+
const next = placeOf(WEIGHTS.upload + (1 - WEIGHTS.upload) * q.fill);
|
|
241
|
+
// the track never goes back: a later poll that reads a little lower holds where it was
|
|
242
|
+
st = overall(next.stage, next.frac) >= overall(st.stage, st.frac) ? { ...st, ...next, waiting: q.waiting } : { ...st, waiting: q.waiting };
|
|
243
|
+
};
|
|
244
|
+
const draw = () => {
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
if (st.mode === 'run' && places) place(now);
|
|
247
|
+
if (st.mode === 'run') { shown = settle(shown != null ? Math.max(0, shown - (now - lastAt) / 1000) : null, est, (now - lastAt) / 1000); lastAt = now; }
|
|
248
|
+
const lines = frame({ ...st, elapsedSec: (now - t0) / 1000, leftSec: shown }, { width: width(), tick: tick++, emoji });
|
|
249
|
+
let buf = drawn ? `\x1b[${drawn}A` : '';
|
|
250
|
+
for (const l of lines) buf += `\r\x1b[2K${l}\n`;
|
|
251
|
+
for (let i = lines.length; i < drawn; i++) buf += '\r\x1b[2K\n';
|
|
252
|
+
if (drawn > lines.length) buf += `\x1b[${drawn - lines.length}A`;
|
|
253
|
+
stream.write(buf);
|
|
254
|
+
drawn = lines.length;
|
|
255
|
+
};
|
|
256
|
+
const show = () => stream.write('\x1b[?25h');
|
|
257
|
+
stream.write('\x1b[?25l');
|
|
258
|
+
process.once('exit', show);
|
|
259
|
+
const timer = setInterval(draw, 200);
|
|
260
|
+
timer.unref?.();
|
|
261
|
+
draw();
|
|
262
|
+
return {
|
|
263
|
+
upload(done, total) { st = { ...st, stage: 'upload', frac: total > 0 ? done / total : 0 }; },
|
|
264
|
+
/** every run's place on each poll, each with `w`, its size — the runs are a queue (`queue`) */
|
|
265
|
+
server(next) { if (!next.length) return; places = next; place(Date.now()); },
|
|
266
|
+
note(text) { if (ended) { stream.write(`${text}\n`); return; } let buf = drawn ? `\x1b[${drawn}A` : ''; for (let i = 0; i < drawn; i++) buf += '\r\x1b[2K\n'; buf += drawn ? `\x1b[${drawn}A` : ''; stream.write(buf + `${text}\n`); drawn = 0; draw(); },
|
|
267
|
+
end(mode, extra = {}) { if (ended) return; ended = true; clearInterval(timer); st = { ...st, ...extra, mode }; draw(); show(); },
|
|
268
|
+
};
|
|
269
|
+
}
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// `diffin login <setup-token>` — trade the 15-minute, single-use token the web page
|
|
2
|
+
// showed for the real credential, and keep it at ~/.diffin/credentials.json (0600).
|
|
3
|
+
// The setup token is worthless on its own: the guard refuses it for every API call,
|
|
4
|
+
// and the exchange consumes it.
|
|
5
|
+
import { SERVER, fetchRetry } from './net.mjs';
|
|
6
|
+
import { readCreds, writeCreds } from './creds.mjs';
|
|
7
|
+
import { fail, row, serverMessage } from './ui.mjs';
|
|
8
|
+
import { forgetAccount } from './state.mjs';
|
|
9
|
+
|
|
10
|
+
export async function login(argv = [], { quiet = false } = {}) {
|
|
11
|
+
const token = (argv.find((a) => !a.startsWith('--')) ?? '').trim();
|
|
12
|
+
if (!token) { fail('설치 토큰이 필요합니다.', 'diffin login <설치토큰> — 토큰은 앱의 연결 화면에 있습니다.'); process.exitCode = 1; return null; }
|
|
13
|
+
const res = await fetchRetry(`${SERVER}/auth/exchange`, () => ({
|
|
14
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token }),
|
|
15
|
+
}));
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
fail(`로그인하지 못했습니다 — ${serverMessage(res.status, await res.text().catch(() => ''))}`, '설치 토큰은 15분 동안 한 번만 쓸 수 있습니다. 앱의 연결 화면에서 새로 받아 다시 실행하세요.');
|
|
18
|
+
process.exitCode = 1; return null;
|
|
19
|
+
}
|
|
20
|
+
const out = await res.json();
|
|
21
|
+
forgetAccount(); // when the next capture may go up was the previous account's
|
|
22
|
+
writeCreds({ ...(readCreds() ?? {}), token: out.token, email: out.email, server: SERVER, ...(out.appUrl ? { appUrl: out.appUrl } : {}) });
|
|
23
|
+
if (!quiet) row('계정', out.email);
|
|
24
|
+
return out;
|
|
25
|
+
}
|