@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 +1 -1
- package/src/deploySource.js +11 -1
- package/src/mcp/tools.js +34 -2
- package/src/pricing.js +77 -0
package/package.json
CHANGED
package/src/deploySource.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|