novence 0.1.0 → 0.1.2

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
 
@@ -26,10 +34,21 @@ Requires Node 18+.
26
34
  ```bash
27
35
  novence deploy # auto-detects dist, build, out, public, _site
28
36
  novence deploy ./site # or point at a directory
37
+ novence deploy report.html # one HTML file (dashboard, report, artifact)
29
38
  ```
30
39
 
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.
40
+ First run with no stored key publishes anonymously (no email), stores
41
+ `~/.novence/credentials` (mode 0600), and prints the live URL. Claim afterwards:
42
+
43
+ ```bash
44
+ novence claim --email you@example.com
45
+ ```
46
+
47
+ Or pass `--email` on first deploy to bootstrap instead of the demo path:
48
+
49
+ ```bash
50
+ novence deploy ./dist --email you@example.com
51
+ ```
33
52
 
34
53
  The live URL is the last line on stdout and progress goes to stderr, so it pipes:
35
54
 
@@ -43,7 +62,7 @@ URL=$(novence deploy ./dist)
43
62
  | --- | --- |
44
63
  | `--name <name>` | Project name (default `site`) |
45
64
  | `--project <id>` | Redeploy an existing project instead of creating one |
46
- | `--email <email>` | Skip the email prompt on first run |
65
+ | `--email <email>` | Bootstrap with email on first deploy instead of anonymous |
47
66
  | `--json` | Machine-readable output; implies `--no-input` |
48
67
  | `--no-input` | Never prompt — fail instead. For CI |
49
68
  | `--api <url>` | Control plane (default `https://api.novence.ai`) |
@@ -59,6 +78,7 @@ novence keys list # API keys on the account
59
78
  novence keys revoke <id> # revoke a key (--force to revoke the one in use)
60
79
  novence projects # list projects and their URLs
61
80
  novence login # sign in, verify email, or --recover a key
81
+ novence claim # attach an email to an anonymous deploy
62
82
  novence whoami # plan, verification, usage against quota
63
83
  ```
64
84
 
@@ -66,7 +86,19 @@ Every command accepts `--json`. Unknown flags are rejected rather than ignored,
66
86
  so a typo like `--nmae` fails loudly instead of silently deploying under the
67
87
  default name.
68
88
 
69
- ## For agents and CI
89
+ ## For agents
90
+
91
+ When a shell is available, prefer the CLI over MCP or raw REST:
92
+
93
+ ```bash
94
+ npx novence deploy ./dist
95
+ novence claim --email you@example.com
96
+ ```
97
+
98
+ Copy Prompt kit (same loop, pasteable): [novence.ai](https://novence.ai).
99
+ Full agent brief: [novence.ai/llms-full.txt](https://novence.ai/llms-full.txt).
100
+
101
+ ## For CI
70
102
 
71
103
  `--json` gives a stable envelope and exits non-zero on failure:
72
104
 
@@ -82,7 +114,7 @@ default name.
82
114
  }
83
115
  ```
84
116
 
85
- In CI, set `NOVENCE_API_KEY` and pass `--no-input`.
117
+ Set `NOVENCE_API_KEY` and pass `--no-input`.
86
118
 
87
119
  Key lookup order: `NOVENCE_API_KEY`, then `./.novence-key`, then
88
120
  `~/.novence/credentials`. Never commit a key.
@@ -91,17 +123,11 @@ Key lookup order: `NOVENCE_API_KEY`, then `./.novence-key`, then
91
123
 
92
124
  Deploys run Lighthouse, axe, and link checks. On unverified accounts checks are
93
125
  skipped — `checks.skipped` is `true` and `checks.passed` is `null`, which is not
94
- a pass. Verify your email with `novence login` to enable them.
95
-
96
- ## Roadmap
97
-
98
- Today the first deploy requires an email up front. Once anonymous deploys land in
99
- the API, `novence deploy` will publish first and let you claim the site
100
- afterwards — no signup before you see a URL.
126
+ a pass. Verify your email with `novence login` (or claim) to enable them.
101
127
 
