@biznetgio/neo-app 0.4.49 → 0.4.64

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.64",
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,56 @@ 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
+
71
+ // assertResourceEnv enforces the session pin on an EXISTING resource addressed by id (JNG #136 §4):
72
+ // if the session is pinned and the resource lives in a different environment, refuse so an
73
+ // ID-based op can't touch the wrong environment's resource. allowCrossEnvironment overrides it;
74
+ // resources with no environment (buckets are shared) and an unpinned session are not guarded.
75
+ async function assertResourceEnv(api, getPath, allowCross = false) {
76
+ if (!sessionEnv || allowCross) return;
77
+ let env;
78
+ try { env = (await api.get(getPath)).data?.environment; } catch { return; } // can't resolve → don't block
79
+ if (env && env !== sessionEnv) {
80
+ throw new Error(`environment_mismatch: this session is pinned to "${sessionEnv}" but that resource is in "${env}". Staging and production must not be mixed (JNG #136). Pass allowCrossEnvironment:true to act on it anyway, or re-pin with neo_set_context.`);
81
+ }
82
+ }
83
+
42
84
  // deleteAndConverge deletes a resource, then polls its endpoint until the inventory
43
85
  // actually converges (GET → 404), so a delete isn't reported done while the record
44
86
  // still exists. Returns a durable operation reference (`del-<id>`) + convergence
@@ -59,6 +101,22 @@ async function deleteAndConverge(api, path, resourceId, kind, delQuery = '') {
59
101
  return { operationId: 'del-' + resourceId, kind, status: (data && data.status) || 'deleted', converged, convergenceChecks: checks };
60
102
  }
61
103
 
104
+ // cpuToVcpu parses a k8s CPU quantity ("500m" → 0.5, "1"/"2" → vCPUs) for cost estimates.
105
+ function cpuToVcpu(v) {
106
+ if (!v) return 0;
107
+ const s = String(v).trim();
108
+ return s.endsWith('m') ? (parseFloat(s) || 0) / 1000 : parseFloat(s) || 0;
109
+ }
110
+ // memToMB parses a k8s memory quantity ("512Mi"/"1Gi"/"512M"/"1G") → MB for cost estimates.
111
+ function memToMB(v) {
112
+ if (!v) return 0;
113
+ const m = String(v).match(/([\d.]+)\s*(Gi|Mi|G|M)?/i);
114
+ if (!m) return 0;
115
+ const n = parseFloat(m[1]) || 0;
116
+ const u = (m[2] || 'Mi').toLowerCase();
117
+ return u === 'gi' || u === 'g' ? n * 1024 : n;
118
+ }
119
+
62
120
  // gbFromSize parses "10Gi"/"10G"/"10" → 10 (integer GB), else undefined.
63
121
  function gbFromSize(v) {
64
122
  if (v == null) return undefined;
@@ -66,6 +124,31 @@ function gbFromSize(v) {
66
124
  return m ? parseInt(m[1], 10) : undefined;
67
125
  }
68
126
 
127
+ // Portal-equivalent limits mirrored here so a quote rejects an invalid config BEFORE
128
+ // showing a price (JNG #132). The server re-enforces these on create (authoritative).
129
+ const MIN_DB_VOLUME_GB = 10;
130
+ const MIN_BUCKET_QUOTA_GB = 10;
131
+ const MIN_PG_MAJOR = 13;
132
+ function validateDbArgs(args) {
133
+ const gb = gbFromSize(args.volumeSize);
134
+ 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}`);
135
+ if (args.engine === 'postgres' && args.version) {
136
+ const maj = parseInt(String(args.version), 10);
137
+ if (maj > 0 && maj < MIN_PG_MAJOR) throw new Error(`PostgreSQL ${args.version} is end-of-life; use ${MIN_PG_MAJOR} or newer`);
138
+ }
139
+ }
140
+ // actingOf returns the signed-in account merged with scope (org/project/env) for a
141
+ // confirmation/dry-run response (JNG #131). Tolerant of an api without actingIdentity.
142
+ async function actingOf(api, extra) {
143
+ let base = {};
144
+ try { if (typeof api.actingIdentity === 'function') base = await api.actingIdentity(); } catch { /* best-effort */ }
145
+ return { ...base, ...extra };
146
+ }
147
+ function validateBucketArgs(args) {
148
+ const gb = args.quotaGB;
149
+ 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}`);
150
+ }
151
+
69
152
  // confirmCost is the Approve & Create gate for billable one-shot creates: unless
70
153
  // args.confirm===true it returns the live-priced cost and creates NOTHING — the
71
154
  // agent shows the user, then re-calls with confirm:true. plan/apply remains the
@@ -82,8 +165,9 @@ async function confirmCost(api, args, spec) {
82
165
  return {
83
166
  requiresConfirmation: true,
84
167
  cost,
168
+ acting: await actingOf(api, { orgId, projectId: args.projectId }), // whose/where — JNG #131
85
169
  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.',
170
+ 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
171
  };
88
172
  }
