@teamvibe/poller 0.1.68 → 0.1.69

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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compare two semver versions. Returns:
3
+ * - 'patch' if only patch differs (1.2.3 → 1.2.4)
4
+ * - 'minor' if minor differs (1.2.3 → 1.3.0)
5
+ * - 'major' if major differs (1.2.3 → 2.0.0)
6
+ * - null if same or current is newer
7
+ */
8
+ export declare function classifyUpdate(current: string, latest: string): 'patch' | 'minor' | 'major' | null;
9
+ /**
10
+ * Check for updates and apply if within policy.
11
+ * Called by the cron job defined in poller.ts.
12
+ *
13
+ * Returns true if an update was applied and poller should restart (exit 0).
14
+ */
15
+ export declare function checkForUpdate(): Promise<boolean>;
@@ -0,0 +1,151 @@
1
+ import { execSync } from 'child_process';
2
+ import { config, POLLER_VERSION } from './config.js';
3
+ import { logger } from './logger.js';
4
+ import { emitEvent } from './event-queue.js';
5
+ import { doctor } from './cli/doctor.js';
6
+ const UPDATE_TIMEOUT_MS = 120_000; // 2 minutes
7
+ const MAX_CONSECUTIVE_FAILURES = 3;
8
+ let consecutiveFailures = 0;
9
+ /**
10
+ * Compare two semver versions. Returns:
11
+ * - 'patch' if only patch differs (1.2.3 → 1.2.4)
12
+ * - 'minor' if minor differs (1.2.3 → 1.3.0)
13
+ * - 'major' if major differs (1.2.3 → 2.0.0)
14
+ * - null if same or current is newer
15
+ */
16
+ export function classifyUpdate(current, latest) {
17
+ const [curMajor, curMinor, curPatch] = current.split('.').map(Number);
18
+ const [latMajor, latMinor, latPatch] = latest.split('.').map(Number);
19
+ if (latMajor > curMajor)
20
+ return 'major';
21
+ if (latMajor < curMajor)
22
+ return null;
23
+ if (latMinor > curMinor)
24
+ return 'minor';
25
+ if (latMinor < curMinor)
26
+ return null;
27
+ if (latPatch > curPatch)
28
+ return 'patch';
29
+ return null;
30
+ }
31
+ /**
32
+ * Fetch the latest version from npm registry.
33
+ */
34
+ function getLatestVersion() {
35
+ try {
36
+ const output = execSync('npm view @teamvibe/poller version', {
37
+ encoding: 'utf-8',
38
+ timeout: 15_000,
39
+ stdio: ['pipe', 'pipe', 'pipe'],
40
+ });
41
+ return output.trim();
42
+ }
43
+ catch (error) {
44
+ logger.warn(`[AutoUpdate] Failed to check npm registry: ${error instanceof Error ? error.message : error}`);
45
+ return null;
46
+ }
47
+ }
48
+ /**
49
+ * Check for updates and apply if within policy.
50
+ * Called by the cron job defined in poller.ts.
51
+ *
52
+ * Returns true if an update was applied and poller should restart (exit 0).
53
+ */
54
+ export async function checkForUpdate() {
55
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
56
+ logger.warn(`[AutoUpdate] Disabled — ${consecutiveFailures} consecutive failures`);
57
+ emitEvent('poller.autoupdate.disabled', 'system', {
58
+ reason: 'max-failures-reached',
59
+ failures: consecutiveFailures,
60
+ currentVersion: POLLER_VERSION,
61
+ });
62
+ return false;
63
+ }
64
+ logger.info(`[AutoUpdate] Checking for updates (current: ${POLLER_VERSION}, policy: ${config.AUTO_UPDATE_POLICY})`);
65
+ // Check npm registry for latest version
66
+ const latestVersion = getLatestVersion();
67
+ if (!latestVersion) {
68
+ return false;
69
+ }
70
+ const updateType = classifyUpdate(POLLER_VERSION, latestVersion);
71
+ if (!updateType) {
72
+ logger.info(`[AutoUpdate] Already on latest version (${POLLER_VERSION})`);
73
+ return false;
74
+ }
75
+ // Check if update type is within policy
76
+ const policyAllows = config.AUTO_UPDATE_POLICY === 'minor'
77
+ ? updateType === 'patch' || updateType === 'minor'
78
+ : updateType === 'patch'; // policy === 'patch'
79
+ if (!policyAllows) {
80
+ logger.info(`[AutoUpdate] ${updateType} update available (${POLLER_VERSION} → ${latestVersion}) but policy is "${config.AUTO_UPDATE_POLICY}", skipping`);
81
+ return false;
82
+ }
83
+ logger.info(`[AutoUpdate] ${updateType} update available: ${POLLER_VERSION} → ${latestVersion}`);
84
+ // Step 1: Pre-check
85
+ logger.info('[AutoUpdate] Running pre-update health check...');
86
+ const preCheckOk = await doctor();
87
+ if (!preCheckOk) {
88
+ logger.warn('[AutoUpdate] Pre-check failed, skipping update');
89
+ consecutiveFailures++;
90
+ emitEvent('poller.autoupdate.failed', 'system', {
91
+ reason: 'pre-check-failed',
92
+ currentVersion: POLLER_VERSION,
93
+ targetVersion: latestVersion,
94
+ });
95
+ return false;
96
+ }
97
+ // Step 2: Install
98
+ logger.info(`[AutoUpdate] Installing @teamvibe/poller@${latestVersion}...`);
99
+ try {
100
+ execSync(`npm install -g @teamvibe/poller@${latestVersion}`, {
101
+ timeout: UPDATE_TIMEOUT_MS,
102
+ stdio: 'pipe',
103
+ encoding: 'utf-8',
104
+ });
105
+ }
106
+ catch (error) {
107
+ const errorMsg = error instanceof Error ? error.message : String(error);
108
+ logger.error(`[AutoUpdate] Install failed: ${errorMsg}`);
109
+ consecutiveFailures++;
110
+ emitEvent('poller.autoupdate.failed', 'system', {
111
+ reason: 'install-failed',
112
+ currentVersion: POLLER_VERSION,
113
+ targetVersion: latestVersion,
114
+ error: errorMsg,
115
+ });
116
+ return false;
117
+ }
118
+ // Step 3: Post-check
119
+ logger.info('[AutoUpdate] Running post-update health check...');
120
+ const postCheckOk = await doctor();
121
+ if (!postCheckOk) {
122
+ logger.warn(`[AutoUpdate] Post-check failed, rolling back to ${POLLER_VERSION}...`);
123
+ try {
124
+ execSync(`npm install -g @teamvibe/poller@${POLLER_VERSION}`, {
125
+ timeout: UPDATE_TIMEOUT_MS,
126
+ stdio: 'pipe',
127
+ encoding: 'utf-8',
128
+ });
129
+ logger.info(`[AutoUpdate] Rolled back to ${POLLER_VERSION}`);
130
+ }
131
+ catch (rollbackError) {
132
+ logger.error(`[AutoUpdate] CRITICAL: Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : rollbackError}`);
133
+ }
134
+ consecutiveFailures++;
135
+ emitEvent('poller.autoupdate.rolledback', 'system', {
136
+ currentVersion: POLLER_VERSION,
137
+ targetVersion: latestVersion,
138
+ reason: 'post-check-failed',
139
+ });
140
+ return false;
141
+ }
142
+ // Success
143
+ consecutiveFailures = 0;
144
+ logger.info(`[AutoUpdate] Successfully updated ${POLLER_VERSION} → ${latestVersion}. Restart required.`);
145
+ emitEvent('poller.autoupdate.success', 'system', {
146
+ previousVersion: POLLER_VERSION,
147
+ newVersion: latestVersion,
148
+ updateType,
149
+ });
150
+ return true;
151
+ }
@@ -5,6 +5,7 @@ import { update } from './update.js';
5
5
  import { logs } from './logs.js';
