@biznetgio/neo-app 0.4.32 → 0.4.34

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.32",
3
+ "version": "0.4.34",
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,8 +6,23 @@ 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 { createHash } from 'node:crypto';
9
10
  import { estimateCost } from './pricing.js';
10
11
 
12
+ // deployToken binds a dry-run to its real deploy: a short hash of the resolved spec
13
+ // that actually deploys. The dry-run returns it; the real call, if given confirmToken,
14
+ // recomputes and refuses a mismatch — so the agent can't show one cost then deploy
15
+ // a different spec (handoff 3.3/3.4; 0.4.31 retest §5).
16
+ function deployToken({ svc, targetEnv, svcType, port, buildCommand, publishDirectory, src }) {
17
+ const canon = JSON.stringify({
18
+ svc, targetEnv, svcType,
19
+ port: port || null, buildCommand: buildCommand || null, publishDirectory: publishDirectory || null,
20
+ src: { mode: src.mode, image: src.image || null, repo: src.repo || null, branch: src.branch || null,
21
+ dir: src.dir ? basename(src.dir) : null, zip: src.zipFile ? basename(src.zipFile) : null },
22
+ });
23
+ return createHash('sha256').update(canon).digest('hex').slice(0, 16);
24
+ }
25
+
11
26
  export function sanitizeName(s) {
12
27
  return (
13
28
  String(s).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'app'
@@ -140,7 +155,7 @@ function looksStatic(dir) {
140
155
  // With `dryRun`, it performs NO create/update/upload/deploy — only the GETs needed to
141
156
  // build a plan — and returns { dryRun:true, plan } (JNG #93).
142
157
  // Returns { pending, approvalId } or { appId, url, result }. `log(msg)` is optional.
143
- export async function deploySource(api, { projectId, src, name, port, build, serviceType, buildCommand, publishDirectory, environment, dryRun = false, timeout = 300, log = () => {} }) {
158
+ export async function deploySource(api, { projectId, src, name, port, build, serviceType, buildCommand, publishDirectory, environment, confirmToken, dryRun = false, timeout = 300, log = () => {} }) {
144
159
  const defaultName =
145
160
  src.mode === 'image' ? src.image.split('/').pop().split(':')[0]
146
161
  : src.mode === 'git' ? basename(src.repo, '.git')
@@ -276,7 +291,16 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
276
291
  const build = svcType === 'static_site' ? 'native nginx static hosting (not Railpack)' : 'built server-side';
277
292
  log(`[dry-run] estimated cost (${svcType}, ${build}, default micro compute): ~${estimatedCost.monthly} ${estimatedCost.currency}/mo (${estimatedCost.hourly}/h) — ${estimatedCost.assumptions}. Use neo_estimate for a specific size.`);
278
293
  } catch { /* price book unavailable — skip the estimate, not the dry-run */ }
279
- return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost };
294
+ return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, confirmToken: deployToken({ svc, targetEnv, svcType, port, buildCommand, publishDirectory, src }) };
295
+ }
296
+
297
+ // Crypto link: if the caller passed the dry-run's confirmToken, the spec about to
298
+ // deploy must still hash to it — otherwise the cost/spec the user approved changed.
299
+ if (confirmToken) {
300
+ const now = deployToken({ svc, targetEnv, svcType, port, buildCommand, publishDirectory, src });
301
+ if (now !== confirmToken) {
302
+ throw new Error(`deploy spec changed since the dry-run (confirmToken ${confirmToken} → ${now}) — re-run with dryRun:true, show the new cost, and confirm again`);
303
+ }
280
304
  }
281
305
 
282
306
  if (src.mode === 'dir' || src.mode === 'zip') {
package/src/mcp/tools.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { resolve, basename } from 'node:path';
6
6
  import { statSync, readFileSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
7
8
  import { deploySource } from '../deploySource.js';
8
9
  import { pkgVersion } from '../util.js';
9
10
  import { estimateCost } from '../pricing.js';
@@ -46,6 +47,15 @@ function gbFromSize(v) {
46
47
  return m ? parseInt(m[1], 10) : undefined;
47
48
  }
48
49
 
50
+ // deletionHashOf is an immutable approval token for a project deletion: a short hash
51
+ // of the exact inventory being deleted. The preview returns it; a cascade delete must
52
+ // echo it back, and the tool recomputes the live inventory and refuses a mismatch — so
53
+ // a project can't be deleted against a stale/partial inventory (0.4.31 retest §7).
54
+ function deletionHashOf(pid, inv, total) {
55
+ const names = Object.entries(inv).flatMap(([k, a]) => a.map((n) => k + ':' + n)).sort();
56
+ return createHash('sha256').update(JSON.stringify({ pid, total, names })).digest('hex').slice(0, 16);
57
+ }
58
+
49
59
  // confirmCost is the Approve & Create gate for billable one-shot creates: unless
50
60
  // args.confirm===true it returns the live-priced cost and creates NOTHING — the
51
61
  // agent shows the user, then re-calls with confirm:true. plan/apply remains the
@@ -124,7 +134,7 @@ export const TOOLS = [
124
134
  { type: 'template', create: true, deploy: true, scale: false, logs: true, variables: true, delete: true },
125
135
  { type: 'static_site', create: 'via_folder', deploy: true, scale: false, logs: true, variables: true, delete: true, nativeStatic: true, note: 'neo_deploy_folder serviceType=static_site with buildCommand/publishDirectory → native nginx static hosting (0.4.27); auto-detected for static-only folders' },
126
136
  { 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)' },
127
- { type: 'database', create: true, deploy: true, get: true, list: true, update: true, backup: true, verify: true, publicAccess: true, delete: true, note: 'neo_create/deploy/list/get/update(resize)/delete_database + backup + verify (SQL round-trip probe) + enable/disable_db_public_access; restore still TODO (handoff 4.4)' },
137
+ { type: 'database', create: true, deploy: true, get: true, list: true, update: true, backup: true, restore: true, verify: true, publicAccess: true, delete: true, note: 'neo_create/deploy/list/get/update(resize)/delete_database + backup + restore (postgres, local backups) + verify (SQL round-trip) + enable/disable_db_public_access (handoff 4.4)' },
128
138
  { type: 'queue', create: true, deploy: true, get: true, list: true, update: true, verify: true, publicAccess: true, delete: true, note: 'neo_create/deploy/list/get/update/verify(publish→persist)/delete_queue + enable/disable_queue_public_access (handoff 4.5)' },
129
139
  { type: 'cron', create: true, get: true, list: true, run: true, runs: true, questionnaire: true, delete: true, note: 'neo_cron_questionnaire (guided) + create/list/get/run/list_runs/delete_cron (handoff 4.6)' },
130
140
  { type: 'object_storage', create: true, get: true, list: true, objects: true, upload: true, verify: true, delete: true, note: 'neo_create/list/get/verify(put→list→delete)/delete_bucket + list/upload/presign(get)/delete objects (handoff 4.7)' },
@@ -135,7 +145,7 @@ export const TOOLS = [
135
145
  controlPlane,
136
146
  resourceTypes,
137
147
  // Verbs this MCP build exposes today (no create-DB/queue/cron/bucket, no costed plan/apply yet).
138
- lifecycleVerbs: ['plan', 'apply', 'get_operation', 'wait_operation', 'delete_stack', 'reconcile_usage', 'service_usage', 'create', 'list', 'get', 'update', 'verify', 'enable_public_access', 'disable_public_access', 'deploy', 'scale', 'logs', 'get_variables', 'set_variable', 'estimate', 'scan_repo', 'bind', 'unbind', 'run_cron', 'cron_questionnaire', 'presign_object', 'backup', 'delete', 'delete_project', 'list_approvals', 'get_approval', 'get_credit', 'list_templates'],
148
+ lifecycleVerbs: ['plan', 'apply', 'get_operation', 'wait_operation', 'delete_stack', 'reconcile_usage', 'service_usage', 'create', 'list', 'get', 'update', 'verify', 'enable_public_access', 'disable_public_access', 'deploy', 'scale', 'logs', 'get_variables', 'set_variable', 'estimate', 'scan_repo', 'bind', 'unbind', 'run_cron', 'cron_questionnaire', 'presign_object', 'backup', 'restore', 'delete', 'delete_project', 'list_approvals', 'get_approval', 'get_credit', 'list_templates'],
139
149
  bindings: { supported: true, targets: ['neo_db', 'neo_queue', 'neo_object_storage'], writeOnly: true, note: 'neo_bind_resource + neo_plan_stack bindings inject DATABASE_URL / NATS_URL (+ NATS_HOST/PORT/USER/PASSWORD) / S3_* server-side at deploy; secrets never returned (handoff 5)' },
140
150
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
141
151
  environments: ['staging', 'production'],
@@ -491,7 +501,8 @@ export const TOOLS = [
491
501
  publishDirectory: str('static_site: directory of built files to serve via nginx (e.g. "dist"); enables native static hosting'),
492
502
  port: int('Container port (optional; ignored for worker)'),
493
503
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production. Never defaulted (handoff §2). Deploy to staging first.' },
494
- dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93)' },
504
+ dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93). Returns a confirmToken.' },
505
+ confirmToken: str('The confirmToken from a prior dryRun — pass it on the real deploy to guarantee the spec/cost the user approved has not changed (rejected on mismatch).'),
495
506
  },
496
507
  required: ['projectId', 'path', 'environment'],
497
508
  },
@@ -516,9 +527,10 @@ export const TOOLS = [
516
527
  publishDirectory: args.publishDirectory,
517
528
  port: args.port,
518
529
  environment,
530
+ confirmToken: args.confirmToken,
519
531
  dryRun: !!args.dryRun,
520
532
  });
521
- if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment, estimatedCost: r.estimatedCost };
533
+ if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment, estimatedCost: r.estimatedCost, confirmToken: r.confirmToken, note: 'To deploy exactly this, re-call without dryRun and pass this confirmToken.' };
522
534
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
523
535
  return { status: r.result, url: r.url || undefined, appId: r.appId };
