@biznetgio/neo-app 0.4.14 → 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.14",
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 });
@@ -23,6 +24,23 @@ function result(status, data) {
23
24
  return data;
24
25
  }
25
26
 
27
+ // Credentials are write-only (handoff 4.4/4.5/4.7/5): never hand a raw secret
28
+ // (password, connection string, access/secret key, token) back to the agent.
29
+ // Recursively replace any secret-looking value with a reference marker so the
30
+ // agent still sees the resource's shape and non-secret identifiers.
31
+ const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey)/i;
32
+ function redactSecrets(value) {
33
+ if (Array.isArray(value)) return value.map(redactSecrets);
34
+ if (value && typeof value === 'object') {
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(value)) {
37
+ out[k] = SECRET_KEY.test(k) ? '[redacted — credentials are write-only; bind them to a consumer instead]' : redactSecrets(v);
38
+ }
39
+ return out;
40
+ }
41
+ return value;
42
+ }
43
+
26
44
  export const TOOLS = [
27
45
  {
28
46
  name: 'neo_whoami',
@@ -65,10 +83,10 @@ export const TOOLS = [
65
83
  { type: 'template', create: true, deploy: true, scale: false, logs: true, variables: true, delete: true },
66
84
  { type: 'static_site', create: 'via_folder', deploy: true, scale: false, logs: true, variables: true, delete: true, note: 'neo_deploy_folder serviceType=static_site (auto-detected for static-only folders); auto-detection from a repo/monorepo is still TODO (handoff 6)' },
67
85
  { 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)' },
68
- { type: 'database', create: false, deploy: false, delete: true, note: 'neo_delete_database exists; create still has no MCP tool (handoff 4.4)' },
69
- { type: 'queue', create: false, deploy: false, delete: true, note: 'neo_delete_queue exists; create still has no MCP tool (handoff 4.5)' },
70
- { type: 'cron', create: false, deploy: false, delete: true, note: 'neo_delete_cron exists; create still has no MCP tool (handoff 4.6)' },
71
- { type: 'object_storage', create: false, deploy: false, delete: true, note: 'neo_delete_bucket exists; create still has no MCP tool (handoff 4.7)' },
86
+ { 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)' },
87
+ { 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)' },
88
+ { type: 'cron', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_cron (0.4.15); manual-run/history still TODO (handoff 4.6)' },
89
+ { 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)' },
72
90
  ];
73
91
 
74
92
  return {
@@ -76,11 +94,11 @@ export const TOOLS = [
76
94
  controlPlane,
77
95
  resourceTypes,
78
96
  // Verbs this MCP build exposes today (no create-DB/queue/cron/bucket, no costed plan/apply yet).
79
- lifecycleVerbs: ['list', 'get', 'deploy', 'scale', 'logs', 'get_variables', 'set_variable', 'delete', 'list_approvals', 'get_approval', 'get_credit', 'list_templates'],
97
+ lifecycleVerbs: ['create', 'list', 'get', 'deploy', 'scale', 'logs', 'get_variables', 'set_variable', 'delete', 'list_approvals', 'get_approval', 'get_credit', 'list_templates'],
80
98
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
81
99
  environments: ['staging', 'production'],
82
100
  pricing,
83
- 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)' },
84
102
  minClientVersion: pkgVersion(),
85
103
  deprecations: [],
86
104
  };
@@ -243,6 +261,37 @@ export const TOOLS = [
243
261
  return (await api.get(`/api/v1/orgs/${orgId}/credit`)).data;
244
262
  },
245
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
+ },
246
295
  {
247
296
  name: 'neo_list_templates',
248
297
  description: 'List deployable templates (e.g. wordpress, n8n, redis).',
@@ -305,7 +354,7 @@ export const TOOLS = [
305
354
  environment: args.environment || 'production',
306
355
  dryRun: !!args.dryRun,
307
356
  });
308
- 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 };
309
358
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
310
359
  return { status: r.result, url: r.url || undefined, appId: r.appId };
311
360
  },
@@ -327,6 +376,177 @@ export const TOOLS = [
327
376
  return found;
328
377
  },
329
378
  },
