@biznetgio/neo-app 0.4.69 → 0.4.73
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 +16 -1
- package/src/commands/estimate.js +82 -0
- package/src/cost.js +48 -0
- package/src/deploySource.js +6 -6
- package/src/index.js +19 -1
- package/src/mcp/tools.js +79 -49
- 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,11 @@ 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}`) : ''));
|
|
38
|
+
// Identify the exact price-book snapshot the quote (and any approval) is pinned to (#170 r18a D1).
|
|
39
|
+
const ver = e.priceBookVersion || r.priceBookVersion;
|
|
40
|
+
if (ver) log(c.dim(` price book: ${ver}`));
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -93,6 +98,16 @@ export async function deploy(source, opts = {}) {
|
|
|
93
98
|
log: step,
|
|
94
99
|
};
|
|
95
100
|
|
|
101
|
+
// --json: machine-readable + non-interactive. A real deploy still requires --yes; otherwise
|
|
102
|
+
// deploySource returns the priced gate (requiresConfirmation + estimatedCost + confirmToken)
|
|
103
|
+
// and deploys nothing. One shape, mirroring the MCP cost schema (#170).
|
|
104
|
+
if (opts.json) {
|
|
105
|
+
const r = await deploySource(api, { ...base, log: () => {}, dryRun, confirm: !!opts.yes });
|
|
106
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
107
|
+
if (r.result && r.result !== 'success' && !r.dryRun && !r.requiresConfirmation && !r.pending) process.exitCode = 1;
|
|
108
|
+
return r;
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
// Cost gate: a real deploy is billable, so it needs explicit approval. `--yes` approves
|
|
97
112
|
// up front (headless/CI); otherwise preview the cost first, then confirm (interactive)
|
|
98
113
|
// or require `--yes` (non-interactive). A spec-bound confirmToken links the approval to
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `neo-app estimate` — a read-only cost quote from the server's authoritative price book
|
|
2
|
+
// (#170 r18a D2). Works WITHOUT a project (org-scoped), unlike `deploy --dry-run` which
|
|
3
|
+
// needs one, so a vibe coder can price a resource before creating anything. Zero-write.
|
|
4
|
+
|
|
5
|
+
import { resolveConfig } from '../config.js';
|
|
6
|
+
import { getCredential } from '../creds.js';
|
|
7
|
+
import { NeoApi } from '../api.js';
|
|
8
|
+
import { readLink } from '../link.js';
|
|
9
|
+
import { specToResources, formatCost } from '../cost.js';
|
|
10
|
+
import { log, ok, fail, c } from '../util.js';
|
|
11
|
+
|
|
12
|
+
// buildSpec turns the CLI flags into the flat cost spec specToResources understands.
|
|
13
|
+
function buildSpec(opts) {
|
|
14
|
+
const spec = {};
|
|
15
|
+
if (opts.tier) spec.computeTier = opts.tier;
|
|
16
|
+
if (opts.vcpu) spec.vcpu = Number(opts.vcpu);
|
|
17
|
+
if (opts.mem) spec.memoryMB = Number(opts.mem);
|
|
18
|
+
if (opts.storage) spec.storageGB = Number(opts.storage);
|
|
19
|
+
if (opts.object) spec.objectStorageGB = Number(opts.object);
|
|
20
|
+
if (opts.replicas) spec.replicas = Number(opts.replicas);
|
|
21
|
+
if (opts.queue) spec.queue = true;
|
|
22
|
+
if (opts.cron) spec.cron = true;
|
|
23
|
+
return spec;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function estimate(opts = {}) {
|
|
27
|
+
const cfg = resolveConfig(opts);
|
|
28
|
+
const cred = getCredential(cfg.baseUrl);
|
|
29
|
+
if (!cred?.token) {
|
|
30
|
+
fail(`not authenticated for ${cfg.baseUrl} — run 'neo-app login'`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
|
|
34
|
+
|
|
35
|
+
const spec = buildSpec(opts);
|
|
36
|
+
const resources = specToResources(spec);
|
|
37
|
+
if (!resources.length) {
|
|
38
|
+
fail('nothing to estimate — pass --tier <micro|small|…> (or --vcpu N --mem MB), --storage GB, --object GB, --queue, or --cron');
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Scope: an explicit/linked project (exact org context) else the account's org — the
|
|
43
|
+
// server authority is the same, so a project-less quote still works.
|
|
44
|
+
const projectId = opts.project || readLink()?.projectId;
|
|
45
|
+
let scope;
|
|
46
|
+
if (projectId) scope = { projectId };
|
|
47
|
+
else {
|
|
48
|
+
let orgId = opts.org;
|
|
49
|
+
if (!orgId) { try { orgId = await api.resolveOrgId(); } catch { orgId = null; } }
|
|
50
|
+
if (!orgId) { fail('no project or organization — run \'neo-app login\', or pass --project <id> / --org <id>'); process.exit(1); }
|
|
51
|
+
scope = { orgId };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let cost;
|
|
55
|
+
try {
|
|
56
|
+
cost = (await api.estimate(scope, { resources })).cost;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
fail(`estimate failed: ${e.message || e}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fail closed when the server can't price a metric (#170: never quote at a guessed rate).
|
|
63
|
+
if (cost?.pricingUnavailable?.length) {
|
|
64
|
+
if (opts.json) process.stdout.write(JSON.stringify({ cost }, null, 2) + '\n');
|
|
65
|
+
fail(`pricing unavailable for: ${cost.pricingUnavailable.join(', ')} — cannot quote`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (opts.json) {
|
|
70
|
+
process.stdout.write(JSON.stringify({ cost }, null, 2) + '\n');
|
|
71
|
+
return cost;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
log(c.bold(`Estimated cost: ${formatCost(cost)}`));
|
|
75
|
+
for (const l of cost.breakdown || []) log(` · ${l.item}: ${l.monthly} ${cost.currency}/mo (${l.hourly}/h)`);
|
|
76
|
+
for (const u of cost.upfront || []) log(` · ${u.item}: ${u.monthly} ${cost.currency} upfront (hourly: n/a)`);
|
|
77
|
+
for (const v of cost.variable || []) log(c.dim(` usage: ${v.rate} ${cost.currency}/${v.unit || v.metric}`));
|
|
78
|
+
if (cost.priceBookVersion) log(c.dim(` price book: ${cost.priceBookVersion} · billing quantum: ${cost.billingQuantum || 'hour'}`));
|
|
79
|
+
for (const a of cost.assumptions || []) log(c.dim(` assumption: ${a}`));
|
|
80
|
+
ok('estimate complete — nothing was created');
|
|
81
|
+
return cost;
|
|
82
|
+
}
|
package/src/cost.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
// A cron has no fixed cost — it's billed per run. The server returns it as a 0 fixed
|
|
30
|
+
// line plus the `cron_run` rate in `variable[]`, which is what the gate surfaces (#170).
|
|
31
|
+
if (spec.cron) resources.push({ kind: 'cron', name: 'cron' });
|
|
32
|
+
// A queue bills ONLY per message (queue_message) — never compute/storage (JNG #52). It is
|
|
33
|
+
// its OWN kind, not a database: mapping queue storage to a `database` resource priced it as
|
|
34
|
+
// DB compute+storage and mislabeled it "database storage" (#170 r18a D5).
|
|
35
|
+
if (spec.queue) resources.push({ kind: 'queue', name: 'queue' });
|
|
36
|
+
return resources;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// formatCost renders the server cost contract as a single-line money summary — the one
|
|
40
|
+
// renderer every gate shares (de-dups the three `~${monthly} ${currency}/mo (${hourly}/h)`
|
|
41
|
+
// copies that drifted across tools.js / deploySource.js / deploy.js). Object-storage
|
|
42
|
+
// upfront is shown separately (it has no hourly equivalent — #170), never folded in.
|
|
43
|
+
export function formatCost(cost) {
|
|
44
|
+
if (!cost) return '';
|
|
45
|
+
const base = `~${cost.monthly} ${cost.currency}/mo (${cost.hourly}/h)`;
|
|
46
|
+
const upfront = (cost.upfront || []).reduce((sum, l) => sum + (l.monthly || 0), 0);
|
|
47
|
+
return upfront ? `${base} + ${upfront} ${cost.currency} upfront` : base;
|
|
48
|
+
}
|
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/index.js
CHANGED
|
@@ -16,6 +16,7 @@ ${c.bold('Commands')}
|
|
|
16
16
|
login Authenticate with a NEO App API key (kap_...)
|
|
17
17
|
link <project-id> Link this repo to a project (writes .neo-app.json)
|
|
18
18
|
deploy [path|.zip|git-url] Deploy a directory (default: cwd), a .zip, a Git repo, or --image <ref>
|
|
19
|
+
estimate Price a resource from the server price book (read-only; no project needed)
|
|
19
20
|
(or pass the same via --source <path|.zip|git-url>)
|
|
20
21
|
project create [name] Create a project (defaults to the dir name) and link it
|
|
21
22
|
project list List projects in your organization (find ids to link)
|
|
@@ -35,6 +36,13 @@ ${c.bold('Options')}
|
|
|
35
36
|
--token <kap_...> API key (also NEO_APP_TOKEN; prompted if omitted)
|
|
36
37
|
--yes Non-interactive (headless/CI); also approves a billable deploy without the cost prompt
|
|
37
38
|
--dry-run Print intended changes without writing
|
|
39
|
+
--json Machine-readable output incl. the normalized cost (deploy, estimate)
|
|
40
|
+
--tier <key> estimate: compute tier (micro|small|medium|large|xlarge)
|
|
41
|
+
--vcpu <n> --mem <MB> estimate: custom compute (instead of --tier)
|
|
42
|
+
--storage <GB> estimate: database/persistent storage
|
|
43
|
+
--object <GB> estimate: object-storage quota (billed monthly-upfront)
|
|
44
|
+
--queue / --cron estimate: a queue (per-message) / cron (per-run) resource
|
|
45
|
+
--replicas <n> estimate: replica count
|
|
38
46
|
-v, --version Print version
|
|
39
47
|
-h, --help Show this help`;
|
|
40
48
|
|
|
@@ -63,6 +71,15 @@ const options = {
|
|
|
63
71
|
prod: { type: 'boolean' },
|
|
64
72
|
yes: { type: 'boolean' },
|
|
65
73
|
'dry-run': { type: 'boolean' },
|
|
74
|
+
json: { type: 'boolean' },
|
|
75
|
+
tier: { type: 'string' },
|
|
76
|
+
vcpu: { type: 'string' },
|
|
77
|
+
mem: { type: 'string' },
|
|
78
|
+
storage: { type: 'string' },
|
|
79
|
+
object: { type: 'string' },
|
|
80
|
+
replicas: { type: 'string' },
|
|
81
|
+
queue: { type: 'boolean' },
|
|
82
|
+
cron: { type: 'boolean' },
|
|
66
83
|
version: { type: 'boolean', short: 'v' },
|
|
67
84
|
help: { type: 'boolean', short: 'h' },
|
|
68
85
|
};
|
|
@@ -85,7 +102,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
85
102
|
// Root help, and `--help` on a KNOWN command, print usage. An UNKNOWN command must
|
|
86
103
|
// still error (nonzero exit) even with `--help`, so a bad command isn't silently
|
|
87
104
|
// accepted (JNG #97). Per-subcommand help is tracked in #89.
|
|
88
|
-
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
|
|
105
|
+
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
|
|
89
106
|
if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd))) return void console.log(HELP);
|
|
90
107
|
|
|
91
108
|
try {
|
|
@@ -96,6 +113,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
96
113
|
if (cmd === 'config' && sub === 'install-mcp') return void (await (await import('./commands/install-mcp.js')).installMcp(opts));
|
|
97
114
|
if (cmd === 'login') return void (await (await import('./commands/login.js')).login(opts));
|
|
98
115
|
if (cmd === 'deploy') return void (await (await import('./commands/deploy.js')).deploy(sub, opts));
|
|
116
|
+
if (cmd === 'estimate') return void (await (await import('./commands/estimate.js')).estimate(opts));
|
|
99
117
|
if (cmd === 'link') return void (await (await import('./commands/link.js')).link(sub, opts));
|
|
100
118
|
if (cmd === 'project' && sub === 'link') return void (await (await import('./commands/link.js')).link(positionals[2], opts));
|
|
101
119
|
if (cmd === 'project' && sub === 'create') return void (await (await import('./commands/link.js')).createProject(positionals[2], opts));
|
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 });
|
|
@@ -153,21 +153,33 @@ function validateBucketArgs(args) {
|
|
|
153
153
|
// args.confirm===true it returns the live-priced cost and creates NOTHING — the
|
|
154
154
|
// agent shows the user, then re-calls with confirm:true. plan/apply remains the
|
|
155
155
|
// server-immutable costed path; this is the lighter client-side gate (handoff 3.3).
|
|
156
|
-
async function confirmCost(api, args, spec) {
|
|
156
|
+
async function confirmCost(api, args, spec, currentSpec) {
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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. When a CURRENT spec is given (a change flow), ask the server for
|
|
163
|
+
// the delta too (current → proposed → increase). Best-effort: a failed estimate must
|
|
164
|
+
// never fail the gate.
|
|
165
|
+
let cost = null, delta = null;
|
|
166
|
+
const resources = specToResources(spec);
|
|
167
|
+
const current = currentSpec ? specToResources(currentSpec) : null;
|
|
168
|
+
if (resources.length && (args.projectId || orgId)) {
|
|
169
|
+
try {
|
|
170
|
+
const body = current && current.length ? { resources, current } : { resources };
|
|
171
|
+
const r = await api.estimate(args.projectId ? { projectId: args.projectId } : { orgId }, body);
|
|
172
|
+
cost = r.cost; delta = r.delta || null;
|
|
173
|
+
} catch { cost = null; }
|
|
174
|
+
}
|
|
165
175
|
return {
|
|
166
176
|
requiresConfirmation: true,
|
|
167
177
|
cost,
|
|
178
|
+
delta, // current → proposed → increase, when a current spec was given (#170 change delta)
|
|
179
|
+
priceBookVersion: cost?.priceBookVersion, // pins the quote to a price-book snapshot (#170)
|
|
168
180
|
acting: await actingOf(api, { orgId, projectId: args.projectId }), // whose/where — JNG #131
|
|
169
|
-
priceBook: orgId ? `org:${orgId}` : 'global-defaults',
|
|
170
|
-
message: 'Billable
|
|
181
|
+
priceBook: cost?.priceBook || (orgId ? `org:${orgId}` : 'global-defaults'),
|
|
182
|
+
message: 'Billable change — show the user BOTH the hourly and monthly cost (and the increase delta, plus any per-use variable rates), then re-call with confirm:true to apply. For an immutable, server-pinned quote use neo_plan_stack → neo_apply_stack.',
|
|
171
183
|
};
|
|
172
184
|
}
|
|
173
185
|
|
|
@@ -181,15 +193,16 @@ async function confirmCost(api, args, spec) {
|
|
|
181
193
|
async function billableGate(api, { projectId, kind, name, confirmationId, confirm, environment }, spec) {
|
|
182
194
|
if (confirmationId) return { query: `?confirmationId=${encodeURIComponent(confirmationId)}` };
|
|
183
195
|
|
|
184
|
-
// Estimate the cost (best-effort) for display and the confirmation summary
|
|
196
|
+
// Estimate the cost (best-effort) for display and the confirmation summary — the server's
|
|
197
|
+
// /estimate is the price authority (#170). billableGate always runs inside a project.
|
|
185
198
|
let orgId = null;
|
|
186
199
|
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
200
|
let cost = null;
|
|
191
|
-
|
|
192
|
-
|
|
201
|
+
const resources = specToResources(spec);
|
|
202
|
+
if (resources.length) {
|
|
203
|
+
try { cost = (await api.estimate({ projectId }, { resources })).cost; } catch { cost = null; }
|
|
204
|
+
}
|
|
205
|
+
const costNote = cost ? formatCost(cost) : '';
|
|
193
206
|
|
|
194
207
|
// Mint a server-side billable_create confirmation bound to this exact resource.
|
|
195
208
|
// Retry a transient 5xx/dropped connection: minting has no lasting side effect (an
|
|
@@ -197,7 +210,7 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
|
|
|
197
210
|
// from failing an otherwise-fine create (same spirit as the api-layer idempotent retry).
|
|
198
211
|
let mint;
|
|
199
212
|
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; }
|
|
213
|
+
try { mint = (await api.post('/api/v1/confirmations', { action: 'billable_create', projectId, kind, name, environment, estimatedCost: costNote, priceBookVersion: cost?.priceBookVersion, cost })).data; break; }
|
|
201
214
|
catch (e) {
|
|
202
215
|
if (attempt < 3 && (e?.status >= 500 || e?.status === 0)) { await new Promise((r) => setTimeout(r, 300 * attempt)); continue; }
|
|
203
216
|
throw e;
|
|
@@ -212,8 +225,9 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
|
|
|
212
225
|
return {
|
|
213
226
|
requiresConfirmation: true,
|
|
214
227
|
cost,
|
|
228
|
+
priceBookVersion: cost?.priceBookVersion, // pins the quote to a price-book snapshot (#170)
|
|
215
229
|
acting: await actingOf(api, { orgId, projectId }), // whose/where — JNG #131
|
|
216
|
-
priceBook: orgId ? `org:${orgId}` : 'global-defaults',
|
|
230
|
+
priceBook: cost?.priceBook || (orgId ? `org:${orgId}` : 'global-defaults'),
|
|
217
231
|
confirmationId: id,
|
|
218
232
|
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
233
|
};
|
|
@@ -492,21 +506,26 @@ export const TOOLS = [
|
|
|
492
506
|
// effective compute — the one being set, else the service's CURRENT compute. Without
|
|
493
507
|
// the current compute, an autoscale-only change estimated to IDR 0 (#123).
|
|
494
508
|
const replicas = args.replicas ?? args.autoscaleMax ?? args.autoscaleMin;
|
|
509
|
+
// Read the service's CURRENT compute/storage once — for the empty-tier default (#123)
|
|
510
|
+
// AND the change delta (current → proposed → increase).
|
|
511
|
+
let svc = {};
|
|
512
|
+
try { const d = (await api.get(`/api/v1/apps/${appId}`)).data; svc = (d && d.application) || d || {}; } catch { /* best-effort */ }
|
|
513
|
+
// A bundled service with no explicit tier bills at the default "micro" (store.DefaultTierKey);
|
|
514
|
+
// using the empty cpuLimit as-is priced the gate at IDR 0 (#123).
|
|
515
|
+
const svcSpec = (svc.billingMode === 'unbundled' && (svc.cpuLimit || svc.memoryLimit))
|
|
516
|
+
? { vcpu: cpuToVcpu(svc.cpuLimit), memoryMB: memToMB(svc.memoryLimit) }
|
|
517
|
+
: { computeTier: svc.cpuLimit || 'micro' };
|
|
495
518
|
let spec;
|
|
496
519
|
if (computeTier) spec = { computeTier, replicas };
|
|
497
520
|
else if (args.cpuLimit || args.memoryLimit) spec = { vcpu: cpuToVcpu(args.cpuLimit), memoryMB: memToMB(args.memoryLimit), replicas };
|
|
498
|
-
else {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
spec = (svc.billingMode === 'unbundled' && (svc.cpuLimit || svc.memoryLimit))
|
|
504
|
-
? { vcpu: cpuToVcpu(svc.cpuLimit), memoryMB: memToMB(svc.memoryLimit), replicas }
|
|
505
|
-
: { computeTier: svc.cpuLimit || 'micro', replicas };
|
|
521
|
+
else spec = { ...svcSpec, replicas };
|
|
522
|
+
const currentSpec = { ...svcSpec, replicas: svc.replicas ?? svc.scaleMin };
|
|
523
|
+
if (args.volumeSize !== undefined) { // persistent-volume price in the gate (JNG #129)
|
|
524
|
+
spec = { ...spec, storageGB: gbFromSize(args.volumeSize) };
|
|
525
|
+
if (svc.volumeSize) currentSpec.storageGB = gbFromSize(svc.volumeSize);
|
|
506
526
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (gate) return { ...gate, change: 'update service compute/scaling/volume', message: 'This changes cost — re-call with confirm:true to apply.' };
|
|
527
|
+
const gate = await confirmCost(api, args, spec, currentSpec);
|
|
528
|
+
if (gate) return { ...gate, change: 'update service compute/scaling/volume', message: 'This changes cost — show BOTH the hourly and monthly cost and the increase, then re-call with confirm:true to apply.' };
|
|
510
529
|
}
|
|
511
530
|
const { status, data } = await api.put(`/api/v1/apps/${appId}`, body);
|
|
512
531
|
const out = redactSecrets(result(status, data));
|
|
@@ -857,15 +876,16 @@ export const TOOLS = [
|
|
|
857
876
|
},
|
|
858
877
|
async call(api, args) {
|
|
859
878
|
if (args.confirm !== true) {
|
|
860
|
-
let computeTier;
|
|
879
|
+
let computeTier, currentReplicas;
|
|
861
880
|
try {
|
|
862
881
|
const apps = (await api.get(`/api/v1/projects/${args.projectId}/apps`)).data;
|
|
863
882
|
const list = Array.isArray(apps) ? apps : (apps.applications || []);
|
|
864
883
|
const svc = list.find((a) => a.name === args.service || a.id === args.service);
|
|
865
|
-
computeTier = svc
|
|
884
|
+
if (svc) { computeTier = svc.cpuLimit || svc.computeTier; currentReplicas = svc.replicas ?? svc.scaleMin; }
|
|
866
885
|
} catch { /* best effort — gate regardless */ }
|
|
867
|
-
|
|
868
|
-
|
|
886
|
+
// Price the delta against the current replica count so the user sees current → proposed → increase.
|
|
887
|
+
const gate = await confirmCost(api, args, { computeTier, replicas: args.replicas }, { computeTier, replicas: currentReplicas });
|
|
888
|
+
if (gate) return { ...gate, change: `scale "${args.service}" to ${args.replicas} replica(s)`, message: 'Scaling changes cost — show the user BOTH time bases and the increase, then re-call with confirm:true to apply.' };
|
|
869
889
|
}
|
|
870
890
|
const { status, data } = await api.post(
|
|
871
891
|
`/api/v1/projects/${args.projectId}/services/${args.service}/scale`,
|
|
@@ -931,17 +951,15 @@ export const TOOLS = [
|
|
|
931
951
|
},
|
|
932
952
|
},
|
|
933
953
|
async call(api, args) {
|
|
934
|
-
//
|
|
954
|
+
// The server /estimate is the sole price authority (#170) — no local money math.
|
|
955
|
+
// Quote at org scope (applies that org's overrides); the cost depends only on the org.
|
|
935
956
|
let orgId = args.orgId;
|
|
936
957
|
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' };
|
|
958
|
+
if (!orgId) throw new Error('no organization for this account — cannot estimate cost');
|
|
959
|
+
const resources = specToResources(args);
|
|
960
|
+
if (!resources.length) throw new Error('nothing to estimate — pass computeTier (or vcpu+memoryMB), storageGB, and/or objectStorageGB');
|
|
961
|
+
const { cost } = await api.estimate({ orgId }, { resources });
|
|
962
|
+
return cost; // normalized contract: currency/hourly/monthly/breakdown/variable/upfront/priceBook/priceBookVersion/assumptions
|
|
945
963
|
},
|
|
946
964
|
},
|
|
947
965
|
{
|
|
@@ -1179,8 +1197,12 @@ export const TOOLS = [
|
|
|
1179
1197
|
async call(api, args) {
|
|
1180
1198
|
const costChanging = args.volumeSize || args.replicas !== undefined;
|
|
1181
1199
|
if (costChanging && args.confirm !== true) {
|
|
1182
|
-
|
|
1183
|
-
|
|
1200
|
+
// Read current storage for the change delta (current → proposed → increase).
|
|
1201
|
+
let cur = {};
|
|
1202
|
+
try { const d = (await api.get(`/api/v1/databases/${args.databaseId}`)).data; cur = (d && d.database) || d || {}; } catch { /* best-effort */ }
|
|
1203
|
+
const currentSpec = { storageGB: cur.volumeSize ? gbFromSize(cur.volumeSize) : undefined, replicas: cur.replicas };
|
|
1204
|
+
const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas }, currentSpec);
|
|
1205
|
+
if (gate) return { ...gate, change: 'resize database (storage/replicas)', message: 'Resizing changes cost — show BOTH time bases and the increase, then re-call with confirm:true to apply.' };
|
|
1184
1206
|
}
|
|
1185
1207
|
const body = {};
|
|
1186
1208
|
if (args.volumeSize) body.volumeSize = args.volumeSize;
|
|
@@ -1299,7 +1321,7 @@ export const TOOLS = [
|
|
|
1299
1321
|
},
|
|
1300
1322
|
async call(api, args) {
|
|
1301
1323
|
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1302
|
-
const gate = await billableGate(api, { projectId: args.projectId, kind: 'queue', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm, environment }, {
|
|
1324
|
+
const gate = await billableGate(api, { projectId: args.projectId, kind: 'queue', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm, environment }, { queue: true }); // queue bills per-message only (JNG #52) — 0 fixed + queue_message rate
|
|
1303
1325
|
if (gate.requiresConfirmation) return { environment, ...gate };
|
|
1304
1326
|
const { projectId, confirm, confirmationId, orgId, ...body } = args;
|
|
1305
1327
|
body.environment = environment; // send the resolved env so the pin actually targets it
|
|
@@ -1354,8 +1376,10 @@ export const TOOLS = [
|
|
|
1354
1376
|
async call(api, args) {
|
|
1355
1377
|
const costChanging = args.volumeSize || args.planClass;
|
|
1356
1378
|
if (costChanging && args.confirm !== true) {
|
|
1357
|
-
|
|
1358
|
-
|
|
1379
|
+
// A queue has no fixed cost — it bills per message (queue_message, JNG #52), so a
|
|
1380
|
+
// storage/plan change doesn't move the fixed bill. The gate surfaces the per-message rate.
|
|
1381
|
+
const gate = await confirmCost(api, args, { queue: true });
|
|
1382
|
+
if (gate) return { ...gate, change: 'update queue (storage/plan)', message: 'Queue bills per message — no fixed-cost change; the queue_message rate is shown. Re-call with confirm:true to apply.' };
|
|
1359
1383
|
}
|
|
1360
1384
|
const { queueId, confirm, orgId, ...body } = args;
|
|
1361
1385
|
const { status, data } = await api.put(`/api/v1/queues/${queueId}`, body);
|
|
@@ -1439,7 +1463,7 @@ export const TOOLS = [
|
|
|
1439
1463
|
},
|
|
1440
1464
|
async call(api, args) {
|
|
1441
1465
|
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1442
|
-
const gate = await billableGate(api, { projectId: args.projectId, kind: 'cron', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, {}); // cron
|
|
1466
|
+
const gate = await billableGate(api, { projectId: args.projectId, kind: 'cron', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { cron: true }); // cron has no fixed cost — the contract surfaces the per-run `cron_run` rate in cost.variable
|
|
1443
1467
|
if (gate.requiresConfirmation) return { environment, ...gate };
|
|
1444
1468
|
// Merge contentType into headers for convenience (unless already set).
|
|
1445
1469
|
if (args.contentType) {
|
|
@@ -2022,7 +2046,11 @@ export const TOOLS = [
|
|
|
2022
2046
|
},
|
|
2023
2047
|
async call(api, args) {
|
|
2024
2048
|
const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/public-access`, { allowlistCidrs: args.allowlistCidrs || [] });
|
|
2025
|
-
|
|
2049
|
+
const out = redactSecrets(result(status, data));
|
|
2050
|
+
// Public access adds no fixed monthly charge, but egress through the gateway is metered
|
|
2051
|
+
// per GB (usage-based). Surface that so enabling it isn't a silent billing change (#170).
|
|
2052
|
+
if (out && typeof out === 'object') out.billingNote = 'No fixed monthly cost is added. Data transfer (egress) through the public TLS gateway is metered per GB — see the usage-based rates via neo_estimate (cost.variable).';
|
|
2053
|
+
return out;
|
|
2026
2054
|
},
|
|
2027
2055
|
},
|
|
2028
2056
|
{
|
|
@@ -2049,7 +2077,9 @@ export const TOOLS = [
|
|
|
2049
2077
|
},
|
|
2050
2078
|
async call(api, args) {
|
|
2051
2079
|
const { status, data } = await api.post(`/api/v1/queues/${args.queueId}/public-access`, { allowlistCidrs: args.allowlistCidrs || [] });
|
|
2052
|
-
|
|
2080
|
+
const out = redactSecrets(result(status, data));
|
|
2081
|
+
if (out && typeof out === 'object') out.billingNote = 'No fixed monthly cost is added. Data transfer (egress) through the public TLS gateway is metered per GB — see the usage-based rates via neo_estimate (cost.variable).';
|
|
2082
|
+
return out;
|
|
2053
2083
|
},
|
|
2054
2084
|
},
|
|
2055
2085
|
{
|
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
|
-
}
|