@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.
@@ -1,20 +1,26 @@
1
1
  import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { resolveProject } from '../lib/auto-project.ts';
2
3
 
3
- const USAGE = 'usage: athsra versions <project>';
4
+ const USAGE = [
5
+ 'usage: athsra versions [<project>]',
6
+ '',
7
+ '<project> 자동 감지 (cwd 기반): basename(cwd) > .athsra > package.json athsra.project',
8
+ ].join('\n');
4
9
 
5
10
  /**
6
- * athsra versions <project>
11
+ * athsra versions [<project>]
7
12
  * - 모든 version + tombstone 상태 출력
8
13
  * - current version 은 * 표시
9
14
  * - tombstone 있으면 (deleted) 헤더로 표시
15
+ * - <project> 생략 시 cwd 기반 자동 감지
10
16
  */
11
17
  export async function versionsCmd(args: string[]): Promise<number> {
12
- const project = args[0];
18
+ const { project } = resolveProject(args);
13
19
  if (!project) {
14
20
  console.error(USAGE);
15
21
  return 2;
16
22
  }
17
- const ctx = loadAuthContext();
23
+ const ctx = await loadAuthContext();
18
24
  if (!ctx) return 1;
19
25
  const { client } = ctx;
20
26
 
package/src/index.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  #!/usr/bin/env bun
2
+ import { projectCmd, roleCmd, usersCmd } from './commands/admin.ts';
3
+ import { adoptCmd } from './commands/adopt.ts';
4
+ import { auditCmd } from './commands/audit.ts';
5
+ import { completionCmd } from './commands/completion.ts';
2
6
  import { deleteCmd } from './commands/delete.ts';
3
7
  import { doctorCmd } from './commands/doctor.ts';
4
8
  import { getCmd } from './commands/get.ts';
5
9
  import { handoffCmd } from './commands/handoff.ts';
6
10
  import { initCmd } from './commands/init.ts';
7
11
  import { loginCmd } from './commands/login.ts';
12
+ import { logoutCmd } from './commands/logout.ts';
8
13
  import { lsCmd } from './commands/ls.ts';
14
+ import { manifestCmd } from './commands/manifest.ts';
15
+ import { mcpCmd } from './commands/mcp.ts';
16
+ import { migrateEnvelopesCmd } from './commands/migrate-envelopes.ts';
9
17
  import { newPhraseCmd } from './commands/new-phrase.ts';
10
18
  import { purgeCmd } from './commands/purge.ts';
11
19
  import { restoreCmd } from './commands/restore.ts';
@@ -13,21 +21,32 @@ import { revokeCmd } from './commands/revoke.ts';
13
21
  import { rollbackCmd } from './commands/rollback.ts';
14
22
  import { rotateMasterCmd } from './commands/rotate-master.ts';
15
23
  import { runCmd } from './commands/run.ts';
24
+ import { serviceTokenCmd } from './commands/service-token.ts';
16
25
  import { setCmd } from './commands/set.ts';
17
26
  import { unsetCmd } from './commands/unset.ts';
18
27
  import { versionsCmd } from './commands/versions.ts';
19
28
 
20
- const VERSION = '0.1.0';
29
+ const VERSION = '1.0.0';
21
30
 
22
31
  const commands: Record<string, (args: string[]) => Promise<number>> = {
23
32
  login: loginCmd,
33
+ logout: logoutCmd,
24
34
  init: initCmd,
35
+ adopt: adoptCmd,
25
36
  set: setCmd,
26
37
  unset: unsetCmd,
27
38
  get: getCmd,
28
39
  ls: lsCmd,
40
+ manifest: manifestCmd,
41
+ mcp: mcpCmd,
29
42
  run: runCmd,
30
43
  doctor: doctorCmd,
44
+ 'service-token': serviceTokenCmd,
45
+ audit: auditCmd,
46
+ users: usersCmd,
47
+ role: roleCmd,
48
+ project: projectCmd,
49
+ 'migrate-envelopes': migrateEnvelopesCmd,
31
50
  'rotate-master': rotateMasterCmd,
32
51
  'new-phrase': newPhraseCmd,
33
52
  handoff: handoffCmd,
@@ -37,6 +56,7 @@ const commands: Record<string, (args: string[]) => Promise<number>> = {
37
56
  delete: deleteCmd,
38
57
  restore: restoreCmd,
39
58
  purge: purgeCmd,
59
+ completion: completionCmd,
40
60
  };
41
61
 
42
62
  const args = Bun.argv.slice(2);
@@ -47,13 +67,25 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
47
67
 
48
68
  Usage:
49
69
  athsra login master pw 입력 + 머신 등록 (Bearer token 발급)
70
+ athsra logout [--full] keyring clear (worker token 유지). --full 시 config 도 삭제
50
71
  athsra init <project> 신규 project 안내
72
+ athsra adopt [<project>] [opts] sibling repo envelope ↔ CF Worker + Workers Builds 1줄 onboarding
51
73
  athsra set <project> KEY=value secret 추가/수정 (다건 가능)
52
74
  athsra unset <project> KEY [...] 특정 key 제거 (envelope 유지)
53
75
  athsra get <project> [KEY] 값 출력 (single 또는 dump)
54
76
  athsra ls [project] [--all] project 또는 key 목록 (--all=deleted 포함)
77
+ athsra manifest {init|show|validate|add|remove} sibling worker 의 secrets opt-in manifest (Option γ)
78
+ athsra mcp Model Context Protocol stdio server (AI agent 통합)
55
79
  athsra run <project> -- <cmd> env inject 후 명령 실행 (Doppler-style)
56
- athsra doctor 환경 검증 (keyring/dbus/worker phase)
80
+ athsra doctor [--audit] 환경 검증 (keyring/dbus/worker). --audit=전 project 빈 값 키 스캔
81
+ athsra service-token create <p> --label=<l> scoped service token 발급 (master pw 없이도 복호, headless 용)
82
+ athsra service-token list [<project>] 발급한 service token 메타 (revoke·만료 점검용)
83
+ athsra service-token revoke <token> service token 무효화
84
+ athsra audit [--actor=...] [--action=...] audit log 조회 (--all=cursor follow, --format=table|json|jsonl)
85
+ athsra users {list|invite <id> [--role=R]} RBAC: user 목록 / 신규 invite (admin role 필요)
86
+ athsra role {grant|revoke} <user_id> <R> role 부여/제거 (admin/dev/viewer/auditor/sa)
87
+ athsra project {share|unshare} <p> <uid> per-project ACL share/unshare (--perms=read|write)
88
+ athsra migrate-envelopes [--apply] 모든 v1 envelope → v2 일괄 마이그레이션 (idempotent)
57
89
 
58
90
  athsra versions <project> 모든 version + tombstone 상태
59
91
  athsra rollback <project> <version_id> 특정 version 으로 current 복원
@@ -65,6 +97,7 @@ Usage:
65
97
  athsra new-phrase BIP-39 12-word phrase 생성 (master pw 권장)
66
98
  athsra handoff [--accept] 새 머신 추가 (issue / accept)
67
99
  athsra revoke [<atk_*>] self 또는 명시 token revoke
100
+ athsra completion {bash|zsh|fish} shell completion script 출력
68
101
 
69
102
  Files / Storage:
70
103
  ~/.athsra/config.json worker URL + machine_id
@@ -77,7 +110,10 @@ Env vars (CI):
77
110
  ATHSRA_PURGE_CONFIRMED=1 skip purge double-confirm
78
111
  ATHSRA_ROLLBACK_CONFIRMED=1 skip rollback confirm
79
112
 
80
- Phase 1.x.1 = Bearer auth + keyring + soft-delete + version history.
113
+ Headless (service token master pw 없이 scoped 복호):
114
+ ATHSRA_TOKEN=ats_... ATHSRA_WORKER_URL=https://... athsra run <project> -- <cmd>
115
+
116
+ Phase 1.x.8 = envelope v2 (DEK + multi-recipient) + service tokens for headless hosts.
81
117
  docs: github.com/modfolio/athsra
82
118
  `);
83
119
  process.exit(0);
@@ -90,10 +126,49 @@ if (cmd === '--version' || cmd === '-V' || cmd === 'version') {
90
126
 
91
127
  const handler = commands[cmd];
92
128
  if (!handler) {
93
- console.error(`Unknown command: ${cmd}\nRun \`athsra --help\` for usage.`);
129
+ // Levenshtein distance 1-2 까지 suggestion ("Did you mean?")
130
+ const suggestions = suggestCommand(cmd, Object.keys(commands));
131
+ let msg = `Unknown command: ${cmd}\n`;
132
+ if (suggestions.length > 0) {
133
+ msg += `Did you mean: ${suggestions.map((s) => `\`${s}\``).join(' or ')}?\n`;
134
+ }
135
+ msg += 'Run `athsra --help` for usage.';
136
+ console.error(msg);
94
137
  process.exit(2);
95
138
  }
96
139
 
140
+ /**
141
+ * Levenshtein distance 기반 명령 typo 추천. distance ≤ 2 + sorted by distance.
142
+ * Bun/Node 표준 — DP 2D matrix, O(m*n) time, O(min) space (단순 구현).
143
+ */
144
+ function suggestCommand(input: string, available: string[]): string[] {
145
+ const scored: { name: string; dist: number }[] = [];
146
+ for (const name of available) {
147
+ const dist = levenshtein(input, name);
148
+ if (dist <= 2) scored.push({ name, dist });
149
+ }
150
+ return scored.sort((a, b) => a.dist - b.dist).map((s) => s.name);
151
+ }
152
+
153
+ function levenshtein(a: string, b: string): number {
154
+ if (a === b) return 0;
155
+ const m = a.length;
156
+ const n = b.length;
157
+ if (m === 0) return n;
158
+ if (n === 0) return m;
159
+ // DP — prev row + cur row
160
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
161
+ for (let i = 1; i <= m; i++) {
162
+ const cur = [i];
163
+ for (let j = 1; j <= n; j++) {
164
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
165
+ cur.push(Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost));
166
+ }
167
+ prev = cur;
168
+ }
169
+ return prev[n] ?? 0;
170
+ }
171
+
97
172
  handler(args.slice(1))
98
173
  .then((code) => process.exit(code))
99
174
  .catch((err: Error) => {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * adopt-context.ts — sibling repo 의 자동 추론 (wrangler.jsonc, git remote, app root).
3
+ *
4
+ * `athsra adopt` 가 매번 사용자에게 worker/repo/root 를 물어보지 않게, cwd 의 파일
5
+ * 시스템 + git 정보로 합리적 기본값 추론. 사용자 명시 --cf-worker / --gh-repo / --root
6
+ * override 우선.
7
+ */
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
11
+ import { dirname, join, relative, resolve } from 'node:path';
12
+
13
+ export interface AdoptInferred {
14
+ /** 발견된 모든 wrangler.jsonc / wrangler.toml 경로 + 추출된 name */
15
+ workers: WrangerWorker[];
16
+ /** git remote 에서 추출한 modfolio/<repo> (없으면 undefined) */
17
+ ghRepo?: string;
18
+ /** sibling repo 의 monorepo root (package.json workspaces 또는 git toplevel) */
19
+ repoRoot: string;
20
+ }
21
+
22
+ export interface WrangerWorker {
23
+ /** wrangler.jsonc 의 `name` */
24
+ name: string;
25
+ /** wrangler.jsonc 가 있는 디렉토리의 absolute 경로 */
26
+ cwd: string;
27
+ /** repoRoot 기준 상대 경로 (Workers Builds root_directory). repo root 면 "." */
28
+ rootDirectory: string;
29
+ }
30
+
31
+ /** JSONC (comments 허용) 안전 파싱 — wrangler.jsonc 가 표준 JSONC 사용. */
32
+ function parseJsonc(text: string): unknown {
33
+ // 단순 cleanup: line comments (//) + block comments (/* */) 제거.
34
+ // 문자열 내부 // 는 보존 — 정공법은 proper JSONC parser 인데 의존성 추가 회피.
35
+ let cleaned = '';
36
+ let i = 0;
37
+ let inString = false;
38
+ let escaped = false;
39
+ while (i < text.length) {
40
+ const c = text[i];
41
+ const next = text[i + 1];
42
+ if (inString) {
43
+ cleaned += c;
44
+ if (escaped) escaped = false;
45
+ else if (c === '\\') escaped = true;
46
+ else if (c === '"') inString = false;
47
+ i++;
48
+ continue;
49
+ }
50
+ if (c === '"') {
51
+ inString = true;
52
+ cleaned += c;
53
+ i++;
54
+ continue;
55
+ }
56
+ if (c === '/' && next === '/') {
57
+ while (i < text.length && text[i] !== '\n') i++;
58
+ continue;
59
+ }
60
+ if (c === '/' && next === '*') {
61
+ i += 2;
62
+ while (i < text.length - 1 && !(text[i] === '*' && text[i + 1] === '/')) i++;
63
+ i += 2;
64
+ continue;
65
+ }
66
+ cleaned += c;
67
+ i++;
68
+ }
69
+ // trailing commas 제거 (objects + arrays). 단순 regex.
70
+ cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
71
+ return JSON.parse(cleaned);
72
+ }
73
+
74
+ /** wrangler.jsonc / wrangler.toml 의 name field. 없으면 null. */
75
+ function readWranglerName(path: string): string | null {
76
+ try {
77
+ const text = readFileSync(path, 'utf-8');
78
+ if (path.endsWith('.toml')) {
79
+ const m = text.match(/^\s*name\s*=\s*"([^"]+)"/m);
80
+ return m?.[1] ?? null;
81
+ }
82
+ const obj = parseJsonc(text) as { name?: unknown };
83
+ return typeof obj.name === 'string' ? obj.name : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /** repo root 찾기 — git rev-parse, 실패 시 cwd 자체. */
90
+ function findRepoRoot(cwd: string): string {
91
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
92
+ cwd,
93
+ encoding: 'utf-8',
94
+ });
95
+ if (result.status === 0) {
96
+ return result.stdout.trim();
97
+ }
98
+ return resolve(cwd);
99
+ }
100
+
101
+ /** repo root 의 git remote origin 에서 modfolio/<repo> 형식 추출. */
102
+ function readGhRepo(repoRoot: string): string | undefined {
103
+ const result = spawnSync('git', ['remote', 'get-url', 'origin'], {
104
+ cwd: repoRoot,
105
+ encoding: 'utf-8',
106
+ });
107
+ if (result.status !== 0) return undefined;
108
+ const url = result.stdout.trim();
109
+ // 패턴: https://github.com/org/repo.git, git@github.com:org/repo.git, ssh://...:port/org/repo.git
110
+ const httpsMatch = url.match(/github\.com[/:]([^/]+)\/([^/.\s]+)(?:\.git)?$/);
111
+ if (httpsMatch) return `${httpsMatch[1]}/${httpsMatch[2]}`;
112
+ return undefined;
113
+ }
114
+
115
+ /** repo 안에서 wrangler 설정 파일 모두 발견 (apps/* 및 root). node_modules 제외. */
116
+ function findWranglerConfigs(repoRoot: string, maxDepth = 4): string[] {
117
+ const results: string[] = [];
118
+ function walk(dir: string, depth: number): void {
119
+ if (depth > maxDepth) return;
120
+ let entries: string[];
121
+ try {
122
+ entries = readdirSync(dir);
123
+ } catch {
124
+ return;
125
+ }
126
+ for (const name of entries) {
127
+ if (name === 'node_modules' || name === '.git' || name.startsWith('.')) continue;
128
+ const full = join(dir, name);
129
+ let s: ReturnType<typeof statSync>;
130
+ try {
131
+ s = statSync(full);
132
+ } catch {
133
+ continue;
134
+ }
135
+ if (s.isDirectory()) {
136
+ walk(full, depth + 1);
137
+ } else if (name === 'wrangler.jsonc' || name === 'wrangler.toml') {
138
+ results.push(full);
139
+ }
140
+ }
141
+ }
142
+ walk(repoRoot, 0);
143
+ return results;
144
+ }
145
+
146
+ export function inferAdoptContext(cwd: string): AdoptInferred {
147
+ const repoRoot = findRepoRoot(cwd);
148
+ const ghRepo = readGhRepo(repoRoot);
149
+ const configs = findWranglerConfigs(repoRoot);
150
+ const workers: WrangerWorker[] = [];
151
+ for (const path of configs) {
152
+ const name = readWranglerName(path);
153
+ if (!name) continue;
154
+ const dir = dirname(path);
155
+ const rel = relative(repoRoot, dir) || '.';
156
+ workers.push({ name, cwd: dir, rootDirectory: rel });
157
+ }
158
+ return { workers, ghRepo, repoRoot };
159
+ }
160
+
161
+ /** GitHub API 로 repo id + owner id 가져오기 (gh CLI 사용). */
162
+ export function fetchGhRepoMeta(
163
+ ghRepo: string,
164
+ ): { repoId: string; ownerId: string; ownerLogin: string } | null {
165
+ const result = spawnSync('gh', ['api', `/repos/${ghRepo}`], { encoding: 'utf-8' });
166
+ if (result.status !== 0) return null;
167
+ try {
168
+ const meta = JSON.parse(result.stdout) as {
169
+ id: number;
170
+ owner: { id: number; login: string };
171
+ };
172
+ return {
173
+ repoId: String(meta.id),
174
+ ownerId: String(meta.owner.id),
175
+ ownerLogin: meta.owner.login,
176
+ };
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /** test helpers — 단순 export */
183
+ export const __test = { parseJsonc, readWranglerName, findWranglerConfigs };
@@ -2,19 +2,54 @@ import { AthsraClient } from './client.ts';
2
2
  import { type Config, loadConfig } from './config.ts';
3
3
  import { getMasterPw, getToken } from './keyring.ts';
4
4
 
5
- export interface AuthContext {
5
+ /** User token mode — keyring 의 master pw + Bearer token 보유, full access. */
6
+ export interface UserAuthContext {
7
+ kind: 'user';
6
8
  config: Config;
7
9
  masterPw: string;
8
10
  token: string;
9
11
  client: AthsraClient;
10
12
  }
11
13
 
14
+ /** Service token mode — ATHSRA_TOKEN env, scoped + read/write, master pw 없이 envelope 복호. */
15
+ export interface ServiceAuthContext {
16
+ kind: 'service';
17
+ /** ats_* token 문자열 — 동시에 Argon2id 의 wrap secret. */
18
+ tokenSecret: string;
19
+ /** envelope.recipients[].id — 헤들리스 unwrap 시 매칭. */
20
+ recipientId: string;
21
+ /** scope: 단일 project. */
22
+ scopeProject: string;
23
+ /** scope: read 또는 write. */
24
+ scopePerms: 'read' | 'write';
25
+ client: AthsraClient;
26
+ workerUrl: string;
27
+ }
28
+
29
+ export type AuthContext = UserAuthContext | ServiceAuthContext;
30
+
12
31
  /**
13
- * 모든 인증 요구 commands (set/get/ls/run/doctor 부분) 의 공통 진입점.
32
+ * 모든 인증 요구 commands 의 공통 진입점. 두 모드 자동 dispatch:
33
+ *
34
+ * - **user (기본)** — `~/.athsra/config.json` + OS keyring 의 master pw + token.
35
+ * keyring 없으면 → null + 안내 (athsra login).
14
36
  *
15
- * 실패 적절한 에러 메시지 출력 + null 반환. 호출자는 `return 1` 만.
37
+ * - **service (headless)** `ATHSRA_TOKEN=ats_*` env 있으면 keyring/config 우회.
38
+ * worker URL 은 `ATHSRA_WORKER_URL` env 또는 config.json fallback.
39
+ * worker `/auth/whoami` 로 scope (project + perms + recipient_id) 확인.
40
+ * NAS / CI 등 헤들리스 호스트 용 — master pw 없이 scoped 복호 가능.
41
+ *
42
+ * 실패 시 stderr 에 안내 + null 반환. 호출자는 `return 1` 만.
16
43
  */
17
- export function loadAuthContext(): AuthContext | null {
44
+ export async function loadAuthContext(): Promise<AuthContext | null> {
45
+ const envToken = process.env.ATHSRA_TOKEN;
46
+ if (envToken?.startsWith('ats_')) {
47
+ return loadServiceContext(envToken);
48
+ }
49
+ return loadUserContext();
50
+ }
51
+
52
+ function loadUserContext(): UserAuthContext | null {
18
53
  const config = loadConfig();
19
54
  if (!config) {
20
55
  console.error('Not logged in. Run `athsra login` first.');
@@ -27,9 +62,47 @@ export function loadAuthContext(): AuthContext | null {
27
62
  return null;
28
63
  }
29
64
  return {
65
+ kind: 'user',
30
66
  config,
31
67
  masterPw,
32
68
  token,
33
69
  client: new AthsraClient(config.workerUrl, token),
34
70
  };
35
71
  }
72
+
73
+ async function loadServiceContext(token: string): Promise<ServiceAuthContext | null> {
74
+ const config = loadConfig(); // optional in service mode
75
+ const workerUrl = process.env.ATHSRA_WORKER_URL ?? config?.workerUrl;
76
+ if (!workerUrl) {
77
+ console.error(
78
+ 'service token 모드인데 worker URL 모름. `ATHSRA_WORKER_URL=https://...` 환경변수 또는 `~/.athsra/config.json` 필요.',
79
+ );
80
+ return null;
81
+ }
82
+ const client = new AthsraClient(workerUrl, token);
83
+ let me: Awaited<ReturnType<typeof client.whoami>>;
84
+ try {
85
+ me = await client.whoami();
86
+ } catch (err) {
87
+ console.error(`service token 검증 실패 (worker /auth/whoami): ${(err as Error).message}`);
88
+ return null;
89
+ }
90
+ if (
91
+ me.kind !== 'service' ||
92
+ !me.scopeProject ||
93
+ (me.scopePerms !== 'read' && me.scopePerms !== 'write') ||
94
+ !me.recipientId
95
+ ) {
96
+ console.error('whoami 응답에 service token scope 정보 없음 — worker 갱신 필요 (Phase 1.x.8+).');
97
+ return null;
98
+ }
99
+ return {
100
+ kind: 'service',
101
+ tokenSecret: token,
102
+ recipientId: me.recipientId,
103
+ scopeProject: me.scopeProject,
104
+ scopePerms: me.scopePerms,
105
+ client,
106
+ workerUrl,
107
+ };
108
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * auto-project.ts — cwd 기반 athsra project 자동 감지.
3
+ *
4
+ * Doppler 의 `--project` flag 와 동일한 UX 격차 해소. modfolio universe 의 23 sibling
5
+ * repo 안에서 `cd ~/code/<repo>` 후 매 명령마다 `<project>` 인자 입력 마찰 제거.
6
+ *
7
+ * Precedence (높은 → 낮은):
8
+ * 1. --project=<x> flag (explicit override, args 에서 추출)
9
+ * 2. positional <project> arg (기존 동작 그대로 — 인자 첫 자리 + `=` 없음)
10
+ * 3. .athsra file (sibling repo 루트, KEY=value 형식 — project=<name> 한 줄)
11
+ * 4. package.json 의 athsra.project field
12
+ * 5. basename(cwd) (마지막 fallback — 23 sibling 의 표준 패턴 = repo 명 = project 명)
13
+ *
14
+ * 사용 (각 command 의 첫 줄):
15
+ * const { project, rest, source } = resolveProject(args);
16
+ * if (!project) { console.error(USAGE); return 2; }
17
+ *
18
+ * 정공법: args 첫 자리가 KEY=value 패턴이면 project 가 아닌 KEY=value 로 인식 → auto-detect.
19
+ * 첫 자리가 project name 이면 (= 없음) explicit 로 인식. 호환성 유지 (기존 명령 그대로).
20
+ */
21
+
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { basename, join } from 'node:path';
24
+
25
+ export interface ProjectResolution {
26
+ project: string | undefined;
27
+ /** project 명이 args 에서 빠진 나머지 args (KEY=value pairs 또는 다른 인자) */
28
+ rest: string[];
29
+ /** 어디서 감지했는가 — 디버깅 / 사용자 안내용 */
30
+ source: 'flag' | 'positional' | 'athsra-file' | 'package-json' | 'cwd' | 'none';
31
+ }
32
+
33
+ /**
34
+ * args 에서 --project=<x> flag 추출 + 나머지 args 반환.
35
+ */
36
+ function extractProjectFlag(args: string[]): { project?: string; rest: string[] } {
37
+ const rest: string[] = [];
38
+ let project: string | undefined;
39
+ for (const arg of args) {
40
+ if (arg.startsWith('--project=')) {
41
+ project = arg.slice('--project='.length);
42
+ } else if (arg === '--project') {
43
+ } else {
44
+ rest.push(arg);
45
+ }
46
+ }
47
+ return project ? { project, rest } : { rest };
48
+ }
49
+
50
+ /**
51
+ * cwd 의 .athsra file 읽기 — `project=<name>` 한 줄 (다른 키 무시, 단순 KEY=value).
52
+ */
53
+ function readAthsraFile(cwd: string): string | undefined {
54
+ const path = join(cwd, '.athsra');
55
+ if (!existsSync(path)) return undefined;
56
+ try {
57
+ const text = readFileSync(path, 'utf-8');
58
+ for (const line of text.split('\n')) {
59
+ const trimmed = line.trim();
60
+ if (!trimmed || trimmed.startsWith('#')) continue;
61
+ const eq = trimmed.indexOf('=');
62
+ if (eq < 0) continue;
63
+ const key = trimmed.slice(0, eq).trim();
64
+ const value = trimmed.slice(eq + 1).trim();
65
+ if (key === 'project' && value) return value;
66
+ }
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * cwd 의 package.json 의 athsra.project field 읽기.
75
+ */
76
+ function readPackageJsonProject(cwd: string): string | undefined {
77
+ const path = join(cwd, 'package.json');
78
+ if (!existsSync(path)) return undefined;
79
+ try {
80
+ const pkg = JSON.parse(readFileSync(path, 'utf-8')) as { athsra?: { project?: string } };
81
+ return pkg.athsra?.project;
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * args 에서 project 를 추출 (positional 또는 flag) + auto-detect fallback.
89
+ *
90
+ * @param args - command 호출 args (e.g. set 의 경우 ['modfolio-pay', 'KEY=val'] 또는 ['KEY=val'])
91
+ * @param opts.cwd - 자동 감지 시작 디렉토리 (기본 process.cwd())
92
+ * @param opts.requirePositional - true 면 positional 만 인식 (auto-detect 비활성). default false.
93
+ */
94
+ export function resolveProject(
95
+ args: string[],
96
+ opts?: { cwd?: string; requirePositional?: boolean },
97
+ ): ProjectResolution {
98
+ // 1. --project=<x> flag 우선
99
+ const flagResult = extractProjectFlag(args);
100
+ if (flagResult.project) {
101
+ return { project: flagResult.project, rest: flagResult.rest, source: 'flag' };
102
+ }
103
+
104
+ const remaining = flagResult.rest;
105
+
106
+ // 2. positional <project> arg — 첫 자리 + `=` 없으면 project 로 인식
107
+ if (remaining.length > 0 && remaining[0] !== undefined && !remaining[0].includes('=')) {
108
+ return { project: remaining[0], rest: remaining.slice(1), source: 'positional' };
109
+ }
110
+
111
+ if (opts?.requirePositional) {
112
+ return { project: undefined, rest: remaining, source: 'none' };
113
+ }
114
+
115
+ // 3. .athsra file (auto-detect)
116
+ const cwd = opts?.cwd ?? process.cwd();
117
+ const fromFile = readAthsraFile(cwd);
118
+ if (fromFile) return { project: fromFile, rest: remaining, source: 'athsra-file' };
119
+
120
+ // 4. package.json athsra.project
121
+ const fromPkg = readPackageJsonProject(cwd);
122
+ if (fromPkg) return { project: fromPkg, rest: remaining, source: 'package-json' };
123
+
124
+ // 5. basename(cwd) — 23 sibling 표준 = repo 명 = project 명
125
+ const base = basename(cwd);
126
+ if (base && base !== '/' && base !== '~' && base !== '.') {
127
+ return { project: base, rest: remaining, source: 'cwd' };
128
+ }
129
+
130
+ return { project: undefined, rest: remaining, source: 'none' };
131
+ }