@kaydash9999/banker-plugins 0.16.0 → 0.16.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code skills + commands — QA, audit, docs, architecture, dependency setup, and wiki; the banker plugin marketplace.",
9
- "version": "0.16.0"
9
+ "version": "0.16.2"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "banker",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
5
5
  "author": {
6
6
  "name": "smallOpenSource",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.16.2] - 2026-10-11
4
+
5
+ ### Security
6
+ - **3d-intro 가 프로젝트 폴더에 든 파일로 실행할 프로그램을 정하지 않는다.** 0.16.1 까지는 남이 만든 저장소에서 3d-intro-build 를 돌리면, 그 저장소가 커밋해 둔 파일이 실행될 수 있었다.\
7
+ 프로젝트 키 파일(`.env.3d-intro.local`)의 `FFMPEG_PATH` 를 ffmpeg 로 실행했다. 이제 그 값은 읽지 않고 이름만 알린다. 독립 검증(`verify-intro.mjs`)은 프로젝트의 `node_modules/playwright` 를 먼저 불러왔다. 이제 `--playwright-from-project` 를 줄 때만 읽는다. Windows 에서는 `npm root -g` 를 프로젝트 폴더에서 돌려 그 폴더의 `npm.cmd` 가 먼저 실행될 수 있었다. 이제 홈 폴더에서 돌린다.
8
+ - ffmpeg 는 셸의 `FFMPEG_PATH`, 홈 설정 파일 `~/.config/banker/3d-intro/settings.env`, 예전 판이 홈 키 파일에 남긴 값, `PATH` 의 절대 경로 항목, `ffmpeg-static` 순으로 찾는다. 경로는 절대 경로만 받고, `PATH` 의 `.` 같은 상대 항목은 건너뛴다. 설정 파일에는 키를 두지 않는다.
9
+ - 키 파일에 줄바꿈이 든 값이나 변수 이름이 아닌 키를 쓰지 않는다. 0.16.1 까지는 콘솔 샘플의 여러 줄 값이 홈 키 파일에 `FFMPEG_PATH` 나 두 번째 endpoint 줄을 심을 수 있었다.
10
+ - `3d-intro-setup` 의 `ffmpeg-static` 폴백은 프로젝트가 아니라 홈의 전용 폴더(`~/.config/banker/3d-intro/tools`)에 설치한다. 프로젝트의 `.npmrc` 와 스크립트가 끼어들지 않는다. 새로 만드는 설정 폴더는 0700 이다.
11
+
12
+ ### Notes
13
+ - 예전 판이 프로젝트 키 파일에만 ffmpeg 경로를 남겼고 `PATH` 에 ffmpeg 가 없다면, 3d-intro-build 가 "ffmpeg not found" 로 멈춘다. 셸에서 `export FFMPEG_PATH=<절대 경로>` 한 뒤 `3d-intro-setup` §2 를 한 번 다시 돌리면 홈 설정 파일에 남는다.
14
+ - 프로젝트에만 playwright 를 설치했다면 독립 검증에 `--playwright-from-project` 를 준다.
15
+
16
+ ## [0.16.1] - 2026-10-11
17
+
18
+ ### Security
19
+ - **3d-intro 의 API 키는 https 로만 간다.** 0.16.0 은 Sora 영상 생성, Azure 이미지 생성과 목록, setup 프리플라이트가 `http://` endpoint 에도 키를 평문으로 보냈다(https 확인은 Sora `resumeClip` 에만 있었다).\
20
+ 이제 키 헤더가 실린 요청은 https 로만 가고, http 는 이 기기의 localhost 만 허용한다. 그 밖의 http endpoint 는 보내기 전에 막는다. Sora 영상 단계는 `invalid_request` 로 멈추고, 그 밖의 호출은 `HTTP 0 (EINSECURE)` 오류다. WAN endpoint 도 https 만 받는다.
21
+ - **리다이렉트로 키, 프롬프트, 유료 제출이 다른 곳에 가지 않는다.** 0.16.0 의 fetch 는 리다이렉트를 따라가며 `api-key` 헤더를 다른 출처에 넘겼고, 307, 308 에서는 프롬프트와 프레임을 다시 보냈다. 이제 리다이렉트를 직접 따라간다(최대 5회).\
22
+ 다른 출처로는 키 헤더 없이 본문 없는 GET 만 보낸다. 본문이 있는 요청은 다른 출처로 따라가지 않아, 그곳의 401, 429 를 키의 답으로 읽고 다른 키로 다시 제출하는 일(이중 과금)이 없다. https 를 벗어나는 리다이렉트(예: 다운로드 URL 이 `http://169.254.169.254` 로 보내는 것)도 따라가지 않는다. Location 이 URL 이 아니면 그 제출을 "보내지 않음" 으로 보지 않는다.
23
+ - **`3d-intro-setup` 이 키를 채팅이나 명령줄로 받지 않는다.** 키는 사람이 `umask 077` 로 만든 키 파일에 직접 적거나, 콘솔 샘플을 비공개 파일로 저장하면 에이전트가 화면에 내지 않고 합친다.\
24
+ 합칠 때는 `resolveCreds` 가 이미 읽는 키 파일(홈 fallback 포함)에 쓰고, 홈 키 파일이 있으면 프로젝트 키 파일을 새로 만들지 않는다(만들면 홈의 키가 가려진다). 파일의 다른 키를 바꾸면 길이로만 알린다. 샘플의 endpoint 가 다른데 샘플에 키가 없으면 저장하지 않는다(기존 키가 새 host 로 가지 않게). SDK 샘플의 `api_key=subscription_key` 같은 변수 이름은 키로 읽지 않는다.\
25
+ 키가 대화나 명령에 드러났다면 Azure 포털의 Regenerate, Model Studio 콘솔의 API Key 관리에서 교체한다.
26
+ - 화면, 오류, 로그에 키가 실리지 않는다. `redact` 는 길이만 보인다(0.16.0 은 앞뒤 4자를 보였고, `sk-ws-` 키는 앞이 상수라 뒤 4자가 드러났다). URL 이 아닌 endpoint 값(붙여 넣은 키일 수 있음)은 원문 대신 `null` 이다. WAN 다운로드 실패 오류의 서명 URL 은 `err.videoUrl` 로만 읽히고 JSON 과 `console.error` 에는 실리지 않는다.
27
+ - WAN 풀 상태 파일은 무작위 이름의 임시 파일을 배타적으로 만들어 써서, 미리 심어 둔 링크를 따라 다른 파일을 덮지 않는다. 폴더는 0700, 파일은 0600 이고, 쓰기나 이름 바꾸기에 실패하면 임시 파일을 지운다.
28
+
29
+ ### Fixed
30
+ - 서버의 `Retry-After` 를 1초에서 10분 사이로만 받는다. 0.16.0 은 `999999999` 면 키를 31년 쉬게 했고, `0` 이면 기다리지 않고 다시 시도했다. 쉬는 키의 기록은 `resetPoolState({ creds, labels })` 로 지운다.
31
+ - setup 프리플라이트가 status 0 의 이유(`EINSECURE`, `ERR_INVALID_URL` 등)를 보인다. FLUX 가 다른 리소스면 그 키를 `AZURE_IMAGE_SERVICES_API_KEY` 로 키 파일에 적는다.
32
+
3
33
  ## [0.16.0] - 2026-10-11
