Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/init-telemetry-stages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"clerk": minor
---

Give every failure a specific error code, and record which step a multi-step command reached.
Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure.
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
throw new CliError(
"Production instance was created but Clerk did not return a domain. " +
"Run `clerk deploy` again to retry domain provisioning.",
{ code: ERROR_CODE.DEPLOY_DOMAIN_MISSING },
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/env/pull.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,6 +147,7 @@ async function pullKeylessKeys(
throw new CliError(
`The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` +
`Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`,
{ code: ERROR_CODE.KEY_PAIR_MISMATCH },
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { join } from "node:path";
import { statSync } from "node:fs";
import { confirm, text } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
import { dirExists, hasPackageJson } from "./context.js";
Expand DownExpand Up@@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<Bootstr
const supported = BOOTSTRAP_REGISTRY.map((e) => e.label).join(", ");
throw new CliError(
`Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`,
{ code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED },
);
}

Expand DownExpand Up@@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi
const candidate = `${base}-${i}`;
if (!(await dirExists(join(cwd, candidate)))) return candidate;
}
throw new CliError(`Could not find an available project name based on '${base}'.`);
throw new CliError(`Could not find an available project name based on '${base}'.`, {
code: ERROR_CODE.PROJECT_DIR_EXISTS,
});
}

async function askProjectName(entry: BootstrapEntry, cwd: string): Promise<string> {
Expand DownExpand Up@@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P

const exitCode = await spawnInherited(command, cwd);
if (exitCode !== 0) {
throw new CliError(`Project generation failed (exit code ${exitCode}).`);
throw new CliError(`Project generation failed (exit code ${exitCode}).`, {
code: ERROR_CODE.GENERATOR_FAILED,
});
}
}

Expand DownExpand Up@@ -231,13 +236,16 @@ export async function promptAndBootstrap(
if (await dirExists(projectDir)) {
throw new CliError(
`Directory '${projectName}' already exists. Pick a different name or remove it first.`,
{ code: ERROR_CODE.PROJECT_DIR_EXISTS },
);
}

await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd);

if (!(await hasPackageJson(projectDir))) {
throw new CliError("Generator did not create a package.json.");
throw new CliError("Generator did not create a package.json.", {
code: ERROR_CODE.GENERATOR_FAILED,
});
}

await installDependencies(pm, projectDir);
Expand Down
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@ import {
skillsMod,
bootstrapMod,
nextStepsMod,
mockExistingProject,
mockMiddlewareScaffold,
} from "../../test/lib/init-harness.ts";
import * as telemetryMod from "../../lib/telemetry.ts";
import { init } from "./index.ts";

describe("init", () => {
Expand DownExpand Up@@ -584,4 +587,85 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

describe("telemetry stages", () => {
/** Spy registered with the harness so its calls reset between tests. */
function trackStages() {
const stage = spyOn(telemetryMod, "setTelemetryStage");
track(stage);
return () => stage.mock.calls.map((call) => call[0]);
}

test("a completed run reports the terminal stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("done");
});

test("a run with nothing to do stops at already_set_up", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("already_set_up");
});

// A run that dies in flag validation must not claim any later stage —
// that's what makes the funnel readable.
test("a rejected flag combination stops at the flags stage", async () => {
setup({ email: "test@test.com" });
const stages = trackStages();

await expect(init({ keyless: true, login: true })).rejects.toThrow();

expect(stages()).toEqual(["flags"]);
});

test("declining the scaffold preview stops at the scaffold stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("scaffold");
});

// Declining the overwrite prompt is the default answer on --starter, so it
// is a common drop-off — and it happens before bootstrapAndDetect runs.
test("declining the starter overwrite prompt stops at the bootstrap stage", async () => {
setup({ email: "test@test.com" });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);
track(
spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue(
Object.assign(new Error(), { name: "UserAbortError" }),
),
);
const stages = trackStages();

await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" });

expect(stages().at(-1)).toBe("bootstrap");
});

