From 5f5fb2add282604c168a12d072ba9ad0193d7c21 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Tue, 8 Sep 2026 10:10:07 -0400 Subject: [PATCH] feat(website): add upload command for single HTML files The CLI could upload files but had no way to publish a single HTML page as a durable public website. `oo website upload` sends one `.html`/`.htm` file (capped at 20 MiB) to team-scoped storage and prints the public URL, using the same team identity ladder as `oo file upload`. Shared `readUploadSourceFile` and `formatFileSize` keep the two upload commands on the same file checks and size rendering. Signed-off-by: Kevin Cui --- docs/commands.md | 48 +- docs/commands.zh-CN.md | 33 +- .../__snapshots__/run-cli.test.ts.snap | 6 + src/application/commands/catalog.ts | 2 + .../__snapshots__/index.cli.test.ts.snap | 2 + src/application/commands/file/text.ts | 16 +- src/application/commands/file/upload.ts | 78 +-- .../commands/shared/file-size.test.ts | 18 + src/application/commands/shared/file-size.ts | 15 + .../shared/upload-source-file.test.ts | 103 ++++ .../commands/shared/upload-source-file.ts | 80 +++ .../commands/telemetry-decisions.test.ts | 14 + .../__snapshots__/upload.cli.test.ts.snap | 63 +++ src/application/commands/website/index.ts | 10 + .../commands/website/upload.cli.test.ts | 527 ++++++++++++++++++ src/application/commands/website/upload.ts | 198 +++++++ src/i18n/catalog.ts | 50 ++ 17 files changed, 1173 insertions(+), 90 deletions(-) create mode 100644 src/application/commands/shared/file-size.test.ts create mode 100644 src/application/commands/shared/file-size.ts create mode 100644 src/application/commands/shared/upload-source-file.test.ts create mode 100644 src/application/commands/shared/upload-source-file.ts create mode 100644 src/application/commands/website/__snapshots__/upload.cli.test.ts.snap create mode 100644 src/application/commands/website/index.ts create mode 100644 src/application/commands/website/upload.cli.test.ts create mode 100644 src/application/commands/website/upload.ts diff --git a/docs/commands.md b/docs/commands.md index 198a46d3..eaa62e88 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -75,7 +75,8 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive). configuration (`oo connector login`) > the active account. - `OO_TEAM_ID`: Run team-aware commands (`oo connector run`, `oo connector proxy`, `oo connector apps`, `oo connector search` / `oo search`, - `oo variables list/get/create/delete`, and `oo file upload`) under the team + `oo variables list/get/create/delete`, `oo file upload`, and + `oo website upload`) under the team with this id. It takes precedence over `OO_TEAM_NAME` and the account's default team; the per-run `--team` flag still outranks it. Before execution the CLI validates the id and resolves its team name (one extra request per @@ -593,11 +594,12 @@ Alias for `oo auth logout`. Team identity selects the team that team-aware commands act for: the connector commands (`oo connector run`, `oo connector proxy`, `oo connector apps`), the variables commands (`oo variables list/get/create/delete`), whose data is -team-owned in the first place, and `oo file upload`, whose upload is billed and -metered under the team. One ladder selects it: the per-run `--team ` -first, then the `OO_TEAM_ID` / `OO_TEAM_NAME` environment overrides, then the -default saved on the active account. When nothing selects a team the command -sends no team selection and the server applies the account's default team. +team-owned in the first place, and `oo file upload` and `oo website upload`, +whose uploads are billed and metered under the team. One ladder selects it: the +per-run `--team ` first, then the `OO_TEAM_ID` / `OO_TEAM_NAME` +environment overrides, then the default saved on the active account. When +nothing selects a team the command sends no team selection and the server +applies the account's default team. These commands help discover which teams your account can use and manage that default. @@ -2635,6 +2637,40 @@ Delete expired or stale file transfer records. not owned by an active download process. - Notes: the JSON response shape is `{ "deletedCount": number }`. +## Website + +### `oo website upload ` + +Upload one HTML file to long-term website storage and print the public URL it +is served from. + +- Arguments: `` is the local HTML file to upload. Its name must end + with `.html` or `.htm` (case-insensitive); other files are rejected with + exit `2`. +- Options: `--format ` returns structured output. Supported value: + `json`. +- Options: `--json` is an alias for `--format=json`. +- Options: `--team ` uploads the website under the given team, so the + upload is billed and metered under that team. When omitted, the team comes + from `OO_TEAM_ID` / `OO_TEAM_NAME` when set, otherwise the active account's + default team. +- Notes: the command requires an OOMOL account. Without one it fails with exit + `1` and asks you to log in first. +- Notes: websites are stored per team. When the account belongs to no team, + the command fails with exit `1` and asks you to create or join one. +- Notes: the team selection is sent only to the website service request; the + file itself goes straight to storage without it. +- Notes: files larger than `20 MiB` are rejected with exit `2`. +- Notes: the file is served as `text/html` from `https://r2.inklycat.com`, + does not expire, and is readable by anyone who has the URL. +- Notes: every upload gets a new URL of the form + `https://r2.inklycat.com/v1///index.html`. The local file name + is not part of it, and uploading again does not replace an earlier website. +- Notes: the JSON response shape is + `{ "fileName": string, "fileSize": number, "url": string }`. +- Notes: text output is the localized success line followed by the file size + and the URL. + ## Variables Store and read named string variables in the OOMOL cloud. Aliases: diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 80e0cc63..32ceda1b 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -62,7 +62,8 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 (`oo connector login`)> 当前激活账号。 - `OO_TEAM_ID`:让团队相关命令(`oo connector run`、`oo connector proxy`、 `oo connector apps`、`oo connector search` / `oo search`、 - `oo variables list/get/create/delete`,以及 `oo file upload`)以该 id + `oo variables list/get/create/delete`、`oo file upload`,以及 + `oo website upload`)以该 id 对应的团队身份运行。优先级高于 `OO_TEAM_NAME` 和账号保存的默认团队; 每次运行的 `--team` 标志仍然优先于它。执行前 CLI 会校验该 id 并解析出团队 名称(每次调用多一个请求),因此请求会同时携带名称与 id;账号无法使用 @@ -487,7 +488,8 @@ oo flow 团队身份决定团队相关命令以哪个团队运行:包括 connector 命令 (`oo connector run`、`oo connector proxy`、`oo connector apps`)、variables 命令(`oo variables list/get/create/delete`,其数据本身就归团队所有),以及 -`oo file upload`(上传按该团队计费与计量)。它由同一条优先级阶梯选出:先是每次 +`oo file upload` 与 `oo website upload`(上传按该团队计费与计量)。它由同一条 +优先级阶梯选出:先是每次 运行的 `--team `,其次是环境变量 `OO_TEAM_ID` / `OO_TEAM_NAME`,最后是 保存在当前账号上的默认团队。没有任何一项选中团队时,命令不发送团队选择,由服务端 套用该账号的默认团队。下列命令用于发现当前账号可用的团队并管理该默认值。 @@ -2200,6 +2202,33 @@ message,也不会出现在 `path` / `sourcePath` 字段之外的额外文件 - 说明:超过 14 天且未被活跃下载进程占用的下载续传 session 会被删除。 - 说明:JSON 输出结构为 `{ "deletedCount": number }`。 +## 网站 + +### `oo website upload ` + +将单个 HTML 文件上传到长期网站存储,并输出其公开访问 URL。 + +- 参数:`` 为要上传的本地 HTML 文件。文件名必须以 `.html` 或 `.htm` + 结尾(不区分大小写),其他文件会被拒绝,退出码为 `2`。 +- 选项:`--format ` 返回结构化输出,目前仅支持 `json`。 +- 选项:`--json` 是 `--format=json` 的别名。 +- 选项:`--team ` 以指定团队上传该网站,上传按该团队计费与计量。 + 未传时,团队来自已设置的 `OO_TEAM_ID` / `OO_TEAM_NAME`,否则为当前账号 + 保存的默认团队。 +- 说明:该命令需要 OOMOL 账号。未登录时以退出码 `1` 结束,并提示先登录。 +- 说明:网站按团队存储。当前账号不属于任何团队时,命令以退出码 `1` 结束, + 并提示先创建或加入团队。 +- 说明:团队选择只随网站服务请求发送;文件本身直接发往存储服务,不携带它。 +- 说明:文件大小超过 `20 MiB` 时会被拒绝,退出码为 `2`。 +- 说明:文件以 `text/html` 类型从 `https://r2.inklycat.com` 提供访问, + 不会过期,任何拿到 URL 的人都可以访问。 +- 说明:每次上传都会得到一个新的 URL,形如 + `https://r2.inklycat.com/v1///index.html`。本地文件名不会出现 + 在 URL 中,重复上传也不会覆盖之前的网站。 +- 说明:JSON 响应形状为 + `{ "fileName": string, "fileSize": number, "url": string }`。 +- 说明:文本输出为本地化的成功提示行,随后是文件大小和 URL。 + ## Variables 在 OOMOL 云端存取具名字符串变量。别名:`oo variable`、`oo var`、 diff --git a/src/application/bootstrap/__snapshots__/run-cli.test.ts.snap b/src/application/bootstrap/__snapshots__/run-cli.test.ts.snap index 63b2a35f..6051a82c 100644 --- a/src/application/bootstrap/__snapshots__/run-cli.test.ts.snap +++ b/src/application/bootstrap/__snapshots__/run-cli.test.ts.snap @@ -63,6 +63,7 @@ Commands: update|upgrade [options] Update the CLI variables|variable|var|vars Manage cloud-stored variables version [options] Print the CLI version + website Publish single-file websites help [command] Show help for a command " , @@ -106,6 +107,7 @@ Commands: update|upgrade [options] Update the CLI variables|variable|var|vars Manage cloud-stored variables version [options] Print the CLI version + website Publish single-file websites help [command] Show help for a command " , @@ -152,6 +154,7 @@ Commands: update|upgrade [options] Update the CLI variables|variable|var|vars Manage cloud-stored variables version [options] Print the CLI version + website Publish single-file websites help [command] Show help for a command " , @@ -193,6 +196,7 @@ exports[`runCli bootstrap renders help in English and Chinese 1`] = ` update|upgrade [options] 更新 CLI variables|variable|var|vars 管理云端变量 version [options] 输出 CLI 版本 + website 发布单文件网站 help [command] 显示命令帮助 " , @@ -233,6 +237,7 @@ Commands: update|upgrade [options] Update the CLI variables|variable|var|vars Manage cloud-stored variables version [options] Print the CLI version + website Publish single-file websites help [command] Show help for a command " , @@ -277,6 +282,7 @@ Commands: update|upgrade [options] Update the CLI variables|variable|var|vars Manage cloud-stored variables version [options] Print the CLI version + website Publish single-file websites help [command] Show help for a command " , diff --git a/src/application/commands/catalog.ts b/src/application/commands/catalog.ts index 392208e1..4d9ea541 100644 --- a/src/application/commands/catalog.ts +++ b/src/application/commands/catalog.ts @@ -23,6 +23,7 @@ import { uninstallCommand } from "./uninstall.ts"; import { updateCommand } from "./update.ts"; import { variablesCommand } from "./variables/index.ts"; import { versionCommand } from "./version.ts"; +import { websiteCommand } from "./website/index.ts"; const globalOptions = [ { @@ -68,6 +69,7 @@ export function createCliCatalog(): CliCatalog { updateCommand, variablesCommand, versionCommand, + websiteCommand, ], }; } diff --git a/src/application/commands/config/__snapshots__/index.cli.test.ts.snap b/src/application/commands/config/__snapshots__/index.cli.test.ts.snap index 680fda9c..ffbb0ce4 100644 --- a/src/application/commands/config/__snapshots__/index.cli.test.ts.snap +++ b/src/application/commands/config/__snapshots__/index.cli.test.ts.snap @@ -49,6 +49,7 @@ Commands: update|upgrade [options] Update the CLI variables|variable|var|vars Manage cloud-stored variables version [options] Print the CLI version + website Publish single-file websites help [command] Show help for a command " , @@ -86,6 +87,7 @@ Commands: update|upgrade [options] 更新 CLI variables|variable|var|vars 管理云端变量 version [options] 输出 CLI 版本 + website 发布单文件网站 help [command] 显示命令帮助 " , diff --git a/src/application/commands/file/text.ts b/src/application/commands/file/text.ts index a470c1d2..cfb717b4 100644 --- a/src/application/commands/file/text.ts +++ b/src/application/commands/file/text.ts @@ -1,6 +1,7 @@ import type { CliExecutionContext } from "../../contracts/cli.ts"; import type { FileUploadRecordView } from "./shared.ts"; +import { formatFileSize } from "../shared/file-size.ts"; type FileTextContext = Pick; @@ -36,18 +37,3 @@ export function formatFileUploadListAsText( .map(record => formatFileUploadRecordAsText(record, context)) .join("\n\n"); } - -function formatFileSize(value: number): string { - const units = ["B", "KiB", "MiB", "GiB"] as const; - let unitIndex = 0; - let size = value; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex += 1; - } - - const decimalPlaces = unitIndex === 0 ? 0 : 2; - - return `${size.toFixed(decimalPlaces)} ${units[unitIndex]}`; -} diff --git a/src/application/commands/file/upload.ts b/src/application/commands/file/upload.ts index 706bfe08..2ebdda19 100644 --- a/src/application/commands/file/upload.ts +++ b/src/application/commands/file/upload.ts @@ -1,15 +1,9 @@ -import type { Stats } from "node:fs"; -import type { - CliCommandDefinition, - CliExecutionContext, -} from "../../contracts/cli.ts"; +import type { CliCommandDefinition } from "../../contracts/cli.ts"; -import { stat } from "node:fs/promises"; -import { basename, resolve } from "node:path"; import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; -import { CliUserError } from "../../contracts/cli.ts"; import { bucketTelemetryBytes } from "../../telemetry/buckets.ts"; +import { readUploadSourceFile } from "../shared/upload-source-file.ts"; import { resolveAccountTeamIdentity, teamIdentityInputShape, @@ -31,10 +25,6 @@ interface FileUploadInput { team?: string; } -type RecordTelemetryProperties = NonNullable< - CliExecutionContext["telemetry"] ->["recordProperties"]; - export const fileUploadCommand: CliCommandDefinition = { name: "upload", summaryKey: "commands.file.upload.summary", @@ -56,11 +46,15 @@ export const fileUploadCommand: CliCommandDefinition = { handler: async (input, context) => { const { account } = await requireIdentity(context); const identity = await resolveAccountTeamIdentity(input, account, context); - const sourceFile = await readSourceFile( - input.filePath, - context.cwd, - context.telemetry?.recordProperties, - ); + const sourceFile = await readUploadSourceFile(input.filePath, context.cwd, { + errorKeys: { + pathNotFile: "errors.fileUpload.pathNotFile", + readFailed: "errors.fileUpload.readFailed", + tooLarge: "errors.fileUpload.tooLarge", + }, + maxSizeBytes: maxFileUploadSizeBytes, + recordTelemetryProperties: context.telemetry?.recordProperties, + }); context.telemetry?.recordProperties({ bytes_total_bucket: bucketTelemetryBytes(sourceFile.fileSize), @@ -121,53 +115,3 @@ export const fileUploadCommand: CliCommandDefinition = { }); }, }; - -async function readSourceFile( - filePath: string, - cwd: string, - recordTelemetryProperties: RecordTelemetryProperties | undefined, -): Promise<{ - file: { - size: number; - slice: (start?: number, end?: number) => Blob; - }; - fileName: string; - fileSize: number; -}> { - const resolvedPath = resolve(cwd, filePath); - let metadata: Stats; - - try { - metadata = await stat(resolvedPath); - } - catch (error) { - throw new CliUserError("errors.fileUpload.readFailed", 1, { - message: error instanceof Error ? error.message : String(error), - path: resolvedPath, - }); - } - - if (!metadata.isFile()) { - throw new CliUserError("errors.fileUpload.pathNotFile", 1, { - path: resolvedPath, - }); - } - - if (metadata.size > maxFileUploadSizeBytes) { - recordTelemetryProperties?.({ - bytes_total_bucket: bucketTelemetryBytes(metadata.size), - rejected_too_large: true, - }); - throw new CliUserError("errors.fileUpload.tooLarge", 2, { - max: maxFileUploadSizeBytes, - path: resolvedPath, - size: metadata.size, - }); - } - - return { - file: Bun.file(resolvedPath), - fileName: basename(resolvedPath), - fileSize: metadata.size, - }; -} diff --git a/src/application/commands/shared/file-size.test.ts b/src/application/commands/shared/file-size.test.ts new file mode 100644 index 00000000..97bd209d --- /dev/null +++ b/src/application/commands/shared/file-size.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; + +import { formatFileSize } from "./file-size.ts"; + +describe("formatFileSize", () => { + test("renders bytes without decimals and larger units with two", () => { + expect(formatFileSize(0)).toBe("0 B"); + expect(formatFileSize(1023)).toBe("1023 B"); + expect(formatFileSize(1024)).toBe("1.00 KiB"); + expect(formatFileSize(1_536)).toBe("1.50 KiB"); + expect(formatFileSize(20 * 1024 * 1024)).toBe("20.00 MiB"); + expect(formatFileSize(3 * 1024 * 1024 * 1024)).toBe("3.00 GiB"); + }); + + test("stops at the largest known unit", () => { + expect(formatFileSize(2048 * 1024 * 1024 * 1024)).toBe("2048.00 GiB"); + }); +}); diff --git a/src/application/commands/shared/file-size.ts b/src/application/commands/shared/file-size.ts new file mode 100644 index 00000000..61438ea3 --- /dev/null +++ b/src/application/commands/shared/file-size.ts @@ -0,0 +1,15 @@ +// Renders a byte count for humans with binary units, two decimals above bytes. +export function formatFileSize(value: number): string { + const units = ["B", "KiB", "MiB", "GiB"] as const; + let unitIndex = 0; + let size = value; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + const decimalPlaces = unitIndex === 0 ? 0 : 2; + + return `${size.toFixed(decimalPlaces)} ${units[unitIndex]}`; +} diff --git a/src/application/commands/shared/upload-source-file.test.ts b/src/application/commands/shared/upload-source-file.test.ts new file mode 100644 index 00000000..099b7a09 --- /dev/null +++ b/src/application/commands/shared/upload-source-file.test.ts @@ -0,0 +1,103 @@ +import { mkdir, truncate } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { + createTemporaryDirectory, + expectCliUserError, + useTemporaryDirectoryCleanup, +} from "../../../../__tests__/helpers.ts"; +import { readUploadSourceFile } from "./upload-source-file.ts"; + +const errorKeys = { + pathNotFile: "errors.fileUpload.pathNotFile", + readFailed: "errors.fileUpload.readFailed", + tooLarge: "errors.fileUpload.tooLarge", +} as const; + +describe("readUploadSourceFile", () => { + const temporaryDirectories = useTemporaryDirectoryCleanup(); + + test("resolves a relative path against cwd and reports the file", async () => { + const cwd = await createTemporaryDirectory("upload-source"); + temporaryDirectories.track(cwd); + await Bun.write(join(cwd, "site.html"), "

