@myerscarpenter/quest-dev 1.2.0 → 1.3.0

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.
@@ -1,43 +1,100 @@
1
1
  /**
2
2
  * Quest stay-awake command
3
- * Keeps Quest screen awake by setting screen timeout to 24 hours
4
- * Restores original timeout on exit (Ctrl-C)
3
+ * Uses Meta Scriptable Testing API (content://com.oculus.rc) to disable
4
+ * autosleep, guardian, and system dialogs for automated testing.
5
+ *
6
+ * Cleanup is critical: with autosleep disabled, the headset drains battery
7
+ * quickly. A watchdog child process ensures cleanup happens even if the
8
+ * parent is killed (TaskStop, terminal close, claude code exit).
5
9
  */
6
10
 
7
- import { checkADBPath } from '../utils/adb.js';
8
- import { execCommand } from '../utils/exec.js';
11
+ import { checkADBPath, getBatteryInfo, formatBatteryInfo } from '../utils/adb.js';
12
+ import { loadPin, loadConfig } from '../utils/config.js';
13
+ import { execCommand, execCommandFull } from '../utils/exec.js';
9
14
  import { execSync, spawn, ChildProcess } from 'child_process';
10
15
  import * as os from 'os';
11
16
  import * as fs from 'fs';
12
17
 
18
+ export interface TestProperties {
19
+ disable_guardian: boolean;
20
+ disable_dialogs: boolean;
21
+ disable_autosleep: boolean;
22
+ set_proximity_close: boolean;
23
+ }
24
+
13
25
  /**
14
- * Get current screen timeout setting
26
+ * Build ADB args for SET_PROPERTY call
15
27
  */
