dsh-computers 0.0.0-stage → 1.3.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 +21 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/state.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side persisted registry (PROTOCOL §2).
|
|
3
|
+
*
|
|
4
|
+
* One JSON document under `${stateDir || $DSH_HOME/computers}/state.json`,
|
|
5
|
+
* written atomically (same-directory tmp file + rename) with mode 0600. The
|
|
6
|
+
* loaded document is the single source of truth in memory; every mutation goes
|
|
7
|
+
* through `mutate()` so prune + persist cannot be forgotten.
|
|
8
|
+
*
|
|
9
|
+
* Node builtins only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** On-disk schema version (the browser API reports its own version 2). */
|
|
18
|
+
export const STATE_VERSION = 1
|
|
19
|
+
|
|
20
|
+
/** A consumed key and an expired key are both dropped one hour after the fact. */
|
|
21
|
+
export const PENDING_KEY_PRUNE_MS = 60 * 60 * 1000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the state directory: explicit config first, then `$DSH_HOME`, then
|
|
25
|
+
* `~/.dsh` (the harness default).
|
|
26
|
+
* @param {string | undefined} configured - `config.stateDir`.
|
|
27
|
+
* @returns {string} absolute directory path.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveStateDir(configured) {
|
|
30
|
+
if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
|
|
31
|
+
const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
|
|
32
|
+
? process.env.DSH_HOME.trim()
|
|
33
|
+
: join(homedir(), '.dsh')
|
|
34
|
+
return join(home, 'computers')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A fresh, empty registry document. */
|
|
38
|
+
export function emptyState() {
|
|
39
|
+
return { version: STATE_VERSION, computers: [], pendingKeys: [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Drop pending keys the protocol lets us forget: one whose `usedAt` is older
|
|
44
|
+
* than an hour, or whose `expiresAt` passed more than an hour ago.
|
|
45
|
+
* @param {object} doc - registry document, mutated in place.
|
|
46
|
+
* @param {number} nowMs - current epoch milliseconds.
|
|
47
|
+
* @returns {number} how many entries were dropped.
|
|
48
|
+
*/
|
|
49
|
+
export function prunePendingKeys(doc, nowMs) {
|
|
50
|
+
const before = doc.pendingKeys.length
|
|
51
|
+
doc.pendingKeys = doc.pendingKeys.filter((entry) => {
|
|
52
|
+
if (entry === null || typeof entry !== 'object') return false
|
|
53
|
+
const usedAt = typeof entry.usedAt === 'string' ? Date.parse(entry.usedAt) : Number.NaN
|
|
54
|
+
if (typeof entry.usedAt === 'string' && Number.isFinite(usedAt)) return nowMs - usedAt <= PENDING_KEY_PRUNE_MS
|
|
55
|
+
const expiresAt = typeof entry.expiresAt === 'string' ? Date.parse(entry.expiresAt) : Number.NaN
|
|
56
|
+
if (!Number.isFinite(expiresAt)) return false
|
|
57
|
+
return nowMs - expiresAt <= PENDING_KEY_PRUNE_MS
|
|
58
|
+
})
|
|
59
|
+
return before - doc.pendingKeys.length
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Coerce an arbitrary parsed value into a registry document we can trust
|
|
64
|
+
* enough to serve. Unknown shapes are dropped rather than crashing the host.
|
|
65
|
+
* @param {unknown} raw - parsed JSON (or undefined when the file was missing).
|
|
66
|
+
* @returns {object} a valid registry document.
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeState(raw, now = undefined) {
|
|
69
|
+
const doc = emptyState()
|
|
70
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return doc
|
|
71
|
+
if (Array.isArray(raw.computers)) {
|
|
72
|
+
doc.computers = raw.computers.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
|
|
73
|
+
for (const computer of doc.computers) {
|
|
74
|
+
// §11.6.4 migration: records written before the activation lifecycle have
|
|
75
|
+
// no `activatedAt` property at all. They were activated by definition, so
|
|
76
|
+
// backfill it from createdAt instead of hiding a working machine. A v1.1
|
|
77
|
+
// enrollment sets `activatedAt: null` explicitly and stays pending.
|
|
78
|
+
if (computer.activatedAt === undefined) {
|
|
79
|
+
computer.activatedAt = typeof computer.createdAt === 'string' ? computer.createdAt : new Date(now ?? Date.now()).toISOString()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(raw.pendingKeys)) {
|
|
84
|
+
doc.pendingKeys = raw.pendingKeys.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
|
|
85
|
+
}
|
|
86
|
+
return doc
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create the registry store for one plugin mount.
|
|
91
|
+
*
|
|
92
|
+
* `now` is injectable so expiry/prune rules are testable without a clock.
|
|
93
|
+
* @param {object} options - store options.
|
|
94
|
+
* @param {string} options.stateDir - resolved directory (see {@link resolveStateDir}).
|
|
95
|
+
* @param {() => number} [options.now] - epoch-millisecond clock.
|
|
96
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
97
|
+
* @returns {object} the store.
|
|
98
|
+
*/
|
|
99
|
+
export function createStore({ stateDir, now = Date.now, onWarn = () => {} }) {
|
|
100
|
+
const file = join(stateDir, 'state.json')
|
|
101
|
+
let doc = emptyState()
|
|
102
|
+
|
|
103
|
+
/** Read and parse the document; a corrupt file is set aside, never fatal. */
|
|
104
|
+
const load = () => {
|
|
105
|
+
let raw
|
|
106
|
+
try {
|
|
107
|
+
raw = readFileSync(file, 'utf8')
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error && error.code !== 'ENOENT') {
|
|
110
|
+
onWarn(`dsh-computers: cannot read ${file}: ${error.message}`)
|
|
111
|
+
}
|
|
112
|
+
doc = emptyState()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
doc = normalizeState(JSON.parse(raw), now())
|
|
117
|
+
} catch {
|
|
118
|
+
// A half-written legacy file must not brick the plugin: keep it for
|
|
119
|
+
// inspection and start from an empty registry.
|
|
120
|
+
const aside = `${file}.corrupt-${now()}`
|
|
121
|
+
try {
|
|
122
|
+
renameSync(file, aside)
|
|
123
|
+
onWarn(`dsh-computers: ${file} was not valid JSON; moved to ${aside}`)
|
|
124
|
+
} catch {
|
|
125
|
+
onWarn(`dsh-computers: ${file} was not valid JSON`)
|
|
126
|
+
}
|
|
127
|
+
doc = emptyState()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Write the document atomically: tmp file in the same directory, then rename. */
|
|
132
|
+
const persist = () => {
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
135
|
+
const tmp = join(stateDir, `.state.json.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
|
|
136
|
+
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
137
|
+
try {
|
|
138
|
+
chmodSync(tmp, 0o600)
|
|
139
|
+
} catch {
|
|
140
|
+
// Platforms without POSIX modes (Windows) simply skip this.
|
|
141
|
+
}
|
|
142
|
+
renameSync(tmp, file)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
onWarn(`dsh-computers: cannot write ${file}: ${error && error.message ? error.message : error}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
load()
|
|
149
|
+
prunePendingKeys(doc, now())
|
|
150
|
+
persist()
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
/** Absolute state directory. */
|
|
154
|
+
dir: stateDir,
|
|
155
|
+
/** Absolute state file path. */
|
|
156
|
+
file,
|
|
157
|
+
/** Read-only view of the live document (callers must not mutate it). */
|
|
158
|
+
read: () => doc,
|
|
159
|
+
/** Deep copy for responses that must not alias live state. */
|
|
160
|
+
snapshot: () => JSON.parse(JSON.stringify(doc)),
|
|
161
|
+
/**
|
|
162
|
+
* Run one atomic read-modify-write: prune, apply `change`, persist.
|
|
163
|
+
* @param {(document: object) => unknown} change - mutation callback.
|
|
164
|
+
* @returns {unknown} whatever `change` returned.
|
|
165
|
+
*/
|
|
166
|
+
mutate(change) {
|
|
167
|
+
const result = change(doc)
|
|
168
|
+
prunePendingKeys(doc, now())
|
|
169
|
+
persist()
|
|
170
|
+
return result
|
|
171
|
+
},
|
|
172
|
+
/**
|
|
173
|
+
* Refresh a computer's `lastSeenAt` (stream open, heartbeat, result POST).
|
|
174
|
+
* @param {string} computerId - computer to touch.
|
|
175
|
+
* @param {string} [at] - ISO timestamp; defaults to now.
|
|
176
|
+
* @returns {boolean} whether the computer exists.
|
|
177
|
+
*/
|
|
178
|
+
touch(computerId, at = new Date(now()).toISOString()) {
|
|
179
|
+
const computer = doc.computers.find((entry) => entry.id === computerId)
|
|
180
|
+
if (computer === undefined) return false
|
|
181
|
+
computer.lastSeenAt = at
|
|
182
|
+
persist()
|
|
183
|
+
return true
|
|
184
|
+
},
|
|
185
|
+
/** Re-read the file from disk (used by tooling and the smoke harness). */
|
|
186
|
+
reload: load,
|
|
187
|
+
/** Expose the clock in use. */
|
|
188
|
+
now,
|
|
189
|
+
}
|
|
190
|
+
}
|