@procedures/mark 0.41.0 → 0.42.0

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/cli.ts CHANGED
@@ -20,6 +20,7 @@ import { loadEcosystemRegistry } from "./ecosystem.js";
20
20
  import { startServerMode, parsePort, parseTransport } from "./server-mode.js";
21
21
  import { connectCliServer } from "./client-mode.js";
22
22
  import type { ColdCommand, CommandRun } from "./command.js";
23
+ import { autoWarm, autoWarmConfig, autoWarmSteps, parseIdleTimeout, type AutoWarmConfig } from "./auto-warm.js";
23
24
  import { startRepl } from "./repl.js";
24
25
  import { findChildren, findProcedure, parseCommandLine, parseLeadingGlobals } from "./args.js";
25
26
  import { groupHelp, rootHelp, validationMessage } from "@procedures/cli-kit";
@@ -86,7 +87,7 @@ function showRootHelp(procedures: readonly CommandLike[]): void {
86
87
  footer: [
87
88
  "Hosts: 'mark serve [--procedures pkgs] [--port N] [--expose patterns] [--events topics]' (the procedure server),",
88
89
  " 'mark mcp' (the dev-tools MCP server over stdio).",
89
- "Modes: 'mark --server [--port N] [--host H] [--transport http|websocket|both] [--events topics] [--probe-port N]' and 'mark -i' (the REPL).",
90
+ "Modes: 'mark --server [--port N] [--host H] [--transport http|websocket|both] [--events topics] [--probe-port N] [--idle-timeout ms]' and 'mark -i' (the REPL).",
90
91
  "Root: 'mark --fs-root <folder> <command>' keeps each path of each procedure in the folder, also in each mode and host.",
91
92
  " It refuses the procedures that it cannot confine: git, pnpm, docker run, a program (FORBIDDEN, outside-root).",
92
93
  ],
@@ -95,7 +96,7 @@ function showRootHelp(procedures: readonly CommandLike[]): void {
95
96
  }
96
97
 
97
98
  /** The global flags that only `--server` takes. */
98
- const SERVER_OPTIONS = ["port", "host", "transport", "events", "probe-port"] as const;
99
+ const SERVER_OPTIONS = ["port", "host", "transport", "events", "probe-port", "idle-timeout"] as const;
99
100
 
100
101
  /** True when the global flags give an option of `--server`. */
101
102
  function hasServerOption(globals: Readonly<Record<string, unknown>>): boolean {
@@ -157,6 +158,11 @@ export interface ExecuteOptions {
157
158
  * then installs no signal handler, does not end the process, and records its exit code in `process.exitCode`.
158
159
  */
159
160
  ownsProcess?: boolean | undefined;
161
+ /**
162
+ * The auto-warm (CLI-27). With the setting `after-cold`, a cold command starts the background
163
+ * host before it ends. Without it, no command starts a host. The bin reads it from `MARK_AUTO_WARM`.
164
+ */
165
+ autoWarm?: AutoWarmConfig | undefined;
160
166
  }
161
167
 
162
168
  /** The registry of the host of a context: its registry, or a registry of its procedures. */
@@ -252,7 +258,7 @@ export async function executeArgs(
252
258
  return;
253
259
  }
254
260
  if (hasServerOption(globals)) {
255
- fail("--port, --host, --transport, --events and --probe-port are options of --server.");
261
+ fail("--port, --host, --transport, --events, --probe-port and --idle-timeout are options of --server.");
256
262
  return;
257
263
  }
258
264
  // The root applies to the whole run: the procedures load with it, and a warm server must have it
@@ -315,6 +321,7 @@ export async function executeArgs(
315
321
  fail(`Error: ${messageOf(error)}`);
316
322
  }
317
323
  });
324
+ await warmAfterCold(cold, options, path, globals.local === true, ctx);
318
325
  await endCommand(cold.command, end, failed);
319
326
  return;
320
327
  }
@@ -473,9 +480,52 @@ export async function executeArgs(
473
480
  failCommand(`Error: ${messageOf(error)}`);
474
481
  }
475
482
  });
483
+ await warmAfterCold(cold, options, path, globals.local === true, ctx);
476
484
  await endCommand(cold.command, end, failed);
477
485
  }
478
486
 
