@narumitw/pi-caffeinate 0.31.0 → 0.38.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 +13 -7
- package/package.json +4 -1
- package/src/caffeinate.ts +182 -73
- package/src/settings.ts +93 -52
package/README.md
CHANGED
|
@@ -59,7 +59,8 @@ If no supported inhibitor is available, the extension stays loaded and reports t
|
|
|
59
59
|
/caffeinate
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Opens keep-awake controls
|
|
62
|
+
Opens standard keep-awake controls in TUI or RPC mode. Print and JSON modes reject the interactive
|
|
63
|
+
menu observably; use the direct `status`, `sleep`, `display`, `stop`, or `help` routes instead.
|
|
63
64
|
|
|
64
65
|
```text
|
|
65
66
|
/caffeinate display
|
|
@@ -83,7 +84,7 @@ Shows whether an inhibitor is active, unavailable, disabled, or idle. The status
|
|
|
83
84
|
/caffeinate mode
|
|
84
85
|
```
|
|
85
86
|
|
|
86
|
-
Opens
|
|
87
|
+
Opens the standard keep-awake mode selector in TUI or RPC mode. Escape closes the selector.
|
|
87
88
|
|
|
88
89
|
```text
|
|
89
90
|
/caffeinate stop
|
|
@@ -119,11 +120,16 @@ when omitted. The file is read at startup and on `/reload`; run `/reload` after
|
|
|
119
120
|
running Pi session before using mode commands.
|
|
120
121
|
|
|
121
122
|
Missing, invalid, or deleted settings default back to `display` mode with quiet mode disabled on
|
|
122
|
-
every supported OS.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
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
|
|
124
|
+
JSON or an invalid recognized field blocks mode saves until repaired instead of being overwritten.
|
|
125
|
+
A failed save keeps the prior runtime mode; if restarting an active inhibitor fails after publication,
|
|
126
|
+
the extension restores the prior saved mode and inhibitor behavior or reports an explicit rollback
|
|
127
|
+
failure.
|
|
128
|
+
|
|
129
|
+
Compatibility: older versions used `pi-caffeinate-settings.json`. A legacy-only file remains
|
|
130
|
+
readable with a warning and is never modified automatically; rename it to `pi-caffeinate.json`.
|
|
131
|
+
The first subsequent settings save writes the canonical file. If both files exist,
|
|
132
|
+
`pi-caffeinate.json` wins and the legacy file is ignored. The legacy filename is deprecated
|
|
127
133
|
and will be removed in a future major release.
|
|
128
134
|
|
|
129
135
|
### Environment variables
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-caffeinate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Pi extension that keeps the computer awake while the agent is running.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,5 +38,8 @@
|
|
|
38
38
|
"type": "git",
|
|
39
39
|
"url": "https://github.com/narumiruna/pi-extensions",
|
|
40
40
|
"directory": "extensions/pi-caffeinate"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@narumitw/pi-tui-kit": "<1"
|
|
41
44
|
}
|
|
42
45
|
}
|
package/src/caffeinate.ts
CHANGED
|
@@ -5,9 +5,16 @@ import type {
|
|
|
5
5
|
ExtensionCommandContext,
|
|
6
6
|
ExtensionContext,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
8
9
|
import { startInhibitorProcess, stopInhibitorProcess } from "./inhibitor-process.js";
|
|
9
10
|
import { formatMode, getInhibitorCommand, type InhibitorCommand } from "./inhibitors.js";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
type CaffeinateMode,
|
|
13
|
+
type CaffeinateSettings,
|
|
14
|
+
loadSettings,
|
|
15
|
+
saveSettings,
|
|
16
|
+
settingsFilePath,
|
|
17
|
+
} from "./settings.js";
|
|
11
18
|
|
|
12
19
|
const STATUS_KEY = "caffeinate";
|
|
13
20
|
const DISABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
@@ -49,6 +56,8 @@ interface CaffeinateState {
|
|
|
49
56
|
settingsError?: string;
|
|
50
57
|
settingsNotice?: string;
|
|
51
58
|
iconWarningShown: boolean;
|
|
59
|
+
sessionGeneration: number;
|
|
60
|
+
menuController: AbortController;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
const state: CaffeinateState = {
|
|
@@ -59,19 +68,26 @@ const state: CaffeinateState = {
|
|
|
59
68
|
quiet: false,
|
|
60
69
|
settingsLoaded: false,
|
|
61
70
|
iconWarningShown: false,
|
|
71
|
+
sessionGeneration: 0,
|
|
72
|
+
menuController: new AbortController(),
|
|
62
73
|
};
|
|
63
74
|
|
|
64
75
|
export default function caffeinate(pi: ExtensionAPI) {
|
|
65
76
|
pi.on("session_start", async (_event, ctx) => {
|
|
77
|
+
const generation = ++state.sessionGeneration;
|
|
78
|
+
replaceMenuController("Caffeinate session replaced");
|
|
66
79
|
state.iconWarningShown = false;
|
|
67
80
|
state.settingsNotice = undefined;
|
|
68
81
|
warnDeprecatedIcon(ctx);
|
|
69
|
-
await loadSettingsIntoState(ctx);
|
|
82
|
+
await loadSettingsIntoState(ctx, generation);
|
|
83
|
+
if (generation !== state.sessionGeneration) return;
|
|
70
84
|
updateStatus(ctx);
|
|
71
85
|
});
|
|
72
86
|
|
|
73
87
|
pi.on("agent_start", async (_event, ctx) => {
|
|
74
|
-
|
|
88
|
+
const generation = state.sessionGeneration;
|
|
89
|
+
await ensureSettingsLoaded(ctx, generation);
|
|
90
|
+
if (generation !== state.sessionGeneration) return;
|
|
75
91
|
state.activeTurns += 1;
|
|
76
92
|
startInhibitor(ctx, { notify: !state.quiet });
|
|
77
93
|
});
|
|
@@ -84,27 +100,37 @@ export default function caffeinate(pi: ExtensionAPI) {
|
|
|
84
100
|
updateStatus(ctx);
|
|
85
101
|
});
|
|
86
102
|
|
|
87
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
103
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
104
|
+
state.sessionGeneration += 1;
|
|
105
|
+
state.menuController.abort(new DOMException("Caffeinate session shut down", "AbortError"));
|
|
88
106
|
state.activeTurns = 0;
|
|
89
107
|
stopInhibitor(ctx, "session shutdown", { notify: false });
|
|
90
108
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
109
|
+
await modeOperationQueue;
|
|
91
110
|
});
|
|
92
111
|
|
|
93
112
|
pi.registerCommand("caffeinate", {
|
|
94
113
|
description: "Open pi-caffeinate keep-awake controls",
|
|
95
114
|
getArgumentCompletions: (prefix) => commandCompletions(prefix),
|
|
96
115
|
handler: async (args, ctx) => {
|
|
97
|
-
|
|
98
|
-
|
|
116
|
+
const generation = state.sessionGeneration;
|
|
117
|
+
const command = parseCommand(args);
|
|
118
|
+
if (command === "sleep" || command === "display") {
|
|
119
|
+
await setModeAfterSettingsLoad(ctx, command, generation);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await ensureSettingsLoaded(ctx, generation);
|
|
123
|
+
if (generation !== state.sessionGeneration) return;
|
|
124
|
+
await handleCaffeinateCommand(args, ctx, generation);
|
|
99
125
|
},
|
|
100
126
|
});
|
|
101
127
|
}
|
|
102
128
|
|
|
103
|
-
async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
|
|
129
|
+
async function handleCaffeinateCommand(args: string, ctx: CommandContext, generation: number) {
|
|
104
130
|
const command = parseCommand(args);
|
|
105
131
|
switch (command) {
|
|
106
132
|
case "menu":
|
|
107
|
-
await showMenu(ctx);
|
|
133
|
+
await showMenu(ctx, generation);
|
|
108
134
|
return;
|
|
109
135
|
case "help":
|
|
110
136
|
ctx.ui.notify(buildCommandGuide(), "info");
|
|
@@ -113,13 +139,13 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
|
|
|
113
139
|
showStatus(ctx);
|
|
114
140
|
return;
|
|
115
141
|
case "mode":
|
|
116
|
-
await showModeSelector(ctx);
|
|
142
|
+
await showModeSelector(ctx, generation);
|
|
117
143
|
return;
|
|
118
144
|
case "sleep":
|
|
119
|
-
await setMode(ctx, "sleep");
|
|
145
|
+
await setMode(ctx, "sleep", generation);
|
|
120
146
|
return;
|
|
121
147
|
case "display":
|
|
122
|
-
await setMode(ctx, "display");
|
|
148
|
+
await setMode(ctx, "display", generation);
|
|
123
149
|
return;
|
|
124
150
|
case "stop":
|
|
125
151
|
stopCaffeinate(ctx, "manual stop");
|
|
@@ -129,81 +155,163 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
|
|
|
129
155
|
ctx.ui.notify(`Unknown /caffeinate command: ${args.trim()}\n\n${buildCommandGuide()}`, "warning");
|
|
130
156
|
}
|
|
131
157
|
|
|
132
|
-
async function showMenu(ctx: CommandContext) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
updateStatus(ctx);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
158
|
+
async function showMenu(ctx: CommandContext, generation: number) {
|
|
159
|
+
await runCaffeinateMenu(ctx, generation, "main");
|
|
160
|
+
}
|
|
138
161
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
case MENU_OPTIONS.status:
|
|
142
|
-
showStatus(ctx);
|
|
143
|
-
return;
|
|
144
|
-
case MENU_OPTIONS.sleep:
|
|
145
|
-
await setMode(ctx, "sleep");
|
|
146
|
-
return;
|
|
147
|
-
case MENU_OPTIONS.display:
|
|
148
|
-
await setMode(ctx, "display");
|
|
149
|
-
return;
|
|
150
|
-
case MENU_OPTIONS.stop:
|
|
151
|
-
stopCaffeinate(ctx, "manual stop");
|
|
152
|
-
return;
|
|
153
|
-
case MENU_OPTIONS.help:
|
|
154
|
-
ctx.ui.notify(buildCommandGuide(), "info");
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
162
|
+
async function showModeSelector(ctx: CommandContext, generation: number) {
|
|
163
|
+
await runCaffeinateMenu(ctx, generation, "mode");
|
|
157
164
|
}
|
|
158
165
|
|
|
159
|
-
async function
|
|
166
|
+
async function runCaffeinateMenu(ctx: CommandContext, generation: number, start: "main" | "mode") {
|
|
160
167
|
if (!ctx.hasUI) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
throw new Error(
|
|
169
|
+
start === "mode"
|
|
170
|
+
? "Mode selection requires TUI or RPC mode. Use /caffeinate sleep or /caffeinate display."
|
|
171
|
+
: `The pi-caffeinate menu requires TUI or RPC mode.\n\n${buildCommandGuide()}\n\n${describeState()}`,
|
|
164
172
|
);
|
|
165
|
-
updateStatus(ctx);
|
|
166
|
-
return;
|
|
167
173
|
}
|
|
174
|
+
type Screen = "main" | "mode";
|
|
175
|
+
type Action = "display" | "sleep" | "status" | "stop" | "help";
|
|
176
|
+
const menu = defineMenu<undefined, Screen, Action>({
|
|
177
|
+
start,
|
|
178
|
+
screens: {
|
|
179
|
+
main: () => ({
|
|
180
|
+
kind: "actions",
|
|
181
|
+
title: "pi-caffeinate controls",
|
|
182
|
+
lines: describeState().split("\n"),
|
|
183
|
+
items: Object.entries(MENU_OPTIONS).map(([id, label]) => ({
|
|
184
|
+
id,
|
|
185
|
+
label,
|
|
186
|
+
action: id as Action,
|
|
187
|
+
})),
|
|
188
|
+
hint: "close",
|
|
189
|
+
}),
|
|
190
|
+
mode: () => ({
|
|
191
|
+
kind: "actions",
|
|
192
|
+
title: `pi-caffeinate mode (current: ${formatMode(state.mode)})`,
|
|
193
|
+
items: Object.entries(MODE_OPTIONS).map(([id, label]) => ({
|
|
194
|
+
id,
|
|
195
|
+
label,
|
|
196
|
+
action: id as Action,
|
|
197
|
+
})),
|
|
198
|
+
hint: "close",
|
|
199
|
+
}),
|
|
200
|
+
},
|
|
201
|
+
actions: {
|
|
202
|
+
display: async () => {
|
|
203
|
+
await setMode(ctx, "display", generation);
|
|
204
|
+
return { kind: "close" };
|
|
205
|
+
},
|
|
206
|
+
sleep: async () => {
|
|
207
|
+
await setMode(ctx, "sleep", generation);
|
|
208
|
+
return { kind: "close" };
|
|
209
|
+
},
|
|
210
|
+
status: async () => {
|
|
211
|
+
showStatus(ctx);
|
|
212
|
+
return { kind: "close" };
|
|
213
|
+
},
|
|
214
|
+
stop: async () => {
|
|
215
|
+
stopCaffeinate(ctx, "manual stop");
|
|
216
|
+
return { kind: "close" };
|
|
217
|
+
},
|
|
218
|
+
help: async () => {
|
|
219
|
+
ctx.ui.notify(buildCommandGuide(), "info");
|
|
220
|
+
return { kind: "close" };
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
await runMenu(ctx, menu, {
|
|
225
|
+
getState: () => undefined,
|
|
226
|
+
signal: state.menuController.signal,
|
|
227
|
+
isCurrent: () => generation === state.sessionGeneration,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
168
230
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
231
|
+
function replaceMenuController(reason: string) {
|
|
232
|
+
state.menuController.abort(new DOMException(reason, "AbortError"));
|
|
233
|
+
state.menuController = new AbortController();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let modeOperationQueue = Promise.resolve();
|
|
237
|
+
|
|
238
|
+
function setModeAfterSettingsLoad(
|
|
239
|
+
ctx: ExtensionContext,
|
|
240
|
+
mode: CaffeinateMode,
|
|
241
|
+
generation: number,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
return enqueueModeOperation(async () => {
|
|
244
|
+
await ensureSettingsLoaded(ctx, generation);
|
|
245
|
+
if (generation !== state.sessionGeneration) return;
|
|
246
|
+
await setModeNow(ctx, mode, generation);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function setMode(ctx: ExtensionContext, mode: CaffeinateMode, generation: number): Promise<void> {
|
|
251
|
+
return enqueueModeOperation(() => setModeNow(ctx, mode, generation));
|
|
180
252
|
}
|
|
181
253
|
|
|
182
|
-
|
|
254
|
+
function enqueueModeOperation(operation: () => Promise<void>): Promise<void> {
|
|
255
|
+
const queued = modeOperationQueue.then(operation);
|
|
256
|
+
modeOperationQueue = queued.catch(() => undefined);
|
|
257
|
+
return queued;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function setModeNow(ctx: ExtensionContext, mode: CaffeinateMode, generation: number) {
|
|
261
|
+
if (generation !== state.sessionGeneration) return;
|
|
183
262
|
const previousMode = state.mode;
|
|
184
|
-
|
|
263
|
+
const previousQuiet = state.quiet;
|
|
264
|
+
const restartRequired = Boolean(state.process && previousMode !== mode && !state.command?.custom);
|
|
185
265
|
state.settingsError = undefined;
|
|
186
266
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
267
|
+
if (restartRequired) {
|
|
268
|
+
state.mode = mode;
|
|
269
|
+
stopInhibitor(ctx, "mode changed", { notify: false });
|
|
270
|
+
startInhibitor(ctx, { notify: false });
|
|
271
|
+
if (!state.process) {
|
|
272
|
+
const applicationError = state.lastError ?? "the inhibitor could not be restarted";
|
|
273
|
+
state.mode = previousMode;
|
|
274
|
+
startInhibitor(ctx, { notify: false });
|
|
275
|
+
const rollbackError = state.process
|
|
276
|
+
? undefined
|
|
277
|
+
: (state.lastError ?? "the previous inhibitor could not be restored");
|
|
278
|
+
state.settingsError = rollbackError
|
|
279
|
+
? `mode application failed (${applicationError}); runtime rollback failed: ${rollbackError}`
|
|
280
|
+
: `mode application failed and was rolled back: ${applicationError}`;
|
|
281
|
+
ctx.ui.notify(`pi-caffeinate ${state.settingsError}`, "warning");
|
|
282
|
+
updateStatus(ctx);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
194
285
|
}
|
|
195
286
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
287
|
+
let savedSettings: CaffeinateSettings;
|
|
288
|
+
try {
|
|
289
|
+
savedSettings = await saveSettings({ mode, updatedAt: Date.now() });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (generation !== state.sessionGeneration) return;
|
|
292
|
+
let rollbackError: string | undefined;
|
|
293
|
+
if (restartRequired) {
|
|
294
|
+
state.mode = previousMode;
|
|
295
|
+
state.quiet = previousQuiet;
|
|
296
|
+
stopInhibitor(ctx, "settings save failed", { notify: false });
|
|
297
|
+
startInhibitor(ctx, { notify: false });
|
|
298
|
+
if (!state.process) rollbackError = state.lastError ?? "the prior inhibitor was not restored";
|
|
299
|
+
}
|
|
300
|
+
state.settingsError = rollbackError
|
|
301
|
+
? `settings save failed (${formatError(error)}); runtime rollback failed: ${rollbackError}`
|
|
302
|
+
: `settings save failed: ${formatError(error)}`;
|
|
303
|
+
ctx.ui.notify(
|
|
304
|
+
`pi-caffeinate mode remains ${formatMode(previousMode)}; settings were not saved: ${state.settingsError}`,
|
|
305
|
+
"warning",
|
|
306
|
+
);
|
|
307
|
+
updateStatus(ctx);
|
|
308
|
+
return;
|
|
199
309
|
}
|
|
200
310
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
saved ? "info" : "warning",
|
|
206
|
-
);
|
|
311
|
+
if (generation !== state.sessionGeneration) return;
|
|
312
|
+
state.mode = mode;
|
|
313
|
+
state.quiet = savedSettings.quiet;
|
|
314
|
+
ctx.ui.notify(`pi-caffeinate mode set to ${formatMode(mode)} and saved.`, "info");
|
|
207
315
|
updateStatus(ctx);
|
|
208
316
|
}
|
|
209
317
|
|
|
@@ -382,12 +490,12 @@ function statusModeLabel() {
|
|
|
382
490
|
return formatMode(state.mode);
|
|
383
491
|
}
|
|
384
492
|
|
|
385
|
-
async function ensureSettingsLoaded(ctx: ExtensionContext) {
|
|
493
|
+
async function ensureSettingsLoaded(ctx: ExtensionContext, generation: number) {
|
|
386
494
|
if (state.disabled || state.settingsLoaded) return;
|
|
387
|
-
await loadSettingsIntoState(ctx);
|
|
495
|
+
await loadSettingsIntoState(ctx, generation);
|
|
388
496
|
}
|
|
389
497
|
|
|
390
|
-
async function loadSettingsIntoState(ctx: ExtensionContext) {
|
|
498
|
+
async function loadSettingsIntoState(ctx: ExtensionContext, generation: number) {
|
|
391
499
|
if (state.disabled) {
|
|
392
500
|
state.settingsLoaded = true;
|
|
393
501
|
state.settingsError = undefined;
|
|
@@ -396,6 +504,7 @@ async function loadSettingsIntoState(ctx: ExtensionContext) {
|
|
|
396
504
|
}
|
|
397
505
|
|
|
398
506
|
const settings = await loadSettings();
|
|
507
|
+
if (generation !== state.sessionGeneration) return;
|
|
399
508
|
state.settingsLoaded = true;
|
|
400
509
|
state.settingsError = undefined;
|
|
401
510
|
if (settings.notice) {
|
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,6 +106,16 @@ 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 };
|
|
@@ -127,13 +133,48 @@ function isCaffeinateMode(value: unknown): value is CaffeinateMode {
|
|
|
127
133
|
return value === "sleep" || value === "display";
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
|
|
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> {
|
|
131
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
|
+
};
|
|
132
166
|
await mkdir(dirname(filePath), { recursive: true });
|
|
133
|
-
const tempFile = `${filePath}.${process.pid}.${
|
|
167
|
+
const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
134
168
|
try {
|
|
135
|
-
await
|
|
136
|
-
|
|
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;
|
|
137
178
|
} catch (error) {
|
|
138
179
|
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
139
180
|
throw error;
|
|
@@ -149,7 +190,7 @@ function legacySettingsFilePath() {
|
|
|
149
190
|
}
|
|
150
191
|
|
|
151
192
|
function agentDir() {
|
|
152
|
-
return
|
|
193
|
+
return getAgentDir();
|
|
153
194
|
}
|
|
154
195
|
|
|
155
196
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|