@goodnesshq/opencode-notification 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/notify-init.mjs +404 -90
- package/README.md +3 -39
- package/package.json +1 -1
- package/plugins/opencode-notifications.mjs +1 -1
|
@@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir, copyFile, stat } from "node:fs/promises";
|
|
|
3
3
|
import { join, basename, dirname } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import * as readline from "node:readline";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
|
|
8
9
|
const REPO_CONFIG_PATH = ".opencode/oc-notify.json";
|
|
@@ -33,7 +34,7 @@ const DEFAULTS = {
|
|
|
33
34
|
ntfy: {
|
|
34
35
|
enabled: true,
|
|
35
36
|
server: "https://ntfy.sh",
|
|
36
|
-
topic: "
|
|
37
|
+
topic: "",
|
|
37
38
|
},
|
|
38
39
|
},
|
|
39
40
|
overrides: {
|
|
@@ -127,6 +128,246 @@ async function copySchema(cwd) {
|
|
|
127
128
|
} catch {}
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
function isInteractive() {
|
|
132
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
133
|
+
if (process.env.CI) return false;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function promptInput(message) {
|
|
138
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
139
|
+
const answer = await rl.question(message);
|
|
140
|
+
await rl.close();
|
|
141
|
+
return answer;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderPrompt(lines, state) {
|
|
145
|
+
if (state.linesRendered > 0) {
|
|
146
|
+
readline.moveCursor(process.stdout, 0, -(state.linesRendered - 1));
|
|
147
|
+
readline.cursorTo(process.stdout, 0);
|
|
148
|
+
readline.clearScreenDown(process.stdout);
|
|
149
|
+
}
|
|
150
|
+
process.stdout.write(lines.join("\n"));
|
|
151
|
+
state.linesRendered = lines.length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function withRawInput(handler) {
|
|
155
|
+
const stdin = process.stdin;
|
|
156
|
+
readline.emitKeypressEvents(stdin);
|
|
157
|
+
const wasRaw = !!stdin.isRaw;
|
|
158
|
+
const wasPaused = stdin.isPaused();
|
|
159
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
160
|
+
if (wasPaused) stdin.resume();
|
|
161
|
+
try {
|
|
162
|
+
return await handler();
|
|
163
|
+
} finally {
|
|
164
|
+
if (stdin.isTTY) stdin.setRawMode(wasRaw);
|
|
165
|
+
if (wasPaused) stdin.pause();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function promptSelect({ message, choices, defaultValue }) {
|
|
170
|
+
return withRawInput(
|
|
171
|
+
() =>
|
|
172
|
+
new Promise((resolve) => {
|
|
173
|
+
const state = { linesRendered: 0 };
|
|
174
|
+
let cursor = choices.findIndex((choice) => choice.value === defaultValue);
|
|
175
|
+
if (cursor < 0) cursor = 0;
|
|
176
|
+
|
|
177
|
+
const render = () => {
|
|
178
|
+
const lines = [];
|
|
179
|
+
lines.push(message);
|
|
180
|
+
for (let index = 0; index < choices.length; index += 1) {
|
|
181
|
+
const choice = choices[index];
|
|
182
|
+
const pointer = index === cursor ? ">" : " ";
|
|
183
|
+
lines.push(` ${pointer} ${choice.name}`);
|
|
184
|
+
}
|
|
185
|
+
renderPrompt(lines, state);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const finish = () => {
|
|
189
|
+
const choice = choices[cursor];
|
|
190
|
+
const label = choice?.name ?? choice?.value ?? "";
|
|
191
|
+
renderPrompt([`${message} ${label}`], state);
|
|
192
|
+
process.stdout.write("\n");
|
|
193
|
+
cleanup();
|
|
194
|
+
resolve(choice?.value);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const onKey = (_char, key) => {
|
|
198
|
+
if (!key) return;
|
|
199
|
+
if (key.ctrl && key.name === "c") {
|
|
200
|
+
cleanup();
|
|
201
|
+
process.stdout.write("\n");
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (key.name === "up") {
|
|
205
|
+
cursor = Math.max(0, cursor - 1);
|
|
206
|
+
render();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (key.name === "down") {
|
|
210
|
+
cursor = Math.min(choices.length - 1, cursor + 1);
|
|
211
|
+
render();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (key.name === "return" || key.name === "enter") {
|
|
215
|
+
finish();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const cleanup = () => {
|
|
220
|
+
process.stdin.removeListener("keypress", onKey);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
process.stdin.on("keypress", onKey);
|
|
224
|
+
render();
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function promptConfirm(message, defaultValue) {
|
|
230
|
+
return promptSelect({
|
|
231
|
+
message,
|
|
232
|
+
choices: [
|
|
233
|
+
{ name: "Yes", value: true },
|
|
234
|
+
{ name: "No", value: false },
|
|
235
|
+
],
|
|
236
|
+
defaultValue: defaultValue ? true : false,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function promptMultiSelect({
|
|
241
|
+
message,
|
|
242
|
+
choices,
|
|
243
|
+
defaultValues = [],
|
|
244
|
+
pageSize = 12,
|
|
245
|
+
}) {
|
|
246
|
+
return withRawInput(
|
|
247
|
+
() =>
|
|
248
|
+
new Promise((resolve) => {
|
|
249
|
+
const state = { linesRendered: 0 };
|
|
250
|
+
let filter = "";
|
|
251
|
+
let cursor = 0;
|
|
252
|
+
const selected = new Set(defaultValues);
|
|
253
|
+
|
|
254
|
+
const getFiltered = () => {
|
|
255
|
+
if (!filter.trim()) return choices;
|
|
256
|
+
const term = filter.toLowerCase();
|
|
257
|
+
return choices.filter(
|
|
258
|
+
(choice) =>
|
|
259
|
+
choice.name.toLowerCase().includes(term) ||
|
|
260
|
+
choice.value.toLowerCase().includes(term),
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const render = () => {
|
|
265
|
+
const filtered = getFiltered();
|
|
266
|
+
if (cursor > Math.max(0, filtered.length - 1)) cursor = 0;
|
|
267
|
+
|
|
268
|
+
const lines = [];
|
|
269
|
+
lines.push(message);
|
|
270
|
+
lines.push(
|
|
271
|
+
`Filter: ${filter || "(type to filter)"} (${selected.size} selected)`,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (filtered.length === 0) {
|
|
275
|
+
lines.push(" No matches");
|
|
276
|
+
renderPrompt(lines, state);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const startIndex = Math.max(
|
|
281
|
+
0,
|
|
282
|
+
Math.min(
|
|
283
|
+
cursor - Math.floor(pageSize / 2),
|
|
284
|
+
Math.max(0, filtered.length - pageSize),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
const endIndex = Math.min(startIndex + pageSize, filtered.length);
|
|
288
|
+
const visible = filtered.slice(startIndex, endIndex);
|
|
289
|
+
|
|
290
|
+
for (let index = 0; index < visible.length; index += 1) {
|
|
291
|
+
const actualIndex = startIndex + index;
|
|
292
|
+
const choice = visible[index];
|
|
293
|
+
const pointer = actualIndex === cursor ? ">" : " ";
|
|
294
|
+
const checked = selected.has(choice.value) ? "[x]" : "[ ]";
|
|
295
|
+
lines.push(` ${pointer} ${checked} ${choice.name}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
renderPrompt(lines, state);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const finish = () => {
|
|
302
|
+
const ordered = choices
|
|
303
|
+
.filter((choice) => selected.has(choice.value))
|
|
304
|
+
.map((choice) => choice.value);
|
|
305
|
+
const summary = ordered.length > 0 ? ordered.join(", ") : "(none)";
|
|
306
|
+
renderPrompt([`${message} ${summary}`], state);
|
|
307
|
+
process.stdout.write("\n");
|
|
308
|
+
cleanup();
|
|
309
|
+
resolve(ordered);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const onKey = (char, key) => {
|
|
313
|
+
if (!key) return;
|
|
314
|
+
if (key.ctrl && key.name === "c") {
|
|
315
|
+
cleanup();
|
|
316
|
+
process.stdout.write("\n");
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
if (key.name === "up") {
|
|
320
|
+
cursor = Math.max(0, cursor - 1);
|
|
321
|
+
render();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (key.name === "down") {
|
|
325
|
+
cursor = Math.min(getFiltered().length - 1, cursor + 1);
|
|
326
|
+
render();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (key.name === "space") {
|
|
330
|
+
const filtered = getFiltered();
|
|
331
|
+
const choice = filtered[cursor];
|
|
332
|
+
if (choice) {
|
|
333
|
+
if (selected.has(choice.value)) {
|
|
334
|
+
selected.delete(choice.value);
|
|
335
|
+
} else {
|
|
336
|
+
selected.add(choice.value);
|
|
337
|
+
}
|
|
338
|
+
render();
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (key.name === "backspace") {
|
|
343
|
+
if (filter.length > 0) {
|
|
344
|
+
filter = filter.slice(0, -1);
|
|
345
|
+
cursor = 0;
|
|
346
|
+
render();
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (key.name === "return" || key.name === "enter") {
|
|
351
|
+
finish();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (char && char.length === 1 && !key.ctrl && !key.meta) {
|
|
355
|
+
filter += char;
|
|
356
|
+
cursor = 0;
|
|
357
|
+
render();
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const cleanup = () => {
|
|
362
|
+
process.stdin.removeListener("keypress", onKey);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
process.stdin.on("keypress", onKey);
|
|
366
|
+
render();
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
130
371
|
|
|
131
372
|
async function sendTestNotification(config, repoName) {
|
|
132
373
|
const title = config.title || `${repoName}@test`;
|
|
@@ -202,119 +443,189 @@ async function main() {
|
|
|
202
443
|
console.log("Installed .opencode/notify-init.mjs and schema in this repo.");
|
|
203
444
|
return;
|
|
204
445
|
}
|
|
205
|
-
const
|
|
446
|
+
const interactive = isInteractive();
|
|
447
|
+
const rl = interactive ? null : createInterface({ input: process.stdin, output: process.stdout });
|
|
206
448
|
|
|
207
449
|
console.log("OpenCode Notify — per-repo setup\n");
|
|
208
450
|
|
|
209
|
-
const
|
|
210
|
-
"Enable notifications for this repo?
|
|
211
|
-
|
|
212
|
-
const enabled = yesNo(enableInput, true);
|
|
451
|
+
const enabled = interactive
|
|
452
|
+
? await promptConfirm("Enable notifications for this repo?", true)
|
|
453
|
+
: yesNo(await rl.question("Enable notifications for this repo? (Y/n) [Y] "), true);
|
|
213
454
|
if (!enabled) {
|
|
214
|
-
await rl.close();
|
|
455
|
+
if (rl) await rl.close();
|
|
215
456
|
return;
|
|
216
457
|
}
|
|
217
458
|
|
|
218
|
-
const titleInput =
|
|
219
|
-
`Notification title (blank = "${repoName}@<branch>"):
|
|
220
|
-
|
|
459
|
+
const titleInput = interactive
|
|
460
|
+
? await promptInput(`Notification title (blank = "${repoName}@<branch>"): `)
|
|
461
|
+
: await rl.question(`Notification title (blank = "${repoName}@<branch>"): `);
|
|
221
462
|
const title = titleInput.trim();
|
|
222
463
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
464
|
+
const tier = interactive
|
|
465
|
+
? await promptSelect({
|
|
466
|
+
message: "Select a tier:",
|
|
467
|
+
choices: [
|
|
468
|
+
{ name: "Minimal", value: "minimal" },
|
|
469
|
+
{ name: "Standard", value: "standard" },
|
|
470
|
+
{ name: "Verbose", value: "verbose" },
|
|
471
|
+
{ name: "Custom", value: "custom" },
|
|
472
|
+
],
|
|
473
|
+
defaultValue: "standard",
|
|
474
|
+
})
|
|
475
|
+
: await (async () => {
|
|
476
|
+
console.log(
|
|
477
|
+
"\nSelect a tier:\n 1) Minimal\n 2) Standard\n 3) Verbose\n 4) Custom",
|
|
478
|
+
);
|
|
479
|
+
const tierInput = await rl.question("> [2] ");
|
|
480
|
+
return tierInput.trim() === "1"
|
|
481
|
+
? "minimal"
|
|
482
|
+
: tierInput.trim() === "3"
|
|
483
|
+
? "verbose"
|
|
484
|
+
: tierInput.trim() === "4"
|
|
485
|
+
? "custom"
|
|
486
|
+
: "standard";
|
|
487
|
+
})();
|
|
488
|
+
|
|
489
|
+
const trigger = interactive
|
|
490
|
+
? await promptSelect({
|
|
491
|
+
message: "Response-complete trigger:",
|
|
492
|
+
choices: [
|
|
493
|
+
{ name: "session.idle (coarse, low noise)", value: "session.idle" },
|
|
494
|
+
{ name: "message.updated (precise)", value: "message.updated" },
|
|
495
|
+
{
|
|
496
|
+
name: "message.part.updated (very precise, more noise)",
|
|
497
|
+
value: "message.part.updated",
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
defaultValue: "message.updated",
|
|
501
|
+
})
|
|
502
|
+
: await (async () => {
|
|
503
|
+
console.log(
|
|
504
|
+
"\nResponse-complete trigger:\n 1) session.idle (coarse, low noise)\n 2) message.updated (precise)\n 3) message.part.updated (very precise, more noise)",
|
|
505
|
+
);
|
|
506
|
+
const triggerInput = await rl.question("> [2] ");
|
|
507
|
+
return triggerInput.trim() === "1"
|
|
508
|
+
? "session.idle"
|
|
509
|
+
: triggerInput.trim() === "3"
|
|
510
|
+
? "message.part.updated"
|
|
511
|
+
: "message.updated";
|
|
512
|
+
})();
|
|
513
|
+
|
|
514
|
+
const macEnabled = interactive
|
|
515
|
+
? await promptConfirm("Enable macOS Notification Center?", true)
|
|
516
|
+
: yesNo(
|
|
517
|
+
await rl.question(
|
|
518
|
+
"\nEnable macOS Notification Center? (Y/n) [Y] ",
|
|
519
|
+
),
|
|
520
|
+
true,
|
|
521
|
+
);
|
|
251
522
|
|
|
252
|
-
const
|
|
253
|
-
|
|
523
|
+
const ntfyEnabled = interactive
|
|
524
|
+
? await promptConfirm("Enable ntfy notifications?", true)
|
|
525
|
+
: yesNo(await rl.question("Enable ntfy notifications? (Y/n) [Y] "), true);
|
|
254
526
|
|
|
255
527
|
let globalConfig = await readJson(GLOBAL_CONFIG_PATH);
|
|
256
528
|
if (!globalConfig) globalConfig = {};
|
|
257
529
|
if (!globalConfig.ntfy) globalConfig.ntfy = {};
|
|
258
530
|
|
|
259
531
|
if (ntfyEnabled) {
|
|
260
|
-
const serverInput =
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
532
|
+
const serverInput = interactive
|
|
533
|
+
? await promptInput(
|
|
534
|
+
`ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
|
|
535
|
+
)
|
|
536
|
+
: await rl.question(
|
|
537
|
+
`ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
|
|
538
|
+
);
|
|
539
|
+
const topicDefault = globalConfig.ntfy.topic || DEFAULTS.channels.ntfy.topic;
|
|
540
|
+
const topicLabel = topicDefault || "<your-topic>";
|
|
541
|
+
const topicInput = interactive
|
|
542
|
+
? await promptInput(
|
|
543
|
+
`ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
|
|
544
|
+
)
|
|
545
|
+
: await rl.question(
|
|
546
|
+
`ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
|
|
547
|
+
);
|
|
266
548
|
if (serverInput.trim()) globalConfig.ntfy.server = serverInput.trim();
|
|
267
549
|
if (topicInput.trim()) globalConfig.ntfy.topic = topicInput.trim();
|
|
268
550
|
}
|
|
269
551
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
552
|
+
const detailLevel = interactive
|
|
553
|
+
? await promptSelect({
|
|
554
|
+
message: "Detail level:",
|
|
555
|
+
choices: [
|
|
556
|
+
{ name: "full", value: "full" },
|
|
557
|
+
{ name: "title-only", value: "title-only" },
|
|
558
|
+
],
|
|
559
|
+
defaultValue: "full",
|
|
560
|
+
})
|
|
561
|
+
: (await rl.question("Detail level (1=full, 2=title-only) [1] ")).trim() === "2"
|
|
562
|
+
? "title-only"
|
|
563
|
+
: "full";
|
|
564
|
+
|
|
565
|
+
const adjustGroups = interactive
|
|
566
|
+
? await promptConfirm("Adjust event groups?", false)
|
|
567
|
+
: yesNo(await rl.question("\nAdjust event groups? (y/N) [N] "), false);
|
|
279
568
|
let includeGroups = [];
|
|
280
569
|
let excludeGroups = [];
|
|
281
570
|
if (adjustGroups) {
|
|
282
|
-
const advanced =
|
|
283
|
-
await
|
|
284
|
-
false
|
|
285
|
-
);
|
|
571
|
+
const advanced = interactive
|
|
572
|
+
? await promptConfirm("Show advanced groups?", false)
|
|
573
|
+
: yesNo(await rl.question("Show advanced groups? (y/N) [N] "), false);
|
|
286
574
|
const allowed = advanced
|
|
287
575
|
? GROUPS_BASIC.concat(GROUPS_ADVANCED)
|
|
288
576
|
: GROUPS_BASIC;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
577
|
+
if (interactive) {
|
|
578
|
+
const choices = allowed.map((group) => ({ name: group, value: group }));
|
|
579
|
+
includeGroups = await promptMultiSelect({
|
|
580
|
+
message: "Include groups:",
|
|
581
|
+
choices,
|
|
582
|
+
defaultValues: [],
|
|
583
|
+
});
|
|
584
|
+
excludeGroups = await promptMultiSelect({
|
|
585
|
+
message: "Exclude groups:",
|
|
586
|
+
choices,
|
|
587
|
+
defaultValues: [],
|
|
588
|
+
});
|
|
589
|
+
} else {
|
|
590
|
+
console.log(`Available groups: ${allowed.join(", ")}`);
|
|
591
|
+
const includeInput = await rl.question(
|
|
592
|
+
"Include groups (comma-separated, blank to skip): ",
|
|
593
|
+
);
|
|
594
|
+
const excludeInput = await rl.question(
|
|
595
|
+
"Exclude groups (comma-separated, blank to skip): ",
|
|
596
|
+
);
|
|
597
|
+
includeGroups = parseList(includeInput, allowed);
|
|
598
|
+
excludeGroups = parseList(excludeInput, allowed);
|
|
599
|
+
}
|
|
298
600
|
}
|
|
299
601
|
|
|
300
|
-
const installTN =
|
|
301
|
-
await
|
|
302
|
-
|
|
303
|
-
|
|
602
|
+
const installTN = interactive
|
|
603
|
+
? await promptConfirm("Use terminal-notifier if available?", true)
|
|
604
|
+
: yesNo(
|
|
605
|
+
await rl.question(
|
|
606
|
+
"\nUse terminal-notifier if available? (Y/n) [Y] ",
|
|
607
|
+
),
|
|
608
|
+
true,
|
|
609
|
+
);
|
|
304
610
|
|
|
305
611
|
if (installTN) {
|
|
306
612
|
const hasTN = await commandExists("terminal-notifier");
|
|
307
613
|
const hasBrew = await commandExists("brew");
|
|
308
614
|
if (!hasTN && hasBrew) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
615
|
+
const installNow = interactive
|
|
616
|
+
? await promptConfirm(
|
|
617
|
+
"terminal-notifier not found. Install with Homebrew now?",
|
|
618
|
+
false,
|
|
619
|
+
)
|
|
620
|
+
: yesNo(
|
|
621
|
+
await rl.question(
|
|
622
|
+
"terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
|
|
623
|
+
),
|
|
624
|
+
false,
|
|
625
|
+
);
|
|
626
|
+
if (installNow) {
|
|
627
|
+
try {
|
|
628
|
+
const { execSync } = await import("node:child_process");
|
|
318
629
|
execSync("brew install terminal-notifier", { stdio: "inherit" });
|
|
319
630
|
} catch {
|
|
320
631
|
console.log("Homebrew install failed. Falling back to AppleScript.");
|
|
@@ -350,25 +661,28 @@ async function main() {
|
|
|
350
661
|
},
|
|
351
662
|
};
|
|
352
663
|
|
|
353
|
-
const
|
|
354
|
-
"
|
|
355
|
-
|
|
356
|
-
|
|
664
|
+
const shouldWrite = interactive
|
|
665
|
+
? await promptConfirm("Write config to .opencode/oc-notify.json?", true)
|
|
666
|
+
: yesNo(
|
|
667
|
+
await rl.question(
|
|
668
|
+
"\nWrite config to .opencode/oc-notify.json? (Y/n) [Y] ",
|
|
669
|
+
),
|
|
670
|
+
true,
|
|
671
|
+
);
|
|
357
672
|
if (shouldWrite) {
|
|
358
673
|
await ensureOpencodeDir(cwd);
|
|
359
674
|
await writeJson(join(cwd, REPO_CONFIG_PATH), config);
|
|
360
675
|
await copySchema(cwd);
|
|
361
676
|
}
|
|
362
677
|
|
|
363
|
-
const
|
|
364
|
-
"Send a test notification now?
|
|
365
|
-
|
|
366
|
-
const sendTest = yesNo(testInput, true);
|
|
678
|
+
const sendTest = interactive
|
|
679
|
+
? await promptConfirm("Send a test notification now?", true)
|
|
680
|
+
: yesNo(await rl.question("Send a test notification now? (Y/n) [Y] "), true);
|
|
367
681
|
if (sendTest) {
|
|
368
682
|
await sendTestNotification(config, repoName);
|
|
369
683
|
}
|
|
370
684
|
|
|
371
|
-
await rl.close();
|
|
685
|
+
if (rl) await rl.close();
|
|
372
686
|
}
|
|
373
687
|
|
|
374
688
|
await main();
|
package/README.md
CHANGED
|
@@ -23,44 +23,8 @@ npx @goodnesshq/opencode-notification install
|
|
|
23
23
|
|
|
24
24
|
This only installs repo assets and prints next steps for manual setup.
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
- One-off: `npx @goodnesshq/opencode-notification@latest setup`
|
|
29
|
-
- Global: `npm update -g @goodnesshq/opencode-notification`
|
|
30
|
-
|
|
31
|
-
## Publish (maintainers)
|
|
32
|
-
|
|
33
|
-
1) Ensure you are authenticated with publish access for `@goodnesshq`.
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
npm whoami
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
If you see an auth error or 2FA error, create a publish token scoped to `@goodnesshq`, then set it:
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
npm token create \
|
|
43
|
-
--name "opencode-notification-publish" \
|
|
44
|
-
--scopes @goodnesshq \
|
|
45
|
-
--packages-and-scopes-permission read-write \
|
|
46
|
-
--bypass-2fa
|
|
47
|
-
npm set //registry.npmjs.org/:_authToken=YOUR_TOKEN
|
|
48
|
-
npm whoami
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
2) Publish:
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
npm publish --access public
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Troubleshooting
|
|
58
|
-
|
|
59
|
-
**OpenCode fails to start after enabling the plugin**
|
|
60
|
-
|
|
61
|
-
- Ensure every entry in `plugin` points to an existing file.
|
|
62
|
-
- Remove stale plugin paths from other repos.
|
|
63
|
-
- Use absolute paths (no `~`).
|
|
26
|
+
Before running the installer, create a unique ntfy topic and set up mobile notifications. See `docs/ntfy-topic.md`.
|
|
27
|
+
The installer uses an interactive, arrow-key prompt flow (with a non-interactive fallback in non-TTY environments).
|
|
64
28
|
|
|
65
29
|
## Config Schema
|
|
66
30
|
|
|
@@ -94,7 +58,7 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
|
|
|
94
58
|
},
|
|
95
59
|
"channels": {
|
|
96
60
|
"mac": { "enabled": true, "method": "auto" },
|
|
97
|
-
"ntfy": { "enabled": true, "server": "https://ntfy.sh", "topic": "
|
|
61
|
+
"ntfy": { "enabled": true, "server": "https://ntfy.sh", "topic": "<your-topic>" }
|
|
98
62
|
}
|
|
99
63
|
}
|
|
100
64
|
```
|
package/package.json
CHANGED