@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 +1 -1
- package/src/deploySource.js +11 -1
- package/src/mcp/tools.js +69 -12
- 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 });
|
|
@@ -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);
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|