@biznetgio/neo-app 0.4.73 → 0.4.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.73",
3
+ "version": "0.4.75",
4
4
  "description": "NEO App CLI — one-command agent onboarding, MCP server, and project ops for coding agents (Codex, Claude Code).",
5
5
  "type": "module",
6
6
  "bin": {
package/prompt.md CHANGED
@@ -6,7 +6,7 @@ Keep this in sync with cli/prompt.md (the npm package's bundled copy).
6
6
  -->
7
7
  # Set this repo up on NEO App
8
8
 
9
- **Version:** 0.4.5 · **Last updated:** 2026-10-15
9
+ **Version:** 0.4.74 · **Last updated:** 2026-11-05
10
10
 
11
11
  These are the official setup instructions from NEO App (Biznet Gio's managed-app
12
12
  platform). If you are an AI coding agent, complete the steps below yourself by running
@@ -17,7 +17,7 @@ into chat, repository files, the skill, or MCP configuration.
17
17
  ## What NEO App runs
18
18
 
19
19
  Web services, background workers, scheduled jobs, static sites, managed databases
20
- (PostgreSQL/MySQL/…), queues (NATS), and object storage — on managed Kubernetes, served
20
+ (PostgreSQL/MySQL/Redis), queues (NATS), and object storage — on managed Kubernetes, served
21
21
  over HTTPS with automatic TLS. It is **not** for GPU training or for apps that need
22
22
  several containers running side by side at runtime (a single app container alongside
23
23
  managed databases is fine). If the repo has no server to put behind a URL (a library or
@@ -118,10 +118,17 @@ tools — never the portal UI. The lifecycle is the same for every resource type
118
118
  components in a monorepo. Ask the human which to deploy; never collapse a monorepo
119
119
  into one service.
120
120
  2. **Plan and show the cost**: `neo_plan_stack` returns an immutable, priced plan
121
- (`planId`, `planHash`, cost in IDR, expiry). Show the human the cost and get an
121
+ (`planId`, `planHash`, cost in IDR with monthly and hourly totals, expiry). Show
122
+ the human the cost and get an
122
123
  explicit **Approve & Create**. Individual creates (`neo_create_database`,
123
124
  `neo_create_queue`) instead return the cost and create nothing until you re-call
124
125
  with `confirm: true`.
126
+ **Pricing is resource-based and hourly (#144).** Compute is priced per **vCPU-hour**
127
+ and per **GB-RAM-hour** independently — there are no packages/tiers. Size a service
128
+ with `vcpu` + `memoryMB` (or `cpuLimit`/`memoryLimit`); a legacy tier key still works
129
+ but resolves to its concrete vCPU/RAM. Storage and object storage bill per GB-hour.
130
+ Time-based components accrue hourly and are **capped at 730 billable hours/month**; a
131
+ monthly figure is a non-authoritative estimate (hourly × up to 730).
125
132
  3. **Apply**: `neo_apply_stack({planId, planHash})`. If the price book changed since the
126
133
  plan, apply is refused — re-plan to re-quote. A `pending_approval` result is the
127
134
  approval policy working, not an error; wait for it.
@@ -0,0 +1,93 @@
1
+ // `neo-app db` — terminal discoverability for the managed-database lifecycle (#149 retest).
2
+ // Read-only inspection (list/get) runs here directly; billable/mutating operations
3
+ // (create, verify, query, backup, restore, redis-command, delete) stay on the MCP
4
+ // server's audited cost-consent path and are listed here so a terminal-only user can
5
+ // discover them from the CLI command tree instead of only through an agent.
6
+
7
+ import { resolveConfig } from '../config.js';
8
+ import { getCredential } from '../creds.js';
9
+ import { NeoApi } from '../api.js';
10
+ import { readLink } from '../link.js';
11
+ import { log, ok, fail, c } from '../util.js';
12
+
13
+ // The full lifecycle, with the MCP tool that performs each one. Shown by `neo-app db`.
14
+ const OPS = [
15
+ ['list', 'neo-app db list', 'List databases in the linked project (read-only)'],
16
+ ['get <id>', 'neo-app db get <id>', 'Show one database, incl. redacted connection string (read-only)'],
17
+ ['create', 'neo_create_database', 'Provision PostgreSQL / MySQL / Redis (billable — cost consent)'],
18
+ ['deploy', 'neo_deploy_database', 'Start/roll out the database pods'],
19
+ ['verify', 'neo_verify_database', 'Round-trip health probe (engine-aware)'],
20
+ ['query', 'neo_db_query', 'Read-only SQL (postgres/mysql)'],
21
+ ['redis', 'neo_redis_command', 'Run a safe Redis command (denylist-guarded)'],
22
+ ['metrics', 'neo_get_database_metrics', 'Engine-aware metrics snapshot'],
23
+ ['backup', 'neo_run_backup / neo_create_backup', 'Create and run a logical backup'],
24
+ ['restore', 'neo_restore_database', 'Restore a SQL dump (postgres/mysql)'],
25
+ ['public', 'neo_enable_db_public_access','Enable/disable TLS public access'],
26
+ ['delete', 'neo_delete_database', 'Delete a database (billable stop)'],
27
+ ];
28
+
29
+ function usage() {
30
+ log(`${c.bold('neo-app db')} — managed database lifecycle (PostgreSQL · MySQL · Redis)\n`);
31
+ log(c.bold('Run directly from the CLI (read-only):'));
32
+ log(' neo-app db list');
33
+ log(' neo-app db get <database-id>\n');
34
+ log(c.bold('Mutating / billable operations run through the MCP server'));
35
+ log(`(start it with ${c.bold('neo-app mcp')} — your agent calls these; the Portal exposes them too):`);
36
+ for (const [, how, desc] of OPS.slice(2)) log(` · ${c.bold(how.padEnd(34))} ${c.dim(desc)}`);
37
+ log(`\nPrice a database before creating it: ${c.bold('neo-app estimate --storage <GB>')}`);
38
+ }
39
+
40
+ function connect(opts) {
41
+ const cfg = resolveConfig(opts);
42
+ const cred = getCredential(cfg.baseUrl);
43
+ if (!cred?.token) { fail(`not authenticated for ${cfg.baseUrl} — run 'neo-app login'`); process.exit(1); }
44
+ return new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
45
+ }
46
+
47
+ function requireProject(opts) {
48
+ const projectId = opts.project || readLink()?.projectId;
49
+ if (!projectId) { fail("no linked project — run 'neo-app link <project-id>' or pass --project <id>"); process.exit(1); }
50
+ return projectId;
51
+ }
52
+
53
+ export async function db(sub, opts = {}, positionals = []) {
54
+ if (!sub || sub === 'help') return void usage();
55
+
56
+ if (sub === 'list' || sub === 'ls') {
57
+ const api = connect(opts);
58
+ const projectId = requireProject(opts);
59
+ let dbs;
60
+ try { dbs = (await api.get(`/api/v1/projects/${projectId}/databases`)).data?.databases || []; }
61
+ catch (e) { fail(`list failed: ${e.message || e}`); process.exit(1); }
62
+ if (opts.json) return void process.stdout.write(JSON.stringify({ databases: dbs }, null, 2) + '\n');
63
+ if (!dbs.length) return void ok('no databases in this project');
64
+ for (const d of dbs) {
65
+ const status = d.status || (d.host ? 'deployed' : 'not_deployed');
66
+ log(` ${c.bold(d.name)} ${c.dim(`${d.engine} ${d.version || ''}`.trim())} ${status} ${c.dim(d.environment || 'production')} ${c.dim(d.id)}`);
67
+ }
68
+ return;
69
+ }
70
+
71
+ if (sub === 'get' || sub === 'show') {
72
+ const id = positionals[0];
73
+ if (!id) { fail('usage: neo-app db get <database-id>'); process.exit(2); }
74
+ const api = connect(opts);
75
+ let view;
76
+ try { view = (await api.get(`/api/v1/databases/${id}`)).data; }
77
+ catch (e) { fail(`get failed: ${e.message || e}`); process.exit(1); }
78
+ if (opts.json) return void process.stdout.write(JSON.stringify(view, null, 2) + '\n');
79
+ for (const [k, v] of Object.entries(view)) if (v !== undefined && v !== null && v !== '') log(` ${c.dim(k.padEnd(18))} ${v}`);
80
+ return;
81
+ }
82
+
83
+ // Any lifecycle verb typed at the CLI: point the user at the discoverable path.
84
+ const known = OPS.find(([key]) => key.split(' ')[0] === sub);
85
+ if (known) {
86
+ log(`${c.bold(sub)} runs through the MCP server (${c.bold(known[1])}) or the Portal — not as a direct CLI write.`);
87
+ log(`Start the MCP server with ${c.bold('neo-app mcp')} so your agent can call it, or use the Portal.`);
88
+ return;
89
+ }
90
+ fail(`unknown db subcommand: ${sub}`);
91
+ usage();
92
+ process.exit(2);
93
+ }
package/src/index.js CHANGED
@@ -20,8 +20,11 @@ ${c.bold('Commands')}
20
20
  (or pass the same via --source <path|.zip|git-url>)
21
21
  project create [name] Create a project (defaults to the dir name) and link it
22
22
  project list List projects in your organization (find ids to link)
23
+ db [list|get|...] Managed database lifecycle (PostgreSQL/MySQL/Redis);
24
+ list/get run here, the rest via the MCP server
23
25
  status | whoami Show connected account, org, environment, linked project
24
- mcp Run the local stdio MCP server (used by agents)
26
+ mcp Run the MCP server: local stdio (default), or hosted
27
+ Streamable HTTP + SSE with --http [--port <n>] [--host <h>]
25
28
 
26
29
  ${c.bold('Options')}
27
30
  --project <id> Target/link a project (agent setup, deploy)
@@ -37,8 +40,8 @@ ${c.bold('Options')}
37
40
  --yes Non-interactive (headless/CI); also approves a billable deploy without the cost prompt
38
41
  --dry-run Print intended changes without writing
39
42
  --json Machine-readable output incl. the normalized cost (deploy, estimate)
40
- --tier <key> estimate: compute tier (micro|small|medium|large|xlarge)
41
- --vcpu <n> --mem <MB> estimate: custom compute (instead of --tier)
43
+ --vcpu <n> --mem <MB> estimate: compute, priced per vCPU-hour + GB-RAM-hour (#144)
44
+ --tier <key> estimate: legacy size shorthand (micro|…|xlarge) → vCPU/RAM
42
45
  --storage <GB> estimate: database/persistent storage
43
46
  --object <GB> estimate: object-storage quota (billed monthly-upfront)
44
47
  --queue / --cron estimate: a queue (per-message) / cron (per-run) resource
@@ -80,6 +83,9 @@ const options = {
80
83
  replicas: { type: 'string' },
81
84
  queue: { type: 'boolean' },
82
85
  cron: { type: 'boolean' },
86
+ http: { type: 'boolean' },
87
+ sse: { type: 'boolean' },
88
+ host: { type: 'string' },
83
89
  version: { type: 'boolean', short: 'v' },
84
90
  help: { type: 'boolean', short: 'h' },
85
91
  };
@@ -102,7 +108,7 @@ export async function main(argv = process.argv.slice(2)) {
102
108
  // Root help, and `--help` on a KNOWN command, print usage. An UNKNOWN command must
103
109
  // still error (nonzero exit) even with `--help`, so a bad command isn't silently
104
110
  // accepted (JNG #97). Per-subcommand help is tracked in #89.
105
- const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
111
+ const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'db', 'version', 'help']);
106
112
  if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd))) return void console.log(HELP);
107
113
 
108
114
  try {
@@ -119,7 +125,13 @@ export async function main(argv = process.argv.slice(2)) {
119
125
  if (cmd === 'project' && sub === 'create') return void (await (await import('./commands/link.js')).createProject(positionals[2], opts));
120
126
  if (cmd === 'project' && (sub === 'list' || sub === 'ls')) return void (await (await import('./commands/link.js')).listProjects(opts));
121
127
  if (cmd === 'status' || cmd === 'whoami') return void (await (await import('./commands/status.js')).status(opts));
122
- if (cmd === 'mcp') return void (await (await import('./mcp/server.js')).runMcpServer());
128
+ if (cmd === 'db') return void (await (await import('./commands/db.js')).db(sub, opts, positionals.slice(2)));
129
+ if (cmd === 'mcp') {
130
+ const mcp = await import('./mcp/server.js');
131
+ // Hosted mode: Streamable HTTP + SSE over one listener (auth per request). Default: stdio.
132
+ if (opts.http || opts.sse || opts.port) return void (await mcp.runMcpHttpServer({ port: opts.port, host: opts.host }));
133
+ return void (await mcp.runMcpServer());
134
+ }
123
135
  if (cmd === 'version') return void console.log(version());
124
136
  if (cmd === 'help') return void console.log(HELP);
125
137
 
package/src/mcp/server.js CHANGED
@@ -1,10 +1,19 @@
1
- // NEO App local stdio MCP server (`neo-app mcp`). Reuses the authenticated CLI
2
- // credential for the configured control-plane — the token is NEVER passed in agent
3
- // config, it is read here from the credential store (or NEO_APP_TOKEN). All logging
4
- // goes to stderr; stdout is the JSON-RPC channel.
1
+ // NEO App MCP server. ONE tool registry (./tools.js, the canonical neo_* contract) served
2
+ // over three transports so every surface is capability- and version-identical (#175):
3
+ // • stdio (`neo-app mcp`) — local agent, reuses the authenticated CLI credential.
4
+ // • Streamable HTTP (`mcp --http`) — hosted, STATELESS (fresh server+transport per POST,
5
+ // JSON responses), auth from the request's Bearer token.
6
+ // Stateless keeps it multi-replica-safe and lets the
7
+ // control plane front it per-request (it owns sessions).
8
+ // • SSE (same `--http` listener) — hosted, session per connection (for direct clients).
9
+ // serverInfo.version is the packed npm version for every transport (never "docker").
10
+ // All logging goes to stderr; stdout is the stdio JSON-RPC channel.
5
11
 
12
+ import { createServer as createHttpServer } from 'node:http';
6
13
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
14
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
16
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
8
17
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
18
  import { resolveConfig } from '../config.js';
10
19
  import { getCredential } from '../creds.js';
@@ -12,16 +21,10 @@ import { NeoApi } from '../api.js';
12
21
  import { pkgVersion } from '../util.js';
13
22
  import { TOOLS } from './tools.js';
14
23
 
15
- export async function runMcpServer() {
16
- const cfg = resolveConfig({});
17
- const cred = getCredential(cfg.baseUrl);
18
- const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred?.token });
19
- if (!cred?.token) {
20
- console.error(`[neo-app mcp] no credential for ${cfg.baseUrl} — run 'neo-app login' or set NEO_APP_TOKEN`);
21
- }
22
-
23
- // serverInfo.version MUST equal the packed npm artifact version (handoff 3.1):
24
- // derive it from package.json, never hard-code it.
24
+ // buildServer wires the shared tool registry onto a fresh MCP Server bound to ONE NeoApi
25
+ // (one session's auth token + env pin). Every transport reuses it, so stdio and hosted
26
+ // HTTP/SSE serve the identical neo_* contract, annotations, and version (#175 parity).
27
+ function buildServer(api) {
25
28
  const server = new Server({ name: 'neo-app', version: pkgVersion() }, { capabilities: { tools: {} } });
26
29
 
27
30
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -40,7 +43,7 @@ export async function runMcpServer() {
40
43
  }
41
44
  if (!api.token) {
42
45
  return {
43
- content: [{ type: 'text', text: 'Not authenticated. Run `neo-app login` or set NEO_APP_TOKEN.' }],
46
+ content: [{ type: 'text', text: 'Not authenticated. Send an Authorization: Bearer <kap_ token> header (hosted), run `neo-app login`, or set NEO_APP_TOKEN (stdio).' }],
44
47
  isError: true,
45
48
  };
46
49
  }
@@ -54,6 +57,104 @@ export async function runMcpServer() {
54
57
  }
55
58
  });
56
59
 
60
+ return server;
61
+ }
62
+
63
+ // runMcpServer: local stdio. One process = one local credential (unchanged behavior).
64
+ export async function runMcpServer() {
65
+ const cfg = resolveConfig({});
66
+ const cred = getCredential(cfg.baseUrl);
67
+ const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred?.token });
68
+ if (!cred?.token) {
69
+ console.error(`[neo-app mcp] no credential for ${cfg.baseUrl} — run 'neo-app login' or set NEO_APP_TOKEN`);
70
+ }
71
+ // serverInfo.version MUST equal the packed npm artifact version (handoff 3.1): buildServer
72
+ // derives it from package.json.
73
+ const server = buildServer(api);
57
74
  await server.connect(new StdioServerTransport());