487
+ /**
488
+ * This function does the auto-warm after a cold command (CLI-27), before the end of its service:
489
+ * with the process of Node, `endCommand()` ends the process. The auto-warm does not operate with
490
+ * `--local`, for a `server.*` command, or after a signal started the stop. An error of the
491
+ * auto-warm does not change the command: with `--verbose`, it prints a warning.
492
+ */
493
+ async function warmAfterCold(
494
+ cold: ColdCommand,
495
+ options: ExecuteOptions,
496
+ path: readonly string[],
497
+ local: boolean,
498
+ ctx: CliContext | ManifestContext
499
+ ): Promise<void> {
500
+ const config = options.autoWarm;
501
+ if (config === undefined || config.setting === "never" || local || path[0] === "server") return;
502
+ if (cold.command.life.state !== "running") return;
503
+ try {
504
+ const { currentBuild } = await import("./lockfile.js");
505
+ const { spawnMarkServer } = await import("@procedures/server");
506
+ const steps = autoWarmSteps(config, { cwd: process.cwd(), build: currentBuild(), fsRoot: ctx.fsRoot }, (spawn) => spawnMarkServer(spawn).child);
507
+ const decision = await autoWarm(config.setting, steps);
508
+ if (ctx.verbose) {
509
+ print.muted(decision.spawn ? `mark: the background host starts on port ${config.port} (${config.setting})` : `mark: no background host: ${decision.reason}`);
510
+ }
511
+ } catch (error) {
512
+ if (ctx.verbose) print.warning(`mark: the auto-warm failed: ${messageOf(error)}`);
513
+ }
514
+ }
515
+
516
+ /**
517
+ * This function reads the auto-warm from the environment. An invalid value gives a message on the
518
+ * standard error and no auto-warm, because the auto-warm does not change the result of a command.
519
+ */
520
+ function readAutoWarm(): AutoWarmConfig | undefined {
521
+ try {
522
+ return autoWarmConfig();
523
+ } catch (error) {
524
+ print.error(`mark: no auto-warm: ${messageOf(error)}`);
525
+ return undefined;
526
+ }
527
+ }
528
+
479
529
  /**
480
530
  * This function starts the cold mode of a command: the procedures host of the CLI server, in this
481
531
  * process, under `run()` (CLI-26). After a startup that failed, it prints the errors, waits for the
@@ -605,7 +655,7 @@ async function startMcp(args: string[], fsRoot?: string): Promise<void> {
605
655
  }
606
656
 
607
657
  /** The options of `run()` and `main()`: the process of the service of each command (CLI-26). */
608
- export type RunCliOptions = Pick<ExecuteOptions, "run" | "ownsProcess">;
658
+ export type RunCliOptions = Pick<ExecuteOptions, "run" | "ownsProcess" | "autoWarm">;
609
659
 
