novence 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,12 @@ until you claim.
7
7
  npx novence deploy ./dist
8
8
  ```
9
9
 
10
+ > **Breaking change in 0.3.0:** `novence keys create <name>` no longer prints the
11
+ > secret. It saves it to `~/.novence/keys/<name>` (mode 0600) and prints only the
12
+ > prefix and path. `KEY=$(novence keys create ci)` now fails with a message —
13
+ > use `KEY=$(novence keys create ci --print)` or `--output <path>`. `--json`
14
+ > omits `apiKey` unless `--print` is passed.
15
+
10
16
  ```text
11
17
  Created project site (abcd1234)
12
18
  Uploaded 12 files
@@ -75,13 +81,50 @@ novence checks [project] # Lighthouse / axe / link results for the last dep
75
81
  novence domains add <host> # attach a custom domain, prints the DNS records
76
82
  novence domains status # custom domain state
77
83
  novence keys list # API keys on the account
78
- novence keys revoke <id> # revoke a key (--force to revoke the one in use)
84
+ novence keys create <name> # named key (e.g. ci), saved to ~/.novence/keys/<name>
85
+ novence keys revoke <id> # revoke a key and delete its ~/.novence/keys file
79
86
  novence projects # list projects and their URLs
80
- novence login # sign in, verify email, or --recover a key
87
+ novence login --email <email> # sign in, or verify your email
81
88
  novence claim # attach an email to an anonymous deploy
82
89
  novence whoami # plan, verification, usage against quota