16
- async function getScreenTimeout(): Promise<number> {
17
- const output = await execCommand('adb', ['shell', 'settings', 'get', 'system', 'screen_off_timeout']);
18
- return parseInt(output.trim(), 10);
28
+ export function buildSetPropertyArgs(pin: string, enabled: boolean): string[] {
29
+ return [
30
+ 'shell', 'content', 'call',
31
+ '--uri', 'content://com.oculus.rc',
32
+ '--method', 'SET_PROPERTY',
33
+ '--extra', `disable_guardian:b:${enabled}`,
34
+ '--extra', `disable_dialogs:b:${enabled}`,
35
+ '--extra', `disable_autosleep:b:${enabled}`,
36
+ '--extra', `set_proximity_close:b:${enabled}`,
37
+ '--extra', `PIN:s:${pin}`,
38
+ ];
19
39
  }
20
40
 
21
41
  /**
22
- * Set screen timeout (in milliseconds)
42
+ * Parse GET_PROPERTY Bundle output into structured data
43
+ * Input: "Bundle[{disable_guardian=true, set_proximity_close=true, disable_dialogs=true, disable_autosleep=true}]"
23
44
  */
24
- async function setScreenTimeout(timeout: number): Promise<void> {
25
- await execCommand('adb', ['shell', 'settings', 'put', 'system', 'screen_off_timeout', timeout.toString()]);
45
+ export function parseTestProperties(output: string): TestProperties {
46
+ const defaults: TestProperties = {
47
+ disable_guardian: false,
48
+ disable_dialogs: false,
49
+ disable_autosleep: false,
50
+ set_proximity_close: false,
51
+ };
52
+
53
+ const match = output.match(/Bundle\[\{(.+)\}\]/);
54
+ if (!match) return defaults;
55
+
56
+ const pairs = match[1].split(',').map(s => s.trim());
57
+ for (const pair of pairs) {
58
+ const [key, value] = pair.split('=');
59
+ if (key && value && key in defaults) {
60
+ (defaults as any)[key] = value === 'true';
61
+ }
62
+ }
63
+
64
+ return defaults;
65
+ }
66
+
67
+ /**
68
+ * Call SET_PROPERTY to enable or disable test mode
69
+ */
70
+ async function setTestProperties(pin: string, enabled: boolean): Promise<void> {
71
+ const args = buildSetPropertyArgs(pin, enabled);
72
+ await execCommand('adb', args);
26
73
  }
27
74
 
28
75
  /**
29
- * Disable Quest proximity sensor (keeps screen on even when not worn)
76
+ * Call GET_PROPERTY and return parsed test properties
30
77
  */
31
- async function disableProximitySensor(): Promise<void> {
32
- await execCommand('adb', ['shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close']);
78
+ async function getTestProperties(): Promise<TestProperties> {
79
+ const result = await execCommandFull('adb', [
80
+ 'shell', 'content', 'call',
81
+ '--uri', 'content://com.oculus.rc',
82
+ '--method', 'GET_PROPERTY',
83
+ ]);
84
+ return parseTestProperties(result.stdout);
33
85
  }
34
86
 
35
87
  /**
36
- * Enable Quest proximity sensor (re-enable normal behavior)
37
- * Note: automation_disable actually RE-ENABLES normal proximity sensor automation
88
+ * Format test properties for display
38
89
  */
39
- async function enableProximitySensor(): Promise<void> {
40
- await execCommand('adb', ['shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.automation_disable']);
90
+ function formatTestProperties(props: TestProperties): string {
91
+ const lines = [
92
+ ` Guardian disabled: ${props.disable_guardian}`,
93
+ ` Dialogs disabled: ${props.disable_dialogs}`,
94
+ ` Autosleep disabled: ${props.disable_autosleep}`,
95
+ ` Proximity close: ${props.set_proximity_close}`,
96
+ ];
97
+ return lines.join('\n');
41
98
  }
42
99
 
43
100
  /**
@@ -47,35 +104,47 @@ async function wakeScreen(): Promise<void> {
47
104
  await execCommand('adb', ['shell', 'input', 'keyevent', 'KEYCODE_WAKEUP']);
48
105
  }
49
106
 
107
+ /**
108
+ * Show current test properties status
109
+ */
110
+ export async function stayAwakeStatus(): Promise<void> {
111
+ checkADBPath();
112
+ const props = await getTestProperties();
113
+ console.log('Scriptable Testing properties:');
114
+ console.log(formatTestProperties(props));
115
+ }
116
+
117
+ /**
118
+ * Manually disable test mode (restore all properties)
119
+ */
120
+ export async function stayAwakeDisable(cliPin?: string): Promise<void> {
121
+ checkADBPath();
122
+ const pin = loadPin(cliPin);
123
+ await setTestProperties(pin, false);
124
+ console.log('Test mode disabled — guardian, dialogs, and autosleep restored');
125
+ }
126
+
50
127
  /**
51
128
  * Child watchdog process - polls for parent death and cleans up
52
129
  */
53
- export async function stayAwakeWatchdog(parentPid: number, originalTimeout: number): Promise<void> {
54
- const pollInterval = 5000; // Check every 5 seconds
130
+ export async function stayAwakeWatchdog(parentPid: number, pin: string): Promise<void> {
131
+ const pollInterval = 5000;
55
132
 
56
133
  const checkParent = setInterval(() => {
57
134
  try {
58
- // Check if parent process still exists
59
135
  process.kill(parentPid, 0);
60
- // Parent still alive, continue polling
61
136
  } catch {
62
- // Parent is dead - perform cleanup
63
137
  console.log('Parent process died, restoring Quest settings...');
64
138
  clearInterval(checkParent);
65
139
 
66
- // Restore settings synchronously
67
140
  try {
68
- execSync(`adb shell settings put system screen_off_timeout ${originalTimeout}`, { stdio: 'ignore' });
69
- execSync(`adb shell am broadcast -a com.oculus.vrpowermanager.automation_disable`, { stdio: 'ignore' });
141
+ const args = buildSetPropertyArgs(pin, false);
142
+ execSync(`adb ${args.join(' ')}`, { stdio: 'ignore' });
70
143
 
71
- // Cleanup PID file
72
144
  const pidFile = `${os.homedir()}/.quest-dev-stay-awake.pid`;
73
- try {
74
- fs.unlinkSync(pidFile);
75
- } catch {}
145
+ try { fs.unlinkSync(pidFile); } catch {}
76
146
 
77
- console.log(`Screen timeout restored to ${originalTimeout}ms (${Math.round(originalTimeout / 1000)}s)`);
78
- console.log('Proximity sensor re-enabled');
147
+ console.log('Test mode disabled guardian, dialogs, and autosleep restored');
79
148
  } catch (err) {
80
149
  console.error('Failed to restore settings:', (err as Error).message);
81
150
  }
@@ -88,11 +157,15 @@ export async function stayAwakeWatchdog(parentPid: number, originalTimeout: numb
88
157
  /**
89
158
  * Main stay-awake command handler
90
159
  */
91
- export async function stayAwakeCommand(idleTimeout: number = 300000): Promise<void> {
92
- // Check prerequisites
160
+ export async function stayAwakeCommand(
161
+ cliPin?: string,
162
+ cliIdleTimeout?: number,
163
+ cliLowBattery?: number,
164
+ verbose: boolean = false,
165
+ ): Promise<void> {
93
166
  checkADBPath();
94
167
 
95
- // Check devices without verbose output
168
+ // Check devices
96
169
  try {
97
170
  const output = await execCommand('adb', ['devices']);
98
171
  const lines = output.trim().split('\n').slice(1);
@@ -107,81 +180,87 @@ export async function stayAwakeCommand(idleTimeout: number = 300000): Promise<vo
107
180
  process.exit(1);
108
181
  }
109
182
 
183
+ const config = loadConfig();
184
+ const pin = loadPin(cliPin);
185
+ const idleTimeout = cliIdleTimeout ?? config.idleTimeout ?? 300000;
186
+ const lowBattery = cliLowBattery ?? config.lowBattery ?? 10;
187
+
110
188
  // PID file management
111
189
  const pidFilePath = `${os.homedir()}/.quest-dev-stay-awake.pid`;
112
190
 
113
- // Check for existing process
114
191
  if (fs.existsSync(pidFilePath)) {
115
192
  const existingPid = parseInt(fs.readFileSync(pidFilePath, 'utf-8'));
116
193
  try {
117
- process.kill(existingPid, 0); // Test if process exists
194
+ process.kill(existingPid, 0);
118
195
  console.error(`Error: stay-awake is already running (PID: ${existingPid})`);
119
196
  process.exit(1);
120
197
  } catch {
121
- // Process dead, cleanup stale PID file
122
198
  fs.unlinkSync(pidFilePath);
123
199
  }
124
200
  }
125
201
 
126
- // Get original timeout
127
- let originalTimeout: number;
128
-
129
- try {
130
- originalTimeout = await getScreenTimeout();
131
- console.log(`Original screen timeout: ${originalTimeout}ms (${Math.round(originalTimeout / 1000)}s)`);
132
- } catch (error) {
133
- console.error('Failed to get current screen timeout');
134
- process.exit(1);
135
- }
202
+ // Show current state
203
+ const beforeProps = await getTestProperties();
204
+ console.log('Current test properties:');
205
+ console.log(formatTestProperties(beforeProps));
136
206
 
137
207
  // Write PID file
138
208
  try {
139
209
  fs.writeFileSync(pidFilePath, process.pid.toString());
140
210
  } catch (error) {
141
- console.warn('Failed to write PID file, hook will not work');
211
+ console.warn('Failed to write PID file');
142
212
  }
143
213
 
144
- // Spawn child watchdog process
214
+ // Spawn watchdog child process
145
215
  let childProcess: ChildProcess | null = null;
146
216
  try {
147
217
  childProcess = spawn(process.execPath, [
148
- process.argv[1], // quest-dev script path
218
+ process.argv[1],
149
219
  'stay-awake-watchdog',
150
220
  '--parent-pid', process.pid.toString(),
151
- '--original-timeout', originalTimeout.toString()
221
+ '--pin', pin,
152
222
  ], {
153
223
  detached: true,
154
- stdio: 'ignore'
224
+ stdio: 'ignore',
155
225
  });
156
-
157
- childProcess.unref(); // Allow parent to exit without waiting for child
226
+ childProcess.unref();
158
227
  } catch (error) {
159
228
  console.warn('Failed to spawn watchdog child process');
160
229
  }
161
230
 
162
- // Wake screen and disable proximity sensor
231
+ // Enable test mode
232
+ try {
233
+ await setTestProperties(pin, true);
234
+ console.log('Test mode enabled — guardian, dialogs, and autosleep disabled');
235
+ } catch (error) {
236
+ console.error('Failed to enable test mode:', (error as Error).message);
237
+ console.error('Requires Quest OS v44+ and a valid Meta Store PIN.');
238
+ process.exit(1);
239
+ }
240
+
241
+ // Wake screen
163
242
  try {
164
243
  await wakeScreen();
165
244
  console.log('Quest screen woken up');
166
-
167
- await disableProximitySensor();
168
- console.log('Proximity sensor disabled');
169
245
  } catch (error) {
170
- console.error('Failed to wake screen or disable proximity sensor:', (error as Error).message);
246
+ console.error('Failed to wake screen:', (error as Error).message);
171
247
  }
172
248
 
173
- // Set timeout to 24 hours (86400000ms)
174
- const longTimeout = 86400000;
249
+ // Battery monitoring state
250
+ let lastReportedBucket = -1; // Track 5% boundary crossings
251
+
252
+ // Initial battery check
175
253
  try {
176
- await setScreenTimeout(longTimeout);
177
- console.log(`Screen timeout set to 24 hours`);
178
- console.log(`Quest will stay awake (idle timeout: ${Math.round(idleTimeout / 1000)}s). Press Ctrl-C to restore original settings.`);
254
+ const battery = await getBatteryInfo();
255
+ console.log(`Battery: ${formatBatteryInfo(battery)}`);
256
+ lastReportedBucket = Math.floor(battery.level / 5) * 5;
179
257
  } catch (error) {
180
- console.error('Failed to set screen timeout');
181
- process.exit(1);
258
+ console.warn('Failed to read battery status');
182
259
  }
183
260
 
184
- // Idle timer mechanism
261
+ console.log(`Quest will stay awake (idle timeout: ${Math.round(idleTimeout / 1000)}s, low battery exit: ${lowBattery}%). Press Ctrl-C to restore.`);
262
+
263
+ // Idle timer
185
264
  let idleTimerHandle: NodeJS.Timeout | null = null;
186
265
  let cleanupInProgress = false;
187
266
 
@@ -193,62 +272,71 @@ export async function stayAwakeCommand(idleTimeout: number = 300000): Promise<vo
193
272
  }, idleTimeout);
194
273
  };
195
274
 
196
- // Set up cleanup on exit (must be synchronous for signal handlers)
275
+ // Cleanup handler
197
276
  const cleanup = () => {
198
- if (cleanupInProgress) return; // Guard against double-cleanup
277
+ if (cleanupInProgress) return;
199
278
  cleanupInProgress = true;
200
279
 
201
- // Clear idle timer
202
280
  if (idleTimerHandle) clearTimeout(idleTimerHandle);
281
+ if (batteryInterval) clearInterval(batteryInterval);
203
282
 
204
- // Kill child watchdog
205
283
  if (childProcess) {
206
- try {
207
- childProcess.kill();
208
- } catch {}
284
+ try { childProcess.kill(); } catch {}
209
285
  }
210
286
 
211
- console.log('\nRestoring original settings...');
287
+ console.log('\nRestoring settings...');
212
288
  try {
213
- // Remove PID file
214
- try {
215
- fs.unlinkSync(pidFilePath);
216
- } catch {}
217
-
218
- // Restore Quest settings
219
- execSync(`adb shell settings put system screen_off_timeout ${originalTimeout}`, { stdio: 'ignore' });
220
- execSync(`adb shell am broadcast -a com.oculus.vrpowermanager.automation_disable`, { stdio: 'ignore' });
221
- console.log(`Screen timeout restored to ${originalTimeout}ms (${Math.round(originalTimeout / 1000)}s)`);
222
- console.log(`Proximity sensor re-enabled`);
289
+ try { fs.unlinkSync(pidFilePath); } catch {}
290
+
291
+ const args = buildSetPropertyArgs(pin, false);
292
+ execSync(`adb ${args.join(' ')}`, { stdio: 'ignore' });
293
+ console.log('Test mode disabled — guardian, dialogs, and autosleep restored');
223
294
  } catch (error) {
224
295
  console.error('Failed to restore settings:', (error as Error).message);
225
296
  }
226
297
  process.exit(0);
227
298
  };
228
299
 
229
- // Handle Ctrl-C and termination
300
+ // Signal handlers
230
301
  process.on('SIGINT', cleanup);
231
302
  process.on('SIGTERM', cleanup);
232
303
  process.on('SIGHUP', cleanup);
233
304
 
234
- // Handle SIGUSR1 for activity reset
305
+ // Activity reset via SIGUSR1
235
306
  process.on('SIGUSR1', () => {
236
- console.log('Activity detected, resetting idle timer');
307
+ const now = new Date().toLocaleTimeString();
308
+ console.log(`[${now}] Activity detected, resetting idle timer`);
237
309
  resetIdleTimer();
238
310
  });
239
311
 
240
312
  // Start idle timer
241
313
  resetIdleTimer();
242
314
 
243
- // Keep process running with an interval that does nothing
244
- console.log('Keeping Quest awake...');
245
- setInterval(() => {
246
- // Do nothing, just keep process alive
247
- }, 60000); // Check every minute
315
+ // Battery monitoring loop (every 60s)
316
+ const batteryInterval = setInterval(async () => {
317
+ try {
318
+ const battery = await getBatteryInfo();
319
+ const currentBucket = Math.floor(battery.level / 5) * 5;
320
+
321
+ if (verbose) {
322
+ console.log(`Battery: ${formatBatteryInfo(battery)}`);
323
+ } else if (currentBucket !== lastReportedBucket) {
324
+ console.log(`Battery: ${formatBatteryInfo(battery)}`);
325
+ }
326
+ lastReportedBucket = currentBucket;
327
+
328
+ if (battery.level <= lowBattery && battery.state === 'not charging') {
329
+ console.log(`\nBattery critically low (${battery.level}%), exiting to preserve battery...`);
330
+ cleanup();
331
+ }
332
+ } catch {
333
+ // Ignore battery check failures (device might be briefly unavailable)
334
+ }
335
+ }, 60000);
248
336
 
249
- // Prevent process from exiting
337
+ // Keep process alive
338
+ console.log('Keeping Quest awake...');
250
339
  await new Promise<void>((resolve) => {
251
- // This will only resolve when cleanup is called
252
340
  process.on('exit', () => resolve());
253
341
  });
254
342
  }
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ import { screenshotCommand } from './commands/screenshot.js';
14
14
  import { openCommand } from './commands/open.js';
15
15
  import { startCommand, stopCommand, statusCommand, tailCommand } from './commands/logcat.js';
16
16
  import { batteryCommand } from './commands/battery.js';
17
- import { stayAwakeCommand, stayAwakeWatchdog } from './commands/stay-awake.js';
17
+ import { stayAwakeCommand, stayAwakeWatchdog, stayAwakeStatus, stayAwakeDisable } from './commands/stay-awake.js';
18
18
 
19
19
  // Read version from package.json
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -155,17 +155,52 @@ cli.command(
155
155
  // Stay-awake command
156
156
  cli.command(
157
157
  'stay-awake',
158
- 'Keep Quest screen awake (sets 24hr timeout, restores on Ctrl-C)',
158
+ 'Keep Quest awake (disables autosleep, guardian, dialogs)',
159
159
  (yargs) => {
160
- return yargs.option('idle-timeout', {
161
- describe: 'Idle timeout in milliseconds (default: 300000 = 5 minutes)',
162
- type: 'number',
163
- default: 300000,
164
- alias: 'i'
165
- });
160
+ return yargs
161
+ .option('pin', {
162
+ describe: 'Meta Store PIN (or set in .quest-dev.json / ~/.config/quest-dev/config.json)',
163
+ type: 'string',
164
+ })
165
+ .option('idle-timeout', {
166
+ describe: 'Idle timeout in milliseconds (default: 300000 = 5 minutes, or set idleTimeout in config)',
167
+ type: 'number',
168
+ alias: 'i',
169
+ })
170
+ .option('low-battery', {
171
+ describe: 'Exit when battery drops to this percentage (default: 10, or set lowBattery in config)',
172
+ type: 'number',
173
+ })
174
+ .option('disable', {
175
+ describe: 'Manually restore all test properties and exit',
176
+ type: 'boolean',
177
+ default: false,
178
+ })
179
+ .option('status', {
180
+ describe: 'Show current property values and exit',
181
+ type: 'boolean',
182
+ default: false,
183
+ })
184
+ .option('verbose', {
185
+ describe: 'Print battery level on every check (every 60s)',
186
+ type: 'boolean',
187
+ default: false,
188
+ alias: 'v',
189
+ });
166
190
  },
167
191
  async (argv) => {
168
- await stayAwakeCommand(argv.idleTimeout as number);
192
+ if (argv.status) {
193
+ await stayAwakeStatus();
194
+ } else if (argv.disable) {
195
+ await stayAwakeDisable(argv.pin as string | undefined);
196
+ } else {
197
+ await stayAwakeCommand(
198
+ argv.pin as string | undefined,
199
+ argv.idleTimeout as number | undefined,
200
+ argv.lowBattery as number | undefined,
201
+ argv.verbose as boolean,
202
+ );
203
+ }
169
204
  }
170
205
  );
171
206
 
@@ -177,15 +212,15 @@ cli.command(
177
212
  return yargs
178
213
  .option('parent-pid', {
179
214
  type: 'number',
180
- demandOption: true
215
+ demandOption: true,
181
216
  })
182
- .option('original-timeout', {
183
- type: 'number',
184
- demandOption: true
217
+ .option('pin', {
218
+ type: 'string',
219
+ demandOption: true,
185
220
  });
186
221
  },
187
222
  async (argv) => {
188
- await stayAwakeWatchdog(argv.parentPid as number, argv.originalTimeout as number);
223
+ await stayAwakeWatchdog(argv.parentPid as number, argv.pin as string);
189
224
  }
190
225
  );
191
226
 
package/src/utils/adb.ts CHANGED
@@ -345,18 +345,21 @@ export async function checkQuestAwake(): Promise<void> {
345
345
  }
346
346
  }
347
347
 
348
+ export interface BatteryInfo {
349
+ level: number;
350
+ state: 'fast charging' | 'charging' | 'not charging';
351
+ }
352
+
348
353
  /**
349
- * Get Quest battery status
350
- * Returns percentage and charging state in one line
354
+ * Get Quest battery info as structured data
351
355
  */
352
- export async function getBatteryStatus(): Promise<string> {
356
+ export async function getBatteryInfo(): Promise<BatteryInfo> {
353
357
  const result = await execCommandFull('adb', ['shell', 'dumpsys', 'battery']);
354
358
 
355
359
  if (result.code !== 0) {
356
360
  throw new Error('Failed to get battery status');
357
361
  }
358
362
 
359
- // Parse battery info
360
363
  let level = 0;
361
364
  let acPowered = false;
362
365
  let usbPowered = false;
@@ -376,18 +379,19 @@ export async function getBatteryStatus(): Promise<string> {
376
379
  }
377
380
  }
378
381
 
379
- // Determine charging state
380
- let state: string;
382
+ let state: BatteryInfo['state'];
381
383
  if (acPowered || usbPowered) {
382
- // Fast charging is typically > 2A (2000000 microamps)
383
- if (maxChargingCurrent > 2000000) {
384
- state = 'fast charging';
385
- } else {
386
- state = 'charging';
387
- }
384
+ state = maxChargingCurrent > 2000000 ? 'fast charging' : 'charging';
388
385
  } else {
389
386
  state = 'not charging';
390
387
  }
391
388
 
392
- return `${level}% ${state}`;
389
+ return { level, state };
390
+ }
391
+
392
+ /**
393
+ * Format battery info as a human-readable string
394
+ */
395
+ export function formatBatteryInfo(info: BatteryInfo): string {
396
+ return `${info.level}% ${info.state}`;
393
397
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Config file loading for quest-dev
3
+ * Resolves settings from CLI flags → .quest-dev.json → ~/.config/quest-dev/config.json
4
+ */
5
+
6
+ import { readFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { homedir } from 'os';
9
+
10
+ export interface QuestDevConfig {
11
+ pin?: string;
12
+ idleTimeout?: number;
13
+ lowBattery?: number;
14
+ }
15
+
16
+ const CONFIG_LOCATIONS = [
17
+ join(process.cwd(), '.quest-dev.json'),
18
+ join(homedir(), '.config', 'quest-dev', 'config.json'),
19
+ ];
20
+
21
+ function tryReadConfig(path: string): QuestDevConfig | null {
22
+ try {
23
+ const content = readFileSync(path, 'utf-8');
24
+ return JSON.parse(content);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Load merged config from all config file locations.
32
+ * First file found wins for each field.
33
+ */
34
+ export function loadConfig(): QuestDevConfig {
35
+ const merged: QuestDevConfig = {};
36
+
37
+ for (const path of CONFIG_LOCATIONS) {
38
+ const config = tryReadConfig(path);
39
+ if (!config) continue;
40
+ if (merged.pin === undefined && config.pin) merged.pin = config.pin;
41
+ if (merged.idleTimeout === undefined && config.idleTimeout !== undefined) merged.idleTimeout = config.idleTimeout;
42
+ if (merged.lowBattery === undefined && config.lowBattery !== undefined) merged.lowBattery = config.lowBattery;
43
+ }
44
+
45
+ return merged;
46
+ }
47
+
48
+ /**
49
+ * Resolve PIN from CLI flag, then config files
50
+ */
51
+ export function loadPin(cliPin?: string): string {
52
+ if (cliPin) return cliPin;
53
+
54
+ const config = loadConfig();
55
+ if (config.pin) return config.pin;
56
+
57
+ console.error('Error: No PIN found');
58
+ console.error('');
59
+ console.error('Provide a PIN via one of:');
60
+ console.error(' --pin <pin> CLI flag');
61
+ console.error(' .quest-dev.json { "pin": "1234" }');
62
+ console.error(' ~/.config/quest-dev/config.json { "pin": "1234" }');
63
+ console.error('');
64
+ console.error('The PIN is your Meta Store PIN for the logged-in account.');
65
+ process.exit(1);
66
+ }
package/src/utils/exec.ts CHANGED
@@ -17,7 +17,6 @@ export function execCommand(command: string, args: string[] = []): Promise<strin
17
17
  return new Promise((resolve, reject) => {
18
18
  const proc = spawn(command, args, {
19
19
  stdio: 'pipe',
20
- shell: true
21
20
  });
22
21
 
23
22
  let stdout = '';
@@ -54,7 +53,6 @@ export function execCommandFull(command: string, args: string[] = []): Promise<E
54
53
  return new Promise((resolve) => {
55
54
  const proc = spawn(command, args, {
56
55
  stdio: 'pipe',
57
- shell: true
58
56
  });
59
57
 
60
58
  let stdout = '';