@devvoid/voidshift-awake 0.2.0 → 0.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.
package/README.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # VoidShift
2
2
 
3
- One CLI for the Void dev tools. Install it once, run `voidshift`, pick the
4
- tools you want, and it sets each one up with good defaults you can change.
3
+ One CLI for the Void dev tools. Install it once, run `vsf` (short for
4
+ `voidshift`), pick the tools you want, and it sets each one up with good
5
+ defaults you can change. Everything setup does can be seen and changed later
6
+ from the command line.
5
7
 
6
8
  > Early release. macOS only for now; Windows and Linux are next.
7
9
 
8
10
  ```sh
9
11
  npm i -g @devvoid/voidshift
10
- voidshift # pick tools and set them up
11
- voidshift --defaults # take every default, ask only what has none
12
+ vsf setup # pick tools and set them up
13
+ vsf setup --defaults # take every default, ask only what has none
14
+ vsf # what every tool is doing (first run: setup)
15
+ vsf help # every command; vsf <tool> help for a tool's
12
16
  ```
13
17
 
14
18
  ## Tools
@@ -22,9 +26,10 @@ voidshift --defaults # take every default, ask only what has none
22
26
  ### awake
23
27
 
24
28
  ```sh
25
- voidshift awake on [--until 7am | --for 6h] [--battery 20]
26
- voidshift awake status
27
- voidshift awake off
29
+ vsf awake on [--until 7am | --for 6h] [--battery 20]
30
+ vsf awake status
31
+ vsf awake logs [-f] # what the watchdog decided
32
+ vsf awake off
28
33
  ```
29
34
 
30
35
  Setup asks for your Mac password once to add a rule that allows only
@@ -58,9 +63,18 @@ your checkout is never touched, and labels the card as it goes (`vs-dev-v1`,
58
63
  `vs-qa-v1`, then `vs-qa-verified` or `vs-parked` with a comment saying why).
59
64
 
60
65
  ```sh
61
- voidshift agents status # workflows, projects, runner
62
- voidshift agents run ENG-123 # run one card now, in this terminal
63
- voidshift agents start | stop # the background runner
66
+ vsf agents status # runner, the card in progress, parked cards
67
+ vsf agents run ENG-123 [--from qa] # run one card now, in this terminal
68
+ vsf agents start | stop | restart # the background runner (stop cancels the card)
69
+ vsf agents pause | resume # pick up no new cards for a while
70
+ vsf agents cancel | retry ENG-123 # stop a card, or queue a parked one again
71
+ vsf agents runs | show ENG-123 # what happened, step by step
72
+ vsf agents logs [ENG-123] [-f] # the runner log, or a card's step logs
73
+ vsf agents workflows # workflows in the order they match
74
+ vsf agents workflow add|edit|show|rename|move|rm
75
+ vsf agents projects | project add|edit|show|rename|rm
76
+ vsf agents homes | link DIR # where the agents are installed
77
+ vsf agents clean # old worktrees and run logs
64
78
  ```
65
79
 
66
80
  ### memory
@@ -73,8 +87,11 @@ decisions and their why, dead ends, things that are not obvious from the code.
73
87
  Background agents read it for each card and save what they learn.
74
88
 
75
89
  ```sh
76
- voidshift memory status # the vault and where recall is on
77
- voidshift memory search WORDS # notes recall would bring up
90
+ vsf memory status # the vault and where recall is on
91
+ vsf memory search WORDS # notes recall would bring up
92
+ vsf memory try "a prompt" # exactly what recall would add to that prompt
93
+ vsf memory check # notes missing from INDEX.md, broken links
94
+ vsf memory on | off # turn recall on or off
78
95
  ```
79
96
 
80
97
  Setup creates a vault at `~/memory` or uses one you already have. Codex asks you
@@ -91,9 +108,15 @@ to trust the recall hook once: run `/hooks` in Codex.
91
108
  - **Your config** lives in `~/.config/voidshift`.
92
109
 
93
110
  ```sh
94
- voidshift doctor # check everything is set up, and whether an update is out
95
- voidshift update # update VoidShift and every tool you added
96
- voidshift tools # list tools
111
+ vsf doctor # check everything is set up, and whether an update is out
112
+ vsf config [tool] # every saved setting
113
+ vsf config set KEY VALUE # e.g. vsf config set agents.models.dev opus
114
+ vsf config edit [tool] # edit settings in $EDITOR, checked before saving
115
+ vsf update # update VoidShift and every tool you added
116
+ vsf tools # list tools
117
+
118
+ Read commands take `--json`. Workflow and project names can be shortened to
119
+ any unique start, e.g. `tether` for `tether-relay-build`.
97
120
  ```