test("a failure inside the generator stops at the bootstrap stage", async () => {
setup();
track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed")));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("bootstrap");
});
});
});
26 changes: 24 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,13 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js";
import {
throwUserAbort,
throwUsageError,
CliError,
ERROR_CODE,
errorMessage,
} from "../../lib/errors.js";
import {
lookupFramework,
isNpmFramework,
Expand All@@ -15,6 +21,7 @@ import {
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import { setTelemetryStage } from "../../lib/telemetry.ts";
import { confirm } from "../../lib/prompts.ts";
import {
createAccountlessApp,
Expand DownExpand Up@@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

setTelemetryStage("flags");
await assertUsableFlags(options, agent);

const frameworkOverride = options.framework
Expand All@@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) {

intro("Setting up Clerk");

setTelemetryStage("detect");
const resolved = options.starter
? await handleStarter(cwd, frameworkOverride, overrides)
: await resolveProjectContext(cwd, frameworkOverride, overrides);
Expand All@@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) {
// stale/broken credential ends up blocked on an interactive browser OAuth
// round-trip it can never complete. So agent mode validates the credential
// (it can fall back to keyless) instead of trusting mere presence.
setTelemetryStage("strategy");
const authed = optsKeyless
? false
: agent
Expand All@@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) {
assertKeylessOnlyFlags(options, strategy);

if (strategy === "authenticate") {
setTelemetryStage("link");
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
Expand All@@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) {
const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm);

if (alreadySetUp) {
setTelemetryStage("already_set_up");
log.success("\nClerk is already set up in this project.");
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
Expand All@@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) {
return;
}

setTelemetryStage("keys");
bar();
await runStrategy(strategy, ctx, {
template: options.template,
Expand All@@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) {

// Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with.
if (options.skills !== false && isNpmFramework(ctx.framework)) {
setTelemetryStage("skills");
bar();
await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm);
}
Expand All@@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

setTelemetryStage("done");
await outro("Done");
}

Expand DownExpand Up@@ -275,11 +290,14 @@ async function bootstrapAndDetect(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides);

const ctx = await gatherContext(bootstrap.projectDir);
if (!ctx) {
throw new CliError("Project generation did not produce a detectable framework.");
throw new CliError("Project generation did not produce a detectable framework.", {
code: ERROR_CODE.FRAMEWORK_UNDETECTED,
});
}
return { ctx, bootstrap };
}
Expand All@@ -289,6 +307,7 @@ async function handleStarter(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
if (!overrides.skipConfirm) {
await confirmOverwrite(cwd);
}
Expand DownExpand Up@@ -323,6 +342,7 @@ async function resolveProjectContext(
if (!isBlank) {
throw new CliError(
`Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`,
{ code: ERROR_CODE.FRAMEWORK_UNDETECTED },
);
}

Expand DownExpand Up@@ -559,11 +579,13 @@ async function detectAndInstall(
if (ctx.existingClerk) {
log.info(dim(`${ctx.framework.sdk} is already installed`));
} else if (isNpmFramework(ctx.framework)) {
setTelemetryStage("install");
await installSdk(ctx);
}
// Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a
// package manager here — the framework's scaffold plan prints install steps.

setTelemetryStage("scaffold");
return scaffoldAndWrite(cwd, ctx, skipConfirm);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
isValidEnv,
setCurrentEnv,
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";
Expand DownExpand Up@@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
{ code: ERROR_CODE.NO_INTERACTIVE_TERMINAL },
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
Expand All@@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
{ code: ERROR_CODE.INVALID_ENVIRONMENT },
);
}

Expand Down
31 changes: 24 additions & 7 deletions packages/cli-core/src/commands/update/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import type { Program } from "../../cli-program.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { green, cyan, yellow, dim } from "../../lib/color.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { isCancelled } from "../../lib/signals.ts";
import {
asdfPluginFromPath,
asdfReshim,
Expand DownExpand Up@@ -134,12 +135,18 @@ async function runGlobalInstall(
const stderr = result.stderr.toString();
const hint = globalInstallCommand(installer, packageSpec);
if (stderr.includes("EACCES") || stderr.includes("permission denied")) {
throw new CliError(`Permission denied. Try: sudo ${hint}`);
throw new CliError(`Permission denied. Try: sudo ${hint}`, {
code: ERROR_CODE.UPDATE_PERMISSION_DENIED,
});
}
if (result.exitCode === 127 || stderr.includes("not found")) {
throw new CliError(`${installer} not found on PATH.`);
throw new CliError(`${installer} not found on PATH.`, {
code: ERROR_CODE.INSTALLER_NOT_FOUND,
});
}
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`);
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`, {
code: ERROR_CODE.UPDATE_FAILED,
});
}

// Homebrew installs whatever version its tap currently publishes, ignoring
Expand All@@ -154,6 +161,7 @@ async function runGlobalInstall(
`Homebrew tap is stale: installed ${installed}, expected ${targetVersion}. ` +
`Update via another installer (e.g. \`npm install -g ${packageSpec}\`) ` +
`or wait for the tap to catch up.`,
{ code: ERROR_CODE.UPDATE_FAILED },
);
}
}
Expand DownExpand Up@@ -262,9 +270,18 @@ export async function update(options: UpdateOptions): Promise<void> {
if (isHuman()) intro("Checking for updates");

const [latest, installDirs] = await Promise.all([
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => {
throw new CliError("Could not reach npm registry. Check your network connection.");
}),
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(
(error: unknown) => {
// A registry that answered — badly, or without the requested channel —
// already carries its own code, and a Ctrl-C is the user's decision,
// not the network's. Only transport and timeout failures are genuinely
// "unreachable", and retrying is only right for those.
if (error instanceof CliError || isCancelled(error)) throw error;
throw new CliError("Could not reach npm registry. Check your network connection.", {
code: ERROR_CODE.REGISTRY_UNREACHABLE,
});
},
),
getInstallerPackageDirs(),
]);

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/init-telemetry-stages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"clerk": minor
---

Give every failure a specific error code, and record which step a multi-step command reached.
Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure.
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
throw new CliError(
"Production instance was created but Clerk did not return a domain. " +
"Run `clerk deploy` again to retry domain provisioning.",
{ code: ERROR_CODE.DEPLOY_DOMAIN_MISSING },
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/env/pull.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,6 +147,7 @@ async function pullKeylessKeys(
throw new CliError(
`The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` +
`Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`,
{ code: ERROR_CODE.KEY_PAIR_MISMATCH },
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { join } from "node:path";
import { statSync } from "node:fs";
import { confirm, text } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
import { dirExists, hasPackageJson } from "./context.js";
Expand DownExpand Up@@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<Bootstr
const supported = BOOTSTRAP_REGISTRY.map((e) => e.label).join(", ");
throw new CliError(
`Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`,
{ code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED },
);
}

Expand DownExpand Up@@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi
const candidate = `${base}-${i}`;
if (!(await dirExists(join(cwd, candidate)))) return candidate;
}
throw new CliError(`Could not find an available project name based on '${base}'.`);
throw new CliError(`Could not find an available project name based on '${base}'.`, {
code: ERROR_CODE.PROJECT_DIR_EXISTS,
});
}

async function askProjectName(entry: BootstrapEntry, cwd: string): Promise<string> {
Expand DownExpand Up@@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P

const exitCode = await spawnInherited(command, cwd);
if (exitCode !== 0) {
throw new CliError(`Project generation failed (exit code ${exitCode}).`);
throw new CliError(`Project generation failed (exit code ${exitCode}).`, {
code: ERROR_CODE.GENERATOR_FAILED,
});
}
}

Expand DownExpand Up@@ -231,13 +236,16 @@ export async function promptAndBootstrap(
if (await dirExists(projectDir)) {
throw new CliError(
`Directory '${projectName}' already exists. Pick a different name or remove it first.`,
{ code: ERROR_CODE.PROJECT_DIR_EXISTS },
);
}

await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd);

if (!(await hasPackageJson(projectDir))) {
throw new CliError("Generator did not create a package.json.");
throw new CliError("Generator did not create a package.json.", {
code: ERROR_CODE.GENERATOR_FAILED,
});
}

await installDependencies(pm, projectDir);
Expand Down
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@ import {
skillsMod,
bootstrapMod,
nextStepsMod,
mockExistingProject,
mockMiddlewareScaffold,
} from "../../test/lib/init-harness.ts";
import * as telemetryMod from "../../lib/telemetry.ts";
import { init } from "./index.ts";

describe("init", () => {
Expand DownExpand Up@@ -584,4 +587,85 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

describe("telemetry stages", () => {
/** Spy registered with the harness so its calls reset between tests. */
function trackStages() {
const stage = spyOn(telemetryMod, "setTelemetryStage");
track(stage);
return () => stage.mock.calls.map((call) => call[0]);
}

test("a completed run reports the terminal stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("done");
});

test("a run with nothing to do stops at already_set_up", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("already_set_up");
});

// A run that dies in flag validation must not claim any later stage —
// that's what makes the funnel readable.
test("a rejected flag combination stops at the flags stage", async () => {
setup({ email: "test@test.com" });
const stages = trackStages();

await expect(init({ keyless: true, login: true })).rejects.toThrow();

expect(stages()).toEqual(["flags"]);
});

test("declining the scaffold preview stops at the scaffold stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("scaffold");
});

// Declining the overwrite prompt is the default answer on --starter, so it
// is a common drop-off — and it happens before bootstrapAndDetect runs.
test("declining the starter overwrite prompt stops at the bootstrap stage", async () => {
setup({ email: "test@test.com" });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);
track(
spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue(
Object.assign(new Error(), { name: "UserAbortError" }),
),
);
const stages = trackStages();

await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" });

expect(stages().at(-1)).toBe("bootstrap");
});

test("a failure inside the generator stops at the bootstrap stage", async () => {
setup();
track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed")));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("bootstrap");
});
});
});
26 changes: 24 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,13 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js";
import {
throwUserAbort,
throwUsageError,
CliError,
ERROR_CODE,
errorMessage,
} from "../../lib/errors.js";
import {
lookupFramework,
isNpmFramework,
Expand All@@ -15,6 +21,7 @@ import {
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import { setTelemetryStage } from "../../lib/telemetry.ts";
import { confirm } from "../../lib/prompts.ts";
import {
createAccountlessApp,
Expand DownExpand Up@@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

setTelemetryStage("flags");
await assertUsableFlags(options, agent);

const frameworkOverride = options.framework
Expand All@@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) {

intro("Setting up Clerk");

setTelemetryStage("detect");
const resolved = options.starter
? await handleStarter(cwd, frameworkOverride, overrides)
: await resolveProjectContext(cwd, frameworkOverride, overrides);
Expand All@@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) {
// stale/broken credential ends up blocked on an interactive browser OAuth
// round-trip it can never complete. So agent mode validates the credential
// (it can fall back to keyless) instead of trusting mere presence.
setTelemetryStage("strategy");
const authed = optsKeyless
? false
: agent
Expand All@@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) {
assertKeylessOnlyFlags(options, strategy);

if (strategy === "authenticate") {
setTelemetryStage("link");
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
Expand All@@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) {
const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm);

if (alreadySetUp) {
setTelemetryStage("already_set_up");
log.success("\nClerk is already set up in this project.");
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
Expand All@@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) {
return;
}

setTelemetryStage("keys");
bar();
await runStrategy(strategy, ctx, {
template: options.template,
Expand All@@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) {

// Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with.
if (options.skills !== false && isNpmFramework(ctx.framework)) {
setTelemetryStage("skills");
bar();
await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm);
}
Expand All@@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

setTelemetryStage("done");
await outro("Done");
}

Expand DownExpand Up@@ -275,11 +290,14 @@ async function bootstrapAndDetect(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides);

const ctx = await gatherContext(bootstrap.projectDir);
if (!ctx) {
throw new CliError("Project generation did not produce a detectable framework.");
throw new CliError("Project generation did not produce a detectable framework.", {
code: ERROR_CODE.FRAMEWORK_UNDETECTED,
});
}
return { ctx, bootstrap };
}
Expand All@@ -289,6 +307,7 @@ async function handleStarter(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
if (!overrides.skipConfirm) {
await confirmOverwrite(cwd);
}
Expand DownExpand Up@@ -323,6 +342,7 @@ async function resolveProjectContext(
if (!isBlank) {
throw new CliError(
`Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`,
{ code: ERROR_CODE.FRAMEWORK_UNDETECTED },
);
}

Expand DownExpand Up@@ -559,11 +579,13 @@ async function detectAndInstall(
if (ctx.existingClerk) {
log.info(dim(`${ctx.framework.sdk} is already installed`));
} else if (isNpmFramework(ctx.framework)) {
setTelemetryStage("install");
await installSdk(ctx);
}
// Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a
// package manager here — the framework's scaffold plan prints install steps.

setTelemetryStage("scaffold");
return scaffoldAndWrite(cwd, ctx, skipConfirm);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
isValidEnv,
setCurrentEnv,
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";
Expand DownExpand Up@@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
{ code: ERROR_CODE.NO_INTERACTIVE_TERMINAL },
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
Expand All@@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
{ code: ERROR_CODE.INVALID_ENVIRONMENT },
);
}

Expand Down
31 changes: 24 additions & 7 deletions packages/cli-core/src/commands/update/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import type { Program } from "../../cli-program.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { green, cyan, yellow, dim } from "../../lib/color.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { isCancelled } from "../../lib/signals.ts";
import {
asdfPluginFromPath,
asdfReshim,
Expand DownExpand Up@@ -134,12 +135,18 @@ async function runGlobalInstall(
const stderr = result.stderr.toString();
const hint = globalInstallCommand(installer, packageSpec);
if (stderr.includes("EACCES") || stderr.includes("permission denied")) {
throw new CliError(`Permission denied. Try: sudo ${hint}`);
throw new CliError(`Permission denied. Try: sudo ${hint}`, {
code: ERROR_CODE.UPDATE_PERMISSION_DENIED,
});
}
if (result.exitCode === 127 || stderr.includes("not found")) {
throw new CliError(`${installer} not found on PATH.`);
throw new CliError(`${installer} not found on PATH.`, {
code: ERROR_CODE.INSTALLER_NOT_FOUND,
});
}
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`);
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`, {
code: ERROR_CODE.UPDATE_FAILED,
});
}

// Homebrew installs whatever version its tap currently publishes, ignoring
Expand All@@ -154,6 +161,7 @@ async function runGlobalInstall(
`Homebrew tap is stale: installed ${installed}, expected ${targetVersion}. ` +
`Update via another installer (e.g. \`npm install -g ${packageSpec}\`) ` +
`or wait for the tap to catch up.`,
{ code: ERROR_CODE.UPDATE_FAILED },
);
}
}
Expand DownExpand Up@@ -262,9 +270,18 @@ export async function update(options: UpdateOptions): Promise<void> {
if (isHuman()) intro("Checking for updates");

const [latest, installDirs] = await Promise.all([
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => {
throw new CliError("Could not reach npm registry. Check your network connection.");
}),
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(
(error: unknown) => {
// A registry that answered — badly, or without the requested channel —
// already carries its own code, and a Ctrl-C is the user's decision,
// not the network's. Only transport and timeout failures are genuinely
// "unreachable", and retrying is only right for those.
if (error instanceof CliError || isCancelled(error)) throw error;
throw new CliError("Could not reach npm registry. Check your network connection.", {
code: ERROR_CODE.REGISTRY_UNREACHABLE,
});
},
),
getInstallerPackageDirs(),
]);

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/init-telemetry-stages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"clerk": minor
---

Give every failure a specific error code, and record which step a multi-step command reached.
Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure.
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
throw new CliError(
"Production instance was created but Clerk did not return a domain. " +
"Run `clerk deploy` again to retry domain provisioning.",
{ code: ERROR_CODE.DEPLOY_DOMAIN_MISSING },
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/env/pull.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,6 +147,7 @@ async function pullKeylessKeys(
throw new CliError(
`The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` +
`Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`,
{ code: ERROR_CODE.KEY_PAIR_MISMATCH },
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { join } from "node:path";
import { statSync } from "node:fs";
import { confirm, text } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
import { dirExists, hasPackageJson } from "./context.js";
Expand DownExpand Up@@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<Bootstr
const supported = BOOTSTRAP_REGISTRY.map((e) => e.label).join(", ");
throw new CliError(
`Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`,
{ code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED },
);
}

Expand DownExpand Up@@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi
const candidate = `${base}-${i}`;
if (!(await dirExists(join(cwd, candidate)))) return candidate;
}
throw new CliError(`Could not find an available project name based on '${base}'.`);
throw new CliError(`Could not find an available project name based on '${base}'.`, {
code: ERROR_CODE.PROJECT_DIR_EXISTS,
});
}

async function askProjectName(entry: BootstrapEntry, cwd: string): Promise<string> {
Expand DownExpand Up@@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P

const exitCode = await spawnInherited(command, cwd);
if (exitCode !== 0) {
throw new CliError(`Project generation failed (exit code ${exitCode}).`);
throw new CliError(`Project generation failed (exit code ${exitCode}).`, {
code: ERROR_CODE.GENERATOR_FAILED,
});
}
}

Expand DownExpand Up@@ -231,13 +236,16 @@ export async function promptAndBootstrap(
if (await dirExists(projectDir)) {
throw new CliError(
`Directory '${projectName}' already exists. Pick a different name or remove it first.`,
{ code: ERROR_CODE.PROJECT_DIR_EXISTS },
);
}

await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd);

if (!(await hasPackageJson(projectDir))) {
throw new CliError("Generator did not create a package.json.");
throw new CliError("Generator did not create a package.json.", {
code: ERROR_CODE.GENERATOR_FAILED,
});
}

await installDependencies(pm, projectDir);
Expand Down
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@ import {
skillsMod,
bootstrapMod,
nextStepsMod,
mockExistingProject,
mockMiddlewareScaffold,
} from "../../test/lib/init-harness.ts";
import * as telemetryMod from "../../lib/telemetry.ts";
import { init } from "./index.ts";

describe("init", () => {
Expand DownExpand Up@@ -584,4 +587,85 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

describe("telemetry stages", () => {
/** Spy registered with the harness so its calls reset between tests. */
function trackStages() {
const stage = spyOn(telemetryMod, "setTelemetryStage");
track(stage);
return () => stage.mock.calls.map((call) => call[0]);
}

test("a completed run reports the terminal stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("done");
});

test("a run with nothing to do stops at already_set_up", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("already_set_up");
});

// A run that dies in flag validation must not claim any later stage —
// that's what makes the funnel readable.
test("a rejected flag combination stops at the flags stage", async () => {
setup({ email: "test@test.com" });
const stages = trackStages();

await expect(init({ keyless: true, login: true })).rejects.toThrow();

expect(stages()).toEqual(["flags"]);
});

test("declining the scaffold preview stops at the scaffold stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("scaffold");
});

// Declining the overwrite prompt is the default answer on --starter, so it
// is a common drop-off — and it happens before bootstrapAndDetect runs.
test("declining the starter overwrite prompt stops at the bootstrap stage", async () => {
setup({ email: "test@test.com" });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);
track(
spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue(
Object.assign(new Error(), { name: "UserAbortError" }),
),
);
const stages = trackStages();

await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" });

expect(stages().at(-1)).toBe("bootstrap");
});

test("a failure inside the generator stops at the bootstrap stage", async () => {
setup();
track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed")));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("bootstrap");
});
});
});
26 changes: 24 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,13 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js";
import {
throwUserAbort,
throwUsageError,
CliError,
ERROR_CODE,
errorMessage,
} from "../../lib/errors.js";
import {
lookupFramework,
isNpmFramework,
Expand All@@ -15,6 +21,7 @@ import {
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import { setTelemetryStage } from "../../lib/telemetry.ts";
import { confirm } from "../../lib/prompts.ts";
import {
createAccountlessApp,
Expand DownExpand Up@@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

setTelemetryStage("flags");
await assertUsableFlags(options, agent);

const frameworkOverride = options.framework
Expand All@@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) {

intro("Setting up Clerk");

setTelemetryStage("detect");
const resolved = options.starter
? await handleStarter(cwd, frameworkOverride, overrides)
: await resolveProjectContext(cwd, frameworkOverride, overrides);
Expand All@@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) {
// stale/broken credential ends up blocked on an interactive browser OAuth
// round-trip it can never complete. So agent mode validates the credential
// (it can fall back to keyless) instead of trusting mere presence.
setTelemetryStage("strategy");
const authed = optsKeyless
? false
: agent
Expand All@@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) {
assertKeylessOnlyFlags(options, strategy);

if (strategy === "authenticate") {
setTelemetryStage("link");
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
Expand All@@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) {
const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm);

if (alreadySetUp) {
setTelemetryStage("already_set_up");
log.success("\nClerk is already set up in this project.");
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
Expand All@@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) {
return;
}

setTelemetryStage("keys");
bar();
await runStrategy(strategy, ctx, {
template: options.template,
Expand All@@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) {

// Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with.
if (options.skills !== false && isNpmFramework(ctx.framework)) {
setTelemetryStage("skills");
bar();
await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm);
}
Expand All@@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

setTelemetryStage("done");
await outro("Done");
}

Expand DownExpand Up@@ -275,11 +290,14 @@ async function bootstrapAndDetect(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides);

const ctx = await gatherContext(bootstrap.projectDir);
if (!ctx) {
throw new CliError("Project generation did not produce a detectable framework.");
throw new CliError("Project generation did not produce a detectable framework.", {
code: ERROR_CODE.FRAMEWORK_UNDETECTED,
});
}
return { ctx, bootstrap };
}
Expand All@@ -289,6 +307,7 @@ async function handleStarter(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
if (!overrides.skipConfirm) {
await confirmOverwrite(cwd);
}
Expand DownExpand Up@@ -323,6 +342,7 @@ async function resolveProjectContext(
if (!isBlank) {
throw new CliError(
`Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`,
{ code: ERROR_CODE.FRAMEWORK_UNDETECTED },
);
}

Expand DownExpand Up@@ -559,11 +579,13 @@ async function detectAndInstall(
if (ctx.existingClerk) {
log.info(dim(`${ctx.framework.sdk} is already installed`));
} else if (isNpmFramework(ctx.framework)) {
setTelemetryStage("install");
await installSdk(ctx);
}
// Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a
// package manager here — the framework's scaffold plan prints install steps.

setTelemetryStage("scaffold");
return scaffoldAndWrite(cwd, ctx, skipConfirm);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
isValidEnv,
setCurrentEnv,
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";
Expand DownExpand Up@@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
{ code: ERROR_CODE.NO_INTERACTIVE_TERMINAL },
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
Expand All@@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
{ code: ERROR_CODE.INVALID_ENVIRONMENT },
);
}

Expand Down
31 changes: 24 additions & 7 deletions packages/cli-core/src/commands/update/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import type { Program } from "../../cli-program.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { green, cyan, yellow, dim } from "../../lib/color.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { isCancelled } from "../../lib/signals.ts";
import {
asdfPluginFromPath,
asdfReshim,
Expand DownExpand Up@@ -134,12 +135,18 @@ async function runGlobalInstall(
const stderr = result.stderr.toString();
const hint = globalInstallCommand(installer, packageSpec);
if (stderr.includes("EACCES") || stderr.includes("permission denied")) {
throw new CliError(`Permission denied. Try: sudo ${hint}`);
throw new CliError(`Permission denied. Try: sudo ${hint}`, {
code: ERROR_CODE.UPDATE_PERMISSION_DENIED,
});
}
if (result.exitCode === 127 || stderr.includes("not found")) {
throw new CliError(`${installer} not found on PATH.`);
throw new CliError(`${installer} not found on PATH.`, {
code: ERROR_CODE.INSTALLER_NOT_FOUND,
});
}
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`);
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`, {
code: ERROR_CODE.UPDATE_FAILED,
});
}

// Homebrew installs whatever version its tap currently publishes, ignoring
Expand All@@ -154,6 +161,7 @@ async function runGlobalInstall(
`Homebrew tap is stale: installed ${installed}, expected ${targetVersion}. ` +
`Update via another installer (e.g. \`npm install -g ${packageSpec}\`) ` +
`or wait for the tap to catch up.`,
{ code: ERROR_CODE.UPDATE_FAILED },
);
}
}
Expand DownExpand Up@@ -262,9 +270,18 @@ export async function update(options: UpdateOptions): Promise<void> {
if (isHuman()) intro("Checking for updates");

const [latest, installDirs] = await Promise.all([
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => {
throw new CliError("Could not reach npm registry. Check your network connection.");
}),
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(
(error: unknown) => {
// A registry that answered — badly, or without the requested channel —
// already carries its own code, and a Ctrl-C is the user's decision,
// not the network's. Only transport and timeout failures are genuinely
// "unreachable", and retrying is only right for those.
if (error instanceof CliError || isCancelled(error)) throw error;
throw new CliError("Could not reach npm registry. Check your network connection.", {
code: ERROR_CODE.REGISTRY_UNREACHABLE,
});
},
),
getInstallerPackageDirs(),
]);

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/init-telemetry-stages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"clerk": minor
---

Give every failure a specific error code, and record which step a multi-step command reached.
Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure.
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
throw new CliError(
"Production instance was created but Clerk did not return a domain. " +
"Run `clerk deploy` again to retry domain provisioning.",
{ code: ERROR_CODE.DEPLOY_DOMAIN_MISSING },
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/env/pull.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,6 +147,7 @@ async function pullKeylessKeys(
throw new CliError(
`The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` +
`Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`,
{ code: ERROR_CODE.KEY_PAIR_MISMATCH },
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { join } from "node:path";
import { statSync } from "node:fs";
import { confirm, text } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
import { dirExists, hasPackageJson } from "./context.js";
Expand DownExpand Up@@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<Bootstr
const supported = BOOTSTRAP_REGISTRY.map((e) => e.label).join(", ");
throw new CliError(
`Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`,
{ code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED },
);
}

Expand DownExpand Up@@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi
const candidate = `${base}-${i}`;
if (!(await dirExists(join(cwd, candidate)))) return candidate;
}
throw new CliError(`Could not find an available project name based on '${base}'.`);
throw new CliError(`Could not find an available project name based on '${base}'.`, {
code: ERROR_CODE.PROJECT_DIR_EXISTS,
});
}

async function askProjectName(entry: BootstrapEntry, cwd: string): Promise<string> {
Expand DownExpand Up@@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P

const exitCode = await spawnInherited(command, cwd);
if (exitCode !== 0) {
throw new CliError(`Project generation failed (exit code ${exitCode}).`);
throw new CliError(`Project generation failed (exit code ${exitCode}).`, {
code: ERROR_CODE.GENERATOR_FAILED,
});
}
}

Expand DownExpand Up@@ -231,13 +236,16 @@ export async function promptAndBootstrap(
if (await dirExists(projectDir)) {
throw new CliError(
`Directory '${projectName}' already exists. Pick a different name or remove it first.`,
{ code: ERROR_CODE.PROJECT_DIR_EXISTS },
);
}

await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd);

if (!(await hasPackageJson(projectDir))) {
throw new CliError("Generator did not create a package.json.");
throw new CliError("Generator did not create a package.json.", {
code: ERROR_CODE.GENERATOR_FAILED,
});
}

await installDependencies(pm, projectDir);
Expand Down
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@ import {
skillsMod,
bootstrapMod,
nextStepsMod,
mockExistingProject,
mockMiddlewareScaffold,
} from "../../test/lib/init-harness.ts";
import * as telemetryMod from "../../lib/telemetry.ts";
import { init } from "./index.ts";

describe("init", () => {
Expand DownExpand Up@@ -584,4 +587,85 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

describe("telemetry stages", () => {
/** Spy registered with the harness so its calls reset between tests. */
function trackStages() {
const stage = spyOn(telemetryMod, "setTelemetryStage");
track(stage);
return () => stage.mock.calls.map((call) => call[0]);
}

test("a completed run reports the terminal stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("done");
});

test("a run with nothing to do stops at already_set_up", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("already_set_up");
});

// A run that dies in flag validation must not claim any later stage —
// that's what makes the funnel readable.
test("a rejected flag combination stops at the flags stage", async () => {
setup({ email: "test@test.com" });
const stages = trackStages();

await expect(init({ keyless: true, login: true })).rejects.toThrow();

expect(stages()).toEqual(["flags"]);
});

test("declining the scaffold preview stops at the scaffold stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("scaffold");
});

// Declining the overwrite prompt is the default answer on --starter, so it
// is a common drop-off — and it happens before bootstrapAndDetect runs.
test("declining the starter overwrite prompt stops at the bootstrap stage", async () => {
setup({ email: "test@test.com" });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);
track(
spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue(
Object.assign(new Error(), { name: "UserAbortError" }),
),
);
const stages = trackStages();

await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" });

expect(stages().at(-1)).toBe("bootstrap");
});

test("a failure inside the generator stops at the bootstrap stage", async () => {
setup();
track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed")));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("bootstrap");
});
});
});
26 changes: 24 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,13 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js";
import {
throwUserAbort,
throwUsageError,
CliError,
ERROR_CODE,
errorMessage,
} from "../../lib/errors.js";
import {
lookupFramework,
isNpmFramework,
Expand All@@ -15,6 +21,7 @@ import {
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import { setTelemetryStage } from "../../lib/telemetry.ts";
import { confirm } from "../../lib/prompts.ts";
import {
createAccountlessApp,
Expand DownExpand Up@@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

setTelemetryStage("flags");
await assertUsableFlags(options, agent);

const frameworkOverride = options.framework
Expand All@@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) {

intro("Setting up Clerk");

setTelemetryStage("detect");
const resolved = options.starter
? await handleStarter(cwd, frameworkOverride, overrides)
: await resolveProjectContext(cwd, frameworkOverride, overrides);
Expand All@@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) {
// stale/broken credential ends up blocked on an interactive browser OAuth
// round-trip it can never complete. So agent mode validates the credential
// (it can fall back to keyless) instead of trusting mere presence.
setTelemetryStage("strategy");
const authed = optsKeyless
? false
: agent
Expand All@@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) {
assertKeylessOnlyFlags(options, strategy);

if (strategy === "authenticate") {
setTelemetryStage("link");
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
Expand All@@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) {
const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm);

if (alreadySetUp) {
setTelemetryStage("already_set_up");
log.success("\nClerk is already set up in this project.");
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
Expand All@@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) {
return;
}

setTelemetryStage("keys");
bar();
await runStrategy(strategy, ctx, {
template: options.template,
Expand All@@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) {

// Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with.
if (options.skills !== false && isNpmFramework(ctx.framework)) {
setTelemetryStage("skills");
bar();
await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm);
}
Expand All@@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

setTelemetryStage("done");
await outro("Done");
}

Expand DownExpand Up@@ -275,11 +290,14 @@ async function bootstrapAndDetect(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides);

const ctx = await gatherContext(bootstrap.projectDir);
if (!ctx) {
throw new CliError("Project generation did not produce a detectable framework.");
throw new CliError("Project generation did not produce a detectable framework.", {
code: ERROR_CODE.FRAMEWORK_UNDETECTED,
});
}
return { ctx, bootstrap };
}
Expand All@@ -289,6 +307,7 @@ async function handleStarter(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
if (!overrides.skipConfirm) {
await confirmOverwrite(cwd);
}
Expand DownExpand Up@@ -323,6 +342,7 @@ async function resolveProjectContext(
if (!isBlank) {
throw new CliError(
`Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`,
{ code: ERROR_CODE.FRAMEWORK_UNDETECTED },
);
}

Expand DownExpand Up@@ -559,11 +579,13 @@ async function detectAndInstall(
if (ctx.existingClerk) {
log.info(dim(`${ctx.framework.sdk} is already installed`));
} else if (isNpmFramework(ctx.framework)) {
setTelemetryStage("install");
await installSdk(ctx);
}
// Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a
// package manager here — the framework's scaffold plan prints install steps.

setTelemetryStage("scaffold");
return scaffoldAndWrite(cwd, ctx, skipConfirm);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
isValidEnv,
setCurrentEnv,
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";
Expand DownExpand Up@@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
{ code: ERROR_CODE.NO_INTERACTIVE_TERMINAL },
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
Expand All@@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
{ code: ERROR_CODE.INVALID_ENVIRONMENT },
);
}

Expand Down
31 changes: 24 additions & 7 deletions packages/cli-core/src/commands/update/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import type { Program } from "../../cli-program.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { green, cyan, yellow, dim } from "../../lib/color.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { isCancelled } from "../../lib/signals.ts";
import {
asdfPluginFromPath,
asdfReshim,
Expand DownExpand Up@@ -134,12 +135,18 @@ async function runGlobalInstall(
const stderr = result.stderr.toString();
const hint = globalInstallCommand(installer, packageSpec);
if (stderr.includes("EACCES") || stderr.includes("permission denied")) {
throw new CliError(`Permission denied. Try: sudo ${hint}`);
throw new CliError(`Permission denied. Try: sudo ${hint}`, {
code: ERROR_CODE.UPDATE_PERMISSION_DENIED,
});
}
if (result.exitCode === 127 || stderr.includes("not found")) {
throw new CliError(`${installer} not found on PATH.`);
throw new CliError(`${installer} not found on PATH.`, {
code: ERROR_CODE.INSTALLER_NOT_FOUND,
});
}
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`);
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`, {
code: ERROR_CODE.UPDATE_FAILED,
});
}

// Homebrew installs whatever version its tap currently publishes, ignoring
Expand All@@ -154,6 +161,7 @@ async function runGlobalInstall(
`Homebrew tap is stale: installed ${installed}, expected ${targetVersion}. ` +
`Update via another installer (e.g. \`npm install -g ${packageSpec}\`) ` +
`or wait for the tap to catch up.`,
{ code: ERROR_CODE.UPDATE_FAILED },
);
}
}
Expand DownExpand Up@@ -262,9 +270,18 @@ export async function update(options: UpdateOptions): Promise<void> {
if (isHuman()) intro("Checking for updates");

const [latest, installDirs] = await Promise.all([
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => {
throw new CliError("Could not reach npm registry. Check your network connection.");
}),
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(
(error: unknown) => {
// A registry that answered — badly, or without the requested channel —
// already carries its own code, and a Ctrl-C is the user's decision,
// not the network's. Only transport and timeout failures are genuinely
// "unreachable", and retrying is only right for those.
if (error instanceof CliError || isCancelled(error)) throw error;
throw new CliError("Could not reach npm registry. Check your network connection.", {
code: ERROR_CODE.REGISTRY_UNREACHABLE,
});
},
),
getInstallerPackageDirs(),
]);

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/init-telemetry-stages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"clerk": minor
---

Give every failure a specific error code, and record which step a multi-step command reached.
Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure.
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
throw new CliError(
"Production instance was created but Clerk did not return a domain. " +
"Run `clerk deploy` again to retry domain provisioning.",
{ code: ERROR_CODE.DEPLOY_DOMAIN_MISSING },
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/env/pull.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,6 +147,7 @@ async function pullKeylessKeys(
throw new CliError(
`The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` +
`Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`,
{ code: ERROR_CODE.KEY_PAIR_MISMATCH },
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { join } from "node:path";
import { statSync } from "node:fs";
import { confirm, text } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
import { dirExists, hasPackageJson } from "./context.js";
Expand DownExpand Up@@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<Bootstr
const supported = BOOTSTRAP_REGISTRY.map((e) => e.label).join(", ");
throw new CliError(
`Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`,
{ code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED },
);
}

Expand DownExpand Up@@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi
const candidate = `${base}-${i}`;
if (!(await dirExists(join(cwd, candidate)))) return candidate;
}
throw new CliError(`Could not find an available project name based on '${base}'.`);
throw new CliError(`Could not find an available project name based on '${base}'.`, {
code: ERROR_CODE.PROJECT_DIR_EXISTS,
});
}

async function askProjectName(entry: BootstrapEntry, cwd: string): Promise<string> {
Expand DownExpand Up@@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P

const exitCode = await spawnInherited(command, cwd);
if (exitCode !== 0) {
throw new CliError(`Project generation failed (exit code ${exitCode}).`);
throw new CliError(`Project generation failed (exit code ${exitCode}).`, {
code: ERROR_CODE.GENERATOR_FAILED,
});
}
}

Expand DownExpand Up@@ -231,13 +236,16 @@ export async function promptAndBootstrap(
if (await dirExists(projectDir)) {
throw new CliError(
`Directory '${projectName}' already exists. Pick a different name or remove it first.`,
{ code: ERROR_CODE.PROJECT_DIR_EXISTS },
);
}

await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd);

if (!(await hasPackageJson(projectDir))) {
throw new CliError("Generator did not create a package.json.");
throw new CliError("Generator did not create a package.json.", {
code: ERROR_CODE.GENERATOR_FAILED,
});
}

await installDependencies(pm, projectDir);
Expand Down
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@ import {
skillsMod,
bootstrapMod,
nextStepsMod,
mockExistingProject,
mockMiddlewareScaffold,
} from "../../test/lib/init-harness.ts";
import * as telemetryMod from "../../lib/telemetry.ts";
import { init } from "./index.ts";

describe("init", () => {
Expand DownExpand Up@@ -584,4 +587,85 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

describe("telemetry stages", () => {
/** Spy registered with the harness so its calls reset between tests. */
function trackStages() {
const stage = spyOn(telemetryMod, "setTelemetryStage");
track(stage);
return () => stage.mock.calls.map((call) => call[0]);
}

test("a completed run reports the terminal stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("done");
});

test("a run with nothing to do stops at already_set_up", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("already_set_up");
});

// A run that dies in flag validation must not claim any later stage —
// that's what makes the funnel readable.
test("a rejected flag combination stops at the flags stage", async () => {
setup({ email: "test@test.com" });
const stages = trackStages();

await expect(init({ keyless: true, login: true })).rejects.toThrow();

expect(stages()).toEqual(["flags"]);
});

test("declining the scaffold preview stops at the scaffold stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("scaffold");
});

// Declining the overwrite prompt is the default answer on --starter, so it
// is a common drop-off — and it happens before bootstrapAndDetect runs.
test("declining the starter overwrite prompt stops at the bootstrap stage", async () => {
setup({ email: "test@test.com" });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);
track(
spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue(
Object.assign(new Error(), { name: "UserAbortError" }),
),
);
const stages = trackStages();

await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" });

expect(stages().at(-1)).toBe("bootstrap");
});

test("a failure inside the generator stops at the bootstrap stage", async () => {
setup();
track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed")));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("bootstrap");
});
});
});
26 changes: 24 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,13 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js";
import {
throwUserAbort,
throwUsageError,
CliError,
ERROR_CODE,
errorMessage,
} from "../../lib/errors.js";
import {
lookupFramework,
isNpmFramework,
Expand All@@ -15,6 +21,7 @@ import {
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import { setTelemetryStage } from "../../lib/telemetry.ts";
import { confirm } from "../../lib/prompts.ts";
import {
createAccountlessApp,
Expand DownExpand Up@@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

setTelemetryStage("flags");
await assertUsableFlags(options, agent);

const frameworkOverride = options.framework
Expand All@@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) {

intro("Setting up Clerk");

setTelemetryStage("detect");
const resolved = options.starter
? await handleStarter(cwd, frameworkOverride, overrides)
: await resolveProjectContext(cwd, frameworkOverride, overrides);
Expand All@@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) {
// stale/broken credential ends up blocked on an interactive browser OAuth
// round-trip it can never complete. So agent mode validates the credential
// (it can fall back to keyless) instead of trusting mere presence.
setTelemetryStage("strategy");
const authed = optsKeyless
? false
: agent
Expand All@@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) {
assertKeylessOnlyFlags(options, strategy);

if (strategy === "authenticate") {
setTelemetryStage("link");
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
Expand All@@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) {
const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm);

if (alreadySetUp) {
setTelemetryStage("already_set_up");
log.success("\nClerk is already set up in this project.");
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
Expand All@@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) {
return;
}

setTelemetryStage("keys");
bar();
await runStrategy(strategy, ctx, {
template: options.template,
Expand All@@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) {

// Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with.
if (options.skills !== false && isNpmFramework(ctx.framework)) {
setTelemetryStage("skills");
bar();
await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm);
}
Expand All@@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

setTelemetryStage("done");
await outro("Done");
}

Expand DownExpand Up@@ -275,11 +290,14 @@ async function bootstrapAndDetect(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides);

const ctx = await gatherContext(bootstrap.projectDir);
if (!ctx) {
throw new CliError("Project generation did not produce a detectable framework.");
throw new CliError("Project generation did not produce a detectable framework.", {
code: ERROR_CODE.FRAMEWORK_UNDETECTED,
});
}
return { ctx, bootstrap };
}
Expand All@@ -289,6 +307,7 @@ async function handleStarter(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
if (!overrides.skipConfirm) {
await confirmOverwrite(cwd);
}
Expand DownExpand Up@@ -323,6 +342,7 @@ async function resolveProjectContext(
if (!isBlank) {
throw new CliError(
`Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`,
{ code: ERROR_CODE.FRAMEWORK_UNDETECTED },
);
}

Expand DownExpand Up@@ -559,11 +579,13 @@ async function detectAndInstall(
if (ctx.existingClerk) {
log.info(dim(`${ctx.framework.sdk} is already installed`));
} else if (isNpmFramework(ctx.framework)) {
setTelemetryStage("install");
await installSdk(ctx);
}
// Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a
// package manager here — the framework's scaffold plan prints install steps.

setTelemetryStage("scaffold");
return scaffoldAndWrite(cwd, ctx, skipConfirm);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
isValidEnv,
setCurrentEnv,
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";
Expand DownExpand Up@@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
{ code: ERROR_CODE.NO_INTERACTIVE_TERMINAL },
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
Expand All@@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
{ code: ERROR_CODE.INVALID_ENVIRONMENT },
);
}

Expand Down
31 changes: 24 additions & 7 deletions packages/cli-core/src/commands/update/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import type { Program } from "../../cli-program.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { green, cyan, yellow, dim } from "../../lib/color.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { isCancelled } from "../../lib/signals.ts";
import {
asdfPluginFromPath,
asdfReshim,
Expand DownExpand Up@@ -134,12 +135,18 @@ async function runGlobalInstall(
const stderr = result.stderr.toString();
const hint = globalInstallCommand(installer, packageSpec);
if (stderr.includes("EACCES") || stderr.includes("permission denied")) {
throw new CliError(`Permission denied. Try: sudo ${hint}`);
throw new CliError(`Permission denied. Try: sudo ${hint}`, {
code: ERROR_CODE.UPDATE_PERMISSION_DENIED,
});
}
if (result.exitCode === 127 || stderr.includes("not found")) {
throw new CliError(`${installer} not found on PATH.`);
throw new CliError(`${installer} not found on PATH.`, {
code: ERROR_CODE.INSTALLER_NOT_FOUND,
});
}
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`);
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`, {
code: ERROR_CODE.UPDATE_FAILED,
});
}

// Homebrew installs whatever version its tap currently publishes, ignoring
Expand All@@ -154,6 +161,7 @@ async function runGlobalInstall(
`Homebrew tap is stale: installed ${installed}, expected ${targetVersion}. ` +
`Update via another installer (e.g. \`npm install -g ${packageSpec}\`) ` +
`or wait for the tap to catch up.`,
{ code: ERROR_CODE.UPDATE_FAILED },
);
}
}
Expand DownExpand Up@@ -262,9 +270,18 @@ export async function update(options: UpdateOptions): Promise<void> {
if (isHuman()) intro("Checking for updates");

const [latest, installDirs] = await Promise.all([
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => {
throw new CliError("Could not reach npm registry. Check your network connection.");
}),
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(
(error: unknown) => {
// A registry that answered — badly, or without the requested channel —
// already carries its own code, and a Ctrl-C is the user's decision,
// not the network's. Only transport and timeout failures are genuinely
// "unreachable", and retrying is only right for those.
if (error instanceof CliError || isCancelled(error)) throw error;
throw new CliError("Could not reach npm registry. Check your network connection.", {
code: ERROR_CODE.REGISTRY_UNREACHABLE,
});
},
),
getInstallerPackageDirs(),
]);

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/init-telemetry-stages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"clerk": minor
---

Give every failure a specific error code, and record which step a multi-step command reached.
Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure.
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
throw new CliError(
"Production instance was created but Clerk did not return a domain. " +
"Run `clerk deploy` again to retry domain provisioning.",
{ code: ERROR_CODE.DEPLOY_DOMAIN_MISSING },
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/env/pull.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,6 +147,7 @@ async function pullKeylessKeys(
throw new CliError(
`The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` +
`Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`,
{ code: ERROR_CODE.KEY_PAIR_MISMATCH },
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { join } from "node:path";
import { statSync } from "node:fs";
import { confirm, text } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
import { dirExists, hasPackageJson } from "./context.js";
Expand DownExpand Up@@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<Bootstr
const supported = BOOTSTRAP_REGISTRY.map((e) => e.label).join(", ");
throw new CliError(
`Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`,
{ code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED },
);
}

Expand DownExpand Up@@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi
const candidate = `${base}-${i}`;
if (!(await dirExists(join(cwd, candidate)))) return candidate;
}
throw new CliError(`Could not find an available project name based on '${base}'.`);
throw new CliError(`Could not find an available project name based on '${base}'.`, {
code: ERROR_CODE.PROJECT_DIR_EXISTS,
});
}

async function askProjectName(entry: BootstrapEntry, cwd: string): Promise<string> {
Expand DownExpand Up@@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P

const exitCode = await spawnInherited(command, cwd);
if (exitCode !== 0) {
throw new CliError(`Project generation failed (exit code ${exitCode}).`);
throw new CliError(`Project generation failed (exit code ${exitCode}).`, {
code: ERROR_CODE.GENERATOR_FAILED,
});
}
}

Expand DownExpand Up@@ -231,13 +236,16 @@ export async function promptAndBootstrap(
if (await dirExists(projectDir)) {
throw new CliError(
`Directory '${projectName}' already exists. Pick a different name or remove it first.`,
{ code: ERROR_CODE.PROJECT_DIR_EXISTS },
);
}

await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd);

if (!(await hasPackageJson(projectDir))) {
throw new CliError("Generator did not create a package.json.");
throw new CliError("Generator did not create a package.json.", {
code: ERROR_CODE.GENERATOR_FAILED,
});
}

await installDependencies(pm, projectDir);
Expand Down
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@ import {
skillsMod,
bootstrapMod,
nextStepsMod,
mockExistingProject,
mockMiddlewareScaffold,
} from "../../test/lib/init-harness.ts";
import * as telemetryMod from "../../lib/telemetry.ts";
import { init } from "./index.ts";

describe("init", () => {
Expand DownExpand Up@@ -584,4 +587,85 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

describe("telemetry stages", () => {
/** Spy registered with the harness so its calls reset between tests. */
function trackStages() {
const stage = spyOn(telemetryMod, "setTelemetryStage");
track(stage);
return () => stage.mock.calls.map((call) => call[0]);
}

test("a completed run reports the terminal stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("done");
});

test("a run with nothing to do stops at already_set_up", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("already_set_up");
});

// A run that dies in flag validation must not claim any later stage —
// that's what makes the funnel readable.
test("a rejected flag combination stops at the flags stage", async () => {
setup({ email: "test@test.com" });
const stages = trackStages();

await expect(init({ keyless: true, login: true })).rejects.toThrow();

expect(stages()).toEqual(["flags"]);
});

test("declining the scaffold preview stops at the scaffold stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("scaffold");
});

// Declining the overwrite prompt is the default answer on --starter, so it
// is a common drop-off — and it happens before bootstrapAndDetect runs.
test("declining the starter overwrite prompt stops at the bootstrap stage", async () => {
setup({ email: "test@test.com" });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);
track(
spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue(
Object.assign(new Error(), { name: "UserAbortError" }),
),
);
const stages = trackStages();

await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" });

expect(stages().at(-1)).toBe("bootstrap");
});

test("a failure inside the generator stops at the bootstrap stage", async () => {
setup();
track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed")));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("bootstrap");
});
});
});
26 changes: 24 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,13 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js";
import {
throwUserAbort,
throwUsageError,
CliError,
ERROR_CODE,
errorMessage,
} from "../../lib/errors.js";
import {
lookupFramework,
isNpmFramework,
Expand All@@ -15,6 +21,7 @@ import {
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import { setTelemetryStage } from "../../lib/telemetry.ts";
import { confirm } from "../../lib/prompts.ts";
import {
createAccountlessApp,
Expand DownExpand Up@@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

setTelemetryStage("flags");
await assertUsableFlags(options, agent);

const frameworkOverride = options.framework
Expand All@@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) {

intro("Setting up Clerk");

setTelemetryStage("detect");
const resolved = options.starter
? await handleStarter(cwd, frameworkOverride, overrides)
: await resolveProjectContext(cwd, frameworkOverride, overrides);
Expand All@@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) {
// stale/broken credential ends up blocked on an interactive browser OAuth
// round-trip it can never complete. So agent mode validates the credential
// (it can fall back to keyless) instead of trusting mere presence.
setTelemetryStage("strategy");
const authed = optsKeyless
? false
: agent
Expand All@@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) {
assertKeylessOnlyFlags(options, strategy);

if (strategy === "authenticate") {
setTelemetryStage("link");
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
Expand All@@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) {
const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm);

if (alreadySetUp) {
setTelemetryStage("already_set_up");
log.success("\nClerk is already set up in this project.");
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
Expand All@@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) {
return;
}

setTelemetryStage("keys");
bar();
await runStrategy(strategy, ctx, {
template: options.template,
Expand All@@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) {

// Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with.
if (options.skills !== false && isNpmFramework(ctx.framework)) {
setTelemetryStage("skills");
bar();
await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm);
}
Expand All@@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

setTelemetryStage("done");
await outro("Done");
}

Expand DownExpand Up@@ -275,11 +290,14 @@ async function bootstrapAndDetect(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides);

const ctx = await gatherContext(bootstrap.projectDir);
if (!ctx) {
throw new CliError("Project generation did not produce a detectable framework.");
throw new CliError("Project generation did not produce a detectable framework.", {
code: ERROR_CODE.FRAMEWORK_UNDETECTED,
});
}
return { ctx, bootstrap };
}
Expand All@@ -289,6 +307,7 @@ async function handleStarter(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
if (!overrides.skipConfirm) {
await confirmOverwrite(cwd);
}
Expand DownExpand Up@@ -323,6 +342,7 @@ async function resolveProjectContext(
if (!isBlank) {
throw new CliError(
`Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`,
{ code: ERROR_CODE.FRAMEWORK_UNDETECTED },
);
}

Expand DownExpand Up@@ -559,11 +579,13 @@ async function detectAndInstall(
if (ctx.existingClerk) {
log.info(dim(`${ctx.framework.sdk} is already installed`));
} else if (isNpmFramework(ctx.framework)) {
setTelemetryStage("install");
await installSdk(ctx);
}
// Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a
// package manager here — the framework's scaffold plan prints install steps.

setTelemetryStage("scaffold");
return scaffoldAndWrite(cwd, ctx, skipConfirm);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
isValidEnv,
setCurrentEnv,
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";
Expand DownExpand Up@@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
{ code: ERROR_CODE.NO_INTERACTIVE_TERMINAL },
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
Expand All@@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
{ code: ERROR_CODE.INVALID_ENVIRONMENT },
);
}

Expand Down
31 changes: 24 additions & 7 deletions packages/cli-core/src/commands/update/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import type { Program } from "../../cli-program.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { green, cyan, yellow, dim } from "../../lib/color.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { isCancelled } from "../../lib/signals.ts";
import {
asdfPluginFromPath,
asdfReshim,
Expand DownExpand Up@@ -134,12 +135,18 @@ async function runGlobalInstall(
const stderr = result.stderr.toString();
const hint = globalInstallCommand(installer, packageSpec);
if (stderr.includes("EACCES") || stderr.includes("permission denied")) {
throw new CliError(`Permission denied. Try: sudo ${hint}`);
throw new CliError(`Permission denied. Try: sudo ${hint}`, {
code: ERROR_CODE.UPDATE_PERMISSION_DENIED,
});
}
if (result.exitCode === 127 || stderr.includes("not found")) {
throw new CliError(`${installer} not found on PATH.`);
throw new CliError(`${installer} not found on PATH.`, {
code: ERROR_CODE.INSTALLER_NOT_FOUND,
});
}
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`);
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`, {
code: ERROR_CODE.UPDATE_FAILED,
});
}

// Homebrew installs whatever version its tap currently publishes, ignoring
Expand All@@ -154,6 +161,7 @@ async function runGlobalInstall(
`Homebrew tap is stale: installed ${installed}, expected ${targetVersion}. ` +
`Update via another installer (e.g. \`npm install -g ${packageSpec}\`) ` +
`or wait for the tap to catch up.`,
{ code: ERROR_CODE.UPDATE_FAILED },
);
}
}
Expand DownExpand Up@@ -262,9 +270,18 @@ export async function update(options: UpdateOptions): Promise<void> {
if (isHuman()) intro("Checking for updates");

const [latest, installDirs] = await Promise.all([
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => {
throw new CliError("Could not reach npm registry. Check your network connection.");
}),
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(
(error: unknown) => {
// A registry that answered — badly, or without the requested channel —
// already carries its own code, and a Ctrl-C is the user's decision,
// not the network's. Only transport and timeout failures are genuinely
// "unreachable", and retrying is only right for those.
if (error instanceof CliError || isCancelled(error)) throw error;
throw new CliError("Could not reach npm registry. Check your network connection.", {
code: ERROR_CODE.REGISTRY_UNREACHABLE,
});
},
),
getInstallerPackageDirs(),
]);

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/init-telemetry-stages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"clerk": minor
---

Give every failure a specific error code, and record which step a multi-step command reached.
Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure.
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
throw new CliError(
"Production instance was created but Clerk did not return a domain. " +
"Run `clerk deploy` again to retry domain provisioning.",
{ code: ERROR_CODE.DEPLOY_DOMAIN_MISSING },
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/env/pull.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,6 +147,7 @@ async function pullKeylessKeys(
throw new CliError(
`The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` +
`Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`,
{ code: ERROR_CODE.KEY_PAIR_MISMATCH },
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { join } from "node:path";
import { statSync } from "node:fs";
import { confirm, text } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
import { dirExists, hasPackageJson } from "./context.js";
Expand DownExpand Up@@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<Bootstr
const supported = BOOTSTRAP_REGISTRY.map((e) => e.label).join(", ");
throw new CliError(
`Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`,
{ code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED },
);
}

Expand DownExpand Up@@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi
const candidate = `${base}-${i}`;
if (!(await dirExists(join(cwd, candidate)))) return candidate;
}
throw new CliError(`Could not find an available project name based on '${base}'.`);
throw new CliError(`Could not find an available project name based on '${base}'.`, {
code: ERROR_CODE.PROJECT_DIR_EXISTS,
});
}

async function askProjectName(entry: BootstrapEntry, cwd: string): Promise<string> {
Expand DownExpand Up@@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P

const exitCode = await spawnInherited(command, cwd);
if (exitCode !== 0) {
throw new CliError(`Project generation failed (exit code ${exitCode}).`);
throw new CliError(`Project generation failed (exit code ${exitCode}).`, {
code: ERROR_CODE.GENERATOR_FAILED,
});
}
}

Expand DownExpand Up@@ -231,13 +236,16 @@ export async function promptAndBootstrap(
if (await dirExists(projectDir)) {
throw new CliError(
`Directory '${projectName}' already exists. Pick a different name or remove it first.`,
{ code: ERROR_CODE.PROJECT_DIR_EXISTS },
);
}

await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd);

if (!(await hasPackageJson(projectDir))) {
throw new CliError("Generator did not create a package.json.");
throw new CliError("Generator did not create a package.json.", {
code: ERROR_CODE.GENERATOR_FAILED,
});
}

await installDependencies(pm, projectDir);
Expand Down
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@ import {
skillsMod,
bootstrapMod,
nextStepsMod,
mockExistingProject,
mockMiddlewareScaffold,
} from "../../test/lib/init-harness.ts";
import * as telemetryMod from "../../lib/telemetry.ts";
import { init } from "./index.ts";

describe("init", () => {
Expand DownExpand Up@@ -584,4 +587,85 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

describe("telemetry stages", () => {
/** Spy registered with the harness so its calls reset between tests. */
function trackStages() {
const stage = spyOn(telemetryMod, "setTelemetryStage");
track(stage);
return () => stage.mock.calls.map((call) => call[0]);
}

test("a completed run reports the terminal stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("done");
});

test("a run with nothing to do stops at already_set_up", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("already_set_up");
});

// A run that dies in flag validation must not claim any later stage —
// that's what makes the funnel readable.
test("a rejected flag combination stops at the flags stage", async () => {
setup({ email: "test@test.com" });
const stages = trackStages();

await expect(init({ keyless: true, login: true })).rejects.toThrow();

expect(stages()).toEqual(["flags"]);
});

test("declining the scaffold preview stops at the scaffold stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("scaffold");
});

// Declining the overwrite prompt is the default answer on --starter, so it
// is a common drop-off — and it happens before bootstrapAndDetect runs.
test("declining the starter overwrite prompt stops at the bootstrap stage", async () => {
setup({ email: "test@test.com" });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);
track(
spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue(
Object.assign(new Error(), { name: "UserAbortError" }),
),
);
const stages = trackStages();

await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" });

expect(stages().at(-1)).toBe("bootstrap");
});

test("a failure inside the generator stops at the bootstrap stage", async () => {
setup();
track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed")));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("bootstrap");
});
});
});
26 changes: 24 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,13 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js";
import {
throwUserAbort,
throwUsageError,
CliError,
ERROR_CODE,
errorMessage,
} from "../../lib/errors.js";
import {
lookupFramework,
isNpmFramework,
Expand All@@ -15,6 +21,7 @@ import {
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import { setTelemetryStage } from "../../lib/telemetry.ts";
import { confirm } from "../../lib/prompts.ts";
import {
createAccountlessApp,
Expand DownExpand Up@@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

setTelemetryStage("flags");
await assertUsableFlags(options, agent);

const frameworkOverride = options.framework
Expand All@@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) {

intro("Setting up Clerk");

setTelemetryStage("detect");
const resolved = options.starter
? await handleStarter(cwd, frameworkOverride, overrides)
: await resolveProjectContext(cwd, frameworkOverride, overrides);
Expand All@@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) {
// stale/broken credential ends up blocked on an interactive browser OAuth
// round-trip it can never complete. So agent mode validates the credential
// (it can fall back to keyless) instead of trusting mere presence.
setTelemetryStage("strategy");
const authed = optsKeyless
? false
: agent
Expand All@@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) {
assertKeylessOnlyFlags(options, strategy);

if (strategy === "authenticate") {
setTelemetryStage("link");
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
Expand All@@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) {
const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm);

if (alreadySetUp) {
setTelemetryStage("already_set_up");
log.success("\nClerk is already set up in this project.");
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
Expand All@@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) {
return;
}

setTelemetryStage("keys");
bar();
await runStrategy(strategy, ctx, {
template: options.template,
Expand All@@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) {

// Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with.
if (options.skills !== false && isNpmFramework(ctx.framework)) {
setTelemetryStage("skills");
bar();
await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm);
}
Expand All@@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

setTelemetryStage("done");
await outro("Done");
}

Expand DownExpand Up@@ -275,11 +290,14 @@ async function bootstrapAndDetect(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides);

const ctx = await gatherContext(bootstrap.projectDir);
if (!ctx) {
throw new CliError("Project generation did not produce a detectable framework.");
throw new CliError("Project generation did not produce a detectable framework.", {
code: ERROR_CODE.FRAMEWORK_UNDETECTED,
});
}
return { ctx, bootstrap };
}
Expand All@@ -289,6 +307,7 @@ async function handleStarter(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
if (!overrides.skipConfirm) {
await confirmOverwrite(cwd);
}
Expand DownExpand Up@@ -323,6 +342,7 @@ async function resolveProjectContext(
if (!isBlank) {
throw new CliError(
`Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`,
{ code: ERROR_CODE.FRAMEWORK_UNDETECTED },
);
}

Expand DownExpand Up@@ -559,11 +579,13 @@ async function detectAndInstall(
if (ctx.existingClerk) {
log.info(dim(`${ctx.framework.sdk} is already installed`));
} else if (isNpmFramework(ctx.framework)) {
setTelemetryStage("install");
await installSdk(ctx);
}
// Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a
// package manager here — the framework's scaffold plan prints install steps.

setTelemetryStage("scaffold");
return scaffoldAndWrite(cwd, ctx, skipConfirm);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
isValidEnv,
setCurrentEnv,
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";
Expand DownExpand Up@@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
{ code: ERROR_CODE.NO_INTERACTIVE_TERMINAL },
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
Expand All@@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
{ code: ERROR_CODE.INVALID_ENVIRONMENT },
);
}

Expand Down
31 changes: 24 additions & 7 deletions packages/cli-core/src/commands/update/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import type { Program } from "../../cli-program.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { green, cyan, yellow, dim } from "../../lib/color.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { isCancelled } from "../../lib/signals.ts";
import {
asdfPluginFromPath,
asdfReshim,
Expand DownExpand Up@@ -134,12 +135,18 @@ async function runGlobalInstall(
const stderr = result.stderr.toString();
const hint = globalInstallCommand(installer, packageSpec);
if (stderr.includes("EACCES") || stderr.includes("permission denied")) {
throw new CliError(`Permission denied. Try: sudo ${hint}`);
throw new CliError(`Permission denied. Try: sudo ${hint}`, {
code: ERROR_CODE.UPDATE_PERMISSION_DENIED,
});
}
if (result.exitCode === 127 || stderr.includes("not found")) {
throw new CliError(`${installer} not found on PATH.`);
throw new CliError(`${installer} not found on PATH.`, {
code: ERROR_CODE.INSTALLER_NOT_FOUND,
});
}
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`);
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`, {
code: ERROR_CODE.UPDATE_FAILED,
});
}

// Homebrew installs whatever version its tap currently publishes, ignoring
Expand All@@ -154,6 +161,7 @@ async function runGlobalInstall(
`Homebrew tap is stale: installed ${installed}, expected ${targetVersion}. ` +
`Update via another installer (e.g. \`npm install -g ${packageSpec}\`) ` +
`or wait for the tap to catch up.`,
{ code: ERROR_CODE.UPDATE_FAILED },
);
}
}
Expand DownExpand Up@@ -262,9 +270,18 @@ export async function update(options: UpdateOptions): Promise<void> {
if (isHuman()) intro("Checking for updates");

const [latest, installDirs] = await Promise.all([
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => {
throw new CliError("Could not reach npm registry. Check your network connection.");
}),
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(
(error: unknown) => {
// A registry that answered — badly, or without the requested channel —
// already carries its own code, and a Ctrl-C is the user's decision,
// not the network's. Only transport and timeout failures are genuinely
// "unreachable", and retrying is only right for those.
if (error instanceof CliError || isCancelled(error)) throw error;
throw new CliError("Could not reach npm registry. Check your network connection.", {
code: ERROR_CODE.REGISTRY_UNREACHABLE,
});
},
),
getInstallerPackageDirs(),
]);

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/init-telemetry-stages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"clerk": minor
---

Give every failure a specific error code, and record which step a multi-step command reached.
Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure.
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
throw new CliError(
"Production instance was created but Clerk did not return a domain. " +
"Run `clerk deploy` again to retry domain provisioning.",
{ code: ERROR_CODE.DEPLOY_DOMAIN_MISSING },
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/env/pull.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,6 +147,7 @@ async function pullKeylessKeys(
throw new CliError(
`The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` +
`Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`,
{ code: ERROR_CODE.KEY_PAIR_MISMATCH },
);
}
}
Expand Down
16 changes: 12 additions & 4 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { join } from "node:path";
import { statSync } from "node:fs";
import { confirm, text } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
import { dirExists, hasPackageJson } from "./context.js";
Expand DownExpand Up@@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<Bootstr
const supported = BOOTSTRAP_REGISTRY.map((e) => e.label).join(", ");
throw new CliError(
`Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`,
{ code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED },
);
}