102
128
  ## Docs
103
129
 
104
- [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)
130
+ [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)
105
131
 
106
132
  ## License
107
133
 
@@ -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,46 +1,73 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, 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, siteUrl, step, warn } from "../lib/ui.js";
7
+ import { fail, isJsonMode, json, prompt, result, siteUrl, spin, step, warn, withSpin } from "../lib/ui.js";
8
8
  import { explain } from "../lib/errors.js";
9
9
  const POLL_INTERVAL_MS = 2000;
10
10
  const POLL_TIMEOUT_MS = 5 * 60 * 1000;
11
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
12
  /** Common build output directories, checked in order when no dir is given. */
13
13
  const CANDIDATE_DIRS = ["dist", "build", "out", "public", "_site"];
14
- function resolveSiteDir(explicit) {
14
+ function resolveSiteSource(explicit) {
15
15
  if (explicit) {
16
- const dir = resolve(explicit);
17
- if (!existsSync(dir) || !statSync(dir).isDirectory())
18
- fail(`Not a directory: ${dir}`);
19
- return dir;
16
+ const target = resolve(explicit);
17
+ if (!existsSync(target))
18
+ fail(`Not found: ${target}`);
19
+ const st = statSync(target);
20
+ if (st.isFile()) {
21
+ if (!target.toLowerCase().endsWith(".html")) {
22
+ fail(`Not a directory or .html file: ${target}`);
23
+ }
24
+ return { kind: "html", path: target };
25
+ }
26
+ if (!st.isDirectory())
27
+ fail(`Not a directory: ${target}`);
28
+ return { kind: "dir", path: target };
20
29
  }
21
30
  for (const candidate of CANDIDATE_DIRS) {
22
31
  const dir = resolve(process.cwd(), candidate);
23
32
  if (existsSync(dir) && statSync(dir).isDirectory())
24
- return dir;
33
+ return { kind: "dir", path: dir };
25
34
  }
26
35
  return fail(`No build output found. Looked for ${CANDIDATE_DIRS.join(", ")} in ${process.cwd()}.\n` +
27
- " Pass one explicitly: novence deploy ./your-dir");
36
+ " Pass a directory or an HTML file: novence deploy ./your-dir or novence deploy report.html");
28
37
  }