90
+ novence dashboard # local account console (alias: novence console)
83
91
  ```
84
92
 
93
+ ## Lost or revoked key
94
+
95
+ ```bash
96
+ novence login --recover --email you@example.com # emails a code, reissues the key
97
+ novence login --recover --email you@example.com --revoke-all # also revoke named keys
98
+ ```
99
+
100
+ Recovery never uses the old key, so a revoked `./.novence-key` can't block it, and
101
+ it rewrites `./.novence-key` if one exists. Without a terminal (agents, CI), run it
102
+ once to send the code, then again with `--code <6 digits>`.
103
+
104
+ ## Named keys
105
+
106
+ `novence keys create ci` saves the key to `~/.novence/keys/ci` (0600) and prints
107
+ only its prefix — the secret never reaches your terminal. Use it with
108
+ `--key-name ci` (or `NOVENCE_KEY_NAME=ci`), or in CI with
109
+ `NOVENCE_API_KEY="$(cat ~/.novence/keys/ci)"`.
110
+
111
+ | Flag | |
112
+ | --- | --- |
113
+ | `--output <path>` | Save somewhere else (0600). Refuses to overwrite without `--force` |
114
+ | `--print` | Print the secret to stdout instead: `KEY=$(novence keys create ci --print)` |
115
+
116
+ ## Dashboard
117
+
118
+ `novence dashboard` fetches your console-kit, writes `~/.novence/console/novence-console.html`
119
+ (mode 0600), serves it on the first free `127.0.0.1` port from 8765, and opens the browser at
120
+ that exact URL. Press Ctrl+C to stop; the file (it holds a live `mgmt_` token) is deleted.
121
+
122
+ - Uses your saved API key. Without one, it signs you in with an email code instead.
123
+ - `--port <n>` first port to try, `--no-open` print the URL only, `--email <email>` skip the prompt.
124
+ - `--ttl <minutes>` session length 5–480 (default 30). Sessions over 30 minutes can view usage
125
+ and open billing, but cannot manage team members.
126
+ - In the page, **Switch account** signs in with an email code when the session expires.
127
+
85
128
  Every command accepts `--json`. Unknown flags are rejected rather than ignored,
86
129
  so a typo like `--nmae` fails loudly instead of silently deploying under the
87
130
  default name.
@@ -116,14 +159,14 @@ Full agent brief: [novence.ai/llms-full.txt](https://novence.ai/llms-full.txt).
116
159
 
117
160
  Set `NOVENCE_API_KEY` and pass `--no-input`.
118
161
 
119
- Key lookup order: `NOVENCE_API_KEY`, then `./.novence-key`, then
120
- `~/.novence/credentials`. Never commit a key.
162
+ Key lookup order: `--key-name <name>` / `NOVENCE_KEY_NAME`, then `NOVENCE_API_KEY`,
163
+ then `./.novence-key`, then `~/.novence/credentials`. Never commit a key.
121
164
 
122
165
  ## Checks
123
166
 
124
167
  Deploys run Lighthouse, axe, and link checks. On unverified accounts checks are
125
168
  skipped — `checks.skipped` is `true` and `checks.passed` is `null`, which is not
126
- a pass. Verify your email with `novence login` (or claim) to enable them.
169
+ a pass. Verify your email with `novence login --email you@example.com` (or claim) to enable them.
127
170
 
128
171
  ## Docs
129
172
 
@@ -1,5 +1,5 @@
1
1
  import { Novence } from "@novence/sdk";
2
- import { loadApiKey, resolveApiBase } from "../lib/config.js";
2
+ import { loadApiKey, notSignedInMessage, resolveApiBase } from "../lib/config.js";
3
3
  import { flagString } from "../lib/args.js";
4
4
  import { explain } from "../lib/errors.js";
5
5
  import { fail, isJsonMode, json, result } from "../lib/ui.js";
@@ -10,7 +10,7 @@ export async function checksCommand(args) {
10
10
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
11
11
  const apiKey = loadApiKey();
12
12
  if (!apiKey)
13
- fail("Not signed in. Run `novence login`.");
13
+ fail(notSignedInMessage());
14
14
  const client = new Novence({ apiKey, baseUrl: apiBase });
15
15
  const wanted = args.positionals[0];
16
16
  let projectId = wanted;
@@ -0,0 +1,2 @@
1
+ import { type ParsedArgs } from "../lib/args.js";
2
+ export declare function dashboardCommand(args: ParsedArgs): Promise<void>;
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { Novence } from "@novence/sdk";
3
- import { loadApiKey, resolveApiBase } from "../lib/config.js";
3
+ import { loadApiKey, notSignedInMessage, resolveApiBase } from "../lib/config.js";
4
4
  import { flagString } from "../lib/args.js";
5
5
  import { explain } from "../lib/errors.js";
6
6
  import { fail, isJsonMode, json, result, step } from "../lib/ui.js";
@@ -35,7 +35,7 @@ export async function dataCommand(args) {
35
35
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
36
36
  const apiKey = loadApiKey();
37
37
  if (!apiKey)
38
- fail("Not signed in. Run `novence login`.");
38
+ fail(notSignedInMessage());
39
39
  const client = new Novence({ apiKey, baseUrl: apiBase });
40
40
  const sub = args.positionals[0] ?? "put";
41
41
  const projectFlag = flagString(args.flags, "project");
@@ -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
- if (deployment.status !== lastStatus) {
46
- spinner.update(`${deployment.status}…`);
47
- lastStatus = deployment.status;
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"));
@@ -1,5 +1,5 @@
1
1
  import { Novence } from "@novence/sdk";
2
- import { loadApiKey, resolveApiBase } from "../lib/config.js";
2
+ import { loadApiKey, notSignedInMessage, resolveApiBase } from "../lib/config.js";
3
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";
@@ -20,7 +20,7 @@ export async function domainsCommand(args) {
20
20
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
21
21
  const apiKey = loadApiKey();
22
22
  if (!apiKey)
23
- fail("Not signed in. Run `novence login`.");
23
+ fail(notSignedInMessage());
24
24
  const client = new Novence({ apiKey, baseUrl: apiBase });
25
25
  const sub = args.positionals[0] ?? "status";
26
26
  const projectFlag = flagString(args.flags, "project");
@@ -1,13 +1,15 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import { Novence } from "@novence/sdk";
2
- import { loadApiKey, resolveApiBase } from "../lib/config.js";
4
+ import { loadApiKey, namedKeyPath, notSignedInMessage, removeNamedKeyFile, resolveApiBase, saveNamedKey, } from "../lib/config.js";
3
5
  import { flagBool, flagString } from "../lib/args.js";
4
6
  import { explain } from "../lib/errors.js";
5
- import { fail, isJsonMode, json, result, step } from "../lib/ui.js";
7
+ import { fail, isJsonMode, json, result, step, warn } from "../lib/ui.js";
6
8
  export async function keysCommand(args) {
7
9
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
8
10
  const apiKey = loadApiKey();
9
11
  if (!apiKey)
10
- fail("Not signed in. Run `novence login`.");
12
+ fail(notSignedInMessage());
11
13
  const client = new Novence({ apiKey, baseUrl: apiBase });
12
14
  const sub = args.positionals[0] ?? "list";
13
15
  try {
@@ -33,22 +35,84 @@ export async function keysCommand(args) {
33
35
  }
34
36
  return;
35
37
  }
38
+ if (sub === "create") {
39
+ const name = args.positionals[1];
40
+ if (!name)
41
+ fail("Usage: novence keys create <name> [--project <id>]\n e.g. novence keys create ci");
42
+ const outputFlag = flagString(args.flags, "output");
43
+ const print = flagBool(args.flags, "print") || outputFlag === "-";
44
+ const outputPath = outputFlag === "-" ? undefined : outputFlag;
45
+ const force = flagBool(args.flags, "force");
46
+ // Checked before the API call so a refused run never mints an orphan key.
47
+ if (!print && !outputPath && !isJsonMode() && !process.stdout.isTTY) {
48
+ fail("keys create no longer prints the secret. Capture it with --print:\n" +
49
+ ` KEY=$(novence keys create ${name} --print)\n` +
50
+ " or write it to a file: --output <path>");
51
+ }
52
+ let target;
53
+ if (!print) {
54
+ target = outputPath ? resolve(outputPath) : namedKeyPath(name);
55
+ if (existsSync(target) && !force)
56
+ fail(`${target} already exists. Pass --force to replace it.`);
57
+ }
58
+ const res = await client.apiKeys.create({ name, projectId: flagString(args.flags, "project") });
59
+ if (print) {
60
+ if (isJsonMode()) {
61
+ json({ ok: true, ...res });
62
+ return;
63
+ }
64
+ result(res.apiKey);
65
+ step(`Key "${res.key.name}" (${res.key.scope}) — shown once. Store it in a secret manager.`);
66
+ return;
67
+ }
68
+ const savedTo = saveNamedKey(name, res.apiKey, { outputPath, force });
69
+ if (isJsonMode()) {
70
+ json({ ok: true, key: res.key, savedTo });
71
+ return;
72
+ }
73
+ const inCi = `NOVENCE_API_KEY="$(cat ${savedTo})"`;
74
+ step(`Key "${res.key.name}" (${res.key.scope}) · ${res.key.keyPrefix}… saved to ${savedTo} (0600).`);
75
+ step(outputPath ? `In CI: ${inCi}` : `Use it here with --key-name ${name}, or in CI with ${inCi}.`);
76
+ step("It keeps working when `novence login --recover` replaces your primary key.");
77
+ result(savedTo);
78
+ return;
79
+ }
36
80
  if (sub === "revoke") {
37
81
  const id = args.positionals[1];
38
82
  if (!id)
39
83
  fail("Usage: novence keys revoke <id> [--force]\n Find ids with: novence keys list");
40
84
  const force = flagBool(args.flags, "force");
85
+ // Only used to clean up ~/.novence/keys/<name>; never block the revoke on it.
86
+ const { keys } = await client.apiKeys
87
+ .list({ projectId: flagString(args.flags, "project"), includeRevoked: true })
88
+ .catch(() => ({ keys: [] }));
89
+ const revoked = keys.find((k) => k.id === id);
41
90
  const res = await client.apiKeys.revoke(id, { force });
91
+ const fileOutcome = revoked ? removeNamedKeyFile(revoked.name, revoked.keyPrefix) : "absent";
42
92
  if (isJsonMode()) {
43
- json({ ok: true, ...res });
93
+ json({ ok: true, ...res, keyFile: fileOutcome });
44
94
  return;
45
95
  }
46
96
  result(`Revoked ${id}`);
97
+ switch (fileOutcome) {
98
+ case "removed":
99
+ step(`Deleted ${namedKeyPath(revoked.name)}.`);
100
+ break;
101
+ case "mismatch":
102
+ warn(`Kept ${namedKeyPath(revoked.name)}: it holds a different key than the one revoked.`);
103
+ break;
104
+ case "absent":
105
+ break;
106
+ default: {
107
+ const unreachable = fileOutcome;
108
+ return unreachable;
109
+ }
110
+ }
47
111
  if (force)
48
- step("That was the key this machine uses — run `novence login` to get a new one.");
112
+ step("That was the key this machine uses — run `novence login --recover --email you@example.com`.");
49
113
  return;
50
114
  }
51
- fail(`Unknown subcommand: ${sub}\n Usage: novence keys list | novence keys revoke <id>`);
115
+ fail(`Unknown subcommand: ${sub}\n Usage: novence keys list | novence keys create <name> | novence keys revoke <id>`);
52
116
  }
53
117
  catch (err) {
54
118
  fail(explain(err, { apiBase }));
@@ -1,39 +1,102 @@
1
- import { Novence } from "@novence/sdk";
2
- import { claimAndMaybeVerify, ensureApiKey } from "../lib/auth.js";
3
- import { credentialsPath, loadApiKey, resolveApiBase, saveApiKey } from "../lib/config.js";
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";
1
+ import { existsSync } from "node:fs";
2
+ import { Novence, NovenceError, reissueApiKey, resendVerification, verifyEmail, } from "@novence/sdk";
3
+ import { claimAndMaybeVerify, ensureApiKey, isAccountExistsError, recoverHint } from "../lib/auth.js";
4
+ import { credentialsPath, keySource, loadApiKey, localKeyPath, resolveApiBase, saveApiKey, } from "../lib/config.js";
5
+ import { flagBool, flagString } from "../lib/args.js";
6
+ import { fail, isJsonMode, prompt, result, step, warn, withSpin } from "../lib/ui.js";
7
7
  export async function loginCommand(args) {
8
- const apiBase = resolveApiBase(flagString(args.flags, "api"));
9
- const emailFlag = flagString(args.flags, "email");
10
- if (!loadApiKey()) {
11
- const email = emailFlag ?? (await prompt(" Email: "));
12
- await ensureApiKey({ apiBase, email, interactive: true });
13
- result(`Signed in. Key stored at ${credentialsPath()}`);
14
- return;
8
+ const recoverFlag = args.flags.recover;
9
+ // `--recover you@x.com` parses as a value; treat it as the email.
10
+ const recoverEmail = typeof recoverFlag === "string" && recoverFlag.includes("@") ? recoverFlag : undefined;
11
+ const ctx = {
12
+ apiBase: resolveApiBase(flagString(args.flags, "api")),
13
+ email: flagString(args.flags, "email") ?? recoverEmail,
14
+ code: flagString(args.flags, "code"),
15
+ revokeAll: flagBool(args.flags, "revoke-all"),
16
+ canPrompt: Boolean(process.stdin.isTTY) && !isJsonMode() && !flagBool(args.flags, "no-input"),
17
+ };
18
+ if (recoverFlag !== undefined)
19
+ return recoverKey(ctx);
20
+ const storedKey = loadApiKey();
21
+ if (!storedKey)
22
+ return signInWithEmail(ctx);
23
+ let snap;
24
+ try {
25
+ snap = await new Novence({ apiKey: storedKey, baseUrl: ctx.apiBase }).account.get();
26
+ }
27
+ catch (err) {
28
+ if (!(err instanceof NovenceError && err.status === 401))
29
+ throw err;
30
+ const lead = `The stored key (${keySource()}) was rejected — it may be revoked.`;
31
+ if (!ctx.canPrompt)
32
+ fail(recoverHint(ctx.email, lead));
33
+ warn(lead);
34
+ return recoverKey(ctx);
15
35
  }
16
- const address = emailFlag ?? (await prompt(" Email: "));
17
- if (!address.includes("@"))
18
- fail(`Not a valid email: ${address}`);
19
- const apiKey = loadApiKey();
20
- const client = new Novence({ apiKey, baseUrl: apiBase });
21
- const snap = await client.account.get();
36
+ const address = await requireEmail(ctx);
22
37
  if (!snap.email) {
23
- await claimAndMaybeVerify({ apiKey, apiBase, email: address, interactive: true });
38
+ await claimAndMaybeVerify({ apiKey: storedKey, apiBase: ctx.apiBase, email: address, interactive: ctx.canPrompt });
24
39
  result(`${address} claimed.`);
25
40
  return;
26
41
  }
27
- const sent = await withSpin(`Sending a code to ${address}…`, () => resendVerification(address, apiBase));
28
- const code = sent.verificationCode ?? (await prompt(" 6-digit code from your email: "));
42
+ const code = ctx.code ?? (await sendCode(address, ctx));
43
+ await verifyEmail(address, code, ctx.apiBase);
44
+ result(`${address} verified.`);
45
+ }
46
+ async function signInWithEmail(ctx) {
47
+ const email = await requireEmail(ctx);
48
+ try {
49
+ await ensureApiKey({ apiBase: ctx.apiBase, email, interactive: ctx.canPrompt });
50
+ result(`Signed in. Key stored at ${credentialsPath()}`);
51
+ }
52
+ catch (err) {
53
+ if (!isAccountExistsError(err))
54
+ throw err;
55
+ if (!ctx.canPrompt)
56
+ fail(err.message);
57
+ step(`${email} already has an account — recovering its key with an email code.`);
58
+ await recoverKey({ ...ctx, email });
59
+ }
60
+ }
61
+ /** Email OTP → POST /v1/auth/reissue-key. Never reads or sends the stored key. */
62
+ async function recoverKey(ctx) {
63
+ const email = await requireEmail(ctx);
64
+ const code = ctx.code ?? (await sendCode(email, ctx, true));
65
+ const issued = await withSpin("Reissuing your key…", () => reissueApiKey(email, code, ctx.apiBase, { revokeAll: ctx.revokeAll }));
66
+ const hadLocalKey = existsSync(localKeyPath());
67
+ const path = saveApiKey(issued.apiKey);
68
+ if (hadLocalKey)
69
+ step(`Also replaced ${localKeyPath()} (it would otherwise shadow the new key).`);
70
+ if (process.env.NOVENCE_API_KEY?.trim()) {
71
+ warn("NOVENCE_API_KEY is set and overrides the new key. Update or unset it.");
72
+ }
73
+ if (issued.remainingKeys.length) {
74
+ step(`Still active: ${issued.remainingKeys.map((k) => `${k.name} (${k.keyPrefix})`).join(", ")}. ` +
75
+ "Use --revoke-all if a key may have leaked.");
76
+ }
77
+ result(`Key reissued for account ${issued.accountId} → ${path}`);
78
+ }
79
+ /** Sends an OTP. Without a TTY, stops and says how to finish with --code. */
80
+ async function sendCode(email, ctx, recovering = false) {
81
+ const sent = await withSpin(`Sending a code to ${email}…`, () => resendVerification(email, ctx.apiBase));
82
+ if (sent.verificationCode)
83
+ return sent.verificationCode;
84
+ if (!ctx.canPrompt) {
85
+ const rerun = recovering
86
+ ? `novence login --recover --email ${email}${ctx.revokeAll ? " --revoke-all" : ""} --code <6 digits>`
87
+ : `novence login --email ${email} --code <6 digits>`;
88
+ fail(`Code sent to ${email}. Re-run:\n ${rerun}`);
89
+ }
90
+ const code = await prompt(" 6-digit code from your email: ");
29
91
  if (!code)
30
92
  fail("No code entered.");
31
- if (flagString(args.flags, "recover") !== undefined || args.flags.recover === true) {
32
- const issued = await reissueApiKey(address, code, apiBase);
33
- saveApiKey(issued.apiKey);
34
- result(`Key reissued for account ${issued.accountId} → ${credentialsPath()}`);
35
- return;
36
- }
37
- await verifyEmail(address, code, apiBase);
38
- result(`${address} verified.`);
93
+ return code;
94
+ }
95
+ async function requireEmail(ctx) {
96
+ const email = ctx.email ?? (ctx.canPrompt ? await prompt(" Email: ") : undefined);
97
+ if (!email)
98
+ fail("Pass --email you@example.com.");
99
+ if (!email.includes("@"))
100
+ fail(`Not a valid email: ${email}`);
101
+ return email;
39
102
  }
@@ -1,26 +1,14 @@
1
- import { spawn } from "node:child_process";
2
1
  import { Novence } from "@novence/sdk";
3
- import { loadApiKey, resolveApiBase } from "../lib/config.js";
2
+ import { loadApiKey, notSignedInMessage, 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();
22
10
  if (!apiKey)
23
- fail("Not signed in. Run `novence login`.");
11
+ fail(notSignedInMessage());
24
12
  const client = new Novence({ apiKey, baseUrl: apiBase });
25
13
  const wanted = args.positionals[0];
26
14
  try {
@@ -1,12 +1,12 @@
1
1
  import { Novence } from "@novence/sdk";
2
- import { loadApiKey, resolveApiBase } from "../lib/config.js";
2
+ import { loadApiKey, notSignedInMessage, resolveApiBase } from "../lib/config.js";
3
3
  import { flagString } from "../lib/args.js";
4
4
  import { fail, isJsonMode, json, result } from "../lib/ui.js";
5
5
  export async function projectsCommand(args) {
6
6
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
7
7
  const apiKey = loadApiKey();
8
8
  if (!apiKey)
9
- fail("Not signed in. Run `novence login`.");
9
+ fail(notSignedInMessage());
10
10
  const client = new Novence({ apiKey, baseUrl: apiBase });
11
11
  const { projects } = await client.projects.list();
12
12
  if (isJsonMode()) {
@@ -1,5 +1,5 @@
1
1
  import { Novence } from "@novence/sdk";
2
- import { loadApiKey, resolveApiBase } from "../lib/config.js";
2
+ import { loadApiKey, notSignedInMessage, resolveApiBase } from "../lib/config.js";
3
3
  import { flagString } from "../lib/args.js";
4
4
  import { explain } from "../lib/errors.js";
5
5
  import { fail, isJsonMode, json, result, siteUrl, step } from "../lib/ui.js";
@@ -7,7 +7,7 @@ export async function rollbackCommand(args) {
7
7
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
8
8
  const apiKey = loadApiKey();
9
9
  if (!apiKey)
10
- fail("Not signed in. Run `novence login`.");
10
+ fail(notSignedInMessage());
11
11
  const client = new Novence({ apiKey, baseUrl: apiBase });
12
12
  const wanted = flagString(args.flags, "project") ?? args.positionals[0];
13
13
  const deploymentId = flagString(args.flags, "deployment");
@@ -1,5 +1,5 @@
1
1
  import { Novence } from "@novence/sdk";
2
- import { loadApiKey, resolveApiBase } from "../lib/config.js";
2
+ import { loadApiKey, notSignedInMessage, resolveApiBase } from "../lib/config.js";
3
3
  import { 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";
@@ -19,7 +19,7 @@ export async function siteMcpCommand(args) {
19
19
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
20
20
  const apiKey = loadApiKey();
21
21
  if (!apiKey)
22
- fail("Not signed in. Run `novence login`.");
22
+ fail(notSignedInMessage());
23
23
  const client = new Novence({ apiKey, baseUrl: apiBase });
24
24
  const sub = (args.positionals[0] ?? "status").toLowerCase();
25
25
  const projectFlag = flagString(args.flags, "project");
@@ -1,12 +1,12 @@
1
1
  import { Novence } from "@novence/sdk";
2
- import { keySource, loadApiKey, resolveApiBase } from "../lib/config.js";
2
+ import { keySource, loadApiKey, notSignedInMessage, resolveApiBase } from "../lib/config.js";
3
3
  import { flagString } from "../lib/args.js";
4
4
  import { fail, isJsonMode, json, result } from "../lib/ui.js";
5
5
  export async function whoamiCommand(args) {
6
6
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
7
7
  const apiKey = loadApiKey();
8
8
  if (!apiKey)
9
- fail("Not signed in. Run `novence login`.");
9
+ fail(notSignedInMessage());
10
10
  const client = new Novence({ apiKey, baseUrl: apiBase });
11
11
  const snap = await client.account.get();
12
12
  const quotas = await client.billing.quotas();
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { assertKnownFlags, flagBool, flagString, parseArgs } from "./lib/args.js";
6
- import { resolveApiBase } from "./lib/config.js";
6
+ import { resolveApiBase, setKeyName } from "./lib/config.js";
7
7
  import { explain } from "./lib/errors.js";
8
8
  import { fail, isJsonMode, result, setJsonMode } from "./lib/ui.js";
9
9
  import { deployCommand } from "./commands/deploy.js";
@@ -18,6 +18,7 @@ import { checksCommand } from "./commands/checks.js";
18
18
  import { rollbackCommand } from "./commands/rollback.js";
19
19
  import { dataCommand } from "./commands/data.js";
20
20
  import { siteMcpCommand } from "./commands/site-mcp.js";
21
+ import { dashboardCommand } from "./commands/dashboard.js";
21
22
  const HELP = `
