idlewatch 0.1.1 → 0.1.3

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.
Files changed (73) hide show
  1. package/README.md +12 -11
  2. package/bin/idlewatch-agent.js +69 -20
  3. package/package.json +12 -1
  4. package/scripts/install-macos-launch-agent.sh +1 -0
  5. package/scripts/validate-onboarding.mjs +88 -32
  6. package/src/config.js +18 -2
  7. package/src/enrollment.js +69 -18
  8. package/tui/src/main.rs +248 -17
  9. package/.github/workflows/ci.yml +0 -99
  10. package/.github/workflows/release-macos-trusted.yml +0 -103
  11. package/docs/onboarding-external.md +0 -58
  12. package/docs/packaging/macos-dmg.md +0 -199
  13. package/docs/packaging/macos-launch-agent.md +0 -70
  14. package/docs/qa/archive/mac-qa-log-2026-02-17.md +0 -5838
  15. package/docs/qa/mac-qa-log.md +0 -2864
  16. package/docs/telemetry/idle-stale-policy.md +0 -57
  17. package/docs/telemetry/openclaw-mapping.md +0 -80
  18. package/test/config.test.mjs +0 -112
  19. package/test/fixtures/gpu-agx.txt +0 -2
  20. package/test/fixtures/gpu-iogpu.txt +0 -2
  21. package/test/fixtures/gpu-top-grep.txt +0 -2
  22. package/test/fixtures/openclaw-fleet-sample-v1.json +0 -68
  23. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +0 -2
  24. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +0 -2
  25. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +0 -2
  26. package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +0 -2
  27. package/test/fixtures/openclaw-stats-current-wrapper.json +0 -12
  28. package/test/fixtures/openclaw-stats-current-wrapper2.json +0 -15
  29. package/test/fixtures/openclaw-stats-data-wrapper.json +0 -21
  30. package/test/fixtures/openclaw-stats-nested-session-wrapper.json +0 -23
  31. package/test/fixtures/openclaw-stats-payload-wrapper.json +0 -1
  32. package/test/fixtures/openclaw-stats-status-current-wrapper.json +0 -19
  33. package/test/fixtures/openclaw-stats.json +0 -17
  34. package/test/fixtures/openclaw-status-ansi-complex-noise.txt +0 -3
  35. package/test/fixtures/openclaw-status-ansi-noise.txt +0 -2
  36. package/test/fixtures/openclaw-status-control-noise.txt +0 -1
  37. package/test/fixtures/openclaw-status-data-wrapper.json +0 -20
  38. package/test/fixtures/openclaw-status-dcs-noise.txt +0 -1
  39. package/test/fixtures/openclaw-status-epoch-seconds.json +0 -15
  40. package/test/fixtures/openclaw-status-mixed-noise.txt +0 -1
  41. package/test/fixtures/openclaw-status-multi-json.txt +0 -3
  42. package/test/fixtures/openclaw-status-nested-recent.json +0 -19
  43. package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +0 -2
  44. package/test/fixtures/openclaw-status-noisy.txt +0 -3
  45. package/test/fixtures/openclaw-status-osc-noise.txt +0 -1
  46. package/test/fixtures/openclaw-status-result-session.json +0 -15
  47. package/test/fixtures/openclaw-status-session-map-with-defaults.json +0 -23
  48. package/test/fixtures/openclaw-status-session-map.json +0 -28
  49. package/test/fixtures/openclaw-status-session-model-name.json +0 -18
  50. package/test/fixtures/openclaw-status-snake-session-wrapper.json +0 -13
  51. package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +0 -25
  52. package/test/fixtures/openclaw-status-stats-current-sessions.json +0 -28
  53. package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +0 -19
  54. package/test/fixtures/openclaw-status-stats-session-default-model.json +0 -27
  55. package/test/fixtures/openclaw-status-status-wrapper.json +0 -13
  56. package/test/fixtures/openclaw-status-strings.json +0 -38
  57. package/test/fixtures/openclaw-status-ts-ms-alias.json +0 -14
  58. package/test/fixtures/openclaw-status-updated-at-ms-alias.json +0 -14
  59. package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +0 -14
  60. package/test/fixtures/openclaw-status-usage-ts-alias.json +0 -14
  61. package/test/fixtures/openclaw-status-wrap-session-object.json +0 -24
  62. package/test/fixtures/openclaw-status.json +0 -41
  63. package/test/fixtures/openclaw-usage-model-name-generic.json +0 -9
  64. package/test/gpu.test.mjs +0 -58
  65. package/test/memory.test.mjs +0 -35
  66. package/test/openclaw-cache.test.mjs +0 -48
  67. package/test/openclaw-env.test.mjs +0 -365
  68. package/test/openclaw-usage.test.mjs +0 -555
  69. package/test/telemetry-mapping.test.mjs +0 -69
  70. package/test/telemetry-row-parser.test.mjs +0 -44
  71. package/test/usage-alert.test.mjs +0 -73
  72. package/test/usage-freshness.test.mjs +0 -63
  73. package/test/validate-dry-run-schema.test.mjs +0 -146
