@token-dashboard/typeless-usage-uploader 0.1.0 → 0.1.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/dist/collector.js DELETED
@@ -1,495 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import { DatabaseSync } from 'node:sqlite';
4
- import {
5
- MAX_BATCH_BYTES,
6
- MAX_EVENTS_PER_BATCH,
7
- PERSISTENT_COLLECTOR_ID_PATH,
8
- REGISTER_REFRESH_SECONDS,
9
- TYPELESS_SCAN_PAGE_SIZE,
10
- } from './constants.js';
11
- import {
12
- isoToEpochMs,
13
- nowTs,
14
- readPersistentCollectorId,
15
- sha1,
16
- sleep,
17
- stableStringify,
18
- writePersistentCollectorId,
19
- } from './utils.js';
20
- import { StateDb } from './state-db.js';
21
-
22
- const TYPELESS_TIMEZONE = 'Asia/Shanghai';
23
-
24
- export function computeTypelessRecordUid(typelessUserId, historyId) {
25
- return sha1(`${typelessUserId}|${historyId}`);
26
- }
27
-
28
- export function loadTypelessAccount(storagePath) {
29
- let payload;
30
- try {
31
- payload = JSON.parse(fs.readFileSync(storagePath, 'utf8'));
32
- } catch {
33
- return null;
34
- }
35
- const userData = payload?.userData;
36
- if (!userData || typeof userData !== 'object') return null;
37
- const role =
38
- userData.role && typeof userData.role === 'object'
39
- ? userData.role
40
- : Array.isArray(userData.roles)
41
- ? userData.roles[0]
42
- : null;
43
- return {
44
- typelessUserId:
45
- typeof userData.user_id === 'string' && userData.user_id.trim()
46
- ? userData.user_id.trim()
47
- : null,
48
- typelessEmail:
49
- typeof userData.email === 'string' && userData.email.trim()
50
- ? userData.email.trim()
51
- : null,
52
- signupMethod:
53
- typeof userData.signup_method === 'string' && userData.signup_method.trim()
54
- ? userData.signup_method.trim()
55
- : null,
56
- typelessCreatedAt:
57
- typeof userData.created_at === 'string' && userData.created_at.trim()
58
- ? userData.created_at.trim()
59
- : null,
60
- typelessRole:
61
- typeof role?.name === 'string' && role.name.trim() ? role.name.trim() : null,
62
- typelessRoleExpiresAt:
63
- typeof role?.exp_time === 'string' && role.exp_time.trim()
64
- ? role.exp_time.trim()
65
- : null,
66
- };
67
- }
68
-
69
- export class TypelessUsageUploader {
70
- constructor({
71
- stateDbPath,
72
- backendUrl,
73
- typelessDbPath,
74
- typelessStoragePath,
75
- intervalSeconds,
76
- persistentCollectorIdPath = PERSISTENT_COLLECTOR_ID_PATH,
77
- pageSize = TYPELESS_SCAN_PAGE_SIZE,
78
- timeZone = TYPELESS_TIMEZONE,
79
- }) {
80
- this.stateDb = new StateDb(stateDbPath);
81
- this.backendUrl = backendUrl?.replace(/\/+$/, '') || null;
82
- this.typelessDbPath = typelessDbPath;
83
- this.typelessStoragePath = typelessStoragePath;
84
- this.intervalSeconds = intervalSeconds;
85
- this.persistentCollectorIdPath = persistentCollectorIdPath;
86
- this.pageSize = pageSize;
87
- this.timeZone = timeZone;
88
- this.identity = this.ensureIdentity();
89
- }
90
-
91
- close() {
92
- this.stateDb.close();
93
- }
94
-
95
- ensureIdentity() {
96
- const identity = this.stateDb.getIdentity();
97
- if (!identity.collectorId) {
98
- const persisted = readPersistentCollectorId(this.persistentCollectorIdPath);
99
- identity.collectorId = persisted ?? `${Date.now().toString(16)}${Math.random()
100
- .toString(16)
101
- .slice(2, 14)}`;
102
- }
103
- writePersistentCollectorId(this.persistentCollectorIdPath, identity.collectorId);
104
- if (!identity.deviceId) {
105
- identity.deviceId = `${os.hostname()}-${Math.random().toString(16).slice(2, 14)}`;
106
- }
107
- if (!identity.hostname) identity.hostname = os.hostname();
108
- this.stateDb.setIdentity(identity);
109
- return this.stateDb.getIdentity();
110
- }
111
-
112
- resetBackfillState() {
113
- this.stateDb.resetBackfillState();
114
- }
115
-
116
- getQueueStats() {
117
- return this.stateDb.getQueueStats();
118
- }
119
-
120
- emptyPayload(collector = null) {
121
- return { collector, events: [] };
122
- }
123
-
124
- payloadHasData(payload) {
125
- return Boolean(payload.events?.length);
126
- }
127
-
128
- payloadBytes(payload) {
129
- return Buffer.byteLength(stableStringify(payload), 'utf8');
130
- }
131
-
132
- payloadCollectorKey(payload) {
133
- return payload?.collector?.typelessUserId ?? '';
134
- }
135
-
136
- mergePayload(basePayload, incoming) {
137
- const collector = basePayload.collector ?? incoming.collector ?? null;
138
- const events = new Map(
139
- (basePayload.events ?? []).map((item) => [item.recordUid, item]),
140
- );
141
- for (const item of incoming.events ?? []) events.set(item.recordUid, item);
142
- return { collector, events: [...events.values()] };
143
- }
144
-
145
- shouldFlushChunk(payload) {
146
- if (!this.payloadHasData(payload)) return false;
147
- return (
148
- payload.events.length >= MAX_EVENTS_PER_BATCH ||
149
- this.payloadBytes(payload) >= MAX_BATCH_BYTES
150
- );
151
- }
152
-
153
- wouldExceedUploadThreshold(payload) {
154
- if (!this.payloadHasData(payload)) return false;
155
- return (
156
- payload.events.length > MAX_EVENTS_PER_BATCH ||
157
- this.payloadBytes(payload) > MAX_BATCH_BYTES
158
- );
159
- }
160
-
161
- appendPayloadToBuffer(incoming) {
162
- if (!this.payloadHasData(incoming)) return 0;
163
- const existing = this.stateDb.getBufferingBatch();
164
- let queuedBatches = 0;
165
- let payloadToSave = incoming;
166
-
167
- if (existing) {
168
- const basePayload = JSON.parse(existing.payload_json);
169
- if (this.payloadCollectorKey(basePayload) !== this.payloadCollectorKey(incoming)) {
170
- queuedBatches += this.stateDb.sealStaleBatches(true);
171
- } else {
172
- const merged = this.mergePayload(basePayload, incoming);
173
- if (this.wouldExceedUploadThreshold(merged)) {
174
- queuedBatches += this.stateDb.sealStaleBatches(true);
175
- } else {
176
- payloadToSave = merged;
177
- }
178
- }
179
- }
180
-
181
- this.stateDb.saveBufferingPayload(payloadToSave);
182
- return queuedBatches + (this.stateDb.sealBufferIfThresholdHit() ? 1 : 0);
183
- }
184
-
185
- collectorRequestBody(account) {
186
- return {
187
- collectorId: this.identity.collectorId,
188
- deviceId: this.identity.deviceId,
189
- hostname: this.identity.hostname ?? os.hostname(),
190
- typelessUserId: account.typelessUserId,
191
- typelessEmail: account.typelessEmail,
192
- signupMethod: account.signupMethod,
193
- typelessCreatedAt: account.typelessCreatedAt,
194
- typelessRole: account.typelessRole,
195
- typelessRoleExpiresAt: account.typelessRoleExpiresAt,
196
- };
197
- }
198
-
199
- async postJson(apiPath, payload) {
200
- if (!this.backendUrl) throw new Error('backendUrl is not configured');
201
- const response = await fetch(`${this.backendUrl}${apiPath}`, {
202
- method: 'POST',
203
- headers: { 'Content-Type': 'application/json' },
204
- body: JSON.stringify(payload),
205
- });
206
- if (!response.ok) {
207
- const text = await response.text();
208
- const detail = text ? `: ${text.slice(0, 500)}` : '';
209
- throw new Error(`HTTP ${response.status} ${response.statusText}${detail}`);
210
- }
211
- const text = await response.text();
212
- return text ? JSON.parse(text) : {};
213
- }
214
-
215
- openSourceDb() {
216
- if (!fs.existsSync(this.typelessDbPath)) return null;
217
- const db = new DatabaseSync(this.typelessDbPath, { readOnly: true });
218
- db.exec('PRAGMA busy_timeout = 1000');
219
- return db;
220
- }
221
-
222
- toEvent(account, row) {
223
- const timestamp = isoToEpochMs(row.created_at);
224
- if (timestamp == null) return null;
225
- const recordUid = computeTypelessRecordUid(account.typelessUserId, row.id);
226
- const charCount = Number(row.char_count ?? 0);
227
- if (!Number.isFinite(charCount) || charCount <= 0) return null;
228
- return {
229
- recordUid,
230
- timestamp,
231
- timezone: this.timeZone,
232
- mode: row.mode ?? null,
233
- status: row.status ?? 'transcript',
234
- appVersion: row.app_version ?? null,
235
- charCount,
236
- isDeleted: false,
237
- };
238
- }
239
-
240
- eventFingerprint(event, updatedAt) {
241
- return stableStringify({
242
- timestamp: event.timestamp,
243
- updatedAt: updatedAt ?? null,
244
- status: event.status ?? null,
245
- mode: event.mode ?? null,
246
- appVersion: event.appVersion ?? null,
247
- charCount: event.charCount,
248
- isDeleted: Boolean(event.isDeleted),
249
- });
250
- }
251
-
252
- buildTombstoneEvent(state) {
253
- let previous;
254
- try {
255
- previous = JSON.parse(state.event_json || '{}');
256
- } catch {
257
- previous = {};
258
- }
259
- return {
260
- recordUid: state.record_uid,
261
- timestamp: Number(previous.timestamp ?? 0),
262
- timezone: previous.timezone ?? this.timeZone,
263
- mode: previous.mode ?? null,
264
- status: previous.status ?? 'transcript',
265
- appVersion: previous.appVersion ?? null,
266
- charCount: 0,
267
- isDeleted: true,
268
- };
269
- }
270
-
271
- async scanTypeless() {
272
- const result = {
273
- accountFound: false,
274
- dbFound: fs.existsSync(this.typelessDbPath),
275
- scannedRecords: 0,
276
- changedEvents: 0,
277
- tombstoneEvents: 0,
278
- batchesQueued: 0,
279
- };
280
- const account = loadTypelessAccount(this.typelessStoragePath);
281
- if (!account?.typelessUserId) return result;
282
- result.accountFound = true;
283
- const db = this.openSourceDb();
284
- if (!db) return result;
285
-
286
- const existingRows = this.stateDb.getEventStates(account.typelessUserId);
287
- const existingByUid = new Map(existingRows.map((row) => [row.record_uid, row]));
288
- const seen = new Set();
289
- const collector = this.collectorRequestBody(account);
290
- let chunk = this.emptyPayload(collector);
291
- let stateUpdates = [];
292
-
293
- const flushChunk = () => {
294
- if (!this.payloadHasData(chunk)) return;
295
- result.batchesQueued += this.appendPayloadToBuffer(chunk);
296
- this.stateDb.upsertEventStates(account.typelessUserId, stateUpdates);
297
- chunk = this.emptyPayload(collector);
298
- stateUpdates = [];
299
- };
300
-
301
- try {
302
- const stmt = db.prepare(`
303
- SELECT
304
- id,
305
- created_at,
306
- updated_at,
307
- status,
308
- mode,
309
- app_version,
310
- LENGTH(refined_text) AS char_count
311
- FROM history
312
- WHERE user_id = ?
313
- AND status = 'transcript'
314
- AND refined_text IS NOT NULL
315
- AND refined_text != ''
316
- ORDER BY created_at ASC, id ASC
317
- LIMIT ? OFFSET ?
318
- `);
319
-
320
- for (let offset = 0; ; offset += this.pageSize) {
321
- const rows = stmt.all(account.typelessUserId, this.pageSize, offset);
322
- if (!rows.length) break;
323
- for (const row of rows) {
324
- result.scannedRecords += 1;
325
- const event = this.toEvent(account, row);
326
- if (!event) continue;
327
- seen.add(event.recordUid);
328
- const fingerprint = this.eventFingerprint(event, row.updated_at);
329
- const existing = existingByUid.get(event.recordUid);
330
- if (
331
- existing &&
332
- existing.fingerprint === fingerprint &&
333
- Number(existing.is_deleted) === 0
334
- ) {
335
- continue;
336
- }
337
- chunk = this.mergePayload(chunk, { events: [event] });
338
- stateUpdates.push({
339
- recordUid: event.recordUid,
340
- fingerprint,
341
- event,
342
- isDeleted: false,
343
- });
344
- result.changedEvents += 1;
345
- if (this.shouldFlushChunk(chunk)) flushChunk();
346
- }
347
- }
348
-
349
- for (const state of existingRows) {
350
- if (seen.has(state.record_uid) || Number(state.is_deleted) === 1) continue;
351
- const event = this.buildTombstoneEvent(state);
352
- const fingerprint = this.eventFingerprint(event, null);
353
- chunk = this.mergePayload(chunk, { events: [event] });
354
- stateUpdates.push({
355
- recordUid: event.recordUid,
356
- fingerprint,
357
- event,
358
- isDeleted: true,
359
- });
360
- result.tombstoneEvents += 1;
361
- if (this.shouldFlushChunk(chunk)) flushChunk();
362
- }
363
-
364
- flushChunk();
365
- return result;
366
- } finally {
367
- db.close();
368
- }
369
- }
370
-
371
- async ensureRemoteRegistration(account) {
372
- if (!this.backendUrl || !account?.typelessUserId) return;
373
- const checkpointKey = `last_register_at:${account.typelessUserId}`;
374
- const checkpoint = this.stateDb.getCheckpoint(checkpointKey);
375
- if (checkpoint && nowTs() - Number(checkpoint) < REGISTER_REFRESH_SECONDS) return;
376
- await this.postJson(
377
- '/typeless-usage/collectors/register',
378
- this.collectorRequestBody(account),
379
- );
380
- this.stateDb.setCheckpoint(checkpointKey, String(nowTs()));
381
- }
382
-
383
- async flushPendingBatches({ failFast = false, concurrency = 5 } = {}) {
384
- if (!this.backendUrl) {
385
- return { uploadedBatches: 0, failedBatches: 0, lastError: null };
386
- }
387
- const account = loadTypelessAccount(this.typelessStoragePath);
388
- if (!account?.typelessUserId) {
389
- return { uploadedBatches: 0, failedBatches: 0, lastError: null };
390
- }
391
-
392
- const fallbackCollectorBody = this.collectorRequestBody(account);
393
- const rows = this.stateDb.iterPendingBatches();
394
- if (!rows.length) {
395
- try {
396
- await this.ensureRemoteRegistration(account);
397
- } catch (error) {
398
- if (failFast) throw error;
399
- return { uploadedBatches: 0, failedBatches: 1, lastError: error };
400
- }
401
- return { uploadedBatches: 0, failedBatches: 0, lastError: null };
402
- }
403
-
404
- let uploadedBatches = 0;
405
- let failedBatches = 0;
406
- let lastError = null;
407
-
408
- const uploadOne = async (row) => {
409
- const payload = JSON.parse(row.payload_json);
410
- const collectorBody = payload.collector ?? fallbackCollectorBody;
411
- if (!collectorBody?.typelessUserId) {
412
- throw new Error('Typeless collector identity is missing from queued batch.');
413
- }
414
- await this.postJson('/typeless-usage/upload', {
415
- idempotencyKey: row.batch_key,
416
- collector: collectorBody,
417
- payloadSizeBytes: Number(row.payload_bytes),
418
- events: Array.isArray(payload.events) ? payload.events : [],
419
- });
420
- };
421
-
422
- const pending = new Set();
423
- let rowIndex = 0;
424
- const enqueue = () => {
425
- while (pending.size < concurrency && rowIndex < rows.length) {
426
- const row = rows[rowIndex++];
427
- const task = uploadOne(row)
428
- .then(() => {
429
- this.stateDb.markBatchUploaded(row.id);
430
- uploadedBatches += 1;
431
- })
432
- .catch((error) => {
433
- this.stateDb.markBatchFailed(
434
- row.id,
435
- Number(row.attempt_count) + 1,
436
- error instanceof Error ? error.message : String(error),
437
- );
438
- failedBatches += 1;
439
- lastError = error;
440
- if (failFast) throw error;
441
- })
442
- .finally(() => pending.delete(task));
443
- pending.add(task);
444
- }
445
- };
446
-
447
- enqueue();
448
- while (pending.size > 0) {
449
- try {
450
- await Promise.race(pending);
451
- } catch {
452
- if (failFast) break;
453
- }
454
- enqueue();
455
- }
456
-
457
- if (failFast && lastError) {
458
- throw new Error(
459
- `Typeless upload batch failed: ${
460
- lastError instanceof Error ? lastError.message : String(lastError)
461
- }`,
462
- );
463
- }
464
-
465
- return { uploadedBatches, failedBatches, lastError };
466
- }
467
-
468
- async runCycle({ forceSeal = false } = {}) {
469
- const scanResult = await this.scanTypeless();
470
- scanResult.batchesQueued += this.stateDb.sealStaleBatches(forceSeal);
471
- const flushResult = await this.flushPendingBatches();
472
- const queueStats = this.getQueueStats();
473
- return {
474
- ...scanResult,
475
- ...flushResult,
476
- ...queueStats,
477
- remainingQueuedBatches:
478
- queueStats.bufferingBatchCount +
479
- queueStats.pendingBatchCount +
480
- queueStats.retryingBatchCount,
481
- };
482
- }
483
-
484
- async watch() {
485
- let firstCycle = true;
486
- while (true) {
487
- const result = await this.runCycle({ forceSeal: firstCycle });
488
- console.log(
489
- `[typeless] account=${result.accountFound ? 'yes' : 'no'} db=${result.dbFound ? 'yes' : 'no'} scanned=${result.scannedRecords} changed=${result.changedEvents} deleted=${result.tombstoneEvents} queued_batches=${result.batchesQueued} uploaded_batches=${result.uploadedBatches} remaining_batches=${result.remainingQueuedBatches}`,
490
- );
491
- firstCycle = false;
492
- await sleep(this.intervalSeconds * 1000);
493
- }
494
- }
495
- }
package/dist/constants.js DELETED
@@ -1,43 +0,0 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
-
4
- export const PRODUCT_NAME = 'Typeless 用量上报';
5
- export const CLI_NAME = 'typeless-usage-uploader';
6
- export const DEFAULT_TYPELESS_DB_PATH = path.join(
7
- os.homedir(),
8
- 'Library',
9
- 'Application Support',
10
- 'Typeless',
11
- 'typeless.db',
12
- );
13
- export const DEFAULT_TYPELESS_STORAGE_PATH = path.join(
14
- os.homedir(),
15
- 'Library',
16
- 'Application Support',
17
- 'Typeless',
18
- 'app-storage.json',
19
- );
20
- export const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.typeless-usage-uploader');
21
- export const DEFAULT_CONFIG_FILE = path.join(DEFAULT_INSTALL_ROOT, 'config.json');
22
- export const DEFAULT_STATE_DB = path.join(DEFAULT_INSTALL_ROOT, 'state.sqlite');
23
- export const DEFAULT_LOG_DIR = path.join(DEFAULT_INSTALL_ROOT, 'logs');
24
- export const DEFAULT_STDOUT_LOG_PATH = path.join(DEFAULT_LOG_DIR, 'stdout.log');
25
- export const DEFAULT_STDERR_LOG_PATH = path.join(DEFAULT_LOG_DIR, 'stderr.log');
26
- export const DEFAULT_LAUNCHD_LABEL = 'com.token-dashboard.typeless-usage-uploader';
27
- export const DEFAULT_LAUNCH_AGENT_PATH = path.join(
28
- os.homedir(),
29
- 'Library',
30
- 'LaunchAgents',
31
- `${DEFAULT_LAUNCHD_LABEL}.plist`,
32
- );
33
- export const PERSISTENT_COLLECTOR_ID_PATH = path.join(
34
- os.homedir(),
35
- '.typeless-usage-uploader-collector-id',
36
- );
37
- export const DEFAULT_BACKEND_URL = 'http://101.126.66.51:8086';
38
- export const TYPELESS_SCAN_PAGE_SIZE = 500;
39
- export const MAX_EVENTS_PER_BATCH = 200;
40
- export const MAX_BATCH_BYTES = 1_000_000;
41
- export const MAX_BUFFER_AGE_SECONDS = 60;
42
- export const REGISTER_REFRESH_SECONDS = 600;
43
- export const STATUS_ONLINE_THRESHOLD_SECONDS = 600;
package/dist/launchd.js DELETED
@@ -1,154 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { spawnSync } from 'node:child_process';
4
-
5
- export function buildLaunchdPlist(runtime) {
6
- const args = [
7
- runtime.nodePath,
8
- runtime.entryFile,
9
- '--config-file',
10
- runtime.configFile,
11
- 'run',
12
- ];
13
- return `<?xml version="1.0" encoding="UTF-8"?>
14
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
15
- <plist version="1.0">
16
- <dict>
17
- <key>Label</key>
18
- <string>${escapeXml(runtime.launchdLabel)}</string>
19
- <key>ProgramArguments</key>
20
- <array>
21
- ${args.map((item) => ` <string>${escapeXml(item)}</string>`).join('\n')}
22
- </array>
23
- <key>RunAtLoad</key>
24
- <true/>
25
- <key>KeepAlive</key>
26
- <true/>
27
- <key>ProcessType</key>
28
- <string>Background</string>
29
- <key>WorkingDirectory</key>
30
- <string>${escapeXml(runtime.installRoot)}</string>
31
- <key>StandardOutPath</key>
32
- <string>${escapeXml(runtime.stdoutLogPath)}</string>
33
- <key>StandardErrorPath</key>
34
- <string>${escapeXml(runtime.stderrLogPath)}</string>
35
- <key>EnvironmentVariables</key>
36
- <dict>
37
- <key>NODE_NO_WARNINGS</key>
38
- <string>1</string>
39
- </dict>
40
- </dict>
41
- </plist>
42
- `;
43
- }
44
-
45
- function escapeXml(value) {
46
- return String(value)
47
- .replaceAll('&', '&amp;')
48
- .replaceAll('<', '&lt;')
49
- .replaceAll('>', '&gt;')
50
- .replaceAll('"', '&quot;')
51
- .replaceAll("'", '&apos;');
52
- }
53
-
54
- export function parseLaunchctlPrint(output) {
55
- const state = output.match(/^\s*state = ([^\n]+)$/m)?.[1]?.trim() ?? null;
56
- const pidRaw = output.match(/^\s*pid = (\d+)$/m)?.[1] ?? null;
57
- const exitRaw = output.match(/^\s*last exit code = (-?\d+)$/m)?.[1] ?? null;
58
- return {
59
- loaded: true,
60
- running: state === 'running' || pidRaw != null,
61
- pid: pidRaw ? Number(pidRaw) : null,
62
- state,
63
- lastExitCode: exitRaw ? Number(exitRaw) : null,
64
- };
65
- }
66
-
67
- export class LaunchdServiceManager {
68
- constructor(runtime) {
69
- this.runtime = runtime;
70
- }
71
-
72
- ensureMacos() {
73
- if (process.platform !== 'darwin') {
74
- throw new Error('launchd management is only supported on macOS.');
75
- }
76
- }
77
-
78
- domainTarget() {
79
- return `gui/${process.getuid()}`;
80
- }
81
-
82
- serviceTarget() {
83
- return `${this.domainTarget()}/${this.runtime.launchdLabel}`;
84
- }
85
-
86
- run(args, { check = true } = {}) {
87
- const result = spawnSync(args[0], args.slice(1), { encoding: 'utf8' });
88
- if (check && result.status !== 0) {
89
- throw new Error(
90
- result.stderr?.trim() || result.stdout?.trim() || `command failed: ${args.join(' ')}`,
91
- );
92
- }
93
- return result;
94
- }
95
-
96
- ensurePlist() {
97
- this.ensureMacos();
98
- if (!this.runtime.entryFile || !fs.existsSync(this.runtime.entryFile)) {
99
- throw new Error(`runtime entry not found: ${this.runtime.entryFile || '(empty)'}`);
100
- }
101
- const plist = buildLaunchdPlist(this.runtime);
102
- fs.mkdirSync(path.dirname(this.runtime.plistPath), { recursive: true });
103
- fs.mkdirSync(path.dirname(this.runtime.stdoutLogPath), { recursive: true });
104
- fs.writeFileSync(this.runtime.plistPath, plist);
105
- this.syncLaunchAgent(plist);
106
- }
107
-
108
- syncLaunchAgent(plist) {
109
- if (!this.runtime.launchAgentPath) return;
110
- if (this.runtime.autoStartOnLogin) {
111
- fs.mkdirSync(path.dirname(this.runtime.launchAgentPath), { recursive: true });
112
- fs.writeFileSync(this.runtime.launchAgentPath, plist);
113
- } else if (fs.existsSync(this.runtime.launchAgentPath)) {
114
- fs.rmSync(this.runtime.launchAgentPath, { force: true });
115
- }
116
- }
117
-
118
- start() {
119
- this.ensurePlist();
120
- this.stop();
121
- this.run(['launchctl', 'bootstrap', this.domainTarget(), this.runtime.plistPath]);
122
- }
123
-
124
- stop() {
125
- this.ensureMacos();
126
- this.run(['launchctl', 'bootout', this.serviceTarget()], { check: false });
127
- }
128
-
129
- restart() {
130
- this.start();
131
- }
132
-
133
- status() {
134
- this.ensureMacos();
135
- const result = this.run(['launchctl', 'print', this.serviceTarget()], { check: false });
136
- if (result.status !== 0) {
137
- return {
138
- loaded: false,
139
- running: false,
140
- pid: null,
141
- state: null,
142
- lastExitCode: null,
143
- };
144
- }
145
- return parseLaunchctlPrint(result.stdout);
146
- }
147
-
148
- uninstall() {
149
- this.stop();
150
- for (const filePath of [this.runtime.plistPath, this.runtime.launchAgentPath]) {
151
- if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
152
- }
153
- }
154
- }