@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.
@@ -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
+ }