@biznetgio/neo-app 0.4.29 → 0.4.31
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/prompt.md +31 -2
- package/src/deploySource.js +2 -1
- package/src/mcp/tools.js +179 -46
package/package.json
CHANGED
package/prompt.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
NEO App setup prompt — served at https://portal.neoapp.biznetgio.dev/prompt.md (staging)
|
|
3
3
|
and https://portal.app.neoapp.id/prompt.md (production).
|
|
4
4
|
Contains NO credentials or user-specific secrets. Versioned; keep last-updated current.
|
|
5
|
-
Keep this in sync with
|
|
5
|
+
Keep this in sync with cli/prompt.md (the npm package's bundled copy).
|
|
6
6
|
-->
|
|
7
7
|
# Set this repo up on NEO App
|
|
8
8
|
|
|
9
|
-
**Version:** 0.4.
|
|
9
|
+
**Version:** 0.4.4 · **Last updated:** 2026-10-15
|
|
10
10
|
|
|
11
11
|
These are the official setup instructions from NEO App (Biznet Gio's managed-app
|
|
12
12
|
platform). If you are an AI coding agent, complete the steps below yourself by running
|
|
@@ -109,6 +109,35 @@ tool. Report the connected account, organization, environment, detected agent
|
|
|
109
109
|
integrations, and any unresolved errors. **Do not deploy unless the human separately
|
|
110
110
|
asked for a deployment.**
|
|
111
111
|
|
|
112
|
+
## Deploying a resource (the MCP lifecycle)
|
|
113
|
+
|
|
114
|
+
Once setup is verified and the human has asked for a deployment, use the `neo_*` MCP
|
|
115
|
+
tools — never the portal UI. The lifecycle is the same for every resource type:
|
|
116
|
+
|
|
117
|
+
1. **Understand the repo** (for code deploys): `neo_scan_repo` enumerates deployable
|
|
118
|
+
components in a monorepo. Ask the human which to deploy; never collapse a monorepo
|
|
119
|
+
into one service.
|
|
120
|
+
2. **Plan and show the cost**: `neo_plan_stack` returns an immutable, priced plan
|
|
121
|
+
(`planId`, `planHash`, cost in IDR, expiry). Show the human the cost and get an
|
|
122
|
+
explicit **Approve & Create**. Individual creates (`neo_create_database`,
|
|
123
|
+
`neo_create_queue`) instead return the cost and create nothing until you re-call
|
|
124
|
+
with `confirm: true`.
|
|
125
|
+
3. **Apply**: `neo_apply_stack({planId, planHash})`. If the price book changed since the
|
|
126
|
+
plan, apply is refused — re-plan to re-quote. A `pending_approval` result is the
|
|
127
|
+
approval policy working, not an error; wait for it.
|
|
128
|
+
4. **Watch it reach health**: `neo_wait_operation` until terminal — the plan deploys in
|
|
129
|
+
dependency order and gates on health.
|
|
130
|
+
5. **Prove it works**: `neo_verify_database` (SQL round-trip), read logs, hit the URL.
|
|
131
|
+
6. **Clean up** when asked: `neo_delete_*` / `neo_delete_stack`, or `neo_delete_project`
|
|
132
|
+
(confirm-guarded) for the whole project.
|
|
133
|
+
|
|
134
|
+
For a **scheduled job**, call `neo_cron_questionnaire` first to collect the schedule and
|
|
135
|
+
target, then `neo_create_cron`. To expose a managed database or queue to the internet,
|
|
136
|
+
use `neo_enable_db_public_access` / `neo_enable_queue_public_access` (deploy it first).
|
|
137
|
+
|
|
138
|
+
Never deploy to **production** without an explicit request, and never default a missing
|
|
139
|
+
environment — every create/deploy names `staging` or `production`.
|
|
140
|
+
|
|
112
141
|
## Environment
|
|
113
142
|
|
|
114
143
|
The CLI targets production by default. Set `NEO_APP_URL` to point it at a different
|
package/src/deploySource.js
CHANGED
|
@@ -273,7 +273,8 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
|
|
|
273
273
|
try {
|
|
274
274
|
const book = (await api.get('/api/v1/pricing')).data;
|
|
275
275
|
estimatedCost = estimateCost(book, { computeTier: 'micro' });
|
|
276
|
-
|
|
276
|
+
const build = svcType === 'static_site' ? 'native nginx static hosting (not Railpack)' : 'built server-side';
|
|
277
|
+
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.`);
|
|
277
278
|
} catch { /* price book unavailable — skip the estimate, not the dry-run */ }
|
|
278
279
|
return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost };
|
|
279
280
|
}
|
package/src/mcp/tools.js
CHANGED
|
@@ -120,10 +120,10 @@ export const TOOLS = [
|
|
|
120
120
|
{ type: 'template', create: true, deploy: true, scale: false, logs: true, variables: true, delete: true },
|
|
121
121
|
{ 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
122
|
{ 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, get: true, list: true, update: true, backup: true, verify: true, publicAccess: true, delete: true, note: 'neo_create/list/get/update(resize)/delete_database + backup + verify (SQL round-trip probe) + enable/disable_db_public_access; restore still TODO (handoff 4.4)' },
|
|
124
|
-
{ type: 'queue', create: true, get: true, list: true, update: true, publicAccess: true, delete: true, note: 'neo_create/list/get/update/delete_queue + enable/disable_queue_public_access (handoff 4.5)' },
|
|
125
|
-
{ type: 'cron', create: true, get: true, list: true, run: true, runs: true, delete: true, note: '
|
|
126
|
-
{ type: 'object_storage', create: true, get: true, list: true, objects: true, upload: true, delete: true, note: 'neo_create/list/get/delete_bucket + list/upload/presign(get)/delete objects (
|
|
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)' },
|
|
124
|
+
{ 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
|
+
{ 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
|
+
{ 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)' },
|
|
127
127
|
];
|
|
128
128
|
|
|
129
129
|
return {
|
|
@@ -131,8 +131,8 @@ export const TOOLS = [
|
|
|
131
131
|
controlPlane,
|
|
132
132
|
resourceTypes,
|
|
133
133
|
// 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', 'presign_object', 'backup', 'delete', 'delete_project', 'list_approvals', 'get_approval', 'get_credit', 'list_templates'],
|
|
135
|
-
bindings: { supported: true, targets: ['neo_db', 'neo_queue'], writeOnly: true, note: 'neo_bind_resource
|
|
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'],
|
|
135
|
+
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
136
|
sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
|
|
137
137
|
environments: ['staging', 'production'],
|
|
138
138
|
pricing,
|
|
@@ -386,32 +386,44 @@ export const TOOLS = [
|
|
|
386
386
|
async call(api, args) {
|
|
387
387
|
const { data } = await api.post('/api/v1/repos/scan', { repoUrl: args.repoUrl, branch: args.branch, gitProviderId: args.gitProviderId });
|
|
388
388
|
const subdirs = data?.subdirs || [];
|
|
389
|
-
const components = subdirs.map((s) =>
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
389
|
+
const components = subdirs.map((s) => {
|
|
390
|
+
const role = s.role || 'service'; // service | worker | support
|
|
391
|
+
const support = role === 'support' || s.deployable === false;
|
|
392
|
+
return {
|
|
393
|
+
path: s.path || '.',
|
|
394
|
+
name: s.name,
|
|
395
|
+
stack: s.type, // docker | node | go | python | java | php | ruby | rust | static
|
|
396
|
+
role, // service | worker | support (library/config — not deployable)
|
|
397
|
+
deployable: !support,
|
|
398
|
+
// Don't suggest a service type for a support/library package; a worker-named
|
|
399
|
+
// root suggests worker; static → static_site; else web.
|
|
400
|
+
suggestedServiceType: support ? null : (role === 'worker' ? 'worker' : (s.type === 'static' ? 'static_site' : 'web')),
|
|
401
|
+
suggestedBuildType: s.type === 'docker' ? 'dockerfile' : 'railpack',
|
|
402
|
+
contextDir: s.path || undefined,
|
|
403
|
+
detectedDependencies: s.deps || [], // database | queue | object_storage, inferred from the manifest
|
|
404
|
+
// Confidence (handoff 6): a Dockerfile/index.html is high; a worker guess from
|
|
405
|
+
// the tree is low (confirm); a plain language marker is medium.
|
|
406
|
+
typeConfidence: support ? 'low' : (role === 'worker' ? 'low' : ((s.type === 'docker' || s.type === 'static') ? 'high' : 'medium')),
|
|
407
|
+
evidence: { marker: s.type, role, manifestDeps: s.deps || [] },
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
const deployable = components.filter((c) => c.deployable);
|
|
411
|
+
const support = components.filter((c) => !c.deployable);
|
|
403
412
|
const anyDeps = components.some((c) => c.detectedDependencies.length);
|
|
404
413
|
return {
|
|
405
414
|
repoUrl: args.repoUrl,
|
|
406
415
|
count: components.length,
|
|
416
|
+
deployableCount: deployable.length,
|
|
407
417
|
components,
|
|
408
418
|
note:
|
|
409
|
-
(
|
|
410
|
-
? 'Monorepo: multiple deployable components. Ask the user which to deploy and create each selected one with its `contextDir` — do NOT collapse them into one service (handoff 6).
|
|
411
|
-
:
|
|
419
|
+
(deployable.length > 1
|
|
420
|
+
? 'Monorepo: multiple deployable components. Ask the user which to deploy and create each selected one with its `contextDir` — do NOT collapse them into one service (handoff 6).'
|
|
421
|
+
: deployable.length === 1
|
|
412
422
|
? 'One deployable component found.'
|
|
413
|
-
: 'No deployable component found
|
|
414
|
-
+ (
|
|
423
|
+
: 'No deployable component found.')
|
|
424
|
+
+ (support.length ? ` ${support.length} support/library package(s) (role:support, deployable:false) were EXCLUDED from deployment — do not deploy config/lib packages (e.g. eslint/tsconfig/shared).` : '')
|
|
425
|
+
+ (components.some((c) => c.role === 'worker') ? ' A component is guessed as a worker (low confidence) — confirm web vs worker with the user.' : ' Worker vs web is a guess; confirm the type with the user.')
|
|
426
|
+
+ (anyDeps ? ' Some components list detectedDependencies (database/queue/object_storage) — consider planning + binding them (confirm first).' : ''),
|
|
415
427
|
};
|
|
416
428
|
},
|
|
417
429
|
},
|
|
@@ -535,6 +547,16 @@ export const TOOLS = [
|
|
|
535
547
|
return redactSecrets(result(status, data));
|
|
536
548
|
},
|
|
537
549
|
},
|
|
550
|
+
{
|
|
551
|
+
name: 'neo_deploy_database',
|
|
552
|
+
description: 'Deploy an individually-created managed database so it actually runs (provisions the cluster, sets its private host/port). Required after neo_create_database before the DB is reachable, bindable, or can have public access enabled. Poll neo_get_database until host is set and neo_verify_database passes. Credentials redacted.',
|
|
553
|
+
mutating: true,
|
|
554
|
+
schema: { type: 'object', properties: { databaseId: str('Database id (from neo_create_database)') }, required: ['databaseId'] },
|
|
555
|
+
async call(api, args) {
|
|
556
|
+
const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/deploy`);
|
|
557
|
+
return redactSecrets(result(status, data));
|
|
558
|
+
},
|
|
559
|
+
},
|
|
538
560
|
{
|
|
539
561
|
name: 'neo_list_databases',
|
|
540
562
|
description: 'List managed databases in a project (credentials redacted).',
|
|
@@ -584,6 +606,26 @@ export const TOOLS = [
|
|
|
584
606
|
return result(status, data);
|
|
585
607
|
},
|
|
586
608
|
},
|
|
609
|
+
{
|
|
610
|
+
name: 'neo_verify_queue',
|
|
611
|
+
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).',
|
|
612
|
+
mutating: true,
|
|
613
|
+
schema: { type: 'object', properties: { queueId: str('Queue id (deployed)') }, required: ['queueId'] },
|
|
614
|
+
async call(api, args) {
|
|
615
|
+
const { status, data } = await api.post(`/api/v1/queues/${args.queueId}/verify`);
|
|
616
|
+
return result(status, data);
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
name: 'neo_verify_bucket',
|
|
621
|
+
description: 'Functionally verify an object-storage bucket (handoff §8/§9): does a put → list → delete round-trip of a tiny temporary object through the real OBS backend, proving the bucket credentials and endpoint work, returning {verified, put, listed, deleted}. The probe object is always cleaned up. No credentials returned.',
|
|
622
|
+
mutating: true,
|
|
623
|
+
schema: { type: 'object', properties: { bucketId: str('Bucket id') }, required: ['bucketId'] },
|
|
624
|
+
async call(api, args) {
|
|
625
|
+
const { status, data } = await api.post(`/api/v1/buckets/${args.bucketId}/verify`);
|
|
626
|
+
return result(status, data);
|
|
627
|
+
},
|
|
628
|
+
},
|
|
587
629
|
{
|
|
588
630
|
name: 'neo_list_backups',
|
|
589
631
|
description: 'List configured database backups in a project (handoff 4.4): schedule, destination, last run status.',
|
|
@@ -649,6 +691,16 @@ export const TOOLS = [
|
|
|
649
691
|
return redactSecrets(result(status, data));
|
|
650
692
|
},
|
|
651
693
|
},
|
|
694
|
+
{
|
|
695
|
+
name: 'neo_deploy_queue',
|
|
696
|
+
description: 'Deploy an individually-created managed queue so it actually runs (provisions the NATS server, sets its private host/port). Required after neo_create_queue before the queue is reachable, bindable, or can have public access enabled. Poll neo_get_queue until host is set and neo_verify_queue passes. Credentials redacted.',
|
|
697
|
+
mutating: true,
|
|
698
|
+
schema: { type: 'object', properties: { queueId: str('Queue id (from neo_create_queue)') }, required: ['queueId'] },
|
|
699
|
+
async call(api, args) {
|
|
700
|
+
const { status, data } = await api.post(`/api/v1/queues/${args.queueId}/deploy`);
|
|
701
|
+
return redactSecrets(result(status, data));
|
|
702
|
+
},
|
|
703
|
+
},
|
|
652
704
|
{
|
|
653
705
|
name: 'neo_list_queues',
|
|
654
706
|
description: 'List managed queues in a project (credentials redacted).',
|
|
@@ -688,9 +740,43 @@ export const TOOLS = [
|
|
|
688
740
|
return redactSecrets(result(status, data));
|
|
689
741
|
},
|
|
690
742
|
},
|
|
743
|
+
{
|
|
744
|
+
name: 'neo_cron_questionnaire',
|
|
745
|
+
description: 'Return the ordered questions (with allowed values and which neo_create_cron field each answer fills) needed to define a scheduled job. Read-only — call this FIRST when a user wants a cron but has not given a full schedule/target, ask them these questions, then call neo_create_cron with the collected answers (handoff 4.6).',
|
|
746
|
+
mutating: false,
|
|
747
|
+
schema: { type: 'object', properties: {} },
|
|
748
|
+
async call() {
|
|
749
|
+
return {
|
|
750
|
+
questions: [
|
|
751
|
+
{ field: 'name', ask: 'What should this job be called?', type: 'string' },
|
|
752
|
+
{ field: 'mode', ask: 'What does the job run?', type: 'enum', options: [
|
|
753
|
+
{ value: 'app_command', when: 'run a command inside an existing service, or a one-off image', fills: ['serviceId OR image', 'command'] },
|
|
754
|
+
{ value: 'http_endpoint', when: 'call a URL on a schedule (webhook/ping)', fills: ['url', 'method'] },
|
|
755
|
+
] },
|
|
756
|
+
{ field: 'scheduleType', ask: 'How often should it run?', type: 'enum', options: [
|
|
757
|
+
{ value: 'cron', fills: ['schedule'], hint: 'cron expression, e.g. "0 * * * *" (hourly)' },
|
|
758
|
+
{ value: 'interval', fills: ['intervalMinutes'], hint: 'every N minutes' },
|
|
759
|
+
{ value: 'seconds', fills: ['intervalMinutes'], hint: 'sub-minute/seconds cadence' },
|
|
760
|
+
{ value: 'once', fills: [], hint: 'run a single time' },
|
|
761
|
+
] },
|
|
762
|
+
{ field: 'timezone', ask: 'Which timezone for the schedule? (optional, IANA, e.g. Asia/Jakarta)', type: 'string', optional: true },
|
|
763
|
+
{ field: 'environment', ask: 'staging or production? (never defaulted)', type: 'enum', options: ['staging', 'production'], required: true },
|
|
764
|
+
],
|
|
765
|
+
branches: {
|
|
766
|
+
app_command: 'Provide serviceId (an existing service) OR image, plus command (argv array).',
|
|
767
|
+
http_endpoint: 'Provide url and method (GET/POST/…).',
|
|
768
|
+
},
|
|
769
|
+
examples: [
|
|
770
|
+
{ intent: 'Run `node cleanup.js` in my web service every night at 2am Jakarta', args: { name: 'nightly-cleanup', mode: 'app_command', scheduleType: 'cron', schedule: '0 2 * * *', timezone: 'Asia/Jakarta', serviceId: '<web service id>', command: ['node', 'cleanup.js'], environment: 'staging' } },
|
|
771
|
+
{ intent: 'Ping a health URL every 15 minutes', args: { name: 'healthcheck', mode: 'http_endpoint', scheduleType: 'interval', intervalMinutes: 15, url: 'https://example.app/health', method: 'GET', environment: 'production' } },
|
|
772
|
+
],
|
|
773
|
+
next: 'Collect the answers, then call neo_create_cron. It may return pending_approval (expected).',
|
|
774
|
+
};
|
|
775
|
+
},
|
|
776
|
+
},
|
|
691
777
|
{
|
|
692
778
|
name: 'neo_create_cron',
|
|
693
|
-
description: 'Create a scheduled job (cron) in a project (handoff 4.6). Target is either an existing service command (mode=app_command, serviceId+command) or an HTTP endpoint (mode=http_endpoint, url+method). Schedule via scheduleType (cron|interval|once|seconds) + schedule/intervalMinutes. May return pending_approval.',
|
|
779
|
+
description: 'Create a scheduled job (cron) in a project (handoff 4.6). If the user gave an incomplete spec, call neo_cron_questionnaire first. Target is either an existing service command (mode=app_command, serviceId+command) or an HTTP endpoint (mode=http_endpoint, url+method). Schedule via scheduleType (cron|interval|once|seconds) + schedule/intervalMinutes. May return pending_approval.',
|
|
694
780
|
mutating: true,
|
|
695
781
|
schema: {
|
|
696
782
|
type: 'object',
|
|
@@ -708,12 +794,15 @@ export const TOOLS = [
|
|
|
708
794
|
url: str('Target URL (mode=http_endpoint)'),
|
|
709
795
|
method: str('HTTP method (mode=http_endpoint), e.g. GET/POST'),
|
|
710
796
|
environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
|
|
797
|
+
confirm: { type: 'boolean', description: 'Approve & Create: without confirm:true the tool returns the cost (crons are billed per run — variable) and creates nothing. Re-call with confirm:true to create.' },
|
|
711
798
|
},
|
|
712
799
|
required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
|
|
713
800
|
},
|
|
714
801
|
async call(api, args) {
|
|
715
802
|
requireEnv(args.environment);
|
|
716
|
-
const
|
|
803
|
+
const gate = await confirmCost(api, args, {}); // cron cost is variable (per-run) — gate surfaces the rate
|
|
804
|
+
if (gate) return gate;
|
|
805
|
+
const { projectId, confirm, orgId, ...body } = args;
|
|
717
806
|
const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons`, body);
|
|
718
807
|
return redactSecrets(result(status, data));
|
|
719
808
|
},
|
|
@@ -767,11 +856,14 @@ export const TOOLS = [
|
|
|
767
856
|
region: str('Region (optional; see neo_list_regions on the portal)'),
|
|
768
857
|
acl: { type: 'string', description: 'private | public-read (optional, default private)' },
|
|
769
858
|
quotaGB: int('Storage quota in GB (optional)'),
|
|
859
|
+
confirm: { type: 'boolean', description: 'Approve & Create: without confirm:true the tool returns the cost and creates nothing. Re-call with confirm:true to actually create.' },
|
|
770
860
|
},
|
|
771
861
|
required: ['projectId', 'name'],
|
|
772
862
|
},
|
|
773
863
|
async call(api, args) {
|
|
774
|
-
const
|
|
864
|
+
const gate = await confirmCost(api, args, { objectStorageGB: gbFromSize(args.quotaGB) });
|
|
865
|
+
if (gate) return gate;
|
|
866
|
+
const { projectId, confirm, orgId, ...body } = args;
|
|
775
867
|
const { status, data } = await api.post(`/api/v1/projects/${projectId}/buckets`, body);
|
|
776
868
|
return redactSecrets(result(status, data));
|
|
777
869
|
},
|
|
@@ -898,7 +990,7 @@ export const TOOLS = [
|
|
|
898
990
|
{
|
|
899
991
|
name: 'neo_plan_stack',
|
|
900
992
|
description:
|
|
901
|
-
'Create an immutable, COSTED plan for a whole stack (services + databases/queues/buckets + bindings) WITHOUT creating anything — zero-write (handoff 3.3). 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.',
|
|
993
|
+
'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.',
|
|
902
994
|
mutating: false,
|
|
903
995
|
schema: {
|
|
904
996
|
type: 'object',
|
|
@@ -907,16 +999,16 @@ export const TOOLS = [
|
|
|
907
999
|
environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
|
|
908
1000
|
resources: {
|
|
909
1001
|
type: 'array',
|
|
910
|
-
description: 'The stack resources. Each needs a unique name + kind (app | database | queue | bucket).',
|
|
1002
|
+
description: 'The stack resources. Each needs a unique name + kind (app | database | queue | bucket | cron).',
|
|
911
1003
|
items: {
|
|
912
1004
|
type: 'object',
|
|
913
1005
|
properties: {
|
|
914
|
-
kind: { type: 'string', enum: ['app', 'database', 'queue', 'bucket'] },
|
|
1006
|
+
kind: { type: 'string', enum: ['app', 'database', 'queue', 'bucket', 'cron'] },
|
|
915
1007
|
name: str('Unique resource name within this stack (bindings reference it)'),
|
|
916
1008
|
serviceType: str('app: web | worker | static_site'),
|
|
917
1009
|
buildCommand: str('app/static_site: build command (e.g. "npm ci && npm run build")'),
|
|
918
1010
|
publishDirectory: str('static_site: built-files directory to serve (e.g. "dist")'),
|
|
919
|
-
image: str('app: prebuilt image reference'),
|
|
1011
|
+
image: str('app/cron: prebuilt image reference'),
|
|
920
1012
|
repoUrl: str('app: git repository URL'),
|
|
921
1013
|
branch: str('app: git branch'),
|
|
922
1014
|
port: int('app: container port'),
|
|
@@ -926,20 +1018,29 @@ export const TOOLS = [
|
|
|
926
1018
|
volumeSize: str('database: storage size, e.g. 10Gi'),
|
|
927
1019
|
quotaGB: int('bucket: storage quota GB'),
|
|
928
1020
|
region: str('bucket: region'),
|
|
1021
|
+
mode: str('cron: app_command | http_endpoint'),
|
|
1022
|
+
scheduleType: str('cron: cron | interval | once | seconds'),
|
|
1023
|
+
schedule: str('cron: cron expression (scheduleType=cron)'),
|
|
1024
|
+
intervalMinutes: int('cron: interval in minutes (scheduleType=interval)'),
|
|
1025
|
+
timezone: str('cron: IANA timezone (optional)'),
|
|
1026
|
+
serviceId: str('cron app_command: target service — another resource NAME in this stack, or a literal service id'),
|
|
1027
|
+
command: { type: 'array', items: { type: 'string' }, description: 'cron app_command: command argv' },
|
|
1028
|
+
url: str('cron http_endpoint: target URL'),
|
|
1029
|
+
method: str('cron http_endpoint: HTTP method'),
|
|
929
1030
|
},
|
|
930
1031
|
required: ['kind', 'name'],
|
|
931
1032
|
},
|
|
932
1033
|
},
|
|
933
1034
|
bindings: {
|
|
934
1035
|
type: 'array',
|
|
935
|
-
description: 'Wire a database/queue into a consumer app (injected as
|
|
1036
|
+
description: 'Wire a database/queue/bucket into a consumer app (injected as env var(s) at deploy, server-side).',
|
|
936
1037
|
items: {
|
|
937
1038
|
type: 'object',
|
|
938
1039
|
properties: {
|
|
939
1040
|
consumer: str('The consumer app resource name'),
|
|
940
|
-
targetType: { type: 'string', enum: ['neo_db', 'neo_queue'] },
|
|
941
|
-
target: str('The database/queue resource name'),
|
|
942
|
-
envKey: str('Env var name (optional; default DATABASE_URL / NATS_URL)'),
|
|
1041
|
+
targetType: { type: 'string', enum: ['neo_db', 'neo_queue', 'neo_object_storage'] },
|
|
1042
|
+
target: str('The database/queue/bucket resource name'),
|
|
1043
|
+
envKey: str('Env var name/prefix (optional; default DATABASE_URL / NATS_URL / S3)'),
|
|
943
1044
|
},
|
|
944
1045
|
required: ['consumer', 'targetType', 'target'],
|
|
945
1046
|
},
|
|
@@ -1125,9 +1226,9 @@ export const TOOLS = [
|
|
|
1125
1226
|
type: 'object',
|
|
1126
1227
|
properties: {
|
|
1127
1228
|
databaseId: str('Database id (postgres, already deployed)'),
|
|
1128
|
-
allowlistCidrs: { type: 'array', items: { type: 'string' }, description: 'CIDR
|
|
1229
|
+
allowlistCidrs: { type: 'array', items: { type: 'string' }, description: 'REQUIRED — at least one real source CIDR, e.g. ["203.0.113.0/24"]. The server rejects an empty list and 0.0.0.0/0 (no open-to-the-world).' },
|
|
1129
1230
|
},
|
|
1130
|
-
required: ['databaseId'],
|
|
1231
|
+
required: ['databaseId', 'allowlistCidrs'],
|
|
1131
1232
|
},
|
|
1132
1233
|
async call(api, args) {
|
|
1133
1234
|
const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/public-access`, { allowlistCidrs: args.allowlistCidrs || [] });
|
|
@@ -1152,9 +1253,9 @@ export const TOOLS = [
|
|
|
1152
1253
|
type: 'object',
|
|
1153
1254
|
properties: {
|
|
1154
1255
|
queueId: str('Queue id (already deployed)'),
|
|
1155
|
-
allowlistCidrs: { type: 'array', items: { type: 'string' }, description: 'CIDR
|
|
1256
|
+
allowlistCidrs: { type: 'array', items: { type: 'string' }, description: 'REQUIRED — at least one real source CIDR, e.g. ["203.0.113.0/24"]. The server rejects an empty list and 0.0.0.0/0.' },
|
|
1156
1257
|
},
|
|
1157
|
-
required: ['queueId'],
|
|
1258
|
+
required: ['queueId', 'allowlistCidrs'],
|
|
1158
1259
|
},
|
|
1159
1260
|
async call(api, args) {
|
|
1160
1261
|
const { status, data } = await api.post(`/api/v1/queues/${args.queueId}/public-access`, { allowlistCidrs: args.allowlistCidrs || [] });
|
|
@@ -1173,23 +1274,55 @@ export const TOOLS = [
|
|
|
1173
1274
|
},
|
|
1174
1275
|
{
|
|
1175
1276
|
name: 'neo_delete_project',
|
|
1176
|
-
description: '
|
|
1277
|
+
description: 'Delete a project and ALL its resources (apps, databases, queues, crons, buckets, namespace + PVCs/CNPG). SAFE BY DEFAULT: without confirm:true it returns a PREVIEW (project name, org, full resource inventory, accrued-usage snapshot, no-refund warning) and deletes NOTHING. A NON-EMPTY project additionally requires cascade:true. Destructive and irreversible. Only the creator or an org owner/admin may delete.',
|
|
1177
1278
|
mutating: true,
|
|
1178
1279
|
destructive: true,
|
|
1179
1280
|
schema: {
|
|
1180
1281
|
type: 'object',
|
|
1181
1282
|
properties: {
|
|
1182
1283
|
projectId: str('Project id'),
|
|
1183
|
-
confirm: { type: 'boolean', description: 'Must be true to
|
|
1284
|
+
confirm: { type: 'boolean', description: 'Must be true to delete. Without it you get a preview only (nothing is deleted).' },
|
|
1285
|
+
cascade: { type: 'boolean', description: 'Required true to delete a NON-EMPTY project — acknowledges every resource in the inventory is destroyed.' },
|
|
1184
1286
|
},
|
|
1185
|
-
required: ['projectId'
|
|
1287
|
+
required: ['projectId'],
|
|
1186
1288
|
},
|
|
1187
1289
|
async call(api, args) {
|
|
1290
|
+
const pid = args.projectId;
|
|
1291
|
+
// Gather inventory + usage BEFORE any delete (this is also the metering snapshot).
|
|
1292
|
+
const safeGet = async (p) => { try { return (await api.get(p)).data; } catch { return null; } };
|
|
1293
|
+
const [proj, apps, dbs, queues, crons, buckets, usage] = await Promise.all([
|
|
1294
|
+
safeGet(`/api/v1/projects/${pid}`),
|
|
1295
|
+
safeGet(`/api/v1/projects/${pid}/apps`),
|
|
1296
|
+
safeGet(`/api/v1/projects/${pid}/databases`),
|
|
1297
|
+
safeGet(`/api/v1/projects/${pid}/queues`),
|
|
1298
|
+
safeGet(`/api/v1/projects/${pid}/crons`),
|
|
1299
|
+
safeGet(`/api/v1/projects/${pid}/buckets`),
|
|
1300
|
+
safeGet(`/api/v1/projects/${pid}/usage`),
|
|
1301
|
+
]);
|
|
1302
|
+
const arr = (x, k) => (Array.isArray(x) ? x : (x && Array.isArray(x[k]) ? x[k] : []));
|
|
1303
|
+
const inv = {
|
|
1304
|
+
services: arr(apps, 'apps').map((a) => a.name || a.id),
|
|
1305
|
+
databases: arr(dbs, 'databases').map((d) => d.name || d.id),
|
|
1306
|
+
queues: arr(queues, 'queues').map((q) => q.name || q.id),
|
|
1307
|
+
crons: arr(crons, 'crons').map((c) => c.name || c.id),
|
|
1308
|
+
buckets: arr(buckets, 'buckets').map((b) => b.name || b.id),
|
|
1309
|
+
};
|
|
1310
|
+
const total = Object.values(inv).reduce((n, a) => n + a.length, 0);
|
|
1311
|
+
const preview = {
|
|
1312
|
+
project: proj ? { id: proj.id, name: proj.name, organizationId: proj.organizationId } : { id: pid },
|
|
1313
|
+
resourceInventory: inv,
|
|
1314
|
+
resourceCount: total,
|
|
1315
|
+
meteringSnapshot: usage ? { period: usage.period, total: usage.total ?? usage.totalCost, currency: usage.currency } : null,
|
|
1316
|
+
warning: 'IRREVERSIBLE: deletes the project, every resource above, and their data (database/queue volumes included). No refund for prepaid or accrued usage.',
|
|
1317
|
+
};
|
|
1188
1318
|
if (args.confirm !== true) {
|
|
1189
|
-
return {
|
|
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.' };
|
|
1190
1320
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
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.` };
|
|
1323
|
+
}
|
|
1324
|
+
const { status, data } = await api.del(`/api/v1/projects/${pid}`);
|
|
1325
|
+
return { deleted: true, project: preview.project, removed: inv, resourceCount: total, meteringSnapshot: preview.meteringSnapshot, result: result(status, data) };
|
|
1193
1326
|
},
|
|
1194
1327
|
},
|
|
1195
1328
|
];
|