89
173
 
@@ -128,9 +212,10 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
128
212
  return {
129
213
  requiresConfirmation: true,
130
214
  cost,
215
+ acting: await actingOf(api, { orgId, projectId }), // whose/where — JNG #131
131
216
  priceBook: orgId ? `org:${orgId}` : 'global-defaults',
132
217
  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.`,
218
+ 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
219
  };
135
220
  }
136
221
 
@@ -161,6 +246,30 @@ export const TOOLS = [
161
246
  return api.whoami();
162
247
  },
163
248
  },
249
+ {
250
+ name: 'neo_set_context',
251
+ 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.',
252
+ mutating: false,
253
+ schema: {
254
+ type: 'object',
255
+ properties: { environment: { type: 'string', enum: ['staging', 'production'], description: 'staging | production — the one environment this session targets' } },
256
+ required: ['environment'],
257
+ },
258
+ async call(api, args) {
259
+ sessionEnv = envStrict(args.environment);
260
+ return { environment: sessionEnv, pinned: true, note: 'Pinned for this session. Env-requiring tools now use it; override per-call by passing environment explicitly.' };
261
+ },
262
+ },
263
+ {
264
+ name: 'neo_get_context',
265
+ 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.',
266
+ mutating: false,
267
+ schema: { type: 'object', properties: {} },
268
+ async call(api) {
269
+ const acct = (typeof api.actingIdentity === 'function') ? await api.actingIdentity().catch(() => ({})) : {};
270
+ return { environment: sessionEnv, ...acct };
271
+ },
272
+ },
164
273
  {
165
274
  name: 'neo_get_capabilities',
166
275
  description:
@@ -208,6 +317,17 @@ export const TOOLS = [
208
317
  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
318
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
210
319
  environments: ['staging', 'production'],
320
+ // Active session context + the one-environment-per-session invariant (JNG #136).
321
+ context: { environment: sessionEnv, pinned: !!sessionEnv },
322
+ environmentIsolation: {
323
+ supported: true,
324
+ pinnedBy: ['neo_set_context', 'first explicit environment on an env-requiring call'],
325
+ failsClosed: true, // env-requiring tools error (environment_not_chosen) instead of defaulting
326
+ refusesMixing: true, // an explicit env != the pin is rejected (environment_mismatch)
327
+ escapeHatch: 'allowCrossEnvironment:true (per call)',
328
+ bucketScope: 'shared', // buckets carry no environment
329
+ 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.',
330
+ },
211
331
  pricing,
212
332
  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
333
  // Project/organization lifecycle (issue #107 §3). Honest to THIS build: project
@@ -308,7 +428,7 @@ export const TOOLS = [
308
428
  },
309
429
  {
310
430
  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.',
431
+ 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
432
  mutating: true,
313
433
  schema: {
314
434
  type: 'object',
@@ -324,6 +444,8 @@ export const TOOLS = [
324
444
  autoscaleMemory: int('Autoscale target memory %'),
325
445
  port: int('Container port'),
326
446
  command: { type: 'array', items: { type: 'string' }, description: 'Override the image command (argv)' },
447
+ 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'] } },
448
+ volumeSize: str('PVC size for the mounts, e.g. "10Gi" (required when setting mounts)'),
327
449
  domain: str('Primary domain ("" clears it back to the default host)'),
328
450
  tls: { type: 'boolean', description: 'Enable/disable managed TLS on the domain' },
329
451
  path: str('Ingress path prefix'),
@@ -333,120 +455,319 @@ export const TOOLS = [
333
455
  startupPath: str('Startup probe path'),
334
456
  healthTimeoutSeconds: int('Health-check timeout (s)'),
335
457
  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.' },
458
+ confirm: { type: 'boolean', description: 'Approve a cost change (compute/replicas/autoscale/volume): without it the new cost is returned and nothing is applied.' },
459
+ ...allowCrossEnvProp,
337
460
  },
338
461
  required: ['appId'],
339
462
  },
340
463
  async call(api, args) {
341
- const { appId, confirm, orgId, computeTier, ...rest } = args;
464
+ const { appId, confirm, orgId, computeTier, allowCrossEnvironment, ...rest } = args;
465
+ await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
342
466
  const body = { ...rest };
343
467
  if (computeTier) { body.billingMode = 'bundled'; body.cpuLimit = computeTier; }
344
468
  else if (args.cpuLimit || args.memoryLimit) { body.billingMode = 'unbundled'; }
345
- const costChanging = computeTier || args.cpuLimit || args.memoryLimit || args.replicas !== undefined || args.autoscaleMax !== undefined;
469
+ 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
470
  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.' };
471
+ // Price the change against the NEW replica count (autoscale uses its ceiling) and the
472
+ // effective compute — the one being set, else the service's CURRENT compute. Without
473
+ // the current compute, an autoscale-only change estimated to IDR 0 (#123).
474
+ const replicas = args.replicas ?? args.autoscaleMax ?? args.autoscaleMin;
475
+ let spec;
476
+ if (computeTier) spec = { computeTier, replicas };
477
+ else if (args.cpuLimit || args.memoryLimit) spec = { vcpu: cpuToVcpu(args.cpuLimit), memoryMB: memToMB(args.memoryLimit), replicas };
478
+ else {
479
+ let svc = {};
480
+ try { const d = (await api.get(`/api/v1/apps/${appId}`)).data; svc = (d && d.application) || d || {}; } catch { /* best-effort */ }
481
+ // A bundled service with no explicit tier bills at the default "micro" (store.DefaultTierKey);
482
+ // using the empty cpuLimit as-is priced the gate at IDR 0 (#123).
483
+ spec = (svc.billingMode === 'unbundled' && (svc.cpuLimit || svc.memoryLimit))
484
+ ? { vcpu: cpuToVcpu(svc.cpuLimit), memoryMB: memToMB(svc.memoryLimit), replicas }
485
+ : { computeTier: svc.cpuLimit || 'micro', replicas };
486
+ }
487
+ if (args.volumeSize !== undefined) spec = { ...spec, storageGB: gbFromSize(args.volumeSize) }; // persistent-volume price in the gate (JNG #129)
488
+ const gate = await confirmCost(api, args, spec);
489
+ if (gate) return { ...gate, change: 'update service compute/scaling/volume', message: 'This changes cost — re-call with confirm:true to apply.' };
349
490
  }
350
491
  const { status, data } = await api.put(`/api/v1/apps/${appId}`, body);
351
- return redactSecrets(result(status, data));
492
+ const out = redactSecrets(result(status, data));
493
+ // Persistent volumes are ReadWriteOnce (single-writer): warn if run with >1 replica (JNG #129).
494
+ if (Array.isArray(args.mounts) && args.mounts.length > 0 && out && typeof out === 'object') {
495
+ const maxReplicas = Math.max(args.replicas || 0, args.autoscaleMax || 0);
496
+ 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.';
497
+ 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.`;
498
+ }
499
+ return out;
352
500
  },
