@athsra/cli 0.1.0 → 1.0.0
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 +9 -4
- package/src/commands/admin.ts +308 -0
- package/src/commands/adopt.ts +490 -0
- package/src/commands/audit.ts +156 -0
- package/src/commands/completion.ts +101 -0
- package/src/commands/delete.ts +1 -1
- package/src/commands/doctor.ts +86 -2
- package/src/commands/get.ts +10 -14
- package/src/commands/handoff.ts +20 -16
- package/src/commands/login.ts +306 -1
- package/src/commands/logout.ts +50 -0
- package/src/commands/ls.ts +24 -14
- package/src/commands/manifest.ts +354 -0
- package/src/commands/mcp.ts +595 -0
- package/src/commands/migrate-envelopes.ts +113 -0
- package/src/commands/purge.ts +1 -1
- package/src/commands/restore.ts +1 -1
- package/src/commands/revoke.ts +10 -7
- package/src/commands/rollback.ts +1 -1
- package/src/commands/rotate-master.ts +42 -48
- package/src/commands/run.ts +49 -16
- package/src/commands/service-token.ts +200 -0
- package/src/commands/set.ts +33 -47
- package/src/commands/unset.ts +9 -38
- package/src/commands/versions.ts +10 -4
- package/src/index.ts +79 -4
- package/src/lib/adopt-context.ts +183 -0
- package/src/lib/auth-context.ts +77 -4
- package/src/lib/auto-project.ts +131 -0
- package/src/lib/client.ts +359 -4
- package/src/lib/env-format.ts +25 -0
- package/src/lib/envelope.ts +76 -0
- package/src/lib/secrets-manifest.ts +309 -0
- package/src/lib/workers-builds.ts +274 -0
- package/src/lib/wrangler-sync.ts +202 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secrets-manifest.ts — sibling worker 가 athsra envelope 에서 sync 할 키를 명시.
|
|
3
|
+
*
|
|
4
|
+
* **Option γ (default-deny + opt-in)** — Phase 2.6 (2026-05-26):
|
|
5
|
+
* envelope 의 모든 키를 무차별 sync 하지 않고, manifest 에 명시된 키만 sync.
|
|
6
|
+
* Hub-not-enforcer 정합 — athsra 가 강제하지 않고, sibling owner 가 명시적으로
|
|
7
|
+
* opt-in. secret leak 사고를 최소 권한 원칙으로 줄임.
|
|
8
|
+
*
|
|
9
|
+
* Default 위치: `<worker.cwd>/.athsra/secrets.json`
|
|
10
|
+
*
|
|
11
|
+
* Schema v1:
|
|
12
|
+
* ```json
|
|
13
|
+
* {
|
|
14
|
+
* "$schema": "https://athsra.com/schema/secrets-manifest-v1.json",
|
|
15
|
+
* "version": "1",
|
|
16
|
+
* "secrets": ["DATABASE_URL", "SESSION_SECRET", ...]
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* 정공법:
|
|
21
|
+
* - 의존성 추가 회피 (Zod X) — manual validation
|
|
22
|
+
* - error message 가 곧 onboarding 가이드 (`athsra manifest init` 안내)
|
|
23
|
+
* - alphabetical sort + atomic write — diff-friendly
|
|
24
|
+
* - host repo biome.json 자동 감지 — sibling 의 indent (tab/space N) 정합
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
export const MANIFEST_DIR = '.athsra';
|
|
30
|
+
export const MANIFEST_FILE = 'secrets.json';
|
|
31
|
+
export const MANIFEST_SCHEMA_VERSION = '1';
|
|
32
|
+
export const MANIFEST_SCHEMA_URL = 'https://athsra.com/schema/secrets-manifest-v1.json';
|
|
33
|
+
|
|
34
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
35
|
+
|
|
36
|
+
export interface SecretsManifest {
|
|
37
|
+
/** JSON Schema URL (informational, editor LSP 용) */
|
|
38
|
+
$schema?: string;
|
|
39
|
+
/** Schema version. 현재 v1 만 지원. */
|
|
40
|
+
version: '1';
|
|
41
|
+
/** envelope 에서 worker 로 sync 할 secret key 목록. alphabetical. */
|
|
42
|
+
secrets: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LoadOptions {
|
|
46
|
+
/** Worker 의 wrangler.jsonc 가 있는 디렉토리. manifest 는 이 안의 .athsra/secrets.json */
|
|
47
|
+
workerCwd: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LoadResult {
|
|
51
|
+
/** 파일이 발견되고 valid 인 경우 manifest, 아니면 null */
|
|
52
|
+
manifest: SecretsManifest | null;
|
|
53
|
+
/** Absolute manifest path (존재 여부 무관) */
|
|
54
|
+
path: string;
|
|
55
|
+
/** 파일이 존재하면 true */
|
|
56
|
+
exists: boolean;
|
|
57
|
+
/** 존재하나 invalid 인 경우 사람 가독 에러 (없으면 undefined) */
|
|
58
|
+
error?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function manifestPath(workerCwd: string): string {
|
|
62
|
+
return join(workerCwd, MANIFEST_DIR, MANIFEST_FILE);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate parsed manifest object.
|
|
67
|
+
*
|
|
68
|
+
* @returns 첫 번째 실패 사유 (string) 또는 valid 시 null
|
|
69
|
+
*/
|
|
70
|
+
function validateManifest(obj: unknown): string | null {
|
|
71
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
72
|
+
return 'manifest root must be a JSON object';
|
|
73
|
+
}
|
|
74
|
+
const m = obj as Record<string, unknown>;
|
|
75
|
+
if (m.version !== MANIFEST_SCHEMA_VERSION) {
|
|
76
|
+
return `unsupported version '${String(m.version)}' (expected '${MANIFEST_SCHEMA_VERSION}')`;
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(m.secrets)) {
|
|
79
|
+
return 'field "secrets" must be a string array';
|
|
80
|
+
}
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
for (let i = 0; i < m.secrets.length; i++) {
|
|
83
|
+
const k = m.secrets[i];
|
|
84
|
+
if (typeof k !== 'string') {
|
|
85
|
+
return `secrets[${i}] is not a string`;
|
|
86
|
+
}
|
|
87
|
+
if (k.length === 0) {
|
|
88
|
+
return `secrets[${i}] is empty`;
|
|
89
|
+
}
|
|
90
|
+
if (!IDENTIFIER_PATTERN.test(k)) {
|
|
91
|
+
return `secrets[${i}] '${k}' is not a valid env var identifier (^[A-Za-z_][A-Za-z0-9_]*$)`;
|
|
92
|
+
}
|
|
93
|
+
if (seen.has(k)) {
|
|
94
|
+
return `secrets[${i}] '${k}' is duplicated`;
|
|
95
|
+
}
|
|
96
|
+
seen.add(k);
|
|
97
|
+
}
|
|
98
|
+
if (m.$schema !== undefined && typeof m.$schema !== 'string') {
|
|
99
|
+
return 'field "$schema" must be a string when present';
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function loadManifest(opts: LoadOptions): LoadResult {
|
|
105
|
+
const path = manifestPath(opts.workerCwd);
|
|
106
|
+
if (!existsSync(path)) {
|
|
107
|
+
return { manifest: null, path, exists: false };
|
|
108
|
+
}
|
|
109
|
+
let text: string;
|
|
110
|
+
try {
|
|
111
|
+
text = readFileSync(path, 'utf-8');
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return {
|
|
114
|
+
manifest: null,
|
|
115
|
+
path,
|
|
116
|
+
exists: true,
|
|
117
|
+
error: `read failed: ${(err as Error).message}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
let parsed: unknown;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(text);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
manifest: null,
|
|
126
|
+
path,
|
|
127
|
+
exists: true,
|
|
128
|
+
error: `JSON parse failed: ${(err as Error).message}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const validationError = validateManifest(parsed);
|
|
132
|
+
if (validationError) {
|
|
133
|
+
return { manifest: null, path, exists: true, error: validationError };
|
|
134
|
+
}
|
|
135
|
+
const m = parsed as SecretsManifest;
|
|
136
|
+
return {
|
|
137
|
+
manifest: { $schema: m.$schema, version: m.version, secrets: [...m.secrets].sort() },
|
|
138
|
+
path,
|
|
139
|
+
exists: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* envelope 의 key set 에 대해 manifest 적용 결과.
|
|
145
|
+
*
|
|
146
|
+
* - `allowed` — envelope ∩ manifest.secrets (실제 sync 대상)
|
|
147
|
+
* - `missing` — manifest 에 있으나 envelope 에 없는 키 (사용자에게 warning)
|
|
148
|
+
* - `excluded` — envelope 에 있으나 manifest 에 없는 키 (sync 제외 됨, 정보)
|
|
149
|
+
*/
|
|
150
|
+
export interface ApplyResult {
|
|
151
|
+
allowed: string[];
|
|
152
|
+
missing: string[];
|
|
153
|
+
excluded: string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function applyManifest(
|
|
157
|
+
manifest: SecretsManifest,
|
|
158
|
+
envelopeKeys: string[],
|
|
159
|
+
): ApplyResult {
|
|
160
|
+
const envSet = new Set(envelopeKeys);
|
|
161
|
+
const manSet = new Set(manifest.secrets);
|
|
162
|
+
const allowed: string[] = [];
|
|
163
|
+
const missing: string[] = [];
|
|
164
|
+
for (const k of manifest.secrets) {
|
|
165
|
+
if (envSet.has(k)) allowed.push(k);
|
|
166
|
+
else missing.push(k);
|
|
167
|
+
}
|
|
168
|
+
const excluded: string[] = [];
|
|
169
|
+
for (const k of envelopeKeys) {
|
|
170
|
+
if (!manSet.has(k)) excluded.push(k);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
allowed: allowed.sort(),
|
|
174
|
+
missing: missing.sort(),
|
|
175
|
+
excluded: excluded.sort(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 새 manifest 객체 생성 — secrets 자동 sort + dedup + 검증.
|
|
181
|
+
*
|
|
182
|
+
* @throws Error 검증 실패 시
|
|
183
|
+
*/
|
|
184
|
+
export function createManifest(secrets: string[]): SecretsManifest {
|
|
185
|
+
const deduped = Array.from(new Set(secrets)).sort();
|
|
186
|
+
const m: SecretsManifest = {
|
|
187
|
+
$schema: MANIFEST_SCHEMA_URL,
|
|
188
|
+
version: MANIFEST_SCHEMA_VERSION,
|
|
189
|
+
secrets: deduped,
|
|
190
|
+
};
|
|
191
|
+
const err = validateManifest(m);
|
|
192
|
+
if (err) {
|
|
193
|
+
throw new Error(`invalid manifest: ${err}`);
|
|
194
|
+
}
|
|
195
|
+
return m;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface HostBiomeConfig {
|
|
199
|
+
/** biome.json 파일이 ancestor 에서 발견됐는지 */
|
|
200
|
+
hasFile: boolean;
|
|
201
|
+
/** formatter 객체 (biome.json 안에 있을 때만, 파싱 실패 또는 미설정 시 null) */
|
|
202
|
+
formatter: { indentStyle?: string; indentWidth?: number } | null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Host repo 의 biome.json (parent 디렉토리 traversal) 검색.
|
|
207
|
+
* sibling repo 별로 indentStyle 이 다를 수 있어 (modfolio-connect=tab, athsra/ecosystem=space)
|
|
208
|
+
* manifest 작성 시 호스트 정합 indent 적용 위함.
|
|
209
|
+
*
|
|
210
|
+
* @returns `hasFile` 로 biome.json 존재 여부 + `formatter` 내용.
|
|
211
|
+
* 존재 안 함 vs 존재하지만 formatter 누락 구분 (biome 기본값 적용 정합).
|
|
212
|
+
* 탐색 깊이는 안전을 위해 10 단계 제한.
|
|
213
|
+
*/
|
|
214
|
+
function findHostBiomeFormatter(workerCwd: string): HostBiomeConfig {
|
|
215
|
+
const MAX_PARENT_LOOKUP = 10;
|
|
216
|
+
let dir = workerCwd;
|
|
217
|
+
for (let i = 0; i < MAX_PARENT_LOOKUP; i++) {
|
|
218
|
+
const biomePath = join(dir, 'biome.json');
|
|
219
|
+
if (existsSync(biomePath)) {
|
|
220
|
+
try {
|
|
221
|
+
const cfg = JSON.parse(readFileSync(biomePath, 'utf-8')) as {
|
|
222
|
+
formatter?: { indentStyle?: string; indentWidth?: number };
|
|
223
|
+
};
|
|
224
|
+
return { hasFile: true, formatter: cfg.formatter ?? null };
|
|
225
|
+
} catch {
|
|
226
|
+
// malformed JSON → 안전 fallback 으로 not-found 처리 (RFC 표준 2-space 적용)
|
|
227
|
+
return { hasFile: false, formatter: null };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const parent = dirname(dir);
|
|
231
|
+
if (parent === dir) return { hasFile: false, formatter: null };
|
|
232
|
+
dir = parent;
|
|
233
|
+
}
|
|
234
|
+
return { hasFile: false, formatter: null };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Host repo 의 biome.json 에서 JSON 작성 indent 추론.
|
|
239
|
+
*
|
|
240
|
+
* - `indentStyle=tab` → `'\t'`
|
|
241
|
+
* - `indentStyle=space` → `indentWidth` (default 2)
|
|
242
|
+
* - biome.json 발견되었으나 formatter 미설정 → `'\t'` (biome 기본값)
|
|
243
|
+
* - biome.json 없음 → `2` (RFC 표준 가독값)
|
|
244
|
+
*/
|
|
245
|
+
export function detectManifestIndent(workerCwd: string): string | number {
|
|
246
|
+
const { hasFile, formatter } = findHostBiomeFormatter(workerCwd);
|
|
247
|
+
if (!hasFile) return 2;
|
|
248
|
+
if (!formatter) return '\t';
|
|
249
|
+
if (formatter.indentStyle === 'tab') return '\t';
|
|
250
|
+
if (formatter.indentStyle === 'space') return formatter.indentWidth ?? 2;
|
|
251
|
+
return '\t';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Atomic write — temp file → rename. mkdir -p `.athsra/` if missing.
|
|
256
|
+
* Host repo biome.json indent 정합 (modfolio-connect tab / athsra·ecosystem 2-space 등).
|
|
257
|
+
*
|
|
258
|
+
* @returns 작성된 absolute path
|
|
259
|
+
*/
|
|
260
|
+
export function saveManifest(workerCwd: string, manifest: SecretsManifest): string {
|
|
261
|
+
const err = validateManifest(manifest);
|
|
262
|
+
if (err) {
|
|
263
|
+
throw new Error(`refuse to save invalid manifest: ${err}`);
|
|
264
|
+
}
|
|
265
|
+
const target = manifestPath(workerCwd);
|
|
266
|
+
const dir = dirname(target);
|
|
267
|
+
if (!existsSync(dir)) {
|
|
268
|
+
mkdirSync(dir, { recursive: true });
|
|
269
|
+
}
|
|
270
|
+
const sorted: SecretsManifest = {
|
|
271
|
+
$schema: manifest.$schema ?? MANIFEST_SCHEMA_URL,
|
|
272
|
+
version: manifest.version,
|
|
273
|
+
secrets: [...manifest.secrets].sort(),
|
|
274
|
+
};
|
|
275
|
+
const indent = detectManifestIndent(workerCwd);
|
|
276
|
+
const text = `${JSON.stringify(sorted, null, indent)}\n`;
|
|
277
|
+
const tmp = `${target}.tmp`;
|
|
278
|
+
writeFileSync(tmp, text, { encoding: 'utf-8' });
|
|
279
|
+
renameSync(tmp, target);
|
|
280
|
+
return target;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Human-friendly error explaining how to fix a missing manifest. */
|
|
284
|
+
export function describeMissingManifest(workerCwd: string, workerName: string): string {
|
|
285
|
+
const path = manifestPath(workerCwd);
|
|
286
|
+
return [
|
|
287
|
+
`✗ secrets manifest not found: ${path}`,
|
|
288
|
+
'',
|
|
289
|
+
'athsra Phase 2.6 (Option γ, 2026-05-26+) 는 default-deny + opt-in 입니다.',
|
|
290
|
+
'envelope 의 모든 키를 무차별 sync 하지 않으려면 manifest 가 필요합니다.',
|
|
291
|
+
'',
|
|
292
|
+
'해결:',
|
|
293
|
+
` 1) 자동 생성 (envelope 의 모든 키를 기본 manifest 로):`,
|
|
294
|
+
` cd ${workerCwd}`,
|
|
295
|
+
` athsra manifest init --worker=${workerName} --all`,
|
|
296
|
+
'',
|
|
297
|
+
` 2) 명시적 키만 (권장, 최소 권한):`,
|
|
298
|
+
` athsra manifest init --worker=${workerName} --keys=KEY1,KEY2,...`,
|
|
299
|
+
'',
|
|
300
|
+
' 3) 일회성 우회 (legacy, 권장 X):',
|
|
301
|
+
' athsra adopt --allow-all',
|
|
302
|
+
' bun scripts/sync-wrangler-secrets.ts ... --allow-all',
|
|
303
|
+
'',
|
|
304
|
+
'docs: knowledge/canon/secret-store.md § Manifest opt-in (γ)',
|
|
305
|
+
].join('\n');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** test helpers */
|
|
309
|
+
export const __test = { validateManifest, findHostBiomeFormatter };
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workers-builds.ts — Cloudflare Workers Builds API client (idempotent).
|
|
3
|
+
*
|
|
4
|
+
* canon: modfolio-ecosystem/knowledge/canon/cf-workers-builds-api.md
|
|
5
|
+
*
|
|
6
|
+
* 모든 함수는 `accountId` + `apiToken` 을 명시 받아 호출 — athsra envelope 또는
|
|
7
|
+
* env 변수 출처와 분리. 호출자 (commands/adopt, scripts/setup-workers-builds) 가
|
|
8
|
+
* token 출처 결정.
|
|
9
|
+
*
|
|
10
|
+
* 정공법: idempotent — 같은 인자로 재호출 시 안전 (PUT/PATCH 또는 GET 후 분기).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const CF_BASE = 'https://api.cloudflare.com/client/v4';
|
|
14
|
+
|
|
15
|
+
export interface CfResult<T> {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
status: number;
|
|
18
|
+
result: T | null;
|
|
19
|
+
errors: { code?: number; message?: string }[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkerService {
|
|
23
|
+
default_environment: { script_tag: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RepoConnection {
|
|
27
|
+
repo_connection_uuid: string;
|
|
28
|
+
repo_id: string;
|
|
29
|
+
repo_name: string;
|
|
30
|
+
provider_account_name: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuildToken {
|
|
34
|
+
build_token_uuid: string;
|
|
35
|
+
build_token_name: string;
|
|
36
|
+
owner_type: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Trigger {
|
|
40
|
+
trigger_uuid: string;
|
|
41
|
+
trigger_name: string;
|
|
42
|
+
root_directory: string;
|
|
43
|
+
build_command: string;
|
|
44
|
+
deploy_command: string;
|
|
45
|
+
branch_includes: string[];
|
|
46
|
+
build_token_name?: string;
|
|
47
|
+
build_token_uuid?: string;
|
|
48
|
+
repo_connection_uuid?: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Build {
|
|
52
|
+
build_uuid: string;
|
|
53
|
+
status: string;
|
|
54
|
+
build_outcome: string | null;
|
|
55
|
+
created_on: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TriggerSpec {
|
|
59
|
+
externalScriptId: string;
|
|
60
|
+
repoConnectionUuid: string;
|
|
61
|
+
buildTokenUuid: string;
|
|
62
|
+
triggerName: string;
|
|
63
|
+
buildCommand: string;
|
|
64
|
+
deployCommand: string;
|
|
65
|
+
rootDirectory: string;
|
|
66
|
+
branchIncludes: string[];
|
|
67
|
+
branchExcludes?: string[];
|
|
68
|
+
pathIncludes?: string[];
|
|
69
|
+
pathExcludes?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function cfApi<T>(
|
|
73
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
74
|
+
path: string,
|
|
75
|
+
apiToken: string,
|
|
76
|
+
body?: unknown,
|
|
77
|
+
): Promise<CfResult<T>> {
|
|
78
|
+
const res = await fetch(`${CF_BASE}${path}`, {
|
|
79
|
+
method,
|
|
80
|
+
headers: {
|
|
81
|
+
Authorization: `Bearer ${apiToken}`,
|
|
82
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
83
|
+
},
|
|
84
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
85
|
+
});
|
|
86
|
+
const json = (await res.json().catch(() => ({}))) as {
|
|
87
|
+
success?: boolean;
|
|
88
|
+
result?: T;
|
|
89
|
+
errors?: { code?: number; message?: string }[];
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
ok: json.success === true,
|
|
93
|
+
status: res.status,
|
|
94
|
+
result: json.result ?? null,
|
|
95
|
+
errors: json.errors ?? [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Worker 의 script_tag (32-hex) 가져오기. Worker 미존재 시 null. */
|
|
100
|
+
export async function getWorkerTag(
|
|
101
|
+
accountId: string,
|
|
102
|
+
apiToken: string,
|
|
103
|
+
workerName: string,
|
|
104
|
+
): Promise<string | null> {
|
|
105
|
+
const res = await cfApi<WorkerService>(
|
|
106
|
+
'GET',
|
|
107
|
+
`/accounts/${accountId}/workers/services/${workerName}`,
|
|
108
|
+
apiToken,
|
|
109
|
+
);
|
|
110
|
+
if (!res.ok || !res.result) return null;
|
|
111
|
+
return res.result.default_environment.script_tag;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Repo connection 보장 (idempotent PUT). */
|
|
115
|
+
export async function ensureRepoConnection(
|
|
116
|
+
accountId: string,
|
|
117
|
+
apiToken: string,
|
|
118
|
+
spec: {
|
|
119
|
+
providerType?: 'github';
|
|
120
|
+
providerAccountId: string;
|
|
121
|
+
providerAccountName: string;
|
|
122
|
+
repoId: string;
|
|
123
|
+
repoName: string;
|
|
124
|
+
},
|
|
125
|
+
): Promise<RepoConnection> {
|
|
126
|
+
const res = await cfApi<RepoConnection>(
|
|
127
|
+
'PUT',
|
|
128
|
+
`/accounts/${accountId}/builds/repos/connections`,
|
|
129
|
+
apiToken,
|
|
130
|
+
{
|
|
131
|
+
provider_type: spec.providerType ?? 'github',
|
|
132
|
+
provider_account_id: spec.providerAccountId,
|
|
133
|
+
provider_account_name: spec.providerAccountName,
|
|
134
|
+
repo_id: spec.repoId,
|
|
135
|
+
repo_name: spec.repoName,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
if (!res.ok || !res.result) {
|
|
139
|
+
const detail = res.errors.map((e) => e.message).join('; ') || `status ${res.status}`;
|
|
140
|
+
throw new Error(`repo connection failed: ${detail}`);
|
|
141
|
+
}
|
|
142
|
+
return res.result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 가장 최근 build token (목록의 [0]). */
|
|
146
|
+
export async function getLatestBuildToken(
|
|
147
|
+
accountId: string,
|
|
148
|
+
apiToken: string,
|
|
149
|
+
): Promise<BuildToken> {
|
|
150
|
+
const res = await cfApi<BuildToken[]>('GET', `/accounts/${accountId}/builds/tokens`, apiToken);
|
|
151
|
+
const first = res.result?.[0];
|
|
152
|
+
if (!res.ok || !first) {
|
|
153
|
+
throw new Error('no build tokens — CF Dashboard 에서 1회 발급 필요');
|
|
154
|
+
}
|
|
155
|
+
return first;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Worker 의 기존 trigger 목록. */
|
|
159
|
+
export async function listTriggers(
|
|
160
|
+
accountId: string,
|
|
161
|
+
apiToken: string,
|
|
162
|
+
workerTag: string,
|
|
163
|
+
): Promise<Trigger[]> {
|
|
164
|
+
const res = await cfApi<Trigger[]>(
|
|
165
|
+
'GET',
|
|
166
|
+
`/accounts/${accountId}/builds/workers/${workerTag}/triggers`,
|
|
167
|
+
apiToken,
|
|
168
|
+
);
|
|
169
|
+
return res.result ?? [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Trigger 생성 또는 갱신 (root + branch 매칭으로 멱등성). */
|
|
173
|
+
export async function upsertTrigger(
|
|
174
|
+
accountId: string,
|
|
175
|
+
apiToken: string,
|
|
176
|
+
spec: TriggerSpec,
|
|
177
|
+
): Promise<{ trigger: Trigger; created: boolean }> {
|
|
178
|
+
const body = {
|
|
179
|
+
external_script_id: spec.externalScriptId,
|
|
180
|
+
repo_connection_uuid: spec.repoConnectionUuid,
|
|
181
|
+
build_token_uuid: spec.buildTokenUuid,
|
|
182
|
+
trigger_name: spec.triggerName,
|
|
183
|
+
build_command: spec.buildCommand,
|
|
184
|
+
deploy_command: spec.deployCommand,
|
|
185
|
+
root_directory: spec.rootDirectory,
|
|
186
|
+
branch_includes: spec.branchIncludes,
|
|
187
|
+
branch_excludes: spec.branchExcludes ?? [],
|
|
188
|
+
path_includes: spec.pathIncludes ?? ['*'],
|
|
189
|
+
path_excludes: spec.pathExcludes ?? [],
|
|
190
|
+
};
|
|
191
|
+
const existing = await listTriggers(accountId, apiToken, spec.externalScriptId);
|
|
192
|
+
const matched = existing.find(
|
|
193
|
+
(t) =>
|
|
194
|
+
t.root_directory === spec.rootDirectory &&
|
|
195
|
+
t.branch_includes?.some((b) => spec.branchIncludes.includes(b)),
|
|
196
|
+
);
|
|
197
|
+
if (matched) {
|
|
198
|
+
const patched = await cfApi<Trigger>(
|
|
199
|
+
'PATCH',
|
|
200
|
+
`/accounts/${accountId}/builds/triggers/${matched.trigger_uuid}`,
|
|
201
|
+
apiToken,
|
|
202
|
+
body,
|
|
203
|
+
);
|
|
204
|
+
if (!patched.ok || !patched.result) {
|
|
205
|
+
const detail = patched.errors.map((e) => e.message).join('; ') || `status ${patched.status}`;
|
|
206
|
+
throw new Error(`trigger PATCH failed: ${detail}`);
|
|
207
|
+
}
|
|
208
|
+
return { trigger: patched.result, created: false };
|
|
209
|
+
}
|
|
210
|
+
const created = await cfApi<Trigger>(
|
|
211
|
+
'POST',
|
|
212
|
+
`/accounts/${accountId}/builds/triggers`,
|
|
213
|
+
apiToken,
|
|
214
|
+
body,
|
|
215
|
+
);
|
|
216
|
+
if (!created.ok || !created.result) {
|
|
217
|
+
const detail = created.errors.map((e) => e.message).join('; ') || `status ${created.status}`;
|
|
218
|
+
throw new Error(`trigger create failed: ${detail}`);
|
|
219
|
+
}
|
|
220
|
+
return { trigger: created.result, created: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Trigger 의 env vars (PATCH = upsert). */
|
|
224
|
+
export async function setTriggerEnvVars(
|
|
225
|
+
accountId: string,
|
|
226
|
+
apiToken: string,
|
|
227
|
+
triggerUuid: string,
|
|
228
|
+
envVars: Record<string, { value: string; is_secret: boolean }>,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const res = await cfApi(
|
|
231
|
+
'PATCH',
|
|
232
|
+
`/accounts/${accountId}/builds/triggers/${triggerUuid}/environment_variables`,
|
|
233
|
+
apiToken,
|
|
234
|
+
envVars,
|
|
235
|
+
);
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
const detail = res.errors.map((e) => e.message).join('; ') || `status ${res.status}`;
|
|
238
|
+
throw new Error(`env vars PATCH failed: ${detail}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Manual build 트리거 (body = {"branch":"<name>"} 필수). */
|
|
243
|
+
export async function triggerManualBuild(
|
|
244
|
+
accountId: string,
|
|
245
|
+
apiToken: string,
|
|
246
|
+
triggerUuid: string,
|
|
247
|
+
branch: string,
|
|
248
|
+
): Promise<Build> {
|
|
249
|
+
const res = await cfApi<Build>(
|
|
250
|
+
'POST',
|
|
251
|
+
`/accounts/${accountId}/builds/triggers/${triggerUuid}/builds`,
|
|
252
|
+
apiToken,
|
|
253
|
+
{ branch },
|
|
254
|
+
);
|
|
255
|
+
if (!res.ok || !res.result) {
|
|
256
|
+
const detail = res.errors.map((e) => e.message).join('; ') || `status ${res.status}`;
|
|
257
|
+
throw new Error(`manual build failed: ${detail}`);
|
|
258
|
+
}
|
|
259
|
+
return res.result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Build 상태 조회. */
|
|
263
|
+
export async function getBuild(
|
|
264
|
+
accountId: string,
|
|
265
|
+
apiToken: string,
|
|
266
|
+
buildUuid: string,
|
|
267
|
+
): Promise<Build | null> {
|
|
268
|
+
const res = await cfApi<Build>(
|
|
269
|
+
'GET',
|
|
270
|
+
`/accounts/${accountId}/builds/builds/${buildUuid}`,
|
|
271
|
+
apiToken,
|
|
272
|
+
);
|
|
273
|
+
return res.result;
|
|
274
|
+
}
|