58
- console.error(`[neo-app mcp] ready: ${cfg.env} ${cfg.baseUrl}`);
75
+ console.error(`[neo-app mcp] ready: ${cfg.env} ${cfg.baseUrl} — ${TOOLS.length} tools v${pkgVersion()}`);
76
+ }
77
+
78
+ const bearer = (req) => {
79
+ const m = /^Bearer\s+(.+)$/i.exec(req.headers['authorization'] || '');
80
+ return m ? m[1].trim() : null;
81
+ };
82
+
83
+ // runMcpHttpServer: hosted Streamable HTTP + SSE. The agent's per-request Bearer token
84
+ // becomes that session's NeoApi token — NEVER a local credential. baseUrl is the control
85
+ // plane this MCP fronts (config / NEO_APP_URL). Returns the http.Server (for tests).
86
+ export async function runMcpHttpServer({ port, host } = {}) {
87
+ const cfg = resolveConfig({});
88
+ // Honor an explicit port, including 0 (ephemeral, used by tests); else PORT, else 8750.
89
+ const listenPort = (port != null && port !== '') ? Number(port) : Number(process.env.PORT || 8750);
90
+ const listenHost = host || process.env.HOST || '0.0.0.0';
91
+ // Mount point for the MCP routes. Default '/mcp'; set NEO_APP_MCP_BASE_PATH=/api/v1/mcp
92
+ // when the control plane reverse-proxies that public path straight through to this sidecar,
93
+ // so the SSE "endpoint" event the client is told to POST back to resolves correctly.
94
+ const base = (process.env.NEO_APP_MCP_BASE_PATH || '/mcp').replace(/\/+$/, '');
95
+ const apiFor = (req) => new NeoApi({ baseUrl: cfg.baseUrl, token: bearer(req) });
96
+
97
+ // SSE keeps per-connection state in-process (sessionId -> transport); the agent's Bearer
98
+ // token is bound to the session's NeoApi when the stream opens. Streamable HTTP is stateless
99
+ // (no map). ponytail: the SSE map → run as a SINGLE replica; add sticky routing or a shared
100
+ // store before scaling SSE horizontally. Streamable HTTP has no such limit.
101
+ const sseSessions = new Map(); // sessionId -> SSEServerTransport
102
+
103
+ const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
104
+
105
+ const httpServer = createHttpServer(async (req, res) => {
106
+ try {
107
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
108
+
109
+ // Unauthenticated liveness/capability probe so infra can confirm the hosted MCP is up
110
+ // and advertises the full contract (version + tool count) — the opposite of the #175
111
+ // "connected but silently reduced" failure.
112
+ if (req.method === 'GET' && (url.pathname === '/healthz' || url.pathname === '/version')) {
113
+ return json(res, 200, { component: 'neo-app-mcp', version: pkgVersion(), tools: TOOLS.length, transports: ['streamable-http', 'sse'] });
114
+ }
115
+
116
+ // --- Streamable HTTP (stateless): a fresh server + transport per POST, JSON responses.
117
+ // Each request is self-contained (the SDK answers tools/list without a prior initialize),
118
+ // so no session id is minted here — the control plane fronting this owns session state. ---
119
+ if (url.pathname === base) {
120
+ if (req.method !== 'POST') {
121
+ res.setHeader('allow', 'POST');
122
+ return json(res, 405, { error: 'method not allowed; stateless Streamable HTTP MCP uses POST' });
123
+ }
124
+ const server = buildServer(apiFor(req));
125
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
126
+ res.on('close', () => { transport.close(); server.close(); });
127
+ await server.connect(transport);
128
+ await transport.handleRequest(req, res); // SDK reads the JSON-RPC body from the stream
129
+ return;
130
+ }
131
+
132
+ // --- SSE (stateful): GET <base>/sse opens the stream, POST <base>/messages carries messages ---
133
+ if (url.pathname === `${base}/sse` && req.method === 'GET') {
134
+ const server = buildServer(apiFor(req));
135
+ const transport = new SSEServerTransport(`${base}/messages`, res);
136
+ sseSessions.set(transport.sessionId, transport);
137
+ res.on('close', () => { sseSessions.delete(transport.sessionId); server.close(); });
138
+ await server.connect(transport); // calls transport.start() → sends the endpoint event
139
+ return;
140
+ }
141
+ if (url.pathname === `${base}/messages` && req.method === 'POST') {
142
+ const transport = sseSessions.get(url.searchParams.get('sessionId'));
143
+ if (!transport) return json(res, 404, { error: 'no such SSE session' });
144
+ await transport.handlePostMessage(req, res);
145
+ return;
146
+ }
147
+
148
+ return json(res, 404, { error: 'not found' });
149
+ } catch (e) {
150
+ console.error('[neo-app mcp] request error', e?.message || e);
151
+ if (!res.headersSent) json(res, 500, { error: String(e?.message || e) });
152
+ else try { res.end(); } catch { /* already closed */ }
153
+ }
154
+ });
155
+
156
+ await new Promise((resolve) => httpServer.listen(listenPort, listenHost, resolve));
157
+ const boundPort = httpServer.address()?.port ?? listenPort;
158
+ console.error(`[neo-app mcp] hosted ready: http://${listenHost}:${boundPort}${base} (streamable) + ${base}/sse — fronting ${cfg.baseUrl}, ${TOOLS.length} tools v${pkgVersion()}`);
159
+ return httpServer;
59
160
  }
