@threadcrumbs/common 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.md +7 -0
- package/README.md +108 -3
- package/harnesses/README.md +213 -0
- package/harnesses/antigravity.mjs +269 -0
- package/harnesses/claude-code.mjs +578 -0
- package/harnesses/codex.mjs +419 -0
- package/harnesses/cursor.mjs +199 -0
- package/harnesses/hermes.mjs +211 -0
- package/harnesses/index.mjs +36 -0
- package/harnesses/kilocode.mjs +361 -0
- package/harnesses/opencode.mjs +449 -0
- package/launch/index.mjs +163 -0
- package/launch/macos.mjs +21 -0
- package/launch/registry.mjs +52 -0
- package/launch/terminal.mjs +213 -0
- package/launch/win-terminal.mjs +87 -0
- package/launch/windows.mjs +96 -0
- package/launch/xdg.mjs +44 -0
- package/lib/anthropic-prices.mjs +70 -0
- package/lib/claude-desktop.mjs +208 -0
- package/lib/claude-transcript.mjs +462 -0
- package/lib/cursor-folder.mjs +107 -0
- package/lib/fsutil.mjs +191 -0
- package/lib/hermes-home.mjs +30 -0
- package/lib/jsonl-follower.mjs +45 -0
- package/lib/opencode-store.mjs +602 -0
- package/lib/sqlite.mjs +29 -0
- package/lib/telemetry-fields.mjs +245 -0
- package/license/index.mjs +101 -0
- package/license/store.mjs +155 -0
- package/orgs/index.mjs +157 -0
- package/package.json +39 -4
- package/telemetry/README.md +148 -0
- package/telemetry/antigravity.mjs +268 -0
- package/telemetry/claude-code.mjs +370 -0
- package/telemetry/codex.mjs +556 -0
- package/telemetry/cursor.mjs +276 -0
- package/telemetry/hermes.mjs +404 -0
- package/telemetry/index.mjs +9 -0
- package/telemetry/kilocode.mjs +31 -0
- package/telemetry/opencode.mjs +36 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
export const RECENT_MS = 24 * 60 * 60 * 1000
|
|
2
|
+
|
|
3
|
+
export const DETAIL_LIMITS = Object.freeze({
|
|
4
|
+
history: 600,
|
|
5
|
+
activity: 100,
|
|
6
|
+
prompts: 50,
|
|
7
|
+
promptText: 2000,
|
|
8
|
+
errors: 20,
|
|
9
|
+
errorText: 500,
|
|
10
|
+
input: 2000,
|
|
11
|
+
compactions: 50,
|
|
12
|
+
subagents: 50,
|
|
13
|
+
hunks: 5,
|
|
14
|
+
hunkLines: 60,
|
|
15
|
+
hunkFiles: 25,
|
|
16
|
+
lineText: 240,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const TITLE_MAX = 120
|
|
20
|
+
const TARGET_MAX = 160
|
|
21
|
+
const PATH_KEYS = ['file_path', 'filePath', 'notebook_path', 'absolute_path', 'target_file']
|
|
22
|
+
const TEXT_KEYS = ['pattern', 'url', 'query', 'description', 'path']
|
|
23
|
+
const BULKY_KEYS = new Set(['old_string', 'new_string', 'old_str', 'new_str', 'content', 'file_text', 'edits', 'new_source'])
|
|
24
|
+
|
|
25
|
+
export const number = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
|
|
26
|
+
export const text = (v) => (typeof v === 'string' ? v : '')
|
|
27
|
+
|
|
28
|
+
export function clip(value, max = TARGET_MAX) {
|
|
29
|
+
const flat = text(value).replace(/\s+/g, ' ').trim()
|
|
30
|
+
return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clipBlock(value, max) {
|
|
34
|
+
const body = text(value).trim()
|
|
35
|
+
return body.length > max ? `${body.slice(0, max - 1).trimEnd()}…` : body
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function titleFromPrompt(prompt) {
|
|
39
|
+
const line = text(prompt).split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
40
|
+
return clip(line, TITLE_MAX)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const slashes = (value) => value.replace(/\\/g, '/').toLowerCase()
|
|
44
|
+
|
|
45
|
+
export function relativePath(file, base) {
|
|
46
|
+
const full = text(file)
|
|
47
|
+
const root = text(base).replace(/[\\/]+$/, '')
|
|
48
|
+
const inside = (dir) =>
|
|
49
|
+
Boolean(dir) && full.length > dir.length + 1 && /[\\/]/.test(full[dir.length]) && slashes(full.slice(0, dir.length)) === slashes(dir)
|
|
50
|
+
if (inside(root)) return full.slice(root.length + 1)
|
|
51
|
+
const parent = root.slice(0, Math.max(root.lastIndexOf('/'), root.lastIndexOf('\\')))
|
|
52
|
+
if (/[\\/]/.test(parent) && inside(parent)) return `..${full[parent.length]}${full.slice(parent.length + 1)}`
|
|
53
|
+
return full
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function commandText(command) {
|
|
57
|
+
if (!Array.isArray(command)) return text(command)
|
|
58
|
+
const args = command.map(String)
|
|
59
|
+
return args.length >= 3 && /^-l?c$/.test(args[1]) ? args[2] : args.join(' ')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function toolTarget(input, cwd = '', max = TARGET_MAX) {
|
|
63
|
+
if (typeof input === 'string') return clip(input.split('\n').find((l) => l.trim()) || '', max)
|
|
64
|
+
if (!input || typeof input !== 'object') return ''
|
|
65
|
+
for (const key of PATH_KEYS) {
|
|
66
|
+
if (text(input[key])) return clip(relativePath(input[key], cwd), max)
|
|
67
|
+
}
|
|
68
|
+
const command = commandText(input.command ?? input.cmd)
|
|
69
|
+
if (command.trim()) return clip(command.split('\n').find((l) => l.trim()), max)
|
|
70
|
+
for (const key of TEXT_KEYS) {
|
|
71
|
+
if (text(input[key])) return clip(key === 'path' ? relativePath(input[key], cwd) : input[key], max)
|
|
72
|
+
}
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function toolInput(input) {
|
|
77
|
+
if (typeof input === 'string') return clipBlock(input, DETAIL_LIMITS.input) || null
|
|
78
|
+
if (!input || typeof input !== 'object') return null
|
|
79
|
+
const command = commandText(input.command ?? input.cmd)
|
|
80
|
+
if (command.trim()) return clipBlock(command, DETAIL_LIMITS.input)
|
|
81
|
+
const kept = Object.fromEntries(Object.entries(input).filter(([key]) => !BULKY_KEYS.has(key)))
|
|
82
|
+
return Object.keys(kept).length ? clipBlock(JSON.stringify(kept, null, 2), DETAIL_LIMITS.input) : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function toolFile(input, cwd = '') {
|
|
86
|
+
if (!input || typeof input !== 'object') return { path: null, line: null }
|
|
87
|
+
for (const key of PATH_KEYS) {
|
|
88
|
+
if (!text(input[key])) continue
|
|
89
|
+
const line = Number.isInteger(input.offset) && input.offset > 0 ? input.offset : null
|
|
90
|
+
return { path: relativePath(input[key], cwd), line }
|
|
91
|
+
}
|
|
92
|
+
return { path: null, line: null }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function keepNewest(list, item, max) {
|
|
96
|
+
list.push(item)
|
|
97
|
+
if (list.length > max) list.splice(0, list.length - max)
|
|
98
|
+
return item
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function patchLines(lines) {
|
|
102
|
+
let added = 0
|
|
103
|
+
let removed = 0
|
|
104
|
+
for (const line of Array.isArray(lines) ? lines : []) {
|
|
105
|
+
if (typeof line !== 'string') continue
|
|
106
|
+
if (line.startsWith('+')) added++
|
|
107
|
+
else if (line.startsWith('-')) removed++
|
|
108
|
+
}
|
|
109
|
+
return { added, removed }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function diffHunk({ line = null, oldLine = null, lines, more = 0 }) {
|
|
113
|
+
const all = (Array.isArray(lines) ? lines : []).filter((l) => typeof l === 'string')
|
|
114
|
+
const kept = all.slice(0, DETAIL_LIMITS.hunkLines).map((l) => (l.length > DETAIL_LIMITS.lineText ? `${l.slice(0, DETAIL_LIMITS.lineText - 1)}…` : l))
|
|
115
|
+
return { line: number(line) || null, oldLine: number(oldLine) || null, lines: kept, cut: all.length - kept.length + more }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function firstChange(hunk) {
|
|
119
|
+
if (!hunk?.line) return null
|
|
120
|
+
return hunk.line + Math.max(0, hunk.lines.findIndex((l) => l.startsWith('+') || l.startsWith('-')))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function trimHunks(files) {
|
|
124
|
+
let kept = 0
|
|
125
|
+
for (const entry of [...files.values()].reverse()) {
|
|
126
|
+
if (entry.hunks?.length && ++kept > DETAIL_LIMITS.hunkFiles) entry.hunks = []
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function addFileChange(files, file, added, removed, hunks = null) {
|
|
131
|
+
const entry = files.get(file) || { path: file, added: 0, removed: 0, edits: 0, line: null, hunks: null }
|
|
132
|
+
entry.added += added
|
|
133
|
+
entry.removed += removed
|
|
134
|
+
entry.edits++
|
|
135
|
+
files.delete(file)
|
|
136
|
+
files.set(file, entry)
|
|
137
|
+
if (!hunks?.length) return entry
|
|
138
|
+
entry.hunks = [...(entry.hunks || []), ...hunks].slice(-DETAIL_LIMITS.hunks)
|
|
139
|
+
entry.line = firstChange(hunks[0]) ?? entry.line
|
|
140
|
+
trimHunks(files)
|
|
141
|
+
return entry
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function fileList(files, cwd) {
|
|
145
|
+
return [...files.values()]
|
|
146
|
+
.map((f) => ({ ...f, path: relativePath(f.path, cwd), hunks: f.hunks ? f.hunks.map((h) => ({ ...h })) : f.hunks ?? null }))
|
|
147
|
+
.sort((a, b) => b.added + b.removed - (a.added + a.removed) || a.path.localeCompare(b.path))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const emptyActivity = () => ({ calls: [], tools: new Map() })
|
|
151
|
+
|
|
152
|
+
export function startCall(activity, { id = '', name, target = '', input = null, path = null, line = null, startedAt, running = true }) {
|
|
153
|
+
if (id && activity.calls.some((c) => c.id === id)) return null
|
|
154
|
+
const call = keepNewest(
|
|
155
|
+
activity.calls,
|
|
156
|
+
{ id, name, target, input, path, line, startedAt, endedAt: null, running, error: null, errorText: null },
|
|
157
|
+
DETAIL_LIMITS.activity,
|
|
158
|
+
)
|
|
159
|
+
const tool = activity.tools.get(name) || { name, calls: 0, errors: 0, ms: 0 }
|
|
160
|
+
tool.calls++
|
|
161
|
+
activity.tools.set(name, tool)
|
|
162
|
+
return call
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function endCall(activity, id, endedAt, error, errorText = null) {
|
|
166
|
+
const call = activity.calls.findLast((c) => c.id === id && c.running)
|
|
167
|
+
if (!call) return null
|
|
168
|
+
call.running = false
|
|
169
|
+
call.endedAt = endedAt ? Math.max(endedAt, call.startedAt) : null
|
|
170
|
+
call.error = error == null ? null : Boolean(error)
|
|
171
|
+
call.errorText = call.error ? clipBlock(errorText, DETAIL_LIMITS.errorText) || null : null
|
|
172
|
+
const tool = activity.tools.get(call.name)
|
|
173
|
+
if (tool) {
|
|
174
|
+
if (call.endedAt) tool.ms += call.endedAt - call.startedAt
|
|
175
|
+
if (call.error) tool.errors++
|
|
176
|
+
}
|
|
177
|
+
return call
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function settleCalls(activity) {
|
|
181
|
+
for (const call of activity.calls) call.running = false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function activityDetail(activity) {
|
|
185
|
+
return {
|
|
186
|
+
activity: activity.calls.map((c) => ({ ...c })),
|
|
187
|
+
tools: [...activity.tools.values()].map((t) => ({ ...t })).sort((a, b) => b.calls - a.calls || b.ms - a.ms),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const planItems = (plan) => (plan || []).map((s) => ({ content: s.content, status: s.status }))
|
|
192
|
+
|
|
193
|
+
export function planSummary(plan) {
|
|
194
|
+
if (!plan?.length) return null
|
|
195
|
+
const current = plan.find((s) => s.status === 'in_progress')
|
|
196
|
+
return {
|
|
197
|
+
total: plan.length,
|
|
198
|
+
done: plan.filter((s) => s.status === 'completed').length,
|
|
199
|
+
current: current ? current.content : '',
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const planStep = (content, status, id) => ({ content: text(content), status: text(status) || 'pending', id: text(id) })
|
|
204
|
+
const TODO_TOOLS = new Set(['TodoWrite', 'todo_write'])
|
|
205
|
+
|
|
206
|
+
function mergeTodos(plan, todos) {
|
|
207
|
+
const next = plan.map((s) => ({ ...s }))
|
|
208
|
+
for (const t of todos) {
|
|
209
|
+
const found = t?.id != null ? next.find((s) => s.id && s.id === text(t.id)) : null
|
|
210
|
+
if (!found) next.push(planStep(t?.content, t?.status, t?.id))
|
|
211
|
+
else {
|
|
212
|
+
if (t.content != null) found.content = text(t.content)
|
|
213
|
+
if (t.status != null) found.status = text(t.status)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return next
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function nextPlan(plan, tool, input) {
|
|
220
|
+
if (!input || typeof input !== 'object') return plan
|
|
221
|
+
if (TODO_TOOLS.has(tool) && Array.isArray(input.todos)) {
|
|
222
|
+
if (input.merge === true && plan) return mergeTodos(plan, input.todos)
|
|
223
|
+
return input.todos.map((t) => planStep(t?.content, t?.status, t?.id))
|
|
224
|
+
}
|
|
225
|
+
if (/(^|__)ccd_turn__wrap_up$/.test(tool)) return plan && plan.map((s) => (s.status === 'completed' ? s : { ...s, status: 'completed' }))
|
|
226
|
+
if (!/(^|__)update_plan$/.test(tool)) return plan
|
|
227
|
+
if (Array.isArray(input.items)) return input.items.map((s) => planStep(s?.content, s?.status))
|
|
228
|
+
if (Array.isArray(input.plan)) return input.plan.map((s) => planStep(s?.step, s?.status))
|
|
229
|
+
if (!plan) return plan
|
|
230
|
+
const next = plan.map((s) => ({ ...s }))
|
|
231
|
+
if (input.advance) {
|
|
232
|
+
const current = next.find((s) => s.status === 'in_progress')
|
|
233
|
+
if (current) current.status = 'completed'
|
|
234
|
+
const upcoming = next.find((s) => s.status === 'pending')
|
|
235
|
+
if (upcoming) upcoming.status = 'in_progress'
|
|
236
|
+
}
|
|
237
|
+
for (const n of Array.isArray(input.done) ? input.done : []) {
|
|
238
|
+
if (next[n - 1]) next[n - 1].status = 'completed'
|
|
239
|
+
}
|
|
240
|
+
if (Number.isInteger(input.start) && next[input.start - 1]) {
|
|
241
|
+
for (const s of next) if (s.status === 'in_progress') s.status = 'pending'
|
|
242
|
+
next[input.start - 1].status = 'in_progress'
|
|
243
|
+
}
|
|
244
|
+
return next
|
|
245
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const VERSION = 'v1'
|
|
2
|
+
const ALGORITHM = { name: 'Ed25519' }
|
|
3
|
+
const DAY = 86400
|
|
4
|
+
|
|
5
|
+
export const LICENSE_LIFETIME = 14 * DAY
|
|
6
|
+
export const LICENSE_GRACE = 14 * DAY
|
|
7
|
+
export const LICENSE_RENEWAL = 7 * DAY
|
|
8
|
+
|
|
9
|
+
export const LICENSE_PUBLIC_KEYS = Object.freeze({
|
|
10
|
+
'2026-10': 'SyR5jP9LWOFCeOb3piiSLh5CYsgqOfkOgm7a7qkSXQs',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const encoder = new TextEncoder()
|
|
14
|
+
const decoder = new TextDecoder()
|
|
15
|
+
|
|
16
|
+
function toBase64Url(bytes) {
|
|
17
|
+
let binary = ''
|
|
18
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
19
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fromBase64Url(text) {
|
|
23
|
+
if (typeof text !== 'string' || !/^[A-Za-z0-9_-]*$/.test(text)) throw new Error('Not base64url')
|
|
24
|
+
const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4))
|
|
25
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const seconds = (value) => typeof value === 'number' && Number.isFinite(value)
|
|
29
|
+
|
|
30
|
+
function wellFormed(claims) {
|
|
31
|
+
if (!claims || typeof claims !== 'object') return false
|
|
32
|
+
if (typeof claims.kid !== 'string' || typeof claims.sub !== 'string' || typeof claims.dev !== 'string') return false
|
|
33
|
+
if (!seconds(claims.iat) || !seconds(claims.exp) || claims.exp <= claims.iat) return false
|
|
34
|
+
if (!claims.ent || typeof claims.ent !== 'object' || Array.isArray(claims.ent)) return false
|
|
35
|
+
return Object.values(claims.ent).every(seconds)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createLicenseKeys(kid) {
|
|
39
|
+
const pair = await crypto.subtle.generateKey(ALGORITHM, true, ['sign', 'verify'])
|
|
40
|
+
const publicKey = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)))
|
|
41
|
+
const { alg, ...jwk } = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
42
|
+
return { kid, publicKey, privateKey: { ...jwk, kid } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function publicKeyOf(privateKey) {
|
|
46
|
+
if (privateKey?.kty !== 'OKP' || privateKey.crv !== 'Ed25519' || typeof privateKey.x !== 'string') throw new Error('Not an Ed25519 key')
|
|
47
|
+
return privateKey.x
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function licenseClaims({ customer, device, entitlements, now = Date.now() / 1000 }) {
|
|
51
|
+
const iat = Math.floor(now)
|
|
52
|
+
return { sub: customer, dev: device, iat, exp: iat + LICENSE_LIFETIME, ent: { ...entitlements } }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function signLicense(claims, privateKey) {
|
|
56
|
+
const body = { ...claims, kid: privateKey.kid }
|
|
57
|
+
if (!wellFormed(body)) throw new Error('A license needs sub, dev, iat, exp after iat, and paid-through times in ent')
|
|
58
|
+
const { alg, ...jwk } = privateKey
|
|
59
|
+
const key = await crypto.subtle.importKey('jwk', jwk, ALGORITHM, false, ['sign'])
|
|
60
|
+
const signed = `${VERSION}.${toBase64Url(encoder.encode(JSON.stringify(body)))}`
|
|
61
|
+
const signature = new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, encoder.encode(signed)))
|
|
62
|
+
return `${signed}.${toBase64Url(signature)}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function verifyLicense(token, { keys = {}, device } = {}) {
|
|
66
|
+
const parts = typeof token === 'string' ? token.trim().split('.') : []
|
|
67
|
+
if (parts.length !== 3) return { ok: false, reason: 'malformed' }
|
|
68
|
+
const [version, body, signature] = parts
|
|
69
|
+
if (version !== VERSION) return { ok: false, reason: 'version' }
|
|
70
|
+
let claims
|
|
71
|
+
let signatureBytes
|
|
72
|
+
try {
|
|
73
|
+
claims = JSON.parse(decoder.decode(fromBase64Url(body)))
|
|
74
|
+
signatureBytes = fromBase64Url(signature)
|
|
75
|
+
} catch {
|
|
76
|
+
return { ok: false, reason: 'malformed' }
|
|
77
|
+
}
|
|
78
|
+
const publicKey = typeof claims?.kid === 'string' && Object.hasOwn(keys, claims.kid) ? keys[claims.kid] : undefined
|
|
79
|
+
if (typeof publicKey !== 'string') return { ok: false, reason: 'unknown key' }
|
|
80
|
+
const key = await crypto.subtle.importKey('raw', fromBase64Url(publicKey), ALGORITHM, false, ['verify'])
|
|
81
|
+
if (!(await crypto.subtle.verify(ALGORITHM, key, signatureBytes, encoder.encode(`${version}.${body}`)))) {
|
|
82
|
+
return { ok: false, reason: 'signature' }
|
|
83
|
+
}
|
|
84
|
+
if (!wellFormed(claims)) return { ok: false, reason: 'malformed' }
|
|
85
|
+
if (device !== undefined && claims.dev !== device) return { ok: false, reason: 'device' }
|
|
86
|
+
return { ok: true, license: claims }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function paidThrough(license, entitlement, now = Date.now() / 1000, grace = LICENSE_GRACE) {
|
|
90
|
+
const through = license?.ent?.[entitlement]
|
|
91
|
+
if (!seconds(through)) return 0
|
|
92
|
+
return now <= license.exp + grace ? through : Math.min(through, license.exp)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function unlocks(license, entitlement, releasedAt, now = Date.now() / 1000, grace = LICENSE_GRACE) {
|
|
96
|
+
return seconds(releasedAt) && releasedAt <= paidThrough(license, entitlement, now, grace)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function renewalDue(license, now = Date.now() / 1000) {
|
|
100
|
+
return now >= license.iat + LICENSE_RENEWAL
|
|
101
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { LICENSE_PUBLIC_KEYS, verifyLicense } from './index.mjs'
|
|
6
|
+
|
|
7
|
+
const FILE = 'license.json'
|
|
8
|
+
const DEVICE = /^[A-Za-z0-9_-]{8,100}$/
|
|
9
|
+
const SEEN_STEP = 3600
|
|
10
|
+
const READ_TRIES = 5
|
|
11
|
+
const RENAME_TRIES = 10
|
|
12
|
+
const PAUSE_MS = 20
|
|
13
|
+
const CLOUD_URL = 'https://threadcrumbs.io/api'
|
|
14
|
+
const LOCAL_CLOUD_URL = 'http://localhost:8787/api'
|
|
15
|
+
|
|
16
|
+
const pause = () => new Promise((resolve) => setTimeout(resolve, PAUSE_MS))
|
|
17
|
+
const isDevice = (value) => typeof value === 'string' && DEVICE.test(value)
|
|
18
|
+
const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
|
19
|
+
|
|
20
|
+
export function threadcrumbsDataDir({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
21
|
+
if (env.THREADCRUMBS_DATA_DIR) return env.THREADCRUMBS_DATA_DIR
|
|
22
|
+
if (platform === 'win32') {
|
|
23
|
+
return path.win32.join(env.APPDATA || path.win32.join(home, 'AppData', 'Roaming'), 'Threadcrumbs')
|
|
24
|
+
}
|
|
25
|
+
if (platform === 'darwin') return path.posix.join(home, 'Library', 'Application Support', 'Threadcrumbs')
|
|
26
|
+
const xdg = env.XDG_DATA_HOME && path.posix.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : ''
|
|
27
|
+
return path.posix.join(xdg || path.posix.join(home, '.local', 'share'), 'threadcrumbs')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function trustedKeys({ env = process.env, development = false } = {}) {
|
|
31
|
+
const extra = development ? env.THREADCRUMBS_LICENSE_KEYS : ''
|
|
32
|
+
if (!extra) return { ...LICENSE_PUBLIC_KEYS }
|
|
33
|
+
let parsed
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(extra)
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error('THREADCRUMBS_LICENSE_KEYS must be JSON: { "<kid>": "<public key>" }')
|
|
38
|
+
}
|
|
39
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Object.values(parsed).every((k) => typeof k === 'string')) {
|
|
40
|
+
throw new Error('THREADCRUMBS_LICENSE_KEYS must map each key ID to a public key')
|
|
41
|
+
}
|
|
42
|
+
return { ...LICENSE_PUBLIC_KEYS, ...parsed }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function cloudUrl({ env = process.env, development = false } = {}) {
|
|
46
|
+
return (env.THREADCRUMBS_CLOUD_URL || (development ? LOCAL_CLOUD_URL : CLOUD_URL)).replace(/\/+$/, '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function check(token, keys, device) {
|
|
50
|
+
try {
|
|
51
|
+
return await verifyLicense(token, { keys, device })
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false, reason: 'malformed' }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function licenseStore({ dir = threadcrumbsDataDir(), keys = LICENSE_PUBLIC_KEYS, now = () => Date.now() / 1000 } = {}) {
|
|
58
|
+
const file = path.join(dir, FILE)
|
|
59
|
+
|
|
60
|
+
async function read() {
|
|
61
|
+
let text
|
|
62
|
+
try {
|
|
63
|
+
text = await fsp.readFile(file, 'utf8')
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code === 'ENOENT') return null
|
|
66
|
+
throw err
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const data = JSON.parse(text)
|
|
70
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {}
|
|
71
|
+
} catch {
|
|
72
|
+
return {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const serialize = (data) => `${JSON.stringify(data, null, 2)}\n`
|
|
77
|
+
|
|
78
|
+
async function replace(data) {
|
|
79
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
80
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
|
|
81
|
+
try {
|
|
82
|
+
await fsp.writeFile(temp, serialize(data), { mode: 0o600 })
|
|
83
|
+
for (let attempt = 1; ; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
return await fsp.rename(temp, file)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (attempt >= RENAME_TRIES || !['EPERM', 'EACCES', 'EBUSY'].includes(err.code)) throw err
|
|
88
|
+
await pause()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
await fsp.rm(temp, { force: true })
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function device() {
|
|
98
|
+
for (let attempt = 0; attempt < READ_TRIES; attempt++) {
|
|
99
|
+
const data = await read()
|
|
100
|
+
if (isDevice(data?.device)) return data.device
|
|
101
|
+
if (data === null) {
|
|
102
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
103
|
+
try {
|
|
104
|
+
await fsp.writeFile(file, serialize({ device: randomBytes(18).toString('base64url'), license: null, seen: 0 }), { flag: 'wx', mode: 0o600 })
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err.code !== 'EEXIST') throw err
|
|
107
|
+
}
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
await pause()
|
|
111
|
+
}
|
|
112
|
+
await replace({ device: randomBytes(18).toString('base64url'), license: null, seen: 0 })
|
|
113
|
+
const data = await read()
|
|
114
|
+
if (!isDevice(data?.device)) throw new Error(`Can't read or replace ${file}`)
|
|
115
|
+
return data.device
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function update(change) {
|
|
119
|
+
const data = (await read()) ?? {}
|
|
120
|
+
await replace({ ...data, ...change, device: data.device })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function current() {
|
|
124
|
+
const clock = now()
|
|
125
|
+
if ((await read()) === null) return { device: null, token: null, license: null, at: clock }
|
|
126
|
+
const dev = await device()
|
|
127
|
+
const data = (await read()) ?? {}
|
|
128
|
+
const seen = finite(data.seen)
|
|
129
|
+
if (typeof data.license !== 'string') return { device: dev, token: null, license: null, at: Math.max(clock, seen) }
|
|
130
|
+
const checked = await check(data.license, keys, dev)
|
|
131
|
+
if (!checked.ok) return { device: dev, token: null, license: null, reason: checked.reason, at: Math.max(clock, seen) }
|
|
132
|
+
if (clock > seen + SEEN_STEP) await update({ seen: clock }).catch(() => {})
|
|
133
|
+
return { device: dev, token: data.license, license: checked.license, at: Math.max(clock, seen, checked.license.iat) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function save(token) {
|
|
137
|
+
const dev = await device()
|
|
138
|
+
const checked = await check(token, keys, dev)
|
|
139
|
+
if (!checked.ok) return checked
|
|
140
|
+
const data = (await read()) ?? {}
|
|
141
|
+
const stored = typeof data.license === 'string' ? await check(data.license, keys, dev) : null
|
|
142
|
+
if (stored?.ok && stored.license.iat > checked.license.iat) {
|
|
143
|
+
return { ok: true, license: stored.license, at: Math.max(now(), finite(data.seen), stored.license.iat) }
|
|
144
|
+
}
|
|
145
|
+
await update({ license: token, seen: checked.license.iat })
|
|
146
|
+
return { ok: true, license: checked.license, at: Math.max(now(), checked.license.iat) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function clear() {
|
|
150
|
+
await device()
|
|
151
|
+
await update({ license: null })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { file, device, current, save, clear }
|
|
155
|
+
}
|
package/orgs/index.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const REMEMBER_MS = 60 * 1000
|
|
6
|
+
const ROOT_LEVELS = 6
|
|
7
|
+
const CODE_HOSTS = new Set(['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'gitea.com'])
|
|
8
|
+
const TOP_LEVEL_DOMAINS = new Set(['com', 'org', 'net', 'io', 'dev', 'app', 'co', 'ai', 'me', 'xyz', 'edu', 'gov', 'info', 'biz', 'tech', 'cloud', 'us', 'uk', 'de', 'fr', 'nl', 'ca', 'au', 'jp', 'ch', 'se', 'no', 'fi', 'es', 'it', 'be', 'at', 'dk', 'pl', 'br', 'in'])
|
|
9
|
+
const NAME = /^[a-z0-9][\w.-]*$/i
|
|
10
|
+
|
|
11
|
+
const clean = (value) => {
|
|
12
|
+
const name = String(value || '').trim().replace(/^[@~]/, '').toLowerCase()
|
|
13
|
+
return NAME.test(name) ? name : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const parseJson = (text) => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(text)
|
|
19
|
+
} catch {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(text.replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'))
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const scopeOf = (name) => clean(/^@([^/\s]+)\/[^/\s]+$/.exec(String(name || ''))?.[1])
|
|
29
|
+
|
|
30
|
+
export const vendorOf = (name) => clean(/^([^/@\s]+)\/[^/\s]+$/.exec(String(name || ''))?.[1])
|
|
31
|
+
|
|
32
|
+
export function ownerOfModule(modulePath) {
|
|
33
|
+
const [host, owner, repo] = String(modulePath || '').split('/')
|
|
34
|
+
return CODE_HOSTS.has(host?.toLowerCase()) && repo ? clean(owner) : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function organizationOfGroup(group) {
|
|
38
|
+
const labels = String(group || '').trim().toLowerCase().split('.').filter(Boolean)
|
|
39
|
+
if (labels.length >= 2 && TOP_LEVEL_DOMAINS.has(labels[0])) return clean(labels[1])
|
|
40
|
+
return labels.length ? clean(labels[0]) : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const prefixOf = (id) => clean(/^([^.\s]+)\.[^\s]+$/.exec(String(id || '').trim())?.[1])
|
|
44
|
+
|
|
45
|
+
const tag = (xml, name) => new RegExp(`<${name}>\\s*([^<]+?)\\s*</${name}>`).exec(xml)?.[1]
|
|
46
|
+
|
|
47
|
+
const MANIFESTS = [
|
|
48
|
+
['package.json', (text) => scopeOf(parseJson(text)?.name)],
|
|
49
|
+
['jsr.json', (text) => scopeOf(parseJson(text)?.name)],
|
|
50
|
+
['deno.json', (text) => scopeOf(parseJson(text)?.name)],
|
|
51
|
+
['deno.jsonc', (text) => scopeOf(parseJson(text)?.name)],
|
|
52
|
+
['composer.json', (text) => vendorOf(parseJson(text)?.name)],
|
|
53
|
+
['go.mod', (text) => ownerOfModule(/^\s*module\s+(\S+)/m.exec(text)?.[1])],
|
|
54
|
+
[
|
|
55
|
+
'pom.xml',
|
|
56
|
+
(text) => {
|
|
57
|
+
const own = text.replace(/<parent>[\s\S]*?<\/parent>/, '').replace(/<(dependencies|dependencyManagement|build|profiles|plugins)>[\s\S]*$/, '')
|
|
58
|
+
return organizationOfGroup(tag(own, 'groupId') || tag(text, 'groupId'))
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
['build.gradle', (text) => organizationOfGroup(/^\s*group\s*=?\s*['"]([^'"]+)['"]/m.exec(text)?.[1])],
|
|
62
|
+
['build.gradle.kts', (text) => organizationOfGroup(/^\s*group\s*=\s*"([^"]+)"/m.exec(text)?.[1])],
|
|
63
|
+
['galaxy.yml', (text) => clean(/^namespace:\s*['"]?([\w-]+)/m.exec(text)?.[1])],
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
export function remoteOwner(gitConfig) {
|
|
67
|
+
const section = /\[remote "origin"\]([\s\S]*?)(?=\n\s*\[|$)/.exec(String(gitConfig || ''))?.[1] || ''
|
|
68
|
+
const url = /^\s*url\s*=\s*(\S+)/m.exec(section)?.[1]
|
|
69
|
+
if (!url) return null
|
|
70
|
+
const match = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)[:/]+([^/]+)\/[^/]+?(?:\.git)?\/?$/i.exec(url)
|
|
71
|
+
return match && CODE_HOSTS.has(match[1].toLowerCase()) ? clean(match[2]) : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const readText = (file) => fsp.readFile(file, 'utf8').catch(() => null)
|
|
75
|
+
|
|
76
|
+
const folderKey = (dir) => {
|
|
77
|
+
const bare = /^\\\\\?\\[A-Za-z]:[\\/]/.test(dir) ? dir.slice(4) : dir
|
|
78
|
+
return /^[A-Za-z]:[\\/]/.test(bare) ? bare[0].toLowerCase() + bare.slice(1) : bare
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function gitConfigOf(dir, read) {
|
|
82
|
+
const own = await read(path.join(dir, '.git', 'config'))
|
|
83
|
+
if (own != null) return own
|
|
84
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec((await read(path.join(dir, '.git'))) || '')?.[1]?.trim()
|
|
85
|
+
if (!pointer) return null
|
|
86
|
+
const gitdir = path.resolve(dir, pointer)
|
|
87
|
+
return (await read(path.join(gitdir, 'config'))) ?? read(path.join(gitdir, '..', '..', 'config'))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function manifestIn(dir, read, list) {
|
|
91
|
+
for (const [file, parse] of MANIFESTS) {
|
|
92
|
+
const text = await read(path.join(dir, file))
|
|
93
|
+
const found = text != null && parse(text)
|
|
94
|
+
if (found) return found
|
|
95
|
+
}
|
|
96
|
+
for (const file of (await list(dir)).filter((name) => /\.(csproj|nuspec)$/i.test(name))) {
|
|
97
|
+
const text = await read(path.join(dir, file))
|
|
98
|
+
const found = text != null && prefixOf(tag(text, 'PackageId') || tag(text, 'id'))
|
|
99
|
+
if (found) return found
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sameFolder = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b)
|
|
105
|
+
|
|
106
|
+
export async function signalsOf(dir, { read = readText, list = (d) => fsp.readdir(d).catch(() => []), home = os.homedir() } = {}) {
|
|
107
|
+
if (!(await list(dir)).length) return { manifest: null, remote: null, root: dir }
|
|
108
|
+
const top = home ? path.resolve(home) : null
|
|
109
|
+
let at = dir
|
|
110
|
+
for (let level = 0; level <= ROOT_LEVELS; level++) {
|
|
111
|
+
const config = await gitConfigOf(at, read)
|
|
112
|
+
if (config != null) return { manifest: await manifestIn(at, read, list), remote: remoteOwner(config), root: at }
|
|
113
|
+
const parent = path.dirname(at)
|
|
114
|
+
if (parent === at || (top && sameFolder(path.resolve(parent), top))) break
|
|
115
|
+
at = parent
|
|
116
|
+
}
|
|
117
|
+
return { manifest: await manifestIn(dir, read, list), remote: null, root: dir }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function organizationsFor(signals) {
|
|
121
|
+
const named = new Map()
|
|
122
|
+
const counted = new Set()
|
|
123
|
+
for (const [dir, { manifest, root }] of signals) {
|
|
124
|
+
const repo = `${root ?? dir}\n${manifest}`
|
|
125
|
+
if (!manifest || counted.has(repo)) continue
|
|
126
|
+
counted.add(repo)
|
|
127
|
+
named.set(manifest, (named.get(manifest) ?? 0) + 1)
|
|
128
|
+
}
|
|
129
|
+
const support = (org) => named.get(org) ?? 0
|
|
130
|
+
const orgs = new Map()
|
|
131
|
+
for (const [dir, { manifest, remote }] of signals) {
|
|
132
|
+
const org = manifest && remote && support(remote) > support(manifest) ? remote : manifest || (support(remote) ? remote : null)
|
|
133
|
+
if (org) orgs.set(dir, org)
|
|
134
|
+
}
|
|
135
|
+
return orgs
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function organizationReader({ signals = signalsOf, now = Date.now } = {}) {
|
|
139
|
+
const cache = new Map()
|
|
140
|
+
const signalsFor = async (dir) => {
|
|
141
|
+
const hit = cache.get(dir)
|
|
142
|
+
if (hit && now() - hit.at < REMEMBER_MS) return hit.value
|
|
143
|
+
const value = await signals(dir)
|
|
144
|
+
cache.set(dir, { at: now(), value })
|
|
145
|
+
return value
|
|
146
|
+
}
|
|
147
|
+
return async function withOrganizations(threads) {
|
|
148
|
+
const keys = new Map(threads.map((t) => t.projectPath).filter((dir) => dir && path.isAbsolute(dir)).map((dir) => [dir, folderKey(dir)]))
|
|
149
|
+
const dirs = [...new Set(keys.values())]
|
|
150
|
+
const found = new Map(await Promise.all(dirs.map(async (dir) => [dir, await signalsFor(dir)])))
|
|
151
|
+
const orgs = organizationsFor(found)
|
|
152
|
+
return threads.map((t) => {
|
|
153
|
+
const org = orgs.get(keys.get(t.projectPath))
|
|
154
|
+
return org ? { ...t, org } : t
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|