@nocville/npc 0.2.2 → 0.2.3

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.
Files changed (96) hide show
  1. package/README.md +23 -5
  2. package/dist/cjs/cli.js +55 -34
  3. package/dist/cjs/core/base-npc.js +8 -0
  4. package/dist/cjs/core/config.js +19 -2
  5. package/dist/cjs/core/errors.js +23 -0
  6. package/dist/cjs/core/http.js +121 -0
  7. package/dist/cjs/core/pixel-art.js +110 -0
  8. package/dist/cjs/core/polling-npc.js +99 -14
  9. package/dist/cjs/core/text.js +13 -0
  10. package/dist/cjs/core/thresholds.js +29 -0
  11. package/dist/cjs/index.js +10 -2
  12. package/dist/cjs/npcs/agent-common/session.js +1 -0
  13. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  14. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  15. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  16. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  17. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  18. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  19. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  20. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  21. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  22. package/dist/cjs/npcs/process-watcher/index.js +61 -51
  23. package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
  24. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  25. package/dist/cjs/npcs/receptionist/index.js +45 -19
  26. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  27. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  28. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  29. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  30. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  31. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  32. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  33. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  34. package/dist/cli.js +56 -35
  35. package/dist/core/base-npc.d.ts +5 -0
  36. package/dist/core/base-npc.js +8 -0
  37. package/dist/core/config.d.ts +5 -0
  38. package/dist/core/config.js +18 -2
  39. package/dist/core/errors.d.ts +11 -0
  40. package/dist/core/errors.js +18 -0
  41. package/dist/core/http.d.ts +52 -0
  42. package/dist/core/http.js +111 -0
  43. package/dist/core/pixel-art.d.ts +38 -0
  44. package/dist/core/pixel-art.js +105 -0
  45. package/dist/core/polling-npc.d.ts +22 -1
  46. package/dist/core/polling-npc.js +98 -13
  47. package/dist/core/text.d.ts +6 -0
  48. package/dist/core/text.js +12 -0
  49. package/dist/core/thresholds.d.ts +9 -1
  50. package/dist/core/thresholds.js +27 -0
  51. package/dist/index.d.ts +5 -2
  52. package/dist/index.js +4 -2
  53. package/dist/npcs/agent-common/session.js +1 -0
  54. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  55. package/dist/npcs/github-watcher/defaults.js +7 -1
  56. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  57. package/dist/npcs/github-watcher/github-api.js +80 -45
  58. package/dist/npcs/github-watcher/index.d.ts +6 -0
  59. package/dist/npcs/github-watcher/index.js +83 -37
  60. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  61. package/dist/npcs/internet-connectivity/checks.js +70 -67
  62. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  63. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  64. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  65. package/dist/npcs/internet-connectivity/index.js +135 -133
  66. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  67. package/dist/npcs/log-watcher/buffer.js +58 -0
  68. package/dist/npcs/log-watcher/index.d.ts +22 -21
  69. package/dist/npcs/log-watcher/index.js +112 -106
  70. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  71. package/dist/npcs/log-watcher/tail.js +101 -54
  72. package/dist/npcs/process-watcher/index.d.ts +1 -1
  73. package/dist/npcs/process-watcher/index.js +62 -52
  74. package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
  75. package/dist/npcs/process-watcher/process-scanner.js +61 -71
  76. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  77. package/dist/npcs/receptionist/defaults.js +4 -0
  78. package/dist/npcs/receptionist/index.d.ts +12 -2
  79. package/dist/npcs/receptionist/index.js +46 -20
  80. package/dist/npcs/receptionist/weather.d.ts +8 -6
  81. package/dist/npcs/receptionist/weather.js +39 -45
  82. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  83. package/dist/npcs/system-monitor/collectors.js +64 -17
  84. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  85. package/dist/npcs/system-monitor/index.d.ts +2 -1
  86. package/dist/npcs/system-monitor/index.js +54 -30
  87. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  88. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  89. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  90. package/dist/npcs/web-page-watcher/capture.js +21 -1
  91. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  92. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  93. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  94. package/dist/npcs/web-page-watcher/index.js +121 -70
  95. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  96. package/package.json +2 -2
package/README.md CHANGED
@@ -150,16 +150,34 @@ on your map automatically and despawns when you press Ctrl-C. Once you've run
150
150
  | `github-watcher` | New commits on a commit board | `nocville-npc github-watcher --repo=owner/name` |
