@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
@@ -1,12 +1,20 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
- import { parse as parseYaml } from 'yaml';
3
+ import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml';
4
4
  import type { CpaConfig } from '../client/types/config.ts';
5
5
  import { CpaStartError } from './errors.ts';
6
6
 
7
- export const DEFAULT_PORT = 8317;
8
7
  export const MANAGED_CONFIG_FILE = 'config.yaml';
9
8
 
9
+ export interface ManagementSettings {
10
+ /** `management.allow-remote`, falling back to the legacy `remote-management` key. */
11
+ allowRemote: boolean;
12
+ /** True when the document already has a non-empty `secret-key`. */
13
+ hasSecret: boolean;
14
+ /** The stored `secret-key` (plaintext or a bcrypt hash), if any. */
15
+ secretKey: string | undefined;
16
+ }
17
+
10
18
  export interface ListenSettings {
11
19
  /** The `server.host` value as configured; empty means all interfaces. */
12
20
  host: string;
@@ -20,27 +28,73 @@ function isRecord(value: unknown): value is Json {
20
28
  return typeof value === 'object' && value !== null && !Array.isArray(value);
21
29
  }
22
30
 
31
+ function hasOwn(value: Json, key: string): boolean {
32
+ return Object.hasOwn(value, key);
33
+ }
34
+
23
35
  /**
24
- * Reads listener settings from a parsed config document. The v8 `server.*`
25
- * keys win over the legacy top-level `host`/`port`/`tls` keys, mirroring CPA.
36
+ * Decodes a port like yaml.v3 decodes into an int: null (and a missing key)
37
+ * becomes 0, a finite float is truncated, and anything else is NaN. CPA does
38
+ * not default the port, so 0 must stay 0 instead of becoming 8317.
39
+ */
40
+ function readPort(value: unknown, present: boolean): number {
41
+ if (!present || value === null || value === undefined) return 0;
42
+ return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : Number.NaN;
43
+ }
44
+
45
+ /**
46
+ * Reads listener settings from a parsed config document using upstream's
47
+ * presence-based precedence: a `server.*` leaf wins whenever it is present,
48
+ * even when it is `0`, `false` or `null` (internal/config/config_v8.go).
49
+ * Legacy top-level `host`/`port`/`tls` are used only when that v8 leaf is
50
+ * absent, and `tls.enable` is resolved leaf by leaf rather than by replacing
51
+ * the whole `tls` object.
26
52
  */
27
53
  export function readListenSettings(doc: unknown): ListenSettings {
28
54
  const root = isRecord(doc) ? doc : {};
29
55
  const server = isRecord(root.server) ? root.server : {};
30
- const pick = (key: string): unknown => (key in server ? server[key] : root[key]);
56
+ const serverTls = isRecord(server.tls) ? server.tls : undefined;
57
+ const legacyTls = isRecord(root.tls) ? root.tls : undefined;
58
+
59
+ const v8Port = hasOwn(server, 'port');
60
+ const host = hasOwn(server, 'host') ? server.host : root.host;
61
+ const port = readPort(v8Port ? server.port : root.port, v8Port || hasOwn(root, 'port'));
62
+
63
+ let tls: unknown;
64
+ if (serverTls !== undefined && hasOwn(serverTls, 'enable')) tls = serverTls.enable;
65
+ else if (legacyTls !== undefined && hasOwn(legacyTls, 'enable')) tls = legacyTls.enable;
31
66
 
32
- const host = pick('host');
33
- const port = pick('port');
34
- const tls = pick('tls');
35
- const parsedPort = typeof port === 'number' ? port : typeof port === 'string' ? Number(port) : Number.NaN;
36
67
  return {
37
68
  host: typeof host === 'string' ? host.trim() : '',
38
- // CPA falls back to 8317 when the port is missing or not positive.
39
- port: Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : port === 0 ? 0 : DEFAULT_PORT,
40
- tls: isRecord(tls) && tls.enable === true,
69
+ port,
70
+ tls: tls === true,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Reads management settings from a parsed config document. The v8 `management`
76
+ * section wins over the legacy `remote-management` alias, mirroring CPA.
77
+ */
78
+ export function readManagementSettings(doc: unknown): ManagementSettings {
79
+ const root = isRecord(doc) ? doc : {};
80
+ const section = isRecord(root.management)
81
+ ? root.management
82
+ : isRecord(root['remote-management'])
83
+ ? root['remote-management']
84
+ : {};
85
+ const secret = section['secret-key'];
86
+ return {
87
+ allowRemote: section['allow-remote'] === true,
88
+ hasSecret: typeof secret === 'string' && secret.trim() !== '',
89
+ secretKey: typeof secret === 'string' && secret.trim() !== '' ? secret : undefined,
41
90
  };
42
91
  }
43
92
 
93
+ // Only known secret keys are collected. Open-ended maps such as `headers`,
94
+ // `payload` and `plugins.configs.*` can hold credentials under arbitrary names;
95
+ // they are deliberately skipped so an ordinary config value is never mistaken
96
+ // for a secret and redacted out of unrelated log lines. Pass `redact` to
97
+ // startServer for those.
44
98
  const SECRET_FIELDS = new Set(['secret-key', 'api-key', 'github-token', 'credential', 'access_token', 'refresh_token']);
45
99
  const SECRET_LISTS = new Set(['api-keys']);
46
100
 
@@ -68,27 +122,39 @@ export function configSecrets(doc: unknown): string[] {
68
122
  export interface PreparedConfig {
69
123
  configPath: string;
70
124
  listen: ListenSettings;
125
+ management: ManagementSettings;
71
126
  secrets: string[];
72
- /** True when the file was written by this package. */
127
+ /** True when the file belongs to the managed state directory. */
73
128
  managed: boolean;
129
+ /** True when an existing file was reused instead of written. */
130
+ reused: boolean;
74
131
  }
75
132
 
76
133
  /**
77
134
  * Writes a `config` object to the managed config file in `stateDir`.
78
135
  *
79
- * The object is serialised as JSON, which is valid YAML and is read by CPA's
80
- * YAML loader unchanged. Listener and auth directory defaults are filled in so
81
- * the process stays inside `stateDir` and binds to loopback unless told otherwise.
136
+ * The object is serialised as YAML so CPA and humans see the same stable shape.
137
+ * Listener and auth directory defaults are filled in so the process stays inside
138
+ * `stateDir` and binds to loopback unless told otherwise.
82
139
  */
83
140
  export async function writeManagedConfig(
84
141
  config: CpaConfig,
85
142
  stateDir: string,
86
143
  listen: { host: string; port: number },
144
+ management?: { secretKeyHash: string; allowRemote: boolean },
87
145
  ): Promise<PreparedConfig> {
88
146
  const doc = structuredClone(config) as Json;
89
147
  const server = isRecord(doc.server) ? doc.server : {};
90
148
  doc.server = { ...server, host: listen.host, port: listen.port };
91
149
  if (doc['config-version'] === undefined) doc['config-version'] = 8;
150
+ if (management !== undefined) {
151
+ const section = isRecord(doc.management) ? doc.management : {};
152
+ doc.management = {
153
+ ...section,
154
+ 'secret-key': management.secretKeyHash,
155
+ 'allow-remote': management.allowRemote,
156
+ };
157
+ }
92
158
  const oauth = isRecord(doc.oauth) ? doc.oauth : {};
93
159
  // Keep credentials inside stateDir unless the config chooses a location (v8 or legacy key).
94
160
  if (oauth['auth-dir'] === undefined && doc['auth-dir'] === undefined) {
@@ -98,8 +164,74 @@ export async function writeManagedConfig(
98
164
  }
99
165
 
100
166
  const configPath = join(stateDir, MANAGED_CONFIG_FILE);
101
- await writeFile(configPath, `${JSON.stringify(doc, null, 2)}\n`, { mode: 0o600 });
102
- return { configPath, listen: readListenSettings(doc), secrets: configSecrets(doc), managed: true };
167
+ await writeFile(configPath, stringifyYaml(doc), { mode: 0o600 });
168
+ return {
169
+ configPath,
170
+ listen: readListenSettings(doc),
171
+ management: readManagementSettings(doc),
172
+ secrets: configSecrets(doc),
173
+ managed: true,
174
+ reused: false,
175
+ };
176
+ }
177
+
178
+ function isFixedPort(port: number): boolean {
179
+ return Number.isInteger(port) && port > 0 && port <= 65_535;
180
+ }
181
+
182
+ /**
183
+ * Reads the managed config file in `stateDir` without modifying it.
184
+ *
185
+ * Returns `undefined` when the file does not exist yet. An existing file must
186
+ * set a fixed `server.port`, because CPA does not report the port it picks and
187
+ * a later start cannot discover it.
188
+ */
189
+ export async function readManagedConfig(stateDir: string): Promise<PreparedConfig | undefined> {
190
+ const configPath = join(stateDir, MANAGED_CONFIG_FILE);
191
+ let text: string;
192
+ try {
193
+ text = await readFile(configPath, 'utf8');
194
+ } catch (cause) {
195
+ if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
196
+ throw new CpaStartError('invalid-options', `Cannot read ${configPath}.`, { cause });
197
+ }
198
+ let doc: unknown;
199
+ try {
200
+ doc = parseYaml(text, { merge: true });
201
+ } catch (cause) {
202
+ throw new CpaStartError('invalid-options', `${configPath} is not valid YAML.`, { cause });
203
+ }
204
+ const listen = readListenSettings(doc);
205
+ if (!isFixedPort(listen.port)) {
206
+ throw new CpaStartError(
207
+ 'invalid-options',
208
+ `${configPath} does not set a fixed server.port. CLIProxyAPI does not report the port it picks; set server.port, delete the file, or pass overwriteConfig: true.`,
209
+ );
210
+ }
211
+ return {
212
+ configPath,
213
+ listen,
214
+ management: readManagementSettings(doc),
215
+ secrets: configSecrets(doc),
216
+ managed: true,
217
+ reused: true,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Sets `management.secret-key` and `management.allow-remote` in a reused
223
+ * managed config, leaving every other key and comment as CPA left them.
224
+ * The legacy `remote-management` section is dropped so it cannot shadow them.
225
+ */
226
+ export async function updateManagedSecret(
227
+ configPath: string,
228
+ management: { secretKeyHash: string; allowRemote: boolean },
229
+ ): Promise<void> {
230
+ const document = parseDocument(await readFile(configPath, 'utf8'), { merge: true });
231
+ document.delete('remote-management');
232
+ document.setIn(['management', 'secret-key'], management.secretKeyHash);
233
+ document.setIn(['management', 'allow-remote'], management.allowRemote);
234
+ await writeFile(configPath, document.toString(), { mode: 0o600 });
103
235
  }
104
236
 
105
237
  /** Reads an existing config file without modifying it. */
@@ -113,16 +245,31 @@ export async function inspectConfigFile(configPath: string): Promise<PreparedCon
113
245
  }
114
246
  let doc: unknown;
115
247
  try {
116
- doc = parseYaml(text);
248
+ doc = parseYaml(text, { merge: true });
117
249
  } catch (cause) {
118
250
  throw new CpaStartError('invalid-options', `configPath ${absolute} is not valid YAML.`, { cause });
119
251
  }
252
+ const root = isRecord(doc) ? doc : {};
253
+ if (hasOwn(root, 'server') && !isRecord(root.server)) {
254
+ throw new CpaStartError('invalid-options', `configPath ${absolute} has a "server" section that is not a mapping.`);
255
+ }
256
+ if (isRecord(root.server) && hasOwn(root.server, 'tls') && !isRecord(root.server.tls)) {
257
+ throw new CpaStartError('invalid-options', `configPath ${absolute} has a "server.tls" section that is not a mapping.`);
258
+ }
120
259
  const listen = readListenSettings(doc);
121
- if (listen.port === 0) {
260
+ if (!isFixedPort(listen.port)) {
261
+ const shown = Number.isNaN(listen.port) ? 'a non-numeric value' : String(listen.port);
122
262
  throw new CpaStartError(
123
263
  'invalid-options',
124
- `configPath ${absolute} sets port 0. CLIProxyAPI does not report the port it picks, so set a fixed port or pass a config object instead.`,
264
+ `configPath ${absolute} does not set a fixed port between 1 and 65535 (got ${shown}). CLIProxyAPI does not default the port: a missing or non-positive port becomes 0 and binds a random port it does not report. Set a fixed port or pass a config object instead.`,
125
265
  );
126
266
  }
127
- return { configPath: absolute, listen, secrets: configSecrets(doc), managed: false };
267
+ return {
268
+ configPath: absolute,
269
+ listen,
270
+ management: readManagementSettings(doc),
271
+ secrets: configSecrets(doc),
272
+ managed: false,
273
+ reused: false,
274
+ };
128
275
  }
@@ -23,9 +23,11 @@ export type CpaStartErrorCode =
23
23
  /** Readiness was not reached within `startupTimeoutMs`. */
24
24
  | 'timeout'
25
25
  /** The `signal` passed to `startServer` was aborted. */
26
- | 'aborted';
26
+ | 'aborted'
27
+ /** `restart` was called after the server stopped. */
28
+ | 'stopped';
27
29
 
28
- /** `startServer` failed. Secrets are redacted from `message` and `output`. */
30
+ /** `startServer` or `restart` failed. Secrets are redacted from `message` and `output`. */
29
31
  export class CpaStartError extends Error {
30
32
  override readonly name = 'CpaStartError';
31
33
  readonly code: CpaStartErrorCode;