6
6
  import { start, stop, restart, status } from './service.js';
7
7
  import { reset } from './reset.js';
8
+ import { doctor } from './doctor.js';
8
9
  function showHelp() {
9
10
  console.log(`TeamVibe Poller
10
11
 
@@ -16,6 +17,7 @@ Commands:
16
17
  Flags: --token <token> --claude-token <token> [--api-url <url>] [--max-concurrent <n>]
17
18
  uninstall Remove the launchd service
18
19
  update Update to the latest version and restart
20
+ doctor Run health checks (verify installation & configuration)
19
21
  start Start the installed service
20
22
  stop Stop the installed service
21
23
  restart Restart the installed service
@@ -57,6 +59,11 @@ export async function handleCommand(command) {
57
59
  case 'restart':
58
60
  restart();
59
61
  break;
62
+ case 'doctor': {
63
+ const healthy = await doctor();
64
+ process.exit(healthy ? 0 : 1);
65
+ break;
66
+ }
60
67
  case 'reset':
61
68
  await reset();
62
69
  break;
@@ -0,0 +1 @@
1
+ export declare function doctor(): Promise<boolean>;
@@ -0,0 +1,195 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { PLIST_PATH, SERVICE_LABEL, TEAMVIBE_DIR, LOGS_DIR } from './plist.js';
5
+ function check(name, fn) {
6
+ try {
7
+ const result = fn();
8
+ if (result === true) {
9
+ return { name, ok: true, message: 'OK' };
10
+ }
11
+ return { name, ok: true, message: result };
12
+ }
13
+ catch (error) {
14
+ return { name, ok: false, message: error instanceof Error ? error.message : String(error) };
15
+ }
16
+ }
17
+ function checkNodeVersion() {
18
+ return check('Node.js version', () => {
19
+ const version = process.version;
20
+ const major = parseInt(version.slice(1).split('.')[0], 10);
21
+ if (major < 18) {
22
+ throw new Error(`Node.js ${version} is below minimum (v18). Please upgrade.`);
23
+ }
24
+ return `${version} (>= v18)`;
25
+ });
26
+ }
27
+ function checkTeamVibeDir() {
28
+ return check('TeamVibe directory', () => {
29
+ if (!fs.existsSync(TEAMVIBE_DIR)) {
30
+ throw new Error(`${TEAMVIBE_DIR} does not exist. Run 'poller install' first.`);
31
+ }
32
+ return TEAMVIBE_DIR;
33
+ });
34
+ }
35
+ function checkEnvFile() {
36
+ return check('Environment config', () => {
37
+ const envPath = path.join(TEAMVIBE_DIR, '.env');
38
+ if (!fs.existsSync(envPath)) {
39
+ throw new Error(`${envPath} not found. Run 'poller install' to configure.`);
40
+ }
41
+ const content = fs.readFileSync(envPath, 'utf-8');
42
+ const hasToken = content.includes('TEAMVIBE_POLLER_TOKEN');
43
+ const hasDirect = content.includes('SQS_QUEUE_URL') && content.includes('SESSIONS_TABLE');
44
+ if (!hasToken && !hasDirect) {
45
+ throw new Error('Missing auth configuration (TEAMVIBE_POLLER_TOKEN or SQS_QUEUE_URL + SESSIONS_TABLE)');
46
+ }
47
+ return hasToken ? 'Token mode configured' : 'Direct mode configured';
48
+ });
49
+ }
50
+ function checkLaunchdPlist() {
51
+ return check('Launchd plist', () => {
52
+ if (process.platform !== 'darwin') {
53
+ return 'Skipped (not macOS)';
54
+ }
55
+ if (!fs.existsSync(PLIST_PATH)) {
56
+ throw new Error(`Plist not found at ${PLIST_PATH}. Run 'poller install'.`);
57
+ }
58
+ const content = fs.readFileSync(PLIST_PATH, 'utf-8');
59
+ if (!content.includes('<key>KeepAlive</key>')) {
60
+ throw new Error('KeepAlive not set in plist — poller will not auto-restart on exit');
61
+ }
62
+ if (content.includes('/_npx/')) {
63
+ throw new Error('Plist references stale npx cache path. Run \'poller update\' to fix.');
64
+ }
65
+ return 'Installed with KeepAlive';
66
+ });
67
+ }
68
+ function checkServiceRunning() {
69
+ return check('Service status', () => {
70
+ if (process.platform !== 'darwin') {
71
+ return 'Skipped (not macOS)';
72
+ }
73
+ try {
74
+ const output = execSync(`launchctl list | grep ${SERVICE_LABEL}`, { encoding: 'utf-8' });
75
+ if (output.trim()) {
76
+ const parts = output.trim().split('\t');
77
+ const pid = parts[0];
78
+ const lastExitStatus = parts[1];
79
+ if (pid === '-') {
80
+ throw new Error(`Service loaded but not running (last exit: ${lastExitStatus})`);
81
+ }
82
+ return `Running (PID: ${pid})`;
83
+ }
84
+ throw new Error('Service not loaded');
85
+ }
86
+ catch (error) {
87
+ if (error instanceof Error && error.message.includes('Service')) {
88
+ throw error;
89
+ }
90
+ throw new Error('Service not loaded in launchd');
91
+ }
92
+ });
93
+ }
94
+ function checkLogsDir() {
95
+ return check('Logs directory', () => {
96
+ if (!fs.existsSync(LOGS_DIR)) {
97
+ throw new Error(`${LOGS_DIR} does not exist`);
98
+ }
99
+ const stdoutLog = path.join(LOGS_DIR, 'poller.stdout.log');
100
+ const stderrLog = path.join(LOGS_DIR, 'poller.stderr.log');
101
+ const hasStdout = fs.existsSync(stdoutLog);
102
+ const hasStderr = fs.existsSync(stderrLog);
103
+ if (!hasStdout && !hasStderr) {
104
+ return 'Directory exists but no log files yet';
105
+ }
106
+ return 'OK';
107
+ });
108
+ }
109
+ function checkClaudeCli() {
110
+ return check('Claude CLI', () => {
111
+ try {
112
+ const output = execSync('which claude', { encoding: 'utf-8' }).trim();
113
+ if (!output) {
114
+ throw new Error('Claude CLI not found in PATH');
115
+ }
116
+ // Try to get version
117
+ try {
118
+ const version = execSync('claude --version 2>/dev/null', { encoding: 'utf-8' }).trim();
119
+ return `${output} (${version})`;
120
+ }
121
+ catch {
122
+ return output;
123
+ }
124
+ }
125
+ catch {
126
+ throw new Error('Claude CLI not found in PATH. Install Claude Code first.');
127
+ }
128
+ });
129
+ }
130
+ function checkBrainsDir() {
131
+ return check('Brains directory', () => {
132
+ const brainsPath = path.join(TEAMVIBE_DIR, 'brains');
133
+ if (!fs.existsSync(brainsPath)) {
134
+ return 'Not yet created (will be created on first message)';
135
+ }
136
+ const brains = fs.readdirSync(brainsPath).filter(f => {
137
+ const stat = fs.statSync(path.join(brainsPath, f));
138
+ return stat.isDirectory();
139
+ });
140
+ return `${brains.length} brain(s) cloned`;
141
+ });
142
+ }
143
+ function checkDiskSpace() {
144
+ return check('Disk space', () => {
145
+ try {
146
+ const output = execSync(`df -h "${TEAMVIBE_DIR}" | tail -1`, { encoding: 'utf-8' }).trim();
147
+ const parts = output.split(/\s+/);
148
+ const available = parts[3];
149
+ const capacityStr = parts[4]?.replace('%', '');
150
+ const capacity = parseInt(capacityStr || '0', 10);
151
+ if (capacity > 95) {
152
+ throw new Error(`Disk ${capacity}% full — only ${available} available`);
153
+ }
154
+ if (capacity > 85) {
155
+ return `Warning: ${capacity}% used (${available} available)`;
156
+ }
157
+ return `${available} available (${capacity}% used)`;
158
+ }
159
+ catch (error) {
160
+ if (error instanceof Error && error.message.includes('Disk'))
161
+ throw error;
162
+ throw new Error('Could not check disk space');
163
+ }
164
+ });
165
+ }
166
+ export async function doctor() {
167
+ console.log('TeamVibe Poller - Health Check\n');
168
+ const results = [
169
+ checkNodeVersion(),
170
+ checkTeamVibeDir(),
171
+ checkEnvFile(),
172
+ checkLaunchdPlist(),
173
+ checkServiceRunning(),
174
+ checkLogsDir(),
175
+ checkClaudeCli(),
176
+ checkBrainsDir(),
177
+ checkDiskSpace(),
178
+ ];
179
+ let hasFailure = false;
180
+ for (const result of results) {
181
+ const icon = result.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
182
+ console.log(` ${icon} ${result.name}: ${result.message}`);
183
+ if (!result.ok)
184
+ hasFailure = true;
185
+ }
186
+ console.log('');
187
+ if (hasFailure) {
188
+ console.log('\x1b[31mSome checks failed. Fix the issues above before proceeding.\x1b[0m');
189
+ return false;
190
+ }
191
+ else {
192
+ console.log('\x1b[32mAll checks passed. Poller is healthy.\x1b[0m');
193
+ return true;
194
+ }
195
+ }
package/dist/config.d.ts CHANGED
@@ -30,6 +30,8 @@ declare const configSchema: z.ZodObject<{
30
30
  MAINTENANCE_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
31
31
  MAINTENANCE_CHECK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
32
32
  MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
33
+ AUTO_UPDATE_POLICY: z.ZodDefault<z.ZodEnum<["patch", "minor", "off"]>>;
34
+ AUTO_UPDATE_SCHEDULE: z.ZodDefault<z.ZodString>;
33
35
  }, "strip", z.ZodTypeAny, {
34
36
  AWS_REGION: string;
35
37
  TEAMVIBE_API_URL: string;
@@ -57,6 +59,8 @@ declare const configSchema: z.ZodObject<{
57
59
  MAINTENANCE_INTERVAL_MS: number;
58
60
  MAINTENANCE_CHECK_INTERVAL_MS: number;
59
61
  MAINTENANCE_ENABLED: boolean;
62
+ AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
63
+ AUTO_UPDATE_SCHEDULE: string;
60
64
  SQS_QUEUE_URL?: string | undefined;
61
65
  SESSIONS_TABLE?: string | undefined;
62
66
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
@@ -90,6 +94,8 @@ declare const configSchema: z.ZodObject<{
90
94
  MAINTENANCE_INTERVAL_MS?: number | undefined;
91
95
  MAINTENANCE_CHECK_INTERVAL_MS?: number | undefined;
92
96
  MAINTENANCE_ENABLED?: string | undefined;
97
+ AUTO_UPDATE_POLICY?: "patch" | "minor" | "off" | undefined;
98
+ AUTO_UPDATE_SCHEDULE?: string | undefined;
93
99
  }>;
94
100
  export type Config = z.infer<typeof configSchema>;
95
101
  export declare function loadConfig(): Config;
@@ -120,6 +126,8 @@ export declare const config: {
120
126
  MAINTENANCE_INTERVAL_MS: number;
121
127
  MAINTENANCE_CHECK_INTERVAL_MS: number;
122
128
  MAINTENANCE_ENABLED: boolean;
129
+ AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
130
+ AUTO_UPDATE_SCHEDULE: string;
123
131
  SQS_QUEUE_URL?: string | undefined;
124
132
  SESSIONS_TABLE?: string | undefined;
125
133
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
package/dist/config.js CHANGED
@@ -46,6 +46,9 @@ const configSchema = z.object({
46
46
  MAINTENANCE_INTERVAL_MS: z.coerce.number().default(21600000), // 6 hours between maintenance per brain
47
47
  MAINTENANCE_CHECK_INTERVAL_MS: z.coerce.number().default(300000), // 5 min check loop
48
48
  MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
49
+ // Auto-update settings
50
+ AUTO_UPDATE_POLICY: z.enum(['patch', 'minor', 'off']).default('patch'),
51
+ AUTO_UPDATE_SCHEDULE: z.string().default('03:00'), // HH:MM in UTC — daily check time
49
52
  });
50
53
  export function loadConfig() {
51
54
  const result = configSchema.safeParse(process.env);
@@ -0,0 +1,4 @@
1
+ import type { ControlMessage } from './types.js';
2
+ export type { ControlMessage };
3
+ export declare function isControlMessage(message: unknown): message is ControlMessage;
4
+ export declare function handleControlMessage(control: ControlMessage): Promise<void>;
@@ -0,0 +1,143 @@
1
+ import { execSync } from 'child_process';
2
+ import { createRequire } from 'module';
3
+ import { logger } from './logger.js';
4
+ import { emitEvent, flush as flushEventQueue } from './event-queue.js';
5
+ import { doctor } from './cli/doctor.js';
6
+ const require = createRequire(import.meta.url);
7
+ const pkg = require('../package.json');
8
+ const UPDATE_TIMEOUT_MS = 120_000; // 2 minutes
9
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
10
+ export function isControlMessage(message) {
11
+ if (typeof message !== 'object' || message === null)
12
+ return false;
13
+ const msg = message;
14
+ return msg['source'] === 'control' && (msg['action'] === 'restart' || msg['action'] === 'update');
15
+ }
16
+ export async function handleControlMessage(control) {
17
+ logger.info(`[Control] Received action: ${control.action}`);
18
+ if (control.action === 'restart') {
19
+ await handleRestart();
20
+ }
21
+ else if (control.action === 'update') {
22
+ await handleUpdate(control.targetVersion);
23
+ }
24
+ }
25
+ async function handleRestart() {
26
+ logger.info('[Control] Initiating graceful restart...');
27
+ emitEvent('poller.restart', 'system', { version: pkg.version });
28
+ await flushEventQueue();
29
+ process.exit(0);
30
+ }
31
+ async function handleUpdate(targetVersion) {
32
+ const currentVersion = pkg.version;
33
+ logger.info(`[Control] Starting safe update. Current version: ${currentVersion}`);
34
+ // Defense-in-depth: sanitize targetVersion even though resolver validates too
35
+ if (targetVersion && !SEMVER_RE.test(targetVersion)) {
36
+ logger.error(`[Control] Invalid targetVersion "${targetVersion}" — must be semver (e.g. 1.2.3)`);
37
+ emitEvent('poller.update.failed', 'system', { reason: 'invalid-version', currentVersion, targetVersion });
38
+ await flushEventQueue();
39
+ return;
40
+ }
41
+ // Step 1: Pre-check — verify current state is healthy
42
+ logger.info('[Control] Running pre-update health check...');
43
+ const preCheckOk = await doctor();
44
+ if (!preCheckOk) {
45
+ logger.error('[Control] Pre-update doctor check failed. Aborting update.');
46
+ emitEvent('poller.update.failed', 'system', {
47
+ reason: 'pre-check-failed',
48
+ currentVersion,
49
+ });
50
+ await flushEventQueue();
51
+ return;
52
+ }
53
+ // Step 2: Determine target version
54
+ // Without explicit targetVersion, find latest and only allow patch updates
55
+ let resolvedVersion = targetVersion;
56
+ if (!resolvedVersion) {
57
+ try {
58
+ resolvedVersion = execSync('npm view @teamvibe/poller version', {
59
+ encoding: 'utf-8',
60
+ timeout: 15_000,
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ }).trim();
63
+ }
64
+ catch {
65
+ logger.error('[Control] Failed to check npm registry for latest version');
66
+ emitEvent('poller.update.failed', 'system', { reason: 'registry-check-failed', currentVersion });
67
+ await flushEventQueue();
68
+ return;
69
+ }
70
+ if (!SEMVER_RE.test(resolvedVersion)) {
71
+ logger.error(`[Control] npm returned invalid version: "${resolvedVersion}"`);
72
+ return;
73
+ }
74
+ if (resolvedVersion === currentVersion) {
75
+ logger.info(`[Control] Already on latest version (${currentVersion})`);
76
+ return;
77
+ }
78
+ }
79
+ const packageSpec = `@teamvibe/poller@${resolvedVersion}`;
80
+ logger.info(`[Control] Installing ${packageSpec}...`);
81
+ try {
82
+ execSync(`npm install -g ${packageSpec}`, {
83
+ timeout: UPDATE_TIMEOUT_MS,
84
+ stdio: 'pipe',
85
+ encoding: 'utf-8',
86
+ });
87
+ logger.info('[Control] npm install completed');
88
+ }
89
+ catch (error) {
90
+ const errorMsg = error instanceof Error ? error.message : String(error);
91
+ logger.error(`[Control] Update failed: ${errorMsg}`);
92
+ // Update failed — no rollback needed (original version still in place)
93
+ emitEvent('poller.update.failed', 'system', {
94
+ reason: 'install-failed',
95
+ currentVersion,
96
+ targetVersion: resolvedVersion,
97
+ error: errorMsg,
98
+ });
99
+ await flushEventQueue();
100
+ return;
101
+ }
102
+ // Step 3: Post-update doctor check
103
+ logger.info('[Control] Running post-update health check...');
104
+ const postCheckOk = await doctor();
105
+ if (!postCheckOk) {
106
+ logger.warn(`[Control] Post-update doctor failed. Rolling back to ${currentVersion}...`);
107
+ // Step 4: Rollback
108
+ try {
109
+ execSync(`npm install -g @teamvibe/poller@${currentVersion}`, {
110
+ timeout: UPDATE_TIMEOUT_MS,
111
+ stdio: 'pipe',
112
+ encoding: 'utf-8',
113
+ });
114
+ logger.info(`[Control] Rolled back to ${currentVersion}`);
115
+ emitEvent('poller.update.rolledback', 'system', {
116
+ currentVersion,
117
+ targetVersion: resolvedVersion,
118
+ reason: 'post-check-failed',
119
+ });
120
+ }
121
+ catch (rollbackError) {
122
+ const errMsg = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
123
+ logger.error(`[Control] CRITICAL: Rollback failed: ${errMsg}`);
124
+ emitEvent('poller.update.rollback-failed', 'system', {
125
+ currentVersion,
126
+ targetVersion: resolvedVersion,
127
+ error: errMsg,
128
+ });
129
+ }
130
+ await flushEventQueue();
131
+ // Restart anyway — launchd will bring us back (exit 1 to signal failure)
132
+ process.exit(1);
133
+ return;
134
+ }
135
+ // Step 5: Success — restart with new version
136
+ logger.info('[Control] Update successful. Restarting with new version...');
137
+ emitEvent('poller.update.success', 'system', {
138
+ previousVersion: currentVersion,
139
+ targetVersion: resolvedVersion,
140
+ });
141
+ await flushEventQueue();
142
+ process.exit(0);
143
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { isControlMessage } from './control-handler.js';
3
+ import { classifyUpdate } from './auto-update.js';
4
+ describe('isControlMessage', () => {
5
+ test('valid restart message', () => {
6
+ expect(isControlMessage({ source: 'control', action: 'restart' })).toBe(true);
7
+ });
8
+ test('valid update message', () => {
9
+ expect(isControlMessage({ source: 'control', action: 'update' })).toBe(true);
10
+ });
11
+ test('valid update with targetVersion', () => {
12
+ expect(isControlMessage({ source: 'control', action: 'update', targetVersion: '1.2.3' })).toBe(true);
13
+ });
14
+ test('rejects non-control source', () => {
15
+ expect(isControlMessage({ source: 'slack', action: 'restart' })).toBe(false);
16
+ });
17
+ test('rejects unknown action', () => {
18
+ expect(isControlMessage({ source: 'control', action: 'delete' })).toBe(false);
19
+ });
20
+ test('rejects null', () => {
21
+ expect(isControlMessage(null)).toBe(false);
22
+ });
23
+ test('rejects undefined', () => {
24
+ expect(isControlMessage(undefined)).toBe(false);
25
+ });
26
+ test('rejects string', () => {
27
+ expect(isControlMessage('control')).toBe(false);
28
+ });
29
+ test('rejects empty object', () => {
30
+ expect(isControlMessage({})).toBe(false);
31
+ });
32
+ test('rejects regular queue message', () => {
33
+ expect(isControlMessage({
34
+ id: '123',
35
+ source: 'cron',
36
+ thread_id: 'thread1',
37
+ sender: { id: 'user1', name: 'Test' },
38
+ text: 'hello',
39
+ })).toBe(false);
40
+ });
41
+ });
42
+ describe('classifyUpdate', () => {
43
+ test('detects patch update', () => {
44
+ expect(classifyUpdate('1.2.3', '1.2.4')).toBe('patch');
45
+ });
46
+ test('detects minor update', () => {
47
+ expect(classifyUpdate('1.2.3', '1.3.0')).toBe('minor');
48
+ });
49
+ test('detects major update', () => {
50
+ expect(classifyUpdate('1.2.3', '2.0.0')).toBe('major');
51
+ });
52
+ test('returns null for same version', () => {
53
+ expect(classifyUpdate('1.2.3', '1.2.3')).toBeNull();
54
+ });
55
+ test('returns null for downgrade (patch)', () => {
56
+ expect(classifyUpdate('1.2.4', '1.2.3')).toBeNull();
57
+ });
58
+ test('returns null for downgrade (minor)', () => {
59
+ expect(classifyUpdate('1.3.0', '1.2.5')).toBeNull();
60
+ });
61
+ test('returns null for downgrade (major)', () => {
62
+ expect(classifyUpdate('2.0.0', '1.9.9')).toBeNull();
63
+ });
64
+ test('handles zero versions', () => {
65
+ expect(classifyUpdate('0.0.1', '0.0.2')).toBe('patch');
66
+ expect(classifyUpdate('0.1.0', '0.2.0')).toBe('minor');
67
+ });
68
+ test('minor update with different patch', () => {
69
+ expect(classifyUpdate('1.2.3', '1.3.5')).toBe('minor');
70
+ });
71
+ test('major update with different minor/patch', () => {
72
+ expect(classifyUpdate('1.2.3', '2.1.0')).toBe('major');
73
+ });
74
+ });
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  const command = process.argv[2];
3
- if (command && ['install', 'uninstall', 'update', 'logs', 'status', 'start', 'stop', 'restart', 'reset', '--help', '-h', '--version', '-v'].includes(command)) {
3
+ if (command && ['install', 'uninstall', 'update', 'doctor', 'logs', 'status', 'start', 'stop', 'restart', 'reset', '--help', '-h', '--version', '-v'].includes(command)) {
4
4
  const { handleCommand } = await import('./cli/commands.js');
5
5
  await handleCommand(command);
6
6
  }