unitup 0.1.2 → 0.1.4

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/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `unitup` project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.4] - 2026-07-31
11
+
12
+ ### Added
13
+ - Local project configuration file support (`unitup.config.json` and `.unitup.json`).
14
+ - `unitup init` CLI command to create and initialize `unitup.config.json` in any directory with custom script, runtime, name, env, group, memory limits, and runtime arguments.
15
+ - `--config <path>` flag for specifying custom configuration files in `unitup add` and `unitup init`.
16
+ - Biome (`@biomejs/biome`) developer tool integration for code linting and formatting (`npm run check`, `npm run format`, `npm run lint`).
17
+ - `CHANGELOG.md` tracking project release history and changes.
18
+
19
+ ## [0.1.3] - 2026-07-31
20
+
21
+ ### Added
22
+ - Native systemd timer-based task scheduler (`unitup schedule`).
23
+ - Systemd-native memory limits per service (`--memory-high`, `--memory-max`, `--memory-swap-max`, `--default-memory`).
24
+ - System memory overview command (`unitup memory` / `unitup top`).
25
+ - Extended multi-runtime support for Node.js, Python, Ruby, PHP, Bun, Deno, Go, Elixir, Shell scripts, and compiled native executables.
26
+ - Advanced `journald` log streaming filters and maintenance commands (`disk-usage`, `rotate`, `vacuum`).
27
+ - Readiness diagnostics CLI tool (`unitup doctor`).
28
+ - Service grouping support (`@group`).
29
+
30
+ ## [0.1.0] - 2026-07-01
31
+
32
+ ### Added
33
+ - Initial release of `unitup` — minimal, zero-dependency systemd user service wrapper.
34
+ - Basic CLI commands: `add`, `start`, `stop`, `restart`, `status`, `logs`, `remove`, `list`.
package/README.md CHANGED
@@ -92,6 +92,40 @@ At its core, `unitup` generates systemd service unit files directly from a gener
92
92
 
93
93
  ## CLI Usage
94
94
 
95
+ ### Project Configuration (`unitup.config.json`)
96
+
97
+ You can create a local configuration file in any project directory to define default service settings:
98
+
99
+ ```bash
100
+ # Initialize a unitup.config.json file in the current folder
101
+ unitup init app.js --name my-app --memory-max 512M --env PORT=3000
102
+
103
+ # Add service using settings from unitup.config.json
104
+ unitup add
105
+
106
+ # Explicit CLI flags override settings in unitup.config.json
107
+ unitup add --memory-max 1G
108
+ ```
109
+
110
+ Example `unitup.config.json`:
111
+ ```json
112
+ {
113
+ "name": "my-app",
114
+ "group": "backend",
115
+ "script": "app.js",
116
+ "runtime": "node",
117
+ "env": {
118
+ "NODE_ENV": "production",
119
+ "PORT": "3000"
120
+ },
121
+ "resources": {
122
+ "memoryMax": "512M",
123
+ "memoryHigh": "400M"
124
+ },
125
+ "restart": "on-failure"
126
+ }
127
+ ```
128
+
95
129
  ### Memory Limits
96
130
 
97
131
  `unitup` allows you to set native systemd memory limits for your services without running background monitoring daemons:
package/biome.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": true,
10
+ "ignore": ["node_modules", "dist", "coverage"]
11
+ },
12
+ "formatter": {
13
+ "enabled": true,
14
+ "indentStyle": "space",
15
+ "indentWidth": 2,
16
+ "lineWidth": 120
17
+ },
18
+ "organizeImports": {
19
+ "enabled": true
20
+ },
21
+ "linter": {
22
+ "enabled": true,
23
+ "rules": {
24
+ "recommended": true,
25
+ "style": {
26
+ "useConstStruct": "off",
27
+ "noUnusedTemplateLiteral": "off"
28
+ },
29
+ "complexity": {
30
+ "noForEach": "off"
31
+ },
32
+ "suspicious": {
33
+ "noEmptyBlockStatements": "off"
34
+ }
35
+ }
36
+ },
37
+ "javascript": {
38
+ "formatter": {
39
+ "quoteStyle": "single",
40
+ "jsxQuoteStyle": "double",
41
+ "trailingCommas": "none",
42
+ "semicolons": "always"
43
+ }
44
+ }
45
+ }
package/index.d.ts CHANGED
@@ -482,6 +482,31 @@ export function checkNodeDiagnostics(customNodePath?: string): Promise<NodeDiagn
482
482
  export function findNodeExecutable(customPath?: string): Promise<string | null>;
