@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +4 -3
- package/skills/3d-intro-build/references/azure-adapter.mjs +83 -28
- package/skills/3d-intro-build/references/verify-intro.mjs +27 -15
- package/skills/3d-intro-build/references/verify.md +1 -1
- package/skills/3d-intro-setup/SKILL.md +13 -8
- package/skills/3d-intro-setup/references/azure-adapter.mjs +83 -28
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.16.
|
|
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.
|
|
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
|
-
//
|
|
54
|
-
if (creds.
|
|
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`
|
|
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(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
return
|
|
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
|
|
501
|
-
*
|
|
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
|
|
507
|
-
if (
|
|
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(
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
521
|
-
} catch { /* ffmpeg-static not installed
|
|
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
|
-
*
|
|
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
|
|
133
|
-
*
|
|
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
|
|
138
|
-
const
|
|
139
|
-
const entry =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
- 전부 막히면(권한/오프라인)
|
|
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 바이너리를 절대 번들하지 않는다** — 위 설치는 전부 사용자
|
|
54
|
+
**★ banker 플러그인 자체(스킬 파일·npm 패키지 배포물)에는 ffmpeg 바이너리를 절대 번들하지 않는다** — 위 설치는 전부 사용자 시스템(홈 폴더 포함)에만 적용된다(motion-graphic-setup과 동일 원칙).
|
|
54
55
|
|
|
55
|
-
검증은 `ffmpeg -version` 대신 **어댑터가 실제 쓰는 해석 로직**(`resolveFfmpeg`: `$FFMPEG_PATH` → `PATH`
|
|
56
|
-
`FFMPEG_PATH`(export)
|
|
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
|
|
64
|
-
if (
|
|
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(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
return
|
|
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
|
|
501
|
-
*
|
|
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
|
|
507
|
-
if (
|
|
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(
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
521
|
-
} catch { /* ffmpeg-static not installed
|
|
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}.
|