@biznetgio/neo-app 0.4.2 → 0.4.7

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.2",
3
+ "version": "0.4.7",
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/prompt.md CHANGED
@@ -6,7 +6,7 @@ Keep this in sync with pkg/api/prompt.md (the copy the Go server embeds).
6
6
  -->
7
7
  # Set this repo up on NEO App
8
8
 
9
- **Version:** 0.4.0 · **Last updated:** 2026-10-12
9
+ **Version:** 0.4.3 · **Last updated:** 2026-10-13
10
10
 
11
11
  These are the official setup instructions from NEO App (Biznet Gio's managed-app
12
12
  platform). If you are an AI coding agent, complete the steps below yourself by running
@@ -23,6 +23,32 @@ several containers running side by side at runtime (a single app container along
23
23
  managed databases is fine). If the repo has no server to put behind a URL (a library or
24
24
  a CLI tool), say so and stop.
25
25
 
26
+ ## Before you start: terminal and Node.js
27
+
28
+ Before running setup, verify that Node.js 18+ and `npx` are available.
29
+
30
+ - If `npx` is available, run the setup command normally.
31
+ - If the coding environment provides a bundled Node.js runtime, use its bundled package
32
+ runner instead of installing another copy of Node.js.
33
+ - Otherwise, ask the human before installing Node.js system-wide.
34
+
35
+ Authentication must run in a visible, interactive terminal. If the agent's normal command
36
+ runner cannot display or accept the API-key prompt, open a dedicated terminal window and
37
+ run the setup command there. On Windows, this may be a separate PowerShell window.
38
+
39
+ Clearly tell the human:
40
+
41
+ 1. Revoke any token accidentally pasted into chat or a normal shell prompt.
42
+ 2. Create a fresh token in *Settings → API tokens*.
43
+ 3. Paste it only when the terminal displays:
44
+ ```
45
+ Paste your NEO App API key (kap_...):
46
+ ```
47
+ 4. Never paste the token into chat.
48
+
49
+ Do not run an API token as a PowerShell command, pass it as a command-line argument, or
50
+ write it to a repository file.
51
+
26
52
  ## 1. Install the CLI, skill, and MCP server
27
53
 
28
54
  One command, every OS and shell (Node 18+). Installs the NEO App skill for your coding
package/src/api.js CHANGED
@@ -46,6 +46,18 @@ export class NeoApi {
46
46
  return { status: res.status, data: payload };
47
47
  }
48
48
 
49
+ // postRaw streams raw bytes (e.g. a gzip-tar build context) as the request body.
50
+ async postRaw(path, body, contentType = 'application/octet-stream') {
51
+ const headers = { 'Content-Type': contentType };
52
+ if (this.token) headers.Authorization = `Bearer ${this.token}`;
53
+ const res = await fetch(this.baseUrl + path, { method: 'POST', headers, body });
54
+ const payload = await res.json().catch(() => null);
55
+ if (res.status >= 400) {
56
+ throw new ApiError(res.status, (payload && payload.error) || res.statusText, payload);
57
+ }
58
+ return { status: res.status, data: payload };
59
+ }
60
+
49
61
  get(path, opts) {
50
62
  return this.request('GET', path, opts);
51
63
  }
@@ -1,29 +1,41 @@
1
- // `neo-app deploy` — create-or-reuse a web service in the linked project and ship it,
2
- // then poll until the deployment reaches a terminal state (parity with `insta deploy`).
3
- // Sources: --image <ref> (prebuilt, no build) or --repo <git-url> (server builds it).
1
+ // `neo-app deploy [source]` — deploy a directory (default: cwd), a .zip, a Git repo, or
2
+ // a prebuilt --image. Thin CLI wrapper around ~/deploySource (shared with the MCP tool).
4
3
 
5
- import { basename } from 'node:path';
4
+ import { resolve } from 'node:path';
5
+ import { statSync } from 'node:fs';
6
6
  import { resolveConfig } from '../config.js';
7
7
  import { getCredential } from '../creds.js';
8
8
  import { NeoApi } from '../api.js';
9
9
  import { readLink } from '../link.js';
10
- import { log, ok, fail, step, c } from '../util.js';
10
+ import { deploySource } from '../deploySource.js';
11
+ import { log, ok, fail, warn, step, c } from '../util.js';
11
12
 
