@ssobig/writer-cli 0.2.2 → 0.3.2

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 (39) hide show
  1. package/README.md +19 -0
  2. package/asset-repository.js +7 -2
  3. package/config.js +2 -1
  4. package/package.json +1 -1
  5. package/templates/mystery-v1/authoring-view-preference.js +22 -5
  6. package/templates/mystery-v1/character-perspective-preview.js +3 -3
  7. package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
  8. package/templates/mystery-v1/component-asset-operations.js +19 -6
  9. package/templates/mystery-v1/component-catalog-contract.js +26 -35
  10. package/templates/mystery-v1/component-draft-operations.js +19 -8
  11. package/templates/mystery-v1/component-field-contracts.js +127 -51
  12. package/templates/mystery-v1/component-id-policy.js +1 -1
  13. package/templates/mystery-v1/component-manager.js +21 -11
  14. package/templates/mystery-v1/component-navigation-counts.js +9 -9
  15. package/templates/mystery-v1/component-registry.js +11 -5
  16. package/templates/mystery-v1/component-renderers.js +12 -8
  17. package/templates/mystery-v1/component-storage-contract.js +28 -9
  18. package/templates/mystery-v1/markdown-document-model.js +444 -0
  19. package/templates/mystery-v1/markdown-image-editor.js +320 -0
  20. package/templates/mystery-v1/markdown-live-editor.js +1224 -0
  21. package/templates/mystery-v1/page-header.js +2 -1
  22. package/templates/mystery-v1/timeline-model.js +235 -0
  23. package/tools/writer-cli/package-lock.json +2 -2
  24. package/tools/writer-cli/package.json +1 -1
  25. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +13 -14
  26. package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -1
  27. package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +4 -1
  28. package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +3 -3
  29. package/tools/writer-cli/skills/ssobig-writer-cli/references/investigation-board.md +9 -0
  30. package/tools/writer-cli/skills/ssobig-writer-cli/references/layout-spec.md +130 -0
  31. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +8 -2
  32. package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +12 -2
  33. package/tools/writer-cli/src/agent-service.cjs +3 -1
  34. package/tools/writer-cli/src/command-registry.cjs +24 -20
  35. package/tools/writer-cli/src/commands.cjs +106 -3
  36. package/tools/writer-cli/src/domain.cjs +296 -1
  37. package/tools/writer-cli/src/gateway.cjs +14 -0
  38. package/tools/writer-cli/src/mutations.cjs +167 -1
  39. package/tools/writer-cli/src/project-import.cjs +27 -22
@@ -2,7 +2,10 @@
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");
8
+ const timelineModel = require("../../../templates/mystery-v1/timeline-model.js");
6
9
  const projectRuntime = require("../../../project-runtime.js");
7
10
  const { applyPatch } = require("./json-patch.cjs");
8
11
  const { clone, equalJson, sha256 } = require("./json.cjs");
@@ -27,6 +30,7 @@ const BINDING_COLUMNS = "view_instance_id,binding_key,data_instance_id,access_mo
27
30
  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
31
  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
32
  const CHECKPOINT_MESSAGE_MAX_LENGTH = 500;
33
+ const INSTANCE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
30
34
 
