@biznetgio/neo-app 0.4.34 → 0.4.36

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/mcp/tools.js +72 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.34",
3
+ "version": "0.4.36",
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": {
package/src/mcp/tools.js CHANGED
@@ -40,6 +40,26 @@ function requireEnv(env) {
40
40
  return e;
41
41
  }
42
42
 
43
+ // deleteAndConverge deletes a resource, then polls its endpoint until the inventory
44
+ // actually converges (GET → 404), so a delete isn't reported done while the record
45
+ // still exists. Returns a durable operation reference (`del-<id>`) + convergence
46
+ // state (handoff 3.6; 0.4.34 retest §9 "every delete returns a durable operation ID").
47
+ async function deleteAndConverge(api, path, resourceId, kind) {
48
+ const { status, data } = await api.del(path);
49
+ let converged = false, checks = 0;
50
+ for (let i = 0; i < 12; i++) {
51
+ checks++;
52
+ try {
53
+ await api.get(path);
54
+ await new Promise((r) => setTimeout(r, 500)); // still present — keep polling
55
+ } catch (e) {
56
+ if (e && e.status === 404) { converged = true; break; }
57
+ await new Promise((r) => setTimeout(r, 500));
58
+ }
59
+ }
60
+ return { operationId: 'del-' + resourceId, kind, status: (data && data.status) || 'deleted', converged, convergenceChecks: checks };
61
+ }
62
+
43
63
  // gbFromSize parses "10Gi"/"10G"/"10" → 10 (integer GB), else undefined.
44
64
  function gbFromSize(v) {
45
65
  if (v == null) return undefined;
@@ -313,7 +333,7 @@ export const TOOLS = [
313
333
  },
314
334
  {
315
335
  name: 'neo_scale_service',
316
- description: 'Scale a service to N replicas. May return pending_approval under org policy.',
336
+ description: 'Scale a service to N replicas. Scaling changes cost, so without confirm:true it returns the new estimated cost and scales nothing (re-call with confirm:true). May return pending_approval under org policy.',
317
337
  mutating: true,
318
338
  schema: {
319
339
  type: 'object',
@@ -321,10 +341,22 @@ export const TOOLS = [
321
341
  projectId: str('Project id'),
322
342
  service: str('Service/app name'),
323
343
  replicas: int('Desired replica count (>=0)'),
344
+ confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true the tool returns the new cost and scales nothing.' },
324
345
  },
325
346
  required: ['projectId', 'service', 'replicas'],
326
347
  },
327
348
  async call(api, args) {
349
+ if (args.confirm !== true) {
350
+ let computeTier;
351
+ try {
352
+ const apps = (await api.get(`/api/v1/projects/${args.projectId}/apps`)).data;
353
+ const list = Array.isArray(apps) ? apps : (apps.applications || []);
354
+ const svc = list.find((a) => a.name === args.service || a.id === args.service);
355
+ computeTier = svc && (svc.cpuLimit || svc.computeTier);
356
+ } catch { /* best effort — gate regardless */ }
357
+ const gate = await confirmCost(api, args, { computeTier, replicas: args.replicas });
358
+ if (gate) return { ...gate, change: `scale "${args.service}" to ${args.replicas} replica(s)`, message: 'Scaling changes cost — show the user and re-call with confirm:true to apply.' };
359
+ }
328
360
  const { status, data } = await api.post(
329
361
  `/api/v1/projects/${args.projectId}/services/${args.service}/scale`,
330
362
  { replicas: args.replicas }
@@ -621,10 +653,16 @@ export const TOOLS = [
621
653
  databaseId: str('Database id'),
622
654
  volumeSize: str('New storage size, e.g. "20Gi" — must be >= current (grow-only)'),
623
655
  replicas: int('Read/failover replicas (CNPG/postgres); total instances = 1 + replicas'),
656
+ confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true a storage/replica resize returns the new cost and applies nothing.' },
624
657
  },
625
658
  required: ['databaseId'],
626
659
  },
627
660
  async call(api, args) {
661
+ const costChanging = args.volumeSize || args.replicas !== undefined;
662
+ if (costChanging && args.confirm !== true) {
663
+ const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
664
+ if (gate) return { ...gate, change: 'resize database (storage/replicas)', message: 'Resizing changes cost — re-call with confirm:true to apply.' };
665
+ }
628
666
  const body = {};
629
667
  if (args.volumeSize) body.volumeSize = args.volumeSize;
630
668
  if (args.replicas !== undefined) body.replicas = args.replicas;
@@ -787,11 +825,17 @@ export const TOOLS = [
787
825
  dlqEnabled: { type: 'boolean', description: 'Enable/disable the dead-letter queue' },
788
826
  volumeSize: str('New storage size, e.g. "20Gi" — must be >= current (grow-only)'),
789
827
  retention: { type: 'object', description: 'Retention policy {maxAgeSeconds, maxBytes, maxMsgs}' },
828
+ confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true a storage/plan change returns the new cost and applies nothing. DLQ/retention-only edits apply directly.' },
790
829
  },
791
830
  required: ['queueId'],
792
831
  },
793
832
  async call(api, args) {
794
- const { queueId, ...body } = args;
833
+ const costChanging = args.volumeSize || args.planClass;
834
+ if (costChanging && args.confirm !== true) {
835
+ const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize) });
836
+ if (gate) return { ...gate, change: 'update queue (storage/plan)', message: 'This changes cost — re-call with confirm:true to apply.' };
837
+ }
838
+ const { queueId, confirm, orgId, ...body } = args;
795
839
  const { status, data } = await api.put(`/api/v1/queues/${queueId}`, body);
796
840
  return redactSecrets(result(status, data));
797
841
  },