22
23
  novence — deploy a static site to the edge
23
24
 
@@ -31,11 +32,32 @@ const HELP = `
31
32
  novence domains add <host> Attach a custom domain (prints DNS records)
32
33
  novence domains status Custom domain state
33
34
  novence keys list API keys on the account
35
+ novence keys create <name> Extra named key (e.g. ci), saved to ~/.novence/keys/<name>
34
36
  novence keys revoke <id> Revoke a key
35
37
  novence projects List projects and their URLs
36
38
  novence login Sign in, verify email, or recover a key
37
39
  novence claim Attach an email to an anonymous deploy
38
40
  novence whoami Plan, verification, and usage
41
+ novence dashboard Open your local account console (alias: console)
42
+
43
+ Login
44
+ novence login --email <email> Sign in (new account) or verify your email
45
+ novence login --recover --email <email> Lost or revoked key: email code → new key
46
+ --revoke-all Also revoke named keys (use if a key may have leaked)
47
+ --code <6 digits> Finish without a prompt (agents, --no-input)
48
+
49
+ Keys
50
+ novence keys create ci Save to ~/.novence/keys/ci (0600); prints only the prefix
51
+ --output <path> Save somewhere else (0600; --force to replace)
52
+ --print Print the secret to stdout instead, e.g. KEY=$(novence keys create ci --print)
53
+ novence keys revoke <id> Also deletes the matching ~/.novence/keys/<name>
54
+
55
+ Dashboard
56
+ novence dashboard Serve the console on the first free 127.0.0.1 port from 8765
57
+ --port <n> First port to try (default 8765)
58
+ --no-open Print the URL without opening a browser
59
+ --email <email> Email for code sign-in when no API key is saved
60
+ --ttl <minutes> Session length 5–480 (default 30; >30 drops member management)
39
61
 
40
62
  Deploy
41
63
  novence deploy Auto-detects dist, build, out, public, _site
@@ -60,6 +82,7 @@ const HELP = `
60
82
  --json Machine-readable output (implies --no-input)
