@biznetgio/neo-app 0.4.63 → 0.4.65

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 +51 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.63",
3
+ "version": "0.4.65",
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
@@ -68,6 +68,19 @@ const allowCrossEnvProp = { allowCrossEnvironment: { type: 'boolean', descriptio
68
68
  // invariant in sequence (each must start from a clean, unpinned session).
69
69
  export function resetSessionEnv() { sessionEnv = null; }
70
70
 
71
+ // assertResourceEnv enforces the session pin on an EXISTING resource addressed by id (JNG #136 §4):
72
+ // if the session is pinned and the resource lives in a different environment, refuse so an
73
+ // ID-based op can't touch the wrong environment's resource. allowCrossEnvironment overrides it;
74
+ // resources with no environment (buckets are shared) and an unpinned session are not guarded.
75
+ async function assertResourceEnv(api, getPath, allowCross = false) {
76
+ if (!sessionEnv || allowCross) return;
77
+ let env;
78
+ try { env = (await api.get(getPath)).data?.environment; } catch { return; } // can't resolve → don't block
79
+ if (env && env !== sessionEnv) {
80
+ throw new Error(`environment_mismatch: this session is pinned to "${sessionEnv}" but that resource is in "${env}". Staging and production must not be mixed (JNG #136). Pass allowCrossEnvironment:true to act on it anyway, or re-pin with neo_set_context.`);
81
+ }
82
+ }
83
+
71
84
  // deleteAndConverge deletes a resource, then polls its endpoint until the inventory
72
85
  // actually converges (GET → 404), so a delete isn't reported done while the record
73
86
  // still exists. Returns a durable operation reference (`del-<id>`) + convergence
@@ -443,11 +456,13 @@ export const TOOLS = [
443
456
  healthTimeoutSeconds: int('Health-check timeout (s)'),
444
457
  healthGraceSeconds: int('Health-check grace period (s)'),
445
458
  confirm: { type: 'boolean', description: 'Approve a cost change (compute/replicas/autoscale/volume): without it the new cost is returned and nothing is applied.' },
459
+ ...allowCrossEnvProp,
446
460
  },
447
461
  required: ['appId'],
448
462
  },
449
463
  async call(api, args) {
450
- const { appId, confirm, orgId, computeTier, ...rest } = args;
464
+ const { appId, confirm, orgId, computeTier, allowCrossEnvironment, ...rest } = args;
465
+ await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
451
466
  const body = { ...rest };
452
467
  if (computeTier) { body.billingMode = 'bundled'; body.cpuLimit = computeTier; }
453
468
  else if (args.cpuLimit || args.memoryLimit) { body.billingMode = 'unbundled'; }
@@ -595,8 +610,9 @@ export const TOOLS = [
595
610
  description:
596
611
  'Trigger a deployment of an app (service). Returns a deployment id — pass it to neo_wait_deployment to block until it finishes (don\'t sleep/guess), or a pending_approval result when org policy gates it. Deploy to staging first.',
597
612
  mutating: true,
598
- schema: { type: 'object', properties: { appId: str('App/service id') }, required: ['appId'] },
613
+ schema: { type: 'object', properties: { appId: str('App/service id'), ...allowCrossEnvProp }, required: ['appId'] },
599
614
  async call(api, args) {
615
+ await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
600
616
  const { status, data } = await api.post(`/api/v1/apps/${args.appId}/deploy`);
601
617
  return result(status, data);
602
618
  },
@@ -679,8 +695,9 @@ export const TOOLS = [
679
695
  name: 'neo_restart_service',
680
696
  description: 'Rolling-restart a service with its CURRENT image + config — no rebuild (JNG #128). Use after a config-only change (neo_set_service_variable, neo_update_service) that doesn\'t need a new build, instead of a full neo_deploy. Returns immediately; poll neo_list_pods for the new pods.',
681
697
  mutating: true,
682
- schema: { type: 'object', properties: { appId: str('Service/app id (from neo_list_services)') }, required: ['appId'] },
698
+ schema: { type: 'object', properties: { appId: str('Service/app id (from neo_list_services)'), ...allowCrossEnvProp }, required: ['appId'] },
683
699
  async call(api, args) {
700
+ await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
684
701
  return (await api.post(`/api/v1/apps/${args.appId}/restart`, {})).data;
685
702
  },
686
703
  },
@@ -695,11 +712,13 @@ export const TOOLS = [
695
712
  command: str('Shell command to run (executed via /bin/sh -c)'),
696
713
  pod: str('Specific pod name (optional; from neo_list_pods) — else the first running pod'),
697
714
  timeoutSeconds: int('Max seconds to wait (default 30, max 120)'),
715
+ ...allowCrossEnvProp,
698
716
  },
699
717
  required: ['appId', 'command'],
700
718
  },
701
719
  async call(api, args) {
702
- const { appId, ...body } = args;
720
+ const { appId, allowCrossEnvironment, ...body } = args;
721
+ await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
703
722
  return (await api.post(`/api/v1/apps/${appId}/exec`, body)).data;
704
723
  },
705
724
  },
@@ -722,10 +741,12 @@ export const TOOLS = [
722
741
  databaseId: str('Database id (postgres, deployed)'),
723
742
  sql: str('SQL to run (piped into psql)'),
724
743
  readOnly: { type: 'boolean', description: 'true (default) runs in a read-only session; false allows writes' },
744
+ ...allowCrossEnvProp,
725
745
  },
726
746
  required: ['databaseId', 'sql'],
727
747
  },
