@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 +1 -1
- package/src/api.js +12 -0
- package/src/deploySource.js +18 -2
- package/src/mcp/tools.js +434 -55
- package/src/skill/SKILL.md +3 -1
package/package.json
CHANGED
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;
|
package/src/deploySource.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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: {
|
|
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
|
-
|
|
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
|
{
|
package/src/skill/SKILL.md
CHANGED
|
@@ -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
|
|