@athsra/cli 1.0.0 → 1.0.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/package.json +2 -2
- package/src/commands/adopt.ts +23 -1
- package/src/commands/manifest.ts +68 -4
- package/src/lib/adopt-context.ts +1 -43
- package/src/lib/jsonc.ts +48 -0
- package/src/lib/wrangler-scan.ts +224 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@athsra/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "athsra CLI — E2EE secret manager on Cloudflare edge. Doppler-style dev UX + zero-knowledge encryption + soft-delete + version history. MIT.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@athsra/crypto": "^
|
|
46
|
+
"@athsra/crypto": "^1.0.0",
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
48
|
"@napi-rs/keyring": "^1.1.6",
|
|
49
49
|
"@scure/bip39": "^2.2.0",
|
package/src/commands/adopt.ts
CHANGED
|
@@ -40,6 +40,11 @@ import { loadAuthContext } from '../lib/auth-context.ts';
|
|
|
40
40
|
import { resolveProject } from '../lib/auto-project.ts';
|
|
41
41
|
import { readPlain } from '../lib/envelope.ts';
|
|
42
42
|
import { createManifest, loadManifest, saveManifest } from '../lib/secrets-manifest.ts';
|
|
43
|
+
import {
|
|
44
|
+
describeConflicts,
|
|
45
|
+
findConflicts,
|
|
46
|
+
scanWranglerConfig,
|
|
47
|
+
} from '../lib/wrangler-scan.ts';
|
|
43
48
|
import {
|
|
44
49
|
ensureRepoConnection,
|
|
45
50
|
getLatestBuildToken,
|
|
@@ -284,13 +289,30 @@ export async function adoptCmd(args: string[]): Promise<number> {
|
|
|
284
289
|
return 1;
|
|
285
290
|
}
|
|
286
291
|
if (!existing.manifest) {
|
|
287
|
-
|
|
292
|
+
let envelopeKeys = Object.entries(envelope)
|
|
288
293
|
.filter(([, v]) => typeof v === 'string' && v.length > 0)
|
|
289
294
|
.map(([k]) => k);
|
|
290
295
|
if (envelopeKeys.length === 0) {
|
|
291
296
|
console.error('✗ --auto-manifest: envelope 에 캡처할 키가 없음');
|
|
292
297
|
return 1;
|
|
293
298
|
}
|
|
299
|
+
// Phase 2.6.2 (v1.0.2): wrangler.jsonc vars/bindings 자동 dedupe.
|
|
300
|
+
// envelope 에서 자동 캡처하는 경로이므로 충돌은 자동 제외 (보고만).
|
|
301
|
+
const scan = scanWranglerConfig(worker.cwd);
|
|
302
|
+
if (scan) {
|
|
303
|
+
const conflicts = findConflicts(scan, envelopeKeys);
|
|
304
|
+
if (conflicts.length > 0) {
|
|
305
|
+
const conflictSet = new Set(conflicts);
|
|
306
|
+
console.log(
|
|
307
|
+
` auto-dedupe (wrangler.jsonc 충돌, ${conflicts.length} key): ${describeConflicts(scan, conflicts)}`,
|
|
308
|
+
);
|
|
309
|
+
envelopeKeys = envelopeKeys.filter((k) => !conflictSet.has(k));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (envelopeKeys.length === 0) {
|
|
313
|
+
console.error('✗ --auto-manifest: dedupe 후 남은 키 0 — 모두 wrangler.jsonc 와 충돌');
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
294
316
|
if (parsed.dryRun) {
|
|
295
317
|
console.log(
|
|
296
318
|
` (dry-run) would create manifest with ${envelopeKeys.length} keys at ${existing.path}`,
|
package/src/commands/manifest.ts
CHANGED
|
@@ -24,6 +24,11 @@ import {
|
|
|
24
24
|
manifestPath,
|
|
25
25
|
saveManifest,
|
|
26
26
|
} from '../lib/secrets-manifest.ts';
|
|
27
|
+
import {
|
|
28
|
+
describeConflicts,
|
|
29
|
+
findConflicts,
|
|
30
|
+
scanWranglerConfig,
|
|
31
|
+
} from '../lib/wrangler-scan.ts';
|
|
27
32
|
|
|
28
33
|
const USAGE = [
|
|
29
34
|
'usage: athsra manifest <subcommand> [options]',
|
|
@@ -31,8 +36,11 @@ const USAGE = [
|
|
|
31
36
|
'Phase 2.6 Option γ — sibling worker 의 secrets opt-in manifest 관리.',
|
|
32
37
|
'',
|
|
33
38
|
'서브명령:',
|
|
34
|
-
' init [--worker=<name>] [--all] [--keys=K1,K2,...] [--keys-from=<file>]',
|
|
39
|
+
' init [--worker=<name>] [--all] [--keys=K1,K2,...] [--keys-from=<file>] [--force]',
|
|
35
40
|
' manifest 신규 작성. --all 은 envelope 전체 키. --keys 명시 (콤마 구분).',
|
|
41
|
+
' v1.0.2+ Phase 2.6.2 — wrangler.jsonc 의 vars/bindings 자동 dedupe.',
|
|
42
|
+
' --all 은 충돌 자동 제외 + 보고. --keys / --keys-from 충돌 시 에러',
|
|
43
|
+
' (--force 로 우회 가능, 의도된 override 라면).',
|
|
36
44
|
'',
|
|
37
45
|
' show [--worker=<name>]',
|
|
38
46
|
' manifest 내용 출력.',
|
|
@@ -40,14 +48,15 @@ const USAGE = [
|
|
|
40
48
|
' validate [<project>] [--worker=<name>]',
|
|
41
49
|
' manifest vs envelope diff. project 명시 시 envelope 부족분 / 초과분 표시.',
|
|
42
50
|
'',
|
|
43
|
-
' add <KEY> [<KEY2>...] [--worker=<name>]',
|
|
44
|
-
' manifest 에 키 추가 (이미 있으면 idempotent).',
|
|
51
|
+
' add <KEY> [<KEY2>...] [--worker=<name>] [--force]',
|
|
52
|
+
' manifest 에 키 추가 (이미 있으면 idempotent). wrangler.jsonc 충돌 시 에러.',
|
|
45
53
|
'',
|
|
46
54
|
' remove <KEY> [<KEY2>...] [--worker=<name>]',
|
|
47
55
|
' manifest 에서 키 제거.',
|
|
48
56
|
'',
|
|
49
57
|
'공통 옵션:',
|
|
50
58
|
' --worker=<name> 다수 wrangler.jsonc 있는 monorepo 에서 명시. 단일이면 생략.',
|
|
59
|
+
' --force wrangler.jsonc 충돌 무시 (의도된 override 일 때).',
|
|
51
60
|
'',
|
|
52
61
|
'manifest 위치: <worker.cwd>/.athsra/secrets.json',
|
|
53
62
|
].join('\n');
|
|
@@ -57,6 +66,7 @@ interface ParsedFlags {
|
|
|
57
66
|
keys?: string[];
|
|
58
67
|
keysFrom?: string;
|
|
59
68
|
all: boolean;
|
|
69
|
+
force: boolean;
|
|
60
70
|
positional: string[];
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -83,10 +93,48 @@ function parseFlags(args: string[]): ParsedFlags {
|
|
|
83
93
|
: undefined,
|
|
84
94
|
keysFrom: typeof flags['keys-from'] === 'string' ? flags['keys-from'] : undefined,
|
|
85
95
|
all: flags.all === true,
|
|
96
|
+
force: flags.force === true,
|
|
86
97
|
positional,
|
|
87
98
|
};
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
/**
|
|
102
|
+
* wrangler.jsonc 의 vars/bindings 와 충돌하는 manifest key 자동 검출 + 처리.
|
|
103
|
+
*
|
|
104
|
+
* @param explicit true 면 사용자가 명시 입력 (--keys / --keys-from / manifest add) —
|
|
105
|
+
* 충돌은 실수일 가능성 → `--force` 없으면 에러로 거부.
|
|
106
|
+
* false 면 envelope 전체 자동 캡처 (--all) — 충돌은 envelope 기존 키 — 자동 제외.
|
|
107
|
+
* @returns 처리 후 keys (자동 제외 결과) + ok 플래그 (false 면 caller 가 즉시 종료)
|
|
108
|
+
*/
|
|
109
|
+
function applyWranglerDedupe(
|
|
110
|
+
workerCwd: string,
|
|
111
|
+
keys: string[],
|
|
112
|
+
explicit: boolean,
|
|
113
|
+
force: boolean,
|
|
114
|
+
): { keys: string[]; ok: boolean } {
|
|
115
|
+
const scan = scanWranglerConfig(workerCwd);
|
|
116
|
+
if (!scan) return { keys, ok: true };
|
|
117
|
+
const conflicts = findConflicts(scan, keys);
|
|
118
|
+
if (conflicts.length === 0) return { keys, ok: true };
|
|
119
|
+
if (explicit && !force) {
|
|
120
|
+
console.error(`✗ ${conflicts.length} key(s) collide with wrangler.jsonc vars/bindings:`);
|
|
121
|
+
console.error(` ${describeConflicts(scan, conflicts)}`);
|
|
122
|
+
console.error(` 파일: ${scan.configPath}`);
|
|
123
|
+
console.error(' wrangler 가 이미 이 식별자를 var 또는 binding 으로 정의함 —');
|
|
124
|
+
console.error(' secret 으로 sync 하면 deploy 시점에 충돌 (CF wrangler 가 거부).');
|
|
125
|
+
console.error(' 해결:');
|
|
126
|
+
console.error(' 1) 명시에서 빼고 재시도 (권장)');
|
|
127
|
+
console.error(' 2) wrangler.jsonc 의 vars/binding 이름 변경');
|
|
128
|
+
console.error(' 3) 의도된 override 면 --force');
|
|
129
|
+
return { keys, ok: false };
|
|
130
|
+
}
|
|
131
|
+
const conflictSet = new Set(conflicts);
|
|
132
|
+
console.log(
|
|
133
|
+
` 자동 제외 (wrangler.jsonc 충돌, ${conflicts.length} key): ${describeConflicts(scan, conflicts)}`,
|
|
134
|
+
);
|
|
135
|
+
return { keys: keys.filter((k) => !conflictSet.has(k)), ok: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
90
138
|
function pickWorker(workers: WrangerWorker[], explicit?: string): WrangerWorker | string {
|
|
91
139
|
if (explicit) {
|
|
92
140
|
const found = workers.find((w) => w.name === explicit);
|
|
@@ -136,10 +184,13 @@ async function cmdInit(flags: ParsedFlags): Promise<number> {
|
|
|
136
184
|
}
|
|
137
185
|
|
|
138
186
|
let secrets: string[];
|
|
187
|
+
let explicit: boolean;
|
|
139
188
|
if (flags.keys && flags.keys.length > 0) {
|
|
140
189
|
secrets = flags.keys;
|
|
190
|
+
explicit = true;
|
|
141
191
|
} else if (flags.keysFrom) {
|
|
142
192
|
secrets = await readKeysFromFile(flags.keysFrom);
|
|
193
|
+
explicit = true;
|
|
143
194
|
} else if (flags.all) {
|
|
144
195
|
// envelope 전체 키 — project 추론 필요
|
|
145
196
|
const { project } = resolveProject([]);
|
|
@@ -160,6 +211,7 @@ async function cmdInit(flags: ParsedFlags): Promise<number> {
|
|
|
160
211
|
secrets = Object.keys(envelope).filter(
|
|
161
212
|
(k) => typeof envelope[k] === 'string' && envelope[k].length > 0,
|
|
162
213
|
);
|
|
214
|
+
explicit = false;
|
|
163
215
|
console.log(` envelope '${project}' 의 ${secrets.length} 키를 manifest 로 캡처`);
|
|
164
216
|
} else {
|
|
165
217
|
console.error('✗ secrets 출처 명시 필요: --keys=K1,K2 / --keys-from=<file> / --all');
|
|
@@ -171,6 +223,15 @@ async function cmdInit(flags: ParsedFlags): Promise<number> {
|
|
|
171
223
|
return 1;
|
|
172
224
|
}
|
|
173
225
|
|
|
226
|
+
// Phase 2.6.2 (v1.0.2): wrangler.jsonc vars/bindings 자동 dedupe
|
|
227
|
+
const deduped = applyWranglerDedupe(worker.cwd, secrets, explicit, flags.force);
|
|
228
|
+
if (!deduped.ok) return 1;
|
|
229
|
+
secrets = deduped.keys;
|
|
230
|
+
if (secrets.length === 0) {
|
|
231
|
+
console.error('✗ dedupe 후 남은 secret 0개 — 모든 키가 wrangler.jsonc 와 충돌');
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
174
235
|
let manifest: SecretsManifest;
|
|
175
236
|
try {
|
|
176
237
|
manifest = createManifest(secrets);
|
|
@@ -302,7 +363,10 @@ function modifyManifest(
|
|
|
302
363
|
const current = new Set(loaded.manifest.secrets);
|
|
303
364
|
const before = current.size;
|
|
304
365
|
if (op === 'add') {
|
|
305
|
-
|
|
366
|
+
// Phase 2.6.2 — manifest add 도 wrangler.jsonc 충돌 검사
|
|
367
|
+
const deduped = applyWranglerDedupe(worker.cwd, keys, true, flags.force);
|
|
368
|
+
if (!deduped.ok) return 1;
|
|
369
|
+
for (const k of deduped.keys) current.add(k);
|
|
306
370
|
} else {
|
|
307
371
|
for (const k of keys) current.delete(k);
|
|
308
372
|
}
|
package/src/lib/adopt-context.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
11
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
12
|
+
import { parseJsonc } from './jsonc.ts';
|
|
12
13
|
|
|
13
14
|
export interface AdoptInferred {
|
|
14
15
|
/** 발견된 모든 wrangler.jsonc / wrangler.toml 경로 + 추출된 name */
|
|
@@ -28,49 +29,6 @@ export interface WrangerWorker {
|
|
|
28
29
|
rootDirectory: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
/** JSONC (comments 허용) 안전 파싱 — wrangler.jsonc 가 표준 JSONC 사용. */
|
|
32
|
-
function parseJsonc(text: string): unknown {
|
|
33
|
-
// 단순 cleanup: line comments (//) + block comments (/* */) 제거.
|
|
34
|
-
// 문자열 내부 // 는 보존 — 정공법은 proper JSONC parser 인데 의존성 추가 회피.
|
|
35
|
-
let cleaned = '';
|
|
36
|
-
let i = 0;
|
|
37
|
-
let inString = false;
|
|
38
|
-
let escaped = false;
|
|
39
|
-
while (i < text.length) {
|
|
40
|
-
const c = text[i];
|
|
41
|
-
const next = text[i + 1];
|
|
42
|
-
if (inString) {
|
|
43
|
-
cleaned += c;
|
|
44
|
-
if (escaped) escaped = false;
|
|
45
|
-
else if (c === '\\') escaped = true;
|
|
46
|
-
else if (c === '"') inString = false;
|
|
47
|
-
i++;
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
if (c === '"') {
|
|
51
|
-
inString = true;
|
|
52
|
-
cleaned += c;
|
|
53
|
-
i++;
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
if (c === '/' && next === '/') {
|
|
57
|
-
while (i < text.length && text[i] !== '\n') i++;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
if (c === '/' && next === '*') {
|
|
61
|
-
i += 2;
|
|
62
|
-
while (i < text.length - 1 && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
63
|
-
i += 2;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
cleaned += c;
|
|
67
|
-
i++;
|
|
68
|
-
}
|
|
69
|
-
// trailing commas 제거 (objects + arrays). 단순 regex.
|
|
70
|
-
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
|
|
71
|
-
return JSON.parse(cleaned);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
32
|
/** wrangler.jsonc / wrangler.toml 의 name field. 없으면 null. */
|
|
75
33
|
function readWranglerName(path: string): string | null {
|
|
76
34
|
try {
|
package/src/lib/jsonc.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsonc.ts — JSON-with-comments parser. 의존성 0.
|
|
3
|
+
*
|
|
4
|
+
* wrangler.jsonc 가 JSONC 사용 (`// line`, block comment, trailing comma 허용).
|
|
5
|
+
* 외부 의존성 추가 회피 — 단순 state machine 으로 in-string 토큰 보호하면서
|
|
6
|
+
* comment 제거 + trailing comma 제거 후 표준 `JSON.parse`.
|
|
7
|
+
*
|
|
8
|
+
* Phase 2.6.2 (2026-05-27, v1.0.2): `adopt-context.ts` 에서 추출. wrangler-scan
|
|
9
|
+
* 이 추가로 재사용 — single source of truth.
|
|
10
|
+
*/
|
|
11
|
+
export function parseJsonc(text: string): unknown {
|
|
12
|
+
let cleaned = '';
|
|
13
|
+
let i = 0;
|
|
14
|
+
let inString = false;
|
|
15
|
+
let escaped = false;
|
|
16
|
+
while (i < text.length) {
|
|
17
|
+
const c = text[i];
|
|
18
|
+
const next = text[i + 1];
|
|
19
|
+
if (inString) {
|
|
20
|
+
cleaned += c;
|
|
21
|
+
if (escaped) escaped = false;
|
|
22
|
+
else if (c === '\\') escaped = true;
|
|
23
|
+
else if (c === '"') inString = false;
|
|
24
|
+
i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (c === '"') {
|
|
28
|
+
inString = true;
|
|
29
|
+
cleaned += c;
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (c === '/' && next === '/') {
|
|
34
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (c === '/' && next === '*') {
|
|
38
|
+
i += 2;
|
|
39
|
+
while (i < text.length - 1 && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
40
|
+
i += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
cleaned += c;
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
|
|
47
|
+
return JSON.parse(cleaned);
|
|
48
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wrangler-scan.ts — wrangler.jsonc 의 vars + 모든 binding 식별자 추출.
|
|
3
|
+
*
|
|
4
|
+
* **목적** (Phase 2.6.2, 2026-05-27, v1.0.2):
|
|
5
|
+
* manifest init / adopt --auto-manifest 시점에 envelope key 가 wrangler.jsonc 의
|
|
6
|
+
* vars (plain text) 또는 binding (KV/R2/D1/queue/DO/...) 식별자와 충돌하지 않도록
|
|
7
|
+
* 자동 dedupe.
|
|
8
|
+
*
|
|
9
|
+
* **배경** (commit 762188d, modfolio-connect Phase 2.6 적용 시):
|
|
10
|
+
* envelope 의 `JWKS_KEYS` (PEM) 가 apps/auth/wrangler.jsonc 의 kv_namespace binding
|
|
11
|
+
* "JWKS_KEYS" 와 충돌. `JWKS_URI`, `PUBLIC_AUTH_URL` 가 apps/app/wrangler.jsonc 의
|
|
12
|
+
* `[vars]` 와 충돌. 수동 dedupe 했지만 v1.0.2 부터 자동 감지 + 제외.
|
|
13
|
+
*
|
|
14
|
+
* **스캔 대상**:
|
|
15
|
+
* - Top-level `vars` (object 의 모든 key)
|
|
16
|
+
* - Top-level 모든 binding 종류 (kv_namespaces, d1_databases, r2_buckets,
|
|
17
|
+
* durable_objects.bindings, queues.producers, workflows, services,
|
|
18
|
+
* analytics_engine_datasets, hyperdrive, vectorize, ai, browser, assets,
|
|
19
|
+
* images, send_email, mtls_certificates, dispatch_namespaces,
|
|
20
|
+
* secret_store_secrets, pipelines, worker_loaders)
|
|
21
|
+
* - `env.*` (per-environment override) — recursive (단, env 안의 env 는 무시)
|
|
22
|
+
*
|
|
23
|
+
* **정공법**:
|
|
24
|
+
* - 의존성 추가 회피 — `lib/jsonc.ts` parser 재사용
|
|
25
|
+
* - 알 수 없는 binding 종류는 무시 (false-negative 가 false-positive 보다 안전 —
|
|
26
|
+
* 미지원 binding 충돌은 향후 BINDING_SPECS 추가로 처리, 사용자에게 환각 dedupe X)
|
|
27
|
+
* - env-specific binding 도 union — 한 env 만 충돌해도 dedupe (worker 가 어느
|
|
28
|
+
* 환경으로 deploy 될지 manifest 시점에 알 수 없음)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { parseJsonc } from './jsonc.ts';
|
|
34
|
+
|
|
35
|
+
export interface WranglerIdentifiers {
|
|
36
|
+
/** [vars] 의 모든 key (env override 포함) */
|
|
37
|
+
vars: string[];
|
|
38
|
+
/** 모든 binding 식별자 (KV/R2/D1/queue/DO/etc) */
|
|
39
|
+
bindings: string[];
|
|
40
|
+
/** vars + bindings 합집합 (dedupe 비교에 사용) */
|
|
41
|
+
all: string[];
|
|
42
|
+
/** 식별자별 출처 — "JWKS_KEYS" → ["kv_namespaces"] 같이 보고용 */
|
|
43
|
+
byType: Record<string, string[]>;
|
|
44
|
+
/** 파싱한 파일의 절대 경로 (보고용) */
|
|
45
|
+
configPath: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface BindingSpec {
|
|
49
|
+
/** wrangler.jsonc 안의 path (dot-delimited; e.g. "durable_objects.bindings") */
|
|
50
|
+
path: string;
|
|
51
|
+
/** entry 안에서 식별자를 가진 field — `binding` (대부분) 또는 `name` (DO/send_email) */
|
|
52
|
+
idKey: 'binding' | 'name';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 알려진 binding 종류 — Cloudflare Workers wrangler.jsonc schema 기반 (2026-05).
|
|
57
|
+
* 새 binding 종류 추가 시 한 줄 추가.
|
|
58
|
+
*/
|
|
59
|
+
const BINDING_SPECS: BindingSpec[] = [
|
|
60
|
+
// array of {binding: string, ...}
|
|
61
|
+
{ path: 'kv_namespaces', idKey: 'binding' },
|
|
62
|
+
{ path: 'd1_databases', idKey: 'binding' },
|
|
63
|
+
{ path: 'r2_buckets', idKey: 'binding' },
|
|
64
|
+
{ path: 'queues.producers', idKey: 'binding' },
|
|
65
|
+
{ path: 'workflows', idKey: 'binding' },
|
|
66
|
+
{ path: 'services', idKey: 'binding' },
|
|
67
|
+
{ path: 'analytics_engine_datasets', idKey: 'binding' },
|
|
68
|
+
{ path: 'hyperdrive', idKey: 'binding' },
|
|
69
|
+
{ path: 'vectorize', idKey: 'binding' },
|
|
70
|
+
{ path: 'mtls_certificates', idKey: 'binding' },
|
|
71
|
+
{ path: 'dispatch_namespaces', idKey: 'binding' },
|
|
72
|
+
{ path: 'secret_store_secrets', idKey: 'binding' },
|
|
73
|
+
{ path: 'pipelines', idKey: 'binding' },
|
|
74
|
+
{ path: 'worker_loaders', idKey: 'binding' },
|
|
75
|
+
// array of {name: string, ...}
|
|
76
|
+
{ path: 'durable_objects.bindings', idKey: 'name' },
|
|
77
|
+
{ path: 'send_email', idKey: 'name' },
|
|
78
|
+
// single object {binding: string}
|
|
79
|
+
{ path: 'ai', idKey: 'binding' },
|
|
80
|
+
{ path: 'browser', idKey: 'binding' },
|
|
81
|
+
{ path: 'images', idKey: 'binding' },
|
|
82
|
+
{ path: 'assets', idKey: 'binding' },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
function getPath(obj: unknown, path: string): unknown {
|
|
86
|
+
const parts = path.split('.');
|
|
87
|
+
let cur: unknown = obj;
|
|
88
|
+
for (const p of parts) {
|
|
89
|
+
if (typeof cur !== 'object' || cur === null) return undefined;
|
|
90
|
+
cur = (cur as Record<string, unknown>)[p];
|
|
91
|
+
}
|
|
92
|
+
return cur;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractFromSpec(obj: unknown, spec: BindingSpec): string[] {
|
|
96
|
+
const node = getPath(obj, spec.path);
|
|
97
|
+
if (node === undefined || node === null) return [];
|
|
98
|
+
const collect = (entry: unknown): string | null => {
|
|
99
|
+
if (typeof entry !== 'object' || entry === null) return null;
|
|
100
|
+
const val = (entry as Record<string, unknown>)[spec.idKey];
|
|
101
|
+
return typeof val === 'string' && val.length > 0 ? val : null;
|
|
102
|
+
};
|
|
103
|
+
if (Array.isArray(node)) {
|
|
104
|
+
const ids: string[] = [];
|
|
105
|
+
for (const entry of node) {
|
|
106
|
+
const id = collect(entry);
|
|
107
|
+
if (id) ids.push(id);
|
|
108
|
+
}
|
|
109
|
+
return ids;
|
|
110
|
+
}
|
|
111
|
+
const single = collect(node);
|
|
112
|
+
return single ? [single] : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractVarsKeys(obj: unknown): string[] {
|
|
116
|
+
const node = getPath(obj, 'vars');
|
|
117
|
+
if (typeof node !== 'object' || node === null || Array.isArray(node)) return [];
|
|
118
|
+
return Object.keys(node);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function pushSource(byType: Record<string, string[]>, key: string, source: string): void {
|
|
122
|
+
const arr = byType[key];
|
|
123
|
+
if (!arr) {
|
|
124
|
+
byType[key] = [source];
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!arr.includes(source)) arr.push(source);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ScanAcc {
|
|
131
|
+
vars: Set<string>;
|
|
132
|
+
bindings: Set<string>;
|
|
133
|
+
byType: Record<string, string[]>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function scanInto(obj: unknown, acc: ScanAcc, envName?: string): void {
|
|
137
|
+
const envPrefix = envName ? `env.${envName}.` : '';
|
|
138
|
+
for (const v of extractVarsKeys(obj)) {
|
|
139
|
+
acc.vars.add(v);
|
|
140
|
+
pushSource(acc.byType, v, `${envPrefix}vars`);
|
|
141
|
+
}
|
|
142
|
+
for (const spec of BINDING_SPECS) {
|
|
143
|
+
for (const id of extractFromSpec(obj, spec)) {
|
|
144
|
+
acc.bindings.add(id);
|
|
145
|
+
pushSource(acc.byType, id, `${envPrefix}${spec.path}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (envName) return;
|
|
149
|
+
const env = getPath(obj, 'env');
|
|
150
|
+
if (typeof env !== 'object' || env === null) return;
|
|
151
|
+
for (const [name, child] of Object.entries(env as Record<string, unknown>)) {
|
|
152
|
+
scanInto(child, acc, name);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `<workerCwd>/wrangler.jsonc` (또는 `.json`) 파싱 후 모든 식별자 추출.
|
|
158
|
+
* 파일 없거나 파싱 실패 시 null — caller 는 dedupe 적용 skip (안전 fallback).
|
|
159
|
+
*
|
|
160
|
+
* wrangler.toml 미지원 — JSONC 만. toml sibling 은 jsonc 마이그레이션 권장.
|
|
161
|
+
*/
|
|
162
|
+
export function scanWranglerConfig(workerCwd: string): WranglerIdentifiers | null {
|
|
163
|
+
const candidates = ['wrangler.jsonc', 'wrangler.json'];
|
|
164
|
+
let parsed: unknown = null;
|
|
165
|
+
let configPath = '';
|
|
166
|
+
for (const name of candidates) {
|
|
167
|
+
const path = join(workerCwd, name);
|
|
168
|
+
if (!existsSync(path)) continue;
|
|
169
|
+
configPath = path;
|
|
170
|
+
try {
|
|
171
|
+
parsed = parseJsonc(readFileSync(path, 'utf-8'));
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (parsed === null) return null;
|
|
178
|
+
|
|
179
|
+
const acc: ScanAcc = {
|
|
180
|
+
vars: new Set<string>(),
|
|
181
|
+
bindings: new Set<string>(),
|
|
182
|
+
byType: {},
|
|
183
|
+
};
|
|
184
|
+
scanInto(parsed, acc);
|
|
185
|
+
const vars = Array.from(acc.vars).sort();
|
|
186
|
+
const bindings = Array.from(acc.bindings).sort();
|
|
187
|
+
const all = Array.from(new Set([...vars, ...bindings])).sort();
|
|
188
|
+
return { vars, bindings, all, byType: acc.byType, configPath };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 사람 가독 conflict 보고 — `describeConflicts(scan, ['JWKS_KEYS', 'JWKS_URI'])`
|
|
193
|
+
* → "JWKS_KEYS (kv_namespaces), JWKS_URI (vars)"
|
|
194
|
+
*/
|
|
195
|
+
export function describeConflicts(
|
|
196
|
+
scan: WranglerIdentifiers,
|
|
197
|
+
conflicts: string[],
|
|
198
|
+
): string {
|
|
199
|
+
return conflicts
|
|
200
|
+
.map((k) => {
|
|
201
|
+
const types = scan.byType[k];
|
|
202
|
+
if (!types || types.length === 0) return k;
|
|
203
|
+
return `${k} (${types.join(', ')})`;
|
|
204
|
+
})
|
|
205
|
+
.join(', ');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* envelope/manifest key 목록에서 wrangler.jsonc 와 충돌하는 키만 반환 (alphabetical).
|
|
210
|
+
* 정공법 helper — caller 는 conflicts.length>0 로 분기 + 보고.
|
|
211
|
+
*/
|
|
212
|
+
export function findConflicts(
|
|
213
|
+
scan: WranglerIdentifiers,
|
|
214
|
+
keys: readonly string[],
|
|
215
|
+
): string[] {
|
|
216
|
+
const all = new Set(scan.all);
|
|
217
|
+
const out: string[] = [];
|
|
218
|
+
for (const k of keys) {
|
|
219
|
+
if (all.has(k)) out.push(k);
|
|
220
|
+
}
|
|
221
|
+
return out.sort();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const __test = { scanInto, extractFromSpec, extractVarsKeys, BINDING_SPECS };
|