idlewatch 0.1.4 → 0.1.6

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 CHANGED
@@ -17,7 +17,7 @@ npx idlewatch quickstart
17
17
  npx idlewatch --dry-run
18
18
  ```
19
19
 
20
- `quickstart` is the happy path: create an API key on idlewatch.com/api, run the wizard, pick a device name + metrics, and IdleWatch saves local config before sending a first sample. By default, quickstart will try to install Rust/Cargo automatically if needed so it can launch the ratatui onboarding flow. Use `idlewatch quickstart --no-tui` to skip the TUI and stay on the plain text setup path without installing Cargo.
20
+ `quickstart` is the happy path: create an API key on idlewatch.com/api, run the wizard, pick a device name + metrics, and IdleWatch saves local config before sending a first sample. It prefers a bundled TUI binary when available, otherwise falls back to a local Cargo build only on developer machines that already have Cargo installed. Use `idlewatch quickstart --no-tui` to skip the TUI and stay on the plain text setup path.
21
21
 
22
22
  ## CLI options
23
23
 
@@ -57,6 +57,8 @@ Use `gpuSource` + `gpuConfidence` in dashboards to decide whether to trust value
57
57
  npx idlewatch quickstart
58
58
  ```
59
59
 
60
+ `idlewatch` is the primary package/command name. `idlewatch-skill` still works as a compatibility alias, but treat it as legacy in user-facing docs.
61
+
60
62
  The wizard keeps setup small:
61
63
  - asks for a **device name**
62
64
  - asks for your **API key** from `idlewatch.com/api`
