@biznetgio/neo-app 0.4.40 → 0.4.42

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.40",
3
+ "version": "0.4.42",
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/api.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // All routes are under /api/v1. A 202 + {status:"pending_approval"} is a normal
4
4
  // result (org approval policy), NOT an error, so only >=400 throws.
5
5
 
6
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
7
+
6
8
  export class ApiError extends Error {
7
9
  constructor(status, message, body) {
8
10
  super(message);
@@ -28,12 +30,28 @@ export class NeoApi {
28
30
  const headers = { Accept: accept || 'application/json' };
29
31
  if (this.token) headers.Authorization = `Bearer ${this.token}`;
30
32
  if (body !== undefined) headers['Content-Type'] = 'application/json';
33
+ const init = { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined };
31
34
 
32
- const res = await fetch(url, {
33
- method,
34
- headers,
35
- body: body !== undefined ? JSON.stringify(body) : undefined,
36
- });
35
+ // Transient-failure retry: a long stack apply polls for minutes, and a single
36
+ // dropped connection ("fetch failed") or a brief 502/503/504 shouldn't fail the
37
+ // whole operation. Only retry IDEMPOTENT methods automatically — a POST/PUT that
38
+ // may have been processed must not be silently re-sent.
39
+ const idempotent = method === 'GET' || method === 'DELETE';
40
+ const maxTries = idempotent ? 4 : 1;
41
+ let res, lastErr;
42
+ for (let attempt = 1; attempt <= maxTries; attempt++) {
43
+ try {
44
+ res = await fetch(url, init);
45
+ } catch (e) {
46
+ lastErr = e; // connection never completed — transient
47
+ if (idempotent && attempt < maxTries) { await sleep(250 * 2 ** (attempt - 1)); continue; }
48
+ throw new ApiError(0, `network error contacting ${this.baseUrl}: ${e?.message || e}`, null);
49
+ }
50
+ if (idempotent && [502, 503, 504].includes(res.status) && attempt < maxTries) {
51
+ await sleep(250 * 2 ** (attempt - 1)); continue;
52
+ }
53
+ break;
54
+ }
37
55
 
38
56
  const ctype = res.headers.get('content-type') || '';
39
57
  const isJson = ctype.includes('application/json');
@@ -8,7 +8,7 @@ import { getCredential } from '../creds.js';
8
8
  import { NeoApi } from '../api.js';
9
9
  import { readLink } from '../link.js';
10
10
  import { deploySource } from '../deploySource.js';
11
- import { log, ok, fail, warn, step, c } from '../util.js';
11
+ import { log, ok, fail, warn, step, c, confirm } from '../util.js';
12
12
 
13
13
  function isGitUrl(s) {
14
14
  if (!s) return false;
@@ -29,6 +29,13 @@ function isDir(s) {
29
29
  }
30
30
  }
31
31
 
32
+ function showCost(r) {
33
+ if (r?.estimatedCost) {
34
+ const e = r.estimatedCost;
35
+ log(c.bold(`Estimated cost: ~${e.monthly} ${e.currency}/mo (${e.hourly}/h)`) + (e.assumptions ? c.dim(` — ${e.assumptions}`) : ''));
36
+ }
37
+ }
38
+
32
39
  export async function deploy(source, opts = {}) {
33
40
  // `--source <path|.zip|git-url>` is an explicit alias for the positional source, as
34
41
  // documented in the deploy examples (JNG #75/#79); the positional still works.
@@ -74,7 +81,7 @@ export async function deploy(source, opts = {}) {
74
81
  const dryRun = !!opts['dry-run'];
75
82
  if (dryRun) log(c.dim('dry-run — no changes will be made'));
76
83
 
77
- const r = await deploySource(api, {
84
+ const base = {
78
85
  projectId,
79
86
  src,
80
87
  name: opts.name,
@@ -82,12 +89,33 @@ export async function deploy(source, opts = {}) {
82
89
  port: opts.port,
83
90
  build: opts.build,
84
91
  environment,
85
- dryRun,
86
92
  timeout: Number(opts.timeout) || 300,
87
93
  log: step,
88
- });
94
+ };
95
+
96
+ // Cost gate: a real deploy is billable, so it needs explicit approval. `--yes` approves
97
+ // up front (headless/CI); otherwise preview the cost first, then confirm (interactive)
98
+ // or require `--yes` (non-interactive). A spec-bound confirmToken links the approval to
99
+ // the exact cost shown, so nothing deploys if the spec drifts.
100
+ let confirmToken;
101
+ if (!dryRun && !opts.yes) {
102
+ const prev = await deploySource(api, { ...base, dryRun: true });
103
+ showCost(prev);
104
+ if (!process.stdin.isTTY) {
105
+ fail('billable deploy — re-run with --yes to approve (non-interactive)');
106
+ process.exit(1);
107
+ }
108
+ if (!(await confirm('Proceed with this billable deploy?'))) {
109
+ ok('cancelled — nothing deployed');
110
+ return prev;
111
+ }
112
+ confirmToken = prev.confirmToken;
113
+ }
114
+
115
+ const r = await deploySource(api, { ...base, dryRun, confirm: !!opts.yes, confirmToken });
89
116
 
90
117
  if (r.dryRun) {
118
+ showCost(r);
91
119
  ok(`dry-run complete — ${r.plan.length} step(s) planned, nothing written`);
92
120
  return r;
93
121
  }
@@ -155,7 +155,7 @@ function looksStatic(dir) {
155
155
  // With `dryRun`, it performs NO create/update/upload/deploy — only the GETs needed to
156
156
  // build a plan — and returns { dryRun:true, plan } (JNG #93).
157
157
  // Returns { pending, approvalId } or { appId, url, result }. `log(msg)` is optional.
158
- export async function deploySource(api, { projectId, src, name, port, build, serviceType, buildCommand, publishDirectory, environment, confirmToken, dryRun = false, timeout = 300, log = () => {} }) {
158
+ export async function deploySource(api, { projectId, src, name, port, build, serviceType, buildCommand, publishDirectory, environment, confirmToken, confirm = false, dryRun = false, timeout = 300, log = () => {} }) {
159
159
  const defaultName =
160
160
  src.mode === 'image' ? src.image.split('/').pop().split(':')[0]
161
161
  : src.mode === 'git' ? basename(src.repo, '.git')
@@ -220,10 +220,16 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
220
220
  }
221
221
  }
222
222
 
223
+ // Cost gate (0.4.40 retest A — a bare deploy must not create a billable service with
224
+ // no cost consent): a real deploy needs explicit approval BEFORE any mutation —
225
+ // either confirm:true or a matching confirmToken from a prior dry-run. Unapproved →
226
+ // run read-only (like a dry-run) and return requiresConfirmation.
227
+ const previewOnly = dryRun || !(confirmToken || confirm === true);
228
+
223
229
  const plan = [];
224
230
  if (!app) {
225
231
  plan.push(`create ${svcType} service "${svc}" (${buildType}) in ${targetEnv}`);
226
- if (!dryRun) {
232
+ if (!previewOnly) {
227
233
  const res = await api.post(`/api/v1/projects/${projectId}/apps`, {
228
234
  name: svc,
229
235
  environment: targetEnv,
@@ -264,7 +270,7 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
264
270
  if (buildType && app.buildType && app.buildType !== buildType) patch.buildType = buildType;
265
271
  if (Object.keys(patch).length) {
266
272
  plan.push(`update "${svc}": ${Object.keys(patch).join(', ')}`);
267
- if (!dryRun) {
273
+ if (!previewOnly) {
268
274
  const upd = await api.put(`/api/v1/apps/${app.id}`, patch);
269
275
  // If the source-config update needs approval, STOP — deploying now would ship
270
276
  // the OLD config while the change is still pending (JNG #96 re-validation).
@@ -279,28 +285,37 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
279
285
  if (src.mode === 'dir' || src.mode === 'zip') plan.push(`upload build context (${src.mode})`);
280
286
  plan.push(`deploy "${svc}"`);
281
287
 
282
- if (dryRun) {
283
- for (const p of plan) log(`[dry-run] would ${p}`);
284
- // Cost confirmation before a billable deploy (handoff 3.3/3.4). A new service
285
- // starts on the default compute tier; estimate from the live price book so the
286
- // user sees the cost in the dry-run. Best-effort — never fail the dry-run on it.
288
+ const token = deployToken({ svc, targetEnv, svcType, port, buildCommand, publishDirectory, src });
289
+ // Build the cost preview (+ spec-bound confirmToken) once — used for the dry-run AND
290
+ // for the mandatory pre-deploy gate below.
291
+ const buildPreview = async () => {
287
292
  let estimatedCost;
288
293
  try {
289
294
  const book = (await api.get('/api/v1/pricing')).data;
290
295
  estimatedCost = estimateCost(book, { computeTier: 'micro' });
296
+ } catch { /* price book unavailable — skip the estimate */ }
297
+ return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, confirmToken: token };
298
+ };
299
+
300
+ // Preview path (explicit dry-run OR an unapproved real deploy). No mutation ran above,
301
+ // so this returns the cost + spec-bound confirmToken and deploys NOTHING. An explicit
302
+ // dry-run returns the plain preview; an unapproved real deploy adds requiresConfirmation
303
+ // (MANDATORY gate 0.4.40 retest A — a bare deploy previously created a billable service
304
+ // with no cost consent).
305
+ if (previewOnly) {
306
+ for (const p of plan) log(`[dry-run] would ${p}`);
307
+ const prev = await buildPreview();
308
+ if (prev.estimatedCost) {
291
309
  const build = svcType === 'static_site' ? 'native nginx static hosting (not Railpack)' : 'built server-side';
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.`);
293
- } catch { /* price book unavailable — skip the estimate, not the dry-run */ }
294
- return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost, confirmToken: deployToken({ svc, targetEnv, svcType, port, buildCommand, publishDirectory, src }) };
310
+ log(`[dry-run] estimated cost (${svcType}, ${build}, default micro compute): ~${prev.estimatedCost.monthly} ${prev.estimatedCost.currency}/mo (${prev.estimatedCost.hourly}/h) — ${prev.estimatedCost.assumptions}. Use neo_estimate for a specific size.`);
311
+ }
312
+ if (dryRun) return prev;
313
+ return { ...prev, requiresConfirmation: true, note: 'Billable deploy — show the user the cost, then re-call with this confirmToken (or confirm:true) to deploy. Nothing was deployed.' };
295
314
  }
296
315
 
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
- }
316
+ // Approved deploy. A supplied confirmToken must still match the resolved spec (cost unchanged).
317
+ if (confirmToken && token !== confirmToken) {
318
+ throw new Error(`deploy spec changed since the dry-run (confirmToken ${confirmToken} → ${token}) — re-run with dryRun:true, show the new cost, and confirm again`);
304
319
  }
305
320
 
306
321
  if (src.mode === 'dir' || src.mode === 'zip') {
package/src/index.js CHANGED
@@ -33,7 +33,7 @@ ${c.bold('Options')}
33
33
  --agent <codex|claude-code> Target one agent (config install-mcp)
34
34
  --url <url> Override the control-plane URL (also NEO_APP_URL)
35
35
  --token <kap_...> API key (also NEO_APP_TOKEN; prompted if omitted)
36
- --yes Non-interactive (headless/CI)
36
+ --yes Non-interactive (headless/CI); also approves a billable deploy without the cost prompt
37
37
  --dry-run Print intended changes without writing
38
38
  -v, --version Print version
39
39
  -h, --help Show this help`;
package/src/mcp/tools.js CHANGED
@@ -614,7 +614,7 @@ export const TOOLS = [
614
614
  {
615
615
  name: 'neo_deploy_folder',
616
616
  description:
617
- 'Deploy a local folder or .zip in a project (built server-side). The path is on the machine running this MCP server. Pick the environment with `environment` (JNG #72). Pick the service type with `serviceType`: web (default), worker (background job, no public URL), or static_site; a plainly-static folder auto-detects as static_site. A worker is never given a public port/ingress; its type is never silently changed on redeploy (handoff 4.2/4.3).',
617
+ 'Deploy a local folder or .zip in a project (built server-side). The path is on the machine running this MCP server. COST GATE (mandatory): a billable deploy requires a confirmToken — call once to get the cost + confirmToken (returns status:requires_confirmation, deploys nothing), show the user, then re-call with that confirmToken to deploy. Pick the environment with `environment` (JNG #72). Pick the service type with `serviceType`: web (default), worker (background job, no public URL), or static_site; a plainly-static folder auto-detects as static_site. A worker is never given a public port/ingress; its type is never silently changed on redeploy (handoff 4.2/4.3).',
618
618
  mutating: true,
619
619
  schema: {
620
620
  type: 'object',
@@ -629,6 +629,7 @@ export const TOOLS = [
629
629
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production. Never defaulted (handoff §2). Deploy to staging first.' },
630
630
  dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93). Returns a confirmToken.' },
631
631
  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).'),
632
+ confirm: { type: 'boolean', description: 'Approve the billable deploy after showing the user the cost (alternative to confirmToken). Without confirmToken or confirm:true the tool returns the cost and deploys nothing.' },
632
633
  },
633
634
  required: ['projectId', 'path', 'environment'],
634
635
  },
@@ -654,9 +655,10 @@ export const TOOLS = [
654
655
  port: args.port,
655
656
  environment,
656
657
  confirmToken: args.confirmToken,
658
+ confirm: args.confirm === true,
657
659
  dryRun: !!args.dryRun,
658
660
  });
659
- 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.' };
661
+ if (r.dryRun) return { status: r.requiresConfirmation ? 'requires_confirmation' : 'dry_run', requiresConfirmation: r.requiresConfirmation || undefined, plan: r.plan, service: r.service, environment: r.environment, estimatedCost: r.estimatedCost, confirmToken: r.confirmToken, note: r.note || 'To deploy exactly this, re-call without dryRun and pass this confirmToken.' };
660
662
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
661
663
  return { status: r.result, url: r.url || undefined, appId: r.appId };
662
664
  },
@@ -1242,6 +1244,13 @@ export const TOOLS = [
1242
1244
  targetService: str('cron http_endpoint: name of a web app resource IN THIS STACK to hit — its deployed URL is resolved at apply (use instead of a literal url for a same-stack service)'),
1243
1245
  targetPath: str('cron http_endpoint: path appended to the targetService URL (e.g. "/cron")'),
1244
1246
  method: str('cron http_endpoint: HTTP method'),
1247
+ headers: { type: 'object', description: 'cron http_endpoint: request headers (secret values redacted on read-back)' },
1248
+ body: str('cron http_endpoint: request body'),
1249
+ contentType: str('cron http_endpoint: Content-Type for the body (merged into headers)'),
1250
+ expectedStatus: int('cron http_endpoint: exact HTTP status required for a run to succeed (default any 2xx)'),
1251
+ timeoutSeconds: int('cron: per-run timeout (seconds)'),
1252
+ retryCount: int('cron: retries on failure'),
1253
+ concurrencyPolicy: { type: 'string', enum: ['Allow', 'Forbid', 'Replace'], description: 'cron: overlap policy (default Allow)' },
1245
1254
  },
1246
1255
  required: ['kind', 'name'],
1247
1256
  },