@biznetgio/neo-app 0.4.32 → 0.4.33

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.33",
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).',
@@ -1307,6 +1339,7 @@ export const TOOLS = [
1307
1339
  projectId: str('Project id'),
1308
1340
  confirm: { type: 'boolean', description: 'Must be true to delete. Without it you get a preview only (nothing is deleted).' },
1309
1341
  cascade: { type: 'boolean', description: 'Required true to delete a NON-EMPTY project — acknowledges every resource in the inventory is destroyed.' },
1342
+ 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
1343
  },
1311
1344
  required: ['projectId'],
1312
1345
  },
@@ -1342,17 +1375,28 @@ export const TOOLS = [
1342
1375
  resourceCount: total,
1343
1376
  meteringSnapshot: usage ? { period: usage.period, total: usage.total ?? usage.totalCost, currency: usage.currency } : null,
1344
1377
  inventoryIncomplete: inventoryIncomplete || undefined,
1378
+ deletionHash: deletionHashOf(pid, inv, total),
1345
1379
  warning: 'IRREVERSIBLE: deletes the project, every resource above, and their data (database/queue volumes included). No refund for prepaid or accrued usage.',
1346
1380
  };
1347
1381
  // Treat an unverifiable inventory as non-empty — never allow the no-cascade path
1348
1382
  // when we could not confirm the project is actually empty.
1349
1383
  const requireCascade = total > 0 || inventoryIncomplete;
1350
1384
  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.' };
1385
+ 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
1386
  }
1353
1387
  if (requireCascade && args.cascade !== true) {
1354
1388
  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
1389
  }
1390
+ // Immutable approval: a non-empty cascade delete must echo the preview's
1391
+ // deletionHash, and it must still match the live inventory.
1392
+ if (total > 0) {
1393
+ if (!args.deletionHash) {
1394
+ return { ...preview, deleted: false, error: 'deletion_hash_required', message: 'Pass deletionHash (from the preview) to confirm you are deleting exactly this inventory.' };
1395
+ }
1396
+ if (args.deletionHash !== preview.deletionHash) {
1397
+ return { ...preview, deleted: false, error: 'inventory_changed', message: 'The project inventory changed since your preview — re-preview and pass the new deletionHash.' };
1398
+ }
1399
+ }
1356
1400
  const { status, data } = await api.del(`/api/v1/projects/${pid}`);
1357
1401
  return { deleted: true, project: preview.project, removed: inv, resourceCount: total, meteringSnapshot: preview.meteringSnapshot, result: result(status, data) };
1358
1402
  },