nightytidy 0.2.13 → 0.3.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/package.json +1 -1
- package/src/agent/firebase-auth.js +57 -0
- package/src/agent/firestore-poller.js +191 -0
- package/src/agent/index.js +48 -0
- package/src/agent/service.js +196 -0
- package/src/agent/setup-flow.js +190 -0
- package/src/cli.js +84 -0
package/package.json
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { debug, info, warn } from '../logger.js';
|
|
2
4
|
|
|
3
5
|
const REFRESH_BUFFER_MS = 15 * 60_000; // Request refresh 15 min before expiry
|
|
@@ -58,6 +60,7 @@ export class FirebaseAuth {
|
|
|
58
60
|
}
|
|
59
61
|
const remainMin = Math.round((this.expiresAt - Date.now()) / 60_000);
|
|
60
62
|
debug(`Firebase auth token updated (expires in ${remainMin}m)`);
|
|
63
|
+
this.saveTokenToDisk(token);
|
|
61
64
|
this._replayQueue();
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -128,6 +131,60 @@ export class FirebaseAuth {
|
|
|
128
131
|
this._replayCallback(queue);
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Persist the raw JWT token to `{configDir}/firebase-token.json`.
|
|
136
|
+
* Atomic write (temp file + rename). Best-effort — logs a warning on failure.
|
|
137
|
+
*/
|
|
138
|
+
saveTokenToDisk(token) {
|
|
139
|
+
try {
|
|
140
|
+
const tokenPath = path.join(this.configDir, 'firebase-token.json');
|
|
141
|
+
const tmpPath = tokenPath + '.tmp';
|
|
142
|
+
const data = JSON.stringify({ token, savedAt: Date.now() });
|
|
143
|
+
fs.writeFileSync(tmpPath, data, 'utf-8');
|
|
144
|
+
fs.renameSync(tmpPath, tokenPath);
|
|
145
|
+
debug('Firebase token saved to disk');
|
|
146
|
+
} catch (err) {
|
|
147
|
+
warn(`Failed to save Firebase token to disk: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Load a previously saved Firebase token from `{configDir}/firebase-token.json`.
|
|
153
|
+
* Returns the token string, or null if missing, corrupt, or expired.
|
|
154
|
+
* Deletes the file if the token is expired.
|
|
155
|
+
*/
|
|
156
|
+
loadTokenFromDisk() {
|
|
157
|
+
const tokenPath = path.join(this.configDir, 'firebase-token.json');
|
|
158
|
+
try {
|
|
159
|
+
const raw = fs.readFileSync(tokenPath, 'utf-8');
|
|
160
|
+
const { token } = JSON.parse(raw);
|
|
161
|
+
if (!token || typeof token !== 'string') return null;
|
|
162
|
+
|
|
163
|
+
const expiry = FirebaseAuth.parseJwtExpiry(token);
|
|
164
|
+
if (expiry !== null && expiry <= Date.now()) {
|
|
165
|
+
// Token is expired — clean up the file
|
|
166
|
+
try { fs.unlinkSync(tokenPath); } catch { /* ignore */ }
|
|
167
|
+
debug('Stored Firebase token is expired — removed from disk');
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return token;
|
|
171
|
+
} catch {
|
|
172
|
+
// File missing or corrupt — not an error
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Attempt to restore Firebase auth state from the on-disk token.
|
|
179
|
+
* Returns true if a valid token was found and restored, false otherwise.
|
|
180
|
+
*/
|
|
181
|
+
restoreToken() {
|
|
182
|
+
const token = this.loadTokenFromDisk();
|
|
183
|
+
if (!token) return false;
|
|
184
|
+
this.setToken(token);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
131
188
|
// Full OAuth flow will be implemented in integration phase
|
|
132
189
|
// For now, this is a placeholder that stores/retrieves tokens
|
|
133
190
|
async authenticate() {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { debug, warn } from '../logger.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FirestorePoller — polls Firestore REST API for queued runs.
|
|
5
|
+
*
|
|
6
|
+
* This is a SECONDARY trigger alongside the existing WebSocket command path.
|
|
7
|
+
* It allows the agent to pick up runs queued by the web app even when no
|
|
8
|
+
* browser is connected.
|
|
9
|
+
*
|
|
10
|
+
* Never throws — all errors are logged and swallowed.
|
|
11
|
+
*/
|
|
12
|
+
export class FirestorePoller {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {import('./firebase-auth.js').FirebaseAuth} opts.firebaseAuth
|
|
16
|
+
* @param {string} opts.projectId Firebase project ID (e.g. 'nightytidy-web')
|
|
17
|
+
* @param {(run: object) => void} opts.onQueuedRunFound Callback when a run is found
|
|
18
|
+
*/
|
|
19
|
+
constructor({ firebaseAuth, projectId, onQueuedRunFound }) {
|
|
20
|
+
this._firebaseAuth = firebaseAuth;
|
|
21
|
+
this._projectId = projectId;
|
|
22
|
+
this._onQueuedRunFound = onQueuedRunFound;
|
|
23
|
+
this._intervalId = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Start polling on a fixed interval.
|
|
28
|
+
* @param {number} intervalMs Polling interval (default 30 s)
|
|
29
|
+
*/
|
|
30
|
+
start(intervalMs = 30_000) {
|
|
31
|
+
if (this._intervalId !== null) return; // already running
|
|
32
|
+
debug(`FirestorePoller: starting (interval ${intervalMs}ms)`);
|
|
33
|
+
this._intervalId = setInterval(() => { this.poll(); }, intervalMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Stop polling. */
|
|
37
|
+
stop() {
|
|
38
|
+
if (this._intervalId !== null) {
|
|
39
|
+
clearInterval(this._intervalId);
|
|
40
|
+
this._intervalId = null;
|
|
41
|
+
debug('FirestorePoller: stopped');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run a single poll cycle.
|
|
47
|
+
* Exported so tests can call it directly without timers.
|
|
48
|
+
*/
|
|
49
|
+
async poll() {
|
|
50
|
+
if (!this._firebaseAuth.isAuthenticated()) {
|
|
51
|
+
debug('FirestorePoller: skipping poll — not authenticated');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const token = this._firebaseAuth.getToken();
|
|
56
|
+
if (!token) {
|
|
57
|
+
debug('FirestorePoller: skipping poll — no token');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const uid = FirestorePoller.extractUidFromToken(token);
|
|
62
|
+
if (!uid) {
|
|
63
|
+
warn('FirestorePoller: could not extract UID from token — skipping poll');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const url = `https://firestore.googleapis.com/v1/projects/${this._projectId}/databases/(default)/documents/users/${uid}:runQuery`;
|
|
68
|
+
|
|
69
|
+
const body = JSON.stringify({
|
|
70
|
+
structuredQuery: {
|
|
71
|
+
from: [{ collectionId: 'runs' }],
|
|
72
|
+
where: {
|
|
73
|
+
fieldFilter: {
|
|
74
|
+
field: { fieldPath: 'status' },
|
|
75
|
+
op: 'EQUAL',
|
|
76
|
+
value: { stringValue: 'queued' },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
orderBy: [{ field: { fieldPath: 'startedAt' }, direction: 'ASCENDING' }],
|
|
80
|
+
limit: 1,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
let response;
|
|
85
|
+
try {
|
|
86
|
+
response = await fetch(url, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
Authorization: `Bearer ${token}`,
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
},
|
|
92
|
+
body,
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
warn(`FirestorePoller: network error during poll — ${err.message}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
warn(`FirestorePoller: poll returned HTTP ${response.status} — skipping`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let results;
|
|
105
|
+
try {
|
|
106
|
+
results = await response.json();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
warn(`FirestorePoller: failed to parse Firestore response — ${err.message}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Firestore REST runQuery returns an array of { document } objects.
|
|
113
|
+
// An empty result set is returned as [{}] (object with no document field).
|
|
114
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const first = results[0];
|
|
119
|
+
if (!first || !first.document) {
|
|
120
|
+
// No queued runs found
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let run;
|
|
125
|
+
try {
|
|
126
|
+
run = FirestorePoller._parseRunDocument(first.document);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
warn(`FirestorePoller: failed to parse run document — ${err.message}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
debug(`FirestorePoller: found queued run ${run.runId} for project ${run.projectId}`);
|
|
133
|
+
this._onQueuedRunFound(run);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract UID from a Firebase ID token (JWT).
|
|
138
|
+
* Reads `user_id` or `sub` from the unverified payload — no crypto needed.
|
|
139
|
+
* Returns null on any parse failure.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} token
|
|
142
|
+
* @returns {string|null}
|
|
143
|
+
*/
|
|
144
|
+
static extractUidFromToken(token) {
|
|
145
|
+
try {
|
|
146
|
+
const parts = token.split('.');
|
|
147
|
+
if (parts.length !== 3) return null;
|
|
148
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
149
|
+
return payload.user_id || payload.sub || null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse a Firestore document object into a plain run object.
|
|
157
|
+
* Throws on missing required fields so callers can log and skip.
|
|
158
|
+
*
|
|
159
|
+
* @param {{ name: string, fields: object }} doc
|
|
160
|
+
* @returns {{ runId, projectId, projectName, selectedSteps, timeout }}
|
|
161
|
+
*/
|
|
162
|
+
static _parseRunDocument(doc) {
|
|
163
|
+
// Document name format:
|
|
164
|
+
// projects/{project}/databases/(default)/documents/users/{uid}/runs/{runId}
|
|
165
|
+
const nameParts = doc.name.split('/');
|
|
166
|
+
const runId = nameParts[nameParts.length - 1];
|
|
167
|
+
|
|
168
|
+
const fields = doc.fields || {};
|
|
169
|
+
|
|
170
|
+
const projectId = fields.projectId?.stringValue ?? null;
|
|
171
|
+
const projectName = fields.projectName?.stringValue ?? null;
|
|
172
|
+
|
|
173
|
+
// selectedSteps can be an arrayValue of integerValue/stringValue entries
|
|
174
|
+
let selectedSteps = [];
|
|
175
|
+
if (fields.selectedSteps?.arrayValue?.values) {
|
|
176
|
+
selectedSteps = fields.selectedSteps.arrayValue.values.map((v) => {
|
|
177
|
+
if (v.integerValue !== undefined) return Number(v.integerValue);
|
|
178
|
+
if (v.stringValue !== undefined) return Number(v.stringValue);
|
|
179
|
+
return null;
|
|
180
|
+
}).filter((n) => n !== null && !isNaN(n));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const timeoutRaw = fields.timeout?.integerValue ?? fields.timeout?.stringValue ?? null;
|
|
184
|
+
const timeout = timeoutRaw !== null ? Number(timeoutRaw) : 45;
|
|
185
|
+
|
|
186
|
+
if (!runId) throw new Error('missing runId in document name');
|
|
187
|
+
if (!projectId) throw new Error('missing projectId field');
|
|
188
|
+
|
|
189
|
+
return { runId, projectId, projectName, selectedSteps, timeout };
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/agent/index.js
CHANGED
|
@@ -12,9 +12,11 @@ import { WebhookDispatcher } from './webhook-dispatcher.js';
|
|
|
12
12
|
import { CliBridge } from './cli-bridge.js';
|
|
13
13
|
import { AgentGit } from './git-integration.js';
|
|
14
14
|
import { FirebaseAuth } from './firebase-auth.js';
|
|
15
|
+
import { FirestorePoller } from './firestore-poller.js';
|
|
15
16
|
import { acquireKeepAwake, releaseKeepAwake } from './keep-awake.js';
|
|
16
17
|
|
|
17
18
|
const FIREBASE_WEBHOOK_URL = 'https://webhookingest-24h6taciuq-uc.a.run.app';
|
|
19
|
+
const FIREBASE_PROJECT_ID = process.env.NIGHTYTIDY_FIREBASE_PROJECT_ID || 'nightytidy-web';
|
|
18
20
|
|
|
19
21
|
// Read version from package.json so it stays in sync with npm
|
|
20
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -26,6 +28,10 @@ export async function startAgent() {
|
|
|
26
28
|
ensureConfigDir(configDir);
|
|
27
29
|
const config = readConfig(configDir);
|
|
28
30
|
|
|
31
|
+
// Write PID file so stop/status subcommands can find this process
|
|
32
|
+
const pidPath = path.join(configDir, 'agent.pid');
|
|
33
|
+
fs.writeFileSync(pidPath, String(process.pid));
|
|
34
|
+
|
|
29
35
|
info(`NightyTidy Agent starting on ${config.machine}`);
|
|
30
36
|
|
|
31
37
|
// Initialize components
|
|
@@ -34,6 +40,14 @@ export async function startAgent() {
|
|
|
34
40
|
|
|
35
41
|
const runQueue = new RunQueue(configDir);
|
|
36
42
|
const firebaseAuth = new FirebaseAuth(configDir);
|
|
43
|
+
|
|
44
|
+
// Restore Firebase token from disk (survives reboots)
|
|
45
|
+
if (firebaseAuth.restoreToken()) {
|
|
46
|
+
debug('Restored Firebase token from disk');
|
|
47
|
+
} else {
|
|
48
|
+
debug('No stored Firebase token — waiting for web app to provide one');
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
const webhookDispatcher = new WebhookDispatcher({
|
|
38
52
|
machine: config.machine,
|
|
39
53
|
version: AGENT_VERSION,
|
|
@@ -464,6 +478,37 @@ export async function startAgent() {
|
|
|
464
478
|
}
|
|
465
479
|
}
|
|
466
480
|
|
|
481
|
+
// Start Firestore polling — secondary trigger for queued runs when no browser is connected.
|
|
482
|
+
// The poller is additive; WebSocket commands still work as the primary path.
|
|
483
|
+
const poller = new FirestorePoller({
|
|
484
|
+
firebaseAuth,
|
|
485
|
+
projectId: FIREBASE_PROJECT_ID,
|
|
486
|
+
onQueuedRunFound: (run) => {
|
|
487
|
+
// Check if this run's project is registered locally
|
|
488
|
+
const project = projectManager.getProject(run.projectId);
|
|
489
|
+
if (!project) {
|
|
490
|
+
debug(`Polled run ${run.runId} for unknown project ${run.projectId} — skipping`);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// Check if already in local queue or currently running
|
|
494
|
+
const queue = runQueue.getQueue();
|
|
495
|
+
const isAlreadyQueued = queue.some(q => q.id === run.runId) ||
|
|
496
|
+
(runQueue.getCurrent()?.id === run.runId);
|
|
497
|
+
if (isAlreadyQueued) return;
|
|
498
|
+
|
|
499
|
+
// Enqueue locally and process
|
|
500
|
+
debug(`Polled queued run ${run.runId} for ${project.name}`);
|
|
501
|
+
runQueue.enqueue({
|
|
502
|
+
projectId: run.projectId,
|
|
503
|
+
steps: run.selectedSteps,
|
|
504
|
+
timeout: run.timeout,
|
|
505
|
+
});
|
|
506
|
+
wsServer.broadcast({ type: 'queue-updated', queue: runQueue.getQueue() });
|
|
507
|
+
if (!runQueue.getCurrent()) processQueue();
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
poller.start(30_000);
|
|
511
|
+
|
|
467
512
|
// Run execution handler
|
|
468
513
|
async function handleStartRun(msg, reply) {
|
|
469
514
|
const project = projectManager.getProject(msg.projectId);
|
|
@@ -1066,8 +1111,11 @@ export async function startAgent() {
|
|
|
1066
1111
|
info('Agent shutting down...');
|
|
1067
1112
|
releaseKeepAwake();
|
|
1068
1113
|
saveInterruptedState();
|
|
1114
|
+
poller.stop();
|
|
1069
1115
|
scheduler.stopAll();
|
|
1070
1116
|
await wsServer.stop();
|
|
1117
|
+
// Remove PID file on clean exit
|
|
1118
|
+
try { fs.unlinkSync(pidPath); } catch { /* ignore — may already be gone */ }
|
|
1071
1119
|
info('Agent stopped');
|
|
1072
1120
|
process.exit(0);
|
|
1073
1121
|
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS service registration for the NightyTidy agent.
|
|
3
|
+
*
|
|
4
|
+
* Registers the agent to start on login/boot using the platform-native
|
|
5
|
+
* service mechanism (Windows Task Scheduler, macOS LaunchAgent, Linux systemd).
|
|
6
|
+
*
|
|
7
|
+
* Never throws — all functions return result objects.
|
|
8
|
+
*/
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { debug } from '../logger.js';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
// package root is two levels up from src/agent/
|
|
18
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
|
|
19
|
+
const BIN_PATH = path.join(PACKAGE_ROOT, 'bin', 'nightytidy.js');
|
|
20
|
+
|
|
21
|
+
const SERVICE_NAME = 'NightyTidy Agent';
|
|
22
|
+
const PLIST_ID = 'com.nightytidy.agent';
|
|
23
|
+
const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_ID}.plist`);
|
|
24
|
+
const SYSTEMD_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
25
|
+
const SYSTEMD_PATH = path.join(SYSTEMD_DIR, 'nightytidy.service');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the command string used to start the agent.
|
|
29
|
+
* Format: `"<node>" "<nightytidy bin>" agent`
|
|
30
|
+
*/
|
|
31
|
+
export function getAgentStartCommand() {
|
|
32
|
+
return `"${process.execPath}" "${BIN_PATH}" agent`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Registers the agent as an OS service (run on login).
|
|
37
|
+
* @returns {{ success: true } | { success: false, error: string, fallbackInstructions: string }}
|
|
38
|
+
*/
|
|
39
|
+
export function registerService() {
|
|
40
|
+
const cmd = getAgentStartCommand();
|
|
41
|
+
debug(`Registering service with command: ${cmd}`);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
_registerWindows(cmd);
|
|
46
|
+
} else if (process.platform === 'darwin') {
|
|
47
|
+
_registerMacOS(cmd);
|
|
48
|
+
} else {
|
|
49
|
+
_registerLinux(cmd);
|
|
50
|
+
}
|
|
51
|
+
debug('Service registered successfully');
|
|
52
|
+
return { success: true };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const error = err.message || String(err);
|
|
55
|
+
debug(`Service registration failed: ${error}`);
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
error,
|
|
59
|
+
fallbackInstructions: `To start manually on login, add this to your startup: ${cmd}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Unregisters the OS service.
|
|
66
|
+
* @returns {{ success: true } | { success: false, error: string }}
|
|
67
|
+
*/
|
|
68
|
+
export function unregisterService() {
|
|
69
|
+
debug('Unregistering service');
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
if (process.platform === 'win32') {
|
|
73
|
+
_unregisterWindows();
|
|
74
|
+
} else if (process.platform === 'darwin') {
|
|
75
|
+
_unregisterMacOS();
|
|
76
|
+
} else {
|
|
77
|
+
_unregisterLinux();
|
|
78
|
+
}
|
|
79
|
+
debug('Service unregistered successfully');
|
|
80
|
+
return { success: true };
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const error = err.message || String(err);
|
|
83
|
+
debug(`Service unregistration failed: ${error}`);
|
|
84
|
+
return { success: false, error };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Platform implementations ---
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Windows: Use the Startup folder (no admin needed).
|
|
92
|
+
* Writes a .vbs wrapper script that launches the agent hidden (no console window).
|
|
93
|
+
* Falls back to schtasks if Startup folder isn't writable.
|
|
94
|
+
*/
|
|
95
|
+
const STARTUP_DIR = process.platform === 'win32'
|
|
96
|
+
? path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
97
|
+
: '';
|
|
98
|
+
const STARTUP_VBS = path.join(STARTUP_DIR, 'NightyTidy Agent.vbs');
|
|
99
|
+
|
|
100
|
+
function _registerWindows(cmd) {
|
|
101
|
+
// Primary: Startup folder (no admin required)
|
|
102
|
+
try {
|
|
103
|
+
// VBS wrapper runs the agent without showing a console window
|
|
104
|
+
const vbs = `' NightyTidy Agent — auto-start on login\r\nSet ws = CreateObject("WScript.Shell")\r\nws.Run ${JSON.stringify(cmd)}, 0, False\r\n`;
|
|
105
|
+
fs.mkdirSync(STARTUP_DIR, { recursive: true });
|
|
106
|
+
fs.writeFileSync(STARTUP_VBS, vbs, 'utf-8');
|
|
107
|
+
debug('Registered via Windows Startup folder');
|
|
108
|
+
return;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
debug(`Startup folder failed: ${err.message}, trying schtasks`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fallback: Task Scheduler (needs admin on some systems)
|
|
114
|
+
execSync(
|
|
115
|
+
`schtasks /create /tn "${SERVICE_NAME}" /tr "${cmd}" /sc onlogon /rl LIMITED /f`,
|
|
116
|
+
{ stdio: 'pipe' },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _unregisterWindows() {
|
|
121
|
+
// Remove Startup folder entry
|
|
122
|
+
try {
|
|
123
|
+
if (fs.existsSync(STARTUP_VBS)) {
|
|
124
|
+
fs.unlinkSync(STARTUP_VBS);
|
|
125
|
+
debug('Removed Startup folder entry');
|
|
126
|
+
}
|
|
127
|
+
} catch { /* ignore */ }
|
|
128
|
+
|
|
129
|
+
// Also try removing schtasks entry (may not exist)
|
|
130
|
+
try {
|
|
131
|
+
execSync(`schtasks /delete /tn "${SERVICE_NAME}" /f`, { stdio: 'pipe' });
|
|
132
|
+
debug('Removed Task Scheduler entry');
|
|
133
|
+
} catch { /* ignore — may not have been registered via schtasks */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _registerMacOS(cmd) {
|
|
137
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
138
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
139
|
+
<plist version="1.0">
|
|
140
|
+
<dict>
|
|
141
|
+
<key>Label</key>
|
|
142
|
+
<string>${PLIST_ID}</string>
|
|
143
|
+
<key>ProgramArguments</key>
|
|
144
|
+
<array>
|
|
145
|
+
<string>${process.execPath}</string>
|
|
146
|
+
<string>${BIN_PATH}</string>
|
|
147
|
+
<string>agent</string>
|
|
148
|
+
</array>
|
|
149
|
+
<key>RunAtLoad</key>
|
|
150
|
+
<true/>
|
|
151
|
+
<key>KeepAlive</key>
|
|
152
|
+
<true/>
|
|
153
|
+
<key>StandardOutPath</key>
|
|
154
|
+
<string>${path.join(os.homedir(), '.nightytidy', 'agent-stdout.log')}</string>
|
|
155
|
+
<key>StandardErrorPath</key>
|
|
156
|
+
<string>${path.join(os.homedir(), '.nightytidy', 'agent-stderr.log')}</string>
|
|
157
|
+
</dict>
|
|
158
|
+
</plist>
|
|
159
|
+
`;
|
|
160
|
+
const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
161
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
162
|
+
fs.writeFileSync(PLIST_PATH, plist, 'utf-8');
|
|
163
|
+
execSync(`launchctl load "${PLIST_PATH}"`, { stdio: 'pipe' });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _unregisterMacOS() {
|
|
167
|
+
if (fs.existsSync(PLIST_PATH)) {
|
|
168
|
+
execSync(`launchctl unload "${PLIST_PATH}"`, { stdio: 'pipe' });
|
|
169
|
+
fs.unlinkSync(PLIST_PATH);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _registerLinux(cmd) {
|
|
174
|
+
const unit = `[Unit]
|
|
175
|
+
Description=NightyTidy Agent
|
|
176
|
+
After=network.target
|
|
177
|
+
|
|
178
|
+
[Service]
|
|
179
|
+
ExecStart=${process.execPath} "${BIN_PATH}" agent
|
|
180
|
+
Restart=always
|
|
181
|
+
RestartSec=10
|
|
182
|
+
|
|
183
|
+
[Install]
|
|
184
|
+
WantedBy=default.target
|
|
185
|
+
`;
|
|
186
|
+
fs.mkdirSync(SYSTEMD_DIR, { recursive: true });
|
|
187
|
+
fs.writeFileSync(SYSTEMD_PATH, unit, 'utf-8');
|
|
188
|
+
execSync('systemctl --user enable --now nightytidy.service', { stdio: 'pipe' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _unregisterLinux() {
|
|
192
|
+
execSync('systemctl --user disable --now nightytidy.service', { stdio: 'pipe' });
|
|
193
|
+
if (fs.existsSync(SYSTEMD_PATH)) {
|
|
194
|
+
fs.unlinkSync(SYSTEMD_PATH);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview One-liner setup command for the NightyTidy agent.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates: browser OAuth → token save → service registration → agent start.
|
|
5
|
+
*
|
|
6
|
+
* Error contract: Never throws. Prints errors and calls process.exit(1) on failure.
|
|
7
|
+
*
|
|
8
|
+
* @module agent/setup-flow
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { getConfigDir, ensureConfigDir } from './config.js';
|
|
17
|
+
import { registerService } from './service.js';
|
|
18
|
+
import { FirebaseAuth } from './firebase-auth.js';
|
|
19
|
+
import { debug } from '../logger.js';
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
// package root is two levels up from src/agent/
|
|
23
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
|
|
24
|
+
const BIN_PATH = path.join(PACKAGE_ROOT, 'bin', 'nightytidy.js');
|
|
25
|
+
|
|
26
|
+
const AUTH_BASE_URL = 'https://nightytidy.com/auth/agent';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Open a URL in the default system browser.
|
|
30
|
+
* Fire-and-forget — detached, stdio ignored.
|
|
31
|
+
* @param {string} url
|
|
32
|
+
*/
|
|
33
|
+
function openBrowser(url) {
|
|
34
|
+
const platform = process.platform;
|
|
35
|
+
if (platform === 'darwin') {
|
|
36
|
+
spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
37
|
+
} else if (platform === 'win32') {
|
|
38
|
+
spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
|
|
39
|
+
} else {
|
|
40
|
+
spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start a temporary HTTP server on 127.0.0.1 and wait for a POST /callback.
|
|
46
|
+
* Resolves with the parsed JSON body, or rejects after a timeout.
|
|
47
|
+
* @returns {{ server: import('node:http').Server, port: number, waitForCallback: () => Promise<object> }}
|
|
48
|
+
*/
|
|
49
|
+
function createCallbackServer() {
|
|
50
|
+
let resolvePayload;
|
|
51
|
+
let rejectPayload;
|
|
52
|
+
|
|
53
|
+
const waitForCallback = () =>
|
|
54
|
+
new Promise((resolve, reject) => {
|
|
55
|
+
resolvePayload = resolve;
|
|
56
|
+
rejectPayload = reject;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const server = createServer((req, res) => {
|
|
60
|
+
// CORS preflight — the web app may send OPTIONS first
|
|
61
|
+
if (req.method === 'OPTIONS') {
|
|
62
|
+
res.writeHead(204, {
|
|
63
|
+
'Access-Control-Allow-Origin': 'https://nightytidy.com',
|
|
64
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
65
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
66
|
+
});
|
|
67
|
+
res.end();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (req.method !== 'POST' || req.url !== '/callback') {
|
|
72
|
+
res.writeHead(404);
|
|
73
|
+
res.end('Not found');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let body = '';
|
|
78
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
79
|
+
req.on('end', () => {
|
|
80
|
+
try {
|
|
81
|
+
const payload = JSON.parse(body);
|
|
82
|
+
res.writeHead(200, {
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
'Access-Control-Allow-Origin': 'https://nightytidy.com',
|
|
85
|
+
});
|
|
86
|
+
res.end(JSON.stringify({ ok: true }));
|
|
87
|
+
debug('OAuth callback received');
|
|
88
|
+
resolvePayload(payload);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
res.writeHead(400);
|
|
91
|
+
res.end('Bad request');
|
|
92
|
+
rejectPayload(new Error(`Invalid callback body: ${err.message}`));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Bind to random port on localhost
|
|
98
|
+
server.listen(0, '127.0.0.1');
|
|
99
|
+
|
|
100
|
+
return { server, waitForCallback };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Main setup flow. Runs all four steps sequentially.
|
|
105
|
+
* Never throws — prints errors and calls process.exit(1).
|
|
106
|
+
*/
|
|
107
|
+
export async function setupAgent() {
|
|
108
|
+
console.log(chalk.bold.cyan('\nNightyTidy Agent Setup'));
|
|
109
|
+
console.log(chalk.dim('────────────────────────────────────────'));
|
|
110
|
+
|
|
111
|
+
// ── Step 1: Authenticate ──────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
console.log(chalk.bold('\nStep 1: Authenticate'));
|
|
114
|
+
|
|
115
|
+
const { server, waitForCallback } = createCallbackServer();
|
|
116
|
+
|
|
117
|
+
// Wait for the server to be assigned a port
|
|
118
|
+
await new Promise((resolve) => server.once('listening', resolve));
|
|
119
|
+
const port = server.address().port;
|
|
120
|
+
debug(`Callback server listening on 127.0.0.1:${port}`);
|
|
121
|
+
|
|
122
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback`;
|
|
123
|
+
const authUrl = `${AUTH_BASE_URL}?callback=${encodeURIComponent(callbackUrl)}`;
|
|
124
|
+
|
|
125
|
+
console.log(chalk.dim(`Opening: ${authUrl}`));
|
|
126
|
+
openBrowser(authUrl);
|
|
127
|
+
console.log(chalk.yellow('Waiting for authorization in browser...'));
|
|
128
|
+
|
|
129
|
+
let payload;
|
|
130
|
+
try {
|
|
131
|
+
payload = await waitForCallback();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
server.close();
|
|
134
|
+
console.error(chalk.red(`\nAuthorization failed: ${err.message}`));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
} finally {
|
|
137
|
+
server.close();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (payload.error) {
|
|
141
|
+
console.error(chalk.red(`\nAuthorization error: ${payload.error}`));
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Step 2: Save credentials ──────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
console.log(chalk.bold('\nStep 2: Save credentials'));
|
|
148
|
+
|
|
149
|
+
const configDir = getConfigDir();
|
|
150
|
+
ensureConfigDir(configDir);
|
|
151
|
+
|
|
152
|
+
const firebaseAuth = new FirebaseAuth(configDir);
|
|
153
|
+
const token = payload.token || payload.idToken;
|
|
154
|
+
|
|
155
|
+
if (!token) {
|
|
156
|
+
console.error(chalk.red('\nNo token received in callback. Cannot authenticate.'));
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
firebaseAuth.setToken(token);
|
|
161
|
+
console.log(chalk.green(' Authenticated'));
|
|
162
|
+
|
|
163
|
+
// ── Step 3: Register service ──────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
console.log(chalk.bold('\nStep 3: Register auto-start service'));
|
|
166
|
+
|
|
167
|
+
const serviceResult = registerService();
|
|
168
|
+
if (serviceResult.success) {
|
|
169
|
+
console.log(chalk.green(' Service registered — agent will start automatically on login'));
|
|
170
|
+
} else {
|
|
171
|
+
console.log(chalk.yellow(` Warning: Could not register service: ${serviceResult.error}`));
|
|
172
|
+
console.log(chalk.dim(` ${serviceResult.fallbackInstructions}`));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Step 4: Start agent ───────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
console.log(chalk.bold('\nStep 4: Start agent'));
|
|
178
|
+
|
|
179
|
+
spawn(process.execPath, [BIN_PATH, 'agent'], { detached: true, stdio: 'ignore' }).unref();
|
|
180
|
+
console.log(chalk.green(' Agent started'));
|
|
181
|
+
|
|
182
|
+
// ── Done ──────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
console.log(chalk.dim('\n────────────────────────────────────────'));
|
|
185
|
+
console.log(
|
|
186
|
+
chalk.bold.green('Done!') +
|
|
187
|
+
' Your agent is running. Visit nightytidy.com to add projects and configure schedules.',
|
|
188
|
+
);
|
|
189
|
+
console.log('');
|
|
190
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -814,11 +814,95 @@ export async function run() {
|
|
|
814
814
|
.option('--skip-dashboard', 'Skip launching the standalone dashboard server (used by GUI)')
|
|
815
815
|
.option('--resume', 'Resume a previously paused run (usage limit / manual restart)');
|
|
816
816
|
|
|
817
|
+
// Handle 'setup-agent' before 'agent' — process.argv.includes('agent') would
|
|
818
|
+
// also match 'setup-agent', so this check must come first.
|
|
819
|
+
if (process.argv.includes('setup-agent')) {
|
|
820
|
+
const { initLogger } = await import('./logger.js');
|
|
821
|
+
initLogger(process.cwd());
|
|
822
|
+
const { setupAgent } = await import('./agent/setup-flow.js');
|
|
823
|
+
await setupAgent();
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
817
827
|
// Handle 'agent' subcommand before Commander parses — avoids breaking
|
|
818
828
|
// Commander's option-only routing when a .command() subcommand is registered.
|
|
819
829
|
if (process.argv.includes('agent')) {
|
|
820
830
|
const { initLogger } = await import('./logger.js');
|
|
821
831
|
initLogger(process.cwd());
|
|
832
|
+
|
|
833
|
+
// Determine the subcommand that follows 'agent' in argv
|
|
834
|
+
const agentIdx = process.argv.indexOf('agent');
|
|
835
|
+
const agentSub = process.argv[agentIdx + 1];
|
|
836
|
+
|
|
837
|
+
if (agentSub === 'stop') {
|
|
838
|
+
// Send SIGTERM to the running agent via its PID file
|
|
839
|
+
const { getConfigDir } = await import('./agent/config.js');
|
|
840
|
+
const fsModule = await import('node:fs');
|
|
841
|
+
const pathModule = await import('node:path');
|
|
842
|
+
const pidPath = pathModule.default.join(getConfigDir(), 'agent.pid');
|
|
843
|
+
try {
|
|
844
|
+
const pid = parseInt(fsModule.default.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
845
|
+
process.kill(pid, 'SIGTERM');
|
|
846
|
+
console.log(`Agent (PID ${pid}) signalled to stop.`);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
console.error(`Could not stop agent: ${err.message}`);
|
|
849
|
+
console.error(`Is the agent running? Check with: nightytidy agent status`);
|
|
850
|
+
process.exitCode = 1;
|
|
851
|
+
}
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (agentSub === 'status') {
|
|
856
|
+
const { getConfigDir } = await import('./agent/config.js');
|
|
857
|
+
const fsModule = await import('node:fs');
|
|
858
|
+
const pathModule = await import('node:path');
|
|
859
|
+
const pidPath = pathModule.default.join(getConfigDir(), 'agent.pid');
|
|
860
|
+
try {
|
|
861
|
+
const pid = parseInt(fsModule.default.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
862
|
+
process.kill(pid, 0); // signal 0 = check existence only
|
|
863
|
+
console.log(`Agent is running (PID ${pid}).`);
|
|
864
|
+
} catch {
|
|
865
|
+
console.log('Agent is not running.');
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (agentSub === 'install-service') {
|
|
871
|
+
const { registerService } = await import('./agent/service.js');
|
|
872
|
+
const result = registerService();
|
|
873
|
+
if (result.success) {
|
|
874
|
+
console.log('NightyTidy Agent service installed. It will start automatically on login.');
|
|
875
|
+
} else {
|
|
876
|
+
console.error(`Failed to install service: ${result.error}`);
|
|
877
|
+
console.error(result.fallbackInstructions);
|
|
878
|
+
process.exitCode = 1;
|
|
879
|
+
}
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (agentSub === 'uninstall-service') {
|
|
884
|
+
const { unregisterService } = await import('./agent/service.js');
|
|
885
|
+
// Stop the agent if it is running before removing the service
|
|
886
|
+
const { getConfigDir } = await import('./agent/config.js');
|
|
887
|
+
const fsModule = await import('node:fs');
|
|
888
|
+
const pathModule = await import('node:path');
|
|
889
|
+
const pidPath = pathModule.default.join(getConfigDir(), 'agent.pid');
|
|
890
|
+
try {
|
|
891
|
+
const pid = parseInt(fsModule.default.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
892
|
+
process.kill(pid, 'SIGTERM');
|
|
893
|
+
} catch { /* agent not running — that's fine */ }
|
|
894
|
+
|
|
895
|
+
const result = unregisterService();
|
|
896
|
+
if (result.success) {
|
|
897
|
+
console.log('NightyTidy Agent service uninstalled.');
|
|
898
|
+
} else {
|
|
899
|
+
console.error(`Failed to uninstall service: ${result.error}`);
|
|
900
|
+
process.exitCode = 1;
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Default: no subcommand — start the agent
|
|
822
906
|
const { startAgent } = await import('./agent/index.js');
|
|
823
907
|
await startAgent();
|
|
824
908
|
return;
|