@biznetgio/neo-app 0.4.49 → 0.4.63

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.49",
3
+ "version": "0.4.63",
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/api.js CHANGED
@@ -108,6 +108,18 @@ export class NeoApi {
108
108
  return { user: me, orgs };
109
109
  }
110
110
 
111
+ // actingIdentity returns the signed-in account (memoized, best-effort) so confirmation /
112
+ // dry-run responses can show WHO/WHERE an action will run and a wrong-account deploy is
113
+ // obvious before it happens (JNG #131). Never throws — returns {} if identity is unavailable.
114
+ async actingIdentity() {
115
+ if (this._acting !== undefined) return this._acting;
116
+ try {
117
+ const me = (await this.get('/api/v1/auth/me')).data || {};
118
+ this._acting = { account: me.email || me.login || me.name || me.id, accountId: me.id };
119
+ } catch { this._acting = {}; }
120
+ return this._acting;
121
+ }
122
+
111
123
  // resolveOrgId returns the preferred org id, or the first org the user belongs to.
112
124
  async resolveOrgId(preferred) {
113
125
  if (preferred) return preferred;
@@ -309,7 +309,12 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
309
309
  const book = (await api.get('/api/v1/pricing')).data;
310
310
  estimatedCost = estimateCost(book, { computeTier: 'micro' });
311
311
  } catch { /* price book unavailable — skip the estimate */ }
312
- return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, confirmToken: token };
312
+ // Include the acting account/env/project so a wrong-account or wrong-environment deploy
313
+ // is obvious before it happens (JNG #131). Best-effort — never fail the preview on it.
314
+ let ident = {};
315
+ try { if (typeof api.actingIdentity === 'function') ident = await api.actingIdentity(); } catch { /* ignore */ }
316
+ const acting = { ...ident, environment: targetEnv, project: projectId };
317
+ return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, confirmToken: token, acting };
313
318
  };
314
319
 
315
320
  // Preview path (explicit dry-run OR an unapproved real deploy). No mutation ran above,
@@ -377,7 +382,18 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
377
382
  const result = await pollDeployment(api, deployId, timeout, log);
378
383
  const view = (await api.get(`/api/v1/apps/${app.id}`)).data?.application || {};
379
384
  const url = view.domain ? `https://${view.domain}` : '';
380
- return { appId: app.id, url, result };
385
+ const out = { appId: app.id, url, result, deploymentId: deployId };
386
+ // On a non-success, surface the deployment id + the tail of the build log + error so the
387
+ // caller can see WHY it failed without triggering a second build (JNG #117).
388
+ if (result !== 'success' && deployId) {
389
+ try {
390
+ const d = (await api.get(`/api/v1/deployments/${deployId}`)).data || {};
391
+ const lines = String(d.log || '').replace(/\n$/, '').split('\n').filter((l) => l.length);
392
+ if (lines.length) out.buildLogTail = lines.slice(-50).join('\n');
393
+ if (d.error) out.error = d.error;
394
+ } catch { /* best-effort — the id is still returned for a manual neo_get_deployment */ }
395
+ }
396
+ return out;
381
397
  }
382
398
 
