@ssobig/writer-cli 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 (73) hide show
  1. package/README.md +35 -0
  2. package/asset-repository.js +278 -0
  3. package/config.js +14 -0
  4. package/package.json +28 -0
  5. package/project-runtime.js +102 -0
  6. package/storage-path.js +110 -0
  7. package/templates/mystery-v1/authoring-view-preference.js +34 -0
  8. package/templates/mystery-v1/character-perspective-preview.js +61 -0
  9. package/templates/mystery-v1/component-asset-operations.js +103 -0
  10. package/templates/mystery-v1/component-autosave.js +121 -0
  11. package/templates/mystery-v1/component-catalog-contract.js +340 -0
  12. package/templates/mystery-v1/component-checkpoint-history.js +145 -0
  13. package/templates/mystery-v1/component-contract.js +90 -0
  14. package/templates/mystery-v1/component-draft-operations.js +313 -0
  15. package/templates/mystery-v1/component-field-contracts.js +595 -0
  16. package/templates/mystery-v1/component-id-policy.js +64 -0
  17. package/templates/mystery-v1/component-manager.js +396 -0
  18. package/templates/mystery-v1/component-navigation-counts.js +64 -0
  19. package/templates/mystery-v1/component-registry.js +205 -0
  20. package/templates/mystery-v1/component-renderers.js +139 -0
  21. package/templates/mystery-v1/component-storage-contract.js +237 -0
  22. package/templates/mystery-v1/external-update-coordinator.js +91 -0
  23. package/templates/mystery-v1/output-clue-card-layout.js +46 -0
  24. package/templates/mystery-v1/page-header.js +26 -0
  25. package/templates/mystery-v1/render-ui-state.js +76 -0
  26. package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
  27. package/templates/mystery-v1/tab-bar.js +87 -0
  28. package/templates/mystery-v1/view-component-contract.js +152 -0
  29. package/templates/mystery-v1/view-component-registry.js +44 -0
  30. package/templates/mystery-v1/view-component-runtime.js +95 -0
  31. package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
  32. package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
  33. package/tools/writer-cli/package-lock.json +121 -0
  34. package/tools/writer-cli/package.json +22 -0
  35. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
  36. package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
  37. package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
  38. package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
  39. package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
  40. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
  41. package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
  42. package/tools/writer-cli/src/agent-paths.cjs +114 -0
  43. package/tools/writer-cli/src/agent-service.cjs +496 -0
  44. package/tools/writer-cli/src/asset-policy.cjs +113 -0
  45. package/tools/writer-cli/src/auth.cjs +655 -0
  46. package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
  47. package/tools/writer-cli/src/command-registry.cjs +152 -0
  48. package/tools/writer-cli/src/commands.cjs +841 -0
  49. package/tools/writer-cli/src/corpus.cjs +83 -0
  50. package/tools/writer-cli/src/daemon-app.cjs +106 -0
  51. package/tools/writer-cli/src/daemon-client.cjs +187 -0
  52. package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
  53. package/tools/writer-cli/src/daemon-runner.cjs +97 -0
  54. package/tools/writer-cli/src/daemon-server.cjs +378 -0
  55. package/tools/writer-cli/src/diagnostics.cjs +235 -0
  56. package/tools/writer-cli/src/domain.cjs +731 -0
  57. package/tools/writer-cli/src/errors.cjs +47 -0
  58. package/tools/writer-cli/src/gateway.cjs +357 -0
  59. package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
  60. package/tools/writer-cli/src/json-patch.cjs +98 -0
  61. package/tools/writer-cli/src/json.cjs +26 -0
  62. package/tools/writer-cli/src/local-index-cache.cjs +139 -0
  63. package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
  64. package/tools/writer-cli/src/local-index-query.cjs +304 -0
  65. package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
  66. package/tools/writer-cli/src/local-index-storage.cjs +284 -0
  67. package/tools/writer-cli/src/local-index.cjs +199 -0
  68. package/tools/writer-cli/src/mutations.cjs +722 -0
  69. package/tools/writer-cli/src/platform-runner.cjs +55 -0
  70. package/tools/writer-cli/src/project-import.cjs +485 -0
  71. package/tools/writer-cli/src/skill-manager.cjs +255 -0
  72. package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
  73. package/tools/writer-cli/src/update-gate.cjs +102 -0
