@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,841 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const config = require("../../../config.js");
7
+ const projectRuntime = require("../../../project-runtime.js");
8
+ const { createAuthManager } = require("./auth.cjs");
9
+ const { createSupabaseGateway } = require("./gateway.cjs");
10
+ const { inspectAssetFile, assertAssetId } = require("./asset-policy.cjs");
11
+ const { sha256 } = require("./json.cjs");
12
+ const {
13
+ componentContract,
14
+ validateLoadedVersion,
15
+ findInstance,
16
+ createComponentPlan,
17
+ createInvestigationBoardLayoutPlan,
18
+ createAssetPlan,
19
+ createProjectCreationPlan,
20
+ createProjectImportPlan,
21
+ createProjectStatusPlan,
22
+ createCatalogDemoPlan,
23
+ createCheckpointPlan,
24
+ normalizeCheckpoint,
25
+ verifyPlan
26
+ } = require("./domain.cjs");
27
+ const { loadTrueWriterCase } = require("./project-import.cjs");
28
+ const { applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
29
+ const { snapshotSummary, diffSnapshots } = require("./checkpoint-diff.cjs");
30
+ const { cliError, normalizeError, redact } = require("./errors.cjs");
31
+ const { startDaemon, stopDaemon } = require("./daemon-runner.cjs");
32
+ const { requestWriterService } = require("./platform-runner.cjs");
33
+ const { purgeAllAgentIndexes } = require("./agent-paths.cjs");
34
+ const { versionReport, doctorReport } = require("./diagnostics.cjs");
35
+ const { helpFor, commandSchema, getCommand, allowedOptions, booleanOptions, normalizeScope } = require("./command-registry.cjs");
36
+ const { installOrUpdate, skillStatus, removeManaged } = require("./skill-manager.cjs");
37
+ const { checkRequiredUpdate } = require("./update-gate.cjs");
38
+
39
+ const HELP = helpFor();
40
+ const BOOLEAN_OPTIONS = booleanOptions();
41
+
42
+ function parseArgs(tokens) {
43
+ const options = {};
44
+ const positional = [];
45
+ for (let index = 0; index < tokens.length; index += 1) {
46
+ const token = tokens[index];
47
+ if (!token.startsWith("--")) {
48
+ positional.push(token);
49
+ continue;
50
+ }
51
+ const name = token.slice(2);
52
+ if (!name) throw cliError("E_USAGE", "비어 있는 option은 사용할 수 없습니다.");
53
+ if (BOOLEAN_OPTIONS.has(name)) {
54
+ options[name] = true;
55
+ continue;
56
+ }
57
+ const value = tokens[index + 1];
58
+ if (value === undefined || value.startsWith("--")) throw cliError("E_USAGE", `--${name} option 값이 필요합니다.`);
59
+ if (Object.hasOwn(options, name)) throw cliError("E_USAGE", `--${name} option을 중복 지정할 수 없습니다.`);
60
+ options[name] = value;
61
+ index += 1;
62
+ }
63
+ return { options, positional };
64
+ }
65
+
66
+ function requireOption(options, name) {
67
+ const value = options[name];
68
+ if (value === undefined || String(value).trim() === "") throw cliError("E_USAGE", `--${name} option이 필요합니다.`);
69
+ return String(value);
70
+ }
71
+
72
+ function versionOption(options) {
73
+ const version = Number(requireOption(options, "version"));
74
+ if (!Number.isSafeInteger(version) || version < 1) throw cliError("E_USAGE", "--version에는 1 이상의 정수가 필요합니다.");
75
+ return version;
76
+ }
77
+
78
+ function optionalVersionOption(options) {
79
+ return options.version === undefined ? null : versionOption(options);
80
+ }
81
+
82
+ function integerValue(value, label, minimum = 0) {
83
+ const number = Number(value);
84
+ if (!Number.isSafeInteger(number) || number < minimum) throw cliError("E_USAGE", `${label}에는 ${minimum} 이상의 정수가 필요합니다.`);
85
+ return number;
86
+ }
87
+
88
+ function statusOption(options, fallback = "active") {
89
+ const status = String(options.status || fallback);
90
+ if (!["active", "archived"].includes(status)) throw cliError("E_USAGE", "--status는 active 또는 archived여야 합니다.");
91
+ return status;
92
+ }
93
+
94
+ function assertAllowedOptions(options, commandPath) {
95
+ const permitted = allowedOptions(commandPath);
96
+ const unknown = Object.keys(options).find(key => !permitted.has(key));
97
+ if (unknown) throw cliError("E_USAGE", `지원하지 않는 option입니다: --${unknown}`);
98
+ }
99
+
100
+ function readJson(filePath, label, fsApi = fs) {
101
+ const absolutePath = path.resolve(String(filePath || ""));
102
+ let source;
103
+ try { source = fsApi.readFileSync(absolutePath, "utf8"); }
104
+ catch (error) { throw cliError("E_FILE_NOT_FOUND", `${label} 파일을 읽을 수 없습니다: ${absolutePath}`, null, error); }
105
+ try { return { path: absolutePath, value: JSON.parse(source) }; }
106
+ catch (error) { throw cliError("E_INVALID_JSON", `${label} 파일이 올바른 JSON이 아닙니다: ${absolutePath}`, null, error); }
107
+ }
108
+
109
+ function reservePrivateJson(filePath, fsApi = fs) {
110
+ const absolutePath = path.resolve(String(filePath || ""));
111
+ fsApi.mkdirSync(path.dirname(absolutePath), { recursive: true, mode: 0o700 });
112
+ let handle;
113
+ try { handle = fsApi.openSync(absolutePath, "wx", 0o600); }
114
+ catch (error) {
115
+ if (error.code === "EEXIST") throw cliError("E_FILE_EXISTS", `기존 파일을 덮어쓰지 않습니다: ${absolutePath}`);
116
+ throw cliError("E_FILE_WRITE", `파일을 쓸 수 없습니다: ${absolutePath}`, null, error);
117
+ }
118
+ let open = true;
119
+ return {
120
+ path: absolutePath,
121
+ commit(value) {
122
+ if (!open) throw cliError("E_FILE_WRITE", `이미 닫힌 파일 reservation입니다: ${absolutePath}`);
123
+ try {
124
+ fsApi.writeFileSync(handle, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8" });
125
+ fsApi.closeSync(handle);
126
+ open = false;
127
+ } catch (error) {
128
+ try { fsApi.closeSync(handle); } catch (closeError) { void closeError; }
129
+ open = false;
130
+ throw cliError("E_FILE_WRITE", `파일을 쓸 수 없습니다: ${absolutePath}`, null, error);
131
+ }
132
+ return absolutePath;
133
+ },
134
+ discard() {
135
+ if (open) {
136
+ try { fsApi.closeSync(handle); } catch (error) { void error; }
137
+ open = false;
138
+ }
139
+ try { fsApi.unlinkSync(absolutePath); } catch (error) { if (error.code !== "ENOENT") throw error; }
140
+ }
141
+ };
142
+ }
143
+
144
+ function writePrivateJson(filePath, value, fsApi = fs) {
145
+ const reservation = reservePrivateJson(filePath, fsApi);
146
+ try { return reservation.commit(value); }
147
+ catch (error) { reservation.discard(); throw error; }
148
+ }
149
+
150
+ function publicProject(project, instances = []) {
151
+ const manuscriptTitle = Array.isArray(instances)
152
+ ? instances.find(instance => instance.templateId === "ssobig.basic")?.data?.title
153
+ : undefined;
154
+ return {
155
+ id: project.id,
156
+ slug: project.slug,
157
+ identityTitle: project.title,
158
+ ...(manuscriptTitle ? { manuscriptTitle } : {}),
159
+ identitySummary: project.summary,
160
+ authorName: project.author_name,
161
+ engineKey: project.engine_key,
162
+ storageNamespace: project.storage_namespace,
163
+ status: project.status,
164
+ isCatalogDemo: project.is_catalog_demo === true,
165
+ updatedAt: project.updated_at
166
+ };
167
+ }
168
+
169
+ function publicVersion(version) {
170
+ return {
171
+ versionNumber: Number(version.version_number),
172
+ identityTitle: version.title,
173
+ displayName: version.display_name,
174
+ createdFromVersion: version.created_from_version,
175
+ updatedAt: version.updated_at
176
+ };
177
+ }
178
+
179
+ function publicInstance(instance, includeData = false) {
180
+ const result = {
181
+ instanceId: instance.instanceId,
182
+ templateId: instance.templateId,
183
+ tab: instance.tab,
184
+ order: instance.order,
185
+ revision: instance.revision,
186
+ required: instance.required,
187
+ removable: instance.removable
188
+ };
189
+ if (includeData) result.data = instance.data;
190
+ return result;
191
+ }
192
+
193
+ function publicCheckpoint(row, projectId, versionNumber) {
194
+ return normalizeCheckpoint({
195
+ ...row,
196
+ project_id: row.project_id || projectId,
197
+ version_number: row.version_number || versionNumber
198
+ }, { projectId, versionNumber });
199
+ }
200
+
201
+ function latestSnapshot(validated) {
202
+ return {
203
+ checkpoint: {
204
+ type: "latest",
205
+ projectId: String(validated.project.id),
206
+ versionNumber: validated.versionNumber,
207
+ componentSetRevision: Number(validated.componentSet.revision),
208
+ componentChecksum: String(validated.componentSet.component_checksum)
209
+ },
210
+ entries: validated.componentRows.map(row => ({
211
+ ...row,
212
+ source_revision: row.revision,
213
+ data_hash: `sha256:${sha256(row.data)}`,
214
+ changed_from_parent: false
215
+ }))
216
+ };
217
+ }
218
+
219
+ function unsupportedCommand(command, subcommand = "") {
220
+ const topLevel = new Set(["sql", "database", "migration", "schema", "view", "renderer"]);
221
+ const component = new Set(["add", "create", "instantiate", "set", "remove", "delete", "reorder", "archive", "enable", "disable", "rename"]);
222
+ const project = new Set(["create", "delete", "rename", "slug", "transfer", "archive", "restore"]);
223
+ if (topLevel.has(command) || (command === "component" && component.has(subcommand)) || (command === "project" && project.has(subcommand))) {
224
+ throw cliError("E_CODE_CHANGE_REQUIRED", "이 작업은 Writer 콘텐츠 CLI의 범위를 벗어납니다. 코드 또는 검토된 migration 작업으로 분리해 주세요.");
225
+ }
226
+ }
227
+
228
+ function nowIso(deps) {
229
+ const value = typeof deps.now === "function" ? deps.now() : new Date();
230
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
231
+ }
232
+
233
+ function planOptions(deps) {
234
+ return {
235
+ planId: typeof deps.randomUUID === "function" ? deps.randomUUID() : crypto.randomUUID(),
236
+ operationId: typeof deps.randomUUID === "function" ? deps.randomUUID() : crypto.randomUUID(),
237
+ createdAt: nowIso(deps)
238
+ };
239
+ }
240
+
241
+ function latestLoadedFromCorpus(corpus, project) {
242
+ const componentSet = corpus.componentSetRows.find(row => String(row.project_id) === String(project.id)
243
+ && String(row.canonical_status) === "active");
244
+ if (!componentSet) throw cliError("E_MIGRATION_REQUIRED", `${project.slug}의 단일 Latest workspace를 찾을 수 없습니다.`);
245
+ const versionNumber = Number(componentSet.version_number);
246
+ const sameWorkspace = row => String(row.project_id) === String(project.id) && Number(row.version_number) === versionNumber;
247
+ const version = corpus.versionRows.find(sameWorkspace);
248
+ if (!version) throw cliError("E_VERSION_NOT_FOUND", `${project.slug}의 v${versionNumber}을 찾을 수 없습니다.`);
249
+ return {
250
+ project,
251
+ version,
252
+ componentSet,
253
+ componentRows: corpus.componentRows.filter(sameWorkspace),
254
+ viewRows: corpus.viewRows.filter(sameWorkspace),
255
+ bindingRows: corpus.bindingRows.filter(sameWorkspace)
256
+ };
257
+ }
258
+
259
+ async function execute(argv, deps) {
260
+ const [command = "help", subcommand = "", ...rest] = argv;
261
+ if (["help", "--help", "-h"].includes(command)) {
262
+ const target = command === "help" ? [subcommand, ...rest].filter(Boolean) : [];
263
+ return { command: "help", data: { scope: normalizeScope(target), help: helpFor(target) } };
264
+ }
265
+ if (["--help", "-h"].includes(subcommand)) return { command: "help", data: { scope: command, help: helpFor(command) } };
266
+ unsupportedCommand(command, subcommand);
267
+ const topLevelOptionCommand = ["version", "doctor", "read", "search", "sync", "command-schema"].includes(command);
268
+ const { options, positional } = parseArgs(topLevelOptionCommand ? [subcommand, ...rest].filter(Boolean) : rest);
269
+ if (!["apply", "validate", "command-schema"].includes(command) && positional.length) throw cliError("E_USAGE", `예상하지 않은 인자입니다: ${positional.join(" ")}`);
270
+ const commandPath = getCommand(`${command}.${subcommand}`) ? `${command}.${subcommand}` : command;
271
+ if (options.help) return { command: "help", data: { scope: commandPath, help: helpFor(commandPath) } };
272
+ if (command === "command-schema") {
273
+ assertAllowedOptions(options, "command-schema");
274
+ if (positional.length > 2) throw cliError("E_USAGE", "command-schema에는 group 또는 정확한 leaf 하나만 지정할 수 있습니다.");
275
+ const scope = positional.length === 2 ? positional.join(".") : (positional[0] || "");
276
+ return { command: "command-schema", data: commandSchema(scope) };
277
+ }
278
+ if (command === "version") {
279
+ assertAllowedOptions(options, "version");
280
+ return { command: "version", data: await (deps.versionReport || versionReport)({
281
+ checkLatest: options.check === true,
282
+ fetch: deps.fetch,
283
+ fetchLatest: deps.fetchLatest,
284
+ computeFingerprint: deps.computeFingerprint,
285
+ nodeVersion: deps.nodeVersion,
286
+ platform: deps.platform,
287
+ arch: deps.arch
288
+ }) };
289
+ }
290
+ if (command === "doctor") {
291
+ assertAllowedOptions(options, "doctor");
292
+ return { command: "doctor", data: await (deps.doctorReport || doctorReport)({
293
+ fs: deps.fs,
294
+ fetch: deps.fetch,
295
+ nodeVersion: deps.nodeVersion,
296
+ platform: deps.platform,
297
+ computeFingerprint: deps.computeFingerprint,
298
+ keychainCheck: deps.keychainCheck,
299
+ credentialStoreCheck: deps.credentialStoreCheck,
300
+ credentialOptions: deps.credentialOptions,
301
+ sessionStatus: deps.sessionStatus,
302
+ healthCheck: deps.healthCheck,
303
+ probeDaemon: deps.probeDaemon,
304
+ now: deps.now,
305
+ checkLatestVersion: deps.enforceUpdates === true,
306
+ fetchLatest: deps.fetchLatest,
307
+ currentVersion: deps.currentVersion
308
+ }) };
309
+ }
310
+ if (command === "skills") {
311
+ if (!getCommand(`skills.${subcommand}`)) throw cliError("E_USAGE", "skills subcommand는 install, status, update, remove 중 하나여야 합니다.");
312
+ assertAllowedOptions(options, `skills.${subcommand}`);
313
+ const skillOptions = {
314
+ target: requireOption(options, "target"),
315
+ codex: options.codex === true,
316
+ claude: options.claude === true,
317
+ dryRun: options["dry-run"] === true,
318
+ managedOnly: options["managed-only"] === true,
319
+ fs: deps.fs,
320
+ payloadRoot: deps.skillPayloadRoot
321
+ };
322
+ if (subcommand === "status") return { command: "skills.status", data: (deps.skillStatus || skillStatus)(skillOptions) };
323
+ if (subcommand === "remove") return { command: "skills.remove", data: (deps.removeManagedSkill || removeManaged)(skillOptions) };
324
+ return { command: `skills.${subcommand}`, data: (deps.installOrUpdateSkill || installOrUpdate)(skillOptions) };
325
+ }
326
+ const registeredCommand = getCommand(commandPath);
327
+ if (!registeredCommand) throw cliError("E_USAGE", "지원하지 않는 Writer CLI 명령입니다. --help로 명령 목록을 확인해 주세요.");
328
+ if (deps.enforceUpdates === true && registeredCommand.updatePolicy === "required") {
329
+ const update = await (deps.updateCheck || checkRequiredUpdate)({
330
+ currentVersion: deps.currentVersion,
331
+ fetchLatest: deps.fetchLatest,
332
+ fetch: deps.fetch,
333
+ timeoutMs: deps.updateTimeoutMs,
334
+ ttlMs: deps.updateTtlMs,
335
+ fs: deps.fs,
336
+ cacheDir: deps.cacheDir,
337
+ platform: deps.platform,
338
+ environment: deps.environment,
339
+ homeDirectory: deps.homeDirectory,
340
+ now: deps.now
341
+ });
342
+ if (update?.warning) deps.stderr.write(`${JSON.stringify({ warning: { code: "W_CLI_UPDATE_CHECK_UNAVAILABLE", message: update.warning } })}\n`);
343
+ }
344
+ const platform = String(deps.platform || process.platform);
345
+ const authOptions = { ...(deps.authOptions || {}), platform };
346
+ const auth = deps.auth || createAuthManager(config, authOptions);
347
+ const gatewayFactory = deps.gatewayFactory || (client => createSupabaseGateway(client, { bucketName: config.assetBucket }));
348
+ const daemonRequest = deps.requestDaemon || ((method, params, options = {}) => requestWriterService(method, params, {
349
+ ...options,
350
+ platform,
351
+ fs: deps.fs,
352
+ environment: deps.environment,
353
+ homeDirectory: deps.homeDirectory,
354
+ cacheDir: deps.cacheDir,
355
+ authOptions,
356
+ gatewayFactory: deps.agentGatewayFactory,
357
+ indexFactory: deps.indexFactory,
358
+ realtime: deps.realtime,
359
+ serviceFactory: deps.serviceFactory
360
+ }));
361
+ const daemonStart = deps.startDaemon || startDaemon;
362
+ const daemonStop = deps.stopDaemon || stopDaemon;
363
+
364
+ if (command === "auth") {
365
+ if (subcommand === "login") {
366
+ assertAllowedOptions(options, "auth.login");
367
+ if (platform !== "win32") {
368
+ try { await daemonStop({ timeoutMs: 5_000 }); }
369
+ catch (error) { if (error.code !== "E_DAEMON_UNAVAILABLE") throw error; }
370
+ }
371
+ const user = await auth.login({
372
+ openBrowser: options["no-open"] !== true,
373
+ onUrl: url => { if (options["no-open"] === true) deps.stderr.write(`브라우저에서 다음 URL을 여세요:\n${url}\n`); }
374
+ });
375
+ return { command: "auth.login", data: { user } };
376
+ }
377
+ if (subcommand === "whoami") {
378
+ assertAllowedOptions(options, "auth.whoami");
379
+ return { command: "auth.whoami", data: { user: await auth.whoami() } };
380
+ }
381
+ if (subcommand === "logout") {
382
+ assertAllowedOptions(options, "auth.logout");
383
+ try {
384
+ const data = await daemonRequest("writer.logout", {}, { autoStart: false, timeoutMs: 120_000 });
385
+ return { command: "auth.logout", data };
386
+ } catch (error) {
387
+ if (error.code !== "E_DAEMON_UNAVAILABLE") throw error;
388
+ const data = await auth.logout();
389
+ const purged = purgeAllAgentIndexes({ fs: deps.fs, platform, environment: deps.environment, homeDirectory: deps.homeDirectory });
390
+ return { command: "auth.logout", data: { ...data, localIndexPurged: true, removedIndexFiles: purged.removedFiles } };
391
+ }
392
+ }
393
+ throw cliError("E_USAGE", "auth subcommand는 login, whoami, logout 중 하나여야 합니다.");
394
+ }
395
+
396
+ if (command === "agent") {
397
+ if (subcommand === "start") {
398
+ assertAllowedOptions(options, "agent.start");
399
+ if (platform === "win32") throw cliError("E_PLATFORM_UNSUPPORTED", "Windows에서는 background daemon을 지원하지 않습니다. CLI는 안전한 in-process backend를 자동으로 사용합니다.");
400
+ return { command: "agent.start", data: await daemonStart() };
401
+ }
402
+ if (subcommand === "status") {
403
+ assertAllowedOptions(options, "agent.status");
404
+ if (platform === "win32") return { command: "agent.status", data: { running: false, supported: false, mode: "in-process" } };
405
+ try { return { command: "agent.status", data: await daemonRequest("daemon.status", {}, { autoStart: false, timeoutMs: 5_000 }) }; }
406
+ catch (error) {
407
+ if (error.code === "E_DAEMON_UNAVAILABLE") return { command: "agent.status", data: { running: false } };
408
+ throw error;
409
+ }
410
+ }
411
+ if (subcommand === "stop") {
412
+ assertAllowedOptions(options, "agent.stop");
413
+ if (platform === "win32") throw cliError("E_PLATFORM_UNSUPPORTED", "Windows에서는 background daemon을 실행하지 않으므로 중지할 daemon이 없습니다.");
414
+ return { command: "agent.stop", data: await daemonStop() };
415
+ }
416
+ if (subcommand === "get") {
417
+ assertAllowedOptions(options, "agent.get");
418
+ return { command: "agent.get", data: await daemonRequest("writer.get", {
419
+ matchId: requireOption(options, "match"),
420
+ ...(options["max-characters"] !== undefined
421
+ ? { maximumCharacters: integerValue(options["max-characters"], "--max-characters", 100) }
422
+ : {})
423
+ }) };
424
+ }
425
+ if (subcommand === "apply") {
426
+ assertAllowedOptions(options, "agent.apply");
427
+ return { command: "agent.apply", data: await daemonRequest("writer.apply", {
428
+ planPath: path.resolve(requireOption(options, "plan")),
429
+ receiptPath: path.resolve(requireOption(options, "receipt"))
430
+ }, { timeoutMs: 600_000 }) };
431
+ }
432
+ if (subcommand === "purge") {
433
+ assertAllowedOptions(options, "agent.purge");
434
+ try { return { command: "agent.purge", data: await daemonRequest("writer.purge", {}, { autoStart: false }) }; }
435
+ catch (error) {
436
+ if (error.code !== "E_DAEMON_UNAVAILABLE") throw error;
437
+ const purged = purgeAllAgentIndexes({ fs: deps.fs, platform, environment: deps.environment, homeDirectory: deps.homeDirectory, cacheDir: deps.cacheDir });
438
+ return { command: "agent.purge", data: { purged: true, removedIndexFiles: purged.removedFiles, cacheAuthoritative: false } };
439
+ }
440
+ }
441
+ throw cliError("E_USAGE", "agent subcommand는 start, status, stop, get, apply, purge 중 하나여야 합니다.");
442
+ }
443
+
444
+ if (command === "sync") {
445
+ const parsed = parseArgs([subcommand, ...rest].filter(Boolean));
446
+ if (parsed.positional.length) throw cliError("E_USAGE", `예상하지 않은 인자입니다: ${parsed.positional.join(" ")}`);
447
+ assertAllowedOptions(parsed.options, "sync");
448
+ return { command: "sync", data: await daemonRequest("writer.sync", {}, { timeoutMs: 120_000 }) };
449
+ }
450
+
451
+ if (command === "read") {
452
+ const parsed = parseArgs([subcommand, ...rest].filter(Boolean));
453
+ if (parsed.positional.length) throw cliError("E_USAGE", `예상하지 않은 인자입니다: ${parsed.positional.join(" ")}`);
454
+ assertAllowedOptions(parsed.options, "read");
455
+ const request = {
456
+ ...(parsed.options.project !== undefined ? { project: String(parsed.options.project) } : {}),
457
+ ...(parsed.options.version !== undefined ? { version: integerValue(parsed.options.version, "--version", 1) } : {}),
458
+ ...(parsed.options.component !== undefined ? { component: String(parsed.options.component) } : {}),
459
+ ...(parsed.options.pointer !== undefined ? { pointer: String(parsed.options.pointer) } : {}),
460
+ includeAssets: parsed.options["include-assets"] === true,
461
+ fresh: parsed.options.fresh === true,
462
+ requireFresh: parsed.options["require-fresh"] === true
463
+ };
464
+ return { command: "read", data: await daemonRequest("writer.read", request, { timeoutMs: 120_000 }) };
465
+ }
466
+
467
+ if (command === "search") {
468
+ const parsed = parseArgs([subcommand, ...rest].filter(Boolean));
469
+ if (parsed.positional.length) throw cliError("E_USAGE", `예상하지 않은 인자입니다: ${parsed.positional.join(" ")}`);
470
+ assertAllowedOptions(parsed.options, "search");
471
+ const mode = String(parsed.options.mode || (parsed.options["value-json"] !== undefined ? "exact" : "substring"));
472
+ if (parsed.options.query !== undefined && parsed.options["value-json"] !== undefined) throw cliError("E_USAGE", "--query와 --value-json은 함께 사용할 수 없습니다.");
473
+ let typedValue;
474
+ if (parsed.options["value-json"] !== undefined) {
475
+ try { typedValue = JSON.parse(parsed.options["value-json"]); }
476
+ catch (error) { throw cliError("E_INVALID_JSON", "--value-json이 올바른 JSON scalar가 아닙니다.", null, error); }
477
+ if (typedValue !== null && !["string", "number", "boolean"].includes(typeof typedValue)) throw cliError("E_USAGE", "--value-json에는 JSON scalar만 사용할 수 있습니다.");
478
+ }
479
+ if (mode !== "all" && parsed.options.query === undefined && parsed.options["value-json"] === undefined) throw cliError("E_USAGE", "--query 또는 --value-json option이 필요합니다.");
480
+ const search = {
481
+ ...(parsed.options.query !== undefined ? { query: String(parsed.options.query) } : {}),
482
+ ...(parsed.options["value-json"] !== undefined ? { value: typedValue } : {}),
483
+ mode,
484
+ ...(parsed.options.project ? { project: parsed.options.project } : {}),
485
+ ...(parsed.options.version ? { version: integerValue(parsed.options.version, "--version", 1) } : {}),
486
+ ...(parsed.options.component ? { component: parsed.options.component } : {}),
487
+ ...(parsed.options.path ? { path: parsed.options.path } : {}),
488
+ ...(parsed.options["path-prefix"] ? { pathPrefix: parsed.options["path-prefix"] } : {}),
489
+ ...(parsed.options["path-glob"] ? { pathGlob: parsed.options["path-glob"] } : {}),
490
+ ...(parsed.options.field ? { field: parsed.options.field } : {}),
491
+ ...(parsed.options["value-type"] ? { valueType: parsed.options["value-type"] } : {}),
492
+ ...(parsed.options.limit ? { limit: integerValue(parsed.options.limit, "--limit", 1) } : {}),
493
+ ...(parsed.options.flags ? { flags: parsed.options.flags } : {}),
494
+ caseSensitive: parsed.options["case-sensitive"] === true,
495
+ includeAssets: parsed.options["include-assets"] === true,
496
+ fresh: parsed.options.fresh === true,
497
+ requireFresh: parsed.options["require-fresh"] === true
498
+ };
499
+ return { command: "search", data: await daemonRequest("writer.search", search, { timeoutMs: 120_000 }) };
500
+ }
501
+
502
+ if (command === "component" && subcommand === "plan-replace") {
503
+ assertAllowedOptions(options, "component.plan-replace");
504
+ if (options.new === undefined) throw cliError("E_USAGE", "--new option이 필요합니다.");
505
+ return { command: "component.plan-replace", data: await daemonRequest("writer.plan_replace", {
506
+ matchId: requireOption(options, "match"),
507
+ oldText: requireOption(options, "old"),
508
+ newText: String(options.new),
509
+ out: path.resolve(requireOption(options, "out")),
510
+ ...(options.occurrence !== undefined ? { occurrenceIndex: integerValue(options.occurrence, "--occurrence", 0) } : {}),
511
+ ...(options.message !== undefined ? { checkpointMessage: String(options.message) } : {})
512
+ }, { timeoutMs: 120_000 }) };
513
+ }
514
+
515
+ return auth.withSession(async ({ client, user }) => {
516
+ const gateway = gatewayFactory(client);
517
+ if (command === "project" && subcommand === "list") {
518
+ assertAllowedOptions(options, "project.list");
519
+ const status = statusOption(options);
520
+ return { command: "project.list", data: { status, projects: (await gateway.listProjects(status)).map(publicProject) } };
521
+ }
522
+ if (command === "project" && subcommand === "show") {
523
+ assertAllowedOptions(options, "project.show");
524
+ const status = statusOption(options);
525
+ const project = await gateway.getProject(requireOption(options, "project"), status);
526
+ try { projectRuntime.resolveProject(project); }
527
+ catch (error) { throw cliError("E_MIGRATION_REQUIRED", error.message, null, error); }
528
+ const versions = await gateway.listVersions(project.id);
529
+ return { command: "project.show", data: { project: publicProject(project), versions: versions.map(publicVersion) } };
530
+ }
531
+ if (command === "project" && subcommand === "plan-create") {
532
+ assertAllowedOptions(options, "project.plan-create");
533
+ const plan = createProjectCreationPlan({
534
+ slug: requireOption(options, "slug"),
535
+ title: requireOption(options, "title"),
536
+ authorName: String(user.displayName || user.email.split("@")[0])
537
+ }, user.email, planOptions(deps));
538
+ if (await gateway.findProjectBySlug(plan.target.slug)) {
539
+ throw cliError("E_CONFLICT", `이미 사용 중인 작품 slug입니다: ${plan.target.slug}`);
540
+ }
541
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
542
+ return {
543
+ command: "project.plan-create",
544
+ data: {
545
+ planFile: outputPath,
546
+ planId: plan.planId,
547
+ digest: plan.digest,
548
+ operation: plan.operation,
549
+ target: plan.target
550
+ }
551
+ };
552
+ }
553
+ if (command === "project" && subcommand === "plan-import") {
554
+ assertAllowedOptions(options, "project.plan-import");
555
+ const imported = loadTrueWriterCase({
556
+ sourceRoot: requireOption(options, "source-root"),
557
+ sourceJson: requireOption(options, "source-json"),
558
+ title: requireOption(options, "title")
559
+ }, { fs: deps.fs });
560
+ const plan = createProjectImportPlan({
561
+ slug: requireOption(options, "slug"),
562
+ title: imported.title,
563
+ authorName: String(user.displayName || user.email.split("@")[0]),
564
+ components: imported.components,
565
+ source: imported.source,
566
+ coverage: imported.coverage,
567
+ assets: imported.assets
568
+ }, user.email, planOptions(deps));
569
+ if (await gateway.findProjectBySlug(plan.target.slug)) {
570
+ throw cliError("E_CONFLICT", `이미 사용 중인 작품 slug입니다: ${plan.target.slug}`);
571
+ }
572
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
573
+ return {
574
+ command: "project.plan-import",
575
+ data: {
576
+ planFile: outputPath,
577
+ planId: plan.planId,
578
+ digest: plan.digest,
579
+ operation: plan.operation,
580
+ target: plan.target,
581
+ source: {
582
+ format: plan.source.format,
583
+ sourceHash: plan.source.sourceHash,
584
+ sourceGameId: plan.source.sourceGameId,
585
+ sourceLastModified: plan.source.sourceLastModified,
586
+ fileCount: plan.source.files.length
587
+ },
588
+ coverage: plan.coverage,
589
+ components: plan.components.map(item => ({ instanceId: item.instanceId, templateId: item.templateId, dataHash: sha256(item.data) })),
590
+ assets: plan.assets.map(item => ({ assetId: item.assetId, name: item.name, type: item.type, size: item.size, contentHash: item.contentHash }))
591
+ }
592
+ };
593
+ }
594
+ if (command === "project" && ["plan-archive", "plan-restore"].includes(subcommand)) {
595
+ assertAllowedOptions(options, `project.${subcommand}`);
596
+ const operation = subcommand === "plan-archive" ? "project.archive" : "project.restore";
597
+ const expectedStatus = operation === "project.archive" ? "active" : "archived";
598
+ const project = await gateway.getProject(requireOption(options, "project"), expectedStatus);
599
+ const plan = createProjectStatusPlan(project, operation, user.email, planOptions(deps));
600
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
601
+ return { command: `project.${subcommand}`, data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, project: plan.project, target: plan.target } };
602
+ }
603
+ if (command === "project" && subcommand === "plan-set-catalog-demo") {
604
+ assertAllowedOptions(options, "project.plan-set-catalog-demo");
605
+ const enabledValue = requireOption(options, "enabled");
606
+ if (!new Set(["true", "false"]).has(enabledValue)) throw cliError("E_USAGE", "--enabled는 true 또는 false여야 합니다.");
607
+ const project = await gateway.getProject(requireOption(options, "project"), "active");
608
+ const plan = createCatalogDemoPlan(project, enabledValue === "true", user.email, planOptions(deps));
609
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
610
+ return { command: "project.plan-set-catalog-demo", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, project: plan.project, target: plan.target } };
611
+ }
612
+
613
+ if (command === "component" && ["list", "get", "plan-patch", "plan-layout-investigation-board"].includes(subcommand)) {
614
+ assertAllowedOptions(options, `component.${subcommand}`);
615
+ const projectRef = requireOption(options, "project");
616
+ const validated = validateLoadedVersion(await gateway.loadLatestVersion(projectRef, optionalVersionOption(options)));
617
+ const version = validated.versionNumber;
618
+ if (subcommand === "list") {
619
+ return { command: "component.list", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, components: validated.instances.map(item => publicInstance(item)) } };
620
+ }
621
+ if (subcommand === "plan-layout-investigation-board") {
622
+ const spec = readJson(requireOption(options, "spec"), "추리 보드 layout spec", deps.fs).value;
623
+ const layoutPlanOptions = planOptions(deps);
624
+ if (options.message !== undefined) layoutPlanOptions.checkpointMessage = String(options.message);
625
+ const plan = createInvestigationBoardLayoutPlan(
626
+ validated,
627
+ options.instance || "investigation-board",
628
+ spec,
629
+ user.email,
630
+ layoutPlanOptions
631
+ );
632
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
633
+ return {
634
+ command: "component.plan-layout-investigation-board",
635
+ data: {
636
+ planFile: outputPath,
637
+ planId: plan.planId,
638
+ digest: plan.digest,
639
+ operation: plan.operation,
640
+ checkpointMessage: plan.checkpointMessage,
641
+ target: plan.target,
642
+ specHash: plan.layout.specHash,
643
+ algorithmVersion: plan.layout.algorithmVersion,
644
+ review: plan.layout.review,
645
+ beforeHash: plan.beforeHash,
646
+ afterHash: plan.afterHash
647
+ }
648
+ };
649
+ }
650
+ const selector = requireOption(options, "instance");
651
+ if (subcommand === "get") {
652
+ return { command: "component.get", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, component: publicInstance(findInstance(validated, selector), true) } };
653
+ }
654
+ const patch = readJson(requireOption(options, "patch"), "patch", deps.fs).value;
655
+ const componentPlanOptions = planOptions(deps);
656
+ if (options.message !== undefined) componentPlanOptions.checkpointMessage = String(options.message);
657
+ const plan = createComponentPlan(validated, selector, patch, user.email, componentPlanOptions);
658
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
659
+ return { command: "component.plan-patch", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, checkpointMessage: plan.checkpointMessage, target: plan.target, patch: plan.patch, beforeHash: plan.beforeHash, afterHash: plan.afterHash } };
660
+ }
661
+
662
+ if (command === "asset" && ["list", "plan-upload", "plan-delete"].includes(subcommand)) {
663
+ assertAllowedOptions(options, `asset.${subcommand}`);
664
+ const projectRef = requireOption(options, "project");
665
+ const validated = validateLoadedVersion(await gateway.loadLatestVersion(projectRef, optionalVersionOption(options)));
666
+ const version = validated.versionNumber;
667
+ const manifest = componentContract.assetManifest(validated.instances);
668
+ if (subcommand === "list") {
669
+ const assetInstance = componentContract.assetInstance(validated.instances);
670
+ return { command: "asset.list", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, revision: assetInstance.revision, assets: manifest.assets } };
671
+ }
672
+ const assetId = assertAssetId(requireOption(options, "asset-id"));
673
+ const operation = subcommand === "plan-upload" ? "asset.upload" : "asset.delete";
674
+ const optionsForPlan = planOptions(deps);
675
+ if (options.message !== undefined) optionsForPlan.checkpointMessage = String(options.message);
676
+ if (operation === "asset.upload") optionsForPlan.file = inspectAssetFile(requireOption(options, "file"), assetId);
677
+ const plan = createAssetPlan(validated, operation, assetId, user.email, optionsForPlan);
678
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
679
+ return { command: subcommand === "plan-upload" ? "asset.plan-upload" : "asset.plan-delete", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, checkpointMessage: plan.checkpointMessage, target: plan.target, file: plan.file || null, previousEntry: plan.previousEntry } };
680
+ }
681
+
682
+ if (command === "checkpoint" && ["list", "status", "show", "diff", "plan-create", "plan-restore"].includes(subcommand)) {
683
+ assertAllowedOptions(options, `checkpoint.${subcommand}`);
684
+ const validated = validateLoadedVersion(await gateway.loadLatestVersion(
685
+ requireOption(options, "project"),
686
+ optionalVersionOption(options)
687
+ ));
688
+ const workspace = {
689
+ project: publicProject(validated.project, validated.instances),
690
+ versionNumber: validated.versionNumber
691
+ };
692
+ if (subcommand === "list" || subcommand === "status") {
693
+ const limit = subcommand === "status" ? 1 : integerValue(options.limit || 50, "--limit", 1);
694
+ if (limit > 100) throw cliError("E_USAGE", "--limit은 100 이하여야 합니다.");
695
+ const before = options.before === undefined ? null : integerValue(options.before, "--before", 1);
696
+ const rows = await gateway.listCheckpoints({
697
+ projectId: validated.project.id,
698
+ versionNumber: validated.versionNumber,
699
+ beforeCheckpointNumber: before,
700
+ limit
701
+ });
702
+ const checkpoints = rows.map(row => publicCheckpoint(row, validated.project.id, validated.versionNumber));
703
+ if (subcommand === "status") {
704
+ const head = checkpoints[0] || null;
705
+ return { command: "checkpoint.status", data: { ...workspace, head, latestChanged: head?.latestChanged ?? null } };
706
+ }
707
+ return {
708
+ command: "checkpoint.list",
709
+ data: {
710
+ ...workspace,
711
+ checkpoints,
712
+ nextBeforeCheckpointNumber: checkpoints.length === limit ? checkpoints.at(-1).number : null
713
+ }
714
+ };
715
+ }
716
+ if (subcommand === "show") {
717
+ const snapshot = await gateway.getCheckpointSnapshot(
718
+ validated.project.id,
719
+ validated.versionNumber,
720
+ requireOption(options, "checkpoint"),
721
+ { includeData: options["include-data"] === true }
722
+ );
723
+ snapshot.checkpoint = publicCheckpoint(snapshot.checkpoint, validated.project.id, validated.versionNumber);
724
+ return { command: "checkpoint.show", data: { ...workspace, ...snapshotSummary(snapshot, options["include-data"] === true) } };
725
+ }
726
+ if (subcommand === "diff") {
727
+ const loadSnapshot = async selector => {
728
+ if (String(selector).toLowerCase() === "latest") return latestSnapshot(validated);
729
+ const snapshot = await gateway.getCheckpointSnapshot(validated.project.id, validated.versionNumber, selector, { includeData: true });
730
+ snapshot.checkpoint = publicCheckpoint(snapshot.checkpoint, validated.project.id, validated.versionNumber);
731
+ return snapshot;
732
+ };
733
+ const [from, to] = await Promise.all([
734
+ loadSnapshot(requireOption(options, "from")),
735
+ loadSnapshot(requireOption(options, "to"))
736
+ ]);
737
+ return { command: "checkpoint.diff", data: { ...workspace, ...diffSnapshots(from, to, { includeValues: options["include-values"] === true }) } };
738
+ }
739
+ const plan = subcommand === "plan-create"
740
+ ? createCheckpointPlan(validated, "checkpoint.create", requireOption(options, "message"), user.email, planOptions(deps))
741
+ : createCheckpointPlan(validated, "checkpoint.restore", requireOption(options, "message"), user.email, {
742
+ ...planOptions(deps),
743
+ target: await gateway.getCheckpoint(validated.project.id, validated.versionNumber, requireOption(options, "checkpoint"))
744
+ });
745
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
746
+ return {
747
+ command: `checkpoint.${subcommand}`,
748
+ data: {
749
+ planFile: outputPath,
750
+ planId: plan.planId,
751
+ digest: plan.digest,
752
+ operation: plan.operation,
753
+ checkpointMessage: plan.checkpointMessage,
754
+ ...(plan.target ? { target: plan.target } : {}),
755
+ readiness: plan.readiness
756
+ }
757
+ };
758
+ }
759
+
760
+ if (command === "validate") {
761
+ const parsed = parseArgs([subcommand, ...rest].filter(Boolean));
762
+ if (parsed.positional.length) throw cliError("E_USAGE", `예상하지 않은 인자입니다: ${parsed.positional.join(" ")}`);
763
+ assertAllowedOptions(parsed.options, "validate");
764
+ const validated = validateLoadedVersion(await gateway.loadLatestVersion(requireOption(parsed.options, "project"), optionalVersionOption(parsed.options)));
765
+ return { command: "validate", data: { valid: true, project: publicProject(validated.project, validated.instances), versionNumber: validated.versionNumber, componentSetRevision: Number(validated.componentSet.revision), componentChecksum: validated.componentSet.component_checksum, componentCount: validated.instances.length, viewCount: validated.views.length } };
766
+ }
767
+
768
+ if (command === "apply") {
769
+ const applyTokens = [subcommand, ...rest].filter(Boolean);
770
+ const parsed = parseArgs(applyTokens);
771
+ assertAllowedOptions(parsed.options, "apply");
772
+ if (parsed.positional.length !== 1) throw cliError("E_USAGE", "apply에는 plan 파일 하나가 필요합니다.");
773
+ const planRead = readJson(parsed.positional[0], "plan", deps.fs);
774
+ const plan = verifyPlan(planRead.value);
775
+ const receiptReservation = parsed.options.receipt ? reservePrivateJson(parsed.options.receipt, deps.fs) : null;
776
+ let receipt;
777
+ try {
778
+ if (plan.operation === "project.create") {
779
+ receipt = await applyProjectCreationPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
780
+ } else if (plan.operation === "project.import") {
781
+ receipt = await applyProjectImportPlan(gateway, plan, user.email, { appliedAt: nowIso(deps), fs: deps.fs });
782
+ } else if (plan.operation === "project.catalog-demo") {
783
+ receipt = await applyCatalogDemoPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
784
+ } else if (plan.operation.startsWith("project.")) {
785
+ receipt = await applyProjectStatusPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
786
+ } else if (plan.operation === "component.patch") {
787
+ receipt = await applyComponentPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
788
+ } else if (plan.operation.startsWith("checkpoint.")) {
789
+ receipt = await applyCheckpointPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
790
+ } else {
791
+ receipt = await applyAssetPlan(gateway, plan, user.email, { appliedAt: nowIso(deps), now: deps.timestamp || Date.now, fs: deps.fs });
792
+ }
793
+ } catch (error) {
794
+ receiptReservation?.discard();
795
+ throw error;
796
+ }
797
+ let receiptFile = null;
798
+ let receiptFileWarning = null;
799
+ if (receiptReservation) {
800
+ try { receiptFile = receiptReservation.commit(receipt); }
801
+ catch (error) {
802
+ try { receiptReservation.discard(); } catch (discardError) { void discardError; }
803
+ const normalized = normalizeError(error);
804
+ receiptFileWarning = { code: normalized.code, message: normalized.message };
805
+ }
806
+ }
807
+ return { command: "apply", data: { planFile: planRead.path, receiptFile, ...(receiptFileWarning ? { receiptFileWarning } : {}), receipt } };
808
+ }
809
+
810
+ throw cliError("E_USAGE", "지원하지 않는 Writer CLI 명령입니다. --help로 명령 목록을 확인해 주세요.");
811
+ });
812
+ }
813
+
814
+ async function runCli(argv, providedDeps = {}) {
815
+ const deps = {
816
+ stdout: providedDeps.stdout || process.stdout,
817
+ stderr: providedDeps.stderr || process.stderr,
818
+ fs: providedDeps.fs || fs,
819
+ ...providedDeps
820
+ };
821
+ const compact = argv.includes("--compact");
822
+ try {
823
+ const result = await execute(argv, deps);
824
+ deps.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, compact ? 0 : 2)}\n`);
825
+ return 0;
826
+ } catch (error) {
827
+ const normalized = normalizeError(error);
828
+ const payload = {
829
+ ok: false,
830
+ error: {
831
+ code: normalized.code,
832
+ message: redact(normalized.message),
833
+ ...(normalized.details ? { details: normalized.details } : {})
834
+ }
835
+ };
836
+ deps.stderr.write(`${JSON.stringify(payload, null, compact ? 0 : 2)}\n`);
837
+ return normalized.code === "E_USAGE" ? 2 : 1;
838
+ }
839
+ }
840
+
841
+ module.exports = Object.freeze({ HELP, parseArgs, readJson, reservePrivateJson, writePrivateJson, execute, runCli });