610
660
  /**
611
661
  * Run the CLI. By default, the caller owns the process: a command records its exit code in
@@ -647,6 +697,8 @@ async function run(argv: string[], options: RunCliOptions = {}): Promise<void> {
647
697
  const events = await eventPatterns(globals.events);
648
698
  // --probe-port: the probes answer on a port of their own (CLI-9, decision D5)
649
699
  const probePort = globals["probe-port"] === undefined ? undefined : parsePort(String(globals["probe-port"]));
700
+ // --idle-timeout: the server stops by itself after this time with no call (CLI-27)
701
+ const idleTimeoutMs = globals["idle-timeout"] === undefined ? undefined : parseIdleTimeout(String(globals["idle-timeout"]));
650
702
  await startServerMode({
651
703
  port,
652
704
  host,
@@ -654,13 +706,14 @@ async function run(argv: string[], options: RunCliOptions = {}): Promise<void> {
654
706
  verbose,
655
707
  events,
656
708
  ...(probePort !== undefined ? { probePort } : {}),
709
+ ...(idleTimeoutMs !== undefined ? { idleTimeoutMs } : {}),
657
710
  ...(fsRoot !== undefined ? { fsRoot } : {}),
658
711
  });
659
712
  return;
660
713
  }
661
714
  // Before, `mark --port 4000 serve` ignored --port without a word
662
715
  if (hasServerOption(globals)) {
663
- fail("--port, --host, --transport, --events and --probe-port are options of --server.", "For mark serve, give them after serve.");
716
+ fail("--port, --host, --transport, --events, --probe-port and --idle-timeout are options of --server.", "For mark serve, give them after serve.");
664
717
  return;
665
718
  }
666
719
 
@@ -694,11 +747,15 @@ async function run(argv: string[], options: RunCliOptions = {}): Promise<void> {
694
747
  return;
695
748
  }
696
749
 
750
+ // The auto-warm (CLI-27): the bin reads MARK_AUTO_WARM. Another caller gives the option, or has none.
751
+ const warm = options.autoWarm ?? (options.ownsProcess === true ? readAutoWarm() : undefined);
752
+ const execute: ExecuteOptions = { ...options, ...(warm !== undefined ? { autoWarm: warm } : {}) };
753
+
697
754
  // The manifest gives the commands without the procedures: the help and a call to the CLI
698
755
  // server do not load them (roadmap 1.3)
699
756
  const manifest = readFreshManifest();
700
757
  if (manifest) {
701
- await executeArgs(argv, { commands: manifest.commands, verbose, fsRoot, load: () => initCli(verbose, fsRoot) }, options);
758
+ await executeArgs(argv, { commands: manifest.commands, verbose, fsRoot, load: () => initCli(verbose, fsRoot) }, execute);
702
759
  return;
703
760
  }
704
761
 
@@ -712,7 +769,7 @@ async function run(argv: string[], options: RunCliOptions = {}): Promise<void> {
712
769
  print.warning(`mark: the manifest was not written: ${error instanceof Error ? error.message : String(error)}`);
713
770
  }
714
771
  }
715
- await executeArgs(argv, ctx, options);
772
+ await executeArgs(argv, ctx, execute);
716
773
  }
717
774
 
718
775
  /**
@@ -36,6 +36,11 @@ export interface ServerModeOptions {
36
36
  * listener answers the probes under `/_probes`.
37
37
  */
38
38
  probePort?: number | undefined;
39
+ /**
40
+ * The idle stop (`--idle-timeout`, CLI-27): the server stops by itself after this number of
41
+ * milliseconds with no call. Without it, the server stops only at a signal or at `mark server stop`.
42
+ */
43
+ idleTimeoutMs?: number | undefined;
39
44
  }
40
45
 
41
46
  /**
@@ -112,6 +117,7 @@ export async function startServerMode(options: ServerModeOptions, run?: Procedur
112
117
  // The options of the host: the same registry and policy as the cold mode of mark (CLI-26)
113
118
  const { markHostOptions } = await import("./command.js");
114
119
  const { runProcedureHost } = await import("@procedures/server");
120
+ const { ms } = await import("@procedures/client");
115
121
  const id = `peer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
116
122
 
117
123
  // The lockfile for client discovery. A client uses this server only from the same folder,
@@ -137,6 +143,8 @@ export async function startServerMode(options: ServerModeOptions, run?: Procedur
137
143
  log: (line) => print.info(line),
138
144
  // --probe-port: the probes answer on a port of their own, else on each listener under /_probes (CLI-9, D5)
139
145
  ...(options.probePort !== undefined ? { probes: { port: options.probePort, host } } : {}),
146
+ // --idle-timeout: the stop request of the lifecycle after a time with no call (CLI-27)
147
+ ...(options.idleTimeoutMs !== undefined ? { idle: { after: ms(options.idleTimeoutMs) } } : {}),
140
148
  ...(run ? { run } : {}),
141
149
  });
142
150
  if (running.life.state !== "running") {
@@ -164,6 +172,9 @@ export async function startServerMode(options: ServerModeOptions, run?: Procedur
164
172
  print.info(`File root: ${fsRoot} (each procedure refuses a path outside it)`);
165
173
  print.info(` Refused under the root: ${refusedUnderRoot?.length ?? 0} procedures that the root cannot confine`);
166
174
  }
175
+ if (options.idleTimeoutMs !== undefined) {
176
+ print.info(`\nIdle stop: after ${options.idleTimeoutMs} ms with no call`);
177
+ }
167
178
  print.info("\nPress Ctrl+C to stop.");
168
179
 
169
180
  // A signal starts the stop in stages, and the runner ends the process (CLI-8)