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