faberwright 0.3.1 → 0.4.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/dist/prompt.js CHANGED
@@ -2,24 +2,63 @@ import pc from "picocolors";
2
2
  let guardFn;
3
3
  /** Wired by the CLI: pauses the composed-input pipe while the selector owns stdin. */
4
4
  export function setSelectGuard(fn) { guardFn = fn; }
5
- export async function select(rl, question, options, defaultIndex = 0) {
5
+ async function selectRaw(rl, question, options, defaultIndex = 0) {
6
6
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
7
7
  const menu = options.map((o, i) => ` ${i + 1}) ${o}`).join("\n");
8
- const ans = (await rl.question(`${question}\n${menu}\nChoose [1-${options.length}] (default ${defaultIndex + 1}): `)).trim();
8
+ let ans;
9
+ try {
10
+ ans = (await rl.question(`${question}\n${menu}\nChoose [1-${options.length}] (default ${defaultIndex + 1}): `)).trim();
11
+ }
12
+ catch {
13
+ // stdin closed mid-prompt (piped input ran out, or Ctrl-D):
14
+ // take the default rather than surfacing a readline stack trace.
15
+ return defaultIndex;
16
+ }
9
17
  const n = Number.parseInt(ans, 10);
10
18
  return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defaultIndex;
11
19
  }
12
- console.log(pc.bold(question) + pc.dim(" ↑/↓ then Enter, or 1-9"));
20
+ // Long lists (a provider can return dozens of models) are unusable with
21
+ // arrow keys alone, so typing filters the list as you go.
22
+ const searchable = options.length > 8;
23
+ console.log(pc.bold(question) +
24
+ pc.dim(searchable ? " ↑/↓ then Enter · type to filter" : " ↑/↓ then Enter, or 1-9"));
13
25
  return new Promise((resolve) => {
14
- let idx = defaultIndex;
15
- let firstRender = true;
26
+ let filter = "";
27
+ let view = options.map((_, i) => i); // indices currently shown
28
+ let cursor = Math.max(0, view.indexOf(defaultIndex));
29
+ let painted = 0; // rows drawn last time
30
+ const applyFilter = () => {
31
+ const q = filter.toLowerCase();
32
+ const next = options
33
+ .map((o, i) => [o, i])
34
+ .filter(([o]) => o.toLowerCase().includes(q))
35
+ .map(([, i]) => i);
36
+ view = next.length ? next : [];
37
+ cursor = 0;
38
+ };
16
39
  const render = () => {
17
- if (!firstRender)
18
- process.stdout.write(`\x1b[${options.length}A`);
19
- firstRender = false;
20
- for (let i = 0; i < options.length; i++) {
40
+ if (painted)
41
+ process.stdout.write(`\x1b[${painted}A`);
42
+ const rows = view.length ? view.length : 1;
43
+ const extra = filter ? 1 : 0;
44
+ for (let r = 0; r < view.length; r++) {
21
45
  process.stdout.write("\x1b[2K");
22
- process.stdout.write((i === idx ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
46
+ const i = view[r];
47
+ process.stdout.write((r === cursor ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
48
+ }
49
+ if (!view.length) {
50
+ process.stdout.write("\x1b[2K" + pc.yellow(` no match for "${filter}"`) + "\n");
51
+ }
52
+ if (filter) {
53
+ process.stdout.write("\x1b[2K" + pc.dim(` filter: ${filter}`) + "\n");
54
+ }
55
+ // clear any rows the previous, longer render left behind
56
+ for (let r = rows + extra; r < painted; r++)
57
+ process.stdout.write("\x1b[2K\n");
58
+ painted = Math.max(rows + extra, painted);
59
+ if (painted > rows + extra) {
60
+ process.stdout.write(`\x1b[${painted - (rows + extra)}A`);
61
+ painted = rows + extra;
23
62
  }
24
63
  };
25
64
  const stdin = process.stdin;
@@ -50,24 +89,48 @@ export async function select(rl, question, options, defaultIndex = 0) {
50
89
  key = s[i];
51
90
  i += 1;
52
91
  }
53
- if (key === "\x1b[A" || key === "k") {
54
- idx = (idx - 1 + options.length) % options.length;
55
- render();
56
- }
57
- else if (key === "\x1b[B" || key === "j") {
58
- idx = (idx + 1) % options.length;
92
+ if (key === "\x1b[A") {
93
+ if (view.length)
94
+ cursor = (cursor - 1 + view.length) % view.length;
59
95
  render();
60
96
  }
61
- else if (key >= "1" && key <= "9" && Number(key) <= options.length) {
62
- idx = Number(key) - 1;
97
+ else if (key === "\x1b[B") {
98
+ if (view.length)
99
+ cursor = (cursor + 1) % view.length;
63
100
  render();
64
- finish(idx);
65
- done = true;
66
101
  }
67
102
  else if (key === "\r" || key === "\n") {
68
- finish(idx);
103
+ if (view.length) {
104
+ finish(view[cursor]);
105
+ done = true;
106
+ }
107
+ }
108
+ else if (key === "\x7f" || key === "\b") { // backspace edits the filter
109
+ if (filter) {
110
+ filter = filter.slice(0, -1);
111
+ applyFilter();
112
+ render();
113
+ }
114
+ }
115
+ // Number shortcuts only while unfiltered; once you're typing, digits
116
+ // are part of the search term (model ids are full of them).
117
+ else if (!filter && !searchable && key >= "1" && key <= "9" && Number(key) <= options.length) {
118
+ finish(Number(key) - 1);
69
119
  done = true;
70
120
  }
121
+ else if (searchable && key >= " " && key !== "\x1b") {
122
+ filter += key;
123
+ applyFilter();
124
+ render();
125
+ }
126
+ else if (!searchable && (key === "k" || key === "j")) {
127
+ if (view.length)
128
+ cursor = (cursor + (key === "k" ? -1 : 1) + view.length) % view.length;
129
+ render();
130
+ }
131
+ // Ctrl-C or Esc cancels. Returning -1 used to leak out as an array
132
+ // index, crashing the caller with "cannot read properties of
133
+ // undefined" — a cancel must be a clean exit, not a bad index.
71
134
  else if (key === "\x03" || key === "\x1b") {
72
135
  finish(-1);
73
136
  done = true;
@@ -78,3 +141,77 @@ export async function select(rl, question, options, defaultIndex = 0) {
78
141
  stdin.on("data", onData);
79
142
  });
80
143
  }
144
+ /**
145
+ * Read a secret without echoing it. A pasted API key that appears on screen
146
+ * survives in terminal scrollback, `script` logs, and screen recordings — so
147
+ * the characters are consumed in raw mode and only a masked length is shown.
148
+ * Falls back to a normal read when there's no TTY (CI piping a key in).
149
+ */
150
+ export async function readSecret(rl, promptText, io) {
151
+ const stdin = io?.stdin ?? process.stdin;
152
+ const stdout = io?.stdout ?? process.stdout;
153
+ if (!stdin.isTTY || !stdout.isTTY) {
154
+ return (await rl.question(promptText)).trim();
155
+ }
156
+ guardFn?.(true);
157
+ rl.pause();
158
+ const wasRaw = stdin.isRaw ?? false;
159
+ stdin.setRawMode(true);
160
+ stdin.resume();
161
+ stdout.write(promptText);
162
+ return new Promise((resolve) => {
163
+ let value = "";
164
+ const done = () => {
165
+ stdin.removeListener("data", onData);
166
+ stdin.setRawMode(wasRaw);
167
+ guardFn?.(false);
168
+ rl.resume();
169
+ stdout.write("\n");
170
+ resolve(value.trim());
171
+ };
172
+ const onData = (buf) => {
173
+ // Terminals wrap pasted text in bracketed-paste markers, ESC[200~ before
174
+ // and ESC[201~ after. The ESC byte is below space and gets dropped by the
175
+ // printable test below, but "[200~" is ordinary text and would be glued
176
+ // onto the secret — which is how a pasted key ends up rejected as
177
+ // malformed. Strip the markers, and any other escape sequence, first.
178
+ const chunk = buf.toString("utf8")
179
+ .replace(/\x1b\[20[01]~/g, "")
180
+ .replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "")
181
+ .replace(/\x1b./g, "");
182
+ for (const ch of chunk) {
183
+ if (ch === "\r" || ch === "\n")
184
+ return done();
185
+ if (ch === "\x03") {
186
+ value = "";
187
+ return done();
188
+ } // Ctrl-C
189
+ if (ch === "\x7f" || ch === "\b") { // backspace
190
+ if (value.length)
191
+ value = value.slice(0, -1);
192
+ continue;
193
+ }
194
+ // Echo nothing at all, the way sudo and ssh do. Masking characters
195
+ // would still reveal the key's length, and a hundred dots for a long
196
+ // key looks like something went wrong.
197
+ if (ch >= " ")
198
+ value += ch;
199
+ }
200
+ };
201
+ stdin.on("data", onData);
202
+ });
203
+ }
204
+ /**
205
+ * Arrow-key menu. Cancelling (Ctrl-C or Esc) exits the process cleanly rather
206
+ * than returning a sentinel index that every caller would have to check —
207
+ * one forgotten check produced a raw TypeError mid-setup.
208
+ */
209
+ export async function select(rl, question, options, defaultIndex = 0) {
210
+ const picked = await selectRaw(rl, question, options, defaultIndex);
211
+ if (picked < 0 || picked >= options.length) {
212
+ process.stdout.write("\n");
213
+ rl.close();
214
+ process.exit(130); // 128 + SIGINT, the conventional cancel code
215
+ }
216
+ return picked;
217
+ }
package/dist/routes.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Vendors, routes, and model aliases.
3
+ *
4
+ * Three levels, chosen in order:
5
+ * vendor — who makes the model (Anthropic, OpenAI-compatible)
6
+ * route — how you reach it and who owns authentication
7
+ * (direct API key, a cloud provider, a local server, a gateway)
8
+ * model — an alias (sonnet/opus/haiku) or a raw model id
9
+ *
10
+ * Aliases exist because the same model has different ids on different routes:
11
+ * the direct API calls it "claude-sonnet-5" while Bedrock wants a long
12
+ * region-prefixed inference-profile id. Aliases stay stable; the mapping moves.
13
+ * For cloud routes the mapping is USER-SUPPLIED — we never guess ids we can't
14
+ * verify, since a wrong one fails at request time with a confusing error.
15
+ */
16
+ export const ROUTES = [
17
+ {
18
+ id: "anthropic-api",
19
+ vendor: "Anthropic",
20
+ label: "Anthropic API",
21
+ hint: "direct, pay per token with your own key",
22
+ wire: "anthropic",
23
+ baseUrl: "https://api.anthropic.com",
24
+ keyEnv: "ANTHROPIC_API_KEY",
25
+ implemented: true,
26
+ },
27
+ {
28
+ // AWS's bedrock-mantle endpoint speaks the first-party Messages dialect and
29
+ // authenticates with a Bedrock API key, so no SigV4 signing is needed —
30
+ // only the base URL and the credential differ from the direct route.
31
+ id: "bedrock",
32
+ vendor: "Anthropic",
33
+ label: "Amazon Bedrock",
34
+ hint: "your AWS account owns auth and billing",
35
+ wire: "anthropic",
36
+ needsRegion: true,
37
+ baseUrlTemplate: "https://bedrock-mantle.{region}.api.aws/anthropic",
38
+ keyEnv: "BEDROCK_API_KEY",
39
+ aliasesArePinned: true, // Bedrock model ids differ per region/deployment
40
+ implemented: true,
41
+ },
42
+ {
43
+ id: "vertex",
44
+ vendor: "Anthropic",
45
+ label: "Google Vertex AI",
46
+ hint: "your GCP project owns auth and billing",
47
+ wire: "anthropic",
48
+ aliasesArePinned: true,
49
+ implemented: false, // needs Google OAuth
50
+ },
51
+ {
52
+ id: "openai-api",
53
+ vendor: "OpenAI",
54
+ label: "OpenAI API",
55
+ hint: "direct, with your own key",
56
+ wire: "openai",
57
+ baseUrl: "https://api.openai.com/v1",
58
+ keyEnv: "OPENAI_API_KEY",
59
+ implemented: true,
60
+ },
61
+ {
62
+ id: "ollama",
63
+ vendor: "Local",
64
+ label: "Ollama (local)",
65
+ hint: "runs on your machine, no key, no cost",
66
+ wire: "openai",
67
+ baseUrl: "http://localhost:11434/v1",
68
+ aliasesArePinned: true,
69
+ implemented: true,
70
+ },
71
+ {
72
+ id: "custom",
73
+ vendor: "Other",
74
+ label: "OpenAI-compatible endpoint",
75
+ hint: "OpenRouter, Groq, Together, vLLM, a gateway…",
76
+ wire: "openai",
77
+ needsBaseUrl: true,
78
+ keyEnv: "OPENAI_API_KEY",
79
+ aliasesArePinned: true,
80
+ implemented: true,
81
+ },
82
+ ];
83
+ export function getRoute(id) {
84
+ return ROUTES.find((r) => r.id === id);
85
+ }
86
+ export const DEFAULT_REGION = "us-east-1";
87
+ /** Endpoint for a route, substituting the region into region-scoped URLs. */
88
+ export function baseUrlFor(route, region) {
89
+ if (route.baseUrlTemplate) {
90
+ return route.baseUrlTemplate.replace("{region}", region || DEFAULT_REGION);
91
+ }
92
+ return route.baseUrl;
93
+ }
94
+ /** Vendors in menu order, each with its routes. */
95
+ export function vendors() {
96
+ const out = [];
97
+ for (const r of ROUTES) {
98
+ const found = out.find((v) => v.vendor === r.vendor);
99
+ if (found)
100
+ found.routes.push(r);
101
+ else
102
+ out.push({ vendor: r.vendor, routes: [r] });
103
+ }
104
+ return out;
105
+ }
106
+ /**
107
+ * Built-in aliases for the direct Anthropic API. Ids on cloud routes differ
108
+ * per deployment and region, so those are pinned by the user instead.
109
+ */
110
+ export const ANTHROPIC_MODELS = [
111
+ { alias: "sonnet", id: "claude-sonnet-5", blurb: "balanced — good default for daily work" },
112
+ { alias: "opus", id: "claude-opus-5", blurb: "most capable — complex, multi-step work" },
113
+ { alias: "haiku", id: "claude-haiku-4-5-20251001", blurb: "fastest and cheapest — simple tasks" },
114
+ ];
115
+ export const OPENAI_MODELS = [
116
+ { alias: "gpt", id: "gpt-4o", blurb: "general purpose" },
117
+ { alias: "mini", id: "gpt-4o-mini", blurb: "cheaper and faster" },
118
+ ];
119
+ /** Models offered for a route; empty when ids must be pinned by the user. */
120
+ export function modelsForRoute(route) {
121
+ if (route.aliasesArePinned)
122
+ return [];
123
+ return route.wire === "anthropic" ? ANTHROPIC_MODELS : OPENAI_MODELS;
124
+ }
125
+ /**
126
+ * Turn whatever the user typed into a concrete model id.
127
+ * Order: explicit per-route pin > built-in alias > treat it as a raw id.
128
+ */
129
+ export function resolveModel(input, route, pins = {}) {
130
+ const key = input.trim();
131
+ if (pins[key])
132
+ return pins[key];
133
+ const hit = modelsForRoute(route).find((m) => m.alias === key);
134
+ return hit ? hit.id : key;
135
+ }
136
+ /** Reverse lookup: show "sonnet (claude-sonnet-5)" instead of a bare id. */
137
+ export function describeModel(id, route, pins = {}) {
138
+ const pinned = Object.entries(pins).find(([, v]) => v === id);
139
+ if (pinned)
140
+ return `${pinned[0]} (${id})`;
141
+ const hit = modelsForRoute(route).find((m) => m.id === id);
142
+ return hit ? `${hit.alias} (${id})` : id;
143
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Global settings: ~/.faber/settings.json
3
+ *
4
+ * Holds named profiles — a route, a model, and any endpoint or credential
5
+ * REFERENCE that route needs. Credentials themselves are never stored here:
6
+ * a profile names the environment variable to read (`apiKeyEnv`), so this file
7
+ * stays safe to sync between machines or check into a dotfiles repo.
8
+ *
9
+ * Resolution order, highest first:
10
+ * env vars > <project>/.faber/config.json > active profile > defaults
11
+ * Project settings beat the global profile so a repo can pin its own model,
12
+ * and env vars beat everything so CI and one-off overrides always work.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ export const DEFAULT_SETTINGS = {
18
+ activeProfile: "default",
19
+ profiles: {
20
+ default: { route: "anthropic-api", model: "sonnet", apiKeyEnv: "ANTHROPIC_API_KEY" },
21
+ },
22
+ };
23
+ export function settingsPath() {
24
+ return path.join(os.homedir(), ".faber", "settings.json");
25
+ }
26
+ export function loadSettings(file = settingsPath()) {
27
+ try {
28
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
29
+ const profiles = raw.profiles && typeof raw.profiles === "object"
30
+ ? raw.profiles
31
+ : DEFAULT_SETTINGS.profiles;
32
+ const active = typeof raw.activeProfile === "string" && profiles[raw.activeProfile]
33
+ ? raw.activeProfile
34
+ : Object.keys(profiles)[0] ?? "default";
35
+ return { activeProfile: active, profiles };
36
+ }
37
+ catch {
38
+ return structuredClone(DEFAULT_SETTINGS); // absent or malformed -> defaults
39
+ }
40
+ }
41
+ export function saveSettings(s, file = settingsPath()) {
42
+ fs.mkdirSync(path.dirname(file), { recursive: true });
43
+ fs.writeFileSync(file, JSON.stringify(s, null, 2) + "\n");
44
+ }
45
+ export function activeProfile(s) {
46
+ return s.profiles[s.activeProfile] ?? DEFAULT_SETTINGS.profiles.default;
47
+ }
48
+ /** Update the active profile in place and persist. */
49
+ export function updateActive(patch, file = settingsPath()) {
50
+ const s = loadSettings(file);
51
+ const name = s.activeProfile;
52
+ s.profiles[name] = { ...activeProfile(s), ...patch };
53
+ saveSettings(s, file);
54
+ return s;
55
+ }
package/dist/sigv4.js ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * AWS SigV4 request signing.
3
+ *
4
+ * Why this exists: in SageMaker Studio, EC2, ECS, Lambda and anywhere else AWS
5
+ * injects an execution role, there IS no API key — credentials arrive as an
6
+ * access key / secret / session token trio, and requests are authenticated by
7
+ * signing them. That's the natural auth path in those environments, and a
8
+ * Bedrock API key would be a second, unnecessary credential.
9
+ *
10
+ * Implemented directly rather than pulling in the AWS SDK: the signing
11
+ * algorithm is ~80 lines of HMAC chaining, and the SDK would add dozens of
12
+ * transitive dependencies to a tool whose whole install story is "zero native
13
+ * deps, nothing to compile".
14
+ *
15
+ * Verified against the signing test vectors AWS publishes for the algorithm.
16
+ */
17
+ import { createHash, createHmac } from "node:crypto";
18
+ import * as fs from "node:fs";
19
+ import * as os from "node:os";
20
+ import * as path from "node:path";
21
+ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
22
+ const hmac = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
23
+ /** ISO8601 basic format: 20260808T210000Z */
24
+ export function amzDate(d = new Date()) {
25
+ return d.toISOString().replace(/[:-]|\.\d{3}/g, "");
26
+ }
27
+ /**
28
+ * Canonical request -> string to sign -> signing key -> Authorization header.
29
+ * Header names are lowercased and sorted; the payload is hashed. Any deviation
30
+ * produces a signature mismatch, so the ordering here is load-bearing.
31
+ */
32
+ export function signRequest(opts) {
33
+ const { method, body, region, service, credentials } = opts;
34
+ const url = new URL(opts.url);
35
+ const now = opts.now ?? new Date();
36
+ const stamp = amzDate(now);
37
+ const date = stamp.slice(0, 8);
38
+ const headers = {
39
+ host: url.host,
40
+ "x-amz-date": stamp,
41
+ ...Object.fromEntries(Object.entries(opts.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])),
42
+ };
43
+ if (credentials.sessionToken)
44
+ headers["x-amz-security-token"] = credentials.sessionToken;
45
+ const signedHeaderNames = Object.keys(headers).sort();
46
+ const canonicalHeaders = signedHeaderNames
47
+ .map((h) => `${h}:${headers[h].trim().replace(/\s+/g, " ")}\n`)
48
+ .join("");
49
+ const signedHeaders = signedHeaderNames.join(";");
50
+ // query params must be sorted and percent-encoded
51
+ const canonicalQuery = [...url.searchParams.entries()]
52
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
53
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
54
+ .join("&");
55
+ const payloadHash = sha256(body);
56
+ const canonicalRequest = [
57
+ method.toUpperCase(),
58
+ url.pathname || "/",
59
+ canonicalQuery,
60
+ canonicalHeaders,
61
+ signedHeaders,
62
+ payloadHash,
63
+ ].join("\n");
64
+ const scope = `${date}/${region}/${service}/aws4_request`;
65
+ const stringToSign = [
66
+ "AWS4-HMAC-SHA256",
67
+ stamp,
68
+ scope,
69
+ sha256(canonicalRequest),
70
+ ].join("\n");
71
+ const kDate = hmac(`AWS4${credentials.secretAccessKey}`, date);
72
+ const kRegion = hmac(kDate, region);
73
+ const kService = hmac(kRegion, service);
74
+ const kSigning = hmac(kService, "aws4_request");
75
+ const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
76
+ return {
77
+ ...headers,
78
+ authorization: `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, ` +
79
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`,
80
+ "x-amz-content-sha256": payloadHash,
81
+ };
82
+ }
83
+ // ────────────────────────────────────────────── credential discovery
84
+ /**
85
+ * Find AWS credentials the way the SDKs do, in the same order. In SageMaker
86
+ * Studio, ECS and Lambda the environment or the container endpoint is
87
+ * populated automatically, so this returns credentials with no setup at all.
88
+ */
89
+ export async function discoverAwsCredentials(profileName = process.env.AWS_PROFILE ?? "default") {
90
+ // 1. environment (SageMaker Studio, CI, explicit exports)
91
+ if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
92
+ return {
93
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
94
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
95
+ sessionToken: process.env.AWS_SESSION_TOKEN,
96
+ source: "environment",
97
+ };
98
+ }
99
+ // 2. container credential endpoint (ECS, SageMaker, CodeBuild)
100
+ const relUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
101
+ const fullUri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
102
+ if (relUri || fullUri) {
103
+ const url = fullUri ?? `http://169.254.170.2${relUri}`;
104
+ const creds = await fetchContainerCredentials(url);
105
+ if (creds)
106
+ return creds;
107
+ }
108
+ // 3. shared credentials file (a laptop with `aws configure` run)
109
+ const fromFile = readSharedCredentials(profileName);
110
+ if (fromFile)
111
+ return fromFile;
112
+ return undefined;
113
+ }
114
+ async function fetchContainerCredentials(url) {
115
+ try {
116
+ const ctl = new AbortController();
117
+ const timer = setTimeout(() => ctl.abort(), 3000);
118
+ const headers = {};
119
+ const token = process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN;
120
+ if (token)
121
+ headers.authorization = token;
122
+ const res = await fetch(url, { headers, signal: ctl.signal });
123
+ clearTimeout(timer);
124
+ if (!res.ok)
125
+ return undefined;
126
+ const b = await res.json();
127
+ if (!b.AccessKeyId || !b.SecretAccessKey)
128
+ return undefined;
129
+ return {
130
+ accessKeyId: b.AccessKeyId,
131
+ secretAccessKey: b.SecretAccessKey,
132
+ sessionToken: b.Token,
133
+ source: "container credentials endpoint",
134
+ };
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ /** Minimal INI reader for ~/.aws/credentials — no dependency needed. */
141
+ export function readSharedCredentials(profileName = "default", file = path.join(os.homedir(), ".aws", "credentials")) {
142
+ let text;
143
+ try {
144
+ text = fs.readFileSync(file, "utf8");
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ const wanted = profileName.replace(/^profile\s+/, "");
150
+ let current = "";
151
+ const section = {};
152
+ for (const raw of text.split("\n")) {
153
+ const line = raw.split(/[#;]/)[0].trim();
154
+ if (!line)
155
+ continue;
156
+ const header = /^\[(.+)\]$/.exec(line);
157
+ if (header) {
158
+ current = header[1].replace(/^profile\s+/, "").trim();
159
+ continue;
160
+ }
161
+ if (current !== wanted)
162
+ continue;
163
+ const eq = line.indexOf("=");
164
+ if (eq === -1)
165
+ continue;
166
+ section[line.slice(0, eq).trim().toLowerCase()] = line.slice(eq + 1).trim();
167
+ }
168
+ const id = section["aws_access_key_id"], secret = section["aws_secret_access_key"];
169
+ if (!id || !secret)
170
+ return undefined;
171
+ return {
172
+ accessKeyId: id,
173
+ secretAccessKey: secret,
174
+ sessionToken: section["aws_session_token"],
175
+ source: `~/.aws/credentials [${wanted}]`,
176
+ };
177
+ }