z2o-cli 0.7.0 → 0.8.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/index.js CHANGED
@@ -15,9 +15,13 @@
15
15
  */
16
16
 
17
17
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, cpSync, rmSync } from 'node:fs';
18
- import { homedir } from 'node:os';
18
+ import { homedir, hostname } from 'node:os';
19
19
  import { join, dirname } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { createServer } from 'node:http';
22
+ import { createHash, randomBytes } from 'node:crypto';
23
+ import { spawn } from 'node:child_process';
24
+ import { createInterface } from 'node:readline/promises';
21
25
 
22
26
  const DEFAULT_API = 'https://z2o.kr/_/backend';
23
27
  // 발급되는 토큰의 모양. `z2o_live_` + base64url.
@@ -81,7 +85,7 @@ async function rpc(method, params, { token: override } = {}) {
81
85
  // `login` 은 **아직 저장하지 않은** 토큰으로 확인해야 한다 — 그래서 넘겨받는 길을 둔다.
82
86
  const token = override || saved;
83
87
  if (!token) {
84
- die('연결된 토큰이 없어요.\n z2o.kr 오른쪽 프로필 「연결 토큰」에서 발급한 뒤 `z2o init <토큰>` 하세요.');
88
+ die('아직 연결 안 됐어요.\n `z2o login` 하면 브라우저에서 「허용」 번으로 연결됩니다.');
85
89
  }
86
90
 
87
91
  if (token && !TOKEN_RE.test(token)) {
@@ -102,7 +106,7 @@ async function rpc(method, params, { token: override } = {}) {
102
106
  die(`서버에 닿지 못했어요 (${api}).\n ${e.message}`);
103
107
  }
104
108
 
105
- if (res.status === 401) die('토큰이 유효하지 않거나 끊겼어요. `z2o login <토큰>` 으로 다시 연결해 주세요.');
109
+ if (res.status === 401) die('연결이 끊겼어요(토큰이 끊겼거나 틀림). `z2o login` 으로 다시 연결해 주세요.');
106
110
  if (res.status === 403) {
107
111
  // 읽기 전용 토큰으로 쓰기를 시도한 경우. 서버 문구를 그대로 전한다.
108
112
  let detail = '';
@@ -131,6 +135,119 @@ async function callTool(name, args, opts) {
131
135
  return text;
132
136
  }
133
137
 
138
+ // ── 브라우저로 붙이기 ────────────────────────────────────
139
+ //
140
+ // 🔑 **사람이 토큰 문자열을 볼 일이 없게 한다.** 터미널에 붙여 넣는 것은 스푸핑·탈취
141
+ // 위험이 있고(2026-09-10 데일리), 비개발자에게는 그 자체가 벽이다. `gh auth login` 과
142
+ // 같은 모양 — 브라우저에서 「허용」을 누르면 여기로 돌아온다.
143
+ //
144
+ // 🔑 **PKCE.** 비밀값은 이 프로세스 밖으로 안 나간다. 브라우저를 오가는 것은 그 해시와
145
+ // 5분짜리 코드뿐이고, 코드는 비밀값 없이는 토큰으로 못 바꾼다.
146
+
147
+ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
148
+
149
+ /** 백엔드 주소에서 웹 주소를 얻는다. 운영은 같은 도메인 뒤에 `/_/backend` 가 붙는다. */
150
+ function webBase(api) {
151
+ if (process.env.Z2O_WEB) return process.env.Z2O_WEB.replace(/\/+$/, '');
152
+ if (/^https?:\/\/(localhost|127\.0\.0\.1):8000$/.test(api)) return api.replace(':8000', ':3000');
153
+ return api.replace(/\/_\/backend$/, '');
154
+ }
155
+
156
+ function openBrowser(url) {
157
+ const [cmd, args] =
158
+ process.platform === 'darwin' ? ['open', [url]]
159
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
160
+ : ['xdg-open', [url]];
161
+ try {
162
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
163
+ p.on('error', () => { /* 못 열어도 주소를 찍어 두었으니 사람이 연다 */ });
164
+ p.unref();
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ const DONE_HTML = (msg) => `<!doctype html><meta charset="utf-8"><title>제로투원</title>
172
+ <body style="font-family:-apple-system,system-ui,sans-serif;background:#f6f6f2;display:grid;place-items:center;height:100vh;margin:0">
173
+ <div style="text-align:center;color:#34322e"><div style="font-size:18px;font-weight:600">${msg}</div>
174
+ <div style="margin-top:8px;font-size:13px;opacity:.55">이 창은 닫아도 돼요.</div></div></body>`;
175
+
176
+ /** 브라우저에서 허용받아 토큰을 저장한다. 워크스페이스를 매었으면 그것도 정해 둔다. */
177
+ async function browserLogin({ paste = false } = {}) {
178
+ const { api } = settings();
179
+ const web = webBase(api);
180
+ const verifier = b64url(randomBytes(32));
181
+ const challenge = b64url(createHash('sha256').update(verifier).digest());
182
+ const state = b64url(randomBytes(16));
183
+ const q = new URLSearchParams({ cc: challenge, state, name: `${hostname().replace(/\.local$/, '')} · z2o` });
184
+
185
+ let code;
186
+ if (paste) {
187
+ // 브라우저가 다른 기기에 있다(원격 서버·SSH). 여기로 돌아올 길이 없으니 코드를 옮겨 적는다.
188
+ q.set('mode', 'paste');
189
+ console.log(`이 주소를 브라우저에서 열고 「허용」을 누르세요:\n\n ${web}/cli-login?${q}\n`);
190
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
191
+ code = (await rl.question('화면에 뜬 연결 코드를 붙여 넣으세요: ')).trim();
192
+ rl.close();
193
+ } else {
194
+ code = await new Promise((resolve, reject) => {
195
+ // 🔑 **내 컴퓨터에서만 받는다**(127.0.0.1). 포트는 비어 있는 아무 곳 — 고정하면 겹친다.
196
+ const server = createServer((req, res) => {
197
+ const u = new URL(req.url || '/', 'http://127.0.0.1');
198
+ if (u.pathname !== '/callback') {
199
+ res.writeHead(404).end();
200
+ return;
201
+ }
202
+ // ⚠️ `state` 가 다르면 이 창에서 시작한 요청이 아니다 — 받지 않는다.
203
+ const got = u.searchParams.get('state') === state ? u.searchParams.get('code') : null;
204
+ res.writeHead(got ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
205
+ res.end(DONE_HTML(got ? '연결됐어요. 터미널로 돌아가세요.' : '요청이 맞지 않아요. 터미널에서 z2o login 을 다시 해 주세요.'));
206
+ if (!got) return;
207
+ clearTimeout(timer);
208
+ server.close();
209
+ resolve(got);
210
+ });
211
+ const timer = setTimeout(() => {
212
+ server.close();
213
+ reject(new Error('5분 안에 허용하지 않아 멈췄어요. `z2o login` 을 다시 해 주세요.'));
214
+ }, 5 * 60 * 1000);
215
+ server.on('error', reject);
216
+ server.listen(0, '127.0.0.1', () => {
217
+ q.set('port', String(server.address().port));
218
+ const url = `${web}/cli-login?${q}`;
219
+ console.log(openBrowser(url)
220
+ ? '브라우저에서 제로투원을 열었어요. 「허용」을 누르면 여기로 돌아옵니다.'
221
+ : '브라우저를 열지 못했어요.');
222
+ console.log(`\n안 열리면 이 주소를 직접 여세요:\n ${url}\n`);
223
+ console.log('(브라우저가 다른 기기에 있으면 Ctrl+C 로 멈추고 `z2o login --paste`)\n');
224
+ });
225
+ });
226
+ }
227
+ if (!code) die('연결 코드가 비었어요.');
228
+
229
+ let res;
230
+ try {
231
+ res = await fetch(`${api}/api/tokens/cli/exchange`, {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({ code, code_verifier: verifier }),
235
+ });
236
+ } catch (e) {
237
+ die(`서버에 닿지 못했어요 (${api}).\n ${e.message}`);
238
+ }
239
+ const body = await res.json().catch(() => ({}));
240
+ if (!res.ok || !body.token) die(body.detail || `서버가 ${res.status} 로 답했어요.`);
241
+
242
+ // 워크스페이스에 매인 토큰이면 그걸 기본으로 — `z2o use` 를 따로 안 해도 된다.
243
+ saveConf({ ...loadConf(), token: body.token, ...(body.company_id ? { companyId: body.company_id } : {}) });
244
+ console.log(
245
+ `연결했어요 — ${body.company_name || '내가 볼 수 있는 워크스페이스 전부'} · ` +
246
+ `${body.scope === 'write' ? '자료·정리까지' : '읽기만'}`,
247
+ );
248
+ return body;
249
+ }
250
+
134
251
  /** 회사 id 를 정한다. `-c` > 환경변수 > 설정 파일 순. */
135
252
  function needCompany(flags) {
136
253
  const id = flags.c || settings().companyId;
@@ -245,6 +362,7 @@ function parseArgs(argv) {
245
362
  const a = argv[i];
246
363
  if (a === '-c' || a === '--company') flags.c = argv[++i];
247
364
  else if (a === '-g' || a === '--global') flags.global = true;
365
+ else if (a === '--paste') flags.paste = true;
248
366
  else if (a === '-h' || a === '--help') flags.help = true;
249
367
  else rest.push(a);
250
368
  }
@@ -253,9 +371,11 @@ function parseArgs(argv) {
253
371
 
254
372
  const HELP = `z2o — 회사 온톨로지를 내가 쓰는 AI에서 꺼내 쓴다
255
373
 
256
- z2o init [토큰] 한 번에 붙이기 — 로그인 + 워크스페이스 + AI 규칙파일
374
+ z2o init 한 번에 붙이기 — 브라우저 로그인 + 워크스페이스 + AI 규칙파일
257
375
  z2o init -g 규칙을 **모든 폴더**에 (~/.claude 등). 폴더마다 안 해도 됨
258
- z2o login <토큰> 토큰만 저장
376
+ z2o login 브라우저에서 「허용」 한 번으로 연결 (토큰을 볼 일이 없다)
377
+ z2o login --paste 브라우저가 다른 기기에 있을 때 — 화면의 코드를 붙여 넣는다
378
+ z2o login <토큰> 발급받은 토큰을 직접 넣기
259
379
  z2o logout 저장한 토큰 지우기
260
380
  z2o whoami 지금 어떻게 붙어 있나
261
381
 
@@ -284,14 +404,23 @@ const HELP = `z2o — 회사 온톨로지를 내가 쓰는 AI에서 꺼내 쓴
284
404
 
285
405
  -c, --company <id> 이번만 다른 워크스페이스로
286
406
  -g, --global init 을 모든 폴더에 적용
407
+ --paste login·init 을 코드 붙여넣기로
287
408
 
288
409
  환경 변수: Z2O_API · Z2O_TOKEN · Z2O_COMPANY (설정 파일보다 우선)`;
289
410
 
290
411
  // ── 명령 ────────────────────────────────────────────────
291
412
 
292
413
  const COMMANDS = {
293
- async login([token]) {
294
- if (!token) die('토큰을 함께 적어 주세요.\n z2o login z2o_live_...');
414
+ async login([token], flags = {}) {
415
+ // 토큰을 안 주면 브라우저로 — 이게 기본 길이다. 토큰을 직접 넣는 길은 남겨 둔다(CI·스크립트).
416
+ if (!token) {
417
+ await browserLogin({ paste: flags.paste });
418
+ if (!settings().companyId) {
419
+ console.log('\n' + (await callTool('list_companies', {})));
420
+ console.log('\n`z2o use <id 또는 이름>` 으로 기본 워크스페이스를 정해 두세요.');
421
+ }
422
+ return;
423
+ }
295
424
  if (!TOKEN_RE.test(token)) {
296
425
  die('토큰 모양이 아니에요. `z2o_live_` 로 시작하는 한 줄이어야 합니다.');
297
426
  }
@@ -314,15 +443,12 @@ const COMMANDS = {
314
443
  const [token] = args;
315
444
  const { token: saved } = settings();
316
445
 
317
- // ① 로그인 — 토큰을 줬으면 그걸로, 이미 붙어 있으면 그대로.
446
+ // ① 로그인 — 토큰을 줬으면 그걸로, 없으면 브라우저로, 이미 붙어 있으면 그대로.
318
447
  if (token) {
319
448
  await COMMANDS.login([token]);
320
449
  } else if (!saved) {
321
- die(
322
- '먼저 열쇠가 필요해요.\n' +
323
- ' 1. https://z2o.kr 로그인 → 오른쪽 위 프로필 → 「연결 토큰」 → 발급\n' +
324
- ' 2. z2o init <발급받은 열쇠>',
325
- );
450
+ await browserLogin({ paste: flags.paste });
451
+ console.log('');
326
452
  } else {
327
453
  console.log('이미 연결돼 있어요.\n');
328
454
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "z2o-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "제로투원 — 회사 온톨로지와 슬랙 맥락을 내가 쓰는 AI에서 꺼내 쓴다",
5
5
  "keywords": [
6
6
  "z2o",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "z2o",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "description": "제로투원 — 회사 위키·슬랙·회의록을 읽고(/z2o:read) 위키에 정리해 넣는다(/z2o:ingest). z2o CLI 를 감싼다.",
6
6
  "author": {
7
7
  "name": "Lindy Operation"