@biznetgio/neo-app 0.4.1 → 0.4.6

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/README.md CHANGED
@@ -34,11 +34,10 @@ Fetch https://portal.app.neoapp.id/prompt.md and follow it to set this repo up o
34
34
  All mutating commands are idempotent, back up config before editing, and never write a
35
35
  token into agent/MCP configuration.
36
36
 
37
- ## Environments
37
+ ## Environment
38
38
 
39
- - **Production** (`https://portal.app.neoapp.id`) is the default.
40
- - **Staging** (`https://portal.neoapp.biznetgio.dev`) is selected by `--staging` or `NODE_ENV=development`.
41
- - `NEO_APP_URL` overrides the control-plane URL entirely (e.g. a local dev server).
39
+ The CLI talks to your NEO App control-plane — production by default. Set `NEO_APP_URL`
40
+ to point it at a different control-plane (e.g. a local dev server).
42
41
 
43
42
  ## Authentication
44
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.1",
3
+ "version": "0.4.6",
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
@@ -85,9 +111,8 @@ asked for a deployment.**
85
111
 
86
112
  ## Environment
87
113
 
88
- Staging is `https://portal.neoapp.biznetgio.dev`; production is
89
- `https://portal.app.neoapp.id`. Production is the default; `NODE_ENV=development` or
90
- `--staging` targets staging; `NEO_APP_URL` overrides the control-plane entirely.
114
+ The CLI targets production by default. Set `NEO_APP_URL` to point it at a different
115
+ control-plane when needed.
91
116
 
92
117
  ## Uninstall
93
118
 
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,44 @@ 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}`)}`);
54
-
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);
71
+ const r = await deploySource(api, {
72
+ projectId,
73
+ src,
74
+ name: opts.name,
75
+ port: opts.port,
76
+ build: opts.build,
77
+ environment,
78
+ timeout: Number(opts.timeout) || 300,
79
+ log: step,
80
+ });
58
81
 
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 });
80
- }
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}).`);
82
+ if (r.pending) {
83
+ ok(`submitted — pending approval (${r.approvalId}). An owner/admin must approve.`);
86
84
  return;
87
85
  }
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) : ''}`);
86
+ if (r.result === 'success') {
87
+ ok(`deployed${r.url ? ' → ' + c.bold(r.url) : ''}`);
97
88
  } else {
98
- fail(`deployment ${result}${deployId ? ` (neo-app logs via 'neo_get_logs' or the portal)` : ''}`);
89
+ fail(`deployment ${r.result} — check logs in the portal or via the neo_get_logs MCP tool`);
99
90
  process.exitCode = 1;
100
91
  }
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';
92
+ return r;
118
93
  }
@@ -0,0 +1,108 @@
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, 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
+ // deploySource ships a resolved source to a project. `src` is one of:
21
+ // { mode:'image', image } { mode:'git', repo, branch? }
22
+ // { mode:'dir', dir } { mode:'zip', zipFile }
23
+ // 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 = () => {} }) {
25
+ const defaultName =
26
+ src.mode === 'image' ? src.image.split('/').pop().split(':')[0]
27
+ : src.mode === 'git' ? basename(src.repo, '.git')
28
+ : src.mode === 'zip' ? basename(src.zipFile, '.zip')
29
+ : basename(src.dir);
30
+ const svc = sanitizeName(name || defaultName);
31
+
32
+ let buildType = 'railpack';
33
+ if (src.mode === 'image') buildType = 'image';
34
+ else if (build) buildType = build;
35
+ else if (src.mode === 'dir' && existsSync(join(src.dir, 'Dockerfile'))) buildType = 'dockerfile';
36
+
37
+ const existing = (await api.get(`/api/v1/projects/${projectId}/apps`)).data?.applications || [];
38
+ let app = existing.find((a) => a.name === svc);
39
+
40
+ 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}`);
55
+ } 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 });
58
+ }
59
+
60
+ if (src.mode === 'dir' || src.mode === 'zip') {
61
+ let archive = src.zipFile;
62
+ let packed = false;
63
+ try {
64
+ let buf, ctype;
65
+ if (src.mode === 'zip') {
66
+ buf = readFileSync(src.zipFile);
67
+ ctype = 'application/zip';
68
+ } else {
69
+ archive = join(tmpdir(), `neo-deploy-${app.id}-${Date.now()}.tgz`);
70
+ packed = true;
71
+ log('packing the directory…');
72
+ execFileSync('tar', ['czf', archive, '--exclude=./.git', '--exclude=./node_modules', '-C', src.dir, '.'], { stdio: 'ignore' });
73
+ buf = readFileSync(archive);
74
+ ctype = 'application/gzip';
75
+ }
76
+ log(`uploading build context (${(buf.length / 1024 / 1024).toFixed(1)} MiB)…`);
77
+ await api.postRaw(`/api/v1/apps/${app.id}/source`, buf, ctype);
78
+ } finally {
79
+ if (packed) rmSync(archive, { force: true });
80
+ }
81
+ }
82
+
83
+ const dep = await api.post(`/api/v1/apps/${app.id}/deploy`);
84
+ if (pending(dep)) return { pending: true, approvalId: dep.data.approvalId };
85
+ const deployId = dep.data?.id;
86
+ log(`deployment ${deployId} started`);
87
+
88
+ const result = await pollDeployment(api, deployId, timeout, log);
89
+ const view = (await api.get(`/api/v1/apps/${app.id}`)).data?.application || {};
90
+ const url = view.domain ? `https://${view.domain}` : '';
91
+ return { appId: app.id, url, result };
92
+ }
93
+
94
+ async function pollDeployment(api, id, timeoutSec, log) {
95
+ if (!id) return 'unknown';
96
+ const deadline = Date.now() + timeoutSec * 1000;
97
+ let last = '';
98
+ while (Date.now() < deadline) {
99
+ const d = (await api.get(`/api/v1/deployments/${id}`)).data || {};
100
+ if (d.status && d.status !== last) {
101
+ log(` ${d.status}`);
102
+ last = d.status;
103
+ }
104
+ if (d.status === 'success' || d.status === 'failed') return d.status;
105
+ await new Promise((r) => setTimeout(r, 4000));
106
+ }
107
+ return 'timeout';
108
+ }
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
@@ -40,17 +40,16 @@ ${c.bold('Options')}
40
40
  --name <name> Service name (deploy; default derived from the source)
