@threadcrumbs/threadgauge 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 +34 -0
- package/NOTICES.md +137 -0
- package/README.md +423 -3
- package/bin/threadgauge.mjs +99 -0
- package/dist/assets/atkinson-hyperlegible-mono-latin-ext-wght-normal-CXgNM06D.woff2 +0 -0
- package/dist/assets/atkinson-hyperlegible-mono-latin-wght-normal-CoA7y8X5.woff2 +0 -0
- package/dist/assets/atkinson-hyperlegible-next-latin-ext-wght-normal-C6vrW8VD.woff2 +0 -0
- package/dist/assets/atkinson-hyperlegible-next-latin-wght-normal-BcXVPD7q.woff2 +0 -0
- package/dist/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-400-normal-DlzeKIMS.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-500-normal-G0uxJNrM.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-600-normal-BkLiAYu4.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-ext-400-normal-BDztQS4c.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-ext-500-normal-BXdp1BvY.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-ext-600-normal-BkQ9hN72.woff2 +0 -0
- package/dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-400-normal-BmvZHCvU.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-500-normal-B1UMRPS4.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-600-normal-CdJSnjCb.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-ext-400-normal-BHj09Mzt.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-ext-500-normal-CDI94yGp.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-ext-600-normal-10nHsj_O.woff2 +0 -0
- package/dist/assets/fira-sans-latin-400-normal-a0AnQzuD.woff2 +0 -0
- package/dist/assets/fira-sans-latin-500-normal-Bvbxc8ch.woff2 +0 -0
- package/dist/assets/fira-sans-latin-600-normal-CXKlxLG9.woff2 +0 -0
- package/dist/assets/fira-sans-latin-700-normal-CRhwpWTq.woff2 +0 -0
- package/dist/assets/fira-sans-latin-ext-400-normal-BLG_mAEf.woff2 +0 -0
- package/dist/assets/fira-sans-latin-ext-500-normal-OO1QnNpY.woff2 +0 -0
- package/dist/assets/fira-sans-latin-ext-600-normal-DTQBTt1m.woff2 +0 -0
- package/dist/assets/fira-sans-latin-ext-700-normal-g2Yrd4J5.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-400-normal-DKyD6XsY.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-500-normal-BqaLkUoX.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-600-normal-CRd5VyFf.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-ext-400-normal-BJ9nxTJg.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-ext-500-normal--oYb3bSD.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-ext-600-normal-CBdNZ9Ci.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-ext-700-normal-aMgXseD4.woff2 +0 -0
- package/dist/assets/inconsolata-latin-ext-wght-normal-C75G6mlG.woff2 +0 -0
- package/dist/assets/inconsolata-latin-wght-normal-DBskYKbm.woff2 +0 -0
- package/dist/assets/index-DGQBjXpa.js +2 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/inter-tight-latin-ext-wght-normal-B4u9PlvR.woff2 +0 -0
- package/dist/assets/inter-tight-latin-wght-normal-DX-nOvPD.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/main-CEA6P2m0.js +2 -0
- package/dist/assets/preload-helper-BXl3LOEh.js +1 -0
- package/dist/assets/shell-DBhI_m_t.css +1 -0
- package/dist/assets/shell-fZRN8tMb.js +251 -0
- package/dist/assets/source-code-pro-latin-ext-wght-normal-0UZNSh4u.woff2 +0 -0
- package/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
- package/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
- package/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
- package/dist/favicon.svg +17 -0
- package/dist/index.html +16 -0
- package/package.json +75 -6
- package/server/api.mjs +188 -0
- package/server/details.mjs +64 -0
- package/server/history.mjs +65 -0
- package/server/http-server.mjs +87 -0
- package/server/lib/local-request.mjs +41 -0
- package/server/lib/open-url.mjs +16 -0
- package/server/lib/ports.mjs +54 -0
- package/server/license.mjs +100 -0
- package/server/monitor.mjs +136 -0
- package/server/open.mjs +49 -0
- package/server/paths.mjs +104 -0
- package/server/serve.mjs +8 -0
- package/server/threadnauts.mjs +23 -0
- package/server/threads.mjs +12 -0
- package/src/tiers.js +20 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { paidThrough, renewalDue, unlocks } from '@threadcrumbs/common/license'
|
|
2
|
+
import { cloudUrl, licenseStore, trustedKeys } from '@threadcrumbs/common/license/store'
|
|
3
|
+
import { ENTITLEMENT, PRO_FEATURES, devEntitlements } from '../src/tiers.js'
|
|
4
|
+
|
|
5
|
+
const RENEW_RETRY_MS = 60 * 60 * 1000
|
|
6
|
+
const TIMEOUT_MS = 15000
|
|
7
|
+
const KEY_LIMIT = 200
|
|
8
|
+
|
|
9
|
+
const CLOUD_ERRORS = {
|
|
10
|
+
bad_request: "That doesn't look like a license key.",
|
|
11
|
+
key_not_valid: "Polar doesn't recognize that key. Copy the whole key from your receipt. It starts with THREADCRUMBS-.",
|
|
12
|
+
device_limit: 'That key is already on as many computers as it allows. Remove one in your Polar customer portal, then try again.',
|
|
13
|
+
polar_unavailable: "Polar didn't answer. Try again in a minute.",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function entitlementsFor(license, at, proFeatures = PRO_FEATURES) {
|
|
17
|
+
const features = Object.fromEntries(
|
|
18
|
+
Object.entries(proFeatures).map(([key, feature]) => [key, Boolean(license) && unlocks(license, ENTITLEMENT, feature.releasedAt, at)]),
|
|
19
|
+
)
|
|
20
|
+
return { pro: Boolean(license) && paidThrough(license, ENTITLEMENT, at) >= at, features }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function summary(license, at) {
|
|
24
|
+
if (!license) return null
|
|
25
|
+
return {
|
|
26
|
+
paidThrough: paidThrough(license, ENTITLEMENT, at) || null,
|
|
27
|
+
renewed: license.iat,
|
|
28
|
+
expires: license.exp,
|
|
29
|
+
entitlements: Object.keys(license.ent),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createLicensing({
|
|
34
|
+
development = false,
|
|
35
|
+
env = process.env,
|
|
36
|
+
store = licenseStore({ keys: trustedKeys({ env, development }) }),
|
|
37
|
+
cloud = cloudUrl({ env, development }),
|
|
38
|
+
fetch = globalThis.fetch,
|
|
39
|
+
clock = Date.now,
|
|
40
|
+
} = {}) {
|
|
41
|
+
let renewing = null
|
|
42
|
+
let triedAt = -Infinity
|
|
43
|
+
|
|
44
|
+
async function post(path, body) {
|
|
45
|
+
const res = await fetch(`${cloud}${path}`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
50
|
+
})
|
|
51
|
+
return { status: res.status, data: await res.json().catch(() => ({})) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renewIfDue({ license, token, at }) {
|
|
55
|
+
if (!cloud || !license || renewing || !renewalDue(license, at) || clock() - triedAt < RENEW_RETRY_MS) return
|
|
56
|
+
triedAt = clock()
|
|
57
|
+
renewing = post('/v1/license/renew', { license: token })
|
|
58
|
+
.then(({ status, data }) => (status === 200 && typeof data.license === 'string' ? store.save(data.license) : null))
|
|
59
|
+
.catch(() => null)
|
|
60
|
+
.finally(() => {
|
|
61
|
+
renewing = null
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function entitlements() {
|
|
66
|
+
const now = await store.current()
|
|
67
|
+
renewIfDue(now)
|
|
68
|
+
const open = development ? devEntitlements() : entitlementsFor(now.license, now.at)
|
|
69
|
+
return { ...open, license: summary(now.license, now.at), activation: Boolean(cloud) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function activate(key) {
|
|
73
|
+
const trimmed = typeof key === 'string' ? key.trim() : ''
|
|
74
|
+
if (!trimmed || trimmed.length > KEY_LIMIT) return { ok: false, error: CLOUD_ERRORS.bad_request }
|
|
75
|
+
if (!cloud) return { ok: false, error: "This copy of Threadgauge can't activate a license yet." }
|
|
76
|
+
let reply
|
|
77
|
+
try {
|
|
78
|
+
reply = await post('/v1/license', { key: trimmed, device: await store.device() })
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { ok: false, error: `Couldn't reach the license service: ${err.message}` }
|
|
81
|
+
}
|
|
82
|
+
const { status, data } = reply
|
|
83
|
+
if (status !== 200 || typeof data.license !== 'string') {
|
|
84
|
+
return { ok: false, error: CLOUD_ERRORS[data.error] || data.message || `The license service answered ${status}` }
|
|
85
|
+
}
|
|
86
|
+
const saved = await store.save(data.license)
|
|
87
|
+
if (!saved.ok) {
|
|
88
|
+
return { ok: false, error: saved.reason === 'unknown key' ? "That license was signed by a key this copy of Threadgauge doesn't trust." : "That license didn't check out." }
|
|
89
|
+
}
|
|
90
|
+
const result = await entitlements()
|
|
91
|
+
const covered = Boolean(result.license?.entitlements.includes(ENTITLEMENT))
|
|
92
|
+
return { ok: true, entitlements: result, note: covered ? null : "That key is on for this computer, but its plan doesn't include Threadgauge Pro. You can switch plans in your Polar customer portal and keep the same key." }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function idle() {
|
|
96
|
+
await renewing
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { entitlements, activate, idle }
|
|
100
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { organizationReader } from '@threadcrumbs/common/orgs'
|
|
3
|
+
import { TELEMETRY } from '@threadcrumbs/common/telemetry'
|
|
4
|
+
|
|
5
|
+
const SETTLE_MS = 250
|
|
6
|
+
const HEARTBEAT_MS = 5000
|
|
7
|
+
|
|
8
|
+
export function createMonitor({ harnesses = TELEMETRY, settleMs = SETTLE_MS, heartbeatMs = HEARTBEAT_MS, organizations = organizationReader() } = {}) {
|
|
9
|
+
const listeners = new Set()
|
|
10
|
+
const watched = new Map()
|
|
11
|
+
let sessions = new Map()
|
|
12
|
+
let harnessStatus = []
|
|
13
|
+
let scannedAt = 0
|
|
14
|
+
let ready = null
|
|
15
|
+
let running = null
|
|
16
|
+
let rescan = false
|
|
17
|
+
let settleTimer = null
|
|
18
|
+
let heartbeat = null
|
|
19
|
+
let scannedOnce
|
|
20
|
+
const firstScan = new Promise((resolve) => (scannedOnce = resolve))
|
|
21
|
+
|
|
22
|
+
async function scanOnce() {
|
|
23
|
+
const found = []
|
|
24
|
+
const status = []
|
|
25
|
+
for (const harness of harnesses) {
|
|
26
|
+
let detected = false
|
|
27
|
+
let error = ''
|
|
28
|
+
try {
|
|
29
|
+
detected = Boolean(await harness.detect())
|
|
30
|
+
if (detected) found.push(...(await harness.scanSessions()))
|
|
31
|
+
} catch (err) {
|
|
32
|
+
error = String(err?.message || err)
|
|
33
|
+
}
|
|
34
|
+
status.push({ id: harness.id, name: harness.name, detected, error })
|
|
35
|
+
}
|
|
36
|
+
const tagged = await organizations(found).catch(() => found)
|
|
37
|
+
const next = new Map(tagged.map((session) => [session.id, { session, json: JSON.stringify(session) }]))
|
|
38
|
+
|
|
39
|
+
const upserts = [...next.values()].filter((e) => sessions.get(e.session.id)?.json !== e.json).map((e) => e.session)
|
|
40
|
+
const removed = [...sessions.keys()].filter((id) => !next.has(id))
|
|
41
|
+
const harnessesChanged = JSON.stringify(status) !== JSON.stringify(harnessStatus)
|
|
42
|
+
sessions = next
|
|
43
|
+
harnessStatus = status
|
|
44
|
+
scannedAt = Date.now()
|
|
45
|
+
watchRoots()
|
|
46
|
+
|
|
47
|
+
if (!upserts.length && !removed.length && !harnessesChanged) return
|
|
48
|
+
const update = { upserts, removed, scannedAt }
|
|
49
|
+
if (harnessesChanged) update.harnesses = status
|
|
50
|
+
for (const listener of listeners) listener(update)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scan() {
|
|
54
|
+
if (running) {
|
|
55
|
+
rescan = true
|
|
56
|
+
return running
|
|
57
|
+
}
|
|
58
|
+
running = (async () => {
|
|
59
|
+
do {
|
|
60
|
+
rescan = false
|
|
61
|
+
try {
|
|
62
|
+
await scanOnce()
|
|
63
|
+
} finally {
|
|
64
|
+
scannedOnce()
|
|
65
|
+
}
|
|
66
|
+
} while (rescan)
|
|
67
|
+
})().finally(() => {
|
|
68
|
+
running = null
|
|
69
|
+
})
|
|
70
|
+
return running
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scheduleScan() {
|
|
74
|
+
if (settleTimer) return
|
|
75
|
+
settleTimer = setTimeout(() => {
|
|
76
|
+
settleTimer = null
|
|
77
|
+
scan()
|
|
78
|
+
}, settleMs)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function watchRoots() {
|
|
82
|
+
for (const harness of harnesses) {
|
|
83
|
+
for (const root of harness.watchRoots?.() || []) {
|
|
84
|
+
if (watched.has(root) || !fs.existsSync(root)) continue
|
|
85
|
+
try {
|
|
86
|
+
const watcher = fs.watch(root, { recursive: true, persistent: false }, scheduleScan)
|
|
87
|
+
watcher.on('error', () => {
|
|
88
|
+
watcher.close()
|
|
89
|
+
watched.delete(root)
|
|
90
|
+
})
|
|
91
|
+
watched.set(root, watcher)
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
start() {
|
|
99
|
+
if (!ready) {
|
|
100
|
+
ready = Promise.race([scan(), firstScan])
|
|
101
|
+
heartbeat = setInterval(scan, heartbeatMs)
|
|
102
|
+
heartbeat.unref()
|
|
103
|
+
}
|
|
104
|
+
return ready
|
|
105
|
+
},
|
|
106
|
+
stop() {
|
|
107
|
+
clearInterval(heartbeat)
|
|
108
|
+
clearTimeout(settleTimer)
|
|
109
|
+
for (const watcher of watched.values()) watcher.close()
|
|
110
|
+
watched.clear()
|
|
111
|
+
listeners.clear()
|
|
112
|
+
},
|
|
113
|
+
scan,
|
|
114
|
+
async detail(id, options = {}) {
|
|
115
|
+
const entry = sessions.get(id)
|
|
116
|
+
if (!entry) {
|
|
117
|
+
const harness = harnesses.find((h) => String(id).startsWith(`${h.id}:`))
|
|
118
|
+
return harness?.readSession ? harness.readSession(id, options) : null
|
|
119
|
+
}
|
|
120
|
+
const harness = harnesses.find((h) => h.id === entry.session.harness)
|
|
121
|
+
const detail = harness?.sessionDetail ? await harness.sessionDetail(id, options) : null
|
|
122
|
+
return { session: entry.session, detail }
|
|
123
|
+
},
|
|
124
|
+
snapshot() {
|
|
125
|
+
return {
|
|
126
|
+
sessions: [...sessions.values()].map((e) => e.session),
|
|
127
|
+
harnesses: harnessStatus,
|
|
128
|
+
scannedAt,
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
subscribe(listener) {
|
|
132
|
+
listeners.add(listener)
|
|
133
|
+
return () => listeners.delete(listener)
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
package/server/open.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { harnessById } from '@threadcrumbs/common/harnesses'
|
|
3
|
+
import { present } from '@threadcrumbs/common/launch'
|
|
4
|
+
import { createThreadCache } from './threads.mjs'
|
|
5
|
+
const PLAIN_ARG = /^[\w@%+=:,./\\-]+$/
|
|
6
|
+
|
|
7
|
+
const quoteFor = (windows) => (arg) => {
|
|
8
|
+
const value = String(arg)
|
|
9
|
+
if (PLAIN_ARG.test(value)) return value
|
|
10
|
+
return windows ? `'${value.replace(/['\u2018\u2019\u201A\u201B]/g, (q) => q + q)}'` : `'${value.replace(/'/g, `'\\''`)}'`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function commandLine({ argv, cwd }, { platform = process.platform } = {}) {
|
|
14
|
+
if (!Array.isArray(argv) || !argv.length) return null
|
|
15
|
+
const windows = platform === 'win32'
|
|
16
|
+
const quote = quoteFor(windows)
|
|
17
|
+
const bin = (windows ? path.win32 : path.posix).basename(String(argv[0])).replace(/\.(exe|cmd|bat|ps1)$/i, '')
|
|
18
|
+
const run = [bin, ...argv.slice(1)].map(quote).join(' ')
|
|
19
|
+
if (!cwd) return run
|
|
20
|
+
return windows ? `cd ${quote(cwd)}; ${run}` : `cd ${quote(cwd)} && ${run}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createOpener({ harnessOf = harnessById, show = present, now = Date.now, platform = process.platform, threadsOf = createThreadCache({ now }) } = {}) {
|
|
24
|
+
async function lookUp(session) {
|
|
25
|
+
const harness = harnessOf(session.harness)
|
|
26
|
+
if (!harness?.openThread) return { harness: null, thread: null, ref: null }
|
|
27
|
+
let thread = (await threadsOf(harness, false)).find((t) => t.id === session.id)
|
|
28
|
+
if (!thread) thread = (await threadsOf(harness, true)).find((t) => t.id === session.id)
|
|
29
|
+
const native = session.id.slice(session.id.indexOf(':') + 1)
|
|
30
|
+
const ref = thread?.ref || { sessionId: native, cliSessionId: native, cwd: session.cwd || '' }
|
|
31
|
+
return { harness, thread, ref }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function openSession(session, via = 'app') {
|
|
35
|
+
const { harness, thread, ref } = await lookUp(session)
|
|
36
|
+
if (!harness) return { ok: false, error: `Threadgauge can't open ${session.harness} threads` }
|
|
37
|
+
if (thread?.canOpen === false) return { ok: false, error: 'That thread has nothing its harness can open' }
|
|
38
|
+
return show(await harness.openThread(ref), via)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
openSession.resume = async function resume(session) {
|
|
42
|
+
const { harness, thread, ref } = await lookUp(session)
|
|
43
|
+
if (!harness || thread?.canOpen === false) return null
|
|
44
|
+
const result = await harness.openThread(ref).catch(() => null)
|
|
45
|
+
return result?.command ? commandLine({ argv: result.command.argv, cwd: result.command.cwd || session.cwd }, { platform }) : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return openSession
|
|
49
|
+
}
|
package/server/paths.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { launch } from '@threadcrumbs/common/launch'
|
|
5
|
+
|
|
6
|
+
export const EDITORS = {
|
|
7
|
+
vscode: { name: 'VS Code', scheme: 'vscode' },
|
|
8
|
+
cursor: { name: 'Cursor', scheme: 'cursor' },
|
|
9
|
+
windsurf: { name: 'Windsurf', scheme: 'windsurf' },
|
|
10
|
+
zed: { name: 'Zed', scheme: 'zed' },
|
|
11
|
+
system: { name: 'the default app', scheme: null },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const keyOf = (file, platform) => (platform === 'win32' ? file.toLowerCase() : file)
|
|
15
|
+
|
|
16
|
+
const WINDOWS_RUNNABLE = new Set([
|
|
17
|
+
'.exe', '.com', '.bat', '.cmd', '.scr', '.pif', '.cpl', '.msc', '.dll', '.sys',
|
|
18
|
+
'.js', '.jse', '.vbs', '.vbe', '.wsf', '.wsh', '.wsc', '.sct',
|
|
19
|
+
'.hta', '.chm',
|
|
20
|
+
'.ps1', '.psm1', '.psd1', '.ps1xml', '.psc1', '.pssc', '.cdxml',
|
|
21
|
+
'.py', '.pyw', '.pyz', '.pyzw', '.pyc', '.pyo',
|
|
22
|
+
'.lnk', '.url', '.scf', '.library-ms', '.searchconnector-ms', '.settingcontent-ms',
|
|
23
|
+
'.msi', '.msp', '.mst', '.msix', '.msixbundle', '.appx', '.appxbundle', '.appinstaller', '.application',
|
|
24
|
+
'.reg', '.inf', '.jar', '.gadget', '.diagcab', '.xll', '.website',
|
|
25
|
+
])
|
|
26
|
+
const MAC_RUNNABLE = new Set(['.app', '.command', '.tool', '.terminal', '.workflow', '.action', '.pkg', '.mpkg', '.jar', '.fileloc'])
|
|
27
|
+
|
|
28
|
+
export function runsWhenOpened(file, { platform = process.platform, pathext = process.env.PATHEXT } = {}) {
|
|
29
|
+
if (platform === 'win32') {
|
|
30
|
+
const name = file.replace(/[. ]+$/, '')
|
|
31
|
+
if (name.lastIndexOf(':') > 1) return true
|
|
32
|
+
const ext = path.extname(name).toLowerCase()
|
|
33
|
+
const listed = (pathext || '').toLowerCase().split(';').filter(Boolean)
|
|
34
|
+
return WINDOWS_RUNNABLE.has(ext) || listed.includes(ext)
|
|
35
|
+
}
|
|
36
|
+
if (platform === 'darwin') return MAC_RUNNABLE.has(path.extname(file).toLowerCase())
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const REVEALERS = { win32: 'File Explorer', darwin: 'Finder' }
|
|
41
|
+
|
|
42
|
+
export function revealFile(file, { platform = process.platform, run = spawn, open = launch } = {}) {
|
|
43
|
+
const [cmd, args, options] =
|
|
44
|
+
platform === 'win32'
|
|
45
|
+
? ['explorer.exe', [`/select,"${file}"`], { windowsVerbatimArguments: true }]
|
|
46
|
+
: platform === 'darwin'
|
|
47
|
+
? ['open', ['-R', file], {}]
|
|
48
|
+
: [null]
|
|
49
|
+
if (!cmd) return open(path.dirname(file))
|
|
50
|
+
const child = run(cmd, args, { ...options, stdio: 'ignore', detached: true })
|
|
51
|
+
child.on?.('error', () => {})
|
|
52
|
+
child.unref?.()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function editorUrl(editor, file, line = null, { platform = process.platform } = {}) {
|
|
56
|
+
const scheme = EDITORS[editor]?.scheme
|
|
57
|
+
if (!scheme) return file
|
|
58
|
+
const forward = platform === 'win32' ? file.replace(/\\/g, '/') : file
|
|
59
|
+
const encoded = encodeURI(forward).replace(/#/g, '%23').replace(/\?/g, '%3F')
|
|
60
|
+
const at = Number.isInteger(line) && line > 0 ? `:${line}` : ''
|
|
61
|
+
return `${scheme}://file${encoded.startsWith('/') ? '' : '/'}${encoded}${at}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function touchedPaths(session, detail, { platform = process.platform } = {}) {
|
|
65
|
+
const base = session.cwd || session.projectPath || ''
|
|
66
|
+
const allowed = new Map()
|
|
67
|
+
const add = (file, kind) => {
|
|
68
|
+
if (!file || !base) return
|
|
69
|
+
const full = path.resolve(base, file)
|
|
70
|
+
if (!allowed.has(keyOf(full, platform))) allowed.set(keyOf(full, platform), { full, kind })
|
|
71
|
+
}
|
|
72
|
+
add(session.cwd, 'folder')
|
|
73
|
+
add(session.projectPath, 'folder')
|
|
74
|
+
add(detail?.source, 'file')
|
|
75
|
+
for (const file of detail?.files || []) add(file.path, 'file')
|
|
76
|
+
for (const call of detail?.activity || []) add(call.path, 'file')
|
|
77
|
+
for (const part of detail?.contextBreakdown?.parts || []) {
|
|
78
|
+
if (part.id === 'memory') for (const item of part.items || []) if (/\.md$/i.test(item.path || '')) add(item.path, 'file')
|
|
79
|
+
}
|
|
80
|
+
return { base, allowed }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createPathOpener({ open = launch, reveal = (file) => revealFile(file, { platform }), stat = fsp.stat, platform = process.platform, pathext = process.env.PATHEXT } = {}) {
|
|
84
|
+
return async function openPath({ session, detail, path: wanted, line = null, editor = 'vscode' }) {
|
|
85
|
+
const { base, allowed } = touchedPaths(session, detail, { platform })
|
|
86
|
+
if (!base) return { ok: false, status: 404, error: "Threadgauge doesn't know this thread's folder" }
|
|
87
|
+
const full = path.resolve(base, typeof wanted === 'string' && wanted ? wanted : base)
|
|
88
|
+
const entry = allowed.get(keyOf(full, platform))
|
|
89
|
+
if (!entry) return { ok: false, status: 403, error: "Threadgauge only opens files and folders this thread touched" }
|
|
90
|
+
const found = await stat(entry.full).catch(() => null)
|
|
91
|
+
if (!found) return { ok: false, status: 404, error: `${path.basename(entry.full)} isn't there anymore` }
|
|
92
|
+
if (found.isDirectory()) {
|
|
93
|
+
open(entry.full)
|
|
94
|
+
return { ok: true, kind: 'folder', path: entry.full }
|
|
95
|
+
}
|
|
96
|
+
const chosen = editor in EDITORS ? editor : 'vscode'
|
|
97
|
+
if (chosen === 'system' && runsWhenOpened(entry.full, { platform, pathext })) {
|
|
98
|
+
reveal(entry.full)
|
|
99
|
+
return { ok: true, kind: 'file', path: entry.full, editor: REVEALERS[platform] || 'the file manager', revealed: true }
|
|
100
|
+
}
|
|
101
|
+
open(editorUrl(chosen, entry.full, Number.isInteger(line) ? line : null, { platform }))
|
|
102
|
+
return { ok: true, kind: 'file', path: entry.full, editor: EDITORS[chosen].name }
|
|
103
|
+
}
|
|
104
|
+
}
|
package/server/serve.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { startServer } from './http-server.mjs'
|
|
2
|
+
import { DEFAULT_PORT } from './lib/ports.mjs'
|
|
3
|
+
|
|
4
|
+
const PORT = Number(process.env.PORT) || DEFAULT_PORT
|
|
5
|
+
const HOST = process.env.THREADGAUGE_HOST || '127.0.0.1'
|
|
6
|
+
|
|
7
|
+
await startServer({ port: PORT, host: HOST })
|
|
8
|
+
console.log(`Threadgauge → http://${HOST}:${PORT}`)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const FIRST_PORT = 5274
|
|
2
|
+
const PORTS_TO_TRY = 20
|
|
3
|
+
const LOOPBACKS = ['127.0.0.1', '::1']
|
|
4
|
+
const PROBE_TIMEOUT_MS = 500
|
|
5
|
+
|
|
6
|
+
const originOf = (host, port) => `http://${host.includes(':') ? `[${host}]` : host}:${port}`
|
|
7
|
+
|
|
8
|
+
async function answersAsThreadnauts(origin, fetch) {
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch(`${origin}/api/about`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
11
|
+
return (await response.json())?.app === 'threadnauts'
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function findThreadnauts({ fetch = globalThis.fetch, first = FIRST_PORT, tries = PORTS_TO_TRY, hosts = LOOPBACKS } = {}) {
|
|
18
|
+
const probes = []
|
|
19
|
+
for (let port = first; port < first + tries; port++) for (const host of hosts) probes.push({ port, origin: originOf(host, port) })
|
|
20
|
+
const found = await Promise.all(probes.map(({ origin }) => answersAsThreadnauts(origin, fetch)))
|
|
21
|
+
const index = found.indexOf(true)
|
|
22
|
+
return index === -1 ? null : `http://localhost:${probes[index].port}/`
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const THREADS_TTL_MS = 30_000
|
|
2
|
+
|
|
3
|
+
export function createThreadCache({ now = Date.now, ttl = THREADS_TTL_MS } = {}) {
|
|
4
|
+
const threads = new Map()
|
|
5
|
+
return async function threadsOf(harness, fresh = false) {
|
|
6
|
+
const hit = threads.get(harness.id)
|
|
7
|
+
if (!fresh && hit && now() - hit.at < ttl) return hit.list
|
|
8
|
+
const list = await harness.scanThreads().catch(() => [])
|
|
9
|
+
threads.set(harness.id, { at: now(), list })
|
|
10
|
+
return list
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/tiers.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const ENTITLEMENT = 'threadgauge.pro'
|
|
2
|
+
|
|
3
|
+
export const UPGRADE_URL = 'https://threadgauge.io/pro'
|
|
4
|
+
|
|
5
|
+
export const released = (year, month, day) => Date.UTC(year, month - 1, day) / 1000
|
|
6
|
+
|
|
7
|
+
// Anything Threadgauge reads from this machine without acting is free, older threads included
|
|
8
|
+
// (2026-11-08), so no feature is Pro yet. Threadbare's controls are listed here when they ship,
|
|
9
|
+
// each as { releasedAt: released(year, month, day), title, text }.
|
|
10
|
+
export const PRO_FEATURES = {}
|
|
11
|
+
|
|
12
|
+
function every(open) {
|
|
13
|
+
return Object.fromEntries(Object.keys(PRO_FEATURES).map((key) => [key, open]))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const freeEntitlements = () => ({ pro: false, features: every(false) })
|
|
17
|
+
|
|
18
|
+
export const devEntitlements = () => ({ pro: true, features: every(true) })
|
|
19
|
+
|
|
20
|
+
export const featureOpen = (entitlements, feature) => entitlements?.features?.[feature] === true
|