@biznetgio/neo-app 0.4.41 → 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 +1 -1
- package/src/commands/deploy.js +32 -4
- package/src/deploySource.js +33 -18
- package/src/index.js +1 -1
- package/src/mcp/tools.js +4 -2
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/deploySource.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
}
|
|
294
|
-
|
|
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
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
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
|
},
|