379
+ // --- Managed resources (handoff 4.4-4.7): create/list/get for DB, Queue, Cron,
380
+ // Object Storage. Credentials in responses are redacted (write-only). A member's
381
+ // create may return pending_approval (202) — surfaced verbatim, not an error. ---
382
+ {
383
+ name: 'neo_create_database',
384
+ description: 'Create a managed database in a project (handoff 4.4). Returns the resource (credentials redacted); poll neo_get_database for readiness, then deploy via REST/portal if needed. May return pending_approval.',
385
+ mutating: true,
386
+ schema: {
387
+ type: 'object',
388
+ properties: {
389
+ projectId: str('Project id'),
390
+ name: str('Database name'),
391
+ engine: { type: 'string', description: 'postgres | mysql (engine)' },
392
+ version: str('Engine version, e.g. "16" (optional)'),
393
+ environment: str('production or staging (optional, default production)'),
394
+ volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
395
+ replicas: int('Read/failover replicas for HA (optional; postgres/CNPG only)'),
396
+ deploymentMode: { type: 'string', description: 'single | ha (optional)' },
397
+ },
398
+ required: ['projectId', 'name', 'engine'],
399
+ },
400
+ async call(api, args) {
401
+ const { projectId, ...body } = args;
402
+ const { status, data } = await api.post(`/api/v1/projects/${projectId}/databases`, body);
403
+ return redactSecrets(result(status, data));
404
+ },
405
+ },
406
+ {
407
+ name: 'neo_list_databases',
408
+ description: 'List managed databases in a project (credentials redacted).',
409
+ mutating: false,
410
+ schema: { type: 'object', properties: { projectId: str('Project id') }, required: ['projectId'] },
411
+ async call(api, args) {
412
+ return redactSecrets((await api.get(`/api/v1/projects/${args.projectId}/databases`)).data);
413
+ },
414
+ },
415
+ {
416
+ name: 'neo_get_database',
417
+ description: 'Get one managed database by id, including provisioning/readiness state (credentials redacted — bind them to a consumer instead).',
418
+ mutating: false,
419
+ schema: { type: 'object', properties: { databaseId: str('Database id') }, required: ['databaseId'] },
420
+ async call(api, args) {
421
+ return redactSecrets((await api.get(`/api/v1/databases/${args.databaseId}`)).data);
422
+ },
423
+ },
424
+ {
425
+ name: 'neo_create_queue',
426
+ description: 'Create a managed queue in a project (handoff 4.5). Credentials redacted. May return pending_approval.',
427
+ mutating: true,
428
+ schema: {
429
+ type: 'object',
430
+ properties: {
431
+ projectId: str('Project id'),
432
+ name: str('Queue name'),
433
+ planClass: str('Plan/engine class (optional)'),
434
+ environment: str('production or staging (optional, default production)'),
435
+ volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
436
+ dlqEnabled: { type: 'boolean', description: 'Enable a dead-letter queue (optional)' },
437
+ },
438
+ required: ['projectId', 'name'],
439
+ },
440
+ async call(api, args) {
441
+ const { projectId, ...body } = args;
442
+ const { status, data } = await api.post(`/api/v1/projects/${projectId}/queues`, body);
443
+ return redactSecrets(result(status, data));
444
+ },
445
+ },
446
+ {
447
+ name: 'neo_list_queues',
448
+ description: 'List managed queues in a project (credentials redacted).',
449
+ mutating: false,
450
+ schema: { type: 'object', properties: { projectId: str('Project id') }, required: ['projectId'] },
451
+ async call(api, args) {
452
+ return redactSecrets((await api.get(`/api/v1/projects/${args.projectId}/queues`)).data);
453
+ },
454
+ },
455
+ {
456
+ name: 'neo_get_queue',
457
+ description: 'Get one managed queue by id, including readiness (credentials redacted).',
458
+ mutating: false,
459
+ schema: { type: 'object', properties: { queueId: str('Queue id') }, required: ['queueId'] },
460
+ async call(api, args) {
461
+ return redactSecrets((await api.get(`/api/v1/queues/${args.queueId}`)).data);
462
+ },
463
+ },
464
+ {
465
+ name: 'neo_create_cron',
466
+ description: 'Create a scheduled job (cron) in a project (handoff 4.6). Target is either an existing service command (mode=app_command, serviceId+command) or an HTTP endpoint (mode=http_endpoint, url+method). Schedule via scheduleType (cron|interval|once|seconds) + schedule/intervalMinutes. May return pending_approval.',
467
+ mutating: true,
468
+ schema: {
469
+ type: 'object',
470
+ properties: {
471
+ projectId: str('Project id'),
472
+ name: str('Job name'),
473
+ mode: { type: 'string', description: 'app_command | http_endpoint' },
474
+ scheduleType: { type: 'string', description: 'cron | interval | once | seconds' },
475
+ schedule: str('Cron expression (when scheduleType=cron), e.g. "0 * * * *"'),
476
+ intervalMinutes: int('Interval in minutes (when scheduleType=interval)'),
477
+ timezone: str('IANA timezone, e.g. "Asia/Jakarta" (optional)'),
478
+ serviceId: str('Target service id (mode=app_command)'),
479
+ image: str('Image to run (mode=app_command, if not targeting a service)'),
480
+ command: { type: 'array', items: { type: 'string' }, description: 'Command argv (mode=app_command)' },
481
+ url: str('Target URL (mode=http_endpoint)'),
482
+ method: str('HTTP method (mode=http_endpoint), e.g. GET/POST'),
483
+ environment: str('production or staging (optional, default production)'),
484
+ },
485
+ required: ['projectId', 'name', 'mode', 'scheduleType'],
486
+ },
487
+ async call(api, args) {
488
+ const { projectId, ...body } = args;
489
+ const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons`, body);
490
+ return redactSecrets(result(status, data));
491
+ },
492
+ },
493
+ {
494
+ name: 'neo_list_crons',
495
+ description: 'List scheduled jobs (crons) in a project.',
496
+ mutating: false,
497
+ schema: { type: 'object', properties: { projectId: str('Project id') }, required: ['projectId'] },
498
+ async call(api, args) {
499
+ return redactSecrets((await api.get(`/api/v1/projects/${args.projectId}/crons`)).data);
500
+ },
501
+ },
502
+ {
503
+ name: 'neo_get_cron',
504
+ description: 'Get one scheduled job (cron) by id, including its schedule and last run.',
505
+ mutating: false,
506
+ schema: { type: 'object', properties: { cronId: str('Cron/scheduled-job id') }, required: ['cronId'] },
507
+ async call(api, args) {
508
+ return redactSecrets((await api.get(`/api/v1/crons/${args.cronId}`)).data);
509
+ },
510
+ },
511
+ {
512
+ name: 'neo_create_bucket',
513
+ 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.',
514
+ mutating: true,
515
+ schema: {
516
+ type: 'object',
517
+ properties: {
518
+ projectId: str('Project id'),
519
+ name: str('Bucket name'),
520
+ region: str('Region (optional; see neo_list_regions on the portal)'),
521
+ acl: { type: 'string', description: 'private | public-read (optional, default private)' },
522
+ quotaGB: int('Storage quota in GB (optional)'),
523
+ },
524
+ required: ['projectId', 'name'],
525
+ },
526
+ async call(api, args) {
527
+ const { projectId, ...body } = args;
528
+ const { status, data } = await api.post(`/api/v1/projects/${projectId}/buckets`, body);
529
+ return redactSecrets(result(status, data));
530
+ },
531
+ },
532
+ {
533
+ name: 'neo_list_buckets',
534
+ description: 'List object-storage buckets in a project.',
535
+ mutating: false,
536
+ schema: { type: 'object', properties: { projectId: str('Project id') }, required: ['projectId'] },
537
+ async call(api, args) {
538
+ return redactSecrets((await api.get(`/api/v1/projects/${args.projectId}/buckets`)).data);
539
+ },
540
+ },
541
+ {
542
+ name: 'neo_get_bucket',
543
+ description: 'Get one object-storage bucket by id (access keys not returned — write-only).',
544
+ mutating: false,
545
+ schema: { type: 'object', properties: { bucketId: str('Bucket id') }, required: ['bucketId'] },
546
+ async call(api, args) {
547
+ return redactSecrets((await api.get(`/api/v1/buckets/${args.bucketId}`)).data);
548
+ },
549
+ },
330
550
  // --- Cleanup (handoff 3.6): MCP can delete every resource type it can reach, so
331
551
  // an agent never leaks a resource it created. These are DESTRUCTIVE and permanent;
332
552
  // the server hard-deletes the record and tears down the running workload. ---
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
+ }