98
121
 
99
122
  ## Development
@@ -0,0 +1,8 @@
1
+ export type AwakeConfig = {
2
+ battery: number;
3
+ heat: boolean;
4
+ revertOnReboot: boolean;
5
+ };
6
+ export declare function validateConfig(value: Record<string, unknown>): string[];
7
+ export declare function readConfig(configDir: string): AwakeConfig;
8
+ export declare function writeConfig(configDir: string, value: Record<string, unknown>): string[];
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ import { DEFAULT_BATTERY_FLOOR, readState, writeState } from "./state.js";
2
+ const KEYS = ['battery', 'heat', 'revertOnReboot'];
3
+ // Returns the problems with a proposed config change; empty means it is fine.
4
+ export function validateConfig(value) {
5
+ const problems = [];
6
+ for (const [key, v] of Object.entries(value)) {
7
+ if (!KEYS.includes(key))
8
+ problems.push(`Unknown setting "${key}". Known: ${KEYS.join(', ')}.`);
9
+ else if (key === 'battery' && !(typeof v === 'number' && Number.isInteger(v) && v >= 5 && v <= 95)) {
10
+ problems.push('battery must be a whole number from 5 to 95.');
11
+ }
12
+ else if (key !== 'battery' && typeof v !== 'boolean')
13
+ problems.push(`${key} must be true or false.`);
14
+ }
15
+ return problems;
16
+ }
17
+ export function readConfig(configDir) {
18
+ const s = readState(configDir);
19
+ return { battery: s.batteryFloor, heat: s.heat !== false, revertOnReboot: s.revertOnReboot !== false };
20
+ }
21
+ export function writeConfig(configDir, value) {
22
+ const problems = validateConfig(value);
23
+ if (problems.length)
24
+ return problems;
25
+ const state = readState(configDir);
26
+ // The written value is the whole config: a missing key goes back to its default.
27
+ state.batteryFloor = value.battery ?? DEFAULT_BATTERY_FLOOR;
28
+ state.heat = value.heat;
29
+ state.revertOnReboot = value.revertOnReboot;
30
+ writeState(configDir, state);
31
+ return [];
32
+ }
package/dist/decide.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface AwakeState {
5
5
  until?: string;
6
6
  batteryFloor: number;
7
7
  lastReason?: string;
8
+ heat?: boolean;
9
+ revertOnReboot?: boolean;
8
10
  }
