@biznetgio/neo-app 0.4.78 → 0.4.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/prompt.md +1 -1
- package/src/commands/template.js +168 -0
- package/src/index.js +11 -5
- package/src/mcp/tools.js +6 -5
package/package.json
CHANGED
package/prompt.md
CHANGED
|
@@ -6,7 +6,7 @@ Keep this in sync with cli/prompt.md (the npm package's bundled copy).
|
|
|
6
6
|
-->
|
|
7
7
|
# Set this repo up on NEO App
|
|
8
8
|
|
|
9
|
-
**Version:** 0.4.
|
|
9
|
+
**Version:** 0.4.80 · **Last updated:** 2026-11-09
|
|
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
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// `neo-app template` — terminal parity for the one-click catalogue (#99 CLI parity).
|
|
2
|
+
// list/show are read-only; plan is a zero-write live cost preview; apply deploys through
|
|
3
|
+
// the SAME server-enforced publication gate as the Portal/MCP — a blocked/draft template
|
|
4
|
+
// is refused (409), so a CLI user gets the same catalogue, review, and gate everywhere.
|
|
5
|
+
|
|
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 { formatCost } from '../cost.js';
|
|
11
|
+
import { log, ok, fail, c } from '../util.js';
|
|
12
|
+
|
|
13
|
+
function connect(opts) {
|
|
14
|
+
const cfg = resolveConfig(opts);
|
|
15
|
+
const cred = getCredential(cfg.baseUrl);
|
|
16
|
+
if (!cred?.token) { fail(`not authenticated for ${cfg.baseUrl} — run 'neo-app login'`); process.exit(1); }
|
|
17
|
+
return new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function requireProject(opts) {
|
|
21
|
+
const projectId = opts.project || readLink()?.projectId;
|
|
22
|
+
if (!projectId) { fail("no linked project — run 'neo-app link <project-id>' or pass --project <id>"); process.exit(1); }
|
|
23
|
+
return projectId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Default list is deployable-only (approved); --all / --view all includes blocked
|
|
27
|
+
// templates with their status (research view), matching GET /templates?view=all.
|
|
28
|
+
async function fetchCatalog(api, all) {
|
|
29
|
+
const q = all ? '?view=all' : '';
|
|
30
|
+
return (await api.get('/api/v1/templates' + q)).data?.templates || [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findTemplate(list, name) {
|
|
34
|
+
return list.find((t) => t.name === name || t.title === name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Map a template's Creates graph into /estimate resource specs (mirrors the Portal/#170),
|
|
38
|
+
// so the CLI prices a template from the same authoritative price book — never hard-coded.
|
|
39
|
+
export function estimateResources(t) {
|
|
40
|
+
return (t.creates || []).map((cr) => {
|
|
41
|
+
const volumeSize = cr.minStorageGB ? `${cr.minStorageGB}Gi` : undefined;
|
|
42
|
+
if (cr.kind === 'app') return { kind: 'app', name: t.name, computeTier: cr.computeTier || 'micro', volumeSize };
|
|
43
|
+
if (cr.kind === 'database') return { kind: 'database', name: `${t.name}-db`, engine: cr.engine, computeTier: cr.computeTier, volumeSize };
|
|
44
|
+
if (cr.kind === 'queue') return { kind: 'queue', name: `${t.name}-queue`, computeTier: cr.computeTier, volumeSize };
|
|
45
|
+
if (cr.kind === 'bucket') return { kind: 'bucket', name: `${t.name}-bucket`, volumeSize };
|
|
46
|
+
return { kind: cr.kind, name: t.name };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function usage() {
|
|
51
|
+
log(`${c.bold('neo-app template')} — one-click application catalogue\n`);
|
|
52
|
+
log(c.bold('Commands:'));
|
|
53
|
+
log(' neo-app template list [--all] [--json] List deployable templates (--all incl. blocked)');
|
|
54
|
+
log(' neo-app template show <name> [--json] Show one template manifest + status');
|
|
55
|
+
log(' neo-app template plan <name> --project <id> [--json] Live cost preview (zero-write)');
|
|
56
|
+
log(' neo-app template apply <name> --project <id> --confirm Deploy (gated; a blocked template is refused)');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function printCost(api, t, projectId) {
|
|
60
|
+
const resources = estimateResources(t);
|
|
61
|
+
if (!resources.length) return null;
|
|
62
|
+
let cost;
|
|
63
|
+
try { cost = (await api.estimate({ projectId }, { resources })).cost; }
|
|
64
|
+
catch (e) { log(c.dim(` (live price unavailable: ${e.message || e})`)); return null; }
|
|
65
|
+
log(c.bold(` Estimated cost: ${formatCost(cost)}`));
|
|
66
|
+
for (const l of cost.breakdown || []) log(` · ${l.item}: ${l.monthly} ${cost.currency}/mo (${l.hourly}/h)`);
|
|
67
|
+
if (cost.priceBookVersion) log(c.dim(` price book: ${cost.priceBookVersion}`));
|
|
68
|
+
return cost;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function cmdList(api, opts) {
|
|
72
|
+
const all = !!(opts.all || opts.view === 'all');
|
|
73
|
+
const cat = await fetchCatalog(api, all);
|
|
74
|
+
if (opts.json) return void process.stdout.write(JSON.stringify({ templates: cat }, null, 2) + '\n');
|
|
75
|
+
if (!cat.length) return void ok('no templates');
|
|
76
|
+
for (const t of cat) {
|
|
77
|
+
const status = (t.status || (t.deployable ? 'approved' : 'unknown')).padEnd(10);
|
|
78
|
+
const flag = t.deployable ? c.dim('deployable') : c.bold('BLOCKED ');
|
|
79
|
+
const src = t.source?.image ? `${t.source.image}:${t.source.version}` : (t.source?.repo || '');
|
|
80
|
+
log(` ${c.bold((t.title || t.name).padEnd(16))} ${status} ${flag} ${c.dim(src)}`);
|
|
81
|
+
}
|
|
82
|
+
log(c.dim('\nReview a template before deploy: neo-app template plan <name> --project <id>'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function cmdShow(api, name, opts) {
|
|
86
|
+
const cat = await fetchCatalog(api, true); // show works for blocked too (research view)
|
|
87
|
+
const t = findTemplate(cat, name);
|
|
88
|
+
if (!t) { fail(`template not found: ${name}`); process.exit(2); }
|
|
89
|
+
if (opts.json) return void process.stdout.write(JSON.stringify(t, null, 2) + '\n');
|
|
90
|
+
log(`${c.bold(t.title || t.name)} ${c.dim(t.category || '')}`);
|
|
91
|
+
log(` status ${t.status}${t.deployable ? '' : c.bold(' (NOT deployable)')}`);
|
|
92
|
+
if (t.blockedReason) log(` unblock ${t.blockedReason}`);
|
|
93
|
+
if (t.description) log(` ${t.description}`);
|
|
94
|
+
if (t.source) log(` source ${t.source.image || t.source.repo}:${t.source.version}${t.source.digest ? '@' + t.source.digest : ''}`);
|
|
95
|
+
if (t.license) log(` license ${t.license}`);
|
|
96
|
+
if (t.reviewedHash) log(` reviewedHash ${t.reviewedHash}`);
|
|
97
|
+
for (const cr of t.creates || []) log(` creates ${cr.kind}${cr.engine ? ' (' + cr.engine + ')' : ''}${cr.minStorageGB ? ' ≥' + cr.minStorageGB + 'GiB' : ''}${cr.note ? ' — ' + cr.note : ''}`);
|
|
98
|
+
for (const r of t.publicRoutes || []) log(` route ${r}`);
|
|
99
|
+
for (const lim of t.limitations || []) log(c.dim(` note ${lim}`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function cmdPlan(api, name, opts) {
|
|
103
|
+
const projectId = requireProject(opts);
|
|
104
|
+
const cat = await fetchCatalog(api, true);
|
|
105
|
+
const t = findTemplate(cat, name);
|
|
106
|
+
if (!t) { fail(`template not found: ${name}`); process.exit(2); }
|
|
107
|
+
if (!t.deployable) { fail(`template '${t.name}' is not deployable (status: ${t.status}). ${t.blockedReason || ''}`.trim()); process.exit(1); }
|
|
108
|
+
if (opts.json) {
|
|
109
|
+
const resources = estimateResources(t);
|
|
110
|
+
let cost = null;
|
|
111
|
+
try { cost = (await api.estimate({ projectId }, { resources })).cost; } catch { /* surfaced as null */ }
|
|
112
|
+
return void process.stdout.write(JSON.stringify({ template: t.name, projectId, creates: t.creates || [], cost }, null, 2) + '\n');
|
|
113
|
+
}
|
|
114
|
+
log(c.bold(`Plan: ${t.title || t.name} → project ${projectId}`));
|
|
115
|
+
for (const cr of t.creates || []) log(` creates ${cr.kind}${cr.engine ? ' (' + cr.engine + ')' : ''}${cr.minStorageGB ? ' ≥' + cr.minStorageGB + 'GiB' : ''}`);
|
|
116
|
+
await printCost(api, t, projectId);
|
|
117
|
+
ok(`zero-write preview — nothing created. Deploy: neo-app template apply ${t.name} --project ${projectId} --confirm`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function cmdApply(api, name, opts) {
|
|
121
|
+
const projectId = requireProject(opts);
|
|
122
|
+
const cat = await fetchCatalog(api, true);
|
|
123
|
+
const t = findTemplate(cat, name);
|
|
124
|
+
if (!t) { fail(`template not found: ${name}`); process.exit(2); }
|
|
125
|
+
// Client-side gate mirrors the server (which also rejects with 409) so a blocked
|
|
126
|
+
// template never even POSTs a deploy.
|
|
127
|
+
if (!t.deployable) { fail(`template '${t.name}' is not deployable (status: ${t.status}). ${t.blockedReason || 'not approved'}`); process.exit(1); }
|
|
128
|
+
// Cost consent: without --confirm we show the price and create nothing.
|
|
129
|
+
if (!opts.confirm && !opts.yes) {
|
|
130
|
+
log(c.bold(`About to deploy ${t.title || t.name} into project ${projectId} (billable):`));
|
|
131
|
+
await printCost(api, t, projectId);
|
|
132
|
+
fail('re-run with --confirm to deploy. Nothing was created.');
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const { status, data } = await api.post(`/api/v1/projects/${projectId}/templates/${encodeURIComponent(t.name)}`, { name: opts.name || t.name });
|
|
137
|
+
if (status === 202 || data?.approvalId) { ok(`deploy requires approval — pending_approval${data?.approvalId ? ' (' + data.approvalId + ')' : ''}`); return; }
|
|
138
|
+
ok(`deployed ${t.name}${data?.url ? ' → ' + data.url : ''}`);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// A blocked template reaches the server gate as 409 — surface it verbatim.
|
|
141
|
+
fail(`deploy failed: ${e.message || e}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function template(sub, opts = {}, positionals = []) {
|
|
147
|
+
if (!sub || sub === 'help' || opts.help) return void usage();
|
|
148
|
+
const api = connect(opts);
|
|
149
|
+
if (sub === 'list' || sub === 'ls') return void (await cmdList(api, opts));
|
|
150
|
+
if (sub === 'show' || sub === 'get') {
|
|
151
|
+
const n = positionals[0];
|
|
152
|
+
if (!n) { fail('usage: neo-app template show <name>'); process.exit(2); }
|
|
153
|
+
return void (await cmdShow(api, n, opts));
|
|
154
|
+
}
|
|
155
|
+
if (sub === 'plan') {
|
|
156
|
+
const n = positionals[0];
|
|
157
|
+
if (!n) { fail('usage: neo-app template plan <name> --project <id>'); process.exit(2); }
|
|
158
|
+
return void (await cmdPlan(api, n, opts));
|
|
159
|
+
}
|
|
160
|
+
if (sub === 'apply' || sub === 'deploy') {
|
|
161
|
+
const n = positionals[0];
|
|
162
|
+
if (!n) { fail('usage: neo-app template apply <name> --project <id> --confirm'); process.exit(2); }
|
|
163
|
+
return void (await cmdApply(api, n, opts));
|
|
164
|
+
}
|
|
165
|
+
fail(`unknown template subcommand: ${sub}`);
|
|
166
|
+
usage();
|
|
167
|
+
process.exit(2);
|
|
168
|
+
}
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,8 @@ ${c.bold('Commands')}
|
|
|
22
22
|
project list List projects in your organization (find ids to link)
|
|
23
23
|
db [list|get|...] Managed database lifecycle (PostgreSQL/MySQL/Redis);
|
|
24
24
|
list/get run here, the rest via the MCP server
|
|
25
|
+
template [list|show|plan|apply] One-click app catalogue; apply is gated
|
|
26
|
+
(a blocked template is refused), plan is a zero-write cost preview
|
|
25
27
|
status | whoami Show connected account, org, environment, linked project
|
|
26
28
|
mcp Run the MCP server: local stdio (default), or hosted
|
|
27
29
|
Streamable HTTP + SSE with --http [--port <n>] [--host <h>]
|
|
@@ -83,6 +85,9 @@ const options = {
|
|
|
83
85
|
replicas: { type: 'string' },
|
|
84
86
|
queue: { type: 'boolean' },
|
|
85
87
|
cron: { type: 'boolean' },
|
|
88
|
+
all: { type: 'boolean' },
|
|
89
|
+
view: { type: 'string' },
|
|
90
|
+
confirm: { type: 'boolean' },
|
|
86
91
|
http: { type: 'boolean' },
|
|
87
92
|
sse: { type: 'boolean' },
|
|
88
93
|
host: { type: 'string' },
|
|
@@ -108,11 +113,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
108
113
|
// Root help, and `--help` on a KNOWN command, print usage. An UNKNOWN command must
|
|
109
114
|
// still error (nonzero exit) even with `--help`, so a bad command isn't silently
|
|
110
115
|
// accepted (JNG #97). Per-subcommand help is tracked in #89.
|
|
111
|
-
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'db', 'version', 'help']);
|
|
112
|
-
// `db --help`
|
|
113
|
-
// generic top-level help — #149
|
|
114
|
-
// still fall back to the root HELP (per-subcommand help tracked in #89).
|
|
115
|
-
if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd) && cmd !== 'db')) return void console.log(HELP);
|
|
116
|
+
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'db', 'template', 'templates', 'version', 'help']);
|
|
117
|
+
// `db`/`template --help` render their OWN command help (their usage() lists the
|
|
118
|
+
// subcommands), not the generic top-level help — #149/#99 CLI discoverability. Other
|
|
119
|
+
// known commands still fall back to the root HELP (per-subcommand help tracked in #89).
|
|
120
|
+
if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd) && cmd !== 'db' && cmd !== 'template' && cmd !== 'templates')) return void console.log(HELP);
|
|
116
121
|
|
|
117
122
|
try {
|
|
118
123
|
// `agent setup` and `setup agent` both work (parity with InstaCloud's `setup agent`).
|
|
@@ -129,6 +134,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
129
134
|
if (cmd === 'project' && (sub === 'list' || sub === 'ls')) return void (await (await import('./commands/link.js')).listProjects(opts));
|
|
130
135
|
if (cmd === 'status' || cmd === 'whoami') return void (await (await import('./commands/status.js')).status(opts));
|
|
131
136
|
if (cmd === 'db') return void (await (await import('./commands/db.js')).db(sub, opts, positionals.slice(2)));
|
|
137
|
+
if (cmd === 'template' || cmd === 'templates') return void (await (await import('./commands/template.js')).template(sub, opts, positionals.slice(2)));
|
|
132
138
|
if (cmd === 'mcp') {
|
|
133
139
|
const mcp = await import('./mcp/server.js');
|
|
134
140
|
// Hosted mode: Streamable HTTP + SSE over one listener (auth per request). Default: stdio.
|
package/src/mcp/tools.js
CHANGED
|
@@ -1048,16 +1048,17 @@ export const TOOLS = [
|
|
|
1048
1048
|
},
|
|
1049
1049
|
{
|
|
1050
1050
|
name: 'neo_list_templates',
|
|
1051
|
-
description: 'List deployable
|
|
1051
|
+
description: 'List deployable (APPROVED) templates with their manifest: the Creates/Reuses resource graph, pinned source/version + reviewedHash, status, min resources, public routes, and limitations — enough to show the graph and a live estimate before deploy. Only approved templates are returned by default (blocked/draft/retired are NOT deployable and are excluded); pass view:"all" for the research view that also includes blocked templates with their status + blockedReason. WordPress was retired.',
|
|
1052
1052
|
mutating: false,
|
|
1053
|
-
schema: { type: 'object', properties: {} },
|
|
1054
|
-
async call(api) {
|
|
1055
|
-
|
|
1053
|
+
schema: { type: 'object', properties: { view: { type: 'string', enum: ['deployable', 'all'], description: 'deployable (default — approved only) | all (research view incl. blocked, with status)' } } },
|
|
1054
|
+
async call(api, args) {
|
|
1055
|
+
const q = args && args.view === 'all' ? '?view=all' : '';
|
|
1056
|
+
return (await api.get('/api/v1/templates' + q)).data;
|
|
1056
1057
|
},
|
|
1057
1058
|
},
|
|
1058
1059
|
{
|
|
1059
1060
|
name: 'neo_deploy_template',
|
|
1060
|
-
description: 'Deploy
|
|
1061
|
+
description: 'Deploy an APPROVED template (e.g. n8n) into a project. Only approved templates deploy: a blocked/draft template returns 409 with the requirements to unblock (e.g. documenso/listmonk need SMTP), and a retired template (e.g. wordpress) returns 410 "no longer available". May return pending_approval.',
|
|
1061
1062
|
mutating: true,
|
|
1062
1063
|
schema: {
|
|
1063
1064
|
type: 'object',
|