524
536
  },
@@ -630,6 +642,26 @@ export const TOOLS = [
630
642
  return result(status, data);
631
643
  },
632
644
  },
645
+ {
646
+ name: 'neo_restore_database',
647
+ description: 'Restore a postgres database from a prior backup (handoff 4.4). DESTRUCTIVE — applies the backup dump into the LIVE database (overwrites/merges) and is irreversible, so it requires confirm:true. Pass backupId for a specific backup, else the latest successful one is used. Local-destination backups only for now.',
648
+ mutating: true,
649
+ destructive: true,
650
+ schema: {
651
+ type: 'object',
652
+ properties: {
653
+ databaseId: str('Database id (postgres)'),
654
+ backupId: str('Backup id to restore from (optional; default the latest successful backup)'),
655
+ confirm: { type: 'boolean', description: 'Must be true — restore overwrites data in the live database.' },
656
+ },
657
+ required: ['databaseId', 'confirm'],
658
+ },
659
+ async call(api, args) {
660
+ const { databaseId, ...body } = args;
661
+ const { status, data } = await api.post(`/api/v1/databases/${databaseId}/restore`, body);
662
+ return result(status, data);
663
+ },
664
+ },
633
665
  {
634
666
  name: 'neo_verify_queue',
635
667
  description: 'Functionally verify a managed queue (handoff §8): runs an in-cluster probe that connects to NATS JetStream, publishes a message to a temporary stream, and confirms it was persisted — a real publish→persist round-trip, returning {verified, log} WITHOUT handing you any credentials. The queue must be deployed. Synchronous (~90s).',
@@ -1014,7 +1046,7 @@ export const TOOLS = [
1014
1046
  {
1015
1047
  name: 'neo_plan_stack',
1016
1048
  description:
1017
- 'Create an immutable, COSTED plan for a whole stack (services + databases/queues/buckets + scheduled jobs + bindings) WITHOUT creating anything — zero-write (handoff 3.3). Supports kind: app|database|queue|bucket|cron and bindings to neo_db|neo_queue|neo_object_storage. Returns planId, planHash, itemized monthly/hourly cost, and ordered phases. Show the cost to the user for approval, then call neo_apply_stack with planId+planHash. The plan expires in 15 minutes.',
1049
+ 'Create an immutable, COSTED plan for a whole stack (services + databases/queues/buckets + scheduled jobs + bindings) WITHOUT creating anything — zero-write (handoff 3.3). Supports kind: app|database|queue|bucket|cron and bindings to neo_db|neo_queue|neo_object_storage. An http_endpoint cron can target a same-stack web app by name (targetService+targetPath) — its URL is resolved at apply. Returns planId, planHash, itemized monthly/hourly cost, and ordered phases. Show the cost to the user for approval, then call neo_apply_stack with planId+planHash. The plan expires in 15 minutes.',
1018
1050
  mutating: false,
1019
1051
  schema: {
1020
1052
  type: 'object',
@@ -1049,7 +1081,9 @@ export const TOOLS = [
1049
1081
  timezone: str('cron: IANA timezone (optional)'),
1050
1082
  serviceId: str('cron app_command: target service — another resource NAME in this stack, or a literal service id'),
1051
1083
  command: { type: 'array', items: { type: 'string' }, description: 'cron app_command: command argv' },
1052
- url: str('cron http_endpoint: target URL'),
1084
+ url: str('cron http_endpoint: literal target URL'),
1085
+ targetService: str('cron http_endpoint: name of a web app resource IN THIS STACK to hit — its deployed URL is resolved at apply (use instead of a literal url for a same-stack service)'),
1086
+ targetPath: str('cron http_endpoint: path appended to the targetService URL (e.g. "/cron")'),
1053
1087
  method: str('cron http_endpoint: HTTP method'),
1054
1088
  },
1055
1089
  required: ['kind', 'name'],
@@ -1307,6 +1341,7 @@ export const TOOLS = [
1307
1341
  projectId: str('Project id'),
1308
1342
  confirm: { type: 'boolean', description: 'Must be true to delete. Without it you get a preview only (nothing is deleted).' },
1309
1343
  cascade: { type: 'boolean', description: 'Required true to delete a NON-EMPTY project — acknowledges every resource in the inventory is destroyed.' },
1344
+ deletionHash: str('For a non-empty project: the deletionHash from the preview. The tool refuses if the live inventory no longer matches it (immutable approval).'),
1310
1345
  },
1311
1346
  required: ['projectId'],
1312
1347
  },
@@ -1342,17 +1377,28 @@ export const TOOLS = [
1342
1377
  resourceCount: total,
1343
1378
  meteringSnapshot: usage ? { period: usage.period, total: usage.total ?? usage.totalCost, currency: usage.currency } : null,
1344
1379
  inventoryIncomplete: inventoryIncomplete || undefined,
1380
+ deletionHash: deletionHashOf(pid, inv, total),
1345
1381
  warning: 'IRREVERSIBLE: deletes the project, every resource above, and their data (database/queue volumes included). No refund for prepaid or accrued usage.',
1346
1382
  };
1347
1383
  // Treat an unverifiable inventory as non-empty — never allow the no-cascade path
1348
1384
  // when we could not confirm the project is actually empty.
1349
1385
  const requireCascade = total > 0 || inventoryIncomplete;
1350
1386
  if (args.confirm !== true) {
1351
- return { ...preview, deleted: false, requiresConfirmation: true, message: requireCascade ? 'Show this inventory to the user. To delete, re-call with confirm:true AND cascade:true.' : 'Empty project. To delete, re-call with confirm:true.' };
1387
+ return { ...preview, deleted: false, requiresConfirmation: true, message: requireCascade ? 'Show this inventory to the user. To delete, re-call with confirm:true, cascade:true, and deletionHash.' : 'Empty project. To delete, re-call with confirm:true.' };
1352
1388
  }
1353
1389
  if (requireCascade && args.cascade !== true) {
1354
1390
  return { ...preview, deleted: false, error: 'cascade_required', message: inventoryIncomplete ? 'Could not fully verify the project inventory — re-call with confirm:true AND cascade:true to delete anyway.' : `This project has ${total} resource(s). Re-call with confirm:true AND cascade:true to delete them all.` };
1355
1391
  }
1392
+ // Immutable approval: a non-empty cascade delete must echo the preview's
1393
+ // deletionHash, and it must still match the live inventory.
1394
+ if (total > 0) {
1395
+ if (!args.deletionHash) {
1396
+ return { ...preview, deleted: false, error: 'deletion_hash_required', message: 'Pass deletionHash (from the preview) to confirm you are deleting exactly this inventory.' };
1397
+ }
1398
+ if (args.deletionHash !== preview.deletionHash) {
1399
+ return { ...preview, deleted: false, error: 'inventory_changed', message: 'The project inventory changed since your preview — re-preview and pass the new deletionHash.' };
1400
+ }
1401
+ }
1356
1402
  const { status, data } = await api.del(`/api/v1/projects/${pid}`);
1357
1403
  return { deleted: true, project: preview.project, removed: inv, resourceCount: total, meteringSnapshot: preview.meteringSnapshot, result: result(status, data) };
1358
1404
  },