@crosshands/cli 0.1.4
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/README.md +15 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +51 -0
- package/dist/bin.js.map +1 -0
- package/dist/broker-host.d.ts +23 -0
- package/dist/broker-host.d.ts.map +1 -0
- package/dist/broker-host.js +87 -0
- package/dist/broker-host.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +511 -0
- package/dist/index.js.map +1 -0
- package/dist/local-client.d.ts +17 -0
- package/dist/local-client.d.ts.map +1 -0
- package/dist/local-client.js +131 -0
- package/dist/local-client.js.map +1 -0
- package/package.json +47 -0
- package/src/bin.ts +56 -0
- package/src/broker-host.ts +132 -0
- package/src/index.ts +558 -0
- package/src/local-client.ts +171 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { chmod, lstat, mkdir, readFile } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { CONTRACT_VERSIONS, createComputerError } from '@crosshands/contract'
|
|
8
|
+
import { LocalControlClient, brokerEndpoint, type LocalControlIdentity } from '@crosshands/runtime'
|
|
9
|
+
|
|
10
|
+
import type { CliBrokerClient } from './index.js'
|
|
11
|
+
|
|
12
|
+
export type LocalClientPaths = {
|
|
13
|
+
identity: LocalControlIdentity
|
|
14
|
+
runtimeDirectory: string
|
|
15
|
+
tokenFile: string
|
|
16
|
+
endpoint: ReturnType<typeof brokerEndpoint>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function localClientPaths(): LocalClientPaths {
|
|
20
|
+
const uid = process.getuid?.()
|
|
21
|
+
const osIdentity =
|
|
22
|
+
process.env.CROSSHANDS_OS_IDENTITY ??
|
|
23
|
+
(uid === undefined
|
|
24
|
+
? `user:${process.env.USERNAME ?? process.env.USER ?? 'unknown'}`
|
|
25
|
+
: `uid:${uid}`)
|
|
26
|
+
const graphicalSessionId =
|
|
27
|
+
process.env.CROSSHANDS_GRAPHICAL_SESSION_ID ??
|
|
28
|
+
process.env.XDG_SESSION_ID ??
|
|
29
|
+
process.env.SECURITYSESSIONID ??
|
|
30
|
+
process.env.SESSIONNAME ??
|
|
31
|
+
`interactive:${osIdentity}`
|
|
32
|
+
const sessionKey = createHash('sha256').update(graphicalSessionId).digest('hex').slice(0, 12)
|
|
33
|
+
const runtimeDirectory = process.env.CROSSHANDS_RUNTIME_DIR ?? defaultRuntimeDirectory(sessionKey)
|
|
34
|
+
const identity = { osIdentity, graphicalSessionId }
|
|
35
|
+
return {
|
|
36
|
+
identity,
|
|
37
|
+
runtimeDirectory,
|
|
38
|
+
tokenFile: join(runtimeDirectory, 'control.token'),
|
|
39
|
+
endpoint: brokerEndpoint({
|
|
40
|
+
platform: process.platform,
|
|
41
|
+
osIdentity,
|
|
42
|
+
graphicalSessionId,
|
|
43
|
+
...(process.platform === 'win32' ? {} : { runtimeDirectory })
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultRuntimeDirectory(sessionKey: string): string {
|
|
49
|
+
if (process.platform === 'darwin') {
|
|
50
|
+
return join(homedir(), 'Library', 'Caches', 'CrossHands', 'runtime', sessionKey)
|
|
51
|
+
}
|
|
52
|
+
if (process.platform === 'linux') {
|
|
53
|
+
const xdgRuntimeDirectory = process.env.XDG_RUNTIME_DIR
|
|
54
|
+
if (xdgRuntimeDirectory === undefined || xdgRuntimeDirectory.length === 0) {
|
|
55
|
+
throw createComputerError(
|
|
56
|
+
'session_unavailable',
|
|
57
|
+
'XDG_RUNTIME_DIR is required for a protected CrossHands broker endpoint'
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
return join(xdgRuntimeDirectory, 'crosshands', sessionKey)
|
|
61
|
+
}
|
|
62
|
+
const localAppData = process.env.LOCALAPPDATA
|
|
63
|
+
if (localAppData === undefined || localAppData.length === 0) {
|
|
64
|
+
throw createComputerError(
|
|
65
|
+
'session_unavailable',
|
|
66
|
+
'LOCALAPPDATA is required for CrossHands runtime state on Windows'
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return join(localAppData, 'CrossHands', 'runtime', sessionKey)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function prepareRuntimeDirectory(path: string): Promise<void> {
|
|
73
|
+
await mkdir(path, { recursive: true, mode: 0o700 })
|
|
74
|
+
const info = await lstat(path)
|
|
75
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
76
|
+
throw createComputerError('provider_unavailable', 'CrossHands runtime path is unsafe')
|
|
77
|
+
if (process.getuid !== undefined && info.uid !== process.getuid())
|
|
78
|
+
throw createComputerError('provider_unavailable', 'CrossHands runtime path has another owner')
|
|
79
|
+
await chmod(path, 0o700)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readSecureToken(path: string): Promise<string> {
|
|
83
|
+
const info = await lstat(path)
|
|
84
|
+
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0)
|
|
85
|
+
throw createComputerError('provider_unavailable', 'CrossHands broker token file is unsafe')
|
|
86
|
+
if (process.getuid !== undefined && info.uid !== process.getuid())
|
|
87
|
+
throw createComputerError('provider_unavailable', 'CrossHands broker token has another owner')
|
|
88
|
+
return (await readFile(path, 'utf8')).trim()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function connect(paths: LocalClientPaths): Promise<LocalControlClient> {
|
|
92
|
+
const token = paths.endpoint.transport === 'unix' ? await readSecureToken(paths.tokenFile) : ''
|
|
93
|
+
return LocalControlClient.connect({
|
|
94
|
+
endpoint: paths.endpoint,
|
|
95
|
+
token,
|
|
96
|
+
versions: CONTRACT_VERSIONS,
|
|
97
|
+
identity: paths.identity
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type ProductionClientOptions = {
|
|
102
|
+
entrypoint?: string
|
|
103
|
+
readinessMs?: number
|
|
104
|
+
spawnBroker?: (entrypoint: string) => void | Promise<void>
|
|
105
|
+
paths?: LocalClientPaths
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function defaultSpawnBroker(entrypoint: string): void {
|
|
109
|
+
const child = spawn(process.execPath, [entrypoint, 'broker'], {
|
|
110
|
+
detached: true,
|
|
111
|
+
stdio: 'ignore',
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
env: process.env
|
|
114
|
+
})
|
|
115
|
+
child.unref()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function brokerIsAbsent(cause: unknown): boolean {
|
|
119
|
+
if (cause === null || typeof cause !== 'object') return false
|
|
120
|
+
const code = (cause as { code?: unknown }).code
|
|
121
|
+
return code === 'ENOENT' || code === 'ECONNREFUSED'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function createProductionBrokerClient(
|
|
125
|
+
options: ProductionClientOptions = {}
|
|
126
|
+
): Promise<CliBrokerClient> {
|
|
127
|
+
const paths = options.paths ?? localClientPaths()
|
|
128
|
+
await prepareRuntimeDirectory(paths.runtimeDirectory)
|
|
129
|
+
try {
|
|
130
|
+
const control = await connect(paths)
|
|
131
|
+
return controlAdapter(control)
|
|
132
|
+
} catch (cause) {
|
|
133
|
+
if (!brokerIsAbsent(cause)) throw cause
|
|
134
|
+
const rawEntrypoint = options.entrypoint ?? process.argv[1]
|
|
135
|
+
if (rawEntrypoint === undefined)
|
|
136
|
+
throw createComputerError(
|
|
137
|
+
'provider_unavailable',
|
|
138
|
+
'Cannot locate the installed CrossHands entrypoint'
|
|
139
|
+
)
|
|
140
|
+
const entrypoint = isAbsolute(rawEntrypoint) ? rawEntrypoint : resolve(rawEntrypoint)
|
|
141
|
+
await (options.spawnBroker ?? defaultSpawnBroker)(entrypoint)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const deadline = Date.now() + (options.readinessMs ?? 2_500)
|
|
145
|
+
let lastError: unknown
|
|
146
|
+
while (Date.now() < deadline) {
|
|
147
|
+
try {
|
|
148
|
+
// oxlint-disable-next-line no-await-in-loop -- readiness requires ordered retries.
|
|
149
|
+
const control = await connect(paths)
|
|
150
|
+
return controlAdapter(control)
|
|
151
|
+
} catch (cause) {
|
|
152
|
+
lastError = cause
|
|
153
|
+
// Polling is bounded and does not expose token or request data.
|
|
154
|
+
// oxlint-disable-next-line no-await-in-loop -- readiness requires ordered retries.
|
|
155
|
+
await new Promise<void>((resolveDelay) => setTimeout(resolveDelay, 50))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw createComputerError(
|
|
159
|
+
'provider_unavailable',
|
|
160
|
+
'CrossHands broker did not become ready; run `crosshands computer doctor --json`',
|
|
161
|
+
{ cause: lastError instanceof Error ? lastError.message : 'unknown' }
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function controlAdapter(control: LocalControlClient): CliBrokerClient {
|
|
166
|
+
return {
|
|
167
|
+
request: async (operation, input) =>
|
|
168
|
+
control.request({ operation, input }, { deadlineMs: 30_000 }),
|
|
169
|
+
close: () => control.close()
|
|
170
|
+
}
|
|
171
|
+
}
|