@@ -374,6 +374,10 @@ if (quickstartRequested) {
374
374
  try {
375
375
  const result = await runEnrollmentWizard({ noTui: args.has('--no-tui') })
376
376
 
377
+ if (!result?.outputEnvFile || !fs.existsSync(result.outputEnvFile)) {
378
+ throw new Error(`setup_did_not_write_env_file:${result?.outputEnvFile || 'unknown'}`)
379
+ }
380
+
377
381
  const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
378
382
  const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
379
383
  stdio: 'inherit',
@@ -395,7 +399,13 @@ if (quickstartRequested) {
395
399
  console.error('Or rerun: idlewatch quickstart')
396
400
  process.exit(onceRun.status ?? 1)
397
401
  } catch (err) {
398
- console.error(`Enrollment failed: ${err.message}`)
402
+ if (String(err?.message || '') === 'setup_cancelled') {
403
+ console.error('Enrollment cancelled before saving config.')
404
+ } else if (String(err?.message || '').startsWith('setup_did_not_write_env_file:')) {
405
+ console.error(`Enrollment failed: setup did not save idlewatch.env (${String(err.message).split(':').slice(1).join(':')}).`)
406
+ } else {
407
+ console.error(`Enrollment failed: ${err.message}`)
408
+ }
399
409
  process.exit(1)
400
410
  }
401
411
  }
@@ -624,9 +634,13 @@ if (firebaseConfigError) {
624
634
  process.exit(1)
625
635
  }
626
636
 
627
- if (!appReady && !(CLOUD_INGEST_URL && CLOUD_API_KEY)) {
637
+ const hasAnyFirebaseConfig = Boolean(PROJECT_ID || CREDS_FILE || CREDS_JSON || CREDS_B64 || FIRESTORE_EMULATOR_HOST)
638
+ const hasCloudConfig = Boolean(CLOUD_INGEST_URL && CLOUD_API_KEY)
639
+ const shouldWarnAboutMissingPublishConfig = !appReady && !hasCloudConfig && !DRY_RUN && !hasAnyFirebaseConfig
640
+
641
+ if (shouldWarnAboutMissingPublishConfig) {
628
642
  console.error(
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 run idlewatch quickstart to link cloud ingest.'
643
+ 'No publish target is configured yet. Running in local-only mode. Run idlewatch quickstart to link cloud ingest, or configure Firebase/emulator mode if you need that path.'
630
644
  )
631
645
  }
632
646
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idlewatch",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Host telemetry collector for IdleWatch",
5
5
  "type": "module",
6
6
  "files": [
@@ -9,6 +9,7 @@
9
9
  "scripts",
10
10
  "skill",
11
11
  "tui/src",
12
+ "tui/bin",
12
13
  "tui/Cargo.toml",
13
14
  "tui/Cargo.lock",
14
15
  "README.md",
package/src/enrollment.js CHANGED
@@ -10,6 +10,24 @@ function defaultConfigDir() {
10
10
  return path.join(os.homedir(), '.idlewatch')
11
11
  }
12
12
 
13
+ function machineName() {
14
+ if (process.platform === 'darwin') {
15
+ const macName = spawnSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8' })
16
+ if (macName.status === 0) {
17
+ const value = String(macName.stdout || '').trim()
18
+ if (value) return value
19
+ }
20
+ }
21
+
22
+ const hostName = String(os.hostname() || '').trim()
23
+ if (hostName) return hostName
24
+
25
+ const envHost = String(process.env.HOSTNAME || '').trim()
26
+ if (envHost) return envHost
27
+
28
+ return 'IdleWatch Device'
29
+ }
30
+
13
31
  function ensureDir(dirPath) {
14
32
  fs.mkdirSync(dirPath, { recursive: true })
15
33
  }
@@ -80,12 +98,12 @@ function looksLikeCloudApiKey(value) {
80
98
  return /^iwk_[A-Za-z0-9_-]{20,}$/.test(String(value || '').trim())
81
99
  }
82
100
 
83
- function normalizeDeviceName(raw, fallback = os.hostname()) {
101
+ function normalizeDeviceName(raw, fallback = machineName()) {
84
102
  const value = String(raw || '').trim().replace(/\s+/g, ' ')
85
103
  return value || fallback
86
104
  }
87
105
 
88
- function sanitizeDeviceId(raw, fallback = os.hostname()) {
106
+ function sanitizeDeviceId(raw, fallback = machineName()) {
89
107
  const base = normalizeDeviceName(raw, fallback).toLowerCase()
90
108
  const sanitized = base
91
109
  .replace(/[^a-z0-9._-]+/g, '-')
@@ -99,43 +117,47 @@ function cargoAvailable() {
99
117
  return cargoProbe.status === 0
100
118
  }
101
119
 
102
- function installRustToolchain() {
103
- if (cargoAvailable()) return { ok: true, installed: false }
104
- if (process.env.IDLEWATCH_DISABLE_RUST_INSTALL === '1') return { ok: false, reason: 'install-disabled' }
105
- if (!process.stdin.isTTY || !process.stdout.isTTY) return { ok: false, reason: 'non-tty' }
106
- if (!['darwin', 'linux'].includes(process.platform)) return { ok: false, reason: `unsupported-platform:${process.platform}` }
120
+ function bundledTuiBinaryPath() {
121
+ const platform = process.platform
122
+ const arch = process.arch
123
+ const ext = platform === 'win32' ? '.exe' : ''
124
+ return path.join(PACKAGE_ROOT, 'tui', 'bin', `${platform}-${arch}`, `idlewatch-setup${ext}`)
125
+ }
126
+
127
+ function tryBundledRustTui({ configDir, outputEnvFile }) {
128
+ const binPath = bundledTuiBinaryPath()
129
+ if (!fs.existsSync(binPath)) return { ok: false, reason: 'bundled-binary-missing', binPath }
130
+
131
+ try {
132
+ fs.chmodSync(binPath, 0o755)
133
+ } catch {
134
+ // best effort
135
+ }
107
136
 
108
- console.log('IdleWatch TUI needs Rust/Cargo. Installing rustup + Cargo now...')
109
- const install = spawnSync('sh', ['-c', 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y'], {
137
+ const run = spawnSync(binPath, [], {
110
138
  stdio: 'inherit',
111
139
  env: {
112
140
  ...process.env,
113
- CARGO_HOME: process.env.CARGO_HOME || path.join(os.homedir(), '.cargo'),
114
- RUSTUP_HOME: process.env.RUSTUP_HOME || path.join(os.homedir(), '.rustup')
141
+ IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
142
+ IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: outputEnvFile
115
143
  }
116
144
  })
117
145
 
118
- if (install.status !== 0) return { ok: false, reason: `rustup-install-failed:${install.status ?? 'unknown'}` }
119
-
120
- const cargoBinDir = path.join(process.env.CARGO_HOME || path.join(os.homedir(), '.cargo'), 'bin')
121
- if (!process.env.PATH?.split(path.delimiter).includes(cargoBinDir)) {
122
- process.env.PATH = `${cargoBinDir}${path.delimiter}${process.env.PATH || ''}`
123
- }
124
-
125
- return cargoAvailable() ? { ok: true, installed: true } : { ok: false, reason: 'cargo-still-missing' }
146
+ if (run.status === 0) return { ok: true, binPath }
147
+ return { ok: false, reason: `bundled-binary-failed:${run.status ?? 'unknown'}`, binPath }
126
148
  }
127
149
 
128
- function tryRustTui({ configDir, outputEnvFile, autoInstallRust = true }) {
150
+ function tryRustTui({ configDir, outputEnvFile }) {
129
151
  const disabled = process.env.IDLEWATCH_DISABLE_RUST_TUI === '1'
130
152
  if (disabled) return { ok: false, reason: 'disabled' }
131
153
 
154
+ const bundled = tryBundledRustTui({ configDir, outputEnvFile })
155
+ if (bundled.ok) return bundled
156
+
132
157
  if (!cargoAvailable()) {
133
- if (autoInstallRust) {
134
- const installResult = installRustToolchain()
135
- if (!installResult.ok) return { ok: false, reason: installResult.reason || 'cargo-missing' }
136
- } else {
137
- return { ok: false, reason: 'cargo-missing' }
138
- }
158
+ return bundled.reason === 'bundled-binary-missing'
159
+ ? { ok: false, reason: 'bundled-binary-missing-and-cargo-missing', binPath: bundled.binPath }
160
+ : { ok: false, reason: 'cargo-missing' }
139
161
  }
140
162
 
141
163
  const manifestPath = path.join(PACKAGE_ROOT, 'tui', 'Cargo.toml')
@@ -170,7 +192,7 @@ export async function runEnrollmentWizard(options = {}) {
170
192
  let mode = options.mode || process.env.IDLEWATCH_ENROLL_MODE || null
171
193
  let cloudApiKey = normalizeCloudApiKey(options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null)
172
194
  let cloudIngestUrl = options.cloudIngestUrl || process.env.IDLEWATCH_CLOUD_INGEST_URL || 'https://api.idlewatch.com/api/ingest'
173
- let deviceName = normalizeDeviceName(options.deviceName || process.env.IDLEWATCH_DEVICE_NAME || os.hostname())
195
+ let deviceName = normalizeDeviceName(options.deviceName || process.env.IDLEWATCH_DEVICE_NAME || machineName())
174
196
 
175
197
  const availableMonitorTargets = detectAvailableMonitorTargets()
176
198
  let monitorTargets = normalizeMonitorTargets(
@@ -179,7 +201,7 @@ export async function runEnrollmentWizard(options = {}) {
179
201
  )
180
202
 
181
203
  if (!nonInteractive && !noTui) {
182
- const tuiResult = tryRustTui({ configDir, outputEnvFile, autoInstallRust: true })
204
+ const tuiResult = tryRustTui({ configDir, outputEnvFile })
183
205
  if (tuiResult.ok) {
184
206
  return {
185
207
  mode: 'tui',
@@ -188,7 +210,9 @@ export async function runEnrollmentWizard(options = {}) {
188
210
  }
189
211
  }
190
212
 
191
- if (!['disabled', 'cargo-missing'].includes(tuiResult.reason || '')) {
213
+ if (tuiResult.reason === 'bundled-binary-missing-and-cargo-missing') {
214
+ console.warn('IdleWatch TUI is not bundled for this platform and Cargo is not installed. Falling back to text setup. Use --no-tui to skip this check.')
215
+ } else if (!['disabled', 'cargo-missing', 'bundled-binary-missing'].includes(tuiResult.reason || '')) {
192
216
  console.warn(`IdleWatch TUI unavailable (${tuiResult.reason || 'unknown'}). Falling back to text setup.`)
193
217
  }
194
218
  }
@@ -223,7 +247,7 @@ export async function runEnrollmentWizard(options = {}) {
223
247
  monitorTargets = normalizeMonitorTargets(monitorInput || suggested, availableMonitorTargets)
224
248
  }
225
249
 
226
- const safeDeviceId = sanitizeDeviceId(options.deviceId || process.env.IDLEWATCH_DEVICE_ID || deviceName, os.hostname())
250
+ const safeDeviceId = sanitizeDeviceId(options.deviceId || process.env.IDLEWATCH_DEVICE_ID || deviceName, machineName())
227
251
  const localLogPath = path.join(configDir, 'logs', `${safeDeviceId}-metrics.ndjson`)
228
252
  const localCachePath = path.join(configDir, 'cache', `${safeDeviceId}-openclaw-last-good.json`)
229
253
 
package/tui/src/main.rs CHANGED
@@ -70,6 +70,34 @@ fn command_exists(cmd: &str, args: &[&str]) -> bool {
70
70
  .unwrap_or(false)
71
71
  }
72
72
 
73
+ fn machine_name() -> String {
74
+ if cfg!(target_os = "macos") {
75
+ if let Ok(output) = Command::new("scutil").args(["--get", "ComputerName"]).output() {
76
+ if output.status.success() {
77
+ let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
78
+ if !name.is_empty() {
79
+ return name;
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ if let Ok(output) = Command::new("hostname").output() {
86
+ if output.status.success() {
87
+ let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
88
+ if !name.is_empty() {
89
+ return name;
90
+ }
91
+ }
92
+ }
93
+
94
+ std::env::var("HOSTNAME")
95
+ .ok()
96
+ .map(|value| value.trim().to_string())
97
+ .filter(|value| !value.is_empty())
98
+ .unwrap_or_else(|| "IdleWatch Device".to_string())
99
+ }
100
+
73
101
  fn parse_env_file(path: &Path) -> ExistingConfig {
74
102
  let mut config = ExistingConfig::default();
75
103
  let Ok(raw) = fs::read_to_string(path) else {
@@ -443,7 +471,7 @@ fn main() -> Result<()> {
443
471
  let env_file = std::env::var("IDLEWATCH_ENROLL_OUTPUT_ENV_FILE")
444
472
  .map(PathBuf::from)
445
473
  .unwrap_or_else(|_| config_dir.join("idlewatch.env"));
446
- let host = sanitize_host(&std::env::var("HOSTNAME").unwrap_or_else(|_| "host".to_string()));
474
+ let host = sanitize_host(&machine_name());
447
475
  let existing = parse_env_file(&env_file);
448
476
 
449
477
  enable_raw_mode()?;
@@ -464,7 +492,7 @@ fn main() -> Result<()> {
464
492
  KeyCode::Char('q') | KeyCode::Esc => {
465
493
  disable_raw_mode()?;
466
494
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
467
- return Ok(());
495
+ return Err(anyhow!("setup_cancelled"));
468
496
  }
469
497
  _ => {}
470
498
  }
@@ -503,7 +531,7 @@ fn main() -> Result<()> {
503
531
  KeyCode::Char('q') | KeyCode::Esc => {
504
532
  disable_raw_mode()?;
505
533
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
506
- return Ok(());
534
+ return Err(anyhow!("setup_cancelled"));
507
535
  }
508
536
  _ => {}
509
537
  }
@@ -545,7 +573,7 @@ fn main() -> Result<()> {
545
573
  KeyCode::Char('q') | KeyCode::Esc => {
546
574
  disable_raw_mode()?;
547
575
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
548
- return Ok(());
576
+ return Err(anyhow!("setup_cancelled"));
549
577
  }
550
578
  KeyCode::Char(c) => {
551
579
  device_name_input.push(c);
@@ -592,7 +620,7 @@ fn main() -> Result<()> {
592
620
  KeyCode::Char('q') | KeyCode::Esc => {
593
621
  disable_raw_mode()?;
594
622
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
595
- return Ok(());
623
+ return Err(anyhow!("setup_cancelled"));
596
624
  }
597
625
  KeyCode::Char(c) => {
598
626
  cloud_api_key_input.push(c);