@ssobig/writer-cli 0.2.2 → 0.3.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 (28) hide show
  1. package/README.md +12 -0
  2. package/config.js +2 -1
  3. package/package.json +1 -1
  4. package/templates/mystery-v1/authoring-view-preference.js +22 -5
  5. package/templates/mystery-v1/character-perspective-preview.js +3 -3
  6. package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
  7. package/templates/mystery-v1/component-asset-operations.js +5 -4
  8. package/templates/mystery-v1/component-catalog-contract.js +26 -35
  9. package/templates/mystery-v1/component-draft-operations.js +19 -8
  10. package/templates/mystery-v1/component-field-contracts.js +29 -49
  11. package/templates/mystery-v1/component-id-policy.js +1 -1
  12. package/templates/mystery-v1/component-manager.js +21 -11
  13. package/templates/mystery-v1/component-navigation-counts.js +3 -8
  14. package/templates/mystery-v1/component-registry.js +11 -5
  15. package/templates/mystery-v1/component-renderers.js +12 -8
  16. package/templates/mystery-v1/markdown-live-editor.js +350 -0
  17. package/templates/mystery-v1/page-header.js +2 -1
  18. package/tools/writer-cli/package-lock.json +2 -2
  19. package/tools/writer-cli/package.json +1 -1
  20. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +1 -1
  21. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +1 -1
  22. package/tools/writer-cli/src/agent-service.cjs +3 -1
  23. package/tools/writer-cli/src/command-registry.cjs +3 -0
  24. package/tools/writer-cli/src/commands.cjs +85 -3
  25. package/tools/writer-cli/src/domain.cjs +250 -1
  26. package/tools/writer-cli/src/gateway.cjs +14 -0
  27. package/tools/writer-cli/src/mutations.cjs +167 -1
  28. package/tools/writer-cli/src/project-import.cjs +12 -21
@@ -2,6 +2,8 @@
2
2
 
3
3
  const crypto = require("node:crypto");
4
4
  const componentContract = require("../../../templates/mystery-v1/component-storage-contract.js");
5
+ const componentCatalog = require("../../../templates/mystery-v1/component-catalog-contract.js");
6
+ const componentRegistry = require("../../../templates/mystery-v1/component-registry.js").createDefaultRegistry();
5
7
  const viewContract = require("../../../templates/mystery-v1/view-component-contract.js");
6
8
  const projectRuntime = require("../../../project-runtime.js");
7
9
  const { applyPatch } = require("./json-patch.cjs");
@@ -27,6 +29,7 @@ const BINDING_COLUMNS = "view_instance_id,binding_key,data_instance_id,access_mo
27
29
  const CHECKPOINT_COLUMNS = "checkpoint_id,project_id,version_number,checkpoint_number,parent_checkpoint_id,restored_from_checkpoint_id,operation_id,message,source,component_set_revision,component_checksum,manifest_hash,changed_instance_ids,created_by_email,created_at";
28
30
  const CHECKPOINT_ENTRY_COLUMNS = "checkpoint_id,project_id,version_number,instance_id,template_id,tab_label,sort_order,is_enabled,is_archived,is_required,is_removable,editor_view_id,preview_view_id,capabilities,config,source_revision,data_hash,changed_from_parent";
29
31
  const CHECKPOINT_MESSAGE_MAX_LENGTH = 500;
32
+ const INSTANCE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
30
33
 
