relayax-cli 0.2.41 → 0.3.41

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.
@@ -22,10 +22,15 @@ export declare function saveTokenData(data: TokenData): void;
22
22
  export declare function saveToken(token: string): void;
23
23
  /**
24
24
  * 유효한 access_token을 반환한다.
25
- * 1. 저장된 토큰이 없으면 undefined
26
- * 2. expires_at이 아직 유효하면 access_token 반환
27
- * 3. 만료되었으면 refresh_token으로 갱신 시도
28
- * 4. 갱신 실패 undefined (재로그인 필요)
25
+ *
26
+ * Supabase는 refresh token rotation을 사용하므로:
27
+ * - refresh 시 이전 refresh_token 무효화됨
28
+ * - 병렬 CLI 호출에서 동시 refresh 방지 필요 (lock)
29
+ * - refresh 성공 시 새 토큰을 즉시 파일에 저장
30
+ *
31
+ * 타이밍:
32
+ * - 만료 10분 전부터 proactive refresh
33
+ * - refresh 실패해도 access_token이 아직 유효하면 계속 사용
29
34
  */
30
35
  export declare function getValidToken(): Promise<string | undefined>;
31
36
  /** 프로젝트 로컬 installed.json 읽기 */
@@ -81,56 +81,138 @@ function saveTokenData(data) {
81
81
  ensureGlobalRelayDir();
82
82
  const tokenFile = path_1.default.join(GLOBAL_RELAY_DIR, 'token');
83
83
  fs_1.default.writeFileSync(tokenFile, JSON.stringify(data), { mode: 0o600 });
84
+ // writeFileSync mode only applies on creation — fix existing files
85
+ fs_1.default.chmodSync(tokenFile, 0o600);
84
86
  }
85
87
  function saveToken(token) {
86
88
  ensureGlobalRelayDir();
87
89
  const tokenFile = path_1.default.join(GLOBAL_RELAY_DIR, 'token');
88
90
  fs_1.default.writeFileSync(tokenFile, JSON.stringify({ access_token: token }), { mode: 0o600 });
91
+ fs_1.default.chmodSync(tokenFile, 0o600);
89
92
  }
93
+ const LOCK_FILE = path_1.default.join(os_1.default.homedir(), '.relay', '.token.lock');
94
+ const LOCK_TIMEOUT = 15000; // 15s
90
95
  /**
91
- * 유효한 access_token을 반환한다.
92
- * 1. 저장된 토큰이 없으면 undefined
93
- * 2. expires_at이 아직 유효하면 access_token 반환
94
- * 3. 만료되었으면 refresh_token으로 갱신 시도
95
- * 4. 갱신 실패 시 undefined (재로그인 필요)
96
+ * 파일 기반 lock — 여러 CLI 프로세스가 동시에 refresh하는 것을 방지.
97
+ * Supabase refresh token rotation으로 인해 동시 refresh가 치명적.
96
98
  */
