@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.
@@ -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: "oc-goodness-attn-1407537",
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 rl = createInterface({ input: process.stdin, output: process.stdout });
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 enableInput = await rl.question(
210
- "Enable notifications for this repo? (Y/n) [Y] ",
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 = await rl.question(
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
- console.log(
224
- "\nSelect a tier:\n 1) Minimal\n 2) Standard\n 3) Verbose\n 4) Custom",
225
- );
226
- const tierInput = await rl.question("> [2] ");
227
- const tier =
228
- tierInput.trim() === "1"
229
- ? "minimal"
230
- : tierInput.trim() === "3"
231
- ? "verbose"
232
- : tierInput.trim() === "4"
233
- ? "custom"
234
- : "standard";
235
-
236
- console.log(
237
- "\nResponse-complete trigger:\n 1) session.idle (coarse, low noise)\n 2) message.updated (precise)\n 3) message.part.updated (very precise, more noise)",
238
- );
239
- const triggerInput = await rl.question("> [2] ");
240
- const trigger =
241
- triggerInput.trim() === "1"
242
- ? "session.idle"
243
- : triggerInput.trim() === "3"
244
- ? "message.part.updated"
245
- : "message.updated";
246
-
247
- const macInput = await rl.question(
248
- "\nEnable macOS Notification Center? (Y/n) [Y] ",
249
- );
250
- const macEnabled = yesNo(macInput, true);
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 ntfyInput = await rl.question("Enable ntfy notifications? (Y/n) [Y] ");
253
- const ntfyEnabled = yesNo(ntfyInput, true);
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 = await rl.question(
261
- `ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
262
- );
263
- const topicInput = await rl.question(
264
- `ntfy topic (blank = ${globalConfig.ntfy.topic || DEFAULTS.channels.ntfy.topic}): `,
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 detailInput = await rl.question(
271
- "Detail level (1=full, 2=title-only) [1] ",
272
- );
273
- const detailLevel = detailInput.trim() === "2" ? "title-only" : "full";
274
-
275
- const adjustGroups = yesNo(
276
- await rl.question("\nAdjust event groups? (y/N) [N] "),
277
- false,
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 = yesNo(
283
- await rl.question("Show advanced groups? (y/N) [N] "),
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
- console.log(`Available groups: ${allowed.join(", ")}`);
290
- const includeInput = await rl.question(
291
- "Include groups (comma-separated, blank to skip): ",
292
- );
293
- const excludeInput = await rl.question(
294
- "Exclude groups (comma-separated, blank to skip): ",
295
- );
296
- includeGroups = parseList(includeInput, allowed);
297
- excludeGroups = parseList(excludeInput, allowed);
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 = yesNo(
301
- await rl.question("\nUse terminal-notifier if available? (Y/n) [Y] "),
302
- true,
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
- const installNow = yesNo(
310
- await rl.question(
311
- "terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
312
- ),
313
- false,
314
- );
315
- if (installNow) {
316
- try {
317
- const { execSync } = await import("node:child_process");
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 writeInput = await rl.question(
354
- "\nWrite config to .opencode/oc-notify.json? (Y/n) [Y] ",
355
- );
356
- const shouldWrite = yesNo(writeInput, true);
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 testInput = await rl.question(
364
- "Send a test notification now? (Y/n) [Y] ",
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
- ### Update guidance
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": "oc-goodness-attn-1407537" }
61
+ "ntfy": { "enabled": true, "server": "https://ntfy.sh", "topic": "<your-topic>" }
98
62
  }
99
63
  }
100
64
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodnesshq/opencode-notification",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Per-repo notification plugin for OpenCode with macOS and ntfy delivery.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -96,7 +96,7 @@ const TITLE_LIMIT = 60;
96
96
  const SUBTITLE_LIMIT = 80;
97
97
  const BODY_LIMIT = 200;
98
98
 
99
- const DEFAULT_TOPIC = "oc-goodness-attn-1407537";
99
+ const DEFAULT_TOPIC = "";
100
100
 
101
101
  function truncate(value, limit) {
102
102
  if (!value) return "";