4
34
 
5
35
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"
@@ -50,19 +50,22 @@ description: "스크롤 위치에 맞춰 3D 플라이스루 영상이 움직이
50
50
  ```js
51
51
  import { resolveCreds, resolveFfmpeg } from './references/azure-adapter.mjs';
52
52
  const creds = resolveCreds({ projectDir }); // _source === null 이면 미설정
53
- // setup 이 ffmpeg 경로를 creds 에 FFMPEG_PATH 로 영속했으면 이 프로세스 env 에 적용(다른 세션/no-PATH 대비)
54
- if (creds.FFMPEG_PATH && !process.env.FFMPEG_PATH) process.env.FFMPEG_PATH = creds.FFMPEG_PATH;
53
+ // ffmpeg 경로는 resolveFfmpeg 가 셸의 FFMPEG_PATH 와 홈의 설정 파일, 키 파일에서만 찾는다. 프로젝트 키 파일의 값은 쓰지 않는다.
54
+ if (creds._ignored) console.log('프로젝트 키 파일에서 쓰지 않는 값:', Object.keys(creds._ignored).join(', ')); // 값은 출력하지 않는다
55
55
  ```
56
56
 
57
57
  - `creds._source` 가 `null` 이면 크리덴셜이 없다. `3d-intro-setup` 으로 보낸다.
58
+ - `creds._ignored` 가 있으면 프로젝트 키 파일의 `FFMPEG_PATH` 를 쓰지 않은 것이다. 저장소와 함께 온 파일이 실행할 프로그램을 정하지 못하게 막은 것이다. 값은 출력하지 않으니 사람이 파일을 열어 확인하고, 그 경로가 필요하면 `3d-intro-setup` §2 로 홈 설정 파일에 남긴다.
58
59
  Claude Code: `/banker:setup` 에서 3d-intro-setup / Codex: `banker-3d-intro-setup`.
59
60
  - `resolveFfmpeg()` 가 throw 하면 ffmpeg 가 없다. 같은 `3d-intro-setup` 으로 보낸다.
60
61
  - 둘 다 통과해야 다음 단계로 간다.
61
62
  - 영상 provider 상태를 무료로 확인해 둔다.
62
63
  `poolSummary({ creds })` 는 WAN 키별 상태(`ok`, `cooldown`, `exhausted`, `invalid`, `malformed`)를 키 원문 없이 돌려준다.
63
- `malformed` 는 endpoint 가 http(s) URL 이 아니거나 모델이 한 id 가 아닌 항목이다(`reason` 이 `endpoint` 또는 `model`). 영상 단계는 이 항목을 건너뛰고, 모두 `malformed` 면 영상 단계에서 멈춘다. 그러니 유료 스틸을 만들기 전에 사용자에게 알리고 `3d-intro-setup` 으로 고친다.
64
+ 쉬는 키를 바로 다시 쓰려면 `resetPoolState({ creds, labels: ['WAN_2'] })` 로 그 키의 기록을 지운다(`labels` 를 빼면 전체). 서버의 `Retry-After` 는 1초에서 10분 사이로만 받는다.
65
+ `malformed` 는 endpoint 가 https URL 이 아니거나(http 는 이 기기의 localhost 만 허용) 모델이 한 id 가 아닌 항목이다(`reason` 이 `endpoint` 또는 `model`). 영상 단계는 이 항목을 건너뛰고, 모두 `malformed` 면 영상 단계에서 멈춘다. 그러니 유료 스틸을 만들기 전에 사용자에게 알리고 `3d-intro-setup` 으로 고친다.
66
+ Azure 호출도 같다. 키는 https 로만 보내고, `http://` endpoint 는 보내기 전에 막는다(Sora 영상 단계는 `invalid_request`, 이미지 생성 같은 그 밖의 호출은 `HTTP 0 (EINSECURE)` 오류).
64
67
  WAN 항목(`WAN_<n>_ENDPOINT`, `WAN_<n>_API_KEY`)이 하나도 없으면 영상은 처음부터 Sora 로 만든다. 이때 정지와 비행 모드(Step 4B)는 쓸 수 없다.
65
- - 독립 검증(Step 6)에는 Node 용 playwright 패키지와 Chromium 이 필요하다. `npx playwright install chromium`(`setup-playwright`)은 브라우저만 받으므로, 패키지도 `npm i -g playwright` 나 프로젝트의 `npm i -D playwright` 로 설치해 둔다.
68
+ - 독립 검증(Step 6)에는 Node 용 playwright 패키지와 Chromium 이 필요하다. `npx playwright install chromium`(`setup-playwright`)은 브라우저만 받으므로, 패키지도 `npm i -g playwright` 로 설치해 둔다. 사용자가 자기 프로젝트에 `npm i -D playwright` 로 설치했다고 확인해 준 때만 Step 6 에 `--playwright-from-project` 를 준다. 프로젝트에 `node_modules/playwright` 가 있다는 이유만으로는 주지 않는다. 이 옵션이 없으면 프로젝트의 `node_modules` 는 읽지 않는다(저장소가 함께 가져온 코드가 실행되지 않게).
66
69
 
67
70
  ---
68
71
 
@@ -134,6 +137,7 @@ const png = await generateImage({ endpoint, key, deployment, prompt, size: ORIEN
134
137
  `generateImage` 는 서버의 `Retry-After` 만큼 기다렸다가 같은 경로로 다시 시도한다(기본 3회, `retry429`). 그래도 실패하면 씬 사이에 간격을 두고 다시 부른다.
135
138
  - **변형(권장)** - 씬마다 테이크를 2개 만들면 사용자가 비교해 고를 수 있다. 스틸 비용이 대략 씬당 2배이니 Step 2 추정에 미리 넣는다.
136
139
  - **FLUX 옵트인** - 씬 간 톤, 질감 일관성을 더 원하면 `generateImageFlux({ width, height })`(ORIENTATION 을 나눠 전달).
140
+ `endpoint` 는 `AZURE_IMAGE_SERVICES_ENDPOINT`, `key` 는 `AZURE_IMAGE_SERVICES_API_KEY` 이고 이 줄이 없으면 `AZURE_IMAGE_API_KEY` 다.
137
141
  FLUX 크리덴셜을 요청했는데 미수집이면 조용히 실패하지 말고 `gpt-image-2` 로 **정직하게 폴백**한다("FLUX 미수집, gpt-image-2 로 진행").
138
142
  - **N3 주의(씨앗 스틸 크기)** - `generateImage` 의 classic 폴백이 뜨면 반환 크기가 `1024x1536`(2:3)라 `720x1280` 이 아니다.
139
143
  체인 씨앗이 되는 스틸은 `probeDims` 로 확인해 ORIENTATION 과 다르면 영상 전에 리사이즈(ffmpeg `scale`)하거나 재생성한다.
@@ -36,8 +36,9 @@ function originOf(url) {
36
36
  const u = new URL(raw);
37
37
  return `${u.protocol}//${u.host}`;
38
38
  } catch {
39
- const m = raw.match(/^(https?:\/\/[^/]+)/i);
40
- return m ? m[1] : raw.replace(/\/+$/, '');
39
+ // Not a URL: null, never the raw text, which may be a key pasted into the endpoint field.
40
+ const m = raw.match(/^(https?:\/\/[^/\s]+)/i);
41
+ return m ? m[1] : null;
41
42
  }
42
43
  }
43
44
 
@@ -50,6 +51,10 @@ function truncate(x, n = 300) {
50
51
  return s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s;
51
52
  }
52
53
 
54
+ // Why an azFetch result has no HTTP answer, for error messages: " (EINSECURE)" and the like. The
55
+ // error's code or name only, never its text: fetch can quote a header value, a key included.
56
+ const why = (r) => (r.error ? ` (${r.error.cause?.code || r.error.code || r.error.name})` : '');
57
+
53
58
  // Last n chars of ffmpeg stderr (the useful part) for error messages.
54
59
  const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
55
60
 
@@ -77,7 +82,7 @@ export function parseConsoleSample(text) {
77
82
  const out = { endpoint: null, deployment: null, apiKey: null, apiVersion: null };
78
83
 
79
84
  // endpoint: an explicit kwarg (python/JS SDK) wins; else the origin of the first URL (curl).
80
- let m = s.match(/(?:azure_endpoint|azure_openai_endpoint|base_url|endpoint)\s*[=:]\s*["']([^"']+)["']/i);
85
+ let m = s.match(/(?:azure_endpoint|azure_openai_endpoint|base_url|endpoint)\s*[=:]\s*["']([^"'\r\n]+)["']/i);
81
86
  if (m) out.endpoint = originOf(m[1]);
82
87
  else { m = s.match(/https?:\/\/[^\s"'`)]+/); if (m) out.endpoint = originOf(m[0]); }
83
88
 
@@ -89,13 +94,16 @@ export function parseConsoleSample(text) {
89
94
  m = s.match(/\/deployments\/([^/?"'\s]+)/i);
90
95
  if (m) out.deployment = decodeURIComponent(m[1]);
91
96
  else {
92
- m = s.match(/(?:azure_deployment|deployment_name|deployment|model)\s*[=:]\s*["']([^"']+)["']/i);
97
+ m = s.match(/(?:azure_deployment|deployment_name|deployment|model)\s*[=:]\s*["']([^"'\r\n]+)["']/i);
93
98
  if (m) out.deployment = m[1];
94
99
  }
95
100
 
96
- // key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>" (python/JS SDK).
97
- m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i);
98
- if (!m) m = s.match(/api[-_]?key["']?\s*[:=]\s*["']?\s*([A-Za-z0-9._-]+)/i);
101
+ // key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>", "api-key": "<k>",
102
+ // apiKey: '<k>' (python/JS SDK). An SDK value counts only in quotes: api_key=subscription_key or
103
+ // apiKey: process.env.X names a variable, and taking that name for the key would replace a real one.
104
+ m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i)
105
+ || s.match(/api-key\s*:\s*([A-Za-z0-9._-]+)/i)
106
+ || s.match(/api[-_]?key["']?\s*[:=]\s*["']([A-Za-z0-9._-]+)["']/i);
99
107
  if (m) out.apiKey = m[1];
100
108
 
101
109
  return out;
@@ -126,56 +134,164 @@ function envValue(raw) {
126
134
  return raw.trimEnd().replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
127
135
  }
128
136
 
137
+ /** The home key file, `~/.config/banker/3d-intro/env`: the keys when no project key file is used. */
138
+ export function homeCredsPath() {
139
+ return path.join(os.homedir(), '.config', 'banker', '3d-intro', 'env');
140
+ }
141
+
142
+ /**
143
+ * This machine's settings file, `~/.config/banker/3d-intro/settings.env` (FFMPEG_PATH). It holds no
144
+ * key, so it can exist without making the home key file look set up.
145
+ */
146
+ export function homeSettingsPath() {
147
+ return path.join(os.homedir(), '.config', 'banker', '3d-intro', 'settings.env');
148
+ }
149
+
150
+ /** The settings file as a KEY=VALUE map; {} when it is missing or cannot be read. */
151
+ export function readSettings() {
152
+ return readEnvQuietly(homeSettingsPath);
153
+ }
154
+
155
+ // A KEY=VALUE file named by fileOf(); {} when there is none or it cannot be read (no home folder, no
156
+ // permission, a folder in its place).
157
+ function readEnvQuietly(fileOf) {
158
+ try { return parseEnvFile(fs.readFileSync(fileOf(), 'utf8')); } catch { return {}; }
159
+ }
160
+
161
+ // Settings that name a program to run. Only the shell or this machine's own files set them (the
162
+ // settings file, or the home key file from an earlier setup): a project's .env.3d-intro.local can come
163
+ // with a repository, and must not choose what runs on this machine.
164
+ const MACHINE_ONLY = ['FFMPEG_PATH'];
165
+
129
166
  /**
130
167
  * Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
131
- * Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found).
168
+ * Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found). From a
169
+ * project file, FFMPEG_PATH is left out and returned under `_ignored` so the caller can say why.
132
170
  */
133
171
  export function resolveCreds({ projectDir } = {}) {
172
+ const home = homeCredsPath();
134
173
  const candidates = [];
135
174
  if (projectDir) candidates.push(path.join(projectDir, '.env.3d-intro.local'));
136
- candidates.push(path.join(os.homedir(), '.config', 'banker', '3d-intro', 'env'));
137
- for (const p of candidates) {
138
- if (fs.existsSync(p)) return { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
139
- }
140
- return { _source: null };
175
+ candidates.push(home);
176
+ const p = candidates.find((c) => fs.existsSync(c));
177
+ if (!p) return { _source: null };
178
+ const creds = { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
179
+ return p === home ? creds : withoutMachineOnly(creds);
180
+ }
181
+
182
+ function withoutMachineOnly(creds) {
183
+ const ignored = Object.fromEntries(MACHINE_ONLY.filter((k) => creds[k] != null).map((k) => [k, creds[k]]));
184
+ for (const k of MACHINE_ONLY) delete creds[k];
185
+ return Object.keys(ignored).length ? { ...creds, _ignored: ignored } : creds;
141
186
  }
142
187
 
143
188
  /**
144
189
  * Persist creds to a KEY=VALUE env file, chmod 0600 where supported. Keys starting with `_`
145
- * and null/undefined values are skipped. Returns the written path.
190
+ * and null/undefined values are skipped. Returns the written path. A name that is not a variable
191
+ * name, or a value with a line break, throws before anything is written: either would add lines of
192
+ * its own (a FFMPEG_PATH, a second endpoint) to the file.
146
193
  */
147
194
  export function persistCreds(creds, { target } = {}) {
148
195
  if (!target) throw new Error('persistCreds: target path required');
149
- const lines = [];
150
- for (const [k, v] of Object.entries(creds || {})) {
151
- if (k.startsWith('_') || v == null) continue;
152
- lines.push(`${k}=${envLiteral(String(v))}`);
153
- }
196
+ const lines = Object.entries(creds || {}).filter(([k, v]) => !k.startsWith('_') && v != null).map(([k, v]) => envLine(k, String(v)));
154
197
  const body = lines.length ? `${lines.join('\n')}\n` : '';
155
- fs.mkdirSync(path.dirname(target), { recursive: true });
198
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
156
199
  fs.writeFileSync(target, body, { mode: 0o600 });
157
200
  try { fs.chmodSync(target, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
158
201
  return target;
159
202
  }
160
203
 
204
+ // One KEY=VALUE line for persistCreds, refusing a name or value that would make lines of its own.
205
+ function envLine(k, v) {
206
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Error(`persistCreds: ${JSON.stringify(k)} is not a variable name; nothing was written`);
207
+ if (/[\r\n]/.test(v)) throw new Error(`persistCreds: ${k} contains a line break; nothing was written`);
208
+ return `${k}=${envLiteral(v)}`;
209
+ }
210
+
161
211
  // Quote a value that parseEnvFile would otherwise cut at " #", trim, or strip of its quotes.
162
212
  function envLiteral(v) {
163
213
  if (!/\s#|^["'\s]|["'\s]$/.test(v)) return v;
164
214
  return v.includes('"') ? `'${v}'` : `"${v}"`;
165
215
  }
166
216
 
167
- /** Redact a secret to `abcd…wxyz(len)`; short (<=8) secrets are fully masked; falsy -> "(missing)". */
217
+ /**
218
+ * Redact a secret to its length only, `…(len)`: no character of it, since keys share fixed
219
+ * prefixes (`sk-ws-`) and a shown tail narrows them down. Falsy -> "(missing)".
220
+ */
168
221
  export function redact(s) {
169
222
  if (!s) return '(missing)';
170
- const str = String(s);
171
- if (str.length <= 8) return `…(${str.length})`;
172
- return `${str.slice(0, 4)}…${str.slice(-4)}(${str.length})`;
223
+ return `…(${String(s).length})`;
173
224
  }
174
225
 
175
226
  // ---------------------------------------------------------------------------
176
227
  // HTTP
177
228
  // ---------------------------------------------------------------------------
178
229
 
230
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
231
+ const SECRET_HEADERS = new Set(['authorization', 'api-key', 'proxy-authorization', 'cookie']);
232
+ const MAX_HOPS = 5;
233
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
234
+ const localHttp = (u) => u.protocol === 'http:' && LOOPBACK.has(u.hostname);
235
+ const refused = (code, message) => Object.assign(new TypeError(`fetch failed: ${message}`), { cause: { code } });
236
+ const isSecret = (name) => SECRET_HEADERS.has(name.toLowerCase());
237
+ const withoutSecrets = (headers) => Object.fromEntries(Object.entries(headers).filter(([k]) => !isSecret(k)));
238
+ // Every form fetch takes (an object, [name, value] pairs, Headers) as one plain object, so a key is
239
+ // found in any of them. Pairs would otherwise read as headers named "0", "1".
240
+ const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.fromEntries(new Headers(h)) : { ...h });
241
+
242
+ /** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
243
+ export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
244
+
245
+ // Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
246
+ // leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
247
+ // while the request is on this machine over http (a local mock); from anywhere else, an https
248
+ // localhost included, it would reach a service here or on the network.
249
+ function redirectTarget(location, from) {
250
+ let next;
251
+ try { next = new URL(location, from); } catch { throw refused('EBADREDIRECT', 'the redirect location is not a URL'); }
252
+ if (next.protocol === 'https:' || (localHttp(next) && localHttp(from))) return next;
253
+ throw refused('EINSECUREREDIRECT', 'a redirect away from https is not followed');
254
+ }
255
+
256
+ // The request for the next hop of a redirect. A request with a body (a prompt, a frame, a paid submit)
257
+ // follows only a 307 or 308 within its origin, which send it again as it was. A 301, 302 or 303 would
258
+ // make it a GET, and another origin is another server: either way the next answer does not answer this
259
+ // request, and its 401 or 429 would read as this key's (a key retired, a submit sent again elsewhere).
260
+ // Without a body, a 303, and a 301 or 302 after a POST, go on as a GET, as browsers do, and a hop to
261
+ // another origin goes without the secret headers.
262
+ function nextHop(req, status, crossOrigin) {
263
+ if (req.body != null && (crossOrigin || status < 307)) {
264
+ throw refused(crossOrigin ? 'ECROSSREDIRECT' : 'EBODYREDIRECT', 'a request with a body follows only a 307 or 308 within its origin');
265
+ }
266
+ const toGet = status === 303 || ((status === 301 || status === 302) && req.method === 'POST');
267
+ const next = toGet ? { ...req, method: 'GET', body: undefined } : req;
268
+ return crossOrigin ? { ...next, headers: withoutSecrets(next.headers) } : next;
269
+ }
270
+
271
+ /**
272
+ * fetch that follows redirects by hand (at most 5): a request without a body as fetch would, but to
273
+ * another origin without the key headers; a request with a body only by a 307 or 308 within its
274
+ * origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
275
+ * any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
276
+ * URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
277
+ * https. Throws as fetch does on a network error.
278
+ */
279
+ export async function fetchGuarded(url, init = {}) {
280
+ let target = new URL(String(url));
281
+ let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
282
+ if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
283
+ for (let hop = 0; hop <= MAX_HOPS; hop++) {
284
+ const r = await globalThis.fetch(target.href, req);
285
+ const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
286
+ if (!location) return r;
287
+ try { await r.body?.cancel(); } catch { /* the redirect body is not needed */ }
288
+ const next = redirectTarget(location, target);
289
+ req = nextHop(req, r.status, next.origin !== target.origin);
290
+ target = next;
291
+ }
292
+ throw refused('ETOOMANYREDIRECTS', 'too many redirects');
293
+ }
294
+
179
295
  // Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
180
296
  function retryAfterMsOf(headers) {
181
297
  const ms = Number(headers.get('retry-after-ms'));
@@ -202,7 +318,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
202
318
  if (body !== undefined) opts.body = body;
203
319
  let r;
204
320
  try {
205
- r = await globalThis.fetch(url, opts);
321
+ r = await fetchGuarded(url, opts);
206
322
  } catch (e) {
207
323
  last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
208
324
  break; // network error is not an auth problem — do not retry other styles
@@ -260,7 +376,7 @@ export async function generateImage({
260
376
  });
261
377
  }
262
378
  const b64 = r.body?.data?.[0]?.b64_json;
263
- if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status} ${truncate(r.body)}`);
379
+ if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
264
380
  return Buffer.from(b64, 'base64');
265
381
  }
266
382
 
@@ -278,7 +394,7 @@ export async function editImage({
278
394
  key, method: 'POST', body: fd,
279
395
  });
280
396
  const b64 = r.body?.data?.[0]?.b64_json;
281
- if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
397
+ if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
282
398
  return Buffer.from(b64, 'base64');
283
399
  }
284
400
 
@@ -289,7 +405,7 @@ export async function generateImageFlux({ endpoint, key, prompt, width, height,
289
405
  body: JSON.stringify({ prompt, width, height }),
290
406
  });
291
407
  const b64 = r.body?.data?.[0]?.b64_json || r.body?.b64_json || r.body?.image;
292
- if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status} ${truncate(r.body)}`);
408
+ if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
293
409
  return Buffer.from(b64, 'base64');
294
410
  }
295
411
 
@@ -322,7 +438,7 @@ export async function createVideo({
322
438
  const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
323
439
  if (!(r.ok && r.body?.id)) {
324
440
  // status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
325
- const err = new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
441
+ const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
326
442
  err.status = r.status;
327
443
  err.errno = r.error?.cause?.code || r.error?.code || null;
328
444
  throw err;
@@ -368,7 +484,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
368
484
  const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
369
485
  key, method: 'GET', binary: true,
370
486
  });
371
- if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
487
+ if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
372
488
  return r.body;
373
489
  }
374
490
 
@@ -413,7 +529,7 @@ export async function createVideoTwoImage({
413
529
  key, method: 'POST', body: fd,
414
530
  });
415
531
  const id = r.body?.id || r.body?.job_id;
416
- if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status} ${truncate(r.body)}`);
532
+ if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
417
533
  return r.body;
418
534
  }
419
535
 
@@ -423,30 +539,43 @@ export async function createVideoTwoImage({
423
539
 
424
540
  /**
425
541
  * Locate an ffmpeg binary without ever invoking a shell:
426
- * 1) $FFMPEG_PATH (if it exists)
427
- * 2) scan $PATH entries (split on path.delimiter) for ffmpeg / ffmpeg.exe
542
+ * 1) $FFMPEG_PATH from the shell, else FFMPEG_PATH in the settings file, else one an earlier setup
543
+ * left in the home key file; absolute paths only
544
+ * 2) the absolute $PATH entries (split on path.delimiter), for ffmpeg / ffmpeg.exe
428
545
  * 3) the optional ffmpeg-static package (dynamic import — may be absent)
546
+ * A project's .env.3d-intro.local never names the program, and "." or another relative PATH entry is
547
+ * skipped: either would let the files of a project, a cloned repository say, choose what runs.
429
548
  * Throws an actionable error if none is found.
430
549
  */
431
550
  export async function resolveFfmpeg() {
432
- const fromEnv = process.env.FFMPEG_PATH;
433
- if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
551
+ const found = namedFfmpeg() || ffmpegOnPath() || await staticFfmpeg();
552
+ if (!found) throw new Error('ffmpeg not found; run motion-graphic-setup or 3d-intro-setup');
553
+ return found;
554
+ }
555
+
556
+ // The ffmpeg this machine names. Absolute only: a relative value would be looked up in the current
557
+ // folder, the project. The files are read only when the shell names nothing usable.
558
+ function namedFfmpeg() {
559
+ const usable = (p) => !!p && path.isAbsolute(p) && fs.existsSync(p);
560
+ if (usable(process.env.FFMPEG_PATH)) return process.env.FFMPEG_PATH;
561
+ return [readSettings(), readEnvQuietly(homeCredsPath)].map((s) => s.FFMPEG_PATH).find(usable) || null;
562
+ }
434
563
 
564
+ function ffmpegOnPath() {
435
565
  const names = process.platform === 'win32' ? ['ffmpeg.exe', 'ffmpeg'] : ['ffmpeg'];
436
- for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
437
- for (const name of names) {
438
- const candidate = path.join(dir, name);
439
- if (fs.existsSync(candidate)) return candidate;
440
- }
566
+ for (const dir of (process.env.PATH || '').split(path.delimiter).filter((d) => path.isAbsolute(d))) {
567
+ const hit = names.map((name) => path.join(dir, name)).find((c) => fs.existsSync(c));
568
+ if (hit) return hit;
441
569
  }
570
+ return null;
571
+ }
442
572
 
573
+ async function staticFfmpeg() {
443
574
  try {
444
575
  const mod = await import('ffmpeg-static');
445
576
  const p = mod?.default || mod;
446
- if (typeof p === 'string' && fs.existsSync(p)) return p;
447
- } catch { /* ffmpeg-static not installed — fall through to the error */ }
448
-
449
- throw new Error('ffmpeg not found; run motion-graphic-setup or 3d-intro-setup');
577
+ return typeof p === 'string' && fs.existsSync(p) ? p : null;
578
+ } catch { return null; /* ffmpeg-static not installed */ }
450
579
  }
451
580
 
452
581
  // Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.
@@ -15,17 +15,19 @@
15
15
  *
16
16
  * Usage:
17
17
  * node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5]
18
- * [--viewports 1280x800,390x844t] [--no-input] [--no-contrast]
18
+ * [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project]
19
19
  * --viewports a trailing t marks a touch screen, r reduced motion (default: the six below)
20
20
  * --header the site's fixed header (default: the engine's .sw-topbar)
21
+ * --playwright-from-project also look in the current folder's node_modules, first (your own project only)
21
22
  * Exit 0 when nothing failed, 1 otherwise; the report (JSON) goes to --out or stdout.
22
23
  *
23
24
  * Needs Playwright with Chromium (setup-playwright skill: npx playwright install chromium), found
24
- * from this folder or from the global npm root.
25
+ * beside this script or in the global npm root, and in the project only when asked.
25
26
  */
26
27
  import { spawnSync } from 'node:child_process';
27
28
  import fs from 'node:fs';
28
29
  import { createRequire } from 'node:module';
30
+ import os from 'node:os';
29
31
  import path from 'node:path';
30
32
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
33
 
@@ -128,21 +130,31 @@ function resolveFrom(base) {
128
130
  try { return createRequire(path.join(base, 'noop.js')).resolve('playwright'); } catch { return null; }
129
131
  }
130
132
 
133
+ // npm's global root. npm runs from the home folder: run from the project, cmd.exe on Windows would
134
+ // take an npm.cmd from that folder first, and a relative PATH entry would do the same anywhere.
135
+ function globalRoot() {
136
+ const win = process.platform === 'win32';
137
+ const r = spawnSync(win ? 'npm.cmd' : 'npm', ['root', '-g'], { encoding: 'utf8', shell: win, cwd: os.homedir() });
138
+ return String(r.stdout || '').trim();
139
+ }
140
+
131
141
  /**
132
- * The playwright package: beside this script, in the project (the current folder), or in the global
133
- * npm root. `npx playwright install chromium` alone fetches the browser, not this package.
142
+ * The playwright package: beside this script, else in the global npm root. The project's own
143
+ * node_modules (the current folder) is read only with `fromProject` (--playwright-from-project), and
144
+ * then first: a repository can ship a node_modules of its own, and loading it runs its code.
145
+ * `npx playwright install chromium` alone fetches the browser, not this package.
134
146
  */
135
- export async function loadPlaywright() {
147
+ export async function loadPlaywright({ fromProject = false } = {}) {
136
148
  try { return await import('playwright'); } catch { /* not beside this script */ }
137
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
138
- const globalRoot = String(spawnSync(npm, ['root', '-g'], { encoding: 'utf8', shell: process.platform === 'win32' }).stdout || '').trim();
139
- const entry = resolveFrom(process.cwd()) || (globalRoot && resolveFrom(globalRoot));
149
+ const fromHere = fromProject ? resolveFrom(process.cwd()) : null;
150
+ const root = fromHere ? null : globalRoot();
151
+ const entry = fromHere || (root && resolveFrom(root));
140
152
  if (entry) {
141
153
  const mod = await import(pathToFileURL(entry).href);
142
154
  return mod.chromium ? mod : mod.default;
143
155
  }
144
156
  throw new Error('verify-intro: the playwright package was not found. Install it and its browser: '
145
- + '`npm i -g playwright && npx playwright install chromium` (or `npm i -D playwright` in the project).');
157
+ + '`npm i -g playwright && npx playwright install chromium`, or for your own project `npm i -D playwright` and --playwright-from-project.');
146
158
  }
147
159
 
148
160
  const navInfo = (page) => page.evaluate(() => {
@@ -419,8 +431,8 @@ async function measureViewport(browser, url, vp, o) {
419
431
  }
420
432
 
421
433
  /** Measures `url` at every viewport. Resolves to { url, viewports: [...], failures }. */
422
- export async function verifyIntro(url, { viewports = VIEWPORTS, header = '.sw-topbar', minContrast = 4.5, input = true, contrast = true } = {}) {
423
- const { chromium } = await loadPlaywright();
434
+ export async function verifyIntro(url, { viewports = VIEWPORTS, header = '.sw-topbar', minContrast = 4.5, input = true, contrast = true, playwrightFromProject = false } = {}) {
435
+ const { chromium } = await loadPlaywright({ fromProject: playwrightFromProject });
424
436
  const browser = await chromium.launch();
425
437
  try {
426
438
  const results = [];
@@ -439,11 +451,11 @@ export function parseViewport(v) {
439
451
  }
440
452
 
441
453
  const FLAGS = { '--out': 'out', '--header': 'header', '--min-contrast': 'minContrast', '--viewports': 'viewports' };
442
- const SWITCHES = { '--no-input': ['input', false], '--no-contrast': ['contrast', false] };
454
+ const SWITCHES = { '--no-input': ['input', false], '--no-contrast': ['contrast', false], '--playwright-from-project': ['playwrightFromProject', true] };
443
455
 
444
- /** The command line: a URL, --flag value or --flag=value, and the two switches. */
456
+ /** The command line: a URL, --flag value or --flag=value, and the switches. */
445
457
  export function parseArgs(argv) {
446
- const o = { url: null, out: null, header: '.sw-topbar', minContrast: 4.5, viewports: VIEWPORTS, input: true, contrast: true };
458
+ const o = { url: null, out: null, header: '.sw-topbar', minContrast: 4.5, viewports: VIEWPORTS, input: true, contrast: true, playwrightFromProject: false };
447
459
  for (let i = 0; i < argv.length; i++) {
448
460
  const [flag, inline] = argv[i].includes('=') ? [argv[i].slice(0, argv[i].indexOf('=')), argv[i].slice(argv[i].indexOf('=') + 1)] : [argv[i], null];
449
461
  if (SWITCHES[flag]) o[SWITCHES[flag][0]] = SWITCHES[flag][1];
@@ -461,7 +473,7 @@ if (invokedDirectly) {
461
473
  let o;
462
474
  try { o = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
463
475
  if (!o.url) {
464
- console.error('usage: node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5] [--viewports 1280x800,390x844t] [--no-input] [--no-contrast]');
476
+ console.error('usage: node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5] [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project]');
465
477
  process.exit(2);
466
478
  }
467
479
  verifyIntro(o.url, o).then((r) => {
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 1. 측정 (verify-intro.mjs)
6
6
 
7
- Node 용 playwright 패키지와 Chromium 이 필요하다(`npm i -g playwright && npx playwright install chromium`, 또는 프로젝트에 `npm i -D playwright`).
7
+ Node 용 playwright 패키지와 Chromium 이 필요하다(`npm i -g playwright && npx playwright install chromium`). 사람이 자기 프로젝트에 `npm i -D playwright` 로 설치했다고 확인한 때만 `--playwright-from-project` 를 준다(폴더에 있다는 이유만으로는 주지 않는다). 이 옵션이 없으면 프로젝트의 `node_modules` 는 읽지 않는다.
8
8
 
9
9
  ```bash
10
10
  node references/verify-intro.mjs http://localhost:<port>/ --out verify.json