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 +139 -13
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
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('
|
|
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('토큰이
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "z2o",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "제로투원 — 회사 위키·슬랙·회의록을 읽고(/z2o:read) 위키에 정리해 넣는다(/z2o:ingest). z2o CLI 를 감싼다.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Lindy Operation"
|