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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"ora": "^9.3.0"
},
"devDependencies": {
"@canup/types": "^0.2.0",
"@canup/contracts": "^0.2.0",
"@types/node": "^22.13.4",
"type-fest": "^5.5.0"
}
Expand Down
32 changes: 16 additions & 16 deletions packages/cli/src/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, test as baseTest, expect, vi } from 'vitest';
import { CanupClient } from './api-client.js';
import { ApiError } from './errors.js';
import { CanupError } from '@canup/contracts';

// ──────────────────────────────────────────────
// Fetch mock setup
Expand Down Expand Up @@ -154,16 +154,16 @@ describe('CanupClient', () => {
// ──── Error handling (via request()) ────

describe('error handling via request()', () => {
test('throws an ApiError with statusCode and code on API error envelope', async () => {
test('throws a CanupError with httpStatus and code on API error envelope', async () => {
mockFetch.mockResolvedValueOnce(errorResponse('NotFoundError', 'App not found', 404));
const client = createClient();

const err = await client.getMe().catch((e: unknown) => e);

expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).message).toBe('App not found');
expect((err as ApiError).statusCode).toBe(404);
expect((err as ApiError).code).toBe('NotFoundError');
expect(err).toBeInstanceOf(CanupError);
expect((err as CanupError).message).toBe('App not found');
expect((err as CanupError).httpStatus).toBe(404);
expect((err as CanupError).code).toBe('NotFoundError');
});

test('error message matches the API error message', async () => {
Expand Down Expand Up @@ -473,7 +473,7 @@ describe('CanupClient', () => {
expect(result).toHaveProperty('ok', false);
});

test('throws an ApiError on HTTP error with statusCode and code', async () => {
test('throws a CanupError on HTTP error with httpStatus and code', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
Expand All @@ -484,13 +484,13 @@ describe('CanupClient', () => {

const err = await client.testCode('a1', 'code', 'nodejs', {}).catch((e: unknown) => e);

expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).message).toBe('Invalid token');
expect((err as ApiError).statusCode).toBe(401);
expect((err as ApiError).code).toBe('AuthError');
expect(err).toBeInstanceOf(CanupError);
expect((err as CanupError).message).toBe('Invalid token');
expect((err as CanupError).httpStatus).toBe(401);
expect((err as CanupError).code).toBe('AuthError');
});

test('falls back to statusText and HttpError when response is not JSON', async () => {
test('falls back to statusText and TRANSPORT_ERROR when response is not JSON', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
Expand All @@ -501,10 +501,10 @@ describe('CanupClient', () => {

const err = await client.testCode('a1', 'code', 'nodejs', {}).catch((e: unknown) => e);

expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).message).toBe('Internal Server Error');
expect((err as ApiError).statusCode).toBe(500);
expect((err as ApiError).code).toBe('HttpError');
expect(err).toBeInstanceOf(CanupError);
expect((err as CanupError).message).toBe('Internal Server Error');
expect((err as CanupError).httpStatus).toBe(500);
expect((err as CanupError).code).toBe('TRANSPORT_ERROR');
});

test('sends Authorization header when token is set', async () => {
Expand Down
21 changes: 7 additions & 14 deletions packages/cli/src/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* HTTP client for the CanUp API.
*
* Uses native fetch (Node.js 18+). Wire shapes come from `@canup/types`, the
* Uses native fetch (Node.js 18+). Wire shapes come from `@canup/contracts`, the
* published contract every endpoint serializes — the client casts responses to
* those types rather than re-declaring them. All app-scoped methods take an
* appId and build app-scoped URLs. The API returns camelCase natively, so there
Expand All @@ -11,7 +11,6 @@
import type {
Action,
ApiKeyCreateResult,
ApiResponse,
App,
AppListItem,
Build,
Expand All @@ -35,10 +34,10 @@ import type {
StripeStatusResult,
TestResult,
User,
} from '@canup/types';
} from '@canup/contracts';
import { DEFAULT_API_URL, API_VERSION } from './constants.js';
import { CLI_USER_AGENT } from './version.js';
import { ApiError } from './errors.js';
import { CanupError, unwrapResponse } from '@canup/contracts';

export class CanupClient {
private apiUrl: string;
Expand Down Expand Up @@ -205,10 +204,10 @@ export class CanupClient {
} catch {
// non-JSON error response
}
throw new ApiError(
res.status,
body?.error?.code ?? 'HttpError',
throw new CanupError(
body?.error?.code ?? 'TRANSPORT_ERROR',
body?.error?.message ?? res.statusText,
res.status,
);
}

Expand Down Expand Up @@ -414,12 +413,6 @@ export class CanupClient {
signal: options?.signal,
});

const body = (await res.json()) as ApiResponse<T>;

if (!body.ok) {
throw new ApiError(res.status, body.error.code, body.error.message);
}

return body.data;
return unwrapResponse<T>(res);
}
}
2 changes: 1 addition & 1 deletion packages/cli/src/commands/actions/_shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import type { TestResult } from '@canup/types';
import type { TestResult } from '@canup/contracts';
import { success, error, dim } from '../../ui/output.js';
import { formatDuration } from '../../lib/format.js';

Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/actions/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ describe('actions delete command', () => {
});

test('handles 404 error when action not found', async ({ client, output, processMocks }) => {
const apiError = new Error('Not found') as Error & { statusCode: number };
apiError.statusCode = 404;
const apiError = new Error('Not found') as Error & { httpStatus: number };
apiError.httpStatus = 404;
client.deleteAction.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand All @@ -50,8 +50,8 @@ describe('actions delete command', () => {
});

test('handles generic error', async ({ client, output, processMocks }) => {
const apiError = new Error('Internal server error') as Error & { statusCode: number };
apiError.statusCode = 500;
const apiError = new Error('Internal server error') as Error & { httpStatus: number };
apiError.httpStatus = 500;
client.deleteAction.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/actions/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export function registerActionsDeleteAction(actionsCommand: Command): void {
await client.deleteAction(config.appId, name);
success(`Deleted ${name}`);
} catch (err) {
const e = err as Error & { statusCode?: number };
if (e.statusCode === 404) {
const e = err as Error & { httpStatus?: number };
if (e.httpStatus === 404) {
error(`Action not found: ${name}`);
hint('Run `canup actions list` to see available actions.');
} else {
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/actions/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,9 @@ describe('actions deploy command', () => {
projectConfig.getActionsDir.mockReturnValue('/project/canup/actions');

const apiError = new Error('Conflict: action already deploying') as Error & {
statusCode: number;
httpStatus: number;
};
apiError.statusCode = 409;
apiError.httpStatus = 409;
client.listActions.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand All @@ -342,8 +342,8 @@ describe('actions deploy command', () => {
test('handles 401 error with re-auth hint', async ({ client, output, processMocks }) => {
projectConfig.getActionsDir.mockReturnValue('/project/canup/actions');

const apiError = new Error('Unauthorized') as Error & { statusCode: number };
apiError.statusCode = 401;
const apiError = new Error('Unauthorized') as Error & { httpStatus: number };
apiError.httpStatus = 401;
client.listActions.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/actions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ export function registerActionsDeployAction(actionsCommand: Command): void {
await deployAll(actionsDir, config.appId, client, remoteActions);
}
} catch (err) {
const e = err as Error & { statusCode?: number };
const e = err as Error & { httpStatus?: number };
error(e.message);
if (e.statusCode === 401) {
if (e.httpStatus === 401) {
hint('Run `canup init` to re-authenticate.');
}
process.exit(1);
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/actions/invocations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ describe('actions invocations command', () => {
});

test('handles 404 error on detail view', async ({ client, output, processMocks }) => {
const apiError = new Error('Not found') as Error & { statusCode: number };
apiError.statusCode = 404;
const apiError = new Error('Not found') as Error & { httpStatus: number };
apiError.httpStatus = 404;
client.getInvocationDetail.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand All @@ -195,8 +195,8 @@ describe('actions invocations command', () => {
});

test('handles 401 auth error', async ({ client, output, processMocks }) => {
const apiError = new Error('Unauthorized') as Error & { statusCode: number };
apiError.statusCode = 401;
const apiError = new Error('Unauthorized') as Error & { httpStatus: number };
apiError.httpStatus = 401;
client.listInvocations.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/actions/invocations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ export function registerActionsInvocationsAction(actionsCommand: Command): void
await showInvocationsList(client, config.appId, slug, limit, options.search);
}
} catch (err) {
const e = err as Error & { statusCode?: number };
if (e.statusCode === 401) {
const e = err as Error & { httpStatus?: number };
if (e.httpStatus === 401) {
error('Not authenticated.');
hint('Run `canup init` to re-authenticate.');
process.exit(1);
}
if (e.statusCode === 404) {
if (e.httpStatus === 404) {
error('Execution not found.');
hint('Run `canup actions invocations` to see recent executions.');
process.exit(1);
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/actions/remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ describe('actions remove command', () => {
});

test('handles 404 error with specific message', async ({ client, output, processMocks }) => {
const apiError = new Error('Not found') as Error & { statusCode: number };
apiError.statusCode = 404;
const apiError = new Error('Not found') as Error & { httpStatus: number };
apiError.httpStatus = 404;
client.deleteAction.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand All @@ -51,8 +51,8 @@ describe('actions remove command', () => {
});

test('handles generic error', async ({ client, output, processMocks }) => {
const apiError = new Error('Internal server error') as Error & { statusCode: number };
apiError.statusCode = 500;
const apiError = new Error('Internal server error') as Error & { httpStatus: number };
apiError.httpStatus = 500;
client.deleteAction.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/actions/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export function registerActionsRemoveAction(actionsCommand: Command): void {
await client.deleteAction(config.appId, name);
success(`Removed ${name}`);
} catch (err) {
const e = err as Error & { statusCode?: number };
if (e.statusCode === 404) {
const e = err as Error & { httpStatus?: number };
if (e.httpStatus === 404) {
error(`Action not found: ${name}`);
hint('Run `canup actions list` to see deployed actions.');
} else {
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/actions/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,8 @@ describe('actions run command', () => {
output,
processMocks,
}) => {
const apiError = new Error('Unauthorized') as Error & { statusCode: number };
apiError.statusCode = 401;
const apiError = new Error('Unauthorized') as Error & { httpStatus: number };
apiError.httpStatus = 401;
client.listActionsWithScript.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand All @@ -377,8 +377,8 @@ describe('actions run command', () => {
output,
processMocks,
}) => {
const apiError = new Error('Not found') as Error & { statusCode: number };
apiError.statusCode = 404;
const apiError = new Error('Not found') as Error & { httpStatus: number };
apiError.httpStatus = 404;
client.listActionsWithScript.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/actions/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ export function registerActionsRunAction(actionsCommand: Command): void {

displayTestResult(result, { success: 'Run succeeded', failure: 'Run failed' });
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode;
const httpStatus = (err as { httpStatus?: number }).httpStatus;
const message = err instanceof Error ? err.message : String(err);

if (statusCode === 401) {
if (httpStatus === 401) {
error('Not authenticated.');
hint('Run `canup login` to re-authenticate.');
process.exit(1);
}

if (statusCode === 404) {
if (httpStatus === 404) {
error(`Action not found: ${name}`);
hint('Deploy the action first with `canup actions deploy`.');
process.exit(1);
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/actions/test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,8 @@ describe('actions test command', () => {
}) => {
mockExistsSync.mockReturnValue(true);

const apiError = new Error('Not found') as Error & { statusCode: number };
apiError.statusCode = 404;
const apiError = new Error('Not found') as Error & { httpStatus: number };
apiError.httpStatus = 404;
client.testCode.mockRejectedValue(apiError);

mockReadFileSync.mockReturnValue('code');
Expand All @@ -425,8 +425,8 @@ describe('actions test command', () => {
test('handles 401 authentication error', async ({ client, output, processMocks }) => {
mockExistsSync.mockReturnValue(true);

const apiError = new Error('Unauthorized') as Error & { statusCode: number };
apiError.statusCode = 401;
const apiError = new Error('Unauthorized') as Error & { httpStatus: number };
apiError.httpStatus = 401;
client.testCode.mockRejectedValue(apiError);

mockReadFileSync.mockReturnValue('code');
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/actions/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,16 +357,16 @@ export function registerActionsTestAction(actionsCommand: Command): void {
await runLocalTest(actionPath, language, params, context);
}
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode;
const httpStatus = (err as { httpStatus?: number }).httpStatus;
const message = err instanceof Error ? err.message : String(err);

if (statusCode === 401) {
if (httpStatus === 401) {
error('Not authenticated.');
hint('Run `canup login` to re-authenticate.');
process.exit(1);
}

if (statusCode === 404) {
if (httpStatus === 404) {
error('App not found on server.');
hint('Check your project config or use local mode (without --remote).');
process.exit(1);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/deps/_shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Language, PackageSpec } from '@canup/types';
import type { Language, PackageSpec } from '@canup/contracts';
import { type CanupClient } from '../../api-client.js';
import { error } from '../../ui/output.js';
import { createSpinner } from '../../ui/spinner.js';
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/deps/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ describe('deps add command', () => {
});

test('handles API error with 401 status', async ({ client, output, processMocks }) => {
const apiError = new Error('Unauthorized') as Error & { statusCode: number };
apiError.statusCode = 401;
const apiError = new Error('Unauthorized') as Error & { httpStatus: number };
apiError.httpStatus = 401;
client.addDeps.mockRejectedValue(apiError);

const { Command } = await import('commander');
Expand Down
Loading
Loading