diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe9a2a56..b7562347 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,13 @@ jobs: build-validation: name: Build Validation runs-on: ubuntu-latest + env: + # Keep CI aligned with the release workflow. Bun 1.3.12 has a sig_size + # calculation bug in macho.zig that truncates the LC_CODE_SIGNATURE blob + # on cross-compiled Darwin binaries (oven-sh/bun#29120). macOS kills the + # resulting unsigned binaries on launch. Unpin once a Bun release includes + # the upstream fix (oven-sh/bun#29122). + CLI_BUN_VERSION: "1.3.11" steps: - name: Checkout repository @@ -28,7 +35,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: "latest" + bun-version: ${{ env.CLI_BUN_VERSION }} - name: Install Node dependencies run: npm ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9d694ec9..0c2fb849 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,13 +11,19 @@ permissions: jobs: build-and-publish: runs-on: ubuntu-latest + env: + # Bun 1.3.12 has a sig_size calculation bug in macho.zig that truncates the + # LC_CODE_SIGNATURE blob on cross-compiled Darwin binaries, so macOS kills + # them on launch (oven-sh/bun#29120). Unpin once a Bun release includes the + # upstream fix (oven-sh/bun#29122). + CLI_BUN_VERSION: '1.3.11' steps: - uses: actions/checkout@v4 with: token: ${{ secrets.GH_TOKEN }} - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: ${{ env.CLI_BUN_VERSION }} - name: Setup binfmt with QEMU run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b1936b..a3593755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## 18.2.0 + +* Added source code and entrypoint validation before local function execution +* Added macOS code signature verification for standalone binary installs +* Added `webhooks` to the list of supported resource types +* Updated Open Runtimes version from v4 to v5 +* Updated runtime start commands from `sh` to `bash` for improved compatibility +* Updated project init flow with contextual next steps and improved prompts +* Fixed Docker process error handling to report exit codes and signals +* Fixed function container startup to detect early exits before port open +* Fixed stderr output in Docker start to write to stderr instead of stdout + ## 18.1.0 * Added site screenshot terminal preview after `push site` deployments diff --git a/Formula/appwrite.rb b/Formula/appwrite.rb index 5f82b0ef..d7a567b8 100644 --- a/Formula/appwrite.rb +++ b/Formula/appwrite.rb @@ -2,7 +2,7 @@ class Appwrite < Formula desc "Command-line tool for interacting with the Appwrite API" homepage "https://appwrite.io" license "BSD-3-Clause" - version "18.1.0" + version "18.2.0" def self.binary_arch Hardware::CPU.arm? ? "arm64" : "x64" diff --git a/README.md b/README.md index 4b6c9b6a..aa6accfa 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Once the installation is complete, you can verify the install using ```sh $ appwrite -v -18.1.0 +18.2.0 ``` ### Install using prebuilt binaries @@ -62,7 +62,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 -18.1.0 +18.2.0 ``` ## Getting Started diff --git a/install.ps1 b/install.ps1 index 9d259143..bbe34ed7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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/18.1.0/appwrite-cli-win-x64.exe" -$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/18.1.0/appwrite-cli-win-arm64.exe" +$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/18.2.0/appwrite-cli-win-x64.exe" +$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/18.2.0/appwrite-cli-win-arm64.exe" $APPWRITE_BINARY_NAME = "appwrite.exe" diff --git a/install.sh b/install.sh index a8b86006..9f60c230 100644 --- a/install.sh +++ b/install.sh @@ -93,10 +93,29 @@ printSuccess() { printf "${GREEN}✅ Done ... ${NC}\n\n" } +verifyMacOSCodeSignature() { + if [ "$OS" != "darwin" ]; then + return + fi + + if ! command -v codesign >/dev/null 2>&1; then + return + fi + + printf "${GREEN}🔏 Verifying macOS code signature ${NC}\n" + if ! codesign -dv $APPWRITE_TEMP_NAME >/dev/null 2>&1; then + printf "${RED}❌ Downloaded macOS binary is missing an embedded code signature. macOS will kill it on launch. ${NC}\n" + printf "${RED}❌ Please retry once the release artifact is refreshed, or use Homebrew/npm as a temporary workaround. ${NC}\n" + rm -f $APPWRITE_TEMP_NAME + exit 1 + fi + printSuccess +} + downloadBinary() { echo "[2/4] Downloading executable for $OS ($ARCH) ..." - GITHUB_LATEST_VERSION="18.1.0" + GITHUB_LATEST_VERSION="18.2.0" GITHUB_FILE="appwrite-cli-${OS}-${ARCH}" GITHUB_URL="https://github.com/$GITHUB_REPOSITORY_NAME/releases/download/$GITHUB_LATEST_VERSION/$GITHUB_FILE" @@ -121,6 +140,8 @@ install() { fi printSuccess + verifyMacOSCodeSignature + printf "${GREEN}📝 Copying temporary file to $APPWRITE_EXECUTABLE_FILEPATH ... ${NC}\n" runAsRoot cp $APPWRITE_TEMP_NAME $APPWRITE_EXECUTABLE_FILEPATH if [ $? -ne 0 ]; then @@ -153,4 +174,4 @@ greeting getSystemInfo downloadBinary install -installCompleted \ No newline at end of file +installCompleted diff --git a/lib/commands/init.ts b/lib/commands/init.ts index 3b1fdf04..1e68f641 100644 --- a/lib/commands/init.ts +++ b/lib/commands/init.ts @@ -3,6 +3,7 @@ import path from "path"; import childProcess from "child_process"; import { Command } from "commander"; import inquirer from "inquirer"; +import chalk from "chalk"; import { getProjectsService, getSitesService } from "../services.js"; import { pullResources } from "./pull.js"; import ID from "../id.js"; @@ -88,6 +89,164 @@ interface SiteTemplateDetails { variables?: SiteTemplateVariable[]; } +interface InitProjectNextStep { + command: string; + description: string; +} + +interface InitProjectStepOptions { + start: "new" | "existing"; + autoPulled: boolean; +} + +const extractSelectionId = (value: string): string => { + const match = value.match(/\(([^()]+)\)$/); + return match ? match[1] : value; +}; + +const getExistingProjectSummary = async ( + projectId: string, +): Promise => { + const projectsService = await getProjectsService(); + const project = await projectsService.get(extractSelectionId(projectId)); + + return { + $id: project.$id, + name: project.name, + region: project.region || "", + }; +}; + +const printInitProjectSuccess = (message: string): void => { + console.log(`${chalk.green.bold("✓")} ${chalk.green(message)}`); +}; + +const printInitProjectNextSteps = (steps: InitProjectNextStep[]): void => { + if (steps.length === 0) { + return; + } + + const longestCommand = steps.reduce( + (longest, step) => Math.max(longest, step.command.length), + 0, + ); + + console.log(""); + console.log(" Next steps:"); + + for (const step of steps) { + const spacing = " ".repeat(longestCommand - step.command.length + 4); + console.log( + ` ${chalk.cyan(step.command)}${spacing}${step.description}`, + ); + } +}; + +const getLocalInitProjectResourceState = () => { + const functions = localConfig.getFunctions(); + const sites = localConfig.getSites(); + const collections = localConfig.getCollections(); + const tables = localConfig.getTables(); + const buckets = localConfig.getBuckets(); + const teams = localConfig.getTeams(); + const webhooks = localConfig.getWebhooks(); + const topics = localConfig.getMessagingTopics(); + + return { + hasFunctions: functions.length > 0, + hasResources: + functions.length > 0 || + sites.length > 0 || + collections.length > 0 || + tables.length > 0 || + buckets.length > 0 || + teams.length > 0 || + webhooks.length > 0 || + topics.length > 0, + }; +}; + +const getInitProjectNextSteps = ({ + start, + autoPulled, +}: InitProjectStepOptions): InitProjectNextStep[] => { + const { hasFunctions, hasResources } = getLocalInitProjectResourceState(); + const nextSteps: InitProjectNextStep[] = []; + + if (start === "existing" && !autoPulled) { + nextSteps.push( + { + command: `${EXECUTABLE_NAME} pull`, + description: "Choose a resource to sync", + }, + { + command: `${EXECUTABLE_NAME} init`, + description: "Create a new resource", + }, + ); + + return nextSteps; + } + + if (start === "new") { + nextSteps.push({ + command: `${EXECUTABLE_NAME} init`, + description: "Create your first resource", + }); + + return nextSteps; + } + + nextSteps.push({ + command: `${EXECUTABLE_NAME} init`, + description: "Create another resource", + }); + + if (hasFunctions) { + nextSteps.push({ + command: `${EXECUTABLE_NAME} run function`, + description: "Run a pulled function locally", + }); + } + + if (hasResources) { + nextSteps.push({ + command: `${EXECUTABLE_NAME} push`, + description: "Deploy your local edits", + }); + } + + return nextSteps; +}; + +const installInitProjectSkills = async (): Promise => { + if (hasSkillsInstalled(localConfig.configDirectoryPath)) { + log("Agent skills already found. Skipping installation."); + return; + } + + try { + const skillsCwd = localConfig.configDirectoryPath; + const { skills, tempDir } = fetchAvailableSkills(); + try { + const detected = detectProjectSkills(skillsCwd, skills); + if (detected.length > 0) { + const names = detected.map((s) => s.dirName); + placeSkills(skillsCwd, tempDir, names, [".agents", ".claude"], true); + printInitProjectSuccess( + `Installed ${names.length} agent skill${names.length === 1 ? "" : "s"}: ${detected.map((s) => s.name).join(", ")}`, + ); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + error(`Failed to install agent skills: ${msg}`); + hint(`You can install them later with '${EXECUTABLE_NAME} init skill'.`); + } +}; + const initResources = async (): Promise => { const actions: Record = { function: initFunction, @@ -144,6 +303,9 @@ const initProject = async ({ log("No changes made. Existing project configuration was kept."); return; } + if (typeof answers.organization === "string") { + answers.organization = extractSelectionId(answers.organization); + } } else { const selectedOrganization = organizationId ?? @@ -153,17 +315,16 @@ const initProject = async ({ const selectedProjectId = projectId ?? (await inquirer.prompt([questionsInitProject[4]])).id; + const normalizedOrganization = extractSelectionId(selectedOrganization); + answers = { start: "existing", project: selectedProjectId, - organization: selectedOrganization, + organization: normalizedOrganization, }; try { - const projectsService = await getProjectsService(); - const existingProject: ExistingProjectSummary = - await projectsService.get(selectedProjectId); - answers.project = existingProject; + answers.project = await getExistingProjectSummary(selectedProjectId); } catch (e) { if (e instanceof AppwriteException && e.code === 404) { answers = { @@ -178,6 +339,10 @@ const initProject = async ({ } } + if (answers.start === "existing" && typeof answers.project === "string") { + answers.project = await getExistingProjectSummary(answers.project); + } + localConfig.clear(); // Clear the config to avoid any conflicts const url = new URL(DEFAULT_ENDPOINT); @@ -208,7 +373,7 @@ const initProject = async ({ answers.region, ); - localConfig.setProject(response["$id"]); + localConfig.setProject(response["$id"], response.name ?? ""); if (answers.region) { localConfig.setEndpoint( `https://${answers.region}.${url.host}${url.pathname}`, @@ -229,7 +394,7 @@ const initProject = async ({ break; } - localConfig.setProject(selectedProject.$id); + localConfig.setProject(selectedProject.$id, selectedProject.name ?? ""); if (isCloud() && selectedProject.region) { localConfig.setEndpoint( @@ -238,54 +403,35 @@ const initProject = async ({ } } - success( - `Project successfully ${answers.start === "existing" ? "linked" : "created"}. Details are now stored in appwrite.config.json file.`, - ); - + let autoPulled = false; if (answers.start === "existing") { const autopullAnswers: InitProjectAutopullAnswer = await inquirer.prompt( questionsInitProjectAutopull, ); + console.log(""); + printInitProjectSuccess("Project linked → appwrite.config.json"); + await installInitProjectSkills(); if (autopullAnswers.autopull) { + console.log(""); + autoPulled = true; cliConfig.all = true; cliConfig.force = true; await pullResources({ skipDeprecated: true, }); - } else { - log( - `You can run '${EXECUTABLE_NAME} pull all' to synchronize all of your existing resources.`, - ); } + } else { + console.log(""); + printInitProjectSuccess("Project created → appwrite.config.json"); + await installInitProjectSkills(); } - hint( - `Next you can use '${EXECUTABLE_NAME} init' to create resources in your project, or use '${EXECUTABLE_NAME} pull' and '${EXECUTABLE_NAME} push' to synchronize your project.`, - ); + const nextSteps = getInitProjectNextSteps({ + start: answers.start, + autoPulled, + }); - if (!hasSkillsInstalled(localConfig.configDirectoryPath)) { - try { - const skillsCwd = localConfig.configDirectoryPath; - log("Setting up Appwrite agent skills ..."); - const { skills, tempDir } = fetchAvailableSkills(); - try { - const detected = detectProjectSkills(skillsCwd, skills); - if (detected.length > 0) { - const names = detected.map((s) => s.dirName); - placeSkills(skillsCwd, tempDir, names, [".agents", ".claude"], true); - success( - `Installed ${names.length} agent skill${names.length === 1 ? "" : "s"} based on your project: ${detected.map((s) => s.name).join(", ")}`, - ); - } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - error(`Failed to install agent skills: ${msg}`); - hint(`You can install them later with '${EXECUTABLE_NAME} init skill'.`); - } - } + printInitProjectNextSteps(nextSteps); }; const initBucket = async (): Promise => { diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 3d655454..73f076e4 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -42,6 +42,7 @@ import { dockerStart, dockerBuild, dockerPull, + assertFunctionSourceCode, } from "../emulation/docker.js"; import { Scopes } from "@appwrite.io/console"; @@ -132,13 +133,15 @@ const runFunction = async ({ }; drawTable([settings]); - log( + hint( "If you wish to change your local settings, update the appwrite.config.json file and rerun the 'appwrite run' command.", ); hint( "Permissions, events, CRON and timeouts don't apply when running locally.", ); + assertFunctionSourceCode(func); + await dockerCleanup(func.$id); process.on("SIGINT", async () => { @@ -248,10 +251,22 @@ const runFunction = async ({ await dockerPull(func); + let hasShownRuntimeLogsHeader = false; + const showRuntimeLogsHeader = (): void => { + if (hasShownRuntimeLogsHeader) { + return; + } + + hasShownRuntimeLogsHeader = true; + log("Runtime logs:"); + }; + new Tail(logsPath).on("line", function (data: string) { + showRuntimeLogsHeader(); process.stdout.write(chalk.white(`${data}\n`)); }); new Tail(errorsPath).on("line", function (data: string) { + showRuntimeLogsHeader(); process.stdout.write(chalk.white(`${data}\n`)); }); @@ -291,6 +306,7 @@ const runFunction = async ({ try { await dockerStop(func.$id); + assertFunctionSourceCode(func); const dependencyFile = files.find((filePath: string) => tool.dependencyFiles.includes(filePath), @@ -369,7 +385,7 @@ const runFunction = async ({ await dockerStart(func, allVariables, portNum!); } } catch (err) { - console.error(err); + error(`Failed to reload function with error: ${getErrorMessage(err)}`); } finally { Queue.unlock(); } @@ -385,8 +401,10 @@ const runFunction = async ({ return; } + process.stdout.write("\n"); log("Starting function using Docker ..."); hint("Function automatically restarts when you edit your code."); + process.stdout.write("\n"); await dockerStart(func, allVariables, portNum!); Queue.unlock(); diff --git a/lib/commands/update.ts b/lib/commands/update.ts index 43263678..dc77b319 100644 --- a/lib/commands/update.ts +++ b/lib/commands/update.ts @@ -68,9 +68,7 @@ const isExpectedStandaloneBinaryPath = (candidatePath: string): boolean => { const basename = path.basename(candidatePath).toLowerCase(); const expectedName = EXECUTABLE_NAME.toLowerCase(); - return ( - basename === expectedName || basename === `${expectedName}.exe` - ); + return basename === expectedName || basename === `${expectedName}.exe`; }; const isDirectoryWritable = (directoryPath: string): boolean => { @@ -90,7 +88,9 @@ const downloadStandaloneBinary = async ( destinationPath: string, ): Promise => { const artifact = getStandaloneBinaryArtifactName(); - const response = await fetch(`${GITHUB_RELEASES_URL}/latest/download/${artifact}`); + const response = await fetch( + `${GITHUB_RELEASES_URL}/latest/download/${artifact}`, + ); if (!response.ok) { throw new Error( @@ -344,9 +344,8 @@ interface UpdateOptions { const updateCli = async ({ manual }: UpdateOptions = {}): Promise => { try { const installationMethod = detectInstallationMethod(); - const latestVersion = await getLatestVersionForInstallation( - installationMethod, - ); + const latestVersion = + await getLatestVersionForInstallation(installationMethod); const comparison = compareVersions(version, latestVersion); diff --git a/lib/constants.ts b/lib/constants.ts index 0dcf6e6e..c2405679 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,7 +1,7 @@ // SDK export const SDK_TITLE = 'Appwrite'; export const SDK_TITLE_LOWER = 'appwrite'; -export const SDK_VERSION = '18.1.0'; +export const SDK_VERSION = '18.2.0'; export const SDK_NAME = 'Command Line'; export const SDK_PLATFORM = 'console'; export const SDK_LANGUAGE = 'cli'; @@ -34,6 +34,7 @@ export const CONFIG_RESOURCE_KEYS = [ "tablesDB", "tables", "teams", + "webhooks", "collections", ] as const; diff --git a/lib/emulation/docker.ts b/lib/emulation/docker.ts index 3b3afd3b..b1e8d69f 100644 --- a/lib/emulation/docker.ts +++ b/lib/emulation/docker.ts @@ -13,6 +13,161 @@ import { openRuntimesVersion, systemTools, Queue } from "./utils.js"; import { getAllFiles } from "../utils.js"; import type { FunctionType } from "../commands/config.js"; +function getFunctionIgnorer( + func: FunctionType, + functionDir: string, +): ignoreModule.Ignore { + const ignorer = ignore(); + ignorer.add(".appwrite"); + + if (func.ignore) { + ignorer.add(func.ignore); + } else if (fs.existsSync(path.join(functionDir, ".gitignore"))) { + ignorer.add( + fs.readFileSync(path.join(functionDir, ".gitignore")).toString(), + ); + } + + return ignorer; +} + +function getFunctionFiles(func: FunctionType): { + functionDir: string; + files: string[]; + ignorer: ignoreModule.Ignore; +} { + const functionDir = path.join(localConfig.getDirname(), func.path); + const ignorer = getFunctionIgnorer(func, functionDir); + const files = getAllFiles(functionDir) + .map((file) => path.relative(functionDir, file)) + .filter((file) => !ignorer.ignores(file)); + + return { functionDir, files, ignorer }; +} + +export function assertFunctionSourceCode(func: FunctionType): void { + const functionDir = path.join(localConfig.getDirname(), func.path); + + if (!fs.existsSync(functionDir)) { + throw new Error( + `Function path '${func.path}' was not found. Add your source code before running locally.`, + ); + } + + const { files, ignorer } = getFunctionFiles(func); + + if (!func.entrypoint) { + throw new Error( + `Function '${func.name}' is missing an entrypoint. Update appwrite.config.json before running locally.`, + ); + } + + const normalizedEntrypoint = path.normalize(func.entrypoint); + const relativeEntrypoint = normalizedEntrypoint.split(path.sep).join("/"); + + if (ignorer.ignores(relativeEntrypoint)) { + throw new Error( + `Entrypoint '${func.entrypoint}' is ignored by your local ignore rules. Update appwrite.config.json or your ignore file before running locally.`, + ); + } + + if (!fs.existsSync(path.join(functionDir, normalizedEntrypoint))) { + throw new Error( + `Entrypoint '${func.entrypoint}' was not found in '${func.path}'. Add your source code before running locally.`, + ); + } + + if (files.length === 0) { + throw new Error( + `No source files were found in '${func.path}'. Add your source code before running locally.`, + ); + } +} + +function getRuntimeImageName(func: FunctionType): string { + const runtimeChunks = func.runtime.split("-"); + const runtimeVersion = runtimeChunks.pop(); + const runtimeName = runtimeChunks.join("-"); + + return `openruntimes/${runtimeName}:${openRuntimesVersion}-${runtimeVersion}`; +} + +async function waitForProcessClose( + process: childProcess.ChildProcessWithoutNullStreams, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + process.once("error", reject); + process.once("close", (code, signal) => { + resolve({ code, signal }); + }); + }); +} + +function assertDockerSuccess( + code: number | null, + signal: NodeJS.Signals | null, + errorMessage: string, +): void { + if (code === 0) { + return; + } + + if (signal) { + throw new Error( + `${errorMessage} Docker process exited with signal ${signal}.`, + ); + } + + throw new Error( + `${errorMessage} Docker process exited with code ${code ?? "unknown"}.`, + ); +} + +function getDockerExitMessage( + code: number | null, + signal: NodeJS.Signals | null, +): string { + if (signal) { + return `Docker process exited with signal ${signal}.`; + } + + return `Docker process exited with code ${code ?? "unknown"}.`; +} + +function waitForProcessOutput( + process: childProcess.ChildProcessWithoutNullStreams, + needle: string, +): Promise { + return new Promise((resolve) => { + let output = ""; + + const onData = (data: Buffer): void => { + output += data.toString(); + + if (output.includes(needle)) { + cleanup(); + resolve(); + } + + if (output.length > needle.length * 4) { + output = output.slice(-needle.length * 4); + } + }; + + const cleanup = (): void => { + process.stdout.off("data", onData); + process.stderr.off("data", onData); + process.off("close", cleanup); + process.off("error", cleanup); + }; + + process.stdout.on("data", onData); + process.stderr.on("data", onData); + process.once("close", cleanup); + process.once("error", cleanup); + }); +} + export async function dockerStop(id: string): Promise { const stopProcess = childProcess.spawn("docker", ["rm", "--force", id], { stdio: "pipe", @@ -28,10 +183,7 @@ export async function dockerStop(id: string): Promise { } export async function dockerPull(func: FunctionType): Promise { - const runtimeChunks = func.runtime.split("-"); - const runtimeVersion = runtimeChunks.pop(); - const runtimeName = runtimeChunks.join("-"); - const imageName = `openruntimes/${runtimeName}:${openRuntimesVersion}-${runtimeVersion}`; + const imageName = getRuntimeImageName(func); log("Verifying Docker image ..."); @@ -43,37 +195,23 @@ export async function dockerPull(func: FunctionType): Promise { }, }); - await new Promise((res) => { - pullProcess.on("close", res); - }); + const { code, signal } = await waitForProcessClose(pullProcess); + assertDockerSuccess( + code, + signal, + `Unable to pull Docker image '${imageName}'.`, + ); } export async function dockerBuild( func: FunctionType, variables: Record, ): Promise { - const runtimeChunks = func.runtime.split("-"); - const runtimeVersion = runtimeChunks.pop(); - const runtimeName = runtimeChunks.join("-"); - const imageName = `openruntimes/${runtimeName}:${openRuntimesVersion}-${runtimeVersion}`; + const imageName = getRuntimeImageName(func); - const functionDir = path.join(localConfig.getDirname(), func.path); + const { functionDir, files } = getFunctionFiles(func); const id = func.$id; - - const ignorer = ignore(); - ignorer.add(".appwrite"); - if (func.ignore) { - ignorer.add(func.ignore); - } else if (fs.existsSync(path.join(functionDir, ".gitignore"))) { - ignorer.add( - fs.readFileSync(path.join(functionDir, ".gitignore")).toString(), - ); - } - - const files = getAllFiles(functionDir) - .map((file) => path.relative(functionDir, file)) - .filter((file) => !ignorer.ignores(file)); const tmpBuildPath = path.join(functionDir, ".appwrite/tmp-build"); if (!fs.existsSync(tmpBuildPath)) { fs.mkdirSync(tmpBuildPath, { recursive: true }); @@ -115,12 +253,25 @@ export async function dockerBuild( }, }); + let hasPrintedBuildSeparator = false; + const writeBuildChunk = ( + stream: NodeJS.WriteStream, + data: Buffer | string, + ): void => { + if (!hasPrintedBuildSeparator) { + stream.write("\n"); + hasPrintedBuildSeparator = true; + } + + stream.write(chalk.blackBright(data)); + }; + buildProcess.stdout.on("data", (data) => { - process.stdout.write(chalk.blackBright(`${data}\n`)); + writeBuildChunk(process.stdout, data); }); buildProcess.stderr.on("data", (data) => { - process.stderr.write(chalk.blackBright(`${data}\n`)); + writeBuildChunk(process.stderr, data); }); killInterval = setInterval(() => { @@ -134,9 +285,7 @@ export async function dockerBuild( } }, 100); - await new Promise((res) => { - buildProcess.on("close", res); - }); + const { code, signal } = await waitForProcessClose(buildProcess); clearInterval(killInterval); killInterval = undefined; @@ -146,6 +295,12 @@ export async function dockerBuild( return; } + assertDockerSuccess( + code, + signal, + `Unable to build function '${func.name}'.`, + ); + const copyPath = path.join( localConfig.getDirname(), func.path, @@ -170,12 +325,17 @@ export async function dockerBuild( }, ); - await new Promise((res) => { - copyProcess.on("close", res); - }); + const copyResult = await waitForProcessClose(copyProcess); + assertDockerSuccess( + copyResult.code, + copyResult.signal, + `Unable to copy built bundle for function '${func.name}'.`, + ); await dockerStop(id); } finally { + await dockerStop(id); + // Clean up interval if still running if (killInterval !== undefined) { clearInterval(killInterval); @@ -204,10 +364,8 @@ export async function dockerStart( // Pack function files const functionDir = path.join(localConfig.getDirname(), func.path); - const runtimeChunks = func.runtime.split("-"); - const runtimeVersion = runtimeChunks.pop(); - const runtimeName = runtimeChunks.join("-"); - const imageName = `openruntimes/${runtimeName}:${openRuntimesVersion}-${runtimeVersion}`; + const imageName = getRuntimeImageName(func); + const runtimeName = func.runtime.split("-").slice(0, -1).join("-"); const tool = systemTools[runtimeName]; @@ -253,18 +411,63 @@ export async function dockerStart( }); startProcess.stderr.on("data", (data) => { - process.stdout.write(chalk.blackBright(data)); + process.stderr.write(chalk.blackBright(data)); }); + const startProcessExit = waitForProcessClose(startProcess); + void startProcessExit.catch(() => {}); + const startupLogDetected = waitForProcessOutput( + startProcess, + "HTTP server successfully started!", + ); + void startupLogDetected.catch(() => {}); + try { - await waitUntilPortOpen(port); + const result = await Promise.race([ + waitUntilPortOpen(port).then(() => ({ + type: "port-open" as const, + })), + startProcessExit.then(({ code, signal }) => ({ + type: "process-exit" as const, + code, + signal, + })), + ]); + + if (result.type === "process-exit") { + throw new Error( + `Function container exited before opening port ${port}. ${getDockerExitMessage(result.code, result.signal)}`, + ); + } + + const postStartupResult = await Promise.race([ + startupLogDetected.then(() => ({ + type: "startup-log" as const, + })), + startProcessExit.then(({ code, signal }) => ({ + type: "process-exit" as const, + code, + signal, + })), + new Promise<{ type: "timeout" }>((resolve) => { + setTimeout(() => resolve({ type: "timeout" }), 1500); + }), + ]); + + if (postStartupResult.type === "process-exit") { + throw new Error( + `Function container exited before startup logs completed. ${getDockerExitMessage(postStartupResult.code, postStartupResult.signal)}`, + ); + } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); error(`Failed to start function with error: ${message}`); return; } + process.stdout.write("\n"); success(`Visit http://localhost:${port}/ to execute your function.`); + process.stdout.write("\n"); } export async function dockerCleanup(functionId: string): Promise { diff --git a/lib/emulation/utils.ts b/lib/emulation/utils.ts index 00d036fc..fd166c97 100644 --- a/lib/emulation/utils.ts +++ b/lib/emulation/utils.ts @@ -4,7 +4,7 @@ import { log } from "../parser.js"; import { sdkForConsole, sdkForProject } from "../sdks.js"; import { Projects, Scopes, Users } from "@appwrite.io/console"; -export const openRuntimesVersion = "v4"; +export const openRuntimesVersion = "v5"; export const runtimeNames: Record = { node: "Node.js", @@ -31,67 +31,67 @@ interface SystemTool { export const systemTools: Record = { node: { isCompiled: false, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: ["package.json", "package-lock.json"], }, php: { isCompiled: false, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: ["composer.json", "composer.lock"], }, ruby: { isCompiled: false, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: ["Gemfile", "Gemfile.lock"], }, python: { isCompiled: false, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: ["requirements.txt", "requirements.lock"], }, "python-ml": { isCompiled: false, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: ["requirements.txt", "requirements.lock"], }, deno: { isCompiled: false, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: [], }, dart: { isCompiled: true, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: [], }, dotnet: { isCompiled: true, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: [], }, java: { isCompiled: true, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: [], }, swift: { isCompiled: true, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: [], }, kotlin: { isCompiled: true, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: [], }, bun: { isCompiled: false, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: ["package.json", "package-lock.json", "bun.lockb"], }, go: { isCompiled: true, - startCommand: "sh helpers/server.sh", + startCommand: "bash helpers/server.sh", dependencyFiles: [], }, }; diff --git a/lib/questions.ts b/lib/questions.ts index f724e031..4b8702a4 100644 --- a/lib/questions.ts +++ b/lib/questions.ts @@ -57,6 +57,24 @@ const validateNonNegativeInteger = (value: string): boolean | string => { return true; }; +const buildSelectionLabel = (name: string, id: string): string => + `${name} (${id})`; + +const extractSelectionId = (value: string): string => { + const match = value.match(/\(([^()]+)\)$/); + return match ? match[1] : value; +}; + +const getInitProjectOverrideMessage = (): string => { + const projectName = localConfig.getProject().projectName; + + if (projectName) { + return `A project is already linked to this directory (${projectName}). Override?`; + } + + return "A project is already linked to this directory. Override?"; +}; + const getIgnores = (runtime: string): string[] => { const language = runtime.split("-").slice(0, -1).join("-"); @@ -166,7 +184,7 @@ export const questionsInitProject: Question[] = [ { type: "confirm", name: "override", - message: `An ${SDK_TITLE} project ( ${localConfig.getProject()["projectId"]} ) is already associated with the current directory. Would you like to override it?`, + message: getInitProjectOverrideMessage(), when() { return Object.keys(localConfig.getProject()).length !== 0; }, @@ -175,14 +193,14 @@ export const questionsInitProject: Question[] = [ type: "list", name: "start", when: whenOverride, - message: "How would you like to start?", + message: "Select a setup method:", choices: [ { - name: "Create new project", + name: "Create a new project", value: "new", }, { - name: "Link directory to an existing project", + name: "Link this directory to an existing project", value: "existing", }, ], @@ -190,7 +208,7 @@ export const questionsInitProject: Question[] = [ { type: "search-list", name: "organization", - message: "Choose your organization", + message: "Choose your organization:", choices: async () => { const client = await sdkForConsole(true); const { teams } = isCloud() @@ -214,9 +232,10 @@ export const questionsInitProject: Question[] = [ ); const choices = teams.map((team: any, _idx: number) => { + const label = buildSelectionLabel(team.name, team["$id"]); return { - name: `${team.name} (${team["$id"]})`, - value: team["$id"], + name: label, + value: label, }; }); @@ -249,13 +268,13 @@ export const questionsInitProject: Question[] = [ { type: "search-list", name: "project", - message: `Choose your ${SDK_TITLE} project.`, + message: "Choose your project:", choices: async (answers: Answers) => { const queries = [ JSON.stringify({ method: "equal", attribute: "teamId", - values: [answers.organization], + values: [extractSelectionId(answers.organization)], }), JSON.stringify({ method: "orderDesc", attribute: "$id" }), ]; @@ -270,14 +289,10 @@ export const questionsInitProject: Question[] = [ ); const choices = projects.map((project: any) => { - const label = `${project.name} (${project["$id"]})`; + const label = buildSelectionLabel(project.name, project["$id"]); return { name: label, - short: label, - value: { - $id: project["$id"], - region: project.region || "", - }, + value: label, }; }); @@ -325,7 +340,7 @@ export const questionsInitProjectAutopull: Question[] = [ { type: "confirm", name: "autopull", - message: `Would you like to pull all resources from the project you just linked?`, + message: "Pull all resources from this project?", }, ]; diff --git a/lib/utils.ts b/lib/utils.ts index 7f204c30..519187ac 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -452,7 +452,10 @@ export const syncVersionCheckCache = ( ): void => { const now = getCurrentTimestamp(); const source = getLatestVersionSource(); - const existingCache = getCacheForVersionSource(readUpdateCheckCache(), source); + const existingCache = getCacheForVersionSource( + readUpdateCheckCache(), + source, + ); const updateAvailable = compareVersions(currentVersion, latestVersion) > 0; tryWriteUpdateCheckCache({ diff --git a/package-lock.json b/package-lock.json index 5d3de827..913b208d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "appwrite-cli", - "version": "18.1.0", + "version": "18.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appwrite-cli", - "version": "18.1.0", + "version": "18.2.0", "license": "BSD-3-Clause", "dependencies": { "@appwrite.io/console": "~9.1.0", diff --git a/package.json b/package.json index 353409c5..6c28362b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "homepage": "https://appwrite.io/support", "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API", - "version": "18.1.0", + "version": "18.2.0", "license": "BSD-3-Clause", "main": "dist/index.cjs", "module": "dist/index.js", diff --git a/scoop/appwrite.config.json b/scoop/appwrite.config.json index c9170017..86e6afda 100644 --- a/scoop/appwrite.config.json +++ b/scoop/appwrite.config.json @@ -1,12 +1,12 @@ { "$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", - "version": "18.1.0", + "version": "18.2.0", "description": "The Appwrite CLI is a command-line application that allows you to interact with Appwrite and perform server-side tasks using your terminal.", "homepage": "https://github.com/appwrite/sdk-for-cli", "license": "BSD-3-Clause", "architecture": { "64bit": { - "url": "https://github.com/appwrite/sdk-for-cli/releases/download/18.1.0/appwrite-cli-win-x64.exe", + "url": "https://github.com/appwrite/sdk-for-cli/releases/download/18.2.0/appwrite-cli-win-x64.exe", "bin": [ [ "appwrite-cli-win-x64.exe", @@ -15,7 +15,7 @@ ] }, "arm64": { - "url": "https://github.com/appwrite/sdk-for-cli/releases/download/18.1.0/appwrite-cli-win-arm64.exe", + "url": "https://github.com/appwrite/sdk-for-cli/releases/download/18.2.0/appwrite-cli-win-arm64.exe", "bin": [ [ "appwrite-cli-win-arm64.exe",