151
151
  | `web-page-watcher` | A web page rendered as a pixel-art TV | `nocville-npc web-page-watcher --url=https://…` |
152
152
 
153
- Run `nocville-npc --help` to see every flag. A few notes:
154
-
153
+ Run `nocville-npc --help` to see every flag. Poll intervals below 5 seconds are raised to 5
154
+ seconds. A few notes:
155
+
156
+ - **system-monitor**: `--disk-path=/data` picks the volume; thresholds are percentages
157
+ (`--cpu-warn`/`--cpu-crit`, `--mem-…`, `--disk-…`). On macOS, memory counts the file cache as
158
+ available, like Activity Monitor.
159
+ - **internet-connectivity**: `--dns-hosts`, `--http-urls` (http/https only) and
160
+ `--latency-hosts` take comma-separated lists; latency targets accept `host:port` or
161
+ `[ipv6]:port`. `--timeout=<ms>` bounds each check (default 5000).
162
+ - **process-watcher**: matches executable names, so `--processes=python` also finds `python3.11`
163
+ but `sh` does not match `bash`. Use a full path to match one binary exactly. macOS and Linux
164
+ only.
155
165
  - **log-watcher**: `--error-pattern="ERROR|FATAL" --window=300 --error-warn=5 --error-crit=20`.
166
+ Files are followed across log rotation and may be created after the watcher starts. Lines
167
+ shown in the office have anything that looks like a secret (tokens, passwords, keys, URL
168
+ credentials) redacted, but treat the screen as visible to your whole team.
156
169
  - **receptionist**: weather comes from the keyless open-meteo API. Set the place with
157
- `--lat=52.5 --lon=13.4` or `--location=…` (otherwise it uses IP geolocation). Add
170
+ `--lat=52.5 --lon=13.4` or `--location=…`. Without either, it estimates your location from
171
+ your public IP via ipapi.co; pass `--no-ip-geo` to turn that off and show only the time. Add
158
172
  `--fahrenheit` for °F.
159
- - **github-watcher**: uses ETag polling. Pass `--token=$GITHUB_TOKEN` for a higher rate limit.
173
+ - **github-watcher**: uses ETag polling. Set `GITHUB_TOKEN` for private repos and a higher rate
174
+ limit (`--token` also works, but flags are visible to other users in the process list).
175
+ Without a token it polls every 90 seconds to stay within GitHub's 60 requests per hour.
160
176
  - **web-page-watcher**: the pixel-art TV needs `npm i puppeteer-core` and a local
161
177
  Chrome/Chromium (`--chrome-path` or `CHROME_PATH`). Without them it falls back to a
162
- title/status ticker. Other options: `--mode=auto|screenshot|basic` and `--screen=160x120`.
178
+ title/status ticker. Other options: `--mode=auto|screenshot|basic` and `--screen=160x120`
179
+ (up to 320x240). Only `http(s)` URLs are accepted. Chrome runs with its sandbox unless you're
180
+ root or pass `--no-chrome-sandbox`; only disable it for pages you trust.
163
181
 
164
182
  ## Programmatic use
165
183
 
package/dist/cjs/cli.js CHANGED
@@ -38,6 +38,7 @@ const node_fs_1 = require("node:fs");
38
38
  const node_os_1 = require("node:os");
39
39
  const node_path_1 = require("node:path");
40
40
  const config_js_1 = require("./core/config.js");
41
+ const errors_js_1 = require("./core/errors.js");
41
42
  const logger_js_1 = require("./core/logger.js");
42
43
  const session_filter_js_1 = require("./core/session-filter.js");
43
44
  // NPC modules are imported lazily per command: `claude-code hook` / `codex hook`
@@ -112,17 +113,23 @@ Internet Connectivity Options:
112
113
  --poll-interval=<ms> Poll interval in ms (default: 30000)
113
114
  --dns-hosts=<list> Comma-separated DNS hosts (default: google.com,cloudflare.com)
114
115
  --http-urls=<list> Comma-separated HTTP URLs to check
115
- --latency-hosts=<list> Comma-separated hosts for latency checks
116
+ --latency-hosts=<list> Comma-separated TCP latency targets: host, host:port or
117
+ [ipv6]:port (default: 8.8.8.8:443,1.1.1.1:443;
118
+ port 80 when omitted)
116
119
  --slow-threshold=<ms> Slow response threshold in ms (default: 1000)