353
501
  },
354
502
  {
355
503
  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.',
504
+ 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
505
  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'] },
506
+ 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
507
  async call(api, args) {
360
- const { status, data } = await api.post(`/api/v1/apps/${args.appId}/domains`, { domain: args.domain });
508
+ const { status, data } = await api.post(`/api/v1/apps/${args.appId}/domains`, { domain: args.domain, confirmed: !!args.confirmed });
361
509
  return result(status, data);
362
510
  },
363
511
  },
364
512
  {
365
513
  name: 'neo_remove_service_domain',
366
- description: 'Detach a custom domain from a service. The default platform host and other domains are untouched.',
514
+ 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
515
  mutating: true,
368
516
  destructive: true,
369
- schema: { type: 'object', properties: { appId: str('Service/app id'), domain: str('Custom domain host to remove') }, required: ['appId', 'domain'] },
517
+ 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
518
  async call(api, args) {
371
- const { status, data } = await api.del(`/api/v1/apps/${args.appId}/domains`, { body: { domain: args.domain } });
519
+ const { status, data } = await api.del(`/api/v1/apps/${args.appId}/domains`, { body: { domain: args.domain, confirm: !!args.confirm } });
372
520
  return result(status, data);
373
521
  },
374
522
  },
375
523
  {
376
524
  name: 'neo_get_deployment',
377
- description: 'Get a deployment by id (poll this after neo_deploy until status is terminal).',
525
+ 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
526
  mutating: false,
379
- schema: { type: 'object', properties: { deploymentId: str('Deployment id') }, required: ['deploymentId'] },
527
+ schema: {
528
+ type: 'object',
529
+ properties: {
530
+ deploymentId: str('Deployment id'),
531
+ order: { type: 'string', enum: ['desc', 'asc'], description: 'build-log order: desc = newest-first (default); asc = oldest-first (chronological)' },
532
+ tail: int('Number of build-log lines to return (default 200, newest end); ignored when full:true'),
533
+ full: { type: 'boolean', description: 'Return the entire build log instead of the last `tail` lines' },
534
+ },
535
+ required: ['deploymentId'],
536
+ },
537
+ async call(api, args) {
538
+ const dep = (await api.get(`/api/v1/deployments/${args.deploymentId}`)).data;
539
+ if (dep && typeof dep.log === 'string' && dep.log) {
540
+ let lines = dep.log.replace(/\n$/, '').split('\n');
541
+ dep.logTotalLines = lines.length;
542
+ const tail = args.full ? lines.length : (args.tail > 0 ? args.tail : 200);
543
+ dep.logTruncated = !args.full && lines.length > tail;
544
+ lines = lines.slice(-tail); // keep the newest `tail` lines
545
+ if (args.order !== 'asc') lines.reverse(); // newest-first by default
546
+ dep.log = lines.join('\n');
547
+ }
548
+ return dep;
549
+ },
550
+ },
551
+ {
552
+ name: 'neo_wait_deployment',
553
+ 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.',
554
+ mutating: false,
555
+ schema: {
556
+ type: 'object',
557
+ properties: {
558
+ deploymentId: str('Deployment id (from neo_deploy / neo_deploy_folder)'),
559
+ timeoutSeconds: int('Max wait in seconds (default 300)'),
560
+ },
561
+ required: ['deploymentId'],
562
+ },
380
563
  async call(api, args) {
381
- return (await api.get(`/api/v1/deployments/${args.deploymentId}`)).data;
564
+ const deadline = Date.now() + (args.timeoutSeconds > 0 ? args.timeoutSeconds : 300) * 1000;
565
+ const terminal = new Set(['success', 'failed']);
566
+ let dep = {};
567
+ for (;;) {
568
+ dep = (await api.get(`/api/v1/deployments/${args.deploymentId}`)).data || {};
569
+ if (terminal.has(dep.status) || Date.now() >= deadline) break;
570
+ await new Promise((r) => setTimeout(r, 4000));
571
+ }
572
+ const lines = String(dep.log || '').replace(/\n$/, '').split('\n').filter((l) => l.length);
573
+ const out = {
574
+ deploymentId: args.deploymentId,
575
+ status: dep.status,
576
+ error: dep.error || undefined,
577
+ logTotalLines: lines.length,
578
+ buildLogTail: lines.length ? lines.slice(-50).reverse().join('\n') : undefined, // newest-first
579
+ };
580
+ if (!terminal.has(dep.status)) {
581
+ out.timedOut = true;
582
+ out.note = 'still in progress at timeout — call neo_wait_deployment again';
583
+ return out;
584
+ }
585
+ // Best-effort: after a successful BUILD, wait until the service has a Ready pod so the
586
+ // caller knows the new revision is actually serving (not just built) — JNG #121.
587
+ if (dep.status === 'success') {
588
+ try {
589
+ const appId = dep.applicationId || dep.composeAppId;
590
+ const pid = dep.projectId;
591
+ let svcName = '';
592
+ try { const a = (await api.get(`/api/v1/apps/${appId}`)).data; svcName = ((a && a.application) || a || {}).name || ''; } catch { /* name best-effort */ }
593
+ const isReady = (p) => { const m = /^(\d+)\/(\d+)$/.exec(p.ready || ''); return p.status === 'Running' && m && +m[1] > 0 && m[1] === m[2]; };
594
+ if (pid) {
595
+ for (;;) {
596
+ const pods = (await api.get(`/api/v1/projects/${pid}/pods`)).data?.pods || [];
597
+ const mine = svcName ? pods.filter((p) => p.service === svcName) : pods;
598
+ if (mine.some(isReady)) { out.rolloutReady = true; break; }
599
+ if (Date.now() >= deadline) { out.rolloutReady = false; out.note = 'build succeeded but no Ready pod yet at timeout'; break; }
600
+ await new Promise((r) => setTimeout(r, 4000));
601
+ }
602
+ }
603
+ } catch { /* rollout check best-effort */ }
604
+ }
605
+ return out;
382
606
  },
383
607
  },