Expand DownExpand Up@@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi
const candidate = `${base}-${i}`;
if (!(await dirExists(join(cwd, candidate)))) return candidate;
}
throw new CliError(`Could not find an available project name based on '${base}'.`);
throw new CliError(`Could not find an available project name based on '${base}'.`, {
code: ERROR_CODE.PROJECT_DIR_EXISTS,
});
}

async function askProjectName(entry: BootstrapEntry, cwd: string): Promise<string> {
Expand DownExpand Up@@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P

const exitCode = await spawnInherited(command, cwd);
if (exitCode !== 0) {
throw new CliError(`Project generation failed (exit code ${exitCode}).`);
throw new CliError(`Project generation failed (exit code ${exitCode}).`, {
code: ERROR_CODE.GENERATOR_FAILED,
});
}
}

Expand DownExpand Up@@ -231,13 +236,16 @@ export async function promptAndBootstrap(
if (await dirExists(projectDir)) {
throw new CliError(
`Directory '${projectName}' already exists. Pick a different name or remove it first.`,
{ code: ERROR_CODE.PROJECT_DIR_EXISTS },
);
}

await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd);

if (!(await hasPackageJson(projectDir))) {
throw new CliError("Generator did not create a package.json.");
throw new CliError("Generator did not create a package.json.", {
code: ERROR_CODE.GENERATOR_FAILED,
});
}