120
+ --timeout=<ms> Per-check timeout in ms (default: 5000)
117
121
 
118
122
  Process Watcher Options:
119
123
  --poll-interval=<ms> Poll interval in ms (default: 15000)
120
- --processes=<list> Comma-separated process names to watch
124
+ --processes=<list> Comma-separated executable names to watch (python
125
+ also matches python3.11; a /path matches exactly)
121
126
  --cpu-warn=<n> CPU warning threshold % (default: 80)
122
127
  --mem-warn=<n> Memory warning threshold MB (default: 500)
123
128
 
124
129
  Log Watcher Options:
125
- --files=<list> Comma-separated log file paths
130
+ --files=<list> Comma-separated log file paths (followed across
131
+ rotation; files may appear later). Lines shown on
132
+ the screen have secrets redacted.
126
133
  --error-pattern=<regex> Error pattern (default: ERROR|FATAL)
127
134
  --warning-pattern=<regex> Warning pattern (default: WARN)
128
135
  --window=<seconds> Sliding window in seconds (default: 300)
@@ -133,23 +140,30 @@ Log Watcher Options:
133
140
  Receptionist Options:
134
141
  --poll-interval=<ms> Clock tick in ms (default: 60000)
135
142
  --lat=<n> --lon=<n> Explicit coordinates for weather
136
- --location=<name> Place name to geocode (else IP geo)
143
+ --location=<name> Place name to geocode (open-meteo)
144
+ --no-ip-geo Without --lat/--lon or --location, don't look up
145
+ this machine's IP location (ipapi.co); show time only
137
146
  --fahrenheit Show temperature in °F (default: °C)
138
147
 
139
148
  GitHub Watcher Options:
140
149
  --repo=<owner/name> Repository to watch (required)
141
150
  --branch=<name> Branch to watch (default: repo default branch)