package/README.md CHANGED
@@ -1,20 +1,20 @@
1
- # idlewatch-skill
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
- npm start
8
+ npm install -g idlewatch
9
+ idlewatch --help
10
10
  ```
11
11
 
12
- With npx (after publish):
12
+ Or run it directly with npx:
13
13
 
14
14
  ```bash
15
- npx idlewatch-skill --help
16
- npx idlewatch-skill quickstart
17
- npx idlewatch-skill --dry-run
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-skill quickstart
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 load generated env and run:
65
+ Then run a one-shot publish check:
66
66
 
67
67
  ```bash
68
- set -a; source "$HOME/.idlewatch/idlewatch.env"; set +a
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
@@ -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-agent\n\nUsage:\n idlewatch-agent [quickstart|dashboard] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run enrollment wizard and generate secure env config\n dashboard Launch local dashboard from local IdleWatch logs\n --dry-run Collect and print one telemetry sample, then exit without 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`)
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 path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
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 path.resolve(parsed.IDLEWATCH_LOCAL_LOG_PATH)
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('✅ Initial telemetry sample sent successfully.')
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.log('⚠️ Initial --once sample did not complete successfully.')
356
- console.log(`You can retry with: set -a; source "${result.outputEnvFile}"; set +a && idlewatch-agent --once`)
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: set -a; source "${result.outputEnvFile}"; set +a && 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 = HOST.replace(/[^a-zA-Z0-9_.-]/g, '_')
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
- ? path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
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
- ? path.resolve(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
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 in local-only mode (stdout logging only). Set FIREBASE_PROJECT_ID + FIREBASE_SERVICE_ACCOUNT_FILE (preferred, or FIREBASE_SERVICE_ACCOUNT_JSON / FIREBASE_SERVICE_ACCOUNT_B64), or use FIREBASE_PROJECT_ID + FIRESTORE_EMULATOR_HOST for emulator writes.'
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-agent received ${signal}, waiting for in-flight sample…`)
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-agent stopped')
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-agent ${mode} host=${HOST} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH}`
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-agent started host=${HOST} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH} monitorTargets=${[...MONITOR_TARGETS].join(',')} openclawUsage=${EFFECTIVE_OPENCLAW_MODE}`
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.1",
3
+ "version": "0.1.3",
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",
@@ -43,6 +43,7 @@ cat > "$PLIST_PATH" <<'PLIST'
43
43
  <key>ProgramArguments</key>
44
44
  <array>
45
45
  <string>{{BIN_PATH}}</string>
46
+ <string>--run</string>
46
47
  </array>
47
48
  <key>RunAtLoad</key>
48
49
  <true/>
@@ -1,42 +1,80 @@
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
- import { spawnSync } from 'node:child_process'
5
+ import { spawn } from 'node:child_process'
5
6
 
6
7
  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
- try {
11
- const sourceCreds = path.join(tmpRoot, 'service-account.json')
12
- fs.writeFileSync(
13
- sourceCreds,
14
- JSON.stringify({
15
- type: 'service_account',
16
- project_id: 'idlewatch-test-project',
17
- private_key_id: 'abc123',
18
- private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
19
- client_email: 'idlewatch@idlewatch-test-project.iam.gserviceaccount.com',
20
- client_id: '123'
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
+ })
33
+
34
+ const address = server.address()
35
+ const cloudIngestUrl = `http://127.0.0.1:${address.port}/api/ingest`
23
36
 
