@nocville/npc 0.3.0 → 0.4.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 +38 -18
- package/dist/cjs/cli.js +24 -3
- package/dist/cjs/core/base-npc.js +1 -0
- package/dist/cjs/core/config.js +23 -1
- package/dist/cjs/core/polling-npc.js +8 -0
- package/dist/cjs/core/user-config.js +2 -0
- package/dist/cjs/doctor.js +19 -0
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/npcs/agent-common/session.js +3 -0
- package/dist/cjs/npcs/codex/watcher.js +1 -0
- package/dist/cjs/npcs/github-watcher/github.js +109 -0
- package/dist/cjs/npcs/office-characters/index.js +11 -1
- package/dist/cjs/npcs/web-page-watcher/basic.js +58 -0
- package/dist/cjs/npcs/web-page-watcher/capture.js +26 -0
- package/dist/cjs/npcs/web-page-watcher/chrome.js +62 -0
- package/dist/cli.js +24 -3
- package/dist/core/base-npc.js +1 -0
- package/dist/core/config.js +23 -1
- package/dist/core/polling-npc.d.ts +3 -0
- package/dist/core/polling-npc.js +8 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/core/user-config.d.ts +1 -0
- package/dist/core/user-config.js +2 -0
- package/dist/doctor.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/npcs/agent-common/session.js +3 -0
- package/dist/npcs/codex/watcher.js +1 -0
- package/dist/npcs/github-watcher/github.d.ts +39 -0
- package/dist/npcs/github-watcher/github.js +103 -0
- package/dist/npcs/office-characters/index.d.ts +5 -0
- package/dist/npcs/office-characters/index.js +9 -0
- package/dist/npcs/web-page-watcher/basic.d.ts +16 -0
- package/dist/npcs/web-page-watcher/basic.js +52 -0
- package/dist/npcs/web-page-watcher/capture.js +26 -0
- package/dist/npcs/web-page-watcher/chrome.d.ts +46 -0
- package/dist/npcs/web-page-watcher/chrome.js +57 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -56,10 +56,12 @@ Monitoring NPC types:
|
|
|
56
56
|
plant-caretaker Waters plants and reviews their performance
|
|
57
57
|
it-technician Inspects tech gear and recommends reboots
|
|
58
58
|
out-of-touch-executive Interrupts coworkers between long lounge breaks
|
|
59
|
+
slightly-inappropriate-hr Desk-based HR with questionable people skills
|
|
59
60
|
github-watcher Call out new commits on a repo
|
|
60
61
|
web-page-watcher Render a watched web page as a pixel-art TV
|
|
61
62
|
|
|
62
63
|
Common Options:
|
|
64
|
+
--workspace=<file> Verified producer workspace settings from team privacy settings.
|
|
63
65
|
--api-key=<key> Team API key (or NOCVILLE_API_KEY env)
|
|
64
66
|
--api-url=<url> Nocville API URL (or NOCVILLE_API_URL env).
|
|
65
67
|
Default: https://nocville.com. For local dev use
|
|
@@ -382,7 +384,8 @@ async function main() {
|
|
|
382
384
|
case 'office-dog':
|
|
383
385
|
case 'plant-caretaker':
|
|
384
386
|
case 'it-technician':
|
|
385
|
-
case 'out-of-touch-executive':
|
|
387
|
+
case 'out-of-touch-executive':
|
|
388
|
+
case 'slightly-inappropriate-hr': {
|
|
386
389
|
if (!config.serviceId)
|
|
387
390
|
config.serviceId = parsed.command;
|
|
388
391
|
const { OFFICE_CHARACTER_NPCS } = await import('./npcs/office-characters/index.js');
|
|
@@ -475,7 +478,20 @@ async function runInit(flags, config) {
|
|
|
475
478
|
let apiKey = stringFlag(flags, 'api-key');
|
|
476
479
|
const apiUrl = stringFlag(flags, 'api-url');
|
|
477
480
|
const map = stringFlag(flags, 'map');
|
|
478
|
-
|
|
481
|
+
const workspace = config.workspace;
|
|
482
|
+
if (workspace)
|
|
483
|
+
apiKey = apiKey || config.apiKey;
|
|
484
|
+
if (workspace && !apiKey) {
|
|
485
|
+
const { resolveApiKey } = await import('./core/api-key.js');
|
|
486
|
+
const resolved = await resolveApiKey({ flags });
|
|
487
|
+
if (!resolved.ok) {
|
|
488
|
+
console.error(resolved.error);
|
|
489
|
+
process.exitCode = 1;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
apiKey = resolved.key;
|
|
493
|
+
}
|
|
494
|
+
if (!apiKey && !apiUrl && !map && !workspace) {
|
|
479
495
|
const saved = loadUserConfig();
|
|
480
496
|
if (saved.apiKey && !process.env['NOCVILLE_API_KEY']?.trim()) {
|
|
481
497
|
console.log('Usage: nocville-npc init --api-key=<key> [--api-url=<url>] [--map=<name>]');
|
|
@@ -491,7 +507,12 @@ async function runInit(flags, config) {
|
|
|
491
507
|
}
|
|
492
508
|
apiKey = resolved.key;
|
|
493
509
|
}
|
|
494
|
-
const path = saveUserConfig({
|
|
510
|
+
const path = saveUserConfig({
|
|
511
|
+
apiKey,
|
|
512
|
+
apiUrl: workspace ? config.apiUrl : apiUrl,
|
|
513
|
+
map: workspace ? config.map : map,
|
|
514
|
+
workspace,
|
|
515
|
+
});
|
|
495
516
|
const saved = loadUserConfig();
|
|
496
517
|
console.log(`✓ Saved ${path} (mode 0600)`);
|
|
497
518
|
console.log(` API: ${saved.apiUrl ?? 'https://nocville.com'} key: ${maskKey(saved.apiKey)}`);
|
package/dist/core/base-npc.js
CHANGED
|
@@ -14,6 +14,7 @@ export class BaseNPC {
|
|
|
14
14
|
// We can't access `this.name` here since it's abstract, so use a placeholder
|
|
15
15
|
this.logger = new Logger('npc', config.debug);
|
|
16
16
|
this.client = new NocvilleClient({
|
|
17
|
+
workspace: config.workspace,
|
|
17
18
|
apiUrl: config.apiUrl,
|
|
18
19
|
apiKey: config.apiKey,
|
|
19
20
|
serviceId: config.serviceId,
|
package/dist/core/config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { loadProducerWorkspace } from '@nocville/node';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { loadUserConfig } from './user-config.js';
|
|
2
4
|
/**
|
|
3
5
|
* Simple hand-rolled CLI arg parser.
|
|
@@ -48,9 +50,16 @@ export function parseArgs(argv) {
|
|
|
48
50
|
*/
|
|
49
51
|
export function resolveConfig(flags, overrides) {
|
|
50
52
|
const saved = loadUserConfig();
|
|
53
|
+
const workspacePath = overrides?.workspace ||
|
|
54
|
+
stringFlag(flags, 'workspace') ||
|
|
55
|
+
process.env['NOCVILLE_WORKSPACE'] ||
|
|
56
|
+
saved.workspace;
|
|
57
|
+
const workspace = workspacePath ? resolve(workspacePath) : undefined;
|
|
58
|
+
const enrollment = workspace ? loadProducerWorkspace(workspace) : undefined;
|
|
51
59
|
const apiUrl = overrides?.apiUrl ||
|
|
52
60
|
stringFlag(flags, 'api-url') ||
|
|
53
61
|
process.env['NOCVILLE_API_URL'] ||
|
|
62
|
+
enrollment?.apiUrl ||
|
|
54
63
|
saved.apiUrl ||
|
|
55
64
|
'https://nocville.com';
|
|
56
65
|
const apiKey = overrides?.apiKey ||
|
|
@@ -68,10 +77,23 @@ export function resolveConfig(flags, overrides) {
|
|
|
68
77
|
const map = overrides?.map ||
|
|
69
78
|
stringFlag(flags, 'map') ||
|
|
70
79
|
process.env['NOCVILLE_MAP'] ||
|
|
80
|
+
enrollment?.encryption.mapId ||
|
|
71
81
|
saved.map ||
|
|
72
82
|
undefined;
|
|
73
83
|
const name = overrides?.name || stringFlag(flags, 'name') || process.env['NOCVILLE_NAME'] || undefined;
|
|
74
|
-
|
|
84
|
+
if (enrollment && apiUrl.replace(/\/+$/, '') !== enrollment.apiUrl)
|
|
85
|
+
throw new Error('API origin does not match the verified workspace');
|
|
86
|
+
if (enrollment && map && map !== enrollment.encryption.mapId)
|
|
87
|
+
throw new Error('Encrypted producers use the map ID in their workspace settings');
|
|
88
|
+
return {
|
|
89
|
+
apiUrl,
|
|
90
|
+
apiKey,
|
|
91
|
+
serviceId,
|
|
92
|
+
debug,
|
|
93
|
+
map: enrollment?.encryption.mapId ?? map,
|
|
94
|
+
name,
|
|
95
|
+
...(workspace ? { workspace } : {}),
|
|
96
|
+
};
|
|
75
97
|
}
|
|
76
98
|
export function stringFlag(flags, key) {
|
|
77
99
|
const val = flags[key];
|
|
@@ -2,6 +2,8 @@ import { BaseNPC } from './base-npc.js';
|
|
|
2
2
|
import type { NpcConfig, CollectedMetrics } from './types.js';
|
|
3
3
|
export interface PollingNpcConfig extends NpcConfig {
|
|
4
4
|
pollInterval?: number;
|
|
5
|
+
/** Local diagnostics; never included in published payloads. */
|
|
6
|
+
onCollection?: (metrics: CollectedMetrics) => void;
|
|
5
7
|
}
|
|
6
8
|
/**
|
|
7
9
|
* Lower bound for any poll interval, so a typo can't exhaust the API's
|
|
@@ -19,6 +21,7 @@ export declare abstract class PollingNPC extends BaseNPC {
|
|
|
19
21
|
private lastPushed;
|
|
20
22
|
private lastPushAt;
|
|
21
23
|
protected pollInterval: number;
|
|
24
|
+
private onCollection?;
|
|
22
25
|
constructor(config: PollingNpcConfig);
|
|
23
26
|
/** Scale staleness to the poll cadence: the server marks unknown at ~2x the interval. */
|
|
24
27
|
get heartbeatSeconds(): number;
|
package/dist/core/polling-npc.js
CHANGED
|
@@ -20,8 +20,10 @@ export class PollingNPC extends BaseNPC {
|
|
|
20
20
|
lastPushed = null;
|
|
21
21
|
lastPushAt = 0;
|
|
22
22
|
pollInterval;
|
|
23
|
+
onCollection;
|
|
23
24
|
constructor(config) {
|
|
24
25
|
super(config);
|
|
26
|
+
this.onCollection = config.onCollection;
|
|
25
27
|
const requested = config.pollInterval ?? 15000;
|
|
26
28
|
this.pollInterval = Number.isFinite(requested)
|
|
27
29
|
? Math.max(MIN_POLL_INTERVAL_MS, requested)
|
|
@@ -114,6 +116,12 @@ export class PollingNPC extends BaseNPC {
|
|
|
114
116
|
// Stopped mid-collection: never push after (or race) the offline push.
|
|
115
117
|
if (this.state !== 'running')
|
|
116
118
|
return;
|
|
119
|
+
try {
|
|
120
|
+
this.onCollection?.(metrics);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* Diagnostics cannot stop publishing. */
|
|
124
|
+
}
|
|
117
125
|
await this.pushMetrics(metrics);
|
|
118
126
|
this.lastPushed = metrics;
|
|
119
127
|
this.lastPushAt = Date.now();
|
package/dist/core/types.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type Archetype = 'service' | 'database' | 'trading' | 'ai-agent' | 'pipel
|
|
|
6
6
|
/** A companion declaration is the SDK's CompanionSpec. */
|
|
7
7
|
export type CompanionDecl = CompanionSpec;
|
|
8
8
|
export interface NpcConfig {
|
|
9
|
+
/** Local verified workspace enrollment bundle. */
|
|
10
|
+
workspace?: string;
|
|
9
11
|
apiUrl: string;
|
|
10
12
|
apiKey: string;
|
|
11
13
|
serviceId: string;
|
package/dist/core/user-config.js
CHANGED
|
@@ -18,6 +18,8 @@ export function loadUserConfig(path = userConfigPath()) {
|
|
|
18
18
|
try {
|
|
19
19
|
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
20
20
|
const out = {};
|
|
21
|
+
if (typeof raw['workspace'] === 'string' && raw['workspace'])
|
|
22
|
+
out.workspace = raw['workspace'];
|
|
21
23
|
if (typeof raw['apiUrl'] === 'string' && raw['apiUrl'])
|
|
22
24
|
out.apiUrl = raw['apiUrl'];
|
|
23
25
|
if (typeof raw['apiKey'] === 'string' && raw['apiKey'])
|
package/dist/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { NocvilleClient } from '@nocville/node';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { readdirSync } from 'node:fs';
|
|
3
4
|
import { isAgentAlive } from './npcs/agent-common/liveness.js';
|
|
@@ -39,6 +40,24 @@ export async function runDoctor(config) {
|
|
|
39
40
|
if (!hasKey) {
|
|
40
41
|
console.log(' [SKIP] POST /api/v1/status — no --api-key / NOCVILLE_API_KEY set');
|
|
41
42
|
}
|
|
43
|
+
else if (config.workspace || config.apiKey.includes('.team_')) {
|
|
44
|
+
// Exercise the locally encrypted path; never send a plaintext probe to a v2 workspace.
|
|
45
|
+
try {
|
|
46
|
+
const client = new NocvilleClient({
|
|
47
|
+
workspace: config.workspace,
|
|
48
|
+
apiUrl: config.apiUrl,
|
|
49
|
+
apiKey: config.apiKey,
|
|
50
|
+
serviceId: 'nocville-doctor',
|
|
51
|
+
retries: 0,
|
|
52
|
+
});
|
|
53
|
+
const result = await client.pushStatus({ status: 'unknown', offline: true });
|
|
54
|
+
pushOk = result.success;
|
|
55
|
+
console.log(` [${pushOk ? 'OK' : 'FAIL'}] Encrypted producer: ${result.error ?? 'accepted'}`);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.log(` [FAIL] Encrypted producer: ${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
42
61
|
else {
|
|
43
62
|
const start = Date.now();
|
|
44
63
|
const t = withTimeout(5000);
|
package/dist/index.d.ts
CHANGED
|
@@ -42,5 +42,5 @@ export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent
|
|
|
42
42
|
export type { CursorSetupOptions } from './npcs/cursor/index.js';
|
|
43
43
|
export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
|
|
44
44
|
export { SecurityNPC } from './npcs/security/index.js';
|
|
45
|
-
export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
|
|
45
|
+
export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, SlightlyInappropriateHRNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
|
|
46
46
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -26,5 +26,5 @@ export { GEMINI_DEFAULTS, GEMINI_ADAPTER, geminiTransition, geminiServiceId, han
|
|
|
26
26
|
export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent, setupCursorHooks, uninstallCursorHooks, installCursorHooksInto, CURSOR_HOOK_EVENTS, } from './npcs/cursor/index.js';
|
|
27
27
|
export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
|
|
28
28
|
export { SecurityNPC } from './npcs/security/index.js';
|
|
29
|
-
export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
|
|
29
|
+
export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, SlightlyInappropriateHRNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
|
|
30
30
|
//# sourceMappingURL=index.js.map
|
|
@@ -241,6 +241,7 @@ export function buildAgentPayload(adapter, state, config) {
|
|
|
241
241
|
}
|
|
242
242
|
async function defaultPush(config, serviceId, payload, timeoutMs) {
|
|
243
243
|
const client = new NocvilleClient({
|
|
244
|
+
workspace: config.workspace,
|
|
244
245
|
apiUrl: config.apiUrl,
|
|
245
246
|
apiKey: config.apiKey,
|
|
246
247
|
serviceId,
|
|
@@ -359,6 +360,8 @@ export function spawnDetachedAgent(adapter, config, args, cliPath = process.argv
|
|
|
359
360
|
if (!cliPath)
|
|
360
361
|
return undefined;
|
|
361
362
|
const env = { ...process.env };
|
|
363
|
+
if (config.workspace)
|
|
364
|
+
env['NOCVILLE_WORKSPACE'] = config.workspace;
|
|
362
365
|
env['NOCVILLE_API_URL'] = config.apiUrl;
|
|
363
366
|
env['NOCVILLE_API_KEY'] = config.apiKey;
|
|
364
367
|
if (config.map)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface CommitInfo {
|
|
2
|
+
sha: string;
|
|
3
|
+
author: string;
|
|
4
|
+
message: string;
|
|
5
|
+
date: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FetchCommitsResult {
|
|
8
|
+
/** 304 Not Modified — the ETag matched, nothing changed, no rate-limit cost. */
|
|
9
|
+
notModified: boolean;
|
|
10
|
+
commits: CommitInfo[];
|
|
11
|
+
etag: string | null;
|
|
12
|
+
rateRemaining: number | null;
|
|
13
|
+
status: number;
|
|
14
|
+
}
|
|
15
|
+
export interface FetchOptions {
|
|
16
|
+
token?: string;
|
|
17
|
+
etag?: string | null;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
perPage?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fetch recent commits from the GitHub REST API using conditional requests.
|
|
23
|
+
* Passing the previous ETag yields a 304 (which does not count against the
|
|
24
|
+
* unauthenticated 60/hour limit) when nothing has changed. Never throws —
|
|
25
|
+
* network errors surface as `status: 0`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function fetchCommits(repo: string, opts?: FetchOptions): Promise<FetchCommitsResult>;
|
|
28
|
+
/** Count how many commits landed within the last `hours`. */
|
|
29
|
+
export declare function commitsWithin(commits: CommitInfo[], hours: number, now?: number): number;
|
|
30
|
+
/** Short relative-time string like "3m ago", "2h ago", "1d ago". */
|
|
31
|
+
export declare function relativeTime(iso: string, now?: number): string;
|
|
32
|
+
/**
|
|
33
|
+
* Return the commits newer than `lastSeenSha`, newest-first. When the sha isn't
|
|
34
|
+
* present in the window (more than a page of new commits, or rewritten history)
|
|
35
|
+
* every fetched commit is treated as new. An undefined sha means "not seeded
|
|
36
|
+
* yet" — returns empty so the caller can seed without a spurious announcement.
|
|
37
|
+
*/
|
|
38
|
+
export declare function newCommitsSince(commits: CommitInfo[], lastSeenSha: string | null): CommitInfo[];
|
|
39
|
+
//# sourceMappingURL=github.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
function toNum(v) {
|
|
2
|
+
if (v === null)
|
|
3
|
+
return null;
|
|
4
|
+
const n = Number(v);
|
|
5
|
+
return Number.isNaN(n) ? null : n;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Fetch recent commits from the GitHub REST API using conditional requests.
|
|
9
|
+
* Passing the previous ETag yields a 304 (which does not count against the
|
|
10
|
+
* unauthenticated 60/hour limit) when nothing has changed. Never throws —
|
|
11
|
+
* network errors surface as `status: 0`.
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchCommits(repo, opts = {}) {
|
|
14
|
+
const perPage = opts.perPage ?? 10;
|
|
15
|
+
const url = `https://api.github.com/repos/${repo}/commits?per_page=${perPage}`;
|
|
16
|
+
const headers = {
|
|
17
|
+
Accept: 'application/vnd.github+json',
|
|
18
|
+
'User-Agent': 'nocville-npc',
|
|
19
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
20
|
+
};
|
|
21
|
+
if (opts.token)
|
|
22
|
+
headers['Authorization'] = `Bearer ${opts.token}`;
|
|
23
|
+
if (opts.etag)
|
|
24
|
+
headers['If-None-Match'] = opts.etag;
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5000);
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(url, { headers, signal: controller.signal });
|
|
29
|
+
const rateRemaining = toNum(res.headers.get('x-ratelimit-remaining'));
|
|
30
|
+
const etag = res.headers.get('etag');
|
|
31
|
+
if (res.status === 304) {
|
|
32
|
+
return { notModified: true, commits: [], etag: opts.etag ?? null, rateRemaining, status: 304 };
|
|
33
|
+
}
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
// Drain the body so the socket can be reused.
|
|
36
|
+
await res.text().catch(() => undefined);
|
|
37
|
+
return { notModified: false, commits: [], etag, rateRemaining, status: res.status };
|
|
38
|
+
}
|
|
39
|
+
const body = (await res.json());
|
|
40
|
+
const commits = body.map((c) => parseCommit(c));
|
|
41
|
+
return { notModified: false, commits, etag, rateRemaining, status: res.status };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { notModified: false, commits: [], etag: null, rateRemaining: null, status: 0 };
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parseCommit(c) {
|
|
51
|
+
const commit = c['commit'] ?? {};
|
|
52
|
+
const commitAuthor = commit['author'] ?? {};
|
|
53
|
+
const ghAuthor = c['author'] ?? {};
|
|
54
|
+
const message = String(commit['message'] ?? '').split('\n')[0] ?? '';
|
|
55
|
+
return {
|
|
56
|
+
sha: String(c['sha'] ?? ''),
|
|
57
|
+
author: String(commitAuthor['name'] ?? ghAuthor['login'] ?? 'unknown'),
|
|
58
|
+
message,
|
|
59
|
+
date: String(commitAuthor['date'] ?? ''),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Count how many commits landed within the last `hours`. */
|
|
63
|
+
export function commitsWithin(commits, hours, now = Date.now()) {
|
|
64
|
+
const cutoff = now - hours * 3600_000;
|
|
65
|
+
return commits.filter((c) => {
|
|
66
|
+
const t = Date.parse(c.date);
|
|
67
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
68
|
+
}).length;
|
|
69
|
+
}
|
|
70
|
+
/** Short relative-time string like "3m ago", "2h ago", "1d ago". */
|
|
71
|
+
export function relativeTime(iso, now = Date.now()) {
|
|
72
|
+
const t = Date.parse(iso);
|
|
73
|
+
if (!Number.isFinite(t))
|
|
74
|
+
return 'unknown';
|
|
75
|
+
const secs = Math.max(0, Math.round((now - t) / 1000));
|
|
76
|
+
if (secs < 60)
|
|
77
|
+
return `${secs}s ago`;
|
|
78
|
+
const mins = Math.round(secs / 60);
|
|
79
|
+
if (mins < 60)
|
|
80
|
+
return `${mins}m ago`;
|
|
81
|
+
const hrs = Math.round(mins / 60);
|
|
82
|
+
if (hrs < 24)
|
|
83
|
+
return `${hrs}h ago`;
|
|
84
|
+
const days = Math.round(hrs / 24);
|
|
85
|
+
return `${days}d ago`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Return the commits newer than `lastSeenSha`, newest-first. When the sha isn't
|
|
89
|
+
* present in the window (more than a page of new commits, or rewritten history)
|
|
90
|
+
* every fetched commit is treated as new. An undefined sha means "not seeded
|
|
91
|
+
* yet" — returns empty so the caller can seed without a spurious announcement.
|
|
92
|
+
*/
|
|
93
|
+
export function newCommitsSince(commits, lastSeenSha) {
|
|
94
|
+
if (!lastSeenSha)
|
|
95
|
+
return [];
|
|
96
|
+
const idx = commits.findIndex((c) => c.sha === lastSeenSha);
|
|
97
|
+
if (idx === 0)
|
|
98
|
+
return []; // newest is already seen
|
|
99
|
+
if (idx === -1)
|
|
100
|
+
return commits; // fell out of the window — treat all as new
|
|
101
|
+
return commits.slice(0, idx);
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=github.js.map
|
|
@@ -37,6 +37,10 @@ export declare class OutOfTouchExecutiveNPC extends OfficeCharacterNPC {
|
|
|
37
37
|
get name(): 'out-of-touch-executive';
|
|
38
38
|
protected get defaultDisplayName(): string;
|
|
39
39
|
}
|
|
40
|
+
export declare class SlightlyInappropriateHRNPC extends OfficeCharacterNPC {
|
|
41
|
+
get name(): 'slightly-inappropriate-hr';
|
|
42
|
+
protected get defaultDisplayName(): string;
|
|
43
|
+
}
|
|
40
44
|
export declare const OFFICE_CHARACTER_NPCS: {
|
|
41
45
|
readonly janitor: typeof JanitorNPC;
|
|
42
46
|
readonly barista: typeof BaristaNPC;
|
|
@@ -45,6 +49,7 @@ export declare const OFFICE_CHARACTER_NPCS: {
|
|
|
45
49
|
readonly 'plant-caretaker': typeof PlantCaretakerNPC;
|
|
46
50
|
readonly 'it-technician': typeof ITTechnicianNPC;
|
|
47
51
|
readonly 'out-of-touch-executive': typeof OutOfTouchExecutiveNPC;
|
|
52
|
+
readonly 'slightly-inappropriate-hr': typeof SlightlyInappropriateHRNPC;
|
|
48
53
|
};
|
|
49
54
|
export {};
|
|
50
55
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -70,6 +70,14 @@ export class OutOfTouchExecutiveNPC extends OfficeCharacterNPC {
|
|
|
70
70
|
return 'Out-of-Touch Executive';
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
export class SlightlyInappropriateHRNPC extends OfficeCharacterNPC {
|
|
74
|
+
get name() {
|
|
75
|
+
return 'slightly-inappropriate-hr';
|
|
76
|
+
}
|
|
77
|
+
get defaultDisplayName() {
|
|
78
|
+
return 'Slightly Inappropriate HR';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
73
81
|
export const OFFICE_CHARACTER_NPCS = {
|
|
74
82
|
janitor: JanitorNPC,
|
|
75
83
|
barista: BaristaNPC,
|
|
@@ -78,5 +86,6 @@ export const OFFICE_CHARACTER_NPCS = {
|
|
|
78
86
|
'plant-caretaker': PlantCaretakerNPC,
|
|
79
87
|
'it-technician': ITTechnicianNPC,
|
|
80
88
|
'out-of-touch-executive': OutOfTouchExecutiveNPC,
|
|
89
|
+
'slightly-inappropriate-hr': SlightlyInappropriateHRNPC,
|
|
81
90
|
};
|
|
82
91
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Status } from '../../core/types.js';
|
|
2
|
+
export interface BasicCheck {
|
|
3
|
+
status: number;
|
|
4
|
+
title: string;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
timedOut: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Extract the <title> text from an HTML string (empty if none). */
|
|
9
|
+
export declare function extractTitle(html: string): string;
|
|
10
|
+
/** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
|
|
11
|
+
export declare function statusFromHttp(code: number, timedOut?: boolean): Status;
|
|
12
|
+
/** Fetch a page and extract HTTP status + <title> (basic, no browser). */
|
|
13
|
+
export declare function basicCheck(url: string, timeoutMs?: number): Promise<BasicCheck>;
|
|
14
|
+
/** Best-effort hostname for a URL, falling back to the raw string. */
|
|
15
|
+
export declare function hostnameOf(url: string): string;
|
|
16
|
+
//# sourceMappingURL=basic.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Extract the <title> text from an HTML string (empty if none). */
|
|
2
|
+
export function extractTitle(html) {
|
|
3
|
+
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
4
|
+
return m ? m[1].replace(/\s+/g, ' ').trim().slice(0, 80) : '';
|
|
5
|
+
}
|
|
6
|
+
/** Map an HTTP status to NPC status: 2xx/3xx ok, 4xx/timeout warn, 5xx/refused crit. */
|
|
7
|
+
export function statusFromHttp(code, timedOut = false) {
|
|
8
|
+
if (code >= 200 && code < 400)
|
|
9
|
+
return 'healthy';
|
|
10
|
+
if (code >= 400 && code < 500)
|
|
11
|
+
return 'warning';
|
|
12
|
+
if (code >= 500)
|
|
13
|
+
return 'critical';
|
|
14
|
+
return timedOut ? 'warning' : 'critical'; // 0 = network error
|
|
15
|
+
}
|
|
16
|
+
/** Fetch a page and extract HTTP status + <title> (basic, no browser). */
|
|
17
|
+
export async function basicCheck(url, timeoutMs = 8000) {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
headers: { 'User-Agent': 'nocville-npc' },
|
|
24
|
+
redirect: 'follow',
|
|
25
|
+
});
|
|
26
|
+
let title = '';
|
|
27
|
+
try {
|
|
28
|
+
title = extractTitle(await res.text());
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// body unavailable; leave title blank
|
|
32
|
+
}
|
|
33
|
+
return { status: res.status, title, ok: res.ok, timedOut: false };
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const timedOut = err instanceof Error && err.name === 'AbortError';
|
|
37
|
+
return { status: 0, title: '', ok: false, timedOut };
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Best-effort hostname for a URL, falling back to the raw string. */
|
|
44
|
+
export function hostnameOf(url) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(url).hostname;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return url.slice(0, 40);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=basic.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
3
|
import { execFileSync } from 'node:child_process';
|
|
3
4
|
const MAC_PATHS = [
|
|
@@ -43,6 +44,25 @@ export function findChrome(explicit) {
|
|
|
43
44
|
return p;
|
|
44
45
|
return null;
|
|
45
46
|
}
|
|
47
|
+
if (process.platform === 'win32') {
|
|
48
|
+
const roots = [
|
|
49
|
+
process.env['LOCALAPPDATA'],
|
|
50
|
+
process.env['PROGRAMFILES'],
|
|
51
|
+
process.env['PROGRAMFILES(X86)'],
|
|
52
|
+
].filter((p) => !!p);
|
|
53
|
+
for (const root of roots)
|
|
54
|
+
for (const relative of [
|
|
55
|
+
['Google', 'Chrome', 'Application', 'chrome.exe'],
|
|
56
|
+
['Microsoft', 'Edge', 'Application', 'msedge.exe'],
|
|
57
|
+
['Chromium', 'Application', 'chrome.exe'],
|
|
58
|
+
['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
|
|
59
|
+
]) {
|
|
60
|
+
const path = join(root, ...relative);
|
|
61
|
+
if (existsSync(path))
|
|
62
|
+
return path;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
46
66
|
if (process.platform === 'linux') {
|
|
47
67
|
for (const bin of LINUX_BINARIES) {
|
|
48
68
|
const resolved = which(bin);
|
|
@@ -76,6 +96,12 @@ export function runningAsRoot() {
|
|
|
76
96
|
*/
|
|
77
97
|
export async function loadPuppeteer() {
|
|
78
98
|
try {
|
|
99
|
+
// Prefer CommonJS in bundled runtimes so Electron resolves through its
|
|
100
|
+
// ASAR loader. Native ESM installations use the optional dynamic import below.
|
|
101
|
+
if (typeof require === 'function') {
|
|
102
|
+
const mod = require('puppeteer-core');
|
|
103
|
+
return mod.default ?? mod;
|
|
104
|
+
}
|
|
79
105
|
const dynImport = new Function('m', 'return import(m)');
|
|
80
106
|
const mod = await dynImport('puppeteer-core');
|
|
81
107
|
return (mod.default ?? mod);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface PuppeteerViewport {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
deviceScaleFactor?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface PuppeteerResponse {
|
|
7
|
+
status(): number;
|
|
8
|
+
}
|
|
9
|
+
export interface PuppeteerPage {
|
|
10
|
+
setViewport(v: PuppeteerViewport): Promise<void>;
|
|
11
|
+
goto(url: string, opts?: {
|
|
12
|
+
waitUntil?: string;
|
|
13
|
+
timeout?: number;
|
|
14
|
+
}): Promise<PuppeteerResponse | null>;
|
|
15
|
+
screenshot(opts: {
|
|
16
|
+
type?: string;
|
|
17
|
+
fullPage?: boolean;
|
|
18
|
+
}): Promise<Buffer | Uint8Array>;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface PuppeteerBrowser {
|
|
22
|
+
newPage(): Promise<PuppeteerPage>;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
/** puppeteer >= 20 exposes `connected`; older versions expose isConnected(). */
|
|
25
|
+
connected?: boolean;
|
|
26
|
+
isConnected?(): boolean;
|
|
27
|
+
on(event: string, cb: () => void): void;
|
|
28
|
+
}
|
|
29
|
+
export interface PuppeteerModule {
|
|
30
|
+
launch(opts: {
|
|
31
|
+
executablePath?: string;
|
|
32
|
+
headless?: boolean | 'new';
|
|
33
|
+
args?: string[];
|
|
34
|
+
}): Promise<PuppeteerBrowser>;
|
|
35
|
+
}
|
|
36
|
+
/** Find a usable Chrome/Chromium executable: explicit path → env → known paths. */
|
|
37
|
+
export declare function findChrome(explicit?: string): string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Load the optional `puppeteer-core` peer dependency without a static import,
|
|
40
|
+
* so the package builds and runs when it isn't installed. Returns null if the
|
|
41
|
+
* module is absent.
|
|
42
|
+
*/
|
|
43
|
+
export declare function loadPuppeteer(): Promise<PuppeteerModule | null>;
|
|
44
|
+
/** True if the browser reports a live connection. */
|
|
45
|
+
export declare function browserConnected(browser: PuppeteerBrowser): boolean;
|
|
46
|
+
//# sourceMappingURL=chrome.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
const CHROME_PATHS = {
|
|
3
|
+
darwin: [
|
|
4
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
5
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
6
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
7
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
8
|
+
],
|
|
9
|
+
linux: [
|
|
10
|
+
'/usr/bin/google-chrome',
|
|
11
|
+
'/usr/bin/google-chrome-stable',
|
|
12
|
+
'/usr/bin/chromium',
|
|
13
|
+
'/usr/bin/chromium-browser',
|
|
14
|
+
'/usr/bin/microsoft-edge',
|
|
15
|
+
'/snap/bin/chromium',
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
/** Find a usable Chrome/Chromium executable: explicit path → env → known paths. */
|
|
19
|
+
export function findChrome(explicit) {
|
|
20
|
+
const envPath = process.env['CHROME_PATH'];
|
|
21
|
+
const candidates = [
|
|
22
|
+
...(explicit ? [explicit] : []),
|
|
23
|
+
...(envPath ? [envPath] : []),
|
|
24
|
+
...(CHROME_PATHS[process.platform] ?? []),
|
|
25
|
+
];
|
|
26
|
+
for (const p of candidates) {
|
|
27
|
+
if (p && existsSync(p))
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Load the optional `puppeteer-core` peer dependency without a static import,
|
|
34
|
+
* so the package builds and runs when it isn't installed. Returns null if the
|
|
35
|
+
* module is absent.
|
|
36
|
+
*/
|
|
37
|
+
export async function loadPuppeteer() {
|
|
38
|
+
try {
|
|
39
|
+
// Indirect dynamic import so TypeScript/bundlers don't require the module
|
|
40
|
+
// at build time.
|
|
41
|
+
const dynImport = new Function('m', 'return import(m)');
|
|
42
|
+
const mod = await dynImport('puppeteer-core');
|
|
43
|
+
return (mod.default ?? mod);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** True if the browser reports a live connection. */
|
|
50
|
+
export function browserConnected(browser) {
|
|
51
|
+
if (typeof browser.connected === 'boolean')
|
|
52
|
+
return browser.connected;
|
|
53
|
+
if (typeof browser.isConnected === 'function')
|
|
54
|
+
return browser.isConnected();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=chrome.js.map
|