@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.
- package/README.md +35 -0
- package/asset-repository.js +278 -0
- package/config.js +14 -0
- package/package.json +28 -0
- package/project-runtime.js +102 -0
- package/storage-path.js +110 -0
- package/templates/mystery-v1/authoring-view-preference.js +34 -0
- package/templates/mystery-v1/character-perspective-preview.js +61 -0
- package/templates/mystery-v1/component-asset-operations.js +103 -0
- package/templates/mystery-v1/component-autosave.js +121 -0
- package/templates/mystery-v1/component-catalog-contract.js +340 -0
- package/templates/mystery-v1/component-checkpoint-history.js +145 -0
- package/templates/mystery-v1/component-contract.js +90 -0
- package/templates/mystery-v1/component-draft-operations.js +313 -0
- package/templates/mystery-v1/component-field-contracts.js +595 -0
- package/templates/mystery-v1/component-id-policy.js +64 -0
- package/templates/mystery-v1/component-manager.js +396 -0
- package/templates/mystery-v1/component-navigation-counts.js +64 -0
- package/templates/mystery-v1/component-registry.js +205 -0
- package/templates/mystery-v1/component-renderers.js +139 -0
- package/templates/mystery-v1/component-storage-contract.js +237 -0
- package/templates/mystery-v1/external-update-coordinator.js +91 -0
- package/templates/mystery-v1/output-clue-card-layout.js +46 -0
- package/templates/mystery-v1/page-header.js +26 -0
- package/templates/mystery-v1/render-ui-state.js +76 -0
- package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
- package/templates/mystery-v1/tab-bar.js +87 -0
- package/templates/mystery-v1/view-component-contract.js +152 -0
- package/templates/mystery-v1/view-component-registry.js +44 -0
- package/templates/mystery-v1/view-component-runtime.js +95 -0
- package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
- package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
- package/tools/writer-cli/package-lock.json +121 -0
- package/tools/writer-cli/package.json +22 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
- package/tools/writer-cli/src/agent-paths.cjs +114 -0
- package/tools/writer-cli/src/agent-service.cjs +496 -0
- package/tools/writer-cli/src/asset-policy.cjs +113 -0
- package/tools/writer-cli/src/auth.cjs +655 -0
- package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
- package/tools/writer-cli/src/command-registry.cjs +152 -0
- package/tools/writer-cli/src/commands.cjs +841 -0
- package/tools/writer-cli/src/corpus.cjs +83 -0
- package/tools/writer-cli/src/daemon-app.cjs +106 -0
- package/tools/writer-cli/src/daemon-client.cjs +187 -0
- package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
- package/tools/writer-cli/src/daemon-runner.cjs +97 -0
- package/tools/writer-cli/src/daemon-server.cjs +378 -0
- package/tools/writer-cli/src/diagnostics.cjs +235 -0
- package/tools/writer-cli/src/domain.cjs +731 -0
- package/tools/writer-cli/src/errors.cjs +47 -0
- package/tools/writer-cli/src/gateway.cjs +357 -0
- package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
- package/tools/writer-cli/src/json-patch.cjs +98 -0
- package/tools/writer-cli/src/json.cjs +26 -0
- package/tools/writer-cli/src/local-index-cache.cjs +139 -0
- package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
- package/tools/writer-cli/src/local-index-query.cjs +304 -0
- package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
- package/tools/writer-cli/src/local-index-storage.cjs +284 -0
- package/tools/writer-cli/src/local-index.cjs +199 -0
- package/tools/writer-cli/src/mutations.cjs +722 -0
- package/tools/writer-cli/src/platform-runner.cjs +55 -0
- package/tools/writer-cli/src/project-import.cjs +485 -0
- package/tools/writer-cli/src/skill-manager.cjs +255 -0
- package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
- package/tools/writer-cli/src/update-gate.cjs +102 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const net = require("node:net");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { cliError } = require("./errors.cjs");
|
|
8
|
+
const {
|
|
9
|
+
PROTOCOL,
|
|
10
|
+
DEFAULT_MAX_REQUEST_BYTES,
|
|
11
|
+
DEFAULT_MAX_RESPONSE_BYTES,
|
|
12
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
13
|
+
byteLimit,
|
|
14
|
+
timeoutLimit,
|
|
15
|
+
requestTimeoutError,
|
|
16
|
+
validateRequest,
|
|
17
|
+
successResponse,
|
|
18
|
+
errorResponse,
|
|
19
|
+
encodeFrame,
|
|
20
|
+
parseFrame
|
|
21
|
+
} = require("./daemon-protocol.cjs");
|
|
22
|
+
|
|
23
|
+
const DEFAULT_CACHE_DIRECTORY = path.join("Library", "Caches", "com.ssobig.writer-agent", "v1");
|
|
24
|
+
const DEFAULT_SOCKET_NAME = "daemon.sock";
|
|
25
|
+
const MAX_SOCKET_PATH_BYTES = 100;
|
|
26
|
+
|
|
27
|
+
function rejectTcpOptions(options) {
|
|
28
|
+
if (Object.hasOwn(options, "host") || Object.hasOwn(options, "port")) {
|
|
29
|
+
throw cliError("E_DAEMON_TCP_FORBIDDEN", "Writer daemon은 Unix domain socket만 사용할 수 있습니다.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveDaemonPaths(options = {}) {
|
|
34
|
+
rejectTcpOptions(options);
|
|
35
|
+
const homeDirectory = path.resolve(String(options.homeDirectory || os.homedir()));
|
|
36
|
+
const requestedSocketPath = options.socketPath ? path.resolve(String(options.socketPath)) : null;
|
|
37
|
+
const cacheDir = path.resolve(String(options.cacheDir || (requestedSocketPath ? path.dirname(requestedSocketPath) : path.join(homeDirectory, DEFAULT_CACHE_DIRECTORY))));
|
|
38
|
+
const socketName = String(options.socketName || DEFAULT_SOCKET_NAME);
|
|
39
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(socketName)) throw cliError("E_DAEMON_CONFIG", "daemon socket 이름이 올바르지 않습니다.");
|
|
40
|
+
const socketPath = requestedSocketPath || path.join(cacheDir, socketName);
|
|
41
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(path.basename(socketPath))) throw cliError("E_DAEMON_CONFIG", "daemon socket 경로의 파일 이름이 올바르지 않습니다.");
|
|
42
|
+
if (path.dirname(socketPath) !== cacheDir) throw cliError("E_DAEMON_CONFIG", "daemon socket은 전용 cache directory 바로 아래에 있어야 합니다.");
|
|
43
|
+
if (Buffer.byteLength(socketPath, "utf8") > MAX_SOCKET_PATH_BYTES) {
|
|
44
|
+
throw cliError("E_DAEMON_CONFIG", `daemon socket 경로가 너무 깁니다: ${Buffer.byteLength(socketPath, "utf8")} bytes`);
|
|
45
|
+
}
|
|
46
|
+
return Object.freeze({ cacheDir, socketPath });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function prepareCacheDirectory(cacheDir, options = {}) {
|
|
50
|
+
const fsApi = options.fs || fs;
|
|
51
|
+
try { fsApi.mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); }
|
|
52
|
+
catch (error) { throw cliError("E_DAEMON_CACHE", "Writer daemon cache directory를 만들 수 없습니다.", null, error); }
|
|
53
|
+
let stat;
|
|
54
|
+
try { stat = fsApi.lstatSync(cacheDir); }
|
|
55
|
+
catch (error) { throw cliError("E_DAEMON_CACHE", "Writer daemon cache directory를 확인할 수 없습니다.", null, error); }
|
|
56
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw cliError("E_DAEMON_CACHE_UNSAFE", "Writer daemon cache 경로는 symlink가 아닌 directory여야 합니다.");
|
|
57
|
+
const uid = typeof options.uid === "number" ? options.uid : process.getuid?.();
|
|
58
|
+
if (Number.isInteger(uid) && Number.isInteger(stat.uid) && stat.uid !== uid) {
|
|
59
|
+
throw cliError("E_DAEMON_CACHE_UNSAFE", "Writer daemon cache directory의 소유자가 현재 사용자와 다릅니다.");
|
|
60
|
+
}
|
|
61
|
+
try { fsApi.chmodSync(cacheDir, 0o700); }
|
|
62
|
+
catch (error) { throw cliError("E_DAEMON_CACHE_UNSAFE", "Writer daemon cache directory 권한을 0700으로 제한할 수 없습니다.", null, error); }
|
|
63
|
+
const verified = fsApi.lstatSync(cacheDir);
|
|
64
|
+
if ((verified.mode & 0o777) !== 0o700) throw cliError("E_DAEMON_CACHE_UNSAFE", "Writer daemon cache directory 권한은 0700이어야 합니다.");
|
|
65
|
+
return verified;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function verifyDaemonEndpoint(paths, options = {}) {
|
|
69
|
+
const fsApi = options.fs || fs;
|
|
70
|
+
const uid = typeof options.uid === "number" ? options.uid : process.getuid?.();
|
|
71
|
+
let directory;
|
|
72
|
+
let socket;
|
|
73
|
+
try {
|
|
74
|
+
directory = fsApi.lstatSync(paths.cacheDir);
|
|
75
|
+
socket = fsApi.lstatSync(paths.socketPath);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code === "ENOENT") throw cliError("E_DAEMON_UNAVAILABLE", "Writer daemon socket이 없습니다.", null, error);
|
|
78
|
+
throw cliError("E_DAEMON_SOCKET", "Writer daemon socket을 확인할 수 없습니다.", null, error);
|
|
79
|
+
}
|
|
80
|
+
if (directory.isSymbolicLink() || !directory.isDirectory() || (directory.mode & 0o777) !== 0o700) {
|
|
81
|
+
throw cliError("E_DAEMON_CACHE_UNSAFE", "Writer daemon cache directory가 안전한 0700 directory가 아닙니다.");
|
|
82
|
+
}
|
|
83
|
+
if (socket.isSymbolicLink() || !socket.isSocket() || (socket.mode & 0o777) !== 0o600) {
|
|
84
|
+
throw cliError("E_DAEMON_SOCKET_UNSAFE", "Writer daemon endpoint가 안전한 0600 Unix socket이 아닙니다.");
|
|
85
|
+
}
|
|
86
|
+
if (Number.isInteger(uid) && ((Number.isInteger(directory.uid) && directory.uid !== uid) || (Number.isInteger(socket.uid) && socket.uid !== uid))) {
|
|
87
|
+
throw cliError("E_DAEMON_SOCKET_UNSAFE", "Writer daemon endpoint의 소유자가 현재 사용자와 다릅니다.");
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze({ directory, socket });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function probeSocket(socketPath, options = {}) {
|
|
93
|
+
const netApi = options.net || net;
|
|
94
|
+
const timeoutMs = Number(options.timeoutMs || 500);
|
|
95
|
+
return new Promise(resolve => {
|
|
96
|
+
let settled = false;
|
|
97
|
+
const socket = netApi.createConnection({ path: socketPath });
|
|
98
|
+
function finish(result) {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
socket.destroy();
|
|
102
|
+
resolve(result);
|
|
103
|
+
}
|
|
104
|
+
socket.once("connect", () => finish({ status: "active" }));
|
|
105
|
+
socket.once("error", error => {
|
|
106
|
+
if (error.code === "ENOENT") finish({ status: "missing" });
|
|
107
|
+
else if (error.code === "ECONNREFUSED") finish({ status: "stale" });
|
|
108
|
+
else finish({ status: "unknown", code: error.code || "" });
|
|
109
|
+
});
|
|
110
|
+
socket.setTimeout(timeoutMs, () => finish({ status: "unknown", code: "ETIMEDOUT" }));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sameFile(left, right) {
|
|
115
|
+
return left && right && left.dev === right.dev && left.ino === right.ino;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function prepareSocketPath(socketPath, options = {}) {
|
|
119
|
+
const fsApi = options.fs || fs;
|
|
120
|
+
let initial;
|
|
121
|
+
try { initial = fsApi.lstatSync(socketPath); }
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error.code === "ENOENT") return;
|
|
124
|
+
throw cliError("E_DAEMON_SOCKET", "기존 Writer daemon socket을 확인할 수 없습니다.", null, error);
|
|
125
|
+
}
|
|
126
|
+
if (initial.isSymbolicLink() || !initial.isSocket()) {
|
|
127
|
+
throw cliError("E_DAEMON_SOCKET_UNSAFE", "기존 daemon socket 경로가 symlink 또는 socket이 아닌 파일입니다. 자동 삭제하지 않습니다.");
|
|
128
|
+
}
|
|
129
|
+
const probe = await (options.probe || probeSocket)(socketPath, options);
|
|
130
|
+
if (probe.status === "active") throw cliError("E_DAEMON_ALREADY_RUNNING", "Writer daemon이 이미 실행 중입니다.");
|
|
131
|
+
if (probe.status !== "stale") {
|
|
132
|
+
throw cliError("E_DAEMON_SOCKET_BUSY", "기존 daemon socket의 안전한 stale 여부를 확인할 수 없어 삭제하지 않습니다.");
|
|
133
|
+
}
|
|
134
|
+
let current;
|
|
135
|
+
try { current = fsApi.lstatSync(socketPath); }
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code === "ENOENT") return;
|
|
138
|
+
throw cliError("E_DAEMON_SOCKET", "기존 Writer daemon socket을 다시 확인할 수 없습니다.", null, error);
|
|
139
|
+
}
|
|
140
|
+
if (!sameFile(initial, current) || current.isSymbolicLink() || !current.isSocket()) {
|
|
141
|
+
throw cliError("E_DAEMON_SOCKET_BUSY", "확인 중 daemon socket이 교체되어 자동 삭제하지 않습니다.");
|
|
142
|
+
}
|
|
143
|
+
try { fsApi.unlinkSync(socketPath); }
|
|
144
|
+
catch (error) { if (error.code !== "ENOENT") throw cliError("E_DAEMON_SOCKET", "stale daemon socket을 제거할 수 없습니다.", null, error); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createDaemonServer(options = {}) {
|
|
148
|
+
rejectTcpOptions(options);
|
|
149
|
+
if (typeof options.handler !== "function") throw cliError("E_DAEMON_CONFIG", "Writer daemon request handler가 필요합니다.");
|
|
150
|
+
const fsApi = options.fs || fs;
|
|
151
|
+
const netApi = options.net || net;
|
|
152
|
+
const paths = resolveDaemonPaths(options);
|
|
153
|
+
const maxRequestBytes = byteLimit(options.maxRequestBytes, DEFAULT_MAX_REQUEST_BYTES, "maxRequestBytes");
|
|
154
|
+
const maxResponseBytes = byteLimit(options.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES, "maxResponseBytes");
|
|
155
|
+
const requestTimeoutMs = timeoutLimit(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
|
|
156
|
+
const closeTimeoutMs = timeoutLimit(options.closeTimeoutMs, 5_000);
|
|
157
|
+
const connections = new Set();
|
|
158
|
+
let server = null;
|
|
159
|
+
let state = "idle";
|
|
160
|
+
let socketIdentity = null;
|
|
161
|
+
const serialLanes = new Map();
|
|
162
|
+
let closePromise = null;
|
|
163
|
+
|
|
164
|
+
function writeResponse(socket, response) {
|
|
165
|
+
let frame;
|
|
166
|
+
try { frame = encodeFrame(response, maxResponseBytes, "response"); }
|
|
167
|
+
catch (error) {
|
|
168
|
+
const fallback = errorResponse(response?.id || null, error);
|
|
169
|
+
try { frame = encodeFrame(fallback, maxResponseBytes, "response"); }
|
|
170
|
+
catch (fallbackError) {
|
|
171
|
+
socket.destroy();
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!socket.destroyed && socket.writable) socket.write(frame);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function protocolFailure(socket, error, id = null, close = false) {
|
|
180
|
+
writeResponse(socket, errorResponse(id, error));
|
|
181
|
+
if (close) socket.end();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function queueRequest(socket, connection, request) {
|
|
185
|
+
connection.pending += 1;
|
|
186
|
+
const deadlineAt = Date.now() + Math.min(requestTimeoutMs, request.timeoutMs);
|
|
187
|
+
const lane = String(typeof options.requestLane === "function" ? options.requestLane(request) : "default");
|
|
188
|
+
const previous = (serialLanes.get(lane) || Promise.resolve()).catch(() => {});
|
|
189
|
+
let completeWork;
|
|
190
|
+
const handlerWork = previous.then(async () => {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
let shutdownAfterResponse = false;
|
|
193
|
+
let timeout;
|
|
194
|
+
const configuredTimeout = deadlineAt - Date.now();
|
|
195
|
+
if (configuredTimeout <= 0) {
|
|
196
|
+
writeResponse(socket, errorResponse(request.id, requestTimeoutError(request.id)));
|
|
197
|
+
connection.pending -= 1;
|
|
198
|
+
if ((state === "closing" || connection.closeAfterPending) && connection.pending === 0) socket.end();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const handlerPromise = Promise.resolve().then(() => options.handler(request, Object.freeze({
|
|
202
|
+
signal: controller.signal,
|
|
203
|
+
protocol: PROTOCOL,
|
|
204
|
+
socketPath: paths.socketPath,
|
|
205
|
+
requestShutdown() { shutdownAfterResponse = true; }
|
|
206
|
+
})));
|
|
207
|
+
handlerPromise.catch(() => {});
|
|
208
|
+
const timeoutPromise = new Promise((resolve, reject) => {
|
|
209
|
+
timeout = setTimeout(() => {
|
|
210
|
+
controller.abort();
|
|
211
|
+
reject(requestTimeoutError(request.id));
|
|
212
|
+
}, configuredTimeout);
|
|
213
|
+
timeout.unref?.();
|
|
214
|
+
});
|
|
215
|
+
try {
|
|
216
|
+
const result = await Promise.race([handlerPromise, timeoutPromise]);
|
|
217
|
+
clearTimeout(timeout);
|
|
218
|
+
writeResponse(socket, successResponse(request.id, result));
|
|
219
|
+
} catch (error) {
|
|
220
|
+
clearTimeout(timeout);
|
|
221
|
+
writeResponse(socket, errorResponse(request.id, error));
|
|
222
|
+
} finally {
|
|
223
|
+
completeWork = handlerPromise.catch(() => {});
|
|
224
|
+
await completeWork;
|
|
225
|
+
connection.pending -= 1;
|
|
226
|
+
if ((state === "closing" || connection.closeAfterPending) && connection.pending === 0) socket.end();
|
|
227
|
+
if (shutdownAfterResponse && state === "running") setImmediate(() => close().catch(() => {}));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
const laneWork = handlerWork.catch(() => {});
|
|
231
|
+
serialLanes.set(lane, laneWork);
|
|
232
|
+
laneWork.finally(() => {
|
|
233
|
+
if (serialLanes.get(lane) === laneWork) serialLanes.delete(lane);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function acceptConnection(socket) {
|
|
238
|
+
socket.setNoDelay?.(true);
|
|
239
|
+
const connection = { buffer: Buffer.alloc(0), pending: 0, closeAfterPending: false };
|
|
240
|
+
Object.defineProperty(socket, "__writerDaemonConnection", { value: connection, configurable: true });
|
|
241
|
+
connections.add(socket);
|
|
242
|
+
socket.on("data", chunk => {
|
|
243
|
+
if (state !== "running" || !Buffer.isBuffer(chunk)) return;
|
|
244
|
+
connection.buffer = Buffer.concat([connection.buffer, chunk]);
|
|
245
|
+
while (true) {
|
|
246
|
+
const newline = connection.buffer.indexOf(0x0a);
|
|
247
|
+
if (newline === -1) {
|
|
248
|
+
if (connection.buffer.length > maxRequestBytes) {
|
|
249
|
+
connection.buffer = Buffer.alloc(0);
|
|
250
|
+
protocolFailure(socket, cliError("E_DAEMON_REQUEST_TOO_LARGE", "daemon request가 허용 크기를 초과했습니다."), null, true);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (newline > maxRequestBytes) {
|
|
255
|
+
connection.buffer = connection.buffer.subarray(newline + 1);
|
|
256
|
+
protocolFailure(socket, cliError("E_DAEMON_REQUEST_TOO_LARGE", "daemon request가 허용 크기를 초과했습니다."), null, true);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const frame = connection.buffer.subarray(0, newline);
|
|
260
|
+
connection.buffer = connection.buffer.subarray(newline + 1);
|
|
261
|
+
let request;
|
|
262
|
+
try { request = validateRequest(parseFrame(frame, "request")); }
|
|
263
|
+
catch (error) {
|
|
264
|
+
protocolFailure(socket, error);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
queueRequest(socket, connection, request);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
socket.once("error", () => {});
|
|
271
|
+
socket.once("close", () => connections.delete(socket));
|
|
272
|
+
if (state !== "running") socket.end();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function start() {
|
|
276
|
+
if (state === "running") return api;
|
|
277
|
+
if (state !== "idle") throw cliError("E_DAEMON_STATE", `Writer daemon을 ${state} 상태에서 시작할 수 없습니다.`);
|
|
278
|
+
state = "starting";
|
|
279
|
+
try {
|
|
280
|
+
prepareCacheDirectory(paths.cacheDir, { fs: fsApi, uid: options.uid });
|
|
281
|
+
await prepareSocketPath(paths.socketPath, { fs: fsApi, net: netApi, probe: options.probeSocket, timeoutMs: options.probeTimeoutMs });
|
|
282
|
+
server = netApi.createServer(acceptConnection);
|
|
283
|
+
server.on("error", () => {});
|
|
284
|
+
await new Promise((resolve, reject) => {
|
|
285
|
+
const onError = error => { server.off("listening", onListening); reject(error); };
|
|
286
|
+
const onListening = () => { server.off("error", onError); resolve(); };
|
|
287
|
+
server.once("error", onError);
|
|
288
|
+
server.once("listening", onListening);
|
|
289
|
+
server.listen(paths.socketPath);
|
|
290
|
+
});
|
|
291
|
+
fsApi.chmodSync(paths.socketPath, 0o600);
|
|
292
|
+
const stat = fsApi.lstatSync(paths.socketPath);
|
|
293
|
+
if (!stat.isSocket() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o600) {
|
|
294
|
+
throw cliError("E_DAEMON_SOCKET_UNSAFE", "daemon socket 권한 또는 파일 형식이 안전하지 않습니다.");
|
|
295
|
+
}
|
|
296
|
+
const uid = typeof options.uid === "number" ? options.uid : process.getuid?.();
|
|
297
|
+
if (Number.isInteger(uid) && Number.isInteger(stat.uid) && stat.uid !== uid) {
|
|
298
|
+
throw cliError("E_DAEMON_SOCKET_UNSAFE", "daemon socket의 소유자가 현재 사용자와 다릅니다.");
|
|
299
|
+
}
|
|
300
|
+
socketIdentity = { dev: stat.dev, ino: stat.ino };
|
|
301
|
+
state = "running";
|
|
302
|
+
return api;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
state = "idle";
|
|
305
|
+
if (server) {
|
|
306
|
+
try { server.close(); } catch (closeError) { void closeError; }
|
|
307
|
+
server = null;
|
|
308
|
+
}
|
|
309
|
+
throw error.code?.startsWith?.("E_") ? error : cliError("E_DAEMON_START", "Writer daemon을 시작할 수 없습니다.", null, error);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function unlinkOwnedSocket() {
|
|
314
|
+
if (!socketIdentity) return;
|
|
315
|
+
try {
|
|
316
|
+
const current = fsApi.lstatSync(paths.socketPath);
|
|
317
|
+
if (sameFile(socketIdentity, current) && current.isSocket()) fsApi.unlinkSync(paths.socketPath);
|
|
318
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
319
|
+
socketIdentity = null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function close() {
|
|
323
|
+
if (closePromise) return closePromise;
|
|
324
|
+
if (state === "idle" || state === "closed") return;
|
|
325
|
+
state = "closing";
|
|
326
|
+
closePromise = (async () => {
|
|
327
|
+
for (const socket of connections) {
|
|
328
|
+
const connection = socket.__writerDaemonConnection;
|
|
329
|
+
if (!connection || connection.pending === 0) socket.end();
|
|
330
|
+
else connection.closeAfterPending = true;
|
|
331
|
+
}
|
|
332
|
+
const closed = new Promise(resolve => {
|
|
333
|
+
if (!server) { resolve(); return; }
|
|
334
|
+
try { server.close(() => resolve()); }
|
|
335
|
+
catch (error) { resolve(); }
|
|
336
|
+
});
|
|
337
|
+
let forceTimer;
|
|
338
|
+
await Promise.race([
|
|
339
|
+
closed,
|
|
340
|
+
new Promise(resolve => {
|
|
341
|
+
forceTimer = setTimeout(() => {
|
|
342
|
+
for (const socket of connections) socket.destroy();
|
|
343
|
+
resolve();
|
|
344
|
+
}, closeTimeoutMs);
|
|
345
|
+
forceTimer.unref?.();
|
|
346
|
+
})
|
|
347
|
+
]);
|
|
348
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
349
|
+
for (const socket of connections) socket.destroy();
|
|
350
|
+
try { unlinkOwnedSocket(); } catch (error) { void error; }
|
|
351
|
+
server = null;
|
|
352
|
+
state = "closed";
|
|
353
|
+
})();
|
|
354
|
+
return closePromise;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const api = Object.freeze({
|
|
358
|
+
protocol: PROTOCOL,
|
|
359
|
+
cacheDir: paths.cacheDir,
|
|
360
|
+
socketPath: paths.socketPath,
|
|
361
|
+
start,
|
|
362
|
+
close,
|
|
363
|
+
get state() { return state; }
|
|
364
|
+
});
|
|
365
|
+
return api;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
module.exports = Object.freeze({
|
|
369
|
+
DEFAULT_CACHE_DIRECTORY,
|
|
370
|
+
DEFAULT_SOCKET_NAME,
|
|
371
|
+
MAX_SOCKET_PATH_BYTES,
|
|
372
|
+
resolveDaemonPaths,
|
|
373
|
+
prepareCacheDirectory,
|
|
374
|
+
verifyDaemonEndpoint,
|
|
375
|
+
probeSocket,
|
|
376
|
+
prepareSocketPath,
|
|
377
|
+
createDaemonServer
|
|
378
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const config = require("../../../config.js");
|
|
5
|
+
const packageManifest = require("../package.json");
|
|
6
|
+
const { createCredentialStorage, createWindowsDpapiStorage, inspectStoredSession } = require("./auth.cjs");
|
|
7
|
+
const { probeDaemon } = require("./daemon-runner.cjs");
|
|
8
|
+
const { computeSourceFingerprint } = require("./source-fingerprint.cjs");
|
|
9
|
+
|
|
10
|
+
const REGISTRY_URL = "https://registry.npmjs.org/@ssobig%2Fwriter-cli/latest";
|
|
11
|
+
|
|
12
|
+
function parseSemver(value) {
|
|
13
|
+
const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || ""));
|
|
14
|
+
if (!match) return null;
|
|
15
|
+
return Object.freeze({ major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ? match[4].split(".") : [] });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function compareIdentifiers(left, right) {
|
|
19
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
20
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
21
|
+
if (leftNumeric && rightNumeric) return Number(left) - Number(right);
|
|
22
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
23
|
+
return left.localeCompare(right, "en");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function compareSemver(leftValue, rightValue) {
|
|
27
|
+
const left = parseSemver(leftValue);
|
|
28
|
+
const right = parseSemver(rightValue);
|
|
29
|
+
if (!left || !right) return null;
|
|
30
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
31
|
+
if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
|
|
32
|
+
}
|
|
33
|
+
if (!left.prerelease.length || !right.prerelease.length) {
|
|
34
|
+
if (left.prerelease.length === right.prerelease.length) return 0;
|
|
35
|
+
return left.prerelease.length ? -1 : 1;
|
|
36
|
+
}
|
|
37
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
38
|
+
for (let index = 0; index < length; index += 1) {
|
|
39
|
+
if (left.prerelease[index] === undefined) return -1;
|
|
40
|
+
if (right.prerelease[index] === undefined) return 1;
|
|
41
|
+
const compared = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
|
|
42
|
+
if (compared !== 0) return compared > 0 ? 1 : -1;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function minimumVersion(requirement) {
|
|
48
|
+
const match = /^>=(\d+\.\d+\.\d+)$/.exec(String(requirement || ""));
|
|
49
|
+
return match ? match[1] : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function nodeSupport(version = process.versions.node, requirement = packageManifest.engines.node) {
|
|
53
|
+
const minimum = minimumVersion(requirement);
|
|
54
|
+
const comparison = minimum ? compareSemver(version, minimum) : null;
|
|
55
|
+
return Object.freeze({ version: String(version), required: String(requirement), supported: comparison !== null && comparison >= 0 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function fetchLatestVersion(options = {}) {
|
|
59
|
+
const fetchImpl = options.fetch || globalThis.fetch;
|
|
60
|
+
if (typeof fetchImpl !== "function") throw new Error("npm registry를 확인할 fetch 구현이 없습니다.");
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(250, Number(options.timeoutMs || 5_000)));
|
|
63
|
+
timeout.unref?.();
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetchImpl(REGISTRY_URL, { headers: { Accept: "application/json" }, redirect: "error", signal: controller.signal });
|
|
66
|
+
if (!response.ok) throw new Error(`npm registry HTTP ${response.status}`);
|
|
67
|
+
const payload = await response.json();
|
|
68
|
+
if (!parseSemver(payload?.version)) throw new Error("npm registry version이 올바르지 않습니다.");
|
|
69
|
+
return String(payload.version);
|
|
70
|
+
} finally {
|
|
71
|
+
clearTimeout(timeout);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function versionReport(options = {}) {
|
|
76
|
+
const currentVersion = String(options.version || packageManifest.version);
|
|
77
|
+
const report = {
|
|
78
|
+
package: String(packageManifest.name),
|
|
79
|
+
version: currentVersion,
|
|
80
|
+
node: nodeSupport(options.nodeVersion || process.versions.node, packageManifest.engines.node),
|
|
81
|
+
platform: String(options.platform || process.platform),
|
|
82
|
+
arch: String(options.arch || process.arch),
|
|
83
|
+
sourceFingerprint: String((options.computeFingerprint || computeSourceFingerprint)())
|
|
84
|
+
};
|
|
85
|
+
if (options.checkLatest === true) {
|
|
86
|
+
try {
|
|
87
|
+
const latestVersion = await (options.fetchLatest || fetchLatestVersion)({ fetch: options.fetch, timeoutMs: options.timeoutMs });
|
|
88
|
+
const comparison = compareSemver(latestVersion, currentVersion);
|
|
89
|
+
report.registry = { status: comparison !== null && comparison > 0 ? "update-available" : "current", latestVersion, updateAvailable: comparison !== null && comparison > 0, error: null };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
report.registry = { status: "unavailable", latestVersion: null, updateAvailable: null, error: "npm registry에서 최신 버전을 확인하지 못했습니다." };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze(report);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function check(id, status, message, code = null, remediation = null) {
|
|
98
|
+
return Object.freeze({ id, status, code, message, remediation });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function overallStatus(checks) {
|
|
102
|
+
if (checks.some(item => item.status === "error")) return "error";
|
|
103
|
+
if (checks.some(item => item.status === "warning")) return "warning";
|
|
104
|
+
return "ok";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function defaultKeychainCheck(options = {}) {
|
|
108
|
+
(options.fs || fs).accessSync("/usr/bin/security", fs.constants.X_OK);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function defaultSessionStatus(options = {}) {
|
|
113
|
+
const storage = createCredentialStorage({ ...(options.credentialOptions || {}), platform: options.platform });
|
|
114
|
+
return inspectStoredSession(storage, { now: options.now });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function defaultCredentialStoreCheck(options = {}) {
|
|
118
|
+
const platform = String(options.platform || process.platform);
|
|
119
|
+
if (platform === "darwin") return defaultKeychainCheck(options);
|
|
120
|
+
if (platform === "win32") {
|
|
121
|
+
const storage = createWindowsDpapiStorage({ ...(options.credentialOptions || {}), platform });
|
|
122
|
+
return storage.probe();
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function defaultHealthCheck(options = {}) {
|
|
128
|
+
const fetchImpl = options.fetch || globalThis.fetch;
|
|
129
|
+
if (typeof fetchImpl !== "function") throw new Error("Supabase health를 확인할 fetch 구현이 없습니다.");
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(250, Number(options.timeoutMs || 5_000)));
|
|
132
|
+
timeout.unref?.();
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetchImpl(`${config.supabaseUrl}/auth/v1/health`, { headers: { apikey: config.supabaseKey, Accept: "application/json" }, redirect: "error", signal: controller.signal });
|
|
135
|
+
if (!response.ok) throw new Error(`Supabase Auth health HTTP ${response.status}`);
|
|
136
|
+
return true;
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timeout);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function doctorReport(options = {}) {
|
|
143
|
+
const checks = [];
|
|
144
|
+
const platform = String(options.platform || process.platform);
|
|
145
|
+
const node = nodeSupport(options.nodeVersion || process.versions.node, packageManifest.engines.node);
|
|
146
|
+
checks.push(node.supported
|
|
147
|
+
? check("node", "ok", `Node.js ${node.version}이 지원 범위입니다.`)
|
|
148
|
+
: check("node", "error", `Node.js ${node.required}가 필요합니다.`, "E_NODE_UNSUPPORTED", `Node.js ${node.required}로 업데이트해 주세요.`));
|
|
149
|
+
const supportedPlatform = ["darwin", "win32"].includes(platform);
|
|
150
|
+
checks.push(supportedPlatform
|
|
151
|
+
? check("platform", "ok", `지원 플랫폼 ${platform === "darwin" ? "macOS" : "Windows"}를 확인했습니다.`)
|
|
152
|
+
: check("platform", "error", `현재 플랫폼 ${platform}은 공개 CLI 인증을 지원하지 않습니다.`, "E_PLATFORM_UNSUPPORTED", "macOS 또는 Windows에서 Writer CLI를 실행해 주세요."));
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const fingerprint = String((options.computeFingerprint || computeSourceFingerprint)());
|
|
156
|
+
checks.push(check("package", "ok", `공개 package runtime 무결성을 확인했습니다: ${fingerprint}`));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
checks.push(check("package", "error", "공개 package runtime 파일이 누락되었거나 손상됐습니다.", "E_PACKAGE_INTEGRITY", "Writer CLI를 최신 버전으로 다시 설치해 주세요."));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (options.checkLatestVersion === true) {
|
|
162
|
+
try {
|
|
163
|
+
const currentVersion = String(options.currentVersion || packageManifest.version);
|
|
164
|
+
const latestVersion = await (options.fetchLatest || fetchLatestVersion)({ fetch: options.fetch, timeoutMs: options.timeoutMs });
|
|
165
|
+
if (compareSemver(latestVersion, currentVersion) > 0) checks.push(check("cli-update", "error", `Writer CLI ${currentVersion}보다 최신 필수 버전 ${latestVersion}이 있습니다.`, "E_CLI_UPDATE_REQUIRED", "npm install --global @ssobig/writer-cli@latest"));
|
|
166
|
+
else checks.push(check("cli-update", "ok", `Writer CLI ${currentVersion}이 npm latest 기준 최신입니다.`));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
checks.push(check("cli-update", "warning", "npm registry에서 최신 Writer CLI 버전을 확인하지 못했습니다.", "W_CLI_UPDATE_CHECK_UNAVAILABLE", "네트워크 연결 후 ssobig-writer version --check를 다시 실행해 주세요."));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let credentialStoreReady = false;
|
|
173
|
+
if (supportedPlatform) {
|
|
174
|
+
try {
|
|
175
|
+
const credentialCheck = options.credentialStoreCheck
|
|
176
|
+
|| (platform === "darwin" && options.keychainCheck)
|
|
177
|
+
|| defaultCredentialStoreCheck;
|
|
178
|
+
credentialStoreReady = await credentialCheck({
|
|
179
|
+
platform,
|
|
180
|
+
fs: options.fs,
|
|
181
|
+
credentialOptions: options.credentialOptions
|
|
182
|
+
});
|
|
183
|
+
if (credentialStoreReady !== true) throw new Error("Credential store check did not confirm availability.");
|
|
184
|
+
checks.push(platform === "darwin"
|
|
185
|
+
? check("keychain", "ok", "macOS Keychain 명령을 사용할 수 있습니다.")
|
|
186
|
+
: check("credential-store", "ok", "Windows DPAPI CurrentUser credential store를 사용할 수 있습니다."));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
checks.push(platform === "darwin"
|
|
189
|
+
? check("keychain", "error", "macOS Keychain을 사용할 수 없습니다.", "E_KEYCHAIN_UNAVAILABLE", "Keychain 접근 권한과 /usr/bin/security를 확인해 주세요.")
|
|
190
|
+
: check("credential-store", "error", "Windows DPAPI credential store를 사용할 수 없습니다.", "E_CREDENTIAL_STORE_UNAVAILABLE", "현재 Windows 사용자 프로필과 PowerShell 실행 환경을 확인해 주세요."));
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
checks.push(check("keychain", "error", "현재 플랫폼에는 지원되는 Writer CLI credential store가 없습니다.", "E_KEYCHAIN_UNAVAILABLE", "macOS에서 Writer CLI를 실행해 주세요."));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (credentialStoreReady) {
|
|
197
|
+
try {
|
|
198
|
+
const session = await (options.sessionStatus || defaultSessionStatus)({ platform, now: options.now, credentialOptions: options.credentialOptions });
|
|
199
|
+
if (session.status === "active") checks.push(check("auth-session", "ok", "로컬 Writer CLI 로그인 세션이 있습니다."));
|
|
200
|
+
else if (session.status === "expired") checks.push(check("auth-session", "warning", "로컬 Writer CLI 로그인 세션이 만료됐습니다.", "E_AUTH_EXPIRED", "ssobig-writer auth login을 실행해 주세요."));
|
|
201
|
+
else if (session.status === "malformed") checks.push(check("auth-session", "error", "로컬 Writer CLI 로그인 세션 형식이 올바르지 않습니다.", "E_AUTH_SESSION_INVALID", "ssobig-writer auth logout 후 다시 로그인해 주세요."));
|
|
202
|
+
else checks.push(check("auth-session", "warning", "로컬 Writer CLI 로그인 세션이 없습니다.", "E_AUTH_REQUIRED", "ssobig-writer auth login을 실행해 주세요."));
|
|
203
|
+
} catch (error) {
|
|
204
|
+
checks.push(check("auth-session", "error", "credential store에서 Writer CLI 로그인 세션을 확인할 수 없습니다.", "E_CREDENTIAL_STORE_UNAVAILABLE", "credential store 접근 권한을 확인해 주세요."));
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
checks.push(check("auth-session", "warning", "credential store를 사용할 수 없어 로그인 세션 검사를 건너뛰었습니다.", "E_AUTH_SESSION_SKIPPED", null));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await (options.healthCheck || defaultHealthCheck)({ fetch: options.fetch, timeoutMs: options.timeoutMs });
|
|
212
|
+
checks.push(check("supabase", "ok", "SSOBIG WRITER Auth 서비스에 연결할 수 있습니다."));
|
|
213
|
+
} catch (error) {
|
|
214
|
+
checks.push(check("supabase", "error", "SSOBIG WRITER Auth 서비스에 연결할 수 없습니다.", "E_SERVICE_UNAVAILABLE", "네트워크 연결을 확인한 뒤 다시 시도해 주세요."));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (platform === "win32") {
|
|
218
|
+
checks.push(check("daemon", "ok", "Windows에서는 background daemon 없이 안전한 in-process backend를 사용합니다."));
|
|
219
|
+
} else {
|
|
220
|
+
try {
|
|
221
|
+
await (options.probeDaemon || probeDaemon)({ timeoutMs: 500 });
|
|
222
|
+
checks.push(check("daemon", "ok", "Writer daemon이 현재 CLI와 같은 계약으로 실행 중입니다."));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (["E_DAEMON_UNAVAILABLE", "E_DAEMON_CONNECT_TIMEOUT"].includes(String(error?.code || ""))) {
|
|
225
|
+
checks.push(check("daemon", "warning", "Writer daemon이 실행 중이 아닙니다.", "E_DAEMON_UNAVAILABLE", "필요할 때 ssobig-writer agent start를 실행해 주세요."));
|
|
226
|
+
} else {
|
|
227
|
+
checks.push(check("daemon", "error", "Writer daemon 상태가 현재 CLI 계약과 맞지 않습니다.", String(error?.code || "E_DAEMON_STATUS"), "ssobig-writer agent stop 후 다시 시작해 주세요."));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return Object.freeze({ overall: overallStatus(checks), checks: Object.freeze(checks) });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = Object.freeze({ REGISTRY_URL, parseSemver, compareSemver, nodeSupport, fetchLatestVersion, versionReport, defaultKeychainCheck, defaultCredentialStoreCheck, defaultSessionStatus, defaultHealthCheck, doctorReport, overallStatus });
|