61
83
  --no-input Never prompt; fail instead
62
84
  --api <url> Control plane (default https://api.novence.ai)
85
+ --key-name <name> Use ~/.novence/keys/<name> (or set NOVENCE_KEY_NAME)
63
86
  -h, --help Show this help
64
87
  -v, --version Show the version
65
88
 
@@ -67,7 +90,8 @@ const HELP = `
67
90
  URL=$(novence deploy ./dist)
68
91
 
69
92
  Auth
70
- Reads NOVENCE_API_KEY, then ./.novence-key, then ~/.novence/credentials.
93
+ Reads --key-name / NOVENCE_KEY_NAME, then NOVENCE_API_KEY, then ./.novence-key,
94
+ then ~/.novence/credentials. Lost key: novence login --recover --email <email>
71
95
  NOVENCE_ATTRIBUTION, when set to JSON, is sent with the first signup.
72
96
 
73
97
  Docs https://novence.ai/docs
@@ -97,14 +121,16 @@ async function main() {
97
121
  open: [],
98
122
  checks: [],
99
123
  domains: ["project", "primary", "redirect"],
100
- keys: ["project", "include-revoked", "force"],
124
+ keys: ["project", "include-revoked", "force", "output", "print"],
101
125
  projects: [],
102
126
  ls: [],
103
- login: ["email", "recover"],
127
+ login: ["email", "recover", "revoke-all", "code"],
104
128
  whoami: [],
105
129
  rollback: ["project", "deployment"],
106
130
  data: ["project"],
107
131
  "site-mcp": ["project"],
132
+ dashboard: ["port", "no-open", "email", "ttl"],
133
+ console: ["port", "no-open", "email", "ttl"],
108
134
  };
109
135
  const allowed = ALLOWED[args.command];
110
136
  if (allowed) {
@@ -112,6 +138,7 @@ async function main() {
112
138
  if (problem)
113
139
  fail(problem);
114
140
  }
141
+ setKeyName(flagString(args.flags, "key-name"));
115
142
  switch (args.command) {
116
143
  case "deploy":
117
144
  return deployCommand(args);
@@ -138,6 +165,9 @@ async function main() {
138
165
  return dataCommand(args);
139
166
  case "site-mcp":
140
167
  return siteMcpCommand(args);
168
+ case "dashboard":
169
+ case "console":
170
+ return dashboardCommand(args);
141
171
  default:
142
172
  fail(`Unknown command: ${args.command}\n Run \`novence --help\` for usage.`);
