@biznetgio/neo-app 0.4.31 → 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.31",
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
@@ -65,13 +75,17 @@ async function confirmCost(api, args, spec) {
65
75
  };
66
76
  }
67
77
 
68
- const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey)/i;
78
+ const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey|authorization)/i;
79
+ // Deployment snapshots (exact keys) embed rendered configs with plaintext creds — a
80
+ // queue's `--pass`, a DB's POSTGRES_PASSWORD. Redact by EXACT key so we don't also
81
+ // hit resourceId/source-of-truth-style names (substring match would catch "reSOURCEid").
82
+ const SECRET_EXACT = new Set(['source', 'installed']);
69
83
  function redactSecrets(value) {
70
84
  if (Array.isArray(value)) return value.map(redactSecrets);
71
85
  if (value && typeof value === 'object') {
72
86
  const out = {};
73
87
  for (const [k, v] of Object.entries(value)) {
74
- out[k] = SECRET_KEY.test(k) ? '[redacted — credentials are write-only; bind them to a consumer instead]' : redactSecrets(v);
88
+ out[k] = (SECRET_KEY.test(k) || SECRET_EXACT.has(k.toLowerCase())) ? '[redacted — credentials are write-only; bind them to a consumer instead]' : redactSecrets(v);
75
89
  }
76
90
  return out;
77
91
  }
@@ -120,7 +134,7 @@ export const TOOLS = [
120
134
  { type: 'template', create: true, deploy: true, scale: false, logs: true, variables: true, delete: true },
121
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' },
122
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)' },
123
- { 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)' },
124
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)' },
125
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)' },
126
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)' },
@@ -131,7 +145,7 @@ export const TOOLS = [
131
145
  controlPlane,
132
146
  resourceTypes,
133
147
  // Verbs this MCP build exposes today (no create-DB/queue/cron/bucket, no costed plan/apply yet).
134
- 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'],
135
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)' },
136
150
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
137
151
  environments: ['staging', 'production'],
@@ -152,6 +166,25 @@ export const TOOLS = [
152
166
  return (await api.get(`/api/v1/orgs/${orgId}/projects`)).data;
153
167
  },
154
168
  },
169
+ {
170
+ name: 'neo_create_project',
171
+ description: 'Create a new project in an organization — use it for a disposable project (e.g. isolated testing) or a fresh project to deploy into. orgId defaults to your primary org. Returns the new project (id, name).',
172
+ mutating: true,
173
+ schema: {
174
+ type: 'object',
175
+ properties: {
176
+ name: str('Project name'),
177
+ description: str('Project description (optional)'),
178
+ orgId: str('Organization id (optional; defaults to your primary org)'),
179
+ },
180
+ required: ['name'],
181
+ },
182
+ async call(api, args) {
183
+ const orgId = await api.resolveOrgId(args.orgId);
184
+ const { status, data } = await api.post(`/api/v1/orgs/${orgId}/projects`, { name: args.name, description: args.description || '' });
185
+ return result(status, data);
186
+ },
187
+ },
155
188
  {
156
189
  name: 'neo_get_project',
157
190
  description: 'Get one project by id.',
@@ -399,6 +432,7 @@ export const TOOLS = [
399
432
  // root suggests worker; static → static_site; else web.
400
433
  suggestedServiceType: support ? null : (role === 'worker' ? 'worker' : (s.type === 'static' ? 'static_site' : 'web')),
401
434
  suggestedBuildType: s.type === 'docker' ? 'dockerfile' : 'railpack',
435
+ suggestedPort: s.port || undefined, // from the Dockerfile EXPOSE, when present
402
436
  contextDir: s.path || undefined,
403
437
  detectedDependencies: s.deps || [], // database | queue | object_storage, inferred from the manifest
404
438
  // Confidence (handoff 6): a Dockerfile/index.html is high; a worker guess from
@@ -467,7 +501,8 @@ export const TOOLS = [
467
501
  publishDirectory: str('static_site: directory of built files to serve via nginx (e.g. "dist"); enables native static hosting'),
468
502
  port: int('Container port (optional; ignored for worker)'),
469
503
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production. Never defaulted (handoff §2). Deploy to staging first.' },
470
- 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).'),
471
506
  },
472
507
  required: ['projectId', 'path', 'environment'],
473
508
  },
