@jiayunxie/aerial 0.1.11 → 0.2.1

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 CHANGED
@@ -11,7 +11,7 @@ It runs locally on `127.0.0.1:18181`. Your GitHub token and Aerial key stay on y
11
11
 
12
12
  Requirements:
13
13
 
14
- - Node.js 22+
14
+ - Node.js 24+
15
15
  - A GitHub account with an active Copilot subscription
16
16
  - Codex CLI and/or Claude Code installed
17
17
 
@@ -21,6 +21,14 @@ npm install -g @jiayunxie/aerial
21
21
 
22
22
  ## Quick Start
23
23
 
24
+ If you are on a restricted network or Claude models are missing from `aerial probe`, enable Aerial's upstream proxy mode first:
25
+
26
+ ```bash
27
+ aerial proxy enable
28
+ ```
29
+
30
+ `proxy enable` discovers local HTTP(S) and SOCKS5 proxies from macOS network settings, PAC files, environment hints, and common local proxy ports, then validates the route before saving anything.
31
+
24
32
  1. Sign in to GitHub:
25
33
 
26
34
  ```bash
@@ -93,10 +101,25 @@ To inspect the full model matrix:
93
101
  aerial probe
94
102
  ```
95
103
 
104
+ ## Upstream Proxy
105
+
106
+ Aerial can route its own GitHub and Copilot upstream traffic through a local HTTP(S) or SOCKS5 proxy while keeping the Codex/Claude client-facing server on `127.0.0.1:18181`.
107
+
108
+ ```bash
109
+ aerial proxy status # show direct/proxy egress IP, region, and Copilot route counts
110
+ aerial proxy enable # auto-discover and validate a working local proxy
111
+ aerial proxy disable # return upstream traffic to direct mode
112
+ ```
113
+
114
+ This is useful when Copilot exposes different model routes by region. In direct mode, `proxy status` reports the direct egress. In enabled mode, it reports the selected local proxy endpoint, proxied egress, and whether the `messages` route is visible for Claude Code. SOCKS5 endpoints, including Shadowsocks PAC entries, are bridged internally; users do not need to find or configure an HTTP proxy port by hand.
115
+
116
+ Advanced override: `AERIAL_UPSTREAM_PROXY` can be set to a bare `http://`, `https://`, `socks://`, `socks5://`, or `socks5h://` proxy endpoint. Path-prefixed proxy URLs are intentionally ignored.
117
+
96
118
  ## Daily Commands
97
119
 
98
120
  ```bash
99
121
  aerial status # setup, login, service, and health summary
122
+ aerial proxy status # upstream proxy mode, egress, and route visibility
100
123
  aerial service install # install and start the background service
101
124
  aerial doctor # local diagnostics
102
125
  aerial disable # restore client configs and uninstall the service
@@ -111,6 +134,7 @@ Advanced service lifecycle commands are documented in `docs/usage.md`.
111
134
  - `Missing GitHub token`: run `aerial login`.
112
135
  - `Invalid or missing Aerial API key`: rerun `aerial setup codex` or `aerial setup claude`, then restart the client.
113
136
  - No models listed during setup: run `aerial login` first, then retry setup.
137
+ - Claude models or the `messages` route are missing: run `aerial proxy enable`, then `aerial probe`.
114
138
  - Port conflict on `18181`: run `aerial status` to see whether another process is using the port.
115
139
  - Need to undo setup: run `aerial disable`, or restore one client with `aerial setup restore <codex|claude> --latest`.
116
140
 
package/docs/usage.md CHANGED
@@ -39,6 +39,26 @@ aerial login
39
39
 
40
40
  Open the printed URL, enter the user code, and authorize the GitHub OAuth device flow. Aerial saves the GitHub access token locally and exchanges it for short-lived Copilot JWTs when proxy requests arrive.
41
41
 
42
+ ## 3.5 Upstream Proxy Mode
43
+
44
+ ```bash
45
+ aerial proxy status
46
+ aerial proxy enable
47
+ aerial proxy disable
48
+ ```
49
+
50
+ Aerial's local server always listens on `127.0.0.1:18181`, but its own GitHub and Copilot upstream traffic can optionally go through a local HTTP(S) or SOCKS5 proxy. This helps in restricted regions where direct Copilot egress may return a smaller model catalog or omit Claude-compatible `/v1/messages` routes.
51
+
52
+ `aerial proxy enable` auto-discovers candidates from `AERIAL_UPSTREAM_PROXY`, standard proxy environment variables, macOS `scutil --proxy`, PAC files, common local SOCKS5 ports such as `1086` and `1080`, and common local HTTP proxy ports such as `1087` and `7890`. It validates that a candidate can reach GitHub and return GitHub's `/rate_limit` JSON before saving `upstreamProxyMode=auto` and the selected endpoint in Aerial's config. If no candidate works, it exits non-zero and leaves the previous config unchanged. PAC files are read with a 1 MiB limit.
53
+
54
+ SOCKS5 endpoints are first-class candidates. When Aerial selects `socks5://127.0.0.1:1086` from a Shadowsocks PAC file, the config stores that original endpoint; at runtime Aerial starts a loopback-only HTTP CONNECT bridge internally so GitHub/Copilot `fetch` and WebSocket traffic can use the same proxy dispatcher path as HTTP(S) proxies. Users do not need to discover or configure a separate HTTP bridge port.
55
+
56
+ For explicit overrides, `AERIAL_UPSTREAM_PROXY` accepts bare `http://`, `https://`, `socks://`, `socks5://`, and `socks5h://` endpoints. Aerial intentionally ignores proxy URLs with path, query, or hash components, because upstream proxy dispatchers expect a host-level proxy endpoint.
57
+
58
+ `aerial proxy status` prints the active mode, the direct or proxied egress IP/region, and the current Copilot route counts from `aerial probe` (`responses`, `responsesWebSocket`, `messages`, and `chat`). In disabled mode the endpoint is `direct`; in enabled mode the endpoint is the selected local proxy plus its discovery source. Use `--json` for the stable `aerial.proxy-status.v1` shape. If a proxy URL contains userinfo, status and enable diagnostics redact it before printing.
59
+
60
+ `aerial proxy disable` switches back to direct upstream traffic and clears the saved proxy endpoint. Running Aerial processes read this config for each upstream request, so no service restart is required for proxy enable/disable.
61
+
42
62
  ## 4. Start Server
