@biznetgio/neo-app 0.0.0-stage → 0.4.0

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
@@ -1,3 +1,69 @@
1
- # Temporary Holding Version
1
+ # @biznetgio/neo-app
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ NEO App CLI — one-command agent onboarding, a local MCP server, and project ops for
4
+ coding agents (Codex, Claude Code).
5
+
6
+ ## Quick start
7
+
8
+ ```bash
9
+ npx -y @biznetgio/neo-app@latest agent setup
10
+ # link a project:
11
+ npx -y @biznetgio/neo-app@latest agent setup --project <project-id>
12
+ ```
13
+
14
+ Or from a coding agent:
15
+
16
+ ```text
17
+ Fetch https://portal.app.neoapp.id/prompt.md and follow it to set this repo up on NEO App.
18
+ ```
19
+
20
+ ## Commands
21
+
22
+ | Command | What it does |
23
+ |---|---|
24
+ | `agent setup` | Authenticate, install the skill, register the MCP server, optionally link a project, health-check |
25
+ | `agent doctor` | Diagnose CLI, API, auth, agent MCP wiring, skill, credential perms |
26
+ | `agent uninstall` | Remove NEO App MCP entries, skill, (on confirm) credentials |
27
+ | `config install-mcp [--agent codex\|claude-code]` | Register the `neo-app` MCP server with detected agents |
28
+ | `login` | Authenticate with a NEO App API key (`kap_...`) |
29
+ | `link <project-id>` | Link this repo to a project (writes `.neo-app.json`) |
30
+ | `project list` | List projects in your organization (find ids to link) |
31
+ | `status` / `whoami` | Show account, org, environment, linked project |
32
+ | `mcp` | Run the local stdio MCP server (agents launch this) |
33
+
34
+ All mutating commands are idempotent, back up config before editing, and never write a
35
+ token into agent/MCP configuration.
36
+
37
+ ## Environments
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).
42
+
43
+ ## Authentication
44
+
45
+ API key (`kap_...`) for now. Create one in the portal (Settings → API tokens).
46
+
47
+ - Interactive: `neo-app login` prompts (hidden) and stores the key at
48
+ `~/.config/neo-app/credentials.json` (mode `0600`), scoped per control-plane URL.
49
+ - Headless/CI: `export NEO_APP_TOKEN=kap_...` — read from the environment, never persisted.
50
+
51
+ The key is kept out of every agent/MCP config file; the MCP server reads it from the
52
+ credential store (or `NEO_APP_TOKEN`) at runtime.
53
+
54
+ ## MCP tools
55
+
56
+ `neo_whoami`, `neo_list_projects`, `neo_get_project`, `neo_list_services`,
57
+ `neo_get_deployment`, `neo_deploy`, `neo_get_logs`, `neo_list_variables`,
58
+ `neo_set_variable`, `neo_scale_service`, `neo_list_approvals`, `neo_get_approval`.
59
+
60
+ Read-only tools are annotated `readOnlyHint`. Production/scaling/etc. operations may
61
+ return a `pending_approval` result — that is the org approval policy working, not an error.
62
+
63
+ ## Develop
64
+
65
+ ```bash
66
+ npm install
67
+ npm test
68
+ node bin/neo-app.js --help
69
+ ```
package/bin/neo-app.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/index.js';
3
+
4
+ main().catch((e) => {
5
+ console.error(e?.message || e);
6
+ process.exit(1);
7
+ });
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "NEO App CLI — one-command agent onboarding, MCP server, and project ops for coding agents (Codex, Claude Code).",
5
+ "type": "module",
6
+ "bin": {
7
+ "neo-app": "bin/neo-app.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "prompt.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.0.0"
22
+ },
23
+ "keywords": [
24
+ "neo-app",
25
+ "biznetgio",
26
+ "mcp",
27
+ "agent",
28
+ "codex",
29
+ "claude-code",
30
+ "paas"
31
+ ],
32
+ "license": "UNLICENSED"
33
+ }
package/prompt.md ADDED
@@ -0,0 +1,105 @@
1
+ <!--
2
+ NEO App setup prompt — served at https://portal.neoapp.biznetgio.dev/prompt.md (staging)
3
+ and https://portal.app.neoapp.id/prompt.md (production).
4
+ Contains NO credentials or user-specific secrets. Versioned; keep last-updated current.
5
+ Keep this in sync with pkg/api/prompt.md (the copy the Go server embeds).
6
+ -->
7
+ # Set this repo up on NEO App
8
+
9
+ **Version:** 0.4.0 · **Last updated:** 2026-10-12
10
+
11
+ These are the official setup instructions from NEO App (Biznet Gio's managed-app
12
+ platform). If you are an AI coding agent, complete the steps below yourself by running
13
+ the commands directly — only stop where a step explicitly needs the human (handing over
14
+ their API key, or choosing whether to create or link a project). Never put an API key
15
+ into chat, repository files, the skill, or MCP configuration.
16
+
17
+ ## What NEO App runs
18
+
19
+ Web services, background workers, scheduled jobs, static sites, managed databases
20
+ (PostgreSQL/MySQL/…), queues (NATS), and object storage — on managed Kubernetes, served
21
+ over HTTPS with automatic TLS. It is **not** for GPU training or for apps that need
22
+ several containers running side by side at runtime (a single app container alongside
23
+ managed databases is fine). If the repo has no server to put behind a URL (a library or
24
+ a CLI tool), say so and stop.
25
+
26
+ ## 1. Install the CLI, skill, and MCP server
27
+
28
+ One command, every OS and shell (Node 18+). Installs the NEO App skill for your coding
29
+ agent, registers the `neo-app` MCP server, and authenticates. Idempotent — safe to
30
+ re-run. Targets **production** by default.
31
+
32
+ ```bash
33
+ npx -y @biznetgio/neo-app@latest agent setup
34
+ ```
35
+
36
+ For the **staging** deployment, use its explicit form:
37
+
38
+ ```bash
39
+ NODE_ENV=development npx -y @biznetgio/neo-app@latest agent setup --staging
40
+ ```
41
+
42
+ If the prompt that sent you here named a project id, link it in the same step:
43
+
44
+ ```bash
45
+ npx -y @biznetgio/neo-app@latest agent setup --project <project-id>
46
+ ```
47
+
48
+ ## 2. Authenticate (human step)
49
+
50
+ `agent setup` runs this when no credential is stored. The human provides their NEO App
51
+ API key when prompted — create one in the portal under **Settings → API tokens**.
52
+ The CLI reads it directly and stores it user-only (`~/.config/neo-app/credentials.json`,
53
+ mode 0600); it never lands in agent/MCP configuration. Relay the portal link to the
54
+ human and let them create and paste the key into the CLI prompt — do not ask them to
55
+ paste it into the chat.
56
+
57
+ ```bash
58
+ npx -y @biznetgio/neo-app@latest login # interactive (prompts for the key)
59
+ ```
60
+
61
+ Headless (VM, SSH, CI): set `NEO_APP_TOKEN=kap_...` and pass `--yes` — the key is read
62
+ from the environment and never written to disk.
63
+
64
+ ## 3. Create or link a project
65
+
66
+ Pick one. Creating is the default for a fresh repo; link when the human names an existing
67
+ project.
68
+
69
+ ```bash
70
+ npx -y @biznetgio/neo-app@latest link <project-id> # existing project
71
+ npx -y @biznetgio/neo-app@latest project list # find project ids
72
+ ```
73
+
74
+ ## 4. Verify
75
+
76
+ ```bash
77
+ npx -y @biznetgio/neo-app@latest status # account, org, environment, linked project
78
+ npx -y @biznetgio/neo-app@latest agent doctor # CLI, API, auth, MCP wiring, skill, perms
79
+ ```
80
+
81
+ Restart your coding agent so it loads the MCP server, then call the `neo_whoami` MCP
82
+ tool. Report the connected account, organization, environment, detected agent
83
+ integrations, and any unresolved errors. **Do not deploy unless the human separately
84
+ asked for a deployment.**
85
+
86
+ ## Environment
87
+
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.
91
+
92
+ ## Uninstall
93
+
94
+ ```bash
95
+ npx -y @biznetgio/neo-app@latest agent uninstall
96
+ ```
97
+
98
+ Removes only the files and configuration NEO App created.
99
+
100
+ ## Safety
101
+
102
+ Do not disable your agent's sandboxing, approval prompts, or TLS verification to
103
+ complete setup. Production, scaling, public-endpoint and paid-resource actions stay
104
+ subject to NEO App's server-side approval policy — a `pending_approval` result is that
105
+ policy working, not an error.
package/src/agents.js ADDED
@@ -0,0 +1,149 @@
1
+ // Detect supported coding agents and register/unregister the `neo-app` MCP server in
2
+ // their config — idempotently, backing up first, and WITHOUT ever writing a token.
3
+ //
4
+ // Codex: ~/.codex/config.toml -> [mcp_servers.neo-app] (TOML)
5
+ // Claude Code: ~/.claude.json -> mcpServers["neo-app"] (JSON, user scope)
6
+
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
10
+ import { onPath, backupFile } from './util.js';
11
+
12
+ const MCP_NAME = 'neo-app';
13
+
14
+ export function agentDefs() {
15
+ return [
16
+ { id: 'codex', name: 'Codex', bin: 'codex', configPath: join(homedir(), '.codex', 'config.toml') },
17
+ { id: 'claude-code', name: 'Claude Code', bin: 'claude', configPath: join(homedir(), '.claude.json') },
18
+ ];
19
+ }
20
+
21
+ // detectAgents marks each agent present if its config file exists OR its CLI is on PATH.
22
+ export function detectAgents() {
23
+ return agentDefs().map((a) => ({ ...a, present: existsSync(a.configPath) || onPath(a.bin) }));
24
+ }
25
+
26
+ // mcpLaunchSpec picks how the agent should start the MCP server: the installed
27
+ // `neo-app` binary if present, else npx (works with the npx-only install path). The
28
+ // env block carries only the non-secret target (NEO_APP_ENV / NEO_APP_URL); the API
29
+ // key is read at runtime from the credential store, never placed here.
30
+ export function mcpLaunchSpec({ env, url }) {
31
+ const spec = onPath('neo-app')
32
+ ? { command: 'neo-app', args: ['mcp'] }
33
+ : { command: 'npx', args: ['-y', '@biznetgio/neo-app@latest', 'mcp'] };
34
+ spec.env = url ? { NEO_APP_URL: url } : { NEO_APP_ENV: env };
35
+ return spec;
36
+ }
37
+
38
+ // ---- Codex (TOML) ----------------------------------------------------------
39
+
40
+ export function codexBlock(spec) {
41
+ const argsToml = JSON.stringify(spec.args); // JSON array is valid TOML array of strings
42
+ const envPairs = Object.entries(spec.env)
43
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
44
+ .join(', ');
45
+ return [
46
+ `[mcp_servers.${MCP_NAME}]`,
47
+ `command = ${JSON.stringify(spec.command)}`,
48
+ `args = ${argsToml}`,
49
+ `env = { ${envPairs} }`,
50
+ ].join('\n');
51
+ }
52
+
53
+ // spliceTomlSection replaces (or appends) the [mcp_servers.neo-app] block. The block
54
+ // runs from its header to the next top-level `[...]` header or EOF. Uses an inline
55
+ // env table so the block is a single contiguous section (no sub-headers to track).
56
+ export function spliceTomlSection(content, block) {
57
+ const lines = content.split('\n');
58
+ const start = lines.findIndex((l) => /^\s*\[mcp_servers\.neo-app\]\s*$/.test(l));
59
+ if (start === -1) {
60
+ const sep = content.trim() ? '\n\n' : '';
61
+ return `${content.replace(/\s*$/, '')}${sep}${block}\n`;
62
+ }
63
+ let end = start + 1;
64
+ while (end < lines.length && !/^\s*\[/.test(lines[end])) end += 1;
65
+ const before = lines.slice(0, start).join('\n').replace(/\s*$/, '');
66
+ const after = lines.slice(end).join('\n').replace(/^\s*/, '');
67
+ return [before, block, after].filter((s) => s.length).join('\n\n').replace(/\s*$/, '') + '\n';
68
+ }
69
+
70
+ function writeCodex(configPath, spec) {
71
+ mkdirSync(join(configPath, '..'), { recursive: true });
72
+ const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
73
+ const bak = backupFile(configPath);
74
+ writeFileSync(configPath, spliceTomlSection(existing, codexBlock(spec)));
75
+ return { bak };
76
+ }
77
+
78
+ function removeCodex(configPath) {
79
+ if (!existsSync(configPath)) return { removed: false, bak: null };
80
+ const content = readFileSync(configPath, 'utf8');
81
+ if (!/^\s*\[mcp_servers\.neo-app\]\s*$/m.test(content)) return { removed: false, bak: null };
82
+ const bak = backupFile(configPath);
83
+ const lines = content.split('\n');
84
+ const start = lines.findIndex((l) => /^\s*\[mcp_servers\.neo-app\]\s*$/.test(l));
85
+ let end = start + 1;
86
+ while (end < lines.length && !/^\s*\[/.test(lines[end])) end += 1;
87
+ const out = [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '') + '\n';
88
+ writeFileSync(configPath, out);
89
+ return { removed: true, bak };
90
+ }
91
+
92
+ // ---- Claude Code (JSON) ----------------------------------------------------
93
+
94
+ function writeClaude(configPath, spec) {
95
+ let data = {};
96
+ if (existsSync(configPath)) {
97
+ try {
98
+ data = JSON.parse(readFileSync(configPath, 'utf8'));
99
+ } catch {
100
+ throw new Error(`${configPath} is not valid JSON — fix or remove it, then retry`);
101
+ }
102
+ }
103
+ const bak = backupFile(configPath);
104
+ data.mcpServers = data.mcpServers || {};
105
+ data.mcpServers[MCP_NAME] = { command: spec.command, args: spec.args, env: spec.env };
106
+ mkdirSync(join(configPath, '..'), { recursive: true });
107
+ writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n');
108
+ return { bak };
109
+ }
110
+
111
+ function removeClaude(configPath) {
112
+ if (!existsSync(configPath)) return { removed: false, bak: null };
113
+ let data;
114
+ try {
115
+ data = JSON.parse(readFileSync(configPath, 'utf8'));
116
+ } catch {
117
+ return { removed: false, bak: null };
118
+ }
119
+ if (!data.mcpServers || !data.mcpServers[MCP_NAME]) return { removed: false, bak: null };
120
+ const bak = backupFile(configPath);
121
+ delete data.mcpServers[MCP_NAME];
122
+ writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n');
123
+ return { removed: true, bak };
124
+ }
125
+
126
+ // registerMcp writes the neo-app MCP entry for one agent. Returns { bak }.
127
+ export function registerMcp(agent, spec) {
128
+ if (agent.id === 'codex') return writeCodex(agent.configPath, spec);
129
+ if (agent.id === 'claude-code') return writeClaude(agent.configPath, spec);
130
+ throw new Error(`unknown agent: ${agent.id}`);
131
+ }
132
+
133
+ export function unregisterMcp(agent) {
134
+ if (agent.id === 'codex') return removeCodex(agent.configPath);
135
+ if (agent.id === 'claude-code') return removeClaude(agent.configPath);
136
+ throw new Error(`unknown agent: ${agent.id}`);
137
+ }
138
+
139
+ // hasMcp reports whether the neo-app MCP entry is already present (for doctor).
140
+ export function hasMcp(agent) {
141
+ if (!existsSync(agent.configPath)) return false;
142
+ const content = readFileSync(agent.configPath, 'utf8');
143
+ if (agent.id === 'codex') return /^\s*\[mcp_servers\.neo-app\]\s*$/m.test(content);
144
+ try {
145
+ return !!JSON.parse(content).mcpServers?.[MCP_NAME];
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
package/src/api.js ADDED
@@ -0,0 +1,76 @@
1
+ // Thin REST client for the NEO App control-plane. Auth is a single scheme:
2
+ // Authorization: Bearer <kap_ token> (confirmed in pkg/api/middleware.go)
3
+ // All routes are under /api/v1. A 202 + {status:"pending_approval"} is a normal
4
+ // result (org approval policy), NOT an error, so only >=400 throws.
5
+
6
+ export class ApiError extends Error {
7
+ constructor(status, message, body) {
8
+ super(message);
9
+ this.status = status;
10
+ this.body = body;
11
+ }
12
+ }
13
+
14
+ export class NeoApi {
15
+ constructor({ baseUrl, token }) {
16
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
17
+ this.token = token;
18
+ }
19
+
20
+ async request(method, path, { query, body, accept } = {}) {
21
+ let url = this.baseUrl + path;
22
+ if (query) {
23
+ const qs = new URLSearchParams(
24
+ Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
25
+ ).toString();
26
+ if (qs) url += `?${qs}`;
27
+ }
28
+ const headers = { Accept: accept || 'application/json' };
29
+ if (this.token) headers.Authorization = `Bearer ${this.token}`;
30
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
31
+
32
+ const res = await fetch(url, {
33
+ method,
34
+ headers,
35
+ body: body !== undefined ? JSON.stringify(body) : undefined,
36
+ });
37
+
38
+ const ctype = res.headers.get('content-type') || '';
39
+ const isJson = ctype.includes('application/json');
40
+ const payload = isJson ? await res.json().catch(() => null) : await res.text();
41
+
42
+ if (res.status >= 400) {
43
+ const msg = (isJson && payload && payload.error) || res.statusText || `HTTP ${res.status}`;
44
+ throw new ApiError(res.status, msg, payload);
45
+ }
46
+ return { status: res.status, data: payload };
47
+ }
48
+
49
+ get(path, opts) {
50
+ return this.request('GET', path, opts);
51
+ }
52
+ post(path, body, opts) {
53
+ return this.request('POST', path, { ...opts, body });
54
+ }
55
+ put(path, body, opts) {
56
+ return this.request('PUT', path, { ...opts, body });
57
+ }
58
+ del(path, opts) {
59
+ return this.request('DELETE', path, opts);
60
+ }
61
+
62
+ // whoami composes the two calls the server needs for identity (no combined endpoint).
63
+ async whoami() {
64
+ const me = (await this.get('/api/v1/auth/me')).data;
65
+ const orgs = (await this.get('/api/v1/orgs')).data?.organizations || [];
66
+ return { user: me, orgs };
67
+ }
68
+
69
+ // resolveOrgId returns the preferred org id, or the first org the user belongs to.
70
+ async resolveOrgId(preferred) {
71
+ if (preferred) return preferred;
72
+ const orgs = (await this.get('/api/v1/orgs')).data?.organizations || [];
73
+ if (!orgs.length) throw new ApiError(404, 'no organizations for this account');
74
+ return orgs[0].id;
75
+ }
76
+ }
@@ -0,0 +1,118 @@
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).
4
+
5
+ import { basename } from 'node:path';
6
+ import { resolveConfig } from '../config.js';
7
+ import { getCredential } from '../creds.js';
8
+ import { NeoApi } from '../api.js';
9
+ import { readLink } from '../link.js';
10
+ import { log, ok, fail, step, c } from '../util.js';
11
+
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
+ );
20
+ }
21
+
22
+ function pending(res) {
23
+ return res.status === 202 && res.data && res.data.status === 'pending_approval';
24
+ }
25
+
26
+ export async function deploy(opts = {}) {
27
+ const cfg = resolveConfig(opts);
28
+ const cred = getCredential(cfg.baseUrl);
29
+ if (!cred?.token) {
30
+ fail(`not authenticated for ${cfg.baseUrl} — run 'neo-app login'`);
31
+ process.exit(1);
32
+ }
33
+ const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
34
+
35
+ const projectId = opts.project || readLink()?.projectId;
36
+ if (!projectId) {
37
+ fail("no project — run 'neo-app link <project-id>' or pass --project <id>");
38
+ process.exit(1);
39
+ }
40
+
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>');
45
+ process.exit(2);
46
+ }
47
+
48
+ const name = sanitizeName(
49
+ opts.name || (image ? image.split('/').pop().split(':')[0] : basename(repo, '.git'))
50
+ );
51
+ const environment = cfg.env === 'staging' ? 'staging' : 'production';
52
+
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);
58
+
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}).`);
86
+ return;
87
+ }
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) : ''}`);
97
+ } else {
98
+ fail(`deployment ${result}${deployId ? ` (neo-app logs via 'neo_get_logs' or the portal)` : ''}`);
99
+ process.exitCode = 1;
100
+ }
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';
118
+ }
@@ -0,0 +1,86 @@
1
+ // `neo-app agent doctor` — validate the whole setup and give actionable results.
2
+
3
+ import { resolveConfig } from '../config.js';
4
+ import { getCredential, storeInfo } from '../creds.js';
5
+ import { NeoApi } from '../api.js';
6
+ import { detectAgents, hasMcp } from '../agents.js';
7
+ import { installedSkillVersion, skillVersion } from '../skill.js';
8
+ import { readLink } from '../link.js';
9
+ import { ok, fail, warn, onPath, c, log } from '../util.js';
10
+ import { readFileSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, join } from 'node:path';
13
+
14
+ function pkgVersion() {
15
+ try {
16
+ const p = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
17
+ return JSON.parse(readFileSync(p, 'utf8')).version;
18
+ } catch {
19
+ return 'unknown';
20
+ }
21
+ }
22
+
23
+ export async function doctor(opts = {}) {
24
+ const cfg = resolveConfig(opts);
25
+ let failures = 0;
26
+ const bad = (m) => { failures += 1; fail(m); };
27
+
28
+ ok(`CLI ${pkgVersion()}`);
29
+
30
+ // API reachability + TLS (open root endpoint, no auth)
31
+ try {
32
+ const res = await fetch(`${cfg.baseUrl}/healthz`);
33
+ if (res.ok) ok(`API ${cfg.baseUrl}`);
34
+ else bad(`API ${cfg.baseUrl} returned HTTP ${res.status}`);
35
+ } catch (e) {
36
+ bad(`API ${cfg.baseUrl} unreachable: ${e.message}`);
37
+ }
38
+
39
+ // Auth
40
+ const cred = getCredential(cfg.baseUrl);
41
+ let who = null;
42
+ if (!cred?.token) {
43
+ bad(`not authenticated for ${cfg.env} — run 'neo-app login'`);
44
+ } else {
45
+ try {
46
+ who = await (new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token })).whoami();
47
+ ok(`authenticated as ${who.user?.email || who.user?.name}`);
48
+ } catch (e) {
49
+ bad(`token invalid: ${e.message}`);
50
+ }
51
+ }
52
+
53
+ // Org / project / env
54
+ const org = who?.orgs?.[0];
55
+ if (org) ok(`org ${org.name} (${org.role})`);
56
+ const link = readLink();
57
+ if (link?.projectId) ok(`project ${link.name || link.projectId} / ${link.environment}`);
58
+ else warn(`no linked project (optional) — 'neo-app agent setup --project <id>'`);
59
+
60
+ // Agents + MCP
61
+ for (const a of detectAgents()) {
62
+ if (!a.present) { warn(`${a.name} not detected`); continue; }
63
+ if (hasMcp(a)) ok(`${a.name} MCP configured`);
64
+ else bad(`${a.name} detected but neo-app MCP not configured — run 'neo-app config install-mcp'`);
65
+ }
66
+
67
+ // Skill
68
+ const installed = installedSkillVersion();
69
+ if (!installed) bad(`skill not installed — run 'neo-app agent setup'`);
70
+ else if (installed !== skillVersion()) warn(`skill ${installed} installed; ${skillVersion()} available — rerun setup`);
71
+ else ok(`skill ${installed}`);
72
+
73
+ // Credential file permissions
74
+ const info = storeInfo();
75
+ if (cred?.source === 'env') ok('credentials from NEO_APP_TOKEN (env)');
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}'`);
79
+
80
+ // Required executables
81
+ onPath('npx') ? ok('npx available') : warn("npx not found — needed for the npx install path");
82
+
83
+ log('');
84
+ if (failures) { fail(`${failures} check(s) failed`); process.exitCode = 1; }
85
+ else log(c.green('All checks passed.'));
86
+ }