@biznetgio/neo-app 0.4.74 → 0.4.77

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.74",
3
+ "version": "0.4.77",
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.74 · **Last updated:** 2026-11-05
9
+ **Version:** 0.4.77 · **Last updated:** 2026-11-07
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
@@ -123,6 +123,12 @@ tools — never the portal UI. The lifecycle is the same for every resource type
123
123
  explicit **Approve & Create**. Individual creates (`neo_create_database`,
124
124
  `neo_create_queue`) instead return the cost and create nothing until you re-call
125
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).
126
132
  3. **Apply**: `neo_apply_stack({planId, planHash})`. If the price book changed since the
127
133
  plan, apply is refused — re-plan to re-quote. A `pending_approval` result is the
128
134
  approval policy working, not an error; wait for it.
@@ -0,0 +1,98 @@
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
+ import { redactSecrets } from '../redact.js';
13
+
14
+ // The full lifecycle, with the MCP tool that performs each one. Shown by `neo-app db`.
15
+ const OPS = [
16
+ ['list', 'neo-app db list', 'List databases in the linked project (read-only)'],
17
+ ['get <id>', 'neo-app db get <id>', 'Show one database, incl. redacted connection string (read-only)'],
18
+ ['create', 'neo_create_database', 'Provision PostgreSQL / MySQL / Redis (billable — cost consent)'],
19
+ ['deploy', 'neo_deploy_database', 'Start/roll out the database pods'],
20
+ ['verify', 'neo_verify_database', 'Round-trip health probe (engine-aware)'],
21
+ ['query', 'neo_db_query', 'Read-only SQL (postgres/mysql)'],
22
+ ['redis', 'neo_redis_command', 'Run a safe Redis command (denylist-guarded)'],
23
+ ['metrics', 'neo_get_database_metrics', 'Engine-aware metrics snapshot'],
24
+ ['backup', 'neo_run_backup / neo_create_backup', 'Create and run a logical backup'],
25
+ ['restore', 'neo_restore_database', 'Restore a SQL dump (postgres/mysql)'],
26
+ ['public', 'neo_enable_db_public_access','Enable/disable TLS public access'],
27
+ ['delete', 'neo_delete_database', 'Delete a database (billable stop)'],
28
+ ];
29
+
30
+ function usage() {
31
+ log(`${c.bold('neo-app db')} — managed database lifecycle (PostgreSQL · MySQL · Redis)\n`);
32
+ log(c.bold('Run directly from the CLI (read-only):'));
33
+ log(' neo-app db list');
34
+ log(' neo-app db get <database-id>\n');
35
+ log(c.bold('Mutating / billable operations run through the MCP server'));
36
+ log(`(start it with ${c.bold('neo-app mcp')} — your agent calls these; the Portal exposes them too):`);
37
+ for (const [, how, desc] of OPS.slice(2)) log(` · ${c.bold(how.padEnd(34))} ${c.dim(desc)}`);
38
+ log(`\nPrice a database before creating it: ${c.bold('neo-app estimate --storage <GB>')}`);
39
+ }
40
+
41
+ function connect(opts) {
42
+ const cfg = resolveConfig(opts);
43
+ const cred = getCredential(cfg.baseUrl);
44
+ if (!cred?.token) { fail(`not authenticated for ${cfg.baseUrl} — run 'neo-app login'`); process.exit(1); }
45
+ return new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
46
+ }
47
+
48
+ function requireProject(opts) {
49
+ const projectId = opts.project || readLink()?.projectId;
50
+ if (!projectId) { fail("no linked project — run 'neo-app link <project-id>' or pass --project <id>"); process.exit(1); }
51
+ return projectId;
52
+ }
53
+
54
+ export async function db(sub, opts = {}, positionals = []) {
55
+ if (!sub || sub === 'help') return void usage();
56
+
57
+ if (sub === 'list' || sub === 'ls') {
58
+ const api = connect(opts);
59
+ const projectId = requireProject(opts);
60
+ let dbs;
61
+ try { dbs = (await api.get(`/api/v1/projects/${projectId}/databases`)).data?.databases || []; }
62
+ catch (e) { fail(`list failed: ${e.message || e}`); process.exit(1); }
63
+ // Credentials are write-only: the API hands an owner/admin an unredacted
64
+ // connectionString (Portal reveal), but a terminal must never echo it (#149 retest).
65
+ dbs = redactSecrets(dbs);
66
+ if (opts.json) return void process.stdout.write(JSON.stringify({ databases: dbs }, null, 2) + '\n');
67
+ if (!dbs.length) return void ok('no databases in this project');
68
+ for (const d of dbs) {
69
+ const status = d.status || (d.host ? 'deployed' : 'not_deployed');
70
+ log(` ${c.bold(d.name)} ${c.dim(`${d.engine} ${d.version || ''}`.trim())} ${status} ${c.dim(d.environment || 'production')} ${c.dim(d.id)}`);
71
+ }
72
+ return;
73
+ }
74
+
75
+ if (sub === 'get' || sub === 'show') {
76
+ const id = positionals[0];
77
+ if (!id) { fail('usage: neo-app db get <database-id>'); process.exit(2); }
78
+ const api = connect(opts);
79
+ let view;
80
+ try { view = (await api.get(`/api/v1/databases/${id}`)).data; }
81
+ catch (e) { fail(`get failed: ${e.message || e}`); process.exit(1); }
82
+ view = redactSecrets(view); // write-only credentials never reach the terminal (#149 retest)
83
+ if (opts.json) return void process.stdout.write(JSON.stringify(view, null, 2) + '\n');
84
+ for (const [k, v] of Object.entries(view)) if (v !== undefined && v !== null && v !== '') log(` ${c.dim(k.padEnd(18))} ${v}`);
85
+ return;
86
+ }
87
+
88
+ // Any lifecycle verb typed at the CLI: point the user at the discoverable path.
89
+ const known = OPS.find(([key]) => key.split(' ')[0] === sub);
90
+ if (known) {
91
+ log(`${c.bold(sub)} runs through the MCP server (${c.bold(known[1])}) or the Portal — not as a direct CLI write.`);
92
+ log(`Start the MCP server with ${c.bold('neo-app mcp')} so your agent can call it, or use the Portal.`);
93
+ return;
94
+ }
95
+ fail(`unknown db subcommand: ${sub}`);
96
+ usage();
97
+ process.exit(2);
98
+ }
package/src/index.js CHANGED
@@ -20,6 +20,8 @@ ${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
26
  mcp Run the MCP server: local stdio (default), or hosted
25
27
  Streamable HTTP + SSE with --http [--port <n>] [--host <h>]
@@ -38,8 +40,8 @@ ${c.bold('Options')}
38
40
  --yes Non-interactive (headless/CI); also approves a billable deploy without the cost prompt
39
41
  --dry-run Print intended changes without writing
40
42
  --json Machine-readable output incl. the normalized cost (deploy, estimate)
41
- --tier <key> estimate: compute tier (micro|small|medium|large|xlarge)
42
- --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
43
45
  --storage <GB> estimate: database/persistent storage
44
46
  --object <GB> estimate: object-storage quota (billed monthly-upfront)
45
47
  --queue / --cron estimate: a queue (per-message) / cron (per-run) resource
@@ -106,8 +108,11 @@ export async function main(argv = process.argv.slice(2)) {
106
108
  // Root help, and `--help` on a KNOWN command, print usage. An UNKNOWN command must
107
109
  // still error (nonzero exit) even with `--help`, so a bad command isn't silently
108
110
  // accepted (JNG #97). Per-subcommand help is tracked in #89.
109
- const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
110
- if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd))) return void console.log(HELP);
111
+ const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'db', 'version', 'help']);
112
+ // `db --help` renders the DB-specific help (its usage() lists the lifecycle), not the
113
+ // generic top-level help — #149 retest CLI discoverability caveat. Other known commands
114
+ // still fall back to the root HELP (per-subcommand help tracked in #89).
115
+ if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd) && cmd !== 'db')) return void console.log(HELP);
111
116
 