43
63
 
44
64
  ```bash
@@ -99,12 +119,15 @@ Non-Opus-4.7 Claude requests are not modified.
99
119
 
100
120
  ```bash
101
121
  aerial status
122
+ aerial proxy status
102
123
  aerial doctor
103
124
  aerial probe
104
125
  ```
105
126
 
106
127
  `aerial status` is the top-level daily check. It combines setup state, local auth files, service state, and health into one short report. Use `--json` for a machine-readable `aerial.status.v1` document.
107
128
 
129
+ `aerial proxy status` is the network-path check. Use it when Claude models disappear, when `/v1/messages` is `0`, or when you want to confirm whether direct or proxied egress is being used.
130
+
108
131
  `aerial status --json` top-level fields: `schema`, `ok`, `setup`, `service`, `nextSteps`, `hints`. `nextSteps` lists actions you must take to reach `ok: true` (login / setup / service install / recover local key). `hints` lists non-blocking advisories that you should still pay attention to, such as `AERIAL_GITHUB_TOKEN` being set for the current shell only (the background service may not see it after reboot). Example:
109
132
 
110
133
  ```json
@@ -217,7 +240,7 @@ Aerial ships a thin platform wrapper around the user-mode service primitives pro
217
240
  - Windows: a Task Scheduler task named `AerialLocalProxy`, `/SC ONLOGON /RL LIMITED`, executes a PowerShell wrapper at `<config-dir>\bin\aerial-service.ps1` (default `%APPDATA%\aerial\bin\aerial-service.ps1`). The wrapper is regenerated on every `aerial service install`. The `/TR` argument is wrapped in escaped quotes so paths that contain spaces or non-ASCII characters work without manual quoting.
218
241
  - Linux: not implemented in this release. `aerial service install|start|stop|restart|uninstall` throws an unsupported-platform error and exits 1; `aerial service status --json` still emits a schema-valid document with `"supported": false` and exits 1. Run `aerial start` directly or wrap it in your own init system.
219
242
 