29
- async function waitForLive(client, projectId) {
38
+ async function waitUntilLive(client, projectId) {
39
+ const spinner = spin("Publishing…");
30
40
  const startedAt = Date.now();
31
- let lastStatus = "";
32
- while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
33
- const { deployment } = await client.deployments.latest(projectId);
34
- if (deployment.status !== lastStatus) {
35
- step(`${deployment.status}…`);
36
- lastStatus = deployment.status;
41
+ try {
42
+ let lastStatus = "";
43
+ while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
44
+ const { deployment } = await client.deployments.latest(projectId);
45
+ if (deployment.status !== lastStatus) {
46
+ spinner.update(`${deployment.status}…`);
47
+ lastStatus = deployment.status;
48
+ }
49
+ if (deployment.status === "live") {
50
+ spinner.stop("Live");
51
+ return deployment;
52
+ }
53
+ if (deployment.status === "failed") {
54
+ spinner.stop();
55
+ return deployment;
56
+ }
57
+ await sleep(POLL_INTERVAL_MS);
37
58
  }
38
- if (deployment.status === "live" || deployment.status === "failed")
39
- return deployment;
40
- await sleep(POLL_INTERVAL_MS);
41
59
  }
60
+ catch (err) {
61
+ spinner.stop();
62
+ throw err;
63
+ }
64
+ spinner.stop();
42
65
  return fail("Timed out after 5 minutes waiting for the deploy to go live.");
43
66
  }
67
+ async function publish(client, projectId) {
68
+ await client.deployments.create(projectId);
69
+ return waitUntilLive(client, projectId);
70
+ }
44
71
  function reportChecks(deployment) {
45
72
  const checks = deployment.checksResults;
46
73
  if (!checks)
@@ -57,44 +84,66 @@ function reportChecks(deployment) {
57
84
  }
58
85
  export async function deployCommand(args) {
59
86
  const apiBase = resolveApiBase(flagString(args.flags, "api"));
60
- const siteDir = resolveSiteDir(args.positionals[0]);
61
- if (!existsSync(resolve(siteDir, "index.html"))) {
62
- fail(`${siteDir} has no index.html — a static site needs one at its root.`);
87
+ const source = resolveSiteSource(args.positionals[0]);
88
+ if (source.kind === "dir" && !existsSync(resolve(source.path, "index.html"))) {
89
+ fail(`${source.path} has no index.html — a static site needs one at its root.`);
63
90
  }
64
91
  let projectId = flagString(args.flags, "project");
65
92
  try {
66
- await runDeploy(args, apiBase, siteDir);
93
+ await runDeploy(args, apiBase, source);
67
94
  }
68
95
  catch (err) {
69
96
  fail(explain(err, { apiBase, projectId }));
70
97
  }
71
98
  }
72
- async function runDeploy(args, apiBase, siteDir) {
73
- const { apiKey } = await ensureApiKey({
99
+ async function runDeploy(args, apiBase, source) {
100
+ const interactive = !isJsonMode() && !flagBool(args.flags, "no-input");
101
+ const creds = await ensureApiKey({
74
102
  apiBase,
75
103
  email: flagString(args.flags, "email"),
76
- interactive: !isJsonMode() && !flagBool(args.flags, "no-input"),
104
+ interactive,
77
105
  });
78
- const client = new Novence({ apiKey, baseUrl: apiBase });
106
+ const client = new Novence({ apiKey: creds.apiKey, baseUrl: apiBase });
79
107
  let projectId = flagString(args.flags, "project");
80
108
  let projectUrl;
81
- if (projectId) {
82
- const { project } = await client.projects.get(projectId);
83
- projectUrl = project.url;
84
- step(`Reusing project ${project.name} (${project.suffix})`);
109
+ let fileCount = 1;
110
+ let deployment;
111
+ if (source.kind === "html") {
112
+ const html = readFileSync(source.path, "utf8");
113
+ const defaultName = basename(source.path).replace(/\.html?$/i, "") || "artifact";
114
+ const name = flagString(args.flags, "name") ?? defaultName;
115
+ const published = await withSpin(`Publishing ${source.path}…`, () => client.publishHtml({
116
+ html,
117
+ title: name,
118
+ projectId,
119
+ }), (result) => (result.created ? "Created project" : "Updated project"));
120
+ projectId = published.projectId;
121
+ projectUrl = published.url;
122
+ if (!projectId)
123
+ fail("Missing project id");
124
+ const htmlProjectId = projectId;
125
+ deployment = await waitUntilLive(client, htmlProjectId);
85
126
  }
86
127
  else {
87
- const name = flagString(args.flags, "name") ?? "site";
88
- const created = await client.projects.create({ name });
89
- projectId = created.project.id;
90
- projectUrl = created.url;
91
- step(`Created project ${name} (${created.project.suffix})`);
128
+ if (projectId) {
129
+ const { project } = await client.projects.get(projectId);
130
+ projectUrl = project.url;
131
+ step(`Reusing project ${project.name} (${project.suffix})`);
132
+ }
133
+ else {
134
+ const name = flagString(args.flags, "name") ?? "site";
135
+ const created = await client.projects.create({ name });
136
+ projectId = created.project.id;
137
+ projectUrl = created.url;
138
+ step(`Created project ${name} (${created.project.suffix})`);
139
+ }
140
+ if (!projectId)
141
+ fail("Missing project id");
142
+ const dirProjectId = projectId;
143
+ const { uploaded } = await withSpin(`Uploading ${source.path}…`, () => client.files.uploadDir(dirProjectId, source.path), ({ uploaded: files }) => `Uploaded ${files.length} file${files.length === 1 ? "" : "s"}`);
144
+ fileCount = uploaded.length;
145
+ deployment = await publish(client, dirProjectId);
92
146
  }
93
- step(`Uploading ${siteDir}…`);
94
- const { uploaded } = await client.files.uploadDir(projectId, siteDir);
95
- step(`Uploaded ${uploaded.length} file${uploaded.length === 1 ? "" : "s"}`);
96
- await client.deployments.create(projectId);
97
- const deployment = await waitForLive(client, projectId);
98
147
  if (deployment.status === "failed") {
99
148
  if (isJsonMode()) {
100
149
  json({ ok: false, projectId, status: deployment.status, error: deployment.errorMessage });
@@ -104,6 +153,8 @@ async function runDeploy(args, apiBase, siteDir) {
104
153
  }
105
154
  reportChecks(deployment);
106
155
  const liveUrl = siteUrl(deployment.liveUrl ?? projectUrl);
156
+ const previewUrl = deployment.previewUrl ? siteUrl(deployment.previewUrl) : null;
157
+ const distinctPreview = previewUrl && previewUrl !== liveUrl ? previewUrl : null;
107
158
  if (isJsonMode()) {
108
159
  json({
109
160
  ok: true,
@@ -111,11 +162,20 @@ async function runDeploy(args, apiBase, siteDir) {
111
162
  deploymentId: deployment.id,
112
163
  status: deployment.status,
113
164
  url: liveUrl,
114
- files: uploaded.length,
165
+ ...(distinctPreview ? { previewUrl: distinctPreview } : {}),
166
+ files: fileCount,
115
167
  checks: deployment.checksResults ?? null,
116
168
  });
117
169
  return;
118
170
  }
171
+ if (distinctPreview)
172
+ step(`Preview ${distinctPreview}`);
119
173
  // Last line, alone, so it pipes cleanly.
120
174
  result(liveUrl);
175
+ if (creds.anonymous && interactive) {
176
+ warn("Claim this site to keep it past 30 days and unlock checks.");
177
+ const email = await prompt(" Email (Enter to skip): ");
178
+ if (email)
179
+ await claimAndMaybeVerify({ apiKey: creds.apiKey, apiBase, email, interactive: true });
180
+ }
121
181
  }
@@ -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 rollbackCommand(args: ParsedArgs): Promise<void>;
@@ -0,0 +1,46 @@
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, siteUrl, step } from "../lib/ui.js";
6
+ export async function rollbackCommand(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 wanted = flagString(args.flags, "project") ?? args.positionals[0];
13
+ const deploymentId = flagString(args.flags, "deployment");
14
+ let projectId = wanted;
15
+ try {
16
+ if (!projectId) {
17
+ const { projects } = await client.projects.list();
18
+ if (projects.length === 0)
19
+ fail("No projects yet. Run `novence deploy ./dist`.");
20
+ projectId = projects[0].id;
21
+ }
22
+ const { deployment } = await client.deployments.rollback(projectId, {
23
+ ...(deploymentId ? { deploymentId } : {}),
24
+ });
25
+ const liveUrl = siteUrl(deployment.liveUrl ?? deployment.previewUrl ?? "");
26
+ const previewUrl = deployment.previewUrl ? siteUrl(deployment.previewUrl) : null;
27
+ if (isJsonMode()) {
28
+ json({
29
+ ok: true,
30
+ projectId,
31
+ deploymentId: deployment.id,
32
+ status: deployment.status,
33
+ url: liveUrl,
34
+ ...(previewUrl && previewUrl !== liveUrl ? { previewUrl } : {}),
35
+ });
36
+ return;
37
+ }
38
+ step("Restored previous snapshot (HTML cache ~60s; hard-refresh if the old page lingers).");
39
+ if (previewUrl && previewUrl !== liveUrl)
40
+ step(`Preview ${previewUrl}`);
41
+ result(liveUrl);
42
+ }
43
+ catch (err) {
44
+ fail(explain(err, { apiBase, projectId: wanted }));
45
+ }
46
+ }
@@ -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
@@ -8,17 +8,20 @@ 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";
10
10
  import { loginCommand } from "./commands/login.js";
11
+ import { claimCommand } from "./commands/claim.js";
11
12
  import { projectsCommand } from "./commands/projects.js";
12
13
  import { whoamiCommand } from "./commands/whoami.js";
13
14
  import { openCommand } from "./commands/open.js";
14
15
  import { domainsCommand } from "./commands/domains.js";
15
16
  import { keysCommand } from "./commands/keys.js";
16
17
  import { checksCommand } from "./commands/checks.js";
18
+ import { rollbackCommand } from "./commands/rollback.js";
17
19
  const HELP = `
18
20
  novence — deploy a static site to the edge
19
21
 
20
22
  Usage
21
- novence deploy [dir] Upload a directory and publish it live
23
+ novence deploy [dir|file] Upload a directory or one .html file and publish it live
24
+ novence rollback Restore the previous live snapshot (Pro/Scale)
22
25
  novence open [project] Open a project's live URL
23
26
  novence checks [project] Quality-gate results for the latest deploy
24
27
  novence domains add <host> Attach a custom domain (prints DNS records)
@@ -27,14 +30,21 @@ const HELP = `
27
30
  novence keys revoke <id> Revoke a key
28
31
  novence projects List projects and their URLs
29
32
  novence login Sign in, verify email, or recover a key
33
+ novence claim Attach an email to an anonymous deploy
30
34
  novence whoami Plan, verification, and usage
31
35
 
32
36
  Deploy
33
37
  novence deploy Auto-detects dist, build, out, public, _site
34
38
  novence deploy ./site Deploy a specific directory
39
+ novence deploy report.html Publish a single HTML file as the site
35
40
  --name <name> Project name (default: "site")
36
41
  --project <id> Redeploy an existing project
37
- --email <email> Skip the email prompt on first run
42
+ --email <email> Skip the email prompt on first run (bootstrap instead of demo)
43
+
44
+ Rollback (Pro/Scale)
45
+ novence rollback Restore the previous live snapshot
46
+ --project <id> Project (default: first in the list)
47
+ --deployment <id> Specific retained deploy (last 5)
38
48
 
39
49
  Global
40
50
  --json Machine-readable output (implies --no-input)
@@ -72,6 +82,7 @@ async function main() {
72
82
  // change behaviour.
73
83
  const ALLOWED = {
74
84
  deploy: ["name", "project", "email"],
85
+ claim: ["email"],
75
86
  open: [],
76
87
  checks: [],
77
88
  domains: ["project", "primary", "redirect"],
@@ -80,6 +91,7 @@ async function main() {
80
91
  ls: [],
81
92
  login: ["email", "recover"],
82
93
  whoami: [],
94
+ rollback: ["project", "deployment"],
83
95
  };
84
96
  const allowed = ALLOWED[args.command];
85
97
  if (allowed) {
@@ -100,11 +112,15 @@ async function main() {
100
112
  return keysCommand(args);
101
113
  case "login":
102
114
  return loginCommand(args);
115
+ case "claim":
116
+ return claimCommand(args);
103
117
  case "projects":
104
118
  case "ls":
105
119
  return projectsCommand(args);
106
120
  case "whoami":
107
121
  return whoamiCommand(args);
122
+ case "rollback":
123
+ return rollbackCommand(args);
108
124
  default:
109
125
  fail(`Unknown command: ${args.command}\n Run \`novence --help\` for usage.`);
110
126
  }
@@ -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
  }
package/dist/lib/ui.d.ts CHANGED
@@ -1,7 +1,18 @@
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;
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "novence",
3
- "version": "0.1.0",
4
- "description": "CLI for Novence \u2014 deploy a static site to the edge in one command.",
3
+ "version": "0.1.2",
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.2"
43
+ "@novence/sdk": "^0.2.4"
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
+ }