@biznetgio/neo-app 0.4.79 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.79",
3
+ "version": "0.4.80",
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 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.79 · **Last updated:** 2026-11-09
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` renders the DB-specific help (its usage() lists the lifecycle), not the
113
- // generic top-level help — #149 retest CLI discoverability caveat. Other known commands
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.