@karmaniverous/jeeves 0.5.1 → 0.5.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.
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync, readFileSync, readdirSync, copyFileSync, rmSync } from 'node:fs';
3
- import { dirname, join, resolve } from 'node:path';
3
+ import { dirname, basename, join, resolve } from 'node:path';
4
4
  import * as commander from 'commander';
5
- import { packageDirectorySync } from 'package-directory';
5
+ import { randomUUID } from 'node:crypto';
6
6
  import { lock } from 'proper-lockfile';
7
7
  import 'semver';
8
+ import 'node:child_process';
8
9
  import { homedir } from 'node:os';
10
+ import { z } from 'zod';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { packageDirectorySync } from 'package-directory';
9
13
 
10
14
  function getDefaultExportFromCjs (x) {
11
15
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
@@ -126,14 +130,14 @@ const COMPONENT_VERSIONS_FILE = 'component-versions.json';
126
130
  * Core library version, inlined at build time.
127
131
  *
128
132
  * @remarks
129
- * The `0.5.0` placeholder is replaced by
133
+ * The `0.5.3` placeholder is replaced by
130
134
  * `@rollup/plugin-replace` during the build with the actual version
131
135
  * from `package.json`. This ensures the correct version survives
132
136
  * when consumers bundle core into their own dist (where runtime
133
137
  * `import.meta.url`-based resolution would find the wrong package.json).
134
138
  */
135
139
  /** The core library version from package.json (inlined at build time). */
136
- const CORE_VERSION = '0.5.0';
140
+ const CORE_VERSION = '0.5.3';
137
141
 
138
142
  /**
139
143
  * Shared file I/O helpers for managed section operations.
@@ -149,27 +153,46 @@ const STALE_LOCK_MS = 120_000;
149
153
  const DEFAULT_CORE_VERSION = CORE_VERSION;
150
154
  /** Lock retry options. */
151
155
  const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
156
+ /** Maximum rename retry attempts on EPERM. */
157
+ const ATOMIC_WRITE_MAX_RETRIES = 3;
158
+ /** Delay between EPERM retries in milliseconds. */
159
+ const ATOMIC_WRITE_RETRY_DELAY_MS = 100;
152
160
  /**
153
161
  * Write content to a file atomically via a temp file + rename.
154
162
  *
163
+ * @remarks
164
+ * Retries the rename up to three times on EPERM (Windows file-handle
165
+ * contention) with a 100 ms synchronous delay between attempts.
166
+ *
155
167
  * @param filePath - Absolute path to the target file.
156
168
  * @param content - Content to write.
157
169
  */
158
170
  function atomicWrite(filePath, content) {
159
171
  const dir = dirname(filePath);
160
- const tempPath = join(dir, `.${String(Date.now())}.tmp`);
172
+ const base = basename(filePath, '.md');
173
+ const tempPath = join(dir, `.${base}.${String(Date.now())}.${randomUUID().slice(0, 8)}.tmp`);
161
174
  writeFileSync(tempPath, content, 'utf-8');
162
- try {
163
- renameSync(tempPath, filePath);
164
- }
165
- catch (err) {
175
+ for (let attempt = 0; attempt < ATOMIC_WRITE_MAX_RETRIES; attempt++) {
166
176
  try {
167
- unlinkSync(tempPath);
177
+ renameSync(tempPath, filePath);
178
+ return;
168
179
  }
169
- catch {
170
- /* best-effort cleanup */
180
+ catch (err) {
181
+ const isEperm = err instanceof Error &&
182
+ 'code' in err &&
183
+ err.code === 'EPERM';
184
+ if (!isEperm || attempt === ATOMIC_WRITE_MAX_RETRIES - 1) {
185
+ try {
186
+ unlinkSync(tempPath);
187
+ }
188
+ catch {
189
+ /* best-effort cleanup */
190
+ }
191
+ throw err;
192
+ }
193
+ // Synchronous sleep before retry (acceptable in atomic write context)
194
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ATOMIC_WRITE_RETRY_DELAY_MS);
171
195
  }
172
- throw err;
173
196
  }
174
197
  }
175
198
  /**
@@ -402,7 +425,7 @@ function parseHeartbeat(fileContent) {
402
425
  const userContent = fileContent.slice(0, headingIndex).trim();
403
426
  const sectionContent = fileContent.slice(headingIndex + HEARTBEAT_HEADING.length);
404
427
  const entries = [];
405
- const h2Re = /^## (jeeves-\S+?|MEMORY\.md)(?:: declined)?$/gm;
428
+ const h2Re = /^## (jeeves-\S+?|\S+\.md)(?:: declined)?$/gm;
406
429
  let match;
407
430
  const h2Positions = [];
408
431
  while ((match = h2Re.exec(sectionContent)) !== null) {
@@ -483,6 +506,15 @@ function sortSectionsByOrder(sections) {
483
506
  * sections within the block, and returns the structured result plus
484
507
  * user content outside the markers.
485
508
  */
509
+ /**
510
+ * Escape a string for safe use as a literal in a RegExp pattern.
511
+ *
512
+ * @param str - The string to escape.
513
+ * @returns The escaped string.
514
+ */
515
+ function escapeForRegex(str) {
516
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
517
+ }
486
518
  /**
487
519
  * Build regex patterns for the given markers.
488
520
  *
@@ -490,11 +522,9 @@ function sortSectionsByOrder(sections) {
490
522
  * @returns Object with begin and end regex patterns.
491
523
  */
492
524
  function buildMarkerPatterns(markers) {
493
- const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
494
- const escapedEnd = markers.end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
495
525
  return {
496
- beginRe: new RegExp(`^<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$`, 'm'),
497
- endRe: new RegExp(`^<!--\\s*${escapedEnd}\\s*-->\\s*$`, 'm'),
526
+ beginRe: new RegExp(`^<!--\\s*${escapeForRegex(markers.begin)}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$`, 'm'),
527
+ endRe: new RegExp(`^<!--\\s*${escapeForRegex(markers.end)}\\s*-->\\s*$`, 'm'),
498
528
  };
499
529
  }
500
530
  /**
@@ -819,6 +849,23 @@ Review is human/agent-mediated — core does not auto-delete.
819
849
  Memory hygiene is checked on every \`ComponentWriter\` cycle alongside component health. When budget or staleness thresholds are breached, a \`## MEMORY.md\` alert appears in HEARTBEAT.md under \`# Jeeves Platform Status\`. The alert includes character count, budget usage percentage, and any stale section names. When memory is healthy, the heading is absent — no alert content, no LLM cost on heartbeat polls.
820
850
 
821
851
  The \`## MEMORY.md\` heading follows the same declined/active lifecycle as component headings (\`## jeeves-{name}\`). Users can decline memory alerts by changing the heading to \`## MEMORY.md: declined\`.
852
+
853
+ ## Workspace File Size Monitoring
854
+
855
+ OpenClaw applies a ~20,000-char injection limit to all workspace bootstrap files (AGENTS.md, SOUL.md, TOOLS.md, USER.md, MEMORY.md). Files exceeding the limit are silently truncated.
856
+
857
+ Core monitors all five files on every \`ComponentWriter\` cycle:
858
+ - Warning at 80% of budget (fixed threshold; not configurable via \`jeeves.config.json\`)
859
+ - Over-budget alert when charCount exceeds the budget
860
+ - Missing files are silently skipped
861
+
862
+ ### HEARTBEAT Integration
863
+
864
+ When a workspace file exceeds the warning threshold, a \`## {filename}\` alert appears in HEARTBEAT.md (e.g., \`## AGENTS.md\`). The alert includes:
865
+ - Character count, budget, and usage percentage
866
+ - Trimming guidance in priority order: (1) move domain-specific content to a local skill, (2) extract reference material to companion files with a pointer, (3) summarize verbose instructions, (4) remove stale content
867
+
868
+ Each file heading follows the same declined/active lifecycle as component headings. Users can decline alerts by changing the heading to \`## {filename}: declined\` (e.g., \`## AGENTS.md: declined\`).
822
869
  `;
823
870
 
824
871
  /**
@@ -843,6 +890,153 @@ function seedSkill(workspacePath) {
843
890
  writeFileSync(skillPath, skillContent, 'utf-8');
844
891
  }
845
892
 
893
+ /**
894
+ * Zod schema for the Jeeves component descriptor.
895
+ *
896
+ * @remarks
897
+ * The descriptor replaces the v0.4.0 `JeevesComponent` interface with a
898
+ * Zod-first approach. The TypeScript type is inferred via `z.infer<>`.
899
+ * Validates at parse time: prime interval, callable functions.
900
+ */
901
+ /**
902
+ * Check whether a number is prime.
903
+ *
904
+ * @param n - Number to check.
905
+ * @returns `true` if n is prime.
906
+ */
907
+ function isPrime(n) {
908
+ if (n < 2)
909
+ return false;
910
+ if (n === 2)
911
+ return true;
912
+ if (n % 2 === 0)
913
+ return false;
914
+ for (let i = 3; i * i <= n; i += 2) {
915
+ if (n % i === 0)
916
+ return false;
917
+ }
918
+ return true;
919
+ }
920
+ /**
921
+ * Zod schema for the Jeeves component descriptor.
922
+ *
923
+ * @remarks
924
+ * Single source of truth for what a component must provide.
925
+ * Factories consume this descriptor to produce CLI commands,
926
+ * plugin tools, and HTTP handlers.
927
+ */
928
+ z.object({
929
+ /** Component name (e.g., 'watcher', 'runner', 'server', 'meta'). */
930
+ name: z.string().min(1, 'name must be a non-empty string'),
931
+ /** Component version (from package.json). */
932
+ version: z.string().min(1, 'version must be a non-empty string'),
933
+ /** npm package name for the service. */
934
+ servicePackage: z.string().min(1),
935
+ /** npm package name for the plugin. */
936
+ pluginPackage: z.string().min(1),
937
+ /** System service name. Defaults to `jeeves-${name}` when not provided. */
938
+ serviceName: z.string().min(1).optional(),
939
+ /** Default port for the service's HTTP API. */
940
+ defaultPort: z.number().int().positive(),
941
+ /** Zod schema for validating config files. */
942
+ configSchema: z.custom((val) => val !== null &&
943
+ typeof val === 'object' &&
944
+ typeof val.parse === 'function', { message: 'configSchema must be a Zod schema' }),
945
+ /** Config file name (e.g., 'jeeves-watcher.config.json'). */
946
+ configFileName: z.string().min(1),
947
+ /** Returns a default config object for `init`. */
948
+ initTemplate: z.function({
949
+ input: [],
950
+ output: z.record(z.string(), z.unknown()),
951
+ }),
952
+ /**
953
+ * Service-side callback after config apply. Receives the merged,
954
+ * validated config (not the raw patch). Optional — if omitted,
955
+ * write-only (service picks up changes on restart).
956
+ */
957
+ onConfigApply: z
958
+ .function({
959
+ input: [z.record(z.string(), z.unknown())],
960
+ output: z.promise(z.void()),
961
+ })
962
+ .optional(),
963
+ /**
964
+ * Custom merge function for config apply. Receives the existing config
965
+ * and the patch, returns the merged result. Optional — if omitted,
966
+ * the default deep-merge (object-recursive, array-replacing) is used.
967
+ *
968
+ * Use this to implement domain-specific merge strategies such as
969
+ * name-based array merging for inference rules.
970
+ */
971
+ customMerge: z
972
+ .function({
973
+ input: [
974
+ z.record(z.string(), z.unknown()),
975
+ z.record(z.string(), z.unknown()),
976
+ ],
977
+ output: z.record(z.string(), z.unknown()),
978
+ })
979
+ .optional(),
980
+ /**
981
+ * Returns command + args for launching the service process.
982
+ * Consumed by `service install`.
983
+ */
984
+ startCommand: z.function({
985
+ input: [z.string()],
986
+ output: z.array(z.string()),
987
+ }),
988
+ /** In-process service entry point for the CLI `start` command. */
989
+ run: z.function({
990
+ input: [z.string()],
991
+ output: z.promise(z.void()),
992
+ }),
993
+ /** TOOLS.md section name (e.g., 'Watcher'). */
994
+ sectionId: z.string().min(1, 'sectionId must be a non-empty string'),
995
+ /** Refresh interval in seconds (must be a prime number). */
996
+ refreshIntervalSeconds: z.number().int().positive().refine(isPrime, {
997
+ message: 'refreshIntervalSeconds must be a prime number',
998
+ }),
999
+ /** Produce the component's TOOLS.md section content. */
1000
+ generateToolsContent: z.function({ input: [], output: z.string() }),
1001
+ /** Component dependencies for HEARTBEAT alert suppression. */
1002
+ dependencies: z
1003
+ .object({
1004
+ /** Components that must be healthy for this component to function. */
1005
+ hard: z.array(z.string()),
1006
+ /** Components that improve behavior but are not strictly required. */
1007
+ soft: z.array(z.string()),
1008
+ })
1009
+ .optional(),
1010
+ /** Extension point: add custom CLI commands to the service CLI. */
1011
+ customCliCommands: z
1012
+ .function({ input: [z.custom()], output: z.void() })
1013
+ .optional(),
1014
+ /** Extension point: return additional plugin tool descriptors. */
1015
+ customPluginTools: z
1016
+ .function({ input: [z.custom()], output: z.array(z.unknown()) })
1017
+ .optional(),
1018
+ });
1019
+
1020
+ /**
1021
+ * Resolve the package root directory from a module's `import.meta.url`.
1022
+ *
1023
+ * @module
1024
+ */
1025
+ /**
1026
+ * Get the nearest package root directory relative to the calling module URL.
1027
+ *
1028
+ * @param importMetaUrl - The `import.meta.url` of the calling module.
1029
+ * @returns The absolute package root path, or `undefined` on any error.
1030
+ */
1031
+ function getPackageRoot(importMetaUrl) {
1032
+ try {
1033
+ return packageDirectorySync({ cwd: fileURLToPath(importMetaUrl) });
1034
+ }
1035
+ catch {
1036
+ return undefined;
1037
+ }
1038
+ }
1039
+
846
1040
  /**
847
1041
  * OpenClaw configuration helpers for plugin CLI installers.
848
1042
  *
@@ -919,16 +1113,18 @@ function patchAllowList(parent, key, label, pluginId, mode) {
919
1113
  * Patch an OpenClaw config for plugin install or uninstall.
920
1114
  *
921
1115
  * @remarks
922
- * Manages `plugins.entries.{pluginId}` and `tools.alsoAllow`.
1116
+ * Manages `plugins.entries.{pluginId}`, `plugins.installs.{pluginId}`,
1117
+ * and `tools.alsoAllow`.
923
1118
  * Idempotent: adding twice produces no duplicates; removing when absent
924
1119
  * produces no errors.
925
1120
  *
926
1121
  * @param config - The parsed OpenClaw config object (mutated in place).
927
1122
  * @param pluginId - The plugin identifier.
928
1123
  * @param mode - Whether to add or remove the plugin.
1124
+ * @param installRecord - Install provenance record (required when mode is 'add').
929
1125
  * @returns Array of log messages describing changes made.
930
1126
  */
931
- function patchConfig(config, pluginId, mode) {
1127
+ function patchConfig(config, pluginId, mode, installRecord) {
932
1128
  const messages = [];
933
1129
  // Ensure plugins section
934
1130
  if (!config.plugins || typeof config.plugins !== 'object') {
@@ -950,6 +1146,24 @@ function patchConfig(config, pluginId, mode) {
950
1146
  Reflect.deleteProperty(entries, pluginId);
951
1147
  messages.push(`Removed "${pluginId}" from plugins.entries`);
952
1148
  }
1149
+ // plugins.installs
1150
+ if (!plugins.installs || typeof plugins.installs !== 'object') {
1151
+ plugins.installs = {};
1152
+ }
1153
+ const installs = plugins.installs;
1154
+ if (mode === 'add' && installRecord) {
1155
+ installs[pluginId] = {
1156
+ source: 'path',
1157
+ installPath: installRecord.installPath,
1158
+ version: installRecord.version,
1159
+ installedAt: installRecord.installedAt ?? new Date().toISOString(),
1160
+ };
1161
+ messages.push(`Wrote install record for "${pluginId}" to plugins.installs`);
1162
+ }
1163
+ else if (mode === 'remove' && pluginId in installs) {
1164
+ Reflect.deleteProperty(installs, pluginId);
1165
+ messages.push(`Removed install record for "${pluginId}" from plugins.installs`);
1166
+ }
953
1167
  // tools.alsoAllow
954
1168
  if (!config.tools || typeof config.tools !== 'object') {
955
1169
  config.tools = {};
@@ -1026,8 +1240,13 @@ function readJsonFile(filePath) {
1026
1240
  * @returns A Commander program ready for `.parse()`.
1027
1241
  */
1028
1242
  function createPluginCli(options) {
1029
- const { pluginId, distDir, pluginPackage, configRoot = 'j:/config', } = options;
1243
+ const { pluginId, importMetaUrl, pluginPackage, configRoot = 'j:/config', } = options;
1030
1244
  const componentName = options.componentName ?? deriveComponentName(pluginId);
1245
+ const pkgRoot = getPackageRoot(importMetaUrl);
1246
+ if (!pkgRoot) {
1247
+ throw new Error(`Unable to resolve package root for plugin CLI: ${pluginPackage}`);
1248
+ }
1249
+ const distDir = join(pkgRoot, 'dist');
1031
1250
  const program = new Command()
1032
1251
  .name(pluginPackage)
1033
1252
  .description(`Jeeves ${componentName} plugin installer`);
@@ -1042,23 +1261,38 @@ function createPluginCli(options) {
1042
1261
  const configPath = resolveConfigPath(openClawHome);
1043
1262
  // 1. Copy dist to extensions
1044
1263
  const extensionsDir = join(openClawHome, 'extensions', pluginId);
1264
+ if (!existsSync(distDir)) {
1265
+ throw new Error(`Plugin dist directory not found: ${distDir}. Ensure the plugin is built before installing.`);
1266
+ }
1045
1267
  console.log(`Copying dist to ${extensionsDir}...`);
1046
1268
  copyDistFiles(distDir, extensionsDir);
1047
1269
  // Copy package.json and openclaw.plugin.json from package root
1048
- const pkgRoot = packageDirectorySync({ cwd: distDir });
1049
- if (pkgRoot) {
1050
- for (const file of ['package.json', 'openclaw.plugin.json']) {
1051
- const src = join(pkgRoot, file);
1052
- if (existsSync(src)) {
1053
- copyFileSync(src, join(extensionsDir, file));
1054
- }
1270
+ for (const file of ['package.json', 'openclaw.plugin.json']) {
1271
+ const src = join(pkgRoot, file);
1272
+ if (existsSync(src)) {
1273
+ copyFileSync(src, join(extensionsDir, file));
1055
1274
  }
1056
1275
  }
1057
1276
  console.log(' ✓ Dist files copied');
1058
1277
  // 2. Patch openclaw.json
1059
1278
  console.log('Patching OpenClaw config...');
1060
1279
  const config = readJsonFile(configPath);
1061
- const messages = patchConfig(config, pluginId, 'add');
1280
+ const pkgJsonPathForVersion = join(extensionsDir, 'package.json');
1281
+ let pluginVersionForRecord;
1282
+ try {
1283
+ const pkgJsonForRecord = readJsonFile(pkgJsonPathForVersion);
1284
+ pluginVersionForRecord =
1285
+ typeof pkgJsonForRecord.version === 'string'
1286
+ ? pkgJsonForRecord.version
1287
+ : undefined;
1288
+ }
1289
+ catch {
1290
+ // best-effort: version may not be available yet
1291
+ }
1292
+ const messages = patchConfig(config, pluginId, 'add', {
1293
+ installPath: extensionsDir,
1294
+ version: pluginVersionForRecord,
1295
+ });
1062
1296
  // 3. Memory slot claim
1063
1297
  if (opts.memory) {
1064
1298
  if (!config.agents || typeof config.agents !== 'object') {
@@ -696,6 +696,21 @@ function createServiceManager(descriptor) {
696
696
  }
697
697
  }
698
698
 
699
+ /**
700
+ * Shared internal utility functions.
701
+ *
702
+ * @packageDocumentation
703
+ */
704
+ /**
705
+ * Extract a human-readable message from an unknown caught value.
706
+ *
707
+ * @param err - The caught value (typically `unknown`).
708
+ * @returns The error message string.
709
+ */
710
+ function getErrorMessage(err) {
711
+ return err instanceof Error ? err.message : String(err);
712
+ }
713
+
699
714
  /**
700
715
  * Workspace-level shared configuration: `jeeves.config.json`.
701
716
  *
@@ -747,7 +762,13 @@ z.object({
747
762
  /** Memory hygiene shared defaults. */
748
763
  memory: workspaceMemoryConfigSchema.optional(),
749
764
  });
750
- /** Built-in workspace config defaults. */
765
+ /**
766
+ * Built-in workspace config defaults.
767
+ *
768
+ * @remarks
769
+ * These defaults are used as the lowest-priority tier in config resolution
770
+ * (below CLI flags, env vars, and `jeeves.config.json` values).
771
+ */
751
772
  const WORKSPACE_CONFIG_DEFAULTS = {
752
773
  core: {
753
774
  workspace: '.',
@@ -773,6 +794,10 @@ const DEFAULT_CONFIG_ROOT = WORKSPACE_CONFIG_DEFAULTS.core.configRoot;
773
794
  * a component descriptor. Components add domain-specific commands
774
795
  * via `descriptor.customCliCommands`.
775
796
  */
797
+ function handleCommandError(action, err) {
798
+ console.error(`${action} failed: ${getErrorMessage(err)}`);
799
+ process.exitCode = 1;
800
+ }
776
801
  /**
777
802
  * Create a standard service CLI program from a component descriptor.
778
803
  *
@@ -825,8 +850,7 @@ function createServiceCli(descriptor) {
825
850
  console.log(JSON.stringify(result, null, 2));
826
851
  }
827
852
  catch (err) {
828
- const msg = err instanceof Error ? err.message : String(err);
829
- console.error(`Service unreachable: ${msg}`);
853
+ console.error(`Service unreachable: ${getErrorMessage(err)}`);
830
854
  process.exitCode = 1;
831
855
  }
832
856
  });
@@ -844,8 +868,7 @@ function createServiceCli(descriptor) {
844
868
  console.log(JSON.stringify(result, null, 2));
845
869
  }
846
870
  catch (err) {
847
- const msg = err instanceof Error ? err.message : String(err);
848
- console.error(`Config query failed: ${msg}`);
871
+ console.error(`Config query failed: ${getErrorMessage(err)}`);
849
872
  process.exitCode = 1;
850
873
  }
851
874
  });
@@ -861,8 +884,7 @@ function createServiceCli(descriptor) {
861
884
  console.log('Config is valid.');
862
885
  }
863
886
  catch (err) {
864
- const msg = err instanceof Error ? err.message : String(err);
865
- console.error(`Validation failed: ${msg}`);
887
+ console.error(`Validation failed: ${getErrorMessage(err)}`);
866
888
  process.exitCode = 1;
867
889
  }
868
890
  });
@@ -899,8 +921,7 @@ function createServiceCli(descriptor) {
899
921
  console.log(JSON.stringify(result, null, 2));
900
922
  }
901
923
  catch (err) {
902
- const msg = err instanceof Error ? err.message : String(err);
903
- console.error(`Config apply failed: ${msg}`);
924
+ console.error(`Config apply failed: ${getErrorMessage(err)}`);
904
925
  process.exitCode = 1;
905
926
  }
906
927
  });
@@ -937,9 +958,7 @@ function createServiceCli(descriptor) {
937
958
  console.log(`Service "${opts.name}" installed.`);
938
959
  }
939
960
  catch (err) {
940
- const msg = err instanceof Error ? err.message : String(err);
941
- console.error(`Install failed: ${msg}`);
942
- process.exitCode = 1;
961
+ handleCommandError('Install', err);
943
962
  }
944
963
  });
945
964
  serviceCmd
@@ -952,9 +971,7 @@ function createServiceCli(descriptor) {
952
971
  console.log(`Service "${opts.name}" uninstalled.`);
953
972
  }
954
973
  catch (err) {
955
- const msg = err instanceof Error ? err.message : String(err);
956
- console.error(`Uninstall failed: ${msg}`);
957
- process.exitCode = 1;
974
+ handleCommandError('Uninstall', err);
958
975
  }
959
976
  });
960
977
  serviceCmd
@@ -967,9 +984,7 @@ function createServiceCli(descriptor) {
967
984
  console.log(`Service "${opts.name}" started.`);
968
985
  }
969
986
  catch (err) {
970
- const msg = err instanceof Error ? err.message : String(err);
971
- console.error(`Start failed: ${msg}`);
972
- process.exitCode = 1;
987
+ handleCommandError('Start', err);
973
988
  }
974
989
  });
975
990
  serviceCmd
@@ -982,9 +997,7 @@ function createServiceCli(descriptor) {
982
997
  console.log(`Service "${opts.name}" stopped.`);
983
998
  }
984
999
  catch (err) {
985
- const msg = err instanceof Error ? err.message : String(err);
986
- console.error(`Stop failed: ${msg}`);
987
- process.exitCode = 1;
1000
+ handleCommandError('Stop', err);
988
1001
  }
989
1002
  });
990
1003
  serviceCmd
@@ -997,9 +1010,7 @@ function createServiceCli(descriptor) {
997
1010
  console.log(`Service "${opts.name}" restarted.`);
998
1011
  }
999
1012
  catch (err) {
1000
- const msg = err instanceof Error ? err.message : String(err);
1001
- console.error(`Restart failed: ${msg}`);
1002
- process.exitCode = 1;
1013
+ handleCommandError('Restart', err);
1003
1014
  }
1004
1015
  });
1005
1016
  serviceCmd
@@ -1012,9 +1023,7 @@ function createServiceCli(descriptor) {
1012
1023
  console.log(`Service "${opts.name}": ${state}`);
1013
1024
  }
1014
1025
  catch (err) {
1015
- const msg = err instanceof Error ? err.message : String(err);
1016
- console.error(`Status failed: ${msg}`);
1017
- process.exitCode = 1;
1026
+ handleCommandError('Status', err);
1018
1027
  }
1019
1028
  });
1020
1029
  // Apply custom CLI commands if provided