@cpajs/cpa 0.0.1-alpha-3 → 0.1.0-rc.2

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.
Files changed (62) hide show
  1. package/README.md +108 -12
  2. package/dist/client/resources/oauth.d.ts +6 -2
  3. package/dist/client/resources/oauth.d.ts.map +1 -1
  4. package/dist/client/resources/oauth.js +28 -8
  5. package/dist/client/resources/oauth.js.map +1 -1
  6. package/dist/client/transport.d.ts.map +1 -1
  7. package/dist/client/transport.js +7 -3
  8. package/dist/client/transport.js.map +1 -1
  9. package/dist/server/args.js +1 -1
  10. package/dist/server/args.js.map +1 -1
  11. package/dist/server/binary.d.ts +19 -0
  12. package/dist/server/binary.d.ts.map +1 -1
  13. package/dist/server/binary.js +35 -1
  14. package/dist/server/binary.js.map +1 -1
  15. package/dist/server/config.d.ts +46 -7
  16. package/dist/server/config.d.ts.map +1 -1
  17. package/dist/server/config.js +148 -22
  18. package/dist/server/config.js.map +1 -1
  19. package/dist/server/errors.d.ts +4 -2
  20. package/dist/server/errors.d.ts.map +1 -1
  21. package/dist/server/errors.js +1 -1
  22. package/dist/server/errors.js.map +1 -1
  23. package/dist/server/index.d.ts +18 -1
  24. package/dist/server/index.d.ts.map +1 -1
  25. package/dist/server/index.js +383 -79
  26. package/dist/server/index.js.map +1 -1
  27. package/dist/server/internal.d.ts +19 -0
  28. package/dist/server/internal.d.ts.map +1 -0
  29. package/dist/server/internal.js +10 -0
  30. package/dist/server/internal.js.map +1 -0
  31. package/dist/server/management.d.ts +29 -0
  32. package/dist/server/management.d.ts.map +1 -0
  33. package/dist/server/management.js +64 -0
  34. package/dist/server/management.js.map +1 -0
  35. package/dist/server/process.d.ts +12 -1
  36. package/dist/server/process.d.ts.map +1 -1
  37. package/dist/server/process.js +55 -13
  38. package/dist/server/process.js.map +1 -1
  39. package/dist/server/readiness.d.ts +16 -2
  40. package/dist/server/readiness.d.ts.map +1 -1
  41. package/dist/server/readiness.js +32 -2
  42. package/dist/server/readiness.js.map +1 -1
  43. package/dist/server/targets.d.ts +4 -0
  44. package/dist/server/targets.d.ts.map +1 -1
  45. package/dist/server/targets.js +6 -0
  46. package/dist/server/targets.js.map +1 -1
  47. package/dist/server/types.d.ts +88 -14
  48. package/dist/server/types.d.ts.map +1 -1
  49. package/package.json +31 -24
  50. package/src/client/resources/oauth.ts +28 -13
  51. package/src/client/transport.ts +7 -3
  52. package/src/server/args.ts +1 -1
  53. package/src/server/binary.ts +46 -1
  54. package/src/server/config.ts +169 -22
  55. package/src/server/errors.ts +4 -2
  56. package/src/server/index.ts +459 -94
  57. package/src/server/internal.ts +20 -0
  58. package/src/server/management.ts +89 -0
  59. package/src/server/process.ts +60 -13
  60. package/src/server/readiness.ts +38 -3
  61. package/src/server/targets.ts +8 -0
  62. package/src/server/types.ts +90 -14