384
608
  {
385
609
  name: 'neo_deploy',
386
610
  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.',
611
+ '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
612
  mutating: true,
389
- schema: { type: 'object', properties: { appId: str('App/service id') }, required: ['appId'] },
613
+ schema: { type: 'object', properties: { appId: str('App/service id'), ...allowCrossEnvProp }, required: ['appId'] },
390
614
  async call(api, args) {
615
+ await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
391
616
  const { status, data } = await api.post(`/api/v1/apps/${args.appId}/deploy`);
392
617
  return result(status, data);
393
618
  },
394
619
  },
395
620
  {
396
621
  name: 'neo_get_logs',
397
- description: 'Read recent logs for a service in a project (plain text). Summarize; redact secrets.',
622
+ 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
623
  mutating: false,
399
624
  schema: {
400
625
  type: 'object',
401
626
  properties: {
402
627
  projectId: str('Project id'),
403
628
  service: str('Service/app name (optional)'),
629
+ pod: str('Pod name to read (optional; from neo_list_pods) — otherwise the first pod of the service'),
404
630
  tail: int('Number of lines (default 200)'),
631
+ order: { type: 'string', enum: ['desc', 'asc'], description: 'desc = newest-first (default); asc = oldest-first (chronological)' },
405
632
  },
406
633
  required: ['projectId'],
407
634
  },
408
635
  async call(api, args) {
409
636
  const { data } = await api.get(`/api/v1/projects/${args.projectId}/logs`, {
410
- query: { service: args.service, tail: args.tail },
637
+ query: { service: args.service, pod: args.pod, tail: args.tail, order: args.order },
411
638
  accept: 'text/plain',
412
639
  });
413
640
  return typeof data === 'string' ? data : JSON.stringify(data);
414
641
  },
415
642
  },