9
11
  export interface SystemReading {
10
12
  sleepDisabled: boolean;
package/dist/decide.js CHANGED
@@ -4,9 +4,9 @@ export function decide(state, sys, now) {
4
4
  if (!state.managed)
5
5
  return { action: 'keep' };
6
6
  if (!sys.sleepDisabled)
7
- return { action: 'forget', reason: 'sleep was turned back on outside voidshift' };
7
+ return { action: 'forget', reason: 'sleep was turned back on outside VoidShift' };
8
8
  const since = state.since ? new Date(state.since) : null;
9
- if (since && sys.bootTime && sys.bootTime > since)
9
+ if (state.revertOnReboot !== false && since && sys.bootTime && sys.bootTime > since)
10
10
  return { action: 'revert', reason: 'the Mac restarted' };
11
11
  if (state.until && now >= new Date(state.until))
12
12
  return { action: 'revert', reason: 'the timer ended' };
@@ -14,7 +14,7 @@ export function decide(state, sys, now) {
14
14
  if (onBattery && percent !== null && percent <= state.batteryFloor) {
15
15
  return { action: 'revert', reason: `battery reached ${percent}%` };
16
16
  }
17
- if (sys.thermalSerious)
17
+ if (state.heat !== false && sys.thermalSerious)
18
18
  return { action: 'revert', reason: 'the Mac is running too hot' };
19
19
  return { action: 'keep' };
20
20
  }
package/dist/index.js CHANGED
@@ -1,22 +1,63 @@
1
- import { rmSync } from 'node:fs';
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, rmSync, statSync } from 'node:fs';
2
2
  import { parseArgs } from 'node:util';
3
+ import { readConfig, writeConfig } from "./config.js";
3
4
  import { decide } from "./decide.js";
4
5
  import { logPath, readState, stateDir, writeState } from "./state.js";
5
- import { SUDOERS_PATH, installSudoers, loadWatchdog, readSystem, removeSudoers, ruleInstalled, ruleWorks, setSleepDisabled, unloadWatchdog, watchdogLoaded, } from "./system.js";
6
- import { parseFor, parseUntil } from "./time.js";
6
+ import { SUDOERS_PATH, installSudoers, loadWatchdog, plistPath, readSystem, removeSudoers, ruleInstalled, ruleWorks, setSleepDisabled, unloadWatchdog, watchdogLoaded, } from "./system.js";
7
+ import { programPaths } from "./plist.js";
8
+ import { formatLeft, minutesLeft, parseFor, parseUntil } from "./time.js";
7
9
  const clock = (d) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
8
10
  function ensureWatchdog(ctx) {
9
11
  return watchdogLoaded() || loadWatchdog(ctx.home, [...ctx.selfCommand, 'awake', 'tick'], logPath(ctx.configDir));
10
12
  }
13
+ const USAGE = {
14
+ on: 'Usage: vsf awake on [--until TIME | --for DURATION] [--battery PERCENT]\n Keep the Mac running with the lid closed. TIME is like 7am or 19:30, DURATION like 6h.',
15
+ off: 'Usage: vsf awake off\n Turn sleep back on.',
16
+ status: 'Usage: vsf awake status [--json]\n Show whether the Mac is being kept awake, and the guardrails.',
17
+ logs: 'Usage: vsf awake logs [-f | --follow] [--lines N]\n Show the last N (default 50) watchdog log lines. -f keeps printing new ones.',
18
+ help: 'Usage: vsf awake help [COMMAND]\n Show usage for one command, or the list.',
19
+ };
20
+ const summary = () => Object.values(USAGE).map((u) => u.split('\n')[0]).join('\n');
21
+ const bad = (ctx, command, e) => {
22
+ ctx.log(e instanceof Error ? e.message.split(/\. To specify|\n/)[0] : String(e));
23
+ ctx.log(USAGE[command]);
24
+ return 2;
25
+ };
26
+ async function help(args, ctx) {
27
+ const name = args.find((a) => !a.startsWith('-'));
28
+ if (!name)
29
+ ctx.log(summary());
30
+ else if (USAGE[name])
31
+ ctx.log(USAGE[name]);
32
+ else {
33
+ ctx.log(`No command called "${name}".`);
34
+ ctx.log(summary());
35
+ return 2;
36
+ }
37
+ return 0;
38
+ }
11
39
  async function on(args, ctx) {
12
- const { values } = parseArgs({
13
- args,
14
- options: { until: { type: 'string' }, for: { type: 'string' }, battery: { type: 'string' } },
15
- });
40
+ let values;
41
+ try {
42
+ ({ values } = parseArgs({
43
+ args,
44
+ options: { until: { type: 'string' }, for: { type: 'string' }, battery: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
45
+ allowPositionals: true,
46
+ strict: true,
47
+ }));
48
+ }
49
+ catch (e) {
50
+ return bad(ctx, 'on', e);
51
+ }
52
+ if (values.help) {
53
+ ctx.log(USAGE.on);
54
+ return 0;
55
+ }
16
56
  const now = new Date();
17
57
  let until = null;
18
58
  if (values.until && values.for) {
19
59
  ctx.log('Use --until or --for, not both.');
60
+ ctx.log(USAGE.on);
20
61
  return 2;
21
62
  }
22
63
  if (values.until)
@@ -25,54 +66,181 @@ async function on(args, ctx) {
25
66
  until = parseFor(values.for, now);
26
67
  if ((values.until || values.for) && !until) {
27
68
  ctx.log('Could not read that time. Try --until 7am, --until 19:30 or --for 6h.');
69
+ ctx.log(USAGE.on);
28
70
  return 2;
29
71
  }
30
- const floor = values.battery ? Number(values.battery) : readState(ctx.configDir).batteryFloor;
72
+ const prior = readState(ctx.configDir);
73
+ const floor = values.battery ? Number(values.battery) : prior.batteryFloor;
31
74
  if (!Number.isInteger(floor) || floor < 5 || floor > 95) {
32
75
  ctx.log('--battery takes a whole percentage between 5 and 95.');
76
+ ctx.log(USAGE.on);
33
77
  return 2;
34
78
  }
79
+ const already = prior.managed && readSystem().sleepDisabled;
35
80
  if (!setSleepDisabled(true)) {
36
- ctx.log('Could not turn sleep off. Run voidshift add awake first.');
81
+ ctx.log('Could not turn sleep off. Run vsf add awake first.');
37
82
  return 1;
38
83
  }
39
84
  writeState(ctx.configDir, {
85
+ ...prior,
40
86
  managed: true,
41
- since: now.toISOString(),
42
- until: until?.toISOString(),
87
+ since: already && prior.since ? prior.since : now.toISOString(),
88
+ // Changing only --battery on a running session keeps its end time.
89
+ until: until ? until.toISOString() : already ? prior.until : undefined,
43
90
  batteryFloor: floor,
44
91
  });
45
92
  if (!ensureWatchdog(ctx))
46
- ctx.log('Warning: the watchdog is not running, so guardrails will not act. Run voidshift doctor.');
47
- const stop = until ? `at ${clock(until)} or at ${floor}% battery` : `at ${floor}% battery`;
48
- ctx.log(`Awake. The lid can close. Stops ${stop}, or with voidshift awake off.`);
49
- ctx.log('Keep it out of a bag: a closed Mac with no airflow runs hot.');
93
+ ctx.log('Warning: the watchdog is not running, so guardrails will not act. Run vsf doctor.');
94
+ const endsAt = until ?? (already && prior.until ? new Date(prior.until) : null);
95
+ const stop = endsAt ? `at ${clock(endsAt)} or at ${floor}% battery` : `at ${floor}% battery`;
96
+ ctx.log(already ? `Updated. Stops ${stop}, or with vsf awake off.` : `Awake. The lid can close. Stops ${stop}, or with vsf awake off.`);
97
+ if (!already)
98
+ ctx.log('Keep it out of a bag: a closed Mac with no airflow runs hot.');
50
99
  return 0;
51
100
  }
52
- async function off(_args, ctx) {
101
+ async function off(args, ctx) {
102
+ try {
103
+ const { values } = parseArgs({ args, options: { help: { type: 'boolean', short: 'h' } }, allowPositionals: true, strict: true });
104
+ if (values.help) {
105
+ ctx.log(USAGE.off);
106
+ return 0;
107
+ }
108
+ }
109
+ catch (e) {
110
+ return bad(ctx, 'off', e);
111
+ }
53
112
  const ok = setSleepDisabled(false);
54
113
  writeState(ctx.configDir, { ...readState(ctx.configDir), managed: false, lastReason: 'you turned it off' });
55
114
  ctx.log(ok ? 'Sleep restored.' : 'Could not restore sleep. Run: sudo pmset -a disablesleep 0');
56
115
  return ok ? 0 : 1;
57
116
  }
58
- async function status(_args, ctx) {
117
+ async function status(args, ctx) {
118
+ let values;
119
+ try {
120
+ ({ values } = parseArgs({
121
+ args,
122
+ options: { json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
123
+ allowPositionals: true,
124
+ strict: true,
125
+ }));
126
+ }
127
+ catch (e) {
128
+ return bad(ctx, 'status', e);
129
+ }
130
+ if (values.help) {
131
+ ctx.log(USAGE.status);
132
+ return 0;
133
+ }
59
134
  const state = readState(ctx.configDir);
60
135
  const sys = readSystem();
136
+ const on = state.managed && sys.sleepDisabled;
137
+ const left = on && state.until ? minutesLeft(new Date(state.until), new Date()) : null;
138
+ const loaded = watchdogLoaded();
139
+ if (values.json) {
140
+ ctx.log(JSON.stringify({
141
+ on,
142
+ sleepDisabledByOther: sys.sleepDisabled && !state.managed,
143
+ since: on ? (state.since ?? null) : null,
144
+ until: on ? (state.until ?? null) : null,
145
+ minutesLeft: left,
146
+ batteryFloor: state.batteryFloor,
147
+ floorApplies: sys.battery.onBattery,
148
+ power: { onBattery: sys.battery.onBattery, percent: sys.battery.percent },
149
+ watchdog: loaded,
150
+ lastReason: state.lastReason ?? null,
151
+ heat: state.heat !== false,
152
+ revertOnReboot: state.revertOnReboot !== false,
153
+ }, null, 2));
154
+ return 0;
155
+ }
61
156
  const power = sys.battery.onBattery ? `on battery, ${sys.battery.percent ?? '?'}%` : 'on power adapter';
62
- if (state.managed && sys.sleepDisabled) {
157
+ if (on) {
63
158
  const since = state.since ? clock(new Date(state.since)) : '?';
64
159
  const stop = state.until ? `at ${clock(new Date(state.until))} or at ${state.batteryFloor}% battery` : `at ${state.batteryFloor}% battery`;
65
160
  ctx.log(`Awake since ${since}. Stops ${stop}. Mac is ${power}.`);
66
161
  }
67
162
  else if (sys.sleepDisabled) {
68
- ctx.log(`Sleep is turned off, but not by voidshift. Mac is ${power}.`);
163
+ ctx.log(`Sleep is turned off, but not by VoidShift. Mac is ${power}.`);
69
164
  }
70
165
  else {
71
166
  const last = state.lastReason ? ` Last session ended because ${state.lastReason}.` : '';
72
167
  ctx.log(`Off. The Mac sleeps normally.${last}`);
73
168
  }
169
+ if (left !== null)
170
+ ctx.log(`Time left: ${formatLeft(left)}.`);
171
+ if (!sys.battery.onBattery)
172
+ ctx.log(`Battery floor ${state.batteryFloor}% applies on battery only; the Mac is on power now.`);
173
+ if (!loaded)
174
+ ctx.log('Watchdog: not loaded, guardrails will not act. vsf setup awake fixes it.');
74
175
  return 0;
75
176
  }
177
+ async function logs(args, ctx) {
178
+ let values;
179
+ try {
180
+ ({ values } = parseArgs({
181
+ args,
182
+ options: { follow: { type: 'boolean', short: 'f' }, lines: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
183
+ allowPositionals: true,
184
+ strict: true,
185
+ }));
186
+ }
187
+ catch (e) {
188
+ return bad(ctx, 'logs', e);
189
+ }
190
+ if (values.help) {
191
+ ctx.log(USAGE.logs);
192
+ return 0;
193
+ }
194
+ const n = values.lines === undefined ? 50 : Number(values.lines);
195
+ if (!Number.isInteger(n) || n < 1) {
196
+ ctx.log('--lines takes a whole number, 1 or more.');
197
+ ctx.log(USAGE.logs);
198
+ return 2;
199
+ }
200
+ const path = logPath(ctx.configDir);
201
+ if (!existsSync(path)) {
202
+ ctx.log('No watchdog log yet.');
203
+ return 0;
204
+ }
205
+ const text = readFileSync(path, 'utf8');
206
+ const lines = text.split('\n');
207
+ if (lines[lines.length - 1] === '')
208
+ lines.pop();
209
+ for (const l of lines.slice(-n))
210
+ ctx.log(l);
211
+ if (!values.follow)
212
+ return 0;
213
+ // Poll for appended bytes until Ctrl+C. A shrunk file means it was rotated.
214
+ let offset = Buffer.byteLength(text);
215
+ return new Promise((resolve) => {
216
+ const timer = setInterval(() => {
217
+ try {
218
+ const size = statSync(path).size;
219
+ if (size < offset)
220
+ offset = 0;
221
+ if (size === offset)
222
+ return;
223
+ const fd = openSync(path, 'r');
224
+ const buf = Buffer.alloc(size - offset);
225
+ readSync(fd, buf, 0, buf.length, offset);
226
+ closeSync(fd);
227
+ offset = size;
228
+ const chunk = buf.toString('utf8').split('\n');
229
+ if (chunk[chunk.length - 1] === '')
230
+ chunk.pop();
231
+ for (const l of chunk)
232
+ ctx.log(l);
233
+ }
234
+ catch {
235
+ // The log may be briefly missing; try again next second.
236
+ }
237
+ }, 1000);
238
+ process.once('SIGINT', () => {
239
+ clearInterval(timer);
240
+ resolve(0);
241
+ });
242
+ });
243
+ }
76
244
  // Run by launchd every minute.
77
245
  async function tick(_args, ctx) {
78
246
  const state = readState(ctx.configDir);
@@ -108,7 +276,7 @@ const awake = {
108
276
  }
109
277
  if (!ensureWatchdog(ctx))
110
278
  throw new Error('could not start the watchdog');
111
- ctx.log(`Ready. voidshift awake on keeps the Mac running until ${floor}% battery or voidshift awake off.`);
279
+ ctx.log(`Ready. vsf awake on keeps the Mac running until ${floor}% battery or vsf awake off.`);
112
280
  },
113
281
  async uninstall(ctx) {
114
282
  const state = readState(ctx.configDir);
@@ -125,21 +293,29 @@ const awake = {
125
293
  checks.push(ruleInstalled() && works
126
294
  ? { name: 'awake rule', status: 'ok', detail: SUDOERS_PATH }
127
295
  : works
128
- ? { name: 'awake rule', status: 'warn', detail: "works only through another tool's sudoers rule", fix: 'voidshift add awake' }
129
- : { name: 'awake rule', status: 'fail', detail: 'cannot turn sleep off without a password', fix: 'voidshift add awake' });
296
+ ? { name: 'awake rule', status: 'warn', detail: "works only through another tool's sudoers rule", fix: 'vsf setup awake' }
297
+ : { name: 'awake rule', status: 'fail', detail: 'cannot turn sleep off without a password', fix: 'vsf setup awake' });
130
298
  checks.push(watchdogLoaded()
131
299
  ? { name: 'awake watchdog', status: 'ok', detail: 'running every minute' }
132
- : { name: 'awake watchdog', status: 'fail', detail: 'not loaded, guardrails will not act', fix: 'voidshift add awake' });
300
+ : { name: 'awake watchdog', status: 'fail', detail: 'not loaded, guardrails will not act', fix: 'vsf setup awake' });
301
+ const plist = plistPath(ctx.home);
302
+ if (existsSync(plist)) {
303
+ const missing = programPaths(readFileSync(plist, 'utf8')).find((p) => !existsSync(p));
304
+ checks.push(missing
305
+ ? { name: 'awake program', status: 'fail', detail: `watchdog points at a missing ${missing}`, fix: 'vsf setup awake' }
306
+ : { name: 'awake program', status: 'ok', detail: 'watchdog program exists' });
307
+ }
133
308
  const state = readState(ctx.configDir);
134
309
  const sys = readSystem();
135
310
  if (sys.sleepDisabled && !state.managed) {
136
- checks.push({ name: 'awake state', status: 'warn', detail: 'sleep is off, but not by voidshift', fix: 'Another tool may be holding it. sudo pmset -a disablesleep 0 resets it' });
311
+ checks.push({ name: 'awake state', status: 'warn', detail: 'sleep is off, but not by VoidShift', fix: 'Another tool may be holding it. sudo pmset -a disablesleep 0 resets it' });
137
312
  }
138
313
  else {
139
314
  checks.push({ name: 'awake state', status: 'ok', detail: state.managed ? 'on' : 'off' });
140
315
  }
141
316
  return checks;
142
317
  },
143
- commands: { on, off, status, tick },
318
+ config: { read: readConfig, write: writeConfig },
319
+ commands: { on, off, status, logs, help, tick },
144
320
  };
145
321
  export default awake;
@@ -0,0 +1 @@
1
+ export declare function programPaths(plist: string): string[];
package/dist/plist.js ADDED
@@ -0,0 +1,9 @@
1
+ // Absolute paths in the watchdog's ProgramArguments, so doctor can spot a
2
+ // plist that points at something that was moved or removed.
3
+ export function programPaths(plist) {
4
+ const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist);
5
+ if (!array)
6
+ return [];
7
+ const unxml = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
8
+ return [...array[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1])).filter((v) => v.startsWith('/'));
9
+ }
package/dist/time.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export declare function parseUntil(input: string, now: Date): Date | null;
2
2
  export declare function parseFor(input: string, now: Date): Date | null;
3
+ export declare function minutesLeft(until: Date, now: Date): number;
4
+ export declare function formatLeft(minutes: number): string;
package/dist/time.js CHANGED
@@ -35,3 +35,12 @@ export function parseFor(input, now) {
35
35
  return null;
36
36
  return new Date(now.getTime() + minutes * 60_000);
37
37
  }
38
+ // Whole minutes until a time, rounded up, never negative.
39
+ export function minutesLeft(until, now) {
40
+ return Math.max(0, Math.ceil((until.getTime() - now.getTime()) / 60_000));
41
+ }
42
+ export function formatLeft(minutes) {
43
+ const h = Math.floor(minutes / 60);
44
+ const m = minutes % 60;
45
+ return h && m ? `${h}h ${m}m` : h ? `${h}h` : `${m}m`;
46
+ }
package/dist/types.d.ts CHANGED
@@ -44,4 +44,8 @@ export interface Tool {
44
44
  uninstall(ctx: ToolContext): Promise<void>;
45
45
  checks(ctx: ToolContext): Promise<Check[]>;
46
46
  commands?: Record<string, ToolCommand>;
47
+ config?: {
48
+ read(configDir: string): Record<string, unknown>;
49
+ write(configDir: string, value: Record<string, unknown>): string[];
50
+ };
47
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devvoid/voidshift-awake",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Keep a Mac running on battery with the lid closed, with guardrails.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {