@kaydash9999/banker-plugins 0.16.1 → 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.1"
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.1",
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,18 @@
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
+
3
16
  ## [0.16.1] - 2026-10-11
4
17
 
5
18
  ### Security
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.16.1",
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,11 +50,12 @@ 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
  - 둘 다 통과해야 다음 단계로 간다.
@@ -64,7 +65,7 @@ if (creds.FFMPEG_PATH && !process.env.FFMPEG_PATH) process.env.FFMPEG_PATH = cre
64
65
  `malformed` 는 endpoint 가 https URL 이 아니거나(http 는 이 기기의 localhost 만 허용) 모델이 한 id 가 아닌 항목이다(`reason` 이 `endpoint` 또는 `model`). 영상 단계는 이 항목을 건너뛰고, 모두 `malformed` 면 영상 단계에서 멈춘다. 그러니 유료 스틸을 만들기 전에 사용자에게 알리고 `3d-intro-setup` 으로 고친다.
65
66
  Azure 호출도 같다. 키는 https 로만 보내고, `http://` endpoint 는 보내기 전에 막는다(Sora 영상 단계는 `invalid_request`, 이미지 생성 같은 그 밖의 호출은 `HTTP 0 (EINSECURE)` 오류).
66
67
  WAN 항목(`WAN_<n>_ENDPOINT`, `WAN_<n>_API_KEY`)이 하나도 없으면 영상은 처음부터 Sora 로 만든다. 이때 정지와 비행 모드(Step 4B)는 쓸 수 없다.
67
- - 독립 검증(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` 는 읽지 않는다(저장소가 함께 가져온 코드가 실행되지 않게).
68
69
 
69
70
  ---
70
71
 
@@ -82,7 +82,7 @@ export function parseConsoleSample(text) {
82
82
  const out = { endpoint: null, deployment: null, apiKey: null, apiVersion: null };
83
83
 
84
84
  // endpoint: an explicit kwarg (python/JS SDK) wins; else the origin of the first URL (curl).
85
- 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);
86
86
  if (m) out.endpoint = originOf(m[1]);
87
87
  else { m = s.match(/https?:\/\/[^\s"'`)]+/); if (m) out.endpoint = originOf(m[0]); }
88
88
 
@@ -94,7 +94,7 @@ export function parseConsoleSample(text) {
94
94
  m = s.match(/\/deployments\/([^/?"'\s]+)/i);
95
95
  if (m) out.deployment = decodeURIComponent(m[1]);
96
96
  else {
97
- 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);
98
98
  if (m) out.deployment = m[1];
99
99
  }
100
100
 
@@ -134,38 +134,80 @@ function envValue(raw) {
134
134
  return raw.trimEnd().replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
135
135
  }
136
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
+
137
166
  /**
138
167
  * Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
139
- * 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.
140
170
  */
141
171
  export function resolveCreds({ projectDir } = {}) {
172
+ const home = homeCredsPath();
142
173
  const candidates = [];
143
174
  if (projectDir) candidates.push(path.join(projectDir, '.env.3d-intro.local'));
144
- candidates.push(path.join(os.homedir(), '.config', 'banker', '3d-intro', 'env'));
145
- for (const p of candidates) {
146
- if (fs.existsSync(p)) return { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
147
- }
148
- 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;
149
186
  }
150
187
 
151
188
  /**
152
189
  * Persist creds to a KEY=VALUE env file, chmod 0600 where supported. Keys starting with `_`
153
- * 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.
154
193
  */
155
194
  export function persistCreds(creds, { target } = {}) {
156
195
  if (!target) throw new Error('persistCreds: target path required');
157
- const lines = [];
158
- for (const [k, v] of Object.entries(creds || {})) {
159
- if (k.startsWith('_') || v == null) continue;
160
- lines.push(`${k}=${envLiteral(String(v))}`);
161
- }
196
+ const lines = Object.entries(creds || {}).filter(([k, v]) => !k.startsWith('_') && v != null).map(([k, v]) => envLine(k, String(v)));
162
197
  const body = lines.length ? `${lines.join('\n')}\n` : '';
163
- fs.mkdirSync(path.dirname(target), { recursive: true });
198
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
164
199
  fs.writeFileSync(target, body, { mode: 0o600 });
165
200
  try { fs.chmodSync(target, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
166
201
  return target;
167
202
  }
168
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
+
169
211
  // Quote a value that parseEnvFile would otherwise cut at " #", trim, or strip of its quotes.
170
212
  function envLiteral(v) {
171
213
  if (!/\s#|^["'\s]|["'\s]$/.test(v)) return v;
@@ -497,30 +539,43 @@ export async function createVideoTwoImage({
497
539
 
498
540
  /**
499
541
  * Locate an ffmpeg binary without ever invoking a shell:
500
- * 1) $FFMPEG_PATH (if it exists)
501
- * 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
502
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.
503
548
  * Throws an actionable error if none is found.
504
549
  */
505
550
  export async function resolveFfmpeg() {
506
- const fromEnv = process.env.FFMPEG_PATH;
507
- 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
+ }
508
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
+ }
563
+
564
+ function ffmpegOnPath() {
509
565
  const names = process.platform === 'win32' ? ['ffmpeg.exe', 'ffmpeg'] : ['ffmpeg'];
510
- for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
511
- for (const name of names) {
512
- const candidate = path.join(dir, name);
513
- if (fs.existsSync(candidate)) return candidate;
514
- }
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;
515
569
  }
570
+ return null;
571
+ }
516
572
 
573
+ async function staticFfmpeg() {
517
574
  try {
518
575
  const mod = await import('ffmpeg-static');
519
576
  const p = mod?.default || mod;
520
- if (typeof p === 'string' && fs.existsSync(p)) return p;
521
- } catch { /* ffmpeg-static not installed — fall through to the error */ }
522
-
523
- 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 */ }
524
579
  }
525
580
 
526
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
@@ -43,28 +43,33 @@ OS별 상세 설치 절차(Ubuntu NodeSource·RHEL8/Rocky8 dnf 모듈·Windows w
43
43
 
44
44
  ## 2. ffmpeg 확보
45
45
  어댑터의 프레임 추출·컬러 매치·크로스페이드·이어붙이기(`extractLastFrame`·`colorMatch`·`crossfade`·`concatClips`, forward-chaining 이음새 처리)가 `ffmpeg` 바이너리를 요구한다.
46
- 설치 절차는 `motion-graphic-setup` §2 와 완전히 동일하므로 반복하지 않고 요약만 남긴다(전체 함정·오프라인 폴백은 그 스킬 참조):
46
+ 설치 절차는 `motion-graphic-setup` §2 와 같으므로 반복하지 않고 요약만 남긴다(전체 함정은 그 스킬 참조. 마지막 폴백의 설치 위치만 다르다):
47
47
  - macOS: `brew install ffmpeg`
48
48
  - Ubuntu/Debian: `sudo apt-get install -y ffmpeg`
49
49
  - RHEL8/Rocky8: BaseOS/AppStream 미포함(라이선스) → RPM Fusion 후 `dnf install -y ffmpeg`
50
50
  - Windows: `winget install --id Gyan.FFmpeg -e`
51
- - 전부 막히면(권한/오프라인) project-local 폴백: `npm install --no-save ffmpeg-static`
51
+ - 전부 막히면(권한/오프라인) 홈 폴더의 전용 폴더에 `ffmpeg-static` 을 받는다. 프로젝트 안에서 npm 을 돌리면 그 프로젝트의 `.npmrc` 와 스크립트가 끼어든다:
52
+ `(cd ~ && npm install --prefix ~/.config/banker/3d-intro/tools ffmpeg-static)` 후 `export FFMPEG_PATH="$(cd ~ && node -p "require(require('path').resolve('.config/banker/3d-intro/tools/node_modules/ffmpeg-static'))")"`
52
53
 
53
- **★ banker 플러그인 자체(스킬 파일·npm 패키지 배포물)에는 ffmpeg 바이너리를 절대 번들하지 않는다** — 위 설치는 전부 사용자 프로젝트/시스템에만 적용된다(motion-graphic-setup과 동일 원칙).
54
+ **★ banker 플러그인 자체(스킬 파일·npm 패키지 배포물)에는 ffmpeg 바이너리를 절대 번들하지 않는다** — 위 설치는 전부 사용자 시스템(홈 폴더 포함)에만 적용된다(motion-graphic-setup과 동일 원칙).
54
55
 
55
- 검증은 `ffmpeg -version` 대신 **어댑터가 실제 쓰는 해석 로직**(`resolveFfmpeg`: `$FFMPEG_PATH` → `PATH` 스캔 → `ffmpeg-static` 순)으로 한다 — 이게 통과해야 3d-intro-build 가 실제로 ffmpeg 를 찾는다.
56
- `FFMPEG_PATH`(export)는 셸 세션 한정이라, 해석된 경로를 **크레덴셜 env 파일에 `FFMPEG_PATH=` 로 영속**해 둔다 — 그래야 다른 세션/프로세스에서 도는 3d-intro-build 도 (`resolveCreds` 로 읽어) ffmpeg 를 찾는다. 시스템 `ffmpeg` 가 `PATH` 에 있으면 이 영속은 불필요하다(그래도 무해).
56
+ 검증은 `ffmpeg -version` 대신 **어댑터가 실제 쓰는 해석 로직**(`resolveFfmpeg`: 셸의 `$FFMPEG_PATH` → 홈 설정 파일 `~/.config/banker/3d-intro/settings.env` 의 `FFMPEG_PATH` → 예전 판이 홈 키 파일 `env` 에 남긴 `FFMPEG_PATH` → `PATH` 의 절대 경로 항목 → `ffmpeg-static` 순. 경로는 모두 절대 경로만)으로 한다 — 이게 통과해야 3d-intro-build 가 실제로 ffmpeg 를 찾는다.
57
+ 셸의 `FFMPEG_PATH`(export, 절대 경로만 받는다)로만 찾았으면 셸 세션 한정이라, 그 경로를 **홈 설정 파일 `settings.env` 에 `FFMPEG_PATH=` 로 영속**해 다른 세션의 3d-intro-build 도 찾게 한다. 시스템 `ffmpeg` 가 `PATH` 에 있으면 영속은 필요 없다. 이 파일에는 키를 두지 않아, 키 파일을 어디에 만들지 정하는 §3 에 영향을 주지 않는다.
58
+ 실행할 프로그램의 경로는 프로젝트 키 파일(`.env.3d-intro.local`)에 두지 않고, 거기 적힌 값은 읽지 않는다. 그 파일은 저장소와 함께 올 수 있어, 남이 만든 저장소가 이 기기에서 실행할 프로그램을 정하게 되기 때문이다.
57
59
  ```bash
58
60
  node -e "
59
61
  (async () => {
60
- const { resolveFfmpeg, resolveCreds, persistCreds } = await import('$ADAPTER');
62
+ const { resolveFfmpeg, resolveCreds, persistCreds, readSettings, homeSettingsPath } = await import('$ADAPTER');
63
+ const proj = resolveCreds({ projectDir: process.cwd() });
64
+ if (proj._ignored) console.log('프로젝트 키 파일에서 쓰지 않는 값:', Object.keys(proj._ignored).join(', ')); // 값은 출력하지 않는다
61
65
  const ff = await resolveFfmpeg().catch((e) => { console.log('ffmpeg 미확보:', e.message); process.exit(1); });
62
66
  console.log('ffmpeg 확보:', ff);
63
- const c = resolveCreds({ projectDir: process.cwd() });
64
- if (c._source && c.FFMPEG_PATH !== ff) { c.FFMPEG_PATH = ff; persistCreds(c, { target: c._source }); console.log('FFMPEG_PATH 영속 ->', c._source); }
67
+ const s = readSettings(); // 홈 설정 파일(키는 두지 않음)
68
+ if (ff === process.env.FFMPEG_PATH && s.FFMPEG_PATH !== ff) { persistCreds({ ...s, FFMPEG_PATH: ff }, { target: homeSettingsPath() }); console.log('FFMPEG_PATH 영속 ->', homeSettingsPath()); }
65
69
  })();
66
70
  "
67
71
  ```
72
+ `프로젝트 키 파일에서 쓰지 않는 값` 이 보이면 예전 판이 그 파일에 남긴 ffmpeg 경로다. 값은 출력하지 않으니 사람이 파일을 열어 그 경로가 맞는지 확인하고, 맞으면 셸에서 `export FFMPEG_PATH=<그 경로>` 한 뒤 이 명령을 다시 돌려 홈 설정 파일에 남긴다.
68
73
 
69
74
  ## 3. 크레덴셜 입력
70
75
  **★시크릿(API key)은 사용자만 입력 — 채팅 로그·커밋·리포트에 원문 노출 금지, 항상 `redact()` 결과만 표기한다.**
@@ -82,7 +82,7 @@ export function parseConsoleSample(text) {
82
82
  const out = { endpoint: null, deployment: null, apiKey: null, apiVersion: null };
83
83
 
84
84
  // endpoint: an explicit kwarg (python/JS SDK) wins; else the origin of the first URL (curl).
85
- 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);
86
86
  if (m) out.endpoint = originOf(m[1]);
87
87
  else { m = s.match(/https?:\/\/[^\s"'`)]+/); if (m) out.endpoint = originOf(m[0]); }
88
88
 
@@ -94,7 +94,7 @@ export function parseConsoleSample(text) {
94
94
  m = s.match(/\/deployments\/([^/?"'\s]+)/i);
95
95
  if (m) out.deployment = decodeURIComponent(m[1]);
96
96
  else {
97
- 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);
98
98
  if (m) out.deployment = m[1];
99
99
  }
100
100
 
@@ -134,38 +134,80 @@ function envValue(raw) {
134
134
  return raw.trimEnd().replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
135
135
  }
136
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
+
137
166
  /**
138
167
  * Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
139
- * 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.
140
170
  */
141
171
  export function resolveCreds({ projectDir } = {}) {
172
+ const home = homeCredsPath();
142
173
  const candidates = [];
143
174
  if (projectDir) candidates.push(path.join(projectDir, '.env.3d-intro.local'));
144
- candidates.push(path.join(os.homedir(), '.config', 'banker', '3d-intro', 'env'));
145
- for (const p of candidates) {
146
- if (fs.existsSync(p)) return { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
147
- }
148
- 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;
149
186
  }
150
187
 
151
188
  /**
152
189
  * Persist creds to a KEY=VALUE env file, chmod 0600 where supported. Keys starting with `_`
153
- * 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.
154
193
  */
155
194
  export function persistCreds(creds, { target } = {}) {
156
195
  if (!target) throw new Error('persistCreds: target path required');
157
- const lines = [];
158
- for (const [k, v] of Object.entries(creds || {})) {
159
- if (k.startsWith('_') || v == null) continue;
160
- lines.push(`${k}=${envLiteral(String(v))}`);
161
- }
196
+ const lines = Object.entries(creds || {}).filter(([k, v]) => !k.startsWith('_') && v != null).map(([k, v]) => envLine(k, String(v)));
162
197
  const body = lines.length ? `${lines.join('\n')}\n` : '';
163
- fs.mkdirSync(path.dirname(target), { recursive: true });
198
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
164
199
  fs.writeFileSync(target, body, { mode: 0o600 });
165
200
  try { fs.chmodSync(target, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
166
201
  return target;
167
202
  }
168
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
+
169
211
  // Quote a value that parseEnvFile would otherwise cut at " #", trim, or strip of its quotes.
170
212
  function envLiteral(v) {
171
213
  if (!/\s#|^["'\s]|["'\s]$/.test(v)) return v;
@@ -497,30 +539,43 @@ export async function createVideoTwoImage({
497
539
 
498
540
  /**
499
541
  * Locate an ffmpeg binary without ever invoking a shell:
500
- * 1) $FFMPEG_PATH (if it exists)
501
- * 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
502
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.
503
548
  * Throws an actionable error if none is found.
504
549
  */
505
550
  export async function resolveFfmpeg() {
506
- const fromEnv = process.env.FFMPEG_PATH;
507
- 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
+ }
508
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
+ }
563
+
564
+ function ffmpegOnPath() {
509
565
  const names = process.platform === 'win32' ? ['ffmpeg.exe', 'ffmpeg'] : ['ffmpeg'];
510
- for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
511
- for (const name of names) {
512
- const candidate = path.join(dir, name);
513
- if (fs.existsSync(candidate)) return candidate;
514
- }
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;
515
569
  }
570
+ return null;
571
+ }
516
572
 
573
+ async function staticFfmpeg() {
517
574
  try {
518
575
  const mod = await import('ffmpeg-static');
519
576
  const p = mod?.default || mod;
520
- if (typeof p === 'string' && fs.existsSync(p)) return p;
521
- } catch { /* ffmpeg-static not installed — fall through to the error */ }
522
-
523
- 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 */ }
524
579
  }
525
580
 
526
581
  // Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.