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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Change Log

## 25.1.0

* Added: `list-organizations` and `list-projects` run at the root, alongside `login`
* Added: Hint pointing at `/v1` when an endpoint misses the API path
* Fixed: `list-organizations`, `list-projects`, and `organization get` work on self-hosted installs
* Fixed: `login --endpoint` verifies the endpoint before prompting for credentials
* Fixed: Trailing slashes in an endpoint no longer produce double-slashed request paths
* Fixed: HTML and oversized error pages are summarized instead of printed verbatim
* Fixed: `--report` issue links stay within the length GitHub accepts

## 25.0.0

* Breaking: Removed the `organizations` command group covering billing, plans, invoices, and add-ons
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Once the installation is complete, you can verify the install using

```sh
$ appwrite -v
25.0.0
25.1.0
```

### Install using prebuilt binaries
Expand Down Expand Up @@ -83,7 +83,7 @@ $ scoop install https://raw.githubusercontent.com/appwrite/sdk-for-cli/master/sc
Once the installation completes, you can verify your install using
```
$ appwrite -v
25.0.0
25.1.0
```

## Getting Started
Expand Down
4 changes: 3 additions & 1 deletion cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { locale } from './lib/commands/services/locale.js';
import { messaging } from './lib/commands/services/messaging.js';
import { migrations } from './lib/commands/services/migrations.js';
import { notifications } from './lib/commands/services/notifications.js';
import { oauth2 } from './lib/commands/services/oauth2.js';
import { oauth2, oauth2ListOrganizationsRootCommand, oauth2ListProjectsRootCommand } from './lib/commands/services/oauth2.js';
import { organization } from './lib/commands/services/organization.js';
import { presences } from './lib/commands/services/presences.js';
import { project } from './lib/commands/services/project.js';
Expand Down Expand Up @@ -227,6 +227,8 @@ if (process.argv.includes('-v') || process.argv.includes('--version')) {
.addCommand(migrations)
.addCommand(notifications)
.addCommand(oauth2)
.addCommand(oauth2ListOrganizationsRootCommand)
.addCommand(oauth2ListProjectsRootCommand)
.addCommand(organization)
.addCommand(presences)
.addCommand(project)
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/oauth2/list-organizations.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
```bash
appwrite oauth2 list-organizations
appwrite list-organizations
```
2 changes: 1 addition & 1 deletion docs/examples/oauth2/list-projects.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
```bash
appwrite oauth2 list-projects
appwrite list-projects
```
4 changes: 2 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
# You can use "View source" of this page to see the full script.

# REPO
$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/25.0.0/appwrite-cli-win-x64.exe"
$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/25.0.0/appwrite-cli-win-arm64.exe"
$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/25.1.0/appwrite-cli-win-x64.exe"
$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/25.1.0/appwrite-cli-win-arm64.exe"

$APPWRITE_BINARY_NAME = "appwrite.exe"

Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ verifyMacOSCodeSignature() {
downloadBinary() {
echo "[2/5] Downloading executable for $OS ($ARCH) ..."

GITHUB_LATEST_VERSION="25.0.0"
GITHUB_LATEST_VERSION="25.1.0"
GITHUB_FILE="appwrite-cli-${OS}-${ARCH}"
GITHUB_URL="https://github.com/$GITHUB_REPOSITORY_NAME/releases/download/$GITHUB_LATEST_VERSION/$GITHUB_FILE"

Expand Down
12 changes: 11 additions & 1 deletion lib/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
removeLegacySessionsExcept,
restoreCurrentSession,
deleteServerSession,
verifyEndpoint,
} from "./session.js";
import { setStoredRefreshToken } from "./refresh-token.js";

Expand Down Expand Up @@ -340,7 +341,10 @@ const loginWithOAuthDevice = async ({
}): Promise<void> => {
const clientId = OAUTH2_CLIENT_ID;
const oauth2 = await getOauth2Service(
await sdkForConsole({ requiresAuth: false, endpointOverride: configEndpoint }),
await sdkForConsole({
requiresAuth: false,
endpointOverride: configEndpoint,
}),
);

globalConfig.addSession(id, { endpoint: configEndpoint, clientId });
Expand Down Expand Up @@ -478,6 +482,12 @@ export const loginCommand = async ({

const shouldUseCloudLogin = isCloudLoginEndpoint(configEndpoint);

// Check the endpoint before anything is prompted for, so a wrong endpoint
// fails immediately instead of after the email and password are typed.
if (endpoint && !shouldUseCloudLogin) {
await verifyEndpoint(configEndpoint);
}

if (shouldUseCloudLogin && (email || password || mfa || code)) {
throw new Error(
`Cloud sign-in happens in your browser. Run '${EXECUTABLE_NAME} login' without --email, --password, --mfa or --code — those options are for self-hosted instances.`,
Expand Down
53 changes: 52 additions & 1 deletion lib/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,55 @@ export const createLegacyConsoleClient = (
return legacyClient;
};

/**
* Confirms an endpoint really is an Appwrite API root before anything is stored
* or prompted for. Failures carry the underlying response's code and type so
* error hints (e.g. a missing `/v1`) can still fire.
*/
export const verifyEndpoint = async (
endpoint: string,
selfSigned: boolean = globalConfig.getSelfSigned(),
): Promise<void> => {
let protocol = "";
try {
protocol = new URL(endpoint).protocol;
} catch {
throw new Error(`Invalid endpoint URL: ${endpoint}`);
}

if (protocol !== "http:" && protocol !== "https:") {
throw new Error(`Invalid endpoint URL: ${endpoint}`);
}

let caught: { code?: number; type?: string; response?: unknown } = {};

try {
const response = (await createLegacyConsoleClient(
endpoint,
selfSigned,
).call("GET", "/health/version")) as { version?: string };

if (response.version) {
return;
}
} catch (e) {
caught = e as { code?: number; type?: string; response?: unknown };
}

const failure = new Error(
"Invalid endpoint or your Appwrite server is not running as expected.",
);
Object.assign(
failure,
{ endpoint },
caught.code === undefined ? {} : { code: caught.code },
caught.type === undefined ? {} : { type: caught.type },
caught.response === undefined ? {} : { response: caught.response },
);

throw failure;
};

export const hasAuthSession = (): boolean =>
globalConfig.getAccessToken() !== "" || globalConfig.getCookie() !== "";

Expand Down Expand Up @@ -117,7 +166,9 @@ export const getSignedInAccounts = (): Array<{
*/
export const isLocalOnlySession = (sessionId: string): boolean => {
const session = getSession(sessionId);
return Boolean(session && !hasStoredRefreshToken(sessionId) && !session.cookie);
return Boolean(
session && !hasStoredRefreshToken(sessionId) && !session.cookie,
);
};

/**
Expand Down
69 changes: 55 additions & 14 deletions lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
EXECUTABLE_NAME,
} from "./constants.js";
import { deleteStoredRefreshToken } from "./auth/refresh-token.js";
import { describeHttpFailure } from "./errors.js";

class Client {
private endpoint: string;
Expand Down Expand Up @@ -144,7 +145,8 @@ class Client {
throw new AppwriteException("Invalid endpoint URL: " + endpoint);
}

this.endpoint = endpoint;
// Paths are appended verbatim, so a trailing slash would produce `//account`.
this.endpoint = endpoint.replace(/\/+$/, "");
return this;
}

Expand All @@ -169,6 +171,15 @@ class Client {
return { ...this.headers };
}

/**
* Records the endpoint the failed request used, so error hints can talk about
* the endpoint actually called rather than the one in config.
*/
private tagEndpoint<T extends Error>(exception: T): T {
(exception as T & { endpoint?: string }).endpoint = this.endpoint;
return exception;
}

async call<T = unknown>(
method: string,
path: string = "",
Expand Down Expand Up @@ -227,17 +238,41 @@ class Client {
}),
});
} catch (error) {
throw new AppwriteException((error as Error).message);
throw this.tagEndpoint(new AppwriteException((error as Error).message));
}

if (response.status >= 400) {
const text = await response.text();
let json: { message?: string; code?: number; type?: string } | undefined =
undefined;
try {
json = JSON.parse(text);
const parsed: unknown = JSON.parse(text);
if (parsed && typeof parsed === "object") {
json = parsed as { message?: string; code?: number; type?: string };
}
} catch (_error) {
throw new AppwriteException(text, response.status, "", text);
json = undefined;
}

if (!json) {
// Proxies, load balancers and the console answer with HTML or plain
// text. Summarize it — the raw body stays on the exception for
// `--verbose` and `--report`.
const failure = describeHttpFailure(
response.status,
text,
response.statusText,
response.headers.get("content-type") ?? undefined,
);

throw this.tagEndpoint(
new AppwriteException(
failure.message,
response.status,
failure.type,
text,
),
);
}

if (
Expand All @@ -262,19 +297,25 @@ class Client {
/role:\s*guests/i.test(json.message);

if (isUnauthorized) {
throw new AppwriteException(
`You are not authenticated. Run '${EXECUTABLE_NAME} login' to authenticate and try again.`,
json.code,
json.type,
text,
throw this.tagEndpoint(
new AppwriteException(
`You are not authenticated. Run '${EXECUTABLE_NAME} login' to authenticate and try again.`,
json.code,
json.type,
text,
),
);
}

throw new AppwriteException(
json.message || text,
json.code,
json.type,
text,
throw this.tagEndpoint(
new AppwriteException(
json.message ||
describeHttpFailure(response.status, text, response.statusText)
.message,
json.code ?? response.status,
json.type,
text,
),
);
}

Expand Down
Loading