elit 3.5.3 → 3.5.5

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/src/pm-cli.ts ADDED
@@ -0,0 +1,2369 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { EOL } from 'node:os';
4
+ import { basename, dirname, extname, join, resolve } from 'node:path';
5
+
6
+ import { watch as createWatcher } from './chokidar';
7
+ import {
8
+ loadConfig,
9
+ type ElitConfig,
10
+ type PmAppConfig,
11
+ type PmConfig,
12
+ type PmHealthCheckConfig,
13
+ type PmRestartPolicy,
14
+ type PmRuntimeName,
15
+ type WapkGoogleDriveConfig,
16
+ type WapkRunConfig,
17
+ } from './config';
18
+
19
+ const DEFAULT_PM_DATA_DIR = join('.elit', 'pm');
20
+ const DEFAULT_PM_DUMP_FILE = 'dump.json';
21
+ const DEFAULT_RESTART_DELAY = 1000;
22
+ const DEFAULT_MAX_RESTARTS = 10;
23
+ const DEFAULT_WATCH_DEBOUNCE = 250;
24
+ const DEFAULT_MIN_UPTIME = 0;
25
+ const DEFAULT_HEALTHCHECK_GRACE_PERIOD = 5000;
26
+ const DEFAULT_HEALTHCHECK_INTERVAL = 10000;
27
+ const DEFAULT_HEALTHCHECK_TIMEOUT = 3000;
28
+ const DEFAULT_HEALTHCHECK_MAX_FAILURES = 3;
29
+ const DEFAULT_LOG_LINES = 40;
30
+ const PM_RECORD_EXTENSION = '.json';
31
+ const SUPPORTED_FILE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']);
32
+ const DEFAULT_WATCH_IGNORE = ['**/.git/**', '**/node_modules/**', '**/.elit/**'];
33
+
34
+ export type PmTargetType = 'script' | 'file' | 'wapk';
35
+ export type PmStatus = 'starting' | 'online' | 'restarting' | 'stopping' | 'stopped' | 'exited' | 'errored';
36
+
37
+ export interface PmResolvedHealthCheck {
38
+ url: string;
39
+ gracePeriod: number;
40
+ interval: number;
41
+ timeout: number;
42
+ maxFailures: number;
43
+ }
44
+
45
+ export interface PmDumpFile {
46
+ version: 1;
47
+ savedAt: string;
48
+ apps: PmSavedAppDefinition[];
49
+ }
50
+
51
+ export interface PmSavedAppDefinition {
52
+ name: string;
53
+ type: PmTargetType;
54
+ cwd: string;
55
+ runtime?: PmRuntimeName;
56
+ env: Record<string, string>;
57
+ script?: string;
58
+ file?: string;
59
+ wapk?: string;
60
+ password?: string;
61
+ wapkRun?: WapkRunConfig;
62
+ restartPolicy: PmRestartPolicy;
63
+ autorestart: boolean;
64
+ restartDelay: number;
65
+ maxRestarts: number;
66
+ minUptime: number;
67
+ watch: boolean;
68
+ watchPaths: string[];
69
+ watchIgnore: string[];
70
+ watchDebounce: number;
71
+ healthCheck?: PmResolvedHealthCheck;
72
+ }
73
+
74
+ export interface ParsedPmStartArgs {
75
+ targetToken?: string;
76
+ name?: string;
77
+ script?: string;
78
+ file?: string;
79
+ wapk?: string;
80
+ wapkRun?: WapkRunConfig;
81
+ runtime?: PmRuntimeName;
82
+ cwd?: string;
83
+ env: Record<string, string>;
84
+ autorestart?: boolean;
85
+ restartDelay?: number;
86
+ maxRestarts?: number;
87
+ password?: string;
88
+ restartPolicy?: PmRestartPolicy;
89
+ minUptime?: number;
90
+ watch?: boolean;
91
+ watchPaths: string[];
92
+ watchIgnore: string[];
93
+ watchDebounce?: number;
94
+ healthCheckUrl?: string;
95
+ healthCheckGracePeriod?: number;
96
+ healthCheckInterval?: number;
97
+ healthCheckTimeout?: number;
98
+ healthCheckMaxFailures?: number;
99
+ }
100
+
101
+ export interface ResolvedPmAppDefinition {
102
+ name: string;
103
+ type: PmTargetType;
104
+ source: 'cli' | 'config';
105
+ cwd: string;
106
+ runtime?: PmRuntimeName;
107
+ env: Record<string, string>;
108
+ script?: string;
109
+ file?: string;
110
+ wapk?: string;
111
+ wapkRun?: WapkRunConfig;
112
+ autorestart: boolean;
113
+ restartDelay: number;
114
+ maxRestarts: number;
115
+ password?: string;
116
+ restartPolicy: PmRestartPolicy;
117
+ minUptime: number;
118
+ watch: boolean;
119
+ watchPaths: string[];
120
+ watchIgnore: string[];
121
+ watchDebounce: number;
122
+ healthCheck?: PmResolvedHealthCheck;
123
+ }
124
+
125
+ export interface PmRecord {
126
+ id: string;
127
+ name: string;
128
+ type: PmTargetType;
129
+ source: 'cli' | 'config';
130
+ cwd: string;
131
+ runtime?: PmRuntimeName;
132
+ env: Record<string, string>;
133
+ script?: string;
134
+ file?: string;
135
+ wapk?: string;
136
+ wapkRun?: WapkRunConfig;
137
+ autorestart: boolean;
138
+ restartDelay: number;
139
+ maxRestarts: number;
140
+ password?: string;
141
+ restartPolicy: PmRestartPolicy;
142
+ minUptime: number;
143
+ watch: boolean;
144
+ watchPaths: string[];
145
+ watchIgnore: string[];
146
+ watchDebounce: number;
147
+ healthCheck?: PmResolvedHealthCheck;
148
+ desiredState: 'running' | 'stopped';
149
+ status: PmStatus;
150
+ commandPreview: string;
151
+ createdAt: string;
152
+ updatedAt: string;
153
+ startedAt?: string;
154
+ stoppedAt?: string;
155
+ runnerPid?: number;
156
+ childPid?: number;
157
+ restartCount: number;
158
+ lastExitCode?: number;
159
+ error?: string;
160
+ logFiles: {
161
+ out: string;
162
+ err: string;
163
+ };
164
+ }
165
+
166
+ interface PmPaths {
167
+ dataDir: string;
168
+ appsDir: string;
169
+ logsDir: string;
170
+ dumpFile: string;
171
+ }
172
+
173
+ interface BuiltPmCommand {
174
+ command: string;
175
+ args: string[];
176
+ shell?: boolean;
177
+ runtime?: PmRuntimeName;
178
+ preview: string;
179
+ }
180
+
181
+ interface PmRecordMatch {
182
+ filePath: string;
183
+ record: PmRecord;
184
+ }
185
+
186
+ interface ParsedPmRunnerArgs {
187
+ dataDir: string;
188
+ id: string;
189
+ }
190
+
191
+ function normalizePmRuntime(value: unknown, optionName = '--runtime'): PmRuntimeName | undefined {
192
+ if (value === undefined || value === null || value === '') {
193
+ return undefined;
194
+ }
195
+
196
+ if (typeof value !== 'string') {
197
+ throw new Error(`${optionName} must be one of: node, bun, deno`);
198
+ }
199
+
200
+ const runtime = value.trim().toLowerCase();
201
+ if (runtime === 'node' || runtime === 'bun' || runtime === 'deno') {
202
+ return runtime;
203
+ }
204
+
205
+ throw new Error(`${optionName} must be one of: node, bun, deno`);
206
+ }
207
+
208
+ function normalizePmRestartPolicy(value: unknown, optionName = '--restart-policy'): PmRestartPolicy | undefined {
209
+ if (value === undefined || value === null || value === '') {
210
+ return undefined;
211
+ }
212
+
213
+ if (typeof value !== 'string') {
214
+ throw new Error(`${optionName} must be one of: always, on-failure, never`);
215
+ }
216
+
217
+ const policy = value.trim().toLowerCase();
218
+ if (policy === 'always' || policy === 'on-failure' || policy === 'never') {
219
+ return policy;
220
+ }
221
+
222
+ throw new Error(`${optionName} must be one of: always, on-failure, never`);
223
+ }
224
+
225
+ function normalizeIntegerOption(value: string, optionName: string, min = 0): number {
226
+ const parsed = Number.parseInt(value, 10);
227
+ if (!Number.isFinite(parsed) || parsed < min) {
228
+ throw new Error(`${optionName} must be a number >= ${min}`);
229
+ }
230
+ return parsed;
231
+ }
232
+
233
+ function normalizeNonEmptyString(value: unknown): string | undefined {
234
+ if (typeof value !== 'string') {
235
+ return undefined;
236
+ }
237
+
238
+ const normalized = value.trim();
239
+ return normalized.length > 0 ? normalized : undefined;
240
+ }
241
+
242
+ function hasPmGoogleDriveConfig(config: WapkGoogleDriveConfig | undefined): boolean {
243
+ return Boolean(
244
+ normalizeNonEmptyString(config?.fileId)
245
+ || normalizeNonEmptyString(config?.accessToken)
246
+ || normalizeNonEmptyString(config?.accessTokenEnv)
247
+ || typeof config?.supportsAllDrives === 'boolean',
248
+ );
249
+ }
250
+
251
+ function hasPmWapkRunConfig(config: WapkRunConfig | undefined): boolean {
252
+ return Boolean(
253
+ normalizeNonEmptyString(config?.file)
254
+ || hasPmGoogleDriveConfig(config?.googleDrive)
255
+ || normalizeNonEmptyString(config?.runtime)
256
+ || typeof config?.syncInterval === 'number'
257
+ || typeof config?.useWatcher === 'boolean'
258
+ || typeof config?.watchArchive === 'boolean'
259
+ || typeof config?.archiveSyncInterval === 'number'
260
+ || normalizeNonEmptyString(config?.password),
261
+ );
262
+ }
263
+
264
+ function mergePmWapkRunConfig(base: WapkRunConfig | undefined, override: WapkRunConfig | undefined): WapkRunConfig | undefined {
265
+ if (!base && !override) {
266
+ return undefined;
267
+ }
268
+
269
+ const googleDrive: WapkGoogleDriveConfig | undefined = hasPmGoogleDriveConfig(base?.googleDrive) || hasPmGoogleDriveConfig(override?.googleDrive)
270
+ ? {
271
+ fileId: override?.googleDrive?.fileId ?? base?.googleDrive?.fileId,
272
+ accessToken: override?.googleDrive?.accessToken ?? base?.googleDrive?.accessToken,
273
+ accessTokenEnv: override?.googleDrive?.accessTokenEnv ?? base?.googleDrive?.accessTokenEnv,
274
+ supportsAllDrives: override?.googleDrive?.supportsAllDrives ?? base?.googleDrive?.supportsAllDrives,
275
+ }
276
+ : undefined;
277
+
278
+ const merged: WapkRunConfig = {
279
+ file: override?.file ?? base?.file,
280
+ googleDrive,
281
+ runtime: override?.runtime ?? base?.runtime,
282
+ syncInterval: override?.syncInterval ?? base?.syncInterval,
283
+ useWatcher: override?.useWatcher ?? base?.useWatcher,
284
+ watchArchive: override?.watchArchive ?? base?.watchArchive,
285
+ archiveSyncInterval: override?.archiveSyncInterval ?? base?.archiveSyncInterval,
286
+ password: override?.password ?? base?.password,
287
+ };
288
+
289
+ return hasPmWapkRunConfig(merged) ? merged : undefined;
290
+ }
291
+
292
+ function stripPmWapkSourceFromRunConfig(config: WapkRunConfig | undefined): WapkRunConfig | undefined {
293
+ if (!config) {
294
+ return undefined;
295
+ }
296
+
297
+ const googleDrive = hasPmGoogleDriveConfig({
298
+ ...config.googleDrive,
299
+ fileId: undefined,
300
+ })
301
+ ? {
302
+ ...config.googleDrive,
303
+ fileId: undefined,
304
+ }
305
+ : undefined;
306
+
307
+ const stripped: WapkRunConfig = {
308
+ file: undefined,
309
+ googleDrive,
310
+ runtime: undefined,
311
+ syncInterval: config.syncInterval,
312
+ useWatcher: config.useWatcher,
313
+ watchArchive: config.watchArchive,
314
+ archiveSyncInterval: config.archiveSyncInterval,
315
+ password: undefined,
316
+ };
317
+
318
+ return hasPmWapkRunConfig(stripped) ? stripped : undefined;
319
+ }
320
+
321
+ function isRemoteWapkArchiveSpecifier(value: string): boolean {
322
+ return /^(?:gdrive|google-drive):\/\/.+/i.test(value.trim());
323
+ }
324
+
325
+ function isWapkArchiveSpecifier(value: string): boolean {
326
+ const normalized = value.trim();
327
+ return normalized.toLowerCase().endsWith('.wapk') || isRemoteWapkArchiveSpecifier(normalized);
328
+ }
329
+
330
+ function buildGoogleDriveWapkSpecifier(fileId: string): string {
331
+ return `gdrive://${fileId}`;
332
+ }
333
+
334
+ function resolvePmWapkSource(value: string | undefined, cwd: string): string | undefined {
335
+ const normalized = normalizeNonEmptyString(value);
336
+ if (!normalized) {
337
+ return undefined;
338
+ }
339
+
340
+ return isRemoteWapkArchiveSpecifier(normalized)
341
+ ? normalized
342
+ : resolve(cwd, normalized);
343
+ }
344
+
345
+ function resolvePmWapkSourceToken(wapk: string | undefined, wapkRun: WapkRunConfig | undefined): string | undefined {
346
+ const googleDriveFileId = normalizeNonEmptyString(wapkRun?.googleDrive?.fileId);
347
+ return normalizeNonEmptyString(wapk)
348
+ ?? normalizeNonEmptyString(wapkRun?.file)
349
+ ?? (googleDriveFileId ? buildGoogleDriveWapkSpecifier(googleDriveFileId) : undefined);
350
+ }
351
+
352
+ function countDefinedPmWapkSources(wapk: string | undefined, wapkRun: WapkRunConfig | undefined): number {
353
+ const values = [
354
+ normalizeNonEmptyString(wapk),
355
+ normalizeNonEmptyString(wapkRun?.file),
356
+ normalizeNonEmptyString(wapkRun?.googleDrive?.fileId),
357
+ ].filter((entry): entry is string => Boolean(entry));
358
+
359
+ return new Set(values).size;
360
+ }
361
+
362
+ function appendPmWapkRunArgs(args: string[], previewParts: string[], wapkRun: WapkRunConfig | undefined): void {
363
+ if (!wapkRun) {
364
+ return;
365
+ }
366
+
367
+ if (typeof wapkRun.syncInterval === 'number' && Number.isFinite(wapkRun.syncInterval) && wapkRun.syncInterval >= 50) {
368
+ const value = String(Math.trunc(wapkRun.syncInterval));
369
+ args.push('--sync-interval', value);
370
+ previewParts.push('--sync-interval', value);
371
+ }
372
+
373
+ if (wapkRun.useWatcher) {
374
+ args.push('--watcher');
375
+ previewParts.push('--watcher');
376
+ }
377
+
378
+ if (typeof wapkRun.watchArchive === 'boolean') {
379
+ const flag = wapkRun.watchArchive ? '--archive-watch' : '--no-archive-watch';
380
+ args.push(flag);
381
+ previewParts.push(flag);
382
+ }
383
+
384
+ if (typeof wapkRun.archiveSyncInterval === 'number' && Number.isFinite(wapkRun.archiveSyncInterval) && wapkRun.archiveSyncInterval >= 50) {
385
+ const value = String(Math.trunc(wapkRun.archiveSyncInterval));
386
+ args.push('--archive-sync-interval', value);
387
+ previewParts.push('--archive-sync-interval', value);
388
+ }
389
+
390
+ const tokenEnv = normalizeNonEmptyString(wapkRun.googleDrive?.accessTokenEnv);
391
+ if (tokenEnv) {
392
+ args.push('--google-drive-token-env', tokenEnv);
393
+ previewParts.push('--google-drive-token-env', tokenEnv);
394
+ }
395
+
396
+ const accessToken = normalizeNonEmptyString(wapkRun.googleDrive?.accessToken);
397
+ if (accessToken) {
398
+ args.push('--google-drive-access-token', accessToken);
399
+ previewParts.push('--google-drive-access-token', '******');
400
+ }
401
+
402
+ if (wapkRun.googleDrive?.supportsAllDrives) {
403
+ args.push('--google-drive-shared-drive');
404
+ previewParts.push('--google-drive-shared-drive');
405
+ }
406
+ }
407
+
408
+ function buildPmWapkPreview(wapk: string, runtime?: PmRuntimeName, password?: string, wapkRun?: WapkRunConfig): string {
409
+ const previewParts = ['elit', 'wapk', 'run', quoteCommandSegment(wapk)];
410
+
411
+ if (runtime) {
412
+ previewParts.push('--runtime', runtime);
413
+ }
414
+ if (password) {
415
+ previewParts.push('--password', '******');
416
+ }
417
+
418
+ appendPmWapkRunArgs([], previewParts, wapkRun);
419
+ return previewParts.join(' ');
420
+ }
421
+
422
+ function sanitizePmProcessName(name: string): string {
423
+ const sanitized = name
424
+ .trim()
425
+ .toLowerCase()
426
+ .replace(/[<>:"/\\|?*\x00-\x1f]+/g, '-')
427
+ .replace(/\s+/g, '-')
428
+ .replace(/-+/g, '-');
429
+
430
+ return sanitized.length > 0 ? sanitized : 'process';
431
+ }
432
+
433
+ function isTypescriptFile(filePath: string): boolean {
434
+ const extension = extname(filePath).toLowerCase();
435
+ return extension === '.ts' || extension === '.mts' || extension === '.cts';
436
+ }
437
+
438
+ function normalizeStringArray(value: unknown): string[] {
439
+ if (!Array.isArray(value)) {
440
+ return [];
441
+ }
442
+
443
+ return value
444
+ .filter((entry): entry is string => typeof entry === 'string')
445
+ .map((entry) => entry.trim())
446
+ .filter((entry) => entry.length > 0);
447
+ }
448
+
449
+ function toWatchGlob(candidatePath: string): string {
450
+ if (!existsSync(candidatePath)) {
451
+ return candidatePath;
452
+ }
453
+
454
+ try {
455
+ return statSync(candidatePath).isDirectory()
456
+ ? join(candidatePath, '**', '*').replace(/\\/g, '/')
457
+ : candidatePath;
458
+ } catch {
459
+ return candidatePath;
460
+ }
461
+ }
462
+
463
+ function normalizeWatchPatterns(paths: string[], cwd: string): string[] {
464
+ return paths
465
+ .map((entry) => resolve(cwd, entry))
466
+ .map(toWatchGlob)
467
+ .map((entry) => entry.replace(/\\/g, '/'));
468
+ }
469
+
470
+ function normalizeWatchIgnorePatterns(paths: string[], cwd: string): string[] {
471
+ return paths
472
+ .map((entry) => entry.trim())
473
+ .filter((entry) => entry.length > 0)
474
+ .map((entry) => {
475
+ if (entry.includes('*') || entry.includes('?')) {
476
+ return entry.replace(/\\/g, '/');
477
+ }
478
+
479
+ const resolvedPath = resolve(cwd, entry);
480
+ return toWatchGlob(resolvedPath).replace(/\\/g, '/');
481
+ });
482
+ }
483
+
484
+ function matchesGlobPattern(filePath: string, pattern: string): boolean {
485
+ const normalizedPath = filePath.replace(/\\/g, '/');
486
+ const normalizedPattern = pattern.replace(/\\/g, '/');
487
+ const regexPattern = normalizedPattern
488
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
489
+ .replace(/\*\*/g, '.*')
490
+ .replace(/\*/g, '[^/]*')
491
+ .replace(/\?/g, '.');
492
+
493
+ return new RegExp(`^${regexPattern}$`).test(normalizedPath);
494
+ }
495
+
496
+ function isIgnoredWatchPath(filePath: string, patterns: string[]): boolean {
497
+ return patterns.some((pattern) => matchesGlobPattern(filePath, pattern));
498
+ }
499
+
500
+ function normalizeHealthCheckConfig(value: unknown): PmResolvedHealthCheck | undefined {
501
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
502
+ return undefined;
503
+ }
504
+
505
+ const config = value as PmHealthCheckConfig;
506
+ if (typeof config.url !== 'string' || config.url.trim().length === 0) {
507
+ return undefined;
508
+ }
509
+
510
+ return {
511
+ url: config.url.trim(),
512
+ gracePeriod: typeof config.gracePeriod === 'number' && Number.isFinite(config.gracePeriod)
513
+ ? Math.max(0, Math.trunc(config.gracePeriod))
514
+ : DEFAULT_HEALTHCHECK_GRACE_PERIOD,
515
+ interval: typeof config.interval === 'number' && Number.isFinite(config.interval)
516
+ ? Math.max(250, Math.trunc(config.interval))
517
+ : DEFAULT_HEALTHCHECK_INTERVAL,
518
+ timeout: typeof config.timeout === 'number' && Number.isFinite(config.timeout)
519
+ ? Math.max(250, Math.trunc(config.timeout))
520
+ : DEFAULT_HEALTHCHECK_TIMEOUT,
521
+ maxFailures: typeof config.maxFailures === 'number' && Number.isFinite(config.maxFailures)
522
+ ? Math.max(1, Math.trunc(config.maxFailures))
523
+ : DEFAULT_HEALTHCHECK_MAX_FAILURES,
524
+ };
525
+ }
526
+
527
+ function looksLikeManagedFile(value: string, cwd: string): boolean {
528
+ const normalized = value.trim();
529
+ if (!normalized) {
530
+ return false;
531
+ }
532
+
533
+ if (isRemoteWapkArchiveSpecifier(normalized)) {
534
+ return false;
535
+ }
536
+
537
+ if (normalized.toLowerCase().endsWith('.wapk')) {
538
+ return true;
539
+ }
540
+
541
+ const extension = extname(normalized).toLowerCase();
542
+ if (SUPPORTED_FILE_EXTENSIONS.has(extension)) {
543
+ return true;
544
+ }
545
+
546
+ if (normalized.includes('/') || normalized.includes('\\') || normalized.startsWith('.')) {
547
+ return existsSync(resolve(cwd, normalized));
548
+ }
549
+
550
+ return false;
551
+ }
552
+
553
+ function normalizeEnvMap(value: unknown): Record<string, string> {
554
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
555
+ return {};
556
+ }
557
+
558
+ const normalized: Record<string, string> = {};
559
+ for (const [key, entryValue] of Object.entries(value)) {
560
+ if (typeof entryValue === 'string') {
561
+ normalized[key] = entryValue;
562
+ continue;
563
+ }
564
+
565
+ if (typeof entryValue === 'number' || typeof entryValue === 'boolean') {
566
+ normalized[key] = String(entryValue);
567
+ }
568
+ }
569
+
570
+ return normalized;
571
+ }
572
+
573
+ function parsePmEnvEntry(input: string): [string, string] {
574
+ const separatorIndex = input.indexOf('=');
575
+ if (separatorIndex <= 0) {
576
+ throw new Error('--env expects KEY=VALUE');
577
+ }
578
+
579
+ const key = input.slice(0, separatorIndex).trim();
580
+ const value = input.slice(separatorIndex + 1);
581
+ if (!key) {
582
+ throw new Error('--env expects KEY=VALUE');
583
+ }
584
+
585
+ return [key, value];
586
+ }
587
+
588
+ function readRequiredValue(args: string[], index: number, optionName: string): string {
589
+ const value = args[index];
590
+ if (value === undefined) {
591
+ throw new Error(`${optionName} requires a value.`);
592
+ }
593
+ return value;
594
+ }
595
+
596
+ function parsePmTarget(parsed: ParsedPmStartArgs, workspaceRoot: string): { configName?: string; script?: string; file?: string; wapk?: string } {
597
+ if (parsed.script) {
598
+ return { script: parsed.script };
599
+ }
600
+
601
+ if (parsed.file) {
602
+ return { file: parsed.file };
603
+ }
604
+
605
+ if (parsed.wapk) {
606
+ return { wapk: parsed.wapk };
607
+ }
608
+
609
+ if (!parsed.targetToken) {
610
+ return {};
611
+ }
612
+
613
+ if (isWapkArchiveSpecifier(parsed.targetToken)) {
614
+ return { wapk: parsed.targetToken };
615
+ }
616
+
617
+ if (looksLikeManagedFile(parsed.targetToken, resolve(workspaceRoot, parsed.cwd ?? '.'))) {
618
+ return { file: parsed.targetToken };
619
+ }
620
+
621
+ return { configName: parsed.targetToken };
622
+ }
623
+
624
+ function getConfiguredPmApps(config: ElitConfig | null): PmAppConfig[] {
625
+ return Array.isArray(config?.pm?.apps) ? config!.pm!.apps! : [];
626
+ }
627
+
628
+ function defaultProcessName(base: { script?: string; file?: string; wapk?: string }, explicitName?: string): string {
629
+ if (explicitName && explicitName.trim()) {
630
+ return explicitName.trim();
631
+ }
632
+
633
+ if (base.file) {
634
+ const fileName = basename(base.file, extname(base.file));
635
+ return fileName || 'process';
636
+ }
637
+
638
+ if (base.wapk) {
639
+ const fileName = basename(base.wapk, extname(base.wapk));
640
+ return fileName || 'wapk-app';
641
+ }
642
+
643
+ if (base.script) {
644
+ const candidate = base.script
645
+ .trim()
646
+ .split(/\s+/)
647
+ .slice(0, 2)
648
+ .join('-');
649
+
650
+ return candidate || 'process';
651
+ }
652
+
653
+ return 'process';
654
+ }
655
+
656
+ function countDefinedTargets(app: Pick<PmAppConfig, 'script' | 'file' | 'wapk'>): number {
657
+ return [app.script, app.file, app.wapk].filter(Boolean).length;
658
+ }
659
+
660
+ function resolvePmPaths(config: PmConfig | undefined, workspaceRoot: string): PmPaths {
661
+ const dataDir = resolve(workspaceRoot, config?.dataDir ?? DEFAULT_PM_DATA_DIR);
662
+ const dumpFile = config?.dumpFile
663
+ ? resolve(workspaceRoot, config.dumpFile)
664
+ : join(dataDir, DEFAULT_PM_DUMP_FILE);
665
+
666
+ return {
667
+ dataDir,
668
+ appsDir: join(dataDir, 'apps'),
669
+ logsDir: join(dataDir, 'logs'),
670
+ dumpFile,
671
+ };
672
+ }
673
+
674
+ function ensurePmDirectories(paths: PmPaths): void {
675
+ mkdirSync(paths.dataDir, { recursive: true });
676
+ mkdirSync(paths.appsDir, { recursive: true });
677
+ mkdirSync(paths.logsDir, { recursive: true });
678
+ mkdirSync(dirname(paths.dumpFile), { recursive: true });
679
+ }
680
+
681
+ function getPmRecordPath(paths: PmPaths, id: string): string {
682
+ return join(paths.appsDir, `${id}${PM_RECORD_EXTENSION}`);
683
+ }
684
+
685
+ function readPmRecord(filePath: string): PmRecord {
686
+ return JSON.parse(readFileSync(filePath, 'utf8')) as PmRecord;
687
+ }
688
+
689
+ function writePmRecord(filePath: string, record: PmRecord): void {
690
+ writeFileSync(filePath, JSON.stringify(record, null, 2));
691
+ }
692
+
693
+ function toSavedAppDefinition(record: PmRecord): PmSavedAppDefinition {
694
+ return {
695
+ name: record.name,
696
+ type: record.type,
697
+ cwd: record.cwd,
698
+ runtime: record.runtime,
699
+ env: record.env,
700
+ script: record.script,
701
+ file: record.file,
702
+ wapk: record.wapk,
703
+ password: record.password,
704
+ wapkRun: record.wapkRun,
705
+ restartPolicy: record.restartPolicy,
706
+ autorestart: record.autorestart,
707
+ restartDelay: record.restartDelay,
708
+ maxRestarts: record.maxRestarts,
709
+ minUptime: record.minUptime,
710
+ watch: record.watch,
711
+ watchPaths: record.watchPaths,
712
+ watchIgnore: record.watchIgnore,
713
+ watchDebounce: record.watchDebounce,
714
+ healthCheck: record.healthCheck,
715
+ };
716
+ }
717
+
718
+ function writePmDumpFile(filePath: string, apps: PmSavedAppDefinition[]): void {
719
+ const dump: PmDumpFile = {
720
+ version: 1,
721
+ savedAt: new Date().toISOString(),
722
+ apps,
723
+ };
724
+
725
+ writeFileSync(filePath, JSON.stringify(dump, null, 2));
726
+ }
727
+
728
+ function readPmDumpFile(filePath: string): PmDumpFile {
729
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<PmDumpFile>;
730
+ if (parsed.version !== 1 || !Array.isArray(parsed.apps)) {
731
+ throw new Error(`Invalid PM dump file: ${filePath}`);
732
+ }
733
+
734
+ return {
735
+ version: 1,
736
+ savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : new Date(0).toISOString(),
737
+ apps: parsed.apps as PmSavedAppDefinition[],
738
+ };
739
+ }
740
+
741
+ function deriveDefaultWatchPaths(type: PmTargetType, cwd: string, file?: string, wapk?: string): string[] {
742
+ if (type === 'file' && file) {
743
+ return [file];
744
+ }
745
+
746
+ if (type === 'wapk' && wapk) {
747
+ return [isRemoteWapkArchiveSpecifier(wapk) ? cwd : wapk];
748
+ }
749
+
750
+ return [cwd];
751
+ }
752
+
753
+ function normalizeResolvedWatchPaths(paths: string[], cwd: string, type: PmTargetType, file?: string, wapk?: string): string[] {
754
+ const sourcePaths = paths.length > 0 ? paths : deriveDefaultWatchPaths(type, cwd, file, wapk);
755
+ return normalizeWatchPatterns(sourcePaths, cwd);
756
+ }
757
+
758
+ function listPmRecordMatches(paths: PmPaths): PmRecordMatch[] {
759
+ if (!existsSync(paths.appsDir)) {
760
+ return [];
761
+ }
762
+
763
+ return readdirSync(paths.appsDir)
764
+ .filter((entry) => entry.endsWith(PM_RECORD_EXTENSION))
765
+ .map((entry) => {
766
+ const filePath = join(paths.appsDir, entry);
767
+ return {
768
+ filePath,
769
+ record: readPmRecord(filePath),
770
+ };
771
+ })
772
+ .sort((left, right) => left.record.name.localeCompare(right.record.name));
773
+ }
774
+
775
+ function findPmRecordMatch(paths: PmPaths, nameOrId: string): PmRecordMatch | undefined {
776
+ const directPath = getPmRecordPath(paths, sanitizePmProcessName(nameOrId));
777
+ if (existsSync(directPath)) {
778
+ return {
779
+ filePath: directPath,
780
+ record: readPmRecord(directPath),
781
+ };
782
+ }
783
+
784
+ return listPmRecordMatches(paths).find((match) => match.record.name === nameOrId);
785
+ }
786
+
787
+ function isProcessAlive(pid: number | undefined): boolean {
788
+ if (!pid || pid <= 0) {
789
+ return false;
790
+ }
791
+
792
+ try {
793
+ process.kill(pid, 0);
794
+ return true;
795
+ } catch (error) {
796
+ const code = (error as NodeJS.ErrnoException).code;
797
+ return code === 'EPERM';
798
+ }
799
+ }
800
+
801
+ function syncPmRecordLiveness(match: PmRecordMatch): PmRecordMatch {
802
+ const { record } = match;
803
+ if (record.desiredState === 'running' && record.runnerPid && !isProcessAlive(record.runnerPid)) {
804
+ const updated: PmRecord = {
805
+ ...record,
806
+ status: record.status === 'stopping' ? 'stopped' : record.status === 'errored' ? 'errored' : 'exited',
807
+ runnerPid: undefined,
808
+ childPid: undefined,
809
+ updatedAt: new Date().toISOString(),
810
+ };
811
+
812
+ writePmRecord(match.filePath, updated);
813
+ return { ...match, record: updated };
814
+ }
815
+
816
+ if (record.childPid && !isProcessAlive(record.childPid)) {
817
+ const updated: PmRecord = {
818
+ ...record,
819
+ childPid: undefined,
820
+ updatedAt: new Date().toISOString(),
821
+ };
822
+
823
+ writePmRecord(match.filePath, updated);
824
+ return { ...match, record: updated };
825
+ }
826
+
827
+ return match;
828
+ }
829
+
830
+ function readCurrentCliInvocation(): { command: string; args: string[] } {
831
+ const cliEntry = process.argv[1];
832
+ if (!cliEntry) {
833
+ throw new Error('Unable to resolve the current Elit CLI entrypoint for pm runner startup.');
834
+ }
835
+
836
+ return {
837
+ command: process.execPath,
838
+ args: [...process.execArgv, cliEntry],
839
+ };
840
+ }
841
+
842
+ function preferCurrentExecutable(runtime: PmRuntimeName): string {
843
+ const executableName = basename(process.execPath).toLowerCase();
844
+
845
+ if (runtime === 'node' && process.release?.name === 'node' && executableName.startsWith('node')) {
846
+ return process.execPath;
847
+ }
848
+
849
+ if (runtime === 'bun' && process.versions?.bun && executableName.startsWith('bun')) {
850
+ return process.execPath;
851
+ }
852
+
853
+ return runtime;
854
+ }
855
+
856
+ function commandExists(command: string): boolean {
857
+ if (command.includes('\\') || command.includes('/')) {
858
+ return existsSync(command);
859
+ }
860
+
861
+ const result = spawnSync(command, ['--version'], {
862
+ stdio: 'ignore',
863
+ windowsHide: true,
864
+ });
865
+
866
+ return !result.error;
867
+ }
868
+
869
+ function ensureCommandAvailable(command: string, displayName: string): void {
870
+ if (commandExists(command)) {
871
+ return;
872
+ }
873
+
874
+ throw new Error(`${displayName} was not found in PATH.`);
875
+ }
876
+
877
+ function resolveTsxExecutable(cwd: string): string | undefined {
878
+ const localPath = join(cwd, 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
879
+ if (existsSync(localPath)) {
880
+ return localPath;
881
+ }
882
+
883
+ const globalCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
884
+ return commandExists(globalCommand) ? globalCommand : undefined;
885
+ }
886
+
887
+ function inferRuntimeFromFile(filePath: string): PmRuntimeName {
888
+ if (isTypescriptFile(filePath) && commandExists('bun')) {
889
+ return 'bun';
890
+ }
891
+
892
+ return 'node';
893
+ }
894
+
895
+ const SIMPLE_PREVIEW_SEGMENT = /^[A-Za-z0-9_./:=+-]+$/;
896
+
897
+ function quoteCommandSegment(value: string): string {
898
+ return SIMPLE_PREVIEW_SEGMENT.test(value) ? value : JSON.stringify(value);
899
+ }
900
+
901
+ export function buildPmCommand(record: PmRecord): BuiltPmCommand {
902
+ if (record.type === 'script') {
903
+ return {
904
+ command: record.script!,
905
+ args: [],
906
+ shell: true,
907
+ runtime: record.runtime,
908
+ preview: record.script!,
909
+ };
910
+ }
911
+
912
+ if (record.type === 'wapk') {
913
+ const cliInvocation = readCurrentCliInvocation();
914
+ const args = [
915
+ ...cliInvocation.args,
916
+ 'wapk',
917
+ 'run',
918
+ record.wapk!,
919
+ ];
920
+
921
+ const previewParts = ['elit', 'wapk', 'run', quoteCommandSegment(record.wapk!)];
922
+
923
+ if (record.runtime) {
924
+ args.push('--runtime', record.runtime);
925
+ previewParts.push('--runtime', record.runtime);
926
+ }
927
+
928
+ if (record.password) {
929
+ args.push('--password', record.password);
930
+ previewParts.push('--password', '******');
931
+ }
932
+
933
+ appendPmWapkRunArgs(args, previewParts, record.wapkRun);
934
+
935
+ return {
936
+ command: cliInvocation.command,
937
+ args,
938
+ preview: previewParts.join(' '),
939
+ runtime: record.runtime,
940
+ };
941
+ }
942
+
943
+ const runtime = record.runtime ?? inferRuntimeFromFile(record.file!);
944
+
945
+ if (runtime === 'bun') {
946
+ const executable = preferCurrentExecutable('bun');
947
+ ensureCommandAvailable(executable, 'Bun runtime');
948
+ return {
949
+ command: executable,
950
+ args: ['run', record.file!],
951
+ runtime,
952
+ preview: `${basename(executable)} run ${quoteCommandSegment(record.file!)}`,
953
+ };
954
+ }
955
+
956
+ if (runtime === 'deno') {
957
+ const executable = preferCurrentExecutable('deno');
958
+ ensureCommandAvailable(executable, 'Deno runtime');
959
+ return {
960
+ command: executable,
961
+ args: ['run', '--allow-all', record.file!],
962
+ runtime,
963
+ preview: `${basename(executable)} run --allow-all ${quoteCommandSegment(record.file!)}`,
964
+ };
965
+ }
966
+
967
+ if (isTypescriptFile(record.file!)) {
968
+ const tsxExecutable = resolveTsxExecutable(record.cwd);
969
+ if (!tsxExecutable) {
970
+ throw new Error('TypeScript file execution with runtime "node" requires tsx to be installed, or use --runtime bun.');
971
+ }
972
+
973
+ return {
974
+ command: tsxExecutable,
975
+ args: [record.file!],
976
+ runtime,
977
+ preview: `${basename(tsxExecutable)} ${quoteCommandSegment(record.file!)}`,
978
+ };
979
+ }
980
+
981
+ const executable = preferCurrentExecutable('node');
982
+ ensureCommandAvailable(executable, 'Node.js runtime');
983
+ return {
984
+ command: executable,
985
+ args: [record.file!],
986
+ runtime,
987
+ preview: `${basename(executable)} ${quoteCommandSegment(record.file!)}`,
988
+ };
989
+ }
990
+
991
+ function createRecordFromDefinition(definition: ResolvedPmAppDefinition, paths: PmPaths, existing?: PmRecord): PmRecord {
992
+ const id = sanitizePmProcessName(definition.name);
993
+ const now = new Date().toISOString();
994
+
995
+ const preview = definition.type === 'script'
996
+ ? definition.script!
997
+ : definition.type === 'wapk'
998
+ ? buildPmWapkPreview(definition.wapk!, definition.runtime, definition.password, definition.wapkRun)
999
+ : `${definition.runtime ?? 'auto'} ${quoteCommandSegment(definition.file!)}`;
1000
+
1001
+ return {
1002
+ id,
1003
+ name: definition.name,
1004
+ type: definition.type,
1005
+ source: definition.source,
1006
+ cwd: definition.cwd,
1007
+ runtime: definition.runtime,
1008
+ env: definition.env,
1009
+ script: definition.script,
1010
+ file: definition.file,
1011
+ wapk: definition.wapk,
1012
+ wapkRun: definition.wapkRun,
1013
+ autorestart: definition.autorestart,
1014
+ restartDelay: definition.restartDelay,
1015
+ maxRestarts: definition.maxRestarts,
1016
+ password: definition.password,
1017
+ restartPolicy: definition.restartPolicy,
1018
+ minUptime: definition.minUptime,
1019
+ watch: definition.watch,
1020
+ watchPaths: definition.watchPaths,
1021
+ watchIgnore: definition.watchIgnore,
1022
+ watchDebounce: definition.watchDebounce,
1023
+ healthCheck: definition.healthCheck,
1024
+ desiredState: 'running',
1025
+ status: 'starting',
1026
+ commandPreview: preview,
1027
+ createdAt: existing?.createdAt ?? now,
1028
+ updatedAt: now,
1029
+ startedAt: undefined,
1030
+ stoppedAt: undefined,
1031
+ runnerPid: undefined,
1032
+ childPid: undefined,
1033
+ restartCount: existing?.restartCount ?? 0,
1034
+ lastExitCode: existing?.lastExitCode,
1035
+ error: undefined,
1036
+ logFiles: existing?.logFiles ?? {
1037
+ out: join(paths.logsDir, `${id}.out.log`),
1038
+ err: join(paths.logsDir, `${id}.err.log`),
1039
+ },
1040
+ };
1041
+ }
1042
+
1043
+ function terminateProcessTree(pid: number): void {
1044
+ if (process.platform === 'win32') {
1045
+ const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
1046
+ stdio: 'ignore',
1047
+ windowsHide: true,
1048
+ });
1049
+
1050
+ if (result.error && (result.error as NodeJS.ErrnoException).code !== 'ENOENT') {
1051
+ throw result.error;
1052
+ }
1053
+
1054
+ return;
1055
+ }
1056
+
1057
+ try {
1058
+ process.kill(pid, 'SIGTERM');
1059
+ } catch (error) {
1060
+ const code = (error as NodeJS.ErrnoException).code;
1061
+ if (code !== 'ESRCH') {
1062
+ throw error;
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ async function startManagedProcess(definition: ResolvedPmAppDefinition, paths: PmPaths): Promise<PmRecord> {
1068
+ ensurePmDirectories(paths);
1069
+
1070
+ const id = sanitizePmProcessName(definition.name);
1071
+ const recordPath = getPmRecordPath(paths, id);
1072
+ const existingMatch = existsSync(recordPath)
1073
+ ? syncPmRecordLiveness({ filePath: recordPath, record: readPmRecord(recordPath) })
1074
+ : undefined;
1075
+
1076
+ if (existingMatch?.record.runnerPid && isProcessAlive(existingMatch.record.runnerPid)) {
1077
+ throw new Error(`Process "${definition.name}" is already running.`);
1078
+ }
1079
+
1080
+ const record = createRecordFromDefinition(definition, paths, existingMatch?.record);
1081
+ writePmRecord(recordPath, record);
1082
+
1083
+ const cliInvocation = readCurrentCliInvocation();
1084
+ const runner = spawn(cliInvocation.command, [
1085
+ ...cliInvocation.args,
1086
+ 'pm',
1087
+ '__run',
1088
+ '--data-dir',
1089
+ paths.dataDir,
1090
+ '--id',
1091
+ record.id,
1092
+ ], {
1093
+ cwd: definition.cwd,
1094
+ detached: true,
1095
+ stdio: 'ignore',
1096
+ windowsHide: true,
1097
+ env: {
1098
+ ...process.env,
1099
+ ELIT_PM_INTERNAL: '1',
1100
+ },
1101
+ });
1102
+
1103
+ if (!runner.pid) {
1104
+ throw new Error(`Failed to start process runner for "${definition.name}".`);
1105
+ }
1106
+
1107
+ runner.unref();
1108
+
1109
+ const startedRecord: PmRecord = {
1110
+ ...record,
1111
+ runnerPid: runner.pid,
1112
+ updatedAt: new Date().toISOString(),
1113
+ };
1114
+ writePmRecord(recordPath, startedRecord);
1115
+ return startedRecord;
1116
+ }
1117
+
1118
+ function readLatestPmRecord(filePath: string, fallback: PmRecord): PmRecord {
1119
+ return existsSync(filePath) ? readPmRecord(filePath) : fallback;
1120
+ }
1121
+
1122
+ function writePmLog(stream: { write: (value: string) => unknown }, message: string): void {
1123
+ stream.write(`[elit pm] ${new Date().toISOString()} ${message}${EOL}`);
1124
+ }
1125
+
1126
+ function waitForExit(code: number | null, signal: string | null): number {
1127
+ if (typeof code === 'number') {
1128
+ return code;
1129
+ }
1130
+
1131
+ if (signal === 'SIGINT' || signal === 'SIGTERM') {
1132
+ return 0;
1133
+ }
1134
+
1135
+ return 1;
1136
+ }
1137
+
1138
+ async function delay(milliseconds: number): Promise<void> {
1139
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
1140
+ }
1141
+
1142
+ async function waitForManagedChildExit(child: ReturnType<typeof spawn>) {
1143
+ return await new Promise((resolvePromise) => {
1144
+ let resolved = false;
1145
+
1146
+ child.once('error', (error) => {
1147
+ if (resolved) {
1148
+ return;
1149
+ }
1150
+ resolved = true;
1151
+ resolvePromise({ code: 1, signal: null, error: error instanceof Error ? error.message : String(error) });
1152
+ });
1153
+
1154
+ child.once('close', (code, signal) => {
1155
+ if (resolved) {
1156
+ return;
1157
+ }
1158
+ resolved = true;
1159
+ resolvePromise({ code, signal });
1160
+ });
1161
+ });
1162
+ }
1163
+
1164
+ async function createPmWatchController(
1165
+ record: PmRecord,
1166
+ onChange: (filePath: string) => void,
1167
+ onError: (message: string) => void,
1168
+ ) {
1169
+ if (!record.watch || record.watchPaths.length === 0) {
1170
+ return {
1171
+ async close() {},
1172
+ };
1173
+ }
1174
+
1175
+ const watcher = createWatcher(record.watchPaths);
1176
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
1177
+
1178
+ const scheduleRestart = (filePath: string): void => {
1179
+ const normalizedPath = filePath.replace(/\\/g, '/');
1180
+ if (isIgnoredWatchPath(normalizedPath, record.watchIgnore)) {
1181
+ return;
1182
+ }
1183
+
1184
+ if (debounceTimer) {
1185
+ clearTimeout(debounceTimer);
1186
+ }
1187
+
1188
+ debounceTimer = setTimeout(() => {
1189
+ debounceTimer = null;
1190
+ onChange(normalizedPath);
1191
+ }, record.watchDebounce);
1192
+ debounceTimer.unref?.();
1193
+ };
1194
+
1195
+ watcher.on('add', scheduleRestart);
1196
+ watcher.on('change', scheduleRestart);
1197
+ watcher.on('unlink', scheduleRestart);
1198
+ watcher.on('error', (error) => onError(error instanceof Error ? error.message : String(error)));
1199
+
1200
+ return {
1201
+ async close() {
1202
+ if (debounceTimer) {
1203
+ clearTimeout(debounceTimer);
1204
+ debounceTimer = null;
1205
+ }
1206
+ await watcher.close();
1207
+ },
1208
+ };
1209
+ }
1210
+
1211
+ function createPmHealthMonitor(
1212
+ record: PmRecord,
1213
+ onFailure: (message: string) => void,
1214
+ onLog: (message: string) => void,
1215
+ ) {
1216
+ if (!record.healthCheck) {
1217
+ return {
1218
+ stop() {},
1219
+ };
1220
+ }
1221
+
1222
+ const healthCheck = record.healthCheck;
1223
+ let stopped = false;
1224
+ let timer: ReturnType<typeof setInterval> | null = null;
1225
+ let initialDelay: ReturnType<typeof setTimeout> | null = null;
1226
+ let inFlight = false;
1227
+ let failureCount = 0;
1228
+
1229
+ const runHealthCheck = async (): Promise<void> => {
1230
+ if (stopped || inFlight) {
1231
+ return;
1232
+ }
1233
+
1234
+ inFlight = true;
1235
+ const controller = new AbortController();
1236
+ const timeoutId = setTimeout(() => controller.abort(), healthCheck.timeout);
1237
+ timeoutId.unref?.();
1238
+
1239
+ try {
1240
+ const response = await fetch(healthCheck.url, {
1241
+ method: 'GET',
1242
+ signal: controller.signal,
1243
+ });
1244
+
1245
+ if (!response.ok) {
1246
+ throw new Error(`health check returned ${response.status}`);
1247
+ }
1248
+
1249
+ failureCount = 0;
1250
+ } catch (error) {
1251
+ failureCount += 1;
1252
+ const message = error instanceof Error ? error.message : String(error);
1253
+ onLog(`health check failed (${failureCount}/${healthCheck.maxFailures}): ${message}`);
1254
+ if (failureCount >= healthCheck.maxFailures) {
1255
+ stopped = true;
1256
+ onFailure(`health check failed ${failureCount} times: ${message}`);
1257
+ }
1258
+ } finally {
1259
+ clearTimeout(timeoutId);
1260
+ inFlight = false;
1261
+ }
1262
+ };
1263
+
1264
+ initialDelay = setTimeout(() => {
1265
+ void runHealthCheck();
1266
+ timer = setInterval(() => {
1267
+ void runHealthCheck();
1268
+ }, healthCheck.interval);
1269
+ timer.unref?.();
1270
+ }, healthCheck.gracePeriod);
1271
+ initialDelay.unref?.();
1272
+
1273
+ return {
1274
+ stop() {
1275
+ stopped = true;
1276
+ if (initialDelay) {
1277
+ clearTimeout(initialDelay);
1278
+ initialDelay = null;
1279
+ }
1280
+ if (timer) {
1281
+ clearInterval(timer);
1282
+ timer = null;
1283
+ }
1284
+ },
1285
+ };
1286
+ }
1287
+
1288
+ function readPlannedRestartRequest(state: { request: { kind: 'watch' | 'health'; detail: string } | null }) {
1289
+ return state.request;
1290
+ }
1291
+
1292
+ async function runManagedProcessLoop(filePath: string, initialRecord: PmRecord): Promise<void> {
1293
+ let record = initialRecord;
1294
+ let activeChild: ReturnType<typeof spawn> | null = null;
1295
+ let stopRequested = false;
1296
+ const restartState: { request: { kind: 'watch' | 'health'; detail: string } | null } = { request: null };
1297
+
1298
+ mkdirSync(dirname(initialRecord.logFiles.out), { recursive: true });
1299
+ mkdirSync(dirname(initialRecord.logFiles.err), { recursive: true });
1300
+
1301
+ const stdoutLog = createWriteStream(initialRecord.logFiles.out, { flags: 'a' });
1302
+ const stderrLog = createWriteStream(initialRecord.logFiles.err, { flags: 'a' });
1303
+
1304
+ const persist = (mutator: (current: PmRecord) => PmRecord): PmRecord => {
1305
+ const current = readLatestPmRecord(filePath, record);
1306
+ record = mutator(current);
1307
+ writePmRecord(filePath, record);
1308
+ return record;
1309
+ };
1310
+
1311
+ const stopActiveChild = (): void => {
1312
+ if (activeChild?.pid && isProcessAlive(activeChild.pid)) {
1313
+ terminateProcessTree(activeChild.pid);
1314
+ }
1315
+ };
1316
+
1317
+ const handleStopSignal = (signal: string) => {
1318
+ stopRequested = true;
1319
+ persist((current) => ({
1320
+ ...current,
1321
+ desiredState: 'stopped',
1322
+ status: 'stopping',
1323
+ updatedAt: new Date().toISOString(),
1324
+ }));
1325
+ writePmLog(stdoutLog, `received ${signal}, stopping managed process`);
1326
+ stopActiveChild();
1327
+ };
1328
+
1329
+ process.on('SIGINT', handleStopSignal);
1330
+ process.on('SIGTERM', handleStopSignal);
1331
+
1332
+ persist((current) => ({
1333
+ ...current,
1334
+ runnerPid: process.pid,
1335
+ desiredState: 'running',
1336
+ status: 'starting',
1337
+ updatedAt: new Date().toISOString(),
1338
+ }));
1339
+
1340
+ try {
1341
+ while (!stopRequested) {
1342
+ restartState.request = null;
1343
+
1344
+ const latest = readLatestPmRecord(filePath, record);
1345
+ if (latest.desiredState === 'stopped') {
1346
+ break;
1347
+ }
1348
+
1349
+ let command: BuiltPmCommand;
1350
+ try {
1351
+ command = buildPmCommand(latest);
1352
+ } catch (error) {
1353
+ const message = error instanceof Error ? error.message : String(error);
1354
+ writePmLog(stderrLog, message);
1355
+ persist((current) => ({
1356
+ ...current,
1357
+ status: 'errored',
1358
+ error: message,
1359
+ runnerPid: undefined,
1360
+ childPid: undefined,
1361
+ updatedAt: new Date().toISOString(),
1362
+ }));
1363
+ return;
1364
+ }
1365
+
1366
+ const child = spawn(command.command, command.args, {
1367
+ cwd: latest.cwd,
1368
+ env: {
1369
+ ...process.env,
1370
+ ...latest.env,
1371
+ ELIT_PM_NAME: latest.name,
1372
+ ELIT_PM_ID: latest.id,
1373
+ },
1374
+ stdio: ['ignore', 'pipe', 'pipe'],
1375
+ windowsHide: true,
1376
+ shell: command.shell,
1377
+ });
1378
+ const childStartedAt = Date.now();
1379
+
1380
+ activeChild = child;
1381
+ if (child.stdout) {
1382
+ child.stdout.pipe(stdoutLog, { end: false });
1383
+ }
1384
+ if (child.stderr) {
1385
+ child.stderr.pipe(stderrLog, { end: false });
1386
+ }
1387
+
1388
+ const startedAt = new Date().toISOString();
1389
+ persist((current) => ({
1390
+ ...current,
1391
+ status: 'online',
1392
+ commandPreview: command.preview,
1393
+ runtime: command.runtime ?? current.runtime,
1394
+ runnerPid: process.pid,
1395
+ childPid: child.pid,
1396
+ startedAt,
1397
+ stoppedAt: undefined,
1398
+ error: undefined,
1399
+ updatedAt: startedAt,
1400
+ }));
1401
+ writePmLog(stdoutLog, `started ${command.preview}${child.pid ? ` (pid ${child.pid})` : ''}`);
1402
+
1403
+ const requestPlannedRestart = (kind: 'watch' | 'health', detail: string): void => {
1404
+ if (stopRequested || restartState.request) {
1405
+ return;
1406
+ }
1407
+
1408
+ restartState.request = { kind, detail };
1409
+ writePmLog(kind === 'health' ? stderrLog : stdoutLog, `${kind} restart requested: ${detail}`);
1410
+ persist((current) => ({
1411
+ ...current,
1412
+ status: 'restarting',
1413
+ updatedAt: new Date().toISOString(),
1414
+ }));
1415
+ stopActiveChild();
1416
+ };
1417
+
1418
+ const watchController = await createPmWatchController(
1419
+ latest,
1420
+ (changedPath) => requestPlannedRestart('watch', changedPath),
1421
+ (message) => writePmLog(stderrLog, `watch error: ${message}`),
1422
+ );
1423
+ const healthMonitor = createPmHealthMonitor(
1424
+ latest,
1425
+ (message) => requestPlannedRestart('health', message),
1426
+ (message) => writePmLog(stdoutLog, message),
1427
+ );
1428
+
1429
+ const exitResult: any = await waitForManagedChildExit(child);
1430
+ await watchController.close();
1431
+ healthMonitor.stop();
1432
+
1433
+ activeChild = null;
1434
+ const exitCode = waitForExit(exitResult.code, exitResult.signal);
1435
+ const current = readLatestPmRecord(filePath, record);
1436
+ const plannedRestart = readPlannedRestartRequest(restartState);
1437
+ const uptime = Math.max(0, Date.now() - childStartedAt);
1438
+ const wasStable = current.minUptime > 0 && uptime >= current.minUptime;
1439
+
1440
+ if (exitResult.error) {
1441
+ writePmLog(stderrLog, exitResult.error);
1442
+ } else if (!plannedRestart) {
1443
+ writePmLog(stdoutLog, `process exited with code ${exitCode}`);
1444
+ }
1445
+
1446
+ if (stopRequested || current.desiredState === 'stopped') {
1447
+ break;
1448
+ }
1449
+
1450
+ const shouldRestartForExit = plannedRestart
1451
+ ? true
1452
+ : current.restartPolicy === 'always'
1453
+ ? true
1454
+ : current.restartPolicy === 'on-failure'
1455
+ ? exitCode !== 0 || Boolean(exitResult.error)
1456
+ : false;
1457
+
1458
+ if (!shouldRestartForExit) {
1459
+ persist((latestRecord) => ({
1460
+ ...latestRecord,
1461
+ status: exitCode === 0 && !exitResult.error ? 'exited' : 'errored',
1462
+ childPid: undefined,
1463
+ runnerPid: undefined,
1464
+ lastExitCode: exitCode,
1465
+ error: exitCode === 0 && !exitResult.error ? undefined : exitResult.error ?? `Process exited with code ${exitCode}.`,
1466
+ stoppedAt: new Date().toISOString(),
1467
+ updatedAt: new Date().toISOString(),
1468
+ }));
1469
+ return;
1470
+ }
1471
+
1472
+ const shouldCountRestart = plannedRestart?.kind !== 'watch';
1473
+ const baseRestartCount = wasStable ? 0 : (current.restartCount ?? 0);
1474
+ const nextRestartCount = shouldCountRestart ? baseRestartCount + 1 : current.restartCount ?? 0;
1475
+ if (nextRestartCount > current.maxRestarts) {
1476
+ persist((latestRecord) => ({
1477
+ ...latestRecord,
1478
+ status: 'errored',
1479
+ childPid: undefined,
1480
+ runnerPid: undefined,
1481
+ restartCount: nextRestartCount,
1482
+ lastExitCode: exitCode,
1483
+ error: plannedRestart
1484
+ ? `Reached max restart attempts (${current.maxRestarts}) after ${plannedRestart.kind} restart requests.`
1485
+ : `Reached max restart attempts (${current.maxRestarts}).`,
1486
+ stoppedAt: new Date().toISOString(),
1487
+ updatedAt: new Date().toISOString(),
1488
+ }));
1489
+ writePmLog(stderrLog, `max restart attempts reached (${current.maxRestarts})`);
1490
+ return;
1491
+ }
1492
+
1493
+ persist((latestRecord) => ({
1494
+ ...latestRecord,
1495
+ status: 'restarting',
1496
+ childPid: undefined,
1497
+ lastExitCode: exitCode,
1498
+ restartCount: nextRestartCount,
1499
+ error: undefined,
1500
+ updatedAt: new Date().toISOString(),
1501
+ }));
1502
+ if (plannedRestart) {
1503
+ writePmLog(
1504
+ plannedRestart.kind === 'health' ? stderrLog : stdoutLog,
1505
+ `restarting in ${current.restartDelay}ms after ${plannedRestart.kind}: ${plannedRestart.detail}`,
1506
+ );
1507
+ } else {
1508
+ writePmLog(stdoutLog, `restarting in ${current.restartDelay}ms`);
1509
+ }
1510
+ await delay(current.restartDelay);
1511
+ }
1512
+ } finally {
1513
+ stopRequested = true;
1514
+ stopActiveChild();
1515
+
1516
+ const finalRecord = readLatestPmRecord(filePath, record);
1517
+ writePmRecord(filePath, {
1518
+ ...finalRecord,
1519
+ desiredState: 'stopped',
1520
+ status:
1521
+ finalRecord.status === 'errored'
1522
+ ? 'errored'
1523
+ : finalRecord.status === 'exited'
1524
+ ? 'exited'
1525
+ : 'stopped',
1526
+ runnerPid: undefined,
1527
+ childPid: undefined,
1528
+ stoppedAt: new Date().toISOString(),
1529
+ updatedAt: new Date().toISOString(),
1530
+ });
1531
+
1532
+ process.off('SIGINT', handleStopSignal);
1533
+ process.off('SIGTERM', handleStopSignal);
1534
+
1535
+ await new Promise<void>((resolvePromise) => stdoutLog.end(resolvePromise));
1536
+ await new Promise<void>((resolvePromise) => stderrLog.end(resolvePromise));
1537
+ }
1538
+ }
1539
+
1540
+ function resolveStartSelection(configApps: PmAppConfig[], parsed: ParsedPmStartArgs, workspaceRoot: string) {
1541
+ const target = parsePmTarget(parsed, workspaceRoot);
1542
+ const hasExplicitWapkSource = Boolean(resolvePmWapkSourceToken(parsed.wapk, parsed.wapkRun));
1543
+ const selectedName = target.configName ?? (!target.script && !target.file && !target.wapk && !hasExplicitWapkSource ? parsed.name : undefined);
1544
+ const selected = selectedName
1545
+ ? configApps.find((app) => app.name === selectedName)
1546
+ : undefined;
1547
+
1548
+ return {
1549
+ selected,
1550
+ startAll: !target.script && !target.file && !target.wapk && !hasExplicitWapkSource && !selectedName,
1551
+ target,
1552
+ };
1553
+ }
1554
+
1555
+ function toPmAppConfig(record: PmRecord): PmAppConfig {
1556
+ return {
1557
+ name: record.name,
1558
+ script: record.script,
1559
+ file: record.file,
1560
+ wapk: record.wapk,
1561
+ wapkRun: record.wapkRun,
1562
+ runtime: record.runtime,
1563
+ cwd: record.cwd,
1564
+ env: record.env,
1565
+ autorestart: record.autorestart,
1566
+ restartDelay: record.restartDelay,
1567
+ maxRestarts: record.maxRestarts,
1568
+ password: record.password,
1569
+ restartPolicy: record.restartPolicy,
1570
+ minUptime: record.minUptime,
1571
+ watch: record.watch,
1572
+ watchPaths: record.watchPaths,
1573
+ watchIgnore: record.watchIgnore,
1574
+ watchDebounce: record.watchDebounce,
1575
+ healthCheck: record.healthCheck,
1576
+ };
1577
+ }
1578
+
1579
+ function toSavedPmAppConfig(app: PmSavedAppDefinition): PmAppConfig {
1580
+ return {
1581
+ name: app.name,
1582
+ script: app.script,
1583
+ file: app.file,
1584
+ wapk: app.wapk,
1585
+ wapkRun: app.wapkRun,
1586
+ runtime: app.runtime,
1587
+ cwd: app.cwd,
1588
+ env: app.env,
1589
+ autorestart: app.autorestart,
1590
+ restartDelay: app.restartDelay,
1591
+ maxRestarts: app.maxRestarts,
1592
+ password: app.password,
1593
+ restartPolicy: app.restartPolicy,
1594
+ minUptime: app.minUptime,
1595
+ watch: app.watch,
1596
+ watchPaths: app.watchPaths,
1597
+ watchIgnore: app.watchIgnore,
1598
+ watchDebounce: app.watchDebounce,
1599
+ healthCheck: app.healthCheck,
1600
+ };
1601
+ }
1602
+
1603
+ function resolvePmAppDefinition(base: PmAppConfig | undefined, parsed: ParsedPmStartArgs, workspaceRoot: string, source: 'cli' | 'config'): ResolvedPmAppDefinition {
1604
+ const target = parsePmTarget(parsed, workspaceRoot);
1605
+ const resolvedCwd = resolve(workspaceRoot, parsed.cwd ?? base?.cwd ?? '.');
1606
+
1607
+ if (countDefinedPmWapkSources(parsed.wapk, parsed.wapkRun) > 1) {
1608
+ throw new Error('Use only one WAPK archive source per pm start: --wapk or --google-drive-file-id.');
1609
+ }
1610
+
1611
+ if (countDefinedPmWapkSources(base?.wapk, base?.wapkRun) > 1) {
1612
+ throw new Error(`Configured pm app "${base?.name ?? parsed.name ?? 'app'}" must define only one WAPK archive source.`);
1613
+ }
1614
+
1615
+ const explicitWapk = resolvePmWapkSource(resolvePmWapkSourceToken(target.wapk, parsed.wapkRun), resolvedCwd);
1616
+ const baseWapk = resolvePmWapkSource(resolvePmWapkSourceToken(base?.wapk, base?.wapkRun), resolvedCwd);
1617
+ const hasExplicitTarget = Boolean(target.script || target.file || explicitWapk);
1618
+ const script = target.script ?? (hasExplicitTarget ? undefined : base?.script);
1619
+ const file = target.file
1620
+ ? resolve(resolvedCwd, target.file)
1621
+ : hasExplicitTarget
1622
+ ? undefined
1623
+ : base?.file
1624
+ ? resolve(resolvedCwd, base.file)
1625
+ : undefined;
1626
+ const wapk = explicitWapk ?? (hasExplicitTarget ? undefined : baseWapk);
1627
+
1628
+ const targetCount = countDefinedTargets({ script, file, wapk });
1629
+ if (targetCount === 0) {
1630
+ throw new Error('pm start requires one target: --script, --file, --wapk, or a configured app name.');
1631
+ }
1632
+ if (targetCount > 1) {
1633
+ throw new Error('A pm app must define exactly one of script, file, or wapk.');
1634
+ }
1635
+
1636
+ const name = defaultProcessName({ script, file, wapk }, parsed.name ?? base?.name);
1637
+ const mergedWapkRun = mergePmWapkRunConfig(base?.wapkRun, parsed.wapkRun);
1638
+ const runtime = normalizePmRuntime(parsed.runtime ?? mergedWapkRun?.runtime ?? base?.runtime, '--runtime');
1639
+
1640
+ let restartPolicy = normalizePmRestartPolicy(parsed.restartPolicy ?? base?.restartPolicy, '--restart-policy')
1641
+ ?? ((base?.autorestart ?? true) ? 'always' : 'never');
1642
+
1643
+ if (parsed.autorestart === false) {
1644
+ restartPolicy = 'never';
1645
+ }
1646
+
1647
+ const autorestart = restartPolicy !== 'never';
1648
+ const watch = parsed.watch ?? base?.watch ?? false;
1649
+ const configuredWatchPaths = parsed.watchPaths.length > 0 ? parsed.watchPaths : normalizeStringArray(base?.watchPaths);
1650
+ const configuredWatchIgnore = [
1651
+ ...DEFAULT_WATCH_IGNORE,
1652
+ ...normalizeStringArray(base?.watchIgnore),
1653
+ ...parsed.watchIgnore,
1654
+ ];
1655
+ const healthCheck = normalizeHealthCheckConfig(parsed.healthCheckUrl
1656
+ ? {
1657
+ url: parsed.healthCheckUrl,
1658
+ gracePeriod: parsed.healthCheckGracePeriod,
1659
+ interval: parsed.healthCheckInterval,
1660
+ timeout: parsed.healthCheckTimeout,
1661
+ maxFailures: parsed.healthCheckMaxFailures,
1662
+ }
1663
+ : base?.healthCheck);
1664
+
1665
+ const password = parsed.password ?? mergedWapkRun?.password ?? base?.password;
1666
+ const wapkRun = stripPmWapkSourceFromRunConfig(mergedWapkRun);
1667
+
1668
+ if (password && !wapk) {
1669
+ throw new Error('--password is only supported when starting a WAPK app.');
1670
+ }
1671
+
1672
+ if (wapkRun && !wapk) {
1673
+ throw new Error('WAPK run options are only supported when starting a WAPK app.');
1674
+ }
1675
+
1676
+ return {
1677
+ name,
1678
+ type: script ? 'script' : wapk ? 'wapk' : 'file',
1679
+ source,
1680
+ cwd: resolvedCwd,
1681
+ runtime,
1682
+ env: {
1683
+ ...normalizeEnvMap(base?.env),
1684
+ ...parsed.env,
1685
+ },
1686
+ script,
1687
+ file,
1688
+ wapk,
1689
+ wapkRun,
1690
+ autorestart,
1691
+ restartDelay: parsed.restartDelay ?? base?.restartDelay ?? DEFAULT_RESTART_DELAY,
1692
+ maxRestarts: parsed.maxRestarts ?? base?.maxRestarts ?? DEFAULT_MAX_RESTARTS,
1693
+ password,
1694
+ restartPolicy,
1695
+ minUptime: parsed.minUptime ?? base?.minUptime ?? DEFAULT_MIN_UPTIME,
1696
+ watch,
1697
+ watchPaths: watch ? normalizeResolvedWatchPaths(configuredWatchPaths, resolvedCwd, script ? 'script' : wapk ? 'wapk' : 'file', file, wapk) : [],
1698
+ watchIgnore: watch ? normalizeWatchIgnorePatterns(configuredWatchIgnore, resolvedCwd) : [],
1699
+ watchDebounce: parsed.watchDebounce ?? base?.watchDebounce ?? DEFAULT_WATCH_DEBOUNCE,
1700
+ healthCheck,
1701
+ };
1702
+ }
1703
+
1704
+ export function resolvePmStartDefinitions(parsed: ParsedPmStartArgs, config: ElitConfig | null, workspaceRoot: string): ResolvedPmAppDefinition[] {
1705
+ const configApps = getConfiguredPmApps(config);
1706
+ const selection = resolveStartSelection(configApps, parsed, workspaceRoot);
1707
+
1708
+ if (selection.startAll) {
1709
+ if (configApps.length === 0) {
1710
+ throw new Error('No pm apps configured in elit.config.* and no start target was provided.');
1711
+ }
1712
+
1713
+ return configApps.map((app) => resolvePmAppDefinition(app, { ...parsed, name: app.name }, workspaceRoot, 'config'));
1714
+ }
1715
+
1716
+ if (selection.selected) {
1717
+ return [resolvePmAppDefinition(selection.selected, parsed, workspaceRoot, 'config')];
1718
+ }
1719
+
1720
+ return [resolvePmAppDefinition(undefined, parsed, workspaceRoot, 'cli')];
1721
+ }
1722
+
1723
+ export function parsePmStartArgs(args: string[]): ParsedPmStartArgs {
1724
+ const parsed: ParsedPmStartArgs = {
1725
+ env: {},
1726
+ watchPaths: [],
1727
+ watchIgnore: [],
1728
+ };
1729
+
1730
+ for (let index = 0; index < args.length; index++) {
1731
+ const arg = args[index];
1732
+
1733
+ switch (arg) {
1734
+ case '--script':
1735
+ parsed.script = readRequiredValue(args, ++index, '--script');
1736
+ break;
1737
+ case '--file':
1738
+ case '-f':
1739
+ parsed.file = readRequiredValue(args, ++index, arg);
1740
+ break;
1741
+ case '--wapk':
1742
+ parsed.wapk = readRequiredValue(args, ++index, '--wapk');
1743
+ break;
1744
+ case '--google-drive-file-id':
1745
+ parsed.wapkRun = {
1746
+ ...parsed.wapkRun,
1747
+ googleDrive: {
1748
+ ...parsed.wapkRun?.googleDrive,
1749
+ fileId: readRequiredValue(args, ++index, '--google-drive-file-id'),
1750
+ },
1751
+ };
1752
+ break;
1753
+ case '--google-drive-token-env':
1754
+ parsed.wapkRun = {
1755
+ ...parsed.wapkRun,
1756
+ googleDrive: {
1757
+ ...parsed.wapkRun?.googleDrive,
1758
+ accessTokenEnv: readRequiredValue(args, ++index, '--google-drive-token-env'),
1759
+ },
1760
+ };
1761
+ break;
1762
+ case '--google-drive-access-token':
1763
+ parsed.wapkRun = {
1764
+ ...parsed.wapkRun,
1765
+ googleDrive: {
1766
+ ...parsed.wapkRun?.googleDrive,
1767
+ accessToken: readRequiredValue(args, ++index, '--google-drive-access-token'),
1768
+ },
1769
+ };
1770
+ break;
1771
+ case '--google-drive-shared-drive':
1772
+ parsed.wapkRun = {
1773
+ ...parsed.wapkRun,
1774
+ googleDrive: {
1775
+ ...parsed.wapkRun?.googleDrive,
1776
+ supportsAllDrives: true,
1777
+ },
1778
+ };
1779
+ break;
1780
+ case '--runtime':
1781
+ case '-r':
1782
+ parsed.runtime = normalizePmRuntime(readRequiredValue(args, ++index, arg), arg);
1783
+ break;
1784
+ case '--name':
1785
+ case '-n':
1786
+ parsed.name = readRequiredValue(args, ++index, arg);
1787
+ break;
1788
+ case '--cwd':
1789
+ parsed.cwd = readRequiredValue(args, ++index, '--cwd');
1790
+ break;
1791
+ case '--env': {
1792
+ const [key, value] = parsePmEnvEntry(readRequiredValue(args, ++index, '--env'));
1793
+ parsed.env[key] = value;
1794
+ break;
1795
+ }
1796
+ case '--password':
1797
+ parsed.password = readRequiredValue(args, ++index, '--password');
1798
+ break;
1799
+ case '--sync-interval':
1800
+ parsed.wapkRun = {
1801
+ ...parsed.wapkRun,
1802
+ syncInterval: normalizeIntegerOption(readRequiredValue(args, ++index, '--sync-interval'), '--sync-interval', 50),
1803
+ };
1804
+ break;
1805
+ case '--watcher':
1806
+ case '--use-watcher':
1807
+ parsed.wapkRun = {
1808
+ ...parsed.wapkRun,
1809
+ useWatcher: true,
1810
+ };
1811
+ break;
1812
+ case '--archive-watch':
1813
+ parsed.wapkRun = {
1814
+ ...parsed.wapkRun,
1815
+ watchArchive: true,
1816
+ };
1817
+ break;
1818
+ case '--no-archive-watch':
1819
+ parsed.wapkRun = {
1820
+ ...parsed.wapkRun,
1821
+ watchArchive: false,
1822
+ };
1823
+ break;
1824
+ case '--archive-sync-interval':
1825
+ parsed.wapkRun = {
1826
+ ...parsed.wapkRun,
1827
+ archiveSyncInterval: normalizeIntegerOption(readRequiredValue(args, ++index, '--archive-sync-interval'), '--archive-sync-interval', 50),
1828
+ };
1829
+ break;
1830
+ case '--restart-policy':
1831
+ parsed.restartPolicy = normalizePmRestartPolicy(readRequiredValue(args, ++index, '--restart-policy'));
1832
+ break;
1833
+ case '--min-uptime':
1834
+ parsed.minUptime = normalizeIntegerOption(readRequiredValue(args, ++index, '--min-uptime'), '--min-uptime');
1835
+ break;
1836
+ case '--watch':
1837
+ parsed.watch = true;
1838
+ break;
1839
+ case '--watch-path':
1840
+ parsed.watch = true;
1841
+ parsed.watchPaths.push(readRequiredValue(args, ++index, '--watch-path'));
1842
+ break;
1843
+ case '--watch-ignore':
1844
+ parsed.watch = true;
1845
+ parsed.watchIgnore.push(readRequiredValue(args, ++index, '--watch-ignore'));
1846
+ break;
1847
+ case '--watch-debounce':
1848
+ parsed.watch = true;
1849
+ parsed.watchDebounce = normalizeIntegerOption(readRequiredValue(args, ++index, '--watch-debounce'), '--watch-debounce');
1850
+ break;
1851
+ case '--health-url':
1852
+ parsed.healthCheckUrl = readRequiredValue(args, ++index, '--health-url');
1853
+ break;
1854
+ case '--health-grace-period':
1855
+ parsed.healthCheckGracePeriod = normalizeIntegerOption(readRequiredValue(args, ++index, '--health-grace-period'), '--health-grace-period');
1856
+ break;
1857
+ case '--health-interval':
1858
+ parsed.healthCheckInterval = normalizeIntegerOption(readRequiredValue(args, ++index, '--health-interval'), '--health-interval', 250);
1859
+ break;
1860
+ case '--health-timeout':
1861
+ parsed.healthCheckTimeout = normalizeIntegerOption(readRequiredValue(args, ++index, '--health-timeout'), '--health-timeout', 250);
1862
+ break;
1863
+ case '--health-max-failures':
1864
+ parsed.healthCheckMaxFailures = normalizeIntegerOption(readRequiredValue(args, ++index, '--health-max-failures'), '--health-max-failures', 1);
1865
+ break;
1866
+ case '--no-autorestart':
1867
+ parsed.autorestart = false;
1868
+ break;
1869
+ case '--restart-delay':
1870
+ parsed.restartDelay = normalizeIntegerOption(readRequiredValue(args, ++index, '--restart-delay'), '--restart-delay');
1871
+ break;
1872
+ case '--max-restarts':
1873
+ parsed.maxRestarts = normalizeIntegerOption(readRequiredValue(args, ++index, '--max-restarts'), '--max-restarts');
1874
+ break;
1875
+ default:
1876
+ if (arg.startsWith('-')) {
1877
+ throw new Error(`Unknown pm option: ${arg}`);
1878
+ }
1879
+
1880
+ if (parsed.targetToken) {
1881
+ throw new Error('pm start accepts at most one positional target.');
1882
+ }
1883
+
1884
+ parsed.targetToken = arg;
1885
+ break;
1886
+ }
1887
+ }
1888
+
1889
+ if (countDefinedPmWapkSources(parsed.wapk, parsed.wapkRun) > 1) {
1890
+ throw new Error('Use only one WAPK archive source per pm start: --wapk or --google-drive-file-id.');
1891
+ }
1892
+
1893
+ const explicitTargets = [parsed.script, parsed.file, resolvePmWapkSourceToken(parsed.wapk, parsed.wapkRun)].filter(Boolean);
1894
+ if (explicitTargets.length > 1) {
1895
+ throw new Error('Use only one target type per pm start: --script, --file, or --wapk.');
1896
+ }
1897
+
1898
+ if (parsed.healthCheckUrl && !/^https?:\/\//i.test(parsed.healthCheckUrl)) {
1899
+ throw new Error('--health-url must be an absolute http:// or https:// URL');
1900
+ }
1901
+
1902
+ return parsed;
1903
+ }
1904
+
1905
+ function padCell(value: string, width: number): string {
1906
+ return value.length >= width ? value : `${value}${' '.repeat(width - value.length)}`;
1907
+ }
1908
+
1909
+ function tailLogFile(filePath: string, lineCount: number): string {
1910
+ if (!existsSync(filePath)) {
1911
+ return '';
1912
+ }
1913
+
1914
+ const lines = readFileSync(filePath, 'utf8').split(/\r?\n/).filter((line) => line.length > 0);
1915
+ return lines.slice(-lineCount).join(EOL);
1916
+ }
1917
+
1918
+ function parseRunnerArgs(args: string[]): ParsedPmRunnerArgs {
1919
+ let dataDir: string | undefined;
1920
+ let id: string | undefined;
1921
+
1922
+ for (let index = 0; index < args.length; index++) {
1923
+ const arg = args[index];
1924
+ switch (arg) {
1925
+ case '--data-dir':
1926
+ dataDir = readRequiredValue(args, ++index, '--data-dir');
1927
+ break;
1928
+ case '--id':
1929
+ id = readRequiredValue(args, ++index, '--id');
1930
+ break;
1931
+ default:
1932
+ throw new Error(`Unknown internal pm runner option: ${arg}`);
1933
+ }
1934
+ }
1935
+
1936
+ if (!dataDir || !id) {
1937
+ throw new Error('Usage: elit pm __run --data-dir <dir> --id <name>');
1938
+ }
1939
+
1940
+ return {
1941
+ dataDir: resolve(dataDir),
1942
+ id,
1943
+ };
1944
+ }
1945
+
1946
+ async function runPmRunner(args: string[]): Promise<void> {
1947
+ const options = parseRunnerArgs(args);
1948
+ const paths: PmPaths = {
1949
+ dataDir: options.dataDir,
1950
+ appsDir: join(options.dataDir, 'apps'),
1951
+ logsDir: join(options.dataDir, 'logs'),
1952
+ dumpFile: join(options.dataDir, DEFAULT_PM_DUMP_FILE),
1953
+ };
1954
+ const match = findPmRecordMatch(paths, options.id);
1955
+ if (!match) {
1956
+ throw new Error(`PM record not found: ${options.id}`);
1957
+ }
1958
+
1959
+ await runManagedProcessLoop(match.filePath, match.record);
1960
+ }
1961
+
1962
+ async function runPmStart(args: string[]): Promise<void> {
1963
+ const parsed = parsePmStartArgs(args);
1964
+ const workspaceRoot = process.cwd();
1965
+ const config = await loadConfig(workspaceRoot);
1966
+ const paths = resolvePmPaths(config?.pm, workspaceRoot);
1967
+ const definitions = resolvePmStartDefinitions(parsed, config, workspaceRoot);
1968
+ const errors: string[] = [];
1969
+
1970
+ for (const definition of definitions) {
1971
+ try {
1972
+ const record = await startManagedProcess(definition, paths);
1973
+ console.log(`[pm] started ${record.name} (${record.type})`);
1974
+ } catch (error) {
1975
+ const message = error instanceof Error ? error.message : String(error);
1976
+ errors.push(`[pm] ${definition.name}: ${message}`);
1977
+ }
1978
+ }
1979
+
1980
+ if (errors.length > 0) {
1981
+ throw new Error(errors.join(EOL));
1982
+ }
1983
+ }
1984
+
1985
+ async function loadPmContext() {
1986
+ const workspaceRoot = process.cwd();
1987
+ const config = await loadConfig(workspaceRoot);
1988
+ return {
1989
+ config,
1990
+ paths: resolvePmPaths(config?.pm, workspaceRoot),
1991
+ };
1992
+ }
1993
+
1994
+ function resolveNamedMatches(paths: PmPaths, value: string): PmRecordMatch[] {
1995
+ if (value === 'all') {
1996
+ return listPmRecordMatches(paths).map(syncPmRecordLiveness);
1997
+ }
1998
+
1999
+ const match = findPmRecordMatch(paths, value);
2000
+ return match ? [syncPmRecordLiveness(match)] : [];
2001
+ }
2002
+
2003
+ function printPmList(paths: PmPaths): void {
2004
+ const matches = listPmRecordMatches(paths).map(syncPmRecordLiveness);
2005
+ if (matches.length === 0) {
2006
+ console.log('No managed processes found.');
2007
+ return;
2008
+ }
2009
+
2010
+ const headers = [
2011
+ padCell('name', 20),
2012
+ padCell('status', 12),
2013
+ padCell('pid', 8),
2014
+ padCell('restarts', 10),
2015
+ padCell('type', 8),
2016
+ 'runtime',
2017
+ ];
2018
+
2019
+ console.log(headers.join(' '));
2020
+ for (const { record } of matches) {
2021
+ console.log([
2022
+ padCell(record.name, 20),
2023
+ padCell(record.status, 12),
2024
+ padCell(record.childPid ? String(record.childPid) : '-', 8),
2025
+ padCell(String(record.restartCount ?? 0), 10),
2026
+ padCell(record.type, 8),
2027
+ record.runtime ?? '-',
2028
+ ].join(' '));
2029
+ }
2030
+ }
2031
+
2032
+ function stopPmMatches(matches: PmRecordMatch[]): number {
2033
+ let stopped = 0;
2034
+
2035
+ for (const match of matches) {
2036
+ const current = syncPmRecordLiveness(match);
2037
+ const updated: PmRecord = {
2038
+ ...current.record,
2039
+ desiredState: 'stopped',
2040
+ status: current.record.runnerPid ? 'stopping' : 'stopped',
2041
+ updatedAt: new Date().toISOString(),
2042
+ stoppedAt: new Date().toISOString(),
2043
+ };
2044
+ writePmRecord(current.filePath, updated);
2045
+
2046
+ if (current.record.runnerPid && isProcessAlive(current.record.runnerPid)) {
2047
+ terminateProcessTree(current.record.runnerPid);
2048
+ } else if (current.record.childPid && isProcessAlive(current.record.childPid)) {
2049
+ terminateProcessTree(current.record.childPid);
2050
+ }
2051
+
2052
+ writePmRecord(current.filePath, {
2053
+ ...updated,
2054
+ runnerPid: undefined,
2055
+ childPid: undefined,
2056
+ status: 'stopped',
2057
+ updatedAt: new Date().toISOString(),
2058
+ });
2059
+ stopped += 1;
2060
+ }
2061
+
2062
+ return stopped;
2063
+ }
2064
+
2065
+ async function runPmStop(args: string[]): Promise<void> {
2066
+ const target = args[0];
2067
+ if (!target) {
2068
+ throw new Error('Usage: elit pm stop <name|all>');
2069
+ }
2070
+
2071
+ const { paths } = await loadPmContext();
2072
+ const matches = resolveNamedMatches(paths, target);
2073
+ if (matches.length === 0) {
2074
+ throw new Error(`No managed process found for: ${target}`);
2075
+ }
2076
+
2077
+ const count = stopPmMatches(matches);
2078
+ console.log(`[pm] stopped ${count} process${count === 1 ? '' : 'es'}`);
2079
+ }
2080
+
2081
+ async function runPmRestart(args: string[]): Promise<void> {
2082
+ const target = args[0];
2083
+ if (!target) {
2084
+ throw new Error('Usage: elit pm restart <name|all>');
2085
+ }
2086
+
2087
+ const { paths } = await loadPmContext();
2088
+ const matches = resolveNamedMatches(paths, target);
2089
+ if (matches.length === 0) {
2090
+ throw new Error(`No managed process found for: ${target}`);
2091
+ }
2092
+
2093
+ stopPmMatches(matches);
2094
+
2095
+ const restarted: string[] = [];
2096
+ for (const match of matches) {
2097
+ const definition = resolvePmAppDefinition(
2098
+ toPmAppConfig(match.record),
2099
+ { name: match.record.name, env: {}, watchPaths: [], watchIgnore: [] },
2100
+ process.cwd(),
2101
+ match.record.source,
2102
+ );
2103
+
2104
+ await startManagedProcess(definition, paths);
2105
+ restarted.push(match.record.name);
2106
+ }
2107
+
2108
+ console.log(`[pm] restarted ${restarted.join(', ')}`);
2109
+ }
2110
+
2111
+ async function runPmSave(): Promise<void> {
2112
+ const { paths } = await loadPmContext();
2113
+ ensurePmDirectories(paths);
2114
+
2115
+ const runningApps = listPmRecordMatches(paths)
2116
+ .map(syncPmRecordLiveness)
2117
+ .filter((match) => match.record.desiredState === 'running' && (
2118
+ match.record.status === 'starting'
2119
+ || match.record.status === 'online'
2120
+ || match.record.status === 'restarting'
2121
+ ))
2122
+ .map((match) => toSavedAppDefinition(match.record));
2123
+
2124
+ writePmDumpFile(paths.dumpFile, runningApps);
2125
+ console.log(`[pm] saved ${runningApps.length} process${runningApps.length === 1 ? '' : 'es'} to ${paths.dumpFile}`);
2126
+ }
2127
+
2128
+ async function runPmResurrect(): Promise<void> {
2129
+ const { paths } = await loadPmContext();
2130
+ if (!existsSync(paths.dumpFile)) {
2131
+ throw new Error(`PM dump file not found: ${paths.dumpFile}`);
2132
+ }
2133
+
2134
+ const dump = readPmDumpFile(paths.dumpFile);
2135
+ if (dump.apps.length === 0) {
2136
+ console.log('[pm] dump file is empty, nothing to resurrect');
2137
+ return;
2138
+ }
2139
+
2140
+ const errors: string[] = [];
2141
+ let restored = 0;
2142
+ for (const app of dump.apps) {
2143
+ try {
2144
+ const definition = resolvePmAppDefinition(
2145
+ toSavedPmAppConfig(app),
2146
+ { name: app.name, env: {}, watchPaths: [], watchIgnore: [] },
2147
+ process.cwd(),
2148
+ 'cli',
2149
+ );
2150
+ await startManagedProcess(definition, paths);
2151
+ restored += 1;
2152
+ } catch (error) {
2153
+ const message = error instanceof Error ? error.message : String(error);
2154
+ errors.push(`[pm] ${app.name}: ${message}`);
2155
+ }
2156
+ }
2157
+
2158
+ if (errors.length > 0) {
2159
+ throw new Error([`[pm] resurrected ${restored} process${restored === 1 ? '' : 'es'}`, ...errors].join(EOL));
2160
+ }
2161
+
2162
+ console.log(`[pm] resurrected ${restored} process${restored === 1 ? '' : 'es'} from ${paths.dumpFile}`);
2163
+ }
2164
+
2165
+ async function runPmDelete(args: string[]): Promise<void> {
2166
+ const target = args[0];
2167
+ if (!target) {
2168
+ throw new Error('Usage: elit pm delete <name|all>');
2169
+ }
2170
+
2171
+ const { paths } = await loadPmContext();
2172
+ const matches = resolveNamedMatches(paths, target);
2173
+ if (matches.length === 0) {
2174
+ throw new Error(`No managed process found for: ${target}`);
2175
+ }
2176
+
2177
+ stopPmMatches(matches);
2178
+
2179
+ for (const match of matches) {
2180
+ if (existsSync(match.record.logFiles.out)) {
2181
+ rmSync(match.record.logFiles.out, { force: true });
2182
+ }
2183
+ if (existsSync(match.record.logFiles.err)) {
2184
+ rmSync(match.record.logFiles.err, { force: true });
2185
+ }
2186
+ rmSync(match.filePath, { force: true });
2187
+ }
2188
+
2189
+ console.log(`[pm] deleted ${matches.length} process${matches.length === 1 ? '' : 'es'}`);
2190
+ }
2191
+
2192
+ async function runPmLogs(args: string[]): Promise<void> {
2193
+ if (args.length === 0) {
2194
+ throw new Error('Usage: elit pm logs <name> [--lines <n>] [--stderr]');
2195
+ }
2196
+
2197
+ let name: string | undefined;
2198
+ let lineCount = DEFAULT_LOG_LINES;
2199
+ let stderrOnly = false;
2200
+
2201
+ for (let index = 0; index < args.length; index++) {
2202
+ const arg = args[index];
2203
+ switch (arg) {
2204
+ case '--lines':
2205
+ lineCount = normalizeIntegerOption(readRequiredValue(args, ++index, '--lines'), '--lines', 1);
2206
+ break;
2207
+ case '--stderr':
2208
+ stderrOnly = true;
2209
+ break;
2210
+ default:
2211
+ if (arg.startsWith('-')) {
2212
+ throw new Error(`Unknown pm logs option: ${arg}`);
2213
+ }
2214
+ if (name) {
2215
+ throw new Error('pm logs accepts exactly one process name.');
2216
+ }
2217
+ name = arg;
2218
+ break;
2219
+ }
2220
+ }
2221
+
2222
+ if (!name) {
2223
+ throw new Error('Usage: elit pm logs <name> [--lines <n>] [--stderr]');
2224
+ }
2225
+
2226
+ const { paths } = await loadPmContext();
2227
+ const match = findPmRecordMatch(paths, name);
2228
+ if (!match) {
2229
+ throw new Error(`No managed process found for: ${name}`);
2230
+ }
2231
+
2232
+ const stdoutContent = stderrOnly ? '' : tailLogFile(match.record.logFiles.out, lineCount);
2233
+ const stderrContent = tailLogFile(match.record.logFiles.err, lineCount);
2234
+
2235
+ if (!stderrOnly) {
2236
+ console.log(`== stdout: ${match.record.logFiles.out} ==`);
2237
+ console.log(stdoutContent || '(empty)');
2238
+ }
2239
+
2240
+ console.log(`== stderr: ${match.record.logFiles.err} ==`);
2241
+ console.log(stderrContent || '(empty)');
2242
+ }
2243
+
2244
+ function printPmHelp(): void {
2245
+ console.log([
2246
+ '',
2247
+ 'Elit PM - lightweight process manager',
2248
+ '',
2249
+ 'Usage:',
2250
+ ' elit pm start --script "npm start" --name my-app --runtime node',
2251
+ ' elit pm start --wapk ./test.wapk --name my-app',
2252
+ ' elit pm start --wapk gdrive://<fileId> --name my-app',
2253
+ ' elit pm start --google-drive-file-id <fileId> --name my-app',
2254
+ ' elit pm start ./app.ts --name my-app',
2255
+ ' elit pm start --file ./app.js --name my-app',
2256
+ ' elit pm start my-app',
2257
+ ' elit pm start',
2258
+ ' elit pm list',
2259
+ ' elit pm stop <name|all>',
2260
+ ' elit pm restart <name|all>',
2261
+ ' elit pm delete <name|all>',
2262
+ ' elit pm save',
2263
+ ' elit pm resurrect',
2264
+ ' elit pm logs <name> --lines 100',
2265
+ '',
2266
+ 'Start Options:',
2267
+ ' --script <command> Run a shell command, for example: npm start',
2268
+ ' --file, -f <path> Run a .js/.mjs/.cjs/.ts file',
2269
+ ' --wapk <source> Run a local .wapk file or a remote source like gdrive://<fileId>',
2270
+ ' --google-drive-file-id <id> Run a WAPK archive directly from Google Drive',
2271
+ ' --google-drive-token-env <name> Env var containing the Google Drive OAuth token',
2272
+ ' --google-drive-access-token <value> OAuth token forwarded to elit wapk run',
2273
+ ' --google-drive-shared-drive Forward supportsAllDrives=true for shared drives',
2274
+ ' --runtime, -r <name> Runtime override: node, bun, deno',
2275
+ ' --name, -n <name> Process name used by list/stop/restart',
2276
+ ' --cwd <dir> Working directory for the managed process',
2277
+ ' --env KEY=VALUE Add or override an environment variable',
2278
+ ' --password <value> Password for locked WAPK archives',
2279
+ ' --sync-interval <ms> Forward WAPK live-sync write interval (>= 50ms)',
2280
+ ' --watcher, --use-watcher Forward event-driven WAPK file watching',
2281
+ ' --archive-watch Pull archive source changes back into the temp WAPK workdir',
2282
+ ' --no-archive-watch Disable archive-source read sync for WAPK apps',
2283
+ ' --archive-sync-interval <ms> Forward WAPK archive read-sync interval (>= 50ms)',
2284
+ ' --restart-policy <mode> Restart policy: always, on-failure, never',
2285
+ ' --min-uptime <ms> Reset crash counter after this healthy uptime',
2286
+ ' --watch Restart when watched files change',
2287
+ ' --watch-path <path> Add a file or directory to watch',
2288
+ ' --watch-ignore <pattern> Ignore watched paths matching this glob-like pattern',
2289
+ ' --watch-debounce <ms> Debounce file-triggered restarts (default 250)',
2290
+ ' --health-url <url> Poll an HTTP endpoint and restart after repeated failures',
2291
+ ' --health-grace-period <ms> Delay before the first health check (default 5000)',
2292
+ ' --health-interval <ms> Health check interval (default 10000)',
2293
+ ' --health-timeout <ms> Per-request health check timeout (default 3000)',
2294
+ ' --health-max-failures <n> Consecutive failures before restart (default 3)',
2295
+ ' --no-autorestart Disable automatic restart',
2296
+ ' --restart-delay <ms> Delay between restart attempts (default 1000)',
2297
+ ' --max-restarts <count> Maximum restart attempts (default 10)',
2298
+ '',
2299
+ 'Config:',
2300
+ ' Add pm.apps[] to elit.config.* and run elit pm start to boot all configured apps.',
2301
+ '',
2302
+ 'Example:',
2303
+ ' export default {',
2304
+ ' pm: {',
2305
+ ' apps: [',
2306
+ ' { name: "api", script: "npm start", cwd: ".", runtime: "node" },',
2307
+ ' { name: "worker", file: "./src/worker.ts", runtime: "bun" },',
2308
+ ' { name: "desktop-app", wapk: "./dist/app.wapk", runtime: "node" },',
2309
+ ' { name: "drive-app", wapkRun: { googleDrive: { fileId: "1AbCdEfGhIjKlMnOp", accessTokenEnv: "GOOGLE_DRIVE_ACCESS_TOKEN" }, useWatcher: true, watchArchive: true } }',
2310
+ ' ]',
2311
+ ' }',
2312
+ ' }',
2313
+ '',
2314
+ 'Notes:',
2315
+ ' - PM state and logs are stored in ./.elit/pm by default.',
2316
+ ' - elit pm save persists running apps to pm.dumpFile or ./.elit/pm/dump.json.',
2317
+ ' - elit pm resurrect restarts whatever was last saved by elit pm save.',
2318
+ ' - elit pm start <name> starts a configured app by name.',
2319
+ ' - TypeScript files with runtime node require tsx, otherwise use --runtime bun.',
2320
+ ' - WAPK processes are executed through elit wapk run inside the manager.',
2321
+ ' - WAPK PM apps can use local archives, gdrive://<fileId>, or pm.apps[].wapkRun.googleDrive.',
2322
+ ].join('\n'));
2323
+ }
2324
+
2325
+ export async function runPmCommand(args: string[]): Promise<void> {
2326
+ if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
2327
+ printPmHelp();
2328
+ return;
2329
+ }
2330
+
2331
+ const command = args[0];
2332
+
2333
+ switch (command) {
2334
+ case 'start':
2335
+ await runPmStart(args.slice(1));
2336
+ return;
2337
+ case 'list':
2338
+ case 'ls': {
2339
+ const { paths } = await loadPmContext();
2340
+ printPmList(paths);
2341
+ return;
2342
+ }
2343
+ case 'stop':
2344
+ await runPmStop(args.slice(1));
2345
+ return;
2346
+ case 'restart':
2347
+ await runPmRestart(args.slice(1));
2348
+ return;
2349
+ case 'delete':
2350
+ case 'remove':
2351
+ case 'rm':
2352
+ await runPmDelete(args.slice(1));
2353
+ return;
2354
+ case 'save':
2355
+ await runPmSave();
2356
+ return;
2357
+ case 'resurrect':
2358
+ await runPmResurrect();
2359
+ return;
2360
+ case 'logs':
2361
+ await runPmLogs(args.slice(1));
2362
+ return;
2363
+ case '__run':
2364
+ await runPmRunner(args.slice(1));
2365
+ return;
2366
+ default:
2367
+ throw new Error(`Unknown pm command: ${command}`);
2368
+ }
2369
+ }