@@ -820,8 +864,16 @@ export const TOOLS = [
820
864
  ],
821
865
  branches: {
822
866
  app_command: 'Provide serviceId (an existing service) OR image, plus command (argv array).',
823
- http_endpoint: 'Provide url and method (GET/POST/…).',
867
+ http_endpoint: 'Provide url and method (GET/POST/…). Optional: headers (object), body + contentType, expectedStatus (exact code required for success), timeoutSeconds, retryCount, concurrencyPolicy (Allow|Forbid|Replace).',
824
868
  },
869
+ httpOptions: [
870
+ { field: 'headers', ask: 'Any request headers? (e.g. Authorization) — object', optional: true },
871
+ { field: 'body', ask: 'A request body? (+ contentType, e.g. application/json)', optional: true },
872
+ { field: 'expectedStatus', ask: 'A specific expected HTTP status for success? (default: any 2xx)', optional: true },
873
+ { field: 'timeoutSeconds', ask: 'A per-run timeout (seconds)?', optional: true },
874
+ { field: 'retryCount', ask: 'How many retries on failure?', optional: true },
875
+ { field: 'concurrencyPolicy', ask: 'If a run overruns the next schedule: Allow | Forbid | Replace?', optional: true },
876
+ ],
825
877
  examples: [
826
878
  { 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' } },
827
879
  { 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' } },
@@ -849,6 +901,13 @@ export const TOOLS = [
849
901
  command: { type: 'array', items: { type: 'string' }, description: 'Command argv (mode=app_command)' },
850
902
  url: str('Target URL (mode=http_endpoint)'),
851
903
  method: str('HTTP method (mode=http_endpoint), e.g. GET/POST'),
904
+ headers: { type: 'object', description: 'HTTP request headers (mode=http_endpoint), e.g. {"Authorization":"Bearer …"}. Secret header values are redacted when read back.' },
905
+ body: str('HTTP request body (mode=http_endpoint)'),
906
+ contentType: str('Content-Type for the body (mode=http_endpoint), e.g. "application/json" — merged into headers'),
907
+ expectedStatus: int('Exact HTTP status a run must receive to count as success (mode=http_endpoint; default: any 2xx)'),
908
+ timeoutSeconds: int('Per-run timeout in seconds (optional) → activeDeadlineSeconds'),
909
+ retryCount: int('Retries on failure (optional) → backoffLimit'),
910
+ concurrencyPolicy: { type: 'string', enum: ['Allow', 'Forbid', 'Replace'], description: 'Overlap policy if a run is still going when the next is due (optional, default Allow)' },
852
911
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
853
912
  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.' },
854
913
  },
@@ -858,7 +917,11 @@ export const TOOLS = [
858
917
  requireEnv(args.environment);
859
918
  const gate = await confirmCost(api, args, {}); // cron cost is variable (per-run) — gate surfaces the rate
860
919
  if (gate) return gate;
861
- const { projectId, confirm, orgId, ...body } = args;
920
+ // Merge contentType into headers for convenience (unless already set).
921
+ if (args.contentType) {
922
+ args = { ...args, headers: { 'Content-Type': args.contentType, ...(args.headers || {}) } };
923
+ }
924
+ const { projectId, confirm, orgId, contentType, ...body } = args;
862
925
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons`, body);
863
926
  return redactSecrets(result(status, data));
864
927
  },
@@ -1228,8 +1291,7 @@ export const TOOLS = [
1228
1291
  destructive: true,
1229
1292
  schema: { type: 'object', properties: { appId: str('Service/app id (from neo_list_services)') }, required: ['appId'] },
1230
1293
  async call(api, args) {
1231
- const { status, data } = await api.del(`/api/v1/apps/${args.appId}`);
1232
- return result(status, data);
1294
+ return deleteAndConverge(api, `/api/v1/apps/${args.appId}`, args.appId, 'service');
1233
1295
  },
1234
1296
  },
1235
1297
  {
@@ -1239,8 +1301,7 @@ export const TOOLS = [
1239
1301
  destructive: true,
1240
1302
  schema: { type: 'object', properties: { databaseId: str('Database id') }, required: ['databaseId'] },
1241
1303
  async call(api, args) {
1242
- const { status, data } = await api.del(`/api/v1/databases/${args.databaseId}`);
1243
- return result(status, data);
1304
+ return deleteAndConverge(api, `/api/v1/databases/${args.databaseId}`, args.databaseId, 'database');
1244
1305
  },
1245
1306
  },
1246
1307
  {
@@ -1250,8 +1311,7 @@ export const TOOLS = [
1250
1311
  destructive: true,
1251
1312
  schema: { type: 'object', properties: { queueId: str('Queue id') }, required: ['queueId'] },
1252
1313
  async call(api, args) {
1253
- const { status, data } = await api.del(`/api/v1/queues/${args.queueId}`);
1254
- return result(status, data);
1314
+ return deleteAndConverge(api, `/api/v1/queues/${args.queueId}`, args.queueId, 'queue');
1255
1315
  },
1256
1316
  },
1257
1317
  {
@@ -1261,8 +1321,7 @@ export const TOOLS = [
1261
1321
  destructive: true,
1262
1322
  schema: { type: 'object', properties: { cronId: str('Cron/scheduled-job id') }, required: ['cronId'] },
1263
1323
  async call(api, args) {
1264
- const { status, data } = await api.del(`/api/v1/crons/${args.cronId}`);
1265
- return result(status, data);
1324
+ return deleteAndConverge(api, `/api/v1/crons/${args.cronId}`, args.cronId, 'cron');
1266
1325
  },
1267
1326
  },
1268
1327
  {
@@ -1272,8 +1331,7 @@ export const TOOLS = [
1272
1331
  destructive: true,
1273
1332
  schema: { type: 'object', properties: { bucketId: str('Bucket id') }, required: ['bucketId'] },
1274
1333
  async call(api, args) {
1275
- const { status, data } = await api.del(`/api/v1/buckets/${args.bucketId}`);
1276
- return result(status, data);
1334
+ return deleteAndConverge(api, `/api/v1/buckets/${args.bucketId}`, args.bucketId, 'bucket');
1277
1335
  },
1278
1336
  },
1279
1337
  {