@athsra/cli 1.0.4 → 1.1.1
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/README.md +34 -10
- package/package.json +3 -3
- package/src/commands/delete.ts +16 -13
- package/src/commands/get.ts +8 -5
- package/src/commands/handoff.ts +13 -3
- package/src/commands/login.ts +208 -61
- package/src/commands/logout.ts +3 -2
- package/src/commands/ls.ts +32 -10
- package/src/commands/manifest.ts +2 -2
- package/src/commands/mcp.ts +259 -7
- package/src/commands/migrate-envelopes.ts +55 -3
- package/src/commands/purge.ts +13 -10
- package/src/commands/restore.ts +10 -6
- package/src/commands/rollback.ts +12 -9
- package/src/commands/rotate-master.ts +13 -13
- package/src/commands/run.ts +6 -24
- package/src/commands/service-token.ts +15 -31
- package/src/commands/set.ts +7 -6
- package/src/commands/unset.ts +11 -8
- package/src/commands/versions.ts +7 -5
- package/src/index.ts +12 -8
- package/src/lib/auth-context.ts +77 -13
- package/src/lib/auth-proof.ts +26 -0
- package/src/lib/auto-project.ts +58 -14
- package/src/lib/client.ts +112 -19
- package/src/lib/config.ts +2 -0
- package/src/lib/device-login.ts +157 -0
- package/src/lib/env-format.ts +1 -1
- package/src/lib/envelope.ts +105 -15
- package/src/lib/identity-key.ts +21 -0
- package/src/lib/keyring.ts +25 -0
- package/src/lib/mcp-register.ts +223 -0
- package/src/lib/mcp-tools/admin.ts +267 -0
- package/src/lib/mcp-tools/args.ts +26 -0
- package/src/lib/mcp-tools/confirm.ts +21 -0
- package/src/lib/mcp-tools/defs.ts +388 -3
- package/src/lib/mcp-tools/login.ts +156 -0
- package/src/lib/mcp-tools/mask.ts +41 -0
- package/src/lib/mcp-tools/read.ts +115 -1
- package/src/lib/mcp-tools/result.ts +5 -5
- package/src/lib/mcp-tools/run.ts +101 -0
- package/src/lib/mcp-tools/write.ts +84 -5
- package/src/lib/oidc-flow.ts +43 -1
- package/src/lib/org-rewrap.ts +9 -3
- package/src/lib/service-tokens.ts +62 -0
package/src/commands/rollback.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
+
import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
|
|
2
3
|
import { promptConfirm } from '../lib/prompt.ts';
|
|
3
4
|
|
|
4
|
-
const USAGE = 'usage: athsra rollback <project> <version_id>'
|
|
5
|
+
const USAGE = ['usage: athsra rollback <project>[:<env>] <version_id>', CONFIG_USAGE_HINT].join(
|
|
6
|
+
'\n',
|
|
7
|
+
);
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
|
-
* athsra rollback <project> <version_id>
|
|
8
|
-
* - 특정 version 을 current 로 복원
|
|
10
|
+
* athsra rollback <project>[:<env>] <version_id>
|
|
11
|
+
* - 특정 version 을 current 로 복원 (해당 환경(config) 한정)
|
|
9
12
|
* - tombstone 있으면 자동 제거 (rollback 은 항상 active 상태로)
|
|
10
13
|
* - confirm 필요 (--yes 로 우회 가능)
|
|
11
14
|
*/
|
|
12
15
|
export async function rollbackCmd(args: string[]): Promise<number> {
|
|
13
|
-
const project = args
|
|
14
|
-
const versionId =
|
|
16
|
+
const { project, config, rest } = resolveProject(args, { requirePositional: true });
|
|
17
|
+
const versionId = rest[0];
|
|
15
18
|
if (!project || !versionId) {
|
|
16
19
|
console.error(USAGE);
|
|
17
20
|
return 2;
|
|
18
21
|
}
|
|
19
|
-
const yes =
|
|
22
|
+
const yes = rest.includes('--yes') || rest.includes('-y');
|
|
20
23
|
|
|
21
24
|
const ctx = await loadAuthContext();
|
|
22
25
|
if (!ctx) return 1;
|
|
@@ -24,12 +27,12 @@ export async function rollbackCmd(args: string[]): Promise<number> {
|
|
|
24
27
|
|
|
25
28
|
if (!yes && process.env.ATHSRA_ROLLBACK_CONFIRMED !== '1') {
|
|
26
29
|
const ok = await promptConfirm(
|
|
27
|
-
`Rollback ${project} to ${versionId}? Current version becomes inactive (still in history).`,
|
|
30
|
+
`Rollback ${project}${configTag(config)} to ${versionId}? Current version becomes inactive (still in history).`,
|
|
28
31
|
);
|
|
29
32
|
if (!ok) return 0;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
const result = await client.rollbackProject(project, versionId);
|
|
33
|
-
console.log(`✓ ${project}: rolled back to ${result.current_version}`);
|
|
35
|
+
const result = await client.rollbackProject(project, versionId, config);
|
|
36
|
+
console.log(`✓ ${project}${configTag(config)}: rolled back to ${result.current_version}`);
|
|
34
37
|
return 0;
|
|
35
38
|
}
|
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
deriveKey,
|
|
3
|
-
ENTERPRISE_KDF,
|
|
4
|
-
fromBase64,
|
|
5
|
-
isValidPhrase,
|
|
6
|
-
normalizePhrase,
|
|
7
|
-
toBase64,
|
|
8
|
-
wordCount,
|
|
9
|
-
} from '@athsra/crypto';
|
|
1
|
+
import { ENTERPRISE_KDF, isValidPhrase, normalizePhrase, wordCount } from '@athsra/crypto';
|
|
10
2
|
import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
|
|
3
|
+
import { deriveMasterProof } from '../lib/auth-proof.ts';
|
|
11
4
|
import { AthsraClient } from '../lib/client.ts';
|
|
5
|
+
import { saveConfig } from '../lib/config.ts';
|
|
12
6
|
import { readPlain, writePlain } from '../lib/envelope.ts';
|
|
13
7
|
import { rewrapForRotation } from '../lib/identity-key.ts';
|
|
14
8
|
import { setMasterPw, setToken } from '../lib/keyring.ts';
|
|
@@ -115,19 +109,25 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
|
|
|
115
109
|
// POST /auth/rotate-master
|
|
116
110
|
console.log('\n• Rotating server-side proof + invalidating all tokens...');
|
|
117
111
|
const info = await client.info();
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
112
|
+
const me = await client.whoami();
|
|
113
|
+
if (me.userId === undefined) {
|
|
114
|
+
console.error('whoami 에 user_id 없음 — 재로그인 필요 (`athsra login`).');
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const oldProof = deriveMasterProof(oldPw, me.userId, info.global_salt);
|
|
118
|
+
const newProof = deriveMasterProof(newPw, me.userId, info.global_salt);
|
|
121
119
|
const rot = await client.rotateMaster(oldProof, newProof, keyRewrap ?? undefined);
|
|
122
120
|
console.log(` ✓ new token issued (machineId=${rot.machineId})`);
|
|
123
121
|
|
|
124
122
|
// 3. keyring 갱신 + 새 client
|
|
125
123
|
setMasterPw(config.machineId, newPw);
|
|
126
124
|
setToken(config.machineId, rot.token);
|
|
125
|
+
const nextConfig = { ...config, userId: me.userId };
|
|
126
|
+
saveConfig(nextConfig);
|
|
127
127
|
const newClient = new AthsraClient(config.workerUrl, rot.token);
|
|
128
128
|
const newCtx: UserAuthContext = {
|
|
129
129
|
kind: 'user',
|
|
130
|
-
config,
|
|
130
|
+
config: nextConfig,
|
|
131
131
|
masterPw: newPw,
|
|
132
132
|
token: rot.token,
|
|
133
133
|
client: newClient,
|
package/src/commands/run.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
3
|
-
import { resolveProject } from '../lib/auto-project.ts';
|
|
4
|
-
import {
|
|
3
|
+
import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
|
|
4
|
+
import { buildChildEnv } from '../lib/env-format.ts';
|
|
5
5
|
import { readPlain } from '../lib/envelope.ts';
|
|
6
6
|
|
|
7
7
|
const USAGE = [
|
|
@@ -9,6 +9,7 @@ const USAGE = [
|
|
|
9
9
|
'',
|
|
10
10
|
'<project> 자동 감지 (cwd 기반): basename(cwd) > .athsra > package.json athsra.project',
|
|
11
11
|
'23 sibling repo 안에서: cd ~/code/<repo> && athsra run -- bun run dev',
|
|
12
|
+
CONFIG_USAGE_HINT,
|
|
12
13
|
].join('\n');
|
|
13
14
|
|
|
14
15
|
export async function runCmd(args: string[]): Promise<number> {
|
|
@@ -19,7 +20,7 @@ export async function runCmd(args: string[]): Promise<number> {
|
|
|
19
20
|
}
|
|
20
21
|
// -- 앞 영역에서 project 추출 (auto-detect 가능). -- 뒤는 cmd.
|
|
21
22
|
const beforeSep = args.slice(0, sepIdx);
|
|
22
|
-
const { project } = resolveProject(beforeSep);
|
|
23
|
+
const { project, config } = resolveProject(beforeSep);
|
|
23
24
|
if (!project) {
|
|
24
25
|
console.error(USAGE);
|
|
25
26
|
return 2;
|
|
@@ -34,9 +35,9 @@ export async function runCmd(args: string[]): Promise<number> {
|
|
|
34
35
|
const ctx = await loadAuthContext(project);
|
|
35
36
|
if (!ctx) return 1;
|
|
36
37
|
|
|
37
|
-
const plain = await readPlain(ctx, project);
|
|
38
|
+
const plain = await readPlain(ctx, project, config);
|
|
38
39
|
if (!plain) {
|
|
39
|
-
console.error(`project not found: ${project}`);
|
|
40
|
+
console.error(`project not found: ${project}${configTag(config)}`);
|
|
40
41
|
return 1;
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -64,22 +65,3 @@ export async function runCmd(args: string[]): Promise<number> {
|
|
|
64
65
|
});
|
|
65
66
|
});
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 자식 프로세스 env 를 만든다. 빈 값 secret 키는 inject 대상에서 제외해
|
|
70
|
-
* 부모 환경 변수를 보존한다.
|
|
71
|
-
*
|
|
72
|
-
* 키 이름만 등록되고 값이 빈 secret (migration scaffolding 산출물) 을
|
|
73
|
-
* `{ ...process.env, ...plain }` 로 합치면 부모 env 의 유효한 값 (예:
|
|
74
|
-
* ~/.zshrc 의 CLOUDFLARE_API_TOKEN) 이 빈 문자열로 덮어써져 배포 인증이
|
|
75
|
-
* 깨진다. 빈 값 키를 제외하면 부모 env 값이 그대로 살아남는다.
|
|
76
|
-
*
|
|
77
|
-
* @returns env — 자식 프로세스 env, skipped — 제외된 빈 값 키 (정렬됨)
|
|
78
|
-
*/
|
|
79
|
-
export function buildChildEnv(
|
|
80
|
-
parentEnv: Record<string, string | undefined>,
|
|
81
|
-
plain: Record<string, string>,
|
|
82
|
-
): { env: Record<string, string | undefined>; skipped: string[] } {
|
|
83
|
-
const { filled, emptyKeys } = partitionEnv(plain);
|
|
84
|
-
return { env: { ...parentEnv, ...filled }, skipped: emptyKeys };
|
|
85
|
-
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { addServiceRecipient, migrateV1ToV2, type SecretEnvelopeV2 } from '@athsra/crypto';
|
|
2
1
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
3
2
|
import { resolveProject } from '../lib/auto-project.ts';
|
|
4
3
|
import { red, yellow } from '../lib/colors.ts';
|
|
4
|
+
import {
|
|
5
|
+
type CreatedServiceToken,
|
|
6
|
+
createServiceTokenWithRecipient,
|
|
7
|
+
} from '../lib/service-tokens.ts';
|
|
5
8
|
|
|
6
9
|
/** worker expiry-notify (apps/worker/src/queue/expiry-notify.ts) 의 최임박 threshold 와 동기. */
|
|
7
10
|
const EXPIRING_SOON_DAYS = 14;
|
|
@@ -58,40 +61,21 @@ async function createCmd(args: string[]): Promise<number> {
|
|
|
58
61
|
console.error('athsra service-token create 은 user token (master pw) 가 필요합니다.');
|
|
59
62
|
return 1;
|
|
60
63
|
}
|
|
61
|
-
const { masterPw, client } = ctx;
|
|
62
64
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
// 발급 + recipient 추가 (lib/service-tokens.ts — MCP athsra_service_token_create 와 공용 경로)
|
|
66
|
+
let created: CreatedServiceToken;
|
|
67
|
+
try {
|
|
68
|
+
created = await createServiceTokenWithRecipient(ctx, {
|
|
69
|
+
project,
|
|
70
|
+
label,
|
|
71
|
+
perms,
|
|
72
|
+
expiresInDays: expiresDays,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error((err as Error).message);
|
|
69
76
|
return 1;
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
// worker 에 service token 발급
|
|
73
|
-
const created = await client.createServiceToken({
|
|
74
|
-
project,
|
|
75
|
-
label,
|
|
76
|
-
perms,
|
|
77
|
-
expiresInDays: expiresDays,
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
// envelope 에 recipient 추가 — v1 이면 v2 로 자동 migrate
|
|
81
|
-
let v2Envelope: SecretEnvelopeV2;
|
|
82
|
-
if (envelope.version === 1) {
|
|
83
|
-
v2Envelope = await migrateV1ToV2(envelope, masterPw);
|
|
84
|
-
} else {
|
|
85
|
-
v2Envelope = envelope;
|
|
86
|
-
}
|
|
87
|
-
const updated = await addServiceRecipient(
|
|
88
|
-
v2Envelope,
|
|
89
|
-
masterPw,
|
|
90
|
-
created.token,
|
|
91
|
-
created.recipient_id,
|
|
92
|
-
);
|
|
93
|
-
await client.putEnvelope(project, updated);
|
|
94
|
-
|
|
95
79
|
console.log('\n✓ service token 발급 완료\n');
|
|
96
80
|
console.log(` token: ${created.token}`);
|
|
97
81
|
console.log(` project: ${created.project}`);
|
package/src/commands/set.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
3
|
-
import { resolveProject } from '../lib/auto-project.ts';
|
|
3
|
+
import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
|
|
4
4
|
import { parseEnv, partitionEnv } from '../lib/env-format.ts';
|
|
5
5
|
import { readPlain, writePlain } from '../lib/envelope.ts';
|
|
6
6
|
|
|
@@ -12,6 +12,7 @@ const USAGE = [
|
|
|
12
12
|
'<project> 자동 감지 (cwd 기반):',
|
|
13
13
|
' --project=<x> > positional > .athsra file > package.json athsra.project > basename(cwd)',
|
|
14
14
|
'23 sibling repo 안에서는 cd 한 후 project 인자 생략 가능 (basename 자동 사용).',
|
|
15
|
+
CONFIG_USAGE_HINT,
|
|
15
16
|
].join('\n');
|
|
16
17
|
|
|
17
18
|
async function readStdin(): Promise<string> {
|
|
@@ -36,14 +37,14 @@ function parsePairsArg(pairs: string[]): Record<string, string> | null {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export async function setCmd(args: string[]): Promise<number> {
|
|
39
|
-
const { project, rest, source } = resolveProject(args);
|
|
40
|
+
const { project, config, rest, source } = resolveProject(args);
|
|
40
41
|
if (!project) {
|
|
41
42
|
console.error(USAGE);
|
|
42
43
|
return 2;
|
|
43
44
|
}
|
|
44
45
|
// mutating 명령 — auto-detect 시점 source 안내 (실수 방지). positional/flag 면 silent.
|
|
45
46
|
if (source !== 'positional' && source !== 'flag') {
|
|
46
|
-
console.log(`(project=${project} auto-detected from ${source})`);
|
|
47
|
+
console.log(`(project=${project}${configTag(config)} auto-detected from ${source})`);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
let updates: Record<string, string>;
|
|
@@ -93,14 +94,14 @@ export async function setCmd(args: string[]): Promise<number> {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
// read-modify-write — v1/v2 dispatcher 가 read 처리, write 는 항상 v2.
|
|
96
|
-
const plain: Record<string, string> = (await readPlain(ctx, project)) ?? {};
|
|
97
|
+
const plain: Record<string, string> = (await readPlain(ctx, project, config)) ?? {};
|
|
97
98
|
for (const [k, v] of Object.entries(updates)) {
|
|
98
99
|
plain[k] = v;
|
|
99
100
|
}
|
|
100
|
-
await writePlain(ctx, project, plain);
|
|
101
|
+
await writePlain(ctx, project, plain, { config });
|
|
101
102
|
|
|
102
103
|
console.log(
|
|
103
|
-
`✓ ${project}: ${updateCount} key${updateCount > 1 ? 's' : ''} set (${Object.keys(plain).length} total)`,
|
|
104
|
+
`✓ ${project}${configTag(config)}: ${updateCount} key${updateCount > 1 ? 's' : ''} set (${Object.keys(plain).length} total)`,
|
|
104
105
|
);
|
|
105
106
|
return 0;
|
|
106
107
|
}
|
package/src/commands/unset.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
+
import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
|
|
2
3
|
import { readPlain, writePlain } from '../lib/envelope.ts';
|
|
3
4
|
|
|
4
|
-
const USAGE =
|
|
5
|
-
'usage: athsra unset <project> KEY1 [KEY2 ...] # 해당 keys 만 제거 (envelope 그대로, 다른 keys 유지)'
|
|
5
|
+
const USAGE = [
|
|
6
|
+
'usage: athsra unset <project>[:<env>] KEY1 [KEY2 ...] # 해당 keys 만 제거 (envelope 그대로, 다른 keys 유지)',
|
|
7
|
+
CONFIG_USAGE_HINT,
|
|
8
|
+
].join('\n');
|
|
6
9
|
|
|
7
10
|
export async function unsetCmd(args: string[]): Promise<number> {
|
|
8
|
-
const project = args
|
|
11
|
+
const { project, config, rest } = resolveProject(args, { requirePositional: true });
|
|
9
12
|
if (!project) {
|
|
10
13
|
console.error(USAGE);
|
|
11
14
|
return 2;
|
|
12
15
|
}
|
|
13
|
-
const keys =
|
|
16
|
+
const keys = rest;
|
|
14
17
|
if (keys.length === 0) {
|
|
15
18
|
console.error('No keys to unset');
|
|
16
19
|
return 2;
|
|
@@ -23,9 +26,9 @@ export async function unsetCmd(args: string[]): Promise<number> {
|
|
|
23
26
|
return 1;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
const plain = await readPlain(ctx, project);
|
|
29
|
+
const plain = await readPlain(ctx, project, config);
|
|
27
30
|
if (!plain) {
|
|
28
|
-
console.error(`project not found: ${project}`);
|
|
31
|
+
console.error(`project not found: ${project}${configTag(config)}`);
|
|
29
32
|
return 1;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -45,9 +48,9 @@ export async function unsetCmd(args: string[]): Promise<number> {
|
|
|
45
48
|
return 1;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
await writePlain(ctx, project, plain);
|
|
51
|
+
await writePlain(ctx, project, plain, { config });
|
|
49
52
|
console.log(
|
|
50
|
-
`✓ ${project}: ${removed.length} key${removed.length > 1 ? 's' : ''} removed (${Object.keys(plain).length} remaining)`,
|
|
53
|
+
`✓ ${project}${configTag(config)}: ${removed.length} key${removed.length > 1 ? 's' : ''} removed (${Object.keys(plain).length} remaining)`,
|
|
51
54
|
);
|
|
52
55
|
return 0;
|
|
53
56
|
}
|
package/src/commands/versions.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
-
import { resolveProject } from '../lib/auto-project.ts';
|
|
2
|
+
import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
|
|
3
3
|
|
|
4
4
|
const USAGE = [
|
|
5
5
|
'usage: athsra versions [<project>]',
|
|
6
6
|
'',
|
|
7
7
|
'<project> 자동 감지 (cwd 기반): basename(cwd) > .athsra > package.json athsra.project',
|
|
8
|
+
CONFIG_USAGE_HINT,
|
|
8
9
|
].join('\n');
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -15,7 +16,7 @@ const USAGE = [
|
|
|
15
16
|
* - <project> 생략 시 cwd 기반 자동 감지
|
|
16
17
|
*/
|
|
17
18
|
export async function versionsCmd(args: string[]): Promise<number> {
|
|
18
|
-
const { project } = resolveProject(args);
|
|
19
|
+
const { project, config } = resolveProject(args);
|
|
19
20
|
if (!project) {
|
|
20
21
|
console.error(USAGE);
|
|
21
22
|
return 2;
|
|
@@ -24,20 +25,21 @@ export async function versionsCmd(args: string[]): Promise<number> {
|
|
|
24
25
|
if (!ctx) return 1;
|
|
25
26
|
const { client } = ctx;
|
|
26
27
|
|
|
27
|
-
const data = await client.listVersions(project);
|
|
28
|
+
const data = await client.listVersions(project, config);
|
|
29
|
+
const tag = configTag(config);
|
|
28
30
|
if (data.tombstone) {
|
|
29
31
|
console.log(
|
|
30
32
|
`(deleted ${data.tombstone.deleted_at} by ${data.tombstone.deleted_by} — ${data.versions.length} version(s) recoverable)`,
|
|
31
33
|
);
|
|
32
34
|
}
|
|
33
35
|
if (data.count === 0) {
|
|
34
|
-
console.log(
|
|
36
|
+
console.log(`(no versions${tag} — project never existed or hard-deleted)`);
|
|
35
37
|
return 0;
|
|
36
38
|
}
|
|
37
39
|
for (const v of data.versions) {
|
|
38
40
|
const marker = v.version_id === data.current_version ? '*' : ' ';
|
|
39
41
|
console.log(`${marker} ${v.version_id} ${v.updated_at} (${v.size}B)`);
|
|
40
42
|
}
|
|
41
|
-
console.log(`(${data.count} version${data.count > 1 ? 's' : ''})`);
|
|
43
|
+
console.log(`(${data.count} version${data.count > 1 ? 's' : ''}${tag})`);
|
|
42
44
|
return 0;
|
|
43
45
|
}
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { setCmd } from './commands/set.ts';
|
|
|
28
28
|
import { unsetCmd } from './commands/unset.ts';
|
|
29
29
|
import { versionsCmd } from './commands/versions.ts';
|
|
30
30
|
|
|
31
|
-
const VERSION = '1.
|
|
31
|
+
const VERSION = '1.1.1';
|
|
32
32
|
|
|
33
33
|
const commands: Record<string, (args: string[]) => Promise<number>> = {
|
|
34
34
|
login: loginCmd,
|
|
@@ -70,14 +70,15 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
70
70
|
console.log(`athsra v${VERSION} — E2EE secret manager on Cloudflare edge
|
|
71
71
|
|
|
72
72
|
Usage:
|
|
73
|
-
athsra login
|
|
73
|
+
athsra login 브라우저 identity 로그인 (master pw 이 기기 미보관)
|
|
74
|
+
athsra login --password founding master pw 등록/재로그인 (OS keyring 저장)
|
|
74
75
|
athsra logout [--full] keyring clear (worker token 유지). --full 시 config 도 삭제
|
|
75
76
|
athsra init <project> 신규 project 안내
|
|
76
77
|
athsra adopt [<project>] [opts] sibling repo envelope ↔ CF Worker + Workers Builds 1줄 onboarding
|
|
77
|
-
athsra set <project> KEY=value
|
|
78
|
-
athsra unset <project> KEY [...]
|
|
79
|
-
athsra get <project> [KEY]
|
|
80
|
-
athsra ls [project] [--all]
|
|
78
|
+
athsra set <project>[:<env>] KEY=value secret 추가/수정 (다건 가능)
|
|
79
|
+
athsra unset <project>[:<env>] KEY [...] 특정 key 제거 (envelope 유지)
|
|
80
|
+
athsra get <project>[:<env>] [KEY] 값 출력 (single 또는 dump)
|
|
81
|
+
athsra ls [project][:<env>] [--all|--configs] project / key / 환경 목록
|
|
81
82
|
athsra manifest {init|show|validate|add|remove} sibling worker 의 secrets opt-in manifest (Option γ)
|
|
82
83
|
athsra mcp Model Context Protocol stdio server (AI agent 통합)
|
|
83
84
|
athsra run <project> -- <cmd> env inject 후 명령 실행 (Doppler-style)
|
|
@@ -98,6 +99,9 @@ Usage:
|
|
|
98
99
|
athsra restore <project> tombstone 제거 + 최신 version 활성화
|
|
99
100
|
athsra purge <project> hard-delete (delete --hard 별칭, double-confirm)
|
|
100
101
|
|
|
102
|
+
환경(config): <project>:<env> 또는 --config=<env> 로 dev/staging/prod 분리 (기본 default).
|
|
103
|
+
모든 secret 명령 공통. 'athsra ls <project> --configs' 로 환경 목록 조회.
|
|
104
|
+
|
|
101
105
|
athsra dr {restore-r2|restore-d1} DR 복원 (backup→STORE / 암호화 D1 dump). dry-run 기본, --execute --confirm
|
|
102
106
|
|
|
103
107
|
athsra rotate-master master pw 변경 (모든 projects re-encrypt)
|
|
@@ -108,7 +112,7 @@ Usage:
|
|
|
108
112
|
|
|
109
113
|
Files / Storage:
|
|
110
114
|
~/.athsra/config.json worker URL + machine_id
|
|
111
|
-
OS keyring (libsecret/Keychain/Cred Manager) master pw + Bearer token
|
|
115
|
+
OS keyring (libsecret/Keychain/Cred Manager) identity key 또는 master pw + Bearer token
|
|
112
116
|
|
|
113
117
|
Env vars (CI):
|
|
114
118
|
ATHSRA_MASTER_PW non-interactive login
|
|
@@ -120,7 +124,7 @@ Env vars (CI):
|
|
|
120
124
|
Headless (service token — master pw 없이 scoped 복호):
|
|
121
125
|
ATHSRA_TOKEN=ats_... ATHSRA_WORKER_URL=https://... athsra run <project> -- <cmd>
|
|
122
126
|
|
|
123
|
-
Phase
|
|
127
|
+
Phase 5 = identity device-login + MCP 3-tier tools + envelope member/self recipients.
|
|
124
128
|
docs: github.com/modfolio/athsra
|
|
125
129
|
`);
|
|
126
130
|
process.exit(0);
|
package/src/lib/auth-context.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fromBase64 } from '@athsra/crypto';
|
|
2
|
+
import { deriveLegacyMasterProofs, deriveMasterProof } from './auth-proof.ts';
|
|
2
3
|
import { AthsraClient } from './client.ts';
|
|
3
|
-
import { type Config, loadConfig } from './config.ts';
|
|
4
|
-
import { getDeviceToken, getMasterPw, getToken, setToken } from './keyring.ts';
|
|
4
|
+
import { type Config, loadConfig, saveConfig } from './config.ts';
|
|
5
|
+
import { getDeviceToken, getIdentityKey, getMasterPw, getToken, setToken } from './keyring.ts';
|
|
5
6
|
|
|
6
|
-
/** User token mode — keyring 의 master pw + Bearer token
|
|
7
|
+
/** User token mode — keyring 의 master pw + Bearer token 보유. */
|
|
7
8
|
export interface UserAuthContext {
|
|
8
9
|
kind: 'user';
|
|
9
10
|
config: Config;
|
|
@@ -27,7 +28,22 @@ export interface ServiceAuthContext {
|
|
|
27
28
|
workerUrl: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Identity 디바이스 모드 (Phase 5) — keyring 의 identity X25519 private key + token. master pw 가
|
|
33
|
+
* 이 머신에 없어도 멤버 경로(member:me recipient)로 envelope 복호·재포장. token 은 atk_*(90일
|
|
34
|
+
* expiry, idle 면제) — 401 시 idle-refresh 불가(master pw 없음)이므로 `athsra login` 재온보딩.
|
|
35
|
+
*/
|
|
36
|
+
export interface IdentityAuthContext {
|
|
37
|
+
kind: 'identity';
|
|
38
|
+
config: Config;
|
|
39
|
+
/** keyring identity-priv → fromBase64. 멤버 복호/재포장에 사용 (worker 미노출). */
|
|
40
|
+
identityPrivateKey: Uint8Array;
|
|
41
|
+
userId: number;
|
|
42
|
+
token: string;
|
|
43
|
+
client: AthsraClient;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type AuthContext = UserAuthContext | ServiceAuthContext | IdentityAuthContext;
|
|
31
47
|
|
|
32
48
|
/**
|
|
33
49
|
* 모든 인증 요구 commands 의 공통 진입점. 두 모드 자동 dispatch:
|
|
@@ -49,9 +65,12 @@ export async function loadAuthContext(project?: string): Promise<AuthContext | n
|
|
|
49
65
|
return loadServiceContext(envToken);
|
|
50
66
|
}
|
|
51
67
|
// 2. user context (master pw + token, full access) — 보유 시 우선
|
|
52
|
-
const userCtx = loadUserContext();
|
|
68
|
+
const userCtx = await loadUserContext();
|
|
53
69
|
if (userCtx) return userCtx;
|
|
54
|
-
// 3.
|
|
70
|
+
// 3. identity 디바이스 모드 (Phase 5) — master pw 없는 머신, identity privkey + atk_* 보유.
|
|
71
|
+
const identityCtx = loadIdentityContext();
|
|
72
|
+
if (identityCtx) return identityCtx;
|
|
73
|
+
// 4. project-scoped device token (device-login agent) — master pw 없는 머신
|
|
55
74
|
// Phase 3 P3: `athsra login --device --project <p>` 가 keyring 에 저장한 ats_*.
|
|
56
75
|
if (project) {
|
|
57
76
|
const deviceToken = getDeviceToken(project);
|
|
@@ -76,21 +95,40 @@ export async function loadAuthContext(project?: string): Promise<AuthContext | n
|
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
/** master pw + token 보유 시 user context. 미보유 시 조용히 null (호출자가 device/안내 처리). */
|
|
79
|
-
function loadUserContext(): UserAuthContext | null {
|
|
80
|
-
|
|
98
|
+
async function loadUserContext(): Promise<UserAuthContext | null> {
|
|
99
|
+
let config = loadConfig();
|
|
81
100
|
if (!config) return null;
|
|
82
101
|
const masterPw = getMasterPw(config.machineId);
|
|
83
102
|
const token = getToken(config.machineId);
|
|
84
103
|
if (!masterPw || !token) return null;
|
|
85
104
|
const client = new AthsraClient(config.workerUrl, token);
|
|
86
|
-
|
|
87
|
-
//
|
|
105
|
+
|
|
106
|
+
// Legacy config migration: older CLI versions did not persist userId. When the current token is
|
|
107
|
+
// still valid, backfill it from whoami so later proof operations use the authenticated user.
|
|
108
|
+
if (config.userId === undefined) {
|
|
109
|
+
try {
|
|
110
|
+
const me = await client.whoami();
|
|
111
|
+
if (me.userId !== undefined) {
|
|
112
|
+
config = { ...config, userId: me.userId };
|
|
113
|
+
saveConfig(config);
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Token may already be idle-revoked. Keep fail-closed idle refresh below instead of guessing.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Phase 3 — idle timeout 자동 재인증. founding password-login token 만 keyring master pw
|
|
121
|
+
// 로 silent re-register 가능. SSO/non-founding user 는 /auth/register 대상이 아니라 재로그인 필요.
|
|
88
122
|
// 기기 잠금 (OS keyring 차단) 이 방어선이므로 작업 중엔 끊김 없이, 잠금 시엔 idle 유효.
|
|
89
123
|
client.setIdleRefresh(async () => {
|
|
90
124
|
try {
|
|
125
|
+
if (config.userId !== 1) return null;
|
|
91
126
|
const info = await client.info();
|
|
92
|
-
const proof =
|
|
93
|
-
|
|
127
|
+
const proof = deriveMasterProof(masterPw, config.userId, info.global_salt);
|
|
128
|
+
// 버전 업그레이드 무중단: 저장 proof 가 옛 스킴이면 worker 가 자동 G-1 마이그레이션.
|
|
129
|
+
const legacyProofs = deriveLegacyMasterProofs(masterPw, info.global_salt);
|
|
130
|
+
const reg = await client.register(proof, config.machineId, legacyProofs);
|
|
131
|
+
if (reg.userId !== undefined && reg.userId !== config.userId) return null;
|
|
94
132
|
let token = reg.token;
|
|
95
133
|
// Phase 4 Slice 6a — re-register 는 자기 personal org 로 token 발급. switch 상태였으면
|
|
96
134
|
// (config.orgId) 다시 그 org 로 전환 — 안 하면 idle 후 사용자 모르게 personal 컨텍스트로 reset.
|
|
@@ -117,6 +155,32 @@ function loadUserContext(): UserAuthContext | null {
|
|
|
117
155
|
};
|
|
118
156
|
}
|
|
119
157
|
|
|
158
|
+
/**
|
|
159
|
+
* identity privkey + token 보유 시 identity context (master pw 없는 머신). config.userId 필수
|
|
160
|
+
* (member recipient 매칭). 미보유/손상 시 조용히 null (호출자가 device/안내 처리).
|
|
161
|
+
*/
|
|
162
|
+
function loadIdentityContext(): IdentityAuthContext | null {
|
|
163
|
+
const config = loadConfig();
|
|
164
|
+
if (!config || config.userId === undefined) return null;
|
|
165
|
+
const privB64 = getIdentityKey(config.machineId);
|
|
166
|
+
const token = getToken(config.machineId);
|
|
167
|
+
if (!privB64 || !token) return null;
|
|
168
|
+
let identityPrivateKey: Uint8Array;
|
|
169
|
+
try {
|
|
170
|
+
identityPrivateKey = fromBase64(privB64);
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
kind: 'identity',
|
|
176
|
+
config,
|
|
177
|
+
identityPrivateKey,
|
|
178
|
+
userId: config.userId,
|
|
179
|
+
token,
|
|
180
|
+
client: new AthsraClient(config.workerUrl, token),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
120
184
|
async function loadServiceContext(
|
|
121
185
|
token: string,
|
|
122
186
|
opts?: { silent?: boolean },
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { deriveKey, deriveProof, fromBase64, toBase64 } from '@athsra/crypto';
|
|
2
|
+
|
|
3
|
+
/** G-1 auth proof — per-user salt + GLOBAL_SALT pepper, base64 wire format. */
|
|
4
|
+
export function deriveMasterProof(
|
|
5
|
+
masterPw: string,
|
|
6
|
+
userId: number,
|
|
7
|
+
globalSaltBase64: string,
|
|
8
|
+
): string {
|
|
9
|
+
return toBase64(deriveProof(masterPw, userId, fromBase64(globalSaltBase64)));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 레거시 proof (pre-G-1, ≤1.0.2): `deriveKey(masterPw, GLOBAL_SALT)` — userId/도메인 prefix 없음.
|
|
14
|
+
*
|
|
15
|
+
* **버전 업그레이드 무중단 마이그레이션 전용.** 현 스킴(G-1) proof 가 mismatch 일 때 CLI 가 이 레거시
|
|
16
|
+
* proof 를 함께 보내고, worker 가 그것으로 검증 성공 시 저장 proof 를 G-1 으로 자동 재작성한다 →
|
|
17
|
+
* CLI 버전이 바뀌어도(옛 스킴 stored ↔ 새 스킴 client) 로그인이 깨지지 않는다. 정공법: 스킴 전환을
|
|
18
|
+
* 게이트가 아니라 자동 마이그레이션으로 흡수. 신규 스킴 추가 시 이 배열에 한 줄만 더하면 된다.
|
|
19
|
+
*/
|
|
20
|
+
export function deriveLegacyMasterProofs(masterPw: string, globalSaltBase64: string): string[] {
|
|
21
|
+
const salt = fromBase64(globalSaltBase64);
|
|
22
|
+
return [
|
|
23
|
+
// gen-0 (≤1.0.2): Argon2id(masterPw, GLOBAL_SALT) — per-user salt 도입 전.
|
|
24
|
+
toBase64(deriveKey(masterPw, salt)),
|
|
25
|
+
];
|
|
26
|
+
}
|