97
- async function getValidToken() {
98
- const data = loadTokenData();
99
- if (!data)
100
- return undefined;
101
- // expires_at이 없거나 아직 유효하면 (30초 여유) 그대로 사용
102
- if (!data.expires_at || data.expires_at > Date.now() / 1000 + 30) {
103
- return data.access_token;
99
+ function acquireLock() {
100
+ try {
101
+ // O_EXCL: 파일이 이미 존재하면 실패 (atomic)
102
+ const fd = fs_1.default.openSync(LOCK_FILE, fs_1.default.constants.O_CREAT | fs_1.default.constants.O_EXCL | fs_1.default.constants.O_WRONLY);
103
+ fs_1.default.writeSync(fd, String(Date.now()));
104
+ fs_1.default.closeSync(fd);
105
+ return true;
104
106
  }
105
- // 만료됨 — refresh 시도
106
- if (!data.refresh_token)
107
- return undefined;
107
+ catch {
108
+ // lock 파일이 이미 있음 — stale check
109
+ try {
110
+ const content = fs_1.default.readFileSync(LOCK_FILE, 'utf-8');
111
+ const lockTime = Number(content);
112
+ if (Date.now() - lockTime > LOCK_TIMEOUT) {
113
+ // stale lock — 제거 후 재시도
114
+ fs_1.default.unlinkSync(LOCK_FILE);
115
+ return acquireLock();
116
+ }
117
+ }
118
+ catch { /* ignore */ }
119
+ return false;
120
+ }
121
+ }
122
+ function releaseLock() {
123
+ try {
124
+ fs_1.default.unlinkSync(LOCK_FILE);
125
+ }
126
+ catch { /* ignore */ }
127
+ }
128
+ async function doRefresh(refreshToken) {
108
129
  try {
109
130
  const res = await fetch(`${exports.API_URL}/api/auth/refresh`, {
110
131
  method: 'POST',
111
132
  headers: { 'Content-Type': 'application/json' },
112
- body: JSON.stringify({ refresh_token: data.refresh_token }),
133
+ body: JSON.stringify({ refresh_token: refreshToken }),
134
+ signal: AbortSignal.timeout(10000),
113
135
  });
114
136
  if (!res.ok)
115
- return undefined;
116
- const refreshed = (await res.json());
117
- saveTokenData(refreshed);
118
- return refreshed.access_token;
137
+ return null;
138
+ return (await res.json());
119
139
  }
120
140
  catch {
141
+ return null;
142
+ }
143
+ }
144
+ /**
145
+ * 유효한 access_token을 반환한다.
146
+ *
147
+ * Supabase는 refresh token rotation을 사용하므로:
148
+ * - refresh 시 이전 refresh_token이 무효화됨
149
+ * - 병렬 CLI 호출에서 동시 refresh 방지 필요 (lock)
150
+ * - refresh 성공 시 새 토큰을 즉시 파일에 저장
151
+ *
152
+ * 타이밍:
153
+ * - 만료 10분 전부터 proactive refresh
154
+ * - refresh 실패해도 access_token이 아직 유효하면 계속 사용
155
+ */
156
+ async function getValidToken() {
157
+ // 매번 파일에서 새로 읽음 (다른 프로세스가 갱신했을 수 있으므로)
158
+ const data = loadTokenData();
159
+ if (!data)
121
160
  return undefined;
161
+ const now = Date.now() / 1000;
162
+ // expires_at이 없으면(레거시) → 유효하다고 간주
163
+ if (!data.expires_at)
164
+ return data.access_token;
165
+ // 10분 이상 남았으면 → 그대로 사용 (refresh 불필요)
166
+ if (data.expires_at > now + 600) {
167
+ return data.access_token;
168
+ }
169
+ // refresh_token 없으면 만료 전까지만 사용
170
+ if (!data.refresh_token) {
171
+ return data.expires_at > now ? data.access_token : undefined;
172
+ }
173
+ // Refresh 시도 — lock으로 프로세스 간 동시 refresh 방지
174
+ if (acquireLock()) {
175
+ try {
176
+ const refreshed = await doRefresh(data.refresh_token);
177
+ if (refreshed) {
178
+ saveTokenData(refreshed);
179
+ return refreshed.access_token;
180
+ }
181
+ }
182
+ finally {
183
+ releaseLock();
184
+ }
185
+ }
186
+ else {
187
+ // 다른 프로세스가 refresh 중 — 잠시 후 파일에서 다시 읽기
188
+ await new Promise((r) => setTimeout(r, 2000));
189
+ const retryData = loadTokenData();
190
+ if (retryData?.expires_at && retryData.expires_at > now + 30) {
191
+ return retryData.access_token;
192
+ }
122
193
  }
194
+ // access_token이 아직 유효하면 사용
195
+ return data.expires_at > now ? data.access_token : undefined;
123
196
  }
124
197
  /**
125
- * `@spaces/{spaceSlug}/{teamSlug}` 레거시 키를 `@{spaceSlug}/{teamSlug}`로 정규화한다.
126
- * installed.json 로드 자동 적용되어 모든 소비자가 일관된 키를 사용한다.
198
+ * 레거시 정규화:
199
+ * - `@spaces/{slug}/{team}` `@{slug}/{team}` (Space 레거시)
200
+ * - `space_slug` → `org_slug` (필드명 마이그레이션)
127
201
  */
128
202
  function normalizeInstalledRegistry(raw) {
129
203
  const normalized = {};
130
204
  for (const [key, value] of Object.entries(raw)) {
205
+ // @spaces/ 레거시 키 정규화
131
206
  const m = key.match(/^@spaces\/([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/);
132
207
  const normalizedKey = m ? `@${m[1]}/${m[2]}` : key;
133
- normalized[normalizedKey] = value;
208
+ // space_slug → org_slug 필드 마이그레이션
209
+ const entry = { ...value };
210
+ if ('space_slug' in entry) {
211
+ const spaceSlugs = entry;
212
+ entry.org_slug = spaceSlugs.space_slug;
213
+ delete spaceSlugs.space_slug;
214
+ }
215
+ normalized[normalizedKey] = entry;
134
216
  }
135
217
  return normalized;
136
218
  }
@@ -17,11 +17,10 @@ const PREAMBLE_END = '<!-- RELAY_PREAMBLE_END -->';
17
17
  * relay CLI가 있으면 사용, 없으면 curl fallback.
18
18
  */
19
19
  function generatePreambleScript(slug, apiUrl) {
20
- // slug format: @space/team → space=space, team=team
20
+ // slug format: @owner/team → extract team slug for ping
21
21
  const stripped = slug.startsWith('@') ? slug.slice(1) : slug;
22
22
  const slashIdx = stripped.indexOf('/');
23
- const spacePart = slashIdx !== -1 ? stripped.slice(0, slashIdx) : stripped;
24
- const teamPart = slashIdx !== -1 ? stripped.slice(slashIdx + 1) : stripped;
23
+ const teamSlug = slashIdx !== -1 ? stripped.slice(slashIdx + 1) : stripped;
25
24
  return `#!/usr/bin/env bash
26
25
  # relay-preamble.sh — auto-generated by relay publish
27
26
  set +e
@@ -32,7 +31,7 @@ DEVICE_HASH=$(echo "$HOSTNAME:$USER" | shasum -a 256 | cut -d' ' -f1)
32
31
  if command -v relay &>/dev/null; then
33
32
  relay ping "${slug}" --quiet 2>/dev/null &
34
33
  else
35
- curl -sf -X POST "${apiUrl}/api/spaces/${spacePart}/teams/${teamPart}/ping" \\
34
+ curl -sf -X POST "${apiUrl}/api/teams/${teamSlug}/ping" \\
36
35
  -H "Content-Type: application/json" \\
37
36
  -d "{\\"device_hash\\":\\"$DEVICE_HASH\\"}" \\
38
37
  2>/dev/null &
@@ -15,6 +15,5 @@ export declare function isSimpleSlug(input: string): boolean;
15
15
  /**
16
16
  * Scoped 또는 단순 slug를 받아 ParsedSlug를 반환한다.
17
17
  * 단순 slug는 서버에 resolve를 요청한다.
18
- * `@spaces/{spaceSlug}/{teamSlug}` 형식은 `@{spaceSlug}/{teamSlug}`로 정규화된다.
19
18
  */
20
19
  export declare function resolveSlug(input: string): Promise<ParsedSlug>;
package/dist/lib/slug.js CHANGED
@@ -28,22 +28,18 @@ function isSimpleSlug(input) {
28
28
  /**
29
29
  * Scoped 또는 단순 slug를 받아 ParsedSlug를 반환한다.
30
30
  * 단순 slug는 서버에 resolve를 요청한다.
31
- * `@spaces/{spaceSlug}/{teamSlug}` 형식은 `@{spaceSlug}/{teamSlug}`로 정규화된다.
32
31
  */
33
32
  async function resolveSlug(input) {
34
- // @spaces/{spaceSlug}/{teamSlug} → @{spaceSlug}/{teamSlug} 정규화
35
- const spacesPrefix = input.match(/^@spaces\/([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/);
36
- const normalized = spacesPrefix ? `@${spacesPrefix[1]}/${spacesPrefix[2]}` : input;
37
33
  // scoped slug면 바로 파싱
38
- const parsed = parseSlug(normalized);
34
+ const parsed = parseSlug(input);
39
35
  if (parsed)
40
36
  return parsed;
41
37
  // 단순 slug인지 검증
42
- if (!isSimpleSlug(normalized)) {
38
+ if (!isSimpleSlug(input)) {
43
39
  throw new Error(`잘못된 slug 형식입니다: '${input}'. @owner/name 또는 name 형태로 입력하세요.`);
44
40
  }
45
41
  // 서버에 resolve 요청
46
- const results = await (0, api_js_1.resolveSlugFromServer)(normalized);
42
+ const results = await (0, api_js_1.resolveSlugFromServer)(input);
47
43
  if (results.length === 0) {
48
44
  throw new Error(`'${input}' 팀을 찾을 수 없습니다.`);
49
45
  }
package/dist/types.d.ts CHANGED
@@ -10,8 +10,8 @@ export interface InstalledTeam {
10
10
  installed_at: string;
11
11
  files: string[];
12
12
  type?: 'team' | 'system';
13
- /** Space 소속 팀인 경우 Space slug */
14
- space_slug?: string;
13
+ /** Org 소속 팀인 경우 Org slug */
14
+ org_slug?: string;
15
15
  /** 배치 범위 — 에이전트가 relay deploy-record로 기록 */
16
16
  deploy_scope?: 'global' | 'local';
17
17
  /** 배치된 파일 절대경로 목록 — 에이전트가 relay deploy-record로 기록 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relayax-cli",
3
- "version": "0.2.41",
3
+ "version": "0.3.41",
4
4
  "description": "RelayAX Agent Team Marketplace CLI - Install and manage agent teams",
5
5
  "main": "dist/index.js",
6
6
  "bin": {