@biznetgio/neo-app 0.4.35 → 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 +103 -45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.35",
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.',
@@ -333,7 +350,7 @@ export const TOOLS = [
333
350
  },
334
351
  {
335
352
  name: 'neo_scale_service',
336
- description: 'Scale a service to N replicas. May return pending_approval under org policy.',
353
+ description: 'Scale a service to N replicas. Scaling changes cost, so without confirm:true it returns the new estimated cost and scales nothing (re-call with confirm:true). May return pending_approval under org policy.',
337
354
  mutating: true,
338
355
  schema: {
339
356
  type: 'object',
@@ -341,10 +358,22 @@ export const TOOLS = [
341
358
  projectId: str('Project id'),
342
359
  service: str('Service/app name'),
343
360
  replicas: int('Desired replica count (>=0)'),
361
+ confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true the tool returns the new cost and scales nothing.' },
344
362
  },
345
363
  required: ['projectId', 'service', 'replicas'],
346
364
  },
347
365
  async call(api, args) {
366
+ if (args.confirm !== true) {
367
+ let computeTier;
368
+ try {
369
+ const apps = (await api.get(`/api/v1/projects/${args.projectId}/apps`)).data;
370
+ const list = Array.isArray(apps) ? apps : (apps.applications || []);
371
+ const svc = list.find((a) => a.name === args.service || a.id === args.service);
372
+ computeTier = svc && (svc.cpuLimit || svc.computeTier);
373
+ } catch { /* best effort — gate regardless */ }
374
+ const gate = await confirmCost(api, args, { computeTier, replicas: args.replicas });
375
+ if (gate) return { ...gate, change: `scale "${args.service}" to ${args.replicas} replica(s)`, message: 'Scaling changes cost — show the user and re-call with confirm:true to apply.' };
376
+ }
348
377
  const { status, data } = await api.post(
349
378
  `/api/v1/projects/${args.projectId}/services/${args.service}/scale`,
350
379
  { replicas: args.replicas }
@@ -641,10 +670,16 @@ export const TOOLS = [
641
670
  databaseId: str('Database id'),
642
671
  volumeSize: str('New storage size, e.g. "20Gi" — must be >= current (grow-only)'),
643
672
  replicas: int('Read/failover replicas (CNPG/postgres); total instances = 1 + replicas'),
673
+ confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true a storage/replica resize returns the new cost and applies nothing.' },
644
674
  },
645
675
  required: ['databaseId'],
646
676
  },
647
677
  async call(api, args) {
678
+ const costChanging = args.volumeSize || args.replicas !== undefined;
679
+ if (costChanging && args.confirm !== true) {
680
+ const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
681
+ if (gate) return { ...gate, change: 'resize database (storage/replicas)', message: 'Resizing changes cost — re-call with confirm:true to apply.' };
682
+ }
648
683
  const body = {};
649
684
  if (args.volumeSize) body.volumeSize = args.volumeSize;
650
685
  if (args.replicas !== undefined) body.replicas = args.replicas;
@@ -807,11 +842,17 @@ export const TOOLS = [
807
842
  dlqEnabled: { type: 'boolean', description: 'Enable/disable the dead-letter queue' },
808
843
  volumeSize: str('New storage size, e.g. "20Gi" — must be >= current (grow-only)'),
809
844
  retention: { type: 'object', description: 'Retention policy {maxAgeSeconds, maxBytes, maxMsgs}' },
845
+ confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true a storage/plan change returns the new cost and applies nothing. DLQ/retention-only edits apply directly.' },
810
846
  },
811
847
  required: ['queueId'],
812
848
  },
813
849
  async call(api, args) {
814
- const { queueId, ...body } = args;
850
+ const costChanging = args.volumeSize || args.planClass;
851
+ if (costChanging && args.confirm !== true) {
852
+ const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize) });
853
+ if (gate) return { ...gate, change: 'update queue (storage/plan)', message: 'This changes cost — re-call with confirm:true to apply.' };
854
+ }
855
+ const { queueId, confirm, orgId, ...body } = args;
815
856
  const { status, data } = await api.put(`/api/v1/queues/${queueId}`, body);
