@biznetgio/neo-app 0.4.69 → 0.4.70
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/api.js +16 -0
- package/src/commands/deploy.js +3 -1
- package/src/cost.js +41 -0
- package/src/deploySource.js +6 -6
- package/src/mcp/tools.js +28 -23
- package/src/pricing.js +0 -77
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -101,6 +101,22 @@ export class NeoApi {
|
|
|
101
101
|
return this.request('DELETE', path, opts);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// estimate asks the control-plane for the canonical cost of a set of resources (#170):
|
|
105
|
+
// the server's planCost is the SOLE price authority, so the CLI never computes money
|
|
106
|
+
// locally. Scope is a project (access + org context) OR an org (project-less quote).
|
|
107
|
+
// Returns { cost, delta? } where cost is the normalized contract
|
|
108
|
+
// ({ currency, hourly, monthly, breakdown, variable, upfront, priceBook,
|
|
109
|
+
// priceBookVersion, billingQuantum, assumptions, pricingUnavailable }).
|
|
110
|
+
async estimate({ projectId, orgId }, { resources, current, environment } = {}) {
|
|
111
|
+
const body = { resources: resources || [] };
|
|
112
|
+
if (environment) body.environment = environment;
|
|
113
|
+
if (current && current.length) body.current = current;
|
|
114
|
+
const path = projectId
|
|
115
|
+
? `/api/v1/projects/${projectId}/estimate`
|
|
116
|
+
: `/api/v1/orgs/${orgId}/estimate`;
|
|
117
|
+
return (await this.post(path, body)).data;
|
|
118
|
+
}
|
|
119
|
+
|
|
104
120
|
// whoami composes the two calls the server needs for identity (no combined endpoint).
|
|
105
121
|
async whoami() {
|
|
106
122
|
const me = (await this.get('/api/v1/auth/me')).data;
|
package/src/commands/deploy.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getCredential } from '../creds.js';
|
|
|
8
8
|
import { NeoApi } from '../api.js';
|
|
9
9
|
import { readLink } from '../link.js';
|
|
10
10
|
import { deploySource } from '../deploySource.js';
|
|
11
|
+
import { formatCost } from '../cost.js';
|
|
11
12
|
import { log, ok, fail, warn, step, c, confirm } from '../util.js';
|
|
12
13
|
|
|
13
14
|
function isGitUrl(s) {
|
|
@@ -32,7 +33,8 @@ function isDir(s) {
|
|
|
32
33
|
function showCost(r) {
|
|
33
34
|
if (r?.estimatedCost) {
|
|
34
35
|
const e = r.estimatedCost;
|
|
35
|
-
|
|
36
|
+
const note = (e.assumptions || []).join('; ');
|
|
37
|
+
log(c.bold(`Estimated cost: ${formatCost(e)}`) + (note ? c.dim(` — ${note}`) : ''));
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
package/src/cost.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// cost.js — the ONE place the CLI turns a flat service spec into the server's resource
|
|
2
|
+
// list and renders the server's normalized cost (#170). The control-plane `planCost`
|
|
3
|
+
// (POST /estimate) is the SOLE price authority; nothing here computes money. The old
|
|
4
|
+
// local engine (pricing.js `estimateCost`, which prorated monthly÷730 itself) is gone —
|
|
5
|
+
// it drifted from the server, which is exactly the divergence #170 removes.
|
|
6
|
+
|
|
7
|
+
// specToResources maps the CLI's flat quote spec to the server's planResourceSpec[]:
|
|
8
|
+
// { computeTier | (vcpu + memoryMB), storageGB, objectStorageGB, replicas }
|
|
9
|
+
// A tier OR custom (unbundled) compute → one app resource; persistent storage → a
|
|
10
|
+
// database; object storage → a bucket. Returns [] when nothing billable is specified
|
|
11
|
+
// (callers treat that as "no estimate" rather than sending an empty request).
|
|
12
|
+
export function specToResources(spec = {}) {
|
|
13
|
+
const resources = [];
|
|
14
|
+
const replicas = spec.replicas && spec.replicas > 0 ? spec.replicas : undefined;
|
|
15
|
+
if (spec.computeTier) {
|
|
16
|
+
resources.push({ kind: 'app', name: 'service', computeTier: spec.computeTier, replicas });
|
|
17
|
+
} else if (spec.vcpu || spec.memoryMB) {
|
|
18
|
+
resources.push({
|
|
19
|
+
kind: 'app',
|
|
20
|
+
name: 'service',
|
|
21
|
+
billingMode: 'unbundled',
|
|
22
|
+
cpuLimit: spec.vcpu ? String(spec.vcpu) : undefined,
|
|
23
|
+
memoryLimit: spec.memoryMB ? `${spec.memoryMB}Mi` : undefined,
|
|
24
|
+
replicas,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (spec.storageGB) resources.push({ kind: 'database', name: 'storage', volumeSize: `${spec.storageGB}Gi` });
|
|
28
|
+
if (spec.objectStorageGB) resources.push({ kind: 'bucket', name: 'object', quotaGB: spec.objectStorageGB });
|
|
29
|
+
return resources;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// formatCost renders the server cost contract as a single-line money summary — the one
|
|
33
|
+
// renderer every gate shares (de-dups the three `~${monthly} ${currency}/mo (${hourly}/h)`
|
|
34
|
+
// copies that drifted across tools.js / deploySource.js / deploy.js). Object-storage
|
|
35
|
+
// upfront is shown separately (it has no hourly equivalent — #170), never folded in.
|
|
36
|
+
export function formatCost(cost) {
|
|
37
|
+
if (!cost) return '';
|
|
38
|
+
const base = `~${cost.monthly} ${cost.currency}/mo (${cost.hourly}/h)`;
|
|
39
|
+
const upfront = (cost.upfront || []).reduce((sum, l) => sum + (l.monthly || 0), 0);
|
|
40
|
+
return upfront ? `${base} + ${upfront} ${cost.currency} upfront` : base;
|
|
41
|
+
}
|
package/src/deploySource.js
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'no
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
|
-
import {
|
|
10
|
+
import { specToResources, formatCost } from './cost.js';
|
|
11
11
|
|
|
12
12
|
// deployToken binds a dry-run to its real deploy: a short hash of the resolved spec
|
|
13
13
|
// that actually deploys. The dry-run returns it; the real call, if given confirmToken,
|
|
@@ -306,15 +306,15 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
|
|
|
306
306
|
const buildPreview = async () => {
|
|
307
307
|
let estimatedCost;
|
|
308
308
|
try {
|
|
309
|
-
|
|
310
|
-
estimatedCost =
|
|
311
|
-
} catch { /*
|
|
309
|
+
// The server /estimate is the price authority (#170); deploys default to micro compute.
|
|
310
|
+
estimatedCost = (await api.estimate({ projectId }, { resources: specToResources({ computeTier: 'micro' }) })).cost;
|
|
311
|
+
} catch { /* estimate unavailable — skip it (best-effort) */ }
|
|
312
312
|
// Include the acting account/env/project so a wrong-account or wrong-environment deploy
|
|
313
313
|
// is obvious before it happens (JNG #131). Best-effort — never fail the preview on it.
|
|
314
314
|
let ident = {};
|
|
315
315
|
try { if (typeof api.actingIdentity === 'function') ident = await api.actingIdentity(); } catch { /* ignore */ }
|
|
316
316
|
const acting = { ...ident, environment: targetEnv, project: projectId };
|
|
317
|
-
return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, confirmToken: token, acting };
|
|
317
|
+
return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, priceBookVersion: estimatedCost?.priceBookVersion, confirmToken: token, acting };
|
|
318
318
|
};
|
|
319
319
|
|
|
320
320
|
// Preview path (explicit dry-run OR an unapproved real deploy). No mutation ran above,
|
|
@@ -327,7 +327,7 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
|
|
|
327
327
|
const prev = await buildPreview();
|
|
328
328
|
if (prev.estimatedCost) {
|
|
329
329
|
const build = svcType === 'static_site' ? 'native nginx static hosting (not Railpack)' : 'built server-side';
|
|
330
|
-
log(`[dry-run] estimated cost (${svcType}, ${build}, default micro compute):
|
|
330
|
+
log(`[dry-run] estimated cost (${svcType}, ${build}, default micro compute): ${formatCost(prev.estimatedCost)} — ${(prev.estimatedCost.assumptions || []).join('; ')}. Use neo_estimate for a specific size.`);
|
|
331
331
|
}
|
|
332
332
|
if (dryRun) return prev;
|
|
333
333
|
return { ...prev, requiresConfirmation: true, note: 'Billable deploy — show the user the cost, then re-call with this confirmToken (or confirm:true) to deploy. Nothing was deployed.' };
|
package/src/mcp/tools.js
CHANGED
|
@@ -6,7 +6,7 @@ import { resolve, basename } from 'node:path';
|
|
|
6
6
|
import { statSync, readFileSync } from 'node:fs';
|
|
7
7
|
import { deploySource } from '../deploySource.js';
|
|
8
8
|
import { pkgVersion } from '../util.js';
|
|
9
|
-
import {
|
|
9
|
+
import { specToResources, formatCost } from '../cost.js';
|
|
10
10
|
|
|
11
11
|
const str = (description) => ({ type: 'string', description });
|
|
12
12
|
const int = (description) => ({ type: 'integer', description });
|
|
@@ -157,16 +157,21 @@ async function confirmCost(api, args, spec) {
|
|
|
157
157
|
if (args.confirm === true) return null;
|
|
158
158
|
let orgId = null;
|
|
159
159
|
try { orgId = await api.resolveOrgId(args.orgId); } catch { orgId = null; }
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
// The server's /estimate is the sole price authority (#170) — the CLI no longer prices
|
|
161
|
+
// locally. Prefer the project scope (exact org context); fall back to org scope for a
|
|
162
|
+
// project-less quote. Best-effort: a failed estimate must never fail the gate.
|
|
163
163
|
let cost = null;
|
|
164
|
-
|
|
164
|
+
const resources = specToResources(spec);
|
|
165
|
+
if (resources.length && (args.projectId || orgId)) {
|
|
166
|
+
try { cost = (await api.estimate(args.projectId ? { projectId: args.projectId } : { orgId }, { resources })).cost; }
|
|
167
|
+
catch { cost = null; }
|
|
168
|
+
}
|
|
165
169
|
return {
|
|
166
170
|
requiresConfirmation: true,
|
|
167
171
|
cost,
|
|
172
|
+
priceBookVersion: cost?.priceBookVersion, // pins the quote to a price-book snapshot (#170)
|
|
168
173
|
acting: await actingOf(api, { orgId, projectId: args.projectId }), // whose/where — JNG #131
|
|
169
|
-
priceBook: orgId ? `org:${orgId}` : 'global-defaults',
|
|
174
|
+
priceBook: cost?.priceBook || (orgId ? `org:${orgId}` : 'global-defaults'),
|
|
170
175
|
message: 'Billable resource — show the user this cost (and the acting account/org), then re-call with confirm:true to create. For an immutable, server-pinned quote use neo_plan_stack → neo_apply_stack.',
|
|
171
176
|
};
|
|
172
177
|
}
|
|
@@ -181,15 +186,16 @@ async function confirmCost(api, args, spec) {
|
|
|
181
186
|
async function billableGate(api, { projectId, kind, name, confirmationId, confirm, environment }, spec) {
|
|
182
187
|
if (confirmationId) return { query: `?confirmationId=${encodeURIComponent(confirmationId)}` };
|
|
183
188
|
|
|
184
|
-
// Estimate the cost (best-effort) for display and the confirmation summary
|
|
189
|
+
// Estimate the cost (best-effort) for display and the confirmation summary — the server's
|
|
190
|
+
// /estimate is the price authority (#170). billableGate always runs inside a project.
|
|
185
191
|
let orgId = null;
|
|
186
192
|
try { orgId = await api.resolveOrgId(); } catch { orgId = null; }
|
|
187
|
-
let book = null;
|
|
188
|
-
try { book = (await api.get(orgId ? `/api/v1/orgs/${orgId}/pricing` : '/api/v1/pricing')).data; }
|
|
189
|
-
catch { try { book = (await api.get('/api/v1/pricing')).data; } catch { book = null; } }
|
|
190
193
|
let cost = null;
|
|
191
|
-
|
|
192
|
-
|
|
194
|
+
const resources = specToResources(spec);
|
|
195
|
+
if (resources.length) {
|
|
196
|
+
try { cost = (await api.estimate({ projectId }, { resources })).cost; } catch { cost = null; }
|
|
197
|
+
}
|
|
198
|
+
const costNote = cost ? formatCost(cost) : '';
|
|
193
199
|
|
|
194
200
|
// Mint a server-side billable_create confirmation bound to this exact resource.
|
|
195
201
|
// Retry a transient 5xx/dropped connection: minting has no lasting side effect (an
|
|
@@ -197,7 +203,7 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
|
|
|
197
203
|
// from failing an otherwise-fine create (same spirit as the api-layer idempotent retry).
|
|
198
204
|
let mint;
|
|
199
205
|
for (let attempt = 1; ; attempt++) {
|
|
200
|
-
try { mint = (await api.post('/api/v1/confirmations', { action: 'billable_create', projectId, kind, name, environment, estimatedCost: costNote })).data; break; }
|
|
206
|
+
try { mint = (await api.post('/api/v1/confirmations', { action: 'billable_create', projectId, kind, name, environment, estimatedCost: costNote, priceBookVersion: cost?.priceBookVersion })).data; break; }
|
|
201
207
|
catch (e) {
|
|
202
208
|
if (attempt < 3 && (e?.status >= 500 || e?.status === 0)) { await new Promise((r) => setTimeout(r, 300 * attempt)); continue; }
|
|
203
209
|
throw e;
|
|
@@ -212,8 +218,9 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
|
|
|
212
218
|
return {
|
|
213
219
|
requiresConfirmation: true,
|
|
214
220
|
cost,
|
|
221
|
+
priceBookVersion: cost?.priceBookVersion, // pins the quote to a price-book snapshot (#170)
|
|
215
222
|
acting: await actingOf(api, { orgId, projectId }), // whose/where — JNG #131
|
|
216
|
-
priceBook: orgId ? `org:${orgId}` : 'global-defaults',
|
|
223
|
+
priceBook: cost?.priceBook || (orgId ? `org:${orgId}` : 'global-defaults'),
|
|
217
224
|
confirmationId: id,
|
|
218
225
|
message: `Billable ${kind} — show the user this cost AND the acting account/org, then approve server-side: neo_confirm_action("${id}", "approve"), and re-call this tool with confirmationId="${id}". (Shortcut: re-call with confirm:true.) Nothing was created.`,
|
|
219
226
|
};
|
|
@@ -931,17 +938,15 @@ export const TOOLS = [
|
|
|
931
938
|
},
|
|
932
939
|
},
|
|
933
940
|
async call(api, args) {
|
|
934
|
-
//
|
|
941
|
+
// The server /estimate is the sole price authority (#170) — no local money math.
|
|
942
|
+
// Quote at org scope (applies that org's overrides); the cost depends only on the org.
|
|
935
943
|
let orgId = args.orgId;
|
|
936
944
|
if (!orgId) { try { orgId = await api.resolveOrgId(); } catch { orgId = null; } }
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
}
|
|
943
|
-
const est = estimateCost(book, args);
|
|
944
|
-
return { ...est, priceBook: orgId ? `org:${orgId}` : 'global-defaults' };
|
|
945
|
+
if (!orgId) throw new Error('no organization for this account — cannot estimate cost');
|
|
946
|
+
const resources = specToResources(args);
|
|
947
|
+
if (!resources.length) throw new Error('nothing to estimate — pass computeTier (or vcpu+memoryMB), storageGB, and/or objectStorageGB');
|
|
948
|
+
const { cost } = await api.estimate({ orgId }, { resources });
|
|
949
|
+
return cost; // normalized contract: currency/hourly/monthly/breakdown/variable/upfront/priceBook/priceBookVersion/assumptions
|
|
945
950
|
},
|
|
946
951
|
},
|
|
947
952
|
{
|
package/src/pricing.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
}
|