package/src/mcp/tools.js CHANGED
@@ -31,10 +31,14 @@ 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
- // 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; })();
34
+ // The session env pin (JNG #136) is PER-API-SESSION state, not a module global, so one
35
+ // hosted MCP process can serve many tenants concurrently without their env pins bleeding
36
+ // across sessions — each MCP session has its own NeoApi, and the pin lives on it. Stdio
37
+ // has a single api for the whole process, so the behavior is identical to a module global.
38
+ // Seeded once per api from NEO_APP_TARGET_ENV; null = not chosen yet.
39
+ function seedSessionEnv() { const e = String(process.env.NEO_APP_TARGET_ENV || '').toLowerCase(); return (e === 'staging' || e === 'production') ? e : null; }
40
+ function getSessionEnv(api) { if (api._sessionEnv === undefined) api._sessionEnv = seedSessionEnv(); return api._sessionEnv; }
41
+ function setSessionEnv(api, v) { api._sessionEnv = v; }
38
42
 
39
43
  // envStrict validates an explicit staging|production value (no fallback).
40
44
  function envStrict(env) {
@@ -48,36 +52,39 @@ function envStrict(env) {
48
52
  // call), a later call for a DIFFERENT environment is refused unless allowCrossEnvironment is
49
53
  // set — so staging and production can't be mixed in one session. The first explicit choice
50
54
  // pins the session so subsequent calls inherit it. Never silently defaulted.
51
- function requireEnv(env, allowCross = false) {
55
+ function requireEnv(api, env, allowCross = false) {
52
56
  const e = String(env || '').toLowerCase();
53
57
  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.`);
58
+ const pinned = getSessionEnv(api);
59
+ if (pinned) {
60
+ if (explicit && explicit !== pinned && !allowCross) {
61
+ throw new Error(`environment_mismatch: this session is pinned to "${pinned}" 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
62
  }
58
- return explicit || sessionEnv;
63
+ return explicit || pinned;
59
64
  }
60
- if (explicit) { sessionEnv = explicit; return explicit; } // pin the first explicit choice
65
+ if (explicit) { setSessionEnv(api, explicit); return explicit; } // pin the first explicit choice
61
66
  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
67
  }
63
68
 
64
69
  // Shared schema fragment: the per-call escape hatch for the session environment pin.
65
70
  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
71
 
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; }
72
+ // resetSessionEnv clears a session's pin. With api-scoped state each test uses a fresh api,
73
+ // so pins never cross tests; kept exported and tolerant of any/no arg for the existing
74
+ // beforeEach(resetSessionEnv) hooks (a no-op when called without a real api).
75
+ export function resetSessionEnv(api) { if (api && typeof api === 'object') api._sessionEnv = null; }
70
76
 
