@biznetgio/neo-app 0.4.12 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
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": {
@@ -78,6 +78,7 @@ export async function deploy(source, opts = {}) {
78
78
  projectId,
79
79
  src,
80
80
  name: opts.name,
81
+ serviceType: opts.type,
81
82
  port: opts.port,
82
83
  build: opts.build,
83
84
  environment,
@@ -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 web service "${svc}" (${buildType}) in ${targetEnv}`);
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: 'web',
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
- port: port ? Number(port) : undefined,
185
- tls: true,
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
@@ -63,8 +63,8 @@ export const TOOLS = [
63
63
  const resourceTypes = [
64
64
  { type: 'web', create: true, deploy: true, scale: true, logs: true, variables: true, delete: false },
65
65
  { type: 'template', create: true, deploy: true, scale: false, logs: true, variables: true, delete: false },
66
- { type: 'static_site', create: false, deploy: true, scale: false, logs: true, variables: true, delete: false, note: 'currently planned/created as web (handoff 4.3)' },
67
- { type: 'worker', create: false, deploy: true, scale: true, logs: true, variables: true, delete: false, note: 'currently planned/created as web (handoff 4.2)' },
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
68
  { type: 'database', create: false, deploy: false, delete: false, note: 'REST exists; no MCP tool yet (handoff 4.4)' },
69
69
  { type: 'queue', create: false, deploy: false, delete: false, note: 'REST exists; no MCP tool yet (handoff 4.5)' },
70
70
  { type: 'cron', create: false, deploy: false, delete: false, note: 'REST exists; no MCP tool yet (handoff 4.6)' },
@@ -270,7 +270,7 @@ export const TOOLS = [
270
270
  {
271
271
  name: 'neo_deploy_folder',
272
272
  description:
273
- 'Deploy a local folder or .zip as a web service in a project (built server-side). The path is on the machine running this MCP server. Pick the environment with `environment` (JNG #72).',
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).',
274
274
  mutating: true,
275
275
  schema: {
276
276
  type: 'object',
@@ -278,7 +278,8 @@ export const TOOLS = [
278
278
  projectId: str('Project id'),
279
279
  path: str('Local folder or .zip path'),
280
280
  name: str('Service name (optional)'),
281
- port: int('Container port (optional)'),
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)'),
282
283
  environment: str('production or staging (optional, default production)'),
283
284
  dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93)' },
284
285
  },
@@ -299,6 +300,7 @@ export const TOOLS = [
299
300
  projectId: args.projectId,
300
301
  src,
301
302
  name: args.name,
303
+ serviceType: args.serviceType,
302
304
  port: args.port,
303
305
  environment: args.environment || 'production',
304
306
  dryRun: !!args.dryRun,