@biznetgio/neo-app 0.4.42 → 0.4.43

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 +81 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.42",
3
+ "version": "0.4.43",
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
@@ -87,6 +87,53 @@ async function confirmCost(api, args, spec) {
87
87
  };
88
88
  }
89
89
 
90
+ // billableGate routes a billable one-shot create through the SAME server-side generic
91
+ // confirmation subsystem as deletes (handoff: generic confirmation for billable creates),
92
+ // so every create is approved + audited server-side and single-use. It returns either:
93
+ // { query } — append to the create POST; the server consumes the confirmation
94
+ // { requiresConfirmation } — a preview; CREATES NOTHING
95
+ // A confirmationId re-plays an already-approved confirmation; confirm:true is a one-call
96
+ // shortcut that mints + auto-approves one; neither returns the cost + a pending confirmationId.
97
+ async function billableGate(api, { projectId, kind, name, confirmationId, confirm }, spec) {
98
+ if (confirmationId) return { query: `?confirmationId=${encodeURIComponent(confirmationId)}` };
99
+
100
+ // Estimate the cost (best-effort) for display and the confirmation summary.
101
+ let orgId = null;
102
+ try { orgId = await api.resolveOrgId(); } catch { orgId = null; }
103
+ let book = null;
104
+ try { book = (await api.get(orgId ? `/api/v1/orgs/${orgId}/pricing` : '/api/v1/pricing')).data; }
105
+ catch { try { book = (await api.get('/api/v1/pricing')).data; } catch { book = null; } }
106
+ let cost = null;
107
+ if (book) { try { cost = estimateCost(book, spec); } catch { cost = null; } }
108
+ const costNote = cost ? `~${cost.monthly} ${cost.currency}/mo (${cost.hourly}/h)` : '';
109
+
110
+ // Mint a server-side billable_create confirmation bound to this exact resource.
111
+ // Retry a transient 5xx/dropped connection: minting has no lasting side effect (an
112
+ // unused pending confirmation just expires), so this is safe and keeps a flaky gateway
113
+ // from failing an otherwise-fine create (same spirit as the api-layer idempotent retry).
114
+ let mint;
115
+ for (let attempt = 1; ; attempt++) {
116
+ try { mint = (await api.post('/api/v1/confirmations', { action: 'billable_create', projectId, kind, name, estimatedCost: costNote })).data; break; }
117
+ catch (e) {
118
+ if (attempt < 3 && (e?.status >= 500 || e?.status === 0)) { await new Promise((r) => setTimeout(r, 300 * attempt)); continue; }
119
+ throw e;
120
+ }
121
+ }
122
+ const id = mint.confirmationId;
123
+ if (confirm === true) {
124
+ // One-call shortcut: approve the just-minted confirmation, then let the create consume it.
125
+ await api.post(`/api/v1/confirmations/${id}/decide`, { decision: 'approve' });
126
+ return { query: `?confirmationId=${encodeURIComponent(id)}` };
127
+ }
128
+ return {
129
+ requiresConfirmation: true,
130
+ cost,
131
+ priceBook: orgId ? `org:${orgId}` : 'global-defaults',
132
+ confirmationId: id,
133
+ message: `Billable ${kind} — show the user this cost, then approve server-side: neo_confirm_action("${id}", "approve"), and re-call this tool with confirmationId="${id}". (Shortcut: re-call with confirm:true.) Nothing was created.`,
134
+ };
135
+ }
136
+
90
137
  const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey|authorization)/i;
91
138
  // Deployment snapshots (exact keys) embed rendered configs with plaintext creds — a
92
139
  // queue's `--pass`, a DB's POSTGRES_PASSWORD. Redact by EXACT key so we don't also
@@ -698,22 +745,23 @@ export const TOOLS = [
698
745
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
699
746
  replicas: int('Read/failover replicas for HA (optional; postgres/CNPG only)'),
700
747
  deploymentMode: { type: 'string', description: 'single | ha (optional)' },
701
- confirm: { type: 'boolean', description: 'Approve & Create: without confirm:true the tool returns the cost and creates nothing. Re-call with confirm:true to actually create.' },
748
+ confirm: { type: 'boolean', description: 'Approve & Create in one call: mints + auto-approves a server-side billable confirmation, then creates. Without confirm (or confirmationId) the tool returns the cost + a pending confirmationId and creates nothing.' },
749
+ confirmationId: str('An APPROVED billable_create confirmation id (from neo_confirm_action) — re-call with it to create. Alternative to confirm:true.'),
702
750
  },
703
751
  required: ['projectId', 'name', 'engine', 'environment'],
704
752
  },
