@threadcrumbs/common 0.0.0-stage → 0.1.1

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 (42) hide show
  1. package/LICENSE.md +34 -0
  2. package/NOTICES.md +33 -0
  3. package/README.md +109 -3
  4. package/harnesses/README.md +213 -0
  5. package/harnesses/antigravity.mjs +269 -0
  6. package/harnesses/claude-code.mjs +578 -0
  7. package/harnesses/codex.mjs +419 -0
  8. package/harnesses/cursor.mjs +199 -0
  9. package/harnesses/hermes.mjs +211 -0
  10. package/harnesses/index.mjs +36 -0
  11. package/harnesses/kilocode.mjs +361 -0
  12. package/harnesses/opencode.mjs +449 -0
  13. package/launch/index.mjs +163 -0
  14. package/launch/macos.mjs +21 -0
  15. package/launch/registry.mjs +52 -0
  16. package/launch/terminal.mjs +213 -0
  17. package/launch/win-terminal.mjs +87 -0
  18. package/launch/windows.mjs +96 -0
  19. package/launch/xdg.mjs +44 -0
  20. package/lib/anthropic-prices.mjs +70 -0
  21. package/lib/claude-desktop.mjs +208 -0
  22. package/lib/claude-transcript.mjs +462 -0
  23. package/lib/cursor-folder.mjs +107 -0
  24. package/lib/fsutil.mjs +191 -0
  25. package/lib/hermes-home.mjs +30 -0
  26. package/lib/jsonl-follower.mjs +45 -0
  27. package/lib/opencode-store.mjs +602 -0
  28. package/lib/sqlite.mjs +29 -0
  29. package/lib/telemetry-fields.mjs +245 -0
  30. package/license/index.mjs +102 -0
  31. package/license/store.mjs +155 -0
  32. package/orgs/index.mjs +157 -0
  33. package/package.json +40 -4
  34. package/telemetry/README.md +148 -0
  35. package/telemetry/antigravity.mjs +268 -0
  36. package/telemetry/claude-code.mjs +370 -0
  37. package/telemetry/codex.mjs +556 -0
  38. package/telemetry/cursor.mjs +276 -0
  39. package/telemetry/hermes.mjs +404 -0
  40. package/telemetry/index.mjs +9 -0
  41. package/telemetry/kilocode.mjs +31 -0
  42. 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,102 @@
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
+ 'backup-2026-11': 'AAADryrdOxw1mX7b17ZODwqpP7ViKX_eu6_QVuaMBX8',
12
+ })
13
+
14
+ const encoder = new TextEncoder()
15
+ const decoder = new TextDecoder()
16
+
17
+ function toBase64Url(bytes) {
18
+ let binary = ''
19
+ for (const byte of bytes) binary += String.fromCharCode(byte)
20
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
21
+ }
22
+
23
+ function fromBase64Url(text) {
24
+ if (typeof text !== 'string' || !/^[A-Za-z0-9_-]*$/.test(text)) throw new Error('Not base64url')
25
+ const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4))
26
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0))
27
+ }
28
+
29
+ const seconds = (value) => typeof value === 'number' && Number.isFinite(value)
30
+
31
+ function wellFormed(claims) {
32
+ if (!claims || typeof claims !== 'object') return false
33
+ if (typeof claims.kid !== 'string' || typeof claims.sub !== 'string' || typeof claims.dev !== 'string') return false
34
+ if (!seconds(claims.iat) || !seconds(claims.exp) || claims.exp <= claims.iat) return false
35
+ if (!claims.ent || typeof claims.ent !== 'object' || Array.isArray(claims.ent)) return false
36
+ return Object.values(claims.ent).every(seconds)
37
+ }
38
+
39
+ export async function createLicenseKeys(kid) {
40
+ const pair = await crypto.subtle.generateKey(ALGORITHM, true, ['sign', 'verify'])
41
+ const publicKey = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)))
42
+ const { alg, ...jwk } = await crypto.subtle.exportKey('jwk', pair.privateKey)
43
+ return { kid, publicKey, privateKey: { ...jwk, kid } }
44
+ }
45
+
46
+ export function publicKeyOf(privateKey) {
47
+ if (privateKey?.kty !== 'OKP' || privateKey.crv !== 'Ed25519' || typeof privateKey.x !== 'string') throw new Error('Not an Ed25519 key')
48
+ return privateKey.x
49
+ }
50
+
51
+ export function licenseClaims({ customer, device, entitlements, now = Date.now() / 1000 }) {
52
+ const iat = Math.floor(now)
53
+ return { sub: customer, dev: device, iat, exp: iat + LICENSE_LIFETIME, ent: { ...entitlements } }
54
+ }
55
+
56
+ export async function signLicense(claims, privateKey) {
57
+ const body = { ...claims, kid: privateKey.kid }
58
+ if (!wellFormed(body)) throw new Error('A license needs sub, dev, iat, exp after iat, and paid-through times in ent')
59
+ const { alg, ...jwk } = privateKey
60
+ const key = await crypto.subtle.importKey('jwk', jwk, ALGORITHM, false, ['sign'])
61
+ const signed = `${VERSION}.${toBase64Url(encoder.encode(JSON.stringify(body)))}`
62
+ const signature = new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, encoder.encode(signed)))
63
+ return `${signed}.${toBase64Url(signature)}`
64
+ }
65
+
66
+ export async function verifyLicense(token, { keys = {}, device } = {}) {
67
+ const parts = typeof token === 'string' ? token.trim().split('.') : []
68
+ if (parts.length !== 3) return { ok: false, reason: 'malformed' }
69
+ const [version, body, signature] = parts
70
+ if (version !== VERSION) return { ok: false, reason: 'version' }
71
+ let claims
72
+ let signatureBytes
73
+ try {
74
+ claims = JSON.parse(decoder.decode(fromBase64Url(body)))
75
+ signatureBytes = fromBase64Url(signature)
76
+ } catch {
77
+ return { ok: false, reason: 'malformed' }
78
+ }
79
+ const publicKey = typeof claims?.kid === 'string' && Object.hasOwn(keys, claims.kid) ? keys[claims.kid] : undefined
80
+ if (typeof publicKey !== 'string') return { ok: false, reason: 'unknown key' }
81
+ const key = await crypto.subtle.importKey('raw', fromBase64Url(publicKey), ALGORITHM, false, ['verify'])
82
+ if (!(await crypto.subtle.verify(ALGORITHM, key, signatureBytes, encoder.encode(`${version}.${body}`)))) {
83
+ return { ok: false, reason: 'signature' }
84
+ }
85
+ if (!wellFormed(claims)) return { ok: false, reason: 'malformed' }
86
+ if (device !== undefined && claims.dev !== device) return { ok: false, reason: 'device' }
87
+ return { ok: true, license: claims }
88
+ }
89
+
90
+ export function paidThrough(license, entitlement, now = Date.now() / 1000, grace = LICENSE_GRACE) {
91
+ const through = license?.ent?.[entitlement]
92
+ if (!seconds(through)) return 0
93
+ return now <= license.exp + grace ? through : Math.min(through, license.exp)
94
+ }
95
+
96
+ export function unlocks(license, entitlement, releasedAt, now = Date.now() / 1000, grace = LICENSE_GRACE) {
97
+ return seconds(releasedAt) && releasedAt <= paidThrough(license, entitlement, now, grace)
98
+ }
99
+
100
+ export function renewalDue(license, now = Date.now() / 1000) {
101
+ return now >= license.iat + LICENSE_RENEWAL
102
+ }
@@ -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
+ }