zigmini 0.9.0-beta.1 → 0.9.0-beta.3

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.
@@ -0,0 +1,330 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { Socket } from "bun";
3
+
4
+ // Bug 221: Bun's WebSocket client misreads valid bridge frames in long sessions (1002/1011 closes on a byte stream
5
+ // that parses cleanly). This client speaks RFC 6455 over plain TCP for ws:// bridge URLs, so frame parsing is ours.
6
+ export const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
7
+ export const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
8
+ const CLOSE_WAIT_MS = 1_000;
9
+ export const HANDSHAKE_LIMIT = 16_384;
10
+
11
+ export type FrameSocketState = 0 | 1 | 2 | 3;
12
+ export interface FrameSocketClose { readonly code: number; readonly reason: string; readonly wasClean: boolean }
13
+
14
+ export function acceptFor(key: string): string {
15
+ return createHash("sha1").update(`${key}${WS_GUID}`).digest("base64");
16
+ }
17
+
18
+ export function encodeFrame(opcode: number, payload: Uint8Array, mask: Uint8Array): Uint8Array {
19
+ if (mask.length !== 4) throw new Error("frame_mask_length");
20
+ const length = payload.length;
21
+ const extra = length < 126 ? 0 : length <= 0xffff ? 2 : 8;
22
+ const frame = new Uint8Array(2 + extra + 4 + length);
23
+ frame[0] = 0x80 | opcode;
24
+ if (extra === 0) frame[1] = 0x80 | length;
25
+ else if (extra === 2) {
26
+ frame[1] = 0x80 | 126;
27
+ frame[2] = length >>> 8;
28
+ frame[3] = length & 0xff;
29
+ } else {
30
+ frame[1] = 0x80 | 127;
31
+ new DataView(frame.buffer).setBigUint64(2, BigInt(length));
32
+ }
33
+ const at = 2 + extra;
34
+ frame.set(mask, at);
35
+ payload.forEach((byte, i) => { frame[at + 4 + i] = byte ^ mask[i & 3]!; });
36
+ return frame;
37
+ }
38
+
39
+ export type HandshakeHead =
40
+ | { readonly kind: "need" }
41
+ | { readonly kind: "too_large" }
42
+ | { readonly kind: "head"; readonly head: string; readonly rest: string };
43
+
44
+ export function handshakeHead(text: string): HandshakeHead {
45
+ const end = text.indexOf("\r\n\r\n");
46
+ if (end === -1) return text.length > HANDSHAKE_LIMIT ? { kind: "too_large" } : { kind: "need" };
47
+ if (end > HANDSHAKE_LIMIT) return { kind: "too_large" };
48
+ return { kind: "head", head: text.slice(0, end), rest: text.slice(end + 4) };
49
+ }
50
+
51
+ export interface WriteSink { write(bytes: Uint8Array): number }
52
+
53
+ export class WriteQueue {
54
+ private readonly pending: Uint8Array[] = [];
55
+
56
+ get size(): number {
57
+ return this.pending.length;
58
+ }
59
+
60
+ write(sink: WriteSink, bytes: Uint8Array): void {
61
+ if (this.pending.length > 0) {
62
+ this.pending.push(bytes);
63
+ return;
64
+ }
65
+ const written = sink.write(bytes);
66
+ if (written < bytes.length) this.pending.push(bytes.subarray(Math.max(0, written)));
67
+ }
68
+
69
+ flush(sink: WriteSink): void {
70
+ while (this.pending.length > 0) {
71
+ const head = this.pending[0]!;
72
+ const written = sink.write(head);
73
+ if (written < head.length) {
74
+ this.pending[0] = head.subarray(Math.max(0, written));
75
+ return;
76
+ }
77
+ this.pending.shift();
78
+ }
79
+ }
80
+ }
81
+
82
+ export interface ParsedFrame { readonly fin: boolean; readonly opcode: number; readonly payload: Uint8Array }
83
+ export type ParseResult =
84
+ | { readonly kind: "frame"; readonly frame: ParsedFrame; readonly used: number }
85
+ | { readonly kind: "need"; readonly bytes: number }
86
+ | { readonly kind: "error"; readonly code: number; readonly reason: string };
87
+
88
+ export function parseFrame(buffer: Uint8Array, maxBytes = MAX_MESSAGE_BYTES): ParseResult {
89
+ if (buffer.length < 2) return { kind: "need", bytes: 2 };
90
+ const first = buffer[0]!;
91
+ const second = buffer[1]!;
92
+ if ((first & 0x70) !== 0) return { kind: "error", code: 1002, reason: "reserved_bits_set" };
93
+ if ((second & 0x80) !== 0) return { kind: "error", code: 1002, reason: "masked_server_frame" };
94
+ const fin = (first & 0x80) !== 0;
95
+ const opcode = first & 0x0f;
96
+ if (![0, 1, 2, 8, 9, 10].includes(opcode)) return { kind: "error", code: 1002, reason: "unknown_opcode" };
97
+ const control = opcode >= 8;
98
+ let length = second & 0x7f;
99
+ let header = 2;
100
+ if (length === 126) {
101
+ if (buffer.length < 4) return { kind: "need", bytes: 4 };
102
+ length = (buffer[2]! << 8) | buffer[3]!;
103
+ header = 4;
104
+ } else if (length === 127) {
105
+ if (buffer.length < 10) return { kind: "need", bytes: 10 };
106
+ length = Number(new DataView(buffer.buffer, buffer.byteOffset + 2, 8).getBigUint64(0));
107
+ header = 10;
108
+ }
109
+ if (control && (!fin || length > 125)) return { kind: "error", code: 1002, reason: "invalid_control_frame" };
110
+ if (length > maxBytes) return { kind: "error", code: 1009, reason: "message_too_big" };
111
+ if (buffer.length < header + length) return { kind: "need", bytes: header + length };
112
+ return { kind: "frame", frame: { fin, opcode, payload: buffer.subarray(header, header + length) }, used: header + length };
113
+ }
114
+
115
+ export class FrameSocket {
116
+ static readonly OPEN = 1;
117
+ readyState: FrameSocketState = 0;
118
+ onopen: (() => void) | null = null;
119
+ onmessage: ((event: { readonly data: string }) => void) | null = null;
120
+ onclose: ((event: FrameSocketClose) => void) | null = null;
121
+ onerror: ((event: { readonly message?: string }) => void) | null = null;
122
+
123
+ private socket: Socket<undefined> | null = null;
124
+ private readonly key = randomBytes(16).toString("base64");
125
+ private handshake = "";
126
+ private input: Uint8Array[] = [];
127
+ private inputBytes = 0;
128
+ private inputNeeded = 2;
129
+ private readonly output = new WriteQueue();
130
+ private fragments: Uint8Array[] = [];
131
+ private fragmentBytes = 0;
132
+ private fragmentOpcode = 0;
133
+ private closeTimer: ReturnType<typeof setTimeout> | null = null;
134
+ private closed = false;
135
+ private readonly decoder = new TextDecoder();
136
+
137
+ constructor(url: string, private readonly headers: Readonly<Record<string, string>> = {}, private readonly maxBytes = MAX_MESSAGE_BYTES) {
138
+ const target = new URL(url);
139
+ if (target.protocol !== "ws:") throw new Error(`frame_socket_unsupported_url:${target.protocol}`);
140
+ for (const [name, value] of Object.entries(headers)) {
141
+ if (/[\r\n:]/.test(name) || /[\r\n]/.test(value)) throw new Error("frame_socket_invalid_header");
142
+ }
143
+ const port = Number(target.port || 80);
144
+ const path = `${target.pathname || "/"}${target.search}`;
145
+ const host = target.port ? `${target.hostname}:${target.port}` : target.hostname;
146
+ Bun.connect({
147
+ hostname: target.hostname.replace(/^\[|\]$/g, ""),
148
+ port,
149
+ socket: {
150
+ open: (socket) => {
151
+ this.socket = socket;
152
+ if (this.closed) {
153
+ socket.end();
154
+ return;
155
+ }
156
+ const lines = [`GET ${path} HTTP/1.1`, `Host: ${host}`, "Connection: Upgrade", "Upgrade: websocket",
157
+ "Sec-WebSocket-Version: 13", `Sec-WebSocket-Key: ${this.key}`,
158
+ ...Object.entries(this.headers).map(([name, value]) => `${name}: ${value}`)];
159
+ this.write(new TextEncoder().encode(`${lines.join("\r\n")}\r\n\r\n`));
160
+ },
161
+ data: (_socket, chunk) => this.receive(chunk),
162
+ drain: () => this.flush(),
163
+ close: () => this.finish(1006, "", false),
164
+ error: (_socket, error) => this.fail(1006, error.message),
165
+ connectError: (_socket, error) => this.fail(1006, error.message),
166
+ },
167
+ }).catch((error: Error) => this.fail(1006, error.message));
168
+ }
169
+
170
+ send(text: string): void {
171
+ if (this.readyState !== FrameSocket.OPEN) throw new Error("frame_socket_not_open");
172
+ this.write(encodeFrame(1, new TextEncoder().encode(text), randomBytes(4)));
173
+ }
174
+
175
+ close(code = 1000, reason = ""): void {
176
+ if (this.readyState >= 2) return;
177
+ if (this.readyState === 0) {
178
+ this.finish(1006, reason, false);
179
+ return;
180
+ }
181
+ this.readyState = 2;
182
+ this.sendClose(code, reason);
183
+ this.closeTimer = setTimeout(() => this.finish(1006, reason, false), CLOSE_WAIT_MS);
184
+ }
185
+
186
+ private sendClose(code: number, reason: string): void {
187
+ const text = new TextEncoder().encode(reason);
188
+ const payload = new Uint8Array(2 + text.length);
189
+ payload[0] = code >>> 8;
190
+ payload[1] = code & 0xff;
191
+ payload.set(text, 2);
192
+ this.write(encodeFrame(8, payload, randomBytes(4)));
193
+ }
194
+
195
+ private write(bytes: Uint8Array): void {
196
+ if (this.socket) this.output.write(this.socket, bytes);
197
+ }
198
+
199
+ private flush(): void {
200
+ if (this.socket) this.output.flush(this.socket);
201
+ }
202
+
203
+ private receive(chunk: Uint8Array): void {
204
+ if (this.closed) return;
205
+ if (this.readyState === 0) {
206
+ this.handshake += Buffer.from(chunk).toString("latin1");
207
+ const parsed = handshakeHead(this.handshake);
208
+ if (parsed.kind === "need") return;
209
+ if (parsed.kind === "too_large") {
210
+ this.fail(1006, "handshake_too_large");
211
+ return;
212
+ }
213
+ this.handshake = "";
214
+ const rest = Buffer.from(parsed.rest, "latin1");
215
+ const [status, ...fields] = parsed.head.split("\r\n");
216
+ const accept = fields.find((line) => /^sec-websocket-accept:/i.test(line))?.split(":")[1]?.trim();
217
+ if (!/^HTTP\/1\.1 101 /.test(status ?? "")) {
218
+ this.fail(1006, `handshake_status:${status ?? ""}`);
219
+ return;
220
+ }
221
+ if (accept !== acceptFor(this.key)) {
222
+ this.fail(1006, "handshake_accept_mismatch");
223
+ return;
224
+ }
225
+ this.readyState = 1;
226
+ this.dispatch(() => this.onopen?.());
227
+ this.receive(rest);
228
+ return;
229
+ }
230
+ this.input.push(chunk.slice());
231
+ this.inputBytes += chunk.length;
232
+ if (this.inputBytes < this.inputNeeded) return;
233
+ const merged = new Uint8Array(this.inputBytes);
234
+ let at = 0;
235
+ for (const part of this.input) {
236
+ merged.set(part, at);
237
+ at += part.length;
238
+ }
239
+ let offset = 0;
240
+ while (!this.closed) {
241
+ const parsed = parseFrame(merged.subarray(offset), this.maxBytes);
242
+ if (parsed.kind === "need") {
243
+ this.inputNeeded = parsed.bytes;
244
+ break;
245
+ }
246
+ if (parsed.kind === "error") {
247
+ this.protocolError(parsed.code, parsed.reason);
248
+ return;
249
+ }
250
+ offset += parsed.used;
251
+ this.handle(parsed.frame);
252
+ }
253
+ const rest = merged.slice(offset);
254
+ this.input = [rest];
255
+ this.inputBytes = rest.length;
256
+ }
257
+
258
+ private handle(frame: ParsedFrame): void {
259
+ const { opcode, payload, fin } = frame;
260
+ if (opcode === 9) {
261
+ if (this.readyState === 1) this.write(encodeFrame(10, payload, randomBytes(4)));
262
+ return;
263
+ }
264
+ if (opcode === 10) return;
265
+ if (opcode === 8) {
266
+ const code = payload.length >= 2 ? (payload[0]! << 8) | payload[1]! : 1005;
267
+ const reason = this.decoder.decode(payload.subarray(2));
268
+ if (this.readyState === 1) this.sendClose(code === 1005 ? 1000 : code, "");
269
+ this.finish(code, reason, true);
270
+ return;
271
+ }
272
+ if (opcode === 0 ? this.fragmentOpcode === 0 : this.fragmentOpcode !== 0) {
273
+ this.protocolError(1002, "unexpected_continuation");
274
+ return;
275
+ }
276
+ if (opcode !== 0) this.fragmentOpcode = opcode;
277
+ this.fragmentBytes += payload.length;
278
+ if (this.fragmentBytes > this.maxBytes) {
279
+ this.protocolError(1009, "message_too_big");
280
+ return;
281
+ }
282
+ this.fragments.push(payload.slice());
283
+ if (!fin) return;
284
+ const whole = new Uint8Array(this.fragmentBytes);
285
+ let at = 0;
286
+ for (const part of this.fragments) {
287
+ whole.set(part, at);
288
+ at += part.length;
289
+ }
290
+ this.fragments = [];
291
+ this.fragmentBytes = 0;
292
+ this.fragmentOpcode = 0;
293
+ if (this.readyState === 1) {
294
+ const data = this.decoder.decode(whole);
295
+ this.dispatch(() => this.onmessage?.({ data }));
296
+ }
297
+ }
298
+
299
+ private protocolError(code: number, reason: string): void {
300
+ if (this.readyState === 1) this.sendClose(code, reason);
301
+ this.dispatch(() => this.onerror?.({ message: `frame_socket_protocol_error:${reason}` }));
302
+ this.finish(code, reason, false);
303
+ }
304
+
305
+ private dispatch(handler: () => void): void {
306
+ try {
307
+ handler();
308
+ } catch (error) {
309
+ queueMicrotask(() => {
310
+ throw error;
311
+ });
312
+ }
313
+ }
314
+
315
+ private fail(code: number, message: string): void {
316
+ if (this.closed) return;
317
+ this.dispatch(() => this.onerror?.({ message }));
318
+ this.finish(code, message, false);
319
+ }
320
+
321
+ private finish(code: number, reason: string, wasClean: boolean): void {
322
+ if (this.closed) return;
323
+ this.closed = true;
324
+ this.readyState = 3;
325
+ if (this.closeTimer) clearTimeout(this.closeTimer);
326
+ this.flush();
327
+ this.socket?.end();
328
+ this.dispatch(() => this.onclose?.({ code, reason, wasClean }));
329
+ }
330
+ }
@@ -10,6 +10,9 @@ import { type BridgeGeneration, type HandoffDeps, type OverlapControl, runHandof
10
10
  import { installedVersion } from "./helper.ts";
11
11
  import { initialOverlap, observe, type OverlapState } from "../runtime/overlap-policy.ts";
12
12
  import { sampleResources } from "../runtime/resource-sample.ts";
13
+ import { agentDir } from "../model-accounts.ts";
14
+ import { attemptRefusal, readGuardState, readStoreSchema, storeIncompatibility } from "../engine/schema-guard.ts";
15
+ import { supportedSchema } from "../engine/thread-schema.ts";
13
16
 
14
17
  const OVERLAP_REASONS: Readonly<Record<string, string>> = {
15
18
  critical_pressure: "메모리 압박 단계가 위험입니다",
@@ -110,6 +113,25 @@ export function systemHandoffDeps(launchd: LaunchdDeps, ctx: LaunchdContext, opt
110
113
  writeRegistry: (registry: SlotRegistry | null) => registry === null ? rmSync(paths.slotsFile, { force: true }) : writeRegistry(paths.slotsFile, registry),
111
114
  snapshot: () => createSnapshot(ctx.packageRoot, paths.generations, version),
112
115
  engineFolder: () => existsSync(paths.omoCurrent) ? basename(realpathSync(paths.omoCurrent)) : null,
116
+ // Same agent folder rule as the bridge's own guard (process env overlaid with config.env) and its pinned engine folder.
117
+ preflight: (record) => {
118
+ let supported: number | null = null;
119
+ try {
120
+ supported = supportedSchema(record.engine === null ? paths.omoCurrent : join(paths.omo, record.engine));
121
+ } catch {
122
+ supported = null;
123
+ }
124
+ let config: Record<string, string>;
125
+ try {
126
+ config = readConfig(launchd, ctx);
127
+ } catch {
128
+ // A broken config.env is reported by startBridge, which reads it next and fails the attempt untouched.
129
+ return null;
130
+ }
131
+ const found = storeIncompatibility(readStoreSchema(agentDir({ ...ctx.env, ...config })), supported);
132
+ return found === null ? null : `저장소 스키마 ${found.store} > 엔진 지원 ${found.supported}`;
133
+ },
134
+ startRefusal: (record) => attemptRefusal(readGuardState(paths.run), { slot: record.slot, engine: record.engine, since: record.since }),
113
135
  startBridge: async (record) => {
114
136
  let config: Record<string, string>;
115
137
  try {
@@ -34,6 +34,10 @@ export interface HandoffDeps {
34
34
  readonly writeRegistry: (registry: SlotRegistry | null) => void;
35
35
  readonly snapshot: () => { readonly name: string; readonly dir: string };
36
36
  readonly engineFolder: () => string | null;
37
+ /** Findings 214-217: why the new slot's engine cannot use the shared store, checked before it starts; null when it can. */
38
+ readonly preflight: (record: SlotRecord) => string | null;
39
+ /** The startup refusal of this attempt (this slot, engine and start time) only; a stale shared record is null. */
40
+ readonly startRefusal: (record: SlotRecord) => string | null;
37
41
  readonly startBridge: (record: SlotRecord) => Promise<string | null>;
38
42
  readonly stopBridge: (slot: RuntimeSlot) => Promise<void>;
39
43
  readonly generation: (record: SlotRecord, action: "status" | "drain" | "terminate") => Promise<BridgeGeneration | null>;
@@ -111,14 +115,18 @@ export async function runHandoff(deps: HandoffDeps): Promise<HandoffResult> {
111
115
  await deps.stopBridge(slot);
112
116
  deps.writeRegistry(stored);
113
117
  };
118
+ const blocked = deps.preflight(fresh);
119
+ if (blocked !== null) return fail(true, `새 세대(슬롯 ${slot}, 엔진 ${fresh.engine ?? "current"})를 시작하지 않았습니다: ${blocked}`);
114
120
  deps.writeRegistry({ schema: 1, slots: [old, fresh] });
115
121
  const started = await deps.startBridge(fresh);
116
122
  if (started !== null) {
117
123
  await undo();
118
124
  return fail(true, `새 세대(슬롯 ${slot})를 시작하지 못했습니다: ${started}`);
119
125
  }
120
- const up = await until(deps, BRIDGE_START_WAIT_MS, async () => (await deps.generation(fresh, "status"))?.omo === "running");
121
- const unhealthy = up ? await deps.healthCheck(fresh) : NOT_UP;
126
+ let refused: string | null = null;
127
+ const up = await until(deps, BRIDGE_START_WAIT_MS, async () => (refused = deps.startRefusal(fresh)) !== null ||
128
+ (await deps.generation(fresh, "status"))?.omo === "running");
129
+ const unhealthy = refused ?? (up ? await deps.healthCheck(fresh) : NOT_UP);
122
130
  if (unhealthy !== null) {
123
131
  await undo();
124
132
  return fail(true, `새 세대(슬롯 ${slot}) 확인 실패: ${unhealthy}`);
@@ -19,6 +19,9 @@ export const STOP_WAIT_MS = 15_000;
19
19
  export const LOST_RECORDS = "업데이트 전 백업 뒤에 생긴 기록(대화 등)은 없어졌을 수 있습니다.";
20
20
  export const UPDATE_AGAIN = "옛 엔진이 공유 폴더 스키마를 읽지 못해 브리지가 멈췄습니다. 다시 업데이트하세요(새 엔진 필요).";
21
21
 
22
+ /** The installer's refusal code (src/engine/install.ts ENGINE_SCHEMA_INCOMPATIBLE) as printed by `setup --engine-only`. */
23
+ export const ENGINE_SCHEMA_REFUSAL = "engine_schema_incompatible";
24
+
22
25
  export interface CommandOutput { readonly code: number; readonly stdout: string; readonly stderr: string }
23
26
 
24
27
  export interface Probe { readonly status: number; readonly omo: string | null; readonly version: string | null }
@@ -143,7 +146,14 @@ export async function runHelper(request: UpdateRequest, deps: HelperDeps): Promi
143
146
  }
144
147
  if (request.engineChanged) {
145
148
  phase("engine");
146
- await bun([join(deps.packageRoot, "src/cli/main.ts"), "setup", "--non-interactive", "--engine-only"], "새 엔진 설치");
149
+ try {
150
+ await bun([join(deps.packageRoot, "src/cli/main.ts"), "setup", "--non-interactive", "--engine-only"], "새 엔진 설치");
151
+ } catch (error) {
152
+ // Findings 214-217: the installer refused before selecting the engine and nothing running was restarted.
153
+ if (!(error instanceof StepError) || !error.message.includes(ENGINE_SCHEMA_REFUSAL)) throw error;
154
+ log(`실패(실행 중인 것은 그대로): ${error.message}`);
155
+ return await untouchedRollback(request, deps, { failure: { untouched: true, message: error.message }, prevTgz, log, phase, bun, finish });
156
+ }
147
157
  }
148
158
  phase("restart");
149
159
  const restarted = await deps.strategy.run({ restart: deps.restart, log, request });
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CONSENT_WORD, type ConsentRecord, noticeSha256, type PermissionNotice, writePending } from "../consent/consent.ts";
4
- import { type StoreReading, updateSchemaNotice } from "../engine/schema-guard.ts";
4
+ import { SCHEMA_INCOMPATIBLE, storeIncompatibility, type StoreReading, updateSchemaNotice } from "../engine/schema-guard.ts";
5
5
  import type { InstallKind } from "../runtime/install-kind.ts";
6
6
  import { type Origin, REQUEST_FILE, type UpdateRequest, readState, updateDir, updateRunning, writeJson, writeState } from "./state.ts";
7
7
  import { TargetError, type TargetInfo } from "./target.ts";
@@ -21,7 +21,7 @@ export const CHECKOUT_MESSAGE = "이 설치(checkout)는 저장소에서 배포
21
21
 
22
22
  export const DRAINING_MESSAGE = "이전 업데이트가 아직 이전 세대를 정리하고 있습니다. 정리가 끝난 뒤 다시 하세요";
23
23
 
24
- export type RefusalCode = "not_updatable" | "in_progress" | "same_version" | "busy" | "consent_on_mac" | "consent_required" | "schema_confirm" | "pending_questions";
24
+ export type RefusalCode = "not_updatable" | "in_progress" | "same_version" | "busy" | "consent_on_mac" | "consent_required" | "schema_confirm" | "pending_questions" | typeof SCHEMA_INCOMPATIBLE;
25
25
 
26
26
  export interface Refusal {
27
27
  readonly ok: false;
@@ -78,10 +78,23 @@ export function schemaLines(target: TargetInfo, store: StoreReading, globalEngin
78
78
  return notice.confirm ? [...notice.lines, ROLLBACK_SCHEMA_WARNING] : null;
79
79
  }
80
80
 
81
+ /**
82
+ * Findings 214-217: the target's engine cannot read the shared store, so the update is refused before any package
83
+ * install or engine-link change. Neither `force` nor `confirmSchema` overrides it; null when not known to be incompatible.
84
+ */
85
+ export function schemaRefusal(target: TargetInfo, store: StoreReading): Refusal | null {
86
+ const found = storeIncompatibility(store, target.engine?.threadSchema ?? null);
87
+ if (found === null) return null;
88
+ return refuse(SCHEMA_INCOMPATIBLE, `공유 폴더 스키마 ${found.store}를 ${target.version}의 엔진 ${target.engine?.omo ?? ""}(지원 ${found.supported})이 읽지 못해 업데이트하지 않았습니다. 스키마 ${found.store}를 지원하는 버전이 필요합니다`,
89
+ { lines: [`저장소 스키마 ${found.store}`, `대상 엔진 지원 ${found.supported}`] });
90
+ }
91
+
81
92
  export function precheck(input: PrecheckInput): Precheck {
82
93
  if (input.kind !== "npm") return refuse("not_updatable", CHECKOUT_MESSAGE);
83
94
  if (input.running) return refuse("in_progress", "업데이트가 이미 진행 중입니다");
84
95
  if (input.target.version === input.current.version) return refuse("same_version", `이미 ${input.current.version}입니다`);
96
+ const incompatible = schemaRefusal(input.target, input.store);
97
+ if (incompatible !== null) return incompatible;
85
98
  if (input.busy && !input.force && input.target.handoff !== true) return refuse("busy", BUSY_MESSAGE);
86
99
  const notice = input.target.notice;
87
100
  const noticeChanged = input.consent?.noticeVersion !== notice.version;