@@ -492,9 +527,10 @@ export const TOOLS = [
492
527
  publishDirectory: args.publishDirectory,
493
528
  port: args.port,
494
529
  environment,
530
+ confirmToken: args.confirmToken,
495
531
  dryRun: !!args.dryRun,
496
532
  });
497
- 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.' };
498
534
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
499
535
  return { status: r.result, url: r.url || undefined, appId: r.appId };
500
536
  },
@@ -606,6 +642,26 @@ export const TOOLS = [
606
642
  return result(status, data);
607
643
  },
608
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
+ },
609
665
  {
610
666
  name: 'neo_verify_queue',
611
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).',
@@ -1283,6 +1339,7 @@ export const TOOLS = [
1283
1339
  projectId: str('Project id'),
1284
1340
  confirm: { type: 'boolean', description: 'Must be true to delete. Without it you get a preview only (nothing is deleted).' },
1285
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).'),
1286
1343
  },
1287
1344
  required: ['projectId'],
1288
1345
  },
@@ -1301,25 +1358,44 @@ export const TOOLS = [
1301
1358
  ]);
1302
1359
  const arr = (x, k) => (Array.isArray(x) ? x : (x && Array.isArray(x[k]) ? x[k] : []));
1303
1360
  const inv = {
1304
- services: arr(apps, 'apps').map((a) => a.name || a.id),
1361
+ services: arr(apps, 'applications').map((a) => a.name || a.id), // list endpoint wraps as {applications:[…]}
1305
1362
  databases: arr(dbs, 'databases').map((d) => d.name || d.id),
1306
1363
  queues: arr(queues, 'queues').map((q) => q.name || q.id),
1307
1364
  crons: arr(crons, 'crons').map((c) => c.name || c.id),
1308
1365
  buckets: arr(buckets, 'buckets').map((b) => b.name || b.id),
1309
1366
  };
1310
1367
  const total = Object.values(inv).reduce((n, a) => n + a.length, 0);
1368
+ // Fail-safe: if any inventory fetch FAILED (null, not empty), we can't prove the
1369
+ // project is empty — treat it as non-empty so the empty-path can't silently
1370
+ // delete hidden resources (the preview must be trustworthy).
1371
+ const inventoryIncomplete = [apps, dbs, queues, crons, buckets].some((x) => x === null);
1311
1372
  const preview = {
1312
1373
  project: proj ? { id: proj.id, name: proj.name, organizationId: proj.organizationId } : { id: pid },
1313
1374
  resourceInventory: inv,
1314
1375
  resourceCount: total,
1315
1376
  meteringSnapshot: usage ? { period: usage.period, total: usage.total ?? usage.totalCost, currency: usage.currency } : null,
1377
+ inventoryIncomplete: inventoryIncomplete || undefined,
1378
+ deletionHash: deletionHashOf(pid, inv, total),
1316
1379
  warning: 'IRREVERSIBLE: deletes the project, every resource above, and their data (database/queue volumes included). No refund for prepaid or accrued usage.',
1317
1380
  };
1381
+ // Treat an unverifiable inventory as non-empty — never allow the no-cascade path
1382
+ // when we could not confirm the project is actually empty.
1383
+ const requireCascade = total > 0 || inventoryIncomplete;
1318
1384
  if (args.confirm !== true) {
1319
- return { ...preview, deleted: false, requiresConfirmation: true, message: total > 0 ? '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.' };
1386
+ }
1387
+ if (requireCascade && args.cascade !== true) {
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.` };
1320
1389
  }
1321
- if (total > 0 && args.cascade !== true) {
1322
- return { ...preview, deleted: false, error: 'cascade_required', message: `This project has ${total} resource(s). Re-call with confirm:true AND cascade:true to delete them all.` };
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
+ }
1323
1399
  }
1324
1400
  const { status, data } = await api.del(`/api/v1/projects/${pid}`);
1325
1401
  return { deleted: true, project: preview.project, removed: inv, resourceCount: total, meteringSnapshot: preview.meteringSnapshot, result: result(status, data) };