idlewatch 0.1.1 → 0.1.2
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 +12 -11
- package/bin/idlewatch-agent.js +69 -20
- package/package.json +12 -1
- package/scripts/install-macos-launch-agent.sh +1 -0
- package/scripts/validate-onboarding.mjs +57 -22
- package/src/enrollment.js +48 -7
- package/tui/src/main.rs +248 -17
- package/.github/workflows/ci.yml +0 -99
- package/.github/workflows/release-macos-trusted.yml +0 -103
- package/docs/onboarding-external.md +0 -58
- package/docs/packaging/macos-dmg.md +0 -199
- package/docs/packaging/macos-launch-agent.md +0 -70
- package/docs/qa/archive/mac-qa-log-2026-02-17.md +0 -5838
- package/docs/qa/mac-qa-log.md +0 -2864
- package/docs/telemetry/idle-stale-policy.md +0 -57
- package/docs/telemetry/openclaw-mapping.md +0 -80
- package/test/config.test.mjs +0 -112
- package/test/fixtures/gpu-agx.txt +0 -2
- package/test/fixtures/gpu-iogpu.txt +0 -2
- package/test/fixtures/gpu-top-grep.txt +0 -2
- package/test/fixtures/openclaw-fleet-sample-v1.json +0 -68
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +0 -2
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +0 -2
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +0 -2
- package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +0 -2
- package/test/fixtures/openclaw-stats-current-wrapper.json +0 -12
- package/test/fixtures/openclaw-stats-current-wrapper2.json +0 -15
- package/test/fixtures/openclaw-stats-data-wrapper.json +0 -21
- package/test/fixtures/openclaw-stats-nested-session-wrapper.json +0 -23
- package/test/fixtures/openclaw-stats-payload-wrapper.json +0 -1
- package/test/fixtures/openclaw-stats-status-current-wrapper.json +0 -19
- package/test/fixtures/openclaw-stats.json +0 -17
- package/test/fixtures/openclaw-status-ansi-complex-noise.txt +0 -3
- package/test/fixtures/openclaw-status-ansi-noise.txt +0 -2
- package/test/fixtures/openclaw-status-control-noise.txt +0 -1
- package/test/fixtures/openclaw-status-data-wrapper.json +0 -20
- package/test/fixtures/openclaw-status-dcs-noise.txt +0 -1
- package/test/fixtures/openclaw-status-epoch-seconds.json +0 -15
- package/test/fixtures/openclaw-status-mixed-noise.txt +0 -1
- package/test/fixtures/openclaw-status-multi-json.txt +0 -3
- package/test/fixtures/openclaw-status-nested-recent.json +0 -19
- package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +0 -2
- package/test/fixtures/openclaw-status-noisy.txt +0 -3
- package/test/fixtures/openclaw-status-osc-noise.txt +0 -1
- package/test/fixtures/openclaw-status-result-session.json +0 -15
- package/test/fixtures/openclaw-status-session-map-with-defaults.json +0 -23
- package/test/fixtures/openclaw-status-session-map.json +0 -28
- package/test/fixtures/openclaw-status-session-model-name.json +0 -18
- package/test/fixtures/openclaw-status-snake-session-wrapper.json +0 -13
- package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +0 -25
- package/test/fixtures/openclaw-status-stats-current-sessions.json +0 -28
- package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +0 -19
- package/test/fixtures/openclaw-status-stats-session-default-model.json +0 -27
- package/test/fixtures/openclaw-status-status-wrapper.json +0 -13
- package/test/fixtures/openclaw-status-strings.json +0 -38
- package/test/fixtures/openclaw-status-ts-ms-alias.json +0 -14
- package/test/fixtures/openclaw-status-updated-at-ms-alias.json +0 -14
- package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +0 -14
- package/test/fixtures/openclaw-status-usage-ts-alias.json +0 -14
- package/test/fixtures/openclaw-status-wrap-session-object.json +0 -24
- package/test/fixtures/openclaw-status.json +0 -41
- package/test/fixtures/openclaw-usage-model-name-generic.json +0 -9
- package/test/gpu.test.mjs +0 -58
- package/test/memory.test.mjs +0 -35
- package/test/openclaw-cache.test.mjs +0 -48
- package/test/openclaw-env.test.mjs +0 -365
- package/test/openclaw-usage.test.mjs +0 -555
- package/test/telemetry-mapping.test.mjs +0 -69
- package/test/telemetry-row-parser.test.mjs +0 -44
- package/test/usage-alert.test.mjs +0 -73
- package/test/usage-freshness.test.mjs +0 -63
- package/test/validate-dry-run-schema.test.mjs +0 -146
package/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
# idlewatch
|
|
1
|
+
# idlewatch
|
|
2
2
|
|
|
3
3
|
Telemetry collector for IdleWatch.
|
|
4
4
|
|
|
5
5
|
## Install / Run
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install
|
|
9
|
-
|
|
8
|
+
npm install -g idlewatch
|
|
9
|
+
idlewatch --help
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Or run it directly with npx:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
npx idlewatch
|
|
16
|
-
npx idlewatch
|
|
17
|
-
npx idlewatch
|
|
15
|
+
npx idlewatch --help
|
|
16
|
+
npx idlewatch quickstart
|
|
17
|
+
npx idlewatch --dry-run
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
`quickstart` runs a first-run setup wizard that writes a local env file and (for production mode) stores a locked-down copy of the service-account key under `~/.idlewatch/`. On hosts with Rust/Cargo available, quickstart launches a ratatui-powered onboarding flow first; otherwise it falls back to the text wizard.
|
|
@@ -54,7 +54,7 @@ Use `gpuSource` + `gpuConfidence` in dashboards to decide whether to trust value
|
|
|
54
54
|
### Recommended: guided enrollment (external users)
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
npx idlewatch
|
|
57
|
+
npx idlewatch quickstart
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
The wizard supports:
|
|
@@ -62,13 +62,14 @@ The wizard supports:
|
|
|
62
62
|
- **Emulator mode**: writes `FIREBASE_PROJECT_ID` + `FIRESTORE_EMULATOR_HOST` only.
|
|
63
63
|
- **Local-only mode**: writes no Firebase credentials.
|
|
64
64
|
|
|
65
|
-
Then
|
|
65
|
+
Then run a one-shot publish check:
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
|
-
|
|
69
|
-
idlewatch-agent --once
|
|
68
|
+
idlewatch --once
|
|
70
69
|
```
|
|
71
70
|
|
|
71
|
+
The saved config is auto-loaded from `~/.idlewatch/idlewatch.env`, so you should not need to manually source the env file in normal use.
|
|
72
|
+
|
|
72
73
|
### Manual wiring
|
|
73
74
|
|
|
74
75
|
```bash
|
package/bin/idlewatch-agent.js
CHANGED
|
@@ -18,7 +18,7 @@ import { enrichWithOpenClawFleetTelemetry } from '../src/telemetry-mapping.js'
|
|
|
18
18
|
import pkg from '../package.json' with { type: 'json' }
|
|
19
19
|
|
|
20
20
|
function printHelp() {
|
|
21
|
-
console.log(`idlewatch
|
|
21
|
+
console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|dashboard|run] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup TUI and save local IdleWatch config\n configure Alias for quickstart; reopen setup to change device name, API key, or metrics\n dashboard Launch local dashboard from local IdleWatch logs\n run Start the background collector using saved local config\n --dry-run Collect and print one telemetry sample, then exit without cloud/Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_DASHBOARD_PORT Local dashboard HTTP port (default: 4373)\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const require = createRequire(import.meta.url)
|
|
@@ -38,6 +38,41 @@ function parseEnvFileToObject(envFilePath) {
|
|
|
38
38
|
return env
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function expandSupportedPathVars(value) {
|
|
42
|
+
if (typeof value !== 'string' || !value) return value
|
|
43
|
+
|
|
44
|
+
const home = process.env.HOME || os.homedir()
|
|
45
|
+
const tmpdir = process.env.TMPDIR || os.tmpdir()
|
|
46
|
+
|
|
47
|
+
return value
|
|
48
|
+
.replace(/^~(?=$|\/)/, home)
|
|
49
|
+
.replace(/\$\{HOME\}|\$HOME/g, home)
|
|
50
|
+
.replace(/\$\{TMPDIR\}|\$TMPDIR/g, tmpdir)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveEnvPath(value) {
|
|
54
|
+
return path.resolve(expandSupportedPathVars(value))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadPersistedEnvIntoProcess() {
|
|
58
|
+
const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
|
|
59
|
+
if (!fs.existsSync(envFile)) return null
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const parsed = parseEnvFileToObject(envFile)
|
|
63
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
64
|
+
if (process.env[key]) continue
|
|
65
|
+
process.env[key] = key.endsWith('_PATH') ? expandSupportedPathVars(value) : value
|
|
66
|
+
}
|
|
67
|
+
return { envFile, parsed }
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error(`Failed to load persisted IdleWatch config from ${envFile}: ${error.message}`)
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const persistedEnv = loadPersistedEnvIntoProcess()
|
|
75
|
+
|
|
41
76
|
function parseMonitorTargets(raw) {
|
|
42
77
|
const allowed = new Set(['cpu', 'memory', 'gpu', 'openclaw'])
|
|
43
78
|
const fallback = ['cpu', 'memory', 'openclaw', 'gpu']
|
|
@@ -70,7 +105,7 @@ function formatBytes(bytes) {
|
|
|
70
105
|
|
|
71
106
|
function resolveDashboardLogPath(host) {
|
|
72
107
|
if (process.env.IDLEWATCH_LOCAL_LOG_PATH) {
|
|
73
|
-
return
|
|
108
|
+
return resolveEnvPath(process.env.IDLEWATCH_LOCAL_LOG_PATH)
|
|
74
109
|
}
|
|
75
110
|
|
|
76
111
|
const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
|
|
@@ -78,7 +113,7 @@ function resolveDashboardLogPath(host) {
|
|
|
78
113
|
try {
|
|
79
114
|
const parsed = parseEnvFileToObject(envFile)
|
|
80
115
|
if (parsed.IDLEWATCH_LOCAL_LOG_PATH) {
|
|
81
|
-
return
|
|
116
|
+
return resolveEnvPath(parsed.IDLEWATCH_LOCAL_LOG_PATH)
|
|
82
117
|
}
|
|
83
118
|
} catch {
|
|
84
119
|
// ignore malformed env file and fallback
|
|
@@ -319,9 +354,11 @@ function runLocalDashboard({ host }) {
|
|
|
319
354
|
}
|
|
320
355
|
|
|
321
356
|
const argv = process.argv.slice(2)
|
|
322
|
-
const quickstartRequested = argv[0] === 'quickstart' || argv.includes('--quickstart')
|
|
323
|
-
const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
|
|
324
357
|
const args = new Set(argv)
|
|
358
|
+
const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
|
|
359
|
+
const runRequested = argv[0] === 'run' || argv.includes('--run')
|
|
360
|
+
const interactiveDefaultRequested = argv.length === 0 && process.stdin.isTTY && process.stdout.isTTY
|
|
361
|
+
const quickstartRequested = argv[0] === 'quickstart' || argv[0] === 'configure' || argv.includes('--quickstart') || argv.includes('--configure') || (interactiveDefaultRequested && !dashboardRequested && !runRequested)
|
|
325
362
|
if (args.has('--help') || args.has('-h')) {
|
|
326
363
|
printHelp()
|
|
327
364
|
process.exit(0)
|
|
@@ -336,7 +373,6 @@ if (dashboardRequested) {
|
|
|
336
373
|
if (quickstartRequested) {
|
|
337
374
|
try {
|
|
338
375
|
const result = await runEnrollmentWizard()
|
|
339
|
-
console.log(`Enrollment complete. Mode=${result.mode} envFile=${result.outputEnvFile}`)
|
|
340
376
|
|
|
341
377
|
const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
|
|
342
378
|
const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
|
|
@@ -348,12 +384,15 @@ if (quickstartRequested) {
|
|
|
348
384
|
})
|
|
349
385
|
|
|
350
386
|
if (onceRun.status === 0) {
|
|
351
|
-
console.log(
|
|
387
|
+
console.log(`✅ Setup complete. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
|
|
388
|
+
console.log('Initial telemetry sample sent successfully.')
|
|
352
389
|
process.exit(0)
|
|
353
390
|
}
|
|
354
391
|
|
|
355
|
-
console.
|
|
356
|
-
console.
|
|
392
|
+
console.error(`⚠️ Setup is not finished yet. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
|
|
393
|
+
console.error('The first required telemetry sample did not publish successfully, so this device may not be linked yet.')
|
|
394
|
+
console.error(`Retry with: idlewatch --once`)
|
|
395
|
+
console.error('Or rerun: idlewatch quickstart')
|
|
357
396
|
process.exit(onceRun.status ?? 1)
|
|
358
397
|
} catch (err) {
|
|
359
398
|
console.error(`Enrollment failed: ${err.message}`)
|
|
@@ -363,8 +402,14 @@ if (quickstartRequested) {
|
|
|
363
402
|
|
|
364
403
|
const DRY_RUN = args.has('--dry-run')
|
|
365
404
|
const ONCE = args.has('--once')
|
|
405
|
+
const DEVICE_NAME = (process.env.IDLEWATCH_DEVICE_NAME || process.env.IDLEWATCH_HOST || os.hostname()).trim()
|
|
406
|
+
const DEVICE_ID = (process.env.IDLEWATCH_DEVICE_ID || DEVICE_NAME)
|
|
407
|
+
.trim()
|
|
408
|
+
.toLowerCase()
|
|
409
|
+
.replace(/[^a-z0-9_.-]+/g, '-')
|
|
410
|
+
.replace(/^-+|-+$/g, '') || 'device'
|
|
366
411
|
const HOST = process.env.IDLEWATCH_HOST || os.hostname()
|
|
367
|
-
const SAFE_HOST =
|
|
412
|
+
const SAFE_HOST = DEVICE_ID.replace(/[^a-zA-Z0-9_.-]/g, '_')
|
|
368
413
|
const INTERVAL_MS = Number(process.env.IDLEWATCH_INTERVAL_MS || 10000)
|
|
369
414
|
const PROJECT_ID = process.env.FIREBASE_PROJECT_ID
|
|
370
415
|
const CREDS_FILE = process.env.FIREBASE_SERVICE_ACCOUNT_FILE
|
|
@@ -379,8 +424,8 @@ const MONITOR_GPU = MONITOR_TARGETS.has('gpu')
|
|
|
379
424
|
const MONITOR_OPENCLAW = MONITOR_TARGETS.has('openclaw')
|
|
380
425
|
const EFFECTIVE_OPENCLAW_MODE = MONITOR_OPENCLAW ? OPENCLAW_USAGE_MODE : 'off'
|
|
381
426
|
const REQUIRE_FIREBASE_WRITES = process.env.IDLEWATCH_REQUIRE_FIREBASE_WRITES === '1'
|
|
382
|
-
const CLOUD_INGEST_URL = process.env.IDLEWATCH_CLOUD_INGEST_URL
|
|
383
|
-
const CLOUD_API_KEY = process.env.IDLEWATCH_CLOUD_API_KEY
|
|
427
|
+
const CLOUD_INGEST_URL = (process.env.IDLEWATCH_CLOUD_INGEST_URL || '').trim()
|
|
428
|
+
const CLOUD_API_KEY = (process.env.IDLEWATCH_CLOUD_API_KEY || '').trim().replace(/^['"]|['"]$/g, '')
|
|
384
429
|
const REQUIRE_CLOUD_WRITES = process.env.IDLEWATCH_REQUIRE_CLOUD_WRITES === '1'
|
|
385
430
|
let cloudIngestKickedOut = false
|
|
386
431
|
let cloudIngestKickoutReason = null
|
|
@@ -399,7 +444,7 @@ const OPENCLAW_PROBE_RETRIES = process.env.IDLEWATCH_OPENCLAW_PROBE_RETRIES
|
|
|
399
444
|
const BASE_DIR = path.join(os.homedir(), '.idlewatch')
|
|
400
445
|
|
|
401
446
|
const LOCAL_LOG_PATH = process.env.IDLEWATCH_LOCAL_LOG_PATH
|
|
402
|
-
?
|
|
447
|
+
? resolveEnvPath(process.env.IDLEWATCH_LOCAL_LOG_PATH)
|
|
403
448
|
: path.join(BASE_DIR, 'logs', `${SAFE_HOST}-metrics.ndjson`)
|
|
404
449
|
|
|
405
450
|
if (!Number.isFinite(INTERVAL_MS) || INTERVAL_MS <= 0) {
|
|
@@ -522,7 +567,7 @@ if (process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS && (!Number.isFinite(OPE
|
|
|
522
567
|
}
|
|
523
568
|
|
|
524
569
|
const OPENCLAW_LAST_GOOD_CACHE_PATH = process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH
|
|
525
|
-
?
|
|
570
|
+
? resolveEnvPath(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
|
|
526
571
|
: path.join(BASE_DIR, 'cache', `${SAFE_HOST}-openclaw-last-good.json`)
|
|
527
572
|
|
|
528
573
|
let appReady = false
|
|
@@ -579,9 +624,9 @@ if (firebaseConfigError) {
|
|
|
579
624
|
process.exit(1)
|
|
580
625
|
}
|
|
581
626
|
|
|
582
|
-
if (!appReady) {
|
|
627
|
+
if (!appReady && !(CLOUD_INGEST_URL && CLOUD_API_KEY)) {
|
|
583
628
|
console.error(
|
|
584
|
-
'Firebase is not configured. Running
|
|
629
|
+
'Firebase is not configured. Running without Firebase writes. Set FIREBASE_PROJECT_ID + FIREBASE_SERVICE_ACCOUNT_FILE (preferred, or FIREBASE_SERVICE_ACCOUNT_JSON / FIREBASE_SERVICE_ACCOUNT_B64), use FIREBASE_PROJECT_ID + FIRESTORE_EMULATOR_HOST for emulator writes, or configure cloud ingest via idlewatch setup.'
|
|
585
630
|
)
|
|
586
631
|
}
|
|
587
632
|
|
|
@@ -1276,6 +1321,10 @@ async function collectSample() {
|
|
|
1276
1321
|
|
|
1277
1322
|
const row = {
|
|
1278
1323
|
host: HOST,
|
|
1324
|
+
hostId: HOST,
|
|
1325
|
+
hostName: HOST,
|
|
1326
|
+
deviceId: DEVICE_ID,
|
|
1327
|
+
deviceName: DEVICE_NAME,
|
|
1279
1328
|
ts: sampleAtMs,
|
|
1280
1329
|
cpuPct: MONITOR_CPU ? cpuPct() : null,
|
|
1281
1330
|
memPct: MONITOR_MEMORY ? usedMemPct : null,
|
|
@@ -1360,10 +1409,10 @@ async function gracefulShutdown(signal) {
|
|
|
1360
1409
|
if (stopped) return
|
|
1361
1410
|
stopped = true
|
|
1362
1411
|
if (inflightTick) {
|
|
1363
|
-
console.log(`idlewatch
|
|
1412
|
+
console.log(`idlewatch received ${signal}, waiting for in-flight sample…`)
|
|
1364
1413
|
try { await inflightTick } catch { /* already logged */ }
|
|
1365
1414
|
}
|
|
1366
|
-
console.log('idlewatch
|
|
1415
|
+
console.log('idlewatch stopped')
|
|
1367
1416
|
process.exit(0)
|
|
1368
1417
|
}
|
|
1369
1418
|
|
|
@@ -1373,7 +1422,7 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
|
|
1373
1422
|
if (DRY_RUN || ONCE) {
|
|
1374
1423
|
const mode = DRY_RUN ? 'dry-run' : 'once'
|
|
1375
1424
|
console.log(
|
|
1376
|
-
`idlewatch
|
|
1425
|
+
`idlewatch ${mode} host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH} env=${persistedEnv?.envFile || 'process'}`
|
|
1377
1426
|
)
|
|
1378
1427
|
tick()
|
|
1379
1428
|
.then(() => process.exit(0))
|
|
@@ -1383,7 +1432,7 @@ if (DRY_RUN || ONCE) {
|
|
|
1383
1432
|
})
|
|
1384
1433
|
} else {
|
|
1385
1434
|
console.log(
|
|
1386
|
-
`idlewatch
|
|
1435
|
+
`idlewatch started host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH} monitorTargets=${[...MONITOR_TARGETS].join(',')} openclawUsage=${EFFECTIVE_OPENCLAW_MODE} env=${persistedEnv?.envFile || 'process'}`
|
|
1387
1436
|
)
|
|
1388
1437
|
loop()
|
|
1389
1438
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idlewatch",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Host telemetry collector for IdleWatch",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"bin",
|
|
8
|
+
"src",
|
|
9
|
+
"scripts",
|
|
10
|
+
"skill",
|
|
11
|
+
"tui/src",
|
|
12
|
+
"tui/Cargo.toml",
|
|
13
|
+
"tui/Cargo.lock",
|
|
14
|
+
"README.md",
|
|
15
|
+
".env.example"
|
|
16
|
+
],
|
|
6
17
|
"bin": {
|
|
7
18
|
"idlewatch": "bin/idlewatch-agent.js",
|
|
8
19
|
"idlewatch-agent": "bin/idlewatch-agent.js",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import http from 'node:http'
|
|
2
3
|
import os from 'node:os'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { spawnSync } from 'node:child_process'
|
|
@@ -7,31 +8,51 @@ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '
|
|
|
7
8
|
const binPath = path.join(repoRoot, 'bin', 'idlewatch-agent.js')
|
|
8
9
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'idlewatch-onboarding-'))
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
const requests = []
|
|
12
|
+
const server = http.createServer((req, res) => {
|
|
13
|
+
let body = ''
|
|
14
|
+
req.on('data', (chunk) => {
|
|
15
|
+
body += chunk
|
|
16
|
+
})
|
|
17
|
+
req.on('end', () => {
|
|
18
|
+
requests.push({
|
|
19
|
+
method: req.method,
|
|
20
|
+
url: req.url,
|
|
21
|
+
headers: req.headers,
|
|
22
|
+
body
|
|
21
23
|
})
|
|
22
|
-
|
|
24
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
25
|
+
res.end(JSON.stringify({ ok: true }))
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
server.listen(0, '127.0.0.1', () => resolve())
|
|
31
|
+
server.once('error', reject)
|
|
32
|
+
})
|
|
23
33
|
|
|
34
|
+
const address = server.address()
|
|
35
|
+
const cloudIngestUrl = `http://127.0.0.1:${address.port}/api/ingest`
|
|
36
|
+
|
|
37
|
+
try {
|
|
24
38
|
const envOut = path.join(tmpRoot, 'generated.env')
|
|
25
39
|
const configDir = path.join(tmpRoot, 'config')
|
|
40
|
+
const localLogPath = path.join(configDir, 'logs', 'validator-box-metrics.ndjson')
|
|
41
|
+
const lastGoodCachePath = path.join(configDir, 'cache', 'validator-box-openclaw-last-good.json')
|
|
42
|
+
|
|
26
43
|
const run = spawnSync(process.execPath, [binPath, 'quickstart'], {
|
|
27
44
|
env: {
|
|
28
45
|
...process.env,
|
|
29
46
|
IDLEWATCH_ENROLL_NON_INTERACTIVE: '1',
|
|
30
47
|
IDLEWATCH_ENROLL_MODE: 'production',
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
IDLEWATCH_CLOUD_API_KEY: 'iwk_abcdefghijklmnopqrstuvwxyz123456',
|
|
49
|
+
IDLEWATCH_CLOUD_INGEST_URL: cloudIngestUrl,
|
|
33
50
|
IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: envOut,
|
|
34
|
-
IDLEWATCH_ENROLL_CONFIG_DIR: configDir
|
|
51
|
+
IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
|
|
52
|
+
IDLEWATCH_DEVICE_NAME: 'Validator Box',
|
|
53
|
+
IDLEWATCH_DEVICE_ID: 'validator-box',
|
|
54
|
+
IDLEWATCH_MONITOR_TARGETS: 'cpu,memory',
|
|
55
|
+
IDLEWATCH_OPENCLAW_USAGE: 'off'
|
|
35
56
|
},
|
|
36
57
|
encoding: 'utf8'
|
|
37
58
|
})
|
|
@@ -45,19 +66,33 @@ try {
|
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
const envContent = fs.readFileSync(envOut, 'utf8')
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
for (const requiredLine of [
|
|
70
|
+
'IDLEWATCH_DEVICE_NAME=Validator Box',
|
|
71
|
+
'IDLEWATCH_DEVICE_ID=validator-box',
|
|
72
|
+
'IDLEWATCH_MONITOR_TARGETS=cpu,memory',
|
|
73
|
+
'IDLEWATCH_OPENCLAW_USAGE=off',
|
|
74
|
+
`IDLEWATCH_LOCAL_LOG_PATH=${localLogPath}`,
|
|
75
|
+
`IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH=${lastGoodCachePath}`,
|
|
76
|
+
`IDLEWATCH_CLOUD_INGEST_URL=${cloudIngestUrl}`,
|
|
77
|
+
'IDLEWATCH_CLOUD_API_KEY=iwk_abcdefghijklmnopqrstuvwxyz123456',
|
|
78
|
+
'IDLEWATCH_REQUIRE_CLOUD_WRITES=1'
|
|
79
|
+
]) {
|
|
80
|
+
if (!envContent.includes(requiredLine)) {
|
|
81
|
+
throw new Error(`env file missing ${requiredLine}`)
|
|
82
|
+
}
|
|
50
83
|
}
|
|
51
|
-
|
|
52
|
-
|
|
84
|
+
|
|
85
|
+
if (requests.length === 0) {
|
|
86
|
+
throw new Error('quickstart did not send the initial telemetry sample')
|
|
53
87
|
}
|
|
54
88
|
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
throw new Error('
|
|
89
|
+
const telemetry = JSON.parse(requests[0].body)
|
|
90
|
+
if (telemetry.deviceId !== 'validator-box') {
|
|
91
|
+
throw new Error('initial telemetry sample used unexpected device id')
|
|
58
92
|
}
|
|
59
93
|
|
|
60
94
|
console.log('onboarding validation passed')
|
|
61
95
|
} finally {
|
|
96
|
+
server.close()
|
|
62
97
|
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
|
63
98
|
}
|
package/src/enrollment.js
CHANGED
|
@@ -58,6 +58,39 @@ function normalizeMonitorTargets(raw, available) {
|
|
|
58
58
|
return [...new Set(parsed)]
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function normalizeCloudApiKey(raw) {
|
|
62
|
+
const trimmed = String(raw || '').trim()
|
|
63
|
+
if (!trimmed) return ''
|
|
64
|
+
|
|
65
|
+
const token = trimmed
|
|
66
|
+
.split(/\s+/)
|
|
67
|
+
.find((part) => part.startsWith('iwk_'))
|
|
68
|
+
|
|
69
|
+
if (token) {
|
|
70
|
+
return token.replace(/^['"]|['",]$/g, '')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return trimmed.replace(/^['"]|['"]$/g, '')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function looksLikeCloudApiKey(value) {
|
|
77
|
+
return /^iwk_[A-Za-z0-9_-]{20,}$/.test(String(value || '').trim())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeDeviceName(raw, fallback = os.hostname()) {
|
|
81
|
+
const value = String(raw || '').trim().replace(/\s+/g, ' ')
|
|
82
|
+
return value || fallback
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sanitizeDeviceId(raw, fallback = os.hostname()) {
|
|
86
|
+
const base = normalizeDeviceName(raw, fallback).toLowerCase()
|
|
87
|
+
const sanitized = base
|
|
88
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
89
|
+
.replace(/^-+|-+$/g, '')
|
|
90
|
+
.slice(0, 80)
|
|
91
|
+
return sanitized || normalizeDeviceName(fallback).replace(/[^a-zA-Z0-9_.-]/g, '_')
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
function tryRustTui({ configDir, outputEnvFile }) {
|
|
62
95
|
const disabled = process.env.IDLEWATCH_DISABLE_RUST_TUI === '1'
|
|
63
96
|
if (disabled) return false
|
|
@@ -94,8 +127,9 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
94
127
|
const outputEnvFile = path.resolve(options.outputEnvFile || process.env.IDLEWATCH_ENROLL_OUTPUT_ENV_FILE || path.join(configDir, 'idlewatch.env'))
|
|
95
128
|
|
|
96
129
|
let mode = options.mode || process.env.IDLEWATCH_ENROLL_MODE || null
|
|
97
|
-
let cloudApiKey = options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null
|
|
130
|
+
let cloudApiKey = normalizeCloudApiKey(options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null)
|
|
98
131
|
let cloudIngestUrl = options.cloudIngestUrl || process.env.IDLEWATCH_CLOUD_INGEST_URL || 'https://api.idlewatch.com/api/ingest'
|
|
132
|
+
let deviceName = normalizeDeviceName(options.deviceName || process.env.IDLEWATCH_DEVICE_NAME || os.hostname())
|
|
99
133
|
|
|
100
134
|
const availableMonitorTargets = detectAvailableMonitorTargets()
|
|
101
135
|
let monitorTargets = normalizeMonitorTargets(
|
|
@@ -119,6 +153,8 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
119
153
|
console.log(`Environment file: ${outputEnvFile}`)
|
|
120
154
|
const modeInput = (await rl.question('\nMode [1/2] (default 1): ')).trim() || '1'
|
|
121
155
|
mode = modeInput === '2' ? 'local' : 'production'
|
|
156
|
+
const deviceNameInput = (await rl.question(`Device name [${deviceName}]: `)).trim()
|
|
157
|
+
deviceName = normalizeDeviceName(deviceNameInput || deviceName)
|
|
122
158
|
}
|
|
123
159
|
|
|
124
160
|
if (!mode) mode = 'production'
|
|
@@ -129,7 +165,7 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
129
165
|
if ((mode === 'production') && !cloudApiKey) {
|
|
130
166
|
if (!rl) throw new Error('Missing cloud API key (IDLEWATCH_CLOUD_API_KEY).')
|
|
131
167
|
console.log('\nPaste the API key from idlewatch.com/api.')
|
|
132
|
-
cloudApiKey = (await rl.question('Cloud API key: '))
|
|
168
|
+
cloudApiKey = normalizeCloudApiKey(await rl.question('Cloud API key: '))
|
|
133
169
|
}
|
|
134
170
|
|
|
135
171
|
if (!nonInteractive && rl) {
|
|
@@ -139,11 +175,14 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
139
175
|
monitorTargets = normalizeMonitorTargets(monitorInput || suggested, availableMonitorTargets)
|
|
140
176
|
}
|
|
141
177
|
|
|
142
|
-
const
|
|
143
|
-
const
|
|
178
|
+
const safeDeviceId = sanitizeDeviceId(options.deviceId || process.env.IDLEWATCH_DEVICE_ID || deviceName, os.hostname())
|
|
179
|
+
const localLogPath = path.join(configDir, 'logs', `${safeDeviceId}-metrics.ndjson`)
|
|
180
|
+
const localCachePath = path.join(configDir, 'cache', `${safeDeviceId}-openclaw-last-good.json`)
|
|
144
181
|
|
|
145
182
|
const envLines = [
|
|
146
183
|
'# Generated by idlewatch-agent quickstart',
|
|
184
|
+
`IDLEWATCH_DEVICE_NAME=${deviceName}`,
|
|
185
|
+
`IDLEWATCH_DEVICE_ID=${safeDeviceId}`,
|
|
147
186
|
`IDLEWATCH_MONITOR_TARGETS=${monitorTargets.join(',')}`,
|
|
148
187
|
`IDLEWATCH_OPENCLAW_USAGE=${monitorTargets.includes('openclaw') ? 'auto' : 'off'}`,
|
|
149
188
|
`IDLEWATCH_LOCAL_LOG_PATH=${localLogPath}`,
|
|
@@ -155,8 +194,8 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
155
194
|
}
|
|
156
195
|
|
|
157
196
|
if (mode === 'production') {
|
|
158
|
-
if (!cloudApiKey) {
|
|
159
|
-
throw new Error('Cloud API key is
|
|
197
|
+
if (!cloudApiKey || !looksLikeCloudApiKey(cloudApiKey)) {
|
|
198
|
+
throw new Error('Cloud API key is invalid. Copy the full key from idlewatch.com/api (starts with iwk_).')
|
|
160
199
|
}
|
|
161
200
|
envLines.push(`IDLEWATCH_CLOUD_INGEST_URL=${cloudIngestUrl}`)
|
|
162
201
|
envLines.push(`IDLEWATCH_CLOUD_API_KEY=${cloudApiKey}`)
|
|
@@ -171,6 +210,8 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
171
210
|
mode,
|
|
172
211
|
configDir,
|
|
173
212
|
outputEnvFile,
|
|
174
|
-
monitorTargets
|
|
213
|
+
monitorTargets,
|
|
214
|
+
deviceName,
|
|
215
|
+
deviceId: safeDeviceId
|
|
175
216
|
}
|
|
176
217
|
}
|