643
+ {
644
+ name: 'neo_list_pods',
645
+ 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.',
646
+ mutating: false,
647
+ schema: {
648
+ type: 'object',
649
+ properties: {
650
+ projectId: str('Project id'),
651
+ service: str('Service/app name to filter by (optional)'),
652
+ },
653
+ required: ['projectId'],
654
+ },
655
+ async call(api, args) {
656
+ const pods = (await api.get(`/api/v1/projects/${args.projectId}/pods`)).data?.pods || [];
657
+ const filtered = args.service ? pods.filter((p) => p.service === args.service) : pods;
658
+ return { pods: filtered, running: filtered.filter((p) => p.status === 'Running').length, total: filtered.length };
659
+ },
660
+ },
416
661
  {
417
662
  name: 'neo_list_variables',
418
- description: 'List environment variable names/values for a project environment. Secret values are masked by the server.',
663
+ 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
664
  mutating: false,
420
665
  schema: {
421
666
  type: 'object',
422
667
  properties: {
423
668
  projectId: str('Project id'),
424
- environment: str('production or staging (default production)'),
669
+ environment: str('production or staging (optional; defaults to the session pin — errors if neither is set, never silently production)'),
670
+ ...allowCrossEnvProp,
425
671
  },
426
672
  required: ['projectId'],
427
673
  },
428
674
  async call(api, args) {
429
- const env = args.environment || 'production';
430
- return (await api.get(`/api/v1/projects/${args.projectId}/environments/${env}/config`)).data;
675
+ const env = requireEnv(args.environment, args.allowCrossEnvironment);
676
+ return { environment: env, ...(await api.get(`/api/v1/projects/${args.projectId}/environments/${env}/config`)).data };
677
+ },
678
+ },
679
+ {
680
+ name: 'neo_get_effective_env',
681
+ 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_*).',
682
+ mutating: false,
683
+ schema: {
684
+ type: 'object',
685
+ properties: { appId: str('Service/app id (from neo_list_services)') },
686
+ required: ['appId'],
687
+ },
688
+ async call(api, args) {
689
+ // Server already masks secret VALUES (value:"***", secret:true); don't run redactSecrets
690
+ // here — it would clobber our own `source`/`secret` field names (JNG #130).
691
+ return (await api.get(`/api/v1/apps/${args.appId}/effective-env`)).data;
692
+ },
693
+ },
694
+ {
695
+ name: 'neo_restart_service',
696
+ 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.',
697
+ mutating: true,
698
+ schema: { type: 'object', properties: { appId: str('Service/app id (from neo_list_services)'), ...allowCrossEnvProp }, required: ['appId'] },
699
+ async call(api, args) {
700
+ await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
701
+ return (await api.post(`/api/v1/apps/${args.appId}/restart`, {})).data;
702
+ },
703
+ },
704
+ {
705
+ name: 'neo_exec',
706
+ 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.',
707
+ mutating: true,
708
+ schema: {
709
+ type: 'object',
710
+ properties: {
711
+ appId: str('Service/app id (from neo_list_services)'),
712
+ command: str('Shell command to run (executed via /bin/sh -c)'),
713
+ pod: str('Specific pod name (optional; from neo_list_pods) — else the first running pod'),
714
+ timeoutSeconds: int('Max seconds to wait (default 30, max 120)'),
715
+ ...allowCrossEnvProp,
716
+ },
717
+ required: ['appId', 'command'],
718
+ },
719
+ async call(api, args) {
720
+ const { appId, allowCrossEnvironment, ...body } = args;
721
+ await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
722
+ return (await api.post(`/api/v1/apps/${appId}/exec`, body)).data;
723
+ },
724
+ },
725
+ {
726
+ name: 'neo_get_database_metrics',
727
+ 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.',
728
+ mutating: false,
729
+ schema: { type: 'object', properties: { databaseId: str('Database id (postgres, deployed)') }, required: ['databaseId'] },
730
+ async call(api, args) {
731
+ return (await api.get(`/api/v1/databases/${args.databaseId}/metrics`)).data;
732
+ },
733
+ },
734
+ {
735
+ name: 'neo_db_query',
736
+ 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;".',
737
+ mutating: true,
738
+ schema: {
739
+ type: 'object',
740
+ properties: {
741
+ databaseId: str('Database id (postgres, deployed)'),
742
+ sql: str('SQL to run (piped into psql)'),
743
+ readOnly: { type: 'boolean', description: 'true (default) runs in a read-only session; false allows writes' },
744
+ ...allowCrossEnvProp,
745
+ },
746
+ required: ['databaseId', 'sql'],
747
+ },
748
+ async call(api, args) {
749
+ await assertResourceEnv(api, `/api/v1/databases/${args.databaseId}`, args.allowCrossEnvironment); // JNG #136 §4
750
+ return (await api.post(`/api/v1/databases/${args.databaseId}/query`, { sql: args.sql, readOnly: args.readOnly })).data;
431
751
  },
432
752
  },