483
483
  export function getDoctorInfo(): Promise<DoctorInfo>;
484
484
 
485
+ export interface ProjectConfig {
486
+ name?: string;
487
+ group?: string;
488
+ script?: string;
489
+ command?: string;
490
+ runtime?: string;
491
+ runtimeArgs?: string[];
492
+ args?: string[];
493
+ env?: Record<string, string>;
494
+ envFile?: string;
495
+ restart?: string;
496
+ resources?: {
497
+ memoryHigh?: string;
498
+ memoryMax?: string;
499
+ memorySwapMax?: string;
500
+ };
501
+ memoryHigh?: string;
502
+ memoryMax?: string;
503
+ memorySwapMax?: string;
504
+ }
505
+
506
+ export function findProjectConfig(dirPath?: string): string | null;
507
+ export function readProjectConfig(filePathOrDir?: string): ProjectConfig | null;
508
+ export function saveProjectConfig(dirPathOrFile?: string, config?: ProjectConfig): string;
509
+
485
510
  // Path & Unit Helpers
486
511
  export function getUserUnitDir(): string;
487
512
  export function getUnitPath(name: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitup",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Minimal, zero-dependency systemd-native service manager for any runtime, script, or executable",
5
5
  "main": "src/index.js",
6
6
  "types": "./index.d.ts",
@@ -9,9 +9,13 @@
9
9
  "unitup": "bin/unitup.js"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test test/*.test.js"
12
+ "test": "node --test test/*.test.js",
13
+ "lint": "biome check .",
14
+ "format": "biome format --write .",
15
+ "check": "biome check ."
13
16
  },
14
17
  "devDependencies": {
18
+ "@biomejs/biome": "^1.9.4",
15
19
  "typescript": "^5.7.3"
16
20
  },
17
21
  "keywords": [
package/src/cli.js CHANGED
@@ -32,7 +32,16 @@ import {
32
32
  disableSchedule,
33
33
  removeSchedule
34
34
  } from './schedule.js';
35
- import { sanitizeServiceName, formatTable, readGlobalConfig, saveGlobalConfig, validateMemorySize } from './utils.js';
35
+ import {
36
+ sanitizeServiceName,
37
+ formatTable,
38
+ readGlobalConfig,
39
+ saveGlobalConfig,
40
+ validateMemorySize,
41
+ findProjectConfig,
42
+ readProjectConfig,
43
+ saveProjectConfig
44
+ } from './utils.js';
36
45
 
37
46
  /**
38
47
  * Custom light CLI argument parser without runtime dependencies.
@@ -51,6 +60,7 @@ export function parseArgs(argv) {
51
60
  command: '',
52
61
  node: '',
53
62
  cwd: '',
63
+ config: '',
54
64
  env: [],
55
65
  envFile: '',
56
66
  restart: 'on-failure',
@@ -108,12 +118,15 @@ export function parseArgs(argv) {
108
118
  } else if (arg.startsWith('--runtime=')) {
109
119
  result.flags.runtime = arg.slice(10);
110
120
  i++;
111
- } else if (arg === '--runtime-arg') {
121
+ } else if (arg === '--runtime-arg' || arg === '--runtime-args') {
112
122
  if (argv[i + 1]) result.flags.runtimeArgs.push(argv[i + 1]);
113
123
  i += 2;
114
124
  } else if (arg.startsWith('--runtime-arg=')) {
115
125
  result.flags.runtimeArgs.push(arg.slice(14));
116
126
  i++;
127
+ } else if (arg.startsWith('--runtime-args=')) {
128
+ result.flags.runtimeArgs.push(arg.slice(15));
129
+ i++;
117
130
  } else if (arg === '--command') {
118
131
  result.flags.command = argv[i + 1] || '';
119
132
  i += 2;
@@ -144,6 +157,12 @@ export function parseArgs(argv) {
144
157
  } else if (arg.startsWith('--cwd=')) {
145
158
  result.flags.cwd = arg.slice(6);
146
159
  i++;
160
+ } else if (arg === '--config') {
161
+ result.flags.config = argv[i + 1] || '';
162
+ i += 2;
163
+ } else if (arg.startsWith('--config=')) {
164
+ result.flags.config = arg.slice(9);
165
+ i++;
147
166
  } else if (arg === '--env') {
148
167
  if (argv[i + 1]) result.flags.env.push(argv[i + 1]);
149
168
  i += 2;
@@ -162,12 +181,15 @@ export function parseArgs(argv) {
162
181
  } else if (arg.startsWith('--restart=')) {
163
182
  result.flags.restart = arg.slice(10);
164
183
  i++;
165
- } else if (arg === '--arg') {
184
+ } else if (arg === '--arg' || arg === '--args') {
166
185
  if (argv[i + 1]) result.flags.args.push(argv[i + 1]);
167
186
  i += 2;
168
187
  } else if (arg.startsWith('--arg=')) {
169
188
  result.flags.args.push(arg.slice(6));
170
189
  i++;
190
+ } else if (arg.startsWith('--args=')) {
191
+ result.flags.args.push(arg.slice(7));
192
+ i++;
171
193
  } else if (arg === '--lines' || arg === '-n') {
172
194
  result.flags.lines = parseInt(argv[i + 1] || '100', 10);
173
195
  i += 2;
@@ -344,6 +366,7 @@ unitup - Minimal systemd user service wrapper for any executable & runtime
344
366
 
345
367
  Usage:
346
368
  unitup doctor Run system readiness and runtime check
369
+ unitup init [script] Create local project config file (unitup.config.json)
347
370
  unitup add <script> [options] Add script or executable as systemd user service
348
371
  unitup start <name|@group> Start a service (--enable to enable on boot)
349
372
  unitup stop <name|@group> Stop a service
@@ -357,6 +380,9 @@ Usage:
357
380
  unitup memory / unitup top Show memory usage for all apps or a specific app
358
381
  unitup config Manage global configuration (--default-memory 1G)
359
382
 
383
+ Config Options:
384
+ --config <path> Path to custom project config file (defaults to unitup.config.json)
385
+
360
386
  Schedule Commands:
361
387
  unitup schedule <script> [options] Create a systemd timer schedule
362
388
  unitup schedules / unitup timers List all schedule timers
@@ -434,10 +460,86 @@ export async function runCli(argv = process.argv.slice(2)) {
434
460
  break;
435
461
  }
436
462
 
437
- case 'add': {
463
+ case 'init': {
464
+ const targetCwd = flags.cwd ? path.resolve(process.cwd(), flags.cwd) : process.cwd();
465
+ const configFilename = flags.config || 'unitup.config.json';
466
+ const targetConfigPath = path.resolve(targetCwd, configFilename);
467
+
468
+ if (fs.existsSync(targetConfigPath) && !flags.force) {
469
+ throw new Error(
470
+ `Configuration file "${path.basename(targetConfigPath)}" already exists at ${targetConfigPath}.\n` +
471
+ `Use --force (-f) to overwrite.`
472
+ );
473
+ }
474
+
438
475
  const scriptArg = positionals[0];
476
+ let scriptPath = scriptArg || '';
439
477
  let name = flags.name;
440
- let absScriptPath = scriptArg ? path.resolve(process.cwd(), scriptArg) : undefined;
478
+ if (!name) {
479
+ if (scriptPath) {
480
+ const baseName = path.basename(scriptPath);
481
+ const ext = path.extname(baseName);
482
+ name = ext ? baseName.slice(0, -ext.length) : baseName;
483
+ } else {
484
+ name = path.basename(targetCwd);
485
+ }
486
+ }
487
+
488
+ const envObj = {};
489
+ for (const e of flags.env) {
490
+ const idx = e.indexOf('=');
491
+ if (idx !== -1) {
492
+ envObj[e.slice(0, idx)] = e.slice(idx + 1);
493
+ } else {
494
+ envObj[e] = '';
495
+ }
496
+ }
497
+
498
+ const projectCfg = {
499
+ name,
500
+ ...(scriptPath ? { script: scriptPath } : {}),
501
+ ...(flags.command ? { command: flags.command } : {}),
502
+ ...(flags.runtime ? { runtime: flags.runtime } : {}),
503
+ ...(flags.runtimeArgs && flags.runtimeArgs.length > 0 ? { runtimeArgs: flags.runtimeArgs } : {}),
504
+ group: flags.group || 'default',
505
+ ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
506
+ ...(flags.envFile ? { envFile: flags.envFile } : {}),
507
+ ...(flags.restart ? { restart: flags.restart } : { restart: 'on-failure' }),
508
+ ...(flags.args && flags.args.length > 0 ? { args: flags.args } : {})
509
+ };
510
+
511
+ if (flags.memoryMax || flags.memoryHigh || flags.memorySwapMax) {
512
+ projectCfg.resources = {};
513
+ if (flags.memoryHigh) projectCfg.resources.memoryHigh = flags.memoryHigh;
514
+ if (flags.memoryMax) projectCfg.resources.memoryMax = flags.memoryMax;
515
+ if (flags.memorySwapMax) projectCfg.resources.memorySwapMax = flags.memorySwapMax;
516
+ }
517
+
518
+ const writtenPath = saveProjectConfig(targetConfigPath, projectCfg);
519
+ console.log(`✓ Project configuration created at ${writtenPath}`);
520
+ console.log(`Run "unitup add" to register service from configuration.`);
521
+ break;
522
+ }
523
+
524
+ case 'add': {
525
+ const targetCwd = flags.cwd ? path.resolve(process.cwd(), flags.cwd) : process.cwd();
526
+ const configPath = flags.config ? path.resolve(targetCwd, flags.config) : findProjectConfig(targetCwd);
527
+ const projectCfg = configPath ? readProjectConfig(configPath) : null;
528
+
529
+ const scriptArg = positionals[0] || (projectCfg ? projectCfg.script : undefined);
530
+ let name = flags.name || (projectCfg ? projectCfg.name : undefined);
531
+ let cmdFlag = flags.command || (projectCfg ? projectCfg.command : undefined);
532
+ let runtimeFlag = flags.runtime || (projectCfg ? projectCfg.runtime : undefined);
533
+ let groupFlag = flags.group || (projectCfg ? projectCfg.group : undefined) || 'default';
534
+ let restartFlag = flags.restart !== 'on-failure' ? flags.restart : ((projectCfg && projectCfg.restart) || 'on-failure');
535
+ let envFileFlag = flags.envFile || (projectCfg ? projectCfg.envFile : undefined);
536
+ let argsFlag = (flags.args && flags.args.length > 0) ? flags.args : ((projectCfg && projectCfg.args) || []);
537
+ let runtimeArgsFlag = (flags.runtimeArgs && flags.runtimeArgs.length > 0) ? flags.runtimeArgs : ((projectCfg && projectCfg.runtimeArgs) || []);
538
+ let memoryHighFlag = flags.memoryHigh || (projectCfg?.resources?.memoryHigh || projectCfg?.memoryHigh || '');
539
+ let memoryMaxFlag = flags.memoryMax || (projectCfg?.resources?.memoryMax || projectCfg?.memoryMax || '');
540
+ let memorySwapMaxFlag = flags.memorySwapMax || (projectCfg?.resources?.memorySwapMax || projectCfg?.memorySwapMax || '');
541
+
542
+ let absScriptPath = scriptArg ? path.resolve(targetCwd, scriptArg) : undefined;
441
543
 
442
544
  if (flags.node) {
443
545
  const resolvedNode = await findNodeExecutable(flags.node);
@@ -446,14 +548,14 @@ export async function runCli(argv = process.argv.slice(2)) {
446
548
  }
447
549
  }
448
550
 
449
- if (flags.command) {
551
+ if (cmdFlag) {
450
552
  if (!name) {
451
553
  if (scriptArg) {
452
554
  const baseName = path.basename(scriptArg);
453
555
  const ext = path.extname(baseName);
454
556
  name = ext ? baseName.slice(0, -ext.length) : baseName;
455
557
  } else {
456
- const baseCmd = path.basename(flags.command);
558
+ const baseCmd = path.basename(cmdFlag);
457
559
  name = baseCmd;
458
560
  }
459
561
  }
@@ -471,7 +573,7 @@ export async function runCli(argv = process.argv.slice(2)) {
471
573
  }
472
574
  }
473
575
 
474
- const envObj = {};
576
+ const envObj = (projectCfg && projectCfg.env && typeof projectCfg.env === 'object') ? { ...projectCfg.env } : {};
475
577
  for (const e of flags.env) {
476
578
  const idx = e.indexOf('=');
477
579
  if (idx !== -1) {
@@ -483,21 +585,21 @@ export async function runCli(argv = process.argv.slice(2)) {
483
585
 
484
586
  const res = await addService({
485
587
  name,
486
- group: flags.group || 'default',
588
+ group: groupFlag,
487
589
  script: absScriptPath,
488
- command: flags.command,
489
- runtime: flags.runtime,
490
- runtimeArgs: flags.runtimeArgs,
590
+ command: cmdFlag,
591
+ runtime: runtimeFlag,
592
+ runtimeArgs: runtimeArgsFlag,
491
593
  nodePath: flags.node,
492
- cwd: flags.cwd ? path.resolve(process.cwd(), flags.cwd) : (absScriptPath ? path.dirname(absScriptPath) : process.cwd()),
594
+ cwd: targetCwd,
493
595
  env: envObj,
494
- envFile: flags.envFile ? path.resolve(process.cwd(), flags.envFile) : undefined,
495
- restart: flags.restart,
496
- args: flags.args,
596
+ envFile: envFileFlag ? path.resolve(targetCwd, envFileFlag) : undefined,
597
+ restart: restartFlag,
598
+ args: argsFlag,
497
599
  start: flags.start,
498
- memoryHigh: flags.memoryHigh,
499
- memoryMax: flags.memoryMax,
500
- memorySwapMax: flags.memorySwapMax,
600
+ memoryHigh: memoryHighFlag,
601
+ memoryMax: memoryMaxFlag,
602
+ memorySwapMax: memorySwapMaxFlag,
501
603
  defaultMemory: flags.defaultMemory,
502
604
  force: flags.force
503
605
  });
package/src/index.js CHANGED
@@ -58,6 +58,9 @@ import {
58
58
  readGlobalConfig,
59
59
  saveGlobalConfig,
60
60
  getConfigFilepath,
61
+ findProjectConfig,
62
+ readProjectConfig,
63
+ saveProjectConfig,
61
64
  resolveEffectiveMemoryLimits,
62
65
  getSchedulesDir,
63
66
  getAppsDir,
@@ -115,6 +118,9 @@ export {
115
118
  readGlobalConfig,
116
119
  saveGlobalConfig,
117
120
  getConfigFilepath,
121
+ findProjectConfig,
122
+ readProjectConfig,
123
+ saveProjectConfig,
118
124
  resolveEffectiveMemoryLimits,
119
125
  getSchedulesDir,
120
126
  getAppsDir,
package/src/systemd.js CHANGED
@@ -5,8 +5,10 @@ import path from 'node:path';
5
5
  import {
6
6
  sanitizeServiceName,
7
7
  getUnitFilename,
8
+ getTimerFilename,
8
9
  formatRelativeTime,
9
- readAppMetadata
10
+ readAppMetadata,
11
+ readScheduleMetadata
10
12
  } from './utils.js';
11
13
  import {
12
14
  getUserUnitDir,
@@ -516,7 +518,7 @@ export async function getServiceStatus(name) {
516
518
  ? formatRelativeTime(startedRaw)
517
519
  : 'never';
518
520
 
519
- const meta = readAppMetadata(safeName);
521
+ const meta = readAppMetadata(safeName) || readScheduleMetadata(safeName);
520
522
  const command = meta?.command || parsed.command || meta?.node || process.execPath;
521
523
  const argsList = meta?.args || (parsed.script ? [parsed.script] : []);
522
524
 
@@ -555,7 +557,7 @@ export async function getServicesByGroup(groupName) {
555
557
  const units = listUnitFiles();
556
558
  const matched = [];
557
559
  for (const unit of units) {
558
- const meta = readAppMetadata(unit.name);
560
+ const meta = readAppMetadata(unit.name) || readScheduleMetadata(unit.name);
559
561
  const itemGroup = meta?.group || 'default';
560
562
  if (itemGroup.toLowerCase() === cleanGroup.toLowerCase()) {
561
563
  matched.push(unit.name);
@@ -572,7 +574,7 @@ export async function listServices(filterOpts = {}) {
572
574
  : null;
573
575
 
574
576
  for (const unit of units) {
575
- const meta = readAppMetadata(unit.name);
577
+ const meta = readAppMetadata(unit.name) || readScheduleMetadata(unit.name);
576
578
  const group = meta?.group || 'default';
577
579
  const runtime = meta?.runtime || 'node';
578
580
 
@@ -604,14 +606,35 @@ export async function listServices(filterOpts = {}) {
604
606
  const unitFileState = show.UnitFileState || 'unknown';
605
607
 
606
608
  let status = 'stopped';
607
- if (activeState === 'active' && subState === 'running') {
608
- status = 'running';
609
- } else if (activeState === 'failed') {
610
- status = 'failed';
611
- } else if (activeState === 'active') {
612
- status = subState;
609
+ if (meta?.type === 'timer' || meta?.schedule) {
610
+ if (activeState === 'active' && subState === 'running') {
611
+ status = 'running';
612
+ } else if (activeState === 'failed') {
613
+ status = 'failed';
614
+ } else {
615
+ try {
616
+ const timerShow = await getServiceShow(getTimerFilename(unit.name));
617
+ if (timerShow.ActiveState === 'active') {
618
+ status = 'scheduled';
619
+ } else if (timerShow.UnitFileState === 'disabled' || timerShow.ActiveState === 'inactive') {
620
+ status = 'disabled';
621
+ } else {
622
+ status = 'scheduled';
623
+ }
624
+ } catch {
625
+ status = 'scheduled';
626
+ }
627
+ }
613
628
  } else {
614
- status = 'stopped';
629
+ if (activeState === 'active' && subState === 'running') {
630
+ status = 'running';
631
+ } else if (activeState === 'failed') {
632
+ status = 'failed';
633
+ } else if (activeState === 'active') {
634
+ status = subState;
635
+ } else {
636
+ status = 'stopped';
637
+ }
615
638
  }
616
639
 
617
640
  const enabled = unitFileState.startsWith('enabled') ? 'yes' : 'no';
@@ -657,16 +680,32 @@ export async function inspectService(name) {
657
680
  throw new Error(`Service "${safeName}" does not exist.`);
658
681
  }
659
682
 
660
- const meta = readAppMetadata(safeName);
683
+ const meta = readAppMetadata(safeName) || readScheduleMetadata(safeName);
661
684
  const statusObj = await getServiceStatus(safeName);
662
685
 
686
+ let inspectStatus = statusObj.status;
687
+ if ((meta?.type === 'timer' || meta?.schedule) && inspectStatus === 'stopped') {
688
+ try {
689
+ const timerShow = await getServiceShow(getTimerFilename(safeName));
690
+ if (timerShow.ActiveState === 'active') {
691
+ inspectStatus = 'scheduled';
692
+ } else if (timerShow.UnitFileState === 'disabled' || timerShow.ActiveState === 'inactive') {
693
+ inspectStatus = 'disabled';
694
+ } else {
695
+ inspectStatus = 'scheduled';
696
+ }
697
+ } catch {
698
+ inspectStatus = 'scheduled';
699
+ }
700
+ }
701
+
663
702
  const command = meta?.command || statusObj.command || statusObj.node || process.execPath;
664
703
  const argsList = meta?.args || (statusObj.script ? [statusObj.script] : []);
665
704
 
666
705
  return {
667
706
  name: safeName,
668
707
  runtime: meta?.runtime || 'node',
669
- status: statusObj.status,
708
+ status: inspectStatus,
670
709
  activeState: statusObj.activeState,
671
710
  subState: statusObj.subState,
672
711
  command,
package/src/utils.js CHANGED
@@ -174,16 +174,26 @@ export function saveAppMetadata(meta) {
174
174
  */
175
175
  export function readAppMetadata(name) {
176
176
  try {
177
- const filePath = getAppMetadataPath(name);
177
+ let filePath = getAppMetadataPath(name);
178
+ if (!fs.existsSync(filePath)) {
179
+ filePath = getScheduleMetadataPath(name);
180
+ }
178
181
  if (!fs.existsSync(filePath)) return null;
179
182
  const content = fs.readFileSync(filePath, 'utf8');
180
183
  const data = JSON.parse(content);
181
184
  if (!data) return null;
182
185
 
183
- // Normalization for legacy metadata format
184
186
  const command = data.command || data.node || process.execPath;
185
187
  const args = data.args || (data.script ? [data.script] : []);
186
- const runtime = data.runtime || 'node';
188
+ let runtime = data.runtime;
189
+ if (!runtime || (runtime === 'node' && data.command)) {
190
+ const baseCmd = path.basename(command).toLowerCase();
191
+ if (baseCmd !== 'node' && baseCmd !== 'nodejs') {
192
+ runtime = 'custom';
193
+ } else {
194
+ runtime = 'node';
195
+ }
196
+ }
187
197
  const script = data.script || args[0] || command;
188
198
  const cwd = data.cwd || (script ? path.dirname(script) : process.cwd());
189
199
 
@@ -472,7 +482,7 @@ export function saveScheduleMetadata(meta) {
472
482
  name: safeName,
473
483
  group: meta.group || 'default',
474
484
  type: 'timer',
475
- runtime: meta.runtime || 'node',
485
+ runtime: meta.runtime || (meta.command ? 'custom' : 'node'),
476
486
  command,
477
487
  args,
478
488
  cwd: meta.cwd ? resolveAbsolutePath(meta.cwd) : process.cwd(),
@@ -502,7 +512,17 @@ export function readScheduleMetadata(name) {
502
512
  const filePath = getScheduleMetadataPath(name);
503
513
  if (!fs.existsSync(filePath)) return null;
504
514
  const content = fs.readFileSync(filePath, 'utf8');
505
- return JSON.parse(content);
515
+ const meta = JSON.parse(content);
516
+ if (!meta) return null;
517
+
518
+ // Normalization for legacy metadata format where runtime defaulted to 'node' for custom commands
519
+ if (meta.runtime === 'node' && meta.command) {
520
+ const baseCmd = path.basename(meta.command).toLowerCase();
521
+ if (baseCmd !== 'node' && baseCmd !== 'nodejs') {
522
+ meta.runtime = 'custom';
523
+ }
524
+ }
525
+ return meta;
506
526
  } catch {
507
527
  return null;
508
528
  }
@@ -679,4 +699,64 @@ export function resolveEffectiveMemoryLimits(opts = {}) {
679
699
  return {};
680
700
  }
681
701
 
702
+ /**
703
+ * Finds the project configuration file path if it exists in the target directory.
704
+ * Standard names checked: unitup.config.json, .unitup.json
705
+ *
706
+ * @param {string} [dirPath=process.cwd()]
707
+ * @returns {string|null}
708
+ */
709
+ export function findProjectConfig(dirPath = process.cwd()) {
710
+ const dir = path.resolve(dirPath);
711
+ const candidates = ['unitup.config.json', '.unitup.json'];
712
+ for (const file of candidates) {
713
+ const full = path.join(dir, file);
714
+ if (fs.existsSync(full)) {
715
+ return full;
716
+ }
717
+ }
718
+ return null;
719
+ }
720
+
721
+ /**
722
+ * Reads a project configuration file.
723
+ *
724
+ * @param {string} [filePathOrDir=process.cwd()]
725
+ * @returns {object|null}
726
+ */
727
+ export function readProjectConfig(filePathOrDir = process.cwd()) {
728
+ try {
729
+ let filePath = filePathOrDir;
730
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
731
+ filePath = findProjectConfig(filePath);
732
+ }
733
+ if (!filePath || !fs.existsSync(filePath)) return null;
734
+ const content = fs.readFileSync(filePath, 'utf8');
735
+ return JSON.parse(content) || null;
736
+ } catch {
737
+ return null;
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Saves a project configuration file (`unitup.config.json`).
743
+ *
744
+ * @param {string} [dirPathOrFile=process.cwd()]
745
+ * @param {object} config
746
+ * @returns {string} File path written to
747
+ */
748
+ export function saveProjectConfig(dirPathOrFile = process.cwd(), config = {}) {
749
+ let filePath = dirPathOrFile;
750
+ if (!filePath.endsWith('.json')) {
751
+ filePath = path.join(filePath, 'unitup.config.json');
752
+ }
753
+ const dir = path.dirname(filePath);
754
+ if (!fs.existsSync(dir)) {
755
+ fs.mkdirSync(dir, { recursive: true });
756
+ }
757
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
758
+ return filePath;
759
+ }
760
+
761
+
682
762