@biznetgio/neo-app 0.4.11 → 0.4.13
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 +1 -0
- package/src/deploySource.js +50 -5
- package/src/index.js +2 -0
- package/src/mcp/tools.js +58 -2
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
package/src/deploySource.js
CHANGED
|
@@ -117,13 +117,29 @@ function zipEntryNames(buf) {
|
|
|
117
117
|
return names;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
export const SERVICE_TYPES = ['web', 'worker', 'static_site'];
|
|
121
|
+
|
|
122
|
+
// A folder with a root index.html and NO server manifest is a static site, not a
|
|
123
|
+
// web service. Used only to fill an UNSET serviceType; an explicit type always
|
|
124
|
+
// wins and the caller logs the detection, so this is never a silent conversion
|
|
125
|
+
// (handoff 4.3). Kept deliberately narrow — a repo with any server manifest
|
|
126
|
+
// (package.json/Dockerfile/go.mod/…) stays a web service.
|
|
127
|
+
const SERVER_MANIFESTS = [
|
|
128
|
+
'package.json', 'Dockerfile', 'go.mod', 'requirements.txt', 'Pipfile',
|
|
129
|
+
'pom.xml', 'build.gradle', 'composer.json', 'Gemfile', 'Cargo.toml',
|
|
130
|
+
];
|
|
131
|
+
function looksStatic(dir) {
|
|
132
|
+
if (!existsSync(join(dir, 'index.html'))) return false;
|
|
133
|
+
return !SERVER_MANIFESTS.some((m) => existsSync(join(dir, m)));
|
|
134
|
+
}
|
|
135
|
+
|
|
120
136
|
// deploySource ships a resolved source to a project. `src` is one of:
|
|
121
137
|
// { mode:'image', image } { mode:'git', repo, branch? }
|
|
122
138
|
// { mode:'dir', dir } { mode:'zip', zipFile }
|
|
123
139
|
// With `dryRun`, it performs NO create/update/upload/deploy — only the GETs needed to
|
|
124
140
|
// build a plan — and returns { dryRun:true, plan } (JNG #93).
|
|
125
141
|
// Returns { pending, approvalId } or { appId, url, result }. `log(msg)` is optional.
|
|
126
|
-
export async function deploySource(api, { projectId, src, name, port, build, environment, dryRun = false, timeout = 300, log = () => {} }) {
|
|
142
|
+
export async function deploySource(api, { projectId, src, name, port, build, serviceType, environment, dryRun = false, timeout = 300, log = () => {} }) {
|
|
127
143
|
const defaultName =
|
|
128
144
|
src.mode === 'image' ? src.image.split('/').pop().split(':')[0]
|
|
129
145
|
: src.mode === 'git' ? basename(src.repo, '.git')
|
|
@@ -132,6 +148,25 @@ export async function deploySource(api, { projectId, src, name, port, build, env
|
|
|
132
148
|
const svc = sanitizeName(name || defaultName);
|
|
133
149
|
const targetEnv = environment || 'production';
|
|
134
150
|
|
|
151
|
+
// Resolve the service type (handoff 4.2/4.3): honor an explicit type; else a
|
|
152
|
+
// plainly-static folder becomes static_site (logged); everything else is web.
|
|
153
|
+
// A worker never gets a public port/ingress — the control plane strips them,
|
|
154
|
+
// and we don't send them either.
|
|
155
|
+
const wantType = serviceType ? String(serviceType).toLowerCase() : '';
|
|
156
|
+
if (wantType && !SERVICE_TYPES.includes(wantType)) {
|
|
157
|
+
throw new Error(`invalid serviceType "${serviceType}" — use one of ${SERVICE_TYPES.join(', ')}`);
|
|
158
|
+
}
|
|
159
|
+
let svcType = wantType;
|
|
160
|
+
if (!svcType) {
|
|
161
|
+
if (src.mode === 'dir' && looksStatic(src.dir)) {
|
|
162
|
+
svcType = 'static_site';
|
|
163
|
+
log('detected a static site (root index.html, no server manifest) — deploying as static_site; pass serviceType to override');
|
|
164
|
+
} else {
|
|
165
|
+
svcType = 'web';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const isWorker = svcType === 'worker';
|
|
169
|
+
|
|
135
170
|
let buildType = 'railpack';
|
|
136
171
|
if (src.mode === 'image') buildType = 'image';
|
|
137
172
|
else if (build) buildType = build;
|
|
@@ -171,24 +206,34 @@ export async function deploySource(api, { projectId, src, name, port, build, env
|
|
|
171
206
|
|
|
172
207
|
const plan = [];
|
|
173
208
|
if (!app) {
|
|
174
|
-
plan.push(`create
|
|
209
|
+
plan.push(`create ${svcType} service "${svc}" (${buildType}) in ${targetEnv}`);
|
|
175
210
|
if (!dryRun) {
|
|
176
211
|
const res = await api.post(`/api/v1/projects/${projectId}/apps`, {
|
|
177
212
|
name: svc,
|
|
178
213
|
environment: targetEnv,
|
|
179
|
-
serviceType:
|
|
214
|
+
serviceType: svcType,
|
|
180
215
|
buildType,
|
|
181
216
|
image: src.image || undefined,
|
|
182
217
|
repoUrl: src.repo || undefined,
|
|
183
218
|
branch: src.branch || undefined,
|
|
184
|
-
|
|
185
|
-
|
|
219
|
+
// A worker has no public listener: don't request a port or TLS/ingress.
|
|
220
|
+
port: isWorker ? undefined : (port ? Number(port) : undefined),
|
|
221
|
+
tls: isWorker ? false : true,
|
|
186
222
|
});
|
|
187
223
|
if (pending(res)) return { pending: true, approvalId: res.data.approvalId };
|
|
188
224
|
app = res.data?.application || res.data;
|
|
189
225
|
log(`created service ${svc}`);
|
|
190
226
|
}
|
|
191
227
|
} else {
|
|
228
|
+
// Never silently convert an existing service's type (handoff 4.2/4.3): if the
|
|
229
|
+
// caller EXPLICITLY asked for a type that differs, refuse — redeploy keeps the
|
|
230
|
+
// existing type. (An auto-detected type never triggers this.)
|
|
231
|
+
if (wantType && app.serviceType && app.serviceType !== svcType) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`service "${svc}" is a ${app.serviceType}, not a ${svcType} — a redeploy keeps its type; ` +
|
|
234
|
+
`create a new service to deploy as ${svcType}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
192
237
|
// Existing service: apply requested source changes (image/repo/branch/build) BEFORE
|
|
193
238
|
// deploying, so a redeploy never silently ships the old source config (JNG #96).
|
|
194
239
|
const patch = {};
|
package/src/index.js
CHANGED
|
@@ -28,6 +28,7 @@ ${c.bold('Options')}
|
|
|
28
28
|
--image <ref> Deploy a prebuilt image (deploy)
|
|
29
29
|
--repo <git-url> Deploy from a Git repo, built server-side (deploy)
|
|
30
30
|
--name <name> Service name (deploy; default derived from the source)
|
|
31
|
+
--type <web|worker|static_site> Service type (deploy; default web, auto-detects static_site)
|
|
31
32
|
--port <n> Container port (deploy)
|
|
32
33
|
--agent <codex|claude-code> Target one agent (config install-mcp)
|
|
33
34
|
--url <url> Override the control-plane URL (also NEO_APP_URL)
|
|
@@ -49,6 +50,7 @@ const options = {
|
|
|
49
50
|
image: { type: 'string' },
|
|
50
51
|
repo: { type: 'string' },
|
|
51
52
|
name: { type: 'string' },
|
|
53
|
+
type: { type: 'string' },
|
|
52
54
|
port: { type: 'string' },
|
|
53
55
|
branch: { type: 'string' },
|
|
54
56
|
build: { type: 'string' },
|
package/src/mcp/tools.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { resolve } from 'node:path';
|
|
6
6
|
import { statSync } from 'node:fs';
|
|
7
7
|
import { deploySource } from '../deploySource.js';
|
|
8
|
+
import { pkgVersion } from '../util.js';
|
|
8
9
|
|
|
9
10
|
const str = (description) => ({ type: 'string', description });
|
|
10
11
|
const int = (description) => ({ type: 'integer', description });
|
|
@@ -32,6 +33,59 @@ export const TOOLS = [
|
|
|
32
33
|
return api.whoami();
|
|
33
34
|
},
|
|
34
35
|
},
|
|
36
|
+
{
|
|
37
|
+
name: 'neo_get_capabilities',
|
|
38
|
+
description:
|
|
39
|
+
'Discover this MCP server version, the control-plane it targets, and which resource types, lifecycle verbs, source types and environments are actually supported today. Call this FIRST to check compatibility before planning a deployment. The support matrix is honest about current gaps (handoff 3.2).',
|
|
40
|
+
mutating: false,
|
|
41
|
+
schema: { type: 'object', properties: {} },
|
|
42
|
+
async call(api) {
|
|
43
|
+
// Control-plane version (open /version endpoint). Best-effort: a null
|
|
44
|
+
// version just means that probe failed, not that the server is down.
|
|
45
|
+
const controlPlane = { baseUrl: api.baseUrl, version: null, gitCommit: null };
|
|
46
|
+
try {
|
|
47
|
+
const v = (await api.get('/version')).data || {};
|
|
48
|
+
controlPlane.version = v.version ?? null;
|
|
49
|
+
controlPlane.gitCommit = v.gitCommit ?? null;
|
|
50
|
+
} catch { /* leave nulls */ }
|
|
51
|
+
|
|
52
|
+
// Price book is readable by any member; surface whether cost data is
|
|
53
|
+
// available (cost consent / neo_plan_stack will consume it).
|
|
54
|
+
const pricing = { readable: false, version: null };
|
|
55
|
+
try {
|
|
56
|
+
const p = (await api.get('/api/v1/pricing')).data;
|
|
57
|
+
pricing.readable = true;
|
|
58
|
+
pricing.version = (p && !Array.isArray(p) && p.version) || null;
|
|
59
|
+
} catch { /* not readable */ }
|
|
60
|
+
|
|
61
|
+
// Honest support matrix: what THIS build does, not the roadmap. `create`
|
|
62
|
+
// is true, false, or 'via_folder' (created through neo_deploy_folder).
|
|
63
|
+
const resourceTypes = [
|
|
64
|
+
{ type: 'web', create: true, deploy: true, scale: true, logs: true, variables: true, delete: false },
|
|
65
|
+
{ type: 'template', create: true, deploy: true, scale: false, logs: true, variables: true, delete: false },
|
|
66
|
+
{ type: 'static_site', create: 'via_folder', deploy: true, scale: false, logs: true, variables: true, delete: false, note: 'neo_deploy_folder serviceType=static_site (auto-detected for static-only folders); auto-detection from a repo/monorepo is still TODO (handoff 6)' },
|
|
67
|
+
{ type: 'worker', create: 'via_folder', deploy: true, scale: true, logs: true, variables: true, delete: false, note: 'neo_deploy_folder serviceType=worker — no public ingress; repo auto-detection still TODO (handoff 6)' },
|
|
68
|
+
{ type: 'database', create: false, deploy: false, delete: false, note: 'REST exists; no MCP tool yet (handoff 4.4)' },
|
|
69
|
+
{ type: 'queue', create: false, deploy: false, delete: false, note: 'REST exists; no MCP tool yet (handoff 4.5)' },
|
|
70
|
+
{ type: 'cron', create: false, deploy: false, delete: false, note: 'REST exists; no MCP tool yet (handoff 4.6)' },
|
|
71
|
+
{ type: 'object_storage', create: false, deploy: false, delete: false, note: 'REST exists; no MCP tool yet (handoff 4.7)' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
mcp: { name: 'neo-app', version: pkgVersion() },
|
|
76
|
+
controlPlane,
|
|
77
|
+
resourceTypes,
|
|
78
|
+
// Verbs this MCP build exposes today (no create-DB/queue/cron/bucket, no delete, no costed plan/apply yet).
|
|
79
|
+
lifecycleVerbs: ['list', 'get', 'deploy', 'scale', 'logs', 'get_variables', 'set_variable', 'list_approvals', 'get_approval', 'get_credit', 'list_templates'],
|
|
80
|
+
sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
|
|
81
|
+
environments: ['staging', 'production'],
|
|
82
|
+
pricing,
|
|
83
|
+
costConsent: { planApplyEnforced: false, note: 'neo_plan_stack / Approve & Create not implemented yet (handoff 3.3-3.4)' },
|
|
84
|
+
minClientVersion: pkgVersion(),
|
|
85
|
+
deprecations: [],
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
},
|
|
35
89
|
{
|
|
36
90
|
name: 'neo_list_projects',
|
|
37
91
|
description: 'List projects in an organization. Omit orgId to use the first org.',
|
|
@@ -216,7 +270,7 @@ export const TOOLS = [
|
|
|
216
270
|
{
|
|
217
271
|
name: 'neo_deploy_folder',
|
|
218
272
|
description:
|
|
219
|
-
'Deploy a local folder or .zip
|
|
273
|
+
'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).',
|
|
220
274
|
mutating: true,
|
|
221
275
|
schema: {
|
|
222
276
|
type: 'object',
|
|
@@ -224,7 +278,8 @@ export const TOOLS = [
|
|
|
224
278
|
projectId: str('Project id'),
|
|
225
279
|
path: str('Local folder or .zip path'),
|
|
226
280
|
name: str('Service name (optional)'),
|
|
227
|
-
|
|
281
|
+
serviceType: { type: 'string', enum: ['web', 'worker', 'static_site'], description: 'web (default) | worker (no public URL) | static_site. Auto-detects static_site for a static-only folder; else web.' },
|
|
282
|
+
port: int('Container port (optional; ignored for worker)'),
|
|
228
283
|
environment: str('production or staging (optional, default production)'),
|
|
229
284
|
dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93)' },
|
|
230
285
|
},
|
|
@@ -245,6 +300,7 @@ export const TOOLS = [
|
|
|
245
300
|
projectId: args.projectId,
|
|
246
301
|
src,
|
|
247
302
|
name: args.name,
|
|
303
|
+
serviceType: args.serviceType,
|
|
248
304
|
port: args.port,
|
|
249
305
|
environment: args.environment || 'production',
|
|
250
306
|
dryRun: !!args.dryRun,
|