@gripforgeai/mcp 0.1.5 → 0.1.10

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,586 @@
1
+ /**
2
+ * npm-only Game Kit delivery into a local engine project. Registered by `server.ts` only: the hosted MCP never
3
+ * reaches the caller's disk.
4
+ *
5
+ * gripforge_gamekit_deliver_local hashes the files a delivery may touch (gripforge/**, assets/gripforge/** and the
6
+ * lock's owned paths, bytes as-is), reads gripforge/gamekits.lock.json, POSTs /api/v1/gamekits/deliver, validates every
7
+ * action (paths inside the project, text sha256, binaries downloaded and checked) and only then executes plan.actions in
8
+ * order. Every file it overwrites or deletes is first copied to gripforge/.backup/<plan id>/ (an existing backup is never
9
+ * overwritten), the lock is written last, and `verify` runs Godot headless when GODOT_BIN is set.
10
+ * gripforge_gamekit_rollback_local restores gripforge/.backup/<lock id>/, removes the files that delivery added when they
11
+ * are unmodified, and puts gripforge/gamekits.lock.prev.json back as the lock.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { spawn } from 'node:child_process';
15
+ import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
16
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
17
+ import { z } from 'zod/v4';
18
+ export const GAMEKIT_LOCAL_TOOL_NAMES = ['gripforge_gamekit_deliver_local', 'gripforge_gamekit_rollback_local'];
19
+ const LOCK_PATH = 'gripforge/gamekits.lock.json';
20
+ const PREV_LOCK_PATH = 'gripforge/gamekits.lock.prev.json';
21
+ const BACKUP_DIR = 'gripforge/.backup';
22
+ const SCAN_ROOTS = ['gripforge', 'assets/gripforge'];
23
+ const GODOT_ROOTS = ['gripforge/', 'assets/gripforge/'];
24
+ const MAX_FILES = 5000;
25
+ const MAX_BINARY_BYTES = 256 * 1024 * 1024;
26
+ const DERIVED = ['.uid', '.import'];
27
+ const SHA256_RE = /^[a-f0-9]{64}$/;
28
+ const KIT_RE = /^[a-z0-9][a-z0-9._-]*$/i;
29
+ const BACKUP_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
30
+ const TARGETS = ['godot', 'unity', 'unreal'];
31
+ class LocalError extends Error {
32
+ code;
33
+ details;
34
+ constructor(code, message, details = {}) {
35
+ super(message);
36
+ this.code = code;
37
+ this.details = details;
38
+ this.name = 'LocalError';
39
+ }
40
+ }
41
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
42
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
43
+ const under = (dir, rel) => join(dir, ...rel.split('/'));
44
+ const message = (e) => (e instanceof Error ? e.message : String(e));
45
+ /** The lock as written into the project: 2-space JSON with a trailing newline (same as the editor bridge). */
46
+ export function serializeEngineLock(lock) {
47
+ return `${JSON.stringify(lock, null, 2)}\n`;
48
+ }
49
+ async function isFile(path) {
50
+ try {
51
+ return (await stat(path)).isFile();
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /** Project-relative path → absolute path inside `dir`; `forbidden_path` for anything else. */
58
+ function projectPath(dir, rel, opts) {
59
+ const what = opts.what ?? 'path';
60
+ if (typeof rel !== 'string' || !rel || rel.length > 512 || rel.includes('\0'))
61
+ throw new LocalError('forbidden_path', `invalid ${what}`, { path: String(rel).slice(0, 120) });
62
+ const norm = rel.replace(/\\/g, '/');
63
+ const segments = norm.split('/');
64
+ if (norm.startsWith('/') || /^[a-z]:/i.test(norm) || segments.some((s) => s === '' || s === '.' || s === '..')) {
65
+ throw new LocalError('forbidden_path', `${what} ${rel} must be project-relative, without "..", "." or empty segments`, { path: rel });
66
+ }
67
+ if (!norm.startsWith(`${BACKUP_DIR}/`) && segments.some((s) => s.startsWith('.')))
68
+ throw new LocalError('forbidden_path', `${what} ${rel} has a hidden segment`, { path: rel });
69
+ if (opts.godot && !GODOT_ROOTS.some((root) => norm.startsWith(root)))
70
+ throw new LocalError('forbidden_path', `${what} ${rel} is outside gripforge/ and assets/gripforge/`, { path: rel });
71
+ const absolute = resolve(dir, ...segments);
72
+ const back = relative(dir, absolute);
73
+ if (!back || back.startsWith('..') || isAbsolute(back))
74
+ throw new LocalError('forbidden_path', `${what} ${rel} escapes the project`, { path: rel });
75
+ return absolute;
76
+ }
77
+ async function projectDir(raw) {
78
+ if (typeof raw !== 'string' || !raw.trim())
79
+ throw new LocalError('bad_request', 'project_dir is required: the absolute path of the engine project.');
80
+ if (!isAbsolute(raw))
81
+ throw new LocalError('bad_request', `project_dir must be an absolute path, got ${raw}`);
82
+ const dir = resolve(raw);
83
+ let directory = false;
84
+ try {
85
+ directory = (await stat(dir)).isDirectory();
86
+ }
87
+ catch {
88
+ directory = false;
89
+ }
90
+ if (!directory)
91
+ throw new LocalError('not_found', `project_dir ${dir} is not a directory`);
92
+ return dir;
93
+ }
94
+ async function readLock(dir, rel) {
95
+ let text;
96
+ try {
97
+ text = await readFile(under(dir, rel), 'utf8');
98
+ }
99
+ catch {
100
+ return { lock: null };
101
+ }
102
+ try {
103
+ const parsed = JSON.parse(text);
104
+ return isRecord(parsed) && typeof parsed.id === 'string' ? { lock: parsed } : { lock: null, warning: `${rel} is not a GripForge lock: ignored` };
105
+ }
106
+ catch {
107
+ return { lock: null, warning: `${rel} is not valid JSON: ignored` };
108
+ }
109
+ }
110
+ /** Files under `root` (project-relative, sorted, hidden entries skipped like the editor bridge). */
111
+ async function walkFiles(dir, root, visit) {
112
+ const walk = async (rel, depth) => {
113
+ if (depth > 24)
114
+ return;
115
+ let entries;
116
+ try {
117
+ entries = await readdir(under(dir, rel), { withFileTypes: true });
118
+ }
119
+ catch {
120
+ return;
121
+ }
122
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
123
+ for (const entry of entries) {
124
+ if (entry.name.startsWith('.'))
125
+ continue;
126
+ const child = `${rel}/${entry.name}`;
127
+ if (entry.isDirectory())
128
+ await walk(child, depth + 1);
129
+ else if (entry.isFile())
130
+ await visit(child);
131
+ }
132
+ };
133
+ await walk(root, 0);
134
+ }
135
+ async function hashProject(dir, lock) {
136
+ const files = {};
137
+ let count = 0;
138
+ const add = async (rel) => {
139
+ if (++count > MAX_FILES)
140
+ throw new LocalError('too_many_files', `more than ${MAX_FILES} files to hash under ${SCAN_ROOTS.join(', ')}`);
141
+ files[rel] = sha256(await readFile(under(dir, rel)));
142
+ };
143
+ for (const root of SCAN_ROOTS)
144
+ await walkFiles(dir, root, add);
145
+ for (const owned of lock?.ownedPaths ?? []) {
146
+ if (typeof owned !== 'string' || owned in files)
147
+ continue;
148
+ let path;
149
+ try {
150
+ path = projectPath(dir, owned, { godot: false });
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ if (await isFile(path))
156
+ await add(owned);
157
+ }
158
+ return files;
159
+ }
160
+ /** `config/features=PackedStringArray("4.3", …)` → `4.3`. */
161
+ async function godotVersion(dir) {
162
+ try {
163
+ const text = await readFile(join(dir, 'project.godot'), 'utf8');
164
+ const features = /config\/features\s*=\s*PackedStringArray\(([^)]*)\)/.exec(text)?.[1];
165
+ return features?.match(/"(\d+\.\d+(?:\.\d+)?)"/)?.[1];
166
+ }
167
+ catch {
168
+ return undefined;
169
+ }
170
+ }
171
+ function lockedHashes(lock) {
172
+ const out = {};
173
+ for (const kit of Object.values(lock?.kits ?? {})) {
174
+ for (const [path, file] of Object.entries(kit?.files ?? {}))
175
+ if (typeof file?.sha256 === 'string')
176
+ out[path] = file.sha256.toLowerCase();
177
+ }
178
+ return out;
179
+ }
180
+ function planView(raw) {
181
+ if (!isRecord(raw))
182
+ return null;
183
+ const plan = raw;
184
+ return {
185
+ id: plan.id,
186
+ target: plan.target,
187
+ mode: plan.mode,
188
+ kits: plan.kits,
189
+ summary: plan.summary,
190
+ actions: Array.isArray(plan.actions) ? plan.actions.length : 0,
191
+ conflicts: (Array.isArray(plan.files) ? plan.files : []).filter((f) => f.change === 'conflict').map((f) => ({ path: f.path, reason: f.reason ?? null })),
192
+ warnings: plan.warnings ?? [],
193
+ blockers: plan.blockers ?? [],
194
+ postInstall: plan.postInstall ?? [],
195
+ dependencies: plan.dependencies ?? [],
196
+ };
197
+ }
198
+ async function atomicWrite(path, bytes) {
199
+ await mkdir(dirname(path), { recursive: true });
200
+ const tmp = `${path}.gf-tmp-${process.pid}`;
201
+ await writeFile(tmp, bytes);
202
+ await rename(tmp, path);
203
+ }
204
+ function run(bin, args, timeoutMs) {
205
+ return new Promise((done) => {
206
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
207
+ let output = '';
208
+ let timedOut = false;
209
+ const take = (chunk) => {
210
+ output = (output + chunk.toString('utf8')).slice(-65_536);
211
+ };
212
+ child.stdout.on('data', take);
213
+ child.stderr.on('data', take);
214
+ const timer = setTimeout(() => {
215
+ timedOut = true;
216
+ child.kill('SIGKILL');
217
+ }, timeoutMs);
218
+ child.on('error', (e) => {
219
+ clearTimeout(timer);
220
+ done({ code: null, output: `${output}\n${e.message}`, timedOut });
221
+ });
222
+ child.on('close', (code) => {
223
+ clearTimeout(timer);
224
+ done({ code, output, timedOut });
225
+ });
226
+ });
227
+ }
228
+ const tail = (text) => text.split('\n').filter((l) => l.trim()).slice(-12).join('\n');
229
+ async function verifyGodot(dir, kits, bin) {
230
+ const steps = [];
231
+ const imported = await run(bin, ['--headless', '--path', dir, '--import'], 300_000);
232
+ steps.push({ step: 'import', ok: imported.code === 0 && !imported.timedOut, code: imported.code, timedOut: imported.timedOut, tail: tail(imported.output) });
233
+ for (const kit of kits) {
234
+ if (!KIT_RE.test(kit.id))
235
+ continue;
236
+ const smoke = `gripforge/kits/${kit.id}/smoke.gd`;
237
+ if (!(await isFile(under(dir, smoke)))) {
238
+ steps.push({ step: `smoke ${kit.id}`, ok: null, skipped: 'no smoke.gd' });
239
+ continue;
240
+ }
241
+ const r = await run(bin, ['--headless', '--path', dir, '--script', `res://${smoke}`], 180_000);
242
+ steps.push({ step: `smoke ${kit.id}`, ok: r.output.includes('GF_SMOKE_OK') && !r.timedOut, code: r.code, timedOut: r.timedOut, tail: tail(r.output) });
243
+ }
244
+ return { ok: steps.every((s) => s.ok !== false), steps };
245
+ }
246
+ export function registerGameKitLocalTools(register, options, schema = z) {
247
+ const workspace = {
248
+ workspace_id: schema.string().max(100).optional().describe('Workspace authorized by the current key. Never infer another user’s workspace.'),
249
+ };
250
+ const projectDirField = schema.string().min(1).max(1024).describe('Absolute path of the engine project (for Godot: the folder holding project.godot).');
251
+ const kitId = schema.string().min(2).max(120).regex(/^[a-z0-9][a-z0-9._-]*$/i).describe('Kit id from the catalogue, dotted (e.g. vehicle.driveable).');
252
+ const toolResult = (data, isError = false) => ({
253
+ ...(isError ? { isError: true } : {}),
254
+ structuredContent: data,
255
+ content: [{ type: 'text', text: JSON.stringify(data) }],
256
+ });
257
+ const failure = (e, partial = {}) => e instanceof LocalError
258
+ ? toolResult({ ...partial, error: e.message, code: e.code, details: e.details }, true)
259
+ : toolResult({ ...partial, error: message(e), code: 'internal' }, true);
260
+ async function callDeliver(body, workspaceId, signal) {
261
+ const key = options.getApiKey() ?? '';
262
+ const res = await fetch(`${options.apiUrl.replace(/\/$/, '')}/api/v1/gamekits/deliver`, {
263
+ method: 'POST',
264
+ headers: {
265
+ 'content-type': 'application/json',
266
+ 'x-api-key': key,
267
+ 'x-gripforge-client': 'mcp',
268
+ ...(options.userAgent ? { 'user-agent': options.userAgent } : {}),
269
+ ...(workspaceId ? { 'x-workspace-id': workspaceId } : {}),
270
+ },
271
+ body: JSON.stringify(body),
272
+ signal: AbortSignal.any([AbortSignal.timeout(180_000), ...(signal ? [signal] : [])]),
273
+ });
274
+ let data;
275
+ try {
276
+ data = await res.json();
277
+ }
278
+ catch {
279
+ data = null;
280
+ }
281
+ return { status: res.status, data: isRecord(data) ? data : { error: `HTTP ${res.status}`, code: 'bad_response' } };
282
+ }
283
+ async function download(url, signal) {
284
+ const base = new URL(options.apiUrl);
285
+ const target = new URL(url, base);
286
+ if (target.protocol !== 'https:' && target.protocol !== 'http:')
287
+ throw new LocalError('download_failed', `unsupported URL scheme ${target.protocol}`);
288
+ const key = options.getApiKey();
289
+ const res = await fetch(target, {
290
+ headers: target.origin === base.origin && key ? { 'x-api-key': key, 'x-gripforge-client': 'mcp' } : {},
291
+ signal: AbortSignal.any([AbortSignal.timeout(300_000), ...(signal ? [signal] : [])]),
292
+ });
293
+ if (!res.ok)
294
+ throw new LocalError('download_failed', `HTTP ${res.status} for ${target.pathname}`);
295
+ if (Number(res.headers.get('content-length') ?? 0) > MAX_BINARY_BYTES)
296
+ throw new LocalError('download_failed', `${target.pathname} is larger than ${MAX_BINARY_BYTES} bytes`);
297
+ const bytes = Buffer.from(await res.arrayBuffer());
298
+ if (bytes.length > MAX_BINARY_BYTES)
299
+ throw new LocalError('download_failed', `${target.pathname} is larger than ${MAX_BINARY_BYTES} bytes`);
300
+ return bytes;
301
+ }
302
+ /** Validate every action and gather contents; nothing touches the project yet. */
303
+ async function prepare(dir, target, plan, bundle, report, signal) {
304
+ const godot = target === 'godot';
305
+ const steps = [];
306
+ for (const [index, action] of plan.actions.entries()) {
307
+ switch (action.op) {
308
+ case 'backup':
309
+ steps.push({ op: 'backup', rel: action.path, from: projectPath(dir, action.path, { godot }), to: projectPath(dir, action.to, { godot, what: 'backup target' }) });
310
+ break;
311
+ case 'write': {
312
+ const text = bundle.files?.[action.path];
313
+ if (typeof text !== 'string')
314
+ throw new LocalError('bundle_incomplete', `bundle.files has no content for ${action.path}`, { index });
315
+ const bytes = Buffer.from(text, 'utf8');
316
+ if (sha256(bytes) !== String(action.sha256).toLowerCase())
317
+ throw new LocalError('hash_mismatch', `${action.path} does not match sha256 ${action.sha256}`, { index });
318
+ steps.push({ op: 'write', rel: action.path, path: projectPath(dir, action.path, { godot }), bytes });
319
+ break;
320
+ }
321
+ case 'writeBin': {
322
+ const path = projectPath(dir, action.path, { godot });
323
+ const ref = bundle.binaries?.[action.path];
324
+ if (!ref) {
325
+ report.skipped.push({ op: action.op, path: action.path, reason: 'binary not resolved: its Library item is neither in the workspace nor published' });
326
+ break;
327
+ }
328
+ const bytes = 'base64' in ref ? Buffer.from(ref.base64, 'base64') : await download(ref.url, signal);
329
+ const expected = (action.sha256 ?? ref.sha256)?.toLowerCase();
330
+ if (expected && SHA256_RE.test(expected) && sha256(bytes) !== expected)
331
+ throw new LocalError('hash_mismatch', `${action.path} does not match sha256 ${expected}`, { index });
332
+ steps.push({ op: 'write', rel: action.path, path, bytes });
333
+ break;
334
+ }
335
+ case 'delete': {
336
+ const siblings = (action.alsoSiblings ?? []).map((s) => (s.startsWith('.') ? `${action.path}${s}` : s));
337
+ steps.push({
338
+ op: 'delete',
339
+ rel: action.path,
340
+ path: projectPath(dir, action.path, { godot }),
341
+ siblings: siblings.map((rel) => ({ rel, path: projectPath(dir, rel, { godot, what: 'sibling' }) })),
342
+ });
343
+ break;
344
+ }
345
+ case 'writeLock': {
346
+ const prev = action.previous && action.previous.endsWith('.json') ? action.previous : PREV_LOCK_PATH;
347
+ steps.push({
348
+ op: 'lock',
349
+ rel: action.path,
350
+ path: projectPath(dir, action.path, { godot, what: 'lock path' }),
351
+ previous: action.previous,
352
+ prevPath: projectPath(dir, prev, { godot, what: 'previous lock path' }),
353
+ bytes: Buffer.from(serializeEngineLock(bundle.lock), 'utf8'),
354
+ });
355
+ break;
356
+ }
357
+ case 'refresh':
358
+ break;
359
+ default:
360
+ throw new LocalError('unknown_action', `unknown action ${String(action.op)}`, { index });
361
+ }
362
+ }
363
+ return steps;
364
+ }
365
+ async function execute(dir, planId, steps, report) {
366
+ const saved = new Set();
367
+ const keep = async (rel, from, to) => {
368
+ if (saved.has(rel) || !(await isFile(from)))
369
+ return;
370
+ saved.add(rel);
371
+ if (await isFile(to))
372
+ return; // a retried delivery keeps the original backup
373
+ await mkdir(dirname(to), { recursive: true });
374
+ await copyFile(from, to);
375
+ report.backedUp.push(rel);
376
+ };
377
+ const safety = (rel, path) => keep(rel, path, under(dir, `${BACKUP_DIR}/${planId}/${rel}`));
378
+ for (const [index, step] of steps.entries()) {
379
+ try {
380
+ switch (step.op) {
381
+ case 'backup':
382
+ if (await isFile(step.from))
383
+ await keep(step.rel, step.from, step.to);
384
+ else
385
+ report.warnings.push(`backup: ${step.rel} is not in the project, nothing to save`);
386
+ break;
387
+ case 'write':
388
+ await safety(step.rel, step.path);
389
+ await atomicWrite(step.path, step.bytes);
390
+ report.written.push(step.rel);
391
+ break;
392
+ case 'delete':
393
+ if (await isFile(step.path)) {
394
+ await safety(step.rel, step.path);
395
+ await rm(step.path, { force: true });
396
+ report.deleted.push(step.rel);
397
+ }
398
+ else {
399
+ report.missing.push(step.rel);
400
+ }
401
+ for (const sibling of step.siblings) {
402
+ if (!(await isFile(sibling.path)))
403
+ continue;
404
+ await safety(sibling.rel, sibling.path);
405
+ await rm(sibling.path, { force: true });
406
+ report.deleted.push(sibling.rel);
407
+ }
408
+ break;
409
+ case 'lock':
410
+ if (step.previous !== null && (await isFile(step.path))) {
411
+ await mkdir(dirname(step.prevPath), { recursive: true });
412
+ await copyFile(step.path, step.prevPath);
413
+ }
414
+ await atomicWrite(step.path, step.bytes);
415
+ report.lock = step.rel;
416
+ break;
417
+ }
418
+ }
419
+ catch (e) {
420
+ throw new LocalError('io', `${step.op} ${step.rel} failed: ${message(e)}`, { index, hint: `gripforge_gamekit_rollback_local { project_dir, backup_id: "${planId}" } restores the backups taken so far` });
421
+ }
422
+ }
423
+ }
424
+ register('gripforge_gamekit_deliver_local', {
425
+ title: 'Deliver Game Kits into a local engine project',
426
+ description: 'npm client only. Deliver Game Kits straight into an engine project folder on this machine: hashes project_dir/gripforge/**, reads gripforge/gamekits.lock.json, asks the GripForge API for the plan, then executes plan.actions with fs — every overwritten or deleted file is copied to gripforge/.backup/<plan id>/ first and the lock is written last. Paths are checked inside project_dir (Godot: gripforge/ and assets/gripforge/ only) and every hash is verified before anything is written. Run dry_run=true first. A blocked plan (conflicts, breaking update) returns the conflicts without touching files: re-run with force or accept_breaking. verify=true runs Godot headless (import, then each kit smoke.gd) when GODOT_BIN is set. Undo with gripforge_gamekit_rollback_local. Credits: the first delivery of a kit major version to an engine costs 1 credit per workspace; re-deliveries, updates within a major and dry runs are free.',
427
+ inputSchema: {
428
+ project_dir: projectDirField,
429
+ target: schema.enum(TARGETS).optional().describe('Engine of the project: godot (default), unity or unreal.'),
430
+ kits: schema
431
+ .array(schema.object({
432
+ id: kitId,
433
+ version: schema.string().max(40).optional().describe('Exact version; only the latest is deliverable.'),
434
+ config: schema.record(schema.string(), schema.unknown()).optional().describe('Kit config, validated against its configSchema.'),
435
+ bindings: schema.record(schema.string(), schema.string()).optional().describe('Asset slot → Library item id (lib_…).'),
436
+ }))
437
+ .max(16)
438
+ .optional()
439
+ .describe('Kits to deliver (at most 16). Omit to deliver the enabled kits of project_id.'),
440
+ project_id: schema.string().min(4).max(80).optional().describe('Game Kit project (gkp_…) whose enabled kits, config and bindings are delivered when kits is omitted.'),
441
+ mode: schema.enum(['install', 'update', 'uninstall']).optional().describe('install (default), update to the latest catalogue version, or uninstall (uses the lock).'),
442
+ dry_run: schema.boolean().optional().describe('true → plan only; nothing is written or charged.'),
443
+ force: schema.boolean().optional().describe('true → back up then overwrite edited managed files and existing seed files.'),
444
+ accept_breaking: schema.boolean().optional().describe('true → allow a breaking (major) update.'),
445
+ verify: schema.boolean().optional().describe('true → after writing, run Godot headless (import + smoke.gd per kit) when GODOT_BIN is set.'),
446
+ ...workspace,
447
+ },
448
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
449
+ }, async (args, extra) => {
450
+ if (!options.getApiKey())
451
+ return toolResult({ error: 'GripForge API key required.', code: 'unauthorized' }, true);
452
+ const report = { backedUp: [], written: [], deleted: [], missing: [], skipped: [], lock: null, warnings: [] };
453
+ let context = {};
454
+ try {
455
+ const dir = await projectDir(args.project_dir);
456
+ const target = (typeof args.target === 'string' ? args.target : 'godot');
457
+ if (!TARGETS.includes(target))
458
+ throw new LocalError('bad_request', 'target must be godot, unity or unreal');
459
+ if (target === 'godot' && !(await isFile(join(dir, 'project.godot'))))
460
+ throw new LocalError('not_a_project', `${dir} has no project.godot: pass the Godot project folder`);
461
+ const kits = Array.isArray(args.kits) ? args.kits : [];
462
+ const mode = typeof args.mode === 'string' ? args.mode : 'install';
463
+ const { lock, warning } = await readLock(dir, LOCK_PATH);
464
+ if (warning)
465
+ report.warnings.push(warning);
466
+ if (!kits.length && typeof args.project_id !== 'string' && !(mode === 'uninstall' && lock))
467
+ throw new LocalError('bad_request', 'Pass kits [{ id }] or project_id (gkp_…).');
468
+ const files = await hashProject(dir, lock);
469
+ const engineVersion = target === 'godot' ? await godotVersion(dir) : undefined;
470
+ const dryRun = args.dry_run === true;
471
+ context = { project_dir: dir, target, dry_run: dryRun, hashed: Object.keys(files).length };
472
+ const { status, data } = await callDeliver({
473
+ target,
474
+ ...(kits.length ? { kits } : {}),
475
+ ...(typeof args.project_id === 'string' ? { projectId: args.project_id } : {}),
476
+ mode,
477
+ project: { target, ...(engineVersion ? { engineVersion } : {}), lock, files },
478
+ dryRun,
479
+ force: args.force === true,
480
+ acceptBreaking: args.accept_breaking === true,
481
+ }, typeof args.workspace_id === 'string' ? args.workspace_id : undefined, extra?.signal);
482
+ if (status >= 400)
483
+ return toolResult({ ...context, applied: false, error: data.error ?? `HTTP ${status}`, code: data.code ?? 'http_error', details: data.details ?? {}, plan: planView(data.plan) }, true);
484
+ const plan = data.plan;
485
+ const bundle = data.bundle;
486
+ if (!plan || !Array.isArray(plan.actions))
487
+ throw new LocalError('bad_response', 'the API answered without a plan');
488
+ context = { ...context, plan: planView(plan), entitlement: data.entitlement, next: data.next };
489
+ if (dryRun || !bundle)
490
+ return toolResult({ ...context, applied: false, ...report });
491
+ if (bundle.planId !== plan.id)
492
+ throw new LocalError('bundle_mismatch', `bundle ${bundle.planId} was not emitted for plan ${plan.id}`);
493
+ if (!BACKUP_ID_RE.test(plan.id))
494
+ throw new LocalError('bad_response', `plan id ${plan.id} cannot name a backup folder`);
495
+ const steps = await prepare(dir, target, plan, bundle, report, extra?.signal);
496
+ await execute(dir, plan.id, steps, report);
497
+ let verify;
498
+ if (args.verify === true) {
499
+ const bin = options.godotBin?.() ?? process.env.GODOT_BIN;
500
+ verify = target !== 'godot' ? { skipped: 'verify runs for Godot projects only' } : !bin ? { skipped: 'set GODOT_BIN to a Godot 4 binary to verify' } : await verifyGodot(dir, plan.kits ?? [], bin);
501
+ }
502
+ const failed = isRecord(verify) && verify.ok === false;
503
+ return toolResult({ ...context, applied: true, ...report, ...(verify ? { verify } : {}) }, failed);
504
+ }
505
+ catch (e) {
506
+ return failure(e, { ...context, applied: false, ...report });
507
+ }
508
+ });
509
+ register('gripforge_gamekit_rollback_local', {
510
+ title: 'Roll back the last local Game Kit delivery',
511
+ description: 'npm client only. Undo the last gripforge_gamekit_deliver_local in project_dir: restores every file saved in gripforge/.backup/<lock id>/, removes the files that delivery added when you did not edit them (edited ones are kept and listed), and puts gripforge/gamekits.lock.prev.json back as gripforge/gamekits.lock.json (or removes the lock after a first install). Pass backup_id (a plan id) to restore the backups of a delivery that failed before writing its lock. Backups are never deleted. 0 credits.',
512
+ inputSchema: {
513
+ project_dir: projectDirField,
514
+ backup_id: schema.string().max(128).optional().describe('Backup folder (plan id) to restore instead of the current lock id; only restores files, the lock is left as is.'),
515
+ ...workspace,
516
+ },
517
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
518
+ }, async (args) => {
519
+ const report = { restored: [], removed: [], keptModified: [], unrestorable: [], lock: 'unchanged' };
520
+ let context = {};
521
+ try {
522
+ const dir = await projectDir(args.project_dir);
523
+ const { lock } = await readLock(dir, LOCK_PATH);
524
+ const { lock: prev } = await readLock(dir, PREV_LOCK_PATH);
525
+ const explicit = typeof args.backup_id === 'string' && args.backup_id.trim() ? args.backup_id.trim() : null;
526
+ const backupId = explicit ?? lock?.id ?? null;
527
+ if (!backupId)
528
+ throw new LocalError('lock_required', `no ${LOCK_PATH} in ${dir}: nothing to roll back (pass backup_id to restore a backup folder)`);
529
+ if (!BACKUP_ID_RE.test(backupId))
530
+ throw new LocalError('bad_request', `invalid backup id ${backupId}`);
531
+ context = { project_dir: dir, backup_id: backupId };
532
+ const root = `${BACKUP_DIR}/${backupId}`;
533
+ const restored = new Set();
534
+ await walkFiles(dir, root, async (rel) => {
535
+ const original = rel.slice(root.length + 1);
536
+ const dest = projectPath(dir, original, { godot: false, what: 'restore target' });
537
+ await mkdir(dirname(dest), { recursive: true });
538
+ await copyFile(under(dir, rel), dest);
539
+ restored.add(original);
540
+ report.restored.push(original);
541
+ });
542
+ if (!explicit && lock) {
543
+ const prevOwned = new Set(prev?.ownedPaths ?? []);
544
+ const locked = lockedHashes(lock);
545
+ for (const owned of lock.ownedPaths ?? []) {
546
+ if (prevOwned.has(owned) || restored.has(owned))
547
+ continue;
548
+ const path = projectPath(dir, owned, { godot: false });
549
+ if (!(await isFile(path)))
550
+ continue;
551
+ if (locked[owned] && sha256(await readFile(path)) !== locked[owned]) {
552
+ report.keptModified.push(owned);
553
+ continue;
554
+ }
555
+ await rm(path, { force: true });
556
+ for (const suffix of DERIVED)
557
+ await rm(`${path}${suffix}`, { force: true });
558
+ report.removed.push(owned);
559
+ }
560
+ for (const [owned, sha] of Object.entries(lockedHashes(prev))) {
561
+ if (restored.has(owned))
562
+ continue;
563
+ const path = projectPath(dir, owned, { godot: false });
564
+ const current = (await isFile(path)) ? sha256(await readFile(path)) : null;
565
+ if (current !== sha)
566
+ report.unrestorable.push(owned);
567
+ }
568
+ const lockPath = under(dir, LOCK_PATH);
569
+ const prevPath = under(dir, PREV_LOCK_PATH);
570
+ if (prev) {
571
+ await atomicWrite(lockPath, await readFile(prevPath));
572
+ await rm(prevPath, { force: true });
573
+ report.lock = `restored ${prev.id}`;
574
+ }
575
+ else {
576
+ await rm(lockPath, { force: true });
577
+ report.lock = 'removed (the rolled back delivery was the first install)';
578
+ }
579
+ }
580
+ return toolResult({ ...context, ...report }, false);
581
+ }
582
+ catch (e) {
583
+ return failure(e, { ...context, ...report });
584
+ }
585
+ });
586
+ }