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
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Three layers, dependencies point downward only (`commands → core → lib`):

- `src/index.ts` — composition root. Folds each command's `register` over yargs via `reduce`, wires shared `deps` (telemetry).
- `src/commands/**` — yargs wiring + presentation only. Register the command, call core, format output, log, set `process.exitCode`, fire the telemetry tracker. No business logic.
- `src/core/**` — orchestration of business logic. Returns `Result`/`Option`; never writes to the console directly (logs only through passed `LogOptions`). **Exception:** interactive commands may drive their own terminal UI from core — e.g. `src/core/project/bootstrap.ts` uses `@clack/prompts` (spinners, `confirm`/`select`, notes) directly because the flow is inherently interactive. Keep non-interactive core free of direct console writes.
- `src/core/**` — orchestration of business logic. Returns `Result`/`Option`; never writes to the console directly (logs only through a passed `Logger`). **Exception:** interactive commands may drive their own terminal UI from core — e.g. `src/core/project/bootstrap.ts` uses the prompts of `src/lib/ui/prompts.ts` (spinners, `confirm`/`select`, notes) directly because the flow is inherently interactive. Keep non-interactive core free of direct console writes.
- `src/lib/**` — reusable primitives: `auth/`, `iapi/`, `mapi/`, `config/`, `telemetry/`, plus `result.ts` and `option.ts`.

