@biznetgio/neo-app 0.4.41 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.41",
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": {
@@ -8,7 +8,7 @@ import { getCredential } from '../creds.js';
8
8
  import { NeoApi } from '../api.js';
9
9
  import { readLink } from '../link.js';
10
10
  import { deploySource } from '../deploySource.js';
11
- import { log, ok, fail, warn, step, c } from '../util.js';
11
+ import { log, ok, fail, warn, step, c, confirm } from '../util.js';
12
12
 
13
13
  function isGitUrl(s) {
14
14
  if (!s) return false;
@@ -29,6 +29,13 @@ function isDir(s) {
29
29
  }
30
30
  }
31
31
 
32
+ function showCost(r) {
33
+ if (r?.estimatedCost) {
34
+ const e = r.estimatedCost;
35
+ log(c.bold(`Estimated cost: ~${e.monthly} ${e.currency}/mo (${e.hourly}/h)`) + (e.assumptions ? c.dim(` — ${e.assumptions}`) : ''));
36
+ }
37
+ }
38
+
32
39
  export async function deploy(source, opts = {}) {
33
40
  // `--source <path|.zip|git-url>` is an explicit alias for the positional source, as
34
41
  // documented in the deploy examples (JNG #75/#79); the positional still works.
@@ -74,7 +81,7 @@ export async function deploy(source, opts = {}) {
74
81
  const dryRun = !!opts['dry-run'];
75
82
  if (dryRun) log(c.dim('dry-run — no changes will be made'));
76
83
 
77
- const r = await deploySource(api, {
84
+ const base = {
78
85
  projectId,
79
86
  src,
80
87
  name: opts.name,
@@ -82,12 +89,33 @@ export async function deploy(source, opts = {}) {
82
89
  port: opts.port,
83
90
  build: opts.build,
84
91
  environment,
85
- dryRun,
86
92
  timeout: Number(opts.timeout) || 300,
87
93
  log: step,
88
- });
94
+ };
95
+
96
+ // Cost gate: a real deploy is billable, so it needs explicit approval. `--yes` approves
97
+ // up front (headless/CI); otherwise preview the cost first, then confirm (interactive)
98
+ // or require `--yes` (non-interactive). A spec-bound confirmToken links the approval to
99
+ // the exact cost shown, so nothing deploys if the spec drifts.
100
+ let confirmToken;
101
+ if (!dryRun && !opts.yes) {
102
+ const prev = await deploySource(api, { ...base, dryRun: true });
103
+ showCost(prev);
104
+ if (!process.stdin.isTTY) {
105
+ fail('billable deploy — re-run with --yes to approve (non-interactive)');
106
+ process.exit(1);
107
+ }
108
+ if (!(await confirm('Proceed with this billable deploy?'))) {
109
+ ok('cancelled — nothing deployed');
110
+ return prev;
111
+ }
112
+ confirmToken = prev.confirmToken;
113
+ }
114
+
115
+ const r = await deploySource(api, { ...base, dryRun, confirm: !!opts.yes, confirmToken });
89
116
 
90
117
  if (r.dryRun) {
118
+ showCost(r);
91
119
  ok(`dry-run complete — ${r.plan.length} step(s) planned, nothing written`);
92
120
  return r;
93
121
  }
@@ -155,7 +155,7 @@ function looksStatic(dir) {
155
155
  // With `dryRun`, it performs NO create/update/upload/deploy — only the GETs needed to
156
156
  // build a plan — and returns { dryRun:true, plan } (JNG #93).
157
157
  // Returns { pending, approvalId } or { appId, url, result }. `log(msg)` is optional.
158
- export async function deploySource(api, { projectId, src, name, port, build, serviceType, buildCommand, publishDirectory, environment, confirmToken, dryRun = false, timeout = 300, log = () => {} }) {
158
+ export async function deploySource(api, { projectId, src, name, port, build, serviceType, buildCommand, publishDirectory, environment, confirmToken, confirm = false, dryRun = false, timeout = 300, log = () => {} }) {
159
159
  const defaultName =
160
160
  src.mode === 'image' ? src.image.split('/').pop().split(':')[0]
161
161
  : src.mode === 'git' ? basename(src.repo, '.git')
@@ -220,10 +220,16 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
220
220
  }
221
221
  }
222
222
 
223
+ // Cost gate (0.4.40 retest A — a bare deploy must not create a billable service with
224
+ // no cost consent): a real deploy needs explicit approval BEFORE any mutation —
225
+ // either confirm:true or a matching confirmToken from a prior dry-run. Unapproved →
226
+ // run read-only (like a dry-run) and return requiresConfirmation.
227
+ const previewOnly = dryRun || !(confirmToken || confirm === true);
228
+
223
229
  const plan = [];
224
230
  if (!app) {
225
231
  plan.push(`create ${svcType} service "${svc}" (${buildType}) in ${targetEnv}`);
226
- if (!dryRun) {
232
+ if (!previewOnly) {
227
233
  const res = await api.post(`/api/v1/projects/${projectId}/apps`, {
228
234
  name: svc,
229
235
  environment: targetEnv,
@@ -264,7 +270,7 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
264
270
  if (buildType && app.buildType && app.buildType !== buildType) patch.buildType = buildType;
265
271
  if (Object.keys(patch).length) {
266
272
  plan.push(`update "${svc}": ${Object.keys(patch).join(', ')}`);
267
- if (!dryRun) {
273
+ if (!previewOnly) {
268
274
  const upd = await api.put(`/api/v1/apps/${app.id}`, patch);
269
275
  // If the source-config update needs approval, STOP — deploying now would ship
270
276
  // the OLD config while the change is still pending (JNG #96 re-validation).
@@ -279,28 +285,37 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
279
285
  if (src.mode === 'dir' || src.mode === 'zip') plan.push(`upload build context (${src.mode})`);
280
286
  plan.push(`deploy "${svc}"`);
281
287
 
282
- if (dryRun) {
283
- for (const p of plan) log(`[dry-run] would ${p}`);
284
- // Cost confirmation before a billable deploy (handoff 3.3/3.4). A new service
285
- // starts on the default compute tier; estimate from the live price book so the
286
- // user sees the cost in the dry-run. Best-effort — never fail the dry-run on it.
288
+ const token = deployToken({ svc, targetEnv, svcType, port, buildCommand, publishDirectory, src });
289
+ // Build the cost preview (+ spec-bound confirmToken) once — used for the dry-run AND
290
+ // for the mandatory pre-deploy gate below.
291
+ const buildPreview = async () => {
287
292
  let estimatedCost;
288
293
  try {
289
294
  const book = (await api.get('/api/v1/pricing')).data;
290
295
  estimatedCost = estimateCost(book, { computeTier: 'micro' });
296
+ } catch { /* price book unavailable — skip the estimate */ }
297
+ return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, confirmToken: token };
298
+ };
299
+
300
+ // Preview path (explicit dry-run OR an unapproved real deploy). No mutation ran above,
301
+ // so this returns the cost + spec-bound confirmToken and deploys NOTHING. An explicit
302
+ // dry-run returns the plain preview; an unapproved real deploy adds requiresConfirmation
303
+ // (MANDATORY gate 0.4.40 retest A — a bare deploy previously created a billable service
304
+ // with no cost consent).
305
+ if (previewOnly) {
306
+ for (const p of plan) log(`[dry-run] would ${p}`);
307
+ const prev = await buildPreview();
308
+ if (prev.estimatedCost) {
291
309
  const build = svcType === 'static_site' ? 'native nginx static hosting (not Railpack)' : 'built server-side';
292
- log(`[dry-run] estimated cost (${svcType}, ${build}, default micro compute): ~${estimatedCost.monthly} ${estimatedCost.currency}/mo (${estimatedCost.hourly}/h) — ${estimatedCost.assumptions}. Use neo_estimate for a specific size.`);
293
- } catch { /* price book unavailable — skip the estimate, not the dry-run */ }
294
- return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, confirmToken: deployToken({ svc, targetEnv, svcType, port, buildCommand, publishDirectory, src }) };
310
+ log(`[dry-run] estimated cost (${svcType}, ${build}, default micro compute): ~${prev.estimatedCost.monthly} ${prev.estimatedCost.currency}/mo (${prev.estimatedCost.hourly}/h) — ${prev.estimatedCost.assumptions}. Use neo_estimate for a specific size.`);
311
+ }
312
+ if (dryRun) return prev;
313
+ return { ...prev, requiresConfirmation: true, note: 'Billable deploy — show the user the cost, then re-call with this confirmToken (or confirm:true) to deploy. Nothing was deployed.' };
295
314
  }
296
315
 
297
- // Crypto link: if the caller passed the dry-run's confirmToken, the spec about to
298
- // deploy must still hash to it — otherwise the cost/spec the user approved changed.
299
- if (confirmToken) {
300
- const now = deployToken({ svc, targetEnv, svcType, port, buildCommand, publishDirectory, src });
301
- if (now !== confirmToken) {
302
- throw new Error(`deploy spec changed since the dry-run (confirmToken ${confirmToken} → ${now}) — re-run with dryRun:true, show the new cost, and confirm again`);
303
- }
316
+ // Approved deploy. A supplied confirmToken must still match the resolved spec (cost unchanged).
317
+ if (confirmToken && token !== confirmToken) {
318
+ throw new Error(`deploy spec changed since the dry-run (confirmToken ${confirmToken} → ${token}) — re-run with dryRun:true, show the new cost, and confirm again`);
304
319
  }
305
320
 
306
321
  if (src.mode === 'dir' || src.mode === 'zip') {
package/src/index.js CHANGED
@@ -33,7 +33,7 @@ ${c.bold('Options')}
33
33
  --agent <codex|claude-code> Target one agent (config install-mcp)
34
34
  --url <url> Override the control-plane URL (also NEO_APP_URL)
35
35
  --token <kap_...> API key (also NEO_APP_TOKEN; prompted if omitted)
36
- --yes Non-interactive (headless/CI)
36
+ --yes Non-interactive (headless/CI); also approves a billable deploy without the cost prompt
37
37
  --dry-run Print intended changes without writing
38
38
  -v, --version Print version
39
39
  -h, --help Show this help`;
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
@@ -614,7 +661,7 @@ export const TOOLS = [
614
661
  {
615
662
  name: 'neo_deploy_folder',
616
663
  description:
617
- 'Deploy a local folder or .zip in a project (built server-side). The path is on the machine running this MCP server. Pick the environment with `environment` (JNG #72). Pick the service type with `serviceType`: web (default), worker (background job, no public URL), or static_site; a plainly-static folder auto-detects as static_site. A worker is never given a public port/ingress; its type is never silently changed on redeploy (handoff 4.2/4.3).',
664
+ 'Deploy a local folder or .zip in a project (built server-side). The path is on the machine running this MCP server. COST GATE (mandatory): a billable deploy requires a confirmToken — call once to get the cost + confirmToken (returns status:requires_confirmation, deploys nothing), show the user, then re-call with that confirmToken to deploy. Pick the environment with `environment` (JNG #72). Pick the service type with `serviceType`: web (default), worker (background job, no public URL), or static_site; a plainly-static folder auto-detects as static_site. A worker is never given a public port/ingress; its type is never silently changed on redeploy (handoff 4.2/4.3).',
618
665
  mutating: true,
619
666
  schema: {
620
667
  type: 'object',
@@ -629,6 +676,7 @@ export const TOOLS = [
629
676
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production. Never defaulted (handoff §2). Deploy to staging first.' },
630
677
  dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93). Returns a confirmToken.' },
631
678
  confirmToken: str('The confirmToken from a prior dryRun — pass it on the real deploy to guarantee the spec/cost the user approved has not changed (rejected on mismatch).'),
679
+ confirm: { type: 'boolean', description: 'Approve the billable deploy after showing the user the cost (alternative to confirmToken). Without confirmToken or confirm:true the tool returns the cost and deploys nothing.' },
632
680
  },
633
681
  required: ['projectId', 'path', 'environment'],
634
682
  },
@@ -654,9 +702,10 @@ export const TOOLS = [
654
702
  port: args.port,
655
703
  environment,
656
704
  confirmToken: args.confirmToken,
705
+ confirm: args.confirm === true,
657
706
  dryRun: !!args.dryRun,
658
707
  });
659
- if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment, estimatedCost: r.estimatedCost, confirmToken: r.confirmToken, note: 'To deploy exactly this, re-call without dryRun and pass this confirmToken.' };
708
+ if (r.dryRun) return { status: r.requiresConfirmation ? 'requires_confirmation' : 'dry_run', requiresConfirmation: r.requiresConfirmation || undefined, plan: r.plan, service: r.service, environment: r.environment, estimatedCost: r.estimatedCost, confirmToken: r.confirmToken, note: r.note || 'To deploy exactly this, re-call without dryRun and pass this confirmToken.' };
660
709
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
661
710
  return { status: r.result, url: r.url || undefined, appId: r.appId };
662
711
  },
@@ -696,22 +745,23 @@ export const TOOLS = [
696
745
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
697
746
  replicas: int('Read/failover replicas for HA (optional; postgres/CNPG only)'),
698
747
  deploymentMode: { type: 'string', description: 'single | ha (optional)' },
699
- 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.'),
700
750
  },
701
751
  required: ['projectId', 'name', 'engine', 'environment'],
702
752
  },
703
753
  async call(api, args) {
704
754
  requireEnv(args.environment);
705
- const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
706
- if (gate) return gate;
707
- const { projectId, confirm, orgId, ...body } = args;
708
- 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);
709
759
  return redactSecrets(result(status, data));
710
760
  },
711
761
  },
712
762
  {
713
763
  name: 'neo_deploy_database',
714
- 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.',
715
765
  mutating: true,
716
766
  schema: { type: 'object', properties: { databaseId: str('Database id (from neo_create_database)') }, required: ['databaseId'] },
717
767
  async call(api, args) {
@@ -866,16 +916,17 @@ export const TOOLS = [
866
916
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
867
917
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
868
918
  dlqEnabled: { type: 'boolean', description: 'Enable a dead-letter queue (optional, default true). Pass false to disable — this is now honored.' },
869
- 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.'),
870
921
  },
871
922
  required: ['projectId', 'name', 'environment'],
872
923
  },
873
924
  async call(api, args) {
874
925
  requireEnv(args.environment);
875
- const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize) });
876
- if (gate) return gate;
877
- const { projectId, confirm, orgId, ...body } = args;
878
- 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);
879
930
  return redactSecrets(result(status, data));
880
931
  },
881
932
  },
@@ -1003,20 +1054,21 @@ export const TOOLS = [
1003
1054
  retryCount: int('Retries on failure (optional) → backoffLimit'),
1004
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)' },
1005
1056
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1006
- 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.'),
1007
1059
  },
1008
1060
  required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
1009
1061
  },
1010
1062
  async call(api, args) {
1011
1063
  requireEnv(args.environment);
1012
- const gate = await confirmCost(api, args, {}); // cron cost is variable (per-run) — gate surfaces the rate
1013
- 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;
1014
1066
  // Merge contentType into headers for convenience (unless already set).
1015
1067
  if (args.contentType) {
1016
1068
  args = { ...args, headers: { 'Content-Type': args.contentType, ...(args.headers || {}) } };
1017
1069
  }
1018
- const { projectId, confirm, orgId, contentType, ...body } = args;
1019
- 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);
1020
1072
  return redactSecrets(result(status, data));
1021
1073
  },
1022
1074
  },
@@ -1069,15 +1121,16 @@ export const TOOLS = [
1069
1121
  region: str('Region (optional; see neo_list_regions on the portal)'),
1070
1122
  acl: { type: 'string', description: 'private | public-read (optional, default private)' },
1071
1123
  quotaGB: int('Storage quota in GB (optional)'),
1072
- 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.'),
1073
1126
  },
1074
1127
  required: ['projectId', 'name'],
1075
1128
  },
1076
1129
  async call(api, args) {
1077
- const gate = await confirmCost(api, args, { objectStorageGB: gbFromSize(args.quotaGB) });
1078
- if (gate) return gate;
1079
- const { projectId, confirm, orgId, ...body } = args;
1080
- 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);
1081
1134
  return redactSecrets(result(status, data));
1082
1135
  },
1083
1136
  },
@@ -1402,15 +1455,21 @@ export const TOOLS = [
1402
1455
  if (args.confirmationId) {
1403
1456
  return deleteAndConverge(api, `/api/v1/apps/${args.appId}`, args.appId, 'service', `?confirmationId=${encodeURIComponent(args.confirmationId)}`);
1404
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).
1405
1460
  let svc = null;
1406
- 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 */ }
1407
1462
  let c;
1408
1463
  try { c = (await api.post('/api/v1/confirmations', { action: 'service_delete', targetId: args.appId })).data; }
1409
1464
  catch (e) { return { deleted: false, error: 'confirmation_failed', message: 'Could not create a deletion confirmation: ' + (e.message || e) }; }
1410
- 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;
1411
1470
  return {
1412
1471
  deleted: false, requiresConfirmation: true,
1413
- service: svc ? { id: svc.id, name: svc.name, type: svc.serviceType, environment: svc.environment } : { id: args.appId },
1472
+ service: svcView,
1414
1473
  warning: 'IRREVERSIBLE: deletes the service and tears down its running workload.',
1415
1474
  confirmationId: c.confirmationId, phraseRequired: phrase,
1416
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}".`,