novence 0.1.3 → 0.2.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 +14 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +182 -0
- package/dist/commands/data.d.ts +2 -0
- package/dist/commands/data.js +62 -0
- package/dist/commands/deploy.js +8 -3
- package/dist/commands/domains.js +33 -4
- package/dist/commands/keys.js +15 -1
- package/dist/commands/login.js +9 -3
- package/dist/commands/open.js +1 -13
- package/dist/commands/rollback.js +1 -1
- package/dist/commands/site-mcp.d.ts +2 -0
- package/dist/commands/site-mcp.js +57 -0
- package/dist/index.js +33 -1
- package/dist/lib/auth.js +19 -3
- package/dist/lib/browser.d.ts +2 -0
- package/dist/lib/browser.js +13 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -75,13 +75,27 @@ novence checks [project] # Lighthouse / axe / link results for the last dep
|
|
|
75
75
|
novence domains add <host> # attach a custom domain, prints the DNS records
|
|
76
76
|
novence domains status # custom domain state
|
|
77
77
|
novence keys list # API keys on the account
|
|
78
|
+
novence keys create <name> # extra named key (e.g. ci) that survives login --recover
|
|
78
79
|
novence keys revoke <id> # revoke a key (--force to revoke the one in use)
|
|
79
80
|
novence projects # list projects and their URLs
|
|
80
81
|
novence login # sign in, verify email, or --recover a key
|
|
81
82
|
novence claim # attach an email to an anonymous deploy
|
|
82
83
|
novence whoami # plan, verification, usage against quota
|
|
84
|
+
novence dashboard # local account console (alias: novence console)
|
|
83
85
|
```
|
|
84
86
|
|
|
87
|
+
## Dashboard
|
|
88
|
+
|
|
89
|
+
`novence dashboard` fetches your console-kit, writes `~/.novence/console/novence-console.html`
|
|
90
|
+
(mode 0600), serves it on the first free `127.0.0.1` port from 8765, and opens the browser at
|
|
91
|
+
that exact URL. Press Ctrl+C to stop; the file (it holds a live `mgmt_` token) is deleted.
|
|
92
|
+
|
|
93
|
+
- Uses your saved API key. Without one, it signs you in with an email code instead.
|
|
94
|
+
- `--port <n>` first port to try, `--no-open` print the URL only, `--email <email>` skip the prompt.
|
|
95
|
+
- `--ttl <minutes>` session length 5–480 (default 30). Sessions over 30 minutes can view usage
|
|
96
|
+
and open billing, but cannot manage team members.
|
|
97
|
+
- In the page, **Switch account** signs in with an email code when the session expires.
|
|
98
|
+
|
|
85
99
|
Every command accepts `--json`. Unknown flags are rejected rather than ignored,
|
|
86
100
|
so a typo like `--nmae` fails loudly instead of silently deploying under the
|
|
87
101
|
default name.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CONSOLE_INJECT_MARKER, Novence, requestSessionOtp, verifySessionOtp, } from "@novence/sdk";
|
|
6
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
7
|
+
import { openInBrowser } from "../lib/browser.js";
|
|
8
|
+
import { loadApiKey, resolveApiBase } from "../lib/config.js";
|
|
9
|
+
import { explain } from "../lib/errors.js";
|
|
10
|
+
import { fail, isJsonMode, json, prompt, result, step, withSpin } from "../lib/ui.js";
|
|
11
|
+
const DEFAULT_PORT = 8765;
|
|
12
|
+
/** Ports tried after the first before falling back to an ephemeral one. */
|
|
13
|
+
const PORT_SCAN = 20;
|
|
14
|
+
const CONSOLE_FILE = "novence-console.html";
|
|
15
|
+
const CONSOLE_DIR = join(homedir(), ".novence", "console");
|
|
16
|
+
export async function dashboardCommand(args) {
|
|
17
|
+
const apiBase = resolveApiBase(flagString(args.flags, "api"));
|
|
18
|
+
const startPort = parsePort(flagString(args.flags, "port"));
|
|
19
|
+
const ttlMinutes = parseTtl(flagString(args.flags, "ttl"));
|
|
20
|
+
let loaded;
|
|
21
|
+
try {
|
|
22
|
+
loaded = await loadKit(args, apiBase, ttlMinutes);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
fail(explain(err, { apiBase }));
|
|
26
|
+
}
|
|
27
|
+
const html = injectConsole(loaded.kit, loaded.token);
|
|
28
|
+
const filePath = writeConsoleFile(html);
|
|
29
|
+
const server = createServer((req, res) => serveConsole(req, res, html, server));
|
|
30
|
+
const port = await listenOnFreePort(server, startPort);
|
|
31
|
+
const url = `http://127.0.0.1:${port}/${CONSOLE_FILE}`;
|
|
32
|
+
const shutdown = () => {
|
|
33
|
+
server.close();
|
|
34
|
+
rmSync(filePath, { force: true });
|
|
35
|
+
process.exit(0);
|
|
36
|
+
};
|
|
37
|
+
process.on("SIGINT", shutdown);
|
|
38
|
+
process.on("SIGTERM", shutdown);
|
|
39
|
+
if (isJsonMode()) {
|
|
40
|
+
json({ ok: true, url, port, file: filePath, expiresAt: loaded.kit.session?.expiresAt ?? null });
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
if (port !== startPort)
|
|
44
|
+
step(`Port ${startPort} is busy — using ${port}.`);
|
|
45
|
+
step(`Serving your console on 127.0.0.1:${port}. Press Ctrl+C to stop (deletes ${filePath}).`);
|
|
46
|
+
result(url);
|
|
47
|
+
}
|
|
48
|
+
if (!flagBool(args.flags, "no-open") && !openInBrowser(url)) {
|
|
49
|
+
step("Could not launch a browser — open the URL above.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function loadKit(args, apiBase, ttlMinutes) {
|
|
53
|
+
const apiKey = loadApiKey();
|
|
54
|
+
if (apiKey) {
|
|
55
|
+
const keyClient = new Novence({ apiKey, baseUrl: apiBase });
|
|
56
|
+
if (ttlMinutes === undefined) {
|
|
57
|
+
const kit = await withSpin("Fetching your console…", () => keyClient.account.consoleKit());
|
|
58
|
+
if (!kit.session?.token)
|
|
59
|
+
throw new Error("console-kit did not return a session token");
|
|
60
|
+
return { kit, token: kit.session.token };
|
|
61
|
+
}
|
|
62
|
+
const session = await keyClient.account.createSession({ ttlMinutes });
|
|
63
|
+
return kitForSession(session.token, apiBase);
|
|
64
|
+
}
|
|
65
|
+
if (isJsonMode() || flagBool(args.flags, "no-input")) {
|
|
66
|
+
fail("No saved API key. Set NOVENCE_API_KEY, run `novence login`, or run `novence dashboard` interactively to sign in with an email code.");
|
|
67
|
+
}
|
|
68
|
+
step("No saved API key — sign in with an email code instead.");
|
|
69
|
+
const email = flagString(args.flags, "email") ?? (await prompt(" Email: "));
|
|
70
|
+
if (!email.includes("@"))
|
|
71
|
+
fail(`Not a valid email: ${email}`);
|
|
72
|
+
await withSpin(`Sending a code to ${email}…`, () => requestSessionOtp(email, apiBase));
|
|
73
|
+
const code = await prompt(" 6-digit code from your email: ");
|
|
74
|
+
if (!/^[0-9]{6}$/.test(code))
|
|
75
|
+
fail("Enter the 6-digit code from the email.");
|
|
76
|
+
const session = await verifySessionOtp(email, code, { ttlMinutes, baseUrl: apiBase });
|
|
77
|
+
return kitForSession(session.token, apiBase);
|
|
78
|
+
}
|
|
79
|
+
/** console-kit with a mgmt_ token reuses that session instead of minting another. */
|
|
80
|
+
async function kitForSession(token, apiBase) {
|
|
81
|
+
const mgmtClient = new Novence({ apiKey: token, baseUrl: apiBase });
|
|
82
|
+
const kit = await withSpin("Fetching your console…", () => mgmtClient.account.consoleKit());
|
|
83
|
+
return { kit, token };
|
|
84
|
+
}
|
|
85
|
+
function injectConsole(kit, token) {
|
|
86
|
+
const template = kit.htmlTemplate;
|
|
87
|
+
if (!template?.includes(CONSOLE_INJECT_MARKER)) {
|
|
88
|
+
fail("The console template is missing its injection marker. Update the CLI: npm i -g novence@latest");
|
|
89
|
+
}
|
|
90
|
+
const payload = `window.__NOVENCE_SNAPSHOT__=${scriptJson(kit.snapshot ?? null)};` +
|
|
91
|
+
`window.__NOVENCE_MGMT_TOKEN__=${scriptJson(token)};`;
|
|
92
|
+
return template.replace(CONSOLE_INJECT_MARKER, () => payload);
|
|
93
|
+
}
|
|
94
|
+
/** JSON that cannot close the surrounding <script> tag. */
|
|
95
|
+
function scriptJson(value) {
|
|
96
|
+
return JSON.stringify(value)
|
|
97
|
+
.replace(/</g, "\\u003c")
|
|
98
|
+
.replace(/\u2028/g, "\\u2028")
|
|
99
|
+
.replace(/\u2029/g, "\\u2029");
|
|
100
|
+
}
|
|
101
|
+
/** Outside the working directory (agents commit scratch dirs); holds a live token, so 0600. */
|
|
102
|
+
function writeConsoleFile(html) {
|
|
103
|
+
mkdirSync(CONSOLE_DIR, { recursive: true, mode: 0o700 });
|
|
104
|
+
const filePath = join(CONSOLE_DIR, CONSOLE_FILE);
|
|
105
|
+
writeFileSync(filePath, html, { mode: 0o600 });
|
|
106
|
+
chmodSync(filePath, 0o600);
|
|
107
|
+
return filePath;
|
|
108
|
+
}
|
|
109
|
+
function serveConsole(req, res, html, server) {
|
|
110
|
+
const port = server.address().port;
|
|
111
|
+
// DNS rebinding: a hostile page can resolve its own name to 127.0.0.1 and read this response.
|
|
112
|
+
const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
113
|
+
if (!allowedHosts.has(req.headers.host ?? "")) {
|
|
114
|
+
res.writeHead(403, { "Content-Type": "text/plain" }).end("Forbidden");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
118
|
+
res.writeHead(405, { Allow: "GET, HEAD" }).end();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
122
|
+
if (path === "/") {
|
|
123
|
+
res.writeHead(302, { Location: `/${CONSOLE_FILE}` }).end();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (path !== `/${CONSOLE_FILE}`) {
|
|
127
|
+
res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
res.writeHead(200, {
|
|
131
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
132
|
+
"Cache-Control": "no-store",
|
|
133
|
+
"X-Content-Type-Options": "nosniff",
|
|
134
|
+
"Referrer-Policy": "no-referrer",
|
|
135
|
+
});
|
|
136
|
+
res.end(req.method === "HEAD" ? undefined : html);
|
|
137
|
+
}
|
|
138
|
+
async function listenOnFreePort(server, startPort) {
|
|
139
|
+
const candidates = Array.from({ length: PORT_SCAN + 1 }, (_, i) => startPort + i).filter((p) => p <= 65535);
|
|
140
|
+
for (const port of [...candidates, 0]) {
|
|
141
|
+
try {
|
|
142
|
+
return await listen(server, port);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err.code !== "EADDRINUSE")
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
fail("Could not bind a local port for the console.");
|
|
150
|
+
}
|
|
151
|
+
function listen(server, port) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const onError = (err) => {
|
|
154
|
+
server.off("listening", onListening);
|
|
155
|
+
reject(err);
|
|
156
|
+
};
|
|
157
|
+
const onListening = () => {
|
|
158
|
+
server.off("error", onError);
|
|
159
|
+
resolve(server.address().port);
|
|
160
|
+
};
|
|
161
|
+
server.once("error", onError);
|
|
162
|
+
server.once("listening", onListening);
|
|
163
|
+
server.listen(port, "127.0.0.1");
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function parsePort(raw) {
|
|
167
|
+
if (raw === undefined)
|
|
168
|
+
return DEFAULT_PORT;
|
|
169
|
+
const port = Number(raw);
|
|
170
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
171
|
+
fail(`Invalid --port: ${raw}`);
|
|
172
|
+
return port;
|
|
173
|
+
}
|
|
174
|
+
function parseTtl(raw) {
|
|
175
|
+
if (raw === undefined)
|
|
176
|
+
return undefined;
|
|
177
|
+
const minutes = Number(raw);
|
|
178
|
+
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 480) {
|
|
179
|
+
fail(`Invalid --ttl: ${raw} (minutes, 5–480)`);
|
|
180
|
+
}
|
|
181
|
+
return minutes;
|
|
182
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { Novence } from "@novence/sdk";
|
|
3
|
+
import { loadApiKey, resolveApiBase } from "../lib/config.js";
|
|
4
|
+
import { flagString } from "../lib/args.js";
|
|
5
|
+
import { explain } from "../lib/errors.js";
|
|
6
|
+
import { fail, isJsonMode, json, result, step } from "../lib/ui.js";
|
|
7
|
+
async function resolveProjectId(client, explicit) {
|
|
8
|
+
if (explicit)
|
|
9
|
+
return explicit;
|
|
10
|
+
const { projects } = await client.projects.list();
|
|
11
|
+
if (projects.length === 0)
|
|
12
|
+
fail("No projects yet. Run `novence deploy ./dist` first.");
|
|
13
|
+
if (projects.length > 1) {
|
|
14
|
+
fail("You have more than one project — say which:\n" +
|
|
15
|
+
" novence data put <name> [file|-] --project <id>");
|
|
16
|
+
}
|
|
17
|
+
return projects[0].id;
|
|
18
|
+
}
|
|
19
|
+
function readJsonSource(src) {
|
|
20
|
+
let text;
|
|
21
|
+
if (!src || src === "-") {
|
|
22
|
+
text = readFileSync(0, "utf8");
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
text = readFileSync(src, "utf8");
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
fail("File must be valid JSON (object or array).");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function dataCommand(args) {
|
|
35
|
+
const apiBase = resolveApiBase(flagString(args.flags, "api"));
|
|
36
|
+
const apiKey = loadApiKey();
|
|
37
|
+
if (!apiKey)
|
|
38
|
+
fail("Not signed in. Run `novence login`.");
|
|
39
|
+
const client = new Novence({ apiKey, baseUrl: apiBase });
|
|
40
|
+
const sub = args.positionals[0] ?? "put";
|
|
41
|
+
const projectFlag = flagString(args.flags, "project");
|
|
42
|
+
try {
|
|
43
|
+
if (sub !== "put") {
|
|
44
|
+
fail("Usage: novence data put <name> [file|-] [--project <id>]");
|
|
45
|
+
}
|
|
46
|
+
const name = args.positionals[1];
|
|
47
|
+
if (!name)
|
|
48
|
+
fail("Usage: novence data put <name> [file|-] [--project <id>]");
|
|
49
|
+
const projectId = await resolveProjectId(client, projectFlag);
|
|
50
|
+
const value = readJsonSource(args.positionals[2]);
|
|
51
|
+
const res = await client.data.put(projectId, name, value);
|
|
52
|
+
if (isJsonMode()) {
|
|
53
|
+
json({ ok: true, projectId, ...res });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
step(`Wrote ${res.path} (${res.bytes} bytes) — live in ~60s`);
|
|
57
|
+
result(String(res.url ?? res.path));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
fail(explain(err, { apiBase, projectId: projectFlag }));
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -42,9 +42,12 @@ async function waitUntilLive(client, projectId) {
|
|
|
42
42
|
let lastStatus = "";
|
|
43
43
|
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
|
44
44
|
const { deployment } = await client.deployments.latest(projectId);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
const label = deployment.status === "queued" && deployment.queuePosition
|
|
46
|
+
? `queued for checks (position ${deployment.queuePosition})`
|
|
47
|
+
: deployment.status;
|
|
48
|
+
if (label !== lastStatus) {
|
|
49
|
+
spinner.update(`${label}…`);
|
|
50
|
+
lastStatus = label;
|
|
48
51
|
}
|
|
49
52
|
if (deployment.status === "live") {
|
|
50
53
|
spinner.stop("Live");
|
|
@@ -81,6 +84,8 @@ function reportChecks(deployment) {
|
|
|
81
84
|
else if (checks.passed === true) {
|
|
82
85
|
step("Checks passed");
|
|
83
86
|
}
|
|
87
|
+
for (const warning of checks.warnings ?? [])
|
|
88
|
+
warn(warning);
|
|
84
89
|
}
|
|
85
90
|
export async function deployCommand(args) {
|
|
86
91
|
const apiBase = resolveApiBase(flagString(args.flags, "api"));
|
package/dist/commands/domains.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Novence } from "@novence/sdk";
|
|
2
2
|
import { loadApiKey, resolveApiBase } from "../lib/config.js";
|
|
3
|
-
import { flagString } from "../lib/args.js";
|
|
3
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
4
4
|
import { explain } from "../lib/errors.js";
|
|
5
5
|
import { fail, isJsonMode, json, result, step } from "../lib/ui.js";
|
|
6
6
|
async function resolveProjectId(client, explicit) {
|
|
@@ -31,19 +31,31 @@ export async function domainsCommand(args) {
|
|
|
31
31
|
fail("Usage: novence domains add <hostname> [--project <id>] [--primary www|apex]");
|
|
32
32
|
}
|
|
33
33
|
const projectId = await resolveProjectId(client, projectFlag);
|
|
34
|
-
const
|
|
34
|
+
const primaryRaw = flagString(args.flags, "primary");
|
|
35
|
+
if (primaryRaw && primaryRaw !== "www" && primaryRaw !== "apex") {
|
|
36
|
+
fail("--primary must be www or apex");
|
|
37
|
+
}
|
|
38
|
+
const redirect = args.flags.redirect === undefined ? undefined : flagBool(args.flags, "redirect");
|
|
39
|
+
const res = await client.domains.configure(projectId, hostname, {
|
|
40
|
+
primary: primaryRaw === "www" || primaryRaw === "apex" ? primaryRaw : undefined,
|
|
41
|
+
redirect,
|
|
42
|
+
});
|
|
35
43
|
if (isJsonMode()) {
|
|
36
44
|
json({ ok: true, projectId, hostname, ...res });
|
|
37
45
|
return;
|
|
38
46
|
}
|
|
39
47
|
step(`Attached ${hostname}`);
|
|
40
48
|
const records = res.instructions?.records ?? [];
|
|
49
|
+
const apexOptions = res.instructions?.apexOptions ?? [];
|
|
41
50
|
if (records.length === 0) {
|
|
42
51
|
result("No DNS records returned — check `novence domains status`.");
|
|
43
52
|
return;
|
|
44
53
|
}
|
|
45
54
|
result("");
|
|
46
|
-
|
|
55
|
+
if (res.instructions?.summary)
|
|
56
|
+
result(String(res.instructions.summary));
|
|
57
|
+
result("");
|
|
58
|
+
result("Required DNS records:");
|
|
47
59
|
result("");
|
|
48
60
|
for (const rec of records) {
|
|
49
61
|
const type = String(rec.type ?? "?");
|
|
@@ -51,8 +63,19 @@ export async function domainsCommand(args) {
|
|
|
51
63
|
const target = String(rec.target ?? rec.value ?? "");
|
|
52
64
|
result(` ${type.padEnd(14)} ${name.padEnd(20)} ${target}`);
|
|
53
65
|
}
|
|
66
|
+
if (apexOptions.length > 0) {
|
|
67
|
+
result("");
|
|
68
|
+
result("Then do exactly one apex option:");
|
|
69
|
+
result("");
|
|
70
|
+
for (const rec of apexOptions) {
|
|
71
|
+
const type = String(rec.type ?? "?");
|
|
72
|
+
const name = String(rec.name ?? rec.host ?? "@");
|
|
73
|
+
const target = String(rec.target ?? rec.value ?? "");
|
|
74
|
+
result(` ${type.padEnd(14)} ${name.padEnd(20)} ${target}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
54
77
|
result("");
|
|
55
|
-
result("Then check
|
|
78
|
+
result("Then check: novence domains status (follow dns.next until empty)");
|
|
56
79
|
return;
|
|
57
80
|
}
|
|
58
81
|
if (sub === "status") {
|
|
@@ -70,6 +93,12 @@ export async function domainsCommand(args) {
|
|
|
70
93
|
}
|
|
71
94
|
result(`domain ${host}`);
|
|
72
95
|
result(`status ${status.customDomainStatus ?? status.status ?? "unknown"}`);
|
|
96
|
+
const dns = status.dns;
|
|
97
|
+
if (dns) {
|
|
98
|
+
result(`www ${dns.www ?? "skipped"}`);
|
|
99
|
+
result(`apex ${dns.apex ?? "skipped"}`);
|
|
100
|
+
result(`next ${dns.next ?? "(none — wait for SSL)"}`);
|
|
101
|
+
}
|
|
73
102
|
const verifications = status.verifications;
|
|
74
103
|
if (Array.isArray(verifications) && verifications.length > 0) {
|
|
75
104
|
result(`checks ${verifications.length} verification record(s) pending or done`);
|
package/dist/commands/keys.js
CHANGED
|
@@ -33,6 +33,20 @@ export async function keysCommand(args) {
|
|
|
33
33
|
}
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
+
if (sub === "create") {
|
|
37
|
+
const name = args.positionals[1];
|
|
38
|
+
if (!name)
|
|
39
|
+
fail("Usage: novence keys create <name> [--project <id>]\n e.g. novence keys create ci");
|
|
40
|
+
const res = await client.apiKeys.create({ name, projectId: flagString(args.flags, "project") });
|
|
41
|
+
if (isJsonMode()) {
|
|
42
|
+
json({ ok: true, ...res });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
result(res.apiKey);
|
|
46
|
+
step(`Key "${res.key.name}" (${res.key.scope}) — shown once. Store it in a secret manager or NOVENCE_API_KEY.`);
|
|
47
|
+
step("It keeps working when `novence login --recover` replaces your primary key.");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
36
50
|
if (sub === "revoke") {
|
|
37
51
|
const id = args.positionals[1];
|
|
38
52
|
if (!id)
|
|
@@ -48,7 +62,7 @@ export async function keysCommand(args) {
|
|
|
48
62
|
step("That was the key this machine uses — run `novence login` to get a new one.");
|
|
49
63
|
return;
|
|
50
64
|
}
|
|
51
|
-
fail(`Unknown subcommand: ${sub}\n Usage: novence keys list | novence keys revoke <id>`);
|
|
65
|
+
fail(`Unknown subcommand: ${sub}\n Usage: novence keys list | novence keys create <name> | novence keys revoke <id>`);
|
|
52
66
|
}
|
|
53
67
|
catch (err) {
|
|
54
68
|
fail(explain(err, { apiBase }));
|
package/dist/commands/login.js
CHANGED
|
@@ -2,8 +2,8 @@ import { Novence } from "@novence/sdk";
|
|
|
2
2
|
import { claimAndMaybeVerify, ensureApiKey } from "../lib/auth.js";
|
|
3
3
|
import { credentialsPath, loadApiKey, resolveApiBase, saveApiKey } from "../lib/config.js";
|
|
4
4
|
import { reissueApiKey, resendVerification, verifyEmail } from "@novence/sdk";
|
|
5
|
-
import { flagString } from "../lib/args.js";
|
|
6
|
-
import { fail, prompt, result, withSpin } from "../lib/ui.js";
|
|
5
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
6
|
+
import { fail, prompt, result, step, withSpin } from "../lib/ui.js";
|
|
7
7
|
export async function loginCommand(args) {
|
|
8
8
|
const apiBase = resolveApiBase(flagString(args.flags, "api"));
|
|
9
9
|
const emailFlag = flagString(args.flags, "email");
|
|
@@ -29,9 +29,15 @@ export async function loginCommand(args) {
|
|
|
29
29
|
if (!code)
|
|
30
30
|
fail("No code entered.");
|
|
31
31
|
if (flagString(args.flags, "recover") !== undefined || args.flags.recover === true) {
|
|
32
|
-
const issued = await reissueApiKey(address, code, apiBase
|
|
32
|
+
const issued = await reissueApiKey(address, code, apiBase, {
|
|
33
|
+
revokeAll: flagBool(args.flags, "revoke-all"),
|
|
34
|
+
});
|
|
33
35
|
saveApiKey(issued.apiKey);
|
|
34
36
|
result(`Key reissued for account ${issued.accountId} → ${credentialsPath()}`);
|
|
37
|
+
if (issued.remainingKeys.length) {
|
|
38
|
+
step(`Still active: ${issued.remainingKeys.map((k) => `${k.name} (${k.keyPrefix})`).join(", ")}. ` +
|
|
39
|
+
"Use --revoke-all if a key may have leaked.");
|
|
40
|
+
}
|
|
35
41
|
return;
|
|
36
42
|
}
|
|
37
43
|
await verifyEmail(address, code, apiBase);
|
package/dist/commands/open.js
CHANGED
|
@@ -1,21 +1,9 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { Novence } from "@novence/sdk";
|
|
3
2
|
import { loadApiKey, resolveApiBase } from "../lib/config.js";
|
|
4
3
|
import { flagString } from "../lib/args.js";
|
|
4
|
+
import { openInBrowser } from "../lib/browser.js";
|
|
5
5
|
import { explain } from "../lib/errors.js";
|
|
6
6
|
import { fail, isJsonMode, json, result, siteUrl, step } from "../lib/ui.js";
|
|
7
|
-
/** Platform opener; falls back to printing the URL when there is no GUI. */
|
|
8
|
-
function openInBrowser(url) {
|
|
9
|
-
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
10
|
-
try {
|
|
11
|
-
const child = spawn(cmd, [url], { stdio: "ignore", detached: true });
|
|
12
|
-
child.unref();
|
|
13
|
-
return true;
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
7
|
export async function openCommand(args) {
|
|
20
8
|
const apiBase = resolveApiBase(flagString(args.flags, "api"));
|
|
21
9
|
const apiKey = loadApiKey();
|
|
@@ -35,7 +35,7 @@ export async function rollbackCommand(args) {
|
|
|
35
35
|
});
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
step("Restored previous snapshot (HTML cache ~60s; hard-refresh if the old page lingers).");
|
|
38
|
+
step("Restored previous snapshot (HTML and unhashed CSS/JS cache ~60s; hard-refresh if the old page lingers).");
|
|
39
39
|
if (previewUrl && previewUrl !== liveUrl)
|
|
40
40
|
step(`Preview ${previewUrl}`);
|
|
41
41
|
result(liveUrl);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Novence } from "@novence/sdk";
|
|
2
|
+
import { loadApiKey, resolveApiBase } from "../lib/config.js";
|
|
3
|
+
import { flagString } from "../lib/args.js";
|
|
4
|
+
import { explain } from "../lib/errors.js";
|
|
5
|
+
import { fail, isJsonMode, json, result, step } from "../lib/ui.js";
|
|
6
|
+
async function resolveProjectId(client, explicit) {
|
|
7
|
+
if (explicit)
|
|
8
|
+
return explicit;
|
|
9
|
+
const { projects } = await client.projects.list();
|
|
10
|
+
if (projects.length === 0)
|
|
11
|
+
fail("No projects yet. Run `novence deploy ./dist` first.");
|
|
12
|
+
if (projects.length > 1) {
|
|
13
|
+
fail("You have more than one project — say which:\n" +
|
|
14
|
+
" novence site-mcp on --project <id>");
|
|
15
|
+
}
|
|
16
|
+
return projects[0].id;
|
|
17
|
+
}
|
|
18
|
+
export async function siteMcpCommand(args) {
|
|
19
|
+
const apiBase = resolveApiBase(flagString(args.flags, "api"));
|
|
20
|
+
const apiKey = loadApiKey();
|
|
21
|
+
if (!apiKey)
|
|
22
|
+
fail("Not signed in. Run `novence login`.");
|
|
23
|
+
const client = new Novence({ apiKey, baseUrl: apiBase });
|
|
24
|
+
const sub = (args.positionals[0] ?? "status").toLowerCase();
|
|
25
|
+
const projectFlag = flagString(args.flags, "project");
|
|
26
|
+
try {
|
|
27
|
+
const projectId = await resolveProjectId(client, projectFlag);
|
|
28
|
+
if (sub === "status") {
|
|
29
|
+
const { project } = await client.projects.get(projectId);
|
|
30
|
+
const mode = project.siteMcp ?? "off";
|
|
31
|
+
const mcpUrl = `${apiBase.replace(/\/$/, "")}/sites/${project.suffix}/mcp`;
|
|
32
|
+
if (isJsonMode()) {
|
|
33
|
+
json({ ok: true, projectId, siteMcp: mode, mcpUrl, wellKnown: `${project.url}/.well-known/mcp.json` });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
step(`siteMcp=${mode}`);
|
|
37
|
+
result(mcpUrl);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (sub !== "on" && sub !== "off" && sub !== "forms" && sub !== "read") {
|
|
41
|
+
fail("Usage: novence site-mcp on|off|forms|status [--project <id>]");
|
|
42
|
+
}
|
|
43
|
+
const siteMcp = sub === "on" ? "read" : sub;
|
|
44
|
+
const { project } = await client.projects.update(projectId, { siteMcp });
|
|
45
|
+
const mode = project.siteMcp ?? siteMcp;
|
|
46
|
+
const mcpUrl = `${apiBase.replace(/\/$/, "")}/sites/${project.suffix}/mcp`;
|
|
47
|
+
if (isJsonMode()) {
|
|
48
|
+
json({ ok: true, projectId, siteMcp: mode, mcpUrl });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
step(`siteMcp=${mode}`);
|
|
52
|
+
result(mcpUrl);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
fail(explain(err, { apiBase, projectId: projectFlag }));
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -16,22 +16,36 @@ import { domainsCommand } from "./commands/domains.js";
|
|
|
16
16
|
import { keysCommand } from "./commands/keys.js";
|
|
17
17
|
import { checksCommand } from "./commands/checks.js";
|
|
18
18
|
import { rollbackCommand } from "./commands/rollback.js";
|
|
19
|
+
import { dataCommand } from "./commands/data.js";
|
|
20
|
+
import { siteMcpCommand } from "./commands/site-mcp.js";
|
|
21
|
+
import { dashboardCommand } from "./commands/dashboard.js";
|
|
19
22
|
const HELP = `
|
|
20
23
|
novence — deploy a static site to the edge
|
|
21
24
|
|
|
22
25
|
Usage
|
|
23
26
|
novence deploy [dir|file] Upload a directory or one .html file and publish it live
|
|
24
27
|
novence rollback Restore the previous live snapshot (Pro/Scale)
|
|
28
|
+
novence data put <name> Write /data/{name}.json live without a deploy (Pro/Scale)
|
|
29
|
+
novence site-mcp on|off|forms Visitor MCP on the live site (Pro/Scale; off by default)
|
|
25
30
|
novence open [project] Open a project's live URL
|
|
26
31
|
novence checks [project] Quality-gate results for the latest deploy
|
|
27
32
|
novence domains add <host> Attach a custom domain (prints DNS records)
|
|
28
33
|
novence domains status Custom domain state
|
|
29
34
|
novence keys list API keys on the account
|
|
35
|
+
novence keys create <name> Extra named key (e.g. ci) that survives login --recover
|
|
30
36
|
novence keys revoke <id> Revoke a key
|
|
31
37
|
novence projects List projects and their URLs
|
|
32
38
|
novence login Sign in, verify email, or recover a key
|
|
33
39
|
novence claim Attach an email to an anonymous deploy
|
|
34
40
|
novence whoami Plan, verification, and usage
|
|
41
|
+
novence dashboard Open your local account console (alias: console)
|
|
42
|
+
|
|
43
|
+
Dashboard
|
|
44
|
+
novence dashboard Serve the console on the first free 127.0.0.1 port from 8765
|
|
45
|
+
--port <n> First port to try (default 8765)
|
|
46
|
+
--no-open Print the URL without opening a browser
|
|
47
|
+
--email <email> Email for code sign-in when no API key is saved
|
|
48
|
+
--ttl <minutes> Session length 5–480 (default 30; >30 drops member management)
|
|
35
49
|
|
|
36
50
|
Deploy
|
|
37
51
|
novence deploy Auto-detects dist, build, out, public, _site
|
|
@@ -46,6 +60,12 @@ const HELP = `
|
|
|
46
60
|
--project <id> Project (default: first in the list)
|
|
47
61
|
--deployment <id> Specific retained deploy (last 5)
|
|
48
62
|
|
|
63
|
+
Live JSON (Pro/Scale)
|
|
64
|
+
novence data put menu ./menu.json
|
|
65
|
+
novence data put hours - Read JSON from stdin
|
|
66
|
+
--project <id> Project (default: first in the list)
|
|
67
|
+
Free: upload /data/*.json and deploy instead.
|
|
68
|
+
|
|
49
69
|
Global
|
|
50
70
|
--json Machine-readable output (implies --no-input)
|
|
51
71
|
--no-input Never prompt; fail instead
|
|
@@ -58,6 +78,7 @@ const HELP = `
|
|
|
58
78
|
|
|
59
79
|
Auth
|
|
60
80
|
Reads NOVENCE_API_KEY, then ./.novence-key, then ~/.novence/credentials.
|
|
81
|
+
NOVENCE_ATTRIBUTION, when set to JSON, is sent with the first signup.
|
|
61
82
|
|
|
62
83
|
Docs https://novence.ai/docs
|
|
63
84
|
`;
|
|
@@ -89,9 +110,13 @@ async function main() {
|
|
|
89
110
|
keys: ["project", "include-revoked", "force"],
|
|
90
111
|
projects: [],
|
|
91
112
|
ls: [],
|
|
92
|
-
login: ["email", "recover"],
|
|
113
|
+
login: ["email", "recover", "revoke-all"],
|
|
93
114
|
whoami: [],
|
|
94
115
|
rollback: ["project", "deployment"],
|
|
116
|
+
data: ["project"],
|
|
117
|
+
"site-mcp": ["project"],
|
|
118
|
+
dashboard: ["port", "no-open", "email", "ttl"],
|
|
119
|
+
console: ["port", "no-open", "email", "ttl"],
|
|
95
120
|
};
|
|
96
121
|
const allowed = ALLOWED[args.command];
|
|
97
122
|
if (allowed) {
|
|
@@ -121,6 +146,13 @@ async function main() {
|
|
|
121
146
|
return whoamiCommand(args);
|
|
122
147
|
case "rollback":
|
|
123
148
|
return rollbackCommand(args);
|
|
149
|
+
case "data":
|
|
150
|
+
return dataCommand(args);
|
|
151
|
+
case "site-mcp":
|
|
152
|
+
return siteMcpCommand(args);
|
|
153
|
+
case "dashboard":
|
|
154
|
+
case "console":
|
|
155
|
+
return dashboardCommand(args);
|
|
124
156
|
default:
|
|
125
157
|
fail(`Unknown command: ${args.command}\n Run \`novence --help\` for usage.`);
|
|
126
158
|
}
|
package/dist/lib/auth.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import { bootstrapAccount, claimAccount, createDemoAccount, verifyEmail } from "@novence/sdk";
|
|
1
|
+
import { bootstrapAccount, claimAccount, createDemoAccount, verifyEmail, } from "@novence/sdk";
|
|
2
2
|
import { loadApiKey, saveApiKey } from "./config.js";
|
|
3
3
|
import { fail, prompt, step, warn, withSpin } from "./ui.js";
|
|
4
|
+
function attributionFromEnv() {
|
|
5
|
+
const raw = process.env.NOVENCE_ATTRIBUTION?.trim();
|
|
6
|
+
let parsed = {};
|
|
7
|
+
if (raw) {
|
|
8
|
+
try {
|
|
9
|
+
const value = JSON.parse(raw);
|
|
10
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
11
|
+
parsed = value;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
parsed = {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { ...parsed, method: "cli" };
|
|
19
|
+
}
|
|
4
20
|
/**
|
|
5
21
|
* Returns a usable API key. With no stored key and no `--email`, creates an
|
|
6
22
|
* anonymous demo account so deploy can print a live URL first.
|
|
@@ -11,7 +27,7 @@ export async function ensureApiKey(opts) {
|
|
|
11
27
|
return { apiKey: existing, fresh: false, anonymous: false };
|
|
12
28
|
if (opts.email)
|
|
13
29
|
return bootstrapWithEmail(opts.email, opts.apiBase, opts.interactive);
|
|
14
|
-
const demo = await withSpin("Creating a temporary account…", () => createDemoAccount(opts.apiBase));
|
|
30
|
+
const demo = await withSpin("Creating a temporary account…", () => createDemoAccount(opts.apiBase, attributionFromEnv()));
|
|
15
31
|
const path = saveApiKey(demo.apiKey);
|
|
16
32
|
step(`Anonymous account ${demo.accountId} · key saved to ${path}`);
|
|
17
33
|
warn("Unclaimed site — run `novence claim --email you@example.com` to keep it.");
|
|
@@ -22,7 +38,7 @@ async function bootstrapWithEmail(email, apiBase, interactive) {
|
|
|
22
38
|
fail(`Not a valid email: ${email}`);
|
|
23
39
|
let boot;
|
|
24
40
|
try {
|
|
25
|
-
boot = await withSpin(`Creating your account (${email})…`, () => bootstrapAccount(email, apiBase));
|
|
41
|
+
boot = await withSpin(`Creating your account (${email})…`, () => bootstrapAccount(email, apiBase, attributionFromEnv()));
|
|
26
42
|
}
|
|
27
43
|
catch (err) {
|
|
28
44
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Platform opener; falls back to printing the URL when there is no GUI. */
|
|
3
|
+
export function openInBrowser(url) {
|
|
4
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
5
|
+
try {
|
|
6
|
+
const child = spawn(cmd, [url], { stdio: "ignore", detached: true });
|
|
7
|
+
child.unref();
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "novence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "CLI for Novence — deploy a static site to the edge in one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@novence/sdk": "^0.
|
|
43
|
+
"@novence/sdk": "^0.3.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^22.10.2",
|