Adding a command: export a `register: RegisterCommand` (see `src/commands/login/login.ts`), then add its import to the `register` array in the parent command or `src/index.ts`.
Expand All @@ -31,7 +31,14 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/
- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`.
- `@kontent-ai/core-sdk` — shared HTTP/SDK layer both clients build on.

**Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error.
**Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error.

### Output channels

- **stdout** — the data the command exists to produce, and nothing else. It is never level-gated: `--logLevel none` must still print a payload, because a response body is not a log.
- **stderr** — everything said *about* producing it: progress, warnings, errors, verbose traces. This is the POSIX meaning of stderr (diagnostics, not errors), and how curl, git and npm behave.

Every handler starts with `const logger = createLoggerFromArgs(args)` (`src/log.ts`) and passes that `Logger` down; core takes it as a parameter or inside its `deps` object. `createLoggerFromArgs` is the only place that resolves the `--logLevel`/`--verbose` pair; everything else builds a logger from a single `LogLevel` via `createLogger`. The `sink` parameter is a test seam, not a routing knob — never point a log at stdout.

## Conventions

Expand Down
11 changes: 6 additions & 5 deletions src/commands/login/login.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type LoginOutcome, performLogin } from "../../core/login/login.js";
import { formatAuthError } from "../../lib/auth/formatAuthError.js";
import { isErr } from "../../lib/result.js";
import { logError, logInfo } from "../../log.js";
import { createLoggerFromArgs } from "../../log.js";
import type { RegisterCommand } from "../../types/yargs.js";

export const register: RegisterCommand = (y, deps) =>
Expand All @@ -10,17 +10,18 @@ export const register: RegisterCommand = (y, deps) =>
describe: "Authenticate with Kontent.ai via Auth0 device flow",
builder: (b) => b,
handler: async (args) => {
const tracker = deps.telemetry.startCommandTracking("login", args);
const logger = createLoggerFromArgs(args);
const tracker = deps.telemetry.startCommandTracking("login", logger);

const result = await performLogin(args);
const result = await performLogin(logger);
if (isErr(result)) {
tracker.fail(result.error.kind);
logError(args, formatAuthError(result.error));
logger.error(formatAuthError(result.error));
process.exitCode = 1;
return;
}
tracker.succeed();
logInfo(args, "standard", formatLoginOutcome(result.value));
logger.info("standard", formatLoginOutcome(result.value));
},
});

Expand Down
11 changes: 6 additions & 5 deletions src/commands/logout/logout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { performLogout } from "../../core/logout/logout.js";
import { formatAuthError } from "../../lib/auth/formatAuthError.js";
import { isErr } from "../../lib/result.js";
import { logError, logInfo } from "../../log.js";
import { createLoggerFromArgs } from "../../log.js";
import type { RegisterCommand } from "../../types/yargs.js";

export const register: RegisterCommand = (y, deps) =>
Expand All @@ -10,16 +10,17 @@ export const register: RegisterCommand = (y, deps) =>
describe: "Clear stored authentication tokens",
builder: (b) => b,
handler: async (args) => {
const tracker = deps.telemetry.startCommandTracking("logout", args);
const logger = createLoggerFromArgs(args);
const tracker = deps.telemetry.startCommandTracking("logout", logger);

const result = await performLogout(args);
const result = await performLogout(logger);
if (isErr(result)) {
tracker.fail(result.error.kind);
logError(args, formatAuthError(result.error));
logger.error(formatAuthError(result.error));
process.exitCode = 1;
return;
}
tracker.succeed();
logInfo(args, "standard", "Logged out.");
logger.info("standard", "Logged out.");
},
});
48 changes: 30 additions & 18 deletions src/commands/project/sample/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { intro, note, outro } from "@clack/prompts";
import { match } from "ts-pattern";
import { getAuthenticatedIapiClient } from "../../../core/iapi/authenticatedClient.js";
import {
Expand All @@ -12,7 +11,8 @@ import { formatIapiError } from "../../../lib/iapi/formatIapiError.js";
import { createMapiClient } from "../../../lib/mapi/client.js";
import { isErr } from "../../../lib/result.js";
import type { Telemetry } from "../../../lib/telemetry/tracking.js";
import { logError } from "../../../log.js";
import { intro, note, outro } from "../../../lib/ui/prompts.js";
import { createLoggerFromArgs, type Logger } from "../../../log.js";
import type { RegisterCommand } from "../../../types/yargs.js";

export const register: RegisterCommand = (sub, deps) =>
Expand All @@ -31,27 +31,31 @@ export const register: RegisterCommand = (sub, deps) =>
default: "./karma-nextjs-app",
describe: "Target directory for the cloned app (must be empty or non-existent)",
}),
handler: async (args) => runBootstrap(args, deps.telemetry),
handler: async (args) => runBootstrap(args, createLoggerFromArgs(args), deps.telemetry),
});

const runBootstrap = async (params: BootstrapParams, telemetry: Telemetry): Promise<void> => {
const tracker = telemetry.startCommandTracking("project sample bootstrap", params);
const runBootstrap = async (
params: BootstrapParams,
logger: Logger,
telemetry: Telemetry,
): Promise<void> => {
const tracker = telemetry.startCommandTracking("project sample bootstrap", logger);
intro("Bootstrap a Kontent.ai project");

const clientResult = await getAuthenticatedIapiClient(params);
const clientResult = await getAuthenticatedIapiClient(logger);
if (isErr(clientResult)) {
tracker.fail(`auth:${clientResult.error.kind}`, { project: params.envId });
logError(params, formatAuthError(clientResult.error));
logger.error(formatAuthError(clientResult.error));
process.exitCode = 1;
return;
}
const iapiClient = clientResult.value;
const mapiClient = createMapiClient({ token: iapiClient.token, envId: params.envId });

const result = await performBootstrap(params, { iapiClient, mapiClient });
const result = await performBootstrap(params, { logger, iapiClient, mapiClient });
if (isErr(result)) {
tracker.fail(bootstrapErrorCode(result.error), { project: params.envId });
handleBootstrapError(params, result.error);
handleBootstrapError(params, logger, result.error);
return;
}

Expand All @@ -76,7 +80,11 @@ const bootstrapErrorCode = (error: BootstrapError): string =>
.with({ kind: "create-key-failed" }, (e) => `create-key-failed:${e.sdkError.details.reason}`)
.otherwise((e) => e.kind);

const handleBootstrapError = (params: BootstrapParams, error: BootstrapError): void =>
const handleBootstrapError = (
params: BootstrapParams,
logger: Logger,
error: BootstrapError,
): void =>
match(error)
// soft exits: the user chose to stop or the environment is not eligible
.with({ kind: "aborted" }, (e) => {
Expand All @@ -88,20 +96,24 @@ const handleBootstrapError = (params: BootstrapParams, error: BootstrapError): v
);
})
.otherwise((hardError) => {
logError(params, formatBootstrapError(params, hardError));
logger.error(formatBootstrapError(params, logger, hardError));
process.exitCode = 1;
});

const formatBootstrapError = (
params: BootstrapParams,
logger: Logger,
error: Exclude<BootstrapError, { kind: "aborted" } | { kind: "unsupported-sample" }>,
): string =>
match(error)
): string => {
const context = { envId: params.envId, isVerbose: logger.isVerbose };

return match(error)
.with({ kind: "target-not-usable" }, (e) => e.message)
.with({ kind: "clone-failed" }, (e) => e.message)
.with({ kind: "project-info-failed" }, (e) => formatIapiError(e.sdkError, params))
.with({ kind: "properties-failed" }, (e) => formatIapiError(e.sdkError, params))
.with({ kind: "list-keys-failed" }, (e) => formatIapiError(e.sdkError, params))
.with({ kind: "key-detail-failed" }, (e) => formatIapiError(e.sdkError, params))
.with({ kind: "create-key-failed" }, (e) => formatIapiError(e.sdkError, params))
.with({ kind: "project-info-failed" }, (e) => formatIapiError(e.sdkError, context))
.with({ kind: "properties-failed" }, (e) => formatIapiError(e.sdkError, context))
.with({ kind: "list-keys-failed" }, (e) => formatIapiError(e.sdkError, context))
.with({ kind: "key-detail-failed" }, (e) => formatIapiError(e.sdkError, context))
.with({ kind: "create-key-failed" }, (e) => formatIapiError(e.sdkError, context))
.exhaustive();
};
3 changes: 2 additions & 1 deletion src/commands/telemetry/disable.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { setTelemetryStatus } from "../../core/telemetry/settings.js";
import { createLoggerFromArgs } from "../../log.js";
import type { RegisterCommand } from "../../types/yargs.js";

export const register: RegisterCommand = (sub) =>
sub.command({
command: "disable",
describe: "Disable anonymous usage telemetry",
builder: (b) => b,
handler: async (args) => setTelemetryStatus(args, false),
handler: async (args) => setTelemetryStatus(createLoggerFromArgs(args), false),
});
3 changes: 2 additions & 1 deletion src/commands/telemetry/enable.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { setTelemetryStatus } from "../../core/telemetry/settings.js";
import { createLoggerFromArgs } from "../../log.js";
import type { RegisterCommand } from "../../types/yargs.js";

export const register: RegisterCommand = (sub) =>
sub.command({
command: "enable",
describe: "Enable anonymous usage telemetry",
builder: (b) => b,
handler: async (args) => setTelemetryStatus(args, true),
handler: async (args) => setTelemetryStatus(createLoggerFromArgs(args), true),
});
3 changes: 2 additions & 1 deletion src/commands/telemetry/status.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { showTelemetryStatus } from "../../core/telemetry/settings.js";
import { createLoggerFromArgs } from "../../log.js";
import type { RegisterCommand } from "../../types/yargs.js";

export const register: RegisterCommand = (sub) =>
sub.command({
command: "status",
describe: "Show whether telemetry is enabled and why",
builder: (b) => b,
handler: async (args) => showTelemetryStatus(args),
handler: async (args) => showTelemetryStatus(createLoggerFromArgs(args)),
});
6 changes: 3 additions & 3 deletions src/core/iapi/authenticatedClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import { getValidAccessToken } from "../../lib/auth/tokenAccess.js";
import type { AuthError } from "../../lib/auth/types.js";
import { createIapiClient, type IapiClient } from "../../lib/iapi/client.js";
import { isErr, ok, type Result } from "../../lib/result.js";
import type { LogOptions } from "../../log.js";
import type { Logger } from "../../log.js";
import { ensureUserIdCached } from "../user/user.js";

export const getAuthenticatedIapiClient = async (
params: LogOptions,
logger: Logger,
): Promise<Result<IapiClient, AuthError>> => {
const tokenResult = await getValidAccessToken();
if (isErr(tokenResult)) {
return tokenResult;
}
const client = createIapiClient({ token: tokenResult.value });
await ensureUserIdCached(params, { client });
await ensureUserIdCached(logger, { client });
return ok(client);
};
53 changes: 22 additions & 31 deletions src/core/login/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,21 @@ import type { AuthError, TokenSet } from "../../lib/auth/types.js";
import { errorMessage } from "../../lib/error.js";
import { createIapiClient } from "../../lib/iapi/client.js";
import { err, isErr, isOk, ok, type Result } from "../../lib/result.js";
import { type LogOptions, logInfo, logWarning } from "../../log.js";
import type { Logger } from "../../log.js";
import { ensureUserIdCached } from "../user/user.js";

export type LoginParams = LogOptions;

export type LoginOutcome = Readonly<{
isAlreadyAuthenticated: boolean;
identifier: string | null;
}>;

export const performLogin = async (
params: LoginParams,
): Promise<Result<LoginOutcome, AuthError>> => {
export const performLogin = async (logger: Logger): Promise<Result<LoginOutcome, AuthError>> => {
const config = getAuth0Config();
const storage = createKeyringStorage();

const stored = await storage.read();
if (isErr(stored)) {
logWarning(params, "verbose", formatAuthError(stored.error));
logger.warning("standard", formatAuthError(stored.error));
}
const storedTokens = isOk(stored) ? stored.value : null;

Expand All @@ -37,7 +33,7 @@ export const performLogin = async (
return match(decision)
.with({ type: "use-existing-token" }, async () => {
if (storedTokens !== null) {
await ensureUserIdCached(params, {
await ensureUserIdCached(logger, {
client: createIapiClient({ token: storedTokens.accessToken }),
});
}
Expand All @@ -51,35 +47,35 @@ export const performLogin = async (
// at the same place, so surface the error and keep the stored session.
return err(refreshed.error);
}
logInfo(params, "standard", "Saved session expired, starting a new sign-in.");
logWarning(params, "verbose", formatAuthError(refreshed.error));
return await runDeviceFlow(params, storage, config);
logger.info("standard", "Saved session expired, starting a new sign-in.");
logger.warning("verbose", formatAuthError(refreshed.error));
return await runDeviceFlow(logger, storage, config);
}
await persistTokens(params, storage, refreshed.value);
await ensureUserIdCached(params, {
await persistTokens(logger, storage, refreshed.value);
await ensureUserIdCached(logger, {
client: createIapiClient({ token: refreshed.value.accessToken }),
});
return ok({
isAlreadyAuthenticated: false,
identifier: identifierFromTokens(refreshed.value),
});
})
.with({ type: "login" }, async () => runDeviceFlow(params, storage, config))
.with({ type: "login" }, async () => runDeviceFlow(logger, storage, config))
.exhaustive();
};

const runDeviceFlow = async (
params: LoginParams,
logger: Logger,
storage: TokenStorage,
config: Auth0Config,
): Promise<Result<LoginOutcome, AuthError>> => {
const result = await loginViaDeviceFlow(config, deviceFlowDeps(params));
const result = await loginViaDeviceFlow(config, deviceFlowDeps(logger));
if (isErr(result)) {
return err(result.error);
}
await persistTokens(params, storage, result.value);
await persistTokens(logger, storage, result.value);
// Fresh login may be a different account, so overwrite the cached userId.
await ensureUserIdCached(params, {
await ensureUserIdCached(logger, {
client: createIapiClient({ token: result.value.accessToken }),
shouldForceRefresh: true,
});
Expand All @@ -90,38 +86,37 @@ const runDeviceFlow = async (
};

const persistTokens = async (
params: LogOptions,
logger: Logger,
storage: TokenStorage,
tokens: TokenSet,
): Promise<void> => {
const written = await storage.write(tokens);
if (isErr(written)) {
logWarning(params, "standard", formatAuthError(written.error));
logger.warning("standard", formatAuthError(written.error));
}
};

const identifierFromTokens = (tokens: TokenSet | null): string | null => tokens?.identifier ?? null;

const deviceFlowDeps = (params: LogOptions): DeviceFlowDeps => ({
const deviceFlowDeps = (logger: Logger): DeviceFlowDeps => ({
onUserCode: async ({ userCode, expiresInSeconds, verificationUriComplete }, done) => {
logInfo(
params,
logger.info(
"standard",
`To sign in, open:\n ${verificationUriComplete}\n` +
`Code: ${userCode} (expires in ${formatExpiry(expiresInSeconds)}).\n` +
"Press Enter to open the browser.",
);

if (!process.stdin.isTTY) {
await tryOpen(params, verificationUriComplete);
await tryOpen(logger, verificationUriComplete);
return;
}

try {
// Re-open the browser on each Enter; once() rejects when `done` aborts (polling settled).
while (!done.aborted) {
await once(process.stdin, "data", { signal: done });
await tryOpen(params, verificationUriComplete);
await tryOpen(logger, verificationUriComplete);
}
} catch {
// `done` aborted (auth done, denied, or expired) — stop re-opening.
Expand All @@ -135,14 +130,10 @@ const deviceFlowDeps = (params: LogOptions): DeviceFlowDeps => ({
const formatExpiry = (seconds: number): string =>
seconds % 60 === 0 ? `${seconds / 60} minutes` : `${seconds} seconds`;

const tryOpen = async (params: LogOptions, url: string): Promise<void> => {
const tryOpen = async (logger: Logger, url: string): Promise<void> => {
try {
await open(url);
} catch (cause) {
logWarning(
params,
"verbose",
`Could not open the browser automatically: ${errorMessage(cause)}`,
);
logger.warning("standard", `Could not open the browser automatically: ${errorMessage(cause)}`);
}
};
Loading