143
173
  }
package/dist/lib/args.js CHANGED
@@ -41,7 +41,7 @@ export function flagBool(flags, name) {
41
41
  return flags[name] === true || flags[name] === "true";
42
42
  }
43
43
  /** Flags every command accepts. */
44
- export const GLOBAL_FLAGS = ["json", "no-input", "api", "help", "version", "quiet"];
44
+ export const GLOBAL_FLAGS = ["json", "no-input", "api", "help", "version", "quiet", "key-name"];
45
45
  /**
46
46
  * Reject unrecognised flags. Without this a typo like `--nmae "My Site"` is
47
47
  * silently dropped and the user gets a differently-named project with no
@@ -1,3 +1,6 @@
1
+ export declare function isAccountExistsError(err: unknown): boolean;
2
+ /** The one command that restores a lost or revoked key. */
3
+ export declare function recoverHint(email: string | undefined, lead: string): string;
1
4
  export interface Credentials {
2
5
  apiKey: string;
3
6
  /** True when this run created the account, so callers can tailor next steps. */
package/dist/lib/auth.js CHANGED
@@ -1,6 +1,15 @@
1
- import { bootstrapAccount, claimAccount, createDemoAccount, verifyEmail, } from "@novence/sdk";
1
+ import { bootstrapAccount, claimAccount, createDemoAccount, NovenceError, verifyEmail, } from "@novence/sdk";
2
2
  import { loadApiKey, saveApiKey } from "./config.js";
3
3
  import { fail, prompt, step, warn, withSpin } from "./ui.js";
4
+ const ACCOUNT_EXISTS = "ACCOUNT_EXISTS";
5
+ export function isAccountExistsError(err) {
6
+ return err?.code === ACCOUNT_EXISTS;
7
+ }
8
+ /** The one command that restores a lost or revoked key. */
9
+ export function recoverHint(email, lead) {
10
+ const who = email ?? "you@example.com";
11
+ return `${email ? `${email} ` : ""}${lead}\n Recover the key with an email code: novence login --recover --email ${who}`;
12
+ }
4
13
  function attributionFromEnv() {
5
14
  const raw = process.env.NOVENCE_ATTRIBUTION?.trim();
6
15
  let parsed = {};
@@ -42,10 +51,11 @@ async function bootstrapWithEmail(email, apiBase, interactive) {
42
51
  }
43
52
  catch (err) {
44
53
  const message = err instanceof Error ? err.message : String(err);
45
- if (/exists|already|409/i.test(message)) {
46
- fail(`${email} already has an account. Recover the key with an OTP:\n` +
47
- " novence login --email " +
48
- email);
54
+ const status = err instanceof NovenceError ? err.status : undefined;
55
+ if (status === 409 || /exists|already/i.test(message)) {
56
+ throw Object.assign(new Error(recoverHint(email, "already has an account.")), {
57
+ code: ACCOUNT_EXISTS,
58
+ });
49
59
  }
50
60
  throw err;
51
61
  }
@@ -0,0 +1,2 @@
1
+ /** Platform opener; falls back to printing the URL when there is no GUI. */
2
+ export declare function openInBrowser(url: string): boolean;
@@ -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
+ }
@@ -1,13 +1,39 @@
1
1
  export declare const DEFAULT_API = "https://api.novence.ai";
2
+ /** Project-local key file; wins over ~/.novence/credentials. */
3
+ export declare function localKeyPath(): string;
2
4
  /** Explicit flag wins, then env, then the public control plane. */
3
5
  export declare function resolveApiBase(flag?: string): string;
6
+ /** Global `--key-name`; falls back to NOVENCE_KEY_NAME. */
7
+ export declare function setKeyName(name: string | undefined): void;
8
+ export declare function isValidKeyFileName(name: string): boolean;
9
+ export declare function namedKeyPath(name: string): string;
4
10
  /**
5
- * Key lookup order: NOVENCE_API_KEY, then a project-local .novence-key (what
6
- * scripts/launch-site.ts writes), then ~/.novence/credentials.
11
+ * Key lookup order: --key-name / NOVENCE_KEY_NAME (~/.novence/keys/<name>),
12
+ * NOVENCE_API_KEY, a project-local .novence-key, then ~/.novence/credentials.
7
13
  */
8
14
  export declare function loadApiKey(): string | null;
9
- /** Written 0600 — this is a live credential. */
15
+ /**
16
+ * Write a credential at mode 0600 from the first byte. Without `overwrite`
17
+ * an existing file is an error; with it, a temp file is renamed over the
18
+ * target so a previously looser mode is not inherited.
19
+ */
20
+ export declare function writeSecretFile(path: string, value: string, opts: {
21
+ overwrite: boolean;
22
+ }): void;
23
+ /**
24
+ * Store the primary key in ~/.novence/credentials. An existing ./.novence-key
25
+ * is replaced too, since it would otherwise shadow the new key.
26
+ */
10
27
  export declare function saveApiKey(apiKey: string): string;
28
+ /** Store a named key at ~/.novence/keys/<name> or `outputPath`. Returns the path written. */
29
+ export declare function saveNamedKey(name: string, apiKey: string, opts?: {
30
+ outputPath?: string;
31
+ force?: boolean;
32
+ }): string;
33
+ export type NamedKeyRemoval = "removed" | "mismatch" | "absent";
34
+ /** Delete ~/.novence/keys/<name> only if it holds the key with `keyPrefix`. */
35
+ export declare function removeNamedKeyFile(name: string, keyPrefix: string): NamedKeyRemoval;
11
36
  export declare function credentialsPath(): string;
12
37
  /** Where loadApiKey() found the key, for display. */
13
38
  export declare function keySource(): string;
39
+ export declare function notSignedInMessage(): string;
@@ -1,54 +1,146 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
- const CONFIG_DIR = join(homedir(), ".novence");
5
- const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials");
3
+ import { dirname, join, resolve } from "node:path";
6
4
  export const DEFAULT_API = "https://api.novence.ai";
5
+ const KEY_FILE_NAME = /^[A-Za-z0-9_-]+$/;
6
+ let keyNameOverride;
7
+ /** Resolved per call so HOME changes (tests, sudo -E) are honoured. */
8
+ function configDir() {
9
+ return join(homedir(), ".novence");
10
+ }
11
+ function credentialsFile() {
12
+ return join(configDir(), "credentials");
13
+ }
14
+ function keysDir() {
15
+ return join(configDir(), "keys");
16
+ }
17
+ /** Project-local key file; wins over ~/.novence/credentials. */
18
+ export function localKeyPath() {
19
+ return join(process.cwd(), ".novence-key");
20
+ }
7
21
  /** Explicit flag wins, then env, then the public control plane. */
8
22
  export function resolveApiBase(flag) {
9
23
  const raw = flag ?? process.env.NOVENCE_BASE_URL ?? process.env.API_BASE_URL ?? DEFAULT_API;
10
24
  return raw.replace(/\/$/, "");
11
25
  }
26
+ /** Global `--key-name`; falls back to NOVENCE_KEY_NAME. */
27
+ export function setKeyName(name) {
28
+ keyNameOverride = name?.trim() || undefined;
29
+ }
30
+ function selectedKeyName() {
31
+ return keyNameOverride ?? (process.env.NOVENCE_KEY_NAME?.trim() || undefined);
32
+ }
33
+ export function isValidKeyFileName(name) {
34
+ return KEY_FILE_NAME.test(name);
35
+ }
36
+ export function namedKeyPath(name) {
37
+ if (!isValidKeyFileName(name)) {
38
+ throw new Error(`Key name "${name}" can't be used as a file name (letters, digits, _ and - only). Pass --output <path>.`);
39
+ }
40
+ return join(keysDir(), name);
41
+ }
42
+ function readKeyFile(path) {
43
+ if (!existsSync(path))
44
+ return null;
45
+ return readFileSync(path, "utf8").trim() || null;
46
+ }
12
47
  /**
13
- * Key lookup order: NOVENCE_API_KEY, then a project-local .novence-key (what
14
- * scripts/launch-site.ts writes), then ~/.novence/credentials.
48
+ * Key lookup order: --key-name / NOVENCE_KEY_NAME (~/.novence/keys/<name>),
49
+ * NOVENCE_API_KEY, a project-local .novence-key, then ~/.novence/credentials.
15
50
  */
16
51
  export function loadApiKey() {
52
+ const name = selectedKeyName();
53
+ if (name) {
54
+ const path = namedKeyPath(name);
55
+ const key = readKeyFile(path);
56
+ if (!key) {
57
+ throw new Error(`No key named "${name}" at ${path}.\n Create it with: novence keys create ${name}`);
58
+ }
59
+ return key;
60
+ }
17
61
  const fromEnv = process.env.NOVENCE_API_KEY?.trim();
18
62
  if (fromEnv)
19
63
  return fromEnv;
20
- const local = join(process.cwd(), ".novence-key");
21
- if (existsSync(local)) {
22
- const key = readFileSync(local, "utf8").trim();
23
- if (key)
24
- return key;
64
+ return readKeyFile(localKeyPath()) ?? readKeyFile(credentialsFile());
65
+ }
66
+ /**
67
+ * Write a credential at mode 0600 from the first byte. Without `overwrite`
68
+ * an existing file is an error; with it, a temp file is renamed over the
69
+ * target so a previously looser mode is not inherited.
70
+ */
71
+ export function writeSecretFile(path, value, opts) {
72
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
73
+ const body = value.trim() + "\n";
74
+ if (!opts.overwrite) {
75
+ try {
76
+ writeFileSync(path, body, { mode: 0o600, flag: "wx" });
77
+ }
78
+ catch (err) {
79
+ if (err.code === "EEXIST") {
80
+ throw new Error(`${path} already exists. Pass --force to replace it.`);
81
+ }
82
+ throw err;
83
+ }
84
+ return;
25
85
  }
26
- if (existsSync(CREDENTIALS_FILE)) {
27
- const key = readFileSync(CREDENTIALS_FILE, "utf8").trim();
28
- if (key)
29
- return key;
86
+ const tmp = `${path}.${process.pid}.tmp`;
87
+ writeFileSync(tmp, body, { mode: 0o600, flag: "wx" });
88
+ try {
89
+ renameSync(tmp, path);
90
+ }
91
+ catch (err) {
92
+ rmSync(tmp, { force: true });
93
+ throw err;
30
94
  }
31
- return null;
32
95
  }
33
- /** Written 0600 — this is a live credential. */
96
+ /**
97
+ * Store the primary key in ~/.novence/credentials. An existing ./.novence-key
98
+ * is replaced too, since it would otherwise shadow the new key.
99
+ */
34
100
  export function saveApiKey(apiKey) {
35
- mkdirSync(dirname(CREDENTIALS_FILE), { recursive: true, mode: 0o700 });
36
- writeFileSync(CREDENTIALS_FILE, apiKey.trim() + "\n", { mode: 0o600 });
37
- chmodSync(CREDENTIALS_FILE, 0o600);
38
- return CREDENTIALS_FILE;
101
+ const path = credentialsFile();
102
+ writeSecretFile(path, apiKey, { overwrite: true });
103
+ if (existsSync(localKeyPath()))
104
+ writeSecretFile(localKeyPath(), apiKey, { overwrite: true });
105
+ return path;
106
+ }
107
+ /** Store a named key at ~/.novence/keys/<name> or `outputPath`. Returns the path written. */
108
+ export function saveNamedKey(name, apiKey, opts = {}) {
109
+ const path = opts.outputPath ? resolve(opts.outputPath) : namedKeyPath(name);
110
+ writeSecretFile(path, apiKey, { overwrite: Boolean(opts.force) });
111
+ return path;
112
+ }
113
+ /** Delete ~/.novence/keys/<name> only if it holds the key with `keyPrefix`. */
114
+ export function removeNamedKeyFile(name, keyPrefix) {
115
+ if (!isValidKeyFileName(name))
116
+ return "absent";
117
+ const path = namedKeyPath(name);
118
+ const key = readKeyFile(path);
119
+ if (!key)
120
+ return "absent";
121
+ if (!key.startsWith(keyPrefix))
122
+ return "mismatch";
123
+ unlinkSync(path);
124
+ return "removed";
39
125
  }
40
126
  export function credentialsPath() {
41
- return CREDENTIALS_FILE;
127
+ return credentialsFile();
42
128
  }
43
129
  /** Where loadApiKey() found the key, for display. */
44
130
  export function keySource() {
131
+ const name = selectedKeyName();
132
+ if (name)
133
+ return namedKeyPath(name);
45
134
  if (process.env.NOVENCE_API_KEY?.trim())
46
135
  return "NOVENCE_API_KEY";
47
- const local = join(process.cwd(), ".novence-key");
48
- if (existsSync(local) && readFileSync(local, "utf8").trim())
49
- return local;
50
- if (existsSync(CREDENTIALS_FILE) && readFileSync(CREDENTIALS_FILE, "utf8").trim()) {
51
- return CREDENTIALS_FILE;
52
- }
136
+ if (readKeyFile(localKeyPath()))
137
+ return localKeyPath();
138
+ if (readKeyFile(credentialsFile()))
139
+ return credentialsFile();
53
140
  return "(none)";
54
141
  }
142
+ export function notSignedInMessage() {
143
+ return (`Not signed in. Looked in NOVENCE_API_KEY, ${localKeyPath()}, ${credentialsFile()}.\n` +
144
+ " New account: novence login --email you@example.com\n" +
145
+ " Lost key: novence login --recover --email you@example.com");
146
+ }
@@ -27,10 +27,13 @@ export function explain(err, ctx) {
27
27
  const { message: detail, status } = err;
28
28
  switch (status) {
29
29
  case 401:
30
- case 403:
31
30
  return (`${detail}\n` +
32
31
  " Your API key was rejected. It may be revoked, or from another environment.\n" +
33
- " Sign in again: novence login");
32
+ " Lost or revoked key: novence login --recover --email you@example.com");
33
+ case 403:
34
+ return (`${detail}\n` +
35
+ " This key lacks permission for that action (project or member keys are limited).\n" +
36
+ " Check which key is in use: novence whoami");
34
37
  case 402:
35
38
  return (`${detail}\n` +
36
39
  " Redeploy an existing project instead: novence deploy --project <id>\n" +
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "novence",
3
- "version": "0.1.4",
4
- "description": "CLI for Novence — deploy a static site to the edge in one command.",
3
+ "version": "0.3.0",
4
+ "description": "CLI for Novence \u2014 deploy a static site to the edge in one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -39,15 +39,18 @@
39
39
  "publishConfig": {
40
40
  "access": "public"
41
41
  },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.json",
44
+ "typecheck": "tsc -p tsconfig.json --noEmit",
45
+ "test": "tsx --test src/commands/login.test.ts src/commands/keys.test.ts",
46
+ "prepublishOnly": "pnpm run build"
47
+ },
42
48
  "dependencies": {
43
- "@novence/sdk": "^0.2.6"
49
+ "@novence/sdk": "^0.3.0"
44
50
  },
45
51
  "devDependencies": {
46
52
  "@types/node": "^22.10.2",
53
+ "tsx": "^4.19.2",
47
54
  "typescript": "^5.7.2"
48
- },
49
- "scripts": {
50
- "build": "tsc -p tsconfig.json",
51
- "typecheck": "tsc -p tsconfig.json --noEmit"
52
55
  }
53
- }
56
+ }