41
41
  --port <n> Container port (deploy)
42
42
  --agent <codex|claude-code> Target one agent (config install-mcp)
43
- --staging | --prod Target staging (portal.neoapp.biznetgio.dev) or production (portal.app.neoapp.id)
44
43
  --url <url> Override the control-plane URL (also NEO_APP_URL)
45
44
  --token <kap_...> API key (also NEO_APP_TOKEN; prompted if omitted)
46
45
  --yes Non-interactive (headless/CI)
47
46
  --dry-run Print intended changes without writing
48
47
  -v, --version Print version
49
- -h, --help Show this help
48
+ -h, --help Show this help`;
50
49
 
51
- ${c.bold('Environment')}
52
- Production is the default. Set NODE_ENV=development (or pass --staging) for staging.
53
- NEO_APP_URL / NEO_APP_TOKEN override the target and credential.`;
50
+ // Internal: --staging / --prod / --env / NODE_ENV select the staging vs production
51
+ // control-plane (see src/config.js). Intentionally undocumented in --help — target
52
+ // selection is an internal/advanced concern; NEO_APP_URL is the public override.
54
53
 
55
54
  const options = {
56
55
  project: { type: 'string' },
@@ -99,7 +98,7 @@ export async function main(argv = process.argv.slice(2)) {
99
98
  if ((cmd === 'agent' && sub === 'uninstall') || (cmd === 'uninstall')) return void (await (await import('./commands/uninstall.js')).uninstall(opts));
100
99
  if (cmd === 'config' && sub === 'install-mcp') return void (await (await import('./commands/install-mcp.js')).installMcp(opts));
101
100
  if (cmd === 'login') return void (await (await import('./commands/login.js')).login(opts));
102
- if (cmd === 'deploy') return void (await (await import('./commands/deploy.js')).deploy(opts));
101
+ if (cmd === 'deploy') return void (await (await import('./commands/deploy.js')).deploy(sub, opts));
103
102
  if (cmd === 'link') return void (await (await import('./commands/link.js')).link(sub, opts));
104
103
  if (cmd === 'project' && sub === 'link') return void (await (await import('./commands/link.js')).link(positionals[2], opts));
105
104
  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,78 @@ 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
+ },
230
+ required: ['projectId', 'path'],
231
+ },
232
+ async call(api, args) {
233
+ const p = resolve(args.path);
234
+ let src;
235
+ try {
236
+ const st = statSync(p);
237
+ if (st.isDirectory()) src = { mode: 'dir', dir: p };
238
+ else if (/\.zip$/i.test(p) && st.isFile()) src = { mode: 'zip', zipFile: p };
239
+ else throw new Error('not a folder or .zip');
240
+ } catch (e) {
241
+ throw new Error(`invalid path '${args.path}': ${e.message}`);
242
+ }
243
+ const r = await deploySource(api, {
244
+ projectId: args.projectId,
245
+ src,
246
+ name: args.name,
247
+ port: args.port,
248
+ environment: args.environment || 'production',
249
+ });
250
+ if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
251
+ return { status: r.result, url: r.url || undefined, appId: r.appId };
252
+ },
253
+ },
178
254
  {
179
255
  name: 'neo_get_approval',
180
256
  description: 'Get one approval request by id (filtered from the org approval list).',
@@ -18,8 +18,7 @@ NEO App is Biznet Gio's managed-app platform. You operate it two ways:
18
18
 
19
19
  ## Environments: staging first
20
20
 
21
- - `portal.neoapp.biznetgio.dev` is **staging**; `portal.app.neoapp.id` is **production**.
22
- - Default to **staging** for every change. Deploy, test, and read logs on staging before touching production.
21
+ - Prefer **staging** for every change: deploy, test, and read logs on staging before touching production.
23
22
  - Only deploy to production when the user explicitly asks, and expect it to require approval (see below).
24
23
 
25
24
  ## Deployments