433
753
  {
434
754
  name: 'neo_set_variable',
435
755
  description:
436
- 'Set one environment variable for a project environment (read-merge-write; other vars preserved). Never echo secret values.',
756
+ '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
757
  mutating: true,
438
758
  schema: {
439
759
  type: 'object',
440
760
  properties: {
441
761
  projectId: str('Project id'),
442
- environment: str('production or staging (default production)'),
762
+ environment: str('production or staging (optional; defaults to the session pin — errors if neither is set, never silently production)'),
763
+ ...allowCrossEnvProp,
443
764
  key: str('Variable name'),
444
765
  value: str('Variable value'),
445
766
  },
446
767
  required: ['projectId', 'key', 'value'],
447
768
  },
448
769
  async call(api, args) {
449
- const env = args.environment || 'production';
770
+ const env = requireEnv(args.environment, args.allowCrossEnvironment);
450
771
  const path = `/api/v1/projects/${args.projectId}/environments/${env}/config`;
451
772
  // The PUT replaces the whole set, so merge current vars first.
452
773
  const current = (await api.get(path)).data?.vars || [];
@@ -457,7 +778,7 @@ export const TOOLS = [
457
778
  const out = result(status, data);
458
779
  // Don't return the value we just set.
459
780
  if (out && out.vars) out.vars = out.vars.map((v) => (v.key === args.key ? { ...v, value: '••••••••' } : v));
460
- return out;
781
+ return { environment: env, ...out };
461
782
  },
462
783
  },
463
784
  {
@@ -472,10 +793,12 @@ export const TOOLS = [
472
793
  key: str('Variable name'),
473
794
  value: str('Variable value'),
474
795
  secret: { type: 'boolean', description: 'Store as a secret (default false)' },
796
+ ...allowCrossEnvProp,
475
797
  },
476
798
  required: ['appId', 'key', 'value'],
477
799
  },
478
800
  async call(api, args) {
801
+ await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
479
802
  const { status, data } = await api.post(`/api/v1/apps/${args.appId}/variables`, { key: args.key, value: args.value, secret: !!args.secret });
480
803
  return result(status, data); // {status:"set", key, redeployRequired} — no value returned
481
804
  },
@@ -682,6 +1005,7 @@ export const TOOLS = [
682
1005
  publishDirectory: str('static_site: directory of built files to serve via nginx (e.g. "dist"); enables native static hosting'),
683
1006
  port: int('Container port (optional; ignored for worker)'),
684
1007
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production. Never defaulted (handoff §2). Deploy to staging first.' },
1008
+ ...allowCrossEnvProp,
685
1009
  dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93). Returns a confirmToken.' },
686
1010
  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
1011
  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 +1013,7 @@ export const TOOLS = [
689
1013
  required: ['projectId', 'path', 'environment'],
690
1014
  },
691
1015
  async call(api, args) {
692
- const environment = requireEnv(args.environment);
1016
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment);
693
1017
  const p = resolve(args.path);
694
1018
  let src;
695
1019
  try {
@@ -713,9 +1037,11 @@ export const TOOLS = [
713
1037
  confirm: args.confirm === true,
714
1038
  dryRun: !!args.dryRun,
715
1039
  });
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.' };
1040
+ 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
1041
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
718
- return { status: r.result, url: r.url || undefined, appId: r.appId };
1042
+ // Always return the deploymentId; on failure include the error + build-log tail so
1043
+ // the caller can diagnose without a second build (JNG #117).
1044
+ return { environment, status: r.result, url: r.url || undefined, appId: r.appId, deploymentId: r.deploymentId, error: r.error, buildLogTail: r.buildLogTail };
719
1045
  },
720
1046
  },
721
1047
  {
@@ -750,6 +1076,7 @@ export const TOOLS = [
750
1076
  engine: { type: 'string', description: 'postgres | mysql (engine)' },
751
1077
  version: str('Engine version, e.g. "16" (optional)'),
752
1078
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1079
+ ...allowCrossEnvProp,
753
1080
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
754
1081
  replicas: int('Read/failover replicas for HA (optional; postgres/CNPG only)'),
755
1082
  deploymentMode: { type: 'string', description: 'single | ha (optional)' },
@@ -759,12 +1086,14 @@ export const TOOLS = [
759
1086
  required: ['projectId', 'name', 'engine', 'environment'],
760
1087
  },
761
1088
  async call(api, args) {
762
- requireEnv(args.environment);
1089
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1090
+ validateDbArgs(args); // reject sub-10Gi / EOL config before pricing (JNG #132)
763
1091
  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;
1092
+ if (gate.requiresConfirmation) return { environment, ...gate };
765
1093
  const { projectId, confirm, confirmationId, orgId, ...body } = args;
1094
+ body.environment = environment; // send the resolved env so the pin actually targets it
766
1095
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/databases${gate.query}`, body);
767
- return redactSecrets(result(status, data));
1096
+ return { environment, ...redactSecrets(result(status, data)) };
768
1097
  },
769
1098
  },
770
1099
  {
@@ -922,6 +1251,7 @@ export const TOOLS = [
922
1251
  name: str('Queue name'),
923
1252
  planClass: str('Plan/engine class (optional)'),
924
1253
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1254
+ ...allowCrossEnvProp,
925
1255
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
926
1256
  dlqEnabled: { type: 'boolean', description: 'Enable a dead-letter queue (optional, default true). Pass false to disable — this is now honored.' },
927
1257
  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 +1260,13 @@ export const TOOLS = [
930
1260
  required: ['projectId', 'name', 'environment'],
931
1261
  },
932
1262
  async call(api, args) {
933
- requireEnv(args.environment);
1263
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
934
1264
  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;
1265
+ if (gate.requiresConfirmation) return { environment, ...gate };
936
1266
  const { projectId, confirm, confirmationId, orgId, ...body } = args;
1267
+ body.environment = environment; // send the resolved env so the pin actually targets it
937
1268
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/queues${gate.query}`, body);
938
- return redactSecrets(result(status, data));
1269
+ return { environment, ...redactSecrets(result(status, data)) };
939
1270
  },
940
1271
  },
