@11agents/cli 0.1.38 → 0.1.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@11agents/cli",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "11agents local runtime and telemetry CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -149,6 +149,14 @@ function createRetryState() {
149
149
  return { failures: 0 }
150
150
  }
151
151
 
152
+ function healthFromScan(scan, extra = {}) {
153
+ const base = scan?.health && typeof scan.health === 'object' ? scan.health : {}
154
+ return {
155
+ ...base,
156
+ ...extra,
157
+ }
158
+ }
159
+
152
160
  async function heartbeatRegisteredRuntime(registration, flags, deps) {
153
161
  const config = configFromFlags(flags)
154
162
  const machineKey = registration?.machine?.machine_key || machineOverride(flags) || ''
@@ -158,9 +166,9 @@ async function heartbeatRegisteredRuntime(registration, flags, deps) {
158
166
  body: {
159
167
  machine_key: machineKey,
160
168
  runtime_providers: (registration?.runtimes || []).map(runtime => runtime.provider).filter(Boolean),
161
- health: {
169
+ health: healthFromScan(registration?.local_scan, {
162
170
  heartbeat_at: new Date().toISOString(),
163
- },
171
+ }),
164
172
  },
165
173
  config,
166
174
  })
@@ -254,7 +262,7 @@ export async function registerRuntime(flags = {}, deps = {}) {
254
262
  config,
255
263
  })
256
264
  log(JSON.stringify(result, null, 2))
257
- return result
265
+ return { ...result, local_scan: scan }
258
266
  }
259
267
 
260
268
  export async function heartbeatRuntime(flags = {}, deps = {}) {
@@ -268,9 +276,9 @@ export async function heartbeatRuntime(flags = {}, deps = {}) {
268
276
  body: {
269
277
  machine_key: scan.machine.machine_key,
270
278
  runtime_providers: scan.runtimes.map(runtime => runtime.provider),
271
- health: {
279
+ health: healthFromScan(scan, {
272
280
  heartbeat_at: new Date().toISOString(),
273
- },
281
+ }),
274
282
  },
275
283
  config,
276
284
  })
