@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 +1 -1
- package/prompt.md +27 -1
- package/src/api.js +12 -0
- package/src/commands/deploy.js +64 -81
- package/src/commands/doctor.js +3 -2
- package/src/creds.js +20 -5
- package/src/deploySource.js +275 -0
- package/src/index.js +9 -5
- package/src/mcp/tools.js +79 -0
package/package.json
CHANGED
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.
|
|
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
|
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,29 +1,41 @@
|
|
|
1
|
-
// `neo-app deploy` —
|
|
2
|
-
//
|
|
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 {
|
|
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 {
|
|
10
|
+
import { deploySource } from '../deploySource.js';
|
|
11
|
+
import { log, ok, fail, warn, step, c } from '../util.js';
|
|
11
12
|
|
|
12
|
-
function
|
|
13
|
-
return
|
|
14
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
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
|
-
|
|
71
|
+
const dryRun = !!opts['dry-run'];
|
|
72
|
+
if (dryRun) log(c.dim('dry-run — no changes will be made'));
|
|
54
73
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 (
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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}
|
|
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
|
|
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
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
|
78
|
-
else bad(`credentials file is
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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).',
|