12
- function sanitizeName(s) {
13
- return (
14
- String(s)
15
- .toLowerCase()
16
- .replace(/[^a-z0-9-]+/g, '-')
17
- .replace(/^-+|-+$/g, '')
18
- .slice(0, 60) || 'app'
19
- );
13
+ function isGitUrl(s) {
14
+ if (!s) return false;
15
+ return /^(https?:\/\/|git@|ssh:\/\/)/.test(s) && (/\.git(#|$)/.test(s) || /(github\.com|gitlab\.|bitbucket\.)/.test(s));
20
16
  }
21
-
22
- function pending(res) {
23
- return res.status === 202 && res.data && res.data.status === 'pending_approval';
17
+ function isZip(s) {
18
+ try {
19
+ return !!s && /\.zip$/i.test(s) && statSync(s).isFile();
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+ function isDir(s) {
25
+ try {
26
+ return !!s && statSync(s).isDirectory();
27
+ } catch {
28
+ return false;
29
+ }
24
30
  }
25
31
 
26
- export async function deploy(opts = {}) {
32
+ export async function deploy(source, opts = {}) {
33
+ // Forgive the common mix-up: --url is the CONTROL-PLANE override, not the Git repo.
34
+ if (!source && !opts.repo && !opts.image && isGitUrl(opts.url)) {
35
+ warn('`--url` is the control-plane URL, not the Git repo — using it as the repo. Next time: `neo-app deploy <git-url>` or `--repo <url>`.');
36
+ opts = { ...opts, repo: opts.url, url: undefined };
37
+ }
38
+
27
39
  const cfg = resolveConfig(opts);
28
40
  const cred = getCredential(cfg.baseUrl);
29
41
  if (!cred?.token) {
@@ -38,81 +50,52 @@ export async function deploy(opts = {}) {
38
50
  process.exit(1);
39
51
  }
40
52
 
41
- const image = opts.image;
42
- const repo = opts.repo;
43
- if (!image && !repo) {
44
- fail('specify a source: --image <ref> or --repo <git-url>');
53
+ // Resolve the source: --image > --repo > positional (git url | .zip | directory) > cwd.
54
+ let src;
55
+ if (opts.image) src = { mode: 'image', image: opts.image };
56
+ else if (opts.repo) src = { mode: 'git', repo: opts.repo, branch: opts.branch };
57
+ else if (source && isGitUrl(source)) src = { mode: 'git', repo: source, branch: opts.branch };
58
+ else if (source && isZip(source)) src = { mode: 'zip', zipFile: resolve(source) };
59
+ else if (source && isDir(source)) src = { mode: 'dir', dir: resolve(source) };
60
+ else if (source) {
61
+ fail(`'${source}' is not a Git URL, a .zip file, or an existing directory (use --image for a prebuilt image)`);
45
62
  process.exit(2);
63
+ } else {
64
+ src = { mode: 'dir', dir: process.cwd() };
65
+ step(`no source given — deploying the current directory (${src.dir})`);
46
66
  }
47
67
 
48
- const name = sanitizeName(
49
- opts.name || (image ? image.split('/').pop().split(':')[0] : basename(repo, '.git'))
50
- );
51
68
  const environment = cfg.env === 'staging' ? 'staging' : 'production';
69
+ log(`${c.bold('Deploying')} ${c.dim(`[${src.mode}] → ${cfg.env} · project ${projectId}`)}`);
52
70
 
53
- log(`${c.bold('Deploying')} ${name} ${c.dim(`→ ${cfg.env} · project ${projectId}`)}`);
71
+ const dryRun = !!opts['dry-run'];
72
+ if (dryRun) log(c.dim('dry-run — no changes will be made'));
54
73
 
55
- // Create-or-reuse the service by name.
56
- const existing = (await api.get(`/api/v1/projects/${projectId}/apps`)).data?.applications || [];
57
- let app = existing.find((a) => a.name === name);
74
+ const r = await deploySource(api, {
75
+ projectId,
76
+ src,
77
+ name: opts.name,
78
+ port: opts.port,
79
+ build: opts.build,
80
+ environment,
81
+ dryRun,
82
+ timeout: Number(opts.timeout) || 300,
83
+ log: step,
84
+ });
58
85
 
59
- if (!app) {
60
- const res = await api.post(`/api/v1/projects/${projectId}/apps`, {
61
- name,
62
- environment,
63
- serviceType: 'web',
64
- buildType: image ? 'image' : opts.build || 'railpack',
65
- image: image || undefined,
66
- repoUrl: repo || undefined,
67
- branch: opts.branch || undefined,
68
- port: opts.port ? Number(opts.port) : undefined,
69
- tls: true,
70
- });
71
- if (pending(res)) {
72
- ok(`create submitted — pending approval (${res.data.approvalId}). An owner/admin must approve, then re-run.`);
73
- return;
74
- }
75
- app = res.data?.application || res.data;
76
- ok(`created service ${name}`);
77
- } else {
78
- step(`service ${name} exists — redeploying`);
79
- if (image && app.image !== image) await api.put(`/api/v1/apps/${app.id}`, { image });
86
+ if (r.dryRun) {
87
+ ok(`dry-run complete — ${r.plan.length} step(s) planned, nothing written`);
88
+ return r;
80
89
  }
81
-
82
- // Ship it.
83
- const dep = await api.post(`/api/v1/apps/${app.id}/deploy`);
84
- if (pending(dep)) {
85
- ok(`deploy submitted — pending approval (${dep.data.approvalId}).`);
90
+ if (r.pending) {
91
+ ok(`submitted — pending approval (${r.approvalId}). An owner/admin must approve.`);
86
92
  return;
87
93
  }
88
- const deployId = dep.data?.id;
89
- step(`deployment ${deployId} started`);
90
-
91
- const result = await pollDeployment(api, deployId, Number(opts.timeout) || 300);
92
-
93
- const view = (await api.get(`/api/v1/apps/${app.id}`)).data?.application || {};
94
- const url = view.domain ? `https://${view.domain}` : '';
95
- if (result === 'success') {
96
- ok(`deployed ${name}${url ? ' → ' + c.bold(url) : ''}`);
94
+ if (r.result === 'success') {
95
+ ok(`deployed${r.url ? ' → ' + c.bold(r.url) : ''}`);
97
96
  } else {
98
- fail(`deployment ${result}${deployId ? ` (neo-app logs via 'neo_get_logs' or the portal)` : ''}`);
97
+ fail(`deployment ${r.result} — check logs in the portal or via the neo_get_logs MCP tool`);
99
98
  process.exitCode = 1;
100
99
  }
101
- return { appId: app.id, url, result };
102
- }
103
-
104
- async function pollDeployment(api, id, timeoutSec) {
105
- if (!id) return 'unknown';
106
- const deadline = Date.now() + timeoutSec * 1000;
107
- let last = '';
108
- while (Date.now() < deadline) {
109
- const d = (await api.get(`/api/v1/deployments/${id}`)).data || {};
110
- if (d.status && d.status !== last) {
111
- step(` ${d.status}`);
112
- last = d.status;
113
- }
114
- if (d.status === 'success' || d.status === 'failed') return d.status;
115
- await new Promise((r) => setTimeout(r, 4000));
116
- }
117
- return 'timeout';
100
+ return r;
118
101
  }
@@ -74,8 +74,9 @@ export async function doctor(opts = {}) {
74
74
  const info = storeInfo();
75
75
  if (cred?.source === 'env') ok('credentials from NEO_APP_TOKEN (env)');
76
76
  else if (!info.exists) warn('no credential file');
77
- else if (info.secure) ok(`credentials file user-only (${info.path})`);
78
- else bad(`credentials file is not 0600 (${info.mode}) — run 'chmod 600 ${info.path}'`);
77
+ else if (info.secure === true) ok(`credentials file access-restricted (${info.path})`);
78
+ else if (info.secure === false) bad(`credentials file is world-accessible${info.mode ? ` (mode ${info.mode})` : ''} — run '${info.remediation}'`);
79
+ else warn(`could not verify credentials file permissions (${info.path})`);
79
80
 
80
81
  // Required executables
81
82
  onPath('npx') ? ok('npx available') : warn("npx not found — needed for the npx install path");
package/src/creds.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { homedir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync, rmSync, statSync } from 'node:fs';
14
+ import { execFileSync } from 'node:child_process';
14
15
 
15
16
  function storeDir() {
16
17
  const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
@@ -69,17 +70,31 @@ export function deleteCredential(baseUrl) {
69
70
  }
70
71
 
71
72
  // storeInfo reports where creds live and whether the file is user-only (for doctor).
73
+ // Security is judged per-platform (JNG #98): POSIX mode bits on Linux/macOS, the NTFS
74
+ // ACL on Windows — where stat.mode is meaningless (it reports 0o666 even for a locked
75
+ // file), so `chmod 600` is never used as the proof. `remediation` is the platform's
76
+ // own fix command. On any error `secure` stays null (unknown) rather than falsely
77
+ // flagged. No credential value is read or returned.
72
78
  export function storeInfo() {
73
79
  const path = storePath();
74
- const info = { path, exists: existsSync(path), secure: null };
75
- if (info.exists) {
76
- try {
80
+ const info = { path, platform: process.platform, exists: existsSync(path), secure: null };
81
+ if (!info.exists) return info;
82
+ try {
83
+ if (process.platform === 'win32') {
84
+ // Secure when the ACL grants no access to broad principals (Everyone / Users /
85
+ // Authenticated Users). icacls prints one grant per line as `principal:(perms)`.
86
+ const acl = execFileSync('icacls', [path], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
87
+ const broad = /(^|\\|\s)(Everyone|Users|Authenticated Users|Tout le monde)\s*:/im.test(acl);
88
+ info.secure = !broad;
89
+ info.remediation = `icacls "${path}" /inheritance:r /grant:r "%USERNAME%":F`;
90
+ } else {
77
91
  const mode = statSync(path).mode & 0o777;
78
92
  info.secure = mode === 0o600;
79
93
  info.mode = mode.toString(8);
80
- } catch {
81
- /* ignore */
94
+ info.remediation = `chmod 600 "${path}"`;
82
95
  }
96
+ } catch {
97
+ /* secure stays null (couldn't determine) */
83
98
  }
84
99
  return info;
85
100
  }
@@ -0,0 +1,275 @@
1
+ // Shared deploy core used by the `deploy` CLI command and the neo_deploy_folder MCP
2
+ // tool: create-or-reuse a web service, upload a local build context (dir → gzip-tar,
3
+ // .zip → as-is) when relevant, trigger the deploy, and poll to a terminal state.
4
+
5
+ import { basename, join } from 'node:path';
6
+ import { existsSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { tmpdir } from 'node:os';
9
+
10
+ export function sanitizeName(s) {
11
+ return (
12
+ String(s).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'app'
13
+ );
14
+ }
15
+
16
+ function pending(res) {
17
+ return res.status === 202 && res.data && res.data.status === 'pending_approval';
18
+ }
19
+
20
+ // --- source safety (JNG #95) --------------------------------------------------
21
+ // Files that must never be uploaded in a build context, whatever the ignore files
22
+ // say. `.env.example`/`.sample`/`.template` are shareable templates, not secrets.
23
+ function isSecretPath(rel) {
24
+ const base = rel.replace(/^\.\//, '').split('/').pop();
25
+ if (base === '.env' || /^\.env\.(?!example$|sample$|template$)[^/]*$/i.test(base)) return true;
26
+ if (/\.(pem|key|p12|pfx|keystore|jks)$/i.test(base)) return true;
27
+ if (/^id_(rsa|dsa|ecdsa|ed25519)$/.test(base)) return true;
28
+ return false;
29
+ }
30
+
31
+ // Exclude patterns: always skip .git/node_modules, then honor .gitignore and
32
+ // .neoappignore (comments, blanks and negations are skipped — a conservative packer).
33
+ function collectExcludes(dir) {
34
+ const excludes = new Set(['.git', 'node_modules']);
35
+ for (const f of ['.gitignore', '.neoappignore']) {
36
+ const p = join(dir, f);
37
+ if (!existsSync(p)) continue;
38
+ for (let line of readFileSync(p, 'utf8').split(/\r?\n/)) {
39
+ line = line.trim();
40
+ if (!line || line.startsWith('#') || line.startsWith('!')) continue;
41
+ excludes.add(line.replace(/^\/+/, '').replace(/\/+$/, ''));
42
+ }
43
+ }
44
+ return excludes;
45
+ }
46
+
47
+ // A relative path is excluded if a pattern matches its basename, the whole path, any
48
+ // path segment (a directory like node_modules), or a simple `*.ext` glob — a small,
49
+ // gitignore-ish matcher good enough for the ignore files we support.
50
+ function matchesExclude(rel, excludes) {
51
+ const parts = rel.split('/');
52
+ const base = parts[parts.length - 1];
53
+ for (const pat of excludes) {
54
+ if (pat === base || pat === rel || parts.includes(pat)) return true;
55
+ if (pat.startsWith('*.') && base.endsWith(pat.slice(1))) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ // Build the explicit list of files to pack (relative to dir), honoring the ignore
61
+ // files and never including secrets. We tar this exact list (tar -T) instead of
62
+ // relying on `tar --exclude`, whose glob semantics differ across tar implementations
63
+ // (GNU vs bsd vs busybox) and silently let ignored/secret files through (JNG #95).
64
+ function collectIncludedFiles(dir) {
65
+ const excludes = collectExcludes(dir);
66
+ const out = [];
67
+ const walk = (d, rel) => {
68
+ let entries;
69
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
70
+ for (const e of entries) {
71
+ const r = rel ? `${rel}/${e.name}` : e.name;
72
+ if (matchesExclude(r, excludes) || isSecretPath(r)) continue;
73
+ if (e.isDirectory()) walk(join(d, e.name), r);
74
+ else out.push(r);
75
+ }
76
+ };
77
+ walk(dir, '');
78
+ return out;
79
+ }
80
+
81
+ // Secret files found under dir (for the heads-up log and the .zip check).
82
+ function findSecretFiles(dir) {
83
+ const out = [];
84
+ const walk = (d, rel) => {
85
+ let entries;
86
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
87
+ for (const e of entries) {
88
+ if (e.name === '.git' || e.name === 'node_modules') continue;
89
+ const r = rel ? `${rel}/${e.name}` : e.name;
90
+ if (e.isDirectory()) walk(join(d, e.name), r);
91
+ else if (isSecretPath(r)) out.push(r);
92
+ }
93
+ };
94
+ walk(dir, '');
95
+ return out;
96
+ }
97
+
98
+ // Read a .zip's entry names from its central directory (no unzip binary needed) so we
99
+ // can audit a prebuilt archive for secret files before uploading it (JNG #95).
100
+ function zipEntryNames(buf) {
101
+ const names = [];
102
+ let eocd = -1;
103
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
104
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
105
+ }
106
+ if (eocd < 0) return null; // not a parseable zip — caller decides how strict to be
107
+ let off = buf.readUInt32LE(eocd + 16);
108
+ const count = buf.readUInt16LE(eocd + 10);
109
+ for (let i = 0; i < count && off + 46 <= buf.length; i++) {
110
+ if (buf.readUInt32LE(off) !== 0x02014b50) break;
111
+ const nameLen = buf.readUInt16LE(off + 28);
112
+ const extraLen = buf.readUInt16LE(off + 30);
113
+ const commentLen = buf.readUInt16LE(off + 32);
114
+ names.push(buf.toString('utf8', off + 46, off + 46 + nameLen));
115
+ off += 46 + nameLen + extraLen + commentLen;
116
+ }
117
+ return names;
118
+ }
119
+
120
+ // deploySource ships a resolved source to a project. `src` is one of:
121
+ // { mode:'image', image } { mode:'git', repo, branch? }
122
+ // { mode:'dir', dir } { mode:'zip', zipFile }
123
+ // With `dryRun`, it performs NO create/update/upload/deploy — only the GETs needed to
124
+ // build a plan — and returns { dryRun:true, plan } (JNG #93).
125
+ // 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 = () => {} }) {
127
+ const defaultName =
128
+ src.mode === 'image' ? src.image.split('/').pop().split(':')[0]
129
+ : src.mode === 'git' ? basename(src.repo, '.git')
130
+ : src.mode === 'zip' ? basename(src.zipFile, '.zip')
131
+ : basename(src.dir);
132
+ const svc = sanitizeName(name || defaultName);
133
+ const targetEnv = environment || 'production';
134
+
135
+ let buildType = 'railpack';
136
+ if (src.mode === 'image') buildType = 'image';
137
+ else if (build) buildType = build;
138
+ else if (src.mode === 'dir' && existsSync(join(src.dir, 'Dockerfile'))) buildType = 'dockerfile';
139
+
140
+ // Select an existing service by name AND environment — a Staging deploy must never
141
+ // reuse/mutate a Production service of the same name, or vice versa (JNG #94). The
142
+ // result is order-independent (filter, not first-match).
143
+ const existing = (await api.get(`/api/v1/projects/${projectId}/apps`)).data?.applications || [];
144
+ const sameName = existing.filter((a) => a.name === svc);
145
+ let app = sameName.find((a) => (a.environment || 'production') === targetEnv) || null;
146
+ if (!app && sameName.length) {
147
+ const envs = [...new Set(sameName.map((a) => a.environment || 'production'))].sort().join(', ');
148
+ throw new Error(
149
+ `a service named "${svc}" already exists in ${envs}, not in ${targetEnv} — ` +
150
+ `choose a different --name or target that environment`,
151
+ );
152
+ }
153
+
154
+ // Source-safety preflight — BEFORE any mutation (and in dry-run too, it's read-only)
155
+ // so an unsafe source aborts before a service is even created (JNG #95). Secrets are
156
+ // named, never their contents.
157
+ let dirSecrets = [];
158
+ if (src.mode === 'dir') {
159
+ dirSecrets = findSecretFiles(src.dir);
160
+ if (dirSecrets.length) log(`excluding secret file(s) from the build context: ${dirSecrets.join(', ')}`);
161
+ } else if (src.mode === 'zip') {
162
+ const names = zipEntryNames(readFileSync(src.zipFile));
163
+ const zipSecrets = (names || []).filter(isSecretPath);
164
+ if (zipSecrets.length) {
165
+ throw new Error(
166
+ `refusing to upload ${basename(src.zipFile)}: it contains secret file(s) ${zipSecrets.join(', ')} — ` +
167
+ `remove them from the archive before deploying`,
168
+ );
169
+ }
170
+ }
171
+
172
+ const plan = [];
173
+ if (!app) {
174
+ plan.push(`create web service "${svc}" (${buildType}) in ${targetEnv}`);
175
+ if (!dryRun) {
176
+ const res = await api.post(`/api/v1/projects/${projectId}/apps`, {
177
+ name: svc,
178
+ environment: targetEnv,
179
+ serviceType: 'web',
180
+ buildType,
181
+ image: src.image || undefined,
182
+ repoUrl: src.repo || undefined,
183
+ branch: src.branch || undefined,
184
+ port: port ? Number(port) : undefined,
185
+ tls: true,
186
+ });
187
+ if (pending(res)) return { pending: true, approvalId: res.data.approvalId };
188
+ app = res.data?.application || res.data;
189
+ log(`created service ${svc}`);
190
+ }
191
+ } else {
192
+ // Existing service: apply requested source changes (image/repo/branch/build) BEFORE
193
+ // deploying, so a redeploy never silently ships the old source config (JNG #96).
194
+ const patch = {};
195
+ if (src.mode === 'image' && src.image && app.image !== src.image) patch.image = src.image;
196
+ if (src.mode === 'git') {
197
+ if (src.repo && app.repoUrl !== src.repo) patch.repoUrl = src.repo;
198
+ if (src.branch && app.branch !== src.branch) patch.branch = src.branch;
199
+ }
200
+ if (buildType && app.buildType && app.buildType !== buildType) patch.buildType = buildType;
201
+ if (Object.keys(patch).length) {
202
+ plan.push(`update "${svc}": ${Object.keys(patch).join(', ')}`);
203
+ if (!dryRun) { await api.put(`/api/v1/apps/${app.id}`, patch); log(`updated source config (${Object.keys(patch).join(', ')})`); }
204
+ } else {
205
+ log(`service ${svc} exists — redeploying`);
206
+ }
207
+ }
208
+
209
+ if (src.mode === 'dir' || src.mode === 'zip') plan.push(`upload build context (${src.mode})`);
210
+ plan.push(`deploy "${svc}"`);
211
+
212
+ if (dryRun) {
213
+ for (const p of plan) log(`[dry-run] would ${p}`);
214
+ return { dryRun: true, plan, service: svc, environment: targetEnv };
215
+ }
216
+
217
+ if (src.mode === 'dir' || src.mode === 'zip') {
218
+ let archive = src.zipFile;
219
+ let packed = false;
220
+ try {
221
+ let buf, ctype;
222
+ if (src.mode === 'zip') {
223
+ buf = readFileSync(src.zipFile);
224
+ ctype = 'application/zip';
225
+ } else {
226
+ archive = join(tmpdir(), `neo-deploy-${app.id}-${Date.now()}.tgz`);
227
+ packed = true;
228
+ log('packing the directory…');
229
+ // Pack exactly the allowed files (ignore-respecting, secret-free) via a list,
230
+ // so no ignored/secret file can slip through a tar --exclude quirk (JNG #95).
231
+ const files = collectIncludedFiles(src.dir);
232
+ if (!files.length) throw new Error('nothing to deploy — the folder is empty after applying ignore rules');
233
+ const listFile = join(tmpdir(), `neo-files-${app.id}-${Date.now()}.txt`);
234
+ writeFileSync(listFile, files.join('\n') + '\n');
235
+ try {
236
+ execFileSync('tar', ['czf', archive, '-C', src.dir, '-T', listFile], { stdio: 'ignore' });
237
+ } finally {
238
+ rmSync(listFile, { force: true });
239
+ }
240
+ buf = readFileSync(archive);
241
+ ctype = 'application/gzip';
242
+ }
243
+ log(`uploading build context (${(buf.length / 1024 / 1024).toFixed(1)} MiB)…`);
244
+ await api.postRaw(`/api/v1/apps/${app.id}/source`, buf, ctype);
245
+ } finally {
246
+ if (packed) rmSync(archive, { force: true });
247
+ }
248
+ }
249
+
250
+ const dep = await api.post(`/api/v1/apps/${app.id}/deploy`);
251
+ if (pending(dep)) return { pending: true, approvalId: dep.data.approvalId };
252
+ const deployId = dep.data?.id;
253
+ log(`deployment ${deployId} started`);
254
+
255
+ const result = await pollDeployment(api, deployId, timeout, log);
256
+ const view = (await api.get(`/api/v1/apps/${app.id}`)).data?.application || {};
257
+ const url = view.domain ? `https://${view.domain}` : '';
258
+ return { appId: app.id, url, result };
259
+ }
260
+
261
+ async function pollDeployment(api, id, timeoutSec, log) {
262
+ if (!id) return 'unknown';
263
+ const deadline = Date.now() + timeoutSec * 1000;
264
+ let last = '';
265
+ while (Date.now() < deadline) {
266
+ const d = (await api.get(`/api/v1/deployments/${id}`)).data || {};
267
+ if (d.status && d.status !== last) {
268
+ log(` ${d.status}`);
269
+ last = d.status;
270
+ }
271
+ if (d.status === 'success' || d.status === 'failed') return d.status;
272
+ await new Promise((r) => setTimeout(r, 4000));
273
+ }
274
+ return 'timeout';
275
+ }
package/src/index.js CHANGED
@@ -27,7 +27,7 @@ ${c.bold('Commands')}
27
27
  config install-mcp Register the neo-app MCP server with detected agents
28
28
  login Authenticate with a NEO App API key (kap_...)
29
29
  link <project-id> Link this repo to a project (writes .neo-app.json)
30
- deploy Deploy a web service: --image <ref> or --repo <git-url>
30
+ deploy [path|.zip|git-url] Deploy a directory (default: cwd), a .zip, a Git repo, or --image <ref>
31
31
  project create [name] Create a project (defaults to the dir name) and link it
32
32
  project list List projects in your organization (find ids to link)
33
33
  status | whoami Show connected account, org, environment, linked project
@@ -85,12 +85,16 @@ export async function main(argv = process.argv.slice(2)) {
85
85
  const { values: opts, positionals } = parsed;
86
86
  if (opts.dev) opts.staging = true;
87
87
 
88
- if (opts.version && positionals.length === 0) return void console.log(version());
89
- if (opts.help || positionals.length === 0) return void console.log(HELP);
90
-
91
88
  const cmd = positionals[0];
92
89
  const sub = positionals[1];
93
90
 
91
+ if (opts.version && positionals.length === 0) return void console.log(version());
92
+ // Root help, and `--help` on a KNOWN command, print usage. An UNKNOWN command must
93
+ // still error (nonzero exit) even with `--help`, so a bad command isn't silently
94
+ // accepted (JNG #97). Per-subcommand help is tracked in #89.
95
+ const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
96
+ if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd))) return void console.log(HELP);
97
+
94
98
  try {
95
99
  // `agent setup` and `setup agent` both work (parity with InstaCloud's `setup agent`).
96
100
  if ((cmd === 'agent' && sub === 'setup') || (cmd === 'setup' && sub === 'agent')) return void (await (await import('./commands/setup.js')).setup(opts));
@@ -98,7 +102,7 @@ export async function main(argv = process.argv.slice(2)) {
98
102
  if ((cmd === 'agent' && sub === 'uninstall') || (cmd === 'uninstall')) return void (await (await import('./commands/uninstall.js')).uninstall(opts));
99
103
  if (cmd === 'config' && sub === 'install-mcp') return void (await (await import('./commands/install-mcp.js')).installMcp(opts));
100
104
  if (cmd === 'login') return void (await (await import('./commands/login.js')).login(opts));
101
- if (cmd === 'deploy') return void (await (await import('./commands/deploy.js')).deploy(opts));
105
+ if (cmd === 'deploy') return void (await (await import('./commands/deploy.js')).deploy(sub, opts));
102
106
  if (cmd === 'link') return void (await (await import('./commands/link.js')).link(sub, opts));
103
107
  if (cmd === 'project' && sub === 'link') return void (await (await import('./commands/link.js')).link(positionals[2], opts));
104
108
  if (cmd === 'project' && sub === 'create') return void (await (await import('./commands/link.js')).createProject(positionals[2], opts));
package/src/mcp/tools.js CHANGED
@@ -2,6 +2,10 @@
2
2
  // map). Read-only vs mutating is annotated; mutating tools surface the server's
3
3
  // 202 pending_approval result verbatim instead of treating it as success/failure.
4
4
 
5
+ import { resolve } from 'node:path';
6
+ import { statSync } from 'node:fs';
7
+ import { deploySource } from '../deploySource.js';
8
+
5
9
  const str = (description) => ({ type: 'string', description });
6
10
  const int = (description) => ({ type: 'integer', description });
7
11
 
@@ -175,6 +179,81 @@ export const TOOLS = [
175
179
  return (await api.get(`/api/v1/orgs/${orgId}/approvals`)).data;
176
180
  },
177
181
  },
182
+ {
183
+ name: 'neo_get_credit',
184
+ description: "Check the organization's prepaid credit balance and status (JNG #72).",
185
+ mutating: false,
186
+ schema: { type: 'object', properties: { orgId: str('Organization id (optional)') } },
187
+ async call(api, args) {
188
+ const orgId = await api.resolveOrgId(args.orgId);
189
+ return (await api.get(`/api/v1/orgs/${orgId}/credit`)).data;
190
+ },
191
+ },
192
+ {
193
+ name: 'neo_list_templates',
194
+ description: 'List deployable templates (e.g. wordpress, n8n, redis).',
195
+ mutating: false,
196
+ schema: { type: 'object', properties: {} },
197
+ async call(api) {
198
+ return (await api.get('/api/v1/templates')).data;
199
+ },
200
+ },
201
+ {
202
+ name: 'neo_deploy_template',
203
+ description: 'Deploy a template (e.g. wordpress, n8n) into a project. May return pending_approval.',
204
+ mutating: true,
205
+ schema: {
206
+ type: 'object',
207
+ properties: { projectId: str('Project id'), name: str('Template name'), domain: str('Custom domain (some templates need it)') },
208
+ required: ['projectId', 'name'],
209
+ },
210
+ async call(api, args) {
211
+ const body = args.domain ? { domain: args.domain } : {};
212
+ const { status, data } = await api.post(`/api/v1/projects/${args.projectId}/templates/${args.name}`, body);
213
+ return result(status, data);
214
+ },
215
+ },
216
+ {
217
+ name: 'neo_deploy_folder',
218
+ description:
219
+ '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).',
220
+ mutating: true,
221
+ schema: {
222
+ type: 'object',
223
+ properties: {
224
+ projectId: str('Project id'),
225
+ path: str('Local folder or .zip path'),
226
+ name: str('Service name (optional)'),
227
+ port: int('Container port (optional)'),
228
+ environment: str('production or staging (optional, default production)'),
229
+ dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93)' },
230
+ },
231
+ required: ['projectId', 'path'],
232
+ },
233
+ async call(api, args) {
234
+ const p = resolve(args.path);
235
+ let src;
236
+ try {
237
+ const st = statSync(p);
238
+ if (st.isDirectory()) src = { mode: 'dir', dir: p };
239
+ else if (/\.zip$/i.test(p) && st.isFile()) src = { mode: 'zip', zipFile: p };
240
+ else throw new Error('not a folder or .zip');
241
+ } catch (e) {
242
+ throw new Error(`invalid path '${args.path}': ${e.message}`);
243
+ }
244
+ const r = await deploySource(api, {
245
+ projectId: args.projectId,
246
+ src,
247
+ name: args.name,
248
+ port: args.port,
249
+ environment: args.environment || 'production',
250
+ dryRun: !!args.dryRun,
251
+ });
252
+ if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment };
253
+ if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
254
+ return { status: r.result, url: r.url || undefined, appId: r.appId };
255
+ },
256
+ },
178
257
  {
179
258
  name: 'neo_get_approval',
180
259
  description: 'Get one approval request by id (filtered from the org approval list).',