@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,255 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const packageManifest = require("../package.json");
7
+ const { cliError } = require("./errors.cjs");
8
+
9
+ const SKILL_NAME = "ssobig-writer-cli";
10
+ const SKILL_VERSION = "0.2.0";
11
+ const MANIFEST_NAME = ".ssobig-writer-skill-manifest.json";
12
+ const PAYLOAD_ROOT = path.resolve(__dirname, "../skills", SKILL_NAME);
13
+ const PLATFORM_PATHS = Object.freeze({ codex: [".agents", "skills"], claude: [".claude", "skills"] });
14
+
15
+ function sha256Buffer(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
16
+
17
+ function safeRelative(value) {
18
+ const normalized = String(value || "").replace(/\\/g, "/").split(path.sep).join("/");
19
+ if (!normalized || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized) || normalized.includes("\0") || normalized.split("/").some(part => !part || part === "." || part === "..")) {
20
+ throw cliError("E_SKILL_PATH", `안전하지 않은 skill 상대 경로입니다: ${normalized || "(empty)"}`);
21
+ }
22
+ return normalized;
23
+ }
24
+
25
+ function walkRegularFiles(root, fsApi = fs, directory = root) {
26
+ const entries = fsApi.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"));
27
+ const files = [];
28
+ for (const entry of entries) {
29
+ const absolute = path.join(directory, entry.name);
30
+ const stat = fsApi.lstatSync(absolute);
31
+ if (stat.isSymbolicLink()) throw cliError("E_SKILL_PATH", `skill payload에는 symbolic link를 포함할 수 없습니다: ${absolute}`);
32
+ if (stat.isDirectory()) files.push(...walkRegularFiles(root, fsApi, absolute));
33
+ else if (stat.isFile()) files.push(safeRelative(path.relative(root, absolute)));
34
+ else throw cliError("E_SKILL_PATH", `skill payload에는 일반 파일만 포함할 수 있습니다: ${absolute}`);
35
+ }
36
+ return files;
37
+ }
38
+
39
+ function payload(fsApi = fs, payloadRoot = PAYLOAD_ROOT) {
40
+ const root = path.resolve(payloadRoot);
41
+ const files = walkRegularFiles(root, fsApi).map(relativePath => {
42
+ const content = fsApi.readFileSync(path.join(root, relativePath));
43
+ return Object.freeze({ path: relativePath, content, sha256: sha256Buffer(content) });
44
+ });
45
+ return Object.freeze({ root, files: Object.freeze(files) });
46
+ }
47
+
48
+ function selectedPlatforms(options = {}) {
49
+ if (options.codex === true && options.claude === true) throw cliError("E_USAGE", "--codex와 --claude는 함께 지정할 수 없습니다. 둘 다 설치하려면 platform option을 생략하세요.");
50
+ if (options.codex === true) return ["codex"];
51
+ if (options.claude === true) return ["claude"];
52
+ return ["codex", "claude"];
53
+ }
54
+
55
+ function assertWorkspace(target, fsApi = fs) {
56
+ const workspace = path.resolve(String(target || ""));
57
+ if (!target) throw cliError("E_USAGE", "--target workspace 경로가 필요합니다.");
58
+ let stat;
59
+ try { stat = fsApi.lstatSync(workspace); }
60
+ catch (error) { throw cliError("E_SKILL_PATH", `skill target workspace를 찾을 수 없습니다: ${workspace}`, null, error); }
61
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw cliError("E_SKILL_PATH", `skill target은 symbolic link가 아닌 directory여야 합니다: ${workspace}`);
62
+ return workspace;
63
+ }
64
+
65
+ function destination(workspace, platform) {
66
+ const root = path.resolve(workspace);
67
+ const resolved = path.resolve(root, ...PLATFORM_PATHS[platform], SKILL_NAME);
68
+ if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw cliError("E_SKILL_PATH", "skill destination이 target workspace를 벗어났습니다.");
69
+ return resolved;
70
+ }
71
+
72
+ function assertExistingParentsSafe(workspace, targetPath, fsApi = fs) {
73
+ let current = path.resolve(workspace);
74
+ const relative = path.relative(current, targetPath);
75
+ for (const segment of relative.split(path.sep).slice(0, -1)) {
76
+ current = path.join(current, segment);
77
+ try {
78
+ const stat = fsApi.lstatSync(current);
79
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw cliError("E_SKILL_PATH", `skill 경로에 안전하지 않은 parent가 있습니다: ${current}`);
80
+ } catch (error) { if (error.code === "ENOENT") break; throw error; }
81
+ }
82
+ }
83
+
84
+ function readManifest(skillDirectory, fsApi = fs) {
85
+ const manifestPath = path.join(skillDirectory, MANIFEST_NAME);
86
+ let stat;
87
+ try { stat = fsApi.lstatSync(manifestPath); }
88
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
89
+ if (stat.isSymbolicLink() || !stat.isFile()) throw cliError("E_SKILL_CONFLICT", `관리 manifest가 일반 파일이 아닙니다: ${manifestPath}`);
90
+ let parsed;
91
+ try { parsed = JSON.parse(fsApi.readFileSync(manifestPath, "utf8")); }
92
+ catch (error) { throw cliError("E_SKILL_CONFLICT", `관리 manifest가 손상되었습니다: ${manifestPath}`, null, error); }
93
+ if (parsed?.manifestVersion !== 1 || parsed?.skillName !== SKILL_NAME || !Array.isArray(parsed.files)) {
94
+ throw cliError("E_SKILL_CONFLICT", `관리 manifest 형식이 올바르지 않습니다: ${manifestPath}`);
95
+ }
96
+ return parsed;
97
+ }
98
+
99
+ function inspectManaged(skillDirectory, fsApi = fs) {
100
+ let stat;
101
+ try { stat = fsApi.lstatSync(skillDirectory); }
102
+ catch (error) { if (error.code === "ENOENT") return Object.freeze({ state: "missing", manifest: null }); throw error; }
103
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw cliError("E_SKILL_CONFLICT", `skill destination이 안전한 directory가 아닙니다: ${skillDirectory}`);
104
+ const manifest = readManifest(skillDirectory, fsApi);
105
+ if (!manifest) throw cliError("E_SKILL_CONFLICT", `기존 skill directory는 Writer CLI가 관리하지 않습니다: ${skillDirectory}`);
106
+ const declared = new Map();
107
+ for (const item of manifest.files) {
108
+ const relativePath = safeRelative(item?.path);
109
+ if (!/^[a-f0-9]{64}$/.test(String(item?.sha256 || "")) || declared.has(relativePath)) throw cliError("E_SKILL_CONFLICT", `관리 manifest file 목록이 올바르지 않습니다: ${skillDirectory}`);
110
+ declared.set(relativePath, String(item.sha256));
111
+ }
112
+ const actual = walkRegularFiles(skillDirectory, fsApi).filter(relativePath => relativePath !== MANIFEST_NAME);
113
+ const extra = actual.find(relativePath => !declared.has(relativePath));
114
+ if (extra) throw cliError("E_SKILL_CONFLICT", `관리되지 않는 skill 파일을 보존하기 위해 중단했습니다: ${path.join(skillDirectory, extra)}`);
115
+ for (const [relativePath, expected] of declared) {
116
+ const filePath = path.resolve(skillDirectory, relativePath);
117
+ if (!filePath.startsWith(`${path.resolve(skillDirectory)}${path.sep}`)) throw cliError("E_SKILL_PATH", "관리 manifest 경로가 skill directory를 벗어났습니다.");
118
+ let fileStat;
119
+ try { fileStat = fsApi.lstatSync(filePath); }
120
+ catch (error) { throw cliError("E_SKILL_CONFLICT", `관리 skill 파일이 누락되었습니다: ${filePath}`, null, error); }
121
+ if (fileStat.isSymbolicLink() || !fileStat.isFile() || sha256Buffer(fsApi.readFileSync(filePath)) !== expected) {
122
+ throw cliError("E_SKILL_CONFLICT", `사용자가 수정한 관리 skill 파일을 덮어쓰지 않습니다: ${filePath}`);
123
+ }
124
+ }
125
+ return Object.freeze({ state: "managed", manifest });
126
+ }
127
+
128
+ function desiredManifest(payloadValue) {
129
+ return Object.freeze({
130
+ manifestVersion: 1,
131
+ skillName: SKILL_NAME,
132
+ skillVersion: SKILL_VERSION,
133
+ cliVersion: packageManifest.version,
134
+ files: payloadValue.files.map(item => ({ path: item.path, sha256: item.sha256 }))
135
+ });
136
+ }
137
+
138
+ function sameDesired(manifest, desired) {
139
+ return JSON.stringify({ skillVersion: manifest.skillVersion, cliVersion: manifest.cliVersion, files: manifest.files })
140
+ === JSON.stringify({ skillVersion: desired.skillVersion, cliVersion: desired.cliVersion, files: desired.files });
141
+ }
142
+
143
+ function writeStage(stagePath, payloadValue, manifest, fsApi = fs) {
144
+ fsApi.mkdirSync(stagePath, { recursive: false, mode: 0o700 });
145
+ for (const item of payloadValue.files) {
146
+ const destinationPath = path.join(stagePath, item.path);
147
+ fsApi.mkdirSync(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
148
+ fsApi.writeFileSync(destinationPath, item.content, { mode: 0o600, flag: "wx" });
149
+ }
150
+ fsApi.writeFileSync(path.join(stagePath, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600, flag: "wx" });
151
+ }
152
+
153
+ function installOrUpdate(options = {}) {
154
+ const fsApi = options.fs || fs;
155
+ const workspace = assertWorkspace(options.target, fsApi);
156
+ const payloadValue = payload(fsApi, options.payloadRoot || PAYLOAD_ROOT);
157
+ const manifest = desiredManifest(payloadValue);
158
+ const targets = selectedPlatforms(options).map(platform => {
159
+ const skillDirectory = destination(workspace, platform);
160
+ assertExistingParentsSafe(workspace, skillDirectory, fsApi);
161
+ const current = inspectManaged(skillDirectory, fsApi);
162
+ return { platform, skillDirectory, current, action: current.state === "missing" ? "install" : (sameDesired(current.manifest, manifest) ? "unchanged" : "update") };
163
+ });
164
+ if (options.dryRun === true || targets.every(target => target.action === "unchanged")) {
165
+ return Object.freeze({ dryRun: options.dryRun === true, workspace, skillName: SKILL_NAME, skillVersion: SKILL_VERSION, targets: targets.map(({ platform, skillDirectory, action }) => ({ platform, path: skillDirectory, action })) });
166
+ }
167
+
168
+ const prepared = [];
169
+ const swapped = [];
170
+ try {
171
+ for (const target of targets.filter(item => item.action !== "unchanged")) {
172
+ const parent = path.dirname(target.skillDirectory);
173
+ fsApi.mkdirSync(parent, { recursive: true, mode: 0o700 });
174
+ assertExistingParentsSafe(workspace, target.skillDirectory, fsApi);
175
+ const stagePath = path.join(parent, `.${SKILL_NAME}.${process.pid}.${crypto.randomUUID()}.stage`);
176
+ writeStage(stagePath, payloadValue, manifest, fsApi);
177
+ prepared.push({ ...target, stagePath, backupPath: `${stagePath}.backup` });
178
+ }
179
+ for (const item of prepared) {
180
+ if (item.current.state === "managed") fsApi.renameSync(item.skillDirectory, item.backupPath);
181
+ try { fsApi.renameSync(item.stagePath, item.skillDirectory); }
182
+ catch (error) {
183
+ if (item.current.state === "managed") fsApi.renameSync(item.backupPath, item.skillDirectory);
184
+ throw error;
185
+ }
186
+ swapped.push(item);
187
+ }
188
+ } catch (error) {
189
+ for (const item of [...swapped].reverse()) {
190
+ try {
191
+ fsApi.rmSync(item.skillDirectory, { recursive: true, force: true });
192
+ if (item.current.state === "managed" && fsApi.existsSync(item.backupPath)) fsApi.renameSync(item.backupPath, item.skillDirectory);
193
+ } catch (rollbackError) { void rollbackError; }
194
+ }
195
+ for (const item of prepared) {
196
+ try { if (fsApi.existsSync(item.stagePath)) fsApi.rmSync(item.stagePath, { recursive: true, force: true }); } catch (cleanupError) { void cleanupError; }
197
+ }
198
+ throw cliError("E_SKILL_WRITE", "skill 설치를 atomic하게 반영하지 못했습니다. 기존 관리 파일은 보존했습니다.", null, error);
199
+ }
200
+ for (const item of swapped) {
201
+ if (item.current.state !== "managed") continue;
202
+ try { fsApi.rmSync(item.backupPath, { recursive: true, force: false }); }
203
+ catch (error) { void error; }
204
+ }
205
+ return Object.freeze({ dryRun: false, workspace, skillName: SKILL_NAME, skillVersion: SKILL_VERSION, targets: targets.map(({ platform, skillDirectory, action }) => ({ platform, path: skillDirectory, action })) });
206
+ }
207
+
208
+ function skillStatus(options = {}) {
209
+ const fsApi = options.fs || fs;
210
+ const workspace = assertWorkspace(options.target, fsApi);
211
+ const desired = desiredManifest(payload(fsApi, options.payloadRoot || PAYLOAD_ROOT));
212
+ const targets = Object.keys(PLATFORM_PATHS).map(platform => {
213
+ const skillDirectory = destination(workspace, platform);
214
+ assertExistingParentsSafe(workspace, skillDirectory, fsApi);
215
+ try {
216
+ const current = inspectManaged(skillDirectory, fsApi);
217
+ if (current.state === "missing") return { platform, path: skillDirectory, status: "missing" };
218
+ return { platform, path: skillDirectory, status: sameDesired(current.manifest, desired) ? "current" : "update-available", installedSkillVersion: current.manifest.skillVersion, installedCliVersion: current.manifest.cliVersion };
219
+ } catch (error) {
220
+ if (error.code === "E_SKILL_CONFLICT") return { platform, path: skillDirectory, status: "conflict", error: error.message };
221
+ throw error;
222
+ }
223
+ });
224
+ return Object.freeze({ workspace, skillName: SKILL_NAME, skillVersion: SKILL_VERSION, targets });
225
+ }
226
+
227
+ function removeManaged(options = {}) {
228
+ if (options.managedOnly !== true) throw cliError("E_USAGE", "skills remove에는 --managed-only가 필요합니다.");
229
+ const fsApi = options.fs || fs;
230
+ const workspace = assertWorkspace(options.target, fsApi);
231
+ const targets = selectedPlatforms(options).map(platform => {
232
+ const skillDirectory = destination(workspace, platform);
233
+ assertExistingParentsSafe(workspace, skillDirectory, fsApi);
234
+ const current = inspectManaged(skillDirectory, fsApi);
235
+ return { platform, skillDirectory, current, action: current.state === "missing" ? "unchanged" : "remove" };
236
+ });
237
+ if (options.dryRun !== true) {
238
+ for (const target of targets.filter(item => item.action === "remove")) {
239
+ for (const item of target.current.manifest.files) fsApi.unlinkSync(path.join(target.skillDirectory, safeRelative(item.path)));
240
+ fsApi.unlinkSync(path.join(target.skillDirectory, MANIFEST_NAME));
241
+ const directories = [];
242
+ const collect = directory => {
243
+ for (const entry of fsApi.readdirSync(directory, { withFileTypes: true })) if (entry.isDirectory() && !entry.isSymbolicLink()) collect(path.join(directory, entry.name));
244
+ directories.push(directory);
245
+ };
246
+ collect(target.skillDirectory);
247
+ for (const directory of directories) {
248
+ try { fsApi.rmdirSync(directory); } catch (error) { if (!error || !["ENOTEMPTY", "ENOENT"].includes(error.code)) throw error; }
249
+ }
250
+ }
251
+ }
252
+ return Object.freeze({ dryRun: options.dryRun === true, workspace, skillName: SKILL_NAME, targets: targets.map(({ platform, skillDirectory, action }) => ({ platform, path: skillDirectory, action })) });
253
+ }
254
+
255
+ module.exports = Object.freeze({ SKILL_NAME, SKILL_VERSION, MANIFEST_NAME, PAYLOAD_ROOT, PLATFORM_PATHS, safeRelative, payload, selectedPlatforms, destination, inspectManaged, installOrUpdate, skillStatus, removeManaged });
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+
7
+ const SOURCE_FINGERPRINT_VERSION = "ssobig-writer-source-v2";
8
+ const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
9
+ const REQUIRED_SHARED_FILES = Object.freeze([
10
+ "config.js",
11
+ "project-runtime.js",
12
+ "asset-repository.js",
13
+ "storage-path.js"
14
+ ]);
15
+
16
+ function normalizedRelative(root, absolutePath) {
17
+ const relative = path.relative(root, absolutePath).split(path.sep).join("/");
18
+ if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) {
19
+ throw new Error(`Writer source fingerprint 경로가 저장소 밖에 있습니다: ${absolutePath}`);
20
+ }
21
+ return relative;
22
+ }
23
+
24
+ function filesWithExtension(directory, extension, fsApi) {
25
+ let entries;
26
+ try { entries = fsApi.readdirSync(directory, { withFileTypes: true }); }
27
+ catch (error) { throw new Error(`Writer source fingerprint 디렉터리를 읽을 수 없습니다: ${directory}`, { cause: error }); }
28
+ return entries
29
+ .filter(entry => entry.isFile() && entry.name.endsWith(extension))
30
+ .map(entry => path.join(directory, entry.name));
31
+ }
32
+
33
+ function filesRecursive(directory, fsApi) {
34
+ return fsApi.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
35
+ const absolute = path.join(directory, entry.name);
36
+ if (entry.isSymbolicLink()) throw new Error(`Writer source fingerprint에는 symbolic link를 포함할 수 없습니다: ${absolute}`);
37
+ if (entry.isDirectory()) return filesRecursive(absolute, fsApi);
38
+ if (entry.isFile()) return [absolute];
39
+ throw new Error(`Writer source fingerprint에는 일반 파일만 포함할 수 있습니다: ${absolute}`);
40
+ });
41
+ }
42
+
43
+ function runtimeSourceFiles(options = {}) {
44
+ const fsApi = options.fs || fs;
45
+ const repositoryRoot = path.resolve(options.repositoryRoot || REPOSITORY_ROOT);
46
+ const files = [
47
+ ...filesWithExtension(path.join(repositoryRoot, "tools/writer-cli/src"), ".cjs", fsApi),
48
+ ...filesWithExtension(path.join(repositoryRoot, "tools/writer-cli/bin"), ".cjs", fsApi),
49
+ ...filesRecursive(path.join(repositoryRoot, "tools/writer-cli/skills"), fsApi),
50
+ ...filesWithExtension(path.join(repositoryRoot, "templates/mystery-v1"), ".js", fsApi),
51
+ path.join(repositoryRoot, "tools/writer-cli/package.json"),
52
+ path.join(repositoryRoot, "tools/writer-cli/package-lock.json"),
53
+ ...REQUIRED_SHARED_FILES.map(fileName => path.join(repositoryRoot, fileName))
54
+ ];
55
+ const relativeFiles = files.map(filePath => normalizedRelative(repositoryRoot, filePath));
56
+ if (new Set(relativeFiles).size !== relativeFiles.length) throw new Error("Writer source fingerprint 대상이 중복되었습니다.");
57
+ return relativeFiles.sort();
58
+ }
59
+
60
+ function computeSourceFingerprint(options = {}) {
61
+ const fsApi = options.fs || fs;
62
+ const repositoryRoot = path.resolve(options.repositoryRoot || REPOSITORY_ROOT);
63
+ const files = options.files ? [...options.files].map(String).sort() : runtimeSourceFiles({ repositoryRoot, fs: fsApi });
64
+ const hash = crypto.createHash("sha256");
65
+ hash.update(`${SOURCE_FINGERPRINT_VERSION}\0`);
66
+ for (const relativePath of files) {
67
+ const absolutePath = path.resolve(repositoryRoot, relativePath);
68
+ if (normalizedRelative(repositoryRoot, absolutePath) !== relativePath.split(path.sep).join("/")) {
69
+ throw new Error(`Writer source fingerprint 상대 경로가 올바르지 않습니다: ${relativePath}`);
70
+ }
71
+ let content;
72
+ try { content = fsApi.readFileSync(absolutePath); }
73
+ catch (error) { throw new Error(`Writer source fingerprint 파일을 읽을 수 없습니다: ${relativePath}`, { cause: error }); }
74
+ hash.update(relativePath);
75
+ hash.update("\0");
76
+ hash.update(String(content.length));
77
+ hash.update("\0");
78
+ hash.update(content);
79
+ hash.update("\0");
80
+ }
81
+ return `${SOURCE_FINGERPRINT_VERSION}:sha256:${hash.digest("hex")}`;
82
+ }
83
+
84
+ module.exports = Object.freeze({
85
+ SOURCE_FINGERPRINT_VERSION,
86
+ REPOSITORY_ROOT,
87
+ REQUIRED_SHARED_FILES,
88
+ runtimeSourceFiles,
89
+ computeSourceFingerprint
90
+ });
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const packageManifest = require("../package.json");
7
+ const { resolveAgentPaths, ensurePrivateDirectory } = require("./agent-paths.cjs");
8
+ const { compareSemver, fetchLatestVersion, parseSemver } = require("./diagnostics.cjs");
9
+ const { cliError } = require("./errors.cjs");
10
+
11
+ const CACHE_FILE = "cli-version-check.json";
12
+ const CACHE_VERSION = 1;
13
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
14
+ const INSTALL_COMMAND = "npm install --global @ssobig/writer-cli@latest";
15
+ const DOWNLOAD_URL = "https://writer.ssobig.com/cli-download";
16
+
17
+ function clock(options = {}) {
18
+ const value = typeof options.now === "function" ? options.now() : Date.now();
19
+ return value instanceof Date ? value.getTime() : Number(value);
20
+ }
21
+
22
+ function cacheLocation(options = {}) {
23
+ const cacheDir = resolveAgentPaths({
24
+ cacheDir: options.cacheDir,
25
+ platform: options.platform,
26
+ environment: options.environment,
27
+ homeDirectory: options.homeDirectory
28
+ }).cacheDir;
29
+ return Object.freeze({ cacheDir, filePath: path.join(cacheDir, CACHE_FILE) });
30
+ }
31
+
32
+ function readCache(filePath, fsApi = fs) {
33
+ try {
34
+ const stat = fsApi.lstatSync(filePath);
35
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
36
+ const parsed = JSON.parse(fsApi.readFileSync(filePath, "utf8"));
37
+ const status = parsed?.status === "unavailable" ? "unavailable" : "current";
38
+ if (parsed?.cacheVersion !== CACHE_VERSION || !Number.isFinite(Number(parsed?.checkedAt))) return null;
39
+ if (status === "current" && !parseSemver(parsed?.latestVersion)) return null;
40
+ return Object.freeze({ cacheVersion: CACHE_VERSION, status, latestVersion: status === "current" ? String(parsed.latestVersion) : null, checkedAt: Number(parsed.checkedAt) });
41
+ } catch (error) { return null; }
42
+ }
43
+
44
+ function writeCache(location, value, options = {}) {
45
+ const fsApi = options.fs || fs;
46
+ ensurePrivateDirectory(location.cacheDir, fsApi, { platform: options.platform });
47
+ const temporary = path.join(location.cacheDir, `.${CACHE_FILE}.${process.pid}.${crypto.randomUUID()}.tmp`);
48
+ try {
49
+ fsApi.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: "wx" });
50
+ fsApi.renameSync(temporary, location.filePath);
51
+ if (String(options.platform || process.platform) !== "win32") fsApi.chmodSync(location.filePath, 0o600);
52
+ } finally {
53
+ try { fsApi.unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; }
54
+ }
55
+ }
56
+
57
+ function updateRequired(currentVersion, latestVersion, source) {
58
+ throw cliError("E_CLI_UPDATE_REQUIRED", `Writer CLI ${currentVersion}은 최신 필수 버전 ${latestVersion}보다 낮아 서버 작업을 실행하지 않았습니다.`, {
59
+ currentVersion,
60
+ latestVersion,
61
+ installCommand: INSTALL_COMMAND,
62
+ downloadUrl: DOWNLOAD_URL,
63
+ source
64
+ });
65
+ }
66
+
67
+ async function checkRequiredUpdate(options = {}) {
68
+ const currentVersion = String(options.currentVersion || packageManifest.version);
69
+ if (!parseSemver(currentVersion)) throw cliError("E_CLI_VERSION", `현재 Writer CLI 버전이 올바르지 않습니다: ${currentVersion}`);
70
+ const fsApi = options.fs || fs;
71
+ const location = cacheLocation(options);
72
+ const now = clock(options);
73
+ const ttlMs = Math.max(0, Number(options.ttlMs ?? DEFAULT_TTL_MS));
74
+ const cached = readCache(location.filePath, fsApi);
75
+ if (cached && now - cached.checkedAt >= 0 && now - cached.checkedAt < ttlMs) {
76
+ if (cached.latestVersion && compareSemver(cached.latestVersion, currentVersion) > 0) updateRequired(currentVersion, cached.latestVersion, "cache");
77
+ if (cached.status === "unavailable") return Object.freeze({ status: "unavailable", currentVersion, latestVersion: null, source: "cache", warning: "npm registry에서 최신 Writer CLI 버전을 확인하지 못해 이번 작업은 차단하지 않습니다." });
78
+ return Object.freeze({ status: "current", currentVersion, latestVersion: cached.latestVersion, source: "cache", warning: null });
79
+ }
80
+ try {
81
+ const latestVersion = await (options.fetchLatest || fetchLatestVersion)({ fetch: options.fetch, timeoutMs: options.timeoutMs });
82
+ let cacheWriteFailed = false;
83
+ try { writeCache(location, { cacheVersion: CACHE_VERSION, status: "current", latestVersion, checkedAt: now }, { fs: fsApi, platform: options.platform }); }
84
+ catch (error) { cacheWriteFailed = true; }
85
+ if (compareSemver(latestVersion, currentVersion) > 0) updateRequired(currentVersion, latestVersion, "registry");
86
+ return Object.freeze({ status: "current", currentVersion, latestVersion, source: "registry", warning: cacheWriteFailed ? "최신 Writer CLI 버전 cache를 안전하게 기록하지 못했지만 registry 확인 결과는 최신입니다." : null });
87
+ } catch (error) {
88
+ if (error?.code === "E_CLI_UPDATE_REQUIRED") throw error;
89
+ if (cached?.latestVersion && compareSemver(cached.latestVersion, currentVersion) > 0) updateRequired(currentVersion, cached.latestVersion, "stale-cache");
90
+ try { writeCache(location, { cacheVersion: CACHE_VERSION, status: "unavailable", latestVersion: null, checkedAt: now }, { fs: fsApi, platform: options.platform }); }
91
+ catch (cacheError) { void cacheError; }
92
+ return Object.freeze({
93
+ status: "unavailable",
94
+ currentVersion,
95
+ latestVersion: cached?.latestVersion || null,
96
+ source: cached ? "stale-cache" : "registry",
97
+ warning: "npm registry에서 최신 Writer CLI 버전을 확인하지 못해 이번 작업은 차단하지 않습니다."
98
+ });
99
+ }
100
+ }
101
+
102
+ module.exports = Object.freeze({ CACHE_FILE, CACHE_VERSION, DEFAULT_TTL_MS, INSTALL_COMMAND, DOWNLOAD_URL, cacheLocation, readCache, writeCache, checkRequiredUpdate });