ultraclaude-agent 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/config-windows.test.ts +93 -0
- package/__tests__/daemon.test.ts +166 -0
- package/__tests__/service-windows.test.ts +123 -0
- package/__tests__/sync-bugs.test.ts +246 -0
- package/__tests__/sync.test.ts +169 -0
- package/__tests__/usage-sync.test.ts +291 -0
- package/__tests__/version-check.test.ts +128 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +105 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +196 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +181 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon.d.ts +27 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +214 -0
- package/dist/daemon.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/service.d.ts +4 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +223 -0
- package/dist/service.js.map +1 -0
- package/dist/sync.d.ts +25 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +344 -0
- package/dist/sync.js.map +1 -0
- package/dist/usage-sync.d.ts +7 -0
- package/dist/usage-sync.d.ts.map +1 -0
- package/dist/usage-sync.js +208 -0
- package/dist/usage-sync.js.map +1 -0
- package/dist/watcher.d.ts +16 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +90 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +31 -0
- package/run.sh +28 -0
- package/src/auth.ts +127 -0
- package/src/cli.ts +235 -0
- package/src/config.ts +207 -0
- package/src/daemon.ts +264 -0
- package/src/index.ts +7 -0
- package/src/logger.ts +42 -0
- package/src/service.ts +237 -0
- package/src/sync.ts +473 -0
- package/src/usage-sync.ts +275 -0
- package/src/watcher.ts +106 -0
- package/tsconfig.build.json +6 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Configuration, paths, and credential management for the sync agent daemon
|
|
2
|
+
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { join, basename } from 'node:path';
|
|
5
|
+
import { readFile, writeFile, mkdir, unlink, access, readdir } from 'node:fs/promises';
|
|
6
|
+
import type { AgentCredentials, ProjectRegistry } from '@ultra-claude/shared';
|
|
7
|
+
|
|
8
|
+
// --- Paths ---
|
|
9
|
+
|
|
10
|
+
function resolveConfigDir(): string {
|
|
11
|
+
if (platform() === 'win32') {
|
|
12
|
+
// Windows: use %APPDATA%\ultraclaude-agent\
|
|
13
|
+
const appData = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
|
|
14
|
+
return join(appData, 'ultraclaude-agent');
|
|
15
|
+
}
|
|
16
|
+
return join(homedir(), '.claude', 'ultra', 'dashboard');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CONFIG_DIR = resolveConfigDir();
|
|
20
|
+
const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
|
|
21
|
+
const PID_FILE = join(CONFIG_DIR, 'daemon.pid');
|
|
22
|
+
const LOG_DIR = join(CONFIG_DIR, 'logs');
|
|
23
|
+
// Registry is auto-discovered from ~/.claude/projects/ — no file needed
|
|
24
|
+
const PROJECT_ID_FILE = '.claude/ultra/project-id';
|
|
25
|
+
|
|
26
|
+
export const paths = {
|
|
27
|
+
configDir: CONFIG_DIR,
|
|
28
|
+
credentials: CREDENTIALS_FILE,
|
|
29
|
+
pid: PID_FILE,
|
|
30
|
+
logDir: LOG_DIR,
|
|
31
|
+
claudeProjects: join(homedir(), '.claude', 'projects'),
|
|
32
|
+
projectIdFile: PROJECT_ID_FILE,
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
// --- Server URL ---
|
|
36
|
+
|
|
37
|
+
export function getServerUrl(credentials?: AgentCredentials | null): string {
|
|
38
|
+
return (
|
|
39
|
+
process.env.ULTRACLAUDE_DASHBOARD_URL ??
|
|
40
|
+
credentials?.serverUrl ??
|
|
41
|
+
'https://dashboard.ultra-claude.dev'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- Credentials ---
|
|
46
|
+
|
|
47
|
+
export async function loadCredentials(): Promise<AgentCredentials | null> {
|
|
48
|
+
try {
|
|
49
|
+
const data = await readFile(CREDENTIALS_FILE, 'utf8');
|
|
50
|
+
return JSON.parse(data) as AgentCredentials;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function saveCredentials(creds: AgentCredentials): Promise<void> {
|
|
57
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
58
|
+
await writeFile(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- PID file ---
|
|
62
|
+
|
|
63
|
+
export async function writePid(): Promise<void> {
|
|
64
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
65
|
+
await writeFile(PID_FILE, String(process.pid));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function readPid(): Promise<number | null> {
|
|
69
|
+
try {
|
|
70
|
+
const pid = parseInt(await readFile(PID_FILE, 'utf8'), 10);
|
|
71
|
+
return Number.isNaN(pid) ? null : pid;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function removePid(): Promise<void> {
|
|
78
|
+
try {
|
|
79
|
+
await unlink(PID_FILE);
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore if already gone
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isDaemonRunning(pid: number): boolean {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Registry (auto-discovery) ---
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decode a Claude Code project directory name back to a real filesystem path.
|
|
98
|
+
* Claude encodes paths by replacing / with - (e.g., /home/user/my-project -> -home-user-my-project).
|
|
99
|
+
* Since hyphens also appear in real directory names, we try all possible splits
|
|
100
|
+
* and return the first path that actually exists on disk.
|
|
101
|
+
*/
|
|
102
|
+
async function decodeProjectPath(encoded: string): Promise<string | null> {
|
|
103
|
+
// Strip leading dash: "-home-user-project" -> "home-user-project"
|
|
104
|
+
const segments = encoded.slice(1).split('-');
|
|
105
|
+
|
|
106
|
+
// Recursively try combining segments with / or - at each position
|
|
107
|
+
async function tryResolve(idx: number, current: string): Promise<string | null> {
|
|
108
|
+
if (idx === segments.length) {
|
|
109
|
+
// Check if this path has a .claude/ultra/version.json
|
|
110
|
+
try {
|
|
111
|
+
await access(join('/', current, '.claude', 'ultra', 'version.json'));
|
|
112
|
+
return '/' + current;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const seg = segments[idx]!;
|
|
119
|
+
|
|
120
|
+
// Try as new path segment (/)
|
|
121
|
+
const withSlash = current ? `${current}/${seg}` : seg;
|
|
122
|
+
const slashResult = await tryResolve(idx + 1, withSlash);
|
|
123
|
+
if (slashResult) return slashResult;
|
|
124
|
+
|
|
125
|
+
// Try as continuation of current segment (-)
|
|
126
|
+
if (current) {
|
|
127
|
+
const lastSlash = current.lastIndexOf('/');
|
|
128
|
+
const prefix = lastSlash >= 0 ? current.slice(0, lastSlash + 1) : '';
|
|
129
|
+
const lastSeg = lastSlash >= 0 ? current.slice(lastSlash + 1) : current;
|
|
130
|
+
const withDash = `${prefix}${lastSeg}-${seg}`;
|
|
131
|
+
return tryResolve(idx + 1, withDash);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return tryResolve(0, '');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Auto-discover Ultra Claude projects by scanning ~/.claude/projects/.
|
|
142
|
+
* A project is an Ultra Claude project if it has .claude/ultra/version.json.
|
|
143
|
+
*/
|
|
144
|
+
export async function loadRegistry(): Promise<ProjectRegistry> {
|
|
145
|
+
const claudeProjectsDir = join(homedir(), '.claude', 'projects');
|
|
146
|
+
const projects: ProjectRegistry['projects'] = [];
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const entries = await readdir(claudeProjectsDir, { withFileTypes: true });
|
|
150
|
+
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (!entry.isDirectory()) continue;
|
|
153
|
+
|
|
154
|
+
const realPath = await decodeProjectPath(entry.name);
|
|
155
|
+
if (realPath) {
|
|
156
|
+
// Read project name from app-context.md or fall back to directory name
|
|
157
|
+
let name = basename(realPath);
|
|
158
|
+
try {
|
|
159
|
+
const ctx = await readFile(join(realPath, '.claude', 'ultra', 'app-context.md'), 'utf8');
|
|
160
|
+
const nameMatch = ctx.match(/\*\*Name:\*\*\s*(.+)/);
|
|
161
|
+
if (nameMatch) name = nameMatch[1]!.trim();
|
|
162
|
+
} catch {
|
|
163
|
+
// Use directory name
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
projects.push({ path: realPath, name });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// ~/.claude/projects/ doesn't exist
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { projects };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Project ID ---
|
|
177
|
+
|
|
178
|
+
export async function getProjectId(projectPath: string): Promise<string | null> {
|
|
179
|
+
try {
|
|
180
|
+
return (await readFile(join(projectPath, PROJECT_ID_FILE), 'utf8')).trim();
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function writeProjectId(projectPath: string, id: string): Promise<void> {
|
|
187
|
+
const dir = join(projectPath, '.claude', 'ultra');
|
|
188
|
+
await mkdir(dir, { recursive: true });
|
|
189
|
+
await writeFile(join(projectPath, PROJECT_ID_FILE), id, 'utf8');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- Log dir ---
|
|
193
|
+
|
|
194
|
+
export async function ensureLogDir(): Promise<void> {
|
|
195
|
+
await mkdir(LOG_DIR, { recursive: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- File existence check ---
|
|
199
|
+
|
|
200
|
+
export async function fileExists(path: string): Promise<boolean> {
|
|
201
|
+
try {
|
|
202
|
+
await access(path);
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Global daemon — manages project registry, per-project watchers, lifecycle
|
|
2
|
+
|
|
3
|
+
import chokidar from 'chokidar';
|
|
4
|
+
import { basename } from 'node:path';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
paths,
|
|
10
|
+
loadCredentials,
|
|
11
|
+
loadRegistry,
|
|
12
|
+
getProjectId,
|
|
13
|
+
writeProjectId,
|
|
14
|
+
writePid,
|
|
15
|
+
removePid,
|
|
16
|
+
} from './config.js';
|
|
17
|
+
import { startProjectWatcher, type ProjectWatcher } from './watcher.js';
|
|
18
|
+
import { createProjectOnServer, initialSync, stopSync } from './sync.js';
|
|
19
|
+
import { startUsageWatcher, type UsageWatcher } from './usage-sync.js';
|
|
20
|
+
import { logger } from './logger.js';
|
|
21
|
+
import { ok, err, type Result } from '@ultra-claude/shared';
|
|
22
|
+
import type { ProjectRegistryEntry } from '@ultra-claude/shared';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compare two semver-style version strings (e.g. "1.2.3").
|
|
26
|
+
* Returns negative if a < b, 0 if equal, positive if a > b.
|
|
27
|
+
*/
|
|
28
|
+
function compareVersions(a: string, b: string): number {
|
|
29
|
+
const partsA = a.split('.').map(Number);
|
|
30
|
+
const partsB = b.split('.').map(Number);
|
|
31
|
+
const len = Math.max(partsA.length, partsB.length);
|
|
32
|
+
for (let i = 0; i < len; i++) {
|
|
33
|
+
const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
|
|
34
|
+
if (diff !== 0) return diff;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check npm registry for a newer version. Non-blocking — network errors are silently ignored.
|
|
41
|
+
*/
|
|
42
|
+
export async function checkForUpdate(): Promise<void> {
|
|
43
|
+
const log = logger.child({ op: 'versionCheck' });
|
|
44
|
+
try {
|
|
45
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
46
|
+
const pkgJson = JSON.parse(await readFile(pkgPath, 'utf8')) as { version: string };
|
|
47
|
+
const currentVersion = pkgJson.version;
|
|
48
|
+
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
51
|
+
|
|
52
|
+
const res = await fetch('https://registry.npmjs.org/ultraclaude-agent/latest', {
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
|
|
57
|
+
if (!res.ok) return;
|
|
58
|
+
|
|
59
|
+
const data = (await res.json()) as { version: string };
|
|
60
|
+
const latestVersion = data.version;
|
|
61
|
+
|
|
62
|
+
if (compareVersions(currentVersion, latestVersion) < 0) {
|
|
63
|
+
log.info(
|
|
64
|
+
{ current: currentVersion, latest: latestVersion },
|
|
65
|
+
`Update available: ${currentVersion} → ${latestVersion}. Run: npm update -g ultraclaude-agent`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Network error, timeout, or parse error — silently ignore
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const activeWatchers = new Map<string, ProjectWatcher>();
|
|
74
|
+
let registryWatcher: ReturnType<typeof chokidar.watch> | null = null;
|
|
75
|
+
let usageWatcher: UsageWatcher | null = null;
|
|
76
|
+
let running = false;
|
|
77
|
+
|
|
78
|
+
type StartDaemonError = 'NOT_LOGGED_IN' | 'ALREADY_RUNNING';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Start the global daemon. Watches the project registry and manages per-project watchers.
|
|
82
|
+
*/
|
|
83
|
+
export async function startDaemon(): Promise<Result<void, StartDaemonError>> {
|
|
84
|
+
const log = logger.child({ op: 'daemon' });
|
|
85
|
+
|
|
86
|
+
if (running) {
|
|
87
|
+
log.warn('Daemon already running in this process');
|
|
88
|
+
return err('ALREADY_RUNNING', 'Daemon already running in this process');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Verify credentials
|
|
92
|
+
const creds = await loadCredentials();
|
|
93
|
+
if (!creds) {
|
|
94
|
+
return err('NOT_LOGGED_IN', 'Not logged in — run `ultraclaude-dashboard-agent login` first');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
running = true;
|
|
98
|
+
await writePid();
|
|
99
|
+
|
|
100
|
+
// Register process-level handlers per error-handling.md §Daemon Process Lifecycle
|
|
101
|
+
const gracefulShutdown = async (exitCode: number) => {
|
|
102
|
+
await stopDaemon();
|
|
103
|
+
process.exit(exitCode);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
process.on('SIGTERM', () => {
|
|
107
|
+
log.info({ signal: 'SIGTERM' }, 'Shutting down');
|
|
108
|
+
gracefulShutdown(0);
|
|
109
|
+
});
|
|
110
|
+
process.on('SIGINT', () => {
|
|
111
|
+
log.info({ signal: 'SIGINT' }, 'Shutting down');
|
|
112
|
+
gracefulShutdown(0);
|
|
113
|
+
});
|
|
114
|
+
process.on('uncaughtException', (error: Error) => {
|
|
115
|
+
logger.fatal({ err: error }, 'Uncaught exception — shutting down');
|
|
116
|
+
gracefulShutdown(1);
|
|
117
|
+
});
|
|
118
|
+
process.on('unhandledRejection', (reason: unknown) => {
|
|
119
|
+
logger.error({ err: reason }, 'Unhandled rejection in background task');
|
|
120
|
+
// Do NOT exit — log and continue. The offending promise should be fixed.
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
log.info('Daemon starting');
|
|
124
|
+
|
|
125
|
+
// Fire-and-forget version check — non-blocking, errors silently ignored
|
|
126
|
+
checkForUpdate().catch(() => {});
|
|
127
|
+
|
|
128
|
+
// Start global usage watcher (watches ~/.claude/ultra/ for usage-status.json and accounts/)
|
|
129
|
+
usageWatcher = startUsageWatcher();
|
|
130
|
+
|
|
131
|
+
// Load initial registry and start watchers
|
|
132
|
+
const registry = await loadRegistry();
|
|
133
|
+
log.info({ projectCount: registry.projects.length }, 'Registry loaded');
|
|
134
|
+
|
|
135
|
+
for (const entry of registry.projects) {
|
|
136
|
+
await ensureProject(entry);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Watch ~/.claude/projects/ for new projects being opened
|
|
140
|
+
registryWatcher = chokidar.watch(paths.claudeProjects, {
|
|
141
|
+
persistent: true,
|
|
142
|
+
ignoreInitial: true,
|
|
143
|
+
depth: 0,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
registryWatcher.on('addDir', async () => {
|
|
147
|
+
log.info('New Claude project directory detected — reconciling');
|
|
148
|
+
await reconcileWatchers();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
log.info('Daemon running');
|
|
152
|
+
return ok(undefined);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stop the daemon and clean up all watchers.
|
|
157
|
+
*/
|
|
158
|
+
export async function stopDaemon(): Promise<void> {
|
|
159
|
+
const log = logger.child({ op: 'daemon' });
|
|
160
|
+
running = false;
|
|
161
|
+
|
|
162
|
+
// Close usage watcher
|
|
163
|
+
if (usageWatcher) {
|
|
164
|
+
await usageWatcher.close();
|
|
165
|
+
usageWatcher = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Close registry watcher
|
|
169
|
+
if (registryWatcher) {
|
|
170
|
+
await registryWatcher.close();
|
|
171
|
+
registryWatcher = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Close all project watchers
|
|
175
|
+
const closePromises = Array.from(activeWatchers.values()).map((w) => w.close());
|
|
176
|
+
await Promise.all(closePromises);
|
|
177
|
+
activeWatchers.clear();
|
|
178
|
+
|
|
179
|
+
stopSync();
|
|
180
|
+
await removePid();
|
|
181
|
+
|
|
182
|
+
log.info('Daemon stopped');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Ensure a project has a server-side ID and start watching it.
|
|
187
|
+
*/
|
|
188
|
+
async function ensureProject(entry: ProjectRegistryEntry): Promise<void> {
|
|
189
|
+
const log = logger.child({ project: entry.name, path: entry.path });
|
|
190
|
+
|
|
191
|
+
// Skip if already watching
|
|
192
|
+
if (activeWatchers.has(entry.path)) return;
|
|
193
|
+
|
|
194
|
+
// Check/create project ID
|
|
195
|
+
let projectId = await getProjectId(entry.path);
|
|
196
|
+
|
|
197
|
+
if (!projectId) {
|
|
198
|
+
log.info('No project-id found — auto-creating on server');
|
|
199
|
+
const slug = entry.name
|
|
200
|
+
.toLowerCase()
|
|
201
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
202
|
+
.replace(/^-+|-+$/g, '');
|
|
203
|
+
const result = await createProjectOnServer(entry.name, slug);
|
|
204
|
+
|
|
205
|
+
if (!result) {
|
|
206
|
+
log.error('Failed to auto-create project — will retry on next registry change');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
projectId = result.id;
|
|
211
|
+
await writeProjectId(entry.path, projectId);
|
|
212
|
+
log.info({ projectId }, 'Project created and ID written');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Start watcher
|
|
216
|
+
const watcher = startProjectWatcher({ projectId, projectPath: entry.path });
|
|
217
|
+
activeWatchers.set(entry.path, watcher);
|
|
218
|
+
|
|
219
|
+
// Initial sync in background (wrapped in catch per error-handling standard)
|
|
220
|
+
initialSync(projectId, entry.path).catch((syncErr: unknown) => {
|
|
221
|
+
log.error({ err: syncErr }, 'Initial sync failed');
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Reconcile active watchers with the current registry.
|
|
227
|
+
* Add watchers for new projects, remove watchers for removed projects.
|
|
228
|
+
*/
|
|
229
|
+
async function reconcileWatchers(): Promise<void> {
|
|
230
|
+
const log = logger.child({ op: 'reconcile' });
|
|
231
|
+
const registry = await loadRegistry();
|
|
232
|
+
const registryPaths = new Set(registry.projects.map((p) => p.path));
|
|
233
|
+
|
|
234
|
+
// Stop watchers for removed projects
|
|
235
|
+
for (const [path, watcher] of activeWatchers) {
|
|
236
|
+
if (!registryPaths.has(path)) {
|
|
237
|
+
log.info({ project: basename(path) }, 'Project removed from registry — stopping watcher');
|
|
238
|
+
await watcher.close();
|
|
239
|
+
activeWatchers.delete(path);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Start watchers for new projects
|
|
244
|
+
for (const entry of registry.projects) {
|
|
245
|
+
if (!activeWatchers.has(entry.path)) {
|
|
246
|
+
log.info({ project: entry.name }, 'New project detected — starting watcher');
|
|
247
|
+
await ensureProject(entry);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get status of all active project watchers.
|
|
254
|
+
*/
|
|
255
|
+
export function getDaemonStatus(): { running: boolean; usageWatcher: boolean; projects: { path: string; projectId: string }[] } {
|
|
256
|
+
return {
|
|
257
|
+
running,
|
|
258
|
+
usageWatcher: usageWatcher !== null,
|
|
259
|
+
projects: Array.from(activeWatchers.values()).map((w) => ({
|
|
260
|
+
path: w.projectPath,
|
|
261
|
+
projectId: w.projectId,
|
|
262
|
+
})),
|
|
263
|
+
};
|
|
264
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @ultra-claude/agent — CLI/daemon entry point
|
|
2
|
+
// Run via: ultraclaude-dashboard-agent <command>
|
|
3
|
+
// See cli.ts for all commands
|
|
4
|
+
|
|
5
|
+
export { startDaemon, stopDaemon, getDaemonStatus } from './daemon.js';
|
|
6
|
+
export { login } from './auth.js';
|
|
7
|
+
export { installService, uninstallService, isServiceInstalled } from './service.js';
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Pino logger for the sync agent daemon
|
|
2
|
+
|
|
3
|
+
import pino from 'pino';
|
|
4
|
+
|
|
5
|
+
const REDACT_CONFIG = {
|
|
6
|
+
paths: [
|
|
7
|
+
'apiKey',
|
|
8
|
+
'*.apiKey',
|
|
9
|
+
'credentials',
|
|
10
|
+
'*.credentials',
|
|
11
|
+
'token',
|
|
12
|
+
'*.token',
|
|
13
|
+
'password',
|
|
14
|
+
'*.password',
|
|
15
|
+
'secret',
|
|
16
|
+
'*.secret',
|
|
17
|
+
'req.headers.authorization',
|
|
18
|
+
'req.headers.cookie',
|
|
19
|
+
],
|
|
20
|
+
censor: '[REDACTED]',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function createLogger() {
|
|
24
|
+
const isForeground = process.argv.includes('--foreground') || process.env.NODE_ENV !== 'production';
|
|
25
|
+
|
|
26
|
+
if (isForeground) {
|
|
27
|
+
// Interactive / foreground mode: pretty-print to stderr
|
|
28
|
+
return pino({
|
|
29
|
+
level: process.env.LOG_LEVEL ?? 'info',
|
|
30
|
+
redact: REDACT_CONFIG,
|
|
31
|
+
transport: { target: 'pino-pretty', options: { colorize: true } },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Background daemon mode: JSON to stdout (captured by systemd/launchd)
|
|
36
|
+
return pino({
|
|
37
|
+
level: process.env.LOG_LEVEL ?? 'info',
|
|
38
|
+
redact: REDACT_CONFIG,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const logger = createLogger();
|