@biznetgio/neo-app 0.4.36 → 0.4.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/mcp/tools.js +77 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.36",
3
+ "version": "0.4.37",
4
4
  "description": "NEO App CLI — one-command agent onboarding, MCP server, and project ops for coding agents (Codex, Claude Code).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/mcp/tools.js CHANGED
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { resolve, basename } from 'node:path';
6
6
  import { statSync, readFileSync } from 'node:fs';
7
- import { createHash } from 'node:crypto';
8
7
  import { deploySource } from '../deploySource.js';
9
8
  import { pkgVersion } from '../util.js';
10
9
  import { estimateCost } from '../pricing.js';
@@ -44,8 +43,8 @@ function requireEnv(env) {
44
43
  // actually converges (GET → 404), so a delete isn't reported done while the record
45
44
  // still exists. Returns a durable operation reference (`del-<id>`) + convergence
46
45
  // state (handoff 3.6; 0.4.34 retest §9 "every delete returns a durable operation ID").
47
- async function deleteAndConverge(api, path, resourceId, kind) {
48
- const { status, data } = await api.del(path);
46
+ async function deleteAndConverge(api, path, resourceId, kind, delQuery = '') {
47
+ const { status, data } = await api.del(path + delQuery);
49
48
  let converged = false, checks = 0;
50
49
  for (let i = 0; i < 12; i++) {
51
50
  checks++;
@@ -67,15 +66,6 @@ function gbFromSize(v) {
67
66
  return m ? parseInt(m[1], 10) : undefined;
68
67
  }
69
68
 
70
- // deletionHashOf is an immutable approval token for a project deletion: a short hash
71
- // of the exact inventory being deleted. The preview returns it; a cascade delete must
72
- // echo it back, and the tool recomputes the live inventory and refuses a mismatch — so
73
- // a project can't be deleted against a stale/partial inventory (0.4.31 retest §7).
74
- function deletionHashOf(pid, inv, total) {
75
- const names = Object.entries(inv).flatMap(([k, a]) => a.map((n) => k + ':' + n)).sort();
76
- return createHash('sha256').update(JSON.stringify({ pid, total, names })).digest('hex').slice(0, 16);
77
- }
78
-
79
69
  // confirmCost is the Approve & Create gate for billable one-shot creates: unless
80
70
  // args.confirm===true it returns the live-priced cost and creates NOTHING — the
81
71
  // agent shows the user, then re-calls with confirm:true. plan/apply remains the
@@ -214,6 +204,33 @@ export const TOOLS = [
214
204
  return (await api.get(`/api/v1/projects/${args.projectId}`)).data;
215
205
  },
216
206
  },
207
+ {
208
+ name: 'neo_get_confirmation',
209
+ description: 'Read a confirmation record (the server-side approval for a high-risk action) by id — its action, status, redacted summary, whether an exact-name phrase is required, and expiry. Read-only; never contains secrets.',
210
+ mutating: false,
211
+ schema: { type: 'object', properties: { confirmationId: str('Confirmation id (from a destructive preview or a plan)') }, required: ['confirmationId'] },
212
+ async call(api, args) {
213
+ return (await api.get(`/api/v1/confirmations/${args.confirmationId}`)).data;
214
+ },
215
+ },
216
+ {
217
+ name: 'neo_confirm_action',
218
+ description: 'Record a human DECISION on a confirmation (NEVER creates or deletes anything itself) — the server-enforced approval step for a high-risk action (project/service deletion, billable create). decision is approve | decline | cancel. Approving a DELETION requires confirmationPhrase to exactly equal the target name (the human must type it). After approve, call the action tool again with this confirmationId to actually perform it (single-use). Show the human the summary from the preview and ask once; never infer consent.',
219
+ mutating: true,
220
+ schema: {
221
+ type: 'object',
222
+ properties: {
223
+ confirmationId: str('Confirmation id (from a destructive preview or a plan)'),
224
+ decision: { type: 'string', enum: ['approve', 'decline', 'cancel'], description: 'approve | decline | cancel' },
225
+ confirmationPhrase: str('For a deletion: the EXACT target name, typed by the human, required to approve'),
226
+ },
227
+ required: ['confirmationId', 'decision'],
228
+ },
229
+ async call(api, args) {
230
+ const { status, data } = await api.post(`/api/v1/confirmations/${args.confirmationId}/decide`, { decision: args.decision, confirmationPhrase: args.confirmationPhrase });
231
+ return result(status, data);
232
+ },
233
+ },
217
234
  {
218
235
  name: 'neo_list_services',
219
236
  description: 'List services/apps in a project.',
@@ -1286,12 +1303,34 @@ export const TOOLS = [
1286
1303
  // the server hard-deletes the record and tears down the running workload. ---
1287
1304
  {
1288
1305
  name: 'neo_delete_service',
1289
- description: 'Permanently delete a service (web / worker / static_site) and tear down its running workload. Destructive and irreversible.',
1306
+ description: 'Delete a service (web / worker / static_site) and tear down its workload. SAFE BY DEFAULT: with no confirmationId it returns the service details + a server-side CONFIRMATION, deleting NOTHING. Then: show the user, call neo_confirm_action(confirmationId, "approve", confirmationPhrase=<EXACT service name>), then re-call with that confirmationId. Server-enforced, single-use, exact-name. Destructive and irreversible.',
1290
1307
  mutating: true,
1291
1308
  destructive: true,
1292
- schema: { type: 'object', properties: { appId: str('Service/app id (from neo_list_services)') }, required: ['appId'] },
1309
+ schema: {
1310
+ type: 'object',
1311
+ properties: {
1312
+ appId: str('Service/app id (from neo_list_services)'),
1313
+ confirmationId: str('An APPROVED confirmation id (from neo_confirm_action). Omit to get the confirmation.'),
1314
+ },
1315
+ required: ['appId'],
1316
+ },
1293
1317
  async call(api, args) {
1294
- return deleteAndConverge(api, `/api/v1/apps/${args.appId}`, args.appId, 'service');
1318
+ if (args.confirmationId) {
1319
+ return deleteAndConverge(api, `/api/v1/apps/${args.appId}`, args.appId, 'service', `?confirmationId=${encodeURIComponent(args.confirmationId)}`);
1320
+ }
1321
+ let svc = null;
1322
+ try { svc = (await api.get(`/api/v1/apps/${args.appId}`)).data; } catch { /* preview best-effort */ }
1323
+ let c;
1324
+ try { c = (await api.post('/api/v1/confirmations', { action: 'service_delete', targetId: args.appId })).data; }
1325
+ catch (e) { return { deleted: false, error: 'confirmation_failed', message: 'Could not create a deletion confirmation: ' + (e.message || e) }; }
1326
+ const phrase = (c.summary && c.summary.phraseRequired) || (svc && svc.name);
1327
+ return {
1328
+ deleted: false, requiresConfirmation: true,
1329
+ service: svc ? { id: svc.id, name: svc.name, type: svc.serviceType, environment: svc.environment } : { id: args.appId },
1330
+ warning: 'IRREVERSIBLE: deletes the service and tears down its running workload.',
1331
+ confirmationId: c.confirmationId, phraseRequired: phrase,
1332
+ message: `To delete: call neo_confirm_action("${c.confirmationId}", "approve", confirmationPhrase="${phrase}") — the human must type the exact service name — then re-call neo_delete_service with confirmationId="${c.confirmationId}".`,
1333
+ };
1295
1334
  },
1296
1335
  },
1297
1336
  {
@@ -1390,21 +1429,24 @@ export const TOOLS = [
1390
1429
  },
1391
1430
  {
1392
1431
  name: 'neo_delete_project',
1393
- description: 'Delete a project and ALL its resources (apps, databases, queues, crons, buckets, namespace + PVCs/CNPG). SAFE BY DEFAULT: without confirm:true it returns a PREVIEW (project name, org, full resource inventory, accrued-usage snapshot, no-refund warning) and deletes NOTHING. A NON-EMPTY project additionally requires cascade:true. Destructive and irreversible. Only the creator or an org owner/admin may delete.',
1432
+ description: 'Delete a project and ALL its resources (apps, databases, queues, crons, buckets, namespace + PVCs/CNPG). SAFE BY DEFAULT: with no confirmationId it returns a PREVIEW (name, org, full inventory, metering, no-refund) AND a server-side CONFIRMATION, deleting NOTHING. Then: show the user, call neo_confirm_action(confirmationId, "approve", confirmationPhrase=<EXACT project name>), then re-call this tool with that confirmationId. Server-enforced, single-use, exact-name. Destructive and irreversible. Only the creator or an org owner/admin may delete.',
1394
1433
  mutating: true,
1395
1434
  destructive: true,
1396
1435
  schema: {
1397
1436
  type: 'object',
1398
1437
  properties: {
1399
1438
  projectId: str('Project id'),
1400
- confirm: { type: 'boolean', description: 'Must be true to delete. Without it you get a preview only (nothing is deleted).' },
1401
- cascade: { type: 'boolean', description: 'Required true to delete a NON-EMPTY project — acknowledges every resource in the inventory is destroyed.' },
1402
- deletionHash: str('For a non-empty project: the deletionHash from the preview. The tool refuses if the live inventory no longer matches it (immutable approval).'),
1439
+ confirmationId: str('An APPROVED confirmation id (from neo_confirm_action). Omit to get the preview + a fresh confirmation.'),
1403
1440
  },
1404
1441
  required: ['projectId'],
1405
1442
  },
1406
1443
  async call(api, args) {
1407
1444
  const pid = args.projectId;
1445
+ // With an approved confirmation: the server consumes it atomically and deletes.
1446
+ if (args.confirmationId) {
1447
+ const { status, data } = await api.del(`/api/v1/projects/${pid}?confirmationId=${encodeURIComponent(args.confirmationId)}`);
1448
+ return { deleted: true, project: { id: pid }, result: result(status, data) };
1449
+ }
1408
1450
  // Gather inventory + usage BEFORE any delete (this is also the metering snapshot).
1409
1451
  const safeGet = async (p) => { try { return (await api.get(p)).data; } catch { return null; } };
1410
1452
  const [proj, apps, dbs, queues, crons, buckets, usage] = await Promise.all([
@@ -1429,36 +1471,28 @@ export const TOOLS = [
1429
1471
  // project is empty — treat it as non-empty so the empty-path can't silently
1430
1472
  // delete hidden resources (the preview must be trustworthy).
1431
1473
  const inventoryIncomplete = [apps, dbs, queues, crons, buckets].some((x) => x === null);
1432
- const preview = {
1474
+ // Create a server-side, single-use, exact-name confirmation for this deletion.
1475
+ let confirmationId, phraseRequired = proj && proj.name;
1476
+ try {
1477
+ const c = (await api.post('/api/v1/confirmations', { action: 'project_delete', projectId: pid })).data;
1478
+ confirmationId = c.confirmationId;
1479
+ phraseRequired = (c.summary && c.summary.phraseRequired) || phraseRequired;
1480
+ } catch (e) {
1481
+ return { deleted: false, error: 'confirmation_failed', message: 'Could not create a deletion confirmation: ' + (e.message || e) };
1482
+ }
1483
+ return {
1484
+ deleted: false,
1485
+ requiresConfirmation: true,
1433
1486
  project: proj ? { id: proj.id, name: proj.name, organizationId: proj.organizationId } : { id: pid },
1434
1487
  resourceInventory: inv,
1435
1488
  resourceCount: total,
1436
1489
  meteringSnapshot: usage ? { period: usage.period, total: usage.total ?? usage.totalCost, currency: usage.currency } : null,
1437
1490
  inventoryIncomplete: inventoryIncomplete || undefined,
1438
- deletionHash: deletionHashOf(pid, inv, total),
1439
- warning: 'IRREVERSIBLE: deletes the project, every resource above, and their data (database/queue volumes included). No refund for prepaid or accrued usage.',
1491
+ warning: 'IRREVERSIBLE: deletes the project, every resource above, and their data (database/queue volumes included). No refund for prepaid or accrued usage. The organization itself remains.',
1492
+ confirmationId,
1493
+ phraseRequired,
1494
+ message: `Show the user this inventory. To delete: call neo_confirm_action("${confirmationId}", "approve", confirmationPhrase="${phraseRequired}") — the human must type the exact project name — then re-call neo_delete_project with confirmationId="${confirmationId}".`,
1440
1495
  };
1441
- // Treat an unverifiable inventory as non-empty — never allow the no-cascade path
1442
- // when we could not confirm the project is actually empty.
1443
- const requireCascade = total > 0 || inventoryIncomplete;
1444
- if (args.confirm !== true) {
1445
- return { ...preview, deleted: false, requiresConfirmation: true, message: requireCascade ? 'Show this inventory to the user. To delete, re-call with confirm:true, cascade:true, and deletionHash.' : 'Empty project. To delete, re-call with confirm:true.' };
1446
- }
1447
- if (requireCascade && args.cascade !== true) {
1448
- return { ...preview, deleted: false, error: 'cascade_required', message: inventoryIncomplete ? 'Could not fully verify the project inventory — re-call with confirm:true AND cascade:true to delete anyway.' : `This project has ${total} resource(s). Re-call with confirm:true AND cascade:true to delete them all.` };
1449
- }
1450
- // Immutable approval: a non-empty cascade delete must echo the preview's
1451
- // deletionHash, and it must still match the live inventory.
1452
- if (total > 0) {
1453
- if (!args.deletionHash) {
1454
- return { ...preview, deleted: false, error: 'deletion_hash_required', message: 'Pass deletionHash (from the preview) to confirm you are deleting exactly this inventory.' };
1455
- }
1456
- if (args.deletionHash !== preview.deletionHash) {
1457
- return { ...preview, deleted: false, error: 'inventory_changed', message: 'The project inventory changed since your preview — re-preview and pass the new deletionHash.' };
1458
- }
1459
- }
1460
- const { status, data } = await api.del(`/api/v1/projects/${pid}`);
1461
- return { deleted: true, project: preview.project, removed: inv, resourceCount: total, meteringSnapshot: preview.meteringSnapshot, result: result(status, data) };
1462
1496
  },
1463
1497
  },
1464
1498
  ];