220
- Both wrappers do the same three things before exec-ing the proxy: (1) startup-rotate the captured stdio log (`aerial-stdio.log` → `.1` → `.2` → `.3`) if it has grown beyond the configured cap; (2) export `AERIAL_LOG_FILE=<config-dir>/logs/aerial.log`, plus `AERIAL_LOG_MAX_BYTES` and `AERIAL_LOG_BACKUPS` (default `5242880` / `3`, or whatever value was present in the installer's environment — see below), and — when `AERIAL_CONFIG_DIR` was set at install time — re-export `AERIAL_CONFIG_DIR` so the service sees the same config root as the installer; (3) `exec` `node src/cli.js start --host <host> --port <port>` with stdout and stderr appended to `aerial-stdio.log`. The structured event log is opt-in via `AERIAL_LOG_FILE`: when this env var is set (always set by the wrapper, never by foreground `aerial start`), structured events go to that file only; when unset, they go to stderr only. There is no double-write.
243
+ Both wrappers do the same three things before exec-ing the proxy: (1) startup-rotate the captured stdio log (`aerial-stdio.log` → `.1` → `.2` → `.3`) if it has grown beyond the configured cap; (2) export `AERIAL_LOG_FILE=<config-dir>/logs/aerial.log`, plus `AERIAL_LOG_MAX_BYTES` and `AERIAL_LOG_BACKUPS` (default `5242880` / `3`, or whatever value was present in the installer's environment — see below), and — when `AERIAL_CONFIG_DIR` was set at install time — re-export `AERIAL_CONFIG_DIR` so the service sees the same config root as the installer; (3) `exec` `node src/cli.js start --host <host> --port <port>` with stdout and stderr appended to `aerial-stdio.log`. The service wrapper prefers a Node.js 24+ binary; on macOS, if the installer is running under an older Node and Codex's bundled Node 24+ exists at `/Applications/Codex.app/Contents/Resources/node`, Aerial uses that for the generated wrapper. Set `AERIAL_SERVICE_NODE` before `aerial service install` to override this selection explicitly. The structured event log is opt-in via `AERIAL_LOG_FILE`: when this env var is set (always set by the wrapper, never by foreground `aerial start`), structured events go to that file only; when unset, they go to stderr only. There is no double-write.
221
244
 
222
245
  Wrapper env values are baked in at install time. If you set `AERIAL_LOG_MAX_BYTES` and/or `AERIAL_LOG_BACKUPS` in the shell before running `aerial service install`, the generated wrapper hard-codes those values and the next start of the service inherits them. Changing the env in another shell after install has no effect on the already-installed wrapper. To apply new values to an already-managed service, rerun `aerial service install` (which always regenerates the definition, including the wrapper) and then `aerial service restart` to swap the running process onto the regenerated wrapper.
223
246
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jiayunxie/aerial",
3
- "version": "0.1.11",
4
- "description": "Local-only GitHub Copilot proxy for Codex CLI and Claude Code.",
3
+ "version": "0.2.1",
4
+ "description": "Local GitHub Copilot proxy for Codex CLI and Claude Code with smart HTTP(S)/SOCKS5 upstream proxy support.",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "homepage": "https://github.com/Xiejiayun/aerial#readme",
@@ -16,7 +16,11 @@
16
16
  "copilot",
17
17
  "codex",
18
18
  "claude-code",
19
- "local-proxy"
19
+ "local-proxy",
20
+ "upstream-proxy",
21
+ "socks5",
22
+ "shadowsocks",
23
+ "restricted-network"
20
24
  ],
21
25
  "files": [
22
26
  "src",
@@ -34,7 +38,7 @@
34
38
  "prepublishOnly": "npm test"
35
39
  },
36
40
  "engines": {
37
- "node": ">=22"
41
+ "node": ">=24"
38
42
  },
39
43
  "license": "MIT",
40
44
  "publishConfig": {
@@ -42,6 +46,7 @@
42
46
  },
43
47
  "dependencies": {
44
48
  "smol-toml": "^1.6.1",
49
+ "socks": "^2.8.9",
45
50
  "undici": "^7.25.0"
46
51
  }
47
52
  }
package/src/auth.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { COPILOT_TOKEN_URL, GITHUB_CLIENT_ID } from "./constants.js";
3
3
  import { githubTokenPath, writePrivateFile } from "./paths.js";
4
4
  import { logEvent } from "./log.js";
5
+ import { upstreamFetch } from "./upstream-fetch.js";
5
6
 
6
7
  let cachedCopilotToken;
7
8
  let refreshPromise;
