@biznetgio/neo-app 0.4.30 → 0.4.32

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.30",
3
+ "version": "0.4.32",
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": {
@@ -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
- log(`[dry-run] estimated cost (default micro compute, ${svcType}): ~${estimatedCost.monthly} ${estimatedCost.currency}/mo (${estimatedCost.hourly}/h) — ${estimatedCost.assumptions}. Use neo_estimate for a specific size.`);
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
@@ -65,13 +65,17 @@ async function confirmCost(api, args, spec) {
65
65
  };
66
66
  }
67
67
 
68
- const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey)/i;
68
+ const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey|authorization)/i;
69
+ // Deployment snapshots (exact keys) embed rendered configs with plaintext creds — a
70
+ // queue's `--pass`, a DB's POSTGRES_PASSWORD. Redact by EXACT key so we don't also
71
+ // hit resourceId/source-of-truth-style names (substring match would catch "reSOURCEid").
72
+ const SECRET_EXACT = new Set(['source', 'installed']);
69
73
  function redactSecrets(value) {
70
74
  if (Array.isArray(value)) return value.map(redactSecrets);
71
75
  if (value && typeof value === 'object') {
72
76
  const out = {};
73
77
  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);
78
+ 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
79
  }
76
80
  return out;
77
81
  }
@@ -120,8 +124,8 @@ export const TOOLS = [
120
124
  { type: 'template', create: true, deploy: true, scale: false, logs: true, variables: true, delete: true },
121
125
  { 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
126
  { 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, verify: true, publicAccess: true, delete: true, note: 'neo_create/list/get/update/verify(publish→persist)/delete_queue + enable/disable_queue_public_access (handoff 4.5)' },
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 probe) + enable/disable_db_public_access; restore still TODO (handoff 4.4)' },
128
+ { 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
129
  { 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
130
  { 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
131
  ];
@@ -132,7 +136,7 @@ export const TOOLS = [
132
136
  resourceTypes,
133
137
  // Verbs this MCP build exposes today (no create-DB/queue/cron/bucket, no costed plan/apply yet).
134
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'],
135
- bindings: { supported: true, targets: ['neo_db', 'neo_queue'], writeOnly: true, note: 'neo_bind_resource injects DATABASE_URL/NATS_URL server-side at deploy; the secret is never returned (handoff 5)' },
139
+ 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
140
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
137
141
  environments: ['staging', 'production'],
138
142
  pricing,
@@ -152,6 +156,25 @@ export const TOOLS = [
152
156
  return (await api.get(`/api/v1/orgs/${orgId}/projects`)).data;
153
157
  },
154
158
  },
159
+ {
160
+ name: 'neo_create_project',
161
+ 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).',
162
+ mutating: true,
163
+ schema: {
164
+ type: 'object',
165
+ properties: {
166
+ name: str('Project name'),
167
+ description: str('Project description (optional)'),
168
+ orgId: str('Organization id (optional; defaults to your primary org)'),
169
+ },
170
+ required: ['name'],
171
+ },
172
+ async call(api, args) {
173
+ const orgId = await api.resolveOrgId(args.orgId);
174
+ const { status, data } = await api.post(`/api/v1/orgs/${orgId}/projects`, { name: args.name, description: args.description || '' });
175
+ return result(status, data);
176
+ },
177
+ },
155
178
  {
156
179
  name: 'neo_get_project',
157
180
  description: 'Get one project by id.',
@@ -386,32 +409,45 @@ export const TOOLS = [
386
409
  async call(api, args) {
387
410
  const { data } = await api.post('/api/v1/repos/scan', { repoUrl: args.repoUrl, branch: args.branch, gitProviderId: args.gitProviderId });
388
411
  const subdirs = data?.subdirs || [];
389
- const components = subdirs.map((s) => ({
390
- path: s.path || '.',
391
- name: s.name,
392
- stack: s.type, // docker | node | go | python | java | php | ruby | rust | static
393
- suggestedServiceType: s.type === 'static' ? 'static_site' : 'web',
394
- suggestedBuildType: s.type === 'docker' ? 'dockerfile' : 'railpack',
395
- contextDir: s.path || undefined,
396
- detectedDependencies: s.deps || [], // database | queue | object_storage, inferred from the manifest
397
- // Confidence + evidence (handoff 6): an explicit Dockerfile/index.html is high
398
- // confidence; a language marker is inferred (medium). The stack marker + any
399
- // detectedDependencies ARE the evidence.
400
- typeConfidence: (s.type === 'docker' || s.type === 'static') ? 'high' : 'medium',
401
- evidence: { marker: s.type, manifestDeps: s.deps || [] },
402
- }));
412
+ const components = subdirs.map((s) => {
413
+ const role = s.role || 'service'; // service | worker | support
414
+ const support = role === 'support' || s.deployable === false;
415
+ return {
416
+ path: s.path || '.',
417
+ name: s.name,
418
+ stack: s.type, // docker | node | go | python | java | php | ruby | rust | static
419
+ role, // service | worker | support (library/config — not deployable)
420
+ deployable: !support,
421
+ // Don't suggest a service type for a support/library package; a worker-named
422
+ // root suggests worker; static → static_site; else web.
423
+ suggestedServiceType: support ? null : (role === 'worker' ? 'worker' : (s.type === 'static' ? 'static_site' : 'web')),
424
+ suggestedBuildType: s.type === 'docker' ? 'dockerfile' : 'railpack',
425
+ suggestedPort: s.port || undefined, // from the Dockerfile EXPOSE, when present
426
+ contextDir: s.path || undefined,
427
+ detectedDependencies: s.deps || [], // database | queue | object_storage, inferred from the manifest
428
+ // Confidence (handoff 6): a Dockerfile/index.html is high; a worker guess from
429
+ // the tree is low (confirm); a plain language marker is medium.
430
+ typeConfidence: support ? 'low' : (role === 'worker' ? 'low' : ((s.type === 'docker' || s.type === 'static') ? 'high' : 'medium')),
431
+ evidence: { marker: s.type, role, manifestDeps: s.deps || [] },
432
+ };
433
+ });
434
+ const deployable = components.filter((c) => c.deployable);
435
+ const support = components.filter((c) => !c.deployable);
403
436
  const anyDeps = components.some((c) => c.detectedDependencies.length);
404
437
  return {
405
438
  repoUrl: args.repoUrl,
406
439
  count: components.length,
440
+ deployableCount: deployable.length,
407
441
  components,
408
442
  note:
409
- (components.length > 1
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). Worker vs web cannot be inferred from the tree alone; confirm the type with the user.'
411
- : components.length === 1
443
+ (deployable.length > 1
444
+ ? '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).'
445
+ : deployable.length === 1
412
446
  ? 'One deployable component found.'
413
- : 'No deployable component found (no Dockerfile/package.json/go.mod/requirements.txt/index.html/… at any root).')
414
- + (anyDeps ? ' A component lists detectedDependencies (database/queue/object_storage) from its manifest — consider planning those resources and binding them (neo_bind_resource); confirm with the user first.' : ''),
447
+ : 'No deployable component found.')
448
+ + (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).` : '')
449
+ + (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.')
450
+ + (anyDeps ? ' Some components list detectedDependencies (database/queue/object_storage) — consider planning + binding them (confirm first).' : ''),
415
451
  };
416
452
  },
417
453
  },
@@ -535,6 +571,16 @@ export const TOOLS = [
535
571
  return redactSecrets(result(status, data));
536
572
  },
537
573
  },
574
+ {
575
+ name: 'neo_deploy_database',
576
+ 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.',
577
+ mutating: true,
578
+ schema: { type: 'object', properties: { databaseId: str('Database id (from neo_create_database)') }, required: ['databaseId'] },
579
+ async call(api, args) {
580
+ const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/deploy`);
581
+ return redactSecrets(result(status, data));
582
+ },
583
+ },
538
584
  {
539
585
  name: 'neo_list_databases',
540
586
  description: 'List managed databases in a project (credentials redacted).',
@@ -669,6 +715,16 @@ export const TOOLS = [
669
715
  return redactSecrets(result(status, data));
670
716
  },
671
717
  },
718
+ {
719
+ name: 'neo_deploy_queue',
720
+ 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.',
721
+ mutating: true,
722
+ schema: { type: 'object', properties: { queueId: str('Queue id (from neo_create_queue)') }, required: ['queueId'] },
723
+ async call(api, args) {
724
+ const { status, data } = await api.post(`/api/v1/queues/${args.queueId}/deploy`);
725
+ return redactSecrets(result(status, data));
726
+ },
727
+ },
672
728
  {
673
729
  name: 'neo_list_queues',
674
730
  description: 'List managed queues in a project (credentials redacted).',
@@ -762,12 +818,15 @@ export const TOOLS = [
762
818
  url: str('Target URL (mode=http_endpoint)'),
763
819
  method: str('HTTP method (mode=http_endpoint), e.g. GET/POST'),
764
820
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
821
+ 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.' },
765
822
  },
766
823
  required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
767
824
  },
768
825
  async call(api, args) {
769
826
  requireEnv(args.environment);
770
- const { projectId, ...body } = args;
827
+ const gate = await confirmCost(api, args, {}); // cron cost is variable (per-run) — gate surfaces the rate
828
+ if (gate) return gate;
829
+ const { projectId, confirm, orgId, ...body } = args;
771
830
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons`, body);
772
831
  return redactSecrets(result(status, data));
773
832
  },
@@ -821,11 +880,14 @@ export const TOOLS = [
821
880
  region: str('Region (optional; see neo_list_regions on the portal)'),
822
881
  acl: { type: 'string', description: 'private | public-read (optional, default private)' },
823
882
  quotaGB: int('Storage quota in GB (optional)'),
883
+ 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.' },
824
884
  },
825
885
  required: ['projectId', 'name'],
826
886
  },
827
887
  async call(api, args) {
828
- const { projectId, ...body } = args;
888
+ const gate = await confirmCost(api, args, { objectStorageGB: gbFromSize(args.quotaGB) });
889
+ if (gate) return gate;
890
+ const { projectId, confirm, orgId, ...body } = args;
829
891
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/buckets`, body);
830
892
  return redactSecrets(result(status, data));
831
893
  },
@@ -952,7 +1014,7 @@ export const TOOLS = [
952
1014
  {
953
1015
  name: 'neo_plan_stack',
954
1016
  description:
955
- '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.',
1017
+ '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.',
956
1018
  mutating: false,
957
1019
  schema: {
958
1020
  type: 'object',
@@ -961,16 +1023,16 @@ export const TOOLS = [
961
1023
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
962
1024
  resources: {
963
1025
  type: 'array',
964
- description: 'The stack resources. Each needs a unique name + kind (app | database | queue | bucket).',
1026
+ description: 'The stack resources. Each needs a unique name + kind (app | database | queue | bucket | cron).',
965
1027
  items: {
966
1028
  type: 'object',
967
1029
  properties: {
968
- kind: { type: 'string', enum: ['app', 'database', 'queue', 'bucket'] },
1030
+ kind: { type: 'string', enum: ['app', 'database', 'queue', 'bucket', 'cron'] },
969
1031
  name: str('Unique resource name within this stack (bindings reference it)'),
970
1032
  serviceType: str('app: web | worker | static_site'),
971
1033
  buildCommand: str('app/static_site: build command (e.g. "npm ci && npm run build")'),
972
1034
  publishDirectory: str('static_site: built-files directory to serve (e.g. "dist")'),
973
- image: str('app: prebuilt image reference'),
1035
+ image: str('app/cron: prebuilt image reference'),
974
1036
  repoUrl: str('app: git repository URL'),
975
1037
  branch: str('app: git branch'),
976
1038
  port: int('app: container port'),
@@ -980,20 +1042,29 @@ export const TOOLS = [
980
1042
  volumeSize: str('database: storage size, e.g. 10Gi'),
981
1043
  quotaGB: int('bucket: storage quota GB'),
982
1044
  region: str('bucket: region'),
1045
+ mode: str('cron: app_command | http_endpoint'),
1046
+ scheduleType: str('cron: cron | interval | once | seconds'),
1047
+ schedule: str('cron: cron expression (scheduleType=cron)'),
1048
+ intervalMinutes: int('cron: interval in minutes (scheduleType=interval)'),
1049
+ timezone: str('cron: IANA timezone (optional)'),
1050
+ serviceId: str('cron app_command: target service — another resource NAME in this stack, or a literal service id'),
1051
+ command: { type: 'array', items: { type: 'string' }, description: 'cron app_command: command argv' },
1052
+ url: str('cron http_endpoint: target URL'),
1053
+ method: str('cron http_endpoint: HTTP method'),
983
1054
  },
984
1055
  required: ['kind', 'name'],
985
1056
  },
986
1057
  },
987
1058
  bindings: {
988
1059
  type: 'array',
989
- description: 'Wire a database/queue into a consumer app (injected as an env var at deploy, server-side).',
1060
+ description: 'Wire a database/queue/bucket into a consumer app (injected as env var(s) at deploy, server-side).',
990
1061
  items: {
991
1062
  type: 'object',
992
1063
  properties: {
993
1064
  consumer: str('The consumer app resource name'),
994
- targetType: { type: 'string', enum: ['neo_db', 'neo_queue'] },
995
- target: str('The database/queue resource name'),
996
- envKey: str('Env var name (optional; default DATABASE_URL / NATS_URL)'),
1065
+ targetType: { type: 'string', enum: ['neo_db', 'neo_queue', 'neo_object_storage'] },
1066
+ target: str('The database/queue/bucket resource name'),
1067
+ envKey: str('Env var name/prefix (optional; default DATABASE_URL / NATS_URL / S3)'),
997
1068
  },
998
1069
  required: ['consumer', 'targetType', 'target'],
999
1070
  },
@@ -1179,9 +1250,9 @@ export const TOOLS = [
1179
1250
  type: 'object',
1180
1251
  properties: {
1181
1252
  databaseId: str('Database id (postgres, already deployed)'),
1182
- allowlistCidrs: { type: 'array', items: { type: 'string' }, description: 'CIDR allowlist, e.g. ["203.0.113.0/24"] (optional; empty = allow any source IP)' },
1253
+ 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).' },
1183
1254
  },
1184
- required: ['databaseId'],
1255
+ required: ['databaseId', 'allowlistCidrs'],
1185
1256
  },
1186
1257
  async call(api, args) {
1187
1258
  const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/public-access`, { allowlistCidrs: args.allowlistCidrs || [] });
@@ -1206,9 +1277,9 @@ export const TOOLS = [
1206
1277
  type: 'object',
1207
1278
  properties: {
1208
1279
  queueId: str('Queue id (already deployed)'),
1209
- allowlistCidrs: { type: 'array', items: { type: 'string' }, description: 'CIDR allowlist (optional; empty = allow any source IP)' },
1280
+ 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.' },
1210
1281
  },
1211
- required: ['queueId'],
1282
+ required: ['queueId', 'allowlistCidrs'],
1212
1283
  },
1213
1284
  async call(api, args) {
1214
1285
  const { status, data } = await api.post(`/api/v1/queues/${args.queueId}/public-access`, { allowlistCidrs: args.allowlistCidrs || [] });
@@ -1227,23 +1298,63 @@ export const TOOLS = [
1227
1298
  },
1228
1299
  {
1229
1300
  name: 'neo_delete_project',
1230
- description: 'Permanently delete a project and ALL its resources (apps, databases, queues, crons, buckets, and the project namespace with its PVCs/CNPG clusters). Destructive and irreversible — nothing is recoverable. Requires confirm:true. Only the project creator or an org owner/admin may delete it.',
1301
+ 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.',
1231
1302
  mutating: true,
1232
1303
  destructive: true,
1233
1304
  schema: {
1234
1305
  type: 'object',
1235
1306
  properties: {
1236
1307
  projectId: str('Project id'),
1237
- confirm: { type: 'boolean', description: 'Must be true to proceed — guards against accidental deletion of an entire project.' },
1308
+ confirm: { type: 'boolean', description: 'Must be true to delete. Without it you get a preview only (nothing is deleted).' },
1309
+ cascade: { type: 'boolean', description: 'Required true to delete a NON-EMPTY project — acknowledges every resource in the inventory is destroyed.' },
1238
1310
  },
1239
- required: ['projectId', 'confirm'],
1311
+ required: ['projectId'],
1240
1312
  },
1241
1313
  async call(api, args) {
1314
+ const pid = args.projectId;
1315
+ // Gather inventory + usage BEFORE any delete (this is also the metering snapshot).
1316
+ const safeGet = async (p) => { try { return (await api.get(p)).data; } catch { return null; } };
1317
+ const [proj, apps, dbs, queues, crons, buckets, usage] = await Promise.all([
1318
+ safeGet(`/api/v1/projects/${pid}`),
1319
+ safeGet(`/api/v1/projects/${pid}/apps`),
1320
+ safeGet(`/api/v1/projects/${pid}/databases`),
1321
+ safeGet(`/api/v1/projects/${pid}/queues`),
1322
+ safeGet(`/api/v1/projects/${pid}/crons`),
1323
+ safeGet(`/api/v1/projects/${pid}/buckets`),
1324
+ safeGet(`/api/v1/projects/${pid}/usage`),
1325
+ ]);
1326
+ const arr = (x, k) => (Array.isArray(x) ? x : (x && Array.isArray(x[k]) ? x[k] : []));
1327
+ const inv = {
1328
+ services: arr(apps, 'applications').map((a) => a.name || a.id), // list endpoint wraps as {applications:[…]}
1329
+ databases: arr(dbs, 'databases').map((d) => d.name || d.id),
1330
+ queues: arr(queues, 'queues').map((q) => q.name || q.id),
1331
+ crons: arr(crons, 'crons').map((c) => c.name || c.id),
1332
+ buckets: arr(buckets, 'buckets').map((b) => b.name || b.id),
1333
+ };
1334
+ const total = Object.values(inv).reduce((n, a) => n + a.length, 0);
1335
+ // Fail-safe: if any inventory fetch FAILED (null, not empty), we can't prove the
1336
+ // project is empty — treat it as non-empty so the empty-path can't silently
1337
+ // delete hidden resources (the preview must be trustworthy).
1338
+ const inventoryIncomplete = [apps, dbs, queues, crons, buckets].some((x) => x === null);
1339
+ const preview = {
1340
+ project: proj ? { id: proj.id, name: proj.name, organizationId: proj.organizationId } : { id: pid },
1341
+ resourceInventory: inv,
1342
+ resourceCount: total,
1343
+ meteringSnapshot: usage ? { period: usage.period, total: usage.total ?? usage.totalCost, currency: usage.currency } : null,
1344
+ inventoryIncomplete: inventoryIncomplete || undefined,
1345
+ warning: 'IRREVERSIBLE: deletes the project, every resource above, and their data (database/queue volumes included). No refund for prepaid or accrued usage.',
1346
+ };
1347
+ // Treat an unverifiable inventory as non-empty — never allow the no-cascade path
1348
+ // when we could not confirm the project is actually empty.
1349
+ const requireCascade = total > 0 || inventoryIncomplete;
1242
1350
  if (args.confirm !== true) {
1243
- return { error: 'refused', message: 'Deleting a project removes ALL its resources and is irreversible. Re-call with confirm:true to proceed.' };
1351
+ return { ...preview, deleted: false, requiresConfirmation: true, message: requireCascade ? '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.' };
1244
1352
  }
1245
- const { status, data } = await api.del(`/api/v1/projects/${args.projectId}`);
1246
- return result(status, data);
1353
+ if (requireCascade && args.cascade !== true) {
1354
+ 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
+ }
1356
+ const { status, data } = await api.del(`/api/v1/projects/${pid}`);
1357
+ return { deleted: true, project: preview.project, removed: inv, resourceCount: total, meteringSnapshot: preview.meteringSnapshot, result: result(status, data) };
1247
1358
  },
1248
1359
  },
1249
1360
  ];