@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.
- package/README.md +108 -12
- package/dist/client/resources/oauth.d.ts +6 -2
- package/dist/client/resources/oauth.d.ts.map +1 -1
- package/dist/client/resources/oauth.js +28 -8
- package/dist/client/resources/oauth.js.map +1 -1
- package/dist/client/transport.d.ts.map +1 -1
- package/dist/client/transport.js +7 -3
- package/dist/client/transport.js.map +1 -1
- package/dist/server/args.js +1 -1
- package/dist/server/args.js.map +1 -1
- package/dist/server/binary.d.ts +19 -0
- package/dist/server/binary.d.ts.map +1 -1
- package/dist/server/binary.js +35 -1
- package/dist/server/binary.js.map +1 -1
- package/dist/server/config.d.ts +46 -7
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +148 -22
- package/dist/server/config.js.map +1 -1
- package/dist/server/errors.d.ts +4 -2
- package/dist/server/errors.d.ts.map +1 -1
- package/dist/server/errors.js +1 -1
- package/dist/server/errors.js.map +1 -1
- package/dist/server/index.d.ts +18 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +383 -79
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.d.ts +19 -0
- package/dist/server/internal.d.ts.map +1 -0
- package/dist/server/internal.js +10 -0
- package/dist/server/internal.js.map +1 -0
- package/dist/server/management.d.ts +29 -0
- package/dist/server/management.d.ts.map +1 -0
- package/dist/server/management.js +64 -0
- package/dist/server/management.js.map +1 -0
- package/dist/server/process.d.ts +12 -1
- package/dist/server/process.d.ts.map +1 -1
- package/dist/server/process.js +55 -13
- package/dist/server/process.js.map +1 -1
- package/dist/server/readiness.d.ts +16 -2
- package/dist/server/readiness.d.ts.map +1 -1
- package/dist/server/readiness.js +32 -2
- package/dist/server/readiness.js.map +1 -1
- package/dist/server/targets.d.ts +4 -0
- package/dist/server/targets.d.ts.map +1 -1
- package/dist/server/targets.js +6 -0
- package/dist/server/targets.js.map +1 -1
- package/dist/server/types.d.ts +88 -14
- package/dist/server/types.d.ts.map +1 -1
- package/package.json +31 -24
- package/src/client/resources/oauth.ts +28 -13
- package/src/client/transport.ts +7 -3
- package/src/server/args.ts +1 -1
- package/src/server/binary.ts +46 -1
- package/src/server/config.ts +169 -22
- package/src/server/errors.ts +4 -2
- package/src/server/index.ts +459 -94
- package/src/server/internal.ts +20 -0
- package/src/server/management.ts +89 -0
- package/src/server/process.ts +60 -13
- package/src/server/readiness.ts +38 -3
- package/src/server/targets.ts +8 -0
- package/src/server/types.ts +90 -14
package/src/server/config.ts
CHANGED
|
@@ -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
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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,
|
|
102
|
-
return {
|
|
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
|
|
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}
|
|
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 {
|
|
267
|
+
return {
|
|
268
|
+
configPath: absolute,
|
|
269
|
+
listen,
|
|
270
|
+
management: readManagementSettings(doc),
|
|
271
|
+
secrets: configSecrets(doc),
|
|
272
|
+
managed: false,
|
|
273
|
+
reused: false,
|
|
274
|
+
};
|
|
128
275
|
}
|
package/src/server/errors.ts
CHANGED
|
@@ -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;
|