hi

"); + + const sourceFile = await readUploadSourceFile("site.html", cwd, { + errorKeys, + maxSizeBytes: 1024, + recordTelemetryProperties: undefined, + }); + + expect(sourceFile.fileName).toBe("site.html"); + expect(sourceFile.fileSize).toBe(9); + await expect(sourceFile.file.text()).resolves.toBe("

hi

"); + }); + + test("reports a missing file with the caller's read error key", async () => { + const cwd = await createTemporaryDirectory("upload-source"); + temporaryDirectories.track(cwd); + + const error = await expectCliUserError( + readUploadSourceFile("missing.html", cwd, { + errorKeys, + maxSizeBytes: 1024, + recordTelemetryProperties: undefined, + }), + ); + + expect(error).toMatchObject({ + exitCode: 1, + key: "errors.fileUpload.readFailed", + params: { path: join(cwd, "missing.html") }, + }); + }); + + test("rejects directories with the caller's not-a-file key", async () => { + const cwd = await createTemporaryDirectory("upload-source"); + temporaryDirectories.track(cwd); + await mkdir(join(cwd, "site")); + + const error = await expectCliUserError( + readUploadSourceFile("site", cwd, { + errorKeys, + maxSizeBytes: 1024, + recordTelemetryProperties: undefined, + }), + ); + + expect(error).toMatchObject({ + exitCode: 1, + key: "errors.fileUpload.pathNotFile", + params: { path: join(cwd, "site") }, + }); + }); + + test("rejects oversize files and records the bucketed size first", async () => { + const cwd = await createTemporaryDirectory("upload-source"); + temporaryDirectories.track(cwd); + const filePath = join(cwd, "large.html"); + const recorded: Record[] = []; + await Bun.write(filePath, ""); + await truncate(filePath, 2048); + + const error = await expectCliUserError( + readUploadSourceFile(filePath, cwd, { + errorKeys, + maxSizeBytes: 1024, + recordTelemetryProperties: (properties) => { + recorded.push(properties); + }, + }), + ); + + expect(error).toMatchObject({ + exitCode: 2, + key: "errors.fileUpload.tooLarge", + params: { max: 1024, path: filePath, size: 2048 }, + }); + expect(recorded).toEqual([ + { bytes_total_bucket: "<1MB", rejected_too_large: true }, + ]); + }); +}); diff --git a/src/application/commands/shared/upload-source-file.ts b/src/application/commands/shared/upload-source-file.ts new file mode 100644 index 00000000..8d37b0ad --- /dev/null +++ b/src/application/commands/shared/upload-source-file.ts @@ -0,0 +1,80 @@ +import type { BunFile } from "bun"; +import type { Stats } from "node:fs"; +import type { MessageKey } from "../../../i18n/catalog.ts"; +import type { CliExecutionContext } from "../../contracts/cli.ts"; + +import { stat } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { CliUserError } from "../../contracts/cli.ts"; +import { bucketTelemetryBytes } from "../../telemetry/buckets.ts"; + +export interface UploadSourceFile { + file: BunFile; + fileName: string; + fileSize: number; +} + +// The per-command error keys, so each upload command keeps its own wording +// (and its own size limit in the tooLarge message) while sharing the checks. +export interface UploadSourceFileErrorKeys { + pathNotFile: MessageKey; + readFailed: MessageKey; + tooLarge: MessageKey; +} + +export type RecordTelemetryProperties = NonNullable< + CliExecutionContext["telemetry"] +>["recordProperties"]; + +interface ReadUploadSourceFileOptions { + errorKeys: UploadSourceFileErrorKeys; + maxSizeBytes: number; + recordTelemetryProperties: RecordTelemetryProperties | undefined; +} + +// Resolves and validates the local file an upload command sends: it must be +// a regular file no larger than the command's limit. A rejected size is +// recorded as bucketed telemetry before throwing, so oversize attempts stay +// visible without the path or file name. +export async function readUploadSourceFile( + filePath: string, + cwd: string, + options: ReadUploadSourceFileOptions, +): Promise { + const resolvedPath = resolve(cwd, filePath); + let metadata: Stats; + + try { + metadata = await stat(resolvedPath); + } + catch (error) { + throw new CliUserError(options.errorKeys.readFailed, 1, { + message: error instanceof Error ? error.message : String(error), + path: resolvedPath, + }); + } + + if (!metadata.isFile()) { + throw new CliUserError(options.errorKeys.pathNotFile, 1, { + path: resolvedPath, + }); + } + + if (metadata.size > options.maxSizeBytes) { + options.recordTelemetryProperties?.({ + bytes_total_bucket: bucketTelemetryBytes(metadata.size), + rejected_too_large: true, + }); + throw new CliUserError(options.errorKeys.tooLarge, 2, { + max: options.maxSizeBytes, + path: resolvedPath, + size: metadata.size, + }); + } + + return { + file: Bun.file(resolvedPath), + fileName: basename(resolvedPath), + fileSize: metadata.size, + }; +} diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index e4e1b0e8..d85bc770 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -631,6 +631,20 @@ const commandTelemetryDecisions = { kind: "generic", reason: "Generic command telemetry is enough; the CLI version and commit are already attached to every event via the global cli_version and cli_commit dimensions, and build time is not telemetry-relevant.", }, + "website": { + kind: "generic", + reason: "Command group; child commands record website dimensions where safe.", + }, + "website.upload": { + kind: "properties", + properties: [ + "bytes_total_bucket", + "identity_source", + "rejected_not_html", + "rejected_too_large", + ], + reason: "Records upload size bucket, both rejection states (not an HTML file, too large), and the identity source (none/flag/env_id/env_name/account) without path, filename, public URL, or team name/id.", + }, } as const satisfies Record; describe("command telemetry decisions", () => { diff --git a/src/application/commands/website/__snapshots__/upload.cli.test.ts.snap b/src/application/commands/website/__snapshots__/upload.cli.test.ts.snap new file mode 100644 index 00000000..f9258913 --- /dev/null +++ b/src/application/commands/website/__snapshots__/upload.cli.test.ts.snap @@ -0,0 +1,63 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`website upload CLI uploads an HTML file and prints its public URL 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": +"Uploaded index.html. + - File size: 32 B + - URL: https://r2.inklycat.com/v1/team-1/site-1/index.html +" +, +} +`; + +exports[`website upload CLI supports json output for both --json and --format=json 1`] = ` +{ + "jsonAliasResult": { + "exitCode": 0, + "stderr": "", + "stdout": +"{"fileName":"page.htm","fileSize":32,"url":"https://r2.inklycat.com/v1/team-1/site-1/index.html"} +" +, + }, + "jsonFormatResult": { + "exitCode": 0, + "stderr": "", + "stdout": +"{"fileName":"page.htm","fileSize":32,"url":"https://r2.inklycat.com/v1/team-1/site-1/index.html"} +" +, + }, +} +`; + +exports[`website upload CLI supports website upload command help 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": +"Upload one HTML file to long-term website storage and print the public URL it is +served from. + +Arguments: + filePath File path + +Options: + --team Upload the website under the given team identity + --format Specify output format (use json for structured output) + --json Alias for --format=json + --show-schema-version Include schemaVersion in JSON output (no effect without + --json) + -h, --help Show help for command + +Global Options: + -V, --version Show the current version + --debug Print the current log file path when the CLI exits + --lang Specify the display language +" +, +} +`; diff --git a/src/application/commands/website/index.ts b/src/application/commands/website/index.ts new file mode 100644 index 00000000..51341101 --- /dev/null +++ b/src/application/commands/website/index.ts @@ -0,0 +1,10 @@ +import type { CliCommandDefinition } from "../../contracts/cli.ts"; + +import { websiteUploadCommand } from "./upload.ts"; + +export const websiteCommand: CliCommandDefinition = { + name: "website", + summaryKey: "commands.website.summary", + descriptionKey: "commands.website.description", + children: [websiteUploadCommand], +}; diff --git a/src/application/commands/website/upload.cli.test.ts b/src/application/commands/website/upload.cli.test.ts new file mode 100644 index 00000000..93497c40 --- /dev/null +++ b/src/application/commands/website/upload.cli.test.ts @@ -0,0 +1,527 @@ +import { truncate } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { + createCliSandbox, + createCliSnapshot, + expectTelemetryFreeOfTeamIdentity, + toRequest, + writeAuthFile, + writeAuthFileWithDefaultTeam, +} from "../../../../__tests__/helpers.ts"; +import { APP_NAME } from "../../config/app-config.ts"; +import { + parseTelemetryRowPayload, + readTelemetryRowsForTest, +} from "../../telemetry/outbox.ts"; +import { maxWebsiteUploadSizeBytes } from "./upload.ts"; + +const generateUploadUrlPath = "/v1/single-website/action/generate-upload-url"; +const uploadUrl = "https://oomol-store-single-website.account.r2.cloudflarestorage.com/v1/team-1/site-1/index.html?X-Amz-Signature=upload-secret"; +const publicUrl = "https://r2.inklycat.com/v1/team-1/site-1/index.html"; +const htmlContent = "hi"; + +describe("website upload CLI", () => { + test("uploads an HTML file and prints its public URL", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, htmlContent); + + const { requests, result } = await runRecordedUpload(sandbox, [ + "website", + "upload", + localFilePath, + ]); + const telemetryPayload = readUploadTelemetryPayload(sandbox); + + expect(createCliSnapshot(result)).toMatchSnapshot(); + expect(requests.map(request => request.url)).toEqual([ + `https://fusion-api.oomol.com${generateUploadUrlPath}`, + uploadUrl, + ]); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.headers.get("Authorization")).toBe("secret-1"); + await expect(requests[0]?.json()).resolves.toEqual({ + fileSize: htmlContent.length, + }); + // The presigned upload authenticates through its signature alone. + expect(requests[1]?.method).toBe("PUT"); + expect(requests[1]?.headers.get("Authorization")).toBeNull(); + expect(requests[1]?.headers.get("Content-Type")).toBe("text/html"); + await expect(requests[1]?.text()).resolves.toBe(htmlContent); + expect(telemetryPayload).toMatchObject({ + properties: { + bytes_total_bucket: "<1KB", + command_full: "website.upload", + identity_source: "none", + rejected_not_html: false, + rejected_too_large: false, + }, + }); + expect(telemetryPayload?.properties).not.toHaveProperty("url"); + expect(JSON.stringify(telemetryPayload)).not.toContain("inklycat"); + } + finally { + await sandbox.cleanup(); + } + }); + + test("supports json output for both --json and --format=json", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "page.htm"); + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, htmlContent); + + const jsonAliasResult = await runRecordedUpload(sandbox, [ + "website", + "upload", + localFilePath, + "--json", + ]); + const jsonFormatResult = await runRecordedUpload(sandbox, [ + "website", + "upload", + localFilePath, + "--format=json", + ]); + + expect({ + jsonAliasResult: createCliSnapshot(jsonAliasResult.result), + jsonFormatResult: createCliSnapshot(jsonFormatResult.result), + }).toMatchSnapshot(); + expect(JSON.parse(jsonAliasResult.result.stdout)).toEqual({ + fileName: "page.htm", + fileSize: htmlContent.length, + url: publicUrl, + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("uses the development fusion-api host for development accounts", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + + try { + await writeAuthFile(sandbox, { + accounts: [ + { + id: "user-1", + name: "Alice", + apiKey: "secret-1", + endpoint: "oomol.dev", + }, + ], + }); + await Bun.write(localFilePath, htmlContent); + + const { requests, result } = await runRecordedUpload(sandbox, [ + "website", + "upload", + localFilePath, + ]); + + expect(result.exitCode).toBe(0); + expect(requests[0]?.url).toBe( + `https://fusion-api.oomol.dev${generateUploadUrlPath}`, + ); + } + finally { + await sandbox.cleanup(); + } + }); + + test("requires login before uploading", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + let called = false; + + try { + await Bun.write(localFilePath, htmlContent); + + const result = await sandbox.run(["website", "upload", localFilePath], { + fetcher: async () => { + called = true; + return new Response("{}"); + }, + }); + + expect(createCliSnapshot(result)).toEqual({ + exitCode: 1, + stderr: "You must log in before using this command.\n", + stdout: "", + }); + expect(called).toBe(false); + } + finally { + await sandbox.cleanup(); + } + }); + + test("rejects files that are not HTML without sending a request", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "report.pdf"); + let called = false; + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, "%PDF-1.7"); + + const result = await sandbox.run(["website", "upload", localFilePath], { + fetcher: async () => { + called = true; + return new Response("{}"); + }, + }); + const telemetryPayload = readUploadTelemetryPayload(sandbox); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe( + `The file ${localFilePath} is not an HTML file. Use a file that ends with .html or .htm.\n`, + ); + expect(called).toBe(false); + expect(telemetryPayload).toMatchObject({ + properties: { + command_full: "website.upload", + error_category: "user_error", + rejected_not_html: true, + }, + }); + expect(telemetryPayload?.properties).not.toHaveProperty("path"); + expect(JSON.stringify(telemetryPayload)).not.toContain("report.pdf"); + } + finally { + await sandbox.cleanup(); + } + }); + + test("accepts an upper-case HTML extension", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "INDEX.HTML"); + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, htmlContent); + + const { result } = await runRecordedUpload(sandbox, [ + "website", + "upload", + localFilePath, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(publicUrl); + } + finally { + await sandbox.cleanup(); + } + }); + + test("records rejected too large telemetry without file identity", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "large-site.html"); + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, ""); + await truncate(localFilePath, maxWebsiteUploadSizeBytes + 1); + + const result = await sandbox.run(["website", "upload", localFilePath]); + const telemetryPayload = readUploadTelemetryPayload(sandbox); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe( + `The file at ${localFilePath} is ${maxWebsiteUploadSizeBytes + 1} bytes, which exceeds the 20 MiB limit of ${maxWebsiteUploadSizeBytes} bytes.\n`, + ); + expect(telemetryPayload).toMatchObject({ + properties: { + bytes_total_bucket: "<100MB", + command_full: "website.upload", + error_category: "user_error", + rejected_not_html: false, + rejected_too_large: true, + }, + }); + expect(telemetryPayload?.properties).not.toHaveProperty("file_name"); + expect(telemetryPayload?.properties).not.toHaveProperty("path"); + } + finally { + await Bun.file(localFilePath).delete(); + await sandbox.cleanup(); + } + }); + + test("explains a missing team context instead of the raw status", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, htmlContent); + + const result = await sandbox.run(["website", "upload", localFilePath], { + fetcher: async () => new Response(JSON.stringify({ + success: false, + message: "request has no team context", + data: null, + errorCode: "provider_error", + }), { status: 400 }), + }); + + expect(createCliSnapshot(result)).toEqual({ + exitCode: 1, + stderr: "Websites are stored per team, and this account belongs to no team. Create or join a team, then run the command again.\n", + stdout: "", + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("reports other service failures with their status", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, htmlContent); + + const result = await sandbox.run(["website", "upload", localFilePath], { + fetcher: async () => new Response(JSON.stringify({ + success: false, + message: "fileSize too large", + }), { status: 400 }), + }); + + expect(createCliSnapshot(result)).toEqual({ + exitCode: 1, + stderr: "The website upload request returned HTTP 400.\n", + stdout: "", + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("rejects unsupported service response bodies", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, htmlContent); + + const result = await sandbox.run(["website", "upload", localFilePath], { + fetcher: async () => new Response(JSON.stringify({ + success: true, + data: { publicURL: "not a url", uploadURL: uploadUrl }, + })), + }); + + expect(createCliSnapshot(result)).toEqual({ + exitCode: 1, + stderr: "The website upload service returned an unsupported response body.\n", + stdout: "", + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("fails when storage rejects the upload", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + let storageRequestCount = 0; + + try { + await writeAuthFile(sandbox); + await Bun.write(localFilePath, htmlContent); + + const result = await sandbox.run(["website", "upload", localFilePath], { + fetcher: async (input, init) => { + const request = toRequest(input, init); + + if (request.url === uploadUrl) { + storageRequestCount += 1; + + return new Response("SignatureDoesNotMatch", { status: 403 }); + } + + return createUploadUrlResponse(); + }, + }); + + expect(createCliSnapshot(result)).toEqual({ + exitCode: 1, + stderr: "The website upload request returned HTTP 403.\n", + stdout: "", + }); + // A signature mismatch is final; only transient statuses retry. + expect(storageRequestCount).toBe(1); + } + finally { + await sandbox.cleanup(); + } + }); + + test("supports website upload command help", async () => { + const sandbox = await createCliSandbox(); + + try { + const result = await sandbox.run(["website", "upload", "--help"]); + + expect(createCliSnapshot(result)).toMatchSnapshot(); + } + finally { + await sandbox.cleanup(); + } + }); +}); + +describe("website upload team identity", () => { + test("sends the account default team headers to the website service only", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "alice-team", { + teamId: "team-system-1", + }); + await Bun.write(localFilePath, htmlContent); + + const { requests, result } = await runRecordedUpload(sandbox, [ + "website", + "upload", + localFilePath, + ]); + const telemetryPayload = readUploadTelemetryPayload(sandbox); + + expect(result.exitCode).toBe(0); + expect(readTeamHeaders(requests)).toEqual([ + ["alice-team", "team-system-1"], + // The presigned upload goes straight to storage and must not + // leak the team selection outside the OOMOL gateway. + [null, null], + ]); + expect(telemetryPayload?.properties).toMatchObject({ + identity_source: "account", + }); + expectTelemetryFreeOfTeamIdentity(telemetryPayload?.properties, [ + "alice-team", + "team-system-1", + ]); + } + finally { + await sandbox.cleanup(); + } + }); + + test("uploads under the team given by --team instead of the account default", async () => { + const sandbox = await createCliSandbox(); + const localFilePath = join(sandbox.env.HOME!, "index.html"); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "alice-team", { + teamId: "team-system-1", + }); + await Bun.write(localFilePath, htmlContent); + + const { requests, result } = await runRecordedUpload(sandbox, [ + "website", + "upload", + localFilePath, + "--team", + "acme", + ]); + const telemetryPayload = readUploadTelemetryPayload(sandbox); + + expect(result.exitCode).toBe(0); + expect(readTeamHeaders(requests)).toEqual([ + ["acme", null], + [null, null], + ]); + expect(telemetryPayload?.properties).toMatchObject({ + identity_source: "flag", + }); + expectTelemetryFreeOfTeamIdentity(telemetryPayload?.properties, [ + "acme", + "alice-team", + ]); + } + finally { + await sandbox.cleanup(); + } + }); +}); + +function createUploadUrlResponse(): Response { + return new Response(JSON.stringify({ + success: true, + data: { + contentLength: htmlContent.length, + contentType: "text/html", + expiresIn: 36_000, + key: "v1/team-1/site-1/index.html", + publicURL: publicUrl, + uploadURL: uploadUrl, + }, + })); +} + +async function runRecordedUpload( + sandbox: Awaited>, + argv: readonly string[], +): Promise<{ + requests: Request[]; + result: Awaited>; +}> { + const requests: Request[] = []; + const result = await sandbox.run([...argv], { + fetcher: async (input, init) => { + const request = toRequest(input, init); + + requests.push(request); + + if (request.url === uploadUrl) { + return new Response(null, { + headers: { ETag: "\"etag-1\"" }, + status: 200, + }); + } + + return createUploadUrlResponse(); + }, + }); + + return { requests, result }; +} + +function readTeamHeaders( + requests: readonly Request[], +): Array<[string | null, string | null]> { + return requests.map(request => [ + request.headers.get("x-oo-team-name"), + request.headers.get("x-oo-team-id"), + ]); +} + +function readUploadTelemetryPayload( + sandbox: Awaited>, +) { + return parseTelemetryRowPayload( + readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + )[0]!, + ); +} diff --git a/src/application/commands/website/upload.ts b/src/application/commands/website/upload.ts new file mode 100644 index 00000000..b2ca82ec --- /dev/null +++ b/src/application/commands/website/upload.ts @@ -0,0 +1,198 @@ +import type { CliCommandDefinition, CliExecutionContext } from "../../contracts/cli.ts"; +import type { AuthAccount } from "../../schemas/auth.ts"; +import type { OoRequestFailure } from "../shared/oo-request.ts"; +import type { UploadSourceFileErrorKeys } from "../shared/upload-source-file.ts"; +import type { TeamIdentity } from "../team/identity.ts"; + +import { extname } from "node:path"; +import { z } from "zod"; +import { requireIdentity } from "../../auth/identity.ts"; +import { CliUserError } from "../../contracts/cli.ts"; +import { createRetryingFetcher } from "../../shared/retrying-fetcher.ts"; +import { bucketTelemetryBytes } from "../../telemetry/buckets.ts"; +import { formatFileSize } from "../shared/file-size.ts"; +import { requestOo, requestOoResponse } from "../shared/oo-request.ts"; +import { readUploadSourceFile } from "../shared/upload-source-file.ts"; +import { + resolveAccountTeamIdentity, + teamIdentityHeaders, + teamIdentityInputShape, + teamOption, +} from "../team/identity.ts"; + +interface WebsiteUploadInput { + filePath: string; + team?: string; +} + +interface WebsiteUploadView { + fileName: string; + fileSize: number; + url: string; +} + +interface WebsiteUploadTarget { + publicUrl: string; + uploadUrl: string; +} + +type WebsiteRequestContext = Pick; + +// The single-website service stores exactly one HTML object per upload and +// signs its size and content type into the upload URL, so both are fixed here +// and must match what the storage request carries byte for byte. +export const maxWebsiteUploadSizeBytes = 20 * 1024 * 1024; +const websiteContentType = "text/html"; +const htmlExtensions = new Set([".html", ".htm"]); +const websiteUploadExtraRetries = 1; + +// The service stores websites per team and refuses to fall back to personal +// storage; this is its message when the gateway established no team context. +const teamContextMissingMessage = "request has no team context"; + +const websiteSourceFileErrorKeys: UploadSourceFileErrorKeys = { + pathNotFile: "errors.websiteUpload.pathNotFile", + readFailed: "errors.websiteUpload.readFailed", + tooLarge: "errors.websiteUpload.tooLarge", +}; + +const generateUploadUrlResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + publicURL: z.url(), + uploadURL: z.url(), + }).passthrough(), +}).passthrough(); + +export const websiteUploadCommand: CliCommandDefinition = { + name: "upload", + summaryKey: "commands.website.upload.summary", + descriptionKey: "commands.website.upload.description", + missingArgumentBehavior: "showHelp", + arguments: [ + { + name: "filePath", + descriptionKey: "arguments.filePath", + required: true, + }, + ], + options: [teamOption("options.websiteUploadTeam")], + output: "standard", + inputSchema: z.object({ + filePath: z.string(), + ...teamIdentityInputShape, + }), + handler: async (input, context) => { + const { account } = await requireIdentity(context); + const identity = await resolveAccountTeamIdentity(input, account, context); + + // The object is served as text/html whatever its bytes are, so only a + // file that names itself HTML is accepted. + if (!htmlExtensions.has(extname(input.filePath).toLowerCase())) { + context.telemetry?.recordProperties({ rejected_not_html: true }); + throw new CliUserError("errors.websiteUpload.notHtml", 2, { + path: input.filePath, + }); + } + + context.telemetry?.recordProperties({ rejected_not_html: false }); + + const sourceFile = await readUploadSourceFile(input.filePath, context.cwd, { + errorKeys: websiteSourceFileErrorKeys, + maxSizeBytes: maxWebsiteUploadSizeBytes, + recordTelemetryProperties: context.telemetry?.recordProperties, + }); + // The bytes that are actually sent decide the size the service signs, + // so the request reports their length rather than the earlier stat. + const fileBytes = new Uint8Array(await sourceFile.file.arrayBuffer()); + const fileSize = fileBytes.byteLength; + + context.telemetry?.recordProperties({ + bytes_total_bucket: bucketTelemetryBytes(fileSize), + rejected_too_large: false, + }); + + const target = await generateWebsiteUploadUrl(account, identity, fileSize, context); + + await uploadWebsiteFile(target.uploadUrl, fileBytes, context); + + const view: WebsiteUploadView = { + fileName: sourceFile.fileName, + fileSize, + url: target.publicUrl, + }; + + context.output.emit(view, () => { + const lines = [ + context.translator.t("website.upload.success", { + fileName: view.fileName, + }), + ` - ${context.translator.t("file.text.fileSize")}: ${formatFileSize(view.fileSize)}`, + ` - ${context.translator.t("website.text.url")}: ${view.url}`, + ]; + + context.stdout.write(`${lines.join("\n")}\n`); + }); + }, +}; + +// The website service call carries the team headers; the presigned upload goes +// straight to storage and never does (see uploadWebsiteFile). +async function generateWebsiteUploadUrl( + account: Pick, + identity: TeamIdentity | undefined, + fileSize: number, + context: WebsiteRequestContext, +): Promise { + const response = await requestOo({ + authorization: account.apiKey, + context, + errors: { scope: "websiteUpload" }, + headers: teamIdentityHeaders(identity), + host: { endpoint: account.endpoint, service: "fusion-api" }, + jsonBody: { fileSize }, + label: "Website upload", + method: "POST", + path: "/v1/single-website/action/generate-upload-url", + schema: generateUploadUrlResponseSchema, + statusErrors: mapWebsiteUploadError, + }); + + return { + publicUrl: response.data.publicURL, + uploadUrl: response.data.uploadURL, + }; +} + +async function uploadWebsiteFile( + uploadUrl: string, + fileBytes: Uint8Array, + context: WebsiteRequestContext, +): Promise { + await requestOoResponse({ + body: fileBytes, + context: { + fetcher: createRetryingFetcher({ + fetcher: context.fetcher, + logger: context.logger, + maxRetries: websiteUploadExtraRetries, + }), + logger: context.logger, + translator: context.translator, + }, + errors: { scope: "websiteUpload" }, + headers: { + "Content-Type": websiteContentType, + }, + host: { baseUrl: uploadUrl }, + label: "Website upload file", + method: "PUT", + }); +} + +function mapWebsiteUploadError(failure: OoRequestFailure): CliUserError | undefined { + return failure.status === 400 + && failure.bodyText?.includes(teamContextMissingMessage) === true + ? new CliUserError("errors.websiteUpload.teamRequired", 1) + : undefined; +} diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 7b7f2438..f0e0a9ad 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -1395,6 +1395,31 @@ export const enMessages = { "variables.list.empty": "No variables.", "variables.create.success": "Saved variable {name} (updated {updatedAt}).", "variables.delete.success": "Deleted variable {name}.", + "commands.website.summary": "Publish single-file websites", + "commands.website.description": "Upload single HTML files to long-term website storage.", + "commands.website.upload.summary": "Upload an HTML file and print its public URL", + "commands.website.upload.description": + "Upload one HTML file to long-term website storage and print the public URL it is served from.", + "options.websiteUploadTeam": + "Upload the website under the given team identity", + "errors.websiteUpload.invalidResponse": + "The website upload service returned an unsupported response body.", + "errors.websiteUpload.notHtml": + "The file {path} is not an HTML file. Use a file that ends with .html or .htm.", + "errors.websiteUpload.pathNotFile": + "The path {path} is not a regular file.", + "errors.websiteUpload.readFailed": + "Failed to read file metadata from {path}: {message}", + "errors.websiteUpload.requestError": + "The website upload request failed: {message}", + "errors.websiteUpload.requestFailed": + "The website upload request returned HTTP {status}.", + "errors.websiteUpload.teamRequired": + "Websites are stored per team, and this account belongs to no team. Create or join a team, then run the command again.", + "errors.websiteUpload.tooLarge": + "The file at {path} is {size} bytes, which exceeds the 20 MiB limit of {max} bytes.", + "website.text.url": "URL", + "website.upload.success": "Uploaded {fileName}.", } as const; export const zhMessages = { @@ -2741,6 +2766,31 @@ export const zhMessages = { "variables.list.empty": "暂无变量。", "variables.create.success": "已保存变量 {name}(更新于 {updatedAt})。", "variables.delete.success": "已删除变量 {name}。", + "commands.website.summary": "发布单文件网站", + "commands.website.description": "将单个 HTML 文件上传到长期网站存储。", + "commands.website.upload.summary": "上传 HTML 文件并输出公开 URL", + "commands.website.upload.description": + "将单个 HTML 文件上传到长期网站存储,并输出其公开访问 URL。", + "options.websiteUploadTeam": + "以指定团队身份上传该网站", + "errors.websiteUpload.invalidResponse": + "网站上传服务返回了不受支持的响应内容。", + "errors.websiteUpload.notHtml": + "文件 {path} 不是 HTML 文件。请使用以 .html 或 .htm 结尾的文件。", + "errors.websiteUpload.pathNotFile": + "路径 {path} 不是普通文件。", + "errors.websiteUpload.readFailed": + "读取文件 {path} 的元数据失败:{message}", + "errors.websiteUpload.requestError": + "网站上传请求失败:{message}", + "errors.websiteUpload.requestFailed": + "网站上传请求返回了 HTTP {status}。", + "errors.websiteUpload.teamRequired": + "网站按团队存储,而当前账号不属于任何团队。请先创建或加入团队,然后重新执行该命令。", + "errors.websiteUpload.tooLarge": + "文件 {path} 的大小为 {size} 字节,超出了 20 MiB 上限 {max} 字节。", + "website.text.url": "URL", + "website.upload.success": "已上传 {fileName}。", } satisfies Record; export const messageCatalog = {