@threadcrumbs/common 0.1.9 → 0.1.10

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/README.md CHANGED
@@ -18,7 +18,7 @@ need it.
18
18
  | `@threadcrumbs/common/orgs` | Which organization each repo belongs to, so the apps can group repos the same way. `organizationReader()` returns `withOrganizations(threads)`, which tags each thread with `org` by its `projectPath` and reads each folder at most once a minute. `signalsOf(dir)` reads one folder's package name and git origin owner, and `organizationsFor(signals)` combines them. The rules are in [DECISIONS.md](DECISIONS.md#a-repos-organization-comes-from-its-package-name). Node only, and it only reads |
19
19
  | `@threadcrumbs/common/launch` | `present(result, via)`: carries out a harness adapter's answer to "open this thread" by fronting the window of a session already running, handing a deep link to the OS, or running the harness's CLI in a new terminal. When nothing on the machine answers the link, it tries the adapter's `fallback` links, such as Claude Code's thread in VS Code, and failing those returns `{ ok: false, error, missing: { name, download, scheme, also }, terminal }`: the app to install, where to get it, other apps that would do, and whether `via: 'terminal'` would work instead. Also `launch(target)` and `resolveFolder(dir)`. Threadgauge uses it, and Threadnauts still has its own copy |
20
20
  | `@threadcrumbs/common/license` | The Threadcrumbs license both apps check offline and the key service signs: `createLicenseKeys`, `publicKeyOf`, `licenseClaims`, `signLicense`, `verifyLicense`, and the unlock rule, `paidThrough` and `unlocks`, plus `renewalDue`. Web APIs only, so the same file runs in a Cloudflare Worker. The format and rules are in [DECISIONS.md](DECISIONS.md#licenses-are-signed-by-threadcrumbs-and-checked-offline). `LICENSE_PUBLIC_KEYS` holds the production keys every app trusts, and never a sandbox key |
21
- | `@threadcrumbs/common/license/store` | Where the license lives on this machine, shared by every app: `licenseStore()` returns `device()`, `current()`, `save(token)`, and `clear()`, over `license.json` in `threadcrumbsDataDir()`. `current().at` is the time to test unlocks at, and `current()` writes nothing on a machine where no one has entered a key. `trustedKeys({ development })` adds a sandbox key in a development build, and `cloudUrl({ development })` is the key service's address: `https://threadcrumbs.io/api`, or the local Worker at `http://localhost:8787/api` in a development build. Node only, so the Worker never loads it |
21
+ | `@threadcrumbs/common/license/store` | Where the license lives on this machine, shared by every app: `licenseStore()` returns `device()`, `current()`, `save(token)`, and `clear()`, over `license.json` in `threadcrumbsDataDir()`. `current().at` is the time to test unlocks at, and `current()` writes nothing on a machine where no one has entered a key. `trustedKeys({ development })` adds a sandbox key in a development build, and `cloudUrl({ development })` is the key service's address: `https://threadcrumbs.io/api`, or the local Worker at `http://localhost:8787/api` in a development build. `portalUrl({ license, device, fetch, cloud })` asks the key service for Polar's customer portal, signed in, where a subscriber changes plan; it resolves to `{ url }` or `{ error, why }`, with `why` ready to show ([DECISIONS.md](DECISIONS.md#changing-plan-happens-in-polars-portal-opened-with-the-license)). Node only, so the Worker never loads it |
22
22
  | `@threadcrumbs/common/ui/theme.css` | The Threadcrumbs look as CSS variables: the Quieter backgrounds, lines, and text levels, Threadcrumbs orange, the six status colors and their plates, `--warn`, added and removed, the chart and gauge colors, focus, type sizes, spacing, radii, and the default `--sans`, `--cond`, and `--mono` stacks. Every element keeps one x-height (`font-size-adjust: 0.51`) |
23
23
  | `@threadcrumbs/common/ui/fonts.css` | `@font-face` rules for Inter, Inter Tight, and JetBrains Mono, Latin and Latin Extended, upright, with their `.woff2` files in `ui/fonts/`. A bundler copies the files into the app, so they load from its own origin. `tools/ui-fonts.mjs` writes both from the `@fontsource` packages |
24
24
  | `@threadcrumbs/common/ui/status` | `STATUSES` (each status's key, name, color, and plate, in the order both apps pin), `STATUS_KEYS`, `URGENCY_ORDER` (for the status row and the waffle), `LIVE_STATUSES`, `statusOf`, the status icons as SVG in `LAMP_ICONS`, and `lampHTML(status)`. `ui/status.css` colors each `[data-st]` and animates the lamps, and reduced motion stops them |
@@ -140,7 +140,7 @@ give its sessions the same ids, which Threadgauge's Open depends on.
140
140
  or imports `launch/` or the license store. `test/launch.test.mjs` covers how `present()` refuses and names a missing app,
141
141
  starting nothing but a registry query on Windows. `test/license.test.mjs` covers signing and the unlock rule, and
142
142
  `test/license-store.test.mjs` covers the shared file, including two apps starting at once on a
143
- fresh machine and a clock set back.
143
+ fresh machine and a clock set back, and `portalUrl()` against a fake key service.
144
144
 
145
145
  GitHub Actions runs the suite on Windows, macOS, and Linux for every push to `master` and every
146
146
  pull request (`.github/workflows/test.yml`). The tests that only run on one OS, like the real
package/license/store.mjs CHANGED
@@ -1,155 +1,192 @@
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
- }
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
+ const CLOUD_TIMEOUT_MS = 15000
16
+
17
+ const pause = () => new Promise((resolve) => setTimeout(resolve, PAUSE_MS))
18
+ const isDevice = (value) => typeof value === 'string' && DEVICE.test(value)
19
+ const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
20
+
21
+ export function threadcrumbsDataDir({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
22
+ if (env.THREADCRUMBS_DATA_DIR) return env.THREADCRUMBS_DATA_DIR
23
+ if (platform === 'win32') {
24
+ return path.win32.join(env.APPDATA || path.win32.join(home, 'AppData', 'Roaming'), 'Threadcrumbs')
25
+ }
26
+ if (platform === 'darwin') return path.posix.join(home, 'Library', 'Application Support', 'Threadcrumbs')
27
+ const xdg = env.XDG_DATA_HOME && path.posix.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : ''
28
+ return path.posix.join(xdg || path.posix.join(home, '.local', 'share'), 'threadcrumbs')
29
+ }
30
+
31
+ export function trustedKeys({ env = process.env, development = false } = {}) {
32
+ const extra = development ? env.THREADCRUMBS_LICENSE_KEYS : ''
33
+ if (!extra) return { ...LICENSE_PUBLIC_KEYS }
34
+ let parsed
35
+ try {
36
+ parsed = JSON.parse(extra)
37
+ } catch {
38
+ throw new Error('THREADCRUMBS_LICENSE_KEYS must be JSON: { "<kid>": "<public key>" }')
39
+ }
40
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Object.values(parsed).every((k) => typeof k === 'string')) {
41
+ throw new Error('THREADCRUMBS_LICENSE_KEYS must map each key ID to a public key')
42
+ }
43
+ return { ...LICENSE_PUBLIC_KEYS, ...parsed }
44
+ }
45
+
46
+ export function cloudUrl({ env = process.env, development = false } = {}) {
47
+ return (env.THREADCRUMBS_CLOUD_URL || (development ? LOCAL_CLOUD_URL : CLOUD_URL)).replace(/\/+$/, '')
48
+ }
49
+
50
+ const PORTAL_REFUSALS = {
51
+ not_activated: 'Enter your license key on this computer first.',
52
+ offline: "Couldn't reach the license service. Check your connection, then try again.",
53
+ unavailable: "The license service didn't answer as expected. Try again in a minute.",
54
+ }
55
+
56
+ const refusal = (error) => ({ error, why: PORTAL_REFUSALS[error] })
57
+
58
+ function isHttps(url) {
59
+ try {
60
+ return new URL(url).protocol === 'https:'
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ export async function portalUrl({ license, device, fetch = globalThis.fetch, cloud = cloudUrl() } = {}) {
67
+ if (typeof license !== 'string' || !license || !isDevice(device)) return refusal('not_activated')
68
+ if (!cloud) return refusal('unavailable')
69
+ let response
70
+ try {
71
+ response = await fetch(`${cloud}/v1/portal`, {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: JSON.stringify({ license, device }),
75
+ signal: AbortSignal.timeout(CLOUD_TIMEOUT_MS),
76
+ })
77
+ } catch {
78
+ return refusal('offline')
79
+ }
80
+ const data = await response.json().catch(() => null)
81
+ if (response.ok && typeof data?.url === 'string' && isHttps(data.url)) return { url: data.url }
82
+ if (!response.ok && typeof data?.error === 'string' && typeof data.why === 'string' && data.why) return { error: data.error, why: data.why }
83
+ return refusal('unavailable')
84
+ }
85
+
86
+ async function check(token, keys, device) {
87
+ try {
88
+ return await verifyLicense(token, { keys, device })
89
+ } catch {
90
+ return { ok: false, reason: 'malformed' }
91
+ }
92
+ }
93
+
94
+ export function licenseStore({ dir = threadcrumbsDataDir(), keys = LICENSE_PUBLIC_KEYS, now = () => Date.now() / 1000 } = {}) {
95
+ const file = path.join(dir, FILE)
96
+
97
+ async function read() {
98
+ let text
99
+ try {
100
+ text = await fsp.readFile(file, 'utf8')
101
+ } catch (err) {
102
+ if (err.code === 'ENOENT') return null
103
+ throw err
104
+ }
105
+ try {
106
+ const data = JSON.parse(text)
107
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {}
108
+ } catch {
109
+ return {}
110
+ }
111
+ }
112
+
113
+ const serialize = (data) => `${JSON.stringify(data, null, 2)}\n`
114
+
115
+ async function replace(data) {
116
+ await fsp.mkdir(dir, { recursive: true })
117
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
118
+ try {
119
+ await fsp.writeFile(temp, serialize(data), { mode: 0o600 })
120
+ for (let attempt = 1; ; attempt++) {
121
+ try {
122
+ return await fsp.rename(temp, file)
123
+ } catch (err) {
124
+ if (attempt >= RENAME_TRIES || !['EPERM', 'EACCES', 'EBUSY'].includes(err.code)) throw err
125
+ await pause()
126
+ }
127
+ }
128
+ } catch (err) {
129
+ await fsp.rm(temp, { force: true })
130
+ throw err
131
+ }
132
+ }
133
+
134
+ async function device() {
135
+ for (let attempt = 0; attempt < READ_TRIES; attempt++) {
136
+ const data = await read()
137
+ if (isDevice(data?.device)) return data.device
138
+ if (data === null) {
139
+ await fsp.mkdir(dir, { recursive: true })
140
+ try {
141
+ await fsp.writeFile(file, serialize({ device: randomBytes(18).toString('base64url'), license: null, seen: 0 }), { flag: 'wx', mode: 0o600 })
142
+ } catch (err) {
143
+ if (err.code !== 'EEXIST') throw err
144
+ }
145
+ continue
146
+ }
147
+ await pause()
148
+ }
149
+ await replace({ device: randomBytes(18).toString('base64url'), license: null, seen: 0 })
150
+ const data = await read()
151
+ if (!isDevice(data?.device)) throw new Error(`Can't read or replace ${file}`)
152
+ return data.device
153
+ }
154
+
155
+ async function update(change) {
156
+ const data = (await read()) ?? {}
157
+ await replace({ ...data, ...change, device: data.device })
158
+ }
159
+
160
+ async function current() {
161
+ const clock = now()
162
+ if ((await read()) === null) return { device: null, token: null, license: null, at: clock }
163
+ const dev = await device()
164
+ const data = (await read()) ?? {}
165
+ const seen = finite(data.seen)
166
+ if (typeof data.license !== 'string') return { device: dev, token: null, license: null, at: Math.max(clock, seen) }
167
+ const checked = await check(data.license, keys, dev)
168
+ if (!checked.ok) return { device: dev, token: null, license: null, reason: checked.reason, at: Math.max(clock, seen) }
169
+ if (clock > seen + SEEN_STEP) await update({ seen: clock }).catch(() => {})
170
+ return { device: dev, token: data.license, license: checked.license, at: Math.max(clock, seen, checked.license.iat) }
171
+ }
172
+
173
+ async function save(token) {
174
+ const dev = await device()
175
+ const checked = await check(token, keys, dev)
176
+ if (!checked.ok) return checked
177
+ const data = (await read()) ?? {}
178
+ const stored = typeof data.license === 'string' ? await check(data.license, keys, dev) : null
179
+ if (stored?.ok && stored.license.iat > checked.license.iat) {
180
+ return { ok: true, license: stored.license, at: Math.max(now(), finite(data.seen), stored.license.iat) }
181
+ }
182
+ await update({ license: token, seen: checked.license.iat })
183
+ return { ok: true, license: checked.license, at: Math.max(now(), checked.license.iat) }
184
+ }
185
+
186
+ async function clear() {
187
+ await device()
188
+ await update({ license: null })
189
+ }
190
+
191
+ return { file, device, current, save, clear }
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadcrumbs/common",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "description": "Code the Threadcrumbs apps share, starting with the readers for each coding-agent harness.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",