@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,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adopt.ts — sibling repo 의 athsra 통합 onboarding 한 줄 명령.
|
|
3
|
+
*
|
|
4
|
+
* 호출 예 (sibling repo 디렉토리 안에서):
|
|
5
|
+
* cd /home/mod/code/modfolio-press
|
|
6
|
+
* athsra adopt
|
|
7
|
+
*
|
|
8
|
+
* 자동 추론:
|
|
9
|
+
* project = basename(cwd) 또는 .athsra / package.json (auto-project.ts)
|
|
10
|
+
* gh-repo = git remote origin
|
|
11
|
+
* cf-worker, root_directory = wrangler.jsonc 자동 발견 + name 추출
|
|
12
|
+
*
|
|
13
|
+
* 명시 override:
|
|
14
|
+
* --project=<x>, --cf-worker=<name>, --root=<dir>, --gh-repo=<org/repo>,
|
|
15
|
+
* --branch=<name>, --build=<cmd>, --deploy=<cmd>, --env=<KEY1,KEY2>,
|
|
16
|
+
* --cf-token-project=<envelope>, --skip-sync, --skip-builds, --manual-build, --dry-run
|
|
17
|
+
*
|
|
18
|
+
* 흐름 (각 step 멱등):
|
|
19
|
+
* 1. envelope 확인 (없으면 안내)
|
|
20
|
+
* 2. CF token 확보 (envelope 또는 환경변수 또는 --cf-token-project)
|
|
21
|
+
* 3. wrangler secrets sync (envelope → worker)
|
|
22
|
+
* 4. Workers Builds setup:
|
|
23
|
+
* a. worker_tag GET (없으면 wrangler deploy 안내)
|
|
24
|
+
* b. repo connection (idempotent PUT)
|
|
25
|
+
* c. latest build token
|
|
26
|
+
* d. trigger upsert (있으면 PATCH, 없으면 POST)
|
|
27
|
+
* e. env vars PATCH (default GITHUB_TOKEN)
|
|
28
|
+
* 5. (manual-build flag 시) 1차 build 트리거
|
|
29
|
+
*
|
|
30
|
+
* canon: modfolio-ecosystem/knowledge/canon/{cf-workers-builds-api,secret-store}.md
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
type AdoptInferred,
|
|
35
|
+
fetchGhRepoMeta,
|
|
36
|
+
inferAdoptContext,
|
|
37
|
+
type WrangerWorker,
|
|
38
|
+
} from '../lib/adopt-context.ts';
|
|
39
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
40
|
+
import { resolveProject } from '../lib/auto-project.ts';
|
|
41
|
+
import { readPlain } from '../lib/envelope.ts';
|
|
42
|
+
import { createManifest, loadManifest, saveManifest } from '../lib/secrets-manifest.ts';
|
|
43
|
+
import {
|
|
44
|
+
ensureRepoConnection,
|
|
45
|
+
getLatestBuildToken,
|
|
46
|
+
getWorkerTag,
|
|
47
|
+
setTriggerEnvVars,
|
|
48
|
+
triggerManualBuild,
|
|
49
|
+
upsertTrigger,
|
|
50
|
+
} from '../lib/workers-builds.ts';
|
|
51
|
+
import {
|
|
52
|
+
ManifestInvalidError,
|
|
53
|
+
ManifestRequiredError,
|
|
54
|
+
syncWranglerSecrets,
|
|
55
|
+
} from '../lib/wrangler-sync.ts';
|
|
56
|
+
|
|
57
|
+
const USAGE = [
|
|
58
|
+
'usage: athsra adopt [<project>] [options]',
|
|
59
|
+
'',
|
|
60
|
+
'sibling repo 의 athsra envelope ↔ CF Worker + Workers Builds 한 줄 onboarding.',
|
|
61
|
+
'',
|
|
62
|
+
'자동 추론 (cwd 기반):',
|
|
63
|
+
' project = basename(cwd) (또는 --project=<x> / .athsra / package.json)',
|
|
64
|
+
' gh-repo = git remote origin',
|
|
65
|
+
' cf-worker = wrangler.jsonc 의 name (cwd 하위 발견된 첫 worker)',
|
|
66
|
+
' root = wrangler.jsonc 위치 (repoRoot 기준 상대 경로)',
|
|
67
|
+
'',
|
|
68
|
+
'옵션:',
|
|
69
|
+
' --cf-worker=<name> 명시 (다중 wrangler.jsonc 시 필수)',
|
|
70
|
+
' --root=<dir> Workers Builds root_directory (default: 자동 추론)',
|
|
71
|
+
' --gh-repo=<org/repo> GitHub repo (default: git remote)',
|
|
72
|
+
' --branch=<name> default main',
|
|
73
|
+
' --build=<cmd> default "bun run build"',
|
|
74
|
+
' --deploy=<cmd> default "bunx wrangler deploy"',
|
|
75
|
+
' --env=<KEY1,KEY2> Workers Builds trigger env vars (default GITHUB_TOKEN)',
|
|
76
|
+
' --cf-token-project=<x> CF API token 출처 envelope (default: project 자체 / env)',
|
|
77
|
+
' --skip-sync wrangler secrets sync 생략',
|
|
78
|
+
' --skip-builds Workers Builds setup 생략',
|
|
79
|
+
' --manual-build setup 후 1차 build 즉시 트리거 (검증)',
|
|
80
|
+
' --auto-manifest manifest 없으면 envelope 전체 키로 자동 생성 후 진행 (sibling 1줄 onboarding)',
|
|
81
|
+
' --allow-all manifest 없을 때 envelope 전체 sync (legacy, manifest 미생성)',
|
|
82
|
+
' --dry-run 전체 흐름 mutate 없이 미리보기',
|
|
83
|
+
'',
|
|
84
|
+
'Phase 2.6 (Option γ): default-deny + manifest opt-in.',
|
|
85
|
+
' manifest 위치: <worker.cwd>/.athsra/secrets.json',
|
|
86
|
+
' 없으면 sync 거부. `athsra manifest init` 또는 `athsra adopt --auto-manifest`.',
|
|
87
|
+
].join('\n');
|
|
88
|
+
|
|
89
|
+
interface ParsedArgs {
|
|
90
|
+
cfWorker?: string;
|
|
91
|
+
root?: string;
|
|
92
|
+
ghRepo?: string;
|
|
93
|
+
branch: string;
|
|
94
|
+
build: string;
|
|
95
|
+
deploy: string;
|
|
96
|
+
envKeys: string[];
|
|
97
|
+
cfTokenProject?: string;
|
|
98
|
+
skipSync: boolean;
|
|
99
|
+
skipBuilds: boolean;
|
|
100
|
+
manualBuild: boolean;
|
|
101
|
+
allowAll: boolean;
|
|
102
|
+
autoManifest: boolean;
|
|
103
|
+
dryRun: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseAdoptArgs(rest: string[]): ParsedArgs {
|
|
107
|
+
const flags: Record<string, string | boolean> = {};
|
|
108
|
+
for (const a of rest) {
|
|
109
|
+
if (a.startsWith('--')) {
|
|
110
|
+
const eq = a.indexOf('=');
|
|
111
|
+
if (eq > 0) flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
112
|
+
else flags[a.slice(2)] = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
cfWorker: typeof flags['cf-worker'] === 'string' ? flags['cf-worker'] : undefined,
|
|
117
|
+
root: typeof flags.root === 'string' ? flags.root : undefined,
|
|
118
|
+
ghRepo: typeof flags['gh-repo'] === 'string' ? flags['gh-repo'] : undefined,
|
|
119
|
+
branch: typeof flags.branch === 'string' ? flags.branch : 'main',
|
|
120
|
+
build: typeof flags.build === 'string' ? flags.build : 'bun run build',
|
|
121
|
+
deploy: typeof flags.deploy === 'string' ? flags.deploy : 'bunx wrangler deploy',
|
|
122
|
+
envKeys: (typeof flags.env === 'string' ? flags.env : 'GITHUB_TOKEN')
|
|
123
|
+
.split(',')
|
|
124
|
+
.map((s) => s.trim())
|
|
125
|
+
.filter(Boolean),
|
|
126
|
+
cfTokenProject:
|
|
127
|
+
typeof flags['cf-token-project'] === 'string' ? flags['cf-token-project'] : undefined,
|
|
128
|
+
skipSync: flags['skip-sync'] === true,
|
|
129
|
+
skipBuilds: flags['skip-builds'] === true,
|
|
130
|
+
manualBuild: flags['manual-build'] === true,
|
|
131
|
+
allowAll: flags['allow-all'] === true,
|
|
132
|
+
autoManifest: flags['auto-manifest'] === true,
|
|
133
|
+
dryRun: flags['dry-run'] === true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function chooseWorker(
|
|
138
|
+
inferred: AdoptInferred,
|
|
139
|
+
explicitName: string | undefined,
|
|
140
|
+
explicitRoot: string | undefined,
|
|
141
|
+
): WrangerWorker | { error: string } {
|
|
142
|
+
if (explicitName) {
|
|
143
|
+
const found = inferred.workers.find((w) => w.name === explicitName);
|
|
144
|
+
if (found) return found;
|
|
145
|
+
// 명시한 worker 가 wrangler.jsonc 에 없으면, 사용자 신뢰 후 root 명시 요구.
|
|
146
|
+
if (!explicitRoot) {
|
|
147
|
+
return {
|
|
148
|
+
error: `worker '${explicitName}' 가 cwd 의 wrangler.jsonc 에서 발견되지 않음. --root=<dir> 명시 필요`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { name: explicitName, cwd: inferred.repoRoot, rootDirectory: explicitRoot };
|
|
152
|
+
}
|
|
153
|
+
const [first] = inferred.workers;
|
|
154
|
+
if (!first) {
|
|
155
|
+
return {
|
|
156
|
+
error:
|
|
157
|
+
'wrangler.jsonc 발견 안 됨. --cf-worker=<name> + --root=<dir> 명시 필요 또는 sibling repo 안에서 호출',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (inferred.workers.length === 1) return first;
|
|
161
|
+
const list = inferred.workers.map((w) => ` - ${w.name} (root=${w.rootDirectory})`).join('\n');
|
|
162
|
+
return {
|
|
163
|
+
error: `다수의 wrangler.jsonc 발견. --cf-worker=<name> 명시:\n${list}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface CfCreds {
|
|
168
|
+
accountId: string;
|
|
169
|
+
apiToken: string;
|
|
170
|
+
/** 어디서 가져왔는지 — 보고용 */
|
|
171
|
+
source: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function resolveCfCreds(
|
|
175
|
+
ctx: import('../lib/auth-context.ts').AuthContext,
|
|
176
|
+
project: string,
|
|
177
|
+
cfTokenProject: string | undefined,
|
|
178
|
+
): Promise<CfCreds | { error: string }> {
|
|
179
|
+
// 1. 환경변수 우선 (athsra run 으로 wrap 한 경우 자동 inject 된 상태)
|
|
180
|
+
const envAcc = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
181
|
+
const envTok = process.env.CLOUDFLARE_API_TOKEN;
|
|
182
|
+
if (envAcc && envTok) {
|
|
183
|
+
return { accountId: envAcc, apiToken: envTok, source: 'env (CLOUDFLARE_*)' };
|
|
184
|
+
}
|
|
185
|
+
// 2. --cf-token-project 명시 envelope
|
|
186
|
+
const tokenProject = cfTokenProject ?? project;
|
|
187
|
+
const env = await readPlain(ctx, tokenProject);
|
|
188
|
+
if (!env) {
|
|
189
|
+
return {
|
|
190
|
+
error: `CF token 출처 envelope '${tokenProject}' not found. --cf-token-project=<x> 명시 또는 athsra set ${tokenProject} CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=...`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const acc = env.CLOUDFLARE_ACCOUNT_ID;
|
|
194
|
+
const tok = env.CLOUDFLARE_API_TOKEN;
|
|
195
|
+
if (!acc || !tok) {
|
|
196
|
+
return {
|
|
197
|
+
error: `envelope '${tokenProject}' 에 CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN 누락. athsra set ${tokenProject} CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=...`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return { accountId: acc, apiToken: tok, source: `envelope '${tokenProject}'` };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function adoptCmd(args: string[]): Promise<number> {
|
|
204
|
+
const { project, rest, source } = resolveProject(args);
|
|
205
|
+
if (!project) {
|
|
206
|
+
console.error(USAGE);
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
210
|
+
console.log(USAGE);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
const parsed = parseAdoptArgs(rest);
|
|
214
|
+
|
|
215
|
+
if (source !== 'positional' && source !== 'flag') {
|
|
216
|
+
console.log(`(project=${project} auto-detected from ${source})`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// auth
|
|
220
|
+
const ctx = await loadAuthContext();
|
|
221
|
+
if (!ctx) return 1;
|
|
222
|
+
if (ctx.kind !== 'user') {
|
|
223
|
+
console.error('athsra adopt 은 user token (master pw) 가 필요합니다.');
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 1. envelope 확인
|
|
228
|
+
const envelope = await readPlain(ctx, project);
|
|
229
|
+
if (!envelope) {
|
|
230
|
+
console.error(`✗ envelope '${project}' not found.`);
|
|
231
|
+
console.error(` 먼저 시크릿을 추가: athsra set ${project} KEY=value`);
|
|
232
|
+
console.error(` 또는 web UI: https://athsra.com/projects`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 2. 자동 추론
|
|
237
|
+
const cwd = process.cwd();
|
|
238
|
+
const inferred = inferAdoptContext(cwd);
|
|
239
|
+
const workerPick = chooseWorker(inferred, parsed.cfWorker, parsed.root);
|
|
240
|
+
if ('error' in workerPick) {
|
|
241
|
+
console.error(`✗ ${workerPick.error}`);
|
|
242
|
+
return 2;
|
|
243
|
+
}
|
|
244
|
+
const worker = workerPick;
|
|
245
|
+
const root = parsed.root ?? worker.rootDirectory;
|
|
246
|
+
const ghRepo = parsed.ghRepo ?? inferred.ghRepo;
|
|
247
|
+
|
|
248
|
+
console.log(`=== athsra adopt: ${project} → ${worker.name} ===`);
|
|
249
|
+
console.log(` envelope keys: ${Object.keys(envelope).filter((k) => envelope[k]).length}`);
|
|
250
|
+
console.log(` worker: ${worker.name}`);
|
|
251
|
+
console.log(` worker cwd: ${worker.cwd}`);
|
|
252
|
+
console.log(` root: ${root}`);
|
|
253
|
+
console.log(` gh-repo: ${ghRepo ?? '(none — Workers Builds skip)'}`);
|
|
254
|
+
console.log(` branch: ${parsed.branch}`);
|
|
255
|
+
console.log(` build: ${parsed.build}`);
|
|
256
|
+
console.log(` deploy: ${parsed.deploy}`);
|
|
257
|
+
console.log(` env vars: ${parsed.envKeys.join(', ')}`);
|
|
258
|
+
console.log(` skip-sync: ${parsed.skipSync}`);
|
|
259
|
+
console.log(` skip-builds: ${parsed.skipBuilds}`);
|
|
260
|
+
console.log(
|
|
261
|
+
` auto-manifest: ${parsed.autoManifest} (manifest 없으면 envelope 전체로 자동 생성)`,
|
|
262
|
+
);
|
|
263
|
+
console.log(` allow-all: ${parsed.allowAll} (manifest bypass — legacy)`);
|
|
264
|
+
console.log(` dry-run: ${parsed.dryRun}`);
|
|
265
|
+
console.log('');
|
|
266
|
+
|
|
267
|
+
// Phase 2.5 — snapshot 캡처 (마지막에 athsra worker D1 mapping 등록용)
|
|
268
|
+
let syncedAt: string | undefined;
|
|
269
|
+
let syncedCount: number | undefined;
|
|
270
|
+
let syncOutcome: 'success' | 'fail' | undefined;
|
|
271
|
+
let triggerUuidCaptured: string | undefined;
|
|
272
|
+
let buildUuidCaptured: string | undefined;
|
|
273
|
+
let buildAtCaptured: string | undefined;
|
|
274
|
+
|
|
275
|
+
// 3. wrangler secrets sync
|
|
276
|
+
if (!parsed.skipSync) {
|
|
277
|
+
console.log(`-- step 1/2: wrangler secrets sync (envelope → ${worker.name}) --`);
|
|
278
|
+
|
|
279
|
+
// Phase 2.7.2 (2026-05-26): --auto-manifest 면 manifest 없을 때 envelope 전체 키로 자동 생성.
|
|
280
|
+
if (parsed.autoManifest) {
|
|
281
|
+
const existing = loadManifest({ workerCwd: worker.cwd });
|
|
282
|
+
if (existing.error) {
|
|
283
|
+
console.error(`✗ manifest invalid (${existing.path}): ${existing.error}`);
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
if (!existing.manifest) {
|
|
287
|
+
const envelopeKeys = Object.entries(envelope)
|
|
288
|
+
.filter(([, v]) => typeof v === 'string' && v.length > 0)
|
|
289
|
+
.map(([k]) => k);
|
|
290
|
+
if (envelopeKeys.length === 0) {
|
|
291
|
+
console.error('✗ --auto-manifest: envelope 에 캡처할 키가 없음');
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
if (parsed.dryRun) {
|
|
295
|
+
console.log(
|
|
296
|
+
` (dry-run) would create manifest with ${envelopeKeys.length} keys at ${existing.path}`,
|
|
297
|
+
);
|
|
298
|
+
} else {
|
|
299
|
+
const created = createManifest(envelopeKeys);
|
|
300
|
+
const saved = saveManifest(worker.cwd, created);
|
|
301
|
+
console.log(` ✓ manifest auto-created: ${saved} (${created.secrets.length} keys)`);
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
console.log(
|
|
305
|
+
` manifest exists (${existing.manifest.secrets.length} keys) — auto-manifest skip`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const syncResult = await syncWranglerSecrets({
|
|
312
|
+
cwd: worker.cwd,
|
|
313
|
+
envelope,
|
|
314
|
+
workerName: worker.name,
|
|
315
|
+
dryRun: parsed.dryRun,
|
|
316
|
+
allowAll: parsed.allowAll,
|
|
317
|
+
});
|
|
318
|
+
if (!parsed.dryRun) {
|
|
319
|
+
syncedAt = new Date().toISOString();
|
|
320
|
+
syncedCount = syncResult.syncedKeys;
|
|
321
|
+
syncOutcome = 'success';
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
syncOutcome = 'fail';
|
|
325
|
+
if (err instanceof ManifestRequiredError) {
|
|
326
|
+
// 친절한 안내 — describeMissingManifest 가 메시지 본문
|
|
327
|
+
console.error('');
|
|
328
|
+
console.error((err as Error).message);
|
|
329
|
+
return 2;
|
|
330
|
+
}
|
|
331
|
+
if (err instanceof ManifestInvalidError) {
|
|
332
|
+
console.error(`✗ ${(err as Error).message}`);
|
|
333
|
+
console.error(' 수정 후 재시도: athsra manifest show / athsra manifest validate');
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
console.error(`✗ wrangler sync failed: ${(err as Error).message}`);
|
|
337
|
+
return 1;
|
|
338
|
+
}
|
|
339
|
+
console.log('');
|
|
340
|
+
} else {
|
|
341
|
+
console.log('-- step 1/2: wrangler secrets sync (--skip-sync) --\n');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 4. Workers Builds
|
|
345
|
+
if (parsed.skipBuilds) {
|
|
346
|
+
console.log('-- step 2/2: Workers Builds (--skip-builds) --\n');
|
|
347
|
+
console.log('=== Done. ===');
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
if (!ghRepo) {
|
|
351
|
+
console.log('-- step 2/2: Workers Builds (skipped — gh-repo 없음) --\n');
|
|
352
|
+
console.log('=== Done (Workers Builds 미설정). ===');
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
console.log('-- step 2/2: Workers Builds setup --');
|
|
356
|
+
|
|
357
|
+
// CF creds
|
|
358
|
+
const creds = await resolveCfCreds(ctx, project, parsed.cfTokenProject);
|
|
359
|
+
if ('error' in creds) {
|
|
360
|
+
console.error(`✗ ${creds.error}`);
|
|
361
|
+
return 1;
|
|
362
|
+
}
|
|
363
|
+
console.log(` CF creds: ${creds.source}`);
|
|
364
|
+
|
|
365
|
+
if (parsed.dryRun) {
|
|
366
|
+
console.log(
|
|
367
|
+
' (dry-run) would: worker_tag GET → repo connection PUT → token GET → trigger upsert → env vars PATCH',
|
|
368
|
+
);
|
|
369
|
+
console.log('\n=== Done (dry-run). ===');
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
// worker_tag
|
|
375
|
+
const workerTag = await getWorkerTag(creds.accountId, creds.apiToken, worker.name);
|
|
376
|
+
if (!workerTag) {
|
|
377
|
+
console.error(`✗ worker '${worker.name}' not found on CF.`);
|
|
378
|
+
console.error(` 먼저 1회 deploy: cd ${worker.cwd} && bunx wrangler deploy`);
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
console.log(` worker_tag: ${workerTag}`);
|
|
382
|
+
|
|
383
|
+
// repo meta (gh API)
|
|
384
|
+
const meta = fetchGhRepoMeta(ghRepo);
|
|
385
|
+
if (!meta) {
|
|
386
|
+
console.error(`✗ gh api /repos/${ghRepo} 실패. gh CLI 인증 확인: gh auth status`);
|
|
387
|
+
return 1;
|
|
388
|
+
}
|
|
389
|
+
console.log(` gh: ${ghRepo} (repo_id=${meta.repoId}, owner_id=${meta.ownerId})`);
|
|
390
|
+
|
|
391
|
+
// repo connection
|
|
392
|
+
const repoName = ghRepo.split('/')[1];
|
|
393
|
+
if (!repoName) {
|
|
394
|
+
console.error(`✗ gh-repo '${ghRepo}' 형식 오류 — 'org/repo' 필요`);
|
|
395
|
+
return 2;
|
|
396
|
+
}
|
|
397
|
+
const conn = await ensureRepoConnection(creds.accountId, creds.apiToken, {
|
|
398
|
+
providerAccountId: meta.ownerId,
|
|
399
|
+
providerAccountName: meta.ownerLogin,
|
|
400
|
+
repoId: meta.repoId,
|
|
401
|
+
repoName,
|
|
402
|
+
});
|
|
403
|
+
console.log(` repo_connection_uuid: ${conn.repo_connection_uuid}`);
|
|
404
|
+
|
|
405
|
+
// build token
|
|
406
|
+
const tok = await getLatestBuildToken(creds.accountId, creds.apiToken);
|
|
407
|
+
console.log(` build_token: ${tok.build_token_name} (${tok.build_token_uuid})`);
|
|
408
|
+
|
|
409
|
+
// trigger upsert
|
|
410
|
+
const { trigger, created } = await upsertTrigger(creds.accountId, creds.apiToken, {
|
|
411
|
+
externalScriptId: workerTag,
|
|
412
|
+
repoConnectionUuid: conn.repo_connection_uuid,
|
|
413
|
+
buildTokenUuid: tok.build_token_uuid,
|
|
414
|
+
triggerName: `Deploy ${worker.name} (${parsed.branch})`,
|
|
415
|
+
buildCommand: parsed.build,
|
|
416
|
+
deployCommand: parsed.deploy,
|
|
417
|
+
rootDirectory: root,
|
|
418
|
+
branchIncludes: [parsed.branch],
|
|
419
|
+
});
|
|
420
|
+
console.log(` trigger: ${trigger.trigger_uuid} (${created ? 'created' : 'updated'})`);
|
|
421
|
+
triggerUuidCaptured = trigger.trigger_uuid;
|
|
422
|
+
|
|
423
|
+
// env vars
|
|
424
|
+
if (parsed.envKeys.length > 0) {
|
|
425
|
+
const envVarsBody: Record<string, { value: string; is_secret: boolean }> = {};
|
|
426
|
+
for (const key of parsed.envKeys) {
|
|
427
|
+
const val = envelope[key];
|
|
428
|
+
if (!val) {
|
|
429
|
+
console.error(
|
|
430
|
+
`✗ env var '${key}' 가 envelope '${project}' 에 없음. athsra set ${project} ${key}=...`,
|
|
431
|
+
);
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
434
|
+
envVarsBody[key] = { value: val, is_secret: true };
|
|
435
|
+
}
|
|
436
|
+
await setTriggerEnvVars(creds.accountId, creds.apiToken, trigger.trigger_uuid, envVarsBody);
|
|
437
|
+
console.log(` env vars: ${parsed.envKeys.join(', ')} (is_secret=true)`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// manual build (옵션)
|
|
441
|
+
if (parsed.manualBuild) {
|
|
442
|
+
const build = await triggerManualBuild(
|
|
443
|
+
creds.accountId,
|
|
444
|
+
creds.apiToken,
|
|
445
|
+
trigger.trigger_uuid,
|
|
446
|
+
parsed.branch,
|
|
447
|
+
);
|
|
448
|
+
console.log(` manual build queued: ${build.build_uuid} (status=${build.status})`);
|
|
449
|
+
buildUuidCaptured = build.build_uuid;
|
|
450
|
+
buildAtCaptured = new Date().toISOString();
|
|
451
|
+
}
|
|
452
|
+
} catch (err) {
|
|
453
|
+
console.error(`✗ Workers Builds setup failed: ${(err as Error).message}`);
|
|
454
|
+
return 1;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 5. athsra worker D1 mapping 등록 (best-effort — endpoint 미 deploy 시 silent skip)
|
|
458
|
+
if (!parsed.dryRun) {
|
|
459
|
+
try {
|
|
460
|
+
const cwResult = await ctx.client.registerCwWorker(project, {
|
|
461
|
+
workerName: worker.name,
|
|
462
|
+
accountId: creds.accountId,
|
|
463
|
+
rootDirectory: root,
|
|
464
|
+
branch: parsed.branch,
|
|
465
|
+
triggerUuid: triggerUuidCaptured,
|
|
466
|
+
});
|
|
467
|
+
if (cwResult === null) {
|
|
468
|
+
console.log(
|
|
469
|
+
'\n (athsra worker /v1/workers endpoint 미 deploy — D1 매핑 skip, 다음 worker deploy 후 자동 등록)',
|
|
470
|
+
);
|
|
471
|
+
} else {
|
|
472
|
+
await ctx.client.updateCwWorker(project, worker.name, {
|
|
473
|
+
lastSyncedAt: syncedAt,
|
|
474
|
+
lastSyncOutcome: syncOutcome,
|
|
475
|
+
lastSyncKeyCount: syncedCount,
|
|
476
|
+
lastBuildUuid: buildUuidCaptured,
|
|
477
|
+
lastBuildAt: buildAtCaptured,
|
|
478
|
+
});
|
|
479
|
+
console.log(
|
|
480
|
+
`\n mapping: id=${cwResult.id} (${cwResult.created ? 'registered' : 'updated'}) — dashboard /projects/${project} 에서 확인`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
} catch (err) {
|
|
484
|
+
console.log(`\n (mapping 등록 실패 — silent: ${(err as Error).message})`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
console.log(`\n=== Done. push to ${parsed.branch} → CF auto-deploy. ===`);
|
|
489
|
+
return 0;
|
|
490
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1.x.9 (2026-05-25) — `athsra audit` CLI 명령.
|
|
3
|
+
*
|
|
4
|
+
* worker `/v1/audit` 호출 → 출력 형식 변환 (table/json/jsonl).
|
|
5
|
+
* 권한: user token (master pw) 또는 service token with audit:read scope.
|
|
6
|
+
*
|
|
7
|
+
* 예시:
|
|
8
|
+
* athsra audit — 최근 100 entries (table)
|
|
9
|
+
* athsra audit --actor=machine-A — 특정 머신 활동
|
|
10
|
+
* athsra audit --action=rate-limit.throttled — 특정 이벤트 타임라인
|
|
11
|
+
* athsra audit --status=429 --from=2026-05-25T00:00:00Z
|
|
12
|
+
* athsra audit --all --format=jsonl — 전체 (cursor 자동) JSONL 출력
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
16
|
+
import type { AuditEntry } from '../lib/client.ts';
|
|
17
|
+
|
|
18
|
+
const USAGE = [
|
|
19
|
+
'usage:',
|
|
20
|
+
' athsra audit [--actor=<id>] [--action=<a>] [--status=<n>] [--from=<iso>] [--to=<iso>] [--limit=<n>] [--all] [--format=table|json|jsonl]',
|
|
21
|
+
'',
|
|
22
|
+
'권한: user token (master pw) 또는 service token with audit:read scope.',
|
|
23
|
+
'',
|
|
24
|
+
'필터:',
|
|
25
|
+
' --actor machineId / "anonymous" / "ip:<ip>" / "token:<hash>" 일치',
|
|
26
|
+
' --action "register.bootstrap", "rate-limit.throttled" 등 일치',
|
|
27
|
+
' --status 100..599 정수 일치',
|
|
28
|
+
' --from ISO 8601 (>= 비교, 예: 2026-05-25T00:00:00Z)',
|
|
29
|
+
' --to ISO 8601 (<= 비교, 예: 2026-05-25T23:59:59Z)',
|
|
30
|
+
'',
|
|
31
|
+
'페이지네이션 / 출력:',
|
|
32
|
+
' --limit 페이지 크기 (default 100, max 1000)',
|
|
33
|
+
' --all 모든 page (cursor 자동 follow 후 일괄 출력)',
|
|
34
|
+
' --format table (default) | json | jsonl',
|
|
35
|
+
].join('\n');
|
|
36
|
+
|
|
37
|
+
interface AuditOpts {
|
|
38
|
+
actor?: string;
|
|
39
|
+
action?: string;
|
|
40
|
+
status?: number;
|
|
41
|
+
from?: string;
|
|
42
|
+
to?: string;
|
|
43
|
+
limit?: number;
|
|
44
|
+
all: boolean;
|
|
45
|
+
format: 'table' | 'json' | 'jsonl';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ParseError {
|
|
49
|
+
error: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseOpts(args: string[]): AuditOpts | ParseError {
|
|
53
|
+
const opts: AuditOpts = { all: false, format: 'table' };
|
|
54
|
+
for (const a of args) {
|
|
55
|
+
if (a === '--all') {
|
|
56
|
+
opts.all = true;
|
|
57
|
+
} else if (a.startsWith('--actor=')) {
|
|
58
|
+
opts.actor = a.slice('--actor='.length);
|
|
59
|
+
} else if (a.startsWith('--action=')) {
|
|
60
|
+
opts.action = a.slice('--action='.length);
|
|
61
|
+
} else if (a.startsWith('--status=')) {
|
|
62
|
+
const n = Number(a.slice('--status='.length));
|
|
63
|
+
if (!Number.isInteger(n) || n < 100 || n > 599) {
|
|
64
|
+
return { error: '--status must be integer 100..599' };
|
|
65
|
+
}
|
|
66
|
+
opts.status = n;
|
|
67
|
+
} else if (a.startsWith('--from=')) {
|
|
68
|
+
opts.from = a.slice('--from='.length);
|
|
69
|
+
} else if (a.startsWith('--to=')) {
|
|
70
|
+
opts.to = a.slice('--to='.length);
|
|
71
|
+
} else if (a.startsWith('--limit=')) {
|
|
72
|
+
const n = Number(a.slice('--limit='.length));
|
|
73
|
+
if (!Number.isInteger(n) || n < 1 || n > 1000) {
|
|
74
|
+
return { error: '--limit must be integer 1..1000' };
|
|
75
|
+
}
|
|
76
|
+
opts.limit = n;
|
|
77
|
+
} else if (a.startsWith('--format=')) {
|
|
78
|
+
const v = a.slice('--format='.length);
|
|
79
|
+
if (v !== 'table' && v !== 'json' && v !== 'jsonl') {
|
|
80
|
+
return { error: '--format must be table|json|jsonl' };
|
|
81
|
+
}
|
|
82
|
+
opts.format = v;
|
|
83
|
+
} else if (a === '--help' || a === '-h') {
|
|
84
|
+
// caller 가 처리
|
|
85
|
+
} else if (a.startsWith('-')) {
|
|
86
|
+
return { error: `Unknown flag: ${a}` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return opts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function printTable(rows: AuditEntry[]): void {
|
|
93
|
+
if (rows.length === 0) {
|
|
94
|
+
console.log('(no audit entries match the given filters)');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
console.log(`${rows.length} entries (newest first):`);
|
|
98
|
+
console.log('');
|
|
99
|
+
for (const r of rows) {
|
|
100
|
+
const actor = r.actor.padEnd(20);
|
|
101
|
+
const status = String(r.status).padStart(3);
|
|
102
|
+
console.log(`${r.ts} ${actor} ${status} ${r.action}`);
|
|
103
|
+
if (r.request_method || r.request_path) {
|
|
104
|
+
console.log(` ${r.request_method ?? ''} ${r.request_path ?? ''}`.trimEnd());
|
|
105
|
+
}
|
|
106
|
+
if (r.meta) {
|
|
107
|
+
console.log(` meta: ${JSON.stringify(r.meta)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function auditCmd(args: string[]): Promise<number> {
|
|
113
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
114
|
+
console.log(USAGE);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const parsed = parseOpts(args);
|
|
119
|
+
if ('error' in parsed) {
|
|
120
|
+
console.error(parsed.error);
|
|
121
|
+
console.error('');
|
|
122
|
+
console.error(USAGE);
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
const opts = parsed;
|
|
126
|
+
|
|
127
|
+
const ctx = await loadAuthContext();
|
|
128
|
+
if (!ctx) return 1;
|
|
129
|
+
const { client } = ctx;
|
|
130
|
+
|
|
131
|
+
const all: AuditEntry[] = [];
|
|
132
|
+
let cursor: string | undefined;
|
|
133
|
+
do {
|
|
134
|
+
const res = await client.queryAudit({
|
|
135
|
+
actor: opts.actor,
|
|
136
|
+
action: opts.action,
|
|
137
|
+
status: opts.status,
|
|
138
|
+
from: opts.from,
|
|
139
|
+
to: opts.to,
|
|
140
|
+
limit: opts.limit,
|
|
141
|
+
cursor,
|
|
142
|
+
});
|
|
143
|
+
all.push(...res.entries);
|
|
144
|
+
cursor = opts.all && res.nextCursor ? res.nextCursor : undefined;
|
|
145
|
+
} while (cursor);
|
|
146
|
+
|
|
147
|
+
if (opts.format === 'json') {
|
|
148
|
+
console.log(JSON.stringify({ entries: all, count: all.length }, null, 2));
|
|
149
|
+
} else if (opts.format === 'jsonl') {
|
|
150
|
+
for (const r of all) console.log(JSON.stringify(r));
|
|
151
|
+
} else {
|
|
152
|
+
printTable(all);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return 0;
|
|
156
|
+
}
|