@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 +1 -1
- package/src/deploySource.js +26 -2
- package/src/mcp/tools.js +49 -5
package/package.json
CHANGED
package/src/deploySource.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
},
|