941
1272
  {
@@ -1062,22 +1393,24 @@ export const TOOLS = [
1062
1393
  retryCount: int('Retries on failure (optional) → backoffLimit'),
1063
1394
  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
1395
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1396
+ ...allowCrossEnvProp,
1065
1397
  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
1398
  confirmationId: str('An APPROVED billable_create confirmation id (from neo_confirm_action) — re-call with it to create. Alternative to confirm:true.'),
1067
1399
  },
1068
1400
  required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
1069
1401
  },
1070
1402
  async call(api, args) {
1071
- requireEnv(args.environment);
1403
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1072
1404
  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;
1405
+ if (gate.requiresConfirmation) return { environment, ...gate };
1074
1406
  // Merge contentType into headers for convenience (unless already set).
1075
1407
  if (args.contentType) {
1076
1408
  args = { ...args, headers: { 'Content-Type': args.contentType, ...(args.headers || {}) } };
1077
1409
  }
1078
1410
  const { projectId, confirm, confirmationId, orgId, contentType, ...body } = args;
1411
+ body.environment = environment; // send the resolved env so the pin actually targets it
1079
1412
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons${gate.query}`, body);
1080
- return redactSecrets(result(status, data));
1413
+ return { environment, ...redactSecrets(result(status, data)) };
1081
1414
  },
1082
1415
  },
1083
1416
  {
@@ -1119,7 +1452,7 @@ export const TOOLS = [
1119
1452
  },
1120
1453
  {
1121
1454
  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.',
1455
+ 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
1456
  mutating: true,
1124
1457
  schema: {
1125
1458
  type: 'object',
@@ -1135,11 +1468,13 @@ export const TOOLS = [
1135
1468
  required: ['projectId', 'name'],
1136
1469
  },
1137
1470
  async call(api, args) {
1471
+ validateBucketArgs(args); // reject sub-10GB quota before pricing (JNG #132)
1138
1472
  const gate = await billableGate(api, { projectId: args.projectId, kind: 'bucket', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { objectStorageGB: gbFromSize(args.quotaGB) });
1139
1473
  if (gate.requiresConfirmation) return gate;
1140
1474
  const { projectId, confirm, confirmationId, orgId, ...body } = args;
1141
1475
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/buckets${gate.query}`, body);
1142
- return redactSecrets(result(status, data));
1476
+ // Buckets have no environment — label the scope so the agent doesn't expect one (JNG #136).
1477
+ return { scope: 'shared', ...redactSecrets(result(status, data)) };
1143
1478
  },
1144
1479
  },
