@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +8 -4
- package/skills/3d-intro-build/references/azure-adapter.mjs +173 -44
- 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-build/references/video-pool.mjs +58 -21
- package/skills/3d-intro-setup/SKILL.md +94 -53
- package/skills/3d-intro-setup/references/azure-adapter.mjs +173 -44
- package/skills/3d-intro-setup/references/video-pool.mjs +58 -21
|
@@ -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,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.
|
|
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
|
-
//
|
|
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
|
- 둘 다 통과해야 다음 단계로 간다.
|
|
61
62
|
- 영상 provider 상태를 무료로 확인해 둔다.
|
|
62
63
|
`poolSummary({ creds })` 는 WAN 키별 상태(`ok`, `cooldown`, `exhausted`, `invalid`, `malformed`)를 키 원문 없이 돌려준다.
|
|
63
|
-
|
|
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`
|
|
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
|
-
|
|
40
|
-
|
|
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>"
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
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;
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
427
|
-
*
|
|
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
|
|
433
|
-
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
|
+
}
|
|
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(
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
447
|
-
} catch { /* ffmpeg-static not installed
|
|
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
|
-
*
|
|
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
|