71
77
  // assertResourceEnv enforces the session pin on an EXISTING resource addressed by id (JNG #136 §4):
72
78
  // if the session is pinned and the resource lives in a different environment, refuse so an
73
79
  // ID-based op can't touch the wrong environment's resource. allowCrossEnvironment overrides it;
74
80
  // resources with no environment (buckets are shared) and an unpinned session are not guarded.
75
81
  async function assertResourceEnv(api, getPath, allowCross = false) {
76
- if (!sessionEnv || allowCross) return;
82
+ const pinned = getSessionEnv(api);
83
+ if (!pinned || allowCross) return;
77
84
  let env;
78
85
  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.`);
86
+ if (env && env !== pinned) {
87
+ throw new Error(`environment_mismatch: this session is pinned to "${pinned}" 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
88
  }
82
89
  }
83
90
 
@@ -201,6 +208,11 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
201
208
  const resources = specToResources(spec);
202
209
  if (resources.length) {
203
210
  try { cost = (await api.estimate({ projectId }, { resources })).cost; } catch { cost = null; }
211
+ } else if (kind === 'database') {
212
+ // A managed DB is storage-billed only (metering.go/JNG #25); with no volume
213
+ // there is no fixed cost. Quote Rp0 rather than a bare null (#149 r18a note:
214
+ // "Redis create returns a null cost estimate") so the gate shows a real figure.
215
+ cost = { monthly: 0, hourly: 0, currency: 'IDR', note: 'storage-billed only; no volume specified' };
204
216
  }
