@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 +68 -2
- package/bin/neo-app.js +7 -0
- package/package.json +31 -4
- package/prompt.md +105 -0
- package/src/agents.js +149 -0
- package/src/api.js +76 -0
- package/src/commands/deploy.js +118 -0
- package/src/commands/doctor.js +86 -0
- package/src/commands/install-mcp.js +47 -0
- package/src/commands/link.js +49 -0
- package/src/commands/login.js +45 -0
- package/src/commands/setup.js +100 -0
- package/src/commands/status.js +39 -0
- package/src/commands/uninstall.js +34 -0
- package/src/config.js +50 -0
- package/src/creds.js +94 -0
- package/src/index.js +118 -0
- package/src/link.js +34 -0
- package/src/mcp/server.js +56 -0
- package/src/mcp/tools.js +195 -0
- package/src/skill/SKILL.md +50 -0
- package/src/skill.js +59 -0
- package/src/util.js +91 -0
package/README.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @biznetgio/neo-app
|
|
2
2
|
|
|
3
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@biznetgio/neo-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|