705
753
  async call(api, args) {
706
754
  requireEnv(args.environment);
707
- const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
708
- if (gate) return gate;
709
- const { projectId, confirm, orgId, ...body } = args;
710
- const { status, data } = await api.post(`/api/v1/projects/${projectId}/databases`, body);
755
+ const gate = await billableGate(api, { projectId: args.projectId, kind: 'database', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
756
+ if (gate.requiresConfirmation) return gate;
757
+ const { projectId, confirm, confirmationId, orgId, ...body } = args;
758
+ const { status, data } = await api.post(`/api/v1/projects/${projectId}/databases${gate.query}`, body);
711
759
  return redactSecrets(result(status, data));
712
760
  },
713
761
  },
714
762
  {
715
763
  name: 'neo_deploy_database',
716
- description: 'Deploy an individually-created managed database so it actually runs (provisions the cluster, sets its private host/port). Required after neo_create_database before the DB is reachable, bindable, or can have public access enabled. Poll neo_get_database until host is set and neo_verify_database passes. Credentials redacted.',
764
+ description: 'Deploy an individually-created managed database so it actually runs (provisions the cluster, sets its private host/port). Required after neo_create_database before the DB is reachable, bindable, or can have public access enabled. For CNPG postgres this returns ready:false — the Cluster is created but initdb runs ~1-3 min and SQL is NOT reachable yet; the deployment status stays "running" and flips to "success" when a ready instance comes up. Do NOT treat the response as terminal: poll neo_verify_database until it passes (authoritative SQL round-trip) before connecting/binding. Credentials redacted.',
717
765
  mutating: true,
718
766
  schema: { type: 'object', properties: { databaseId: str('Database id (from neo_create_database)') }, required: ['databaseId'] },
719
767
  async call(api, args) {
@@ -868,16 +916,17 @@ export const TOOLS = [
868
916
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
869
917
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
870
918
  dlqEnabled: { type: 'boolean', description: 'Enable a dead-letter queue (optional, default true). Pass false to disable — this is now honored.' },
871
- confirm: { type: 'boolean', description: 'Approve & Create: without confirm:true the tool returns the cost and creates nothing. Re-call with confirm:true to actually create.' },
919
+ confirm: { type: 'boolean', description: 'Approve & Create in one call: mints + auto-approves a server-side billable confirmation, then creates. Without confirm (or confirmationId) the tool returns the cost + a pending confirmationId and creates nothing.' },
920
+ confirmationId: str('An APPROVED billable_create confirmation id (from neo_confirm_action) — re-call with it to create. Alternative to confirm:true.'),
872
921
  },
873
922
  required: ['projectId', 'name', 'environment'],
874
923
  },
875
924
  async call(api, args) {
876
925
  requireEnv(args.environment);
877
- const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize) });
878
- if (gate) return gate;
879
- const { projectId, confirm, orgId, ...body } = args;
880
- const { status, data } = await api.post(`/api/v1/projects/${projectId}/queues`, body);
926
+ const gate = await billableGate(api, { projectId: args.projectId, kind: 'queue', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { storageGB: gbFromSize(args.volumeSize) });
927
+ if (gate.requiresConfirmation) return gate;
928
+ const { projectId, confirm, confirmationId, orgId, ...body } = args;
929
+ const { status, data } = await api.post(`/api/v1/projects/${projectId}/queues${gate.query}`, body);
881
930
  return redactSecrets(result(status, data));
882
931
  },
883
932
  },
@@ -1005,20 +1054,21 @@ export const TOOLS = [
1005
1054
  retryCount: int('Retries on failure (optional) → backoffLimit'),
1006
1055
  concurrencyPolicy: { type: 'string', enum: ['Allow', 'Forbid', 'Replace'], description: 'Overlap policy if a run is still going when the next is due (optional, default Allow)' },
1007
1056
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1008
- confirm: { type: 'boolean', description: 'Approve & Create: without confirm:true the tool returns the cost (crons are billed per run — variable) and creates nothing. Re-call with confirm:true to create.' },
1057
+ confirm: { type: 'boolean', description: 'Approve & Create in one call: mints + auto-approves a server-side billable confirmation (crons are billed per run — variable), then creates. Without confirm (or confirmationId) the tool returns the cost + a pending confirmationId and creates nothing.' },
1058
+ confirmationId: str('An APPROVED billable_create confirmation id (from neo_confirm_action) — re-call with it to create. Alternative to confirm:true.'),
1009
1059
  },
1010
1060
  required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
1011
1061
  },
