@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,83 @@
1
+ "use strict";
2
+
3
+ const { validateLoadedVersion } = require("./domain.cjs");
4
+ const { normalizeError } = require("./errors.cjs");
5
+ const { sha256 } = require("./json.cjs");
6
+
7
+ function rowKey(projectId, versionNumber) {
8
+ return `${String(projectId)}\u0000${Number(versionNumber)}`;
9
+ }
10
+
11
+ function groupRows(rows) {
12
+ const grouped = new Map();
13
+ for (const row of rows || []) {
14
+ const key = rowKey(row?.project_id, row?.version_number);
15
+ if (!grouped.has(key)) grouped.set(key, []);
16
+ grouped.get(key).push(row);
17
+ }
18
+ return grouped;
19
+ }
20
+
21
+ function buildValidatedSnapshot(corpus, actor, options = {}) {
22
+ const projects = new Map((corpus?.projects || []).map(project => [String(project.id), project]));
23
+ const sets = new Map((corpus?.componentSetRows || []).map(row => [rowKey(row.project_id, row.version_number), row]));
24
+ const components = groupRows(corpus?.componentRows);
25
+ const views = groupRows(corpus?.viewRows);
26
+ const bindings = groupRows(corpus?.bindingRows);
27
+ const versions = [];
28
+ const rejected = [];
29
+
30
+ for (const version of corpus?.versionRows || []) {
31
+ const project = projects.get(String(version.project_id));
32
+ const key = rowKey(version.project_id, version.version_number);
33
+ try {
34
+ const loaded = {
35
+ project,
36
+ version,
37
+ componentSet: sets.get(key) || null,
38
+ componentRows: components.get(key) || [],
39
+ viewRows: views.get(key) || [],
40
+ bindingRows: bindings.get(key) || []
41
+ };
42
+ const validated = validateLoadedVersion(loaded);
43
+ versions.push(Object.freeze({
44
+ ...loaded,
45
+ versionNumber: validated.versionNumber,
46
+ instances: validated.instances,
47
+ views: validated.views
48
+ }));
49
+ } catch (error) {
50
+ const normalized = normalizeError(error);
51
+ rejected.push(Object.freeze({
52
+ projectId: String(version.project_id || ""),
53
+ projectSlug: String(project?.slug || ""),
54
+ versionNumber: Number(version.version_number),
55
+ code: normalized.code,
56
+ message: normalized.message
57
+ }));
58
+ }
59
+ }
60
+
61
+ const syncedAtValue = typeof options.now === "function" ? options.now() : new Date();
62
+ const syncedAt = syncedAtValue instanceof Date ? syncedAtValue.toISOString() : new Date(syncedAtValue).toISOString();
63
+ const revisionVector = versions.map(item => ({
64
+ projectId: item.project.id,
65
+ versionNumber: item.versionNumber,
66
+ componentSetRevision: Number(item.componentSet.revision),
67
+ componentChecksum: String(item.componentSet.component_checksum)
68
+ }));
69
+ return Object.freeze({
70
+ snapshot: Object.freeze({
71
+ source: "supabase-authoritative-read",
72
+ authoritative: false,
73
+ disposable: true,
74
+ syncedAt,
75
+ actor: { id: String(actor?.id || ""), email: String(actor?.email || "") },
76
+ revisionVectorHash: sha256(revisionVector),
77
+ versions
78
+ }),
79
+ rejected: Object.freeze(rejected)
80
+ });
81
+ }
82
+
83
+ module.exports = Object.freeze({ buildValidatedSnapshot, groupRows, rowKey });
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const { createAgentService } = require("./agent-service.cjs");
5
+ const { resolveAgentPaths, ensurePrivateDirectory } = require("./agent-paths.cjs");
6
+ const { createDaemonServer } = require("./daemon-server.cjs");
7
+ const { cliError } = require("./errors.cjs");
8
+ const { INDEX_SCHEMA_VERSION } = require("./local-index.cjs");
9
+ const { computeSourceFingerprint } = require("./source-fingerprint.cjs");
10
+ const { version: TOOL_VERSION } = require("../package.json");
11
+ const { checkRequiredUpdate } = require("./update-gate.cjs");
12
+
13
+ const METHOD_MAP = Object.freeze({
14
+ "writer.sync": "sync",
15
+ "writer.search": "search",
16
+ "writer.read": "read",
17
+ "writer.get": "get",
18
+ "writer.plan_replace": "planReplace",
19
+ "writer.apply": "apply",
20
+ "writer.purge": "purge",
21
+ "writer.logout": "logout"
22
+ });
23
+
24
+ async function runDaemon(options = {}) {
25
+ if (options.enforceUpdates === true) {
26
+ const update = await (options.updateCheck || checkRequiredUpdate)({
27
+ currentVersion: TOOL_VERSION,
28
+ fetchLatest: options.fetchLatest,
29
+ fetch: options.fetch,
30
+ timeoutMs: options.updateTimeoutMs,
31
+ ttlMs: options.updateTtlMs,
32
+ fs: options.fs,
33
+ cacheDir: options.cacheDir,
34
+ platform: options.platform,
35
+ environment: options.environment,
36
+ homeDirectory: options.homeDirectory,
37
+ now: options.now
38
+ });
39
+ if (update?.warning) (options.stderr || process.stderr).write(`${JSON.stringify({ warning: { code: "W_CLI_UPDATE_CHECK_UNAVAILABLE", message: update.warning } })}\n`);
40
+ }
41
+ const sourceFingerprint = String(options.sourceFingerprint || computeSourceFingerprint());
42
+ const paths = resolveAgentPaths({ cacheDir: options.cacheDir });
43
+ ensurePrivateDirectory(paths.cacheDir, options.fs || fs);
44
+ ensurePrivateDirectory(paths.daemonDir, options.fs || fs);
45
+ const service = options.service || createAgentService({
46
+ cacheDir: paths.cacheDir,
47
+ auth: options.auth,
48
+ authOptions: options.authOptions,
49
+ gatewayFactory: options.gatewayFactory,
50
+ indexFactory: options.indexFactory,
51
+ realtime: options.realtime
52
+ });
53
+ let closing = false;
54
+ let server;
55
+
56
+ async function close() {
57
+ if (closing) return;
58
+ closing = true;
59
+ await server?.close();
60
+ await service.close();
61
+ }
62
+
63
+ async function handler(request, context) {
64
+ if (request.method === "daemon.ping") return { running: true, protocol: request.protocol, toolVersion: TOOL_VERSION, indexSchemaVersion: INDEX_SCHEMA_VERSION, sourceFingerprint, pid: process.pid };
65
+ if (request.method === "daemon.status") return { ...(await service.status()), protocol: request.protocol, toolVersion: TOOL_VERSION, indexSchemaVersion: INDEX_SCHEMA_VERSION, sourceFingerprint };
66
+ if (request.method === "daemon.shutdown") {
67
+ setImmediate(() => close().catch(() => {}));
68
+ return { shuttingDown: true };
69
+ }
70
+ const method = METHOD_MAP[request.method];
71
+ if (!method) throw cliError("E_DAEMON_METHOD", `지원하지 않는 Writer daemon method입니다: ${request.method}`);
72
+ const result = await service.handle(method, request.params);
73
+ if (request.method === "writer.logout") {
74
+ setImmediate(() => close().catch(() => {}));
75
+ }
76
+ return result;
77
+ }
78
+
79
+ server = createDaemonServer({
80
+ cacheDir: paths.daemonDir,
81
+ socketPath: paths.socketPath,
82
+ handler,
83
+ requestLane: request => ["daemon.ping", "daemon.status", "writer.search", "writer.read"].includes(request.method) ? "local-read" : "authoritative-state",
84
+ requestTimeoutMs: options.requestTimeoutMs || 600_000,
85
+ closeTimeoutMs: options.closeTimeoutMs
86
+ });
87
+ await server.start();
88
+
89
+ const signalHandler = () => { close().catch(() => {}); };
90
+ process.once("SIGINT", signalHandler);
91
+ process.once("SIGTERM", signalHandler);
92
+ return Object.freeze({
93
+ cacheDir: paths.cacheDir,
94
+ daemonDir: paths.daemonDir,
95
+ socketPath: paths.socketPath,
96
+ service,
97
+ server,
98
+ close: async () => {
99
+ process.off("SIGINT", signalHandler);
100
+ process.off("SIGTERM", signalHandler);
101
+ await close();
102
+ }
103
+ });
104
+ }
105
+
106
+ module.exports = Object.freeze({ METHOD_MAP, runDaemon });
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+
3
+ const net = require("node:net");
4
+ const { cliError, WriterCliError } = require("./errors.cjs");
5
+ const { resolveDaemonPaths, verifyDaemonEndpoint } = require("./daemon-server.cjs");
6
+ const {
7
+ PROTOCOL,
8
+ DEFAULT_MAX_REQUEST_BYTES,
9
+ DEFAULT_MAX_RESPONSE_BYTES,
10
+ DEFAULT_REQUEST_TIMEOUT_MS,
11
+ byteLimit,
12
+ timeoutLimit,
13
+ requestTimeoutError,
14
+ createRequest,
15
+ validateResponse,
16
+ encodeFrame,
17
+ parseFrame
18
+ } = require("./daemon-protocol.cjs");
19
+
20
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
21
+
22
+ function createDaemonClient(options = {}) {
23
+ if (Object.hasOwn(options, "host") || Object.hasOwn(options, "port")) {
24
+ throw cliError("E_DAEMON_TCP_FORBIDDEN", "Writer daemon은 Unix domain socket만 사용할 수 있습니다.");
25
+ }
26
+ const netApi = options.net || net;
27
+ const paths = resolveDaemonPaths(options);
28
+ const { socketPath } = paths;
29
+ const maxRequestBytes = byteLimit(options.maxRequestBytes, DEFAULT_MAX_REQUEST_BYTES, "maxRequestBytes");
30
+ const maxResponseBytes = byteLimit(options.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES, "maxResponseBytes");
31
+ const defaultTimeoutMs = timeoutLimit(options.timeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
32
+ const connectTimeoutMs = timeoutLimit(options.connectTimeoutMs, DEFAULT_CONNECT_TIMEOUT_MS);
33
+ const pending = new Map();
34
+ const reservedIds = new Set();
35
+ let socket = null;
36
+ let connectPromise = null;
37
+ let connectingSocket = null;
38
+ let rejectConnection = null;
39
+ let responseBuffer = Buffer.alloc(0);
40
+ let closed = false;
41
+
42
+ function rejectAll(error) {
43
+ for (const entry of pending.values()) {
44
+ clearTimeout(entry.timer);
45
+ entry.reject(error);
46
+ }
47
+ pending.clear();
48
+ }
49
+
50
+ function failConnection(error) {
51
+ const normalized = error?.code?.startsWith?.("E_")
52
+ ? error
53
+ : cliError("E_DAEMON_UNAVAILABLE", "Writer daemon 연결이 종료되었습니다.", null, error);
54
+ rejectAll(normalized);
55
+ if (socket && !socket.destroyed) socket.destroy();
56
+ socket = null;
57
+ connectPromise = null;
58
+ responseBuffer = Buffer.alloc(0);
59
+ }
60
+
61
+ function receive(chunk) {
62
+ responseBuffer = Buffer.concat([responseBuffer, chunk]);
63
+ while (true) {
64
+ const newline = responseBuffer.indexOf(0x0a);
65
+ if (newline === -1) {
66
+ if (responseBuffer.length > maxResponseBytes) failConnection(cliError("E_DAEMON_RESPONSE_TOO_LARGE", "daemon response가 허용 크기를 초과했습니다."));
67
+ return;
68
+ }
69
+ if (newline > maxResponseBytes) {
70
+ failConnection(cliError("E_DAEMON_RESPONSE_TOO_LARGE", "daemon response가 허용 크기를 초과했습니다."));
71
+ return;
72
+ }
73
+ const frame = responseBuffer.subarray(0, newline);
74
+ responseBuffer = responseBuffer.subarray(newline + 1);
75
+ let response;
76
+ try { response = validateResponse(parseFrame(frame, "response")); }
77
+ catch (error) { failConnection(error); return; }
78
+ const entry = pending.get(response.id);
79
+ if (!entry) continue;
80
+ pending.delete(response.id);
81
+ clearTimeout(entry.timer);
82
+ if (response.ok) entry.resolve(response.result);
83
+ else entry.reject(new WriterCliError(response.error.code, response.error.message, response.error.details || null));
84
+ }
85
+ }
86
+
87
+ function connect() {
88
+ if (closed) return Promise.reject(cliError("E_DAEMON_CLIENT_CLOSED", "닫힌 Writer daemon client는 다시 사용할 수 없습니다."));
89
+ if (socket && !socket.destroyed && socket.readyState === "open") return Promise.resolve(socket);
90
+ if (connectPromise) return connectPromise;
91
+ verifyDaemonEndpoint(paths, { fs: options.fs, uid: options.uid });
92
+ connectPromise = new Promise((resolve, reject) => {
93
+ const candidate = netApi.createConnection({ path: socketPath });
94
+ connectingSocket = candidate;
95
+ let settled = false;
96
+ rejectConnection = error => {
97
+ if (settled) return;
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ candidate.destroy();
101
+ connectingSocket = null;
102
+ rejectConnection = null;
103
+ connectPromise = null;
104
+ reject(error);
105
+ };
106
+ const timer = setTimeout(() => {
107
+ rejectConnection?.(cliError("E_DAEMON_CONNECT_TIMEOUT", "Writer daemon 연결 시간이 초과되었습니다."));
108
+ }, connectTimeoutMs);
109
+ timer.unref?.();
110
+ candidate.once("connect", () => {
111
+ if (settled) return;
112
+ settled = true;
113
+ clearTimeout(timer);
114
+ socket = candidate;
115
+ connectingSocket = null;
116
+ rejectConnection = null;
117
+ connectPromise = null;
118
+ candidate.setNoDelay?.(true);
119
+ candidate.on("data", receive);
120
+ candidate.once("error", error => failConnection(error));
121
+ candidate.once("close", () => {
122
+ if (socket === candidate) failConnection(cliError("E_DAEMON_UNAVAILABLE", "Writer daemon 연결이 종료되었습니다."));
123
+ });
124
+ resolve(candidate);
125
+ });
126
+ candidate.once("error", error => {
127
+ if (settled) return;
128
+ rejectConnection?.(cliError("E_DAEMON_UNAVAILABLE", "Writer daemon에 연결할 수 없습니다.", null, error));
129
+ });
130
+ });
131
+ return connectPromise;
132
+ }
133
+
134
+ async function request(method, params = {}, requestOptions = {}) {
135
+ if (closed) throw cliError("E_DAEMON_CLIENT_CLOSED", "닫힌 Writer daemon client는 다시 사용할 수 없습니다.");
136
+ const timeoutMs = timeoutLimit(requestOptions.timeoutMs, defaultTimeoutMs);
137
+ const requestValue = createRequest(method, params, { id: requestOptions.id, timeoutMs });
138
+ if (pending.has(requestValue.id) || reservedIds.has(requestValue.id)) throw cliError("E_DAEMON_DUPLICATE_ID", "동일한 daemon request id가 이미 처리 중입니다.");
139
+ const frame = encodeFrame(requestValue, maxRequestBytes, "request");
140
+ reservedIds.add(requestValue.id);
141
+ let activeSocket;
142
+ try { activeSocket = await connect(); }
143
+ catch (error) { reservedIds.delete(requestValue.id); throw error; }
144
+ reservedIds.delete(requestValue.id);
145
+ return new Promise((resolve, reject) => {
146
+ const timer = setTimeout(() => {
147
+ if (!pending.delete(requestValue.id)) return;
148
+ reject(requestTimeoutError(requestValue.id));
149
+ }, timeoutMs + 100);
150
+ timer.unref?.();
151
+ pending.set(requestValue.id, { resolve, reject, timer });
152
+ activeSocket.write(frame, error => {
153
+ if (!error) return;
154
+ const entry = pending.get(requestValue.id);
155
+ if (!entry) return;
156
+ pending.delete(requestValue.id);
157
+ clearTimeout(entry.timer);
158
+ reject(cliError("E_DAEMON_UNAVAILABLE", "Writer daemon 요청을 보낼 수 없습니다.", null, error));
159
+ });
160
+ });
161
+ }
162
+
163
+ async function close() {
164
+ if (closed) return;
165
+ closed = true;
166
+ rejectAll(cliError("E_DAEMON_CLIENT_CLOSED", "Writer daemon client가 닫혔습니다."));
167
+ reservedIds.clear();
168
+ if (rejectConnection) rejectConnection(cliError("E_DAEMON_CLIENT_CLOSED", "Writer daemon client가 닫혔습니다."));
169
+ else connectingSocket?.destroy();
170
+ connectingSocket = null;
171
+ rejectConnection = null;
172
+ const current = socket;
173
+ socket = null;
174
+ connectPromise = null;
175
+ if (!current || current.destroyed) return;
176
+ await new Promise(resolve => {
177
+ const timer = setTimeout(() => { current.destroy(); resolve(); }, 250);
178
+ timer.unref?.();
179
+ current.once("close", () => { clearTimeout(timer); resolve(); });
180
+ current.end();
181
+ });
182
+ }
183
+
184
+ return Object.freeze({ protocol: PROTOCOL, socketPath, request, close });
185
+ }
186
+
187
+ module.exports = Object.freeze({ DEFAULT_CONNECT_TIMEOUT_MS, createDaemonClient });
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const { TextDecoder } = require("node:util");
5
+ const { cliError, normalizeError, redact } = require("./errors.cjs");
6
+
7
+ const PROTOCOL = "ssobig-writer-daemon-v1";
8
+ const DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
9
+ const DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
10
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
11
+ const MIN_MESSAGE_BYTES = 256;
12
+ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
13
+ const MIN_TIMEOUT_MS = 50;
14
+ const MAX_TIMEOUT_MS = 600_000;
15
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
16
+
17
+ function byteLimit(value, fallback, label) {
18
+ const candidate = value === undefined ? fallback : Number(value);
19
+ if (!Number.isSafeInteger(candidate) || candidate < MIN_MESSAGE_BYTES || candidate > MAX_MESSAGE_BYTES) {
20
+ throw cliError("E_DAEMON_CONFIG", `${label}은 ${MIN_MESSAGE_BYTES}~${MAX_MESSAGE_BYTES} byte 정수여야 합니다.`);
21
+ }
22
+ return candidate;
23
+ }
24
+
25
+ function timeoutLimit(value, fallback = DEFAULT_REQUEST_TIMEOUT_MS) {
26
+ const candidate = value === undefined ? fallback : Number(value);
27
+ if (!Number.isSafeInteger(candidate) || candidate < MIN_TIMEOUT_MS || candidate > MAX_TIMEOUT_MS) {
28
+ throw cliError("E_DAEMON_CONFIG", `timeout은 ${MIN_TIMEOUT_MS}~${MAX_TIMEOUT_MS}ms 정수여야 합니다.`);
29
+ }
30
+ return candidate;
31
+ }
32
+
33
+ function requestTimeoutError(requestId) {
34
+ const id = assertRequestId(requestId);
35
+ return cliError(
36
+ "E_DAEMON_REQUEST_TIMEOUT",
37
+ "Writer daemon 요청 처리 시간이 초과되어 최종 결과를 알 수 없습니다. 같은 요청을 자동 재시도하지 마세요.",
38
+ { requestId: id, outcome: "unknown", doNotRetry: true }
39
+ );
40
+ }
41
+
42
+ function isRecord(value) {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
44
+ const prototype = Object.getPrototypeOf(value);
45
+ return prototype === Object.prototype || prototype === null;
46
+ }
47
+
48
+ function assertRequestId(id) {
49
+ const value = String(id || "");
50
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
51
+ throw cliError("E_DAEMON_PROTOCOL", "daemon request id 형식이 올바르지 않습니다.");
52
+ }
53
+ return value;
54
+ }
55
+
56
+ function assertMethod(method) {
57
+ const value = String(method || "");
58
+ if (!/^[a-z][a-z0-9_.:-]{0,127}$/.test(value)) {
59
+ throw cliError("E_DAEMON_PROTOCOL", "daemon method 형식이 올바르지 않습니다.");
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function createRequest(method, params = {}, options = {}) {
65
+ if (!isRecord(params)) throw cliError("E_DAEMON_PROTOCOL", "daemon params는 JSON object여야 합니다.");
66
+ return {
67
+ protocol: PROTOCOL,
68
+ id: assertRequestId(options.id || crypto.randomUUID()),
69
+ method: assertMethod(method),
70
+ params,
71
+ timeoutMs: timeoutLimit(options.timeoutMs)
72
+ };
73
+ }
74
+
75
+ function validateRequest(value) {
76
+ if (!isRecord(value) || value.protocol !== PROTOCOL) {
77
+ throw cliError("E_DAEMON_PROTOCOL", "지원하지 않는 daemon protocol입니다.");
78
+ }
79
+ if (!isRecord(value.params)) throw cliError("E_DAEMON_PROTOCOL", "daemon params는 JSON object여야 합니다.");
80
+ const allowed = new Set(["protocol", "id", "method", "params", "timeoutMs"]);
81
+ const unknown = Object.keys(value).find(key => !allowed.has(key));
82
+ if (unknown) throw cliError("E_DAEMON_PROTOCOL", `지원하지 않는 daemon request 필드입니다: ${unknown}`);
83
+ return {
84
+ protocol: PROTOCOL,
85
+ id: assertRequestId(value.id),
86
+ method: assertMethod(value.method),
87
+ params: value.params,
88
+ timeoutMs: timeoutLimit(value.timeoutMs)
89
+ };
90
+ }
91
+
92
+ function successResponse(id, result) {
93
+ return { protocol: PROTOCOL, id: assertRequestId(id), ok: true, result: result === undefined ? null : result };
94
+ }
95
+
96
+ function sanitizeValue(value, key = "", depth = 0) {
97
+ if (/token|authorization|cookie|session/i.test(key)) return "[REDACTED]";
98
+ if (depth > 20) return "[REDACTED_DEPTH]";
99
+ if (typeof value === "string") return redact(value);
100
+ if (Array.isArray(value)) return value.map(item => sanitizeValue(item, "", depth + 1));
101
+ if (isRecord(value)) {
102
+ const output = {};
103
+ for (const [childKey, childValue] of Object.entries(value)) output[childKey] = sanitizeValue(childValue, childKey, depth + 1);
104
+ return output;
105
+ }
106
+ if (value === null || ["boolean", "number"].includes(typeof value)) return value;
107
+ return String(value ?? "");
108
+ }
109
+
110
+ function errorResponse(id, error) {
111
+ const normalized = normalizeError(error);
112
+ return {
113
+ protocol: PROTOCOL,
114
+ id: id === null ? null : assertRequestId(id),
115
+ ok: false,
116
+ error: {
117
+ code: normalized.code,
118
+ message: redact(normalized.message),
119
+ ...(normalized.details ? { details: sanitizeValue(normalized.details) } : {})
120
+ }
121
+ };
122
+ }
123
+
124
+ function validateResponse(value) {
125
+ if (!isRecord(value) || value.protocol !== PROTOCOL || typeof value.ok !== "boolean") {
126
+ throw cliError("E_DAEMON_PROTOCOL", "daemon response 형식이 올바르지 않습니다.");
127
+ }
128
+ const id = assertRequestId(value.id);
129
+ if (value.ok) {
130
+ if (!Object.hasOwn(value, "result")) throw cliError("E_DAEMON_PROTOCOL", "daemon success response에 result가 없습니다.");
131
+ return { protocol: PROTOCOL, id, ok: true, result: value.result };
132
+ }
133
+ if (!isRecord(value.error) || !/^E_[A-Z0-9_]+$/.test(String(value.error.code || "")) || typeof value.error.message !== "string") {
134
+ throw cliError("E_DAEMON_PROTOCOL", "daemon error response 형식이 올바르지 않습니다.");
135
+ }
136
+ return { protocol: PROTOCOL, id, ok: false, error: value.error };
137
+ }
138
+
139
+ function encodeFrame(value, maxBytes, kind = "message") {
140
+ let json;
141
+ try { json = JSON.stringify(value); }
142
+ catch (error) { throw cliError("E_DAEMON_PROTOCOL", `daemon ${kind}를 JSON으로 만들 수 없습니다.`, null, error); }
143
+ if (json === undefined) throw cliError("E_DAEMON_PROTOCOL", `daemon ${kind}를 JSON으로 만들 수 없습니다.`);
144
+ const frame = Buffer.from(`${json}\n`, "utf8");
145
+ if (frame.length - 1 > maxBytes) {
146
+ const code = kind === "request" ? "E_DAEMON_REQUEST_TOO_LARGE" : "E_DAEMON_RESPONSE_TOO_LARGE";
147
+ throw cliError(code, `daemon ${kind}가 허용 크기를 초과했습니다.`);
148
+ }
149
+ return frame;
150
+ }
151
+
152
+ function parseFrame(buffer, kind = "message") {
153
+ let source;
154
+ try { source = utf8Decoder.decode(buffer); }
155
+ catch (error) { throw cliError("E_DAEMON_PROTOCOL", `daemon ${kind}가 UTF-8이 아닙니다.`, null, error); }
156
+ if (source.endsWith("\r")) source = source.slice(0, -1);
157
+ if (!source) throw cliError("E_DAEMON_PROTOCOL", `비어 있는 daemon ${kind}입니다.`);
158
+ try { return JSON.parse(source); }
159
+ catch (error) { throw cliError("E_DAEMON_PROTOCOL", `daemon ${kind} JSON이 올바르지 않습니다.`, null, error); }
160
+ }
161
+
162
+ module.exports = Object.freeze({
163
+ PROTOCOL,
164
+ DEFAULT_MAX_REQUEST_BYTES,
165
+ DEFAULT_MAX_RESPONSE_BYTES,
166
+ DEFAULT_REQUEST_TIMEOUT_MS,
167
+ MIN_MESSAGE_BYTES,
168
+ MAX_MESSAGE_BYTES,
169
+ MIN_TIMEOUT_MS,
170
+ MAX_TIMEOUT_MS,
171
+ byteLimit,
172
+ timeoutLimit,
173
+ requestTimeoutError,
174
+ isRecord,
175
+ assertRequestId,
176
+ assertMethod,
177
+ createRequest,
178
+ validateRequest,
179
+ successResponse,
180
+ errorResponse,
181
+ validateResponse,
182
+ encodeFrame,
183
+ parseFrame
184
+ });
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+ const { spawn } = require("node:child_process");
5
+ const { setTimeout: delay } = require("node:timers/promises");
6
+ const { createDaemonClient } = require("./daemon-client.cjs");
7
+ const { resolveAgentPaths } = require("./agent-paths.cjs");
8
+ const { cliError } = require("./errors.cjs");
9
+ const { INDEX_SCHEMA_VERSION } = require("./local-index.cjs");
10
+ const { computeSourceFingerprint } = require("./source-fingerprint.cjs");
11
+ const { version: TOOL_VERSION } = require("../package.json");
12
+
13
+ const DAEMON_BIN = path.resolve(__dirname, "../bin/ssobig-writer-daemon.cjs");
14
+ const CACHE_ENV = "SSOBIG_WRITER_AGENT_CACHE_DIR";
15
+
16
+ function effectiveCacheDir(options = {}, environment = process.env) {
17
+ return options.cacheDir || environment[CACHE_ENV] || undefined;
18
+ }
19
+
20
+ function daemonClient(options = {}) {
21
+ const paths = resolveAgentPaths({ cacheDir: effectiveCacheDir(options) });
22
+ return createDaemonClient({ cacheDir: paths.daemonDir, socketPath: paths.socketPath, timeoutMs: options.timeoutMs });
23
+ }
24
+
25
+ async function probeDaemon(options = {}) {
26
+ const expectedSourceFingerprint = String(options.sourceFingerprint || computeSourceFingerprint());
27
+ const client = daemonClient({ ...options, timeoutMs: options.timeoutMs || 1_000 });
28
+ try {
29
+ const result = await client.request("daemon.ping", {}, { timeoutMs: options.timeoutMs || 1_000 });
30
+ if (result?.toolVersion !== TOOL_VERSION
31
+ || result?.indexSchemaVersion !== INDEX_SCHEMA_VERSION
32
+ || result?.sourceFingerprint !== expectedSourceFingerprint) {
33
+ throw cliError("E_DAEMON_VERSION_MISMATCH", "실행 중인 Writer daemon과 현재 CLI source 계약이 다릅니다.", {
34
+ expectedToolVersion: TOOL_VERSION,
35
+ actualToolVersion: result?.toolVersion || null,
36
+ expectedIndexSchemaVersion: INDEX_SCHEMA_VERSION,
37
+ actualIndexSchemaVersion: result?.indexSchemaVersion || null,
38
+ expectedSourceFingerprint,
39
+ actualSourceFingerprint: result?.sourceFingerprint || null
40
+ });
41
+ }
42
+ return result;
43
+ }
44
+ finally { await client.close(); }
45
+ }
46
+
47
+ async function startDaemon(options = {}) {
48
+ try { return { alreadyRunning: true, ...(await probeDaemon({ ...options, timeoutMs: 250 })) }; }
49
+ catch (error) {
50
+ if (error.code === "E_DAEMON_VERSION_MISMATCH") await stopDaemon({ ...options, timeoutMs: 120_000 });
51
+ else
52
+ if (!["E_DAEMON_UNAVAILABLE", "E_DAEMON_CONNECT_TIMEOUT"].includes(error.code)) throw error;
53
+ }
54
+ const child = (options.spawn || spawn)(process.execPath, [DAEMON_BIN, "--foreground"], {
55
+ detached: true,
56
+ shell: false,
57
+ stdio: "ignore",
58
+ env: { ...process.env, ...(effectiveCacheDir(options) ? { [CACHE_ENV]: path.resolve(String(effectiveCacheDir(options))) } : {}) }
59
+ });
60
+ child.unref?.();
61
+ const deadline = Date.now() + Math.max(500, Number(options.startTimeoutMs || 5_000));
62
+ let lastError = null;
63
+ while (Date.now() < deadline) {
64
+ await delay(50);
65
+ try { return { alreadyRunning: false, ...(await probeDaemon({ ...options, timeoutMs: 250 })) }; }
66
+ catch (error) { lastError = error; }
67
+ }
68
+ throw cliError("E_DAEMON_START", "Writer daemon이 시간 안에 시작되지 않았습니다.", null, lastError);
69
+ }
70
+
71
+ async function requestDaemon(method, params = {}, options = {}) {
72
+ if (options.autoStart !== false) await startDaemon(options);
73
+ const client = daemonClient(options);
74
+ try { return await client.request(method, params, { timeoutMs: options.timeoutMs }); }
75
+ catch (error) {
76
+ if (method === "writer.apply" && error.code === "E_DAEMON_REQUEST_TIMEOUT") {
77
+ throw cliError("E_AMBIGUOUS_SAVE", "daemon apply 결과를 시간 안에 확인하지 못했습니다. 같은 plan을 재적용하지 말고 receipt와 서버 원본을 확인해 주세요.", {
78
+ ...(error.details || {}),
79
+ readBackRequired: true,
80
+ doNotRetry: true
81
+ }, error);
82
+ }
83
+ throw error;
84
+ }
85
+ finally { await client.close(); }
86
+ }
87
+
88
+ async function stopDaemon(options = {}) {
89
+ const client = daemonClient({ ...options, timeoutMs: options.timeoutMs || 5_000 });
90
+ try { return await client.request("daemon.shutdown", {}, { timeoutMs: options.timeoutMs || 5_000 }); }
91
+ catch (error) {
92
+ if (error.code === "E_DAEMON_UNAVAILABLE") return { running: false };
93
+ throw error;
94
+ } finally { await client.close(); }
95
+ }
96
+
97
+ module.exports = Object.freeze({ DAEMON_BIN, CACHE_ENV, effectiveCacheDir, daemonClient, probeDaemon, startDaemon, requestDaemon, stopDaemon });