democut 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/cli.js +916 -0
  4. package/package.json +48 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 InfoGrab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # democut
2
+
3
+ DemoCut(democut) 에이전트 인증 CLI — OAuth2 **Device Flow**(RFC 8628). Higgsfield 의
4
+ `auth login` 과 같은 방식으로, 에이전트가 한 번 로그인하면 워크스페이스 스코프 토큰을 받아
5
+ 이후 호출에 Bearer 가 자동으로 붙는다.
6
+
7
+ ## 설치
8
+
9
+ ```bash
10
+ npm install -g democut # 또는: npx democut auth login
11
+ ```
12
+
13
+ ## 사용
14
+
15
+ ```bash
16
+ democut auth login # device flow 로 로그인 → 토큰 저장
17
+ democut auth status # 현재 정체/워크스페이스/티어
18
+ democut auth logout
19
+
20
+ # 헤드리스(서버/CI/에이전트): 브라우저 안 열고 코드+URL 만 출력
21
+ democut auth login --no-browser
22
+ # 기계 판독(래퍼가 코드를 Slack 등에 게시):
23
+ democut auth login --json
24
+ ```
25
+
26
+ `--base-url <url>` 또는 `DEMOCUT_BASE_URL` 로 서버를 지정한다(기본: prod).
27
+
28
+ ## 동작
29
+
30
+ 1. `auth login` 이 서버에 device authorization 요청 → `user_code` + 승인 URL 수신.
31
+ 2. 데스크톱이면 브라우저 자동 오픈, 헤드리스면 코드+URL 출력 → **사람이 브라우저에서
32
+ 로그인하고 워크스페이스(조직)를 선택해 승인**.
33
+ 3. CLI 가 토큰 엔드포인트를 폴링 → 승인되면 `access`/`refresh` 토큰을 `~/.democut/credentials.json`
34
+ (권한 600)에 저장.
35
+ 4. 이후 `access`(1h)는 만료 시 `refresh`(장수명)로 자동 갱신.
36
+
37
+ 토큰은 **승인한 사람의 워크스페이스와 플랜(tier/role)** 으로 스코프된다 — 워크스페이스마다
38
+ 각자의 에이전트를 붙일 수 있는 멀티테넌트 구조.
39
+
40
+ ## 영상 생성
41
+
42
+ ```bash
43
+ democut video estimate [--duration <초>] [--resolution <해상도>]
44
+ democut video generate "<프롬프트>" [--yes] [--wait] [--output <경로>]
45
+ democut video status <job_id> [--wait] [--output <경로>]
46
+ ```
47
+
48
+ ### 사용 흐름
49
+
50
+ 1. **비용 확인**: `democut video estimate` 로 제출 없이 예상 비용만 본다.
51
+ ```bash
52
+ democut video estimate --duration 10 --resolution 1080p
53
+ # 출력: 예상 비용: $2.21 (514,080 tokens · dreamina-seedance-2-0-260128)
54
+ ```
55
+
56
+ 2. **영상 생성**: `democut video generate` 로 제출. **비용 승인이 필요하다.**
57
+ - **대화형 환경** (데스크톱·터미널): 확인 프롬프트가 뜬다.
58
+ - **비대화형** (서버·CI·에이전트): `--yes` 플래그가 필수 — 없으면 거부.
59
+ ```bash
60
+ # 대화형 (확인 프롬프트 있음)
61
+ democut video generate "고양이가 춤춘다"
62
+ # 예상 비용: $0.49 (113,400 tokens · dreamina-seedance-2-0-260128)
63
+ # $0.49 가 청구됩니다. 생성할까요? [y/N]
64
+
65
+ # 비대화형 (MCP/에이전트)
66
+ democut video generate "고양이가 춤춘다" --yes
67
+ ```
68
+
69
+ 3. **완료 대기 & 다운로드**:
70
+ ```bash
71
+ # 상태 조회만
72
+ democut video status <job_id>
73
+
74
+ # 완료까지 기다리고 저장 (24시간 만료 URL)
75
+ democut video status <job_id> --wait --output video.mp4
76
+ ```
77
+
78
+ ### 주의사항
79
+
80
+ - **생성이 성공하면 과금된다** — 서버는 성공을 처음 관측한 시점에 원장에 기록한다(폴링을 반복해도 한 번).
81
+ 실패한 생성은 원장에 남지 않지만, 제출이 업스트림에 접수된 뒤의 재시도는 별개 생성이라 다시 과금된다.
82
+ - **산출물 URL 은 24시간 후 만료된다** — `--output` 으로 즉시 받아두지 않으면 재생성해야 한다(재과금).
83
+ - **CLI 는 제출을 자동 재시도하지 않는다** — BytePlus 가 멱등키(Idempotency-Key)를 지원하지 않아 재시도가 곧 재과금이다.
84
+ 실패해 보여도 먼저 `democut video status <job_id>` 로 확인할 것.
85
+ - **`--wait` 시한 초과는 실패가 아니다** — 작업은 서버에서 계속 진행된다(종료 코드 5). 새로 제출하지 말고 `status` 로 확인한다.
86
+
87
+ ### 종료 코드
88
+
89
+ | 코드 | 뜻 | 대응 |
90
+ |---|---|---|
91
+ | 0 | 성공 | — |
92
+ | 1 | 일반 오류 | 미로그인·사용자 취소·생성 실패·알 수 없음 |
93
+ | 2 | 파라미터 오류 (재시도 가능) | 해상도/길이 잘못 됨·예산 초과·`--yes` 누락. **과금 없음.** |
94
+ | 3 | 일시적 업스트림 오류 | **그대로 재시도해도 안전하다.** |
95
+ | 4 | 업스트림 거부 (결정적) | **재시도하면 재과금될 수 있다 — 이 코드가 뜨면 상태 조회 후 사람에게 문의.** |
96
+ | 5 | `--wait` 시한 초과 | **실패가 아니다.** 작업은 계속 진행 중 — `status <job_id>` 로 확인. 재제출 금지. |
97
+
98
+ ### 옵션
99
+
100
+ | 옵션 | 설명 |
101
+ |---|---|
102
+ | `--duration <초>` | 모델별 허용 범위(2.0 계열 4~15, 2.5 계열 4~30). 기본 5초 |
103
+ | `--resolution <r>` | 480p / 720p(기본) / 1080p / 4k |
104
+ | `--ratio <비율>` | 16:9(기본) / 9:16 / 1:1 / 4:3 / 21:9 / adaptive |
105
+ | `--yes` / `-y` | 비대화형에서 필수. 비용 확인 프롬프트 스킵 |
106
+ | `--wait` | 완료까지 대기. `--timeout <초>` 로 조정 (기본 900초 = 15분) |
107
+ | `-o`, `--output <경로>` | 생성된 mp4 저장. 서명 URL 이 24시간 후 만료되므로 사실상 필수 |
108
+ | `--json` | 기계 판독용 JSON 한 줄 |
109
+ | `--base-url <url>` | 서버 지정 (기본: prod) |
110
+
111
+ ## 개발
112
+
113
+ ```bash
114
+ npm install
115
+ npm run typecheck
116
+ npm test
117
+ npm run build # dist/cli.js (bin: democut) 번들
118
+ ```
119
+
120
+ ## 게시 (npm publish)
121
+
122
+ **아직 게시되지 않았다** — `npm install -g democut` 은 현재 404 다.
123
+ 게시는 사람이 한다(에이전트 범위 밖).
124
+
125
+ > ⚠️ **게시가 문서 승격보다 먼저다.** `democut` 은 **무스코프**라 우리가 게시하기 전까지
126
+ > **누구나 그 이름으로 올릴 수 있다**(스코프 패키지와 달리 네임스페이스 보호가 없다).
127
+ > `/docs/agents` 의 복사 버튼과 `/updates` 공지가 먼저 나가면, 그 사이 제3자가 악성
128
+ > `democut` 을 선점해 사용자가 그것을 설치하는 창이 열린다 —
129
+ > `democut auth login` 이 워크스페이스 토큰을 다루므로 토큰 탈취로 이어질 수 있다
130
+ > (CWE-829, faceta 리뷰 2026-08-21).
131
+ >
132
+ > 따라서 **머지 → npm publish → 승격(promote)** 순서를 지킨다. 승격 전에 확인:
133
+ > ```bash
134
+ > curl -s -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org/democut # 200 이어야 한다
135
+ > ```
136
+
137
+ 순서:
138
+
139
+ 1. **서버 라우터가 프로덕션에 있어야 한다.** `democut video` 는 `/api/videogen` 을 호출하고
140
+ 기본 base URL 이 프로덕션(`https://democut.ai`)이다. 그 라우터가 없으면 게시해도 모든
141
+ video 커맨드가 404 다. 확인:
142
+ ```bash
143
+ curl -s -o /dev/null -w '%{http_code}\n' -X POST https://democut.ai/api/videogen/estimate
144
+ # 401 이면 배포됨(인증 필요) · 404 면 미승격
145
+ ```
146
+ 2. **org 는 필요 없다.** 이 패키지는 **무스코프**(`democut`)라 개인 계정으로 바로 게시되고,
147
+ 무스코프는 기본이 public 이라 `--access public` 도 필요 없다.
148
+
149
+ > `@democut` **스코프**는 쓸 수 없다 — 그 이름은 이미 npm 계정 하나가 점유하고 있고
150
+ > (username 과 org 는 같은 네임스페이스다) 그래서 `democut` org 를 만들 수 없다.
151
+ > **패키지 이름은 그와 별개 네임스페이스**라 무스코프 `democut` 은 그대로 쓸 수 있다.
152
+ > 향후 `@democut/mcp` 같은 스코프 패키지가 필요해지면 그 계정을 되찾아야 한다
153
+ > (이메일을 못 받는 미인증 계정이면 로그인 후 계정 설정에서 이메일을 교체하거나,
154
+ > 비밀번호를 모르면 npm Support 로 문의).
155
+
156
+ 3. `npm login` 후:
157
+ ```bash
158
+ cd cli
159
+ npm run build # prepublishOnly 가 자동으로도 돌지만 먼저 확인
160
+ npm publish # 무스코프는 기본 public — --access 불필요
161
+ ```
162
+
163
+ > **토큰을 코드나 대화에 넣지 않는다.** `npm login` 은 브라우저 인증이고 토큰은 `~/.npmrc`
164
+ > 에만 남는다. CI 에서 자동 게시가 필요해지면 npm **Automation token**(publish 전용,
165
+ > 이 패키지로 스코프 제한)을 발급해 **GitLab CI/CD 변수**(masked + protected)에 두고
166
+ > `NODE_AUTH_TOKEN` 으로 주입한다 — 이 레포가 배포 시크릿에 쓰는 방식과 같다.
167
+
168
+ 게시되는 것은 `files` 에 적힌 **`dist/` 뿐**이다(소스·테스트는 포함되지 않는다).
169
+ `npm pack --dry-run` 으로 목록을 먼저 확인할 수 있다.
package/dist/cli.js ADDED
@@ -0,0 +1,916 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { realpathSync } from "fs";
5
+ import { fileURLToPath } from "url";
6
+
7
+ // src/device-flow.ts
8
+ var DeviceFlowError = class extends Error {
9
+ constructor(code, message) {
10
+ super(message ?? code);
11
+ this.code = code;
12
+ this.name = "DeviceFlowError";
13
+ }
14
+ code;
15
+ };
16
+ var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
17
+ var SLOW_DOWN_INCREMENT_MS = 5e3;
18
+ async function postJson(url, body, fetchImpl) {
19
+ const res = await fetchImpl(url, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify(body)
23
+ });
24
+ const text = await res.text();
25
+ let json = {};
26
+ if (text) {
27
+ try {
28
+ json = JSON.parse(text);
29
+ } catch {
30
+ json = {};
31
+ }
32
+ }
33
+ return { ok: res.ok, status: res.status, json };
34
+ }
35
+ function oauthError(json) {
36
+ const detail = json?.detail;
37
+ if (detail && typeof detail === "object" && typeof detail.error === "string") {
38
+ return detail.error;
39
+ }
40
+ return "invalid_grant";
41
+ }
42
+ async function requestDeviceAuthorization(baseUrl, fetchImpl = fetch) {
43
+ const { ok, status: status2, json } = await postJson(
44
+ `${baseUrl}/api/oauth/device/authorize`,
45
+ {},
46
+ fetchImpl
47
+ );
48
+ if (!ok) {
49
+ if (status2 === 503) {
50
+ throw new DeviceFlowError(
51
+ "disabled",
52
+ "\uC11C\uBC84\uC5D0\uC11C device flow \uAC00 \uBE44\uD65C\uC131\uD654\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4 (DEMOCUT_DEVICE_TOKEN_SECRET \uBBF8\uC124\uC815)."
53
+ );
54
+ }
55
+ throw new DeviceFlowError("authorize_failed", `device authorize \uC2E4\uD328 (HTTP ${status2}).`);
56
+ }
57
+ return json;
58
+ }
59
+ async function pollTokenOnce(baseUrl, deviceCode, fetchImpl = fetch) {
60
+ const { ok, json } = await postJson(
61
+ `${baseUrl}/api/oauth/token`,
62
+ { grant_type: DEVICE_GRANT, device_code: deviceCode },
63
+ fetchImpl
64
+ );
65
+ if (ok) return { status: "approved", tokens: json };
66
+ const err3 = oauthError(json);
67
+ if (err3 === "authorization_pending") return { status: "pending" };
68
+ if (err3 === "slow_down") return { status: "slow_down" };
69
+ throw new DeviceFlowError(err3, describeError(err3));
70
+ }
71
+ async function refreshToken(baseUrl, refresh, fetchImpl = fetch) {
72
+ const { ok, json } = await postJson(
73
+ `${baseUrl}/api/oauth/token`,
74
+ { grant_type: "refresh_token", refresh_token: refresh },
75
+ fetchImpl
76
+ );
77
+ if (!ok) throw new DeviceFlowError(oauthError(json), "\uD1A0\uD070 \uAC31\uC2E0\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4(\uC7AC\uB85C\uADF8\uC778 \uD544\uC694).");
78
+ return json;
79
+ }
80
+ async function pollUntilApproved(baseUrl, auth, deps = {}) {
81
+ const fetchImpl = deps.fetchImpl ?? fetch;
82
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
83
+ const now = deps.now ?? (() => Math.floor(Date.now() / 1e3));
84
+ let intervalMs = Math.max(1, auth.interval) * 1e3;
85
+ const deadline = now() + auth.expires_in;
86
+ while (now() < deadline) {
87
+ await sleep(intervalMs);
88
+ const out3 = await pollTokenOnce(baseUrl, auth.device_code, fetchImpl);
89
+ if (out3.status === "approved") return out3.tokens;
90
+ if (out3.status === "slow_down") intervalMs += SLOW_DOWN_INCREMENT_MS;
91
+ deps.onPending?.();
92
+ }
93
+ throw new DeviceFlowError("expired_token", describeError("expired_token"));
94
+ }
95
+ function describeError(code) {
96
+ switch (code) {
97
+ case "expired_token":
98
+ return "\uAE30\uAE30 \uCF54\uB4DC\uAC00 \uB9CC\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. democut auth login \uC744 \uB2E4\uC2DC \uC2E4\uD589\uD558\uC138\uC694.";
99
+ case "access_denied":
100
+ return "\uC2B9\uC778\uC774 \uAC70\uBD80\uB418\uC5C8\uC2B5\uB2C8\uB2E4.";
101
+ case "invalid_grant":
102
+ return "\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 \uCF54\uB4DC\uC785\uB2C8\uB2E4.";
103
+ default:
104
+ return code;
105
+ }
106
+ }
107
+
108
+ // src/token-store.ts
109
+ import {
110
+ chmodSync,
111
+ existsSync,
112
+ mkdirSync,
113
+ readFileSync,
114
+ renameSync,
115
+ rmSync,
116
+ writeFileSync
117
+ } from "fs";
118
+ import { randomBytes } from "crypto";
119
+
120
+ // src/config.ts
121
+ import { homedir } from "os";
122
+ import { join } from "path";
123
+ var DEFAULT_BASE_URL = "https://democut.ai";
124
+ function resolveBaseUrl(override) {
125
+ const raw = (override || process.env.DEMOCUT_BASE_URL || DEFAULT_BASE_URL).trim();
126
+ return raw.replace(/\/+$/, "");
127
+ }
128
+ function configDir() {
129
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
130
+ return xdg ? join(xdg, "democut") : join(homedir(), ".democut");
131
+ }
132
+ function credentialsPath() {
133
+ return join(configDir(), "credentials.json");
134
+ }
135
+
136
+ // src/token-store.ts
137
+ function paths(p) {
138
+ return { dir: p?.dir ?? configDir(), path: p?.path ?? credentialsPath() };
139
+ }
140
+ function readStore(path) {
141
+ if (!existsSync(path)) return {};
142
+ try {
143
+ const data = JSON.parse(readFileSync(path, "utf8"));
144
+ return data && typeof data === "object" ? data : {};
145
+ } catch {
146
+ return {};
147
+ }
148
+ }
149
+ function writeStore(store, dir, path) {
150
+ mkdirSync(dir, { recursive: true, mode: 448 });
151
+ const tmp = `${path}.${randomBytes(8).toString("hex")}.tmp`;
152
+ writeFileSync(tmp, `${JSON.stringify(store, null, 2)}
153
+ `, { flag: "wx", mode: 384 });
154
+ chmodSync(tmp, 384);
155
+ renameSync(tmp, path);
156
+ }
157
+ function loadCredentials(baseUrl, p) {
158
+ return readStore(paths(p).path)[baseUrl] ?? null;
159
+ }
160
+ function saveCredentials(creds, p) {
161
+ const { dir, path } = paths(p);
162
+ const store = readStore(path);
163
+ store[creds.base_url] = creds;
164
+ writeStore(store, dir, path);
165
+ }
166
+ function clearCredentials(baseUrl, p) {
167
+ const { dir, path } = paths(p);
168
+ const store = readStore(path);
169
+ if (!(baseUrl in store)) return;
170
+ delete store[baseUrl];
171
+ if (Object.keys(store).length === 0) {
172
+ if (existsSync(path)) rmSync(path);
173
+ } else {
174
+ writeStore(store, dir, path);
175
+ }
176
+ }
177
+
178
+ // src/auth.ts
179
+ var EXPIRY_SKEW_S = 60;
180
+ var NotLoggedInError = class extends Error {
181
+ };
182
+ async function getValidAccessToken(baseUrl, deps = {}) {
183
+ const now = deps.now ?? (() => Math.floor(Date.now() / 1e3));
184
+ const creds = loadCredentials(baseUrl, deps.paths);
185
+ if (!creds) {
186
+ throw new NotLoggedInError(
187
+ `\uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4 (${baseUrl}). 'democut auth login' \uC744 \uC2E4\uD589\uD558\uC138\uC694.`
188
+ );
189
+ }
190
+ if (creds.expires_at - EXPIRY_SKEW_S > now()) {
191
+ return creds.access_token;
192
+ }
193
+ const t = await refreshToken(baseUrl, creds.refresh_token, deps.fetchImpl);
194
+ const updated = {
195
+ base_url: baseUrl,
196
+ access_token: t.access_token,
197
+ refresh_token: t.refresh_token || creds.refresh_token,
198
+ workspace_id: creds.workspace_id,
199
+ expires_at: now() + t.expires_in
200
+ };
201
+ saveCredentials(updated, deps.paths);
202
+ return updated.access_token;
203
+ }
204
+ async function whoami(baseUrl, accessToken, fetchImpl = fetch) {
205
+ const res = await fetchImpl(`${baseUrl}/api/auth/whoami`, {
206
+ headers: { Authorization: `Bearer ${accessToken}` }
207
+ });
208
+ if (!res.ok) throw new Error(`whoami \uC2E4\uD328 (HTTP ${res.status}).`);
209
+ return await res.json();
210
+ }
211
+
212
+ // src/open-browser.ts
213
+ import { spawn } from "child_process";
214
+ function isHeadless() {
215
+ if (!process.stdout.isTTY) return true;
216
+ if (process.platform === "linux" && !process.env.DISPLAY) return true;
217
+ return false;
218
+ }
219
+ function openBrowser(url) {
220
+ try {
221
+ if (process.platform === "darwin") {
222
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
223
+ } else if (process.platform === "win32") {
224
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
225
+ } else {
226
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
227
+ }
228
+ } catch {
229
+ }
230
+ }
231
+
232
+ // src/commands/auth.ts
233
+ var out = (s) => process.stdout.write(`${s}
234
+ `);
235
+ var err = (s) => process.stderr.write(`${s}
236
+ `);
237
+ async function loginCommand(opts) {
238
+ const base = resolveBaseUrl(opts.baseUrl);
239
+ const auth = await requestDeviceAuthorization(base);
240
+ if (opts.json) {
241
+ out(
242
+ JSON.stringify({
243
+ user_code: auth.user_code,
244
+ verification_uri: auth.verification_uri,
245
+ verification_uri_complete: auth.verification_uri_complete,
246
+ expires_in: auth.expires_in
247
+ })
248
+ );
249
+ } else {
250
+ out("");
251
+ out(" \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uC544\uB798 \uC8FC\uC18C\uB97C \uC5F4\uACE0 \uCF54\uB4DC\uB97C \uC785\uB825\uD574 \uC2B9\uC778\uD558\uC138\uC694:");
252
+ out("");
253
+ out(` URL : ${auth.verification_uri}`);
254
+ out(` \uCF54\uB4DC: ${auth.user_code}`);
255
+ out("");
256
+ if (!opts.noBrowser && !isHeadless()) {
257
+ out(" (\uBE0C\uB77C\uC6B0\uC800\uB97C \uC790\uB3D9\uC73C\uB85C \uC5FD\uB2C8\uB2E4\u2026)");
258
+ openBrowser(auth.verification_uri_complete);
259
+ }
260
+ process.stderr.write(" \uC2B9\uC778 \uB300\uAE30 \uC911");
261
+ }
262
+ const tokens = await pollUntilApproved(base, auth, {
263
+ onPending: () => {
264
+ if (!opts.json) process.stderr.write(".");
265
+ }
266
+ });
267
+ const expiresAt = Math.floor(Date.now() / 1e3) + tokens.expires_in;
268
+ let workspaceId = null;
269
+ try {
270
+ workspaceId = (await whoami(base, tokens.access_token)).workspace_id;
271
+ } catch {
272
+ }
273
+ saveCredentials({
274
+ base_url: base,
275
+ access_token: tokens.access_token,
276
+ refresh_token: tokens.refresh_token,
277
+ workspace_id: workspaceId,
278
+ expires_at: expiresAt
279
+ });
280
+ if (opts.json) {
281
+ out(JSON.stringify({ status: "approved", workspace_id: workspaceId }));
282
+ } else {
283
+ err("");
284
+ out("");
285
+ out(`\u2713 \uB85C\uADF8\uC778\uB428${workspaceId ? ` (\uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4: ${workspaceId})` : ""}. \uC790\uACA9\uC99D\uBA85\uC744 \uC800\uC7A5\uD588\uC2B5\uB2C8\uB2E4.`);
286
+ }
287
+ return 0;
288
+ }
289
+ async function statusCommand(opts) {
290
+ const base = resolveBaseUrl(opts.baseUrl);
291
+ if (!loadCredentials(base)) {
292
+ out(`\uB85C\uADF8\uC544\uC6C3 \uC0C1\uD0DC\uC785\uB2C8\uB2E4 (${base}). 'democut auth login' \uC73C\uB85C \uB85C\uADF8\uC778\uD558\uC138\uC694.`);
293
+ return 1;
294
+ }
295
+ try {
296
+ const token = await getValidAccessToken(base);
297
+ const me = await whoami(base, token);
298
+ if (opts.json) {
299
+ out(JSON.stringify({ logged_in: true, base_url: base, ...me }));
300
+ } else {
301
+ out(`\uB85C\uADF8\uC778\uB428 (${base})`);
302
+ out(` \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4: ${me.workspace_id ?? "-"}`);
303
+ out(` \uC815\uCCB4: ${me.kind} \uC5ED\uD560: ${me.role ?? "-"} \uD2F0\uC5B4: ${me.tier ?? "-"}`);
304
+ }
305
+ return 0;
306
+ } catch (e) {
307
+ const msg = e instanceof NotLoggedInError ? e.message : `\uC790\uACA9\uC99D\uBA85 \uB9CC\uB8CC/\uBB34\uD6A8: ${e.message}`;
308
+ out(`${msg}
309
+ \uB2E4\uC2DC \uB85C\uADF8\uC778\uD558\uC138\uC694: 'democut auth login'`);
310
+ return 1;
311
+ }
312
+ }
313
+ function logoutCommand(opts) {
314
+ const base = resolveBaseUrl(opts.baseUrl);
315
+ clearCredentials(base);
316
+ out(`\uB85C\uADF8\uC544\uC6C3\uB428 (${base}).`);
317
+ return 0;
318
+ }
319
+
320
+ // src/api-client.ts
321
+ var ApiError = class extends Error {
322
+ constructor(status2, code, message, retriable, extra = {}) {
323
+ super(message);
324
+ this.status = status2;
325
+ this.code = code;
326
+ this.retriable = retriable;
327
+ this.extra = extra;
328
+ this.name = "ApiError";
329
+ }
330
+ status;
331
+ code;
332
+ retriable;
333
+ extra;
334
+ };
335
+ function parseDetail(status2, body) {
336
+ const detail = body?.detail;
337
+ if (detail && typeof detail === "object" && !Array.isArray(detail)) {
338
+ const d = detail;
339
+ if (typeof d.code === "string" || typeof d.message === "string") {
340
+ const { code, message, retriable, ...extra } = d;
341
+ return new ApiError(
342
+ status2,
343
+ typeof code === "string" ? code : `HTTP_${status2}`,
344
+ typeof message === "string" ? message : `HTTP ${status2}`,
345
+ typeof retriable === "boolean" ? retriable : null,
346
+ extra
347
+ );
348
+ }
349
+ }
350
+ if (typeof detail === "string") {
351
+ return new ApiError(status2, `HTTP_${status2}`, detail, null);
352
+ }
353
+ if (Array.isArray(detail)) {
354
+ const lines = detail.map((e) => {
355
+ const item = e;
356
+ const where = Array.isArray(item.loc) ? item.loc.filter((p) => p !== "body").join(".") : "";
357
+ const msg = typeof item.msg === "string" ? item.msg : JSON.stringify(e);
358
+ return where ? `${where}: ${msg}` : msg;
359
+ });
360
+ return new ApiError(status2, "VALIDATION_ERROR", lines.join("\n"), true);
361
+ }
362
+ return new ApiError(status2, `HTTP_${status2}`, `\uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4 (HTTP ${status2}).`, null);
363
+ }
364
+ async function apiRequest(baseUrl, path, init = {}, deps = {}) {
365
+ const fetchImpl = deps.fetchImpl ?? fetch;
366
+ const token = await getValidAccessToken(baseUrl, deps);
367
+ const headers = { Authorization: `Bearer ${token}` };
368
+ if (init.body !== void 0) headers["Content-Type"] = "application/json";
369
+ const res = await fetchImpl(`${baseUrl}${path}`, {
370
+ method: init.method ?? "GET",
371
+ headers,
372
+ ...init.body === void 0 ? {} : { body: JSON.stringify(init.body) }
373
+ });
374
+ const text = await res.text();
375
+ let json = null;
376
+ if (text) {
377
+ try {
378
+ json = JSON.parse(text);
379
+ } catch {
380
+ json = null;
381
+ }
382
+ }
383
+ if (!res.ok) {
384
+ if (res.status === 401) {
385
+ throw new ApiError(
386
+ 401,
387
+ "UNAUTHENTICATED",
388
+ "\uC778\uC99D\uC774 \uB9CC\uB8CC\uB418\uC5C8\uAC70\uB098 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. 'democut auth login' \uC73C\uB85C \uB2E4\uC2DC \uB85C\uADF8\uC778\uD558\uC138\uC694.",
389
+ false
390
+ );
391
+ }
392
+ throw parseDetail(res.status, json);
393
+ }
394
+ return json;
395
+ }
396
+
397
+ // src/confirm.ts
398
+ import { createInterface } from "readline/promises";
399
+ function isInteractive() {
400
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
401
+ }
402
+ async function confirm(question) {
403
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
404
+ try {
405
+ const answer = await rl.question(`${question} [y/N] `);
406
+ return /^y(es)?$/i.test(answer.trim());
407
+ } finally {
408
+ rl.close();
409
+ }
410
+ }
411
+
412
+ // src/videogen.ts
413
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "cancelled"]);
414
+ function isTerminal(status2) {
415
+ return TERMINAL_STATUSES.has(status2);
416
+ }
417
+ function estimate(baseUrl, body, deps = {}) {
418
+ return apiRequest(baseUrl, "/api/videogen/estimate", { method: "POST", body }, deps);
419
+ }
420
+ function submit(baseUrl, body, deps = {}) {
421
+ return apiRequest(baseUrl, "/api/videogen", { method: "POST", body }, deps);
422
+ }
423
+ function status(baseUrl, jobId, projectSlug, deps = {}) {
424
+ const q = projectSlug ? `?project_slug=${encodeURIComponent(projectSlug)}` : "";
425
+ return apiRequest(
426
+ baseUrl,
427
+ `/api/videogen/${encodeURIComponent(jobId)}${q}`,
428
+ {},
429
+ deps
430
+ );
431
+ }
432
+ var WaitTimeout = class extends Error {
433
+ constructor(jobId, waitedS) {
434
+ super(
435
+ `${waitedS}\uCD08 \uC548\uC5D0 \uC644\uB8CC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uC791\uC5C5\uC740 \uC11C\uBC84\uC5D0\uC11C \uACC4\uC18D \uC9C4\uD589\uB429\uB2C8\uB2E4 \u2014 'democut video status ${jobId}' \uB85C \uB2E4\uC2DC \uD655\uC778\uD558\uC138\uC694 (\uC0C8\uB85C \uC81C\uCD9C\uD558\uBA74 \uB2E4\uC2DC \uACFC\uAE08\uB429\uB2C8\uB2E4).`
436
+ );
437
+ this.jobId = jobId;
438
+ this.waitedS = waitedS;
439
+ this.name = "WaitTimeout";
440
+ }
441
+ jobId;
442
+ waitedS;
443
+ };
444
+ var POLL_INTERVAL_S = 5;
445
+ async function waitForCompletion(baseUrl, jobId, projectSlug, timeoutS, deps = {}) {
446
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
447
+ const now = deps.now ?? (() => Math.floor(Date.now() / 1e3));
448
+ const deadline = now() + timeoutS;
449
+ for (; ; ) {
450
+ const job = await status(baseUrl, jobId, projectSlug, deps);
451
+ if (isTerminal(job.status)) return job;
452
+ deps.onTick?.(job);
453
+ if (now() >= deadline) throw new WaitTimeout(jobId, timeoutS);
454
+ await sleep(POLL_INTERVAL_S * 1e3);
455
+ }
456
+ }
457
+ async function download(videoUrl, outPath, fetchImpl = fetch) {
458
+ const { writeFile } = await import("fs/promises");
459
+ const res = await fetchImpl(videoUrl);
460
+ if (!res.ok) {
461
+ throw new Error(
462
+ `\uC0B0\uCD9C\uBB3C \uB0B4\uB824\uBC1B\uAE30 \uC2E4\uD328 (HTTP ${res.status}). \uC11C\uBA85 URL \uC740 24\uC2DC\uAC04 \uD6C4 \uB9CC\uB8CC\uB429\uB2C8\uB2E4.`
463
+ );
464
+ }
465
+ await writeFile(outPath, Buffer.from(await res.arrayBuffer()));
466
+ }
467
+
468
+ // src/commands/video.ts
469
+ var EXIT = {
470
+ OK: 0,
471
+ /** 일반 오류 — 미로그인·사용자 취소·생성 실패·알 수 없음. */
472
+ GENERAL: 1,
473
+ /** 요청을 고쳐서 다시 보내면 된다 — 파라미터 오류·예산 초과·승인 누락. 과금 없음. */
474
+ FIXABLE: 2,
475
+ /** 일시적 업스트림 오류. **그대로 재시도해도 안전하다.** */
476
+ TRANSIENT: 3,
477
+ /** 업스트림이 거부. **재시도하면 재과금될 수 있다 — 자동 재시도 금지.** */
478
+ UPSTREAM_REJECTED: 4,
479
+ /**
480
+ * `--wait` 시한 초과. **실패가 아니다** — 작업은 서버에서 계속 돌고 있다.
481
+ *
482
+ * 일반 오류(1)와 같은 코드를 쓰면 에이전트가 "실패했으니 다시" 로 읽고 재제출해
483
+ * 두 번 과금된다. 그래서 별도 코드로 뗀다: 할 일은 재제출이 아니라 status 재조회다.
484
+ */
485
+ WAIT_TIMEOUT: 5
486
+ };
487
+ var out2 = (s) => process.stdout.write(`${s}
488
+ `);
489
+ var err2 = (s) => process.stderr.write(`${s}
490
+ `);
491
+ function emit(opts, ok, payload) {
492
+ if (opts.json) out2(JSON.stringify({ ok, ...payload }));
493
+ }
494
+ function usd(v) {
495
+ return v < 0.01 ? `$${v.toFixed(4)}` : `$${v.toFixed(2)}`;
496
+ }
497
+ function parseIntOpt(raw, name) {
498
+ if (raw === void 0) return void 0;
499
+ const n = Number(raw);
500
+ if (!Number.isInteger(n)) throw new UsageError(`--${name} \uC740 \uC815\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4: ${raw}`);
501
+ return n;
502
+ }
503
+ var UsageError = class extends Error {
504
+ };
505
+ function renderApiError(e, opts) {
506
+ const code = e.status === 402 || e.status === 422 ? EXIT.FIXABLE : e.status === 503 ? EXIT.TRANSIENT : e.status === 502 ? EXIT.UPSTREAM_REJECTED : EXIT.GENERAL;
507
+ if (opts.json) {
508
+ emit(opts, false, {
509
+ error: { status: e.status, code: e.code, message: e.message, retriable: e.retriable, ...e.extra },
510
+ exit_code: code
511
+ });
512
+ return code;
513
+ }
514
+ err2(`\uC624\uB958 [${e.code}] ${e.message}`);
515
+ if (e.status === 402) {
516
+ const limit = e.extra.limit_usd;
517
+ const would = e.extra.would_cost_usd;
518
+ if (typeof limit === "number" && typeof would === "number") {
519
+ err2(` \uC0C1\uD55C ${usd(limit)} \xB7 \uC774 \uC694\uCCAD ${usd(would)}`);
520
+ }
521
+ err2(" \uD574\uC0C1\uB3C4\uB098 \uAE38\uC774\uB97C \uB0AE\uCDB0 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694. \uC544\uC9C1 \uACFC\uAE08\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.");
522
+ } else if (e.status === 422) {
523
+ err2(" \uD30C\uB77C\uBBF8\uD130\uB97C \uACE0\uCCD0 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694. \uC544\uC9C1 \uACFC\uAE08\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.");
524
+ } else if (e.status === 503) {
525
+ err2(" \uC77C\uC2DC\uC801\uC778 \uC624\uB958\uC785\uB2C8\uB2E4. \uC7A0\uC2DC \uD6C4 \uADF8\uB300\uB85C \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uB3C4 \uC548\uC804\uD569\uB2C8\uB2E4.");
526
+ } else if (e.status === 502) {
527
+ err2(" \u26A0\uFE0F \uC81C\uCD9C\uC774 \uC774\uBBF8 \uC811\uC218\uB410\uC744 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uADF8\uB300\uB85C \uB2E4\uC2DC \uC81C\uCD9C\uD558\uBA74 \uB450 \uBC88 \uACFC\uAE08\uB420 \uC218 \uC788\uC73C\uB2C8,");
528
+ err2(" \uBA3C\uC800 'democut video status <job_id>' \uB85C \uD655\uC778\uD558\uAC70\uB098 \uC0AC\uB78C\uC5D0\uAC8C \uBB38\uC758\uD558\uC138\uC694.");
529
+ } else if (e.status === 403) {
530
+ err2(" \uC774 \uC6CC\uD06C\uC2A4\uD398\uC774\uC2A4\uC5D0\uC11C \uC601\uC0C1\uC744 \uC0DD\uC131\uD560 \uAD8C\uD55C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4(\uBA64\uBC84 \uC774\uC0C1 \uD544\uC694).");
531
+ }
532
+ return code;
533
+ }
534
+ async function guarded(opts, fn) {
535
+ try {
536
+ return await fn();
537
+ } catch (e) {
538
+ if (e instanceof NotLoggedInError) {
539
+ if (opts.json) emit(opts, false, { error: { code: "NOT_LOGGED_IN", message: e.message } });
540
+ else err2(e.message);
541
+ return EXIT.GENERAL;
542
+ }
543
+ if (e instanceof ApiError) return renderApiError(e, opts);
544
+ if (e instanceof UsageError) {
545
+ if (opts.json) emit(opts, false, { error: { code: "USAGE", message: e.message } });
546
+ else err2(`\uC0AC\uC6A9\uBC95 \uC624\uB958: ${e.message}`);
547
+ return EXIT.FIXABLE;
548
+ }
549
+ if (e instanceof WaitTimeout) {
550
+ if (opts.json) {
551
+ emit(opts, false, {
552
+ error: { code: "WAIT_TIMEOUT", message: e.message, retriable: false },
553
+ job_id: e.jobId,
554
+ still_running: true
555
+ });
556
+ } else {
557
+ err2(e.message);
558
+ }
559
+ return EXIT.WAIT_TIMEOUT;
560
+ }
561
+ const msg = e?.message ?? String(e);
562
+ if (opts.json) emit(opts, false, { error: { code: "UNKNOWN", message: msg } });
563
+ else err2(`\uC624\uB958: ${msg}`);
564
+ return EXIT.GENERAL;
565
+ }
566
+ }
567
+ function buildEstimateIn(opts) {
568
+ return {
569
+ duration_s: parseIntOpt(opts.duration, "duration") ?? 5,
570
+ resolution: opts.resolution ?? "720p",
571
+ ratio: opts.ratio ?? "16:9",
572
+ ...opts.model ? { model: opts.model } : {}
573
+ };
574
+ }
575
+ function estimateCommand(opts, deps = {}) {
576
+ return guarded(opts, async () => {
577
+ const base = resolveBaseUrl(opts.baseUrl);
578
+ const r = await estimate(base, buildEstimateIn(opts), deps);
579
+ if (opts.json) {
580
+ emit(opts, true, { tokens: r.tokens, cost_usd: r.cost_usd, model: r.model });
581
+ } else {
582
+ out2(`\uC608\uC0C1 \uBE44\uC6A9: ${usd(r.cost_usd)} (${r.tokens.toLocaleString()} tokens \xB7 ${r.model})`);
583
+ }
584
+ return EXIT.OK;
585
+ });
586
+ }
587
+ function generateCommand(prompt, opts, deps = {}) {
588
+ return guarded(opts, async () => {
589
+ if (!prompt || !prompt.trim()) {
590
+ throw new UsageError('\uD504\uB86C\uD504\uD2B8\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. \uC608: democut video generate "\uACE0\uC591\uC774\uAC00 \uCDA4\uCD98\uB2E4"');
591
+ }
592
+ const base = resolveBaseUrl(opts.baseUrl);
593
+ const est = buildEstimateIn(opts);
594
+ const quote = await estimate(base, est, deps);
595
+ if (!opts.json) {
596
+ out2(`\uC608\uC0C1 \uBE44\uC6A9: ${usd(quote.cost_usd)} (${quote.tokens.toLocaleString()} tokens \xB7 ${quote.model})`);
597
+ out2(` ${est.duration_s}\uCD08 \xB7 ${est.resolution} \xB7 ${est.ratio}`);
598
+ }
599
+ if (!opts.yes) {
600
+ if (!(deps.isInteractiveImpl ?? isInteractive)()) {
601
+ const message = `\uBE44\uB300\uD654\uD615 \uD658\uACBD\uC774\uB77C \uBE44\uC6A9 \uC2B9\uC778\uC744 \uBC1B\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 (\uC608\uC0C1 ${usd(quote.cost_usd)}). \uD655\uC778\uD588\uB2E4\uBA74 --yes \uB97C \uBD99\uC5EC \uB2E4\uC2DC \uC2E4\uD589\uD558\uC138\uC694.`;
602
+ if (opts.json) {
603
+ emit(opts, false, {
604
+ error: { code: "CONFIRMATION_REQUIRED", message },
605
+ estimated_cost_usd: quote.cost_usd
606
+ });
607
+ } else {
608
+ err2(message);
609
+ }
610
+ return EXIT.FIXABLE;
611
+ }
612
+ if (opts.json) {
613
+ const message = `--json \uBAA8\uB4DC\uC5D0\uC11C\uB294 --yes \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4 (\uC608\uC0C1 ${usd(quote.cost_usd)}).`;
614
+ emit(opts, false, {
615
+ error: { code: "CONFIRMATION_REQUIRED", message },
616
+ estimated_cost_usd: quote.cost_usd
617
+ });
618
+ return EXIT.FIXABLE;
619
+ }
620
+ const ask = deps.confirmImpl ?? confirm;
621
+ if (!await ask(`${usd(quote.cost_usd)} \uAC00 \uCCAD\uAD6C\uB429\uB2C8\uB2E4. \uC0DD\uC131\uD560\uAE4C\uC694?`)) {
622
+ out2("\uCDE8\uC18C\uD588\uC2B5\uB2C8\uB2E4. \uACFC\uAE08\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.");
623
+ return EXIT.GENERAL;
624
+ }
625
+ }
626
+ const body = {
627
+ prompt,
628
+ duration_s: est.duration_s,
629
+ resolution: est.resolution,
630
+ ratio: est.ratio,
631
+ generate_audio: Boolean(opts.audio),
632
+ watermark: Boolean(opts.watermark),
633
+ ...opts.model ? { model: opts.model } : {},
634
+ ...opts.seed !== void 0 ? { seed: parseIntOpt(opts.seed, "seed") } : {},
635
+ ...opts.imageUrls?.length ? { image_urls: opts.imageUrls } : {},
636
+ ...opts.project ? { project_slug: opts.project } : {}
637
+ };
638
+ const job = await submit(base, body, deps);
639
+ if (job.duplicate) {
640
+ if (job.pending) {
641
+ const message = "\uAC19\uC740 \uC694\uCCAD\uC774 \uBC29\uAE08 \uC81C\uCD9C\uB418\uC5B4 \uC9C4\uD589 \uC911\uC785\uB2C8\uB2E4. \uC7A0\uC2DC \uD6C4 'democut video status <job_id>' \uB85C \uD655\uC778\uD558\uC138\uC694.";
642
+ if (opts.json) emit(opts, true, { duplicate: true, pending: true, job_id: null, message });
643
+ else out2(message);
644
+ return EXIT.OK;
645
+ }
646
+ if (!opts.json) out2(`\uAC19\uC740 \uC694\uCCAD\uC774 \uC774\uBBF8 \uC9C4\uD589 \uC911\uC785\uB2C8\uB2E4 (job ${job.job_id}). \uC0C8\uB85C \uACFC\uAE08\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.`);
647
+ } else if (!opts.json) {
648
+ out2(`\uC81C\uCD9C\uB428: job ${job.job_id} (\uC608\uC0C1 ${usd(job.estimated_cost_usd)})`);
649
+ }
650
+ if (!opts.wait) {
651
+ if (opts.json) {
652
+ emit(opts, true, {
653
+ job_id: job.job_id,
654
+ status: job.status,
655
+ model: job.model,
656
+ estimated_cost_usd: job.estimated_cost_usd,
657
+ duplicate: job.duplicate,
658
+ pending: job.pending
659
+ });
660
+ } else {
661
+ const projectFlag = opts.project ? ` --project ${opts.project}` : "";
662
+ out2(`\uC644\uB8CC \uD655\uC778: democut video status ${job.job_id}${projectFlag}`);
663
+ }
664
+ return EXIT.OK;
665
+ }
666
+ return finishWithWait(base, job.job_id, opts, deps);
667
+ });
668
+ }
669
+ function statusCommand2(jobId, opts, deps = {}) {
670
+ return guarded(opts, async () => {
671
+ if (!jobId) throw new UsageError("job_id \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. \uC608: democut video status <job_id>");
672
+ const base = resolveBaseUrl(opts.baseUrl);
673
+ if (opts.wait) return finishWithWait(base, jobId, opts, deps);
674
+ const job = await status(base, jobId, opts.project, deps);
675
+ return report(job, opts, deps);
676
+ });
677
+ }
678
+ async function finishWithWait(base, jobId, opts, deps) {
679
+ const timeoutS = parseIntOpt(opts.timeout, "timeout") ?? 900;
680
+ if (!opts.json) process.stderr.write(" \uC0DD\uC131 \uB300\uAE30 \uC911");
681
+ const job = await waitForCompletion(base, jobId, opts.project, timeoutS, {
682
+ ...deps,
683
+ onTick: () => {
684
+ if (!opts.json) process.stderr.write(".");
685
+ }
686
+ });
687
+ if (!opts.json) err2("");
688
+ return report(job, opts, deps);
689
+ }
690
+ async function report(job, opts, deps = {}) {
691
+ if (job.status === "failed" || job.status === "cancelled") {
692
+ if (opts.json) {
693
+ emit(opts, false, { job_id: job.job_id, status: job.status, error: job.error });
694
+ } else {
695
+ err2(`\uC0DD\uC131 \uC2E4\uD328 (${job.status}): ${job.error ?? "\uC0AC\uC720 \uBBF8\uC0C1"}`);
696
+ }
697
+ return EXIT.GENERAL;
698
+ }
699
+ let saved = null;
700
+ let downloadError = null;
701
+ if (job.video_url && opts.output) {
702
+ try {
703
+ await (deps.downloadImpl ?? download)(job.video_url, opts.output);
704
+ saved = opts.output;
705
+ } catch (e) {
706
+ downloadError = e?.message ?? String(e);
707
+ }
708
+ }
709
+ if (opts.json) {
710
+ emit(opts, downloadError === null, {
711
+ job_id: job.job_id,
712
+ status: job.status,
713
+ model: job.model,
714
+ video_url: job.video_url ?? null,
715
+ tokens: job.tokens ?? null,
716
+ cost_usd: job.cost_usd ?? null,
717
+ seed: job.seed ?? null,
718
+ saved_to: saved,
719
+ ...downloadError ? { download_error: downloadError } : {}
720
+ });
721
+ } else {
722
+ out2(`\uC0C1\uD0DC: ${job.status}`);
723
+ if (job.cost_usd != null) out2(` \uBE44\uC6A9: ${usd(job.cost_usd)}${job.tokens != null ? ` (${job.tokens.toLocaleString()} tokens)` : ""}`);
724
+ if (saved) out2(` \uC800\uC7A5\uB428: ${saved}`);
725
+ else if (job.video_url) {
726
+ out2(` URL : ${job.video_url}`);
727
+ if (downloadError) {
728
+ err2(` \uB0B4\uB824\uBC1B\uAE30 \uC2E4\uD328: ${downloadError}`);
729
+ err2(" \u26A0\uFE0F \uC704 URL \uB85C \uC9C1\uC811 \uBC1B\uC73C\uC138\uC694 \u2014 24\uC2DC\uAC04 \uD6C4 \uB9CC\uB8CC\uB418\uBA70, \uB2E4\uC2DC \uB9CC\uB4E4\uBA74 \uB610 \uACFC\uAE08\uB429\uB2C8\uB2E4.");
730
+ } else {
731
+ out2(" \u26A0\uFE0F \uC774 URL \uC740 24\uC2DC\uAC04 \uD6C4 \uB9CC\uB8CC\uB429\uB2C8\uB2E4 \u2014 \uC9C0\uAE08 \uB0B4\uB824\uBC1B\uC73C\uC138\uC694 (--output <\uACBD\uB85C>).");
732
+ }
733
+ }
734
+ }
735
+ return downloadError === null ? EXIT.OK : EXIT.GENERAL;
736
+ }
737
+
738
+ // src/cli.ts
739
+ var VERSION = "0.2.0";
740
+ var HELP = `democut \u2014 DemoCut \uC5D0\uC774\uC804\uD2B8 CLI (OAuth2 device flow + \uC601\uC0C1 \uC0DD\uC131)
741
+
742
+ \uC0AC\uC6A9\uBC95:
743
+ democut auth login [--no-browser] [--json] [--base-url <url>]
744
+ democut auth status [--json] [--base-url <url>]
745
+ democut auth logout [--base-url <url>]
746
+
747
+ democut video estimate [--duration <\uCD08>] [--resolution <\uD574\uC0C1\uB3C4>] [--ratio <\uBE44\uC728>] [--model <id>]
748
+ democut video generate "<\uD504\uB86C\uD504\uD2B8>" [--yes] [--wait] [--output <\uACBD\uB85C>] [\uC0DD\uC131 \uC635\uC158\u2026]
749
+ democut video status <job_id> [--wait] [--output <\uACBD\uB85C>] [--project <slug>]
750
+
751
+ \uC0DD\uC131 \uC635\uC158:
752
+ --duration <\uCD08> \uAE30\uBCF8 5 (\uBAA8\uB378\uBCC4 \uD5C8\uC6A9 \uBC94\uC704 \uB2E4\uB984: 2.0=4~15, 2.5=4~30)
753
+ --resolution <r> 480p | 720p(\uAE30\uBCF8) | 1080p | 4k
754
+ --ratio <a> 16:9(\uAE30\uBCF8) | 9:16 | 1:1 | 4:3 | 21:9 | adaptive
755
+ --model <id> \uAE30\uBCF8 \uC11C\uBC84 \uC124\uC815\uAC12
756
+ --seed <n> \uACB0\uC815\uB860\uC801 \uC0DD\uC131\uC6A9 seed
757
+ --image-url <url> image\u2192video \uCC38\uC870 (\uACF5\uAC1C HTTPS URL, \uCD5C\uB300 4\uD68C \uBC18\uBCF5)
758
+ --audio \uC624\uB514\uC624 \uD568\uAED8 \uC0DD\uC131
759
+ --watermark \uC6CC\uD130\uB9C8\uD06C \uB123\uAE30
760
+ --project <slug> \uBE44\uC6A9\uC744 \uC774 \uD504\uB85C\uC81D\uD2B8\uB85C \uADC0\uC18D
761
+
762
+ \uACF5\uD1B5 \uC635\uC158:
763
+ -y, --yes \uBE44\uC6A9 \uD655\uC778 \uC5C6\uC774 \uC9C4\uD589 (\uBE44\uB300\uD654\uD615\uC5D0\uC11C\uB294 \uD544\uC218)
764
+ --wait \uC644\uB8CC\uAE4C\uC9C0 \uAE30\uB2E4\uB9BC
765
+ --timeout <\uCD08> --wait \uC2DC\uD55C (\uAE30\uBCF8 900)
766
+ -o, --output <\uACBD\uB85C> \uC0B0\uCD9C\uBB3C \uC800\uC7A5 (\uC11C\uBA85 URL \uC740 24\uC2DC\uAC04 \uD6C4 \uB9CC\uB8CC \u2014 \uBC1B\uC544 \uB450\uC138\uC694)
767
+ --json \uAE30\uACC4 \uD310\uB3C5\uC6A9 JSON \uD55C \uC904
768
+ --base-url <u> DemoCut \uC11C\uBC84 URL (\uAE30\uBCF8: $DEMOCUT_BASE_URL \uB610\uB294 prod)
769
+ -h, --help \uB3C4\uC6C0\uB9D0
770
+ -v, --version \uBC84\uC804
771
+
772
+ \uC885\uB8CC \uCF54\uB4DC:
773
+ 0 \uC131\uACF5 1 \uC77C\uBC18 \uC624\uB958(\uBBF8\uB85C\uADF8\uC778\xB7\uCDE8\uC18C\xB7\uC0DD\uC131 \uC2E4\uD328) 2 \uACE0\uCCD0\uC11C \uC7AC\uC2DC\uB3C4 \uAC00\uB2A5(\uACFC\uAE08 \uC5C6\uC74C)
774
+ 3 \uC77C\uC2DC \uC624\uB958(\uADF8\uB300\uB85C \uC7AC\uC2DC\uB3C4 \uC548\uC804) 4 \uC5C5\uC2A4\uD2B8\uB9BC \uAC70\uBD80(\uC7AC\uC2DC\uB3C4 \uC2DC \uC7AC\uACFC\uAE08 \uC704\uD5D8)
775
+ 5 --wait \uC2DC\uD55C \uCD08\uACFC \u2014 \uC2E4\uD328 \uC544\uB2D8. \uC791\uC5C5\uC740 \uACC4\uC18D \uC9C4\uD589 \uC911\uC774\uB2C8 status \uB85C \uD655\uC778(\uC7AC\uC81C\uCD9C \uAE08\uC9C0)`;
776
+ var VALUE_FLAGS = {
777
+ "--base-url": "baseUrl",
778
+ "--duration": "duration",
779
+ "--resolution": "resolution",
780
+ "--ratio": "ratio",
781
+ "--model": "model",
782
+ "--seed": "seed",
783
+ "--project": "project",
784
+ "--output": "output",
785
+ "-o": "output",
786
+ "--timeout": "timeout"
787
+ };
788
+ var BOOL_FLAGS = {
789
+ "--no-browser": "noBrowser",
790
+ "--device": "noBrowser",
791
+ "--json": "json",
792
+ "--yes": "yes",
793
+ "-y": "yes",
794
+ "--wait": "wait",
795
+ "--audio": "audio",
796
+ "--watermark": "watermark"
797
+ };
798
+ var HELP_FLAGS = /* @__PURE__ */ new Set(["-h", "--help", "-v", "--version"]);
799
+ function isKnownFlag(token) {
800
+ return token in VALUE_FLAGS || token in BOOL_FLAGS || HELP_FLAGS.has(token) || token === "--image-url" || token.startsWith("--image-url=");
801
+ }
802
+ function parseArgs(argv) {
803
+ const positional = [];
804
+ const options = {};
805
+ let help = false;
806
+ let version = false;
807
+ for (let i = 0; i < argv.length; i++) {
808
+ const a = argv[i];
809
+ if (!a) continue;
810
+ if (a === "-h" || a === "--help") {
811
+ help = true;
812
+ continue;
813
+ }
814
+ if (a === "-v" || a === "--version") {
815
+ version = true;
816
+ continue;
817
+ }
818
+ if (a === "--image-url" || a.startsWith("--image-url=")) {
819
+ const v = a.startsWith("--image-url=") ? a.slice("--image-url=".length) : argv[++i];
820
+ if (v) (options.imageUrls ??= []).push(v);
821
+ continue;
822
+ }
823
+ const boolKey = BOOL_FLAGS[a];
824
+ if (boolKey) {
825
+ options[boolKey] = true;
826
+ continue;
827
+ }
828
+ const valueKey = VALUE_FLAGS[a];
829
+ if (valueKey) {
830
+ const v = argv[++i];
831
+ if (v === void 0 || isKnownFlag(v)) {
832
+ throw new UsageError(`${a} \uC5D0 \uAC12\uC774 \uD544\uC694\uD569\uB2C8\uB2E4.`);
833
+ }
834
+ options[valueKey] = v;
835
+ continue;
836
+ }
837
+ const eq = a.indexOf("=");
838
+ if (eq > 0) {
839
+ const key = VALUE_FLAGS[a.slice(0, eq)];
840
+ if (key) {
841
+ options[key] = a.slice(eq + 1);
842
+ continue;
843
+ }
844
+ }
845
+ positional.push(a);
846
+ }
847
+ return { positional, options, help, version };
848
+ }
849
+ async function run(argv, deps = {}) {
850
+ let parsed;
851
+ try {
852
+ parsed = parseArgs(argv);
853
+ } catch (e) {
854
+ if (e instanceof UsageError) {
855
+ process.stderr.write(`\uC0AC\uC6A9\uBC95 \uC624\uB958: ${e.message}
856
+ `);
857
+ return EXIT.FIXABLE;
858
+ }
859
+ throw e;
860
+ }
861
+ const { positional, options, help, version } = parsed;
862
+ if (version) {
863
+ process.stdout.write(`${VERSION}
864
+ `);
865
+ return 0;
866
+ }
867
+ const [group, sub, arg] = positional;
868
+ if (help || !group) {
869
+ process.stdout.write(`${HELP}
870
+ `);
871
+ return help ? EXIT.OK : EXIT.FIXABLE;
872
+ }
873
+ if (group === "auth") {
874
+ if (sub === "login") return loginCommand(options);
875
+ if (sub === "status") return statusCommand(options);
876
+ if (sub === "logout") return Promise.resolve(logoutCommand(options));
877
+ process.stderr.write(`\uC54C \uC218 \uC5C6\uB294 auth \uBA85\uB839: ${sub ?? "(\uC5C6\uC74C)"}
878
+ ${HELP}
879
+ `);
880
+ return EXIT.FIXABLE;
881
+ }
882
+ if (group === "video") {
883
+ if (sub === "estimate") return estimateCommand(options, deps);
884
+ if (sub === "generate") return generateCommand(arg, options, deps);
885
+ if (sub === "status") return statusCommand2(arg, options, deps);
886
+ process.stderr.write(`\uC54C \uC218 \uC5C6\uB294 video \uBA85\uB839: ${sub ?? "(\uC5C6\uC74C)"}
887
+ ${HELP}
888
+ `);
889
+ return EXIT.FIXABLE;
890
+ }
891
+ process.stderr.write(`\uC54C \uC218 \uC5C6\uB294 \uBA85\uB839: ${group}
892
+ ${HELP}
893
+ `);
894
+ return EXIT.FIXABLE;
895
+ }
896
+ var invokedDirectly = (() => {
897
+ const arg = process.argv[1];
898
+ if (arg === void 0) return false;
899
+ try {
900
+ return realpathSync(arg) === realpathSync(fileURLToPath(import.meta.url));
901
+ } catch {
902
+ return false;
903
+ }
904
+ })();
905
+ if (invokedDirectly || process.env.DEMOCUT_CLI_RUN === "1") {
906
+ run(process.argv.slice(2)).then((code) => process.exit(code)).catch((e) => {
907
+ process.stderr.write(`\uC624\uB958: ${e?.message ?? String(e)}
908
+ `);
909
+ process.exit(1);
910
+ });
911
+ }
912
+ export {
913
+ VERSION,
914
+ parseArgs,
915
+ run
916
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "democut",
3
+ "version": "0.2.0",
4
+ "description": "DemoCut 에이전트 CLI — device flow 로그인(RFC 8628) + 영상 생성",
5
+ "license": "MIT",
6
+ "author": "InfoGrab",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://gitlab.com/infograb/ai/democut/democut-app.git",
10
+ "directory": "cli"
11
+ },
12
+ "bugs": {
13
+ "url": "https://gitlab.com/infograb/ai/democut/democut-app/-/issues"
14
+ },
15
+ "homepage": "https://democut.ai",
16
+ "keywords": [
17
+ "video-generation",
18
+ "ai-video",
19
+ "cli",
20
+ "auth",
21
+ "oauth2",
22
+ "agent"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "democut": "dist/cli.js"
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "scripts": {
35
+ "build": "tsup",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run",
38
+ "prepublishOnly": "npm run build",
39
+ "generate:types": "openapi-typescript http://localhost:8000/api/_spec/openapi.json -o src/api.types.ts"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.10.0",
43
+ "openapi-typescript": "^7.13.0",
44
+ "tsup": "^8.3.5",
45
+ "typescript": "^5.6.3",
46
+ "vitest": "^2.1.8"
47
+ }
48
+ }