31
35
  function isObject(value) {
32
36
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -77,6 +81,17 @@ function findInstance(validated, selector) {
77
81
  throw cliError("E_COMPONENT_NOT_FOUND", `활성 Component를 찾을 수 없습니다: ${value || "(비어 있음)"}`);
78
82
  }
79
83
 
84
+ function findStoredInstance(validated, selector) {
85
+ const value = String(selector || "").trim();
86
+ const stored = validated.componentRows.map(componentContract.normalizeInstance).filter(Boolean);
87
+ const byId = stored.find(instance => instance.instanceId === value);
88
+ if (byId) return byId;
89
+ const byTemplate = stored.filter(instance => instance.templateId === value);
90
+ if (byTemplate.length === 1) return byTemplate[0];
91
+ if (byTemplate.length > 1) throw cliError("E_AMBIGUOUS_INSTANCE", `${value} Template의 Instance가 여러 개입니다. instance ID를 지정해 주세요.`);
92
+ throw cliError("E_COMPONENT_NOT_FOUND", `저장된 Component를 찾을 수 없습니다: ${value || "(비어 있음)"}`);
93
+ }
94
+
80
95
  function assertGenericPatchAllowed(instance) {
81
96
  if (instance.templateId === "ssobig.assets") {
82
97
  throw cliError("E_ASSET_COMMAND_REQUIRED", "ssobig.assets Component는 generic patch로 수정할 수 없습니다. asset 명령을 사용해 주세요.");
@@ -140,6 +155,166 @@ function checkpointMessage(value, fallback) {
140
155
  return message;
141
156
  }
142
157
 
158
+ function componentViewReview(validated, descriptor, instanceId, tabLabel) {
159
+ const activeByTemplate = new Map();
160
+ for (const instance of validated.instances) {
161
+ if (!activeByTemplate.has(instance.templateId)) activeByTemplate.set(instance.templateId, instance);
162
+ }
163
+ activeByTemplate.set(descriptor.templateId, { instanceId, templateId: descriptor.templateId });
164
+ const bindings = [];
165
+ for (const binding of descriptor.view.bindings) {
166
+ const target = activeByTemplate.get(binding.dataTemplateId);
167
+ if (!target) {
168
+ if (binding.required) throw cliError("E_VALIDATION", `${descriptor.templateId} 사용에는 활성 ${binding.dataTemplateId} Component가 필요합니다.`);
169
+ continue;
170
+ }
171
+ bindings.push({ key: binding.key, dataInstanceId: target.instanceId, accessMode: binding.accessMode, required: binding.required });
172
+ }
173
+ const viewInstanceId = descriptor.allowMultiple ? `${instanceId}-workbench` : descriptor.view.instanceId;
174
+ if (!INSTANCE_ID_PATTERN.test(viewInstanceId)) throw cliError("E_VALIDATION", `생성될 View instance ID가 올바르지 않습니다: ${viewInstanceId}`);
175
+ return {
176
+ viewInstanceId,
177
+ viewTemplateId: descriptor.view.templateId,
178
+ label: `${tabLabel} 작업 화면`,
179
+ rendererId: descriptor.view.rendererId,
180
+ modes: clone(descriptor.view.supportedModes),
181
+ bindings
182
+ };
183
+ }
184
+
185
+ function componentAddReview(validated, input = {}) {
186
+ const templateId = String(input.templateId || "").trim();
187
+ const descriptor = componentCatalog.get(templateId);
188
+ if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view) {
189
+ throw cliError("E_CODE_CHANGE_REQUIRED", `CLI에서 추가할 수 없는 Component Template입니다: ${templateId || "(비어 있음)"}`);
190
+ }
191
+ const requestedInstanceId = String(input.instanceId || "").trim();
192
+ if (descriptor.allowMultiple && !requestedInstanceId) {
193
+ throw cliError("E_VALIDATION", `${templateId}에는 고유한 instance ID가 필요합니다.`);
194
+ }
195
+ const instanceId = requestedInstanceId || descriptor.defaultInstanceId;
196
+ if (!INSTANCE_ID_PATTERN.test(instanceId)) throw cliError("E_VALIDATION", `Component instance ID가 올바르지 않습니다: ${instanceId || "(비어 있음)"}`);
197
+ const tabLabel = String(input.tabLabel || descriptor.tabLabel || "").trim();
198
+ if (!tabLabel || tabLabel.length > 120) throw cliError("E_VALIDATION", "Component label은 비어 있지 않은 120자 이하 문자열이어야 합니다.");
199
+
200
+ const conflictingRow = validated.componentRows.find(row => String(row.instance_id) === instanceId
201
+ || (!descriptor.allowMultiple && String(row.template_id) === templateId));
202
+ if (conflictingRow) {
203
+ const state = conflictingRow.is_archived || !conflictingRow.is_enabled ? "보관된" : "활성";
204
+ throw cliError("E_CONFLICT", `${state} Component가 이미 존재합니다: ${conflictingRow.instance_id}`);
205
+ }
206
+
207
+ const initialData = clone(descriptor.defaultData);
208
+ const componentSpec = componentContract.componentSpec(templateId);
209
+ if (!componentSpec) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 ${templateId}의 Data schema가 없습니다.`);
210
+ const dataError = componentSpec.validate(initialData);
211
+ if (dataError) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 ${templateId} 기본 data가 현재 schema와 일치하지 않습니다: ${dataError}`);
212
+ return Object.freeze({
213
+ target: {
214
+ templateId,
215
+ instanceId,
216
+ tabLabel,
217
+ expectedState: "missing",
218
+ allowMultiple: descriptor.allowMultiple
219
+ },
220
+ initialData,
221
+ composition: componentViewReview(validated, descriptor, instanceId, tabLabel)
222
+ });
223
+ }
224
+
225
+ function createComponentAddPlan(validated, input, actorEmail, options = {}) {
226
+ const review = componentAddReview(validated, input);
227
+ return finalizePlan({
228
+ ...basePlan(validated, actorEmail, "component.add", options),
229
+ target: review.target,
230
+ initialData: review.initialData,
231
+ initialDataHash: sha256(review.initialData),
232
+ composition: review.composition
233
+ });
234
+ }
235
+
236
+ function componentActiveReview(validated, input = {}) {
237
+ if (typeof input.active !== "boolean") throw cliError("E_VALIDATION", "Component 사용 여부는 true 또는 false여야 합니다.");
238
+ const instance = findStoredInstance(validated, input.instanceId);
239
+ const descriptor = componentCatalog.get(instance.templateId);
240
+ if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view || instance.required || !instance.removable) {
241
+ throw cliError("E_CODE_CHANGE_REQUIRED", `사용 여부를 변경할 수 없는 Component입니다: ${instance.instanceId}`);
242
+ }
243
+ const targetActive = input.active === true;
244
+ const currentActive = instance.enabled === true && instance.archived === false;
245
+ if (currentActive === targetActive) throw cliError("E_NO_CHANGES", `Component가 이미 ${targetActive ? "사용 중" : "사용 안 함"} 상태입니다: ${instance.instanceId}`);
246
+ if (!targetActive) {
247
+ const requiredDependents = validated.views.filter(view => view.bindings.some(binding => binding.dataInstanceId === instance.instanceId
248
+ && binding.required && !(binding.key === "primary" && view.rendererId === instance.templateId)));
249
+ if (requiredDependents.length) {
250
+ throw cliError("E_VALIDATION", `${instance.tab}을 사용 안 함으로 전환하기 전에 필수로 의존하는 Component를 먼저 보관해야 합니다.`, {
251
+ dependentViews: requiredDependents.map(view => view.viewInstanceId)
252
+ });
253
+ }
254
+ }
255
+ return Object.freeze({
256
+ target: {
257
+ instanceId: instance.instanceId,
258
+ templateId: instance.templateId,
259
+ tabLabel: instance.tab,
260
+ expectedActive: currentActive,
261
+ active: targetActive,
262
+ expectedRevision: instance.revision,
263
+ expectedDataHash: sha256(instance.data)
264
+ },
265
+ composition: targetActive
266
+ ? componentViewReview(validated, descriptor, instance.instanceId, instance.tab)
267
+ : { viewInstanceId: descriptor.allowMultiple ? `${instance.instanceId}-workbench` : descriptor.view.instanceId, expectedState: "absent" }
268
+ });
269
+ }
270
+
271
+ function createComponentActivePlan(validated, input, actorEmail, options = {}) {
272
+ const review = componentActiveReview(validated, input);
273
+ return finalizePlan({
274
+ ...basePlan(validated, actorEmail, "component.set-active", options),
275
+ target: review.target,
276
+ composition: review.composition
277
+ });
278
+ }
279
+
280
+ function componentFieldReview(validated, selector, fieldKey, enabled) {
281
+ const instance = findInstance(validated, selector);
282
+ const definition = componentRegistry.get(instance.templateId);
283
+ const field = definition?.fieldConfiguration?.find(item => item.key === String(fieldKey || ""));
284
+ if (!field) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 속성 구성 항목이 아닙니다: ${fieldKey || "(비어 있음)"}`);
285
+ if (field.required) throw cliError("E_VALIDATION", `필수 속성은 사용 안 함으로 전환할 수 없습니다: ${field.label}`);
286
+ const optionalFields = definition.fieldConfiguration.filter(item => !item.required);
287
+ const hasSelection = Array.isArray(instance.data?.enabledOptionalFields);
288
+ const selected = new Set(hasSelection ? instance.data.enabledOptionalFields : optionalFields.map(item => item.key));
289
+ const beforeEnabled = selected.has(field.key);
290
+ if (beforeEnabled === enabled) throw cliError("E_NO_CHANGES", `${field.label} 속성이 이미 ${enabled ? "사용" : "사용 안 함"} 상태입니다.`);
291
+ if (enabled) selected.add(field.key);
292
+ else selected.delete(field.key);
293
+ const afterFields = optionalFields.filter(item => selected.has(item.key)).map(item => item.key);
294
+ const patch = hasSelection
295
+ ? [{ op: "test", path: "/enabledOptionalFields", value: clone(instance.data.enabledOptionalFields) }, { op: "replace", path: "/enabledOptionalFields", value: afterFields }]
296
+ : [{ op: "add", path: "/enabledOptionalFields", value: afterFields }];
297
+ return Object.freeze({ instance, field, beforeEnabled, enabled, afterFields, patch });
298
+ }
299
+
300
+ function createComponentFieldPlan(validated, selector, fieldKey, enabled, actorEmail, options = {}) {
301
+ const review = componentFieldReview(validated, selector, fieldKey, enabled);
302
+ const base = createComponentPlan(validated, review.instance.instanceId, review.patch, actorEmail, {
303
+ ...options,
304
+ checkpointMessage: options.checkpointMessage || `CLI: ${review.instance.tab} 선택 속성 ${review.field.label} ${enabled ? "사용" : "사용 안 함"}`
305
+ });
306
+ const enriched = clone(base);
307
+ delete enriched.digest;
308
+ enriched.fieldSelection = {
309
+ key: review.field.key,
310
+ label: review.field.label,
311
+ beforeEnabled: review.beforeEnabled,
312
+ enabled: review.enabled,
313
+ enabledOptionalFields: review.afterFields
314
+ };
315
+ return finalizePlan(enriched);
316
+ }
317
+
143
318
  function createComponentPlan(validated, selector, patch, actorEmail, options = {}) {
144
319
  const instance = findInstance(validated, selector);
145
320
  assertGenericPatchAllowed(instance);
@@ -225,6 +400,50 @@ function createInvestigationBoardLayoutPlan(validated, selector, spec, actorEmai
225
400
  return finalizePlan(plan);
226
401
  }
227
402
 
403
+ function timelineEntryCountsByCharacter(entries) {
404
+ const counts = new Map();
405
+ for (const entry of entries) {
406
+ if (entry.lane?.kind !== "character") continue;
407
+ const characterId = String(entry.lane.characterId || "");
408
+ counts.set(characterId, (counts.get(characterId) || 0) + 1);
409
+ }
410
+ return Object.fromEntries([...counts.keys()].sort().map(characterId => [characterId, counts.get(characterId)]));
411
+ }
412
+
413
+ function createTimelineMigrationPlan(validated, selector, actorEmail, options = {}) {
414
+ const instance = findInstance(validated, selector || "timeline");
415
+ if (instance.templateId !== "ssobig.timeline") {
416
+ throw cliError("E_VALIDATION", "타임라인 v2 이관은 ssobig.timeline Component에만 사용할 수 있습니다.");
417
+ }
418
+ const shape = timelineModel.detectShape(instance.data);
419
+ if (shape === "v2") throw cliError("E_NO_CHANGES", "타임라인 Component가 이미 v2 모양입니다.");
420
+ if (shape !== "legacy") throw cliError("E_VALIDATION", "타임라인 Component data가 legacy 또는 v2 모양이 아닙니다.");
421
+ const converted = timelineModel.convertLegacyTimeline(instance.data, { idFactory: options.idFactory });
422
+ const review = timelineModel.compareLegacyToV2(instance.data, converted);
423
+ if (!review.ok) throw cliError("E_VALIDATION", `타임라인 v2 변환이 원본과 일치하지 않습니다: ${review.mismatches.join(" / ")}`);
424
+ const patch = [
425
+ { op: "test", path: "/events", value: clone(instance.data.events) },
426
+ { op: "remove", path: "/events" },
427
+ { op: "add", path: "/timeLevels", value: clone(converted.timeLevels) },
428
+ { op: "add", path: "/entries", value: clone(converted.entries) }
429
+ ];
430
+ const componentPlan = createComponentPlan(validated, instance.instanceId, patch, actorEmail, {
431
+ ...options,
432
+ checkpointMessage: options.checkpointMessage || "CLI: 타임라인 v2 이관"
433
+ });
434
+ const plan = clone(componentPlan);
435
+ delete plan.digest;
436
+ plan.migration = {
437
+ kind: "timeline-v2",
438
+ counts: clone(review.counts),
439
+ mismatches: clone(review.mismatches),
440
+ levelCount: converted.timeLevels.length,
441
+ entryCount: converted.entries.length,
442
+ entryCountByCharacter: timelineEntryCountsByCharacter(converted.entries)
443
+ };
444
+ return finalizePlan(plan);
445
+ }
446
+
228
447
  function createAssetPlan(validated, operation, assetId, actorEmail, options = {}) {
229
448
  const assetInstance = componentContract.assetInstance(validated.instances);
230
449
  const manifest = componentContract.assetManifest(validated.instances);
@@ -461,7 +680,7 @@ function verifyPlan(plan) {
461
680
  if (!UUID_PATTERN.test(String(plan.planId || "")) || typeof plan.digest !== "string" || plan.digest !== planDigest(plan)) {
462
681
  throw cliError("E_INVALID_PLAN", "plan 식별자 또는 digest가 올바르지 않습니다.");
463
682
  }
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입니다.");
683
+ 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
684
  if (["component.patch", "asset.upload", "asset.delete", "checkpoint.create", "checkpoint.restore"].includes(plan.operation)) {
466
685
  let normalizedCheckpointMessage = "";
467
686
  try { normalizedCheckpointMessage = checkpointMessage(plan.checkpointMessage, ""); }
@@ -502,6 +721,36 @@ function verifyPlan(plan) {
502
721
  || String(plan.target?.scaffold || "") !== target.scaffold) {
503
722
  throw cliError("E_INVALID_PLAN", "새 작품 plan의 입력값이 올바르지 않습니다.");
504
723
  }
724
+ } else if (plan.operation === "component.add") {
725
+ const descriptor = componentCatalog.get(String(plan.target?.templateId || ""));
726
+ if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view
727
+ || !INSTANCE_ID_PATTERN.test(String(plan.target?.instanceId || ""))
728
+ || String(plan.target?.expectedState || "") !== "missing"
729
+ || Boolean(plan.target?.allowMultiple) !== descriptor.allowMultiple
730
+ || !String(plan.target?.tabLabel || "").trim()
731
+ || String(plan.target.tabLabel).length > 120
732
+ || !equalJson(plan.initialData, descriptor.defaultData)
733
+ || String(plan.initialDataHash || "") !== sha256(descriptor.defaultData)
734
+ || !isObject(plan.composition)
735
+ || !INSTANCE_ID_PATTERN.test(String(plan.composition.viewInstanceId || ""))
736
+ || String(plan.composition.viewTemplateId || "") !== descriptor.view.templateId
737
+ || String(plan.composition.rendererId || "") !== descriptor.view.rendererId
738
+ || !Array.isArray(plan.composition.bindings)) {
739
+ throw cliError("E_INVALID_PLAN", "Component 추가 plan의 대상 또는 구성 review가 올바르지 않습니다.");
740
+ }
741
+ } else if (plan.operation === "component.set-active") {
742
+ const descriptor = componentCatalog.get(String(plan.target?.templateId || ""));
743
+ if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view
744
+ || !INSTANCE_ID_PATTERN.test(String(plan.target?.instanceId || ""))
745
+ || !String(plan.target?.tabLabel || "").trim()
746
+ || typeof plan.target?.expectedActive !== "boolean"
747
+ || typeof plan.target?.active !== "boolean"
748
+ || plan.target.expectedActive === plan.target.active
749
+ || !Number.isSafeInteger(Number(plan.target?.expectedRevision))
750
+ || !/^[0-9a-f]{64}$/.test(String(plan.target?.expectedDataHash || ""))
751
+ || !isObject(plan.composition)) {
752
+ throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan의 대상 또는 구성 review가 올바르지 않습니다.");
753
+ }
505
754
  } else if (plan.operation === "project.import") {
506
755
  const target = projectCreationTarget(plan.target);
507
756
  const components = normalizeImportComponents(plan.components);
@@ -655,10 +904,50 @@ function validateComponentPlanForApply(plan, validated, actorEmail) {
655
904
  throw cliError("E_INVALID_PLAN", "추리 보드 layout plan의 spec, review 또는 생성 결과가 일치하지 않습니다.");
656
905
  }
657
906
  }
907
+ if (Object.hasOwn(plan, "fieldSelection")) {
908
+ const review = componentFieldReview(validated, instance.instanceId, plan.fieldSelection?.key, plan.fieldSelection?.enabled === true);
909
+ const expected = {
910
+ key: review.field.key,
911
+ label: review.field.label,
912
+ beforeEnabled: review.beforeEnabled,
913
+ enabled: review.enabled,
914
+ enabledOptionalFields: review.afterFields
915
+ };
916
+ if (!equalJson(plan.fieldSelection, expected) || !equalJson(plan.patch, review.patch)) {
917
+ throw cliError("E_INVALID_PLAN", "선택 속성 plan의 review와 patch가 현재 등록 계약과 일치하지 않습니다.");
918
+ }
919
+ }
658
920
  const nextInstance = validateHypotheticalData(validated, instance, recomputed);
659
921
  return { instance, nextInstance, payload: componentContract.instancePayload(nextInstance) };
660
922
  }
661
923
 
924
+ function validateComponentAddPlanForApply(plan, validated, actorEmail) {
925
+ assertPlanContext(plan, validated, actorEmail);
926
+ if (plan.operation !== "component.add") throw cliError("E_INVALID_PLAN", "Component 추가 plan이 아닙니다.");
927
+ const current = componentAddReview(validated, {
928
+ templateId: plan.target?.templateId,
929
+ instanceId: plan.target?.instanceId,
930
+ tabLabel: plan.target?.tabLabel
931
+ });
932
+ if (!equalJson(plan.target, current.target)
933
+ || !equalJson(plan.initialData, current.initialData)
934
+ || plan.initialDataHash !== sha256(current.initialData)
935
+ || !equalJson(plan.composition, current.composition)) {
936
+ throw cliError("E_INVALID_PLAN", "Component 추가 plan의 review가 현재 등록 계약과 일치하지 않습니다.");
937
+ }
938
+ return current;
939
+ }
940
+
941
+ function validateComponentActivePlanForApply(plan, validated, actorEmail) {
942
+ assertPlanContext(plan, validated, actorEmail);
943
+ if (plan.operation !== "component.set-active") throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan이 아닙니다.");
944
+ const current = componentActiveReview(validated, { instanceId: plan.target?.instanceId, active: plan.target?.active });
945
+ if (!equalJson(plan.target, current.target) || !equalJson(plan.composition, current.composition)) {
946
+ throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan의 review가 현재 등록 계약과 일치하지 않습니다.");
947
+ }
948
+ return current;
949
+ }
950
+
662
951
  function validateAssetPlanForApply(plan, validated, actorEmail) {
663
952
  assertPlanContext(plan, validated, actorEmail);
664
953
  if (!["asset.upload", "asset.delete"].includes(plan.operation)) throw cliError("E_INVALID_PLAN", "에셋 plan이 아닙니다.");
@@ -709,9 +998,13 @@ module.exports = Object.freeze({
709
998
  componentContract,
710
999
  validateLoadedVersion,
711
1000
  findInstance,
1001
+ createComponentActivePlan,
1002
+ createComponentAddPlan,
1003
+ createComponentFieldPlan,
712
1004
  createComponentPlan,
713
1005
  investigationBoardReferences,
714
1006
  createInvestigationBoardLayoutPlan,
1007
+ createTimelineMigrationPlan,
715
1008
  createAssetPlan,
716
1009
  createProjectCreationPlan,
717
1010
  createProjectImportPlan,
@@ -724,6 +1017,8 @@ module.exports = Object.freeze({
724
1017
  validateProjectImportPlanForApply,
725
1018
  validateProjectStatusPlanForApply,
726
1019
  validateCatalogDemoPlanForApply,
1020
+ validateComponentActivePlanForApply,
1021
+ validateComponentAddPlanForApply,
727
1022
  validateComponentPlanForApply,
728
1023
  validateAssetPlanForApply,
729
1024
  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 });