@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,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wrangler-sync.ts — athsra envelope 의 key=value 를 sibling worker 의 wrangler
|
|
3
|
+
* secrets 로 bulk inject.
|
|
4
|
+
*
|
|
5
|
+
* canon: modfolio-ecosystem/knowledge/canon/secret-store.md (athsra v3, universe-wide)
|
|
6
|
+
*
|
|
7
|
+
* CF 의 `wrangler secret bulk` 는 단일 호출당 25 키 제한 (code 100160). 자동 chunk
|
|
8
|
+
* 분할. tmp file mode 0o600 으로 디스크 노출 최소화 + finally 에서 강제 cleanup.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2.6 (2026-05-26, Option γ): default-deny + manifest opt-in.
|
|
11
|
+
* `<workerCwd>/.athsra/secrets.json` manifest 가 sync 대상 키를 명시.
|
|
12
|
+
* manifest 없으면 sync 거부 (allowAll=true 로 legacy override 가능).
|
|
13
|
+
*
|
|
14
|
+
* 정공법: idempotent — 같은 envelope+worker 로 재호출 시 wrangler 가 PATCH 처리.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { $ } from 'bun';
|
|
21
|
+
import { applyManifest, describeMissingManifest, loadManifest } from './secrets-manifest.ts';
|
|
22
|
+
|
|
23
|
+
export interface SyncOptions {
|
|
24
|
+
/** Worker 의 wrangler.jsonc 가 있는 디렉토리 (wrangler CLI 의 cwd) */
|
|
25
|
+
cwd: string;
|
|
26
|
+
/** envelope key → value map. 빈 값/undefined 키는 호출자가 사전 제거 */
|
|
27
|
+
envelope: Record<string, string>;
|
|
28
|
+
/** CF Worker 이름 (wrangler.jsonc 의 `name`) */
|
|
29
|
+
workerName: string;
|
|
30
|
+
/** true 면 wrangler 호출 안 하고 sync 대상 key 목록만 반환 */
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
/** progress / 결과 console 출력 비활성 (caller 가 직접 출력하면 true) */
|
|
33
|
+
silent?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* true 면 manifest 무시하고 envelope 전체 sync (Phase 2.6 legacy override).
|
|
36
|
+
* 권장 X — 명시적 ack 가 필요한 경우만.
|
|
37
|
+
*/
|
|
38
|
+
allowAll?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SyncResult {
|
|
42
|
+
/** wrangler 에 sync 된 key 수 (dryRun 이면 sync 됐을 key 수) */
|
|
43
|
+
syncedKeys: number;
|
|
44
|
+
/** dryRun 시 출력할 key 목록 (정렬됨) */
|
|
45
|
+
keysToSync: string[];
|
|
46
|
+
/** manifest 에 있으나 envelope 에 없는 키 — onboarding gap */
|
|
47
|
+
missingFromEnvelope: string[];
|
|
48
|
+
/** envelope 에 있으나 manifest 에 없는 키 — 의도된 제외 */
|
|
49
|
+
excludedFromManifest: string[];
|
|
50
|
+
/** allowAll=true 였는지 (감사 추적용) */
|
|
51
|
+
bypassedManifest: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ManifestRequiredError extends Error {
|
|
55
|
+
readonly workerCwd: string;
|
|
56
|
+
readonly workerName: string;
|
|
57
|
+
constructor(workerCwd: string, workerName: string) {
|
|
58
|
+
super(describeMissingManifest(workerCwd, workerName));
|
|
59
|
+
this.name = 'ManifestRequiredError';
|
|
60
|
+
this.workerCwd = workerCwd;
|
|
61
|
+
this.workerName = workerName;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class ManifestInvalidError extends Error {
|
|
66
|
+
readonly path: string;
|
|
67
|
+
constructor(path: string, reason: string) {
|
|
68
|
+
super(`secrets manifest invalid (${path}): ${reason}`);
|
|
69
|
+
this.name = 'ManifestInvalidError';
|
|
70
|
+
this.path = path;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const CHUNK_SIZE = 25;
|
|
75
|
+
|
|
76
|
+
export async function syncWranglerSecrets(opts: SyncOptions): Promise<SyncResult> {
|
|
77
|
+
const filled: Record<string, string> = {};
|
|
78
|
+
for (const [k, v] of Object.entries(opts.envelope)) {
|
|
79
|
+
if (typeof v === 'string' && v.length > 0) filled[k] = v;
|
|
80
|
+
}
|
|
81
|
+
const envelopeKeys = Object.keys(filled).sort();
|
|
82
|
+
|
|
83
|
+
// Phase 2.6 — manifest 적용 (default-deny)
|
|
84
|
+
const loaded = loadManifest({ workerCwd: opts.cwd });
|
|
85
|
+
let allowedKeys: string[];
|
|
86
|
+
let missingFromEnvelope: string[] = [];
|
|
87
|
+
let excludedFromManifest: string[] = [];
|
|
88
|
+
let bypassedManifest = false;
|
|
89
|
+
|
|
90
|
+
if (loaded.error) {
|
|
91
|
+
throw new ManifestInvalidError(loaded.path, loaded.error);
|
|
92
|
+
}
|
|
93
|
+
if (loaded.manifest) {
|
|
94
|
+
const applied = applyManifest(loaded.manifest, envelopeKeys);
|
|
95
|
+
allowedKeys = applied.allowed;
|
|
96
|
+
missingFromEnvelope = applied.missing;
|
|
97
|
+
excludedFromManifest = applied.excluded;
|
|
98
|
+
if (!opts.silent) {
|
|
99
|
+
console.log(` manifest: ${loaded.path} (${loaded.manifest.secrets.length} keys opt-in)`);
|
|
100
|
+
if (excludedFromManifest.length > 0) {
|
|
101
|
+
console.log(
|
|
102
|
+
` excluded by manifest: ${excludedFromManifest.length} envelope keys (${excludedFromManifest.slice(0, 3).join(', ')}${excludedFromManifest.length > 3 ? ', ...' : ''})`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (missingFromEnvelope.length > 0) {
|
|
106
|
+
console.log(
|
|
107
|
+
` ⚠ manifest references ${missingFromEnvelope.length} keys missing from envelope: ${missingFromEnvelope.join(', ')}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} else if (opts.allowAll) {
|
|
112
|
+
allowedKeys = envelopeKeys;
|
|
113
|
+
bypassedManifest = true;
|
|
114
|
+
if (!opts.silent) {
|
|
115
|
+
console.log(
|
|
116
|
+
` ⚠ manifest absent + --allow-all → envelope 전체 ${envelopeKeys.length} keys sync (legacy)`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// default-deny
|
|
121
|
+
throw new ManifestRequiredError(opts.cwd, opts.workerName);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const count = allowedKeys.length;
|
|
125
|
+
|
|
126
|
+
if (count === 0) {
|
|
127
|
+
if (!opts.silent) {
|
|
128
|
+
if (loaded.manifest) {
|
|
129
|
+
console.log('(no overlap between manifest and envelope — nothing to sync)');
|
|
130
|
+
} else {
|
|
131
|
+
console.log('(no non-empty keys to sync)');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
syncedKeys: 0,
|
|
136
|
+
keysToSync: [],
|
|
137
|
+
missingFromEnvelope,
|
|
138
|
+
excludedFromManifest,
|
|
139
|
+
bypassedManifest,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (opts.dryRun) {
|
|
144
|
+
if (!opts.silent) {
|
|
145
|
+
console.log(`--dry-run: would sync ${count} keys to ${opts.workerName}:`);
|
|
146
|
+
for (const k of allowedKeys) console.log(` ${k}`);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
syncedKeys: count,
|
|
150
|
+
keysToSync: allowedKeys,
|
|
151
|
+
missingFromEnvelope,
|
|
152
|
+
excludedFromManifest,
|
|
153
|
+
bypassedManifest,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const allowedPairs: [string, string][] = [];
|
|
158
|
+
for (const k of allowedKeys) {
|
|
159
|
+
const v = filled[k];
|
|
160
|
+
if (typeof v === 'string') allowedPairs.push([k, v]);
|
|
161
|
+
}
|
|
162
|
+
const chunks: Record<string, string>[] = [];
|
|
163
|
+
for (let i = 0; i < allowedPairs.length; i += CHUNK_SIZE) {
|
|
164
|
+
chunks.push(Object.fromEntries(allowedPairs.slice(i, i + CHUNK_SIZE)));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'athsra-sync-'));
|
|
168
|
+
try {
|
|
169
|
+
let synced = 0;
|
|
170
|
+
for (const [idx, chunk] of chunks.entries()) {
|
|
171
|
+
const tmpFile = join(tmpDir, `${opts.workerName}-chunk-${idx}.json`);
|
|
172
|
+
writeFileSync(tmpFile, JSON.stringify(chunk), { mode: 0o600 });
|
|
173
|
+
const chunkSize = Object.keys(chunk).length;
|
|
174
|
+
if (!opts.silent) {
|
|
175
|
+
console.log(` chunk ${idx + 1}/${chunks.length} (${chunkSize} keys) → ${opts.workerName}`);
|
|
176
|
+
}
|
|
177
|
+
const result = await $`bunx wrangler secret bulk ${tmpFile} --name ${opts.workerName}`
|
|
178
|
+
.cwd(opts.cwd)
|
|
179
|
+
.quiet()
|
|
180
|
+
.nothrow();
|
|
181
|
+
if (result.exitCode !== 0) {
|
|
182
|
+
const stderr = result.stderr.toString();
|
|
183
|
+
const stdout = result.stdout.toString();
|
|
184
|
+
const detail = stderr || stdout || `exit ${result.exitCode}`;
|
|
185
|
+
throw new Error(`wrangler secret bulk failed (chunk ${idx + 1}): ${detail}`);
|
|
186
|
+
}
|
|
187
|
+
synced += chunkSize;
|
|
188
|
+
}
|
|
189
|
+
if (!opts.silent) {
|
|
190
|
+
console.log(`✓ ${opts.workerName}: ${synced} secrets synced`);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
syncedKeys: synced,
|
|
194
|
+
keysToSync: allowedKeys,
|
|
195
|
+
missingFromEnvelope,
|
|
196
|
+
excludedFromManifest,
|
|
197
|
+
bypassedManifest,
|
|
198
|
+
};
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|