@@ -0,0 +1,89 @@
1
+ import { compare, hash } from 'bcryptjs';
2
+ import { CpaStartError } from './errors.ts';
3
+ import type { ManagementOptions } from './types.ts';
4
+
5
+ /** bcrypt cost; matches upstream's `bcrypt.DefaultCost` (about 50-100 ms per hash). */
6
+ export const BCRYPT_COST = 10;
7
+
8
+ // bcrypt only uses the first 72 bytes and upstream's Go implementation rejects
9
+ // longer passwords, so a hash of a longer key could never authenticate.
10
+ const MAX_BCRYPT_BYTES = 72;
11
+
12
+ export interface ResolvedManagement {
13
+ /** The plaintext key, when the caller supplied one. */
14
+ key: string | undefined;
15
+ /** True when the key travels through `MANAGEMENT_PASSWORD`. */
16
+ viaEnvironment: boolean;
17
+ /** Value for `management.allow-remote` in a managed config. */
18
+ allowRemote: boolean;
19
+ /** bcrypt hash for `management.secret-key` (default mode only). */
20
+ secretKeyHash: string | undefined;
21
+ }
22
+
23
+ /**
24
+ * Normalises the management options and hashes the key for the default mode.
25
+ * The deprecated `managementKey` alias maps to the same safe default, not to
26
+ * the old `MANAGEMENT_PASSWORD` behaviour.
27
+ */
28
+ export async function resolveManagement(options: {
29
+ management?: ManagementOptions;
30
+ managementKey?: string;
31
+ }): Promise<ResolvedManagement> {
32
+ if (options.management !== undefined && options.managementKey !== undefined) {
33
+ throw new CpaStartError(
34
+ 'invalid-options',
35
+ 'management and managementKey are mutually exclusive; managementKey is the deprecated alias for management.key.',
36
+ );
37
+ }
38
+ const management =
39
+ options.management ?? (options.managementKey === undefined ? undefined : { key: options.managementKey });
40
+ if (management === undefined) {
41
+ return {
42
+ key: undefined,
43
+ viaEnvironment: false,
44
+ allowRemote: false,
45
+ secretKeyHash: undefined,
46
+ };
47
+ }
48
+ const key = management.key;
49
+ if (typeof key !== 'string' || key.trim() === '') {
50
+ throw new CpaStartError('invalid-options', 'management.key must not be empty.');
51
+ }
52
+ if (management.viaEnvironment === true) {
53
+ // Upstream trims MANAGEMENT_PASSWORD, so a key with surrounding whitespace
54
+ // would not match what the caller sends from its own client.
55
+ if (key !== key.trim()) {
56
+ throw new CpaStartError('invalid-options', 'management.key must not start or end with whitespace when viaEnvironment is true.');
57
+ }
58
+ return {
59
+ key,
60
+ viaEnvironment: true,
61
+ allowRemote: management.allowRemote === true,
62
+ secretKeyHash: undefined,
63
+ };
64
+ }
65
+ if (Buffer.byteLength(key, 'utf8') > MAX_BCRYPT_BYTES) {
66
+ throw new CpaStartError(
67
+ 'invalid-options',
68
+ `management.key is longer than ${MAX_BCRYPT_BYTES} bytes, which bcrypt cannot represent. Use management.viaEnvironment: true for a longer key.`,
69
+ );
70
+ }
71
+ return {
72
+ key,
73
+ viaEnvironment: false,
74
+ allowRemote: management.allowRemote === true,
75
+ secretKeyHash: await hash(key, BCRYPT_COST),
76
+ };
77
+ }
78
+
79
+ const BCRYPT_HASH = /^\$2[aby]\$/;
80
+
81
+ /**
82
+ * True when `stored` (a `management.secret-key` value) authenticates `key` the
83
+ * way upstream checks it: bcrypt hashes are compared, a plaintext value is
84
+ * hashed by CPA on load and so must equal the key.
85
+ */
86
+ export async function keyMatches(key: string, stored: string | undefined): Promise<boolean> {
87
+ if (stored === undefined) return false;
88
+ return BCRYPT_HASH.test(stored) ? compare(key, stored) : stored === key;
89
+ }
@@ -3,16 +3,23 @@ import type { Readable } from 'node:stream';
3
3
  import type { ExitResult, LogLine } from './types.ts';
4
4
 