@@ -0,0 +1,258 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import { access, readFile } from 'node:fs/promises'
4
+ import { constants } from 'node:fs'
5
+ import { execFile } from 'node:child_process'
6
+ import { promisify } from 'node:util'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
+ const CLI_ROOT = path.resolve(__dirname, '..')
13
+ const BUNDLED_DEVICES_CSV = path.join(CLI_ROOT, 'mobile-runtime', 'data-templates', 'devices.example.csv')
14
+
15
+ export const DEVICE_IDENTITY_PROPS = [
16
+ 'ro.serialno',
17
+ 'ro.boot.serialno',
18
+ 'ro.vendor.boot.serialno',
19
+ 'ro.product.serialno',
20
+ ]
21
+
22
+ function defaultMobileHome(env = process.env, homeDir = os.homedir()) {
23
+ return path.resolve(env.ELEVENAGENTS_MOBILE_HOME || path.join(homeDir, '.11agents', 'mobile'))
24
+ }
25
+
26
+ function cleanIdentity(value = '') {
27
+ return String(value || '').trim().toLowerCase()
28
+ }
29
+
30
+ function cleanStatus(value = '') {
31
+ return String(value || '').trim().toLowerCase()
32
+ }
33
+
34
+ function isActiveRegistryRow(row) {
35
+ const status = cleanStatus(row.status || 'active')
36
+ return status !== 'inactive' && status !== 'disabled'
37
+ }
38
+
39
+ function deviceSortKey(device) {
40
+ const match = /^D(\d{2})$/i.exec(device.device_id || '')
41
+ if (match) return Number(match[1])
42
+ const fallback = String(device.device_id || device.adb_transport || device.adb_serial || device.hardware_id || '')
43
+ return 1000 + (fallback.charCodeAt(0) || 0)
44
+ }
45
+
46
+ function parseCsvLine(line = '') {
47
+ const out = []
48
+ let value = ''
49
+ let quoted = false
50
+ for (let i = 0; i < line.length; i += 1) {
51
+ const ch = line[i]
52
+ if (ch === '"') {
53
+ if (quoted && line[i + 1] === '"') {
54
+ value += '"'
55
+ i += 1
56
+ } else {
57
+ quoted = !quoted
58
+ }
59
+ continue
60
+ }
61
+ if (ch === ',' && !quoted) {
62
+ out.push(value)
63
+ value = ''
64
+ continue
65
+ }
66
+ value += ch
67
+ }
68
+ out.push(value)
69
+ return out
70
+ }
71
+
72
+ export function parseDeviceRegistryCsv(text = '') {
73
+ const lines = String(text || '').split(/\r?\n/).filter(line => line.trim().length)
74
+ if (!lines.length) return []
75
+ const headers = parseCsvLine(lines[0])
76
+ return lines.slice(1).map(line => {
77
+ const values = parseCsvLine(line)
78
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']))
79
+ })
80
+ }
81
+
82
+ async function canExecute(filePath, accessFn = access) {
83
+ try {
84
+ await accessFn(filePath, constants.X_OK)
85
+ return true
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ export async function resolveAdbBin({ env = process.env, homeDir = os.homedir(), access: accessFn = access } = {}) {
92
+ if (env.ADB_BIN && await canExecute(env.ADB_BIN, accessFn)) return env.ADB_BIN
93
+
94
+ const pathValue = env.PATH || ''
95
+ for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
96
+ const candidate = path.join(dir, process.platform === 'win32' ? 'adb.exe' : 'adb')
97
+ if (await canExecute(candidate, accessFn)) return candidate
98
+ }
99
+
100
+ const candidates = [
101
+ path.join(homeDir, 'Library', 'Android', 'sdk', 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb'),
102
+ '/usr/local/bin/adb',
103
+ '/opt/homebrew/bin/adb',
104
+ ]
105
+ for (const candidate of candidates) {
106
+ if (await canExecute(candidate, accessFn)) return candidate
107
+ }
108
+
109
+ return ''
110
+ }
111
+
112
+ async function loadDeviceRegistry({ env, homeDir, devicesCsvPath, readFile: read = readFile } = {}) {
113
+ const preferred = devicesCsvPath || path.join(defaultMobileHome(env, homeDir), 'data', 'devices.csv')
114
+ for (const candidate of [preferred, BUNDLED_DEVICES_CSV]) {
115
+ try {
116
+ const content = await read(candidate, 'utf8')
117
+ return parseDeviceRegistryCsv(content).filter(isActiveRegistryRow)
118
+ } catch {}
119
+ }
120
+ return []
121
+ }
122
+
123
+ export function parseAdbDevices(text = '') {
124
+ const devices = []
125
+ for (const line of String(text || '').split(/\r?\n/)) {
126
+ const trimmed = line.trim()
127
+ if (!trimmed || /^List of devices attached/i.test(trimmed)) continue
128
+ const parts = trimmed.split(/\s+/)
129
+ if (!parts[0]) continue
130
+ devices.push({
131
+ transport: parts[0],
132
+ state: parts[1] || 'unknown',
133
+ })
134
+ }
135
+ return devices
136
+ }
137
+
138
+ async function adbExec(exec, adbBin, args, timeout = 5000) {
139
+ return exec(adbBin, args, { timeout })
140
+ }
141
+
142
+ async function readDeviceHardwareId({ exec, adbBin, transport }) {
143
+ for (const prop of DEVICE_IDENTITY_PROPS) {
144
+ try {
145
+ const { stdout } = await adbExec(exec, adbBin, ['-s', transport, 'shell', 'getprop', prop], 2500)
146
+ const value = String(stdout || '').trim()
147
+ const clean = cleanIdentity(value)
148
+ if (clean && !['unknown', 'null', 'none'].includes(clean)) return value
149
+ } catch {}
150
+ }
151
+ return ''
152
+ }
153
+
154
+ function registryIdentityCandidates(row) {
155
+ return [
156
+ row.hardware_id,
157
+ row.physical_id,
158
+ row.adb_serial,
159
+ ].map(cleanIdentity).filter(Boolean)
160
+ }
161
+
162
+ function publicDeviceRow(row, transport, hardwareId) {
163
+ return {
164
+ device_id: String(row.device_id || ''),
165
+ hardware_id: String(row.hardware_id || row.physical_id || hardwareId || row.adb_serial || ''),
166
+ adb_serial: String(row.adb_serial || ''),
167
+ adb_transport: transport.transport,
168
+ adb_state: transport.state,
169
+ online: transport.state === 'device',
170
+ }
171
+ }
172
+
173
+ function unknownDeviceRow(transport, hardwareId) {
174
+ return {
175
+ device_id: '',
176
+ hardware_id: hardwareId || '',
177
+ adb_serial: '',
178
+ adb_transport: transport.transport,
179
+ adb_state: transport.state,
180
+ online: transport.state === 'device',
181
+ }
182
+ }
183
+
184
+ export async function scanConnectedMobileDevices({
185
+ env = process.env,
186
+ homeDir = os.homedir(),
187
+ devicesCsvPath,
188
+ execFile: exec = execFileAsync,
189
+ access: accessFn = access,
190
+ readFile: read = readFile,
191
+ } = {}) {
192
+ const adbBin = await resolveAdbBin({ env, homeDir, access: accessFn })
193
+ if (!adbBin) {
194
+ return {
195
+ devices: [],
196
+ scan: { status: 'adb_missing', scanned_at: new Date().toISOString() },
197
+ }
198
+ }
199
+
200
+ let transports = []
201
+ try {
202
+ const { stdout } = await adbExec(exec, adbBin, ['devices'], 5000)
203
+ transports = parseAdbDevices(stdout)
204
+ } catch (error) {
205
+ return {
206
+ devices: [],
207
+ scan: {
208
+ status: 'adb_failed',
209
+ scanned_at: new Date().toISOString(),
210
+ error: error instanceof Error ? error.message : String(error),
211
+ },
212
+ }
213
+ }
214
+
215
+ const registry = await loadDeviceRegistry({ env, homeDir, devicesCsvPath, readFile: read })
216
+ const transportDetails = []
217
+ const transportByIdentity = new Map()
218
+ for (const transport of transports) {
219
+ const hardwareId = transport.state === 'device'
220
+ ? await readDeviceHardwareId({ exec, adbBin, transport: transport.transport })
221
+ : ''
222
+ const detail = { ...transport, hardware_id: hardwareId }
223
+ transportDetails.push(detail)
224
+ for (const identity of [transport.transport, hardwareId].map(cleanIdentity).filter(Boolean)) {
225
+ if (!transportByIdentity.has(identity)) transportByIdentity.set(identity, detail)
226
+ }
227
+ }
228
+
229
+ const matchedTransports = new Set()
230
+ const devices = []
231
+ for (const row of registry) {
232
+ let match = null
233
+ for (const identity of registryIdentityCandidates(row)) {
234
+ match = transportByIdentity.get(identity)
235
+ if (match) break
236
+ }
237
+ if (!match) continue
238
+ matchedTransports.add(match.transport)
239
+ devices.push(publicDeviceRow(row, match, match.hardware_id))
240
+ }
241
+
242
+ for (const transport of transportDetails) {
243
+ if (!matchedTransports.has(transport.transport)) {
244
+ devices.push(unknownDeviceRow(transport, transport.hardware_id))
245
+ }
246
+ }
247
+
248
+ devices.sort((a, b) => deviceSortKey(a) - deviceSortKey(b))
249
+ return {
250
+ devices,
251
+ scan: {
252
+ status: 'ok',
253
+ scanned_at: new Date().toISOString(),
254
+ adb_path: adbBin,
255
+ connected_count: devices.length,
256
+ },
257
+ }
258
+ }
@@ -4,6 +4,7 @@ import { access } from 'node:fs/promises'
4
4
  import { constants } from 'node:fs'
5
5
  import { execFile } from 'node:child_process'
6
6
  import { promisify } from 'node:util'
7
+ import { scanConnectedMobileDevices } from './mobile-device-scan.js'
7
8
 
8
9
  const execFileAsync = promisify(execFile)
9
10
 
@@ -69,6 +70,7 @@ export async function buildRuntimeScan({
69
70
  cliVersion = 'dev',
70
71
  lookupExecutable: lookup = lookupExecutable,
71
72
  readVersion = readRuntimeVersion,
73
+ scanMobileDevices = scanConnectedMobileDevices,
72
74
  } = {}) {
73
75
  const host = hostname()
74
76
  const runtimes = []
@@ -97,6 +99,22 @@ export async function buildRuntimeScan({
97
99
  })
98
100
  }
99
101
 
102
+ const health = {
103
+ scan_at: new Date().toISOString(),
104
+ }
105
+ try {
106
+ const mobile = await scanMobileDevices({ env })
107
+ health.mobile_devices = Array.isArray(mobile?.devices) ? mobile.devices : []
108
+ if (mobile?.scan) health.mobile_device_scan = mobile.scan
109
+ } catch (error) {
110
+ health.mobile_devices = []
111
+ health.mobile_device_scan = {
112
+ status: 'failed',
113
+ scanned_at: new Date().toISOString(),
114
+ error: error instanceof Error ? error.message : String(error),
115
+ }
116
+ }
117
+
100
118
  return {
101
119
  machine: {
102
120
  machine_key: machineKeyFromEnv(env, host),
@@ -107,8 +125,6 @@ export async function buildRuntimeScan({
107
125
  capabilities: ['local_runtime'],
108
126
  },
109
127
  runtimes,
110
- health: {
111
- scan_at: new Date().toISOString(),
112
- },
128
+ health,
113
129
  }
114
130
  }