novence 0.0.2 → 0.1.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
@@ -51,11 +51,21 @@ URL=$(novence deploy ./dist)
51
51
  ## Other commands
52
52
 
53
53
  ```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
54
+ novence open [project] # open the live URL in a browser
55
+ novence checks [project] # Lighthouse / axe / link results for the last deploy
56
+ novence domains add <host> # attach a custom domain, prints the DNS records
57
+ novence domains status # custom domain state
58
+ novence keys list # API keys on the account
59
+ novence keys revoke <id> # revoke a key (--force to revoke the one in use)
60
+ novence projects # list projects and their URLs
61
+ novence login # sign in, verify email, or --recover a key
62
+ novence whoami # plan, verification, usage against quota
57
63
  ```
58
64
 
65
+ Every command accepts `--json`. Unknown flags are rejected rather than ignored,
66
+ so a typo like `--nmae` fails loudly instead of silently deploying under the
67
+ default name.
68
+
59
69
  ## For agents and CI
60
70
 
61
71
  `--json` gives a stable envelope and exits non-zero on failure:
@@ -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
+ }
@@ -4,7 +4,8 @@ import { Novence } from "@novence/sdk";
4
4
  import { 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, result, siteUrl, step, warn } 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));
@@ -60,6 +61,15 @@ export async function deployCommand(args) {
60
61
  if (!existsSync(resolve(siteDir, "index.html"))) {
61
62
  fail(`${siteDir} has no index.html — a static site needs one at its root.`);
62
63
  }
64
+ let projectId = flagString(args.flags, "project");
65
+ try {
66
+ await runDeploy(args, apiBase, siteDir);
67
+ }
68
+ catch (err) {
69
+ fail(explain(err, { apiBase, projectId }));
70
+ }
71
+ }
72
+ async function runDeploy(args, apiBase, siteDir) {
63
73
  const { apiKey } = await ensureApiKey({
64
74
  apiBase,
65
75
  email: flagString(args.flags, "email"),
@@ -67,17 +77,17 @@ export async function deployCommand(args) {
67
77
  });
68
78
  const client = new Novence({ apiKey, baseUrl: apiBase });
69
79
  let projectId = flagString(args.flags, "project");
70
- let siteUrl;
80
+ let projectUrl;
71
81
  if (projectId) {
72
82
  const { project } = await client.projects.get(projectId);
73
- siteUrl = project.url;
83
+ projectUrl = project.url;
74
84
  step(`Reusing project ${project.name} (${project.suffix})`);
75
85
  }
76
86
  else {
77
87
  const name = flagString(args.flags, "name") ?? "site";
78
88
  const created = await client.projects.create({ name });
79
89
  projectId = created.project.id;
80
- siteUrl = created.url;
90
+ projectUrl = created.url;
81
91
  step(`Created project ${name} (${created.project.suffix})`);
82
92
  }
83
93
  step(`Uploading ${siteDir}…`);
@@ -93,7 +103,7 @@ export async function deployCommand(args) {
93
103
  fail(deployment.errorMessage ?? "Deploy failed.");
94
104
  }
95
105
  reportChecks(deployment);
96
- const liveUrl = (deployment.liveUrl ?? siteUrl).replace(/\/?$/, "/");
106
+ const liveUrl = siteUrl(deployment.liveUrl ?? projectUrl);
97
107
  if (isJsonMode()) {
98
108
  json({
99
109
  ok: true,
@@ -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
+ }
@@ -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
+ }
package/dist/index.js CHANGED
@@ -2,20 +2,32 @@
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";
9
11
  import { projectsCommand } from "./commands/projects.js";
10
12
  import { whoamiCommand } from "./commands/whoami.js";
13
+ import { openCommand } from "./commands/open.js";
14
+ import { domainsCommand } from "./commands/domains.js";
15
+ import { keysCommand } from "./commands/keys.js";
16
+ import { checksCommand } from "./commands/checks.js";
11
17
  const HELP = `
12
18
  novence — deploy a static site to the edge
13
19
 
14
20
  Usage
15
21
  novence deploy [dir] Upload a directory and publish it live
22
+ novence open [project] Open a project's live URL
23
+ novence checks [project] Quality-gate results for the latest deploy
24
+ novence domains add <host> Attach a custom domain (prints DNS records)
25
+ novence domains status Custom domain state
26
+ novence keys list API keys on the account
27
+ novence keys revoke <id> Revoke a key
28
+ novence projects List projects and their URLs
16
29
  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
30
+ novence whoami Plan, verification, and usage
19
31
 
20
32
  Deploy
21
33
  novence deploy Auto-detects dist, build, out, public, _site
@@ -56,9 +68,36 @@ async function main() {
56
68
  process.stdout.write(HELP);
57
69
  return;
58
70
  }
71
+ // Per-command flag allowlists — a typo should fail loudly, not silently
72
+ // change behaviour.
73
+ const ALLOWED = {
74
+ deploy: ["name", "project", "email"],
75
+ open: [],
76
+ checks: [],
77
+ domains: ["project", "primary", "redirect"],
78
+ keys: ["project", "include-revoked", "force"],
79
+ projects: [],
80
+ ls: [],
81
+ login: ["email", "recover"],
82
+ whoami: [],
83
+ };
84
+ const allowed = ALLOWED[args.command];
85
+ if (allowed) {
86
+ const problem = assertKnownFlags(args.flags, allowed, args.command);
87
+ if (problem)
88
+ fail(problem);
89
+ }
59
90
  switch (args.command) {
60
91
  case "deploy":
61
92
  return deployCommand(args);
93
+ case "open":
94
+ return openCommand(args);
95
+ case "checks":
96
+ return checksCommand(args);
97
+ case "domains":
98
+ return domainsCommand(args);
99
+ case "keys":
100
+ return keysCommand(args);
62
101
  case "login":
63
102
  return loginCommand(args);
64
103
  case "projects":
@@ -71,5 +110,7 @@ async function main() {
71
110
  }
72
111
  }
73
112
  main().catch((err) => {
74
- fail(err instanceof Error ? err.message : String(err));
113
+ // Last-resort handler; commands that know a projectId explain it themselves.
114
+ const apiBase = resolveApiBase(flagString(parseArgs(process.argv.slice(2)).flags, "api"));
115
+ fail(explain(err, { apiBase }));
75
116
  });
@@ -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
+ }
@@ -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
@@ -8,3 +8,5 @@ export declare function result(value: string): void;
8
8
  export declare function json(value: unknown): void;
9
9
  export declare function fail(message: string): never;
10
10
  export declare function prompt(question: string): Promise<string>;
11
+ /** Canonical site URL shape — always one trailing slash, everywhere. */
12
+ export declare function siteUrl(url: string): string;
package/dist/lib/ui.js CHANGED
@@ -46,3 +46,7 @@ export async function prompt(question) {
46
46
  function stderr() {
47
47
  return process.stderr;
48
48
  }
49
+ /** Canonical site URL shape — always one trailing slash, everywhere. */
50
+ export function siteUrl(url) {
51
+ return url.replace(/\/?$/, "/");
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novence",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "CLI for Novence \u2014 deploy a static site to the edge in one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "prepublishOnly": "pnpm run build"
41
41
  },
42
42
  "dependencies": {
43
- "@novence/sdk": "^0.2.0"
43
+ "@novence/sdk": "^0.2.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.10.2",