@biznetgio/neo-app 0.4.6 → 0.4.8

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.6",
3
+ "version": "0.4.8",
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": {
@@ -68,6 +68,9 @@ export async function deploy(source, opts = {}) {
68
68
  const environment = cfg.env === 'staging' ? 'staging' : 'production';
69
69
  log(`${c.bold('Deploying')} ${c.dim(`[${src.mode}] → ${cfg.env} · project ${projectId}`)}`);
70
70
 
71
+ const dryRun = !!opts['dry-run'];
72
+ if (dryRun) log(c.dim('dry-run — no changes will be made'));
73
+
71
74
  const r = await deploySource(api, {
72
75
  projectId,
73
76
  src,
@@ -75,10 +78,15 @@ export async function deploy(source, opts = {}) {
75
78
  port: opts.port,
76
79
  build: opts.build,
77
80
  environment,
81
+ dryRun,
78
82
  timeout: Number(opts.timeout) || 300,
79
83
  log: step,
80
84
  });
81
85
 
86
+ if (r.dryRun) {
87
+ ok(`dry-run complete — ${r.plan.length} step(s) planned, nothing written`);
88
+ return r;
89
+ }
82
90
  if (r.pending) {
83
91
  ok(`submitted — pending approval (${r.approvalId}). An owner/admin must approve.`);
84
92
  return;
@@ -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
  }
@@ -3,7 +3,7 @@
3
3
  // .zip → as-is) when relevant, trigger the deploy, and poll to a terminal state.
4
4
 
5
5
  import { basename, join } from 'node:path';
6
- import { existsSync, readFileSync, rmSync } from 'node:fs';
6
+ import { existsSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs';
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { tmpdir } from 'node:os';
9
9
 
@@ -17,44 +17,201 @@ function pending(res) {
17
17
  return res.status === 202 && res.data && res.data.status === 'pending_approval';
18
18
  }
19
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
+
20
120
  // deploySource ships a resolved source to a project. `src` is one of:
21
121
  // { mode:'image', image } { mode:'git', repo, branch? }
22
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).
23
125
  // Returns { pending, approvalId } or { appId, url, result }. `log(msg)` is optional.
24
- export async function deploySource(api, { projectId, src, name, port, build, environment, timeout = 300, log = () => {} }) {
126
+ export async function deploySource(api, { projectId, src, name, port, build, environment, dryRun = false, timeout = 300, log = () => {} }) {
25
127
  const defaultName =
26
128
  src.mode === 'image' ? src.image.split('/').pop().split(':')[0]
27
129
  : src.mode === 'git' ? basename(src.repo, '.git')
28
130
  : src.mode === 'zip' ? basename(src.zipFile, '.zip')
29
131
  : basename(src.dir);
30
132
  const svc = sanitizeName(name || defaultName);
133
+ const targetEnv = environment || 'production';
31
134
 
32
135
  let buildType = 'railpack';
33
136
  if (src.mode === 'image') buildType = 'image';
34
137
  else if (build) buildType = build;
35
138
  else if (src.mode === 'dir' && existsSync(join(src.dir, 'Dockerfile'))) buildType = 'dockerfile';
36
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).
37
143
  const existing = (await api.get(`/api/v1/projects/${projectId}/apps`)).data?.applications || [];
38
- let app = existing.find((a) => a.name === svc);
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
+ }
39
171
 
172
+ const plan = [];
40
173
  if (!app) {
41
- const res = await api.post(`/api/v1/projects/${projectId}/apps`, {
42
- name: svc,
43
- environment: environment || 'production',
44
- serviceType: 'web',
45
- buildType,
46
- image: src.image || undefined,
47
- repoUrl: src.repo || undefined,
48
- branch: src.branch || undefined,
49
- port: port ? Number(port) : undefined,
50
- tls: true,
51
- });
52
- if (pending(res)) return { pending: true, approvalId: res.data.approvalId };
53
- app = res.data?.application || res.data;
54
- log(`created service ${svc}`);
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
+ }
55
191
  } else {
56
- log(`service ${svc} exists — redeploying`);
57
- if (src.mode === 'image' && app.image !== src.image) await api.put(`/api/v1/apps/${app.id}`, { image: src.image });
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 };
58
215
  }
59
216
 
60
217
  if (src.mode === 'dir' || src.mode === 'zip') {
@@ -69,7 +226,17 @@ export async function deploySource(api, { projectId, src, name, port, build, env
69
226
  archive = join(tmpdir(), `neo-deploy-${app.id}-${Date.now()}.tgz`);
70
227
  packed = true;
71
228
  log('packing the directory…');
72
- execFileSync('tar', ['czf', archive, '--exclude=./.git', '--exclude=./node_modules', '-C', src.dir, '.'], { stdio: 'ignore' });
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
+ }
73
240
  buf = readFileSync(archive);
74
241
  ctype = 'application/gzip';
75
242
  }
package/src/index.js CHANGED
@@ -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));
package/src/mcp/tools.js CHANGED
@@ -226,6 +226,7 @@ export const TOOLS = [
226
226
  name: str('Service name (optional)'),
227
227
  port: int('Container port (optional)'),
228
228
  environment: str('production or staging (optional, default production)'),
229
+ dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93)' },
229
230
  },
230
231
  required: ['projectId', 'path'],
231
232
  },
@@ -246,7 +247,9 @@ export const TOOLS = [
246
247
  name: args.name,
247
248
  port: args.port,
248
249
  environment: args.environment || 'production',
250
+ dryRun: !!args.dryRun,
249
251
  });
252
+ if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment };
250
253
  if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
251
254
  return { status: r.result, url: r.url || undefined, appId: r.appId };
252
255
  },