112
117
  try {
113
118
  // `agent setup` and `setup agent` both work (parity with InstaCloud's `setup agent`).
@@ -123,6 +128,7 @@ export async function main(argv = process.argv.slice(2)) {
123
128
  if (cmd === 'project' && sub === 'create') return void (await (await import('./commands/link.js')).createProject(positionals[2], opts));
124
129
  if (cmd === 'project' && (sub === 'list' || sub === 'ls')) return void (await (await import('./commands/link.js')).listProjects(opts));
125
130
  if (cmd === 'status' || cmd === 'whoami') return void (await (await import('./commands/status.js')).status(opts));
131
+ if (cmd === 'db') return void (await (await import('./commands/db.js')).db(sub, opts, positionals.slice(2)));
126
132
  if (cmd === 'mcp') {
127
133
  const mcp = await import('./mcp/server.js');
128
134
  // Hosted mode: Streamable HTTP + SSE over one listener (auth per request). Default: stdio.
package/src/mcp/tools.js CHANGED
@@ -7,6 +7,7 @@ import { statSync, readFileSync } from 'node:fs';
7
7
  import { deploySource } from '../deploySource.js';
8
8
  import { pkgVersion } from '../util.js';
9
9
  import { specToResources, formatCost } from '../cost.js';
10
+ import { redactSecrets } from '../redact.js';
10
11
 
11
12
  const str = (description) => ({ type: 'string', description });
12
13
  const int = (description) => ({ type: 'integer', description });
@@ -245,22 +246,8 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
245
246
  };
246
247
  }
247
248
 
248
- const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey|authorization)/i;
249
- // Deployment snapshots (exact keys) embed rendered configs with plaintext creds — a
250
- // queue's `--pass`, a DB's POSTGRES_PASSWORD. Redact by EXACT key so we don't also
251
- // hit resourceId/source-of-truth-style names (substring match would catch "reSOURCEid").
252
- const SECRET_EXACT = new Set(['source', 'installed']);
253
- function redactSecrets(value) {
254
- if (Array.isArray(value)) return value.map(redactSecrets);
255
- if (value && typeof value === 'object') {
256
- const out = {};
257
- for (const [k, v] of Object.entries(value)) {
258
- out[k] = (SECRET_KEY.test(k) || SECRET_EXACT.has(k.toLowerCase())) ? '[redacted — credentials are write-only; bind them to a consumer instead]' : redactSecrets(v);
259
- }
260
- return out;
261
- }
262
- return value;
263
- }
249
+ // redactSecrets lives in ../redact.js — the single redactor the CLI `db` commands share,
250
+ // so every terminal/JSON output path masks credentials identically (#149 retest).
264
251
 