1012
1062
  async call(api, args) {
1013
1063
  requireEnv(args.environment);
1014
- const gate = await confirmCost(api, args, {}); // cron cost is variable (per-run) — gate surfaces the rate
1015
- if (gate) return gate;
1064
+ const gate = await billableGate(api, { projectId: args.projectId, kind: 'cron', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, {}); // cron cost is variable (per-run) — gate surfaces the rate
1065
+ if (gate.requiresConfirmation) return gate;
1016
1066
  // Merge contentType into headers for convenience (unless already set).
1017
1067
  if (args.contentType) {
1018
1068
  args = { ...args, headers: { 'Content-Type': args.contentType, ...(args.headers || {}) } };
1019
1069
  }
1020
- const { projectId, confirm, orgId, contentType, ...body } = args;
1021
- const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons`, body);
1070
+ const { projectId, confirm, confirmationId, orgId, contentType, ...body } = args;
1071
+ const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons${gate.query}`, body);
1022
1072
  return redactSecrets(result(status, data));
1023
1073
  },
1024
1074
  },
@@ -1071,15 +1121,16 @@ export const TOOLS = [
1071
1121
  region: str('Region (optional; see neo_list_regions on the portal)'),
1072
1122
  acl: { type: 'string', description: 'private | public-read (optional, default private)' },
1073
1123
  quotaGB: int('Storage quota in GB (optional)'),
1074
- confirm: { type: 'boolean', description: 'Approve & Create: without confirm:true the tool returns the cost and creates nothing. Re-call with confirm:true to actually create.' },
1124
+ confirm: { type: 'boolean', description: 'Approve & Create in one call: mints + auto-approves a server-side billable confirmation, then creates. Without confirm (or confirmationId) the tool returns the cost + a pending confirmationId and creates nothing.' },
1125
+ confirmationId: str('An APPROVED billable_create confirmation id (from neo_confirm_action) — re-call with it to create. Alternative to confirm:true.'),
1075
1126
  },
1076
1127
  required: ['projectId', 'name'],
1077
1128
  },
1078
1129
  async call(api, args) {
1079
- const gate = await confirmCost(api, args, { objectStorageGB: gbFromSize(args.quotaGB) });
1080
- if (gate) return gate;
1081
- const { projectId, confirm, orgId, ...body } = args;
1082
- const { status, data } = await api.post(`/api/v1/projects/${projectId}/buckets`, body);
1130
+ const gate = await billableGate(api, { projectId: args.projectId, kind: 'bucket', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { objectStorageGB: gbFromSize(args.quotaGB) });
1131
+ if (gate.requiresConfirmation) return gate;
1132
+ const { projectId, confirm, confirmationId, orgId, ...body } = args;
1133
+ const { status, data } = await api.post(`/api/v1/projects/${projectId}/buckets${gate.query}`, body);
1083
1134
  return redactSecrets(result(status, data));
1084
1135
  },
1085
1136
  },
@@ -1404,15 +1455,21 @@ export const TOOLS = [
1404
1455
  if (args.confirmationId) {
1405
1456
  return deleteAndConverge(api, `/api/v1/apps/${args.appId}`, args.appId, 'service', `?confirmationId=${encodeURIComponent(args.confirmationId)}`);
1406
1457
  }
1458
+ // GET /apps/{id} returns { application: {...} }, so unwrap it — reading .data directly
1459
+ // left every field undefined and the preview showed service:{} (retest B).
1407
1460
  let svc = null;
1408
- try { svc = (await api.get(`/api/v1/apps/${args.appId}`)).data; } catch { /* preview best-effort */ }
1461
+ try { const d = (await api.get(`/api/v1/apps/${args.appId}`)).data; svc = (d && d.application) || d; } catch { /* preview best-effort */ }
1409
1462
  let c;
1410
1463
  try { c = (await api.post('/api/v1/confirmations', { action: 'service_delete', targetId: args.appId })).data; }
1411
1464
  catch (e) { return { deleted: false, error: 'confirmation_failed', message: 'Could not create a deletion confirmation: ' + (e.message || e) }; }
1412
- const phrase = (c.summary && c.summary.phraseRequired) || (svc && svc.name);
1465
+ // Prefer the server confirmation's authoritative identity (what neo_get_confirmation shows);
1466
+ // fall back to the unwrapped GET, then to just the id.
1467
+ const svcView = (c.summary && c.summary.service) ||
1468
+ (svc ? { id: svc.id, name: svc.name, type: svc.serviceType, environment: svc.environment } : { id: args.appId });
1469
+ const phrase = (c.summary && c.summary.phraseRequired) || svcView.name;
1413
1470
  return {
1414
1471
  deleted: false, requiresConfirmation: true,
1415
- service: svc ? { id: svc.id, name: svc.name, type: svc.serviceType, environment: svc.environment } : { id: args.appId },
1472
+ service: svcView,
1416
1473
  warning: 'IRREVERSIBLE: deletes the service and tears down its running workload.',
1417
1474
  confirmationId: c.confirmationId, phraseRequired: phrase,
1418
1475
  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}".`,