816
857
  return redactSecrets(result(status, data));
817
858
  },
@@ -1262,12 +1303,34 @@ export const TOOLS = [
1262
1303
  // the server hard-deletes the record and tears down the running workload. ---
1263
1304
  {
1264
1305
  name: 'neo_delete_service',
1265
- 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.',
1266
1307
  mutating: true,
1267
1308
  destructive: true,
1268
- 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
+ },
1269
1317
  async call(api, args) {
1270
- 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
+ };
1271
1334
  },
1272
1335
  },
1273
1336
  {
@@ -1366,21 +1429,24 @@ export const TOOLS = [
1366
1429
  },
1367
1430
  {
1368
1431
  name: 'neo_delete_project',
1369
- 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.',
1370
1433
  mutating: true,
1371
1434
  destructive: true,
1372
1435
  schema: {
1373
1436
  type: 'object',
1374
1437
  properties: {
1375
1438
  projectId: str('Project id'),
1376
- confirm: { type: 'boolean', description: 'Must be true to delete. Without it you get a preview only (nothing is deleted).' },
1377
- cascade: { type: 'boolean', description: 'Required true to delete a NON-EMPTY project — acknowledges every resource in the inventory is destroyed.' },
1378
- 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.'),
1379
1440
  },
1380
1441
  required: ['projectId'],
1381
1442
  },
1382
1443
  async call(api, args) {
1383
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
+ }
1384
1450
  // Gather inventory + usage BEFORE any delete (this is also the metering snapshot).
1385
1451
  const safeGet = async (p) => { try { return (await api.get(p)).data; } catch { return null; } };
1386
1452
  const [proj, apps, dbs, queues, crons, buckets, usage] = await Promise.all([
@@ -1405,36 +1471,28 @@ export const TOOLS = [
1405
1471
  // project is empty — treat it as non-empty so the empty-path can't silently
1406
1472
  // delete hidden resources (the preview must be trustworthy).
1407
1473
  const inventoryIncomplete = [apps, dbs, queues, crons, buckets].some((x) => x === null);
1408
- 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,
1409
1486
  project: proj ? { id: proj.id, name: proj.name, organizationId: proj.organizationId } : { id: pid },
1410
1487
  resourceInventory: inv,
1411
1488
  resourceCount: total,
1412
1489
  meteringSnapshot: usage ? { period: usage.period, total: usage.total ?? usage.totalCost, currency: usage.currency } : null,
1413
1490
  inventoryIncomplete: inventoryIncomplete || undefined,
1414
- deletionHash: deletionHashOf(pid, inv, total),
1415
- 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}".`,
1416
1495
  };
1417
- // Treat an unverifiable inventory as non-empty — never allow the no-cascade path
1418
- // when we could not confirm the project is actually empty.
1419
- const requireCascade = total > 0 || inventoryIncomplete;
1420
- if (args.confirm !== true) {
1421
- 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.' };
1422
- }
1423
- if (requireCascade && args.cascade !== true) {
1424
- 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.` };
1425
- }
1426
- // Immutable approval: a non-empty cascade delete must echo the preview's
1427
- // deletionHash, and it must still match the live inventory.
1428
- if (total > 0) {
1429
- if (!args.deletionHash) {
1430
- return { ...preview, deleted: false, error: 'deletion_hash_required', message: 'Pass deletionHash (from the preview) to confirm you are deleting exactly this inventory.' };
1431
- }
1432
- if (args.deletionHash !== preview.deletionHash) {
1433
- return { ...preview, deleted: false, error: 'inventory_changed', message: 'The project inventory changed since your preview — re-preview and pass the new deletionHash.' };
1434
- }
1435
- }
1436
- const { status, data } = await api.del(`/api/v1/projects/${pid}`);
1437
- return { deleted: true, project: preview.project, removed: inv, resourceCount: total, meteringSnapshot: preview.meteringSnapshot, result: result(status, data) };
1438
1496
  },
1439
1497
  },
1440
1498
  ];