@narumitw/pi-caffeinate 0.28.0 → 0.35.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 +10 -5
- package/package.json +3 -3
- package/src/caffeinate.ts +119 -52
- package/src/inhibitors.ts +23 -4
- package/src/settings.ts +98 -53
package/README.md
CHANGED
|
@@ -119,11 +119,16 @@ when omitted. The file is read at startup and on `/reload`; run `/reload` after
|
|
|
119
119
|
running Pi session before using mode commands.
|
|
120
120
|
|
|
121
121
|
Missing, invalid, or deleted settings default back to `display` mode with quiet mode disabled on
|
|
122
|
-
every supported OS.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
every supported OS. A missing file stays absent until the first successful mode change. Within one Pi process, mode saves run in invocation order, reread the latest valid document, and preserve unknown fields. Malformed
|
|
123
|
+
JSON or an invalid recognized field blocks mode saves until repaired instead of being overwritten.
|
|
124
|
+
A failed save keeps the prior runtime mode; if restarting an active inhibitor fails after publication,
|
|
125
|
+
the extension restores the prior saved mode and inhibitor behavior or reports an explicit rollback
|
|
126
|
+
failure.
|
|
127
|
+
|
|
128
|
+
Compatibility: older versions used `pi-caffeinate-settings.json`. A legacy-only file remains
|
|
129
|
+
readable with a warning and is never modified automatically; rename it to `pi-caffeinate.json`.
|
|
130
|
+
The first subsequent settings save writes the canonical file. If both files exist,
|
|
131
|
+
`pi-caffeinate.json` wins and the legacy file is ignored. The legacy filename is deprecated
|
|
127
132
|
and will be removed in a future major release.
|
|
128
133
|
|
|
129
134
|
### Environment variables
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-caffeinate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Pi extension that keeps the computer awake while the agent is running.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@biomejs/biome": "2.5.
|
|
33
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
32
|
+
"@biomejs/biome": "2.5.5",
|
|
33
|
+
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
34
34
|
"@types/node": "26.1.1",
|
|
35
35
|
"typescript": "7.0.2"
|
|
36
36
|
},
|
package/src/caffeinate.ts
CHANGED
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
2
|
import process from "node:process";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionCommandContext,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
3
8
|
import { startInhibitorProcess, stopInhibitorProcess } from "./inhibitor-process.js";
|
|
4
|
-
import {
|
|
5
|
-
formatMode,
|
|
6
|
-
getInhibitorCommand,
|
|
7
|
-
type InhibitorCommand,
|
|
8
|
-
splitCommand,
|
|
9
|
-
windowsInhibitorScript,
|
|
10
|
-
} from "./inhibitors.js";
|
|
9
|
+
import { formatMode, getInhibitorCommand, type InhibitorCommand } from "./inhibitors.js";
|
|
11
10
|
import {
|
|
12
11
|
type CaffeinateMode,
|
|
12
|
+
type CaffeinateSettings,
|
|
13
13
|
loadSettings,
|
|
14
|
-
normalizeCaffeinateSettings,
|
|
15
14
|
saveSettings,
|
|
16
15
|
settingsFilePath,
|
|
17
16
|
} from "./settings.js";
|
|
18
|
-
import type {
|
|
19
|
-
ExtensionAPI,
|
|
20
|
-
ExtensionCommandContext,
|
|
21
|
-
ExtensionContext,
|
|
22
|
-
} from "@earendil-works/pi-coding-agent";
|
|
23
17
|
|
|
24
18
|
const STATUS_KEY = "caffeinate";
|
|
25
19
|
const DISABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
@@ -61,6 +55,7 @@ interface CaffeinateState {
|
|
|
61
55
|
settingsError?: string;
|
|
62
56
|
settingsNotice?: string;
|
|
63
57
|
iconWarningShown: boolean;
|
|
58
|
+
sessionGeneration: number;
|
|
64
59
|
}
|
|
65
60
|
|
|
66
61
|
const state: CaffeinateState = {
|
|
@@ -71,19 +66,24 @@ const state: CaffeinateState = {
|
|
|
71
66
|
quiet: false,
|
|
72
67
|
settingsLoaded: false,
|
|
73
68
|
iconWarningShown: false,
|
|
69
|
+
sessionGeneration: 0,
|
|
74
70
|
};
|
|
75
71
|
|
|
76
72
|
export default function caffeinate(pi: ExtensionAPI) {
|
|
77
73
|
pi.on("session_start", async (_event, ctx) => {
|
|
74
|
+
const generation = ++state.sessionGeneration;
|
|
78
75
|
state.iconWarningShown = false;
|
|
79
76
|
state.settingsNotice = undefined;
|
|
80
77
|
warnDeprecatedIcon(ctx);
|
|
81
|
-
await loadSettingsIntoState(ctx);
|
|
78
|
+
await loadSettingsIntoState(ctx, generation);
|
|
79
|
+
if (generation !== state.sessionGeneration) return;
|
|
82
80
|
updateStatus(ctx);
|
|
83
81
|
});
|
|
84
82
|
|
|
85
83
|
pi.on("agent_start", async (_event, ctx) => {
|
|
86
|
-
|
|
84
|
+
const generation = state.sessionGeneration;
|
|
85
|
+
await ensureSettingsLoaded(ctx, generation);
|
|
86
|
+
if (generation !== state.sessionGeneration) return;
|
|
87
87
|
state.activeTurns += 1;
|
|
88
88
|
startInhibitor(ctx, { notify: !state.quiet });
|
|
89
89
|
});
|
|
@@ -96,28 +96,36 @@ export default function caffeinate(pi: ExtensionAPI) {
|
|
|
96
96
|
updateStatus(ctx);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
99
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
100
|
+
state.sessionGeneration += 1;
|
|
100
101
|
state.activeTurns = 0;
|
|
101
102
|
stopInhibitor(ctx, "session shutdown", { notify: false });
|
|
102
103
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
104
|
+
await modeOperationQueue;
|
|
103
105
|
});
|
|
104
106
|
|
|
105
107
|
pi.registerCommand("caffeinate", {
|
|
106
108
|
description: "Open pi-caffeinate keep-awake controls",
|
|
107
109
|
getArgumentCompletions: (prefix) => commandCompletions(prefix),
|
|
108
110
|
handler: async (args, ctx) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
const generation = state.sessionGeneration;
|
|
112
|
+
const command = parseCommand(args);
|
|
113
|
+
if (command === "sleep" || command === "display") {
|
|
114
|
+
await setModeAfterSettingsLoad(ctx, command, generation);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await ensureSettingsLoaded(ctx, generation);
|
|
118
|
+
if (generation !== state.sessionGeneration) return;
|
|
119
|
+
await handleCaffeinateCommand(args, ctx, generation);
|
|
111
120
|
},
|
|
112
121
|
});
|
|
113
|
-
|
|
114
122
|
}
|
|
115
123
|
|
|
116
|
-
async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
|
|
124
|
+
async function handleCaffeinateCommand(args: string, ctx: CommandContext, generation: number) {
|
|
117
125
|
const command = parseCommand(args);
|
|
118
126
|
switch (command) {
|
|
119
127
|
case "menu":
|
|
120
|
-
await showMenu(ctx);
|
|
128
|
+
await showMenu(ctx, generation);
|
|
121
129
|
return;
|
|
122
130
|
case "help":
|
|
123
131
|
ctx.ui.notify(buildCommandGuide(), "info");
|
|
@@ -126,13 +134,13 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
|
|
|
126
134
|
showStatus(ctx);
|
|
127
135
|
return;
|
|
128
136
|
case "mode":
|
|
129
|
-
await showModeSelector(ctx);
|
|
137
|
+
await showModeSelector(ctx, generation);
|
|
130
138
|
return;
|
|
131
139
|
case "sleep":
|
|
132
|
-
await setMode(ctx, "sleep");
|
|
140
|
+
await setMode(ctx, "sleep", generation);
|
|
133
141
|
return;
|
|
134
142
|
case "display":
|
|
135
|
-
await setMode(ctx, "display");
|
|
143
|
+
await setMode(ctx, "display", generation);
|
|
136
144
|
return;
|
|
137
145
|
case "stop":
|
|
138
146
|
stopCaffeinate(ctx, "manual stop");
|
|
@@ -142,7 +150,7 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
|
|
|
142
150
|
ctx.ui.notify(`Unknown /caffeinate command: ${args.trim()}\n\n${buildCommandGuide()}`, "warning");
|
|
143
151
|
}
|
|
144
152
|
|
|
145
|
-
async function showMenu(ctx: CommandContext) {
|
|
153
|
+
async function showMenu(ctx: CommandContext, generation: number) {
|
|
146
154
|
if (!ctx.hasUI) {
|
|
147
155
|
ctx.ui.notify(`${buildCommandGuide()}\n\n${describeState()}`, statusLevel());
|
|
148
156
|
updateStatus(ctx);
|
|
@@ -150,15 +158,16 @@ async function showMenu(ctx: CommandContext) {
|
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
const choice = await ctx.ui.select("pi-caffeinate controls", Object.values(MENU_OPTIONS));
|
|
161
|
+
if (generation !== state.sessionGeneration) return;
|
|
153
162
|
switch (choice) {
|
|
154
163
|
case MENU_OPTIONS.status:
|
|
155
164
|
showStatus(ctx);
|
|
156
165
|
return;
|
|
157
166
|
case MENU_OPTIONS.sleep:
|
|
158
|
-
await setMode(ctx, "sleep");
|
|
167
|
+
await setMode(ctx, "sleep", generation);
|
|
159
168
|
return;
|
|
160
169
|
case MENU_OPTIONS.display:
|
|
161
|
-
await setMode(ctx, "display");
|
|
170
|
+
await setMode(ctx, "display", generation);
|
|
162
171
|
return;
|
|
163
172
|
case MENU_OPTIONS.stop:
|
|
164
173
|
stopCaffeinate(ctx, "manual stop");
|
|
@@ -169,7 +178,7 @@ async function showMenu(ctx: CommandContext) {
|
|
|
169
178
|
}
|
|
170
179
|
}
|
|
171
180
|
|
|
172
|
-
async function showModeSelector(ctx: CommandContext) {
|
|
181
|
+
async function showModeSelector(ctx: CommandContext, generation: number) {
|
|
173
182
|
if (!ctx.hasUI) {
|
|
174
183
|
ctx.ui.notify(
|
|
175
184
|
`Mode selection needs an interactive UI. Run /caffeinate sleep or /caffeinate display.\n\n${describeState()}`,
|
|
@@ -183,40 +192,95 @@ async function showModeSelector(ctx: CommandContext) {
|
|
|
183
192
|
`pi-caffeinate mode (current: ${formatMode(state.mode)})`,
|
|
184
193
|
Object.values(MODE_OPTIONS),
|
|
185
194
|
);
|
|
195
|
+
if (generation !== state.sessionGeneration) return;
|
|
186
196
|
if (choice === MODE_OPTIONS.sleep) {
|
|
187
|
-
await setMode(ctx, "sleep");
|
|
197
|
+
await setMode(ctx, "sleep", generation);
|
|
188
198
|
return;
|
|
189
199
|
}
|
|
190
200
|
if (choice === MODE_OPTIONS.display) {
|
|
191
|
-
await setMode(ctx, "display");
|
|
201
|
+
await setMode(ctx, "display", generation);
|
|
192
202
|
}
|
|
193
203
|
}
|
|
194
204
|
|
|
195
|
-
|
|
205
|
+
let modeOperationQueue = Promise.resolve();
|
|
206
|
+
|
|
207
|
+
function setModeAfterSettingsLoad(
|
|
208
|
+
ctx: ExtensionContext,
|
|
209
|
+
mode: CaffeinateMode,
|
|
210
|
+
generation: number,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
return enqueueModeOperation(async () => {
|
|
213
|
+
await ensureSettingsLoaded(ctx, generation);
|
|
214
|
+
if (generation !== state.sessionGeneration) return;
|
|
215
|
+
await setModeNow(ctx, mode, generation);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function setMode(ctx: ExtensionContext, mode: CaffeinateMode, generation: number): Promise<void> {
|
|
220
|
+
return enqueueModeOperation(() => setModeNow(ctx, mode, generation));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function enqueueModeOperation(operation: () => Promise<void>): Promise<void> {
|
|
224
|
+
const queued = modeOperationQueue.then(operation);
|
|
225
|
+
modeOperationQueue = queued.catch(() => undefined);
|
|
226
|
+
return queued;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function setModeNow(ctx: ExtensionContext, mode: CaffeinateMode, generation: number) {
|
|
230
|
+
if (generation !== state.sessionGeneration) return;
|
|
196
231
|
const previousMode = state.mode;
|
|
197
|
-
|
|
232
|
+
const previousQuiet = state.quiet;
|
|
233
|
+
const restartRequired = Boolean(state.process && previousMode !== mode && !state.command?.custom);
|
|
198
234
|
state.settingsError = undefined;
|
|
199
235
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
236
|
+
if (restartRequired) {
|
|
237
|
+
state.mode = mode;
|
|
238
|
+
stopInhibitor(ctx, "mode changed", { notify: false });
|
|
239
|
+
startInhibitor(ctx, { notify: false });
|
|
240
|
+
if (!state.process) {
|
|
241
|
+
const applicationError = state.lastError ?? "the inhibitor could not be restarted";
|
|
242
|
+
state.mode = previousMode;
|
|
243
|
+
startInhibitor(ctx, { notify: false });
|
|
244
|
+
const rollbackError = state.process
|
|
245
|
+
? undefined
|
|
246
|
+
: (state.lastError ?? "the previous inhibitor could not be restored");
|
|
247
|
+
state.settingsError = rollbackError
|
|
248
|
+
? `mode application failed (${applicationError}); runtime rollback failed: ${rollbackError}`
|
|
249
|
+
: `mode application failed and was rolled back: ${applicationError}`;
|
|
250
|
+
ctx.ui.notify(`pi-caffeinate ${state.settingsError}`, "warning");
|
|
251
|
+
updateStatus(ctx);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
207
254
|
}
|
|
208
255
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
256
|
+
let savedSettings: CaffeinateSettings;
|
|
257
|
+
try {
|
|
258
|
+
savedSettings = await saveSettings({ mode, updatedAt: Date.now() });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (generation !== state.sessionGeneration) return;
|
|
261
|
+
let rollbackError: string | undefined;
|
|
262
|
+
if (restartRequired) {
|
|
263
|
+
state.mode = previousMode;
|
|
264
|
+
state.quiet = previousQuiet;
|
|
265
|
+
stopInhibitor(ctx, "settings save failed", { notify: false });
|
|
266
|
+
startInhibitor(ctx, { notify: false });
|
|
267
|
+
if (!state.process) rollbackError = state.lastError ?? "the prior inhibitor was not restored";
|
|
268
|
+
}
|
|
269
|
+
state.settingsError = rollbackError
|
|
270
|
+
? `settings save failed (${formatError(error)}); runtime rollback failed: ${rollbackError}`
|
|
271
|
+
: `settings save failed: ${formatError(error)}`;
|
|
272
|
+
ctx.ui.notify(
|
|
273
|
+
`pi-caffeinate mode remains ${formatMode(previousMode)}; settings were not saved: ${state.settingsError}`,
|
|
274
|
+
"warning",
|
|
275
|
+
);
|
|
276
|
+
updateStatus(ctx);
|
|
277
|
+
return;
|
|
212
278
|
}
|
|
213
279
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
saved ? "info" : "warning",
|
|
219
|
-
);
|
|
280
|
+
if (generation !== state.sessionGeneration) return;
|
|
281
|
+
state.mode = mode;
|
|
282
|
+
state.quiet = savedSettings.quiet;
|
|
283
|
+
ctx.ui.notify(`pi-caffeinate mode set to ${formatMode(mode)} and saved.`, "info");
|
|
220
284
|
updateStatus(ctx);
|
|
221
285
|
}
|
|
222
286
|
|
|
@@ -380,7 +444,9 @@ function describeState() {
|
|
|
380
444
|
return lines.join("\n");
|
|
381
445
|
}
|
|
382
446
|
|
|
383
|
-
lines.unshift(
|
|
447
|
+
lines.unshift(
|
|
448
|
+
"pi-caffeinate is idle and will keep the computer awake during the next agent run.",
|
|
449
|
+
);
|
|
384
450
|
return lines.join("\n");
|
|
385
451
|
}
|
|
386
452
|
|
|
@@ -393,12 +459,12 @@ function statusModeLabel() {
|
|
|
393
459
|
return formatMode(state.mode);
|
|
394
460
|
}
|
|
395
461
|
|
|
396
|
-
async function ensureSettingsLoaded(ctx: ExtensionContext) {
|
|
462
|
+
async function ensureSettingsLoaded(ctx: ExtensionContext, generation: number) {
|
|
397
463
|
if (state.disabled || state.settingsLoaded) return;
|
|
398
|
-
await loadSettingsIntoState(ctx);
|
|
464
|
+
await loadSettingsIntoState(ctx, generation);
|
|
399
465
|
}
|
|
400
466
|
|
|
401
|
-
async function loadSettingsIntoState(ctx: ExtensionContext) {
|
|
467
|
+
async function loadSettingsIntoState(ctx: ExtensionContext, generation: number) {
|
|
402
468
|
if (state.disabled) {
|
|
403
469
|
state.settingsLoaded = true;
|
|
404
470
|
state.settingsError = undefined;
|
|
@@ -407,6 +473,7 @@ async function loadSettingsIntoState(ctx: ExtensionContext) {
|
|
|
407
473
|
}
|
|
408
474
|
|
|
409
475
|
const settings = await loadSettings();
|
|
476
|
+
if (generation !== state.sessionGeneration) return;
|
|
410
477
|
state.settingsLoaded = true;
|
|
411
478
|
state.settingsError = undefined;
|
|
412
479
|
if (settings.notice) {
|
package/src/inhibitors.ts
CHANGED
|
@@ -18,7 +18,11 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
|
|
|
18
18
|
if (command) return { command, args, description: `custom command (${command})`, custom: true };
|
|
19
19
|
}
|
|
20
20
|
if (process.platform === "darwin") {
|
|
21
|
-
return parentBoundUnixCommand(
|
|
21
|
+
return parentBoundUnixCommand(
|
|
22
|
+
"caffeinate",
|
|
23
|
+
macCaffeinateArgs(mode),
|
|
24
|
+
caffeinateDescription(mode),
|
|
25
|
+
);
|
|
22
26
|
}
|
|
23
27
|
if (process.platform === "linux") {
|
|
24
28
|
if (isWsl() && commandExists("powershell.exe")) {
|
|
@@ -40,7 +44,11 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
|
|
|
40
44
|
);
|
|
41
45
|
}
|
|
42
46
|
if (commandExists("caffeinate")) {
|
|
43
|
-
return parentBoundUnixCommand(
|
|
47
|
+
return parentBoundUnixCommand(
|
|
48
|
+
"caffeinate",
|
|
49
|
+
macCaffeinateArgs(mode),
|
|
50
|
+
caffeinateDescription(mode),
|
|
51
|
+
);
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
54
|
if (process.platform === "win32") return windowsPowerInhibitorCommand("powershell.exe", mode);
|
|
@@ -55,10 +63,21 @@ function caffeinateDescription(mode: CaffeinateMode) {
|
|
|
55
63
|
return `caffeinate (${formatMode(mode)})`;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
function parentBoundUnixCommand(
|
|
66
|
+
function parentBoundUnixCommand(
|
|
67
|
+
command: string,
|
|
68
|
+
args: string[],
|
|
69
|
+
description: string,
|
|
70
|
+
): InhibitorCommand {
|
|
59
71
|
return {
|
|
60
72
|
command: "sh",
|
|
61
|
-
args: [
|
|
73
|
+
args: [
|
|
74
|
+
"-c",
|
|
75
|
+
unixParentBoundScript(),
|
|
76
|
+
"pi-caffeinate-watch",
|
|
77
|
+
String(process.pid),
|
|
78
|
+
command,
|
|
79
|
+
...args,
|
|
80
|
+
],
|
|
62
81
|
description,
|
|
63
82
|
};
|
|
64
83
|
}
|
package/src/settings.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { constants } from "node:fs";
|
|
2
|
-
import { access,
|
|
3
|
-
import { homedir } from "node:os";
|
|
3
|
+
import { access, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
7
|
|
|
7
8
|
const NEW_SETTINGS_FILE = "pi-caffeinate.json";
|
|
8
9
|
const LEGACY_SETTINGS_FILE = "pi-caffeinate-settings.json";
|
|
@@ -15,17 +16,23 @@ export interface CaffeinateSettings {
|
|
|
15
16
|
updatedAt: number;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
export interface SettingsFileOperations {
|
|
20
|
+
write(path: string, data: string): Promise<void>;
|
|
21
|
+
rename(source: string, destination: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
|
|
25
|
+
write: (path, data) => writeFile(path, data, "utf8").then(() => undefined),
|
|
26
|
+
rename,
|
|
27
|
+
};
|
|
28
|
+
|
|
18
29
|
export type SettingsLoadResult =
|
|
19
30
|
| { kind: "missing"; notice?: string }
|
|
20
31
|
| { kind: "invalid"; reason: string; notice?: string }
|
|
21
32
|
| { kind: "loaded"; settings: CaffeinateSettings; notice?: string };
|
|
22
33
|
|
|
23
|
-
type SettingsMigrationResult = {
|
|
24
|
-
kind: "migrated" | "failed";
|
|
25
|
-
notice: string;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
34
|
export async function loadSettings(): Promise<SettingsLoadResult> {
|
|
35
|
+
await settingsSaveQueue;
|
|
29
36
|
const newPath = settingsFilePath();
|
|
30
37
|
const newSettings = await readSettingsFile(newPath);
|
|
31
38
|
if (newSettings.kind !== "missing") return withLegacyIgnoredNotice(newSettings);
|
|
@@ -39,36 +46,49 @@ export async function loadSettings(): Promise<SettingsLoadResult> {
|
|
|
39
46
|
if (legacySettings.kind === "missing") return { kind: "missing" };
|
|
40
47
|
if (legacySettings.kind === "invalid") return legacySettings;
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return withLegacyIgnoredNotice(settingsCreatedDuringMigration);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return { ...legacySettings, notice: migration.notice };
|
|
49
|
+
return {
|
|
50
|
+
...legacySettings,
|
|
51
|
+
notice: `Using legacy ${LEGACY_SETTINGS_FILE}; rename it to ${NEW_SETTINGS_FILE}. Future saves write ${NEW_SETTINGS_FILE} without modifying the legacy file.`,
|
|
52
|
+
};
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
interface SettingsDocumentResult {
|
|
56
|
+
result: SettingsLoadResult;
|
|
57
|
+
document?: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readSettingsDocument(filePath: string): Promise<SettingsDocumentResult> {
|
|
53
61
|
let text: string;
|
|
54
62
|
try {
|
|
55
63
|
text = await readFile(filePath, "utf8");
|
|
56
64
|
} catch (error) {
|
|
57
|
-
if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
|
|
58
|
-
return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
|
|
65
|
+
if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
|
|
66
|
+
return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
|
|
59
67
|
}
|
|
60
68
|
try {
|
|
61
|
-
const
|
|
62
|
-
|
|
69
|
+
const document = JSON.parse(text) as unknown;
|
|
70
|
+
const settings = normalizeCaffeinateSettings(document);
|
|
71
|
+
if (settings) {
|
|
72
|
+
return {
|
|
73
|
+
result: { kind: "loaded", settings },
|
|
74
|
+
document: { ...(document as Record<string, unknown>) },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
63
77
|
return {
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
result: {
|
|
79
|
+
kind: "invalid",
|
|
80
|
+
reason: `${filePath}: expected { "mode": "sleep" | "display", optional "quiet": boolean }`,
|
|
81
|
+
},
|
|
66
82
|
};
|
|
67
83
|
} catch (error) {
|
|
68
|
-
return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
|
|
84
|
+
return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
|
|
69
85
|
}
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
|
|
89
|
+
return (await readSettingsDocument(filePath)).result;
|
|
90
|
+
}
|
|
91
|
+
|
|
72
92
|
async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
|
|
73
93
|
if (!(await fileExists(legacySettingsFilePath()))) return settings;
|
|
74
94
|
return {
|
|
@@ -77,30 +97,6 @@ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<Se
|
|
|
77
97
|
};
|
|
78
98
|
}
|
|
79
99
|
|
|
80
|
-
async function migrateLegacySettings(legacyPath: string): Promise<SettingsMigrationResult> {
|
|
81
|
-
const newPath = settingsFilePath();
|
|
82
|
-
try {
|
|
83
|
-
await link(legacyPath, newPath);
|
|
84
|
-
} catch (error) {
|
|
85
|
-
return {
|
|
86
|
-
kind: "failed",
|
|
87
|
-
notice: `pi-caffeinate legacy settings migration failed: could not migrate ${legacyPath} to ${newPath}: ${formatError(error)}. The legacy file was used for this session; future saves will write ${NEW_SETTINGS_FILE}.`,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
try {
|
|
91
|
-
await rm(legacyPath, { force: true });
|
|
92
|
-
} catch (error) {
|
|
93
|
-
return {
|
|
94
|
-
kind: "migrated",
|
|
95
|
-
notice: `pi-caffeinate settings migrated from ${legacyPath} to ${newPath}, but the legacy file could not be removed: ${formatError(error)}. Delete ${LEGACY_SETTINGS_FILE} after confirming your settings.`,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
kind: "migrated",
|
|
100
|
-
notice: `pi-caffeinate settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE} is deprecated and will be removed in a future major release.`,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
100
|
async function fileExists(filePath: string) {
|
|
105
101
|
try {
|
|
106
102
|
await access(filePath, constants.F_OK);
|
|
@@ -110,26 +106,75 @@ async function fileExists(filePath: string) {
|
|
|
110
106
|
}
|
|
111
107
|
}
|
|
112
108
|
|
|
109
|
+
async function pathEntryExists(filePath: string) {
|
|
110
|
+
try {
|
|
111
|
+
await lstat(filePath);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
113
119
|
export function normalizeCaffeinateSettings(value: unknown): CaffeinateSettings | undefined {
|
|
114
120
|
if (!value || typeof value !== "object") return undefined;
|
|
115
121
|
const settings = value as { mode?: unknown; quiet?: unknown; updatedAt?: unknown };
|
|
116
122
|
if (!isCaffeinateMode(settings.mode)) return undefined;
|
|
117
123
|
if (settings.quiet !== undefined && typeof settings.quiet !== "boolean") return undefined;
|
|
118
124
|
if (settings.updatedAt !== undefined && typeof settings.updatedAt !== "number") return undefined;
|
|
119
|
-
return {
|
|
125
|
+
return {
|
|
126
|
+
mode: settings.mode,
|
|
127
|
+
quiet: settings.quiet ?? false,
|
|
128
|
+
updatedAt: settings.updatedAt ?? 0,
|
|
129
|
+
};
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
function isCaffeinateMode(value: unknown): value is CaffeinateMode {
|
|
123
133
|
return value === "sleep" || value === "display";
|
|
124
134
|
}
|
|
125
135
|
|
|
126
|
-
|
|
136
|
+
let settingsSaveQueue: Promise<unknown> = Promise.resolve();
|
|
137
|
+
|
|
138
|
+
export function saveSettings(
|
|
139
|
+
settings: Omit<CaffeinateSettings, "quiet"> & { quiet?: boolean },
|
|
140
|
+
operations: Partial<SettingsFileOperations> = {},
|
|
141
|
+
): Promise<CaffeinateSettings> {
|
|
142
|
+
const operation = settingsSaveQueue.then(() => saveSettingsNow(settings, operations));
|
|
143
|
+
settingsSaveQueue = operation.catch(() => undefined);
|
|
144
|
+
return operation;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function saveSettingsNow(
|
|
148
|
+
settings: Omit<CaffeinateSettings, "quiet"> & { quiet?: boolean },
|
|
149
|
+
operations: Partial<SettingsFileOperations>,
|
|
150
|
+
): Promise<CaffeinateSettings> {
|
|
127
151
|
const filePath = settingsFilePath();
|
|
152
|
+
let current = await readSettingsDocument(filePath);
|
|
153
|
+
const replaceCanonical = current.result.kind !== "missing";
|
|
154
|
+
if (!replaceCanonical) current = await readSettingsDocument(legacySettingsFilePath());
|
|
155
|
+
if (current.result.kind === "invalid") {
|
|
156
|
+
throw new Error(`Cannot save pi-caffeinate settings until you repair ${current.result.reason}`);
|
|
157
|
+
}
|
|
158
|
+
const document = current.document ?? {};
|
|
159
|
+
const quiet =
|
|
160
|
+
settings.quiet ?? (current.result.kind === "loaded" ? current.result.settings.quiet : false);
|
|
161
|
+
const nextSettings = { mode: settings.mode, quiet, updatedAt: settings.updatedAt };
|
|
162
|
+
const nextDocument = {
|
|
163
|
+
...document,
|
|
164
|
+
...nextSettings,
|
|
165
|
+
};
|
|
128
166
|
await mkdir(dirname(filePath), { recursive: true });
|
|
129
|
-
const tempFile = `${filePath}.${process.pid}.${
|
|
167
|
+
const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
130
168
|
try {
|
|
131
|
-
await
|
|
132
|
-
|
|
169
|
+
await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
|
|
170
|
+
tempFile,
|
|
171
|
+
`${JSON.stringify(nextDocument, null, 2)}\n`,
|
|
172
|
+
);
|
|
173
|
+
if (!replaceCanonical && (await pathEntryExists(filePath))) {
|
|
174
|
+
throw new Error(`${NEW_SETTINGS_FILE} was created concurrently; reopen settings and retry.`);
|
|
175
|
+
}
|
|
176
|
+
await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(tempFile, filePath);
|
|
177
|
+
return nextSettings;
|
|
133
178
|
} catch (error) {
|
|
134
179
|
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
135
180
|
throw error;
|
|
@@ -145,7 +190,7 @@ function legacySettingsFilePath() {
|
|
|
145
190
|
}
|
|
146
191
|
|
|
147
192
|
function agentDir() {
|
|
148
|
-
return
|
|
193
|
+
return getAgentDir();
|
|
149
194
|
}
|
|
150
195
|
|
|
151
196
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|