142
- --token=<token> GitHub token (or GITHUB_TOKEN env)
143
- --poll-interval=<ms> Poll interval in ms (default: 60000)
151
+ --token=<token> GitHub token (prefer the GITHUB_TOKEN env: flags are
152
+ visible in the process list)
153
+ --poll-interval=<ms> Poll interval in ms (default: 60000 with a token,
154
+ 90000 without, to stay under GitHub's 60/hour)
144
155
 
145
156
  Web Page Watcher Options:
146
- --url=<url> Page to watch (required)
157
+ --url=<url> http(s) page to watch (required)
147
158
  --mode=<auto|screenshot|basic> Render mode (default: auto)
148
159
  --interval=<ms> Poll interval in ms (default: 60000, floor 15000)
149
- --viewport=<WxH> Browser viewport (default: screen x 8)
150
- --screen=<WxH> TV pixel resolution (default: 160x120; e.g. 160x100)
151
- --wait-until=<event> Puppeteer waitUntil (default: networkidle2)
160
+ --viewport=<WxH> Browser viewport (default: screen x 8, max 4096x4096)
161
+ --screen=<WxH> TV pixel resolution (default: 160x120, max 320x240)
162
+ --wait-until=<event> load, domcontentloaded, networkidle0 or networkidle2
163
+ (default: networkidle2)
152
164
  --chrome-path=<path> Chrome executable (or CHROME_PATH env)
165
+ --no-chrome-sandbox Run Chrome without its sandbox (only for trusted
166
+ pages; automatic when running as root)
153
167
  (needs puppeteer-core + Chrome for the pixel-art TV; falls back to basic)
154
168
  `.trim();
155
169
  async function main() {
@@ -310,18 +324,10 @@ async function main() {
310
324
  if (!config.serviceId) {
311
325
  config.serviceId = 'internet-connectivity';
312
326
  }
313
- const dnsHosts = (0, config_js_1.stringFlag)(parsed.flags, 'dns-hosts')?.split(',');
314
- const httpUrls = (0, config_js_1.stringFlag)(parsed.flags, 'http-urls')?.split(',');
315
- const latencyHostsRaw = (0, config_js_1.stringFlag)(parsed.flags, 'latency-hosts')?.split(',');
316
- const latencyHosts = latencyHostsRaw?.map((h) => ({ host: h.trim(), port: 80 }));
317
327
  const { InternetConnectivityNPC } = await Promise.resolve().then(() => __importStar(require('./npcs/internet-connectivity/index.js')));
318
328
  const npc = new InternetConnectivityNPC({
319
329
  ...config,
320
- pollInterval: (0, config_js_1.numberFlag)(parsed.flags, 'poll-interval'),
321
- dnsHosts,
322
- httpUrls,
323
- latencyHosts,
324
- slowThreshold: (0, config_js_1.numberFlag)(parsed.flags, 'slow-threshold'),
330
+ ...InternetConnectivityNPC.configFromFlags(parsed.flags),
325
331
  });
326
332
  logger.info('Starting internet-connectivity NPC');
327
333
  await npc.start();
@@ -331,8 +337,8 @@ async function main() {
331
337
  if (!config.serviceId) {
332
338
  config.serviceId = 'process-watcher';
333
339
  }
334
- const processesStr = (0, config_js_1.stringFlag)(parsed.flags, 'processes');
335
- if (!processesStr) {
340
+ const processes = (0, config_js_1.listFlag)(parsed.flags, 'processes');
341
+ if (!processes) {
336
342
  console.error('Error: --processes is required (comma-separated list)');
337
343
  process.exit(1);
338
344
  }
@@ -340,7 +346,7 @@ async function main() {
340
346
  const npc = new ProcessWatcherNPC({
341
347
  ...config,
342
348
  pollInterval: (0, config_js_1.numberFlag)(parsed.flags, 'poll-interval'),
343
- processes: processesStr.split(','),
349
+ processes,
344
350
  cpuWarn: (0, config_js_1.numberFlag)(parsed.flags, 'cpu-warn'),
345
351
  memWarn: (0, config_js_1.numberFlag)(parsed.flags, 'mem-warn'),
346
352
  });
@@ -349,8 +355,8 @@ async function main() {
349
355
  break;
350
356
  }
351
357
  case 'log-watcher': {
352
- const filesStr = (0, config_js_1.stringFlag)(parsed.flags, 'files');
353
- if (!filesStr) {
358
+ const files = (0, config_js_1.listFlag)(parsed.flags, 'files');
359
+ if (!files) {
354
360
  console.error('Error: --files is required (comma-separated list)');
355
361
  process.exit(1);
356
362
  }
@@ -360,7 +366,7 @@ async function main() {
360
366
  const { LogWatcherNPC } = await Promise.resolve().then(() => __importStar(require('./npcs/log-watcher/index.js')));
361
367
  const npc = new LogWatcherNPC({
362
368
  ...config,
363
- files: filesStr.split(','),
369
+ files,
364
370
  errorPattern: (0, config_js_1.stringFlag)(parsed.flags, 'error-pattern'),
365
371
  warningPattern: (0, config_js_1.stringFlag)(parsed.flags, 'warning-pattern'),
366
372
  windowSeconds: (0, config_js_1.numberFlag)(parsed.flags, 'window'),
@@ -384,6 +390,7 @@ async function main() {
384
390
  lon: (0, config_js_1.numberFlag)(parsed.flags, 'lon'),
385
391
  location: (0, config_js_1.stringFlag)(parsed.flags, 'location'),
386
392
  fahrenheit: (0, config_js_1.boolFlag)(parsed.flags, 'fahrenheit') ?? false,
393
+ ipGeo: (0, config_js_1.boolFlag)(parsed.flags, 'no-ip-geo') !== true,
387
394
  });
388
395
  logger.info('Starting receptionist NPC');
389
396
  await npc.start();
@@ -400,11 +407,15 @@ async function main() {
400
407
  config.serviceId = `github-${shortName.replace(/[^a-zA-Z0-9_-]/g, '-')}`.slice(0, 100);
401
408
  }
402
409
  const { GitHubWatcherNPC } = await Promise.resolve().then(() => __importStar(require('./npcs/github-watcher/index.js')));
410
+ const tokenFlag = (0, config_js_1.stringFlag)(parsed.flags, 'token');
411
+ if (tokenFlag) {
412
+ logger.warn('--token is visible to other local users in the process list; prefer GITHUB_TOKEN');
413
+ }
403
414
  const npc = new GitHubWatcherNPC({
404
415
  ...config,
405
416
  repo,
406
417
  branch: (0, config_js_1.stringFlag)(parsed.flags, 'branch'),
407
- token: (0, config_js_1.stringFlag)(parsed.flags, 'token') ?? process.env['GITHUB_TOKEN'],
418
+ token: tokenFlag ?? process.env['GITHUB_TOKEN'],
408
419
  pollInterval: (0, config_js_1.numberFlag)(parsed.flags, 'poll-interval'),
409
420
  });
410
421
  logger.info('Starting github-watcher NPC');
@@ -420,22 +431,28 @@ async function main() {
420
431
  const { hostnameOf } = await Promise.resolve().then(() => __importStar(require('./npcs/web-page-watcher/basic-mode.js')));
421
432
  const { parseDimensions } = await Promise.resolve().then(() => __importStar(require('./npcs/web-page-watcher/defaults.js')));
422
433
  const { WebPageWatcherNPC } = await Promise.resolve().then(() => __importStar(require('./npcs/web-page-watcher/index.js')));
423
- const parseWxH = (v) => v ? (parseDimensions(v) ?? undefined) : undefined;
434
+ const parseWxH = (flag) => {
435
+ const v = (0, config_js_1.stringFlag)(parsed.flags, flag);
436
+ if (v === undefined)
437
+ return undefined;
438
+ const dims = parseDimensions(v);
439
+ if (!dims)
440
+ throw new errors_js_1.NpcConfigError(`--${flag} must look like 160x120`);
441
+ return dims;
442
+ };
424
443
  if (!config.serviceId) {
425
444
  config.serviceId = `webpage-${hostnameOf(url)}`
426
445
  .replace(/[^a-zA-Z0-9_.-]/g, '-')
427
446
  .slice(0, 100);
428
447
  }
429
- const modeFlag = (0, config_js_1.stringFlag)(parsed.flags, 'mode');
430
- const mode = modeFlag === 'auto' || modeFlag === 'screenshot' || modeFlag === 'basic'
431
- ? modeFlag
432
- : undefined;
433
- const viewport = parseWxH((0, config_js_1.stringFlag)(parsed.flags, 'viewport'));
434
- const screen = parseWxH((0, config_js_1.stringFlag)(parsed.flags, 'screen'));
448
+ const viewport = parseWxH('viewport');
449
+ const screen = parseWxH('screen');
435
450
  const npc = new WebPageWatcherNPC({
436
451
  ...config,
437
452
  url,
438
- mode,
453
+ // Validated (with a clear error) by the constructor.
454
+ mode: (0, config_js_1.stringFlag)(parsed.flags, 'mode'),
455
+ chromeSandbox: (0, config_js_1.boolFlag)(parsed.flags, 'no-chrome-sandbox') !== true,
439
456
  chromePath: (0, config_js_1.stringFlag)(parsed.flags, 'chrome-path'),
440
457
  waitUntil: (0, config_js_1.stringFlag)(parsed.flags, 'wait-until'),
441
458
  viewportWidth: viewport?.width,
@@ -578,6 +595,10 @@ main().catch((err) => {
578
595
  process.stdout.write('{}\n');
579
596
  return;
580
597
  }
598
+ if (err instanceof errors_js_1.NpcConfigError) {
599
+ console.error(`Error: ${err.message}`);
600
+ process.exit(1);
601
+ }
581
602
  console.error('Fatal error:', err);
582
603
  process.exit(1);
583
604
  });
@@ -42,6 +42,13 @@ class BaseNPC {
42
42
  const name = this.displayName;
43
43
  return name ? (0, text_js_1.truncate)(name, 32) : undefined;
44
44
  }
45
+ /**
46
+ * NPC type sent as the push `kind` (groups NPCs for per-type rules such as "all process
47
+ * watchers work in the Server Room"). Defaults to `name` when it is a valid slug.
48
+ */
49
+ get kind() {
50
+ return /^[a-z0-9][a-z0-9-]{0,39}$/.test(this.name) ? this.name : undefined;
51
+ }
45
52
  /** NPC archetype — steers movement speed and default behavior. Override per NPC. */
46
53
  get archetype() {
47
54
  return 'service';
@@ -116,6 +123,7 @@ class BaseNPC {
116
123
  message: metrics.message ? (0, text_js_1.truncate)(metrics.message, 500) : undefined,
117
124
  metrics: metrics.metrics,
118
125
  archetype: this.archetype,
126
+ kind: this.kind,
119
127
  subStatus: metrics.subStatus,
120
128
  screenData: metrics.screenData,
121
129
  behavior: Object.keys(behavior).length > 0 ? behavior : undefined,
@@ -4,6 +4,7 @@ exports.parseArgs = parseArgs;
4
4
  exports.resolveConfig = resolveConfig;
5
5
  exports.stringFlag = stringFlag;
6
6
  exports.boolFlag = boolFlag;
7
+ exports.listFlag = listFlag;
7
8
  exports.numberFlag = numberFlag;
8
9
  const user_config_js_1 = require("./user-config.js");
9
10
  /**
@@ -96,11 +97,27 @@ function boolFlag(flags, key) {
96
97
  return false;
97
98
  return undefined;
98
99
  }
100
+ /**
101
+ * A comma-separated list flag (`--processes=nginx,redis`): entries trimmed,
102
+ * empties dropped, duplicates removed. Undefined when the flag is absent or empty.
103
+ */
104
+ function listFlag(flags, key) {
105
+ const raw = stringFlag(flags, key);
106
+ if (raw === undefined)
107
+ return undefined;
108
+ const items = [
109
+ ...new Set(raw
110
+ .split(',')
111
+ .map((s) => s.trim())
112
+ .filter(Boolean)),
113
+ ];
114
+ return items.length > 0 ? items : undefined;
115
+ }
99
116
  function numberFlag(flags, key) {
100
117
  const val = flags[key];
101
- if (typeof val === 'string') {
118
+ if (typeof val === 'string' && val.trim() !== '') {
102
119
  const n = Number(val);
103
- if (!Number.isNaN(n))
120
+ if (Number.isFinite(n))
104
121
  return n;
105
122
  }
106
123
  return undefined;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NpcConfigError = void 0;
4
+ exports.assertPosix = assertPosix;
5
+ /**
6
+ * A user-facing configuration problem (bad flag value, unsupported platform).
7
+ * The CLI prints only its message and exits 1 — no stack trace — so throw it
8
+ * for anything the user can fix by changing how they run the NPC.
9
+ */
10
+ class NpcConfigError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'NpcConfigError';
14
+ }
15
+ }
16
+ exports.NpcConfigError = NpcConfigError;
17
+ /** Throw an NpcConfigError on Windows, for NPCs that rely on POSIX tools or paths. */
18
+ function assertPosix(npcName, hint) {
19
+ if (process.platform === 'win32') {
20
+ throw new NpcConfigError(`${npcName} is not supported on Windows. ${hint}`);
21
+ }
22
+ }
23
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ /**
3
+ * Small, bounded HTTP helpers for NPCs that read untrusted remote content
4
+ * (weather/geo APIs, GitHub, watched web pages). Every call has a timeout and
5
+ * every body read has a byte cap, so a slow or hostile server can neither hang
6
+ * a collection nor balloon memory.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.DEFAULT_MAX_BODY_BYTES = exports.NPC_USER_AGENT = void 0;
10
+ exports.isHttpUrl = isHttpUrl;
11
+ exports.displayUrl = displayUrl;
12
+ exports.readTextCapped = readTextCapped;
13
+ exports.discardBody = discardBody;
14
+ exports.fetchJson = fetchJson;
15
+ exports.asRecord = asRecord;
16
+ exports.finiteNumber = finiteNumber;
17
+ /** The `User-Agent` sent by every NPC request. */
18
+ exports.NPC_USER_AGENT = 'nocville-npc';
19
+ /** Default cap for a response body read into memory. */
20
+ exports.DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
21
+ /** True for an absolute http(s) URL (anything else — file:, data:, javascript: — is refused). */
22
+ function isHttpUrl(value) {
23
+ try {
24
+ const url = new URL(value);
25
+ return url.protocol === 'http:' || url.protocol === 'https:';
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /** A URL rendered for logs/screens: scheme, host and path only — never credentials or query. */
32
+ function displayUrl(value) {
33
+ try {
34
+ const url = new URL(value);
35
+ return `${url.protocol}//${url.host}${url.pathname === '/' ? '' : url.pathname}`;
36
+ }
37
+ catch {
38
+ return '(invalid url)';
39
+ }
40
+ }
41
+ /**
42
+ * Read a response body as UTF-8, keeping at most `maxBytes`. Streams when the
43
+ * body is available (cancelling the rest); otherwise falls back to `text()`
44
+ * after rejecting an oversized declared Content-Length. Returns the (possibly
45
+ * truncated) text and whether it was truncated.
46
+ */
47
+ async function readTextCapped(res, maxBytes = exports.DEFAULT_MAX_BODY_BYTES) {
48
+ const reader = res.body?.getReader();
49
+ if (!reader) {
50
+ const declared = Number(res.headers?.get('content-length') ?? NaN);
51
+ if (Number.isFinite(declared) && declared > maxBytes)
52
+ return { text: '', truncated: true };
53
+ const text = await res.text();
54
+ return text.length > maxBytes
55
+ ? { text: text.slice(0, maxBytes), truncated: true }
56
+ : { text, truncated: false };
57
+ }
58
+ const chunks = [];
59
+ let total = 0;
60
+ let truncated = false;
61
+ try {
62
+ for (;;) {
63
+ const { done, value } = await reader.read();
64
+ if (done)
65
+ break;
66
+ if (total + value.byteLength > maxBytes) {
67
+ chunks.push(value.subarray(0, maxBytes - total));
68
+ total = maxBytes;
69
+ truncated = true;
70
+ break;
71
+ }
72
+ chunks.push(value);
73
+ total += value.byteLength;
74
+ }
75
+ }
76
+ finally {
77
+ if (truncated)
78
+ await reader.cancel().catch(() => undefined);
79
+ reader.releaseLock();
80
+ }
81
+ return { text: new TextDecoder().decode(Buffer.concat(chunks)), truncated };
82
+ }
83
+ /** Discard a response body without reading it (frees the connection). */
84
+ async function discardBody(res) {
85
+ await res.body?.cancel().catch(() => undefined);
86
+ }
87
+ /**
88
+ * GET a JSON document with a timeout and size cap. Returns null on any network
89
+ * error, non-2xx status, oversized body or malformed JSON — callers degrade
90
+ * gracefully rather than throw.
91
+ */
92
+ async function fetchJson(url, opts) {
93
+ try {
94
+ const res = await fetch(url, {
95
+ signal: AbortSignal.timeout(opts.timeoutMs),
96
+ headers: { 'User-Agent': exports.NPC_USER_AGENT, Accept: 'application/json', ...opts.headers },
97
+ });
98
+ if (!res.ok) {
99
+ await discardBody(res);
100
+ return null;
101
+ }
102
+ const { text, truncated } = await readTextCapped(res, opts.maxBytes ?? 256 * 1024);
103
+ if (truncated)
104
+ return null;
105
+ return JSON.parse(text);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ /** Narrow an unknown JSON value to a plain object (arrays and null excluded). */
112
+ function asRecord(value) {
113
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
114
+ ? value
115
+ : null;
116
+ }
117
+ /** A finite number from an unknown JSON value, else null. */
118
+ function finiteNumber(value) {
119
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
120
+ }
121
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.encodePixelArt = encodePixelArt;
4
+ exports.pixelArtFromPng = pixelArtFromPng;
5
+ exports.pixelDevice = pixelDevice;
6
+ const node_fs_1 = require("node:fs");
7
+ const pngjs_1 = require("pngjs");
8
+ /**
9
+ * Helpers for custom pixel-art companions (`screenType: 'pixel-device'`): your NPC's own
10
+ * device, sign or machine, drawn frameless beside it in the office.
11
+ *
12
+ * Art is 32 pixels per tile (a 2×3-tile device is 64×96, like the office furniture) and is
13
+ * sent as `pixel-image` screen data: a palette of up to 255 colours plus one byte per pixel.
14
+ * Fully transparent pixels (alpha < 128) become see-through, and several frames stacked top
15
+ * to bottom animate at `fps`. Push a new image whenever its state changes (e.g. an LED turns
16
+ * red); the server rate-limits pushes, so animate with frames rather than many pushes.
17
+ */
18
+ /** Largest single screen-data value the API accepts (base64 pixels). */
19
+ const MAX_VALUE = 220_000;
20
+ const MAX_COLORS = 255;
21
+ const MAX_FRAMES = 16;
22
+ /** Encode RGBA frames as `pixel-image` screen data. Throws with a fix-it message. */
23
+ function encodePixelArt(art) {
24
+ const { width, height, frames } = art;
25
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1)
26
+ throw new Error('Pixel art needs a positive whole-number width and height.');
27
+ if (width > 192 || height > 192)
28
+ throw new Error(`Pixel art is ${width}×${height}; keep it within 192×192 (6×6 tiles).`);
29
+ if (frames.length < 1 || frames.length > MAX_FRAMES)
30
+ throw new Error(`Pixel art needs 1–${MAX_FRAMES} frames (got ${frames.length}).`);
31
+ const size = width * height;
32
+ for (const frame of frames)
33
+ if (frame.length !== size * 4)
34
+ throw new Error(`Each frame must be ${width}×${height} RGBA (${size * 4} bytes).`);
35
+ // Index 0 is reserved for transparency when any pixel is see-through.
36
+ const transparent = frames.some((f) => {
37
+ for (let i = 3; i < f.length; i += 4)
38
+ if (f[i] < 128)
39
+ return true;
40
+ return false;
41
+ });
42
+ const colors = new Map();
43
+ const palette = transparent ? [0, 0, 0] : [];
44
+ const pixels = Buffer.alloc(size * frames.length);
45
+ frames.forEach((frame, f) => {
46
+ for (let i = 0; i < size; i++) {
47
+ const r = frame[i * 4], g = frame[i * 4 + 1], b = frame[i * 4 + 2];
48
+ if (transparent && frame[i * 4 + 3] < 128) {
49
+ pixels[f * size + i] = 0;
50
+ continue;
51
+ }
52
+ const key = (r << 16) | (g << 8) | b;
53
+ let index = colors.get(key);
54
+ if (index === undefined) {
55
+ index = palette.length / 3;
56
+ if (index > MAX_COLORS)
57
+ throw new Error(`Pixel art uses more than ${MAX_COLORS} colours; reduce the palette (pixel art usually needs 8–32).`);
58
+ colors.set(key, index);
59
+ palette.push(r, g, b);
60
+ }
61
+ pixels[f * size + i] = index;
62
+ }
63
+ });
64
+ const tokens = [`idx8`, `${width}x${height}`, `p${palette.length / 3}`];
65
+ if (transparent)
66
+ tokens.push('t0');
67
+ if (frames.length > 1) {
68
+ const fps = Math.min(12, Math.max(1, Math.round(art.fps ?? 4)));
69
+ tokens.push(`f${frames.length}`, `fps${fps}`);
70
+ }
71
+ const encoded = pixels.toString('base64');
72
+ if (encoded.length > MAX_VALUE)
73
+ throw new Error('Pixel art is too large to send; use fewer frames or a smaller size.');
74
+ return {
75
+ layout: 'pixel-image',
76
+ values: [
77
+ { label: 'fmt', value: tokens.join(';') },
78
+ { label: 'palette', value: Buffer.from(palette).toString('base64') },
79
+ { label: 'pixels', value: encoded },
80
+ ],
81
+ };
82
+ }
83
+ /**
84
+ * Load a PNG (path or bytes) as pixel-device screen data. For animation, stack frames
85
+ * top to bottom in one PNG and pass `frameHeight`.
86
+ */
87
+ function pixelArtFromPng(png, opts = {}) {
88
+ const image = pngjs_1.PNG.sync.read(typeof png === 'string' ? (0, node_fs_1.readFileSync)(png) : png);
89
+ const frameHeight = opts.frameHeight ?? image.height;
90
+ if (frameHeight < 1 || image.height % frameHeight !== 0)
91
+ throw new Error(`PNG height ${image.height} is not a whole number of ${frameHeight}px frames.`);
92
+ const frameBytes = image.width * frameHeight * 4;
93
+ const frames = [];
94
+ for (let offset = 0; offset < image.data.length; offset += frameBytes)
95
+ frames.push(image.data.subarray(offset, offset + frameBytes));
96
+ return encodePixelArt({ width: image.width, height: frameHeight, frames, fps: opts.fps });
97
+ }
98
+ /**
99
+ * Companion declaration for a pixel device: `size` in tiles (art is 32px per tile).
100
+ * Freestanding by default so it sits on the floor next to the NPC's home.
101
+ */
102
+ function pixelDevice(opts) {
103
+ return {
104
+ screenType: 'pixel-device',
105
+ size: { w: opts.w, h: opts.h },
106
+ placement: opts.placement ?? 'freestanding',
107
+ ...(opts.label ? { label: opts.label } : {}),
108
+ };
109
+ }
110
+ //# sourceMappingURL=pixel-art.js.map