5
5
  const MAX_RECENT_LINES = 200;
6
+ /** Longest line kept, in UTF-16 code units; longer output is truncated with a marker. */
7
+ export const MAX_LINE_LENGTH = 64 * 1024;
6
8
 
7
- // Children still running when the parent exits are killed so they never outlive it.
8
- const live = new Set<ChildProcess>();
9
+ // Children still running when this process exits normally (or calls
10
+ // process.exit()) are killed. A fatal signal with no listener, e.g. a default
11
+ // SIGTERM or SIGKILL, ends Node without emitting 'exit'; there the launcher
12
+ // (cpa-launcher, see binary.ts) stops CPA instead. This library does not
13
+ // install signal handlers, because doing so would change how the host process
14
+ // reacts to them.
15
+ const live = new Set<ManagedProcess>();
9
16
  let exitHookInstalled = false;
10
- function trackChild(child: ChildProcess): void {
11
- live.add(child);
17
+ function track(proc: ManagedProcess): void {
18
+ live.add(proc);
12
19
  if (exitHookInstalled) return;
13
20
  exitHookInstalled = true;
14
21
  process.once('exit', () => {
15
- for (const c of live) c.kill('SIGKILL');
22
+ for (const p of live) p.kill('SIGKILL');
16
23
  });
17
24
  }
18
25
 
@@ -21,6 +28,12 @@ export interface SpawnOptions {
21
28
  env: NodeJS.ProcessEnv;
22
29
  redact: (text: string) => string;
23
30
  onLog?: ((log: LogLine) => void) | undefined;
31
+ /**
32
+ * Supervise mode of cpa-launcher: start it in its own process group, signal
33
+ * the whole group (launcher and CPA), and keep a stdin pipe open whose EOF
34
+ * tells the launcher that this process has died.
35
+ */
36
+ supervised?: boolean;
24
37
  }
25
38
 
26
39
  /** A spawned CPA process with line-buffered, redacted output and idempotent stop. */
@@ -35,18 +48,26 @@ export class ManagedProcess {
35
48
  readonly #redact: (text: string) => string;
36
49
  readonly #onLog: ((log: LogLine) => void) | undefined;
37
50
  readonly #lineListeners = new Set<(log: LogLine) => void>();
51
+ readonly #supervised: boolean;
38
52
  #stopping: Promise<ExitResult> | undefined;
39
53
 
40
54
  constructor(binaryPath: string, args: readonly string[], options: SpawnOptions) {
41
55
  this.#redact = options.redact;
42
56
  this.#onLog = options.onLog;
57
+ this.#supervised = options.supervised === true;
43
58
  this.child = spawn(binaryPath, [...args], {
44
59
  cwd: options.cwd,
45
60
  env: options.env,
46
- stdio: ['ignore', 'pipe', 'pipe'],
61
+ // The supervised launcher's stdin is a liveness pipe: never written, closed by
62
+ // the OS when this process dies.
63
+ stdio: [this.#supervised ? 'pipe' : 'ignore', 'pipe', 'pipe'],
64
+ // Its own process group, so stop() can signal the launcher and CPA together.
65
+ detached: this.#supervised,
47
66
  windowsHide: true,
48
67
  });
49
- trackChild(this.child);
68
+ // A liveness pipe with nobody reading it is expected after the launcher exits.
69
+ this.child.stdin?.on('error', () => {});
70
+ track(this);
50
71
 
51
72
  this.exited = new Promise<ExitResult>((resolve) => {
52
73
  this.child.once('error', (error) => {
@@ -64,7 +85,7 @@ export class ManagedProcess {
64
85
  #finish(resolve: (result: ExitResult) => void, result: ExitResult): void {
65
86
  if (this.exitResult) return;
66
87
  this.exitResult = result;
67
- live.delete(this.child);
88
+ live.delete(this);
68
89
  resolve(result);
69
90
  }
70
91
 
@@ -72,8 +93,14 @@ export class ManagedProcess {
72
93
  if (!stream) return;
73
94
  stream.setEncoding('utf8');
74
95
  let pending = '';
96
+ // Characters dropped from the line currently being received; bounds memory
97
+ // when the child writes very long or never-terminated lines.
98
+ let dropped = 0;
75
99
  const emit = (raw: string): void => {
76
- const log: LogLine = { stream: name, line: this.#redact(raw.replace(/\r$/, '')) };
100
+ const text = raw.replace(/\r$/, '');
101
+ const line = dropped > 0 ? `${text} …[truncated ${dropped} chars]` : text;
102
+ dropped = 0;
103
+ const log: LogLine = { stream: name, line: this.#redact(line) };
77
104
  this.recentLogs.push(log);
78
105
  if (this.recentLogs.length > MAX_RECENT_LINES) this.recentLogs.shift();
79
106
  for (const listener of this.#lineListeners) listener(log);
@@ -83,9 +110,14 @@ export class ManagedProcess {
83
110
  pending += chunk;
84
111
  let index: number;
85
112
  while ((index = pending.indexOf('\n')) !== -1) {
86
- emit(pending.slice(0, index));
113
+ if (index > MAX_LINE_LENGTH) dropped += index - MAX_LINE_LENGTH;
114
+ emit(pending.slice(0, Math.min(index, MAX_LINE_LENGTH)));
87
115
  pending = pending.slice(index + 1);
88
116
  }
117
+ if (pending.length > MAX_LINE_LENGTH) {
118
+ dropped += pending.length - MAX_LINE_LENGTH;
119
+ pending = pending.slice(0, MAX_LINE_LENGTH);
120
+ }
89
121
  });
90
122
  stream.on('end', () => {
91
123
  if (pending) emit(pending);
@@ -93,6 +125,20 @@ export class ManagedProcess {
93
125
  });
94
126
  }
95
127
 
128
+ /** Signals the process, or the launcher's whole process group in supervise mode. */
129
+ kill(signal: NodeJS.Signals): void {
130
+ const pid = this.child.pid;
131
+ if (this.#supervised && pid !== undefined) {
132
+ try {
133
+ process.kill(-pid, signal);
134
+ return;
135
+ } catch {
136
+ // The group is already gone; fall back to the direct child.
137
+ }
138
+ }
139
+ this.child.kill(signal);
140
+ }
141
+
96
142
  onLine(listener: (log: LogLine) => void): () => void {
97
143
  this.#lineListeners.add(listener);
98
144
  return () => this.#lineListeners.delete(listener);
@@ -104,19 +150,20 @@ export class ManagedProcess {
104
150
 
105
151
  /**
106
152
  * Sends SIGTERM and waits up to `timeoutMs` before SIGKILL. On Windows Node
107
- * can only terminate the process forcefully, so both steps are immediate.
153
+ * cannot deliver SIGTERM: both steps terminate the process forcefully, so
154
+ * there is no grace period and the timeout is irrelevant.
108
155
  */
109
156
  stop(timeoutMs: number): Promise<ExitResult> {
110
157
  if (this.exitResult) return Promise.resolve(this.exitResult);
111
158
  this.#stopping ??= (async () => {
112
- this.child.kill('SIGTERM');
159
+ this.kill('SIGTERM');
113
160
  let timer: NodeJS.Timeout | undefined;
114
161
  const timedOut = new Promise<'timeout'>((resolve) => {
115
162
  timer = setTimeout(() => resolve('timeout'), timeoutMs);
116
163
  });
117
164
  const first = await Promise.race([this.exited, timedOut]);
118
165
  clearTimeout(timer);
119
- if (first === 'timeout') this.child.kill('SIGKILL');
166
+ if (first === 'timeout') this.kill('SIGKILL');
120
167
  return this.exited;
121
168
  })();
122
169
  return this.#stopping;
@@ -9,6 +9,15 @@ export function connectHost(bindHost: string): string {
9
9
  return bindHost;
10
10
  }
11
11
 
12
+ /** True when a bind host only accepts loopback connections. */
13
+ export function isLoopbackHost(host: string): boolean {
14
+ const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, '');
15
+ if (normalized === '' || normalized === '0.0.0.0' || normalized === '::') return false;
16
+ if (normalized === 'localhost' || normalized === '::1') return true;
17
+ if (normalized.startsWith('::ffff:127.')) return true;
18
+ return /^127(?:\.\d{1,3}){3}$/.test(normalized);
19
+ }
20
+
12
21
  export function formatOrigin(protocol: 'http' | 'https', host: string, port: number): string {
13
22
  const h = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
14
23
  return `${protocol}://${h}:${port}`;
@@ -32,8 +41,21 @@ export function probeHealth(origin: string, timeoutMs: number): Promise<boolean>
32
41
  });
33
42
  }
34
43
 
35
- /** Asks the OS for a currently free TCP port on `host`. */
36
- export function findFreePort(host: string): Promise<number> {
44
+ /** A free port whose probing listener stays open until `release()` is called. */
45
+ export interface PortReservation {
46
+ port: number;
47
+ /** Closes the probing listener so a child process can bind the port. */
48
+ release(): Promise<void>;
49
+ }
50
+
51
+ /**
52
+ * Reserves a currently free TCP port on `host`.
53
+ *
54
+ * The listening socket is left open until `release()` so another process cannot
55
+ * take the port while the caller finishes preparing the child. The window
56
+ * between `release()` and the child's own bind cannot be removed entirely.
57
+ */
58
+ export function findFreePort(host: string): Promise<PortReservation> {
37
59
  return new Promise((resolve, reject) => {
38
60
  const server = createServer();
39
61
  server.unref();
@@ -41,7 +63,20 @@ export function findFreePort(host: string): Promise<number> {
41
63
  server.listen({ host: connectHost(host), port: 0, exclusive: true }, () => {
42
64
  const address = server.address();
43
65
  const port = typeof address === 'object' && address ? address.port : 0;
44
- server.close(() => (port ? resolve(port) : reject(new Error('Could not allocate a free port.'))));
66
+ if (!port) {
67
+ server.close(() => reject(new Error('Could not allocate a free port.')));
68
+ return;
69
+ }
70
+ let released: Promise<void> | undefined;
71
+ resolve({
72
+ port,
73
+ release() {
74
+ released ??= new Promise<void>((resolveClose) => {
75
+ server.close(() => resolveClose());
76
+ });
77
+ return released;
78
+ },
79
+ });
45
80
  });
46
81
  });
47
82
  }
@@ -131,3 +131,11 @@ export function packageName(target: Target): string {
131
131
  export function binaryName(target: Pick<Target, 'os'>): string {
132
132
  return target.os === 'win32' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
133
133
  }
134
+
135
+ /** Name of the launcher shipped next to the CPA binary. Windows needs none (see binary.ts). */
136
+ export const LAUNCHER_NAME = 'cpa-launcher';
137
+
138
+ /** True for targets whose platform package ships `cpa-launcher`. */
139
+ export function hasLauncher(target: Pick<Target, 'os'>): boolean {
140
+ return target.os !== 'win32';
141
+ }
@@ -18,6 +18,23 @@ export interface HomeOptions {
18
18
  disableClusterDiscovery?: boolean;
19
19
  }
20
20
 
21
+ export interface ManagementOptions {
22
+ /** Management API key. Empty keys are rejected. */
23
+ key: string;
24
+ /**
25
+ * Write `management.allow-remote`, which lets non-loopback clients use the
26
+ * management API. Default `false`; ignored by `viaEnvironment`, which always
27
+ * enables remote access upstream.
28
+ */
29
+ allowRemote?: boolean;
30
+ /**
31
+ * Pass the key to CPA through `MANAGEMENT_PASSWORD` instead of writing a
32
+ * bcrypt hash into the managed config. Upstream treats that variable as
33
+ * enabling remote management and compares the key in plaintext.
34
+ */
35
+ viaEnvironment?: boolean;
36
+ }
37
+
21
38
  interface CommonServerOptions {
22
39
  /** Use this executable instead of the bundled platform package. */
23
40
  binaryPath?: string;
@@ -26,10 +43,15 @@ interface CommonServerOptions {
26
43
  * directory. When omitted, a temporary directory is created and removed on stop.
27
44
  */
28
45
  stateDir?: string;
46
+ /** Management API key and how it reaches CPA. */
47
+ management?: ManagementOptions;
29
48
  /**
30
- * Management key passed to CPA through the `MANAGEMENT_PASSWORD` environment
31
- * variable, so it never touches disk. Note that upstream treats this variable
32
- * as allowing remote management access regardless of `management.allow-remote`.
49
+ * Deprecated alias for `management: { key }`. It uses the safe default
50
+ * (bcrypt hash in the config, remote management disabled) and will be removed
51
+ * in a future release. Use `management.viaEnvironment` for the old
52
+ * `MANAGEMENT_PASSWORD` behaviour.
53
+ *
54
+ * @deprecated Use `management` instead.
33
55
  */
34
56
  managementKey?: string;
35
57
  /**
@@ -37,6 +59,22 @@ interface CommonServerOptions {
37
59
  * (`-local-model`). Explicit `models.*` config sources still win.
38
60
  */
39
61
  localModel?: boolean;
62
+ /**
63
+ * Start CPA through `cpa-launcher` so it cannot outlive this process, even
64
+ * when this process is killed with SIGKILL or crashes. Default `true`.
65
+ *
66
+ * - Linux: the launcher sets PR_SET_PDEATHSIG and execs CPA (no extra process).
67
+ * - macOS / BSD: the launcher stays as CPA's parent and stops it when this
68
+ * process dies; `pid` is then the launcher's pid, and signals sent to it are
69
+ * forwarded to CPA.
70
+ * - Windows: not needed; Node already kills child processes with their parent.
71
+ *
72
+ * The launcher ships next to the CPA binary in every platform package. With a
73
+ * custom `binaryPath` it is looked up next to that binary and then in an
74
+ * installed platform package; if none is found CPA is started directly and a
75
+ * notice is logged through `onLog`.
76
+ */
77
+ launcher?: boolean;
40
78
  /** Run as a node of a CPA Home control plane. Home mode disables the local management API. */
41
79
  home?: HomeOptions;
42
80
  /**
@@ -55,25 +93,41 @@ interface CommonServerOptions {
55
93
  onLog?: (log: LogLine) => void;
56
94
  /** Additional strings to redact from logs and errors. */
57
95
  redact?: readonly string[];
58
- /** Default grace period for `stop()` before the process is killed. Default 10 000 ms. */
96
+ /** Default grace period for `stop()` before the process is killed. Ignored on Windows, where `stop()` terminates forcefully. Default 10 000 ms. */
59
97
  stopTimeoutMs?: number;
60
98
  }
61
99
 
62
100
  export interface StartWithConfigOptions extends CommonServerOptions {
63
101
  /**
64
- * Native CPA configuration. It is written to `<stateDir>/config.yaml` on
65
- * every start, replacing whatever CPA persisted there during the last run.
102
+ * Native CPA configuration.
103
+ *
104
+ * Written as YAML to `<stateDir>/config.yaml` only when that file does not
105
+ * exist. On later starts the existing file is reused, so changes made through
106
+ * the management API (or by CPA itself) survive a `stop()` / `startServer()`
107
+ * cycle. Set `overwriteConfig: true` to replace it with this object.
66
108
  */
67
109
  config?: CpaConfig;
68
110
  configPath?: never;
69
- /** Bind host. Overrides `config.server.host`. Default `127.0.0.1`. */
111
+ /**
112
+ * Replace `<stateDir>/config.yaml` even when it already exists. Default
113
+ * `false`. When the existing file is kept, `config`, `host` and `port` are not
114
+ * applied and a message is emitted through `onLog`.
115
+ */
116
+ overwriteConfig?: boolean;
117
+ /** Bind host, when cpajs writes the managed file. Default `config.server.host`, else `127.0.0.1`. */
70
118
  host?: string;
71
- /** Bind port. Overrides `config.server.port`. Default `0`, meaning a free port is picked. */
119
+ /** Bind port, when cpajs writes the managed file. Default `config.server.port`, else `0` (a free port is picked). */
72
120
  port?: number;
73
121
  }
74
122
 
75
123
  export interface StartWithConfigPathOptions extends CommonServerOptions {
76
- /** Existing CPA config file, handed to CPA as-is. CPA may rewrite it (e.g. to hash `secret-key`). */
124
+ /**
125
+ * Existing CPA config file, handed to CPA as-is. CPA may rewrite it on
126
+ * startup: it hashes a plaintext `management.secret-key`, removes legacy
127
+ * keys that conflict with their v8 equivalents, and normalizes the layout.
128
+ * The file must set a fixed positive port; a missing or non-positive port
129
+ * makes CPA bind a random port it does not report.
130
+ */
77
131
  configPath: string;
78
132
  config?: never;
79
133
  host?: never;
@@ -88,10 +142,20 @@ export interface ExitResult {
88
142
  }
89
143
 
90
144
  export interface StopOptions {
91
- /** Grace period after the termination signal before the process is killed. */
145
+ /** Grace period after the termination signal before the process is killed. Ignored on Windows, where the process is terminated forcefully right away. */
92
146
  timeoutMs?: number;
93
147
  }
94
148
 
149
+ export interface RestartOptions {
150
+ /**
151
+ * Grace period after the termination signal before the old process is killed.
152
+ * Defaults to `stopTimeoutMs`.
153
+ */
154
+ timeoutMs?: number;
155
+ /** Cancels the restart. The server is left stopped, like any other failed restart. */
156
+ signal?: AbortSignal;
157
+ }
158
+
95
159
  export interface CpaServer {
96
160
  /** e.g. `http://127.0.0.1:8317`. Use as the client `baseUrl`. */
97
161
  readonly origin: string;
@@ -103,13 +167,25 @@ export interface CpaServer {
103
167
  readonly binaryPath: string;
104
168
  readonly configPath: string;
105
169
  readonly stateDir: string;
106
- /** The management key given in the options, if any. */
107
- readonly managementKey: string | undefined;
108
170
  readonly process: ChildProcess;
109
171
  /** Recent output lines (redacted), oldest first. */
110
172
  readonly recentLogs: readonly LogLine[];
111
- /** Settles when the process exits, whether or not `stop()` was called. */
173
+ /**
174
+ * Settles when the process exits for good: on `stop()` or an unexpected exit.
175
+ * A restart stops one process and starts another, so it does not settle this.
176
+ */
112
177
  readonly exited: Promise<ExitResult>;
113
- /** Terminates CPA gracefully, killing it after the grace period. Idempotent. */
178
+ /**
179
+ * Stops CPA. On POSIX it sends SIGTERM and then SIGKILL after the grace
180
+ * period. On Windows Node cannot deliver a graceful signal, so the process is
181
+ * terminated forcefully (there is no grace period and CPA is not asked to
182
+ * flush state or update auth files). Idempotent.
183
+ */
114
184
  stop(options?: StopOptions): Promise<ExitResult>;
185
+ /**
186
+ * Restarts CPA with the original options, reusing `origin`, `baseUrl`, `host`,
187
+ * `port`, `stateDir` and the existing config file. Concurrent calls share one
188
+ * operation. A failed or cancelled restart leaves the server stopped.
189
+ */
190
+ restart(options?: RestartOptions): Promise<void>;
115
191
  }