novence 0.0.2 → 0.1.1

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
@@ -1,15 +1,23 @@
1
1
  # novence
2
2
 
3
- Deploy a static site to the edge in one command.
3
+ Deploy a static site to the edge in one command. Prints the live URL. No email
4
+ until you claim.
4
5
 
5
6
  ```bash
6
7
  npx novence deploy ./dist
7
8
  ```
8
9
 
10
+ ```text
11
+ Created project site (abcd1234)
12
+ Uploaded 12 files
13
+ https://abcd1234.novence.ai/
14
+ ```
15
+
9
16
  Uploads the directory, runs quality checks (Lighthouse, axe, links), publishes to
10
17
  `https://{suffix}.novence.ai/`, and prints the live URL.
11
18
 
12
19
  Part of [Novence](https://novence.ai) — static site hosting built for AI agents.
20
+ Same loop as the homepage kit: Copy Prompt · email OTP · or `npx novence deploy`.
13
21
 
14
22
  ## Install
15
23
 
@@ -28,8 +36,18 @@ novence deploy # auto-detects dist, build, out, public, _site
28
36
  novence deploy ./site # or point at a directory
29
37
  ```
30
38
 
31
- First run asks for an email and a 6-digit code, then stores your API key in
32
- `~/.novence/credentials` (mode 0600). After that, deploys are one command.
39
+ First run with no stored key publishes anonymously (no email), stores
40
+ `~/.novence/credentials` (mode 0600), and prints the live URL. Claim afterwards:
41
+
42
+ ```bash
43
+ novence claim --email you@example.com
44
+ ```
45
+
46
+ Or pass `--email` on first deploy to bootstrap instead of the demo path:
47
+
48
+ ```bash
49
+ novence deploy ./dist --email you@example.com
50
+ ```
33
51
 
34
52
  The live URL is the last line on stdout and progress goes to stderr, so it pipes:
35
53
 
@@ -43,7 +61,7 @@ URL=$(novence deploy ./dist)
43
61
  | --- | --- |
44
62
  | `--name <name>` | Project name (default `site`) |
45
63
  | `--project <id>` | Redeploy an existing project instead of creating one |
46
- | `--email <email>` | Skip the email prompt on first run |
64
+ | `--email <email>` | Bootstrap with email on first deploy instead of anonymous |
47
65
  | `--json` | Machine-readable output; implies `--no-input` |
48
66
  | `--no-input` | Never prompt — fail instead. For CI |
49
67
  | `--api <url>` | Control plane (default `https://api.novence.ai`) |
@@ -51,12 +69,35 @@ URL=$(novence deploy ./dist)
51
69
  ## Other commands
52
70
 
53
71
  ```bash
54
- novence login # sign in, verify email, or recover a key with --recover
55
- novence projects # list projects and their URLs
56
- novence whoami # plan, verification, usage against quota
72
+ novence open [project] # open the live URL in a browser
73
+ novence checks [project] # Lighthouse / axe / link results for the last deploy
74
+ novence domains add <host> # attach a custom domain, prints the DNS records
75
+ novence domains status # custom domain state
76
+ novence keys list # API keys on the account
77
+ novence keys revoke <id> # revoke a key (--force to revoke the one in use)
78
+ novence projects # list projects and their URLs
79
+ novence login # sign in, verify email, or --recover a key
80
+ novence claim # attach an email to an anonymous deploy
81
+ novence whoami # plan, verification, usage against quota
57
82
  ```
58
83
 
59
- ## For agents and CI
84
+ Every command accepts `--json`. Unknown flags are rejected rather than ignored,
85
+ so a typo like `--nmae` fails loudly instead of silently deploying under the
86
+ default name.
87
+
88
+ ## For agents
89
+
90
+ When a shell is available, prefer the CLI over MCP or raw REST:
91
+
92
+ ```bash
93
+ npx novence deploy ./dist
94
+ novence claim --email you@example.com
95
+ ```
96
+
97
+ Copy Prompt kit (same loop, pasteable): [novence.ai](https://novence.ai).
98
+ Full agent brief: [novence.ai/llms-full.txt](https://novence.ai/llms-full.txt).
99
+
100
+ ## For CI
60
101
 
61
102
  `--json` gives a stable envelope and exits non-zero on failure:
62
103
 
@@ -72,7 +113,7 @@ novence whoami # plan, verification, usage against quota
72
113
  }
73
114
  ```
74
115
 
75
- In CI, set `NOVENCE_API_KEY` and pass `--no-input`.
116
+ Set `NOVENCE_API_KEY` and pass `--no-input`.
76
117
 
77
118
  Key lookup order: `NOVENCE_API_KEY`, then `./.novence-key`, then
78
119
  `~/.novence/credentials`. Never commit a key.
@@ -81,17 +122,11 @@ Key lookup order: `NOVENCE_API_KEY`, then `./.novence-key`, then
81
122
 
82
123
  Deploys run Lighthouse, axe, and link checks. On unverified accounts checks are
83
124
  skipped — `checks.skipped` is `true` and `checks.passed` is `null`, which is not
84
- a pass. Verify your email with `novence login` to enable them.
85
-
86
- ## Roadmap
87
-
88
- Today the first deploy requires an email up front. Once anonymous deploys land in
89
- the API, `novence deploy` will publish first and let you claim the site
90
- afterwards — no signup before you see a URL.
125
+ a pass. Verify your email with `novence login` (or claim) to enable them.
91
126
 
92
127
  ## Docs
93
128
 
94
- [Documentation](https://novence.ai/docs) · [Pricing](https://novence.ai/pricing) · [TypeScript SDK](https://www.npmjs.com/package/@novence/sdk) · [MCP server](https://novence.ai/mcp)
129
+ [CLI reference](https://novence.ai/cli) · [Documentation](https://novence.ai/docs) · [Pricing](https://novence.ai/pricing) · [TypeScript SDK](https://www.npmjs.com/package/@novence/sdk) · [MCP server](https://novence.ai/mcp)
95
130
 
96
131
  ## License
97
132
 
@@ -0,0 +1,2 @@
1
+ import { type ParsedArgs } from "../lib/args.js";
2
+ export declare function checksCommand(args: ParsedArgs): Promise<void>;
@@ -0,0 +1,69 @@
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 } from "../lib/ui.js";
6
+ function pct(value) {
7
+ return typeof value === "number" ? String(Math.round(value)) : "—";
8
+ }
9
+ export async function checksCommand(args) {
10
+ const apiBase = resolveApiBase(flagString(args.flags, "api"));
11
+ const apiKey = loadApiKey();
12
+ if (!apiKey)
13
+ fail("Not signed in. Run `novence login`.");
14
+ const client = new Novence({ apiKey, baseUrl: apiBase });
15
+ const wanted = args.positionals[0];
16
+ let projectId = wanted;
17
+ try {
18
+ if (!projectId) {
19
+ const { projects } = await client.projects.list();
20
+ if (projects.length === 0)
21
+ fail("No projects yet. Run `novence deploy ./dist`.");
22
+ projectId = projects[0].id;
23
+ }
24
+ const { deployment } = await client.deployments.latest(projectId);
25
+ const checks = deployment.checksResults;
26
+ if (isJsonMode()) {
27
+ json({ ok: true, projectId, status: deployment.status, checks: checks ?? null });
28
+ return;
29
+ }
30
+ result(`deployment ${deployment.id}`);
31
+ result(`status ${deployment.status}`);
32
+ if (deployment.errorMessage)
33
+ result(`error ${deployment.errorMessage}`);
34
+ if (!checks) {
35
+ result("checks not available for this deployment");
36
+ return;
37
+ }
38
+ if (checks.skipped) {
39
+ result(`checks skipped${checks.reason ? ` (${checks.reason})` : ""}`);
40
+ result(" Verify your email to enable checks: novence login");
41
+ return;
42
+ }
43
+ result(`checks ${checks.passed === true ? "passed" : checks.passed === false ? "FAILED" : "unknown"}`);
44
+ const lh = checks.lighthouse;
45
+ if (lh) {
46
+ result(`lighthouse perf ${pct(lh.performance)} a11y ${pct(lh.accessibility)} ` +
47
+ `seo ${pct(lh.seo)} best-practices ${pct(lh.bestPractices)}`);
48
+ }
49
+ const axe = checks.axe;
50
+ if (axe) {
51
+ result(`axe critical ${axe.critical ?? 0} serious ${axe.serious ?? 0} ` +
52
+ `moderate ${axe.moderate ?? 0} minor ${axe.minor ?? 0}`);
53
+ }
54
+ const links = checks.links;
55
+ if (links) {
56
+ result(`links ${links.checked ?? 0} checked, ${(links.broken ?? []).length} broken`);
57
+ }
58
+ if (deployment.checksUrl) {
59
+ // The API returns a path; make it something you can actually open.
60
+ const url = deployment.checksUrl.startsWith("http")
61
+ ? deployment.checksUrl
62
+ : `${apiBase}${deployment.checksUrl}`;
63
+ result(`report ${url}`);
64
+ }
65
+ }
66
+ catch (err) {
67
+ fail(explain(err, { apiBase, projectId: wanted }));
68
+ }
69
+ }
@@ -0,0 +1,2 @@
1
+ import { type ParsedArgs } from "../lib/args.js";
2
+ export declare function claimCommand(args: ParsedArgs): Promise<void>;
@@ -0,0 +1,16 @@
1
+ import { loadApiKey, resolveApiBase } from "../lib/config.js";
2
+ import { claimAndMaybeVerify } from "../lib/auth.js";
3
+ import { flagBool, flagString } from "../lib/args.js";
4
+ import { fail, isJsonMode, prompt, result } from "../lib/ui.js";
5
+ export async function claimCommand(args) {
6
+ const apiBase = resolveApiBase(flagString(args.flags, "api"));
7
+ const apiKey = loadApiKey();
8
+ if (!apiKey)
9
+ fail("No API key. Deploy once (`novence deploy`) or set NOVENCE_API_KEY.");
10
+ const interactive = !isJsonMode() && !flagBool(args.flags, "no-input");
11
+ const email = flagString(args.flags, "email") ?? (interactive ? await prompt(" Email: ") : "");
12
+ if (!email)
13
+ fail("Email required. Use `--email you@example.com`.");
14
+ await claimAndMaybeVerify({ apiKey, apiBase, email, interactive });
15
+ result(`Claimed ${email}. Verify if you have not already.`);
16
+ }
@@ -1,10 +1,11 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { Novence } from "@novence/sdk";
4
- import { ensureApiKey } from "../lib/auth.js";
4
+ import { claimAndMaybeVerify, ensureApiKey } from "../lib/auth.js";
5
5
  import { resolveApiBase } from "../lib/config.js";
6
6
  import { flagBool, flagString } from "../lib/args.js";
7
- import { fail, isJsonMode, json, result, step, warn } from "../lib/ui.js";
7
+ import { fail, isJsonMode, json, prompt, result, siteUrl, spin, step, warn, withSpin } from "../lib/ui.js";
8
+ import { explain } from "../lib/errors.js";
8
9
  const POLL_INTERVAL_MS = 2000;
9
10
  const POLL_TIMEOUT_MS = 5 * 60 * 1000;
10
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -25,19 +26,34 @@ function resolveSiteDir(explicit) {
25
26
  return fail(`No build output found. Looked for ${CANDIDATE_DIRS.join(", ")} in ${process.cwd()}.\n` +
26
27
  " Pass one explicitly: novence deploy ./your-dir");
27
28
  }
28
- async function waitForLive(client, projectId) {
29
+ async function publish(client, projectId) {
30
+ const spinner = spin("Publishing…");
29
31
  const startedAt = Date.now();
30
- let lastStatus = "";
31
- while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
32
- const { deployment } = await client.deployments.latest(projectId);
33
- if (deployment.status !== lastStatus) {
34
- step(`${deployment.status}…`);
35
- lastStatus = deployment.status;
32
+ try {
33
+ await client.deployments.create(projectId);
34
+ let lastStatus = "";
35
+ while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
36
+ const { deployment } = await client.deployments.latest(projectId);
37
+ if (deployment.status !== lastStatus) {
38
+ spinner.update(`${deployment.status}…`);
39
+ lastStatus = deployment.status;
40
+ }
41
+ if (deployment.status === "live") {
42
+ spinner.stop("Live");
43
+ return deployment;
44
+ }
45
+ if (deployment.status === "failed") {
46
+ spinner.stop();
47
+ return deployment;
48
+ }
49
+ await sleep(POLL_INTERVAL_MS);
36
50
  }
37
- if (deployment.status === "live" || deployment.status === "failed")
38
- return deployment;
39
- await sleep(POLL_INTERVAL_MS);
40
51
  }
52
+ catch (err) {
53
+ spinner.stop();
54
+ throw err;
55
+ }
56
+ spinner.stop();
41
57
  return fail("Timed out after 5 minutes waiting for the deploy to go live.");
42
58
  }
43
59
  function reportChecks(deployment) {
@@ -60,31 +76,38 @@ export async function deployCommand(args) {
60
76
  if (!existsSync(resolve(siteDir, "index.html"))) {
61
77
  fail(`${siteDir} has no index.html — a static site needs one at its root.`);
62
78
  }
63
- const { apiKey } = await ensureApiKey({
79
+ let projectId = flagString(args.flags, "project");
80
+ try {
81
+ await runDeploy(args, apiBase, siteDir);
82
+ }
83
+ catch (err) {
84
+ fail(explain(err, { apiBase, projectId }));
85
+ }
86
+ }
87
+ async function runDeploy(args, apiBase, siteDir) {
88
+ const interactive = !isJsonMode() && !flagBool(args.flags, "no-input");
89
+ const creds = await ensureApiKey({
64
90
  apiBase,
65
91
  email: flagString(args.flags, "email"),
66
- interactive: !isJsonMode() && !flagBool(args.flags, "no-input"),
92
+ interactive,
67
93
  });
68
- const client = new Novence({ apiKey, baseUrl: apiBase });
94
+ const client = new Novence({ apiKey: creds.apiKey, baseUrl: apiBase });
69
95
  let projectId = flagString(args.flags, "project");
70
- let siteUrl;
96
+ let projectUrl;
71
97
  if (projectId) {
72
98
  const { project } = await client.projects.get(projectId);
73
- siteUrl = project.url;
99
+ projectUrl = project.url;
74
100
  step(`Reusing project ${project.name} (${project.suffix})`);
75
101
  }
76
102
  else {
77
103
  const name = flagString(args.flags, "name") ?? "site";
78
104
  const created = await client.projects.create({ name });
79
105
  projectId = created.project.id;
80
- siteUrl = created.url;
106
+ projectUrl = created.url;
81
107
  step(`Created project ${name} (${created.project.suffix})`);
82
108
  }
83
- step(`Uploading ${siteDir}…`);
84
- const { uploaded } = await client.files.uploadDir(projectId, siteDir);
85
- step(`Uploaded ${uploaded.length} file${uploaded.length === 1 ? "" : "s"}`);
86
- await client.deployments.create(projectId);
87
- const deployment = await waitForLive(client, projectId);
109
+ const { uploaded } = await withSpin(`Uploading ${siteDir}…`, () => client.files.uploadDir(projectId, siteDir), ({ uploaded: files }) => `Uploaded ${files.length} file${files.length === 1 ? "" : "s"}`);
110
+ const deployment = await publish(client, projectId);
88
111
  if (deployment.status === "failed") {
89
112
  if (isJsonMode()) {
90
113
  json({ ok: false, projectId, status: deployment.status, error: deployment.errorMessage });
@@ -93,7 +116,7 @@ export async function deployCommand(args) {
93
116
  fail(deployment.errorMessage ?? "Deploy failed.");
94
117
  }
95
118
  reportChecks(deployment);
96
- const liveUrl = (deployment.liveUrl ?? siteUrl).replace(/\/?$/, "/");
119
+ const liveUrl = siteUrl(deployment.liveUrl ?? projectUrl);
97
120
  if (isJsonMode()) {
98
121
  json({
99
122
  ok: true,
@@ -108,4 +131,10 @@ export async function deployCommand(args) {
108
131
  }
109
132
  // Last line, alone, so it pipes cleanly.
110
133
  result(liveUrl);
134
+ if (creds.anonymous && interactive) {
135
+ warn("Claim this site to keep it past 30 days and unlock checks.");
136
+ const email = await prompt(" Email (Enter to skip): ");
137
+ if (email)
138
+ await claimAndMaybeVerify({ apiKey: creds.apiKey, apiBase, email, interactive: true });
139
+ }
111
140
  }
@@ -0,0 +1,2 @@
1
+ import { type ParsedArgs } from "../lib/args.js";
2
+ export declare function domainsCommand(args: ParsedArgs): Promise<void>;
@@ -0,0 +1,84 @@
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 domains add <hostname> --project <id>\n" +
15
+ " novence projects");
16
+ }
17
+ return projects[0].id;
18
+ }
19
+ export async function domainsCommand(args) {
20
+ const apiBase = resolveApiBase(flagString(args.flags, "api"));
21
+ const apiKey = loadApiKey();
22
+ if (!apiKey)
23
+ fail("Not signed in. Run `novence login`.");
24
+ const client = new Novence({ apiKey, baseUrl: apiBase });
25
+ const sub = args.positionals[0] ?? "status";
26
+ const projectFlag = flagString(args.flags, "project");
27
+ try {
28
+ if (sub === "add") {
29
+ const hostname = args.positionals[1];
30
+ if (!hostname) {
31
+ fail("Usage: novence domains add <hostname> [--project <id>] [--primary www|apex]");
32
+ }
33
+ const projectId = await resolveProjectId(client, projectFlag);
34
+ const res = await client.domains.configure(projectId, hostname);
35
+ if (isJsonMode()) {
36
+ json({ ok: true, projectId, hostname, ...res });
37
+ return;
38
+ }
39
+ step(`Attached ${hostname}`);
40
+ const records = res.instructions?.records ?? [];
41
+ if (records.length === 0) {
42
+ result("No DNS records returned — check `novence domains status`.");
43
+ return;
44
+ }
45
+ result("");
46
+ result("Create these DNS records at your registrar:");
47
+ result("");
48
+ for (const rec of records) {
49
+ const type = String(rec.type ?? "?");
50
+ const name = String(rec.name ?? rec.host ?? "@");
51
+ const target = String(rec.target ?? rec.value ?? "");
52
+ result(` ${type.padEnd(14)} ${name.padEnd(20)} ${target}`);
53
+ }
54
+ result("");
55
+ result("Then check propagation with: novence domains status");
56
+ return;
57
+ }
58
+ if (sub === "status") {
59
+ const projectId = await resolveProjectId(client, projectFlag);
60
+ const status = await client.domains.status(projectId);
61
+ if (isJsonMode()) {
62
+ json({ ok: true, projectId, ...status });
63
+ return;
64
+ }
65
+ const host = status.customDomain ?? status.hostname;
66
+ if (!host) {
67
+ result("No custom domain attached.");
68
+ result(" Attach one with: novence domains add <hostname>");
69
+ return;
70
+ }
71
+ result(`domain ${host}`);
72
+ result(`status ${status.customDomainStatus ?? status.status ?? "unknown"}`);
73
+ const verifications = status.verifications;
74
+ if (Array.isArray(verifications) && verifications.length > 0) {
75
+ result(`checks ${verifications.length} verification record(s) pending or done`);
76
+ }
77
+ return;
78
+ }
79
+ fail(`Unknown subcommand: ${sub}\n Usage: novence domains add <hostname> | novence domains status`);
80
+ }
81
+ catch (err) {
82
+ fail(explain(err, { apiBase, projectId: projectFlag }));
83
+ }
84
+ }
@@ -0,0 +1,2 @@
1
+ import { type ParsedArgs } from "../lib/args.js";
2
+ export declare function keysCommand(args: ParsedArgs): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import { Novence } from "@novence/sdk";
2
+ import { loadApiKey, resolveApiBase } from "../lib/config.js";
3
+ import { flagBool, flagString } from "../lib/args.js";
4
+ import { explain } from "../lib/errors.js";
5
+ import { fail, isJsonMode, json, result, step } from "../lib/ui.js";
6
+ export async function keysCommand(args) {
7
+ const apiBase = resolveApiBase(flagString(args.flags, "api"));
8
+ const apiKey = loadApiKey();
9
+ if (!apiKey)
10
+ fail("Not signed in. Run `novence login`.");
11
+ const client = new Novence({ apiKey, baseUrl: apiBase });
12
+ const sub = args.positionals[0] ?? "list";
13
+ try {
14
+ if (sub === "list") {
15
+ const { keys } = await client.apiKeys.list({
16
+ projectId: flagString(args.flags, "project"),
17
+ includeRevoked: flagBool(args.flags, "include-revoked"),
18
+ });
19
+ if (isJsonMode()) {
20
+ json({ ok: true, keys });
21
+ return;
22
+ }
23
+ if (keys.length === 0) {
24
+ result("No keys.");
25
+ return;
26
+ }
27
+ const w = Math.max(...keys.map((k) => k.name.length), 4);
28
+ for (const k of keys) {
29
+ const marks = [k.current ? "current" : "", k.revokedAt ? "revoked" : ""]
30
+ .filter(Boolean)
31
+ .join(", ");
32
+ result(`${k.keyPrefix} ${k.name.padEnd(w)} ${k.scope.padEnd(7)} ${k.id}${marks ? ` (${marks})` : ""}`);
33
+ }
34
+ return;
35
+ }
36
+ if (sub === "revoke") {
37
+ const id = args.positionals[1];
38
+ if (!id)
39
+ fail("Usage: novence keys revoke <id> [--force]\n Find ids with: novence keys list");
40
+ const force = flagBool(args.flags, "force");
41
+ const res = await client.apiKeys.revoke(id, { force });
42
+ if (isJsonMode()) {
43
+ json({ ok: true, ...res });
44
+ return;
45
+ }
46
+ result(`Revoked ${id}`);
47
+ if (force)
48
+ step("That was the key this machine uses — run `novence login` to get a new one.");
49
+ return;
50
+ }
51
+ fail(`Unknown subcommand: ${sub}\n Usage: novence keys list | novence keys revoke <id>`);
52
+ }
53
+ catch (err) {
54
+ fail(explain(err, { apiBase }));
55
+ }
56
+ }
@@ -1,23 +1,30 @@
1
- import { reissueApiKey, resendVerification, verifyEmail } from "@novence/sdk";
2
- import { ensureApiKey } from "../lib/auth.js";
1
+ import { Novence } from "@novence/sdk";
2
+ import { claimAndMaybeVerify, ensureApiKey } from "../lib/auth.js";
3
3
  import { credentialsPath, loadApiKey, resolveApiBase, saveApiKey } from "../lib/config.js";
4
+ import { reissueApiKey, resendVerification, verifyEmail } from "@novence/sdk";
4
5
  import { flagString } from "../lib/args.js";
5
- import { fail, prompt, result, step } from "../lib/ui.js";
6
+ import { fail, prompt, result, withSpin } from "../lib/ui.js";
6
7
  export async function loginCommand(args) {
7
8
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
8
- const email = flagString(args.flags, "email");
9
- // No key yet — the normal bootstrap path handles account creation + OTP.
9
+ const emailFlag = flagString(args.flags, "email");
10
10
  if (!loadApiKey()) {
11
+ const email = emailFlag ?? (await prompt(" Email: "));
11
12
  await ensureApiKey({ apiBase, email, interactive: true });
12
13
  result(`Signed in. Key stored at ${credentialsPath()}`);
13
14
  return;
14
15
  }
15
- // Key exists. Either verify this email, or recover a lost key via OTP.
16
- const address = email ?? (await prompt(" Email: "));
16
+ const address = emailFlag ?? (await prompt(" Email: "));
17
17
  if (!address.includes("@"))
18
18
  fail(`Not a valid email: ${address}`);
19
- step(`Sending a code to ${address}…`);
20
- const sent = await resendVerification(address, apiBase);
19
+ const apiKey = loadApiKey();
20
+ const client = new Novence({ apiKey, baseUrl: apiBase });
21
+ const snap = await client.account.get();
22
+ if (!snap.email) {
23
+ await claimAndMaybeVerify({ apiKey, apiBase, email: address, interactive: true });
24
+ result(`${address} claimed.`);
25
+ return;
26
+ }
27
+ const sent = await withSpin(`Sending a code to ${address}…`, () => resendVerification(address, apiBase));
21
28
  const code = sent.verificationCode ?? (await prompt(" 6-digit code from your email: "));
22
29
  if (!code)
23
30
  fail("No code entered.");
@@ -0,0 +1,2 @@
1
+ import { type ParsedArgs } from "../lib/args.js";
2
+ export declare function openCommand(args: ParsedArgs): Promise<void>;
@@ -0,0 +1,51 @@
1
+ import { spawn } from "node:child_process";
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, 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
+ export async function openCommand(args) {
20
+ const apiBase = resolveApiBase(flagString(args.flags, "api"));
21
+ const apiKey = loadApiKey();
22
+ if (!apiKey)
23
+ fail("Not signed in. Run `novence login`.");
24
+ const client = new Novence({ apiKey, baseUrl: apiBase });
25
+ const wanted = args.positionals[0];
26
+ try {
27
+ const { projects } = await client.projects.list();
28
+ if (projects.length === 0) {
29
+ fail("No projects yet. Run `novence deploy ./dist` to create one.");
30
+ }
31
+ // Match on id, suffix, or name so any of them works.
32
+ const project = wanted
33
+ ? projects.find((p) => p.id === wanted || p.suffix === wanted || p.name === wanted)
34
+ : projects[0];
35
+ if (!project) {
36
+ fail(`No project matching "${wanted}".\n List them with: novence projects`);
37
+ }
38
+ if (isJsonMode()) {
39
+ json({ ok: true, projectId: project.id, url: siteUrl(project.url) });
40
+ return;
41
+ }
42
+ const target = siteUrl(project.url);
43
+ const opened = openInBrowser(target);
44
+ if (!opened)
45
+ step("Could not launch a browser — the URL is below.");
46
+ result(target);
47
+ }
48
+ catch (err) {
49
+ fail(explain(err, { apiBase }));
50
+ }
51
+ }
@@ -8,14 +8,23 @@ export async function whoamiCommand(args) {
8
8
  if (!apiKey)
9
9
  fail("Not signed in. Run `novence login`.");
10
10
  const client = new Novence({ apiKey, baseUrl: apiBase });
11
+ const snap = await client.account.get();
11
12
  const quotas = await client.billing.quotas();
12
13
  // Project count is not part of the usage payload — count them directly.
13
14
  const { projects } = await client.projects.list();
14
15
  if (isJsonMode()) {
15
- json({ ok: true, ...quotas, projectCount: projects.length, apiBase });
16
+ json({
17
+ ok: true,
18
+ ...quotas,
19
+ email: snap.email ?? null,
20
+ anonymous: !snap.email,
21
+ projectCount: projects.length,
22
+ apiBase,
23
+ });
16
24
  return;
17
25
  }
18
26
  result(`plan ${quotas.plan}`);
27
+ result(`email ${typeof snap.email === "string" ? snap.email : "(unclaimed)"}`);
19
28
  result(`email verified ${quotas.emailVerified ? "yes" : "no"}`);
20
29
  result(`api ${apiBase}`);
21
30
  result(`key ${keySource()}`);
package/dist/index.js CHANGED
@@ -2,27 +2,41 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { flagBool, parseArgs } from "./lib/args.js";
5
+ import { assertKnownFlags, flagBool, flagString, parseArgs } from "./lib/args.js";
6
+ import { resolveApiBase } from "./lib/config.js";
7
+ import { explain } from "./lib/errors.js";
6
8
  import { fail, isJsonMode, result, setJsonMode } from "./lib/ui.js";
7
9
  import { deployCommand } from "./commands/deploy.js";
8
10
  import { loginCommand } from "./commands/login.js";
11
+ import { claimCommand } from "./commands/claim.js";
9
12
  import { projectsCommand } from "./commands/projects.js";
10
13
  import { whoamiCommand } from "./commands/whoami.js";
14
+ import { openCommand } from "./commands/open.js";
15
+ import { domainsCommand } from "./commands/domains.js";
16
+ import { keysCommand } from "./commands/keys.js";
17
+ import { checksCommand } from "./commands/checks.js";
11
18
  const HELP = `
12
19
  novence — deploy a static site to the edge
13
20
 
14
21
  Usage
15
22
  novence deploy [dir] Upload a directory and publish it live
23
+ novence open [project] Open a project's live URL
24
+ novence checks [project] Quality-gate results for the latest deploy
25
+ novence domains add <host> Attach a custom domain (prints DNS records)
26
+ novence domains status Custom domain state
27
+ novence keys list API keys on the account
28
+ novence keys revoke <id> Revoke a key
29
+ novence projects List projects and their URLs
16
30
  novence login Sign in, verify email, or recover a key
17
- novence projects List your projects and their URLs
18
- novence whoami Show plan, verification, and usage
31
+ novence claim Attach an email to an anonymous deploy
32
+ novence whoami Plan, verification, and usage
19
33
 
20
34
  Deploy
21
35
  novence deploy Auto-detects dist, build, out, public, _site
22
36
  novence deploy ./site Deploy a specific directory
23
37
  --name <name> Project name (default: "site")
24
38
  --project <id> Redeploy an existing project
25
- --email <email> Skip the email prompt on first run
39
+ --email <email> Skip the email prompt on first run (bootstrap instead of demo)
26
40
 
27
41
  Global
28
42
  --json Machine-readable output (implies --no-input)
@@ -56,11 +70,41 @@ async function main() {
56
70
  process.stdout.write(HELP);
57
71
  return;
58
72
  }
73
+ // Per-command flag allowlists — a typo should fail loudly, not silently
74
+ // change behaviour.
75
+ const ALLOWED = {
76
+ deploy: ["name", "project", "email"],
77
+ claim: ["email"],
78
+ open: [],
79
+ checks: [],
80
+ domains: ["project", "primary", "redirect"],
81
+ keys: ["project", "include-revoked", "force"],
82
+ projects: [],
83
+ ls: [],
84
+ login: ["email", "recover"],
85
+ whoami: [],
86
+ };
87
+ const allowed = ALLOWED[args.command];
88
+ if (allowed) {
89
+ const problem = assertKnownFlags(args.flags, allowed, args.command);
90
+ if (problem)
91
+ fail(problem);
92
+ }
59
93
  switch (args.command) {
60
94
  case "deploy":
61
95
  return deployCommand(args);
96
+ case "open":
97
+ return openCommand(args);
98
+ case "checks":
99
+ return checksCommand(args);
100
+ case "domains":
101
+ return domainsCommand(args);
102
+ case "keys":
103
+ return keysCommand(args);
62
104
  case "login":
63
105
  return loginCommand(args);
106
+ case "claim":
107
+ return claimCommand(args);
64
108
  case "projects":
65
109
  case "ls":
66
110
  return projectsCommand(args);
@@ -71,5 +115,7 @@ async function main() {
71
115
  }
72
116
  }
73
117
  main().catch((err) => {
74
- fail(err instanceof Error ? err.message : String(err));
118
+ // Last-resort handler; commands that know a projectId explain it themselves.
119
+ const apiBase = resolveApiBase(flagString(parseArgs(process.argv.slice(2)).flags, "api"));
120
+ fail(explain(err, { apiBase }));
75
121
  });
@@ -7,3 +7,11 @@ export interface ParsedArgs {
7
7
  export declare function parseArgs(argv: string[]): ParsedArgs;
8
8
  export declare function flagString(flags: ParsedArgs["flags"], name: string): string | undefined;
9
9
  export declare function flagBool(flags: ParsedArgs["flags"], name: string): boolean;
10
+ /** Flags every command accepts. */
11
+ export declare const GLOBAL_FLAGS: string[];
12
+ /**
13
+ * Reject unrecognised flags. Without this a typo like `--nmae "My Site"` is
14
+ * silently dropped and the user gets a differently-named project with no
15
+ * explanation.
16
+ */
17
+ export declare function assertKnownFlags(flags: ParsedArgs["flags"], allowed: string[], command: string): string | null;
package/dist/lib/args.js CHANGED
@@ -40,3 +40,20 @@ export function flagString(flags, name) {
40
40
  export function flagBool(flags, name) {
41
41
  return flags[name] === true || flags[name] === "true";
42
42
  }
43
+ /** Flags every command accepts. */
44
+ export const GLOBAL_FLAGS = ["json", "no-input", "api", "help", "version", "quiet"];
45
+ /**
46
+ * Reject unrecognised flags. Without this a typo like `--nmae "My Site"` is
47
+ * silently dropped and the user gets a differently-named project with no
48
+ * explanation.
49
+ */
50
+ export function assertKnownFlags(flags, allowed, command) {
51
+ const known = new Set([...allowed, ...GLOBAL_FLAGS]);
52
+ const unknown = Object.keys(flags).filter((f) => !known.has(f));
53
+ if (unknown.length === 0)
54
+ return null;
55
+ const valid = [...allowed, ...GLOBAL_FLAGS].sort().map((f) => `--${f}`).join(" ");
56
+ return (`Unknown ${unknown.length > 1 ? "flags" : "flag"} for \`novence ${command}\`: ` +
57
+ unknown.map((f) => `--${f}`).join(", ") +
58
+ `\n Valid flags: ${valid}`);
59
+ }
@@ -2,16 +2,20 @@ export interface Credentials {
2
2
  apiKey: string;
3
3
  /** True when this run created the account, so callers can tailor next steps. */
4
4
  fresh: boolean;
5
+ anonymous: boolean;
5
6
  }
6
7
  /**
7
- * Returns a usable API key, bootstrapping interactively if none is stored.
8
- *
9
- * The API currently requires an email at bootstrap. Once anonymous deploys land
10
- * (`POST /v1/demo`), deploy should publish first and claim afterwards — see the
11
- * deploy-first note in the README.
8
+ * Returns a usable API key. With no stored key and no `--email`, creates an
9
+ * anonymous demo account so deploy can print a live URL first.
12
10
  */
13
11
  export declare function ensureApiKey(opts: {
14
12
  apiBase: string;
15
13
  email?: string;
16
14
  interactive: boolean;
17
15
  }): Promise<Credentials>;
16
+ export declare function claimAndMaybeVerify(opts: {
17
+ apiKey: string;
18
+ apiBase: string;
19
+ email: string;
20
+ interactive: boolean;
21
+ }): Promise<void>;
package/dist/lib/auth.js CHANGED
@@ -1,34 +1,35 @@
1
- import { bootstrapAccount, verifyEmail } from "@novence/sdk";
1
+ import { bootstrapAccount, claimAccount, createDemoAccount, verifyEmail } from "@novence/sdk";
2
2
  import { loadApiKey, saveApiKey } from "./config.js";
3
- import { fail, prompt, step, warn } from "./ui.js";
3
+ import { fail, prompt, step, warn, withSpin } from "./ui.js";
4
4
  /**
5
- * Returns a usable API key, bootstrapping interactively if none is stored.
6
- *
7
- * The API currently requires an email at bootstrap. Once anonymous deploys land
8
- * (`POST /v1/demo`), deploy should publish first and claim afterwards — see the
9
- * deploy-first note in the README.
5
+ * Returns a usable API key. With no stored key and no `--email`, creates an
6
+ * anonymous demo account so deploy can print a live URL first.
10
7
  */
11
8
  export async function ensureApiKey(opts) {
12
9
  const existing = loadApiKey();
13
10
  if (existing)
14
- return { apiKey: existing, fresh: false };
15
- if (!opts.interactive) {
16
- fail("No API key found. Set NOVENCE_API_KEY, or run `novence login` first.\n" +
17
- " Non-interactive runs cannot complete the email OTP step.");
18
- }
19
- const email = opts.email ?? (await prompt(" Email (for your account + site URL): "));
11
+ return { apiKey: existing, fresh: false, anonymous: false };
12
+ if (opts.email)
13
+ return bootstrapWithEmail(opts.email, opts.apiBase, opts.interactive);
14
+ const demo = await withSpin("Creating a temporary account…", () => createDemoAccount(opts.apiBase));
15
+ const path = saveApiKey(demo.apiKey);
16
+ step(`Anonymous account ${demo.accountId} · key saved to ${path}`);
17
+ warn("Unclaimed site — run `novence claim --email you@example.com` to keep it.");
18
+ return { apiKey: demo.apiKey, fresh: true, anonymous: true };
19
+ }
20
+ async function bootstrapWithEmail(email, apiBase, interactive) {
20
21
  if (!email.includes("@"))
21
22
  fail(`Not a valid email: ${email}`);
22
- step(`Creating your account (${email})…`);
23
23
  let boot;
24
24
  try {
25
- boot = await bootstrapAccount(email, opts.apiBase);
25
+ boot = await withSpin(`Creating your account (${email})…`, () => bootstrapAccount(email, apiBase));
26
26
  }
27
27
  catch (err) {
28
28
  const message = err instanceof Error ? err.message : String(err);
29
29
  if (/exists|already|409/i.test(message)) {
30
30
  fail(`${email} already has an account. Recover the key with an OTP:\n` +
31
- " novence login --email " + email);
31
+ " novence login --email " +
32
+ email);
32
33
  }
33
34
  throw err;
34
35
  }
@@ -36,19 +37,42 @@ export async function ensureApiKey(opts) {
36
37
  step(`Account ${boot.accountId} · plan ${boot.plan}`);
37
38
  step(`Key saved to ${path}`);
38
39
  if (boot.verificationCode) {
39
- // Local/dev API returns the OTP inline.
40
- await verifyEmail(email, boot.verificationCode, opts.apiBase);
40
+ await verifyEmail(email, boot.verificationCode, apiBase);
41
41
  step("Email verified");
42
42
  }
43
- else if (boot.emailVerificationRequired) {
43
+ else if (boot.emailVerificationRequired && interactive) {
44
44
  const code = await prompt(" 6-digit code from your email (Enter to skip): ");
45
45
  if (code) {
46
- await verifyEmail(email, code, opts.apiBase);
46
+ await verifyEmail(email, code, apiBase);
47
47
  step("Email verified");
48
48
  }
49
49
  else {
50
50
  warn("Unverified: 1 project, 3 deploys/mo, checks skipped. Run `novence login` to verify.");
51
51
  }
52
52
  }
53
- return { apiKey: boot.apiKey, fresh: true };
53
+ else if (boot.emailVerificationRequired) {
54
+ warn("Unverified: run `novence login --email " + email + "` to unlock checks.");
55
+ }
56
+ return { apiKey: boot.apiKey, fresh: true, anonymous: false };
57
+ }
58
+ export async function claimAndMaybeVerify(opts) {
59
+ if (!opts.email.includes("@"))
60
+ fail(`Not a valid email: ${opts.email}`);
61
+ const claimed = await withSpin(`Claiming account for ${opts.email}…`, () => claimAccount(opts.apiKey, opts.email, opts.apiBase));
62
+ if (claimed.verificationCode) {
63
+ await verifyEmail(opts.email, claimed.verificationCode, opts.apiBase);
64
+ step("Email verified");
65
+ return;
66
+ }
67
+ if (!opts.interactive) {
68
+ warn("OTP emailed. Run `novence login --email " + opts.email + "` to verify.");
69
+ return;
70
+ }
71
+ const code = await prompt(" 6-digit code from your email: ");
72
+ if (!code) {
73
+ warn("Skipped verify. Run `novence login --email " + opts.email + "` later.");
74
+ return;
75
+ }
76
+ await verifyEmail(opts.email, code, opts.apiBase);
77
+ step("Email verified");
54
78
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Turn an SDK error into something a user can act on. Every branch names the
3
+ * next command to run — a bare "Unauthorized" tells nobody anything.
4
+ */
5
+ export declare function explain(err: unknown, ctx: {
6
+ apiBase: string;
7
+ projectId?: string;
8
+ }): string;
@@ -0,0 +1,57 @@
1
+ import { NovenceError } from "@novence/sdk";
2
+ /** Node wraps connection failures in a TypeError with the real cause nested. */
3
+ function networkCause(err) {
4
+ if (!(err instanceof Error))
5
+ return null;
6
+ const cause = err.cause;
7
+ const code = cause?.code;
8
+ if (code)
9
+ return code;
10
+ return /fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN/i.test(err.message)
11
+ ? "ECONNREFUSED"
12
+ : null;
13
+ }
14
+ /**
15
+ * Turn an SDK error into something a user can act on. Every branch names the
16
+ * next command to run — a bare "Unauthorized" tells nobody anything.
17
+ */
18
+ export function explain(err, ctx) {
19
+ const net = networkCause(err);
20
+ if (net) {
21
+ const hint = net === "ENOTFOUND" || net === "EAI_AGAIN"
22
+ ? "The host could not be resolved — check the URL and your DNS."
23
+ : "Nothing is listening there — check the URL, or your network/VPN.";
24
+ return `Cannot reach the Novence API at ${ctx.apiBase} (${net}).\n ${hint}`;
25
+ }
26
+ if (err instanceof NovenceError) {
27
+ const { message: detail, status } = err;
28
+ switch (status) {
29
+ case 401:
30
+ case 403:
31
+ return (`${detail}\n` +
32
+ " Your API key was rejected. It may be revoked, or from another environment.\n" +
33
+ " Sign in again: novence login");
34
+ case 402:
35
+ return (`${detail}\n` +
36
+ " Redeploy an existing project instead: novence deploy --project <id>\n" +
37
+ " List what you have: novence projects\n" +
38
+ " Or raise your limits: https://novence.ai/pricing");
39
+ case 404:
40
+ return ctx.projectId
41
+ ? `Project ${ctx.projectId} not found on this account.\n` +
42
+ " Check the id with: novence projects"
43
+ : `${detail}\n The requested resource does not exist on this account.`;
44
+ case 409:
45
+ return detail;
46
+ case 429:
47
+ return `${detail}\n You are being rate limited. Wait a minute and retry.`;
48
+ default:
49
+ if (status >= 500) {
50
+ return (`${detail} (HTTP ${status})\n` +
51
+ " The API returned a server error. Check https://novence.ai/status and retry.");
52
+ }
53
+ return detail;
54
+ }
55
+ }
56
+ return err instanceof Error ? err.message : String(err);
57
+ }
package/dist/lib/ui.d.ts CHANGED
@@ -1,10 +1,23 @@
1
+ export interface Spinner {
2
+ update(message: string): void;
3
+ stop(done?: string): void;
4
+ }
1
5
  export declare function setJsonMode(on: boolean): void;
2
6
  export declare function isJsonMode(): boolean;
7
+ export declare function isInteractive(): boolean;
3
8
  /** Progress goes to stderr so `novence deploy … > url.txt` stays clean. */
4
9
  export declare function step(message: string): void;
10
+ /**
11
+ * Animate a packet-on-a-wire spinner on stderr while work runs.
12
+ * No-ops the animation in --json, CI, or when stderr is not a TTY.
13
+ */
14
+ export declare function spin(label: string): Spinner;
15
+ export declare function withSpin<T>(label: string, work: (spinner: Spinner) => Promise<T>, done?: string | ((value: T) => string)): Promise<T>;
5
16
  export declare function warn(message: string): void;
6
17
  /** The one line callers pipe. Always stdout. */
7
18
  export declare function result(value: string): void;
8
19
  export declare function json(value: unknown): void;
9
20
  export declare function fail(message: string): never;
10
21
  export declare function prompt(question: string): Promise<string>;
22
+ /** Canonical site URL shape — always one trailing slash, everywhere. */
23
+ export declare function siteUrl(url: string): string;
package/dist/lib/ui.js CHANGED
@@ -1,18 +1,107 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
3
  let jsonMode = false;
4
+ let cursorHidden = false;
5
+ let activeSpinner = null;
6
+ /** Packet traveling along a wire. Same width every frame so the line does not jump. */
7
+ const PACKET_FRAMES = ["──·────", "───·───", "────·──", "─────·─", "──────·", "─────·─", "────·──", "───·───"];
8
+ const PACKET_INTERVAL_MS = 100;
4
9
  export function setJsonMode(on) {
5
10
  jsonMode = on;
6
11
  }
7
12
  export function isJsonMode() {
8
13
  return jsonMode;
9
14
  }
15
+ export function isInteractive() {
16
+ return Boolean(process.stderr.isTTY) && !jsonMode && !process.env.CI;
17
+ }
18
+ function hideCursor() {
19
+ if (cursorHidden || !process.stderr.isTTY)
20
+ return;
21
+ process.stderr.write("\x1b[?25l");
22
+ cursorHidden = true;
23
+ }
24
+ function showCursor() {
25
+ if (!cursorHidden)
26
+ return;
27
+ process.stderr.write("\x1b[?25h");
28
+ cursorHidden = false;
29
+ }
30
+ process.on("exit", showCursor);
31
+ function stopActiveSpinner() {
32
+ activeSpinner?.stop();
33
+ }
10
34
  /** Progress goes to stderr so `novence deploy … > url.txt` stays clean. */
11
35
  export function step(message) {
12
36
  if (!jsonMode)
13
37
  process.stderr.write(` ${message}\n`);
14
38
  }
39
+ /**
40
+ * Animate a packet-on-a-wire spinner on stderr while work runs.
41
+ * No-ops the animation in --json, CI, or when stderr is not a TTY.
42
+ */
43
+ export function spin(label) {
44
+ stopActiveSpinner();
45
+ if (!isInteractive()) {
46
+ step(label);
47
+ const spinner = {
48
+ update() { },
49
+ stop(done) {
50
+ if (activeSpinner !== spinner)
51
+ return;
52
+ activeSpinner = null;
53
+ if (done)
54
+ step(done);
55
+ },
56
+ };
57
+ activeSpinner = spinner;
58
+ return spinner;
59
+ }
60
+ let i = 0;
61
+ let text = label;
62
+ let stopped = false;
63
+ hideCursor();
64
+ const draw = () => {
65
+ process.stderr.write(`\r ${PACKET_FRAMES[i % PACKET_FRAMES.length]} ${text}\x1b[K`);
66
+ i += 1;
67
+ };
68
+ draw();
69
+ const id = setInterval(draw, PACKET_INTERVAL_MS);
70
+ const spinner = {
71
+ update(message) {
72
+ text = message;
73
+ },
74
+ stop(done) {
75
+ if (stopped)
76
+ return;
77
+ stopped = true;
78
+ clearInterval(id);
79
+ if (activeSpinner === spinner)
80
+ activeSpinner = null;
81
+ if (done)
82
+ process.stderr.write(`\r ${done}\x1b[K\n`);
83
+ else
84
+ process.stderr.write(`\r\x1b[K`);
85
+ showCursor();
86
+ },
87
+ };
88
+ activeSpinner = spinner;
89
+ return spinner;
90
+ }
91
+ export async function withSpin(label, work, done) {
92
+ const spinner = spin(label);
93
+ try {
94
+ const value = await work(spinner);
95
+ spinner.stop(typeof done === "function" ? done(value) : done);
96
+ return value;
97
+ }
98
+ catch (err) {
99
+ spinner.stop();
100
+ throw err;
101
+ }
102
+ }
15
103
  export function warn(message) {
104
+ stopActiveSpinner();
16
105
  process.stderr.write(` ! ${message}\n`);
17
106
  }
18
107
  /** The one line callers pipe. Always stdout. */
@@ -23,6 +112,7 @@ export function json(value) {
23
112
  stdout.write(JSON.stringify(value, null, 2) + "\n");
24
113
  }
25
114
  export function fail(message) {
115
+ stopActiveSpinner();
26
116
  if (jsonMode) {
27
117
  stdout.write(JSON.stringify({ ok: false, error: message }, null, 2) + "\n");
28
118
  }
@@ -32,6 +122,7 @@ export function fail(message) {
32
122
  process.exit(1);
33
123
  }
34
124
  export async function prompt(question) {
125
+ stopActiveSpinner();
35
126
  if (!stdin.isTTY) {
36
127
  fail(`Cannot prompt for "${question.trim()}" — stdin is not a TTY. Pass the value as a flag.`);
37
128
  }
@@ -46,3 +137,7 @@ export async function prompt(question) {
46
137
  function stderr() {
47
138
  return process.stderr;
48
139
  }
140
+ /** Canonical site URL shape — always one trailing slash, everywhere. */
141
+ export function siteUrl(url) {
142
+ return url.replace(/\/?$/, "/");
143
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "novence",
3
- "version": "0.0.2",
4
- "description": "CLI for Novence \u2014 deploy a static site to the edge in one command.",
3
+ "version": "0.1.1",
4
+ "description": "CLI for Novence — deploy a static site to the edge in one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "engines": {
18
18
  "node": ">=18"
19
19
  },
20
- "homepage": "https://novence.ai/docs",
20
+ "homepage": "https://novence.ai/cli",
21
21
  "bugs": {
22
22
  "url": "https://novence.ai/support"
23
23
  },
@@ -29,21 +29,25 @@
29
29
  "hosting",
30
30
  "edge",
31
31
  "static-site",
32
- "ai-agents"
32
+ "ai-agents",
33
+ "claude",
34
+ "cursor",
35
+ "mcp-server",
36
+ "static-hosting",
37
+ "netlify-alternative"
33
38
  ],
34
39
  "publishConfig": {
35
40
  "access": "public"
36
41
  },
37
- "scripts": {
38
- "build": "tsc -p tsconfig.json",
39
- "typecheck": "tsc -p tsconfig.json --noEmit",
40
- "prepublishOnly": "pnpm run build"
41
- },
42
42
  "dependencies": {
43
- "@novence/sdk": "^0.2.0"
43
+ "@novence/sdk": "^0.2.3"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.10.2",
47
47
  "typescript": "^5.7.2"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc -p tsconfig.json",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit"
48
52
  }
49
- }
53
+ }