205
217
  const costNote = cost ? formatCost(cost) : '';
206
218
 
@@ -270,8 +282,8 @@ export const TOOLS = [
270
282
  required: ['environment'],
271
283
  },
272
284
  async call(api, args) {
273
- sessionEnv = envStrict(args.environment);
274
- return { environment: sessionEnv, pinned: true, note: 'Pinned for this session. Env-requiring tools now use it; override per-call by passing environment explicitly.' };
285
+ setSessionEnv(api, envStrict(args.environment));
286
+ return { environment: getSessionEnv(api), pinned: true, note: 'Pinned for this session. Env-requiring tools now use it; override per-call by passing environment explicitly.' };
275
287
  },
276
288
  },
277
289
  {
@@ -281,7 +293,7 @@ export const TOOLS = [
281
293
  schema: { type: 'object', properties: {} },
282
294
  async call(api) {
283
295
  const acct = (typeof api.actingIdentity === 'function') ? await api.actingIdentity().catch(() => ({})) : {};
284
- return { environment: sessionEnv, ...acct };
296
+ return { environment: getSessionEnv(api), ...acct };
285
297
  },
286
298
  },
287
299
  {
@@ -317,6 +329,10 @@ export const TOOLS = [
317
329
  const v = (await api.get('/version')).data || {};
318
330
  controlPlane.version = v.version ?? null;
319
331
  controlPlane.gitCommit = v.gitCommit ?? null;
332
+ // Report the PUBLIC control-plane URL when the server advertises one — the
333
+ // hosted sidecar reaches the control plane on loopback (127.0.0.1), so
334
+ // api.baseUrl alone would leak the internal address (#175 r18a retest).
335
+ if (v.publicUrl) controlPlane.baseUrl = v.publicUrl;
320
336
  } catch { /* leave nulls */ }
321
337
 
322
338
  // Price book is readable by any member; surface whether cost data is
@@ -335,7 +351,7 @@ export const TOOLS = [
335
351
  { type: 'template', create: true, deploy: true, scale: false, update: true, logs: true, variables: true, delete: true },
336
352
  { type: 'static_site', create: 'via_folder', deploy: true, scale: false, update: true, domains: true, logs: true, variables: true, delete: true, nativeStatic: true, note: 'neo_deploy_folder serviceType=static_site with buildCommand/publishDirectory → native nginx (0.4.27); neo_update_service for domain/tls/path' },
337
353
  { type: 'worker', create: 'via_folder', deploy: true, scale: true, update: true, health: true, autoscale: true, logs: true, variables: true, usage: true, delete: true, note: 'neo_deploy_folder serviceType=worker (no public ingress) + neo_update_service (compute/replicas/autoscale/command/health)' },
338
- { type: 'database', create: true, deploy: true, get: true, list: true, update: true, backup: true, restore: true, verify: true, publicAccess: true, delete: true, note: 'neo_create/deploy/list/get/update(resize)/delete_database + backup + restore (postgres, local backups) + verify (SQL round-trip) + enable/disable_db_public_access (handoff 4.4)' },
354
+ { type: 'database', create: true, deploy: true, get: true, list: true, update: true, backup: true, restore: true, verify: true, publicAccess: 'postgres-only', delete: true, engines: ['postgres', 'mysql', 'mariadb', 'redis'], note: 'neo_create/deploy/list/get/update(resize)/delete_database + backup + restore (postgres & mysql/mariadb SQL dumps; redis RDB restores via pod file-swap) + verify (postgres/mysql SQL round-trip, redis SET/GET/TTL/DEL). Managed DBs are storage-billed only (no compute line). publicAccess is postgres-only today; mysql/redis are private-binding only (handoff 4.4, #149).' },
339
355
  { type: 'queue', create: true, deploy: true, get: true, list: true, update: true, verify: true, publicAccess: true, delete: true, note: 'neo_create/deploy/list/get/update/verify(publish→persist)/delete_queue + enable/disable_queue_public_access (handoff 4.5)' },
340
356
  { type: 'cron', create: true, get: true, list: true, run: true, runs: true, questionnaire: true, delete: true, note: 'neo_cron_questionnaire (guided) + create/list/get/run/list_runs/delete_cron (handoff 4.6)' },
341
357
  { type: 'object_storage', create: true, get: true, list: true, objects: true, upload: true, verify: true, delete: true, note: 'neo_create/list/get/verify(put→list→delete)/delete_bucket + list/upload/presign(get)/delete objects (handoff 4.7)' },
@@ -352,7 +368,7 @@ export const TOOLS = [
352
368
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
353
369
  environments: ['staging', 'production'],
354
370
  // Active session context + the one-environment-per-session invariant (JNG #136).
355
- context: { environment: sessionEnv, pinned: !!sessionEnv },
371
+ context: { environment: getSessionEnv(api), pinned: !!getSessionEnv(api) },
356
372
  environmentIsolation: {
357
373
  supported: true,
358
374
  pinnedBy: ['neo_set_context', 'first explicit environment on an env-requiring call'],
@@ -468,9 +484,9 @@ export const TOOLS = [
468
484
  type: 'object',
469
485
  properties: {
470
486
  appId: str('Service/app id'),
471
- computeTier: str('Bundled compute tier (micro | small | medium | large | xlarge)'),
472
- cpuLimit: str('Custom CPU limit, e.g. "500m" (use with memoryLimit; unbundled)'),
473
- memoryLimit: str('Custom memory limit, e.g. "512Mi" (unbundled)'),
487
+ computeTier: str('Legacy size shorthand (micro|small|medium|large|xlarge) — maps to its concrete vCPU/RAM. Prefer cpuLimit+memoryLimit. Compute is resource-based (#144): priced per vCPU-hour + GB-RAM-hour, no packages.'),
488
+ cpuLimit: str('CPU, e.g. "500m" or "1" (use with memoryLimit) — resource-based billing'),
489
+ memoryLimit: str('Memory, e.g. "512Mi" or "1Gi" (use with cpuLimit) — resource-based billing'),
474
490
  replicas: int('Fixed replica count'),
475
491
  autoscaleMin: int('Autoscale min replicas'),
476
492
  autoscaleMax: int('Autoscale max replicas'),
@@ -498,7 +514,9 @@ export const TOOLS = [
498
514
  const { appId, confirm, orgId, computeTier, allowCrossEnvironment, ...rest } = args;
499
515
  await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
500
516
  const body = { ...rest };
501
- if (computeTier) { body.billingMode = 'bundled'; body.cpuLimit = computeTier; }
517
+ // #144: compute is resource-based. A legacy tier shorthand is passed through as
518
+ // cpuLimit; the control plane maps it to concrete vCPU/RAM and bills unbundled.
519
+ if (computeTier) { body.cpuLimit = computeTier; }
502
520
  else if (args.cpuLimit || args.memoryLimit) { body.billingMode = 'unbundled'; }
503
521
  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);
504
522
  if (costChanging && confirm !== true) {
@@ -729,7 +747,7 @@ export const TOOLS = [
729
747
  required: ['projectId'],
730
748
  },
731
749
  async call(api, args) {
732
- const env = requireEnv(args.environment, args.allowCrossEnvironment);
750
+ const env = requireEnv(api, args.environment, args.allowCrossEnvironment);
733
751
  return { environment: env, ...(await api.get(`/api/v1/projects/${args.projectId}/environments/${env}/config`)).data };
734
752
  },
735
753
  },
@@ -781,22 +799,22 @@ export const TOOLS = [
781
799
  },
782
800
  {
783
801
  name: 'neo_get_database_metrics',
784
- 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.',
802
+ description: 'Current resource usage for a managed database — postgres, mysql/mariadb, or redis (#149). Always: cpuMillicores + memoryBytes (metrics-server). SQL engines: databaseSizeBytes vs volumeSize, connections / maxConnections / connectionsPercent. Redis: usedMemoryBytes / maxMemoryBytes / memoryPercent, connectedClients, keys, opsPerSec, hitRatioPercent, evictedKeys, role. Snapshot only (no history). Use before a migration or resize to check headroom.',
785
803
  mutating: false,
786
- schema: { type: 'object', properties: { databaseId: str('Database id (postgres, deployed)') }, required: ['databaseId'] },
804
+ schema: { type: 'object', properties: { databaseId: str('Database id (deployed)') }, required: ['databaseId'] },
787
805
  async call(api, args) {
788
806
  return (await api.get(`/api/v1/databases/${args.databaseId}/metrics`)).data;
789
807
  },
790
808
  },
791
809
  {
792
810
  name: 'neo_db_query',
793
- 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;".',
811
+ description: 'Run a SQL query inside a managed SQL database\'s primary pod (postgres or mysql/mariadb) and return the result (JNG #128, #149). 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. For Redis use neo_redis_command. Use for quick checks like "SELECT count(*) FROM sessions;".',
794
812
  mutating: true,
795
813
  schema: {
796
814
  type: 'object',
797
815
  properties: {
798
- databaseId: str('Database id (postgres, deployed)'),
799
- sql: str('SQL to run (piped into psql)'),
816
+ databaseId: str('Database id (postgres/mysql, deployed)'),
817
+ sql: str('SQL to run (piped into the engine client)'),
800
818
  readOnly: { type: 'boolean', description: 'true (default) runs in a read-only session; false allows writes' },
801
819
  ...allowCrossEnvProp,
802
820
  },
@@ -807,6 +825,24 @@ export const TOOLS = [
807
825
  return (await api.post(`/api/v1/databases/${args.databaseId}/query`, { sql: args.sql, readOnly: args.readOnly })).data;
808
826
  },
809
827
  },
828
+ {
829
+ name: 'neo_redis_command',
830
+ description: 'Run a single Redis command inside a managed redis instance\'s pod (safe inspection, #149). Destructive/administrative commands are blocked (FLUSHALL, FLUSHDB, CONFIG, SHUTDOWN, EVAL, CLUSTER, ACL, CLIENT, REPLICAOF, …); ordinary commands (GET, TTL, TYPE, DBSIZE, INFO, SCAN, LLEN, HGETALL, …) are allowed. Credentials are never exposed; the database must be deployed. For SQL engines use neo_db_query.',
831
+ mutating: true,
832
+ schema: {
833
+ type: 'object',
834
+ properties: {
835
+ databaseId: str('Database id (redis, deployed)'),
836
+ command: str('Redis command, e.g. "GET mykey" or "INFO memory"'),
837
+ ...allowCrossEnvProp,
838
+ },
839
+ required: ['databaseId', 'command'],
840
+ },
841
+ async call(api, args) {
842
+ await assertResourceEnv(api, `/api/v1/databases/${args.databaseId}`, args.allowCrossEnvironment); // JNG #136 §4
843
+ return (await api.post(`/api/v1/databases/${args.databaseId}/redis-command`, { command: args.command })).data;
844
+ },
845
+ },
810
846
  {
811
847
  name: 'neo_set_variable',
812
848
  description:
@@ -824,7 +860,7 @@ export const TOOLS = [
824
860
  required: ['projectId', 'key', 'value'],
825
861
  },
826
862
  async call(api, args) {
827
- const env = requireEnv(args.environment, args.allowCrossEnvironment);
863
+ const env = requireEnv(api, args.environment, args.allowCrossEnvironment);
828
864
  const path = `/api/v1/projects/${args.projectId}/environments/${env}/config`;
829
865
  // The PUT replaces the whole set, so merge current vars first.
830
866
  const current = (await api.get(path)).data?.vars || [];
@@ -936,14 +972,14 @@ export const TOOLS = [
936
972
  {
937
973
  name: 'neo_estimate',
938
974
  description:
939
- 'Estimate the hourly and monthly cost (IDR) of a service or resource from the LIVE price book, so you can show the user the cost BEFORE a billable create/deploy (handoff 3.3/3.4). Read-only. Covers compute (a tier, or custom vcpu/memoryMB), persistent storage, and object storage; variable usage (queue messages, cron runs) is excluded and noted. Pass orgId to apply that org\'s price overrides.',
975
+ 'Estimate the hourly and monthly cost (IDR) of a service or resource from the LIVE price book, so you can show the user the cost BEFORE a billable create/deploy (handoff 3.3/3.4). Read-only. Compute is resource-based (#144): priced per vCPU-hour + GB-RAM-hour independently (no packages), hourly, capped at 730 billable hours/month. Covers compute (vcpu+memoryMB, or a legacy tier shorthand that resolves to concrete vCPU/RAM), persistent storage, and object storage (all per GB-hour); variable usage (queue messages, cron runs) is excluded and noted. The monthly figure is a non-authoritative estimate (hourly × up to 730). Pass orgId to apply that org\'s price overrides.',
940
976
  mutating: false,
941
977
  schema: {
942
978
  type: 'object',
943
979
  properties: {
944
- computeTier: str('Compute tier key: micro | small | medium | large | xlarge (bundled compute)'),
945
- vcpu: { type: 'number', description: 'Custom vCPU count (use instead of computeTier)' },
946
- memoryMB: int('Custom memory in MB (use with vcpu)'),
980
+ computeTier: str('Legacy size shorthand (micro|small|medium|large|xlarge) — resolves to concrete vCPU/RAM. Prefer vcpu+memoryMB. Compute is resource-based (#144), not packaged.'),
981
+ vcpu: { type: 'number', description: 'vCPU count — priced per vCPU-hour (preferred over computeTier)' },
982
+ memoryMB: int('Memory in MB — priced per GB-RAM-hour (use with vcpu)'),
947
983
  storageGB: int('Persistent/database storage in GB (optional)'),
948
984
  objectStorageGB: int('Object-storage quota in GB (optional)'),
949
985
  replicas: int('Replica count (default 1)'),
@@ -1023,7 +1059,7 @@ export const TOOLS = [
1023
1059
  },
1024
1060
  {
1025
1061
  name: 'neo_list_templates',
1026
- description: 'List deployable templates (e.g. wordpress, n8n, redis).',
1062
+ description: 'List deployable templates (e.g. n8n) with their manifest: the Creates/Reuses resource graph, pinned source/version, min resources, public routes, and limitations — enough to show the resource graph and a live estimate before deploy. WordPress was retired.',
1027
1063
  mutating: false,
1028
1064
  schema: { type: 'object', properties: {} },
1029
1065
  async call(api) {
@@ -1032,7 +1068,7 @@ export const TOOLS = [
1032
1068
  },
1033
1069
  {
1034
1070
  name: 'neo_deploy_template',
1035
- description: 'Deploy a template (e.g. wordpress, n8n) into a project. May return pending_approval.',
1071
+ description: 'Deploy a template (e.g. n8n) into a project. May return pending_approval. Retired templates (e.g. wordpress) return 410 "no longer available".',
1036
1072
  mutating: true,
1037
1073
  schema: {
1038
1074
  type: 'object',
@@ -1069,7 +1105,7 @@ export const TOOLS = [
1069
1105
  required: ['projectId', 'path', 'environment'],
1070
1106
  },
1071
1107
  async call(api, args) {
1072
- const environment = requireEnv(args.environment, args.allowCrossEnvironment);
1108
+ const environment = requireEnv(api, args.environment, args.allowCrossEnvironment);
1073
1109
  const p = resolve(args.path);
1074
1110
  let src;
1075
1111
  try {
@@ -1129,7 +1165,7 @@ export const TOOLS = [
1129
1165
  properties: {
1130
1166
  projectId: str('Project id'),
1131
1167
  name: str('Database name'),
1132
- engine: { type: 'string', description: 'postgres | mysql (engine)' },
1168
+ engine: { type: 'string', description: 'postgres | mysql | redis (also mariadb, mongo) — engine' },
1133
1169
  version: str('Engine version, e.g. "16" (optional)'),
1134
1170
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1135
1171
  ...allowCrossEnvProp,
@@ -1142,7 +1178,7 @@ export const TOOLS = [
1142
1178
  required: ['projectId', 'name', 'engine', 'environment'],
1143
1179
  },
1144
1180
  async call(api, args) {
1145
- const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1181
+ const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1146
1182
  validateDbArgs(args); // reject sub-10Gi / EOL config before pricing (JNG #132)
1147
1183
  const gate = await billableGate(api, { projectId: args.projectId, kind: 'database', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm, environment }, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
1148
1184
  if (gate.requiresConfirmation) return { environment, ...gate };
@@ -1213,9 +1249,9 @@ export const TOOLS = [
1213
1249
  },
1214
1250
  {
1215
1251
  name: 'neo_verify_database',
1216
- description: 'Functionally verify a managed database (handoff §8.1): runs an in-cluster probe that opens the DB and does a SQL round-trip (SELECT 1), returning {verified, log} — WITHOUT handing you any credentials. Postgres only for now. Synchronous (waits up to ~60s for the probe).',
1252
+ description: 'Functionally verify a managed database (handoff §8.1, #149): runs an in-cluster probe that connects and does an engine-level round trip — postgres/mysql a disposable SQL transaction, redis a namespaced SET/GET/TTL/DEL — returning {verified, check, log} WITHOUT handing you any credentials. This is the authoritative readiness check after neo_deploy_database. Synchronous (waits up to ~60s for the probe).',
1217
1253
  mutating: true,
1218
- schema: { type: 'object', properties: { databaseId: str('Database id (postgres)') }, required: ['databaseId'] },
1254
+ schema: { type: 'object', properties: { databaseId: str('Database id (deployed)') }, required: ['databaseId'] },
1219
1255
  async call(api, args) {
1220
1256
  const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/verify`);
1221
1257
  return result(status, data);
@@ -1223,13 +1259,13 @@ export const TOOLS = [
1223
1259
  },
1224
1260
  {
1225
1261
  name: 'neo_restore_database',
1226
- description: 'Restore a postgres database from a prior backup (handoff 4.4). DESTRUCTIVE — applies the backup dump into the LIVE database (overwrites/merges) and is irreversible, so it requires confirm:true. Pass backupId for a specific backup, else the latest successful one is used. Local-destination backups only for now.',
1262
+ description: 'Restore a postgres or mysql/mariadb database from a prior backup (handoff 4.4, #149). DESTRUCTIVE — applies the backup SQL dump into the LIVE database (overwrites/merges) and is irreversible, so it requires confirm:true. Pass backupId for a specific backup, else the latest successful one is used. Local-destination backups only for now. Redis RDB snapshots restore by a pod-level file swap, not here.',
1227
1263
  mutating: true,
1228
1264
  destructive: true,
1229
1265
  schema: {
1230
1266
  type: 'object',
1231
1267
  properties: {
1232
- databaseId: str('Database id (postgres)'),
1268
+ databaseId: str('Database id (postgres/mysql)'),
1233
1269
  backupId: str('Backup id to restore from (optional; default the latest successful backup)'),
1234
1270
  confirm: { type: 'boolean', description: 'Must be true — restore overwrites data in the live database.' },
1235
1271
  },
@@ -1320,7 +1356,7 @@ export const TOOLS = [
1320
1356
  required: ['projectId', 'name', 'environment'],
1321
1357
  },
1322
1358
  async call(api, args) {
1323
- const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1359
+ const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1324
1360
  const gate = await billableGate(api, { projectId: args.projectId, kind: 'queue', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm, environment }, { queue: true }); // queue bills per-message only (JNG #52) — 0 fixed + queue_message rate
1325
1361
  if (gate.requiresConfirmation) return { environment, ...gate };
1326
1362
  const { projectId, confirm, confirmationId, orgId, ...body } = args;
@@ -1462,7 +1498,7 @@ export const TOOLS = [
1462
1498
  required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
1463
1499
  },
1464
1500
  async call(api, args) {
1465
- const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1501
+ const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1466
1502
  const gate = await billableGate(api, { projectId: args.projectId, kind: 'cron', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { cron: true }); // cron has no fixed cost — the contract surfaces the per-run `cron_run` rate in cost.variable
1467
1503
  if (gate.requiresConfirmation) return { environment, ...gate };
1468
1504
  // Merge contentType into headers for convenience (unless already set).
@@ -1637,8 +1673,9 @@ export const TOOLS = [
1637
1673
  if (!allowCrossEnvironment) {
1638
1674
  let cEnv;
1639
1675
  try { cEnv = (await api.get(`/api/v1/apps/${appId}`)).data?.environment; } catch { /* can't resolve → don't block */ }
1640
- if (sessionEnv && cEnv && cEnv !== sessionEnv) {
1641
- 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.`);
1676
+ const pinnedEnv = getSessionEnv(api);
1677
+ if (pinnedEnv && cEnv && cEnv !== pinnedEnv) {
1678
+ throw new Error(`environment_mismatch: this session is pinned to "${pinnedEnv}" but that service is in "${cEnv}" (JNG #136). Pass allowCrossEnvironment:true to override, or re-pin with neo_set_context.`);
1642
1679
  }
1643
1680
  if (args.targetType !== 'neo_object_storage') {
1644
1681
  const tPath = args.targetType === 'neo_db' ? `/api/v1/databases/${args.targetId}` : `/api/v1/queues/${args.targetId}`;
@@ -1744,7 +1781,9 @@ export const TOOLS = [
1744
1781
  branch: str('app: git branch'),
1745
1782
  deps: { type: 'array', items: { type: 'string', enum: ['database', 'queue', 'object_storage'] }, description: 'app: managed-resource dependencies this service needs (from neo_scan_repo `deps`). The plan flags any dep not satisfied by a binding as incompleteTopology and apply refuses it unless acknowledged — so a service needing NATS_URL/DATABASE_URL isn\'t deployed into a 503 (JNG #112 §7).' },
1746
1783
  port: int('app: container port'),
1747
- computeTier: str('app/db/queue: micro | small | medium | large | xlarge'),
1784
+ computeTier: str('app: legacy size shorthand (micro|small|medium|large|xlarge) → concrete vCPU/RAM; prefer cpuLimit+memoryLimit. Compute is resource-based (#144).'),
1785
+ cpuLimit: str('app: CPU, e.g. "0.5" or "1" (use with memoryLimit) — resource-based'),
1786
+ memoryLimit: str('app: memory, e.g. "512M" or "1Gi" (use with cpuLimit) — resource-based'),
1748
1787
  engine: str('database: postgres | mysql'),
1749
1788
  version: str('database: engine version, e.g. 16'),
1750
1789
  volumeSize: str('database: storage size, e.g. 10Gi'),
@@ -1790,7 +1829,7 @@ export const TOOLS = [
1790
1829
  required: ['projectId', 'environment', 'resources'],
1791
1830
  },
1792
1831
  async call(api, args) {
1793
- const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1832
+ const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1794
1833
  const { projectId, ...body } = args;
1795
1834
  body.environment = environment; // send the resolved env so the pin actually targets it
1796
1835
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/plans`, body);