265
252
  export const TOOLS = [
266
253
  {
@@ -484,9 +471,9 @@ export const TOOLS = [
484
471
  type: 'object',
485
472
  properties: {
486
473
  appId: str('Service/app id'),
487
- computeTier: str('Bundled compute tier (micro | small | medium | large | xlarge)'),
488
- cpuLimit: str('Custom CPU limit, e.g. "500m" (use with memoryLimit; unbundled)'),
489
- memoryLimit: str('Custom memory limit, e.g. "512Mi" (unbundled)'),
474
+ 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.'),
475
+ cpuLimit: str('CPU, e.g. "500m" or "1" (use with memoryLimit) — resource-based billing'),
476
+ memoryLimit: str('Memory, e.g. "512Mi" or "1Gi" (use with cpuLimit) — resource-based billing'),
490
477
  replicas: int('Fixed replica count'),
491
478
  autoscaleMin: int('Autoscale min replicas'),
492
479
  autoscaleMax: int('Autoscale max replicas'),
@@ -514,7 +501,9 @@ export const TOOLS = [
514
501
  const { appId, confirm, orgId, computeTier, allowCrossEnvironment, ...rest } = args;
515
502
  await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
516
503
  const body = { ...rest };
517
- if (computeTier) { body.billingMode = 'bundled'; body.cpuLimit = computeTier; }
504
+ // #144: compute is resource-based. A legacy tier shorthand is passed through as
505
+ // cpuLimit; the control plane maps it to concrete vCPU/RAM and bills unbundled.
506
+ if (computeTier) { body.cpuLimit = computeTier; }
518
507
  else if (args.cpuLimit || args.memoryLimit) { body.billingMode = 'unbundled'; }
519
508
  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);
520
509
  if (costChanging && confirm !== true) {
@@ -970,16 +959,18 @@ export const TOOLS = [
970
959
  {
971
960
  name: 'neo_estimate',
972
961
  description:
973
- '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.',
962
+ '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), plus queue and cron (0 fixed cost — their per-message/per-run rates are disclosed in variable[]). The monthly figure is a non-authoritative estimate (hourly × up to 730). Pass orgId to apply that org\'s price overrides.',
974
963
  mutating: false,
975
964
  schema: {
976
965
  type: 'object',
977
966
  properties: {
978
- computeTier: str('Compute tier key: micro | small | medium | large | xlarge (bundled compute)'),
979
- vcpu: { type: 'number', description: 'Custom vCPU count (use instead of computeTier)' },
980
- memoryMB: int('Custom memory in MB (use with vcpu)'),
967
+ 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.'),
968
+ vcpu: { type: 'number', description: 'vCPU count — priced per vCPU-hour (preferred over computeTier)' },
969
+ memoryMB: int('Memory in MB — priced per GB-RAM-hour (use with vcpu)'),
981
970
  storageGB: int('Persistent/database storage in GB (optional)'),
982
971
  objectStorageGB: int('Object-storage quota in GB (optional)'),
972
+ queue: { type: 'boolean', description: 'Include a NEO Queue — 0 fixed cost; discloses the per-message (queue_message) rate in variable[]' },
973
+ cron: { type: 'boolean', description: 'Include a Scheduled Job — 0 fixed cost; discloses the per-run (cron_run) rate in variable[]' },
983
974
  replicas: int('Replica count (default 1)'),
984
975
  orgId: str('Organization id (optional — applies that org\'s price overrides)'),
985
976
  },
@@ -991,7 +982,7 @@ export const TOOLS = [
991
982
  if (!orgId) { try { orgId = await api.resolveOrgId(); } catch { orgId = null; } }
992
983
  if (!orgId) throw new Error('no organization for this account — cannot estimate cost');
993
984
  const resources = specToResources(args);
994
- if (!resources.length) throw new Error('nothing to estimate — pass computeTier (or vcpu+memoryMB), storageGB, and/or objectStorageGB');
985
+ if (!resources.length) throw new Error('nothing to estimate — pass computeTier (or vcpu+memoryMB), storageGB, objectStorageGB, queue, and/or cron');
995
986
  const { cost } = await api.estimate({ orgId }, { resources });
996
987
  return cost; // normalized contract: currency/hourly/monthly/breakdown/variable/upfront/priceBook/priceBookVersion/assumptions
997
988
  },
@@ -1779,7 +1770,9 @@ export const TOOLS = [
1779
1770
  branch: str('app: git branch'),
1780
1771
  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).' },
1781
1772
  port: int('app: container port'),
1782
- computeTier: str('app/db/queue: micro | small | medium | large | xlarge'),
1773
+ computeTier: str('app: legacy size shorthand (micro|small|medium|large|xlarge) → concrete vCPU/RAM; prefer cpuLimit+memoryLimit. Compute is resource-based (#144).'),
1774
+ cpuLimit: str('app: CPU, e.g. "0.5" or "1" (use with memoryLimit) — resource-based'),
1775
+ memoryLimit: str('app: memory, e.g. "512M" or "1Gi" (use with cpuLimit) — resource-based'),
1783
1776
  engine: str('database: postgres | mysql'),
1784
1777
  version: str('database: engine version, e.g. 16'),
1785
1778
  volumeSize: str('database: storage size, e.g. 10Gi'),
package/src/redact.js ADDED
@@ -0,0 +1,26 @@
1
+ // Single redactor for every CLI/MCP path that prints a control-plane response to a
2
+ // terminal, a file, or JSON (#149 retest SECURITY BLOCKER): managed-DB/queue credentials
3
+ // are write-only. The control-plane API still returns an unredacted connectionString to an
4
+ // owner/admin (the Portal's RBAC-gated reveal), so a terminal tool that echoes that response
5
+ // raw leaks a plaintext password. Every CLI/MCP output routes through redactSecrets so a
6
+ // credential never reaches a shell, a log, or a JSON dump — matching MCP's write-only stance.
7
+
8
+ const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey|authorization)/i;
9
+ // Deployment snapshots (exact keys) embed rendered configs with plaintext creds — a
10
+ // queue's `--pass`, a DB's POSTGRES_PASSWORD. Redact by EXACT key so we don't also hit
11
+ // resourceId/source-of-truth-style names (a substring match would catch "reSOURCEid").
12
+ const SECRET_EXACT = new Set(['source', 'installed']);
13
+
14
+ export const REDACTED = '[redacted — credentials are write-only; bind them to a consumer instead]';
15
+
16
+ export function redactSecrets(value) {
17
+ if (Array.isArray(value)) return value.map(redactSecrets);
18
+ if (value && typeof value === 'object') {
19
+ const out = {};
20
+ for (const [k, v] of Object.entries(value)) {
21
+ out[k] = (SECRET_KEY.test(k) || SECRET_EXACT.has(k.toLowerCase())) ? REDACTED : redactSecrets(v);
22
+ }
23
+ return out;
24
+ }
25
+ return value;
26
+ }