@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.
- package/README.md +35 -0
- package/asset-repository.js +278 -0
- package/config.js +14 -0
- package/package.json +28 -0
- package/project-runtime.js +102 -0
- package/storage-path.js +110 -0
- package/templates/mystery-v1/authoring-view-preference.js +34 -0
- package/templates/mystery-v1/character-perspective-preview.js +61 -0
- package/templates/mystery-v1/component-asset-operations.js +103 -0
- package/templates/mystery-v1/component-autosave.js +121 -0
- package/templates/mystery-v1/component-catalog-contract.js +340 -0
- package/templates/mystery-v1/component-checkpoint-history.js +145 -0
- package/templates/mystery-v1/component-contract.js +90 -0
- package/templates/mystery-v1/component-draft-operations.js +313 -0
- package/templates/mystery-v1/component-field-contracts.js +595 -0
- package/templates/mystery-v1/component-id-policy.js +64 -0
- package/templates/mystery-v1/component-manager.js +396 -0
- package/templates/mystery-v1/component-navigation-counts.js +64 -0
- package/templates/mystery-v1/component-registry.js +205 -0
- package/templates/mystery-v1/component-renderers.js +139 -0
- package/templates/mystery-v1/component-storage-contract.js +237 -0
- package/templates/mystery-v1/external-update-coordinator.js +91 -0
- package/templates/mystery-v1/output-clue-card-layout.js +46 -0
- package/templates/mystery-v1/page-header.js +26 -0
- package/templates/mystery-v1/render-ui-state.js +76 -0
- package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
- package/templates/mystery-v1/tab-bar.js +87 -0
- package/templates/mystery-v1/view-component-contract.js +152 -0
- package/templates/mystery-v1/view-component-registry.js +44 -0
- package/templates/mystery-v1/view-component-runtime.js +95 -0
- package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
- package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
- package/tools/writer-cli/package-lock.json +121 -0
- package/tools/writer-cli/package.json +22 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
- package/tools/writer-cli/src/agent-paths.cjs +114 -0
- package/tools/writer-cli/src/agent-service.cjs +496 -0
- package/tools/writer-cli/src/asset-policy.cjs +113 -0
- package/tools/writer-cli/src/auth.cjs +655 -0
- package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
- package/tools/writer-cli/src/command-registry.cjs +152 -0
- package/tools/writer-cli/src/commands.cjs +841 -0
- package/tools/writer-cli/src/corpus.cjs +83 -0
- package/tools/writer-cli/src/daemon-app.cjs +106 -0
- package/tools/writer-cli/src/daemon-client.cjs +187 -0
- package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
- package/tools/writer-cli/src/daemon-runner.cjs +97 -0
- package/tools/writer-cli/src/daemon-server.cjs +378 -0
- package/tools/writer-cli/src/diagnostics.cjs +235 -0
- package/tools/writer-cli/src/domain.cjs +731 -0
- package/tools/writer-cli/src/errors.cjs +47 -0
- package/tools/writer-cli/src/gateway.cjs +357 -0
- package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
- package/tools/writer-cli/src/json-patch.cjs +98 -0
- package/tools/writer-cli/src/json.cjs +26 -0
- package/tools/writer-cli/src/local-index-cache.cjs +139 -0
- package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
- package/tools/writer-cli/src/local-index-query.cjs +304 -0
- package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
- package/tools/writer-cli/src/local-index-storage.cjs +284 -0
- package/tools/writer-cli/src/local-index.cjs +199 -0
- package/tools/writer-cli/src/mutations.cjs +722 -0
- package/tools/writer-cli/src/platform-runner.cjs +55 -0
- package/tools/writer-cli/src/project-import.cjs +485 -0
- package/tools/writer-cli/src/skill-manager.cjs +255 -0
- package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
- package/tools/writer-cli/src/update-gate.cjs +102 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @ssobig/writer-cli
|
|
2
|
+
|
|
3
|
+
SSOBIG WRITER의 공식 Agent CLI다. Google 계정으로 로그인한 뒤 본인이 소유하거나 공유받은 Writer 작품을 서버 권한 범위 안에서 읽고, 검토된 plan만 적용한다.
|
|
4
|
+
|
|
5
|
+
현재 지원 환경은 macOS 또는 Windows 10/11과 Node.js 22.13.0 이상이다. CLI는 일반 Writer 회원의 소유자·공동 편집자·뷰어 권한을 그대로 사용하며, server RLS와 focused RPC를 우회하지 않는다.
|
|
6
|
+
|
|
7
|
+
## 설치
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --global @ssobig/writer-cli@latest
|
|
11
|
+
ssobig-writer doctor
|
|
12
|
+
ssobig-writer skills install --target <내-workspace>
|
|
13
|
+
ssobig-writer auth login
|
|
14
|
+
ssobig-writer auth whoami
|
|
15
|
+
ssobig-writer project list
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
CLI는 stdout에 JSON 문서 하나를 출력한다. 원고 변경은 `plan -> 사람 검토 -> apply -> authoritative read-back` 절차만 사용하며, 범용 SQL이나 직접 table update를 제공하지 않는다.
|
|
19
|
+
|
|
20
|
+
## 업데이트와 제거
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
ssobig-writer version --check
|
|
24
|
+
npm install --global @ssobig/writer-cli@latest
|
|
25
|
+
|
|
26
|
+
ssobig-writer auth logout
|
|
27
|
+
ssobig-writer agent purge
|
|
28
|
+
npm uninstall --global @ssobig/writer-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
0.2.0부터 npm `latest`보다 낮은 CLI의 서버 접속 명령은 실행 전에 `E_CLI_UPDATE_REQUIRED`로 중단된다. help, targeted `command-schema`, `version --check`, `doctor`, `skills *`, logout과 daemon 정리 명령은 업데이트 전에도 사용할 수 있다. 0.1.0 사용자는 위 npm 설치 명령을 다시 실행해 0.2.0 이상으로 전환해야 한다. registry가 일시적으로 응답하지 않으면 새 작업을 차단하지 않지만, 로컬 cache가 이미 더 높은 버전을 확인했다면 계속 업데이트를 요구한다.
|
|
32
|
+
|
|
33
|
+
`doctor`는 설치·최신 버전·credential session 존재·Auth health·실행 backend 버전을 읽기 전용으로 진단하고 token, 이메일, 원고 값을 출력하지 않는다. macOS 세션은 Keychain에 저장되고 private Unix daemon을 사용한다. Windows 세션은 현재 Windows 사용자만 해독할 수 있도록 DPAPI CurrentUser로 보호되며 CLI는 background daemon 대신 in-process backend를 자동 사용한다. 따라서 Windows에서는 `agent start`와 `agent stop`이 지원되지 않는다.
|
|
34
|
+
|
|
35
|
+
상세 설치, 업데이트, 삭제 방법은 [SSOBIG WRITER 운영 가이드](https://writer.ssobig.com/)에서 확인한다.
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
root.SomiAssetRepository = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : window, function () {
|
|
6
|
+
function cloneManifest(manifest) {
|
|
7
|
+
return { ...(manifest || {}), assets: { ...(manifest?.assets || {}) } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeSnapshot(snapshot) {
|
|
11
|
+
if (snapshot && typeof snapshot === "object" && Object.prototype.hasOwnProperty.call(snapshot, "manifest")) {
|
|
12
|
+
return { manifest: cloneManifest(snapshot.manifest), revision: snapshot.revision, instanceId: snapshot.instanceId };
|
|
13
|
+
}
|
|
14
|
+
return { manifest: cloneManifest(snapshot), revision: undefined, instanceId: undefined };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function manifestReferencesPath(manifest, path) {
|
|
18
|
+
return Boolean(path && Object.values(manifest?.assets || {}).some(asset => !asset?.deleted && asset?.path === path));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildAssetRestorePlan(manifest) {
|
|
22
|
+
return Object.entries(manifest?.assets || {})
|
|
23
|
+
.filter(([, asset]) => Boolean(asset?.path && !asset.deleted))
|
|
24
|
+
.map(([assetId, asset]) => ({ localAssetId: assetId, manifestAssetId: assetId, asset }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function restoreAssetStore(manifest, options = {}) {
|
|
28
|
+
if (typeof options.deleteLocalAssetIds !== "function") throw new TypeError("에셋 복원에는 deleteLocalAssetIds 함수가 필요합니다.");
|
|
29
|
+
if (typeof options.downloadAsset !== "function") throw new TypeError("에셋 복원에는 downloadAsset 함수가 필요합니다.");
|
|
30
|
+
if (typeof options.putLocalAsset !== "function") throw new TypeError("에셋 복원에는 putLocalAsset 함수가 필요합니다.");
|
|
31
|
+
const queue = buildAssetRestorePlan(manifest);
|
|
32
|
+
const restoredLocalAssetIds = [];
|
|
33
|
+
const concurrency = Math.max(1, Number(options.concurrency || 6));
|
|
34
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
35
|
+
while (queue.length) {
|
|
36
|
+
const entry = queue.shift();
|
|
37
|
+
const blob = await options.downloadAsset(entry);
|
|
38
|
+
if (!blob) continue;
|
|
39
|
+
await options.putLocalAsset(entry.localAssetId, blob);
|
|
40
|
+
restoredLocalAssetIds.push(entry.localAssetId);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
await Promise.all(workers);
|
|
44
|
+
return { clearLocalAssetIds: [], restoredLocalAssetIds };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function defaultContentHash(blob) {
|
|
48
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
49
|
+
if (globalThis.crypto?.subtle) {
|
|
50
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
51
|
+
return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, "0")).join("");
|
|
52
|
+
}
|
|
53
|
+
let hash = 2166136261;
|
|
54
|
+
bytes.forEach(value => {
|
|
55
|
+
hash ^= value;
|
|
56
|
+
hash = Math.imul(hash, 16777619);
|
|
57
|
+
});
|
|
58
|
+
return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultOperationId() {
|
|
62
|
+
const random = globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2);
|
|
63
|
+
return `${Date.now()}-${random}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class AssetRepository {
|
|
67
|
+
constructor(options = {}) {
|
|
68
|
+
for (const name of ["readManifest", "saveManifest", "uploadBlob", "removeBlobs", "buildImmutablePath"]) {
|
|
69
|
+
if (typeof options[name] !== "function") throw new TypeError(`AssetRepository에는 ${name} 함수가 필요합니다.`);
|
|
70
|
+
}
|
|
71
|
+
this.readManifest = options.readManifest;
|
|
72
|
+
this.saveManifest = options.saveManifest;
|
|
73
|
+
this.uploadBlob = options.uploadBlob;
|
|
74
|
+
this.removeBlobs = options.removeBlobs;
|
|
75
|
+
this.buildImmutablePath = options.buildImmutablePath;
|
|
76
|
+
this.contentHash = options.contentHash || defaultContentHash;
|
|
77
|
+
this.operationId = options.operationId || defaultOperationId;
|
|
78
|
+
this.onManifest = typeof options.onManifest === "function" ? options.onManifest : () => {};
|
|
79
|
+
this.onOrphan = typeof options.onOrphan === "function" ? options.onOrphan : () => {};
|
|
80
|
+
this.onRecoveredCommit = typeof options.onRecoveredCommit === "function" ? options.onRecoveredCommit : () => {};
|
|
81
|
+
this.now = options.now || Date.now;
|
|
82
|
+
this.chain = Promise.resolve();
|
|
83
|
+
this.orphans = [];
|
|
84
|
+
this.failures = new Map();
|
|
85
|
+
this.pendingCount = 0;
|
|
86
|
+
this.cancelledError = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
enqueue(assetId, operation) {
|
|
90
|
+
if (this.cancelledError) return Promise.reject(this.cancelledError);
|
|
91
|
+
this.pendingCount += 1;
|
|
92
|
+
const result = this.chain.catch(() => {}).then(() => {
|
|
93
|
+
if (this.cancelledError) throw this.cancelledError;
|
|
94
|
+
return operation();
|
|
95
|
+
}).then(value => {
|
|
96
|
+
this.failures.delete(assetId);
|
|
97
|
+
return value;
|
|
98
|
+
}).catch(error => {
|
|
99
|
+
this.failures.set(assetId, error);
|
|
100
|
+
throw error;
|
|
101
|
+
}).finally(() => {
|
|
102
|
+
this.pendingCount -= 1;
|
|
103
|
+
});
|
|
104
|
+
this.chain = result;
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
cancelAll(error) {
|
|
109
|
+
if (!this.cancelledError) {
|
|
110
|
+
this.cancelledError = error instanceof Error ? error : new Error(String(error || "에셋 저장이 취소되었습니다."));
|
|
111
|
+
}
|
|
112
|
+
return this.cancelledError;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
upload(assetId, blob, options = {}) {
|
|
116
|
+
const key = String(assetId);
|
|
117
|
+
return this.enqueue(key, () => this.performUpload(key, blob, options));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
remove(assetId, options = {}) {
|
|
121
|
+
const key = String(assetId);
|
|
122
|
+
return this.enqueue(key, () => this.performRemove(key, options));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async drain() {
|
|
126
|
+
while (true) {
|
|
127
|
+
const activeChain = this.chain;
|
|
128
|
+
let result;
|
|
129
|
+
try { result = await activeChain; } catch (error) {}
|
|
130
|
+
if (activeChain !== this.chain) continue;
|
|
131
|
+
if (this.failures.size) {
|
|
132
|
+
const error = new AggregateError([...this.failures.values()], "일부 에셋을 저장하지 못했습니다.");
|
|
133
|
+
error.code = "SOMI_ASSET_DRAIN_FAILED";
|
|
134
|
+
error.assetIds = [...this.failures.keys()];
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
hasPending() {
|
|
142
|
+
return this.pendingCount > 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
hasFailures() {
|
|
146
|
+
return this.failures.size > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
orphan(path, reason, error = null) {
|
|
150
|
+
const record = { path, reason, error, recordedAt: this.now() };
|
|
151
|
+
this.orphans.push(record);
|
|
152
|
+
try { this.onOrphan(record); } catch (callbackError) { console.error(callbackError); }
|
|
153
|
+
return record;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getOrphans() {
|
|
157
|
+
return this.orphans.map(orphan => ({ ...orphan }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async removePaths(paths, reason) {
|
|
161
|
+
const uniquePaths = [...new Set((paths || []).filter(Boolean))];
|
|
162
|
+
if (!uniquePaths.length) return true;
|
|
163
|
+
try {
|
|
164
|
+
await this.removeBlobs(uniquePaths);
|
|
165
|
+
return true;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
uniquePaths.forEach(path => this.orphan(path, reason, error));
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 매니페스트에 한 번이라도 커밋된 경로는 물리 삭제하지 않는다.
|
|
173
|
+
// 복사본이 원본의 자산 경로를 그대로 참조하므로, 원본에서 지운 자산을
|
|
174
|
+
// 스토리지에서 없애면 복사본의 참조가 함께 깨진다.
|
|
175
|
+
// 매니페스트에서 빠진 자산은 해당 작품에서만 보이지 않게 되고 파일은 남는다.
|
|
176
|
+
|
|
177
|
+
async performUpload(assetId, blob, options) {
|
|
178
|
+
if (!blob || typeof blob.arrayBuffer !== "function") throw new TypeError("업로드할 에셋은 Blob이어야 합니다.");
|
|
179
|
+
const contentHash = await this.contentHash(blob);
|
|
180
|
+
const operationId = String(this.operationId());
|
|
181
|
+
const currentSnapshot = normalizeSnapshot(await this.readManifest());
|
|
182
|
+
const currentManifest = currentSnapshot.manifest;
|
|
183
|
+
const previous = currentManifest.assets[assetId];
|
|
184
|
+
if (options.dedupe !== false && previous?.path && !previous.deleted && previous.contentHash === contentHash) {
|
|
185
|
+
return { unchanged: true, path: previous.path, manifest: currentManifest, entry: previous };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const path = this.buildImmutablePath({ assetId, blob, contentHash, operationId, options, previous });
|
|
189
|
+
if (!path || previous?.path === path) throw new Error("에셋 저장 경로는 기존 경로와 다른 immutable 경로여야 합니다.");
|
|
190
|
+
try {
|
|
191
|
+
await this.uploadBlob(path, blob, { upsert: false, contentType: blob.type || undefined });
|
|
192
|
+
} catch (uploadError) {
|
|
193
|
+
await this.removePaths([path], "failed-blob-upload");
|
|
194
|
+
throw uploadError;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const entry = {
|
|
198
|
+
...(options.metadata || {}),
|
|
199
|
+
path,
|
|
200
|
+
name: String(options.name || blob.name || assetId),
|
|
201
|
+
type: String(options.type || blob.type || "application/octet-stream"),
|
|
202
|
+
contentHash,
|
|
203
|
+
operationId,
|
|
204
|
+
updatedAt: this.now()
|
|
205
|
+
};
|
|
206
|
+
const nextManifest = cloneManifest(currentManifest);
|
|
207
|
+
nextManifest.assets[assetId] = entry;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await this.saveManifest(nextManifest, currentSnapshot);
|
|
211
|
+
} catch (saveError) {
|
|
212
|
+
let authoritativeManifest = null;
|
|
213
|
+
try { authoritativeManifest = normalizeSnapshot(await this.readManifest()).manifest; }
|
|
214
|
+
catch (readError) { this.orphan(path, "manifest-state-unknown", readError); }
|
|
215
|
+
if (authoritativeManifest) this.onManifest(authoritativeManifest);
|
|
216
|
+
if (authoritativeManifest?.assets?.[assetId]?.path === path) {
|
|
217
|
+
this.onRecoveredCommit({ assetId, path, manifest: authoritativeManifest, entry: authoritativeManifest.assets[assetId] });
|
|
218
|
+
return { path, manifest: authoritativeManifest, entry: authoritativeManifest.assets[assetId], recoveredCommit: true };
|
|
219
|
+
}
|
|
220
|
+
if (authoritativeManifest && !manifestReferencesPath(authoritativeManifest, path)) {
|
|
221
|
+
await this.removePaths([path], "failed-pointer-upload");
|
|
222
|
+
}
|
|
223
|
+
throw saveError;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.onManifest(nextManifest);
|
|
227
|
+
// 교체된 이전 파일은 남겨 둔다. 다른 작품(복사본)이 그 경로를 참조할 수 있다.
|
|
228
|
+
return { path, manifest: nextManifest, entry };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async performRemove(assetId, options) {
|
|
232
|
+
const currentSnapshot = normalizeSnapshot(await this.readManifest());
|
|
233
|
+
const currentManifest = currentSnapshot.manifest;
|
|
234
|
+
const previous = currentManifest.assets[assetId];
|
|
235
|
+
if (!previous?.path && !options.tombstone) return { unchanged: true, manifest: currentManifest };
|
|
236
|
+
if (options.tombstone && previous?.deleted === true) return { unchanged: true, manifest: currentManifest };
|
|
237
|
+
|
|
238
|
+
const nextManifest = cloneManifest(currentManifest);
|
|
239
|
+
if (options.tombstone) {
|
|
240
|
+
nextManifest.assets[assetId] = {
|
|
241
|
+
deleted: true,
|
|
242
|
+
operationId: String(this.operationId()),
|
|
243
|
+
updatedAt: this.now()
|
|
244
|
+
};
|
|
245
|
+
} else {
|
|
246
|
+
delete nextManifest.assets[assetId];
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await this.saveManifest(nextManifest, currentSnapshot);
|
|
250
|
+
} catch (saveError) {
|
|
251
|
+
let authoritativeManifest = null;
|
|
252
|
+
try { authoritativeManifest = normalizeSnapshot(await this.readManifest()).manifest; }
|
|
253
|
+
catch (readError) { void readError; }
|
|
254
|
+
const authoritativeEntry = authoritativeManifest?.assets?.[assetId];
|
|
255
|
+
const expectedOperationId = nextManifest.assets?.[assetId]?.operationId;
|
|
256
|
+
const committed = options.tombstone
|
|
257
|
+
&& authoritativeEntry?.deleted === true
|
|
258
|
+
&& authoritativeEntry.operationId === expectedOperationId;
|
|
259
|
+
if (!authoritativeManifest || !committed) throw saveError;
|
|
260
|
+
this.onManifest(authoritativeManifest);
|
|
261
|
+
this.onRecoveredCommit({ assetId, manifest: authoritativeManifest, entry: authoritativeEntry || null, deletion: true });
|
|
262
|
+
return { deleted: true, manifest: authoritativeManifest, previousPath: previous?.path, recoveredCommit: true };
|
|
263
|
+
}
|
|
264
|
+
this.onManifest(nextManifest);
|
|
265
|
+
|
|
266
|
+
// 매니페스트에서만 제거한다. 파일은 남겨 복사본의 참조를 지킨다.
|
|
267
|
+
return { deleted: true, manifest: nextManifest, previousPath: previous?.path };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
AssetRepository,
|
|
273
|
+
defaultContentHash,
|
|
274
|
+
manifestReferencesPath,
|
|
275
|
+
buildAssetRestorePlan,
|
|
276
|
+
restoreAssetStore
|
|
277
|
+
};
|
|
278
|
+
});
|
package/config.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
(function (globalScope, factory) {
|
|
2
|
+
const config = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = config;
|
|
4
|
+
if (globalScope) globalScope.SOMI_CONFIG = config;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
supabaseUrl: "https://tlyioijsopxeegzfjlqe.supabase.co",
|
|
10
|
+
supabaseKey: "sb_publishable_AdEHgXPGJ2gKGVAjb7RYSg_YzUuT6jB",
|
|
11
|
+
assetBucket: "ssobig-writer-assets",
|
|
12
|
+
staffDomain: "ssobig.com"
|
|
13
|
+
});
|
|
14
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ssobig/writer-cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Official agent CLI for SSOBIG WRITER",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/neofrigate/writer-ssobig.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://writer.ssobig.com/",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/neofrigate/writer-ssobig/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"ssobig-writer": "tools/writer-cli/bin/ssobig-writer.cjs",
|
|
17
|
+
"ssobig-writer-daemon": "tools/writer-cli/bin/ssobig-writer-daemon.cjs"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.13.0"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@supabase/supabase-js": "2.110.9"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
(function (globalScope, factory) {
|
|
2
|
+
const runtime = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = runtime;
|
|
4
|
+
if (globalScope) globalScope.SomiProjectRuntime = runtime;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
10
|
+
const PROJECT_STORAGE_NAMESPACE_PATTERN = /^project-[0-9a-f]{32}-$/;
|
|
11
|
+
|
|
12
|
+
const ENGINES = Object.freeze({
|
|
13
|
+
"mystery-v1": Object.freeze({
|
|
14
|
+
id: "mystery-v1",
|
|
15
|
+
frameTarget: "./templates/mystery-v1.html",
|
|
16
|
+
passesSlug: true
|
|
17
|
+
})
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
function normalizeSlug(value) {
|
|
22
|
+
return String(value || "").trim().toLowerCase().replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function projectStorageNamespace(projectId) {
|
|
26
|
+
const compactId = String(projectId || "").trim().toLowerCase().replace(/-/g, "");
|
|
27
|
+
if (!/^[0-9a-f]{32}$/.test(compactId)) throw new Error("유효한 작품 UUID가 필요합니다.");
|
|
28
|
+
return `project-${compactId}-`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function projectRecordError(message, field = "") {
|
|
32
|
+
const error = new Error(message);
|
|
33
|
+
error.code = "SOMI_INVALID_PROJECT_RECORD";
|
|
34
|
+
error.field = field;
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveStrictProject(project = {}) {
|
|
39
|
+
const id = String(project.id || "").trim().toLowerCase();
|
|
40
|
+
const rawSlug = String(project.slug || "").trim();
|
|
41
|
+
const slug = normalizeSlug(rawSlug);
|
|
42
|
+
const engine = String(project.engine_key || project.engineKey || "").trim();
|
|
43
|
+
const rawStorageNamespace = String(project.storage_namespace || project.storageNamespace || "").trim();
|
|
44
|
+
const storageNamespace = rawStorageNamespace.toLowerCase();
|
|
45
|
+
|
|
46
|
+
if (!UUID_PATTERN.test(id)) throw projectRecordError("신규 작품 레코드에 유효한 id UUID가 필요합니다.", "id");
|
|
47
|
+
if (!SLUG_PATTERN.test(rawSlug) || rawSlug !== slug) throw projectRecordError("작품 slug 형식이 올바르지 않습니다.", "slug");
|
|
48
|
+
if (engine !== "mystery-v1") throw projectRecordError(`지원하지 않는 Component Writer engine입니다: ${engine || "(빈 값)"}`, "engine_key");
|
|
49
|
+
if (rawStorageNamespace !== storageNamespace || !PROJECT_STORAGE_NAMESPACE_PATTERN.test(storageNamespace)) {
|
|
50
|
+
throw projectRecordError("신규 작품의 storage_namespace는 UUID 기반 형식이어야 합니다.", "storage_namespace");
|
|
51
|
+
}
|
|
52
|
+
if (storageNamespace !== projectStorageNamespace(id)) {
|
|
53
|
+
throw projectRecordError("작품 id와 storage_namespace가 일치하지 않습니다.", "storage_namespace");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const engineDefinition = ENGINES[engine];
|
|
57
|
+
return Object.freeze({
|
|
58
|
+
slug,
|
|
59
|
+
storageNamespace,
|
|
60
|
+
engine,
|
|
61
|
+
route: `/${slug}`,
|
|
62
|
+
frameTarget: engineDefinition.frameTarget,
|
|
63
|
+
passesSlug: engineDefinition.passesSlug
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseProjectRecord(project = {}, options = {}) {
|
|
68
|
+
if (!project || typeof project !== "object" || Array.isArray(project)) {
|
|
69
|
+
throw projectRecordError("작품 레코드는 객체여야 합니다.");
|
|
70
|
+
}
|
|
71
|
+
const mode = String(options.mode || "strict");
|
|
72
|
+
if (!["auto", "strict"].includes(mode)) throw projectRecordError("작품 해석은 strict mode만 지원합니다.");
|
|
73
|
+
return resolveStrictProject(project);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveProject(project = {}) {
|
|
77
|
+
return parseProjectRecord(project, { mode: "strict" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function browserRoute(runtime = {}, locationLike = {}) {
|
|
81
|
+
const route = String(runtime.route || "");
|
|
82
|
+
const slug = String(runtime.slug || "");
|
|
83
|
+
const hostname = String(locationLike.hostname || "").trim().toLowerCase();
|
|
84
|
+
const isLoopback = ["127.0.0.1", "localhost", "::1", "[::1]"].includes(hostname);
|
|
85
|
+
if (isLoopback && SLUG_PATTERN.test(slug)) {
|
|
86
|
+
return `/workbench.html?slug=${encodeURIComponent(slug)}`;
|
|
87
|
+
}
|
|
88
|
+
return route;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
ENGINES,
|
|
93
|
+
SLUG_PATTERN,
|
|
94
|
+
UUID_PATTERN,
|
|
95
|
+
PROJECT_STORAGE_NAMESPACE_PATTERN,
|
|
96
|
+
normalizeSlug,
|
|
97
|
+
projectStorageNamespace,
|
|
98
|
+
parseProjectRecord,
|
|
99
|
+
resolveProject,
|
|
100
|
+
browserRoute
|
|
101
|
+
});
|
|
102
|
+
});
|
package/storage-path.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
(function (globalScope, factory) {
|
|
2
|
+
const storagePath = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = storagePath;
|
|
4
|
+
if (globalScope) globalScope.SomiStoragePath = storagePath;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
function safePathSegment(value, fallback = "asset") {
|
|
9
|
+
const normalized = String(value || "")
|
|
10
|
+
.normalize("NFKD")
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9.@_-]+/g, "-")
|
|
13
|
+
.replace(/-{2,}/g, "-")
|
|
14
|
+
.replace(/^[.-]+|[.-]+$/g, "");
|
|
15
|
+
const safeFallback = String(fallback || "asset").replace(/[^a-zA-Z0-9@_-]+/g, "-") || "asset";
|
|
16
|
+
return normalized && normalized !== "." && normalized !== ".." ? normalized : safeFallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeFileName(value, fallback = "asset.bin") {
|
|
20
|
+
const candidate = String(value || "").split(/[\\/]/).pop() || "";
|
|
21
|
+
const dotIndex = candidate.lastIndexOf(".");
|
|
22
|
+
const hasExtension = dotIndex > 0 && dotIndex < candidate.length - 1;
|
|
23
|
+
const stem = safePathSegment(hasExtension ? candidate.slice(0, dotIndex) : candidate, "asset");
|
|
24
|
+
const extension = hasExtension ? safePathSegment(candidate.slice(dotIndex + 1), "") : "";
|
|
25
|
+
const result = extension ? `${stem}.${extension}` : stem;
|
|
26
|
+
return result || safePathSegment(fallback, "asset.bin");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertStorageObjectPath(path) {
|
|
30
|
+
const candidate = String(path || "").trim().replace(/^\/+|\/+$/g, "");
|
|
31
|
+
if (!candidate) throw new Error("Storage 경로가 비어 있습니다.");
|
|
32
|
+
const segments = candidate.split("/");
|
|
33
|
+
if (segments.some(segment => !segment || segment === "." || segment === ".." || /[\\\u0000-\u001f]/.test(segment))) {
|
|
34
|
+
throw new Error("Storage 경로에 안전하지 않은 segment가 있습니다.");
|
|
35
|
+
}
|
|
36
|
+
return segments.join("/");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function joinStoragePath(...segments) {
|
|
40
|
+
return assertStorageObjectPath(segments.map(segment => String(segment || "").replace(/^\/+|\/+$/g, "")).join("/"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function encodedStoragePath(path) {
|
|
44
|
+
return assertStorageObjectPath(path).split("/").map(segment => encodeURIComponent(segment)).join("/");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function publicObjectMarker(bucketName) {
|
|
48
|
+
return `/storage/v1/object/public/${encodeURIComponent(String(bucketName || ""))}/`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assetSource(value, bucketName) {
|
|
52
|
+
let rawCandidate = value;
|
|
53
|
+
if (value && typeof value === "object") {
|
|
54
|
+
rawCandidate = [value.path, value.publicUrl, value.url]
|
|
55
|
+
.find(candidate => typeof candidate === "string" && candidate.trim()) || "";
|
|
56
|
+
}
|
|
57
|
+
if (typeof rawCandidate !== "string") return null;
|
|
58
|
+
const candidate = rawCandidate.trim();
|
|
59
|
+
if (!candidate) return null;
|
|
60
|
+
if (!/^https?:\/\//i.test(candidate)) {
|
|
61
|
+
try {
|
|
62
|
+
const path = assertStorageObjectPath(candidate);
|
|
63
|
+
return Object.freeze({ key: `path:${path}`, path, url: "", original: candidate });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL(candidate);
|
|
70
|
+
const marker = publicObjectMarker(bucketName);
|
|
71
|
+
if (url.pathname.startsWith(marker)) {
|
|
72
|
+
const path = assertStorageObjectPath(decodeURIComponent(url.pathname.slice(marker.length)));
|
|
73
|
+
return Object.freeze({ key: `path:${path}`, path, url: "", original: candidate });
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze({ key: `url:${candidate}`, path: "", url: candidate, original: candidate });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function copiedAssetPath(source, sourceRoot, targetRoot, assetId, metadata = {}) {
|
|
82
|
+
const safeSourceRoot = assertStorageObjectPath(sourceRoot);
|
|
83
|
+
const safeTargetRoot = assertStorageObjectPath(targetRoot);
|
|
84
|
+
const sourcePath = String(source?.path || "");
|
|
85
|
+
if (sourcePath.startsWith(`${safeSourceRoot}/`)) {
|
|
86
|
+
return joinStoragePath(safeTargetRoot, sourcePath.slice(safeSourceRoot.length + 1));
|
|
87
|
+
}
|
|
88
|
+
const pathname = sourcePath || (() => {
|
|
89
|
+
try { return decodeURIComponent(new URL(source?.url || "").pathname); }
|
|
90
|
+
catch (error) { return ""; }
|
|
91
|
+
})();
|
|
92
|
+
const folderMatch = pathname.match(/\/(image|json)\/(.+)$/i);
|
|
93
|
+
if (folderMatch) return joinStoragePath(safeTargetRoot, folderMatch[1].toLowerCase(), assertStorageObjectPath(folderMatch[2]));
|
|
94
|
+
const rawFileName = pathname.split("/").pop() || String(metadata.name || `${safePathSegment(assetId)}.bin`);
|
|
95
|
+
const fileName = safeFileName(rawFileName, `${safePathSegment(assetId)}.bin`);
|
|
96
|
+
const isJson = metadata.type === "application/json" || /\.json$/i.test(fileName);
|
|
97
|
+
return joinStoragePath(safeTargetRoot, isJson ? "json" : "image", `${safePathSegment(assetId)}-${fileName}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
safePathSegment,
|
|
102
|
+
safeFileName,
|
|
103
|
+
assertStorageObjectPath,
|
|
104
|
+
joinStoragePath,
|
|
105
|
+
encodedStoragePath,
|
|
106
|
+
publicObjectMarker,
|
|
107
|
+
assetSource,
|
|
108
|
+
copiedAssetPath
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory(root);
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterWorkbenchAuthoringViewPreference = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function (root) {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = "somi-workbench-authoring-view";
|
|
9
|
+
const DEFAULT_VIEW = "preview";
|
|
10
|
+
const VIEWS = Object.freeze(["input", "preview"]);
|
|
11
|
+
|
|
12
|
+
function normalize(value) {
|
|
13
|
+
return VIEWS.includes(value) ? value : DEFAULT_VIEW;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function deviceStorage() {
|
|
17
|
+
try { return root?.localStorage || null; }
|
|
18
|
+
catch (_) { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function read(storage = deviceStorage()) {
|
|
22
|
+
try { return normalize(storage?.getItem(STORAGE_KEY)); }
|
|
23
|
+
catch (_) { return DEFAULT_VIEW; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function write(view, storage = deviceStorage()) {
|
|
27
|
+
const normalized = normalize(view);
|
|
28
|
+
try { storage?.setItem(STORAGE_KEY, normalized); }
|
|
29
|
+
catch (_) { /* 기기 저장소를 사용할 수 없어도 현재 화면 전환은 유지한다. */ }
|
|
30
|
+
return normalized;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return Object.freeze({ STORAGE_KEY, DEFAULT_VIEW, VIEWS, normalize, read, write });
|
|
34
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterWorkbenchCharacterPerspectivePreview = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const CARD_ORDER = Object.freeze([
|
|
9
|
+
Object.freeze({ key: "progress", label: "진행", templateId: "ssobig.progress" }),
|
|
10
|
+
Object.freeze({ key: "common", label: "공통정보", templateId: "ssobig.common" }),
|
|
11
|
+
Object.freeze({ key: "character", label: "캐릭터", templateId: "ssobig.character" })
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function characters(instance, data) {
|
|
15
|
+
const records = data?.characters && typeof data.characters === "object" ? data.characters : {};
|
|
16
|
+
const names = data?.names && typeof data.names === "object" ? data.names : {};
|
|
17
|
+
const deleted = new Set(Array.isArray(data?.deletedColumns) ? data.deletedColumns : []);
|
|
18
|
+
const order = Array.isArray(data?.order) ? data.order : Object.keys(records);
|
|
19
|
+
return order
|
|
20
|
+
.filter(id => !deleted.has(id) && Object.hasOwn(records, id) && records[id]?.isPlayable === true)
|
|
21
|
+
.map(id => Object.freeze({ id, name: String(names[id] || id), value: records[id] }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function selectCharacter(instance, data, requestedId) {
|
|
25
|
+
const entries = characters(instance, data);
|
|
26
|
+
return entries.find(entry => entry.id === requestedId) || entries[0] || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function characterData(data, characterId) {
|
|
30
|
+
const source = data && typeof data === "object" ? data : {};
|
|
31
|
+
const value = source.characters?.[characterId];
|
|
32
|
+
if (!value) return null;
|
|
33
|
+
return {
|
|
34
|
+
...source,
|
|
35
|
+
characters: { [characterId]: value },
|
|
36
|
+
names: { [characterId]: source.names?.[characterId] || characterId },
|
|
37
|
+
order: [characterId],
|
|
38
|
+
deletedColumns: []
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function build(components, dataFor, requestedCharacterId) {
|
|
43
|
+
const active = Array.isArray(components) ? components : [];
|
|
44
|
+
const byTemplate = new Map(active.map(instance => [instance.templateId, instance]));
|
|
45
|
+
const characterInstance = byTemplate.get("ssobig.character") || null;
|
|
46
|
+
const sourceCharacterData = characterInstance ? dataFor(characterInstance) : null;
|
|
47
|
+
const availableCharacters = characters(characterInstance, sourceCharacterData);
|
|
48
|
+
const selectedCharacter = selectCharacter(characterInstance, sourceCharacterData, requestedCharacterId);
|
|
49
|
+
const cards = CARD_ORDER.flatMap(definition => {
|
|
50
|
+
const instance = byTemplate.get(definition.templateId);
|
|
51
|
+
if (!instance) return [];
|
|
52
|
+
const data = definition.key === "character"
|
|
53
|
+
? characterData(sourceCharacterData, selectedCharacter?.id)
|
|
54
|
+
: dataFor(instance);
|
|
55
|
+
return data ? [{ ...definition, instance, data }] : [];
|
|
56
|
+
});
|
|
57
|
+
return Object.freeze({ characters: availableCharacters, selectedCharacter, cards });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return Object.freeze({ CARD_ORDER, characters, selectCharacter, characterData, build });
|
|
61
|
+
});
|