@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,496 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const config = require("../../../config.js");
5
+ const { createAuthManager } = require("./auth.cjs");
6
+ const { createSupabaseGateway } = require("./gateway.cjs");
7
+ const { buildValidatedSnapshot } = require("./corpus.cjs");
8
+ const {
9
+ createComponentPlan,
10
+ findInstance,
11
+ verifyPlan,
12
+ validateLoadedVersion
13
+ } = require("./domain.cjs");
14
+ const { applyProjectCreationPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
15
+ const { pointerSegments } = require("./json-patch.cjs");
16
+ const { equalJson, sha256 } = require("./json.cjs");
17
+ const { cliError, normalizeError } = require("./errors.cjs");
18
+ const { readJson, reservePrivateJson, writePrivateJson } = require("./commands.cjs");
19
+ const { resolveAgentPaths, ensurePrivateDirectory, purgeAllAgentIndexes } = require("./agent-paths.cjs");
20
+
21
+ function pointerValue(document, pointer) {
22
+ let value = document;
23
+ for (const segment of pointerSegments(pointer)) {
24
+ if (Array.isArray(value)) {
25
+ if (!/^(0|[1-9][0-9]*)$/.test(segment) || Number(segment) >= value.length) {
26
+ throw cliError("E_INVALID_PATCH", `JSON Pointer 대상이 없습니다: ${pointer}`);
27
+ }
28
+ value = value[Number(segment)];
29
+ } else if (value && typeof value === "object" && Object.hasOwn(value, segment)) {
30
+ value = value[segment];
31
+ } else {
32
+ throw cliError("E_INVALID_PATCH", `JSON Pointer 대상이 없습니다: ${pointer}`);
33
+ }
34
+ }
35
+ return value;
36
+ }
37
+
38
+ function replaceOccurrence(source, oldText, newText, occurrenceIndex) {
39
+ if (typeof source !== "string") throw cliError("E_REPLACE_TYPE", "텍스트 부분 교체 대상은 문자열 필드여야 합니다.");
40
+ if (typeof oldText !== "string" || oldText.length === 0) throw cliError("E_USAGE", "oldText에는 비어 있지 않은 문자열이 필요합니다.");
41
+ if (typeof newText !== "string") throw cliError("E_USAGE", "newText에는 문자열이 필요합니다.");
42
+ const offsets = [];
43
+ let cursor = 0;
44
+ while (cursor <= source.length - oldText.length) {
45
+ const found = source.indexOf(oldText, cursor);
46
+ if (found < 0) break;
47
+ offsets.push(found);
48
+ cursor = found + Math.max(1, oldText.length);
49
+ }
50
+ if (!offsets.length) throw cliError("E_CONFLICT", "서버 원본에서 교체할 기존 텍스트를 찾을 수 없습니다. 다시 검색해 주세요.");
51
+ let selected = occurrenceIndex;
52
+ if (selected === undefined || selected === null) {
53
+ if (offsets.length !== 1) throw cliError("E_AMBIGUOUS_MATCH", "같은 필드에 기존 텍스트가 여러 번 있습니다. occurrenceIndex를 명시해 주세요.", { occurrences: offsets.length });
54
+ selected = 0;
55
+ }
56
+ selected = Number(selected);
57
+ if (!Number.isSafeInteger(selected) || selected < 0 || selected >= offsets.length) {
58
+ throw cliError("E_USAGE", `occurrenceIndex는 0부터 ${offsets.length - 1} 사이여야 합니다.`);
59
+ }
60
+ const offset = offsets[selected];
61
+ return source.slice(0, offset) + newText + source.slice(offset + oldText.length);
62
+ }
63
+
64
+ function dataShape(value) {
65
+ if (Array.isArray(value)) return { type: "array", items: value.length };
66
+ if (value && typeof value === "object") {
67
+ const fields = Object.keys(value);
68
+ return { type: "object", fieldCount: fields.length, fields };
69
+ }
70
+ return { type: value === null ? "null" : typeof value };
71
+ }
72
+
73
+ function selectedValue(value, maximumCharacters = 2_000) {
74
+ if (typeof value !== "string") return { value, valueIncluded: true };
75
+ const characters = [...value];
76
+ if (characters.length <= maximumCharacters) return { value, valueIncluded: true, characters: characters.length };
77
+ return {
78
+ preview: `${characters.slice(0, maximumCharacters).join("")}…`,
79
+ valueIncluded: false,
80
+ truncated: true,
81
+ characters: characters.length
82
+ };
83
+ }
84
+
85
+ function createAgentService(options = {}) {
86
+ const platform = String(options.platform || process.platform);
87
+ const auth = options.auth || createAuthManager(config, { ...(options.authOptions || {}), platform, persistent: true });
88
+ const gatewayFactory = options.gatewayFactory || (client => createSupabaseGateway(client, { bucketName: config.assetBucket }));
89
+ const indexFactory = options.indexFactory || (indexOptions => require("./local-index.cjs").createLocalIndex(indexOptions));
90
+ const cacheDir = resolveAgentPaths({
91
+ cacheDir: options.cacheDir,
92
+ platform,
93
+ environment: options.environment,
94
+ homeDirectory: options.homeDirectory
95
+ }).cacheDir;
96
+ const now = options.now || (() => new Date());
97
+ const retentionMs = Math.max(86_400_000, Number(options.retentionMs || 7 * 86_400_000));
98
+ let localIndex = null;
99
+ let indexActorId = "";
100
+ let realtimeClient = null;
101
+ let realtimeChannel = null;
102
+ let realtimeState = "disabled";
103
+ let realtimeSubscribedOnce = false;
104
+ let invalidatedAt = null;
105
+ let invalidationReason = null;
106
+ let invalidationGeneration = 0;
107
+ let syncPromise = null;
108
+ let lastSyncError = null;
109
+
110
+ function isoNow() {
111
+ const value = now();
112
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
113
+ }
114
+
115
+ async function ensureIndex(user) {
116
+ if (localIndex && indexActorId === String(user.id)) return localIndex;
117
+ if (localIndex) await localIndex.close();
118
+ const paths = resolveAgentPaths({ cacheDir, userId: user.id, platform });
119
+ ensurePrivateDirectory(paths.userCacheDir, options.fs, { platform });
120
+ let opened = await indexFactory({ cacheDir: paths.userCacheDir, filePath: paths.indexPath });
121
+ const openedStatus = await opened.status({ maxAgeMs: retentionMs, now: isoNow() });
122
+ if (openedStatus?.ready && Number(openedStatus.freshness?.ageMs) > retentionMs) {
123
+ await opened.purge();
124
+ opened = await indexFactory({ cacheDir: paths.userCacheDir, filePath: paths.indexPath });
125
+ }
126
+ localIndex = opened;
127
+ indexActorId = String(user.id);
128
+ return localIndex;
129
+ }
130
+
131
+ function invalidate(reason) {
132
+ invalidationGeneration += 1;
133
+ invalidatedAt = isoNow();
134
+ invalidationReason = String(reason || "change-hint");
135
+ }
136
+
137
+ function startRealtime(client) {
138
+ if (options.realtime === false || realtimeChannel || typeof client?.channel !== "function") return;
139
+ realtimeClient = client;
140
+ realtimeState = "connecting";
141
+ let channel = client.channel("ssobig-writer-agent-index-invalidation");
142
+ for (const table of ["somi_projects", "somi_project_component_sets", "somi_project_component_instances"]) {
143
+ channel = channel.on("postgres_changes", { event: "*", schema: "public", table }, () => invalidate(`realtime:${table}`));
144
+ }
145
+ realtimeChannel = channel.subscribe(status => {
146
+ realtimeState = String(status || "unknown").toLowerCase();
147
+ if (status === "SUBSCRIBED") {
148
+ if (realtimeSubscribedOnce) invalidate("realtime-reconnect");
149
+ realtimeSubscribedOnce = true;
150
+ }
151
+ });
152
+ }
153
+
154
+ async function performSync(context, params = {}, startedGeneration = invalidationGeneration) {
155
+ const gateway = gatewayFactory(context.client);
156
+ startRealtime(context.client);
157
+ let corpus;
158
+ for (let attempt = 0; attempt < 2; attempt += 1) {
159
+ try { corpus = await gateway.loadCorpus("active"); break; }
160
+ catch (error) {
161
+ if (error.code !== "E_CORPUS_CHANGED" || attempt === 1) throw error;
162
+ }
163
+ }
164
+ const built = buildValidatedSnapshot(corpus, context.user, { now });
165
+ const index = await ensureIndex(context.user);
166
+ const indexStatus = await index.replaceSnapshot(built.snapshot);
167
+ if (invalidationGeneration === startedGeneration) {
168
+ invalidatedAt = null;
169
+ invalidationReason = null;
170
+ }
171
+ lastSyncError = null;
172
+ return {
173
+ authoritativeSource: "supabase",
174
+ cacheAuthoritative: false,
175
+ reason: String(params.reason || "manual"),
176
+ syncedAt: built.snapshot.syncedAt,
177
+ revisionVectorHash: built.snapshot.revisionVectorHash,
178
+ indexedVersions: built.snapshot.versions.length,
179
+ rejected: built.rejected,
180
+ index: indexStatus
181
+ };
182
+ }
183
+
184
+ async function sync(params = {}) {
185
+ if (syncPromise) return syncPromise;
186
+ const startedGeneration = invalidationGeneration;
187
+ syncPromise = auth.withSession(context => performSync(context, params, startedGeneration))
188
+ .catch(error => {
189
+ lastSyncError = normalizeError(error);
190
+ throw error;
191
+ })
192
+ .finally(() => { syncPromise = null; });
193
+ return syncPromise;
194
+ }
195
+
196
+ async function withLocalIndex(callback) {
197
+ return auth.withSession(async context => {
198
+ startRealtime(context.client);
199
+ return callback(await ensureIndex(context.user), context);
200
+ });
201
+ }
202
+
203
+ async function ensureReadableIndex(params = {}, reason = "read") {
204
+ if (params.fresh === true) {
205
+ await sync({ reason: `${reason}-fresh` });
206
+ return;
207
+ }
208
+ const status = await withLocalIndex(index => index.status());
209
+ if (!status.ready && status.available !== true) {
210
+ await sync({ reason: `${reason}-initial` });
211
+ return;
212
+ }
213
+ if (params.requireFresh === true && (invalidatedAt || status.freshness?.status !== "fresh")) {
214
+ await sync({ reason: `${reason}-fresh` });
215
+ }
216
+ }
217
+
218
+ async function search(params = {}) {
219
+ await ensureReadableIndex(params, "search");
220
+ return withLocalIndex(async index => {
221
+ const result = await index.search(params);
222
+ return {
223
+ ...result,
224
+ invalidatedAt,
225
+ invalidationReason,
226
+ realtimeState,
227
+ cacheAuthoritative: false,
228
+ requiresServerReadBeforeWrite: true
229
+ };
230
+ });
231
+ }
232
+
233
+ async function read(params = {}) {
234
+ if (params.component !== undefined && params.project === undefined) {
235
+ throw cliError("E_USAGE", "component를 읽으려면 project가 필요합니다.");
236
+ }
237
+ if (params.pointer !== undefined && params.component === undefined) {
238
+ throw cliError("E_USAGE", "JSON pointer를 읽으려면 component가 필요합니다.");
239
+ }
240
+ await ensureReadableIndex(params, "read");
241
+ return withLocalIndex(async index => {
242
+ const local = await index.getComponent({
243
+ ...(params.project !== undefined ? { project: params.project } : {}),
244
+ ...(params.version !== undefined ? { version: params.version } : {}),
245
+ ...(params.component !== undefined ? { component: params.component } : {}),
246
+ includeAssets: params.includeAssets === true || params.component !== undefined
247
+ });
248
+ const base = {
249
+ authoritative: false,
250
+ source: local.source,
251
+ requiresServerReadBeforeWrite: true,
252
+ freshness: local.freshness,
253
+ invalidatedAt,
254
+ invalidationReason
255
+ };
256
+ if (params.project === undefined) {
257
+ const grouped = new Map();
258
+ for (const component of local.components) {
259
+ const key = `${component.project.id}:${component.versionNumber}`;
260
+ if (!grouped.has(key)) grouped.set(key, {
261
+ project: component.project,
262
+ versionNumber: component.versionNumber,
263
+ componentCount: 0,
264
+ title: null
265
+ });
266
+ const summary = grouped.get(key);
267
+ summary.componentCount += 1;
268
+ if (component.templateId === "ssobig.basic" && typeof component.data?.title === "string") summary.title = component.data.title;
269
+ }
270
+ return { ...base, projects: [...grouped.values()] };
271
+ }
272
+ if (params.component === undefined) {
273
+ if (!local.components.length) throw cliError("E_PROJECT_NOT_FOUND", "로컬 인덱스에서 작품을 찾을 수 없습니다. 필요하면 fresh read를 실행해 주세요.");
274
+ return {
275
+ ...base,
276
+ project: local.components[0]?.project || { id: null, slug: String(params.project) },
277
+ versionNumber: local.components[0]?.versionNumber || params.version || null,
278
+ components: local.components.map(component => ({
279
+ instanceId: component.instanceId,
280
+ templateId: component.templateId,
281
+ revision: component.revision,
282
+ dataHash: component.dataHash,
283
+ shape: dataShape(component.data)
284
+ }))
285
+ };
286
+ }
287
+ if (!local.components.length) throw cliError("E_COMPONENT_NOT_FOUND", "로컬 인덱스에서 Component를 찾을 수 없습니다. 필요하면 fresh read를 실행해 주세요.");
288
+ if (local.components.length > 1) throw cliError("E_AMBIGUOUS_COMPONENT", "Component selector가 여러 Instance와 일치합니다. instance id를 사용해 주세요.");
289
+ const component = local.components[0];
290
+ const identity = {
291
+ instanceId: component.instanceId,
292
+ templateId: component.templateId,
293
+ revision: component.revision,
294
+ dataHash: component.dataHash
295
+ };
296
+ if (params.pointer !== undefined) {
297
+ const value = pointerValue(component.data, String(params.pointer));
298
+ return { ...base, project: component.project, versionNumber: component.versionNumber, component: identity, pointer: String(params.pointer), value };
299
+ }
300
+ return { ...base, project: component.project, versionNumber: component.versionNumber, component: identity, data: component.data };
301
+ });
302
+ }
303
+
304
+ async function resolveMatch(params, callback) {
305
+ const matchId = String(params?.matchId || "");
306
+ if (!matchId) throw cliError("E_USAGE", "matchId가 필요합니다.");
307
+ return auth.withSession(async context => {
308
+ const index = await ensureIndex(context.user);
309
+ const local = await index.getMatch(matchId);
310
+ const rawMatch = local?.match || local;
311
+ const match = rawMatch ? {
312
+ ...rawMatch,
313
+ projectId: rawMatch.projectId || rawMatch.project?.id,
314
+ projectSlug: rawMatch.projectSlug || rawMatch.project?.slug,
315
+ instanceId: rawMatch.instanceId || rawMatch.component?.instanceId,
316
+ templateId: rawMatch.templateId || rawMatch.component?.templateId,
317
+ instanceRevision: rawMatch.instanceRevision ?? rawMatch.component?.revision,
318
+ dataHash: rawMatch.dataHash || rawMatch.component?.dataHash,
319
+ componentSetRevision: rawMatch.componentSetRevision ?? rawMatch.componentSet?.revision,
320
+ componentChecksum: rawMatch.componentChecksum || rawMatch.componentSet?.checksum
321
+ } : null;
322
+ if (!match) throw cliError("E_MATCH_NOT_FOUND", "로컬 검색 결과를 찾을 수 없습니다. 다시 검색해 주세요.");
323
+ if (!match.projectId || !match.instanceId || typeof match.pointer !== "string") {
324
+ throw cliError("E_INDEX_CORRUPT", "로컬 검색 결과 identity가 완전하지 않습니다. writer_sync를 다시 실행해 주세요.");
325
+ }
326
+ const gateway = gatewayFactory(context.client);
327
+ const validated = validateLoadedVersion(await gateway.loadVersion(match.projectId, Number(match.versionNumber)));
328
+ const instance = findInstance(validated, match.instanceId);
329
+ const value = pointerValue(instance.data, match.pointer);
330
+ const staleMatch = Number(match.instanceRevision) !== Number(instance.revision)
331
+ || String(match.dataHash || "") !== sha256(instance.data)
332
+ || Number(match.componentSetRevision) !== Number(validated.componentSet.revision)
333
+ || String(match.componentChecksum || "") !== String(validated.componentSet.component_checksum || "");
334
+ return callback({ context, gateway, validated, instance, match, value, staleMatch });
335
+ });
336
+ }
337
+
338
+ async function get(params = {}) {
339
+ const maximumCharacters = Number(params.maximumCharacters ?? 2_000);
340
+ if (!Number.isSafeInteger(maximumCharacters) || maximumCharacters < 100 || maximumCharacters > 20_000) {
341
+ throw cliError("E_USAGE", "maximumCharacters는 100~20000 사이의 정수여야 합니다.");
342
+ }
343
+ return resolveMatch(params, async ({ validated, instance, match, value, staleMatch }) => ({
344
+ authoritative: true,
345
+ source: "supabase-authoritative-read",
346
+ project: { id: validated.project.id, slug: validated.project.slug },
347
+ versionNumber: validated.versionNumber,
348
+ component: {
349
+ instanceId: instance.instanceId,
350
+ templateId: instance.templateId,
351
+ revision: instance.revision,
352
+ dataHash: sha256(instance.data)
353
+ },
354
+ selected: {
355
+ matchId: match.matchId || String(params.matchId),
356
+ pointer: match.pointer,
357
+ staleMatch,
358
+ ...selectedValue(value, maximumCharacters)
359
+ }
360
+ }));
361
+ }
362
+
363
+ async function planReplace(params = {}) {
364
+ const outputPath = String(params.out || params.planPath || "");
365
+ if (!outputPath) throw cliError("E_USAGE", "검토할 plan 파일 경로(out)가 필요합니다.");
366
+ return resolveMatch(params, async ({ context, validated, instance, match, value, staleMatch }) => {
367
+ if (staleMatch) throw cliError("E_MATCH_STALE", "검색 결과 이후 Component revision 또는 hash가 변경되었습니다. writer_sync 후 다시 검색해 주세요.");
368
+ let replacement;
369
+ if (Object.hasOwn(params, "oldText") || Object.hasOwn(params, "newText")) {
370
+ replacement = replaceOccurrence(value, params.oldText, params.newText, params.occurrenceIndex);
371
+ } else {
372
+ if (!Object.hasOwn(params, "expectedValue") || !Object.hasOwn(params, "replacementValue")) {
373
+ throw cliError("E_USAGE", "oldText/newText 또는 expectedValue/replacementValue가 필요합니다.");
374
+ }
375
+ if (!equalJson(value, params.expectedValue)) throw cliError("E_CONFLICT", "서버 원본 값이 expectedValue와 다릅니다. 다시 검색해 주세요.");
376
+ replacement = params.replacementValue;
377
+ }
378
+ const patch = [
379
+ { op: "test", path: match.pointer, value },
380
+ { op: "replace", path: match.pointer, value: replacement }
381
+ ];
382
+ const plan = createComponentPlan(validated, instance.instanceId, patch, context.user.email, {
383
+ planId: crypto.randomUUID(),
384
+ createdAt: isoNow(),
385
+ ...(params.checkpointMessage !== undefined ? { checkpointMessage: String(params.checkpointMessage) } : {})
386
+ });
387
+ const planFile = writePrivateJson(outputPath, plan);
388
+ return {
389
+ planFile,
390
+ planId: plan.planId,
391
+ digest: plan.digest,
392
+ digestPurpose: "accidental-corruption-detection-not-approval-signature",
393
+ operation: plan.operation,
394
+ checkpointMessage: plan.checkpointMessage,
395
+ project: plan.project,
396
+ versionNumber: plan.versionNumber,
397
+ target: plan.target,
398
+ pointer: match.pointer,
399
+ beforeHash: plan.beforeHash,
400
+ afterHash: plan.afterHash,
401
+ reviewRequired: true,
402
+ applyRequiresExplicitCall: true
403
+ };
404
+ });
405
+ }
406
+
407
+ async function apply(params = {}) {
408
+ const planPath = String(params.planPath || params.plan || "");
409
+ const receiptPath = String(params.receiptPath || params.receipt || "");
410
+ if (!planPath || !receiptPath) throw cliError("E_USAGE", "planPath와 새 receiptPath가 필요합니다.");
411
+ return auth.withSession(async context => {
412
+ const planRead = readJson(planPath, "plan");
413
+ const plan = verifyPlan(planRead.value);
414
+ const reservation = reservePrivateJson(receiptPath);
415
+ const gateway = gatewayFactory(context.client);
416
+ let receipt;
417
+ try {
418
+ if (plan.operation === "project.create") receipt = await applyProjectCreationPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
419
+ else if (plan.operation === "project.catalog-demo") receipt = await applyCatalogDemoPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
420
+ else if (plan.operation.startsWith("project.")) receipt = await applyProjectStatusPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
421
+ else if (plan.operation === "component.patch") receipt = await applyComponentPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
422
+ else if (plan.operation.startsWith("checkpoint.")) receipt = await applyCheckpointPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
423
+ else receipt = await applyAssetPlan(gateway, plan, context.user.email, { appliedAt: isoNow(), now: Date.now });
424
+ } catch (error) {
425
+ reservation.discard();
426
+ throw error;
427
+ }
428
+ let receiptFile;
429
+ try { receiptFile = reservation.commit(receipt); }
430
+ catch (error) {
431
+ try { reservation.discard(); } catch (discardError) { void discardError; }
432
+ throw cliError("E_AMBIGUOUS_RECEIPT", "서버 쓰기는 완료됐지만 receipt 파일을 확정할 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
433
+ }
434
+ invalidate("apply-readback-complete");
435
+ return { planFile: planRead.path, receiptFile, receipt, indexRefresh: "required" };
436
+ });
437
+ }
438
+
439
+ async function status() {
440
+ return {
441
+ running: true,
442
+ cacheDir,
443
+ cacheAuthoritative: false,
444
+ index: localIndex ? await localIndex.status() : { available: false, reason: "not-opened" },
445
+ invalidatedAt,
446
+ invalidationReason,
447
+ realtimeState,
448
+ syncing: Boolean(syncPromise),
449
+ lastSyncError: lastSyncError ? { code: lastSyncError.code, message: lastSyncError.message } : null
450
+ };
451
+ }
452
+
453
+ async function purge() {
454
+ const index = localIndex;
455
+ localIndex = null;
456
+ indexActorId = "";
457
+ const openedResult = index ? await index.purge() : null;
458
+ const remainingResult = purgeAllAgentIndexes({ cacheDir, platform, fs: options.fs });
459
+ invalidatedAt = null;
460
+ invalidationReason = null;
461
+ return { purged: true, removedIndexFiles: Number(openedResult?.removedFiles || 0) + remainingResult.removedFiles, cacheAuthoritative: false };
462
+ }
463
+
464
+ async function logout() {
465
+ const purgeResult = await purge();
466
+ const result = await auth.logout();
467
+ return { ...result, localIndexPurged: true, removedIndexFiles: purgeResult.removedIndexFiles };
468
+ }
469
+
470
+ async function close() {
471
+ if (realtimeClient && realtimeChannel) {
472
+ try { await realtimeClient.removeChannel(realtimeChannel); } catch (error) { void error; }
473
+ }
474
+ realtimeChannel = null;
475
+ if (localIndex) await localIndex.close();
476
+ localIndex = null;
477
+ await auth.dispose?.();
478
+ }
479
+
480
+ async function handle(method, params = {}) {
481
+ if (method === "status") return status();
482
+ if (method === "sync") return sync(params);
483
+ if (method === "search") return search(params);
484
+ if (method === "read") return read(params);
485
+ if (method === "get") return get(params);
486
+ if (method === "planReplace") return planReplace(params);
487
+ if (method === "apply") return apply(params);
488
+ if (method === "purge") return purge();
489
+ if (method === "logout") return logout();
490
+ throw cliError("E_DAEMON_METHOD", `지원하지 않는 Writer agent method입니다: ${String(method || "")}`);
491
+ }
492
+
493
+ return Object.freeze({ handle, sync, search, read, get, planReplace, apply, status, purge, logout, close });
494
+ }
495
+
496
+ module.exports = Object.freeze({ createAgentService, pointerValue, replaceOccurrence });
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { sha256 } = require("./json.cjs");
6
+ const { cliError } = require("./errors.cjs");
7
+
8
+ const MAX_BYTES = 15 * 1024 * 1024;
9
+ const TYPES_BY_EXTENSION = Object.freeze({
10
+ ".jpg": "image/jpeg",
11
+ ".jpeg": "image/jpeg",
12
+ ".png": "image/png",
13
+ ".webp": "image/webp",
14
+ ".gif": "image/gif",
15
+ ".json": "application/json"
16
+ });
17
+ const EXTENSION_BY_TYPE = Object.freeze({
18
+ "image/jpeg": "jpg",
19
+ "image/png": "png",
20
+ "image/webp": "webp",
21
+ "image/gif": "gif",
22
+ "application/json": "json"
23
+ });
24
+
25
+ function isImageAssetId(assetId) {
26
+ return ["poster", "logo", "workspace-background"].includes(assetId)
27
+ || assetId.startsWith("character-")
28
+ || assetId.startsWith("clue-image-");
29
+ }
30
+
31
+ function assertAssetId(assetId) {
32
+ const value = String(assetId || "");
33
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value)) throw cliError("E_VALIDATION", `에셋 ID 형식이 올바르지 않습니다: ${value || "(비어 있음)"}`);
34
+ return value;
35
+ }
36
+
37
+ function hasMagic(bytes, expected) {
38
+ return expected.every((value, index) => bytes[index] === value);
39
+ }
40
+
41
+ function assertFileContent(buffer, type) {
42
+ if (type === "image/jpeg" && !hasMagic(buffer, [0xff, 0xd8, 0xff])) throw cliError("E_VALIDATION", "JPEG 파일 signature가 올바르지 않습니다.");
43
+ if (type === "image/png" && !hasMagic(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) throw cliError("E_VALIDATION", "PNG 파일 signature가 올바르지 않습니다.");
44
+ if (type === "image/gif" && !["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii"))) throw cliError("E_VALIDATION", "GIF 파일 signature가 올바르지 않습니다.");
45
+ if (type === "image/webp" && !(buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP")) throw cliError("E_VALIDATION", "WebP 파일 signature가 올바르지 않습니다.");
46
+ if (type === "application/json") {
47
+ try { JSON.parse(buffer.toString("utf8")); }
48
+ catch (error) { throw cliError("E_VALIDATION", "JSON 파일 내용을 파싱할 수 없습니다.", null, error); }
49
+ }
50
+ }
51
+
52
+ function readAssetFile(filePath, assetId, options = {}) {
53
+ const fsApi = options.fs || fs;
54
+ const absolutePath = path.resolve(String(filePath || ""));
55
+ const type = TYPES_BY_EXTENSION[path.extname(absolutePath).toLowerCase()];
56
+ if (!type) throw cliError("E_VALIDATION", "JPEG, PNG, WebP, GIF 이미지와 JSON 파일만 업로드할 수 있습니다.");
57
+ const normalizedAssetId = assertAssetId(assetId);
58
+ if (isImageAssetId(normalizedAssetId) && type === "application/json") throw cliError("E_VALIDATION", "포스터, 로고, 배경, 캐릭터와 단서 에셋에는 이미지만 연결할 수 있습니다.");
59
+ let handle = null;
60
+ let stat;
61
+ try {
62
+ const pathStat = fsApi.lstatSync(absolutePath);
63
+ if (!pathStat.isFile()) throw cliError("E_VALIDATION", `업로드 대상은 symlink가 아닌 일반 파일이어야 합니다: ${absolutePath}`);
64
+ const noFollow = Number(fsApi.constants?.O_NOFOLLOW ?? fs.constants.O_NOFOLLOW ?? 0);
65
+ handle = fsApi.openSync(absolutePath, fs.constants.O_RDONLY | noFollow);
66
+ stat = fsApi.fstatSync(handle);
67
+ }
68
+ catch (error) {
69
+ if (error.code?.startsWith?.("E_")) throw error;
70
+ throw cliError("E_FILE_NOT_FOUND", `업로드 파일을 읽을 수 없습니다: ${absolutePath}`, null, error);
71
+ }
72
+ if (!stat.isFile()) {
73
+ try { fsApi.closeSync(handle); } catch (error) { void error; }
74
+ throw cliError("E_VALIDATION", `업로드 대상이 일반 파일이 아닙니다: ${absolutePath}`);
75
+ }
76
+ if (stat.size > MAX_BYTES) {
77
+ try { fsApi.closeSync(handle); } catch (error) { void error; }
78
+ throw cliError("E_VALIDATION", "파일 크기는 15MB 이하여야 합니다.");
79
+ }
80
+ const buffer = Buffer.allocUnsafe(stat.size);
81
+ let offset = 0;
82
+ try {
83
+ while (offset < stat.size) {
84
+ const count = fsApi.readSync(handle, buffer, offset, stat.size - offset, offset);
85
+ if (count === 0) break;
86
+ offset += count;
87
+ }
88
+ const afterStat = fsApi.fstatSync(handle);
89
+ if (offset !== stat.size || afterStat.size !== stat.size) throw cliError("E_CONFLICT", "파일을 읽는 동안 크기가 변경되었습니다. 새 plan을 만들어 주세요.");
90
+ } catch (error) {
91
+ if (error.code?.startsWith?.("E_")) throw error;
92
+ throw cliError("E_FILE_NOT_FOUND", `업로드 파일을 읽을 수 없습니다: ${absolutePath}`, null, error);
93
+ } finally {
94
+ try { fsApi.closeSync(handle); } catch (error) { void error; }
95
+ }
96
+ assertFileContent(buffer, type);
97
+ const metadata = Object.freeze({
98
+ path: absolutePath,
99
+ name: path.basename(absolutePath),
100
+ type,
101
+ extension: EXTENSION_BY_TYPE[type],
102
+ size: stat.size,
103
+ mtimeMs: Math.trunc(stat.mtimeMs),
104
+ contentHash: sha256(buffer)
105
+ });
106
+ return Object.freeze({ metadata, buffer });
107
+ }
108
+
109
+ function inspectAssetFile(filePath, assetId, options = {}) {
110
+ return readAssetFile(filePath, assetId, options).metadata;
111
+ }
112
+
113
+ module.exports = Object.freeze({ MAX_BYTES, TYPES_BY_EXTENSION, EXTENSION_BY_TYPE, isImageAssetId, assertAssetId, readAssetFile, inspectAssetFile });