383
399
  async function pollDeployment(api, id, timeoutSec, log) {
package/src/mcp/tools.js CHANGED
@@ -31,14 +31,43 @@ function result(status, data) {
31
31
  // §2 safety: a create/deploy mutation must name its environment explicitly —
32
32
  // NEVER default to production. A silent prod mutation is the exact risk the
33
33
  // handoff calls out ("Never default a missing environment to production").
34
- function requireEnv(env) {
34
+ // sessionEnv pins ONE target environment (staging|production) for this MCP session so an
35
+ // agent doesn't mix staging and production within a task (JNG #136). Seeded from
36
+ // NEO_APP_TARGET_ENV; set at runtime with neo_set_context. null = not chosen yet.
37
+ let sessionEnv = (() => { const e = String(process.env.NEO_APP_TARGET_ENV || '').toLowerCase(); return (e === 'staging' || e === 'production') ? e : null; })();
38
+
39
+ // envStrict validates an explicit staging|production value (no fallback).
40
+ function envStrict(env) {
35
41
  const e = String(env || '').toLowerCase();
36
- if (e !== 'staging' && e !== 'production') {
37
- throw new Error('environment is required and must be "staging" or "production" — it is never defaulted (handoff §2: never silently mutate production). Test on staging first.');
38
- }
42
+ if (e !== 'staging' && e !== 'production') throw new Error('environment must be "staging" or "production"');
39
43
  return e;
40
44
  }
41
45
 
46
+ // requireEnv resolves the target environment and enforces the one-environment-per-session
47
+ // invariant (JNG #136): once an environment is pinned (neo_set_context or the first explicit
48
+ // call), a later call for a DIFFERENT environment is refused unless allowCrossEnvironment is
49
+ // set — so staging and production can't be mixed in one session. The first explicit choice
50
+ // pins the session so subsequent calls inherit it. Never silently defaulted.
51
+ function requireEnv(env, allowCross = false) {
52
+ const e = String(env || '').toLowerCase();
53
+ const explicit = (e === 'staging' || e === 'production') ? e : null;
54
+ if (sessionEnv) {
55
+ if (explicit && explicit !== sessionEnv && !allowCross) {
56
+ throw new Error(`environment_mismatch: this session is pinned to "${sessionEnv}" but the call requested "${explicit}". Staging and production must not be mixed in one session (JNG #136). Pass allowCrossEnvironment:true to allow this one call, or re-pin with neo_set_context.`);
57
+ }
58
+ return explicit || sessionEnv;
59
+ }
60
+ if (explicit) { sessionEnv = explicit; return explicit; } // pin the first explicit choice
61
+ throw new Error('environment_not_chosen: pin it once with neo_set_context({environment:"staging"|"production"}) (ask the user which if unclear; prefer staging), or pass environment explicitly. Never defaulted so staging and production don\'t get mixed (JNG #136).');
62
+ }
63
+
64
+ // Shared schema fragment: the per-call escape hatch for the session environment pin.
65
+ const allowCrossEnvProp = { allowCrossEnvironment: { type: 'boolean', description: 'Override the session environment pin for THIS call only (JNG #136). A call whose environment differs from the pinned one is refused by default so staging and production are not mixed; set true to allow this one cross-environment action.' } };
66
+
67
+ // resetSessionEnv clears the session pin. Exported for tests that exercise the pin
68
+ // invariant in sequence (each must start from a clean, unpinned session).
69
+ export function resetSessionEnv() { sessionEnv = null; }
70
+
42
71
  // deleteAndConverge deletes a resource, then polls its endpoint until the inventory
43
72
  // actually converges (GET → 404), so a delete isn't reported done while the record
44
73
  // still exists. Returns a durable operation reference (`del-<id>`) + convergence
@@ -59,6 +88,22 @@ async function deleteAndConverge(api, path, resourceId, kind, delQuery = '') {
59
88
  return { operationId: 'del-' + resourceId, kind, status: (data && data.status) || 'deleted', converged, convergenceChecks: checks };
60
89
  }
61
90
 
91
+ // cpuToVcpu parses a k8s CPU quantity ("500m" → 0.5, "1"/"2" → vCPUs) for cost estimates.
92
+ function cpuToVcpu(v) {
93
+ if (!v) return 0;
94
+ const s = String(v).trim();
95
+ return s.endsWith('m') ? (parseFloat(s) || 0) / 1000 : parseFloat(s) || 0;
96
+ }
97
+ // memToMB parses a k8s memory quantity ("512Mi"/"1Gi"/"512M"/"1G") → MB for cost estimates.
98
+ function memToMB(v) {
99
+ if (!v) return 0;
100
+ const m = String(v).match(/([\d.]+)\s*(Gi|Mi|G|M)?/i);
101
+ if (!m) return 0;
102
+ const n = parseFloat(m[1]) || 0;
103
+ const u = (m[2] || 'Mi').toLowerCase();
104
+ return u === 'gi' || u === 'g' ? n * 1024 : n;
105
+ }
106
+
62
107
  // gbFromSize parses "10Gi"/"10G"/"10" → 10 (integer GB), else undefined.
63
108
  function gbFromSize(v) {
64
109
  if (v == null) return undefined;
@@ -66,6 +111,31 @@ function gbFromSize(v) {
66
111
  return m ? parseInt(m[1], 10) : undefined;
67
112
  }
68
113
 
114
+ // Portal-equivalent limits mirrored here so a quote rejects an invalid config BEFORE
115
+ // showing a price (JNG #132). The server re-enforces these on create (authoritative).
116
+ const MIN_DB_VOLUME_GB = 10;
117
+ const MIN_BUCKET_QUOTA_GB = 10;
118
+ const MIN_PG_MAJOR = 13;
119
+ function validateDbArgs(args) {
120
+ const gb = gbFromSize(args.volumeSize);
121
+ if (gb !== undefined && gb < MIN_DB_VOLUME_GB) throw new Error(`volumeSize must be at least ${MIN_DB_VOLUME_GB}Gi (the portal minimum); got ${args.volumeSize}`);
122
+ if (args.engine === 'postgres' && args.version) {
123
+ const maj = parseInt(String(args.version), 10);
124
+ if (maj > 0 && maj < MIN_PG_MAJOR) throw new Error(`PostgreSQL ${args.version} is end-of-life; use ${MIN_PG_MAJOR} or newer`);
125
+ }
126
+ }
127
+ // actingOf returns the signed-in account merged with scope (org/project/env) for a
128
+ // confirmation/dry-run response (JNG #131). Tolerant of an api without actingIdentity.
129
+ async function actingOf(api, extra) {
130
+ let base = {};
131
+ try { if (typeof api.actingIdentity === 'function') base = await api.actingIdentity(); } catch { /* best-effort */ }
132
+ return { ...base, ...extra };
133
+ }
134
+ function validateBucketArgs(args) {
135
+ const gb = args.quotaGB;
136
+ if (gb != null && gb !== 0 && gb < MIN_BUCKET_QUOTA_GB) throw new Error(`quotaGB must be at least ${MIN_BUCKET_QUOTA_GB} (the portal minimum) or 0 for unset; got ${gb}`);
137
+ }
138
+
69
139
  // confirmCost is the Approve & Create gate for billable one-shot creates: unless
70
140
  // args.confirm===true it returns the live-priced cost and creates NOTHING — the
71
141
  // agent shows the user, then re-calls with confirm:true. plan/apply remains the
@@ -82,8 +152,9 @@ async function confirmCost(api, args, spec) {
82
152
  return {
83
153
  requiresConfirmation: true,
84
154
  cost,
155
+ acting: await actingOf(api, { orgId, projectId: args.projectId }), // whose/where — JNG #131
85
156
  priceBook: orgId ? `org:${orgId}` : 'global-defaults',
86
- message: 'Billable resource — show the user this cost, then re-call with confirm:true to create. For an immutable, server-pinned quote use neo_plan_stack → neo_apply_stack.',
157
+ message: 'Billable resource — show the user this cost (and the acting account/org), then re-call with confirm:true to create. For an immutable, server-pinned quote use neo_plan_stack → neo_apply_stack.',
87
158
  };
88
159
  }
89
160
 
@@ -128,9 +199,10 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
128
199
  return {
129
200
  requiresConfirmation: true,
130
201
  cost,
202
+ acting: await actingOf(api, { orgId, projectId }), // whose/where — JNG #131
131
203
  priceBook: orgId ? `org:${orgId}` : 'global-defaults',
132
204
  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.`,
205
+ message: `Billable ${kind} — show the user this cost AND the acting account/org, 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
206
  };
135
207
  }
136
208
 
@@ -161,6 +233,30 @@ export const TOOLS = [
161
233
  return api.whoami();
162
234
  },
163
235
  },
236
+ {
237
+ name: 'neo_set_context',
238
+ description: 'Pin the target ENVIRONMENT (staging | production) for THIS session so every deploy/create uses it and staging/production don\'t get mixed in one task (JNG #136). Set it ONCE — ask the user which environment if unclear; prefer staging. After this, env-requiring tools (neo_deploy_folder, neo_create_database/queue/cron, neo_plan_stack, …) use it unless a call passes environment explicitly. Without a pin (and no explicit environment), those tools error asking you to choose.',
239
+ mutating: false,
240
+ schema: {
241
+ type: 'object',
242
+ properties: { environment: { type: 'string', enum: ['staging', 'production'], description: 'staging | production — the one environment this session targets' } },
243
+ required: ['environment'],
244
+ },
245
+ async call(api, args) {
246
+ sessionEnv = envStrict(args.environment);
247
+ return { environment: sessionEnv, pinned: true, note: 'Pinned for this session. Env-requiring tools now use it; override per-call by passing environment explicitly.' };
248
+ },
249
+ },
250
+ {
251
+ name: 'neo_get_context',
252
+ description: 'Show the current session context: the pinned target environment (from neo_set_context / NEO_APP_TARGET_ENV) and the acting account — so you can confirm WHICH environment and WHO before acting (JNG #136/#131). environment is null when not pinned yet.',
253
+ mutating: false,
254
+ schema: { type: 'object', properties: {} },
255
+ async call(api) {
256
+ const acct = (typeof api.actingIdentity === 'function') ? await api.actingIdentity().catch(() => ({})) : {};
257
+ return { environment: sessionEnv, ...acct };
258
+ },
259
+ },
164
260
  {
165
261
  name: 'neo_get_capabilities',
166
262
  description:
@@ -208,6 +304,17 @@ export const TOOLS = [
208
304
  bindings: { supported: true, targets: ['neo_db', 'neo_queue', 'neo_object_storage'], writeOnly: true, note: 'neo_bind_resource + neo_plan_stack bindings inject DATABASE_URL / NATS_URL (+ NATS_HOST/PORT/USER/PASSWORD) / S3_* server-side at deploy; secrets never returned (handoff 5)' },
209
305
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
210
306
  environments: ['staging', 'production'],
307
+ // Active session context + the one-environment-per-session invariant (JNG #136).
308
+ context: { environment: sessionEnv, pinned: !!sessionEnv },
309
+ environmentIsolation: {
310
+ supported: true,
311
+ pinnedBy: ['neo_set_context', 'first explicit environment on an env-requiring call'],
312
+ failsClosed: true, // env-requiring tools error (environment_not_chosen) instead of defaulting
313
+ refusesMixing: true, // an explicit env != the pin is rejected (environment_mismatch)
314
+ escapeHatch: 'allowCrossEnvironment:true (per call)',
315
+ bucketScope: 'shared', // buckets carry no environment
316
+ note: 'One target environment per MCP session; set once with neo_set_context (or the first explicit call), then reads/writes inherit it; cross-environment calls are refused unless allowCrossEnvironment:true.',
317
+ },
211
318
  pricing,
212
319
  costConsent: { estimateAvailable: true, dryRunIncludesCost: true, planApplyEnforced: true, note: 'neo_plan_stack returns a costed immutable plan; neo_apply_stack is server-enforced (hash/expiry/one-shot) so a billable stack requires a reviewed plan (handoff 3.3-3.5)' },
213
320
  // Project/organization lifecycle (issue #107 §3). Honest to THIS build: project
@@ -308,7 +415,7 @@ export const TOOLS = [
308
415
  },
309
416
  {
310
417
  name: 'neo_update_service',
311
- description: 'Update a service/app config (handoff §4.1/§4.2). Exposes compute (computeTier OR cpuLimit+memoryLimit), replicas, autoscale, port, command, domain, tls, ingress path, and health/liveness/readiness/startup checks. Cost-changing fields (compute/replicas/autoscale) require confirm:true — without it the new cost is returned and nothing is applied. Redeploy the service for changes to take effect. Omitted fields are left unchanged.',
418
+ description: 'Update a service/app config (handoff §4.1/§4.2). Exposes compute (computeTier OR cpuLimit+memoryLimit), replicas, autoscale, port, command, domain, tls, ingress path, PERSISTENT VOLUMES (mounts+volumeSize — so local-disk data survives redeploys/restarts, JNG #129), and health/liveness/readiness/startup checks. Cost-changing fields (compute/replicas/autoscale/volume) require confirm:true — without it the new cost is returned and nothing is applied. Redeploy the service for changes to take effect. Omitted fields are left unchanged.',
312
419
  mutating: true,
313
420
  schema: {
314
421
  type: 'object',
@@ -324,6 +431,8 @@ export const TOOLS = [
324
431
  autoscaleMemory: int('Autoscale target memory %'),
325
432
  port: int('Container port'),
326
433
  command: { type: 'array', items: { type: 'string' }, description: 'Override the image command (argv)' },
434
+ mounts: { type: 'array', description: 'Persistent volume mounts so on-disk data (uploads, caches) survives redeploys/restarts (JNG #129). Each: {name, path} where path is the in-container dir, e.g. {name:"uploads", path:"/app/.emdash/uploads"}. Set volumeSize too. NOTE: volumes are ReadWriteOnce (single-writer) — don\'t run >1 replica/autoscale with a mount.', items: { type: 'object', properties: { name: str('Volume name'), path: str('Mount path in the container') }, required: ['name', 'path'] } },
435
+ volumeSize: str('PVC size for the mounts, e.g. "10Gi" (required when setting mounts)'),
327
436
  domain: str('Primary domain ("" clears it back to the default host)'),
328
437
  tls: { type: 'boolean', description: 'Enable/disable managed TLS on the domain' },
329
438
  path: str('Ingress path prefix'),
@@ -333,7 +442,7 @@ export const TOOLS = [
333
442
  startupPath: str('Startup probe path'),
334
443
  healthTimeoutSeconds: int('Health-check timeout (s)'),
335
444
  healthGraceSeconds: int('Health-check grace period (s)'),
336
- confirm: { type: 'boolean', description: 'Approve a cost change (compute/replicas/autoscale): without it the new cost is returned and nothing is applied.' },
445
+ confirm: { type: 'boolean', description: 'Approve a cost change (compute/replicas/autoscale/volume): without it the new cost is returned and nothing is applied.' },
337
446
  },
338
447
  required: ['appId'],
339
448
  },
@@ -342,49 +451,149 @@ export const TOOLS = [
342
451
  const body = { ...rest };
343
452
  if (computeTier) { body.billingMode = 'bundled'; body.cpuLimit = computeTier; }
344
453
  else if (args.cpuLimit || args.memoryLimit) { body.billingMode = 'unbundled'; }
345
- const costChanging = computeTier || args.cpuLimit || args.memoryLimit || args.replicas !== undefined || args.autoscaleMax !== undefined;
454
+ const costChanging = computeTier || args.cpuLimit || args.memoryLimit || args.replicas !== undefined || args.autoscaleMax !== undefined || args.autoscaleMin !== undefined || args.volumeSize !== undefined || (Array.isArray(args.mounts) && args.mounts.length > 0);
346
455
  if (costChanging && confirm !== true) {
347
- const gate = await confirmCost(api, args, { computeTier, replicas: args.replicas ?? args.autoscaleMax });
348
- if (gate) return { ...gate, change: 'update service compute/scaling', message: 'This changes cost — re-call with confirm:true to apply.' };
456
+ // Price the change against the NEW replica count (autoscale uses its ceiling) and the
457
+ // effective compute — the one being set, else the service's CURRENT compute. Without
458
+ // the current compute, an autoscale-only change estimated to IDR 0 (#123).
459
+ const replicas = args.replicas ?? args.autoscaleMax ?? args.autoscaleMin;
460
+ let spec;
461
+ if (computeTier) spec = { computeTier, replicas };
462
+ else if (args.cpuLimit || args.memoryLimit) spec = { vcpu: cpuToVcpu(args.cpuLimit), memoryMB: memToMB(args.memoryLimit), replicas };
463
+ else {
464
+ let svc = {};
465
+ try { const d = (await api.get(`/api/v1/apps/${appId}`)).data; svc = (d && d.application) || d || {}; } catch { /* best-effort */ }
466
+ // A bundled service with no explicit tier bills at the default "micro" (store.DefaultTierKey);
467
+ // using the empty cpuLimit as-is priced the gate at IDR 0 (#123).
468
+ spec = (svc.billingMode === 'unbundled' && (svc.cpuLimit || svc.memoryLimit))
469
+ ? { vcpu: cpuToVcpu(svc.cpuLimit), memoryMB: memToMB(svc.memoryLimit), replicas }
470
+ : { computeTier: svc.cpuLimit || 'micro', replicas };
471
+ }
472
+ if (args.volumeSize !== undefined) spec = { ...spec, storageGB: gbFromSize(args.volumeSize) }; // persistent-volume price in the gate (JNG #129)
473
+ const gate = await confirmCost(api, args, spec);
474
+ if (gate) return { ...gate, change: 'update service compute/scaling/volume', message: 'This changes cost — re-call with confirm:true to apply.' };
349
475
  }
350
476
  const { status, data } = await api.put(`/api/v1/apps/${appId}`, body);
351
- return redactSecrets(result(status, data));
477
+ const out = redactSecrets(result(status, data));
478
+ // Persistent volumes are ReadWriteOnce (single-writer): warn if run with >1 replica (JNG #129).
479
+ if (Array.isArray(args.mounts) && args.mounts.length > 0 && out && typeof out === 'object') {
480
+ const maxReplicas = Math.max(args.replicas || 0, args.autoscaleMax || 0);
481
+ out.volumeNote = 'Persistent volumes are ReadWriteOnce (single-writer). Keep this service at 1 replica (no autoscale) unless the volume is shareable, or pods will fail to mount it.';
482
+ if (maxReplicas > 1) out.warning = `This sets a volume AND ${maxReplicas} replicas — a ReadWriteOnce volume can't attach to multiple pods. Use 1 replica, or don't mount a volume here.`;
483
+ }
484
+ return out;
352
485
  },
353
486
  },
354
487
  {
355
488
  name: 'neo_add_service_domain',
356
- description: 'Attach an additional custom ingress domain to a service (handoff §4.1). Point the domain\'s DNS at the platform first; managed TLS is issued automatically. The default platform host is preserved.',
489
+ description: 'Attach a custom ingress domain to a service (handoff §4.1; JNG #112). Call WITHOUT confirmed to get the exact CNAME target to point DNS at — nothing is attached until the DNS resolves OR you re-call with confirmed:true (so an unpointed host can\'t attach with broken TLS). Managed TLS is issued on the next deploy. The default platform host is preserved.',
357
490
  mutating: true,
358
- schema: { type: 'object', properties: { appId: str('Service/app id'), domain: str('Custom domain host, e.g. "app.example.com"') }, required: ['appId', 'domain'] },
491
+ schema: { type: 'object', properties: { appId: str('Service/app id'), domain: str('Custom domain host, e.g. "app.example.com"'), confirmed: { type: 'boolean', description: 'Confirm DNS is pointed at the returned CNAME target; without it an unpointed host returns the CNAME instructions and attaches nothing' } }, required: ['appId', 'domain'] },
359
492
  async call(api, args) {
360
- const { status, data } = await api.post(`/api/v1/apps/${args.appId}/domains`, { domain: args.domain });
493
+ const { status, data } = await api.post(`/api/v1/apps/${args.appId}/domains`, { domain: args.domain, confirmed: !!args.confirmed });
361
494
  return result(status, data);
362
495
  },
363
496
  },
364
497
  {
365
498
  name: 'neo_remove_service_domain',
366
- description: 'Detach a custom domain from a service. The default platform host and other domains are untouched.',
499
+ description: 'Detach a custom domain from a service (JNG #112). SAFE BY DEFAULT: without confirm:true it returns a preview (what will be detached) and removes nothing; re-call with confirm:true to detach — dropping its ingress + managed TLS on the next deploy. The default platform host and other domains are untouched.',
367
500
  mutating: true,
368
501
  destructive: true,
369
- schema: { type: 'object', properties: { appId: str('Service/app id'), domain: str('Custom domain host to remove') }, required: ['appId', 'domain'] },
502
+ schema: { type: 'object', properties: { appId: str('Service/app id'), domain: str('Custom domain host to remove'), confirm: { type: 'boolean', description: 'Approve the destructive detach; without it a preview is returned and nothing is removed' } }, required: ['appId', 'domain'] },
370
503
  async call(api, args) {
371
- const { status, data } = await api.del(`/api/v1/apps/${args.appId}/domains`, { body: { domain: args.domain } });
504
+ const { status, data } = await api.del(`/api/v1/apps/${args.appId}/domains`, { body: { domain: args.domain, confirm: !!args.confirm } });
372
505
  return result(status, data);
373
506
  },
374
507
  },
375
508
  {
376
509
  name: 'neo_get_deployment',
377
- description: 'Get a deployment by id (poll this after neo_deploy until status is terminal).',
510
+ description: 'Get a deployment by id (poll this after neo_deploy until status is terminal). The build log is returned NEWEST-FIRST and TRUNCATED to the last 200 lines by default so the failure reason is at the top and huge logs do not overflow the tool output (JNG #124, #117). Use tail to change the line count, full:true for the entire log, order:"asc" for chronological build output. logTotalLines/logTruncated report how much was cut.',
378
511
  mutating: false,
379
- schema: { type: 'object', properties: { deploymentId: str('Deployment id') }, required: ['deploymentId'] },
512
+ schema: {
513
+ type: 'object',
514
+ properties: {
515
+ deploymentId: str('Deployment id'),
516
+ order: { type: 'string', enum: ['desc', 'asc'], description: 'build-log order: desc = newest-first (default); asc = oldest-first (chronological)' },
517
+ tail: int('Number of build-log lines to return (default 200, newest end); ignored when full:true'),
518
+ full: { type: 'boolean', description: 'Return the entire build log instead of the last `tail` lines' },
519
+ },
520
+ required: ['deploymentId'],
521
+ },
522
+ async call(api, args) {
523
+ const dep = (await api.get(`/api/v1/deployments/${args.deploymentId}`)).data;
524
+ if (dep && typeof dep.log === 'string' && dep.log) {
525
+ let lines = dep.log.replace(/\n$/, '').split('\n');
526
+ dep.logTotalLines = lines.length;
527
+ const tail = args.full ? lines.length : (args.tail > 0 ? args.tail : 200);
528
+ dep.logTruncated = !args.full && lines.length > tail;
529
+ lines = lines.slice(-tail); // keep the newest `tail` lines
530
+ if (args.order !== 'asc') lines.reverse(); // newest-first by default
531
+ dep.log = lines.join('\n');
532
+ }
533
+ return dep;
534
+ },
535
+ },
536
+ {
537
+ name: 'neo_wait_deployment',
538
+ description: 'Block until a deployment reaches a terminal state (success | failed) or the timeout — polls server-side so you do NOT sleep/guess or re-pull the whole build log (JNG #121). Returns status, error, the last ~50 build-log lines (newest-first), logTotalLines, and rolloutReady (whether the service has a Ready pod after a successful build — the new revision is actually serving). One call replaces a poll loop.',
539
+ mutating: false,
540
+ schema: {
541
+ type: 'object',
542
+ properties: {
543
+ deploymentId: str('Deployment id (from neo_deploy / neo_deploy_folder)'),
544
+ timeoutSeconds: int('Max wait in seconds (default 300)'),
545
+ },
546
+ required: ['deploymentId'],
547
+ },
380
548
  async call(api, args) {
381
- return (await api.get(`/api/v1/deployments/${args.deploymentId}`)).data;
549
+ const deadline = Date.now() + (args.timeoutSeconds > 0 ? args.timeoutSeconds : 300) * 1000;
550
+ const terminal = new Set(['success', 'failed']);
551
+ let dep = {};
552
+ for (;;) {
553
+ dep = (await api.get(`/api/v1/deployments/${args.deploymentId}`)).data || {};
554
+ if (terminal.has(dep.status) || Date.now() >= deadline) break;
555
+ await new Promise((r) => setTimeout(r, 4000));
556
+ }
557
+ const lines = String(dep.log || '').replace(/\n$/, '').split('\n').filter((l) => l.length);
558
+ const out = {
559
+ deploymentId: args.deploymentId,
560
+ status: dep.status,
561
+ error: dep.error || undefined,
562
+ logTotalLines: lines.length,
563
+ buildLogTail: lines.length ? lines.slice(-50).reverse().join('\n') : undefined, // newest-first
564
+ };
565
+ if (!terminal.has(dep.status)) {
566
+ out.timedOut = true;
567
+ out.note = 'still in progress at timeout — call neo_wait_deployment again';
568
+ return out;
569
+ }
570
+ // Best-effort: after a successful BUILD, wait until the service has a Ready pod so the
571
+ // caller knows the new revision is actually serving (not just built) — JNG #121.
572
+ if (dep.status === 'success') {
573
+ try {
574
+ const appId = dep.applicationId || dep.composeAppId;
575
+ const pid = dep.projectId;
576
+ let svcName = '';
577
+ try { const a = (await api.get(`/api/v1/apps/${appId}`)).data; svcName = ((a && a.application) || a || {}).name || ''; } catch { /* name best-effort */ }
578
+ const isReady = (p) => { const m = /^(\d+)\/(\d+)$/.exec(p.ready || ''); return p.status === 'Running' && m && +m[1] > 0 && m[1] === m[2]; };
579
+ if (pid) {
580
+ for (;;) {
581
+ const pods = (await api.get(`/api/v1/projects/${pid}/pods`)).data?.pods || [];
582
+ const mine = svcName ? pods.filter((p) => p.service === svcName) : pods;
583
+ if (mine.some(isReady)) { out.rolloutReady = true; break; }
584
+ if (Date.now() >= deadline) { out.rolloutReady = false; out.note = 'build succeeded but no Ready pod yet at timeout'; break; }
585
+ await new Promise((r) => setTimeout(r, 4000));
586
+ }
587
+ }
588
+ } catch { /* rollout check best-effort */ }
589
+ }
590
+ return out;
382
591
  },
383
592
  },
384
593
  {
385
594
  name: 'neo_deploy',
386
595
  description:
387
- 'Trigger a deployment of an app (service). Returns a deployment to poll, or a pending_approval result when org policy gates it. Deploy to staging first.',
596
+ 'Trigger a deployment of an app (service). Returns a deployment id — pass it to neo_wait_deployment to block until it finishes (don\'t sleep/guess), or a pending_approval result when org policy gates it. Deploy to staging first.',
388
597
  mutating: true,
389
598
  schema: { type: 'object', properties: { appId: str('App/service id') }, required: ['appId'] },
390
599
  async call(api, args) {
@@ -394,59 +603,150 @@ export const TOOLS = [
394
603
  },
395
604
  {
396
605
  name: 'neo_get_logs',
397
- description: 'Read recent logs for a service in a project (plain text). Summarize; redact secrets.',
606
+ description: 'Read recent logs for a service in a project (plain text), NEWEST-FIRST by default so the latest error is at the top (JNG #124). Pass order:"asc" for oldest-first/chronological. Each line is prefixed with the kubelet UTC timestamp. Target one pod with `pod` (from neo_list_pods). When no pod exists (a failed build), the latest deployment BUILD log is returned instead (JNG #117). Summarize; redact secrets.',
398
607
  mutating: false,
399
608
  schema: {
400
609
  type: 'object',
401
610
  properties: {
402
611
  projectId: str('Project id'),
403
612
  service: str('Service/app name (optional)'),
613
+ pod: str('Pod name to read (optional; from neo_list_pods) — otherwise the first pod of the service'),
404
614
  tail: int('Number of lines (default 200)'),
615
+ order: { type: 'string', enum: ['desc', 'asc'], description: 'desc = newest-first (default); asc = oldest-first (chronological)' },
405
616
  },
406
617
  required: ['projectId'],
407
618
  },
408
619
  async call(api, args) {
409
620
  const { data } = await api.get(`/api/v1/projects/${args.projectId}/logs`, {
410
- query: { service: args.service, tail: args.tail },
621
+ query: { service: args.service, pod: args.pod, tail: args.tail, order: args.order },
411
622
  accept: 'text/plain',
412
623
  });
413
624
  return typeof data === 'string' ? data : JSON.stringify(data);
414
625
  },
415
626
  },
627
+ {
628
+ name: 'neo_list_pods',
629
+ description: 'List the running pods for a project (optionally one service) so you can see what is ACTUALLY running, not just the desired config (JNG #130): per pod — name, service, ready (readyContainers/total), status, restarts, image (which build), startedAt (UTC), node, and reason (ImagePullBackOff/CrashLoopBackOff when unhealthy). Use a pod name with neo_get_logs to target it.',
630
+ mutating: false,
631
+ schema: {
632
+ type: 'object',
633
+ properties: {
634
+ projectId: str('Project id'),
635
+ service: str('Service/app name to filter by (optional)'),
636
+ },
637
+ required: ['projectId'],
638
+ },
639
+ async call(api, args) {
640
+ const pods = (await api.get(`/api/v1/projects/${args.projectId}/pods`)).data?.pods || [];
641
+ const filtered = args.service ? pods.filter((p) => p.service === args.service) : pods;
642
+ return { pods: filtered, running: filtered.filter((p) => p.status === 'Running').length, total: filtered.length };
643
+ },
644
+ },
416
645
  {
417
646
  name: 'neo_list_variables',
418
- description: 'List environment variable names/values for a project environment. Secret values are masked by the server.',
647
+ description: 'List the project/environment DEFAULT environment variables. Uses the session-pinned environment (neo_set_context) unless you pass environment explicitly; fails closed if neither is set — it is never defaulted to production (JNG #136). These are inherited by every service in that environment. For what ONE service actually receives — including per-service vars and binding-injected ones — use neo_get_effective_env. Secret values are masked by the server.',
419
648
  mutating: false,
420
649
  schema: {
421
650
  type: 'object',
422
651
  properties: {
423
652
  projectId: str('Project id'),
424
- environment: str('production or staging (default production)'),
653
+ environment: str('production or staging (optional; defaults to the session pin — errors if neither is set, never silently production)'),
654
+ ...allowCrossEnvProp,
425
655
  },
426
656
  required: ['projectId'],
427
657
  },
428
658
  async call(api, args) {
429
- const env = args.environment || 'production';
430
- return (await api.get(`/api/v1/projects/${args.projectId}/environments/${env}/config`)).data;
659
+ const env = requireEnv(args.environment, args.allowCrossEnvironment);
660
+ return { environment: env, ...(await api.get(`/api/v1/projects/${args.projectId}/environments/${env}/config`)).data };
661
+ },
662
+ },
663
+ {
664
+ name: 'neo_get_effective_env',
665
+ description: 'Show the EFFECTIVE environment of one service (JNG #130): every variable name the container actually receives, each tagged with its source (environment | service | binding:<name>) and the value masked when it is secret. Unlike neo_list_variables (project/env defaults only), this includes per-service variables AND binding-injected ones (DATABASE_URL, S3_*, NATS_*).',
666
+ mutating: false,
667
+ schema: {
668
+ type: 'object',
669
+ properties: { appId: str('Service/app id (from neo_list_services)') },
670
+ required: ['appId'],
671
+ },
672
+ async call(api, args) {
673
+ // Server already masks secret VALUES (value:"***", secret:true); don't run redactSecrets
674
+ // here — it would clobber our own `source`/`secret` field names (JNG #130).
675
+ return (await api.get(`/api/v1/apps/${args.appId}/effective-env`)).data;
676
+ },
677
+ },
678
+ {
679
+ name: 'neo_restart_service',
680
+ description: 'Rolling-restart a service with its CURRENT image + config — no rebuild (JNG #128). Use after a config-only change (neo_set_service_variable, neo_update_service) that doesn\'t need a new build, instead of a full neo_deploy. Returns immediately; poll neo_list_pods for the new pods.',
681
+ mutating: true,
682
+ schema: { type: 'object', properties: { appId: str('Service/app id (from neo_list_services)') }, required: ['appId'] },
683
+ async call(api, args) {
684
+ return (await api.post(`/api/v1/apps/${args.appId}/restart`, {})).data;
685
+ },
686
+ },
687
+ {
688
+ name: 'neo_exec',
689
+ description: 'Run a one-off shell command in a running pod of a service and return stdout/stderr/exitCode (JNG #128) — e.g. command:"env | grep ^S3_" to see injected vars, or "ls -la". Service-level secret values are masked in the output (best-effort). Not a shell session; for interactive use the portal terminal. CAUTION: runs real commands in the live pod.',
690
+ mutating: true,
691
+ schema: {
692
+ type: 'object',
693
+ properties: {
694
+ appId: str('Service/app id (from neo_list_services)'),
695
+ command: str('Shell command to run (executed via /bin/sh -c)'),
696
+ pod: str('Specific pod name (optional; from neo_list_pods) — else the first running pod'),
697
+ timeoutSeconds: int('Max seconds to wait (default 30, max 120)'),
698
+ },
699
+ required: ['appId', 'command'],
700
+ },
701
+ async call(api, args) {
702
+ const { appId, ...body } = args;
703
+ return (await api.post(`/api/v1/apps/${appId}/exec`, body)).data;
704
+ },
705
+ },
706
+ {
707
+ name: 'neo_get_database_metrics',
708
+ description: 'Current resource usage for a managed postgres database (JNG #133): cpuMillicores + memoryBytes (metrics-server), databaseSizeBytes vs volumeSize (is it filling up?), and connections / maxConnections / connectionsPercent. Snapshot only (no history). Use before a migration or resize to check headroom.',
709
+ mutating: false,
710
+ schema: { type: 'object', properties: { databaseId: str('Database id (postgres, deployed)') }, required: ['databaseId'] },
711
+ async call(api, args) {
712
+ return (await api.get(`/api/v1/databases/${args.databaseId}/metrics`)).data;
713
+ },
714
+ },
715
+ {
716
+ name: 'neo_db_query',
717
+ description: 'Run a SQL query inside a managed postgres database\'s primary pod and return the result (JNG #128). READ-ONLY by default (the session is set read-only, so any write errors); pass readOnly:false to allow writes (CAUTION). Credentials are never exposed. The database must be deployed. Use for quick checks like "SELECT count(*) FROM sessions;".',
718
+ mutating: true,
719
+ schema: {
720
+ type: 'object',
721
+ properties: {
722
+ databaseId: str('Database id (postgres, deployed)'),
723
+ sql: str('SQL to run (piped into psql)'),
724
+ readOnly: { type: 'boolean', description: 'true (default) runs in a read-only session; false allows writes' },
725
+ },
726
+ required: ['databaseId', 'sql'],
727
+ },
728
+ async call(api, args) {
729
+ return (await api.post(`/api/v1/databases/${args.databaseId}/query`, { sql: args.sql, readOnly: args.readOnly })).data;
431
730
  },
432
731
  },
433
732
  {
434
733
  name: 'neo_set_variable',
435
734
  description:
436
- 'Set one environment variable for a project environment (read-merge-write; other vars preserved). Never echo secret values.',
735
+ 'Set one environment variable for a project environment (read-merge-write; other vars preserved). Uses the session-pinned environment (neo_set_context) unless you pass environment explicitly; fails closed if neither is set — never defaulted to production (JNG #136). Never echo secret values.',
437
736
  mutating: true,
438
737
  schema: {
439
738
  type: 'object',
440
739
  properties: {
441
740
  projectId: str('Project id'),
442
- environment: str('production or staging (default production)'),
741
+ environment: str('production or staging (optional; defaults to the session pin — errors if neither is set, never silently production)'),
742
+ ...allowCrossEnvProp,
443
743
  key: str('Variable name'),
444
744
  value: str('Variable value'),
445
745
  },
446
746
  required: ['projectId', 'key', 'value'],
447
747
  },
448
748
  async call(api, args) {
449
- const env = args.environment || 'production';
749
+ const env = requireEnv(args.environment, args.allowCrossEnvironment);
450
750
  const path = `/api/v1/projects/${args.projectId}/environments/${env}/config`;
451
751
  // The PUT replaces the whole set, so merge current vars first.
452
752
  const current = (await api.get(path)).data?.vars || [];
@@ -457,7 +757,7 @@ export const TOOLS = [
457
757
  const out = result(status, data);
458
758
  // Don't return the value we just set.
459
759
  if (out && out.vars) out.vars = out.vars.map((v) => (v.key === args.key ? { ...v, value: '••••••••' } : v));
460
- return out;
760
+ return { environment: env, ...out };
461
761
  },
462
762
  },
463
763
  {
@@ -682,6 +982,7 @@ export const TOOLS = [
682
982
  publishDirectory: str('static_site: directory of built files to serve via nginx (e.g. "dist"); enables native static hosting'),
683
983
  port: int('Container port (optional; ignored for worker)'),
684
984
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production. Never defaulted (handoff §2). Deploy to staging first.' },
985
+ ...allowCrossEnvProp,
685
986
  dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93). Returns a confirmToken.' },
686
987
  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).'),
687
988
  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.' },
@@ -689,7 +990,7 @@ export const TOOLS = [
689
990
  required: ['projectId', 'path', 'environment'],
690
991
  },
691
992
  async call(api, args) {
692
- const environment = requireEnv(args.environment);
993
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment);
693
994
  const p = resolve(args.path);
694
995
  let src;
695
996
  try {
@@ -713,9 +1014,11 @@ export const TOOLS = [
713
1014
  confirm: args.confirm === true,
714
1015
  dryRun: !!args.dryRun,
715
1016
  });
716
- 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.' };
1017
+ if (r.dryRun) return { status: r.requiresConfirmation ? 'requires_confirmation' : 'dry_run', requiresConfirmation: r.requiresConfirmation || undefined, plan: r.plan, service: r.service, environment: r.environment, acting: r.acting, estimatedCost: r.estimatedCost, confirmToken: r.confirmToken, note: r.note || 'To deploy exactly this, re-call without dryRun and pass this confirmToken.' };
717
1018
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
718
- return { status: r.result, url: r.url || undefined, appId: r.appId };
1019
+ // Always return the deploymentId; on failure include the error + build-log tail so
1020
+ // the caller can diagnose without a second build (JNG #117).
1021
+ return { environment, status: r.result, url: r.url || undefined, appId: r.appId, deploymentId: r.deploymentId, error: r.error, buildLogTail: r.buildLogTail };
719
1022
  },
720
1023
  },
721
1024
  {
@@ -750,6 +1053,7 @@ export const TOOLS = [
750
1053
  engine: { type: 'string', description: 'postgres | mysql (engine)' },
751
1054
  version: str('Engine version, e.g. "16" (optional)'),
752
1055
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1056
+ ...allowCrossEnvProp,
753
1057
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
754
1058
  replicas: int('Read/failover replicas for HA (optional; postgres/CNPG only)'),
755
1059
  deploymentMode: { type: 'string', description: 'single | ha (optional)' },
@@ -759,12 +1063,14 @@ export const TOOLS = [
759
1063
  required: ['projectId', 'name', 'engine', 'environment'],
760
1064
  },
761
1065
  async call(api, args) {
762
- requireEnv(args.environment);
1066
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1067
+ validateDbArgs(args); // reject sub-10Gi / EOL config before pricing (JNG #132)
763
1068
  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 });
764
- if (gate.requiresConfirmation) return gate;
1069
+ if (gate.requiresConfirmation) return { environment, ...gate };
765
1070
  const { projectId, confirm, confirmationId, orgId, ...body } = args;
1071
+ body.environment = environment; // send the resolved env so the pin actually targets it
766
1072
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/databases${gate.query}`, body);
767
- return redactSecrets(result(status, data));
1073
+ return { environment, ...redactSecrets(result(status, data)) };
768
1074
  },
769
1075
  },
770
1076
  {
@@ -922,6 +1228,7 @@ export const TOOLS = [
922
1228
  name: str('Queue name'),
923
1229
  planClass: str('Plan/engine class (optional)'),
924
1230
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1231
+ ...allowCrossEnvProp,
925
1232
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
926
1233
  dlqEnabled: { type: 'boolean', description: 'Enable a dead-letter queue (optional, default true). Pass false to disable — this is now honored.' },
927
1234
  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.' },
@@ -930,12 +1237,13 @@ export const TOOLS = [
930
1237
  required: ['projectId', 'name', 'environment'],
931
1238
  },
932
1239
  async call(api, args) {
933
- requireEnv(args.environment);
1240
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
934
1241
  const gate = await billableGate(api, { projectId: args.projectId, kind: 'queue', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { storageGB: gbFromSize(args.volumeSize) });
935
- if (gate.requiresConfirmation) return gate;
1242
+ if (gate.requiresConfirmation) return { environment, ...gate };
936
1243
  const { projectId, confirm, confirmationId, orgId, ...body } = args;
1244
+ body.environment = environment; // send the resolved env so the pin actually targets it
937
1245
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/queues${gate.query}`, body);
938
- return redactSecrets(result(status, data));
1246
+ return { environment, ...redactSecrets(result(status, data)) };
939
1247
  },
940
1248
  },
941
1249
  {
@@ -1062,22 +1370,24 @@ export const TOOLS = [
1062
1370
  retryCount: int('Retries on failure (optional) → backoffLimit'),
1063
1371
  concurrencyPolicy: { type: 'string', enum: ['Allow', 'Forbid', 'Replace'], description: 'Overlap policy if a run is still going when the next is due (optional, default Allow)' },
1064
1372
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1373
+ ...allowCrossEnvProp,
1065
1374
  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.' },
1066
1375
  confirmationId: str('An APPROVED billable_create confirmation id (from neo_confirm_action) — re-call with it to create. Alternative to confirm:true.'),
1067
1376
  },
1068
1377
  required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
1069
1378
  },
1070
1379
  async call(api, args) {
1071
- requireEnv(args.environment);
1380
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1072
1381
  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
1073
- if (gate.requiresConfirmation) return gate;
1382
+ if (gate.requiresConfirmation) return { environment, ...gate };
1074
1383
  // Merge contentType into headers for convenience (unless already set).
1075
1384
  if (args.contentType) {
1076
1385
  args = { ...args, headers: { 'Content-Type': args.contentType, ...(args.headers || {}) } };
1077
1386
  }
1078
1387
  const { projectId, confirm, confirmationId, orgId, contentType, ...body } = args;
1388
+ body.environment = environment; // send the resolved env so the pin actually targets it
1079
1389
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons${gate.query}`, body);
1080
- return redactSecrets(result(status, data));
1390
+ return { environment, ...redactSecrets(result(status, data)) };
1081
1391
  },
1082
1392
  },
1083
1393
  {
@@ -1119,7 +1429,7 @@ export const TOOLS = [
1119
1429
  },
1120
1430
  {
1121
1431
  name: 'neo_create_bucket',
1122
- description: 'Create an S3-compatible object-storage bucket in a project (handoff 4.7). Access keys are provisioned server-side and are NOT returned (write-only). May return pending_approval.',
1432
+ description: 'Create an S3-compatible object-storage bucket in a project (handoff 4.7). Buckets are project-scoped and SHARED across environments — they carry no staging/production environment (scope:"shared"), unlike services/databases (JNG #136). Access keys are provisioned server-side and are NOT returned (write-only). May return pending_approval.',
1123
1433
  mutating: true,
1124
1434
  schema: {
1125
1435
  type: 'object',
@@ -1135,11 +1445,13 @@ export const TOOLS = [
1135
1445
  required: ['projectId', 'name'],
1136
1446
  },
1137
1447
  async call(api, args) {
1448
+ validateBucketArgs(args); // reject sub-10GB quota before pricing (JNG #132)
1138
1449
  const gate = await billableGate(api, { projectId: args.projectId, kind: 'bucket', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { objectStorageGB: gbFromSize(args.quotaGB) });
1139
1450
  if (gate.requiresConfirmation) return gate;
1140
1451
  const { projectId, confirm, confirmationId, orgId, ...body } = args;
1141
1452
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/buckets${gate.query}`, body);
1142
- return redactSecrets(result(status, data));
1453
+ // Buckets have no environment — label the scope so the agent doesn't expect one (JNG #136).
1454
+ return { scope: 'shared', ...redactSecrets(result(status, data)) };
1143
1455
  },
1144
1456
  },
1145
1457
  {
@@ -1220,7 +1532,7 @@ export const TOOLS = [
1220
1532
  {
1221
1533
  name: 'neo_bind_resource',
1222
1534
  description:
1223
- 'Bind a managed database, queue, or object-storage bucket to a consumer service so its connection is injected as environment variable(s) at the consumer\'s next deploy — WITHOUT ever exposing the secret to you (handoff 5, write-only). envKey defaults: DATABASE_URL (neo_db), NATS_URL (neo_queue); for neo_object_storage it is a PREFIX (default S3) injecting <P>_ENDPOINT/_BUCKET/_REGION + secret <P>_ACCESS_KEY/_SECRET_KEY. After binding, redeploy the consumer. Target and consumer must be in the same project.',
1535
+ 'Bind a managed database, queue, or object-storage bucket to a consumer service so its connection is injected as environment variable(s) at the consumer\'s next deploy — WITHOUT ever exposing the secret to you (handoff 5, write-only). envKey defaults: DATABASE_URL (neo_db), NATS_URL (neo_queue); for neo_object_storage it is a PREFIX (default S3) injecting <P>_ENDPOINT/_BUCKET/_REGION + secret creds under both <P>_ACCESS_KEY/_SECRET_KEY and the AWS-SDK names <P>_ACCESS_KEY_ID/_SECRET_ACCESS_KEY (use envKey="AWS" for AWS_* names). <P>_ENDPOINT is the public S3 host apps can actually reach. After binding, redeploy the consumer. Target and consumer must be in the same project.',
1224
1536
  mutating: true,
1225
1537
  schema: {
1226
1538
  type: 'object',
@@ -1312,6 +1624,7 @@ export const TOOLS = [
1312
1624
  properties: {
1313
1625
  projectId: str('Project id'),
1314
1626
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1627
+ ...allowCrossEnvProp,
1315
1628
  resources: {
1316
1629
  type: 'array',
1317
1630
  description: 'The stack resources. Each needs a unique name + kind (app | database | queue | bucket | cron).',
@@ -1373,10 +1686,12 @@ export const TOOLS = [
1373
1686
  required: ['projectId', 'environment', 'resources'],
1374
1687
  },
1375
1688
  async call(api, args) {
1376
- requireEnv(args.environment);
1689
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1377
1690
  const { projectId, ...body } = args;
1691
+ body.environment = environment; // send the resolved env so the pin actually targets it
1378
1692
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/plans`, body);
1379
- return result(status, data);
1693
+ const out = result(status, data);
1694
+ return (out && typeof out === 'object') ? { environment, ...out } : out;
1380
1695
  },
1381
1696
  },
1382
1697
  {
@@ -1563,12 +1878,35 @@ export const TOOLS = [
1563
1878
  },
1564
1879
  {
1565
1880
  name: 'neo_delete_bucket',
1566
- description: 'Permanently delete an object-storage bucket. Destructive and irreversible — bucket contents are lost.',
1881
+ description: 'Delete an object-storage bucket and all its objects. SAFE BY DEFAULT: with no confirmationId it returns the bucket details + a server-side CONFIRMATION, deleting NOTHING. Then: show the user, call neo_confirm_action(confirmationId, "approve", confirmationPhrase=<EXACT bucket name>), then re-call with that confirmationId. Server-enforced, single-use, exact-name (JNG #138). Destructive and irreversible — bucket contents are lost.',
1567
1882
  mutating: true,
1568
1883
  destructive: true,
1569
- schema: { type: 'object', properties: { bucketId: str('Bucket id') }, required: ['bucketId'] },
1884
+ schema: {
1885
+ type: 'object',
1886
+ properties: {
1887
+ bucketId: str('Bucket id'),
1888
+ confirmationId: str('An APPROVED confirmation id (from neo_confirm_action). Omit to get the confirmation.'),
1889
+ },
1890
+ required: ['bucketId'],
1891
+ },
1570
1892
  async call(api, args) {
1571
- return deleteAndConverge(api, `/api/v1/buckets/${args.bucketId}`, args.bucketId, 'bucket');
1893
+ if (args.confirmationId) {
1894
+ return deleteAndConverge(api, `/api/v1/buckets/${args.bucketId}`, args.bucketId, 'bucket', `?confirmationId=${encodeURIComponent(args.confirmationId)}`);
1895
+ }
1896
+ let bk = null;
1897
+ try { bk = (await api.get(`/api/v1/buckets/${args.bucketId}`)).data; } catch { /* preview best-effort */ }
1898
+ let c;
1899
+ try { c = (await api.post('/api/v1/confirmations', { action: 'bucket_delete', targetId: args.bucketId })).data; }
1900
+ catch (e) { return { deleted: false, error: 'confirmation_failed', message: 'Could not create a deletion confirmation: ' + (e.message || e) }; }
1901
+ const bkView = (c.summary && c.summary.bucket) || (bk ? { id: bk.id, name: bk.name, region: bk.region } : { id: args.bucketId });
1902
+ const phrase = (c.summary && c.summary.phraseRequired) || bkView.name;
1903
+ return {
1904
+ deleted: false, requiresConfirmation: true,
1905
+ bucket: bkView,
1906
+ warning: 'IRREVERSIBLE: deletes the bucket AND all objects in it.',
1907
+ confirmationId: c.confirmationId, phraseRequired: phrase,
1908
+ message: `To delete: call neo_confirm_action("${c.confirmationId}", "approve", confirmationPhrase="${phrase}") — the human must type the exact bucket name — then re-call neo_delete_bucket with confirmationId="${c.confirmationId}".`,
1909
+ };
1572
1910
  },
1573
1911
  },
1574
1912
  {
@@ -18,8 +18,10 @@ NEO App is Biznet Gio's managed-app platform. You operate it two ways:
18
18
 
19
19
  ## Environments: staging first
20
20
 
21
+ - **Pin ONE environment for the session up front**: call `neo_set_context({environment})` once (confirm `neo_get_context` shows the right account + environment). After that, every deploy/create **and read** (`neo_list_variables`, `neo_set_variable`) inherits it — don't mix staging and production in one task. If you haven't pinned and don't pass `environment`, the tools fail closed (`environment_not_chosen`) asking you to choose — they are never defaulted to production.
22
+ - Once pinned, a call for a **different** environment is refused (`environment_mismatch`). That guard is deliberate: start a new session or re-pin to switch. Only when you truly must touch the other environment in the same session, pass `allowCrossEnvironment:true` on that one call. Buckets carry no environment (`scope:"shared"`).
21
23
  - Prefer **staging** for every change: deploy, test, and read logs on staging before touching production.
22
- - Only deploy to production when the user explicitly asks, and expect it to require approval (see below).
24
+ - Only deploy to production when the user explicitly asks (pin `production` deliberately), and expect it to require approval (see below).
23
25
 
24
26
  ## Deployments
25
27