@@ -12,7 +13,7 @@ function formBody(values) {
12
13
 
13
14
  export async function startDeviceFlow() {
14
15
  logEvent("login_start");
15
- const response = await fetch("https://github.com/login/device/code", {
16
+ const response = await upstreamFetch("https://github.com/login/device/code", {
16
17
  method: "POST",
17
18
  headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
18
19
  body: formBody({ client_id: GITHUB_CLIENT_ID, scope: "read:user copilot" })
@@ -25,7 +26,7 @@ export async function pollDeviceFlow(deviceCode, intervalSeconds) {
25
26
  let interval = Math.max(Number(intervalSeconds || 5), 1);
26
27
  while (true) {
27
28
  await new Promise((resolve) => setTimeout(resolve, interval * 1000));
28
- const response = await fetch("https://github.com/login/oauth/access_token", {
29
+ const response = await upstreamFetch("https://github.com/login/oauth/access_token", {
29
30
  method: "POST",
30
31
  headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
31
32
  body: formBody({ client_id: GITHUB_CLIENT_ID, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" })
@@ -75,7 +76,7 @@ function jwtExpirySeconds(token) {
75
76
 
76
77
  export async function exchangeCopilotToken(githubToken = readGitHubToken()) {
77
78
  if (!githubToken) throw new Error("Missing GitHub token. Run: aerial login");
78
- const response = await fetch(COPILOT_TOKEN_URL, {
79
+ const response = await upstreamFetch(COPILOT_TOKEN_URL, {
79
80
  headers: {
80
81
  authorization: `Bearer ${githubToken}`,
81
82
  accept: "application/json",
package/src/cli.js CHANGED
@@ -12,6 +12,8 @@ import { chooseSetupModel, formatModelChoices } from "./model-selection.js";
12
12
  import { chooseSetupEffort, formatEffortSelection, assertValidEffort } from "./setup-selection.js";
13
13
  import { printVersion } from "./version.js";
14
14
  import { computeAppStatus } from "./app-status.js";
15
+ import { disableUpstreamProxy, enableUpstreamProxy, probeEgress, upstreamProxyState } from "./upstream-fetch.js";
16
+ import { redactProxyEndpoint, redactProxySource } from "./proxy-config.js";
15
17
 
16
18
  const CLI_ENTRY = fileURLToPath(import.meta.url);
17
19
 
@@ -42,6 +44,7 @@ Usage:
42
44
  aerial setup claude [--model <id>] [--effort <low|medium|high|xhigh|max>]
43
45
  aerial service install
44
46
  aerial status [--json]
47
+ aerial proxy status|enable|disable [--json]
45
48
 
46
49
  Diagnostics and rollback:
47
50
  aerial setup status [--json]
@@ -147,6 +150,180 @@ function printServiceSummary(status) {
147
150
  console.log(`health: ${health}`);
148
151
  }
149
152
 
153
+ function printRestoreResults(results) {
154
+ for (const r of Object.values(results)) {
155
+ if (r.restored) {
156
+ console.log(`Restored ${r.target}: ${r.file} <- ${r.from}`);
157
+ if (r.snapshot) console.log(` pre-restore snapshot: ${r.snapshot}`);
158
+ } else if (r.reason === "no_backup") {
159
+ console.log(`Restored ${r.target}: no backup to restore`);
160
+ } else if (r.error) {
161
+ console.log(`Restored ${r.target}: FAILED ${r.error}`);
162
+ }
163
+ }
164
+ }
165
+
166
+ function printServiceDiagnostics(diagnostics) {
167
+ if (!diagnostics) return;
168
+ if (diagnostics.stdioLog) console.log(` stdio log: ${diagnostics.stdioLog}`);
169
+ if (diagnostics.aerialLog) console.log(` aerial log: ${diagnostics.aerialLog}`);
170
+ if (diagnostics.wrapperNode) console.log(` wrapper node: ${diagnostics.wrapperNode}`);
171
+ if (diagnostics.health) {
172
+ const h = diagnostics.health;
173
+ const tail = h.lastError ? `, last error: ${h.lastError}` : (h.lastStatus !== undefined ? `, last status: ${h.lastStatus}` : "");
174
+ console.log(` health probe: ${h.attempts} attempts over ${h.elapsedMs}ms${tail}`);
175
+ }
176
+ console.log(` Run: aerial service status --json`);
177
+ }
178
+
179
+ function printServiceWarning(result) {
180
+ if (result.warning) console.log(` WARNING: ${result.warning.message}`);
181
+ }
182
+
183
+ function formatEgress(egress) {
184
+ if (!egress?.ok) return `unavailable (${egress?.error || "unknown"})`;
185
+ const place = [egress.city, egress.region, egress.country].filter(Boolean).join(", ");
186
+ return [egress.ip, place].filter(Boolean).join(" ");
187
+ }
188
+
189
+ async function proxyRouteSummary() {
190
+ try {
191
+ const report = await runProbe();
192
+ if (!report.ok) return { ok: false, error: JSON.stringify(report.error) };
193
+ return {
194
+ ok: true,
195
+ models: report.models.length,
196
+ responses: report.summary.responses,
197
+ responsesWebSocket: report.summary.websocketResponses,
198
+ messages: report.summary.messages,
199
+ chat: report.summary.chat
200
+ };
201
+ } catch (err) {
202
+ return { ok: false, error: err.message };
203
+ }
204
+ }
205
+
206
+ async function proxyStatus({ includeRoutes = true } = {}) {
207
+ const state = upstreamProxyState(loadConfig());
208
+ const egress = await probeEgress({ endpoint: state.endpoint });
209
+ return {
210
+ schema: "aerial.proxy-status.v1",
211
+ mode: state.mode,
212
+ enabled: state.enabled,
213
+ endpoint: state.endpoint,
214
+ source: state.source,
215
+ egress,
216
+ routes: includeRoutes ? await proxyRouteSummary() : undefined
217
+ };
218
+ }
219
+
220
+ function publicProxyCandidate(candidate) {
221
+ if (!candidate || typeof candidate !== "object") return candidate;
222
+ return {
223
+ ...candidate,
224
+ endpoint: redactProxyEndpoint(candidate.endpoint),
225
+ source: redactProxySource(candidate.source)
226
+ };
227
+ }
228
+
229
+ function publicProxyStatus(status) {
230
+ if (!status || typeof status !== "object") return status;
231
+ return {
232
+ ...status,
233
+ endpoint: redactProxyEndpoint(status.endpoint),
234
+ source: redactProxySource(status.source)
235
+ };
236
+ }
237
+
238
+ function publicProxyEnableResult(result) {
239
+ if (!result || typeof result !== "object") return result;
240
+ return {
241
+ ...result,
242
+ selected: publicProxyCandidate(result.selected),
243
+ candidates: Array.isArray(result.candidates) ? result.candidates.map(publicProxyCandidate) : result.candidates,
244
+ status: publicProxyStatus(result.status)
245
+ };
246
+ }
247
+
248
+ function printProxyStatus(status) {
249
+ const safeStatus = publicProxyStatus(status);
250
+ console.log(`proxy: ${status.enabled ? `enabled (${status.mode})` : "disabled"}`);
251
+ console.log(`endpoint: ${status.enabled ? `${safeStatus.endpoint} (${safeStatus.source})` : "direct"}`);
252
+ console.log(`egress: ${formatEgress(status.egress)}`);
253
+ if (status.routes) {
254
+ if (status.routes.ok) {
255
+ console.log(`copilot routes: responses=${status.routes.responses}, responsesWebSocket=${status.routes.responsesWebSocket}, messages=${status.routes.messages}, chat=${status.routes.chat}`);
256
+ } else {
257
+ console.log(`copilot routes: unavailable (${status.routes.error})`);
258
+ }
259
+ }
260
+ }
261
+
262
+ async function runProxyCli(subcommand, rest) {
263
+ const json = rest.includes("--json") || subcommand === "--json";
264
+ if (!subcommand || subcommand === "status" || subcommand === "--json") {
265
+ const status = await proxyStatus();
266
+ if (json) console.log(JSON.stringify(publicProxyStatus(status), null, 2));
267
+ else printProxyStatus(status);
268
+ return;
269
+ }
270
+ if (subcommand === "enable") {
271
+ const result = await enableUpstreamProxy();
272
+ if (!result.ok) {
273
+ if (json) console.log(JSON.stringify(publicProxyEnableResult(result), null, 2));
274
+ else {
275
+ console.error(`Proxy enable failed: ${result.error}`);
276
+ if (result.candidates?.length) {
277
+ console.error(`Checked: ${result.candidates.map((c) => redactProxyEndpoint(c.endpoint)).join(", ")}`);
278
+ }
279
+ }
280
+ process.exitCode = 1;
281
+ return;
282
+ }
283
+ const status = await proxyStatus();
284
+ if (json) console.log(JSON.stringify(publicProxyEnableResult({ ...result, status }), null, 2));
285
+ else {
286
+ console.log(`Proxy enabled: ${redactProxyEndpoint(result.selected.endpoint)} (${redactProxySource(result.selected.source)})`);
287
+ printProxyStatus(status);
288
+ }
289
+ return;
290
+ }
291
+ if (subcommand === "disable") {
292
+ disableUpstreamProxy();
293
+ const status = await proxyStatus();
294
+ if (json) console.log(JSON.stringify(publicProxyStatus(status), null, 2));
295
+ else {
296
+ console.log("Proxy disabled.");
297
+ printProxyStatus(status);
298
+ }
299
+ return;
300
+ }
301
+ throw new Error("Usage: aerial proxy status|enable|disable [--json]");
302
+ }
303
+
304
+ function printServiceUninstallResult(r, { prefix = "Service uninstall" } = {}) {
305
+ if (r.note === "no service installed") console.log(`${prefix}: no service installed`);
306
+ else if (r.ok) console.log(`${prefix}: ok (${r.platform})`);
307
+ else {
308
+ console.log(`${prefix}: FAILED (${r.reason || "see stderr"})`);
309
+ if (r.message) console.log(` ${r.message}`);
310
+ else console.log(` Retry with: aerial service uninstall`);
311
+ }
312
+ if (r.delete?.stderr) console.log(` schtasks stderr: ${r.delete.stderr.trim()}`);
313
+ if (r.bootout?.stderr) console.log(` bootout stderr: ${r.bootout.stderr.trim()}`);
314
+ }
315
+
316
+ async function runServiceCliAction(action, render) {
317
+ try {
318
+ const result = await action();
319
+ render(result);
320
+ process.exitCode = result.ok ? 0 : 1;
321
+ } catch (err) {
322
+ console.error(err.message);
323
+ process.exitCode = 1;
324
+ }
325
+ }
326
+
150
327
  async function appStatus({ json = false } = {}) {
151
328
  const setup = setupStatus();
152
329
  const service = await serviceStatus();
@@ -180,6 +357,11 @@ async function main() {
180
357
  return;
181
358
  }
182
359
 
360
+ if (command === "proxy") {
361
+ await runProxyCli(subcommand, rest);
362
+ return;
363
+ }
364
+
183
365
  if (command === "login") {
184
366
  const loginArgs = args.slice(1);
185
367
  const force = loginArgs.includes("--force");
@@ -307,16 +489,7 @@ async function main() {
307
489
  if (!rest.includes("--latest")) throw new Error("aerial setup restore: only --latest is supported in this release");
308
490
  if (which === "all") {
309
491
  const { ok, results } = restoreAllClients();
310
- for (const r of Object.values(results)) {
311
- if (r.restored) {
312
- console.log(`Restored ${r.target}: ${r.file} <- ${r.from}`);
313
- if (r.snapshot) console.log(` pre-restore snapshot: ${r.snapshot}`);
314
- } else if (r.reason === "no_backup") {
315
- console.log(`Restored ${r.target}: no backup to restore`);
316
- } else if (r.error) {
317
- console.log(`Restored ${r.target}: FAILED ${r.error}`);
318
- }
319
- }
492
+ printRestoreResults(results);
320
493
  process.exitCode = ok ? 0 : 1;
321
494
  return;
322
495
  }
@@ -334,16 +507,7 @@ async function main() {
334
507
 
335
508
  if (command === "disable") {
336
509
  const { ok: restoreOk, results } = restoreAllClients();
337
- for (const r of Object.values(results)) {
338
- if (r.restored) {
339
- console.log(`Restored ${r.target}: ${r.file} <- ${r.from}`);
340
- if (r.snapshot) console.log(` pre-restore snapshot: ${r.snapshot}`);
341
- } else if (r.reason === "no_backup") {
342
- console.log(`Restored ${r.target}: no backup to restore`);
343
- } else if (r.error) {
344
- console.log(`Restored ${r.target}: FAILED ${r.error}`);
345
- }
346
- }
510
+ printRestoreResults(results);
347
511
  if (!restoreOk) {
348
512
  console.log("service uninstall: skipped because client restore reported failures; resolve restore errors then rerun `aerial disable` or `aerial service uninstall`.");
349
513
  process.exitCode = 1;
@@ -351,15 +515,7 @@ async function main() {
351
515
  }
352
516
  try {
353
517
  const r = serviceUninstall();
354
- if (r.note === "no service installed") console.log("service uninstall: no service installed");
355
- else if (r.ok) console.log(`service uninstall: ok (${r.platform})`);
356
- else {
357
- console.log(`service uninstall: FAILED (${r.reason || "see stderr"})`);
358
- if (r.message) console.log(` ${r.message}`);
359
- else console.log(` Retry with: aerial service uninstall`);
360
- }
361
- if (r.bootout?.stderr) console.log(` bootout stderr: ${r.bootout.stderr.trim()}`);
362
- if (r.delete?.stderr) console.log(` schtasks stderr: ${r.delete.stderr.trim()}`);
518
+ printServiceUninstallResult(r, { prefix: "service uninstall" });
363
519
  process.exitCode = r.ok ? 0 : 1;
364
520
  } catch (err) {
365
521
  if (/unsupported platform/.test(err.message)) {
@@ -376,8 +532,7 @@ async function main() {
376
532
 
377
533
  if (command === "service") {
378
534
  if (subcommand === "install") {
379
- try {
380
- const r = await serviceInstall();
535
+ await runServiceCliAction(serviceInstall, (r) => {
381
536
  if (r.ok && r.note) console.log(`Service install: ${r.note} (${r.platform}).`);
382
537
  else if (r.ok) console.log(`Service installed (${r.platform}).`);
383
538
  else console.log(`Service install: FAILED (${r.reason || "unknown"}): ${r.message || ""}`);
@@ -387,98 +542,44 @@ async function main() {
387
542
  if (r.bootstrap?.stderr) console.log(` bootstrap stderr: ${r.bootstrap.stderr.trim()}`);
388
543
  if (r.create?.stderr) console.log(` schtasks stderr: ${r.create.stderr.trim()}`);
389
544
  if (r.run?.stderr) console.log(` schtasks /Run stderr: ${r.run.stderr.trim()}`);
390
- if (r.diagnostics) {
391
- if (r.diagnostics.stdioLog) console.log(` stdio log: ${r.diagnostics.stdioLog}`);
392
- if (r.diagnostics.aerialLog) console.log(` aerial log: ${r.diagnostics.aerialLog}`);
393
- if (r.diagnostics.wrapperNode) console.log(` wrapper node: ${r.diagnostics.wrapperNode}`);
394
- if (r.diagnostics.health) {
395
- const h = r.diagnostics.health;
396
- const tail = h.lastError ? `, last error: ${h.lastError}` : (h.lastStatus !== undefined ? `, last status: ${h.lastStatus}` : "");
397
- console.log(` health probe: ${h.attempts} attempts over ${h.elapsedMs}ms${tail}`);
398
- }
399
- console.log(` Run: aerial service status --json`);
400
- }
401
- if (r.warning) console.log(` WARNING: ${r.warning.message}`);
402
- process.exitCode = r.ok ? 0 : 1;
403
- } catch (err) {
404
- console.error(err.message);
405
- process.exitCode = 1;
406
- }
545
+ printServiceDiagnostics(r.diagnostics);
546
+ printServiceWarning(r);
547
+ });
407
548
  return;
408
549
  }
409
550
  if (subcommand === "start") {
410
- try {
411
- const r = await serviceStart();
551
+ await runServiceCliAction(serviceStart, (r) => {
412
552
  if (r.ok && r.note) console.log(`Service start: ${r.note} (${r.platform})`);
413
553
  else if (r.ok) console.log(`Service start: ok (${r.platform})`);
414
554
  else console.log(`Service start: FAILED (${r.reason || `status=${r.status}`})${r.message ? ": " + r.message : ""}`);
415
555
  if (r.stderr) console.log(` ${r.stderr.trim()}`);
416
- if (r.diagnostics) {
417
- if (r.diagnostics.stdioLog) console.log(` stdio log: ${r.diagnostics.stdioLog}`);
418
- if (r.diagnostics.aerialLog) console.log(` aerial log: ${r.diagnostics.aerialLog}`);
419
- if (r.diagnostics.wrapperNode) console.log(` wrapper node: ${r.diagnostics.wrapperNode}`);
420
- if (r.diagnostics.health) {
421
- const h = r.diagnostics.health;
422
- const tail = h.lastError ? `, last error: ${h.lastError}` : (h.lastStatus !== undefined ? `, last status: ${h.lastStatus}` : "");
423
- console.log(` health probe: ${h.attempts} attempts over ${h.elapsedMs}ms${tail}`);
424
- }
425
- console.log(` Run: aerial service status --json`);
426
- }
427
- if (r.warning) console.log(` WARNING: ${r.warning.message}`);
428
- process.exitCode = r.ok ? 0 : 1;
429
- } catch (err) {
430
- console.error(err.message);
431
- process.exitCode = 1;
432
- }
556
+ printServiceDiagnostics(r.diagnostics);
557
+ printServiceWarning(r);
558
+ });
433
559
  return;
434
560
  }
435
561
  if (subcommand === "stop") {
436
- try {
437
- const r = serviceStop();
562
+ await runServiceCliAction(serviceStop, (r) => {
438
563
  if (r.note) console.log(`Service stop: ${r.note} (${r.platform})`);
439
564
  else if (r.ok) console.log(`Service stop: ok (${r.platform})`);
440
565
  else console.log(`Service stop: FAILED (status=${r.status})`);
441
566
  if (r.stderr) console.log(` ${r.stderr.trim()}`);
442
- process.exitCode = r.ok ? 0 : 1;
443
- } catch (err) {
444
- console.error(err.message);
445
- process.exitCode = 1;
446
- }
567
+ });
447
568
  return;
448
569
  }
449
570
  if (subcommand === "restart") {
450
- try {
451
- const r = await serviceRestart();
571
+ await runServiceCliAction(serviceRestart, (r) => {
452
572
  if (!r.ok && r.reason === "stop_failed") console.log(`Service restart: FAILED on stop; start not attempted`);
453
573
  else if (r.ok) console.log(`Service restart: ok`);
454
574
  else console.log(`Service restart: FAILED`);
455
575
  if (r.stop?.stderr) console.log(` stop: ${r.stop.stderr.trim()}`);
456
576
  if (r.start?.stderr) console.log(` start: ${r.start.stderr.trim()}`);
457
- if (r.warning) console.log(` WARNING: ${r.warning.message}`);
458
- process.exitCode = r.ok ? 0 : 1;
459
- } catch (err) {
460
- console.error(err.message);
461
- process.exitCode = 1;
462
- }
577
+ printServiceWarning(r);
578
+ });
463
579
  return;
464
580
  }
465
581
  if (subcommand === "uninstall") {
466
- try {
467
- const r = serviceUninstall();
468
- if (r.note === "no service installed") console.log("Service uninstall: no service installed");
469
- else if (r.ok) console.log(`Service uninstall: ok (${r.platform})`);
470
- else {
471
- console.log(`Service uninstall: FAILED (${r.reason || "see stderr"})`);
472
- if (r.message) console.log(` ${r.message}`);
473
- else console.log(` Retry with: aerial service uninstall`);
474
- }
475
- if (r.delete?.stderr) console.log(` schtasks stderr: ${r.delete.stderr.trim()}`);
476
- if (r.bootout?.stderr) console.log(` bootout stderr: ${r.bootout.stderr.trim()}`);
477
- process.exitCode = r.ok ? 0 : 1;
478
- } catch (err) {
479
- console.error(err.message);
480
- process.exitCode = 1;
481
- }
582
+ await runServiceCliAction(serviceUninstall, printServiceUninstallResult);
482
583
  return;
483
584
  }
484
585
  if (subcommand === "status") {
package/src/config.js CHANGED
@@ -3,6 +3,7 @@ import { CONFIG_VERSION, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_VERSIONS } from "./
3
3
  import { apiKeyPath, configPath, readJsonIfExists, writeJsonPrivate, writePrivateFile } from "./paths.js";
4
4
  import { hashApiKey, randomApiKey, verifyApiKey } from "./crypto.js";
5
5
  import { DEFAULT_EFFORT, normalizeEffort } from "./setup-selection.js";
6
+ import { PROXY_MODE_AUTO, normalizeProxyEndpoint, normalizeProxyMode } from "./proxy-config.js";
6
7
 
7
8
  export function defaultConfig() {
8
9
  return {
@@ -15,6 +16,9 @@ export function defaultConfig() {
15
16
  logLevel: "info",
16
17
  promptCacheRetention: "in_memory",
17
18
  promptCacheKey: "auto",
19
+ upstreamProxyMode: "disabled",
20
+ upstreamProxyEndpoint: undefined,
21
+ upstreamProxySource: undefined,
18
22
  versions: DEFAULT_VERSIONS
19
23
  };
20
24
  }
@@ -42,7 +46,24 @@ export function loadConfig() {
42
46
  const promptCacheRetention = envRetention === undefined ? (loaded.promptCacheRetention ?? defaults.promptCacheRetention) : envRetention;
43
47
  const promptCacheKey = envCacheKey === undefined ? (loaded.promptCacheKey ?? defaults.promptCacheKey) : envCacheKey;
44
48
  const defaultEffort = normalizeEffort(loaded.defaultEffort) || DEFAULT_EFFORT;
45
- return { ...defaults, ...loaded, defaultEffort, promptCacheRetention, promptCacheKey, versions: { ...DEFAULT_VERSIONS, ...(loaded.versions || {}) } };
49
+ const loadedProxyMode = normalizeProxyMode(loaded.upstreamProxyMode);
50
+ const loadedProxyEndpoint = normalizeProxyEndpoint(loaded.upstreamProxyEndpoint);
51
+ const upstreamProxyEndpoint = loadedProxyMode === PROXY_MODE_AUTO ? loadedProxyEndpoint : undefined;
52
+ const upstreamProxyMode = upstreamProxyEndpoint ? PROXY_MODE_AUTO : "disabled";
53
+ const upstreamProxySource = upstreamProxyEndpoint && typeof loaded.upstreamProxySource === "string"
54
+ ? loaded.upstreamProxySource
55
+ : undefined;
56
+ return {
57
+ ...defaults,
58
+ ...loaded,
59
+ defaultEffort,
60
+ promptCacheRetention,
61
+ promptCacheKey,
62
+ upstreamProxyMode,
63
+ upstreamProxyEndpoint,
64
+ upstreamProxySource,
65
+ versions: { ...DEFAULT_VERSIONS, ...(loaded.versions || {}) }
66
+ };
46
67
  }
47
68
 
48
69
  export function saveConfig(config) {