@biznetgio/neo-app 0.4.15 → 0.4.16

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.16",
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 });
@@ -97,7 +98,7 @@ export const TOOLS = [
97
98
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
98
99
  environments: ['staging', 'production'],
99
100
  pricing,
100
- costConsent: { planApplyEnforced: false, note: 'neo_plan_stack / Approve & Create not implemented yet (handoff 3.3-3.4)' },
101
+ 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
102
  minClientVersion: pkgVersion(),
102
103
  deprecations: [],
103
104
  };
@@ -260,6 +261,37 @@ export const TOOLS = [
260
261
  return (await api.get(`/api/v1/orgs/${orgId}/credit`)).data;
261
262
  },
262
263
  },
264
+ {
265
+ name: 'neo_estimate',
266
+ description:
267
+ '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.',
268
+ mutating: false,
269
+ schema: {
270
+ type: 'object',
271
+ properties: {
272
+ computeTier: str('Compute tier key: micro | small | medium | large | xlarge (bundled compute)'),
273
+ vcpu: { type: 'number', description: 'Custom vCPU count (use instead of computeTier)' },
274
+ memoryMB: int('Custom memory in MB (use with vcpu)'),
275
+ storageGB: int('Persistent/database storage in GB (optional)'),
276
+ objectStorageGB: int('Object-storage quota in GB (optional)'),
277
+ replicas: int('Replica count (default 1)'),
278
+ orgId: str('Organization id (optional — applies that org\'s price overrides)'),
279
+ },
280
+ },
281
+ async call(api, args) {
282
+ // Prefer the org price book (applies overrides); fall back to global defaults.
283
+ let orgId = args.orgId;
284
+ if (!orgId) { try { orgId = await api.resolveOrgId(); } catch { orgId = null; } }
285
+ let book;
286
+ try {
287
+ book = (await api.get(orgId ? `/api/v1/orgs/${orgId}/pricing` : '/api/v1/pricing')).data;
288
+ } catch {
289
+ book = (await api.get('/api/v1/pricing')).data; // org book may 404; global always readable
290
+ }
291
+ const est = estimateCost(book, args);
292
+ return { ...est, priceBook: orgId ? `org:${orgId}` : 'global-defaults' };
293
+ },
294
+ },
263
295
  {
264
296
  name: 'neo_list_templates',
265
297
  description: 'List deployable templates (e.g. wordpress, n8n, redis).',
@@ -322,7 +354,7 @@ export const TOOLS = [
322
354
  environment: args.environment || 'production',
323
355
  dryRun: !!args.dryRun,
324
356
  });
325
- if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment };
357
+ if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment, estimatedCost: r.estimatedCost };
326
358
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
327
359
  return { status: r.result, url: r.url || undefined, appId: r.appId };
328
360
  },
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
+ }