37
+ function runQuickstart(env) {
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(process.execPath, [binPath, 'quickstart'], {
40
+ env,
41
+ stdio: ['ignore', 'pipe', 'pipe']
42
+ })
43
+
44
+ let stdout = ''
45
+ let stderr = ''
46
+ child.stdout.on('data', (chunk) => {
47
+ stdout += chunk
48
+ })
49
+ child.stderr.on('data', (chunk) => {
50
+ stderr += chunk
51
+ })
52
+ child.on('error', reject)
53
+ child.on('close', (code) => resolve({ code, stdout, stderr }))
54
+ })
55
+ }
56
+
57
+ try {
24
58
  const envOut = path.join(tmpRoot, 'generated.env')
25
59
  const configDir = path.join(tmpRoot, 'config')
26
- const run = spawnSync(process.execPath, [binPath, 'quickstart'], {
27
- env: {
28
- ...process.env,
29
- IDLEWATCH_ENROLL_NON_INTERACTIVE: '1',
30
- IDLEWATCH_ENROLL_MODE: 'production',
31
- IDLEWATCH_ENROLL_PROJECT_ID: 'idlewatch-test-project',
32
- IDLEWATCH_ENROLL_SERVICE_ACCOUNT_FILE: sourceCreds,
33
- IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: envOut,
34
- IDLEWATCH_ENROLL_CONFIG_DIR: configDir
35
- },
36
- encoding: 'utf8'
60
+ const localLogPath = path.join(configDir, 'logs', 'validator-box-metrics.ndjson')
61
+ const lastGoodCachePath = path.join(configDir, 'cache', 'validator-box-openclaw-last-good.json')
62
+
63
+ const run = await runQuickstart({
64
+ ...process.env,
65
+ IDLEWATCH_ENROLL_NON_INTERACTIVE: '1',
66
+ IDLEWATCH_ENROLL_MODE: 'production',
67
+ IDLEWATCH_CLOUD_API_KEY: 'iwk_abcdefghijklmnopqrstuvwxyz123456',
68
+ IDLEWATCH_CLOUD_INGEST_URL: cloudIngestUrl,
69
+ IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: envOut,
70
+ IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
71
+ IDLEWATCH_DEVICE_NAME: 'Validator Box',
72
+ IDLEWATCH_DEVICE_ID: 'validator-box',
73
+ IDLEWATCH_MONITOR_TARGETS: 'cpu,memory',
74
+ IDLEWATCH_OPENCLAW_USAGE: 'off'
37
75
  })
38
76
 
39
- if (run.status !== 0) {
77
+ if (run.code !== 0) {
40
78
  throw new Error(`quickstart failed\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`)
41
79
  }
42
80
 
@@ -45,19 +83,37 @@ try {
45
83
  }
46
84
 
47
85
  const envContent = fs.readFileSync(envOut, 'utf8')
48
- if (!envContent.includes('FIREBASE_PROJECT_ID=idlewatch-test-project')) {
49
- throw new Error('env file missing FIREBASE_PROJECT_ID')
86
+ for (const requiredLine of [
87
+ 'IDLEWATCH_DEVICE_NAME=Validator Box',
88
+ 'IDLEWATCH_DEVICE_ID=validator-box',
89
+ 'IDLEWATCH_MONITOR_TARGETS=cpu,memory',
90
+ 'IDLEWATCH_OPENCLAW_USAGE=off',
91
+ `IDLEWATCH_LOCAL_LOG_PATH=${localLogPath}`,
92
+ `IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH=${lastGoodCachePath}`,
93
+ `IDLEWATCH_CLOUD_INGEST_URL=${cloudIngestUrl}`,
94
+ 'IDLEWATCH_CLOUD_API_KEY=iwk_abcdefghijklmnopqrstuvwxyz123456',
95
+ 'IDLEWATCH_REQUIRE_CLOUD_WRITES=1'
96
+ ]) {
97
+ if (!envContent.includes(requiredLine)) {
98
+ throw new Error(`env file missing ${requiredLine}`)
99
+ }
100
+ }
101
+
102
+ if (!run.stdout.includes('✅ Setup complete.')) {
103
+ throw new Error('quickstart success output did not include setup completion summary')
50
104
  }
51
- if (!envContent.includes('FIREBASE_SERVICE_ACCOUNT_FILE=')) {
52
- throw new Error('env file missing FIREBASE_SERVICE_ACCOUNT_FILE')
105
+
106
+ if (requests.length === 0) {
107
+ throw new Error('quickstart did not send the initial telemetry sample')
53
108
  }
54
109
 
55
- const copiedCreds = path.join(configDir, 'credentials', 'idlewatch-test-project-service-account.json')
56
- if (!fs.existsSync(copiedCreds)) {
57
- throw new Error('quickstart did not copy service account file to secure credentials path')
110
+ const telemetry = JSON.parse(requests[0].body)
111
+ if (telemetry.deviceId !== 'validator-box') {
112
+ throw new Error('initial telemetry sample used unexpected device id')
58
113
  }
59
114
 
60
115
  console.log('onboarding validation passed')
61
116
  } finally {
117
+ await new Promise((resolve) => server.close(resolve))
62
118
  fs.rmSync(tmpRoot, { recursive: true, force: true })
63
119
  }
package/src/config.js CHANGED
@@ -21,6 +21,22 @@ const isNonNegFinite = (v) => Number.isFinite(v) && v >= 0
21
21
  const isNonNegInt = (v) => Number.isInteger(v) && v >= 0
22
22
  const isBool01 = (v) => v === 0 || v === 1
23
23
 
24
+ function expandSupportedPathVars(value) {
25
+ if (typeof value !== 'string' || !value) return value
26
+
27
+ const home = process.env.HOME || os.homedir()
28
+ const tmpdir = process.env.TMPDIR || os.tmpdir()
29
+
30
+ return value
31
+ .replace(/^~(?=$|\/)/, home)
32
+ .replace(/\$\{HOME\}|\$HOME/g, home)
33
+ .replace(/\$\{TMPDIR\}|\$TMPDIR/g, tmpdir)
34
+ }
35
+
36
+ function resolveEnvPath(value) {
37
+ return path.resolve(expandSupportedPathVars(value))
38
+ }
39
+
24
40
  /**
25
41
  * Build the full IdleWatch configuration from environment variables.
26
42
  * Throws on invalid values.
@@ -63,11 +79,11 @@ export function buildConfig() {
63
79
  const BASE_DIR = path.join(os.homedir(), '.idlewatch')
64
80
 
65
81
  const LOCAL_LOG_PATH = process.env.IDLEWATCH_LOCAL_LOG_PATH
66
- ? path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
82
+ ? resolveEnvPath(process.env.IDLEWATCH_LOCAL_LOG_PATH)
67
83
  : path.join(BASE_DIR, 'logs', `${SAFE_HOST}-metrics.ndjson`)
68
84
 
69
85
  const OPENCLAW_LAST_GOOD_CACHE_PATH = process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH
70
- ? path.resolve(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
86
+ ? resolveEnvPath(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
71
87
  : path.join(BASE_DIR, 'cache', `${SAFE_HOST}-openclaw-last-good.json`)
72
88
 
73
89
  return Object.freeze({
package/src/enrollment.js CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import readline from 'node:readline/promises'
5
5
  import process from 'node:process'
6
+ import { fileURLToPath } from 'node:url'
6
7
  import { spawnSync } from 'node:child_process'
7
8
 
8
9
  function defaultConfigDir() {
@@ -24,6 +25,8 @@ function writeSecureFile(filePath, content) {
24
25
  }
25
26
 
26
27
  const MONITOR_TARGET_CHOICES = ['cpu', 'memory', 'gpu', 'openclaw']
28
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url))
29
+ const PACKAGE_ROOT = path.resolve(MODULE_DIR, '..')
27
30
 
28
31
  function commandExists(bin, args = ['--version']) {
29
32
  const result = spawnSync(bin, args, { stdio: 'ignore' })
@@ -58,15 +61,48 @@ function normalizeMonitorTargets(raw, available) {
58
61
  return [...new Set(parsed)]
59
62
  }
60
63
 
64
+ function normalizeCloudApiKey(raw) {
65
+ const trimmed = String(raw || '').trim()
66
+ if (!trimmed) return ''
67
+
68
+ const token = trimmed
69
+ .split(/\s+/)
70
+ .find((part) => part.startsWith('iwk_'))
71
+
72
+ if (token) {
73
+ return token.replace(/^['"]|['",]$/g, '')
74
+ }
75
+
76
+ return trimmed.replace(/^['"]|['"]$/g, '')
77
+ }
78
+
79
+ function looksLikeCloudApiKey(value) {
80
+ return /^iwk_[A-Za-z0-9_-]{20,}$/.test(String(value || '').trim())
81
+ }
82
+
83
+ function normalizeDeviceName(raw, fallback = os.hostname()) {
84
+ const value = String(raw || '').trim().replace(/\s+/g, ' ')
85
+ return value || fallback
86
+ }
87
+
88
+ function sanitizeDeviceId(raw, fallback = os.hostname()) {
89
+ const base = normalizeDeviceName(raw, fallback).toLowerCase()
90
+ const sanitized = base
91
+ .replace(/[^a-z0-9._-]+/g, '-')
92
+ .replace(/^-+|-+$/g, '')
93
+ .slice(0, 80)
94
+ return sanitized || normalizeDeviceName(fallback).replace(/[^a-zA-Z0-9_.-]/g, '_')
95
+ }
96
+
61
97
  function tryRustTui({ configDir, outputEnvFile }) {
62
98
  const disabled = process.env.IDLEWATCH_DISABLE_RUST_TUI === '1'
63
- if (disabled) return false
99
+ if (disabled) return { ok: false, reason: 'disabled' }
64
100
 
65
101
  const cargoProbe = spawnSync('cargo', ['--version'], { stdio: 'ignore' })
66
- if (cargoProbe.status !== 0) return false
102
+ if (cargoProbe.status !== 0) return { ok: false, reason: 'cargo-missing' }
67
103
 
68
- const manifestPath = path.resolve(process.cwd(), 'tui', 'Cargo.toml')
69
- if (!fs.existsSync(manifestPath)) return false
104
+ const manifestPath = path.join(PACKAGE_ROOT, 'tui', 'Cargo.toml')
105
+ if (!fs.existsSync(manifestPath)) return { ok: false, reason: 'manifest-missing', manifestPath }
70
106
 
71
107
  const run = spawnSync('cargo', ['run', '--quiet', '--manifest-path', manifestPath], {
72
108
  stdio: 'inherit',
@@ -78,10 +114,10 @@ function tryRustTui({ configDir, outputEnvFile }) {
78
114
  })
79
115
 
80
116
  if (run.status === 0) {
81
- return true
117
+ return { ok: true, manifestPath }
82
118
  }
83
119
 
84
- return false
120
+ return { ok: false, reason: `cargo-run-failed:${run.status ?? 'unknown'}`, manifestPath }
85
121
  }
86
122
 
87
123
  function promptModeText() {
@@ -94,8 +130,9 @@ export async function runEnrollmentWizard(options = {}) {
94
130
  const outputEnvFile = path.resolve(options.outputEnvFile || process.env.IDLEWATCH_ENROLL_OUTPUT_ENV_FILE || path.join(configDir, 'idlewatch.env'))
95
131
 
96
132
  let mode = options.mode || process.env.IDLEWATCH_ENROLL_MODE || null
97
- let cloudApiKey = options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null
133
+ let cloudApiKey = normalizeCloudApiKey(options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null)
98
134
  let cloudIngestUrl = options.cloudIngestUrl || process.env.IDLEWATCH_CLOUD_INGEST_URL || 'https://api.idlewatch.com/api/ingest'
135
+ let deviceName = normalizeDeviceName(options.deviceName || process.env.IDLEWATCH_DEVICE_NAME || os.hostname())
99
136
 
100
137
  const availableMonitorTargets = detectAvailableMonitorTargets()
101
138
  let monitorTargets = normalizeMonitorTargets(
@@ -103,11 +140,18 @@ export async function runEnrollmentWizard(options = {}) {
103
140
  availableMonitorTargets
104
141
  )
105
142
 
106
- if (!nonInteractive && tryRustTui({ configDir, outputEnvFile })) {
107
- return {
108
- mode: 'tui',
109
- configDir,
110
- outputEnvFile
143
+ if (!nonInteractive) {
144
+ const tuiResult = tryRustTui({ configDir, outputEnvFile })
145
+ if (tuiResult.ok) {
146
+ return {
147
+ mode: 'tui',
148
+ configDir,
149
+ outputEnvFile
150
+ }
151
+ }
152
+
153
+ if (!['disabled', 'cargo-missing'].includes(tuiResult.reason || '')) {
154
+ console.warn(`IdleWatch TUI unavailable (${tuiResult.reason || 'unknown'}). Falling back to text setup.`)
111
155
  }
112
156
  }
113
157
 
@@ -119,6 +163,8 @@ export async function runEnrollmentWizard(options = {}) {
119
163
  console.log(`Environment file: ${outputEnvFile}`)
120
164
  const modeInput = (await rl.question('\nMode [1/2] (default 1): ')).trim() || '1'
121
165
  mode = modeInput === '2' ? 'local' : 'production'
166
+ const deviceNameInput = (await rl.question(`Device name [${deviceName}]: `)).trim()
167
+ deviceName = normalizeDeviceName(deviceNameInput || deviceName)
122
168
  }
123
169
 
124
170
  if (!mode) mode = 'production'
@@ -129,7 +175,7 @@ export async function runEnrollmentWizard(options = {}) {
129
175
  if ((mode === 'production') && !cloudApiKey) {
130
176
  if (!rl) throw new Error('Missing cloud API key (IDLEWATCH_CLOUD_API_KEY).')
131
177
  console.log('\nPaste the API key from idlewatch.com/api.')
132
- cloudApiKey = (await rl.question('Cloud API key: ')).trim()
178
+ cloudApiKey = normalizeCloudApiKey(await rl.question('Cloud API key: '))
133
179
  }
134
180
 
135
181
  if (!nonInteractive && rl) {
@@ -139,11 +185,14 @@ export async function runEnrollmentWizard(options = {}) {
139
185
  monitorTargets = normalizeMonitorTargets(monitorInput || suggested, availableMonitorTargets)
140
186
  }
141
187
 
142
- const localLogPath = path.join(configDir, 'logs', `${os.hostname().replace(/[^a-zA-Z0-9_.-]/g, '_')}-metrics.ndjson`)
143
- const localCachePath = path.join(configDir, 'cache', `${os.hostname().replace(/[^a-zA-Z0-9_.-]/g, '_')}-openclaw-last-good.json`)
188
+ const safeDeviceId = sanitizeDeviceId(options.deviceId || process.env.IDLEWATCH_DEVICE_ID || deviceName, os.hostname())
189
+ const localLogPath = path.join(configDir, 'logs', `${safeDeviceId}-metrics.ndjson`)
190
+ const localCachePath = path.join(configDir, 'cache', `${safeDeviceId}-openclaw-last-good.json`)
144
191
 
145
192
  const envLines = [
146
193
  '# Generated by idlewatch-agent quickstart',
194
+ `IDLEWATCH_DEVICE_NAME=${deviceName}`,
195
+ `IDLEWATCH_DEVICE_ID=${safeDeviceId}`,
147
196
  `IDLEWATCH_MONITOR_TARGETS=${monitorTargets.join(',')}`,
148
197
  `IDLEWATCH_OPENCLAW_USAGE=${monitorTargets.includes('openclaw') ? 'auto' : 'off'}`,
149
198
  `IDLEWATCH_LOCAL_LOG_PATH=${localLogPath}`,
@@ -155,8 +204,8 @@ export async function runEnrollmentWizard(options = {}) {
155
204
  }
156
205
 
157
206
  if (mode === 'production') {
158
- if (!cloudApiKey) {
159
- throw new Error('Cloud API key is required for production mode.')
207
+ if (!cloudApiKey || !looksLikeCloudApiKey(cloudApiKey)) {
208
+ throw new Error('Cloud API key is invalid. Copy the full key from idlewatch.com/api (starts with iwk_).')
160
209
  }
161
210
  envLines.push(`IDLEWATCH_CLOUD_INGEST_URL=${cloudIngestUrl}`)
162
211
  envLines.push(`IDLEWATCH_CLOUD_API_KEY=${cloudApiKey}`)
@@ -171,6 +220,8 @@ export async function runEnrollmentWizard(options = {}) {
171
220
  mode,
172
221
  configDir,
173
222
  outputEnvFile,
174
- monitorTargets
223
+ monitorTargets,
224
+ deviceName,
225
+ deviceId: safeDeviceId
175
226
  }
176
227
  }