@biznetgio/neo-app 0.4.15 → 0.4.17

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.15",
3
+ "version": "0.4.17",
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": {
@@ -6,6 +6,7 @@ import { basename, join } from 'node:path';
6
6
  import { existsSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs';
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { tmpdir } from 'node:os';
9
+ import { estimateCost } from './pricing.js';
9
10
 
10
11
  export function sanitizeName(s) {
11
12
  return (
@@ -262,7 +263,16 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
262
263
 
263
264
  if (dryRun) {
264
265
  for (const p of plan) log(`[dry-run] would ${p}`);
265
- return { dryRun: true, plan, service: svc, environment: targetEnv };
266
+ // Cost confirmation before a billable deploy (handoff 3.3/3.4). A new service
267
+ // starts on the default compute tier; estimate from the live price book so the
268
+ // user sees the cost in the dry-run. Best-effort — never fail the dry-run on it.
269
+ let estimatedCost;
270
+ try {
271
+ const book = (await api.get('/api/v1/pricing')).data;
272
+ estimatedCost = estimateCost(book, { computeTier: 'micro' });
273
+ log(`[dry-run] estimated cost (default micro compute, ${svcType}): ~${estimatedCost.monthly} ${estimatedCost.currency}/mo (${estimatedCost.hourly}/h) — ${estimatedCost.assumptions}. Use neo_estimate for a specific size.`);
274
+ } catch { /* price book unavailable — skip the estimate, not the dry-run */ }
275
+ return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost };
266
276
  }
267
277
 
268
278
  if (src.mode === 'dir' || src.mode === 'zip') {
package/src/mcp/tools.js CHANGED
@@ -6,6 +6,7 @@ import { resolve } from 'node:path';
6
6
  import { statSync } from 'node:fs';
7
7
  import { deploySource } from '../deploySource.js';
8
8
  import { pkgVersion } from '../util.js';
9
+ import { estimateCost } from '../pricing.js';
9
10
 
10
11
  const str = (description) => ({ type: 'string', description });
11
12
  const int = (description) => ({ type: 'integer', description });
@@ -27,6 +28,17 @@ function result(status, data) {
27
28
  // (password, connection string, access/secret key, token) back to the agent.
28
29
  // Recursively replace any secret-looking value with a reference marker so the
29
30
  // agent still sees the resource's shape and non-secret identifiers.
31
+ // §2 safety: a create/deploy mutation must name its environment explicitly —
32
+ // NEVER default to production. A silent prod mutation is the exact risk the
33
+ // handoff calls out ("Never default a missing environment to production").
34
+ function requireEnv(env) {
35
+ 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
+ }
39
+ return e;
40
+ }
41
+
30
42
  const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey)/i;
31
43
  function redactSecrets(value) {
32
44
  if (Array.isArray(value)) return value.map(redactSecrets);
@@ -84,7 +96,7 @@ export const TOOLS = [
84
96
  { type: 'worker', create: 'via_folder', deploy: true, scale: true, logs: true, variables: true, delete: true, note: 'neo_deploy_folder serviceType=worker — no public ingress; repo auto-detection still TODO (handoff 6)' },
85
97
  { type: 'database', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_database (0.4.15); update/plan still TODO (handoff 4.4)' },
86
98
  { type: 'queue', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_queue (0.4.15); update/plan still TODO (handoff 4.5)' },
87
- { type: 'cron', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_cron (0.4.15); manual-run/history still TODO (handoff 4.6)' },
99
+ { type: 'cron', create: true, get: true, list: true, run: true, delete: true, note: 'neo_create/list/get/run/delete_cron (0.4.15-0.4.17); run-history still TODO (handoff 4.6)' },
88
100
  { type: 'object_storage', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_bucket (0.4.15); object put/get still TODO (handoff 4.7)' },
89
101
  ];
90
102
 
@@ -97,7 +109,7 @@ export const TOOLS = [
97
109
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
98
110
  environments: ['staging', 'production'],
99
111
  pricing,
100
- costConsent: { planApplyEnforced: false, note: 'neo_plan_stack / Approve & Create not implemented yet (handoff 3.3-3.4)' },
112
+ costConsent: { estimateAvailable: true, dryRunIncludesCost: true, planApplyEnforced: false, note: 'neo_estimate + deploy dry-run show cost (0.4.16); server-enforced neo_plan_stack / Approve & Create is still TODO (handoff 3.3-3.5)' },
101
113
  minClientVersion: pkgVersion(),
102
114
  deprecations: [],
103
115
  };
@@ -260,6 +272,37 @@ export const TOOLS = [
260
272
  return (await api.get(`/api/v1/orgs/${orgId}/credit`)).data;
261
273
  },
262
274
  },
275
+ {
276
+ name: 'neo_estimate',
277
+ description:
278
+ '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.',
279
+ mutating: false,
280
+ schema: {
281
+ type: 'object',
282
+ properties: {
283
+ computeTier: str('Compute tier key: micro | small | medium | large | xlarge (bundled compute)'),
284
+ vcpu: { type: 'number', description: 'Custom vCPU count (use instead of computeTier)' },
285
+ memoryMB: int('Custom memory in MB (use with vcpu)'),
286
+ storageGB: int('Persistent/database storage in GB (optional)'),
287
+ objectStorageGB: int('Object-storage quota in GB (optional)'),
288
+ replicas: int('Replica count (default 1)'),
289
+ orgId: str('Organization id (optional — applies that org\'s price overrides)'),
290
+ },
291
+ },
292
+ async call(api, args) {
293
+ // Prefer the org price book (applies overrides); fall back to global defaults.
294
+ let orgId = args.orgId;
295
+ if (!orgId) { try { orgId = await api.resolveOrgId(); } catch { orgId = null; } }
296
+ let book;
297
+ try {
298
+ book = (await api.get(orgId ? `/api/v1/orgs/${orgId}/pricing` : '/api/v1/pricing')).data;
299
+ } catch {
300
+ book = (await api.get('/api/v1/pricing')).data; // org book may 404; global always readable
301
+ }
302
+ const est = estimateCost(book, args);
303
+ return { ...est, priceBook: orgId ? `org:${orgId}` : 'global-defaults' };
304
+ },
305
+ },
263
306
  {
264
307
  name: 'neo_list_templates',
265
308
  description: 'List deployable templates (e.g. wordpress, n8n, redis).',
@@ -297,12 +340,13 @@ export const TOOLS = [
297
340
  name: str('Service name (optional)'),
298
341
  serviceType: { type: 'string', enum: ['web', 'worker', 'static_site'], description: 'web (default) | worker (no public URL) | static_site. Auto-detects static_site for a static-only folder; else web.' },
299
342
  port: int('Container port (optional; ignored for worker)'),
300
- environment: str('production or staging (optional, default production)'),
343
+ environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production. Never defaulted (handoff §2). Deploy to staging first.' },
301
344
  dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93)' },
302
345
  },
303
- required: ['projectId', 'path'],
346
+ required: ['projectId', 'path', 'environment'],
304
347
  },
305
348
  async call(api, args) {
349
+ const environment = requireEnv(args.environment);
306
350
  const p = resolve(args.path);
307
351
  let src;
308
352
  try {
@@ -319,10 +363,10 @@ export const TOOLS = [
319
363
  name: args.name,
320
364
  serviceType: args.serviceType,
321
365
  port: args.port,
322
- environment: args.environment || 'production',
366
+ environment,
323
367
  dryRun: !!args.dryRun,
324
368
  });
325
- if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment };
369
+ if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment, estimatedCost: r.estimatedCost };
326
370
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
327
371
  return { status: r.result, url: r.url || undefined, appId: r.appId };
328
372
  },
@@ -358,14 +402,15 @@ export const TOOLS = [
358
402
  name: str('Database name'),
359
403
  engine: { type: 'string', description: 'postgres | mysql (engine)' },
360
404
  version: str('Engine version, e.g. "16" (optional)'),
361
- environment: str('production or staging (optional, default production)'),
405
+ environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
362
406
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
363
407
  replicas: int('Read/failover replicas for HA (optional; postgres/CNPG only)'),
364
408
  deploymentMode: { type: 'string', description: 'single | ha (optional)' },
365
409
  },
366
- required: ['projectId', 'name', 'engine'],
410
+ required: ['projectId', 'name', 'engine', 'environment'],
367
411
  },
368
412
  async call(api, args) {
413
+ requireEnv(args.environment);
369
414
  const { projectId, ...body } = args;
370
415
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/databases`, body);
371
416
  return redactSecrets(result(status, data));
@@ -399,13 +444,14 @@ export const TOOLS = [
399
444
  projectId: str('Project id'),
400
445
  name: str('Queue name'),
401
446
  planClass: str('Plan/engine class (optional)'),
402
- environment: str('production or staging (optional, default production)'),
447
+ environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
403
448
  volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
404
449
  dlqEnabled: { type: 'boolean', description: 'Enable a dead-letter queue (optional)' },
405
450
  },
406
- required: ['projectId', 'name'],
451
+ required: ['projectId', 'name', 'environment'],
407
452
  },
408
453
  async call(api, args) {
454
+ requireEnv(args.environment);
409
455
  const { projectId, ...body } = args;
410
456
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/queues`, body);
411
457
  return redactSecrets(result(status, data));
@@ -448,11 +494,12 @@ export const TOOLS = [
448
494
  command: { type: 'array', items: { type: 'string' }, description: 'Command argv (mode=app_command)' },
449
495
  url: str('Target URL (mode=http_endpoint)'),
450
496
  method: str('HTTP method (mode=http_endpoint), e.g. GET/POST'),
451
- environment: str('production or staging (optional, default production)'),
497
+ environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
452
498
  },
453
- required: ['projectId', 'name', 'mode', 'scheduleType'],
499
+ required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
454
500
  },
455
501
  async call(api, args) {
502
+ requireEnv(args.environment);
456
503
  const { projectId, ...body } = args;
457
504
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons`, body);
458
505
  return redactSecrets(result(status, data));
@@ -476,6 +523,16 @@ export const TOOLS = [
476
523
  return redactSecrets((await api.get(`/api/v1/crons/${args.cronId}`)).data);
477
524
  },
478
525
  },
526
+ {
527
+ name: 'neo_run_cron',
528
+ description: 'Trigger a scheduled job (cron) to run now, out of schedule (handoff 4.6). Returns the run result. Not applicable to a continuous "seconds" cron.',
529
+ mutating: true,
530
+ schema: { type: 'object', properties: { cronId: str('Cron/scheduled-job id') }, required: ['cronId'] },
531
+ async call(api, args) {
532
+ const { status, data } = await api.post(`/api/v1/crons/${args.cronId}/run`);
533
+ return redactSecrets(result(status, data));
534
+ },
535
+ },
479
536
  {
480
537
  name: 'neo_create_bucket',
481
538
  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.',
package/src/pricing.js ADDED
@@ -0,0 +1,77 @@
1
+ // Cost estimation from the NEO App price book (handoff 3.3/3.4 — cost consent).
2
+ // Pure + dependency-free so it's trivially testable and reusable by both the
3
+ // neo_estimate MCP tool and the deploy dry-run. The price book is the live
4
+ // `/pricing` response: { prices: [{metric,key,rate,unit,currency,active}], tiers:[{key,vcpu,memoryMB,defaultRate}] }.
5
+ // defaultRate is IDR/MONTH; hourly SKUs prorate over 730 hours/month.
6
+
7
+ export const HOURS_PER_MONTH = 730;
8
+
9
+ // priceOf finds an active price entry for a metric (+ optional tier key), or null.
10
+ function priceOf(prices, metric, key = '') {
11
+ return (prices || []).find((x) => x.metric === metric && (x.key || '') === key && x.active !== false) || null;
12
+ }
13
+
14
+ // The price book is NOT self-describing by metric name: `compute_tier_hour`
15
+ // actually carries unit:"month" with a MONTHLY rate, while `vcpu_hour`/`gb_ram_hour`
16
+ // are genuinely hourly. So always normalize by the entry's `unit`, never the name.
17
+ function hourlyFrom(price) {
18
+ if (!price) return null;
19
+ return /month/i.test(price.unit || '') ? price.rate / HOURS_PER_MONTH : price.rate;
20
+ }
21
+ function monthlyFrom(price) {
22
+ if (!price) return null;
23
+ return /month/i.test(price.unit || '') ? price.rate : price.rate * HOURS_PER_MONTH;
24
+ }
25
+
26
+ // estimateCost returns { currency, hourly, monthly, breakdown, assumptions } for
27
+ // a spec: { computeTier?, vcpu?, memoryMB?, storageGB?, objectStorageGB?, replicas? }.
28
+ // Compute is a bundled tier (compute_tier_hour, else the tier's defaultRate/730)
29
+ // or custom (vcpu_hour*vCPU + gb_ram_hour*GB-RAM). Storage/object use their SKUs.
30
+ export function estimateCost(book, spec = {}) {
31
+ const prices = book?.prices || [];
32
+ const tiers = book?.tiers || [];
33
+ const replicas = spec.replicas && spec.replicas > 0 ? spec.replicas : 1;
34
+ const breakdown = [];
35
+ let hourly = 0;
36
+ let monthlyExtra = 0; // monthly-only SKUs (storage/object)
37
+
38
+ if (spec.computeTier) {
39
+ let h = hourlyFrom(priceOf(prices, 'compute_tier_hour', spec.computeTier));
40
+ if (h == null) {
41
+ const tier = tiers.find((t) => t.key === spec.computeTier);
42
+ h = tier ? tier.defaultRate / HOURS_PER_MONTH : null; // defaultRate is monthly
43
+ }
44
+ if (h == null) {
45
+ const known = tiers.map((t) => t.key).join(', ') || 'none in price book';
46
+ throw new Error(`unknown compute tier "${spec.computeTier}" — known tiers: ${known}`);
47
+ }
48
+ hourly += h * replicas;
49
+ breakdown.push({ item: `compute ${spec.computeTier}${replicas > 1 ? ` x${replicas}` : ''}`, hourly: h * replicas });
50
+ } else if (spec.vcpu || spec.memoryMB) {
51
+ const vc = hourlyFrom(priceOf(prices, 'vcpu_hour')) || 0;
52
+ const gr = hourlyFrom(priceOf(prices, 'gb_ram_hour')) || 0;
53
+ const h = (vc * (spec.vcpu || 0) + gr * ((spec.memoryMB || 0) / 1024)) * replicas;
54
+ hourly += h;
55
+ breakdown.push({ item: `compute custom (${spec.vcpu || 0} vCPU, ${spec.memoryMB || 0} MB)${replicas > 1 ? ` x${replicas}` : ''}`, hourly: h });
56
+ }
57
+
58
+ if (spec.storageGB) {
59
+ const m = monthlyFrom(priceOf(prices, 'storage_gb_month') || priceOf(prices, 'gb_storage_hour'));
60
+ if (m != null) { monthlyExtra += m * spec.storageGB; breakdown.push({ item: `storage ${spec.storageGB} GB`, monthly: m * spec.storageGB }); }
61
+ }
62
+ if (spec.objectStorageGB) {
63
+ const m = monthlyFrom(priceOf(prices, 'object_gb_month') || priceOf(prices, 'object_gb_hour')) || 0;
64
+ monthlyExtra += m * spec.objectStorageGB;
65
+ breakdown.push({ item: `object storage ${spec.objectStorageGB} GB`, monthly: m * spec.objectStorageGB });
66
+ }
67
+
68
+ const monthly = hourly * HOURS_PER_MONTH + monthlyExtra;
69
+ const currency = (prices.find((p) => p.currency)?.currency) || 'IDR';
70
+ return {
71
+ currency,
72
+ hourly: Math.round(hourly * 10000) / 10000,
73
+ monthly: Math.round(monthly),
74
+ breakdown,
75
+ assumptions: `${HOURS_PER_MONTH} hours/month; variable usage (queue messages, cron runs, egress) not included — this is an estimate, not a quote`,
76
+ };
77
+ }