@nocville/node 0.0.0-stage → 0.2.1

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 CHANGED
@@ -1,3 +1,68 @@
1
- # Temporary Holding Version
1
+ # @nocville/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Node.js SDK for pushing status updates to Nocville.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @nocville/node
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { NocvilleClient } from '@nocville/node';
15
+
16
+ const client = new NocvilleClient({
17
+ apiKey: 'your-api-key',
18
+ serviceId: 'my-service',
19
+ // apiUrl defaults to https://nocville.com
20
+ });
21
+
22
+ // Push a status update
23
+ const response = await client.pushStatus({
24
+ status: 'healthy',
25
+ mood: 'happy',
26
+ message: 'All systems operational',
27
+ metrics: {
28
+ cpu: 45,
29
+ memory: 60,
30
+ requests: 1250,
31
+ },
32
+ });
33
+
34
+ // Quick helpers
35
+ await client.healthy('Everything is fine');
36
+ await client.warning('High memory usage');
37
+ await client.critical('Service down!');
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ | Option | Type | Default | Description |
43
+ | ------------ | ------- | ---------------------- | ------------------------------------------- |
44
+ | `apiKey` | string | required | Team API key for authentication |
45
+ | `apiUrl` | string | `https://nocville.com` | Nocville API URL (override for development) |
46
+ | `serviceId` | string | required | The service ID this client represents |
47
+ | `timeout` | number | 5000 | Request timeout in milliseconds |
48
+ | `retries` | number | 3 | Number of retry attempts on failure |
49
+ | `retryDelay` | number | 1000 | Base delay between retries in milliseconds |
50
+ | `debug` | boolean | false | Enable debug logging |
51
+
52
+ ## Status Values
53
+
54
+ - `healthy` - Service is operating normally
55
+ - `warning` - Service has non-critical issues
56
+ - `critical` - Service has critical issues
57
+ - `unknown` - Service status cannot be determined
58
+
59
+ ## Mood Values
60
+
61
+ - `happy` - NPC appears happy (typically for healthy status)
62
+ - `concerned` - NPC appears concerned (typically for warning status)
63
+ - `stressed` - NPC appears stressed (typically for critical status)
64
+ - `idle` - NPC appears neutral (typically for unknown status)
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,373 @@
1
+ "use strict";
2
+ /**
3
+ * Nocville Status Pusher SDK for Node.js
4
+ *
5
+ * @example
6
+ * ```typescript
7
+ * import { NocvilleClient } from '@nocville/node';
8
+ *
9
+ * const client = new NocvilleClient({
10
+ * apiKey: 'your-api-key',
11
+ * serviceId: 'my-service',
12
+ * });
13
+ *
14
+ * // Push a status update
15
+ * await client.pushStatus({
16
+ * status: 'healthy',
17
+ * mood: 'happy',
18
+ * message: 'All systems operational',
19
+ * metrics: {
20
+ * cpu: 45,
21
+ * memory: 60,
22
+ * requests: 1250,
23
+ * },
24
+ * });
25
+ * ```
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.NocvilleClient = void 0;
29
+ class StatusPushError extends Error {
30
+ constructor(statusCode, body) {
31
+ super(`HTTP ${statusCode}: ${body}`);
32
+ this.statusCode = statusCode;
33
+ }
34
+ }
35
+ class NocvilleClient {
36
+ constructor(config) {
37
+ this.lastStatus = 'unknown';
38
+ this.lastMood = 'idle';
39
+ this.lastUpdate = 0;
40
+ this._links = [];
41
+ this._actions = [];
42
+ this._logBuffer = [];
43
+ this._heartbeatInterval = null;
44
+ this.config = {
45
+ apiUrl: 'https://nocville.com',
46
+ timeout: 5000,
47
+ retries: 3,
48
+ retryDelay: 1000,
49
+ debug: false,
50
+ ...config,
51
+ };
52
+ this.config.apiUrl = this.config.apiUrl.replace(/\/+$/, '');
53
+ if (!this.config.apiKey) {
54
+ throw new Error('apiKey is required');
55
+ }
56
+ if (!this.config.serviceId) {
57
+ throw new Error('serviceId is required');
58
+ }
59
+ }
60
+ /**
61
+ * Push a status update to the Nocville server
62
+ */
63
+ async pushStatus(update) {
64
+ const payload = {
65
+ serviceId: this.config.serviceId,
66
+ status: update.status,
67
+ mood: update.mood || this.moodFromStatus(update.status),
68
+ message: update.message,
69
+ metrics: update.metrics,
70
+ };
71
+ if (update.agentStats !== undefined)
72
+ payload.agentStats = update.agentStats;
73
+ if (update.appearance !== undefined)
74
+ payload.appearance = update.appearance;
75
+ if (update.name !== undefined)
76
+ payload.name = update.name;
77
+ if (update.archetype !== undefined)
78
+ payload.archetype = update.archetype;
79
+ if (update.subStatus !== undefined)
80
+ payload.subStatus = update.subStatus;
81
+ if (update.screenData !== undefined)
82
+ payload.screenData = update.screenData;
83
+ if (update.alertConfig !== undefined)
84
+ payload.alertConfig = update.alertConfig;
85
+ if (update.behavior !== undefined)
86
+ payload.behavior = update.behavior;
87
+ if (update.companion !== undefined)
88
+ payload.companion = update.companion;
89
+ if (update.offline !== undefined)
90
+ payload.offline = update.offline;
91
+ if (update.map !== undefined)
92
+ payload.map = update.map;
93
+ // Merge stored links/actions with per-call overrides
94
+ const links = update.links || (this._links.length > 0 ? this._links : undefined);
95
+ const actions = update.actions || (this._actions.length > 0 ? this._actions : undefined);
96
+ if (links)
97
+ payload.links = links;
98
+ if (actions)
99
+ payload.actions = actions;
100
+ let lastError = null;
101
+ for (let attempt = 0; attempt <= this.config.retries; attempt++) {
102
+ try {
103
+ if (attempt > 0) {
104
+ const delay = this.config.retryDelay * Math.pow(2, attempt - 1);
105
+ this.log(`Retry attempt ${attempt} after ${delay}ms`);
106
+ await this.sleep(delay);
107
+ }
108
+ const response = await this.makeRequest(payload);
109
+ this.lastStatus = update.status;
110
+ this.lastMood = update.mood || this.moodFromStatus(update.status);
111
+ this.lastUpdate = Date.now();
112
+ return {
113
+ success: true,
114
+ timestamp: this.lastUpdate,
115
+ };
116
+ }
117
+ catch (error) {
118
+ lastError = error;
119
+ this.log(`Request failed: ${lastError.message}`);
120
+ if (lastError instanceof StatusPushError &&
121
+ [400, 401, 403, 404].includes(lastError.statusCode))
122
+ break;
123
+ }
124
+ }
125
+ return {
126
+ success: false,
127
+ timestamp: Date.now(),
128
+ error: lastError?.message || 'Unknown error',
129
+ statusCode: lastError instanceof StatusPushError ? lastError.statusCode : undefined,
130
+ };
131
+ }
132
+ /**
133
+ * Quick status helpers
134
+ */
135
+ async healthy(message, metrics) {
136
+ return this.pushStatus({ status: 'healthy', mood: 'happy', message, metrics });
137
+ }
138
+ async warning(message, metrics) {
139
+ return this.pushStatus({ status: 'warning', mood: 'concerned', message, metrics });
140
+ }
141
+ async critical(message, metrics) {
142
+ return this.pushStatus({ status: 'critical', mood: 'stressed', message, metrics });
143
+ }
144
+ /**
145
+ * Get the last known status
146
+ */
147
+ getLastStatus() {
148
+ return {
149
+ status: this.lastStatus,
150
+ timestamp: this.lastUpdate,
151
+ };
152
+ }
153
+ /**
154
+ * Push screen data for NPC display
155
+ */
156
+ async pushScreen(data) {
157
+ return this.pushStatus({
158
+ status: this.lastStatus,
159
+ mood: this.lastMood,
160
+ screenData: data,
161
+ });
162
+ }
163
+ /**
164
+ * Push metrics as a multi-metric gauge layout
165
+ */
166
+ async pushGauges(metrics) {
167
+ const screenData = {
168
+ layout: 'multi-metric',
169
+ values: Object.entries(metrics).map(([label, value]) => ({
170
+ label,
171
+ value: String(value),
172
+ })),
173
+ };
174
+ return this.pushScreen(screenData);
175
+ }
176
+ /**
177
+ * Push a ticker-style screen display
178
+ */
179
+ async pushTicker(values) {
180
+ const screenData = {
181
+ layout: 'ticker',
182
+ values: values.map((v) => ({
183
+ label: v.label,
184
+ value: v.value,
185
+ trend: v.trend,
186
+ })),
187
+ };
188
+ return this.pushScreen(screenData);
189
+ }
190
+ /**
191
+ * Push a log line to a rolling terminal display (keeps last 20 lines)
192
+ */
193
+ async pushLogLine(line) {
194
+ this._logBuffer.push(line);
195
+ if (this._logBuffer.length > 20) {
196
+ this._logBuffer = this._logBuffer.slice(-20);
197
+ }
198
+ const screenData = {
199
+ layout: 'log-terminal',
200
+ values: [{ label: 'log', value: this._logBuffer.join('\n') }],
201
+ };
202
+ return this.pushScreen(screenData);
203
+ }
204
+ /**
205
+ * Start a heartbeat that periodically pushes the current status
206
+ */
207
+ startHeartbeat(intervalMs = 30000) {
208
+ this.stopHeartbeat();
209
+ this._heartbeatInterval = setInterval(() => {
210
+ this.pushStatus({
211
+ status: this.lastStatus,
212
+ mood: this.lastMood,
213
+ }).catch((err) => this.log(`Heartbeat push failed: ${err}`));
214
+ }, intervalMs);
215
+ }
216
+ /**
217
+ * Stop the heartbeat interval
218
+ */
219
+ stopHeartbeat() {
220
+ if (this._heartbeatInterval !== null) {
221
+ clearInterval(this._heartbeatInterval);
222
+ this._heartbeatInterval = null;
223
+ }
224
+ }
225
+ /**
226
+ * Set links to be included in every subsequent push
227
+ */
228
+ setLinks(links) {
229
+ this._links = links;
230
+ }
231
+ /**
232
+ * Set actions to be included in every subsequent push
233
+ */
234
+ setActions(actions) {
235
+ this._actions = actions;
236
+ }
237
+ /**
238
+ * Open an incident for this service
239
+ */
240
+ async openIncident(title, severity = 'critical') {
241
+ const url = `${this.config.apiUrl}/api/v1/teams/_/incidents/sdk`;
242
+ const controller = new AbortController();
243
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
244
+ try {
245
+ const response = await fetch(url, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ Authorization: `Bearer ${this.config.apiKey}`,
250
+ },
251
+ body: JSON.stringify({
252
+ serviceId: this.config.serviceId,
253
+ title,
254
+ severity,
255
+ }),
256
+ signal: controller.signal,
257
+ });
258
+ if (!response.ok) {
259
+ const errorText = await response.text();
260
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
261
+ }
262
+ const result = (await response.json());
263
+ this.log(`Incident opened: ${result.data.id}`);
264
+ return { id: result.data.id };
265
+ }
266
+ finally {
267
+ clearTimeout(timeoutId);
268
+ }
269
+ }
270
+ /**
271
+ * Resolve an incident by ID
272
+ */
273
+ async resolveIncident(incidentId) {
274
+ const url = `${this.config.apiUrl}/api/v1/teams/_/incidents/${incidentId}/sdk`;
275
+ const controller = new AbortController();
276
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
277
+ try {
278
+ const response = await fetch(url, {
279
+ method: 'PATCH',
280
+ headers: {
281
+ 'Content-Type': 'application/json',
282
+ Authorization: `Bearer ${this.config.apiKey}`,
283
+ },
284
+ body: JSON.stringify({ status: 'resolved' }),
285
+ signal: controller.signal,
286
+ });
287
+ if (!response.ok) {
288
+ const errorText = await response.text();
289
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
290
+ }
291
+ this.log(`Incident resolved: ${incidentId}`);
292
+ }
293
+ finally {
294
+ clearTimeout(timeoutId);
295
+ }
296
+ }
297
+ /**
298
+ * Add a note to an incident timeline
299
+ */
300
+ async addIncidentNote(incidentId, note) {
301
+ const url = `${this.config.apiUrl}/api/v1/teams/_/incidents/${incidentId}/events/sdk`;
302
+ const controller = new AbortController();
303
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
304
+ try {
305
+ const response = await fetch(url, {
306
+ method: 'POST',
307
+ headers: {
308
+ 'Content-Type': 'application/json',
309
+ Authorization: `Bearer ${this.config.apiKey}`,
310
+ },
311
+ body: JSON.stringify({
312
+ type: 'note',
313
+ data: { note },
314
+ }),
315
+ signal: controller.signal,
316
+ });
317
+ if (!response.ok) {
318
+ const errorText = await response.text();
319
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
320
+ }
321
+ this.log(`Note added to incident: ${incidentId}`);
322
+ }
323
+ finally {
324
+ clearTimeout(timeoutId);
325
+ }
326
+ }
327
+ moodFromStatus(status) {
328
+ switch (status) {
329
+ case 'healthy':
330
+ return 'happy';
331
+ case 'warning':
332
+ return 'concerned';
333
+ case 'critical':
334
+ return 'stressed';
335
+ default:
336
+ return 'idle';
337
+ }
338
+ }
339
+ async makeRequest(payload) {
340
+ const url = `${this.config.apiUrl}/api/v1/status`;
341
+ const controller = new AbortController();
342
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
343
+ try {
344
+ const response = await fetch(url, {
345
+ method: 'POST',
346
+ headers: {
347
+ 'Content-Type': 'application/json',
348
+ Authorization: `Bearer ${this.config.apiKey}`,
349
+ },
350
+ body: JSON.stringify(payload),
351
+ signal: controller.signal,
352
+ });
353
+ if (!response.ok) {
354
+ const errorText = await response.text();
355
+ throw new StatusPushError(response.status, errorText);
356
+ }
357
+ }
358
+ finally {
359
+ clearTimeout(timeoutId);
360
+ }
361
+ }
362
+ sleep(ms) {
363
+ return new Promise((resolve) => setTimeout(resolve, ms));
364
+ }
365
+ log(message) {
366
+ if (this.config.debug) {
367
+ console.log(`[nocville] ${message}`);
368
+ }
369
+ }
370
+ }
371
+ exports.NocvilleClient = NocvilleClient;
372
+ // Default export for convenience
373
+ exports.default = NocvilleClient;
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Nocville Status Pusher SDK for Node.js
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { NocvilleClient } from '@nocville/node';
7
+ *
8
+ * const client = new NocvilleClient({
9
+ * apiKey: 'your-api-key',
10
+ * serviceId: 'my-service',
11
+ * });
12
+ *
13
+ * // Push a status update
14
+ * await client.pushStatus({
15
+ * status: 'healthy',
16
+ * mood: 'happy',
17
+ * message: 'All systems operational',
18
+ * metrics: {
19
+ * cpu: 45,
20
+ * memory: 60,
21
+ * requests: 1250,
22
+ * },
23
+ * });
24
+ * ```
25
+ */
26
+ export type Status = 'healthy' | 'warning' | 'critical' | 'unknown';
27
+ export type Mood = 'happy' | 'concerned' | 'stressed' | 'idle';
28
+ export interface ScreenValue {
29
+ label: string;
30
+ value: string;
31
+ color?: string;
32
+ trend?: 'up' | 'down' | 'flat';
33
+ sparkline?: number[];
34
+ }
35
+ export interface ScreenData {
36
+ layout: string;
37
+ values: ScreenValue[];
38
+ }
39
+ export interface NPCLink {
40
+ label: string;
41
+ url: string;
42
+ icon?: string;
43
+ }
44
+ export interface NPCAction {
45
+ id: string;
46
+ label: string;
47
+ type: 'confirm' | 'webhook' | 'link';
48
+ url?: string;
49
+ requiresAdmin?: boolean;
50
+ }
51
+ export interface AlertConfig {
52
+ notifyOnCritical?: boolean;
53
+ soundEnabled?: boolean;
54
+ autoAcknowledgeAfter?: number;
55
+ heartbeatInterval?: number;
56
+ }
57
+ /** Companion pixel-screen types the server knows how to render. */
58
+ export type CompanionScreenType = 'log-terminal' | 'ticker-tape' | 'crt-monitor' | 'dashboard-wall' | 'status-light' | 'gauge-panel' | 'web-view' | 'desk-sign' | 'commit-board' | 'welcome-sign';
59
+ export type BehaviorActivity = 'busy' | 'idle' | 'waiting' | 'critical';
60
+ export type BehaviorIdle = 'seek-lounge' | 'seek-desk' | 'wander' | 'stay';
61
+ export type BehaviorPose = 'sit' | 'sleep' | 'stand';
62
+ export type CompanionPlacement = 'adjacent-wall' | 'on-desk' | 'freestanding';
63
+ export interface BehaviorPatrol {
64
+ /** [minMinutes, maxMinutes] between patrols */
65
+ everyMinutes: [number, number];
66
+ /** Number of stops per patrol */
67
+ stops?: number;
68
+ }
69
+ export type BehaviorHome = {
70
+ near: 'entrance' | 'center';
71
+ } | {
72
+ x: number;
73
+ y: number;
74
+ };
75
+ /**
76
+ * Behavior hints that steer how the NPC moves and idles in the world.
77
+ * All fields optional; the server merges hints → archetype defaults → global defaults.
78
+ */
79
+ export interface BehaviorHints {
80
+ /** Schema version */
81
+ v?: number;
82
+ /** Explicit activity override; else derived server-side */
83
+ activity?: BehaviorActivity;
84
+ /** Where to go when idle */
85
+ idle?: BehaviorIdle;
86
+ /** Where to go when busy: 'seek-desk' | 'wander' | 'stay' | 'seek-object:<category|catalogId>' */
87
+ busy?: string;
88
+ /** Pose to strike when idle */
89
+ idlePose?: BehaviorPose;
90
+ /** Emote to show while waiting (e.g. 'raised-hand') */
91
+ waitingEmote?: string;
92
+ /** Greet players on first appearance */
93
+ greetPlayers?: boolean;
94
+ /** Patrol schedule */
95
+ patrol?: BehaviorPatrol;
96
+ /** Home base to return to */
97
+ home?: BehaviorHome;
98
+ }
99
+ export interface CompanionSize {
100
+ /** Width in tiles (1-6) */
101
+ w: number;
102
+ /** Height in tiles (1-6) */
103
+ h: number;
104
+ }
105
+ /** A companion pixel-art object that spawns alongside the NPC. */
106
+ export interface CompanionSpec {
107
+ screenType: CompanionScreenType;
108
+ size?: CompanionSize;
109
+ placement: CompanionPlacement;
110
+ label?: string;
111
+ }
112
+ export interface NocvilleConfig {
113
+ /** Nocville API URL (default: https://nocville.com) */
114
+ apiUrl?: string;
115
+ /** Team API key for authentication */
116
+ apiKey: string;
117
+ /** The service ID this client represents */
118
+ serviceId: string;
119
+ /** Request timeout in milliseconds (default: 5000) */
120
+ timeout?: number;
121
+ /** Number of retry attempts on failure (default: 3) */
122
+ retries?: number;
123
+ /** Base delay between retries in milliseconds (default: 1000) */
124
+ retryDelay?: number;
125
+ /** Enable debug logging (default: false) */
126
+ debug?: boolean;
127
+ }
128
+ export interface AgentStats {
129
+ filesChanged: number;
130
+ insertions: number;
131
+ deletions: number;
132
+ lastFiles: string[];
133
+ /** Reserved for future usage reporting; the hook collector does not compute these. */
134
+ tokensIn?: number;
135
+ tokensOut?: number;
136
+ costUsd?: number;
137
+ }
138
+ export interface StatusUpdate {
139
+ agentStats?: AgentStats;
140
+ /** The current status of the service */
141
+ status: Status;
142
+ /** The mood/emotional state for NPC visualization */
143
+ mood?: Mood;
144
+ /** Optional message to display */
145
+ message?: string;
146
+ /** Friendly display name shown on the NPC banner (falls back to serviceId). */
147
+ name?: string;
148
+ /** Optional metrics to track */
149
+ metrics?: Record<string, number | string>;
150
+ /** NPC archetype identifier */
151
+ archetype?: string;
152
+ /** Sub-status for more granular state */
153
+ subStatus?: string;
154
+ /** Screen data for NPC display */
155
+ screenData?: ScreenData;
156
+ /** Links associated with this NPC */
157
+ links?: NPCLink[];
158
+ /** Actions available on this NPC */
159
+ actions?: NPCAction[];
160
+ /** Alert configuration */
161
+ alertConfig?: AlertConfig;
162
+ /** Behavior hints steering NPC movement/idle/busy */
163
+ behavior?: BehaviorHints;
164
+ /** Companion pixel-art object spec */
165
+ companion?: CompanionSpec;
166
+ /**
167
+ * Appearance hint. `family` dresses an ai-agent NPC in its branded look
168
+ * ('claude' = terracotta Claude tee, 'codex' = black OpenAI tee).
169
+ */
170
+ appearance?: {
171
+ family?: 'claude' | 'codex' | 'gemini' | 'cursor';
172
+ };
173
+ /** Clean-shutdown sentinel — prompts the server to despawn the NPC promptly */
174
+ offline?: boolean;
175
+ /**
176
+ * Target map for this push — a map id (e.g. `map_abc123`) or a map name
177
+ * (case-insensitive). Omit to target the team's default map.
178
+ */
179
+ map?: string;
180
+ }
181
+ export interface PushResponse {
182
+ /** HTTP status on failure, absent for network errors. */
183
+ statusCode?: number;
184
+ success: boolean;
185
+ timestamp: number;
186
+ error?: string;
187
+ }
188
+ export declare class NocvilleClient {
189
+ private config;
190
+ private lastStatus;
191
+ private lastMood;
192
+ private lastUpdate;
193
+ private _links;
194
+ private _actions;
195
+ private _logBuffer;
196
+ private _heartbeatInterval;
197
+ constructor(config: NocvilleConfig);
198
+ /**
199
+ * Push a status update to the Nocville server
200
+ */
201
+ pushStatus(update: StatusUpdate): Promise<PushResponse>;
202
+ /**
203
+ * Quick status helpers
204
+ */
205
+ healthy(message?: string, metrics?: Record<string, number | string>): Promise<PushResponse>;
206
+ warning(message?: string, metrics?: Record<string, number | string>): Promise<PushResponse>;
207
+ critical(message?: string, metrics?: Record<string, number | string>): Promise<PushResponse>;
208
+ /**
209
+ * Get the last known status
210
+ */
211
+ getLastStatus(): {
212
+ status: Status;
213
+ timestamp: number;
214
+ };
215
+ /**
216
+ * Push screen data for NPC display
217
+ */
218
+ pushScreen(data: ScreenData): Promise<PushResponse>;
219
+ /**
220
+ * Push metrics as a multi-metric gauge layout
221
+ */
222
+ pushGauges(metrics: Record<string, number>): Promise<PushResponse>;
223
+ /**
224
+ * Push a ticker-style screen display
225
+ */
226
+ pushTicker(values: Array<{
227
+ label: string;
228
+ value: string;
229
+ trend?: 'up' | 'down' | 'flat';
230
+ }>): Promise<PushResponse>;
231
+ /**
232
+ * Push a log line to a rolling terminal display (keeps last 20 lines)
233
+ */
234
+ pushLogLine(line: string): Promise<PushResponse>;
235
+ /**
236
+ * Start a heartbeat that periodically pushes the current status
237
+ */
238
+ startHeartbeat(intervalMs?: number): void;
239
+ /**
240
+ * Stop the heartbeat interval
241
+ */
242
+ stopHeartbeat(): void;
243
+ /**
244
+ * Set links to be included in every subsequent push
245
+ */
246
+ setLinks(links: NPCLink[]): void;
247
+ /**
248
+ * Set actions to be included in every subsequent push
249
+ */
250
+ setActions(actions: NPCAction[]): void;
251
+ /**
252
+ * Open an incident for this service
253
+ */
254
+ openIncident(title: string, severity?: 'minor' | 'major' | 'critical'): Promise<{
255
+ id: string;
256
+ }>;
257
+ /**
258
+ * Resolve an incident by ID
259
+ */
260
+ resolveIncident(incidentId: string): Promise<void>;
261
+ /**
262
+ * Add a note to an incident timeline
263
+ */
264
+ addIncidentNote(incidentId: string, note: string): Promise<void>;
265
+ private moodFromStatus;
266
+ private makeRequest;
267
+ private sleep;
268
+ private log;
269
+ }
270
+ export default NocvilleClient;
271
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Nocville Status Pusher SDK for Node.js
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { NocvilleClient } from '@nocville/node';
7
+ *
8
+ * const client = new NocvilleClient({
9
+ * apiKey: 'your-api-key',
10
+ * serviceId: 'my-service',
11
+ * });
12
+ *
13
+ * // Push a status update
14
+ * await client.pushStatus({
15
+ * status: 'healthy',
16
+ * mood: 'happy',
17
+ * message: 'All systems operational',
18
+ * metrics: {
19
+ * cpu: 45,
20
+ * memory: 60,
21
+ * requests: 1250,
22
+ * },
23
+ * });
24
+ * ```
25
+ */
26
+ class StatusPushError extends Error {
27
+ constructor(statusCode, body) {
28
+ super(`HTTP ${statusCode}: ${body}`);
29
+ this.statusCode = statusCode;
30
+ }
31
+ }
32
+ export class NocvilleClient {
33
+ constructor(config) {
34
+ this.lastStatus = 'unknown';
35
+ this.lastMood = 'idle';
36
+ this.lastUpdate = 0;
37
+ this._links = [];
38
+ this._actions = [];
39
+ this._logBuffer = [];
40
+ this._heartbeatInterval = null;
41
+ this.config = {
42
+ apiUrl: 'https://nocville.com',
43
+ timeout: 5000,
44
+ retries: 3,
45
+ retryDelay: 1000,
46
+ debug: false,
47
+ ...config,
48
+ };
49
+ this.config.apiUrl = this.config.apiUrl.replace(/\/+$/, '');
50
+ if (!this.config.apiKey) {
51
+ throw new Error('apiKey is required');
52
+ }
53
+ if (!this.config.serviceId) {
54
+ throw new Error('serviceId is required');
55
+ }
56
+ }
57
+ /**
58
+ * Push a status update to the Nocville server
59
+ */
60
+ async pushStatus(update) {
61
+ const payload = {
62
+ serviceId: this.config.serviceId,
63
+ status: update.status,
64
+ mood: update.mood || this.moodFromStatus(update.status),
65
+ message: update.message,
66
+ metrics: update.metrics,
67
+ };
68
+ if (update.agentStats !== undefined)
69
+ payload.agentStats = update.agentStats;
70
+ if (update.appearance !== undefined)
71
+ payload.appearance = update.appearance;
72
+ if (update.name !== undefined)
73
+ payload.name = update.name;
74
+ if (update.archetype !== undefined)
75
+ payload.archetype = update.archetype;
76
+ if (update.subStatus !== undefined)
77
+ payload.subStatus = update.subStatus;
78
+ if (update.screenData !== undefined)
79
+ payload.screenData = update.screenData;
80
+ if (update.alertConfig !== undefined)
81
+ payload.alertConfig = update.alertConfig;
82
+ if (update.behavior !== undefined)
83
+ payload.behavior = update.behavior;
84
+ if (update.companion !== undefined)
85
+ payload.companion = update.companion;
86
+ if (update.offline !== undefined)
87
+ payload.offline = update.offline;
88
+ if (update.map !== undefined)
89
+ payload.map = update.map;
90
+ // Merge stored links/actions with per-call overrides
91
+ const links = update.links || (this._links.length > 0 ? this._links : undefined);
92
+ const actions = update.actions || (this._actions.length > 0 ? this._actions : undefined);
93
+ if (links)
94
+ payload.links = links;
95
+ if (actions)
96
+ payload.actions = actions;
97
+ let lastError = null;
98
+ for (let attempt = 0; attempt <= this.config.retries; attempt++) {
99
+ try {
100
+ if (attempt > 0) {
101
+ const delay = this.config.retryDelay * Math.pow(2, attempt - 1);
102
+ this.log(`Retry attempt ${attempt} after ${delay}ms`);
103
+ await this.sleep(delay);
104
+ }
105
+ const response = await this.makeRequest(payload);
106
+ this.lastStatus = update.status;
107
+ this.lastMood = update.mood || this.moodFromStatus(update.status);
108
+ this.lastUpdate = Date.now();
109
+ return {
110
+ success: true,
111
+ timestamp: this.lastUpdate,
112
+ };
113
+ }
114
+ catch (error) {
115
+ lastError = error;
116
+ this.log(`Request failed: ${lastError.message}`);
117
+ if (lastError instanceof StatusPushError &&
118
+ [400, 401, 403, 404].includes(lastError.statusCode))
119
+ break;
120
+ }
121
+ }
122
+ return {
123
+ success: false,
124
+ timestamp: Date.now(),
125
+ error: lastError?.message || 'Unknown error',
126
+ statusCode: lastError instanceof StatusPushError ? lastError.statusCode : undefined,
127
+ };
128
+ }
129
+ /**
130
+ * Quick status helpers
131
+ */
132
+ async healthy(message, metrics) {
133
+ return this.pushStatus({ status: 'healthy', mood: 'happy', message, metrics });
134
+ }
135
+ async warning(message, metrics) {
136
+ return this.pushStatus({ status: 'warning', mood: 'concerned', message, metrics });
137
+ }
138
+ async critical(message, metrics) {
139
+ return this.pushStatus({ status: 'critical', mood: 'stressed', message, metrics });
140
+ }
141
+ /**
142
+ * Get the last known status
143
+ */
144
+ getLastStatus() {
145
+ return {
146
+ status: this.lastStatus,
147
+ timestamp: this.lastUpdate,
148
+ };
149
+ }
150
+ /**
151
+ * Push screen data for NPC display
152
+ */
153
+ async pushScreen(data) {
154
+ return this.pushStatus({
155
+ status: this.lastStatus,
156
+ mood: this.lastMood,
157
+ screenData: data,
158
+ });
159
+ }
160
+ /**
161
+ * Push metrics as a multi-metric gauge layout
162
+ */
163
+ async pushGauges(metrics) {
164
+ const screenData = {
165
+ layout: 'multi-metric',
166
+ values: Object.entries(metrics).map(([label, value]) => ({
167
+ label,
168
+ value: String(value),
169
+ })),
170
+ };
171
+ return this.pushScreen(screenData);
172
+ }
173
+ /**
174
+ * Push a ticker-style screen display
175
+ */
176
+ async pushTicker(values) {
177
+ const screenData = {
178
+ layout: 'ticker',
179
+ values: values.map((v) => ({
180
+ label: v.label,
181
+ value: v.value,
182
+ trend: v.trend,
183
+ })),
184
+ };
185
+ return this.pushScreen(screenData);
186
+ }
187
+ /**
188
+ * Push a log line to a rolling terminal display (keeps last 20 lines)
189
+ */
190
+ async pushLogLine(line) {
191
+ this._logBuffer.push(line);
192
+ if (this._logBuffer.length > 20) {
193
+ this._logBuffer = this._logBuffer.slice(-20);
194
+ }
195
+ const screenData = {
196
+ layout: 'log-terminal',
197
+ values: [{ label: 'log', value: this._logBuffer.join('\n') }],
198
+ };
199
+ return this.pushScreen(screenData);
200
+ }
201
+ /**
202
+ * Start a heartbeat that periodically pushes the current status
203
+ */
204
+ startHeartbeat(intervalMs = 30000) {
205
+ this.stopHeartbeat();
206
+ this._heartbeatInterval = setInterval(() => {
207
+ this.pushStatus({
208
+ status: this.lastStatus,
209
+ mood: this.lastMood,
210
+ }).catch((err) => this.log(`Heartbeat push failed: ${err}`));
211
+ }, intervalMs);
212
+ }
213
+ /**
214
+ * Stop the heartbeat interval
215
+ */
216
+ stopHeartbeat() {
217
+ if (this._heartbeatInterval !== null) {
218
+ clearInterval(this._heartbeatInterval);
219
+ this._heartbeatInterval = null;
220
+ }
221
+ }
222
+ /**
223
+ * Set links to be included in every subsequent push
224
+ */
225
+ setLinks(links) {
226
+ this._links = links;
227
+ }
228
+ /**
229
+ * Set actions to be included in every subsequent push
230
+ */
231
+ setActions(actions) {
232
+ this._actions = actions;
233
+ }
234
+ /**
235
+ * Open an incident for this service
236
+ */
237
+ async openIncident(title, severity = 'critical') {
238
+ const url = `${this.config.apiUrl}/api/v1/teams/_/incidents/sdk`;
239
+ const controller = new AbortController();
240
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
241
+ try {
242
+ const response = await fetch(url, {
243
+ method: 'POST',
244
+ headers: {
245
+ 'Content-Type': 'application/json',
246
+ Authorization: `Bearer ${this.config.apiKey}`,
247
+ },
248
+ body: JSON.stringify({
249
+ serviceId: this.config.serviceId,
250
+ title,
251
+ severity,
252
+ }),
253
+ signal: controller.signal,
254
+ });
255
+ if (!response.ok) {
256
+ const errorText = await response.text();
257
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
258
+ }
259
+ const result = (await response.json());
260
+ this.log(`Incident opened: ${result.data.id}`);
261
+ return { id: result.data.id };
262
+ }
263
+ finally {
264
+ clearTimeout(timeoutId);
265
+ }
266
+ }
267
+ /**
268
+ * Resolve an incident by ID
269
+ */
270
+ async resolveIncident(incidentId) {
271
+ const url = `${this.config.apiUrl}/api/v1/teams/_/incidents/${incidentId}/sdk`;
272
+ const controller = new AbortController();
273
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
274
+ try {
275
+ const response = await fetch(url, {
276
+ method: 'PATCH',
277
+ headers: {
278
+ 'Content-Type': 'application/json',
279
+ Authorization: `Bearer ${this.config.apiKey}`,
280
+ },
281
+ body: JSON.stringify({ status: 'resolved' }),
282
+ signal: controller.signal,
283
+ });
284
+ if (!response.ok) {
285
+ const errorText = await response.text();
286
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
287
+ }
288
+ this.log(`Incident resolved: ${incidentId}`);
289
+ }
290
+ finally {
291
+ clearTimeout(timeoutId);
292
+ }
293
+ }
294
+ /**
295
+ * Add a note to an incident timeline
296
+ */
297
+ async addIncidentNote(incidentId, note) {
298
+ const url = `${this.config.apiUrl}/api/v1/teams/_/incidents/${incidentId}/events/sdk`;
299
+ const controller = new AbortController();
300
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
301
+ try {
302
+ const response = await fetch(url, {
303
+ method: 'POST',
304
+ headers: {
305
+ 'Content-Type': 'application/json',
306
+ Authorization: `Bearer ${this.config.apiKey}`,
307
+ },
308
+ body: JSON.stringify({
309
+ type: 'note',
310
+ data: { note },
311
+ }),
312
+ signal: controller.signal,
313
+ });
314
+ if (!response.ok) {
315
+ const errorText = await response.text();
316
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
317
+ }
318
+ this.log(`Note added to incident: ${incidentId}`);
319
+ }
320
+ finally {
321
+ clearTimeout(timeoutId);
322
+ }
323
+ }
324
+ moodFromStatus(status) {
325
+ switch (status) {
326
+ case 'healthy':
327
+ return 'happy';
328
+ case 'warning':
329
+ return 'concerned';
330
+ case 'critical':
331
+ return 'stressed';
332
+ default:
333
+ return 'idle';
334
+ }
335
+ }
336
+ async makeRequest(payload) {
337
+ const url = `${this.config.apiUrl}/api/v1/status`;
338
+ const controller = new AbortController();
339
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
340
+ try {
341
+ const response = await fetch(url, {
342
+ method: 'POST',
343
+ headers: {
344
+ 'Content-Type': 'application/json',
345
+ Authorization: `Bearer ${this.config.apiKey}`,
346
+ },
347
+ body: JSON.stringify(payload),
348
+ signal: controller.signal,
349
+ });
350
+ if (!response.ok) {
351
+ const errorText = await response.text();
352
+ throw new StatusPushError(response.status, errorText);
353
+ }
354
+ }
355
+ finally {
356
+ clearTimeout(timeoutId);
357
+ }
358
+ }
359
+ sleep(ms) {
360
+ return new Promise((resolve) => setTimeout(resolve, ms));
361
+ }
362
+ log(message) {
363
+ if (this.config.debug) {
364
+ console.log(`[nocville] ${message}`);
365
+ }
366
+ }
367
+ }
368
+ // Default export for convenience
369
+ export default NocvilleClient;
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@nocville/node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.1",
4
+ "description": "Nocville Status Pusher SDK for Node.js",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/cjs/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "!dist/**/*.map",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "dependencies": {},
22
+ "devDependencies": {
23
+ "typescript": "^5.3.2",
24
+ "vitest": "^3.2.6"
25
+ },
26
+ "keywords": [
27
+ "nocville",
28
+ "monitoring",
29
+ "noc",
30
+ "status"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/marshmansf/nocville.git",
36
+ "directory": "libs/nocville-node"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "engines": {
42
+ "node": ">=20.12.0"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))\"",
46
+ "clean": "rm -rf dist",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run",
49
+ "test:watch": "vitest"
50
+ }
6
51
  }