728
748
  async call(api, args) {
749
+ await assertResourceEnv(api, `/api/v1/databases/${args.databaseId}`, args.allowCrossEnvironment); // JNG #136 §4
729
750
  return (await api.post(`/api/v1/databases/${args.databaseId}/query`, { sql: args.sql, readOnly: args.readOnly })).data;
730
751
  },
731
752
  },
@@ -772,10 +793,12 @@ export const TOOLS = [
772
793
  key: str('Variable name'),
773
794
  value: str('Variable value'),
774
795
  secret: { type: 'boolean', description: 'Store as a secret (default false)' },
796
+ ...allowCrossEnvProp,
775
797
  },
776
798
  required: ['appId', 'key', 'value'],
777
799
  },
778
800
  async call(api, args) {
801
+ await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
779
802
  const { status, data } = await api.post(`/api/v1/apps/${args.appId}/variables`, { key: args.key, value: args.value, secret: !!args.secret });
780
803
  return result(status, data); // {status:"set", key, redeployRequired} — no value returned
781
804
  },
@@ -1420,7 +1443,7 @@ export const TOOLS = [
1420
1443
  },
1421
1444
  {
1422
1445
  name: 'neo_list_cron_runs',
1423
- description: 'List the recent runs of a scheduled job (cron) with their status/timing — run history + output verification (handoff 4.6).',
1446
+ description: 'List the recent runs of a scheduled job (cron): status (running|succeeded|failed), startedAt/finishedAt, and for a FAILED run the terminal diagnostics — reason, message, and the container exitCode — so you can see WHY it failed, not just that it did (JNG #112, handoff 4.6).',
1424
1447
  mutating: false,
1425
1448
  schema: { type: 'object', properties: { cronId: str('Cron/scheduled-job id') }, required: ['cronId'] },
1426
1449
  async call(api, args) {
@@ -1541,11 +1564,29 @@ export const TOOLS = [
1541
1564
  targetType: { type: 'string', enum: ['neo_db', 'neo_queue', 'neo_object_storage'], description: 'neo_db | neo_queue | neo_object_storage — the resource to attach' },
1542
1565
  targetId: str('The database, queue, or bucket id to bind'),
1543
1566
  envKey: str('Env var name / prefix (optional; default DATABASE_URL, NATS_URL, or S3 prefix)'),
1567
+ ...allowCrossEnvProp,
1544
1568
  },
1545
1569
  required: ['appId', 'targetType', 'targetId'],
1546
1570
  },
1547
1571
  async call(api, args) {
1548
- const { appId, ...body } = args;
1572
+ const { appId, allowCrossEnvironment, ...body } = args;
1573
+ // Environment isolation (JNG #136 §4): the consumer must match the session pin, and a
1574
+ // consumer must not bind a target from a DIFFERENT environment. Buckets are shared (no env).
1575
+ if (!allowCrossEnvironment) {
1576
+ let cEnv;
1577
+ try { cEnv = (await api.get(`/api/v1/apps/${appId}`)).data?.environment; } catch { /* can't resolve → don't block */ }
1578
+ if (sessionEnv && cEnv && cEnv !== sessionEnv) {
1579
+ throw new Error(`environment_mismatch: this session is pinned to "${sessionEnv}" but that service is in "${cEnv}" (JNG #136). Pass allowCrossEnvironment:true to override, or re-pin with neo_set_context.`);
1580
+ }
1581
+ if (args.targetType !== 'neo_object_storage') {
1582
+ const tPath = args.targetType === 'neo_db' ? `/api/v1/databases/${args.targetId}` : `/api/v1/queues/${args.targetId}`;
1583
+ let tEnv;
1584
+ try { tEnv = (await api.get(tPath)).data?.environment; } catch { /* ignore */ }
1585
+ if (cEnv && tEnv && cEnv !== tEnv) {
1586
+ throw new Error(`environment_mismatch: cannot bind a "${tEnv}" ${args.targetType} to a "${cEnv}" service — binding across environments is refused (JNG #136). Pass allowCrossEnvironment:true to override.`);
1587
+ }
1588
+ }
1589
+ }
1549
1590
  const { status, data } = await api.post(`/api/v1/apps/${appId}/bindings`, body);
1550
1591
  return redactSecrets(result(status, data));
1551
1592
  },
@@ -1639,6 +1680,7 @@ export const TOOLS = [
1639
1680
  image: str('app/cron: prebuilt image reference'),
1640
1681
  repoUrl: str('app: git repository URL'),
1641
1682
  branch: str('app: git branch'),
1683
+ deps: { type: 'array', items: { type: 'string', enum: ['database', 'queue', 'object_storage'] }, description: 'app: managed-resource dependencies this service needs (from neo_scan_repo `deps`). The plan flags any dep not satisfied by a binding as incompleteTopology and apply refuses it unless acknowledged — so a service needing NATS_URL/DATABASE_URL isn\'t deployed into a 503 (JNG #112 §7).' },
1642
1684
  port: int('app: container port'),
1643
1685
  computeTier: str('app/db/queue: micro | small | medium | large | xlarge'),
1644
1686
  engine: str('database: postgres | mysql'),
@@ -1697,18 +1739,19 @@ export const TOOLS = [
1697
1739
  {
1698
1740
  name: 'neo_apply_stack',
1699
1741
  description:
1700
- 'Apply a planned stack. REQUIRES the planId and the exact planHash from neo_plan_stack — the SERVER rejects a missing, changed, expired, or already-applied plan, so a billable stack can never be created without first creating + reviewing its costed plan (handoff 3.4/3.5, server-enforced approval). Creates resources in dependency order (foundations → services → bindings → deploy) and returns an operationId to poll with neo_get_operation.',
1742
+ 'Apply a planned stack. REQUIRES the planId and the exact planHash from neo_plan_stack — the SERVER rejects a missing, changed, expired, or already-applied plan, so a billable stack can never be created without first creating + reviewing its costed plan (handoff 3.4/3.5, server-enforced approval). Creates resources in dependency order (foundations → services → bindings → deploy) and returns an operationId to poll with neo_get_operation. If the plan has an incompleteTopology (a service dependency not bound), apply returns error "incomplete_topology" — wire the missing resource + re-plan, or pass acknowledgeIncompleteTopology:true to deploy anyway.',
1701
1743
  mutating: true,
1702
1744
  schema: {
1703
1745
  type: 'object',
1704
1746
  properties: {
1705
1747
  planId: str('Plan id from neo_plan_stack'),
1706
1748
  planHash: str('Plan hash from neo_plan_stack — must match exactly (proves you reviewed this plan)'),
1749
+ acknowledgeIncompleteTopology: { type: 'boolean', description: 'Deploy past a detected unmet dependency (incompleteTopology) — e.g. a service that needs NATS_URL with no queue bound. Default false refuses it (JNG #112 §7).' },
1707
1750
  },
1708
1751
  required: ['planId', 'planHash'],
1709
1752
  },
1710
1753
  async call(api, args) {
1711
- const { status, data } = await api.post(`/api/v1/plans/${args.planId}/apply`, { planHash: args.planHash });
1754
+ const { status, data } = await api.post(`/api/v1/plans/${args.planId}/apply`, { planHash: args.planHash, acknowledgeIncompleteTopology: !!args.acknowledgeIncompleteTopology });
1712
1755
  return result(status, data);
1713
1756
  },
1714
1757
  },