1145
1480
  {
@@ -1220,7 +1555,7 @@ export const TOOLS = [
1220
1555
  {
1221
1556
  name: 'neo_bind_resource',
1222
1557
  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.',
1558
+ '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
1559
  mutating: true,
1225
1560
  schema: {
1226
1561
  type: 'object',
@@ -1229,11 +1564,29 @@ export const TOOLS = [
1229
1564
  targetType: { type: 'string', enum: ['neo_db', 'neo_queue', 'neo_object_storage'], description: 'neo_db | neo_queue | neo_object_storage — the resource to attach' },
1230
1565
  targetId: str('The database, queue, or bucket id to bind'),
1231
1566
  envKey: str('Env var name / prefix (optional; default DATABASE_URL, NATS_URL, or S3 prefix)'),
1567
+ ...allowCrossEnvProp,
1232
1568
  },
1233
1569
  required: ['appId', 'targetType', 'targetId'],
1234
1570
  },
1235
1571
  async call(api, args) {
1236
- const { appId, ...body } = args;
1572
+ const { appId, allowCrossEnvironment, ...body } = args;
1573
+ // Environment isolation (JNG #136 §4): the consumer must match the session pin, and a
1574
+ // consumer must not bind a target from a DIFFERENT environment. Buckets are shared (no env).
1575
+ if (!allowCrossEnvironment) {
1576
+ let cEnv;
1577
+ try { cEnv = (await api.get(`/api/v1/apps/${appId}`)).data?.environment; } catch { /* can't resolve → don't block */ }
1578
+ if (sessionEnv && cEnv && cEnv !== sessionEnv) {
1579
+ throw new Error(`environment_mismatch: this session is pinned to "${sessionEnv}" but that service is in "${cEnv}" (JNG #136). Pass allowCrossEnvironment:true to override, or re-pin with neo_set_context.`);
1580
+ }
1581
+ if (args.targetType !== 'neo_object_storage') {
1582
+ const tPath = args.targetType === 'neo_db' ? `/api/v1/databases/${args.targetId}` : `/api/v1/queues/${args.targetId}`;
1583
+ let tEnv;
1584
+ try { tEnv = (await api.get(tPath)).data?.environment; } catch { /* ignore */ }
1585
+ if (cEnv && tEnv && cEnv !== tEnv) {
1586
+ throw new Error(`environment_mismatch: cannot bind a "${tEnv}" ${args.targetType} to a "${cEnv}" service — binding across environments is refused (JNG #136). Pass allowCrossEnvironment:true to override.`);
1587
+ }
1588
+ }
1589
+ }
1237
1590
  const { status, data } = await api.post(`/api/v1/apps/${appId}/bindings`, body);
1238
1591
  return redactSecrets(result(status, data));
1239
1592
  },
@@ -1312,6 +1665,7 @@ export const TOOLS = [
1312
1665
  properties: {
1313
1666
  projectId: str('Project id'),
1314
1667
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1668
+ ...allowCrossEnvProp,
1315
1669
  resources: {
1316
1670
  type: 'array',
1317
1671
  description: 'The stack resources. Each needs a unique name + kind (app | database | queue | bucket | cron).',
@@ -1373,10 +1727,12 @@ export const TOOLS = [
1373
1727
  required: ['projectId', 'environment', 'resources'],
1374
1728
  },
1375
1729
  async call(api, args) {
1376
- requireEnv(args.environment);
1730
+ const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1377
1731
  const { projectId, ...body } = args;
1732
+ body.environment = environment; // send the resolved env so the pin actually targets it
1378
1733
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/plans`, body);
1379
- return result(status, data);
1734
+ const out = result(status, data);
1735
+ return (out && typeof out === 'object') ? { environment, ...out } : out;
1380
1736
  },
1381
1737
  },
1382
1738
  {
@@ -1563,12 +1919,35 @@ export const TOOLS = [
1563
1919
  },
1564
1920
  {
1565
1921
  name: 'neo_delete_bucket',
1566
- description: 'Permanently delete an object-storage bucket. Destructive and irreversible — bucket contents are lost.',
1922
+ 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
1923
  mutating: true,
1568
1924
  destructive: true,
1569
- schema: { type: 'object', properties: { bucketId: str('Bucket id') }, required: ['bucketId'] },
1925
+ schema: {
1926
+ type: 'object',
1927
+ properties: {
1928
+ bucketId: str('Bucket id'),
1929
+ confirmationId: str('An APPROVED confirmation id (from neo_confirm_action). Omit to get the confirmation.'),
1930
+ },
1931
+ required: ['bucketId'],
1932
+ },
1570
1933
  async call(api, args) {
1571
- return deleteAndConverge(api, `/api/v1/buckets/${args.bucketId}`, args.bucketId, 'bucket');
1934
+ if (args.confirmationId) {
1935
+ return deleteAndConverge(api, `/api/v1/buckets/${args.bucketId}`, args.bucketId, 'bucket', `?confirmationId=${encodeURIComponent(args.confirmationId)}`);
1936
+ }
1937
+ let bk = null;
1938
+ try { bk = (await api.get(`/api/v1/buckets/${args.bucketId}`)).data; } catch { /* preview best-effort */ }
1939
+ let c;
1940
+ try { c = (await api.post('/api/v1/confirmations', { action: 'bucket_delete', targetId: args.bucketId })).data; }
1941
+ catch (e) { return { deleted: false, error: 'confirmation_failed', message: 'Could not create a deletion confirmation: ' + (e.message || e) }; }
1942
+ const bkView = (c.summary && c.summary.bucket) || (bk ? { id: bk.id, name: bk.name, region: bk.region } : { id: args.bucketId });
1943
+ const phrase = (c.summary && c.summary.phraseRequired) || bkView.name;
1944
+ return {
1945
+ deleted: false, requiresConfirmation: true,
1946
+ bucket: bkView,
1947
+ warning: 'IRREVERSIBLE: deletes the bucket AND all objects in it.',
1948
+ confirmationId: c.confirmationId, phraseRequired: phrase,
1949
+ 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}".`,
1950
+ };
1572
1951
  },
1573
1952
  },
1574
1953
  {
@@ -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