unitup 0.1.3 → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
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
+ ### Fixed
20
+ - Fixed script argument resolution in all runtime adapters (`node`, `python`, `ruby`, `php`, `bun`, `deno`, `shell`, `go`, `elixir`, `native`) to resolve relative script paths against `opts.cwd` instead of `process.cwd()`.
21
+
22
+ ## [0.1.3] - 2026-07-31
23
+
24
+ ### Added
25
+ - Native systemd timer-based task scheduler (`unitup schedule`).
26
+ - Systemd-native memory limits per service (`--memory-high`, `--memory-max`, `--memory-swap-max`, `--default-memory`).
27
+ - System memory overview command (`unitup memory` / `unitup top`).
28
+ - Extended multi-runtime support for Node.js, Python, Ruby, PHP, Bun, Deno, Go, Elixir, Shell scripts, and compiled native executables.
29
+ - Advanced `journald` log streaming filters and maintenance commands (`disk-usage`, `rotate`, `vacuum`).
30
+ - Readiness diagnostics CLI tool (`unitup doctor`).
31
+ - Service grouping support (`@group`).
32
+
33
+ ## [0.1.0] - 2026-07-01
34
+
35
+ ### Added
36
+ - Initial release of `unitup` — minimal, zero-dependency systemd user service wrapper.
37
+ - 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.3",
3
+ "version": "0.1.5",
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',
@@ -147,6 +157,12 @@ export function parseArgs(argv) {
147
157
  } else if (arg.startsWith('--cwd=')) {
148
158
  result.flags.cwd = arg.slice(6);
149
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++;
150
166
  } else if (arg === '--env') {
151
167
  if (argv[i + 1]) result.flags.env.push(argv[i + 1]);
152
168
  i += 2;
@@ -350,6 +366,7 @@ unitup - Minimal systemd user service wrapper for any executable & runtime
350
366
 
351
367
  Usage:
352
368
  unitup doctor Run system readiness and runtime check
369
+ unitup init [script] Create local project config file (unitup.config.json)
353
370
  unitup add <script> [options] Add script or executable as systemd user service
354
371
  unitup start <name|@group> Start a service (--enable to enable on boot)
355
372
  unitup stop <name|@group> Stop a service
@@ -363,6 +380,9 @@ Usage:
363
380
  unitup memory / unitup top Show memory usage for all apps or a specific app
364
381
  unitup config Manage global configuration (--default-memory 1G)
365
382
 
383
+ Config Options:
384
+ --config <path> Path to custom project config file (defaults to unitup.config.json)
385
+
366
386
  Schedule Commands:
367
387
  unitup schedule <script> [options] Create a systemd timer schedule
368
388
  unitup schedules / unitup timers List all schedule timers
@@ -440,10 +460,86 @@ export async function runCli(argv = process.argv.slice(2)) {
440
460
  break;
441
461
  }
442
462
 
443
- 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
+
444
475
  const scriptArg = positionals[0];
476
+ let scriptPath = scriptArg || '';
445
477
  let name = flags.name;
446
- 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;
447
543
 
448
544
  if (flags.node) {
449
545
  const resolvedNode = await findNodeExecutable(flags.node);
@@ -452,14 +548,14 @@ export async function runCli(argv = process.argv.slice(2)) {
452
548
  }
453
549
  }
454
550
 
455
- if (flags.command) {
551
+ if (cmdFlag) {
456
552
  if (!name) {
457
553
  if (scriptArg) {
458
554
  const baseName = path.basename(scriptArg);
459
555
  const ext = path.extname(baseName);
460
556
  name = ext ? baseName.slice(0, -ext.length) : baseName;
461
557
  } else {
462
- const baseCmd = path.basename(flags.command);
558
+ const baseCmd = path.basename(cmdFlag);
463
559
  name = baseCmd;
464
560
  }
465
561
  }
@@ -477,7 +573,7 @@ export async function runCli(argv = process.argv.slice(2)) {
477
573
  }
478
574
  }
479
575
 
480
- const envObj = {};
576
+ const envObj = (projectCfg && projectCfg.env && typeof projectCfg.env === 'object') ? { ...projectCfg.env } : {};
481
577
  for (const e of flags.env) {
482
578
  const idx = e.indexOf('=');
483
579
  if (idx !== -1) {
@@ -489,21 +585,21 @@ export async function runCli(argv = process.argv.slice(2)) {
489
585
 
490
586
  const res = await addService({
491
587
  name,
492
- group: flags.group || 'default',
588
+ group: groupFlag,
493
589
  script: absScriptPath,
494
- command: flags.command,
495
- runtime: flags.runtime,
496
- runtimeArgs: flags.runtimeArgs,
590
+ command: cmdFlag,
591
+ runtime: runtimeFlag,
592
+ runtimeArgs: runtimeArgsFlag,
497
593
  nodePath: flags.node,
498
- cwd: flags.cwd ? path.resolve(process.cwd(), flags.cwd) : (absScriptPath ? path.dirname(absScriptPath) : process.cwd()),
594
+ cwd: targetCwd,
499
595
  env: envObj,
500
- envFile: flags.envFile ? path.resolve(process.cwd(), flags.envFile) : undefined,
501
- restart: flags.restart,
502
- args: flags.args,
596
+ envFile: envFileFlag ? path.resolve(targetCwd, envFileFlag) : undefined,
597
+ restart: restartFlag,
598
+ args: argsFlag,
503
599
  start: flags.start,
504
- memoryHigh: flags.memoryHigh,
505
- memoryMax: flags.memoryMax,
506
- memorySwapMax: flags.memorySwapMax,
600
+ memoryHigh: memoryHighFlag,
601
+ memoryMax: memoryMaxFlag,
602
+ memorySwapMax: memorySwapMaxFlag,
507
603
  defaultMemory: flags.defaultMemory,
508
604
  force: flags.force
509
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,
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createBunAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createBunAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '-v');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createDenoAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createDenoAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '--version');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createElixirAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createElixirAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '-v');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createGoAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createGoAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, 'version');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -11,7 +11,7 @@ import { createNativeAdapter } from './native.js';
11
11
  import { createGoAdapter } from './go.js';
12
12
  import { createElixirAdapter } from './elixir.js';
13
13
 
14
- export function detectRuntime(filepath) {
14
+ export function detectRuntime(filepath, opts = {}) {
15
15
  if (!filepath) {
16
16
  throw new Error('Script path or command is required for runtime detection.');
17
17
  }
@@ -42,7 +42,8 @@ export function detectRuntime(filepath) {
42
42
  }
43
43
 
44
44
  // Inspect shebang header if file exists
45
- const absPath = path.resolve(process.cwd(), filepath);
45
+ const baseDir = opts.cwd || process.cwd();
46
+ const absPath = path.resolve(baseDir, filepath);
46
47
  if (fs.existsSync(absPath)) {
47
48
  try {
48
49
  const fd = fs.openSync(absPath, 'r');
@@ -85,9 +86,11 @@ export function detectRuntime(filepath) {
85
86
  }
86
87
 
87
88
  export async function resolveRuntimeConfig(opts = {}) {
89
+ const baseDir = opts.cwd || process.cwd();
90
+
88
91
  // 1. If explicit --command is provided, bypass runtime auto-detection
89
92
  if (opts.command) {
90
- const absCommand = path.resolve(process.cwd(), opts.command);
93
+ const absCommand = path.resolve(baseDir, opts.command);
91
94
  if (!fs.existsSync(absCommand)) {
92
95
  // If binary doesn't exist as exact file path, check if it's in PATH or native executable
93
96
  const { findRuntimeExecutable } = await import('./common.js');
@@ -116,7 +119,7 @@ export async function resolveRuntimeConfig(opts = {}) {
116
119
  // 2. Resolve target runtime name
117
120
  let runtimeName = opts.runtime;
118
121
  if (!runtimeName) {
119
- runtimeName = detectRuntime(opts.script);
122
+ runtimeName = detectRuntime(opts.script, opts);
120
123
  }
121
124
 
122
125
  const cleanRuntime = runtimeName.toLowerCase();
@@ -2,13 +2,14 @@ import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
 
4
4
  export async function createNativeAdapter(opts = {}) {
5
+ const baseDir = opts.cwd || process.cwd();
5
6
  const targetPath = opts.script || opts.command;
6
7
 
7
8
  if (!targetPath) {
8
9
  throw new Error('Native executable path is required.');
9
10
  }
10
11
 
11
- const absPath = path.resolve(process.cwd(), targetPath);
12
+ const absPath = path.resolve(baseDir, targetPath);
12
13
 
13
14
  if (!fs.existsSync(absPath)) {
14
15
  throw new Error(`Executable file does not exist: ${absPath}`);
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createNodeAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command || opts.nodePath;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -38,7 +39,7 @@ export async function createNodeAdapter(opts = {}) {
38
39
  }
39
40
 
40
41
  const version = await getBinaryVersion(execPath, '-v');
41
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
42
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
42
43
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
43
44
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
44
45
 
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createPhpAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createPhpAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '-v');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createPythonAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createPythonAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '--version');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createRubyAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createRubyAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '-v');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -3,11 +3,12 @@ import fs from 'node:fs';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createShellAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createShellAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '--version');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
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,7 +174,10 @@ 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);
@@ -696,4 +699,64 @@ export function resolveEffectiveMemoryLimits(opts = {}) {
696
699
  return {};
697
700
  }
698
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
+
699
762