await installDependencies(pm, projectDir);
Expand Down
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,10 @@ import {
skillsMod,
bootstrapMod,
nextStepsMod,
mockExistingProject,
mockMiddlewareScaffold,
} from "../../test/lib/init-harness.ts";
import * as telemetryMod from "../../lib/telemetry.ts";
import { init } from "./index.ts";

describe("init", () => {
Expand DownExpand Up@@ -584,4 +587,85 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

describe("telemetry stages", () => {
/** Spy registered with the harness so its calls reset between tests. */
function trackStages() {
const stage = spyOn(telemetryMod, "setTelemetryStage");
track(stage);
return () => stage.mock.calls.map((call) => call[0]);
}

test("a completed run reports the terminal stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("done");
});

test("a run with nothing to do stops at already_set_up", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
const stages = trackStages();

await init({ yes: true });

expect(stages().at(-1)).toBe("already_set_up");
});

// A run that dies in flag validation must not claim any later stage —
// that's what makes the funnel readable.
test("a rejected flag combination stops at the flags stage", async () => {
setup({ email: "test@test.com" });
const stages = trackStages();

await expect(init({ keyless: true, login: true })).rejects.toThrow();

expect(stages()).toEqual(["flags"]);
});

test("declining the scaffold preview stops at the scaffold stage", async () => {
setup({ email: "test@test.com" });
mockExistingProject(FAKE_CTX);
mockMiddlewareScaffold();
track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("scaffold");
});

// Declining the overwrite prompt is the default answer on --starter, so it
// is a common drop-off — and it happens before bootstrapAndDetect runs.
test("declining the starter overwrite prompt stops at the bootstrap stage", async () => {
setup({ email: "test@test.com" });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);
track(
spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue(
Object.assign(new Error(), { name: "UserAbortError" }),
),
);
const stages = trackStages();

await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" });

expect(stages().at(-1)).toBe("bootstrap");
});

test("a failure inside the generator stops at the bootstrap stage", async () => {
setup();
track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed")));
const stages = trackStages();

await expect(init({})).rejects.toThrow();

expect(stages().at(-1)).toBe("bootstrap");
});
});
});
26 changes: 24 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,13 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js";
import {
throwUserAbort,
throwUsageError,
CliError,
ERROR_CODE,
errorMessage,
} from "../../lib/errors.js";
import {
lookupFramework,
isNpmFramework,
Expand All@@ -15,6 +21,7 @@ import {
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import { setTelemetryStage } from "../../lib/telemetry.ts";
import { confirm } from "../../lib/prompts.ts";
import {
createAccountlessApp,
Expand DownExpand Up@@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

setTelemetryStage("flags");
await assertUsableFlags(options, agent);

const frameworkOverride = options.framework
Expand All@@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) {

intro("Setting up Clerk");

setTelemetryStage("detect");
const resolved = options.starter
? await handleStarter(cwd, frameworkOverride, overrides)
: await resolveProjectContext(cwd, frameworkOverride, overrides);
Expand All@@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) {
// stale/broken credential ends up blocked on an interactive browser OAuth
// round-trip it can never complete. So agent mode validates the credential
// (it can fall back to keyless) instead of trusting mere presence.
setTelemetryStage("strategy");
const authed = optsKeyless
? false
: agent
Expand All@@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) {
assertKeylessOnlyFlags(options, strategy);

if (strategy === "authenticate") {
setTelemetryStage("link");
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
Expand All@@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) {
const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm);

if (alreadySetUp) {
setTelemetryStage("already_set_up");
log.success("\nClerk is already set up in this project.");
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
Expand All@@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) {
return;
}

setTelemetryStage("keys");
bar();
await runStrategy(strategy, ctx, {
template: options.template,
Expand All@@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) {

// Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with.
if (options.skills !== false && isNpmFramework(ctx.framework)) {
setTelemetryStage("skills");
bar();
await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm);
}
Expand All@@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

setTelemetryStage("done");
await outro("Done");
}

Expand DownExpand Up@@ -275,11 +290,14 @@ async function bootstrapAndDetect(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides);

const ctx = await gatherContext(bootstrap.projectDir);
if (!ctx) {
throw new CliError("Project generation did not produce a detectable framework.");
throw new CliError("Project generation did not produce a detectable framework.", {
code: ERROR_CODE.FRAMEWORK_UNDETECTED,
});
}
return { ctx, bootstrap };
}
Expand All@@ -289,6 +307,7 @@ async function handleStarter(
frameworkOverride: FrameworkInfo | undefined,
overrides: BootstrapOverrides,
): Promise<ResolvedContext> {
setTelemetryStage("bootstrap");
if (!overrides.skipConfirm) {
await confirmOverwrite(cwd);
}
Expand DownExpand Up@@ -323,6 +342,7 @@ async function resolveProjectContext(
if (!isBlank) {
throw new CliError(
`Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`,
{ code: ERROR_CODE.FRAMEWORK_UNDETECTED },
);
}

Expand DownExpand Up@@ -559,11 +579,13 @@ async function detectAndInstall(
if (ctx.existingClerk) {
log.info(dim(`${ctx.framework.sdk} is already installed`));
} else if (isNpmFramework(ctx.framework)) {
setTelemetryStage("install");
await installSdk(ctx);
}
// Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a
// package manager here — the framework's scaffold plan prints install steps.

setTelemetryStage("scaffold");
return scaffoldAndWrite(cwd, ctx, skipConfirm);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ import {
isValidEnv,
setCurrentEnv,
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";
Expand DownExpand Up@@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
{ code: ERROR_CODE.NO_INTERACTIVE_TERMINAL },
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
Expand All@@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
{ code: ERROR_CODE.INVALID_ENVIRONMENT },
);
}

Expand Down
31 changes: 24 additions & 7 deletions packages/cli-core/src/commands/update/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
import type { Program } from "../../cli-program.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { green, cyan, yellow, dim } from "../../lib/color.ts";
import { CliError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { isCancelled } from "../../lib/signals.ts";
import {
asdfPluginFromPath,
asdfReshim,
Expand DownExpand Up@@ -134,12 +135,18 @@ async function runGlobalInstall(
const stderr = result.stderr.toString();
const hint = globalInstallCommand(installer, packageSpec);
if (stderr.includes("EACCES") || stderr.includes("permission denied")) {
throw new CliError(`Permission denied. Try: sudo ${hint}`);
throw new CliError(`Permission denied. Try: sudo ${hint}`, {
code: ERROR_CODE.UPDATE_PERMISSION_DENIED,
});
}
if (result.exitCode === 127 || stderr.includes("not found")) {
throw new CliError(`${installer} not found on PATH.`);
throw new CliError(`${installer} not found on PATH.`, {
code: ERROR_CODE.INSTALLER_NOT_FOUND,
});
}
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`);
throw new CliError(`Update failed: ${stderr.trim() || "unknown error"}`, {
code: ERROR_CODE.UPDATE_FAILED,
});
}

// Homebrew installs whatever version its tap currently publishes, ignoring
Expand All@@ -154,6 +161,7 @@ async function runGlobalInstall(
`Homebrew tap is stale: installed ${installed}, expected ${targetVersion}. ` +
`Update via another installer (e.g. \`npm install -g ${packageSpec}\`) ` +
`or wait for the tap to catch up.`,
{ code: ERROR_CODE.UPDATE_FAILED },
);
}
}
Expand DownExpand Up@@ -262,9 +270,18 @@ export async function update(options: UpdateOptions): Promise<void> {
if (isHuman()) intro("Checking for updates");

const [latest, installDirs] = await Promise.all([
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => {
throw new CliError("Could not reach npm registry. Check your network connection.");
}),
withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(
(error: unknown) => {
// A registry that answered — badly, or without the requested channel —
// already carries its own code, and a Ctrl-C is the user's decision,
// not the network's. Only transport and timeout failures are genuinely
// "unreachable", and retrying is only right for those.
if (error instanceof CliError || isCancelled(error)) throw error;
throw new CliError("Could not reach npm registry. Check your network connection.", {
code: ERROR_CODE.REGISTRY_UNREACHABLE,
});
},
),
getInstallerPackageDirs(),
]);

Expand Down
Loading