@ectplsm/relic 0.5.0 → 0.5.1
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 +2 -0
- package/dist/adapters/local/local-engram-repository.d.ts +3 -1
- package/dist/adapters/local/local-engram-repository.js +25 -0
- package/dist/adapters/mikoshi/client.d.ts +3 -1
- package/dist/adapters/mikoshi/client.js +33 -3
- package/dist/core/entities/engram.d.ts +28 -0
- package/dist/core/entities/engram.js +4 -0
- package/dist/core/ports/engram-repository.d.ts +17 -1
- package/dist/core/ports/mikoshi.d.ts +24 -0
- package/dist/core/ports/mikoshi.js +17 -0
- package/dist/core/sync/avatar.d.ts +121 -0
- package/dist/core/sync/avatar.js +352 -0
- package/dist/core/usecases/mikoshi-download.d.ts +5 -0
- package/dist/core/usecases/mikoshi-download.js +17 -1
- package/dist/core/usecases/mikoshi-pull.d.ts +26 -0
- package/dist/core/usecases/mikoshi-pull.js +47 -3
- package/dist/core/usecases/mikoshi-push.d.ts +129 -2
- package/dist/core/usecases/mikoshi-push.js +316 -11
- package/dist/interfaces/cli/commands/mikoshi.js +150 -13
- package/package.json +1 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { isAbsolute, resolve, extname } from "node:path";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { lookup } from "node:dns/promises";
|
|
6
|
+
/**
|
|
7
|
+
* Avatar path / hash utilities — engram-avatar-upload プラン準拠
|
|
8
|
+
*
|
|
9
|
+
* IDENTITY.md 内の `- **Avatar:** <path>` を検出し、
|
|
10
|
+
* 絶対パス / Engram 相対パスの両方を解決する。
|
|
11
|
+
* Phase 4 以降は URL 値も parse するが、既存 pull 互換のため
|
|
12
|
+
* `parseAvatarPath()` は従来どおり path のみ返す。
|
|
13
|
+
*/
|
|
14
|
+
/** Avatar 行を検出する正規表現 */
|
|
15
|
+
const AVATAR_LINE_PATTERN = /^-\s+\*\*Avatar:\*\*\s*(.+)$/m;
|
|
16
|
+
/**
|
|
17
|
+
* 分割用の正規表現。
|
|
18
|
+
*
|
|
19
|
+
* group 1: `- **Avatar:** ` までの prefix(末尾の空白含む)
|
|
20
|
+
* group 2: Avatar の値部分(行末まで)
|
|
21
|
+
*/
|
|
22
|
+
const AVATAR_LINE_PATTERN_SPLIT = /^(-\s+\*\*Avatar:\*\*\s*)(.+)$/m;
|
|
23
|
+
/** クライアントが許可する MIME タイプ */
|
|
24
|
+
const SUPPORTED_MIME = {
|
|
25
|
+
".jpg": "image/jpeg",
|
|
26
|
+
".jpeg": "image/jpeg",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".webp": "image/webp",
|
|
29
|
+
};
|
|
30
|
+
const SUPPORTED_MIME_SET = new Set(Object.values(SUPPORTED_MIME));
|
|
31
|
+
const MAX_REDIRECTS = 3;
|
|
32
|
+
/**
|
|
33
|
+
* IDENTITY.md から Avatar フィールドの生の値を抽出する。
|
|
34
|
+
*
|
|
35
|
+
* - マッチしなければ null
|
|
36
|
+
* - 値が URL(http://, https://)の場合は Mikoshi 管理外として null
|
|
37
|
+
* - 空白のみの値も null として扱う
|
|
38
|
+
*/
|
|
39
|
+
export function parseAvatarPath(identity) {
|
|
40
|
+
const ref = parseAvatarRef(identity);
|
|
41
|
+
return ref?.kind === "path" ? ref.value : null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* IDENTITY.md から Avatar フィールドの参照先を抽出する。
|
|
45
|
+
*
|
|
46
|
+
* - マッチしなければ null
|
|
47
|
+
* - 空白のみの値も null
|
|
48
|
+
* - `http(s)://` は URL として返す
|
|
49
|
+
* - それ以外は path として返す
|
|
50
|
+
*/
|
|
51
|
+
export function parseAvatarRef(identity) {
|
|
52
|
+
const match = identity.match(AVATAR_LINE_PATTERN);
|
|
53
|
+
if (!match)
|
|
54
|
+
return null;
|
|
55
|
+
const raw = match[1].trim();
|
|
56
|
+
if (raw.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
59
|
+
return { kind: "url", value: raw };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "path", value: raw };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Avatar パスを絶対パスに解決する。
|
|
65
|
+
*
|
|
66
|
+
* - 絶対パス → そのまま
|
|
67
|
+
* - 相対パス → Engram ディレクトリ基準で resolve
|
|
68
|
+
*/
|
|
69
|
+
export function resolveAvatarPath(rawPath, engramDir) {
|
|
70
|
+
return isAbsolute(rawPath) ? rawPath : resolve(engramDir, rawPath);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* ファイル内容の SHA-256 ハッシュを計算する。
|
|
74
|
+
*
|
|
75
|
+
* 出力形式: "sha256:<64 lowercase hex>"
|
|
76
|
+
*
|
|
77
|
+
* ストリーム処理なので 2MB 制限を超える巨大ファイルでも OOM しない。
|
|
78
|
+
*/
|
|
79
|
+
export function computeAvatarHash(filePath) {
|
|
80
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
81
|
+
const hash = createHash("sha256");
|
|
82
|
+
const stream = createReadStream(filePath);
|
|
83
|
+
stream.on("error", rejectPromise);
|
|
84
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
85
|
+
stream.on("end", () => resolvePromise(`sha256:${hash.digest("hex")}`));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
export function computeAvatarHashFromBytes(bytes) {
|
|
89
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 拡張子から MIME タイプを判定する。
|
|
93
|
+
*
|
|
94
|
+
* 許可フォーマット: JPEG, PNG, WebP
|
|
95
|
+
* 対応外なら null(呼び出し側でエラーにする)
|
|
96
|
+
*
|
|
97
|
+
* バイナリ判定は Mikoshi 側の sharp に任せる方針。
|
|
98
|
+
* クライアントは軽量な事前バリデーションのみ。
|
|
99
|
+
*/
|
|
100
|
+
export function detectAvatarMimeType(filePath) {
|
|
101
|
+
const ext = extname(filePath).toLowerCase();
|
|
102
|
+
return SUPPORTED_MIME[ext] ?? null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* レスポンス header / magic number の両方から MIME を判定する。
|
|
106
|
+
*
|
|
107
|
+
* 両方あって不一致なら null を返す。
|
|
108
|
+
*/
|
|
109
|
+
export function detectAvatarMimeTypeFromBytes(bytes, headerMimeType) {
|
|
110
|
+
const normalizedHeader = normalizeHeaderMimeType(headerMimeType);
|
|
111
|
+
const magicMime = detectAvatarMimeTypeByMagic(bytes);
|
|
112
|
+
if (normalizedHeader && magicMime && normalizedHeader !== magicMime) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return magicMime ?? normalizedHeader ?? null;
|
|
116
|
+
}
|
|
117
|
+
export function isHttpsUrl(raw) {
|
|
118
|
+
try {
|
|
119
|
+
return new URL(raw).protocol === "https:";
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* ホストが private / loopback / link-local なら true。
|
|
127
|
+
*
|
|
128
|
+
* hostname の場合は DNS lookup して、返ってきた全アドレスを検査する。
|
|
129
|
+
* DNS rebinding の完全対策ではない。事故防止用。
|
|
130
|
+
*/
|
|
131
|
+
export async function isPrivateHost(url) {
|
|
132
|
+
const hostname = url.hostname;
|
|
133
|
+
if (!hostname)
|
|
134
|
+
return true;
|
|
135
|
+
if (isIpAddressPrivate(hostname)) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
if (isIP(hostname) !== 0) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const addresses = await lookup(hostname, { all: true });
|
|
143
|
+
if (addresses.length === 0)
|
|
144
|
+
return true;
|
|
145
|
+
return addresses.some((entry) => isIpAddressPrivate(entry.address));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export async function fetchAvatarFromUrl(url, maxBytes, timeoutMs) {
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
154
|
+
try {
|
|
155
|
+
let currentUrl = url;
|
|
156
|
+
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
|
|
157
|
+
if (!isHttpsUrl(currentUrl)) {
|
|
158
|
+
throw new Error(`Avatar URL must use HTTPS: ${currentUrl}`);
|
|
159
|
+
}
|
|
160
|
+
const parsed = new URL(currentUrl);
|
|
161
|
+
if (await isPrivateHost(parsed)) {
|
|
162
|
+
throw new Error(`Avatar URL points to a private host: ${currentUrl}`);
|
|
163
|
+
}
|
|
164
|
+
const response = await fetch(parsed, {
|
|
165
|
+
method: "GET",
|
|
166
|
+
redirect: "manual",
|
|
167
|
+
signal: controller.signal,
|
|
168
|
+
headers: {
|
|
169
|
+
Accept: "image/jpeg, image/png, image/webp",
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
if (isRedirectResponse(response.status)) {
|
|
173
|
+
const location = response.headers.get("location");
|
|
174
|
+
if (!location) {
|
|
175
|
+
throw new Error(`Avatar URL redirected without Location header: ${currentUrl}`);
|
|
176
|
+
}
|
|
177
|
+
if (redirectCount === MAX_REDIRECTS) {
|
|
178
|
+
throw new Error(`Avatar URL exceeded redirect limit (${MAX_REDIRECTS})`);
|
|
179
|
+
}
|
|
180
|
+
currentUrl = new URL(location, parsed).toString();
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
throw new Error(`Avatar URL fetch failed with HTTP ${response.status}`);
|
|
185
|
+
}
|
|
186
|
+
const contentLength = response.headers.get("content-length");
|
|
187
|
+
if (contentLength) {
|
|
188
|
+
const parsedLength = Number.parseInt(contentLength, 10);
|
|
189
|
+
if (Number.isFinite(parsedLength) && parsedLength > maxBytes) {
|
|
190
|
+
throw new Error(`Avatar URL content-length exceeds ${maxBytes} bytes (actual: ${parsedLength})`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const bytes = await readResponseBody(response, maxBytes);
|
|
194
|
+
const mimeType = detectAvatarMimeTypeFromBytes(bytes, response.headers.get("content-type"));
|
|
195
|
+
if (!mimeType) {
|
|
196
|
+
throw new Error("Avatar URL returned an unsupported image format");
|
|
197
|
+
}
|
|
198
|
+
return { bytes, mimeType, finalUrl: currentUrl };
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`Avatar URL exceeded redirect limit (${MAX_REDIRECTS})`);
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* IDENTITY.md を Avatar 行の値を境に前後分離する。
|
|
208
|
+
*
|
|
209
|
+
* 書き換えと drift 検出の両方で使う基本操作。
|
|
210
|
+
*/
|
|
211
|
+
export function splitAroundAvatar(identity) {
|
|
212
|
+
const match = AVATAR_LINE_PATTERN_SPLIT.exec(identity);
|
|
213
|
+
if (!match) {
|
|
214
|
+
return { before: identity, rawValue: null, after: "" };
|
|
215
|
+
}
|
|
216
|
+
const prefix = match[1];
|
|
217
|
+
const value = match[2];
|
|
218
|
+
const valueStart = match.index + prefix.length;
|
|
219
|
+
const valueEnd = valueStart + value.length;
|
|
220
|
+
return {
|
|
221
|
+
before: identity.slice(0, valueStart),
|
|
222
|
+
rawValue: value,
|
|
223
|
+
after: identity.slice(valueEnd),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* IDENTITY.md の Avatar 行の値だけを差し替えて新しい文字列を返す。
|
|
228
|
+
*
|
|
229
|
+
* Avatar 行が存在しない場合は元の identity をそのまま返す
|
|
230
|
+
* (新しい Avatar 行は挿入しない — 保守的な挙動)。
|
|
231
|
+
*/
|
|
232
|
+
export function rewriteAvatarValue(identity, newValue) {
|
|
233
|
+
const split = splitAroundAvatar(identity);
|
|
234
|
+
if (split.rawValue === null)
|
|
235
|
+
return identity;
|
|
236
|
+
return split.before + newValue + split.after;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 2つの IDENTITY.md の差分が Avatar 行の値だけかどうかを判定する。
|
|
240
|
+
*
|
|
241
|
+
* 判定条件:
|
|
242
|
+
* - 両方に Avatar 行が存在する
|
|
243
|
+
* - Avatar 行の前後 (`before` / `after`) が完全一致
|
|
244
|
+
* - Avatar 行の値だけ異なる
|
|
245
|
+
*
|
|
246
|
+
* PATH 前後の format 揺れ(改行数・空白数など)は
|
|
247
|
+
* `before` / `after` の文字列一致にそのまま反映されるため、
|
|
248
|
+
* 揺れがあれば avatar-only drift とは判定されない(false positive を避ける)。
|
|
249
|
+
*/
|
|
250
|
+
export function isAvatarOnlyDrift(local, remote) {
|
|
251
|
+
const a = splitAroundAvatar(local);
|
|
252
|
+
const b = splitAroundAvatar(remote);
|
|
253
|
+
if (a.rawValue === null || b.rawValue === null) {
|
|
254
|
+
return { drift: false, localValue: null, remoteValue: null };
|
|
255
|
+
}
|
|
256
|
+
if (a.before === b.before && a.after === b.after && a.rawValue !== b.rawValue) {
|
|
257
|
+
return { drift: true, localValue: a.rawValue, remoteValue: b.rawValue };
|
|
258
|
+
}
|
|
259
|
+
return { drift: false, localValue: null, remoteValue: null };
|
|
260
|
+
}
|
|
261
|
+
function normalizeHeaderMimeType(headerMimeType) {
|
|
262
|
+
if (!headerMimeType)
|
|
263
|
+
return null;
|
|
264
|
+
const mimeType = headerMimeType.split(";")[0]?.trim().toLowerCase();
|
|
265
|
+
return mimeType && SUPPORTED_MIME_SET.has(mimeType) ? mimeType : null;
|
|
266
|
+
}
|
|
267
|
+
function detectAvatarMimeTypeByMagic(bytes) {
|
|
268
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
269
|
+
return "image/jpeg";
|
|
270
|
+
}
|
|
271
|
+
if (bytes.length >= 8 &&
|
|
272
|
+
bytes[0] === 0x89 &&
|
|
273
|
+
bytes[1] === 0x50 &&
|
|
274
|
+
bytes[2] === 0x4e &&
|
|
275
|
+
bytes[3] === 0x47 &&
|
|
276
|
+
bytes[4] === 0x0d &&
|
|
277
|
+
bytes[5] === 0x0a &&
|
|
278
|
+
bytes[6] === 0x1a &&
|
|
279
|
+
bytes[7] === 0x0a) {
|
|
280
|
+
return "image/png";
|
|
281
|
+
}
|
|
282
|
+
if (bytes.length >= 12 &&
|
|
283
|
+
bytes[0] === 0x52 &&
|
|
284
|
+
bytes[1] === 0x49 &&
|
|
285
|
+
bytes[2] === 0x46 &&
|
|
286
|
+
bytes[3] === 0x46 &&
|
|
287
|
+
bytes[8] === 0x57 &&
|
|
288
|
+
bytes[9] === 0x45 &&
|
|
289
|
+
bytes[10] === 0x42 &&
|
|
290
|
+
bytes[11] === 0x50) {
|
|
291
|
+
return "image/webp";
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
async function readResponseBody(response, maxBytes) {
|
|
296
|
+
const reader = response.body?.getReader();
|
|
297
|
+
if (!reader) {
|
|
298
|
+
throw new Error("Avatar URL response body is empty");
|
|
299
|
+
}
|
|
300
|
+
const chunks = [];
|
|
301
|
+
let totalBytes = 0;
|
|
302
|
+
while (true) {
|
|
303
|
+
const { done, value } = await reader.read();
|
|
304
|
+
if (done)
|
|
305
|
+
break;
|
|
306
|
+
if (!value)
|
|
307
|
+
continue;
|
|
308
|
+
totalBytes += value.byteLength;
|
|
309
|
+
if (totalBytes > maxBytes) {
|
|
310
|
+
throw new Error(`Avatar URL body exceeds ${maxBytes} bytes`);
|
|
311
|
+
}
|
|
312
|
+
chunks.push(value);
|
|
313
|
+
}
|
|
314
|
+
return Buffer.concat(chunks);
|
|
315
|
+
}
|
|
316
|
+
function isRedirectResponse(status) {
|
|
317
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
318
|
+
}
|
|
319
|
+
function isIpAddressPrivate(value) {
|
|
320
|
+
const family = isIP(value);
|
|
321
|
+
if (family === 4)
|
|
322
|
+
return isPrivateIpv4(value);
|
|
323
|
+
if (family === 6)
|
|
324
|
+
return isPrivateIpv6(value);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
function isPrivateIpv4(value) {
|
|
328
|
+
const parts = value.split(".").map((part) => Number.parseInt(part, 10));
|
|
329
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
const [a, b] = parts;
|
|
333
|
+
if (a === 10)
|
|
334
|
+
return true;
|
|
335
|
+
if (a === 127)
|
|
336
|
+
return true;
|
|
337
|
+
if (a === 169 && b === 254)
|
|
338
|
+
return true;
|
|
339
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
340
|
+
return true;
|
|
341
|
+
if (a === 192 && b === 168)
|
|
342
|
+
return true;
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
function isPrivateIpv6(value) {
|
|
346
|
+
const normalized = value.toLowerCase();
|
|
347
|
+
if (normalized === "::1")
|
|
348
|
+
return true;
|
|
349
|
+
if (normalized.startsWith("fc") || normalized.startsWith("fd"))
|
|
350
|
+
return true;
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
@@ -4,6 +4,11 @@ export interface MikoshiDownloadResult {
|
|
|
4
4
|
engramId: string;
|
|
5
5
|
engramName: string;
|
|
6
6
|
cloudEngramId: string;
|
|
7
|
+
/**
|
|
8
|
+
* リモートに `avatarUrl` があり、ローカルに書き込んだ IDENTITY.md の
|
|
9
|
+
* Avatar 行をその URL で書き換えた場合にセットされる。
|
|
10
|
+
*/
|
|
11
|
+
rewrittenAvatarUrl?: string;
|
|
7
12
|
}
|
|
8
13
|
export declare class MikoshiDownloadAlreadyExistsError extends Error {
|
|
9
14
|
readonly engramId: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rewriteAvatarValue } from "../sync/avatar.js";
|
|
1
2
|
// ---------------------------------------------------------------------------
|
|
2
3
|
// Errors
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
@@ -55,6 +56,20 @@ export class MikoshiDownload {
|
|
|
55
56
|
if (!soul || !identity) {
|
|
56
57
|
throw new MikoshiDownloadPersonaMissingError(cloudEngram.id);
|
|
57
58
|
}
|
|
59
|
+
// Avatar URL fallback (Phase 3):
|
|
60
|
+
// リモートに avatarUrl があれば、IDENTITY.md の Avatar 行をその URL に
|
|
61
|
+
// 書き換えてから保存する。新規ダウンロード時はローカルに画像が無いので
|
|
62
|
+
// URL 版のほうが Construct / Shell で表示できて実用的。
|
|
63
|
+
// Avatar 行が無ければ rewriteAvatarValue は no-op なので何も起こらない。
|
|
64
|
+
let identityToWrite = identity;
|
|
65
|
+
let rewrittenAvatarUrl;
|
|
66
|
+
if (detail.avatarUrl) {
|
|
67
|
+
const next = rewriteAvatarValue(identity, detail.avatarUrl);
|
|
68
|
+
if (next !== identity) {
|
|
69
|
+
identityToWrite = next;
|
|
70
|
+
rewrittenAvatarUrl = detail.avatarUrl;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
58
73
|
// 4. ローカルに新規作成
|
|
59
74
|
const now = new Date().toISOString();
|
|
60
75
|
await this.localRepo.save({
|
|
@@ -68,13 +83,14 @@ export class MikoshiDownload {
|
|
|
68
83
|
},
|
|
69
84
|
files: {
|
|
70
85
|
soul,
|
|
71
|
-
identity,
|
|
86
|
+
identity: identityToWrite,
|
|
72
87
|
},
|
|
73
88
|
});
|
|
74
89
|
return {
|
|
75
90
|
engramId,
|
|
76
91
|
engramName: detail.name,
|
|
77
92
|
cloudEngramId: cloudEngram.id,
|
|
93
|
+
rewrittenAvatarUrl,
|
|
78
94
|
};
|
|
79
95
|
}
|
|
80
96
|
}
|
|
@@ -5,7 +5,21 @@ export interface MikoshiPullDiff {
|
|
|
5
5
|
soulDiffers: boolean;
|
|
6
6
|
identityDiffers: boolean;
|
|
7
7
|
remoteSoul: string;
|
|
8
|
+
/**
|
|
9
|
+
* 書き込み対象の IDENTITY.md。
|
|
10
|
+
*
|
|
11
|
+
* リモート IDENTITY.md の Avatar 行を
|
|
12
|
+
* `avatarUrl` に書き換えた後の最終形で保持する。
|
|
13
|
+
* 書き換えが発生しなかった場合は remote 原文と同一。
|
|
14
|
+
*/
|
|
8
15
|
remoteIdentity: string;
|
|
16
|
+
/**
|
|
17
|
+
* Avatar 行を Mikoshi 上の URL に書き換えたかどうか。
|
|
18
|
+
*
|
|
19
|
+
* 書き換えが発生したときのみ URL 文字列がセットされ、
|
|
20
|
+
* CLI が書き換え結果をユーザーへ通知するのに使う。
|
|
21
|
+
*/
|
|
22
|
+
rewrittenAvatarUrl?: string;
|
|
9
23
|
}
|
|
10
24
|
export interface MikoshiPullResult {
|
|
11
25
|
outcome: MikoshiPullOutcome;
|
|
@@ -41,4 +55,16 @@ export declare class MikoshiPull {
|
|
|
41
55
|
result: MikoshiPullResult;
|
|
42
56
|
apply?: () => Promise<void>;
|
|
43
57
|
}>;
|
|
58
|
+
/**
|
|
59
|
+
* apply 対象となる IDENTITY.md を決定する。
|
|
60
|
+
*
|
|
61
|
+
* - リモートに `avatarUrl` が無い → remote をそのまま
|
|
62
|
+
* - ローカルに Avatar 行の path があり、そのファイルが存在する
|
|
63
|
+
* → ローカル画像が source of truth なので remote (path) を採用
|
|
64
|
+
* - それ以外(ローカル画像なし / ローカル値が URL / path だけあるがファイル欠如)
|
|
65
|
+
* → remote IDENTITY の Avatar 行を R2 URL に書き換え
|
|
66
|
+
*
|
|
67
|
+
* Avatar 行自体が無ければ rewrite は no-op で remote と等価になる。
|
|
68
|
+
*/
|
|
69
|
+
private resolveAppliedIdentity;
|
|
44
70
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { parseAvatarRef, resolveAvatarPath, rewriteAvatarValue, } from "../sync/avatar.js";
|
|
1
3
|
// ---------------------------------------------------------------------------
|
|
2
4
|
// Errors
|
|
3
5
|
// ---------------------------------------------------------------------------
|
|
@@ -58,9 +60,24 @@ export class MikoshiPull {
|
|
|
58
60
|
if (!remoteSoul || !remoteIdentity) {
|
|
59
61
|
throw new MikoshiPullPersonaMissingError(cloudEngram.id);
|
|
60
62
|
}
|
|
63
|
+
// Avatar URL fallback (Phase 3):
|
|
64
|
+
// ローカルに有効な avatar 画像があれば remote IDENTITY をそのまま採用。
|
|
65
|
+
// ローカル画像が無い / ローカル値が URL の場合で remote.avatarUrl があれば
|
|
66
|
+
// apply 対象の IDENTITY を URL 版に差し替える。Avatar 行自体が無ければ
|
|
67
|
+
// rewriteAvatarValue は no-op なので何も追加しない。
|
|
68
|
+
const engramDir = this.localRepo.getEngramPath(engramId);
|
|
69
|
+
const appliedIdentity = this.resolveAppliedIdentity({
|
|
70
|
+
remoteIdentity,
|
|
71
|
+
remoteAvatarUrl: detail.avatarUrl,
|
|
72
|
+
localIdentity: local.files.identity,
|
|
73
|
+
engramDir,
|
|
74
|
+
});
|
|
75
|
+
const rewrittenAvatarUrl = detail.avatarUrl && appliedIdentity !== remoteIdentity
|
|
76
|
+
? detail.avatarUrl
|
|
77
|
+
: undefined;
|
|
61
78
|
// 4. 差分計算 (末尾空白の差異は無視)
|
|
62
79
|
const soulDiffers = normalizeContent(local.files.soul) !== normalizeContent(remoteSoul);
|
|
63
|
-
const identityDiffers = normalizeContent(local.files.identity) !== normalizeContent(
|
|
80
|
+
const identityDiffers = normalizeContent(local.files.identity) !== normalizeContent(appliedIdentity);
|
|
64
81
|
if (!soulDiffers && !identityDiffers) {
|
|
65
82
|
return {
|
|
66
83
|
result: {
|
|
@@ -75,7 +92,8 @@ export class MikoshiPull {
|
|
|
75
92
|
soulDiffers,
|
|
76
93
|
identityDiffers,
|
|
77
94
|
remoteSoul,
|
|
78
|
-
remoteIdentity,
|
|
95
|
+
remoteIdentity: appliedIdentity,
|
|
96
|
+
rewrittenAvatarUrl,
|
|
79
97
|
};
|
|
80
98
|
// 5. apply クロージャ
|
|
81
99
|
const apply = async () => {
|
|
@@ -86,7 +104,7 @@ export class MikoshiPull {
|
|
|
86
104
|
if (soulDiffers)
|
|
87
105
|
updatedFiles.soul = remoteSoul;
|
|
88
106
|
if (identityDiffers)
|
|
89
|
-
updatedFiles.identity =
|
|
107
|
+
updatedFiles.identity = appliedIdentity;
|
|
90
108
|
await this.localRepo.save({
|
|
91
109
|
meta: { ...fresh.meta, updatedAt: new Date().toISOString() },
|
|
92
110
|
files: updatedFiles,
|
|
@@ -103,6 +121,32 @@ export class MikoshiPull {
|
|
|
103
121
|
apply,
|
|
104
122
|
};
|
|
105
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* apply 対象となる IDENTITY.md を決定する。
|
|
126
|
+
*
|
|
127
|
+
* - リモートに `avatarUrl` が無い → remote をそのまま
|
|
128
|
+
* - ローカルに Avatar 行の path があり、そのファイルが存在する
|
|
129
|
+
* → ローカル画像が source of truth なので remote (path) を採用
|
|
130
|
+
* - それ以外(ローカル画像なし / ローカル値が URL / path だけあるがファイル欠如)
|
|
131
|
+
* → remote IDENTITY の Avatar 行を R2 URL に書き換え
|
|
132
|
+
*
|
|
133
|
+
* Avatar 行自体が無ければ rewrite は no-op で remote と等価になる。
|
|
134
|
+
*/
|
|
135
|
+
resolveAppliedIdentity(args) {
|
|
136
|
+
const { remoteIdentity, remoteAvatarUrl, localIdentity, engramDir } = args;
|
|
137
|
+
if (!remoteAvatarUrl)
|
|
138
|
+
return remoteIdentity;
|
|
139
|
+
if (localIdentity && engramDir) {
|
|
140
|
+
const localRef = parseAvatarRef(localIdentity);
|
|
141
|
+
if (localRef?.kind === "path") {
|
|
142
|
+
const resolved = resolveAvatarPath(localRef.value, engramDir);
|
|
143
|
+
if (existsSync(resolved)) {
|
|
144
|
+
return remoteIdentity;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return rewriteAvatarValue(remoteIdentity, remoteAvatarUrl);
|
|
149
|
+
}
|
|
106
150
|
}
|
|
107
151
|
// ---------------------------------------------------------------------------
|
|
108
152
|
// Helpers
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import type { EngramRepository } from "../ports/engram-repository.js";
|
|
2
2
|
import type { MikoshiClient } from "../ports/mikoshi.js";
|
|
3
3
|
export type MikoshiPushOutcome = "create_required" | "push_required" | "already_synced";
|
|
4
|
+
/**
|
|
5
|
+
* Avatar 差分判定の結果。
|
|
6
|
+
*
|
|
7
|
+
* - `no_avatar_field` : IDENTITY.md に Avatar フィールドが無い
|
|
8
|
+
* - `no_local_file` : Avatar フィールドはあるがローカルにファイルが無い(削除は自動化しない)
|
|
9
|
+
* - `skip` : ローカルハッシュがリモート (manifest) と一致
|
|
10
|
+
* - `upload_required` : 初回 or ハッシュ不一致。アップロード候補
|
|
11
|
+
*/
|
|
12
|
+
export type MikoshiPushAvatarOutcome = "no_avatar_field" | "no_local_file" | "skip" | "upload_required";
|
|
13
|
+
export type MikoshiPushAvatarSkipReason = "remote_avatar_url_matches_identity" | "source_url_unchanged" | "local_file_hash_unchanged";
|
|
14
|
+
export interface MikoshiPushAvatarInfo {
|
|
15
|
+
outcome: MikoshiPushAvatarOutcome;
|
|
16
|
+
source?: "file" | "url";
|
|
17
|
+
/** 解決済みの絶対パス (outcome が no_avatar_field 以外で設定) */
|
|
18
|
+
localPath?: string;
|
|
19
|
+
/** IDENTITY.md に書かれていた生のパス値 */
|
|
20
|
+
rawPath?: string;
|
|
21
|
+
/** IDENTITY.md に書かれていた外部 URL */
|
|
22
|
+
sourceUrl?: string;
|
|
23
|
+
/** `sha256:<hex>` 形式。outcome が "skip" / "upload_required" のときのみ設定 */
|
|
24
|
+
localHash?: string;
|
|
25
|
+
/** `skip` のときの理由 */
|
|
26
|
+
skipReason?: MikoshiPushAvatarSkipReason;
|
|
27
|
+
/** バリデーション済み MIME。upload_required のときのみ設定 */
|
|
28
|
+
localMimeType?: string;
|
|
29
|
+
/** ファイルサイズ (bytes)。upload_required のときのみ設定 */
|
|
30
|
+
localSize?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* persona hash が不一致で、かつ local と remote の IDENTITY.md の差分が
|
|
34
|
+
* Avatar 行の値だけだった場合にセットされる。
|
|
35
|
+
*
|
|
36
|
+
* pull 側で R2 URL にローカルを書き換えたときに自然に発生するケース。
|
|
37
|
+
* CLI はこの値を使って「これは Avatar URL の drift ですよ」という
|
|
38
|
+
* 専用メッセージを出してから push 承認を取り直す。
|
|
39
|
+
*/
|
|
40
|
+
export interface MikoshiPushAvatarDrift {
|
|
41
|
+
localValue: string;
|
|
42
|
+
remoteValue: string;
|
|
43
|
+
}
|
|
4
44
|
export interface MikoshiPushResult {
|
|
5
45
|
outcome: MikoshiPushOutcome;
|
|
6
46
|
engramId: string;
|
|
@@ -8,11 +48,37 @@ export interface MikoshiPushResult {
|
|
|
8
48
|
/** クラウド側の canonical ID (existing remote only) */
|
|
9
49
|
cloudEngramId?: string;
|
|
10
50
|
remotePersonaHash?: string | null;
|
|
51
|
+
/** Avatar 差分情報 (check 時点のスナップショット) */
|
|
52
|
+
avatar?: MikoshiPushAvatarInfo;
|
|
53
|
+
/** push_required のとき、IDENTITY.md の diff が Avatar 行だけなら設定 */
|
|
54
|
+
avatarDrift?: MikoshiPushAvatarDrift;
|
|
11
55
|
}
|
|
56
|
+
export type MikoshiPushAvatarAction = "uploaded" | "skipped" | "failed";
|
|
57
|
+
export type MikoshiPushAvatarFailureStage = "fetch" | "upload";
|
|
58
|
+
export type MikoshiPushAvatarProgressStage = "fetching" | "uploading";
|
|
12
59
|
export interface MikoshiPushApplyResult {
|
|
13
|
-
|
|
60
|
+
/**
|
|
61
|
+
* - `created` : 新規 Engram を作成した(avatar は同じ apply 内で追随)
|
|
62
|
+
* - `updated` : persona を上書きした
|
|
63
|
+
* - `avatar_only` : persona は同期済みだが avatar だけ更新した
|
|
64
|
+
*/
|
|
65
|
+
action: "created" | "updated" | "avatar_only";
|
|
14
66
|
cloudEngramId: string;
|
|
67
|
+
/** persona を更新したケースのみ設定 */
|
|
15
68
|
newPersonaHash?: string;
|
|
69
|
+
/** Avatar アップロードの結果 */
|
|
70
|
+
avatarAction?: MikoshiPushAvatarAction;
|
|
71
|
+
/** アップロード成功時のみ設定される R2 上の URL */
|
|
72
|
+
newAvatarUrl?: string;
|
|
73
|
+
/** `avatarAction === "failed"` の場合に設定される失敗原因 */
|
|
74
|
+
avatarError?: Error;
|
|
75
|
+
/** `avatarAction === "failed"` の場合に設定される失敗段階 */
|
|
76
|
+
avatarFailureStage?: MikoshiPushAvatarFailureStage;
|
|
77
|
+
/** `avatarAction === "skipped"` の場合に設定される理由 */
|
|
78
|
+
avatarSkipReason?: MikoshiPushAvatarSkipReason;
|
|
79
|
+
}
|
|
80
|
+
export interface MikoshiPushApplyOptions {
|
|
81
|
+
onAvatarProgress?: (stage: MikoshiPushAvatarProgressStage) => void;
|
|
16
82
|
}
|
|
17
83
|
export declare class MikoshiPushEngramNotFoundError extends Error {
|
|
18
84
|
readonly engramId: string;
|
|
@@ -27,13 +93,74 @@ export declare class MikoshiPushPersonaConflictError extends Error {
|
|
|
27
93
|
readonly conflictRemoteHash?: string | undefined;
|
|
28
94
|
constructor(engramId: string, conflictRemoteHash?: string | undefined);
|
|
29
95
|
}
|
|
96
|
+
export declare class MikoshiPushAvatarReadError extends Error {
|
|
97
|
+
readonly engramId: string;
|
|
98
|
+
readonly avatarPath: string;
|
|
99
|
+
readonly cause?: unknown | undefined;
|
|
100
|
+
constructor(engramId: string, avatarPath: string, cause?: unknown | undefined);
|
|
101
|
+
}
|
|
102
|
+
export declare class MikoshiPushAvatarTooLargeError extends Error {
|
|
103
|
+
readonly engramId: string;
|
|
104
|
+
readonly avatarPath: string;
|
|
105
|
+
readonly actualBytes: number;
|
|
106
|
+
readonly maxBytes: number;
|
|
107
|
+
constructor(engramId: string, avatarPath: string, actualBytes: number, maxBytes: number);
|
|
108
|
+
}
|
|
109
|
+
export declare class MikoshiPushAvatarInvalidMimeError extends Error {
|
|
110
|
+
readonly engramId: string;
|
|
111
|
+
readonly avatarPath: string;
|
|
112
|
+
constructor(engramId: string, avatarPath: string);
|
|
113
|
+
}
|
|
114
|
+
export declare class MikoshiPushAvatarHttpError extends Error {
|
|
115
|
+
readonly engramId: string;
|
|
116
|
+
readonly avatarUrl: string;
|
|
117
|
+
constructor(engramId: string, avatarUrl: string);
|
|
118
|
+
}
|
|
119
|
+
export declare class MikoshiPushAvatarPrivateHostError extends Error {
|
|
120
|
+
readonly engramId: string;
|
|
121
|
+
readonly avatarUrl: string;
|
|
122
|
+
constructor(engramId: string, avatarUrl: string);
|
|
123
|
+
}
|
|
124
|
+
export declare class MikoshiPushAvatarFetchError extends Error {
|
|
125
|
+
readonly engramId: string;
|
|
126
|
+
readonly avatarUrl: string;
|
|
127
|
+
readonly cause?: unknown | undefined;
|
|
128
|
+
constructor(engramId: string, avatarUrl: string, cause?: unknown | undefined);
|
|
129
|
+
}
|
|
30
130
|
export declare class MikoshiPush {
|
|
31
131
|
private readonly localRepo;
|
|
32
132
|
private readonly mikoshi;
|
|
33
133
|
constructor(localRepo: EngramRepository, mikoshi: MikoshiClient);
|
|
34
134
|
check(engramId: string): Promise<{
|
|
35
135
|
result: MikoshiPushResult;
|
|
36
|
-
apply?: () => Promise<MikoshiPushApplyResult>;
|
|
136
|
+
apply?: (options?: MikoshiPushApplyOptions) => Promise<MikoshiPushApplyResult>;
|
|
37
137
|
}>;
|
|
38
138
|
execute(engramId: string): Promise<MikoshiPushApplyResult | undefined>;
|
|
139
|
+
/**
|
|
140
|
+
* IDENTITY.md の Avatar フィールドを解析し、ローカルファイルを検査して
|
|
141
|
+
* 差分情報を返す。
|
|
142
|
+
*
|
|
143
|
+
* 判定順:
|
|
144
|
+
* - Avatar フィールドなし → `no_avatar_field`
|
|
145
|
+
* - URL 値で、remote.avatarUrl と一致 or manifest の source URL と一致 → `skip`
|
|
146
|
+
* - URL 値で、それ以外 → `upload_required`
|
|
147
|
+
* - フィールドありだがファイル不在 → `no_local_file`(削除は自動化しない)
|
|
148
|
+
* - リモートに avatarUrl が **あり** かつ manifest ハッシュと一致 → `skip`
|
|
149
|
+
* - リモートに avatarUrl が **無い**、または ハッシュ不一致 → `upload_required`
|
|
150
|
+
*
|
|
151
|
+
* リモート側の avatarUrl が無ければ manifest ハッシュの一致は意味を持たない。
|
|
152
|
+
* これは Mikoshi エンドポイント切り替えや、リモート側で手動削除された
|
|
153
|
+
* ケースで skip で詰まるのを防ぐため。
|
|
154
|
+
*
|
|
155
|
+
* MIME 非対応 / サイズ超過 / 読み取り失敗はエラーとして throw する。
|
|
156
|
+
*/
|
|
157
|
+
private inspectAvatar;
|
|
158
|
+
/**
|
|
159
|
+
* 必要なら avatar をアップロードし、成功時に manifest を更新する。
|
|
160
|
+
*
|
|
161
|
+
* - `upload_required` 以外 → `avatarAction: "skipped"` で即返す
|
|
162
|
+
* - upload に失敗しても persona の成功は巻き戻さず、
|
|
163
|
+
* `avatarAction: "failed"` と `avatarError` で呼び出し側に知らせる
|
|
164
|
+
*/
|
|
165
|
+
private applyAvatarUpload;
|
|
39
166
|
}
|