@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 +1 -1
- package/src/deploySource.js +26 -2
- package/src/mcp/tools.js +86 -10
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
|
|
@@ -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
|
|
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, '
|
|
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:
|
|
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
|
-
|
|
1322
|
-
|
|
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) };
|