@@ -0,0 +1,655 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const http = require("node:http");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const { spawn } = require("node:child_process");
9
+ const { cliError } = require("./errors.cjs");
10
+
11
+ const KEYCHAIN_SERVICE = "com.ssobig.writer-cli.auth";
12
+ const AUTH_STORAGE_KEY = "ssobig-writer-cli-auth-v1";
13
+ const KEYCHAIN_CHUNK_VERSION = "v2";
14
+ const KEYCHAIN_SECRET_LIMIT = 120;
15
+ const KEYCHAIN_CHUNK_SIZE = 96;
16
+ const KEYCHAIN_MANIFEST_PATTERN = /^v2\|([a-f0-9]{32})\|([1-9][0-9]{0,3})\|([a-f0-9]{64})$/;
17
+ const DPAPI_STORAGE_VERSION = "ssobig-writer-dpapi-v1";
18
+ const DPAPI_MAX_SECRET_BYTES = 4 * 1024 * 1024;
19
+ const DPAPI_SCRIPT = String.raw`$ErrorActionPreference = 'Stop'
20
+ Add-Type -AssemblyName System.Security
21
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
22
+ $data = [Convert]::FromBase64String([string]$request.data)
23
+ $entropy = [Text.Encoding]::UTF8.GetBytes([string]$request.entropy)
24
+ $scope = [Security.Cryptography.DataProtectionScope]::CurrentUser
25
+ if ([string]$request.operation -eq 'protect') {
26
+ $result = [Security.Cryptography.ProtectedData]::Protect($data, $entropy, $scope)
27
+ } elseif ([string]$request.operation -eq 'unprotect') {
28
+ $result = [Security.Cryptography.ProtectedData]::Unprotect($data, $entropy, $scope)
29
+ } else {
30
+ throw 'Unsupported DPAPI operation.'
31
+ }
32
+ [Console]::Out.Write([Convert]::ToBase64String($result))`;
33
+
34
+ function delay(ms) {
35
+ return new Promise(resolve => setTimeout(resolve, ms));
36
+ }
37
+
38
+ function createFileLock(options = {}) {
39
+ const fsApi = options.fs || fs;
40
+ const platform = String(options.platform || process.platform);
41
+ const environment = options.environment || process.env;
42
+ const homeDirectory = options.homeDirectory || os.homedir();
43
+ const windowsRoot = environment.LOCALAPPDATA || path.join(homeDirectory, "AppData", "Local");
44
+ const baseDirectory = options.baseDirectory || (platform === "win32"
45
+ ? path.join(windowsRoot, "SSOBIG", "WriterCLI", "locks")
46
+ : path.join(homeDirectory, "Library", "Caches", "ssobig-writer-cli", "locks"));
47
+ const staleMs = Number(options.staleMs || 600_000);
48
+ const recoveryStaleMs = Number(options.recoveryStaleMs || 10_000);
49
+ const randomUUID = options.randomUUID || crypto.randomUUID;
50
+ const processAlive = options.processAlive || (pid => {
51
+ if (!Number.isSafeInteger(pid) || pid < 1) return false;
52
+ try { process.kill(pid, 0); return true; }
53
+ catch (error) { return error.code === "EPERM"; }
54
+ });
55
+
56
+ function readOwner(lockPath) {
57
+ try { return JSON.parse(fsApi.readFileSync(lockPath, "utf8")); }
58
+ catch (error) { if (error.code === "ENOENT") return null; return null; }
59
+ }
60
+
61
+ function ownedBy(lockPath, token) {
62
+ return readOwner(lockPath)?.token === token;
63
+ }
64
+
65
+ function releaseOwned(lockPath, token, handle = null) {
66
+ if (handle !== null) {
67
+ try { fsApi.closeSync(handle); } catch (error) { void error; }
68
+ }
69
+ if (!ownedBy(lockPath, token)) return;
70
+ try { fsApi.unlinkSync(lockPath); } catch (error) { if (error.code !== "ENOENT") throw error; }
71
+ }
72
+
73
+ async function lock(name, acquireTimeout, callback) {
74
+ fsApi.mkdirSync(baseDirectory, { recursive: true, mode: 0o700 });
75
+ const fileName = `${crypto.createHash("sha256").update(String(name)).digest("hex")}.lock`;
76
+ const lockPath = path.join(baseDirectory, fileName);
77
+ const recoveryPath = `${lockPath}.recovery`;
78
+ const startedAt = Date.now();
79
+ const timeoutMs = Number(acquireTimeout) < 0 ? 10_000 : Math.min(Math.max(Number(acquireTimeout || 10_000), 250), 10_000);
80
+ let handle = null;
81
+ const token = randomUUID();
82
+ while (!handle) {
83
+ if (fsApi.existsSync(recoveryPath)) {
84
+ try {
85
+ const recoveryStat = fsApi.statSync(recoveryPath);
86
+ const recoveryOwner = readOwner(recoveryPath);
87
+ if (Date.now() - recoveryStat.mtimeMs > recoveryStaleMs && !processAlive(Number(recoveryOwner?.pid))) {
88
+ releaseOwned(recoveryPath, recoveryOwner?.token);
89
+ continue;
90
+ }
91
+ } catch (error) {
92
+ if (error.code === "ENOENT") continue;
93
+ throw cliError("E_AUTH_LOCK", "인증 recovery lock 상태를 확인할 수 없습니다.", null, error);
94
+ }
95
+ if (Date.now() - startedAt >= timeoutMs) throw cliError("E_AUTH_BUSY", "다른 Writer CLI 인증 작업이 진행 중입니다. 잠시 후 다시 시도해 주세요.");
96
+ await delay(100);
97
+ continue;
98
+ }
99
+ try {
100
+ handle = fsApi.openSync(lockPath, "wx", 0o600);
101
+ try { fsApi.writeFileSync(handle, JSON.stringify({ pid: process.pid, token, createdAt: Date.now() })); }
102
+ catch (writeError) {
103
+ try { fsApi.closeSync(handle); } catch (closeError) { void closeError; }
104
+ handle = null;
105
+ try { fsApi.unlinkSync(lockPath); } catch (unlinkError) { void unlinkError; }
106
+ throw writeError;
107
+ }
108
+ } catch (error) {
109
+ if (error.code !== "EEXIST") throw cliError("E_AUTH_LOCK", "인증 lock을 만들 수 없습니다.", null, error);
110
+ try {
111
+ const stat = fsApi.statSync(lockPath);
112
+ const owner = readOwner(lockPath);
113
+ if (Date.now() - stat.mtimeMs > staleMs && !processAlive(Number(owner?.pid))) {
114
+ let recoveryHandle = null;
115
+ const recoveryToken = randomUUID();
116
+ try {
117
+ recoveryHandle = fsApi.openSync(recoveryPath, "wx", 0o600);
118
+ try { fsApi.writeFileSync(recoveryHandle, JSON.stringify({ pid: process.pid, token: recoveryToken, createdAt: Date.now() })); }
119
+ catch (writeError) {
120
+ try { fsApi.closeSync(recoveryHandle); } catch (closeError) { void closeError; }
121
+ recoveryHandle = null;
122
+ try { fsApi.unlinkSync(recoveryPath); } catch (unlinkError) { void unlinkError; }
123
+ throw writeError;
124
+ }
125
+ const latestStat = fsApi.statSync(lockPath);
126
+ const latestOwner = readOwner(lockPath);
127
+ if (latestOwner?.token === owner?.token
128
+ && Date.now() - latestStat.mtimeMs > staleMs
129
+ && !processAlive(Number(latestOwner?.pid))) {
130
+ fsApi.unlinkSync(lockPath);
131
+ }
132
+ } catch (recoveryError) {
133
+ if (!["EEXIST", "ENOENT"].includes(recoveryError.code)) throw recoveryError;
134
+ } finally {
135
+ if (recoveryHandle !== null) releaseOwned(recoveryPath, recoveryToken, recoveryHandle);
136
+ }
137
+ continue;
138
+ }
139
+ } catch (statError) {
140
+ if (statError.code === "ENOENT") continue;
141
+ throw cliError("E_AUTH_LOCK", "인증 lock 상태를 확인할 수 없습니다.", null, statError);
142
+ }
143
+ if (Date.now() - startedAt >= timeoutMs) throw cliError("E_AUTH_BUSY", "다른 Writer CLI 인증 작업이 진행 중입니다. 잠시 후 다시 시도해 주세요.");
144
+ await delay(100);
145
+ }
146
+ }
147
+ const heartbeatMs = Math.max(100, Math.min(Math.floor(staleMs / 3), 30_000));
148
+ const heartbeat = setInterval(() => {
149
+ if (!ownedBy(lockPath, token)) return;
150
+ try { fsApi.utimesSync(lockPath, new Date(), new Date()); } catch (error) { void error; }
151
+ }, heartbeatMs);
152
+ heartbeat.unref?.();
153
+ try { return await callback(); }
154
+ finally {
155
+ clearInterval(heartbeat);
156
+ releaseOwned(lockPath, token, handle);
157
+ }
158
+ }
159
+
160
+ return Object.freeze({ lock });
161
+ }
162
+
163
+ function defaultCommandRunner(command, args, input = "") {
164
+ return new Promise((resolve, reject) => {
165
+ const child = spawn(command, args, { shell: false, stdio: ["pipe", "pipe", "pipe"] });
166
+ const stdout = [];
167
+ const stderr = [];
168
+ child.stdout.on("data", chunk => stdout.push(chunk));
169
+ child.stderr.on("data", chunk => stderr.push(chunk));
170
+ child.on("error", reject);
171
+ child.stdin.on("error", reject);
172
+ child.on("close", (code, signal) => resolve({ code, signal, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }));
173
+ child.stdin.end(input);
174
+ });
175
+ }
176
+
177
+ function commandSucceeded(result) {
178
+ return result?.code === 0 && !result?.signal;
179
+ }
180
+
181
+ function createKeychainStorage(options = {}) {
182
+ if ((options.platform || process.platform) !== "darwin") throw cliError("E_KEYCHAIN_UNAVAILABLE", "Writer CLI 인증은 현재 macOS Keychain에서만 지원합니다.");
183
+ const run = options.run || defaultCommandRunner;
184
+ const service = String(options.service || KEYCHAIN_SERVICE);
185
+
186
+ async function readSecret(account) {
187
+ const result = await run("/usr/bin/security", ["find-generic-password", "-a", String(account), "-s", service, "-w"]);
188
+ if (result.code === 44 && !result.signal) return null;
189
+ if (!commandSucceeded(result)) throw cliError("E_KEYCHAIN_UNAVAILABLE", "macOS Keychain에서 Writer CLI 세션을 읽을 수 없습니다.");
190
+ return result.stdout.replace(/\r?\n$/, "");
191
+ }
192
+
193
+ async function writeSecret(account, value) {
194
+ const secret = String(value);
195
+ if (secret.length > KEYCHAIN_SECRET_LIMIT) throw cliError("E_KEYCHAIN_UNAVAILABLE", "macOS Keychain chunk 크기 제한을 초과했습니다.");
196
+ const result = await run("/usr/bin/security", ["add-generic-password", "-a", String(account), "-s", service, "-U", "-w"], `${secret}\n${secret}\n`);
197
+ if (!commandSucceeded(result)) throw cliError("E_KEYCHAIN_UNAVAILABLE", "macOS Keychain에 Writer CLI 세션을 저장할 수 없습니다.");
198
+ }
199
+
200
+ async function deleteSecret(account) {
201
+ const result = await run("/usr/bin/security", ["delete-generic-password", "-a", String(account), "-s", service]);
202
+ if (result.signal || ![0, 44].includes(result.code)) throw cliError("E_KEYCHAIN_UNAVAILABLE", "macOS Keychain에서 Writer CLI 세션을 삭제할 수 없습니다.");
203
+ }
204
+
205
+ function parseManifest(value) {
206
+ const match = KEYCHAIN_MANIFEST_PATTERN.exec(String(value || ""));
207
+ if (!match) return null;
208
+ return Object.freeze({ generation: match[1], count: Number(match[2]), hash: match[3] });
209
+ }
210
+
211
+ function chunkAccount(key, generation, index) {
212
+ return `${String(key)}::${KEYCHAIN_CHUNK_VERSION}::${generation}::${index}`;
213
+ }
214
+
215
+ async function deleteGeneration(key, manifest) {
216
+ if (!manifest) return;
217
+ for (let index = 0; index < manifest.count; index += 1) {
218
+ await deleteSecret(chunkAccount(key, manifest.generation, index));
219
+ }
220
+ }
221
+
222
+ async function getItem(key) {
223
+ const stored = await readSecret(key);
224
+ if (stored === null) return null;
225
+ const manifest = parseManifest(stored);
226
+ if (!manifest) return stored;
227
+ const chunks = [];
228
+ for (let index = 0; index < manifest.count; index += 1) {
229
+ const chunk = await readSecret(chunkAccount(key, manifest.generation, index));
230
+ if (chunk === null) throw cliError("E_KEYCHAIN_UNAVAILABLE", "macOS Keychain의 Writer CLI 세션 chunk가 누락되었습니다.");
231
+ chunks.push(chunk);
232
+ }
233
+ const encoded = chunks.join("");
234
+ const bytes = Buffer.from(encoded, "base64");
235
+ if (bytes.toString("base64") !== encoded || crypto.createHash("sha256").update(bytes).digest("hex") !== manifest.hash) {
236
+ throw cliError("E_KEYCHAIN_UNAVAILABLE", "macOS Keychain의 Writer CLI 세션 무결성을 확인할 수 없습니다.");
237
+ }
238
+ return bytes.toString("utf8");
239
+ }
240
+
241
+ async function setItem(key, value) {
242
+ const previous = parseManifest(await readSecret(key));
243
+ const bytes = Buffer.from(String(value), "utf8");
244
+ const encoded = bytes.toString("base64");
245
+ const chunks = encoded.match(new RegExp(`.{1,${KEYCHAIN_CHUNK_SIZE}}`, "g")) || [""];
246
+ const generation = crypto.randomBytes(16).toString("hex");
247
+ const manifest = `${KEYCHAIN_CHUNK_VERSION}|${generation}|${chunks.length}|${crypto.createHash("sha256").update(bytes).digest("hex")}`;
248
+ const writtenAccounts = [];
249
+ try {
250
+ for (let index = 0; index < chunks.length; index += 1) {
251
+ const account = chunkAccount(key, generation, index);
252
+ await writeSecret(account, chunks[index]);
253
+ writtenAccounts.push(account);
254
+ }
255
+ await writeSecret(key, manifest);
256
+ } catch (error) {
257
+ for (const account of writtenAccounts) {
258
+ try { await deleteSecret(account); } catch (cleanupError) { void cleanupError; }
259
+ }
260
+ throw error;
261
+ }
262
+ try { await deleteGeneration(key, previous); } catch (cleanupError) { void cleanupError; }
263
+ }
264
+
265
+ async function removeItem(key) {
266
+ const stored = await readSecret(key);
267
+ const manifest = parseManifest(stored);
268
+ try { await deleteGeneration(key, manifest); }
269
+ finally { await deleteSecret(key); }
270
+ }
271
+
272
+ return Object.freeze({ isServer: true, getItem, setItem, removeItem });
273
+ }
274
+
275
+ function canonicalBase64(value, label) {
276
+ const normalized = String(value || "").trim();
277
+ if (!normalized || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized)) {
278
+ throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", `${label} 응답 형식이 올바르지 않습니다.`);
279
+ }
280
+ const bytes = Buffer.from(normalized, "base64");
281
+ if (bytes.toString("base64") !== normalized) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", `${label} 응답 무결성을 확인할 수 없습니다.`);
282
+ return bytes;
283
+ }
284
+
285
+ function createWindowsDpapiStorage(options = {}) {
286
+ if ((options.platform || process.platform) !== "win32") {
287
+ throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows DPAPI credential store는 Windows에서만 사용할 수 있습니다.");
288
+ }
289
+ const fsApi = options.fs || fs;
290
+ const run = options.run || defaultCommandRunner;
291
+ const environment = options.environment || process.env;
292
+ const homeDirectory = options.homeDirectory || os.homedir();
293
+ const localAppData = options.localAppData || environment.LOCALAPPDATA || path.join(homeDirectory, "AppData", "Local");
294
+ const directory = path.resolve(String(options.directory || path.join(localAppData, "SSOBIG", "WriterCLI", "credentials")));
295
+ const entropy = String(options.entropy || KEYCHAIN_SERVICE);
296
+ const powershell = String(options.powershell || "powershell.exe");
297
+ const encodedScript = Buffer.from(DPAPI_SCRIPT, "utf16le").toString("base64");
298
+
299
+ function ensureDirectory() {
300
+ try {
301
+ const existing = fsApi.lstatSync(directory);
302
+ if (existing.isSymbolicLink() || !existing.isDirectory()) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows credential store 경로가 안전한 directory가 아닙니다.");
303
+ } catch (error) {
304
+ if (error?.code !== "ENOENT") throw error;
305
+ fsApi.mkdirSync(directory, { recursive: true });
306
+ }
307
+ const verified = fsApi.lstatSync(directory);
308
+ if (verified.isSymbolicLink() || !verified.isDirectory()) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows credential store 경로가 안전한 directory가 아닙니다.");
309
+ return directory;
310
+ }
311
+
312
+ function itemPath(key) {
313
+ return path.join(directory, `${crypto.createHash("sha256").update(String(key)).digest("hex")}.dpapi`);
314
+ }
315
+
316
+ async function transform(operation, bytes, context = "probe") {
317
+ const input = Buffer.from(bytes);
318
+ if (input.length > DPAPI_MAX_SECRET_BYTES) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows credential store 값이 허용 크기를 초과했습니다.");
319
+ let result;
320
+ try {
321
+ result = await run(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodedScript], JSON.stringify({
322
+ operation,
323
+ data: input.toString("base64"),
324
+ entropy: `${entropy}:${String(context)}`
325
+ }));
326
+ } catch (error) {
327
+ throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows DPAPI를 실행할 수 없습니다.", null, error);
328
+ }
329
+ if (!commandSucceeded(result)) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows DPAPI가 credential 값을 처리하지 못했습니다.");
330
+ return canonicalBase64(result.stdout, "Windows DPAPI");
331
+ }
332
+
333
+ async function getItem(key) {
334
+ const target = itemPath(key);
335
+ let envelope;
336
+ try {
337
+ const stat = fsApi.lstatSync(target);
338
+ if (stat.isSymbolicLink() || !stat.isFile()) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows credential 파일이 안전한 일반 파일이 아닙니다.");
339
+ envelope = String(fsApi.readFileSync(target, "utf8"));
340
+ } catch (error) {
341
+ if (error?.code === "ENOENT") return null;
342
+ throw error;
343
+ }
344
+ const prefix = `${DPAPI_STORAGE_VERSION}:`;
345
+ if (!envelope.startsWith(prefix)) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows credential 파일 형식이 올바르지 않습니다.");
346
+ const encrypted = canonicalBase64(envelope.slice(prefix.length), "Windows credential");
347
+ const clear = await transform("unprotect", encrypted, key);
348
+ return clear.toString("utf8");
349
+ }
350
+
351
+ async function setItem(key, value) {
352
+ ensureDirectory();
353
+ const target = itemPath(key);
354
+ try {
355
+ if (fsApi.lstatSync(target).isSymbolicLink()) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows credential 파일은 symbolic link일 수 없습니다.");
356
+ } catch (error) { if (error?.code !== "ENOENT") throw error; }
357
+ const encrypted = await transform("protect", Buffer.from(String(value), "utf8"), key);
358
+ const temporary = path.join(directory, `.${path.basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
359
+ let descriptor = null;
360
+ try {
361
+ descriptor = fsApi.openSync(temporary, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
362
+ fsApi.writeFileSync(descriptor, `${DPAPI_STORAGE_VERSION}:${encrypted.toString("base64")}`);
363
+ fsApi.fsyncSync(descriptor);
364
+ fsApi.closeSync(descriptor);
365
+ descriptor = null;
366
+ fsApi.renameSync(temporary, target);
367
+ } finally {
368
+ if (descriptor !== null) try { fsApi.closeSync(descriptor); } catch (error) { void error; }
369
+ try { fsApi.unlinkSync(temporary); } catch (error) { if (error?.code !== "ENOENT") throw error; }
370
+ }
371
+ }
372
+
373
+ async function removeItem(key) {
374
+ const target = itemPath(key);
375
+ try {
376
+ const stat = fsApi.lstatSync(target);
377
+ if (stat.isSymbolicLink() || !stat.isFile()) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows credential 파일이 안전한 일반 파일이 아닙니다.");
378
+ fsApi.unlinkSync(target);
379
+ } catch (error) { if (error?.code !== "ENOENT") throw error; }
380
+ }
381
+
382
+ async function probe() {
383
+ const clear = Buffer.from("ssobig-writer-dpapi-probe", "utf8");
384
+ const encrypted = await transform("protect", clear, "probe");
385
+ const decrypted = await transform("unprotect", encrypted, "probe");
386
+ if (!decrypted.equals(clear)) throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", "Windows DPAPI round-trip 검증에 실패했습니다.");
387
+ return true;
388
+ }
389
+
390
+ return Object.freeze({ isServer: true, kind: "windows-dpapi", directory, getItem, setItem, removeItem, probe });
391
+ }
392
+
393
+ function createCredentialStorage(options = {}) {
394
+ const platform = String(options.platform || process.platform);
395
+ if (platform === "darwin") return createKeychainStorage(options);
396
+ if (platform === "win32") return createWindowsDpapiStorage(options);
397
+ throw cliError("E_CREDENTIAL_STORE_UNAVAILABLE", `현재 플랫폼 ${platform}에는 지원되는 Writer CLI credential store가 없습니다.`);
398
+ }
399
+
400
+ function defaultCreateClient(url, key, options) {
401
+ let createClient;
402
+ try { ({ createClient } = require("@supabase/supabase-js")); }
403
+ catch (error) {
404
+ throw cliError("E_DEPENDENCY_MISSING", "먼저 tools/writer-cli에서 npm ci --ignore-scripts를 실행해 주세요.", null, error);
405
+ }
406
+ return createClient(url, key, options);
407
+ }
408
+
409
+ function defaultOpenBrowser(url, options = {}) {
410
+ const platform = String(options.platform || process.platform);
411
+ const spawnProcess = options.spawn || spawn;
412
+ const invocation = platform === "darwin" ? ["/usr/bin/open", [url]]
413
+ : platform === "win32" ? ["explorer.exe", [url]]
414
+ : platform === "linux" ? ["xdg-open", [url]] : null;
415
+ if (!invocation) return Promise.reject(cliError("E_BROWSER_OPEN", `현재 플랫폼 ${platform}에서 브라우저를 자동으로 열 수 없습니다.`));
416
+ return new Promise((resolve, reject) => {
417
+ const child = spawnProcess(invocation[0], invocation[1], { shell: false, detached: true, stdio: "ignore" });
418
+ child.on("error", reject);
419
+ child.on("spawn", () => { child.unref(); resolve(); });
420
+ });
421
+ }
422
+
423
+ function createAuthManager(config, options = {}) {
424
+ const storage = options.storage || createCredentialStorage({ ...(options.credential || options.keychain || {}), platform: options.platform });
425
+ const authLock = options.authLock || createFileLock({ ...(options.lock || {}), platform: options.platform });
426
+ const createClient = options.createClient || defaultCreateClient;
427
+ const openBrowser = options.openBrowser || (url => defaultOpenBrowser(url, { platform: options.platform }));
428
+ const httpApi = options.http || http;
429
+ const loginTimeoutMs = Number(options.loginTimeoutMs || 180_000);
430
+ const persistent = options.persistent === true;
431
+ const staffVerificationMs = Math.max(0, Number(options.staffVerificationMs ?? 300_000));
432
+ const now = options.now || Date.now;
433
+ let persistentClient = null;
434
+ let verifiedUser = null;
435
+ let verifiedAt = 0;
436
+ let verificationPromise = null;
437
+
438
+ function client() {
439
+ if (persistent && persistentClient) return persistentClient;
440
+ const created = createClient(config.supabaseUrl, config.supabaseKey, {
441
+ auth: {
442
+ flowType: "pkce",
443
+ persistSession: true,
444
+ storageKey: AUTH_STORAGE_KEY,
445
+ storage,
446
+ autoRefreshToken: persistent,
447
+ detectSessionInUrl: false,
448
+ debug: false,
449
+ lock: authLock.lock
450
+ }
451
+ });
452
+ if (persistent) {
453
+ persistentClient = created;
454
+ }
455
+ return created;
456
+ }
457
+
458
+ async function assertWriterAccount(authClient) {
459
+ const { data: userData, error: userError } = await authClient.auth.getUser();
460
+ if (userError || !userData?.user) throw cliError("E_AUTH_REQUIRED", "Google 계정으로 Writer CLI에 로그인해 주세요.", null, userError);
461
+ const email = String(userData.user.email || "").trim().toLowerCase();
462
+ if (!email) throw cliError("E_AUTHORIZATION", "이메일이 확인된 Google 계정이 필요합니다.");
463
+ const { data, error } = await authClient.rpc("ensure_somi_writer_account");
464
+ const account = Array.isArray(data) ? data[0] : data;
465
+ if (error || !account?.user_id) throw cliError("E_AUTHORIZATION", "Writer 회원정보와 접근권한을 확인할 수 없습니다.", null, error);
466
+ if (String(account.user_id) !== String(userData.user.id || "")
467
+ || String(account.email || "").trim().toLowerCase() !== email) {
468
+ throw cliError("E_AUTHORIZATION", "Writer 회원정보가 현재 Google 로그인과 일치하지 않습니다.");
469
+ }
470
+ return Object.freeze({
471
+ id: String(account.user_id),
472
+ email: String(account.email || email),
473
+ displayName: String(account.display_name || email.split("@")[0]),
474
+ isAdmin: account.is_admin === true,
475
+ activeOwnedCount: Number(account.active_owned_count || 0),
476
+ activeOwnedLimit: account.active_owned_limit === null ? null : Number(account.active_owned_limit)
477
+ });
478
+ }
479
+
480
+ async function withSession(callback) {
481
+ const authClient = client();
482
+ const currentTime = Number(now());
483
+ if (!verifiedUser || currentTime - verifiedAt >= staffVerificationMs) {
484
+ verificationPromise ||= assertWriterAccount(authClient).then(user => {
485
+ verifiedUser = user;
486
+ verifiedAt = Number(now());
487
+ return user;
488
+ }).finally(() => { verificationPromise = null; });
489
+ await verificationPromise;
490
+ }
491
+ const user = verifiedUser;
492
+ try { return await callback({ client: authClient, user }); }
493
+ catch (error) {
494
+ if (["E_AUTH_REQUIRED", "E_AUTHORIZATION", "42501"].includes(String(error?.code || "")) || [401, 403].includes(Number(error?.status))) {
495
+ verifiedUser = null;
496
+ verifiedAt = 0;
497
+ }
498
+ throw error;
499
+ }
500
+ }
501
+
502
+ async function login(loginOptions = {}) {
503
+ return authLock.lock("ssobig-writer-cli-login", 10_000, async () => {
504
+ const authClient = client();
505
+ let handled = false;
506
+ let completed = false;
507
+ let arrivalTimeout = null;
508
+ let callbackResolve;
509
+ let callbackReject;
510
+ let expectedHost = "";
511
+ const callback = new Promise((resolve, reject) => { callbackResolve = resolve; callbackReject = reject; });
512
+ callback.catch(() => {});
513
+ function closeCallbackServer({ force = false } = {}) {
514
+ server.close();
515
+ if (force) server.closeAllConnections?.();
516
+ }
517
+ const server = httpApi.createServer(async (request, response) => {
518
+ let requestUrl;
519
+ try { requestUrl = new URL(request.url, `http://${expectedHost}`); }
520
+ catch (error) { response.writeHead(400).end("Invalid callback"); return; }
521
+ if (completed || !expectedHost || request.headers.host !== expectedHost || requestUrl.pathname !== "/auth/callback" || handled) {
522
+ response.writeHead(404, { "Cache-Control": "no-store" }).end("Not found");
523
+ return;
524
+ }
525
+ handled = true;
526
+ if (arrivalTimeout) clearTimeout(arrivalTimeout);
527
+ let exchangeSucceeded = false;
528
+ try {
529
+ const oauthError = requestUrl.searchParams.get("error_description") || requestUrl.searchParams.get("error");
530
+ if (oauthError) throw cliError("E_AUTH_REQUIRED", `Google 로그인에 실패했습니다: ${oauthError}`);
531
+ const code = requestUrl.searchParams.get("code");
532
+ if (!code) throw cliError("E_AUTH_REQUIRED", "OAuth callback에 인증 code가 없습니다.");
533
+ const { error } = await authClient.auth.exchangeCodeForSession(code);
534
+ if (error) throw error;
535
+ exchangeSucceeded = true;
536
+ const user = await assertWriterAccount(authClient);
537
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
538
+ response.end("<!doctype html><meta charset=\"utf-8\"><title>SSOBIG WRITER CLI</title><p>로그인이 완료되었습니다. 이 창을 닫아도 됩니다.</p>");
539
+ completed = true;
540
+ callbackResolve(user);
541
+ } catch (error) {
542
+ try {
543
+ if (exchangeSucceeded) await authClient.auth.signOut({ scope: "local" });
544
+ else await storage.removeItem(`${AUTH_STORAGE_KEY}-code-verifier`);
545
+ } catch (cleanupError) { void cleanupError; }
546
+ response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
547
+ response.end("Writer CLI 로그인을 완료하지 못했습니다. 터미널의 오류를 확인해 주세요.");
548
+ completed = true;
549
+ callbackReject(error);
550
+ } finally {
551
+ closeCallbackServer();
552
+ }
553
+ });
554
+
555
+ await new Promise((resolve, reject) => {
556
+ server.once("error", reject);
557
+ server.listen(0, "127.0.0.1", () => {
558
+ expectedHost = `127.0.0.1:${server.address().port}`;
559
+ resolve();
560
+ });
561
+ });
562
+ arrivalTimeout = setTimeout(() => {
563
+ if (completed) return;
564
+ completed = true;
565
+ closeCallbackServer({ force: true });
566
+ callbackReject(cliError("E_AUTH_TIMEOUT", "Writer CLI 로그인이 시간 안에 완료되지 않았습니다."));
567
+ }, loginTimeoutMs);
568
+ try {
569
+ const port = server.address().port;
570
+ const redirectTo = `http://127.0.0.1:${port}/auth/callback`;
571
+ const { data, error } = await authClient.auth.signInWithOAuth({
572
+ provider: "google",
573
+ options: {
574
+ redirectTo,
575
+ skipBrowserRedirect: true,
576
+ queryParams: { prompt: "select_account" }
577
+ }
578
+ });
579
+ if (error || !data?.url) throw cliError("E_AUTH_REQUIRED", "Google 로그인 URL을 만들 수 없습니다.", null, error);
580
+ if (typeof loginOptions.onUrl === "function") loginOptions.onUrl(data.url);
581
+ if (loginOptions.openBrowser !== false) await openBrowser(data.url);
582
+ return await callback;
583
+ } catch (error) {
584
+ closeCallbackServer({ force: !completed });
585
+ try { await storage.removeItem(`${AUTH_STORAGE_KEY}-code-verifier`); } catch (cleanupError) { void cleanupError; }
586
+ throw error;
587
+ } finally {
588
+ if (arrivalTimeout) clearTimeout(arrivalTimeout);
589
+ }
590
+ });
591
+ }
592
+
593
+ async function whoami() {
594
+ return withSession(async ({ user }) => user);
595
+ }
596
+
597
+ async function logout() {
598
+ const authClient = client();
599
+ let signOutError = null;
600
+ try {
601
+ const result = await authClient.auth.signOut({ scope: "local" });
602
+ signOutError = result.error || null;
603
+ } catch (error) {
604
+ signOutError = error;
605
+ }
606
+ await storage.removeItem(AUTH_STORAGE_KEY);
607
+ await storage.removeItem(`${AUTH_STORAGE_KEY}-code-verifier`);
608
+ if (persistentClient?.auth?.stopAutoRefresh) persistentClient.auth.stopAutoRefresh();
609
+ persistentClient = null;
610
+ verifiedUser = null;
611
+ verifiedAt = 0;
612
+ return { loggedOut: true, remoteSignOutWarning: Boolean(signOutError) };
613
+ }
614
+
615
+ async function dispose() {
616
+ const authClient = persistentClient;
617
+ persistentClient = null;
618
+ verifiedUser = null;
619
+ verifiedAt = 0;
620
+ verificationPromise = null;
621
+ if (authClient?.auth?.stopAutoRefresh) authClient.auth.stopAutoRefresh();
622
+ }
623
+
624
+ return Object.freeze({ login, logout, whoami, withSession, dispose, storage });
625
+ }
626
+
627
+ async function inspectStoredSession(storage, options = {}) {
628
+ const stored = await storage.getItem(AUTH_STORAGE_KEY);
629
+ if (stored === null || stored === undefined || stored === "") return Object.freeze({ status: "missing" });
630
+ let session;
631
+ try { session = JSON.parse(String(stored)); }
632
+ catch (error) { return Object.freeze({ status: "malformed" }); }
633
+ if (!session || typeof session !== "object" || typeof session.access_token !== "string" || !session.access_token) {
634
+ return Object.freeze({ status: "malformed" });
635
+ }
636
+ const expiresAt = Number(session.expires_at || 0);
637
+ const nowSeconds = Math.floor(Number(typeof options.now === "function" ? options.now() : Date.now()) / 1000);
638
+ if (Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= nowSeconds) return Object.freeze({ status: "expired" });
639
+ return Object.freeze({ status: "active" });
640
+ }
641
+
642
+ module.exports = Object.freeze({
643
+ KEYCHAIN_SERVICE,
644
+ AUTH_STORAGE_KEY,
645
+ DPAPI_STORAGE_VERSION,
646
+ createFileLock,
647
+ createKeychainStorage,
648
+ createWindowsDpapiStorage,
649
+ createCredentialStorage,
650
+ createAuthManager,
651
+ inspectStoredSession,
652
+ defaultCommandRunner,
653
+ commandSucceeded,
654
+ defaultOpenBrowser
655
+ });