31
34
  function isObject(value) {
32
35
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -77,6 +80,17 @@ function findInstance(validated, selector) {
77
80
  throw cliError("E_COMPONENT_NOT_FOUND", `활성 Component를 찾을 수 없습니다: ${value || "(비어 있음)"}`);
78
81
  }
79
82
 
83
+ function findStoredInstance(validated, selector) {
84
+ const value = String(selector || "").trim();
85
+ const stored = validated.componentRows.map(componentContract.normalizeInstance).filter(Boolean);
86
+ const byId = stored.find(instance => instance.instanceId === value);
87
+ if (byId) return byId;
88
+ const byTemplate = stored.filter(instance => instance.templateId === value);
89
+ if (byTemplate.length === 1) return byTemplate[0];
90
+ if (byTemplate.length > 1) throw cliError("E_AMBIGUOUS_INSTANCE", `${value} Template의 Instance가 여러 개입니다. instance ID를 지정해 주세요.`);
91
+ throw cliError("E_COMPONENT_NOT_FOUND", `저장된 Component를 찾을 수 없습니다: ${value || "(비어 있음)"}`);
92
+ }
93
+
80
94
  function assertGenericPatchAllowed(instance) {
81
95
  if (instance.templateId === "ssobig.assets") {
82
96
  throw cliError("E_ASSET_COMMAND_REQUIRED", "ssobig.assets Component는 generic patch로 수정할 수 없습니다. asset 명령을 사용해 주세요.");
@@ -140,6 +154,166 @@ function checkpointMessage(value, fallback) {
140
154
  return message;
141
155
  }
142
156
 
157
+ function componentViewReview(validated, descriptor, instanceId, tabLabel) {
158
+ const activeByTemplate = new Map();
159
+ for (const instance of validated.instances) {
160
+ if (!activeByTemplate.has(instance.templateId)) activeByTemplate.set(instance.templateId, instance);
161
+ }
162
+ activeByTemplate.set(descriptor.templateId, { instanceId, templateId: descriptor.templateId });
163
+ const bindings = [];
164
+ for (const binding of descriptor.view.bindings) {
165
+ const target = activeByTemplate.get(binding.dataTemplateId);
166
+ if (!target) {
167
+ if (binding.required) throw cliError("E_VALIDATION", `${descriptor.templateId} 사용에는 활성 ${binding.dataTemplateId} Component가 필요합니다.`);
168
+ continue;
169
+ }
170
+ bindings.push({ key: binding.key, dataInstanceId: target.instanceId, accessMode: binding.accessMode, required: binding.required });
171
+ }
172
+ const viewInstanceId = descriptor.allowMultiple ? `${instanceId}-workbench` : descriptor.view.instanceId;
173
+ if (!INSTANCE_ID_PATTERN.test(viewInstanceId)) throw cliError("E_VALIDATION", `생성될 View instance ID가 올바르지 않습니다: ${viewInstanceId}`);
174
+ return {
175
+ viewInstanceId,
176
+ viewTemplateId: descriptor.view.templateId,
177
+ label: `${tabLabel} 작업 화면`,
178
+ rendererId: descriptor.view.rendererId,
179
+ modes: clone(descriptor.view.supportedModes),
180
+ bindings
181
+ };
182
+ }
183
+
184
+ function componentAddReview(validated, input = {}) {
185
+ const templateId = String(input.templateId || "").trim();
186
+ const descriptor = componentCatalog.get(templateId);
187
+ if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view) {
188
+ throw cliError("E_CODE_CHANGE_REQUIRED", `CLI에서 추가할 수 없는 Component Template입니다: ${templateId || "(비어 있음)"}`);
189
+ }
190
+ const requestedInstanceId = String(input.instanceId || "").trim();
191
+ if (descriptor.allowMultiple && !requestedInstanceId) {
192
+ throw cliError("E_VALIDATION", `${templateId}에는 고유한 instance ID가 필요합니다.`);
193
+ }
194
+ const instanceId = requestedInstanceId || descriptor.defaultInstanceId;
195
+ if (!INSTANCE_ID_PATTERN.test(instanceId)) throw cliError("E_VALIDATION", `Component instance ID가 올바르지 않습니다: ${instanceId || "(비어 있음)"}`);
196
+ const tabLabel = String(input.tabLabel || descriptor.tabLabel || "").trim();
197
+ if (!tabLabel || tabLabel.length > 120) throw cliError("E_VALIDATION", "Component label은 비어 있지 않은 120자 이하 문자열이어야 합니다.");
198
+
199
+ const conflictingRow = validated.componentRows.find(row => String(row.instance_id) === instanceId
200
+ || (!descriptor.allowMultiple && String(row.template_id) === templateId));
201
+ if (conflictingRow) {
202
+ const state = conflictingRow.is_archived || !conflictingRow.is_enabled ? "보관된" : "활성";
203
+ throw cliError("E_CONFLICT", `${state} Component가 이미 존재합니다: ${conflictingRow.instance_id}`);
204
+ }
205
+
206
+ const initialData = clone(descriptor.defaultData);
207
+ const componentSpec = componentContract.componentSpec(templateId);
208
+ if (!componentSpec) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 ${templateId}의 Data schema가 없습니다.`);
209
+ const dataError = componentSpec.validate(initialData);
210
+ if (dataError) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 ${templateId} 기본 data가 현재 schema와 일치하지 않습니다: ${dataError}`);
211
+ return Object.freeze({
212
+ target: {
213
+ templateId,
214
+ instanceId,
215
+ tabLabel,
216
+ expectedState: "missing",
217
+ allowMultiple: descriptor.allowMultiple
218
+ },
219
+ initialData,
220
+ composition: componentViewReview(validated, descriptor, instanceId, tabLabel)
221
+ });
222
+ }
223
+
224
+ function createComponentAddPlan(validated, input, actorEmail, options = {}) {
225
+ const review = componentAddReview(validated, input);
226
+ return finalizePlan({
227
+ ...basePlan(validated, actorEmail, "component.add", options),
228
+ target: review.target,
229
+ initialData: review.initialData,
230
+ initialDataHash: sha256(review.initialData),
231
+ composition: review.composition
232
+ });
233
+ }
234
+
235
+ function componentActiveReview(validated, input = {}) {
236
+ if (typeof input.active !== "boolean") throw cliError("E_VALIDATION", "Component 사용 여부는 true 또는 false여야 합니다.");
237
+ const instance = findStoredInstance(validated, input.instanceId);
238
+ const descriptor = componentCatalog.get(instance.templateId);
239
+ if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view || instance.required || !instance.removable) {
240
+ throw cliError("E_CODE_CHANGE_REQUIRED", `사용 여부를 변경할 수 없는 Component입니다: ${instance.instanceId}`);
241
+ }
242
+ const targetActive = input.active === true;
243
+ const currentActive = instance.enabled === true && instance.archived === false;
244
+ if (currentActive === targetActive) throw cliError("E_NO_CHANGES", `Component가 이미 ${targetActive ? "사용 중" : "사용 안 함"} 상태입니다: ${instance.instanceId}`);
245
+ if (!targetActive) {
246
+ const requiredDependents = validated.views.filter(view => view.bindings.some(binding => binding.dataInstanceId === instance.instanceId
247
+ && binding.required && !(binding.key === "primary" && view.rendererId === instance.templateId)));
248
+ if (requiredDependents.length) {
249
+ throw cliError("E_VALIDATION", `${instance.tab}을 사용 안 함으로 전환하기 전에 필수로 의존하는 Component를 먼저 보관해야 합니다.`, {
250
+ dependentViews: requiredDependents.map(view => view.viewInstanceId)
251
+ });
252
+ }
253
+ }
254
+ return Object.freeze({
255
+ target: {
256
+ instanceId: instance.instanceId,
257
+ templateId: instance.templateId,
258
+ tabLabel: instance.tab,
259
+ expectedActive: currentActive,
260
+ active: targetActive,
261
+ expectedRevision: instance.revision,
262
+ expectedDataHash: sha256(instance.data)
263
+ },
264
+ composition: targetActive
265
+ ? componentViewReview(validated, descriptor, instance.instanceId, instance.tab)
266
+ : { viewInstanceId: descriptor.allowMultiple ? `${instance.instanceId}-workbench` : descriptor.view.instanceId, expectedState: "absent" }
267
+ });
268
+ }
269
+
270
+ function createComponentActivePlan(validated, input, actorEmail, options = {}) {
271
+ const review = componentActiveReview(validated, input);
272
+ return finalizePlan({
273
+ ...basePlan(validated, actorEmail, "component.set-active", options),
274
+ target: review.target,
275
+ composition: review.composition
276
+ });
277
+ }
278
+
279
+ function componentFieldReview(validated, selector, fieldKey, enabled) {
280
+ const instance = findInstance(validated, selector);
281
+ const definition = componentRegistry.get(instance.templateId);
282
+ const field = definition?.fieldConfiguration?.find(item => item.key === String(fieldKey || ""));
283
+ if (!field) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 속성 구성 항목이 아닙니다: ${fieldKey || "(비어 있음)"}`);
284
+ if (field.required) throw cliError("E_VALIDATION", `필수 속성은 사용 안 함으로 전환할 수 없습니다: ${field.label}`);
285
+ const optionalFields = definition.fieldConfiguration.filter(item => !item.required);
286
+ const hasSelection = Array.isArray(instance.data?.enabledOptionalFields);
287
+ const selected = new Set(hasSelection ? instance.data.enabledOptionalFields : optionalFields.map(item => item.key));
288
+ const beforeEnabled = selected.has(field.key);
289
+ if (beforeEnabled === enabled) throw cliError("E_NO_CHANGES", `${field.label} 속성이 이미 ${enabled ? "사용" : "사용 안 함"} 상태입니다.`);
290
+ if (enabled) selected.add(field.key);
291
+ else selected.delete(field.key);
292
+ const afterFields = optionalFields.filter(item => selected.has(item.key)).map(item => item.key);
293
+ const patch = hasSelection
294
+ ? [{ op: "test", path: "/enabledOptionalFields", value: clone(instance.data.enabledOptionalFields) }, { op: "replace", path: "/enabledOptionalFields", value: afterFields }]
295
+ : [{ op: "add", path: "/enabledOptionalFields", value: afterFields }];
296
+ return Object.freeze({ instance, field, beforeEnabled, enabled, afterFields, patch });
297
+ }
298
+
299
+ function createComponentFieldPlan(validated, selector, fieldKey, enabled, actorEmail, options = {}) {
300
+ const review = componentFieldReview(validated, selector, fieldKey, enabled);
301
+ const base = createComponentPlan(validated, review.instance.instanceId, review.patch, actorEmail, {
302
+ ...options,
303
+ checkpointMessage: options.checkpointMessage || `CLI: ${review.instance.tab} 선택 속성 ${review.field.label} ${enabled ? "사용" : "사용 안 함"}`
304
+ });
305
+ const enriched = clone(base);
306
+ delete enriched.digest;
307
+ enriched.fieldSelection = {
308
+ key: review.field.key,
309
+ label: review.field.label,
310
+ beforeEnabled: review.beforeEnabled,
311
+ enabled: review.enabled,
312
+ enabledOptionalFields: review.afterFields
313
+ };
314
+ return finalizePlan(enriched);
315
+ }
316
+
143
317
  function createComponentPlan(validated, selector, patch, actorEmail, options = {}) {
144
318
  const instance = findInstance(validated, selector);
145
319
  assertGenericPatchAllowed(instance);
@@ -461,7 +635,7 @@ function verifyPlan(plan) {
461
635
  if (!UUID_PATTERN.test(String(plan.planId || "")) || typeof plan.digest !== "string" || plan.digest !== planDigest(plan)) {
462
636
  throw cliError("E_INVALID_PLAN", "plan 식별자 또는 digest가 올바르지 않습니다.");
463
637
  }
464
- if (!["component.patch", "asset.upload", "asset.delete", "project.create", "project.import", "project.archive", "project.restore", "project.catalog-demo", "checkpoint.create", "checkpoint.restore"].includes(plan.operation)) throw cliError("E_INVALID_PLAN", "지원하지 않는 plan operation입니다.");
638
+ if (!["component.add", "component.set-active", "component.patch", "asset.upload", "asset.delete", "project.create", "project.import", "project.archive", "project.restore", "project.catalog-demo", "checkpoint.create", "checkpoint.restore"].includes(plan.operation)) throw cliError("E_INVALID_PLAN", "지원하지 않는 plan operation입니다.");
465
639
  if (["component.patch", "asset.upload", "asset.delete", "checkpoint.create", "checkpoint.restore"].includes(plan.operation)) {
466
640
  let normalizedCheckpointMessage = "";
467
641
  try { normalizedCheckpointMessage = checkpointMessage(plan.checkpointMessage, ""); }
@@ -502,6 +676,36 @@ function verifyPlan(plan) {
502
676
  || String(plan.target?.scaffold || "") !== target.scaffold) {
503
677
  throw cliError("E_INVALID_PLAN", "새 작품 plan의 입력값이 올바르지 않습니다.");
504
678
  }
679
+ } else if (plan.operation === "component.add") {
680
+ const descriptor = componentCatalog.get(String(plan.target?.templateId || ""));
681
+ if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view
682
+ || !INSTANCE_ID_PATTERN.test(String(plan.target?.instanceId || ""))
683
+ || String(plan.target?.expectedState || "") !== "missing"
684
+ || Boolean(plan.target?.allowMultiple) !== descriptor.allowMultiple
685
+ || !String(plan.target?.tabLabel || "").trim()
686
+ || String(plan.target.tabLabel).length > 120
687
+ || !equalJson(plan.initialData, descriptor.defaultData)
688
+ || String(plan.initialDataHash || "") !== sha256(descriptor.defaultData)
689
+ || !isObject(plan.composition)
690
+ || !INSTANCE_ID_PATTERN.test(String(plan.composition.viewInstanceId || ""))
691
+ || String(plan.composition.viewTemplateId || "") !== descriptor.view.templateId
692
+ || String(plan.composition.rendererId || "") !== descriptor.view.rendererId
693
+ || !Array.isArray(plan.composition.bindings)) {
694
+ throw cliError("E_INVALID_PLAN", "Component 추가 plan의 대상 또는 구성 review가 올바르지 않습니다.");
695
+ }
696
+ } else if (plan.operation === "component.set-active") {
697
+ const descriptor = componentCatalog.get(String(plan.target?.templateId || ""));
698
+ if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view
699
+ || !INSTANCE_ID_PATTERN.test(String(plan.target?.instanceId || ""))
700
+ || !String(plan.target?.tabLabel || "").trim()
701
+ || typeof plan.target?.expectedActive !== "boolean"
702
+ || typeof plan.target?.active !== "boolean"
703
+ || plan.target.expectedActive === plan.target.active
704
+ || !Number.isSafeInteger(Number(plan.target?.expectedRevision))
705
+ || !/^[0-9a-f]{64}$/.test(String(plan.target?.expectedDataHash || ""))
706
+ || !isObject(plan.composition)) {
707
+ throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan의 대상 또는 구성 review가 올바르지 않습니다.");
708
+ }
505
709
  } else if (plan.operation === "project.import") {
506
710
  const target = projectCreationTarget(plan.target);
507
711
  const components = normalizeImportComponents(plan.components);
@@ -655,10 +859,50 @@ function validateComponentPlanForApply(plan, validated, actorEmail) {
655
859
  throw cliError("E_INVALID_PLAN", "추리 보드 layout plan의 spec, review 또는 생성 결과가 일치하지 않습니다.");
656
860
  }
657
861
  }
862
+ if (Object.hasOwn(plan, "fieldSelection")) {
863
+ const review = componentFieldReview(validated, instance.instanceId, plan.fieldSelection?.key, plan.fieldSelection?.enabled === true);
864
+ const expected = {
865
+ key: review.field.key,
866
+ label: review.field.label,
867
+ beforeEnabled: review.beforeEnabled,
868
+ enabled: review.enabled,
869
+ enabledOptionalFields: review.afterFields
870
+ };
871
+ if (!equalJson(plan.fieldSelection, expected) || !equalJson(plan.patch, review.patch)) {
872
+ throw cliError("E_INVALID_PLAN", "선택 속성 plan의 review와 patch가 현재 등록 계약과 일치하지 않습니다.");
873
+ }
874
+ }
658
875
  const nextInstance = validateHypotheticalData(validated, instance, recomputed);
659
876
  return { instance, nextInstance, payload: componentContract.instancePayload(nextInstance) };
660
877
  }
661
878
 
879
+ function validateComponentAddPlanForApply(plan, validated, actorEmail) {
880
+ assertPlanContext(plan, validated, actorEmail);
881
+ if (plan.operation !== "component.add") throw cliError("E_INVALID_PLAN", "Component 추가 plan이 아닙니다.");
882
+ const current = componentAddReview(validated, {
883
+ templateId: plan.target?.templateId,
884
+ instanceId: plan.target?.instanceId,
885
+ tabLabel: plan.target?.tabLabel
886
+ });
887
+ if (!equalJson(plan.target, current.target)
888
+ || !equalJson(plan.initialData, current.initialData)
889
+ || plan.initialDataHash !== sha256(current.initialData)
890
+ || !equalJson(plan.composition, current.composition)) {
891
+ throw cliError("E_INVALID_PLAN", "Component 추가 plan의 review가 현재 등록 계약과 일치하지 않습니다.");
892
+ }
893
+ return current;
894
+ }
895
+
896
+ function validateComponentActivePlanForApply(plan, validated, actorEmail) {
897
+ assertPlanContext(plan, validated, actorEmail);
898
+ if (plan.operation !== "component.set-active") throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan이 아닙니다.");
899
+ const current = componentActiveReview(validated, { instanceId: plan.target?.instanceId, active: plan.target?.active });
900
+ if (!equalJson(plan.target, current.target) || !equalJson(plan.composition, current.composition)) {
901
+ throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan의 review가 현재 등록 계약과 일치하지 않습니다.");
902
+ }
903
+ return current;
904
+ }
905
+
662
906
  function validateAssetPlanForApply(plan, validated, actorEmail) {
663
907
  assertPlanContext(plan, validated, actorEmail);
664
908
  if (!["asset.upload", "asset.delete"].includes(plan.operation)) throw cliError("E_INVALID_PLAN", "에셋 plan이 아닙니다.");
@@ -709,6 +953,9 @@ module.exports = Object.freeze({
709
953
  componentContract,
710
954
  validateLoadedVersion,
711
955
  findInstance,
956
+ createComponentActivePlan,
957
+ createComponentAddPlan,
958
+ createComponentFieldPlan,
712
959
  createComponentPlan,
713
960
  investigationBoardReferences,
714
961
  createInvestigationBoardLayoutPlan,
@@ -724,6 +971,8 @@ module.exports = Object.freeze({
724
971
  validateProjectImportPlanForApply,
725
972
  validateProjectStatusPlanForApply,
726
973
  validateCatalogDemoPlanForApply,
974
+ validateComponentActivePlanForApply,
975
+ validateComponentAddPlanForApply,
727
976
  validateComponentPlanForApply,
728
977
  validateAssetPlanForApply,
729
978
  validateCheckpointPlanForApply,
@@ -278,6 +278,19 @@ function createSupabaseGateway(client, options = {}) {
278
278
  return dataOrThrow(result);
279
279
  }
280
280
 
281
+ async function setComponentActive(input) {
282
+ return dataOrThrow(await client.rpc("set_somi_project_component_active", {
283
+ p_project_id: String(input.projectId),
284
+ p_version_number: Number(input.versionNumber),
285
+ p_expected_set_revision: Number(input.expectedSetRevision),
286
+ p_template_id: String(input.templateId),
287
+ p_instance_id: String(input.instanceId),
288
+ p_tab_label: String(input.tabLabel),
289
+ p_active: input.active === true,
290
+ p_updated_by_email: String(input.actorEmail || "")
291
+ }));
292
+ }
293
+
281
294
  async function setProjectArchived(input) {
282
295
  return dataOrThrow(await client.rpc("set_somi_project_archived", {
283
296
  p_project_id: String(input.projectId || ""),
@@ -345,6 +358,7 @@ function createSupabaseGateway(client, options = {}) {
345
358
  createCheckpoint,
346
359
  restoreCheckpoint,
347
360
  saveInstance,
361
+ setComponentActive,
348
362
  setProjectArchived,
349
363
  setCatalogDemoProject,
350
364
  createScaffoldProject,
@@ -14,6 +14,8 @@ const {
14
14
  validateProjectImportPlanForApply,
15
15
  validateProjectStatusPlanForApply,
16
16
  validateCatalogDemoPlanForApply,
17
+ validateComponentActivePlanForApply,
18
+ validateComponentAddPlanForApply,
17
19
  validateComponentPlanForApply,
18
20
  validateAssetPlanForApply,
19
21
  validateCheckpointPlanForApply,
@@ -346,6 +348,170 @@ async function verifyAutomaticCheckpoint(gateway, plan, rpcResponse, validated,
346
348
  return checkpoint;
347
349
  }
348
350
 
351
+ async function applyComponentAddPlan(gateway, plan, actorEmail, options = {}) {
352
+ const before = await loadValidated(gateway, plan.project.id, plan.versionNumber);
353
+ validateComponentAddPlanForApply(plan, before, actorEmail);
354
+ let response;
355
+ try {
356
+ response = await gateway.setComponentActive({
357
+ projectId: before.project.id,
358
+ versionNumber: before.versionNumber,
359
+ expectedSetRevision: before.componentSet.revision,
360
+ templateId: plan.target.templateId,
361
+ instanceId: plan.target.instanceId,
362
+ tabLabel: plan.target.tabLabel,
363
+ active: true,
364
+ actorEmail
365
+ });
366
+ } catch (error) {
367
+ const normalized = normalizeError(error);
368
+ if (["E_CONFLICT", "E_AUTHORIZATION", "E_VALIDATION", "E_CODE_CHANGE_REQUIRED"].includes(normalized.code)) throw normalized;
369
+ let observed = null;
370
+ try {
371
+ const current = await gateway.loadVersion(plan.project.id, plan.versionNumber);
372
+ const row = current.componentRows.find(item => String(item.instance_id) === String(plan.target.instanceId));
373
+ if (row) observed = {
374
+ instanceId: String(row.instance_id),
375
+ templateId: String(row.template_id),
376
+ active: row.is_enabled === true && row.is_archived !== true,
377
+ revision: Number(row.revision),
378
+ componentSetRevision: Number(current.componentSet?.revision || 0)
379
+ };
380
+ } catch (readError) { void readError; }
381
+ throw cliError("E_AMBIGUOUS_SAVE", "Component 추가 요청의 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 Component 원본을 확인해 주세요.", {
382
+ requestErrorCode: normalized.code,
383
+ ...(observed ? { observed } : {})
384
+ }, normalized);
385
+ }
386
+
387
+ let after;
388
+ try { after = await loadValidated(gateway, plan.project.id, plan.versionNumber); }
389
+ catch (error) {
390
+ throw cliError("E_AMBIGUOUS_SAVE", "Component 추가 후 활성 구성을 다시 읽고 검증할 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
391
+ }
392
+ const row = rpcRow(response);
393
+ const instance = findInstance(after, plan.target.instanceId);
394
+ const view = after.views.find(item => item.viewInstanceId === plan.composition.viewInstanceId);
395
+ const actualBindings = view?.bindings.map(binding => ({
396
+ key: binding.key,
397
+ dataInstanceId: binding.dataInstanceId,
398
+ accessMode: binding.accessMode,
399
+ required: binding.required
400
+ })) || [];
401
+ const responseMatches = String(row?.saved_instance_id || "") === instance.instanceId
402
+ && Number(row?.saved_instance_revision) === instance.revision
403
+ && Number(row?.saved_set_revision) === Number(after.componentSet.revision)
404
+ && String(row?.saved_component_checksum || "") === String(after.componentSet.component_checksum || "")
405
+ && row?.is_active === true;
406
+ const componentMatches = instance.templateId === plan.target.templateId
407
+ && instance.tab === plan.target.tabLabel
408
+ && equalJson(instance.data, plan.initialData)
409
+ && instance.enabled === true
410
+ && instance.archived === false
411
+ && instance.required === false
412
+ && instance.removable === true;
413
+ const compositionMatches = view
414
+ && view.templateId === plan.composition.viewTemplateId
415
+ && view.label === plan.composition.label
416
+ && view.rendererId === plan.composition.rendererId
417
+ && equalJson(view.modes, plan.composition.modes)
418
+ && equalJson(actualBindings, plan.composition.bindings);
419
+ if (!responseMatches || !componentMatches || !compositionMatches
420
+ || Number(after.componentSet.revision) <= Number(before.componentSet.revision)) {
421
+ throw cliError("E_AMBIGUOUS_SAVE", "Component 추가 RPC 응답과 authoritative Data/View/binding read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
422
+ }
423
+ return Object.freeze({
424
+ ...receiptBase(plan, actorEmail, options),
425
+ target: clone(plan.target),
426
+ initialDataHash: plan.initialDataHash,
427
+ component: {
428
+ instanceId: instance.instanceId,
429
+ templateId: instance.templateId,
430
+ tabLabel: instance.tab,
431
+ revision: instance.revision
432
+ },
433
+ composition: clone(plan.composition),
434
+ componentSetRevision: Number(after.componentSet.revision),
435
+ componentChecksum: String(after.componentSet.component_checksum),
436
+ recoveredFromAmbiguousResponse: false
437
+ });
438
+ }
439
+
440
+ async function applyComponentActivePlan(gateway, plan, actorEmail, options = {}) {
441
+ const before = await loadValidated(gateway, plan.project.id, plan.versionNumber);
442
+ validateComponentActivePlanForApply(plan, before, actorEmail);
443
+ let response;
444
+ try {
445
+ response = await gateway.setComponentActive({
446
+ projectId: before.project.id,
447
+ versionNumber: before.versionNumber,
448
+ expectedSetRevision: before.componentSet.revision,
449
+ templateId: plan.target.templateId,
450
+ instanceId: plan.target.instanceId,
451
+ tabLabel: plan.target.tabLabel,
452
+ active: plan.target.active,
453
+ actorEmail
454
+ });
455
+ } catch (error) {
456
+ const normalized = normalizeError(error);
457
+ if (["E_CONFLICT", "E_AUTHORIZATION", "E_VALIDATION", "E_CODE_CHANGE_REQUIRED"].includes(normalized.code)) throw normalized;
458
+ throw cliError("E_AMBIGUOUS_SAVE", "Component 사용 여부 변경 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 Component 원본을 확인해 주세요.", {
459
+ requestErrorCode: normalized.code
460
+ }, normalized);
461
+ }
462
+
463
+ let after;
464
+ try { after = await loadValidated(gateway, plan.project.id, plan.versionNumber); }
465
+ catch (error) {
466
+ throw cliError("E_AMBIGUOUS_SAVE", "Component 사용 여부 변경 후 활성 구성을 다시 읽고 검증할 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
467
+ }
468
+ const row = rpcRow(response);
469
+ const storedRow = after.componentRows.find(item => String(item.instance_id) === plan.target.instanceId);
470
+ const activeInstance = after.instances.find(item => item.instanceId === plan.target.instanceId) || null;
471
+ const view = after.views.find(item => item.viewInstanceId === plan.composition.viewInstanceId) || null;
472
+ const actualBindings = view?.bindings.map(binding => ({
473
+ key: binding.key,
474
+ dataInstanceId: binding.dataInstanceId,
475
+ accessMode: binding.accessMode,
476
+ required: binding.required
477
+ })) || [];
478
+ const dataPreserved = storedRow && sha256(storedRow.data) === plan.target.expectedDataHash
479
+ && Number(storedRow.revision) === Number(plan.target.expectedRevision);
480
+ const responseMatches = String(row?.saved_instance_id || "") === plan.target.instanceId
481
+ && Number(row?.saved_instance_revision) === Number(plan.target.expectedRevision)
482
+ && Number(row?.saved_set_revision) === Number(after.componentSet.revision)
483
+ && String(row?.saved_component_checksum || "") === String(after.componentSet.component_checksum || "")
484
+ && row?.is_active === plan.target.active;
485
+ const stateMatches = plan.target.active
486
+ ? activeInstance && storedRow.is_enabled === true && storedRow.is_archived === false && view
487
+ && view.templateId === plan.composition.viewTemplateId
488
+ && view.label === plan.composition.label
489
+ && view.rendererId === plan.composition.rendererId
490
+ && equalJson(view.modes, plan.composition.modes)
491
+ && equalJson(actualBindings, plan.composition.bindings)
492
+ : !activeInstance && storedRow?.is_enabled === false && storedRow?.is_archived === true && !view;
493
+ if (!dataPreserved || !responseMatches || !stateMatches
494
+ || Number(after.componentSet.revision) <= Number(before.componentSet.revision)) {
495
+ throw cliError("E_AMBIGUOUS_SAVE", "Component 사용 여부 RPC 응답과 authoritative Data/View/binding read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
496
+ }
497
+ return Object.freeze({
498
+ ...receiptBase(plan, actorEmail, options),
499
+ target: clone(plan.target),
500
+ component: {
501
+ instanceId: plan.target.instanceId,
502
+ templateId: plan.target.templateId,
503
+ tabLabel: plan.target.tabLabel,
504
+ revision: Number(storedRow.revision),
505
+ active: plan.target.active,
506
+ archived: !plan.target.active,
507
+ dataHash: sha256(storedRow.data)
508
+ },
509
+ componentSetRevision: Number(after.componentSet.revision),
510
+ componentChecksum: String(after.componentSet.component_checksum),
511
+ recoveredFromAmbiguousResponse: false
512
+ });
513
+ }
514
+
349
515
  async function applyComponentPlan(gateway, plan, actorEmail, options = {}) {
350
516
  const before = await loadValidated(gateway, plan.project.id, plan.versionNumber);
351
517
  const prepared = validateComponentPlanForApply(plan, before, actorEmail);
@@ -719,4 +885,4 @@ async function applyAssetPlan(gateway, plan, actorEmail, options = {}) {
719
885
  });
720
886
  }
721
887
 
722
- module.exports = Object.freeze({ applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan, comparePlannedFile, assetPathPrefix });
888
+ module.exports = Object.freeze({ applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentActivePlan, applyComponentAddPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan, comparePlannedFile, assetPathPrefix });
@@ -164,13 +164,12 @@ function characterComponent(characterSource) {
164
164
  tag: "플레이어 캐릭터",
165
165
  color: CHARACTER_COLORS[characterId],
166
166
  customSections,
167
- containers: [{ id: crypto.randomUUID(), role: "primary", title: "기본 정보", sectionIds: customSections.map(section => section.id) }],
168
- authorNote: ""
167
+ containers: [{ id: crypto.randomUUID(), role: "primary", title: "기본 정보", sectionIds: customSections.map(section => section.id) }]
169
168
  };
170
169
  }
171
170
  return {
172
171
  characters, names, order, customCharacters: [], deletedColumns: [],
173
- enabledOptionalFields: ["image", "color", "tag", "authorNote"]
172
+ enabledOptionalFields: ["image", "color", "tag"]
174
173
  };
175
174
  }
176
175
 
@@ -195,7 +194,7 @@ function progressComponent(flowSource) {
195
194
  };
196
195
  }
197
196
 
198
- function cluesComponent(resourceSource, writerNote) {
197
+ function cluesComponent(resourceSource) {
199
198
  if (!isObject(resourceSource)) throw cliError("E_VALIDATION", "단서 resource 원본이 객체가 아닙니다.");
200
199
  const clues = [];
201
200
  Object.entries(resourceSource).forEach(([sourceId, item]) => {
@@ -207,7 +206,6 @@ function cluesComponent(resourceSource, writerNote) {
207
206
  title: name,
208
207
  location: source.verification === undefined ? "1차 조사" : "2차 조사",
209
208
  description: text(source.content),
210
- secret: text(source.verification),
211
209
  tags: [source.verification === undefined ? "자료" : "제보"],
212
210
  color: source.verification === undefined ? "#497fa8" : "#a86b49",
213
211
  confirmed: true,
@@ -216,8 +214,7 @@ function cluesComponent(resourceSource, writerNote) {
216
214
  });
217
215
  return {
218
216
  clues,
219
- enabledOptionalFields: ["confirmed", "image", "location", "tags", "color", "secret"],
220
- writerNote
217
+ enabledOptionalFields: ["confirmed", "image", "location", "tags", "color"]
221
218
  };
222
219
  }
223
220
 
@@ -372,25 +369,19 @@ function loadTrueWriterCase(input, options = {}) {
372
369
  }),
373
370
  component("progress", progress),
374
371
  component("common", {
375
- sections: {
376
- overview: docs["00-overview.md"],
377
- prologue: text(jsonData.game_set.flow?.["00 - 프롤로그"]?.script)
378
- },
379
- customSections: [],
380
- sectionOrder: ["overview", "prologue"],
381
- baseTitles: { overview: "작품 개요", prologue: "프롤로그" },
382
- deletedBaseSections: []
372
+ documents: [
373
+ { id: crypto.randomUUID(), title: "작품 개요", body: docs["00-overview.md"], subdocuments: [] },
374
+ { id: crypto.randomUUID(), title: "프롤로그", body: text(jsonData.game_set.flow?.["00 - 프롤로그"]?.script), subdocuments: [] },
375
+ { id: crypto.randomUUID(), title: "사건의 전말", body: "디자이너 전용 정답 및 사건의 전말", subdocuments: [{ id: crypto.randomUUID(), title: "사건의 진실", body: docs["01-truth.md"] }] },
376
+ { id: crypto.randomUUID(), title: "에필로그", body: "게임 종료 후 공개 문서", subdocuments: [{ id: crypto.randomUUID(), title: "에필로그", body: text(jsonData.game_set.flow?.["14 - 에필로그"]?.script) }] }
377
+ ],
378
+ deletedDocumentIds: []
383
379
  }),
384
380
  component("characters", characterComponent(jsonData.game_set.character)),
385
381
  component("assets", { assets: {} }),
386
- component("clues", cluesComponent(jsonData.resource, docs["04-clues.md"])),
382
+ component("clues", cluesComponent(jsonData.resource)),
387
383
  component("timeline", { events: timelineEvents }),
388
384
  component("ending", ending),
389
- component("postgame", {
390
- truth: [{ id: crypto.randomUUID(), title: "사건의 진실", body: docs["01-truth.md"] }],
391
- epilogue: [{ id: crypto.randomUUID(), title: "에필로그", body: text(jsonData.game_set.flow?.["14 - 에필로그"]?.script) }],
392
- intro: { truth: "디자이너 전용 정답 및 사건의 전말", epilogue: "게임 종료 후 공개 문서" }
393
- }),
394
385
  component("author-notes", {
395
386
  notes: [
396
387
  ["character-design", "캐릭터 설계 원문", docs["02-characters.md"]],