From 2cfc403ddff8ac247052f12f694663c03aceb3f2 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Mon, 6 Jul 2026 01:11:14 -0400 Subject: [PATCH 1/2] feat(connector): list all connected apps with org identity `oo connector apps` now answers "which providers are connected": the service argument is optional, and omitting it lists every connected app under the effective identity. Add `--organization` (alias `--org`) and `--personal` flags mirroring `connector run`, so the listing can be scoped to an organization or forced to the personal identity; a self-hosted connector rejects `--organization` and ignores any configured default. Both backends are queried through `GET /v1/apps?status=active`, not the open-source `/api/connections` route. In the self-hosted open-connector, `/v1/*` is the runtime-token auth scope the CLI already holds, while `/api/connections` requires an admin token the CLI never has; `/v1/apps` also returns the same app-view shape as the hosted service, so one schema parses both. Replace the tab-separated text output with a column-aligned, color-coded table: services use the connector accent color, statuses are green/yellow/red by health, and the default connection is marked with a green check. Colors flow through the writer-aware palette, so piped or `NO_COLOR` output stays plain aligned text. Signed-off-by: Kevin Cui --- docs/commands.md | 27 +- docs/commands.zh-CN.md | 18 +- .../commands/connector/apps.test.ts | 110 +++++ src/application/commands/connector/apps.ts | 218 +++++++-- .../commands/connector/index.cli.test.ts | 432 +++++++++++++++++- src/application/commands/connector/shared.ts | 109 +++-- .../commands/telemetry-decisions.test.ts | 9 +- src/i18n/catalog.ts | 24 +- 8 files changed, 861 insertions(+), 86 deletions(-) create mode 100644 src/application/commands/connector/apps.test.ts diff --git a/docs/commands.md b/docs/commands.md index a53d620..d095b1e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -691,21 +691,38 @@ Validate input data and run one connector action. existing unsupported errors because the self-hosted runtime does not expose the async lifecycle contract. -### `oo connector apps ` +### `oo connector apps [serviceName]` -List connected connector apps for one service. This command is read-only. +List connected connector apps under the effective identity. This command is +read-only. -- Arguments: `` is the service name. +- Arguments: `[serviceName]` is optional. When omitted, the command lists every + connected app across all providers. When provided, the listing is scoped to + that one service. +- Options: `--organization ` lists connected apps under the given + organization identity instead of your personal identity. `--org ` is an + alias for `--organization `. When omitted, the listing uses the + `identity.organization` config default if set, otherwise your personal + identity. +- Options: `--personal` lists connected apps under your personal identity and + ignores any configured default organization. It cannot be combined with + `--organization`. - Options: `--format=json` and `--json` print a JSON array. - Output: JSON entries include the stable CLI fields `service`, `connectionName`, `displayName`, `accountLabel`, `status`, `authType`, `isDefault`, and `scopes`. App id fields are not included. - Output: when an app has no connection name, JSON output uses `null` and text output prints `-`. -- Output: text output prints one tab-separated row per app with connection - name, name, status, auth type, and default marker. +- Output: text output prints one column-aligned row per app. The listing across + all providers leads with a `Service` column; the single-service listing omits + it because the service is fixed by the argument. On a color-capable terminal + the status and default columns are color-coded; piped or `NO_COLOR` output is + plain aligned text. - Notes: use the listed `connectionName` value with `oo connector run --connection-name `. +- Notes: against a self-hosted connector, `--organization` is rejected with exit + `2`, a configured `identity.organization` default is ignored, and `--personal` + is accepted. ### `oo connector proxy ` diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index e361ded..47d1141 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -591,19 +591,29 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- 由于自部署 runtime 不提供异步 lifecycle contract,`--wait` 和 `--wait-result` 会以现有的“不支持”错误失败。 -### `oo connector apps ` +### `oo connector apps [serviceName]` -列出一个服务下已连接的 connector app。该命令只读。 +按当前生效身份列出已连接的 connector app。该命令只读。 -- 参数:`` 为服务名。 +- 参数:`[serviceName]` 可选。省略时列出所有 provider 下已连接的 app;提供时仅列出 + 该服务的 app。 +- 选项:`--organization ` 以指定组织身份列出已连接的 app,而非个人身份。 + `--org ` 是 `--organization ` 的别名。省略时,若配置了 + `identity.organization` 默认值则按该组织列出,否则按个人身份列出。 +- 选项:`--personal` 以个人身份列出已连接的 app,并忽略已配置的默认组织。该选项不能与 + `--organization` 同时使用。 - 选项:`--format=json` 和 `--json` 会输出 JSON 数组。 - 输出:JSON 条目包含稳定 CLI 字段 `service`、`connectionName`、`displayName`、 `accountLabel`、`status`、`authType`、`isDefault` 和 `scopes`。不会包含 app id 字段。 - 输出:当 app 没有连接名称时,JSON 输出使用 `null`,文本输出显示 `-`。 -- 输出:文本输出每个 app 一行,以 tab 分隔连接名称、名称、状态、认证类型和默认标记。 +- 输出:文本输出每个 app 一行,列对齐。跨全部 provider 的列表以 `Service` 列开头; + 单服务列表则省略该列,因为服务已由参数固定。在支持颜色的终端上,状态列与默认列会 + 着色;管道输出或 `NO_COLOR` 下为纯对齐文本。 - 说明:可将列出的 `connectionName` 值传给 `oo connector run --connection-name `。 +- 说明:对自部署 Connector,`--organization` 会以退出码 `2` 拒绝,已配置的 + `identity.organization` 默认值会被忽略,`--personal` 可正常使用。 ### `oo connector proxy ` diff --git a/src/application/commands/connector/apps.test.ts b/src/application/commands/connector/apps.test.ts new file mode 100644 index 0000000..ba929c1 --- /dev/null +++ b/src/application/commands/connector/apps.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; + +import { createTerminalColors } from "../../terminal-colors.ts"; +import { formatConnectorAppsAsText } from "./apps.ts"; + +type ConnectorAppRow = Parameters[0][number]; + +const greenOpenCode = ""; +const yellowOpenCode = ""; +const redOpenCode = ""; + +describe("formatConnectorAppsAsText", () => { + test("color-codes an active status, the service, and the default marker", () => { + const output = formatConnectorAppsAsText( + [sampleApp({ isDefault: true, service: "gmail", status: "active" })], + "all", + createTranslatorStub(), + createTerminalColors(true), + ); + + expect(output).toContain("["); + expect(output).toContain(greenOpenCode); + expect(output).toContain("✓"); + expect(output).toContain("gmail"); + }); + + test("colors a reauth-required status yellow and an error status red", () => { + const colors = createTerminalColors(true); + const reauth = formatConnectorAppsAsText( + [sampleApp({ status: "reauth_required" })], + "service", + createTranslatorStub(), + colors, + ); + const errored = formatConnectorAppsAsText( + [sampleApp({ status: "error" })], + "service", + createTranslatorStub(), + colors, + ); + + expect(reauth).toContain(yellowOpenCode); + expect(errored).toContain(redOpenCode); + }); + + test("aligns columns as plain text without escape sequences when colors are disabled", () => { + const output = formatConnectorAppsAsText( + [ + sampleApp({ displayName: "Work Gmail", service: "gmail" }), + sampleApp({ + connectionName: null, + displayName: "Linear", + isDefault: false, + service: "x", + }), + ], + "all", + createTranslatorStub(), + createTerminalColors(false), + ); + const lines = output.split("\n"); + + expect(output).not.toContain("["); + // Different service-name widths are padded to a shared column width, so + // the following column lines up across rows. + expect(lines[1]!.indexOf("Work Gmail")).toBe(lines[2]!.indexOf("Linear")); + // A missing connection name and a non-default app both render a dash. + expect(output).toContain("-"); + }); + + test("returns the no-connections message for an empty all-scope listing", () => { + const output = formatConnectorAppsAsText( + [], + "all", + createTranslatorStub(), + createTerminalColors(false), + ); + + expect(output).toBe("connector.apps.text.noConnections"); + }); + + test("returns the per-service no-results message for an empty service-scope listing", () => { + const output = formatConnectorAppsAsText( + [], + "service", + createTranslatorStub(), + createTerminalColors(false), + ); + + expect(output).toBe("connector.apps.text.noResults"); + }); +}); + +function sampleApp(overrides: Partial = {}): ConnectorAppRow { + return { + accountLabel: "user@example.com", + authType: "oauth2", + connectionName: "work", + displayName: "Work Gmail", + isDefault: true, + scopes: [], + service: "gmail", + status: "active", + ...overrides, + }; +} + +function createTranslatorStub(): { t: (key: string) => string } { + return { t: key => key }; +} diff --git a/src/application/commands/connector/apps.ts b/src/application/commands/connector/apps.ts index 33aac9e..f69547f 100644 --- a/src/application/commands/connector/apps.ts +++ b/src/application/commands/connector/apps.ts @@ -1,19 +1,43 @@ import type { CliCommandDefinition, CliExecutionContext } from "../../contracts/cli.ts"; +import type { TerminalColors } from "../../terminal-colors.ts"; + import type { ConnectorAppView } from "./shared.ts"; import { z } from "zod"; +import { CliUserError } from "../../contracts/cli.ts"; +import { getConfiguredIdentityOrganization } from "../../schemas/settings.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; +import { createWriterColors } from "../../terminal-colors.ts"; import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; import { createFormatInputError } from "../shared/input-parsing.ts"; +import { resolveConnectorIdentity } from "./identity.ts"; +import { connectorSearchServiceColor } from "./search-provider.ts"; import { connectorFormatValues, + listConnectorApps, listConnectorAppsByService, } from "./shared.ts"; import { resolveConnectorTarget } from "./target.ts"; +// Connector app connection statuses (from the apps API) mapped to the terminal +// color that conveys their health at a glance. Unknown statuses fall back to a +// neutral gray. +const connectorAppStatusColors = { + active: "green", + disconnected: "gray", + error: "red", + reauth_required: "yellow", +} as const; + +// Whether the command listed every connected app or scoped the listing to one +// service. Recorded as privacy-safe telemetry. +type ConnectorAppsListScope = "all" | "service"; + interface ConnectorAppsInput { format?: (typeof connectorFormatValues)[number]; - serviceName: string; + organization?: string; + personal?: boolean; + serviceName?: string; showSchemaVersion?: boolean; } @@ -32,37 +56,80 @@ export const connectorAppsCommand: CliCommandDefinition = { name: "apps", summaryKey: "commands.connector.apps.summary", descriptionKey: "commands.connector.apps.description", - missingArgumentBehavior: "showHelp", arguments: [ { name: "serviceName", - descriptionKey: "arguments.serviceName", - required: true, + descriptionKey: "arguments.connectorAppsServiceName", + required: false, }, ], options: [ + { + name: "organization", + longFlag: "--organization", + aliasFlags: ["--org"], + valueName: "organization", + descriptionKey: "options.connectorAppsOrganization", + }, + { + name: "personal", + longFlag: "--personal", + descriptionKey: "options.connectorAppsPersonal", + }, ...jsonOutputOptions, ], inputSchema: z.object({ format: z.enum(connectorFormatValues).optional(), - serviceName: z.string(), + organization: z.string().optional(), + personal: z.boolean().optional(), + serviceName: z.string().optional(), showSchemaVersion: z.boolean().optional(), }), mapInputError: (_, rawInput) => createFormatInputError(rawInput), handler: async (input, context) => { + if (input.personal === true && input.organization !== undefined) { + throw new CliUserError("errors.connectorRun.identityConflict", 2); + } + + const organizationFlag = input.organization?.trim(); + if (input.organization !== undefined && organizationFlag === "") { + throw new CliUserError("errors.connectorRun.organizationEmpty", 2); + } + + const serviceName = input.serviceName?.trim(); + const hasService = serviceName !== undefined && serviceName !== ""; + const listScope: ConnectorAppsListScope = hasService ? "service" : "all"; + const target = await resolveConnectorTarget(context); + // Mirrors `connector run`: the self-hosted runtime has no organization + // concept, so an explicit --organization is rejected and any configured + // default identity is ignored. + if (target.kind === "self_hosted" && organizationFlag !== undefined) { + throw new CliUserError("errors.connector.organizationUnsupported", 2); + } + + const settings = await context.settingsStore.read(); + const { identity, source: identitySource } = target.kind === "self_hosted" + ? { identity: {}, source: "personal" as const } + : resolveConnectorIdentity({ + configOrganization: getConfiguredIdentityOrganization(settings), + organizationFlag, + personalFlag: input.personal === true, + }); + context.telemetry?.recordProperties({ connector_kind: target.kind, + identity_source: identitySource, + list_scope: listScope, }); - const apps = await listConnectorAppsByService( - { - serviceName: input.serviceName, - target, - }, - context, - ); + const apps = serviceName !== undefined && serviceName !== "" + ? await listConnectorAppsByService( + { identity, serviceName, target }, + context, + ) + : await listConnectorApps({ identity, target }, context); const output = apps.map(createConnectorAppListItem); context.telemetry?.recordProperties({ @@ -76,7 +143,14 @@ export const connectorAppsCommand: CliCommandDefinition = { return; } - context.stdout.write(`${formatConnectorAppsAsText(output, context)}\n`); + context.stdout.write( + `${formatConnectorAppsAsText( + output, + listScope, + context.translator, + createWriterColors(context.stdout), + )}\n`, + ); }, }; @@ -93,32 +167,106 @@ function createConnectorAppListItem(app: ConnectorAppView): ConnectorAppListItem }; } -type ConnectorAppsTextContext = Pick; +type ConnectorAppsTranslator = Pick; -function formatConnectorAppsAsText( +interface ConnectorAppsColumn { + header: string; + render: (app: ConnectorAppListItem) => string; +} + +// Renders the app listing as a color-coded, column-aligned table. Colors are +// applied through the writer-aware palette, so a non-TTY / NO_COLOR stream (and +// tests) receive plain aligned text. +export function formatConnectorAppsAsText( apps: readonly ConnectorAppListItem[], - context: ConnectorAppsTextContext, + listScope: ConnectorAppsListScope, + translator: ConnectorAppsTranslator, + colors: TerminalColors, ): string { if (apps.length === 0) { - return context.translator.t("connector.apps.text.noResults"); + return translator.t( + listScope === "service" + ? "connector.apps.text.noResults" + : "connector.apps.text.noConnections", + ); } - return [ - [ - context.translator.t("connector.apps.text.connectionName"), - context.translator.t("connector.apps.text.name"), - context.translator.t("connector.apps.text.status"), - context.translator.t("connector.apps.text.auth"), - context.translator.t("connector.apps.text.default"), - ].join("\t"), - ...apps.map(app => [ - app.connectionName ?? "-", - app.displayName, - app.status, - app.authType ?? "-", - app.isDefault - ? context.translator.t("connector.apps.text.default.yes") - : context.translator.t("connector.apps.text.default.no"), - ].join("\t")), - ].join("\n"); + const columns = createConnectorAppsColumns(listScope, translator, colors); + const headerCells = columns.map(column => colors.dim(column.header)); + const rows = apps.map(app => columns.map(column => column.render(app))); + // Column widths are computed from the visible (ANSI-stripped) length so the + // color escape sequences never skew the alignment. + const widths = columns.map((_, index) => Math.max( + visibleWidth(headerCells[index]!, colors), + ...rows.map(row => visibleWidth(row[index]!, colors)), + )); + + return [headerCells, ...rows] + .map(cells => joinConnectorAppsRow(cells, widths, colors)) + .join("\n"); +} + +function createConnectorAppsColumns( + listScope: ConnectorAppsListScope, + translator: ConnectorAppsTranslator, + colors: TerminalColors, +): ConnectorAppsColumn[] { + const serviceColor = colors.hex(connectorSearchServiceColor); + // The list-all view spans services, so it leads with a Service column; the + // by-service view keeps its original columns because the service is implied + // by the argument. + const serviceColumn: ConnectorAppsColumn = { + header: translator.t("connector.apps.text.service"), + render: app => serviceColor(app.service), + }; + const columns: ConnectorAppsColumn[] = [ + { + header: translator.t("connector.apps.text.connectionName"), + render: app => app.connectionName ?? colors.dim("-"), + }, + { + header: translator.t("connector.apps.text.name"), + render: app => colors.bold(app.displayName), + }, + { + header: translator.t("connector.apps.text.status"), + render: app => colorConnectorAppStatus(app.status, colors), + }, + { + header: translator.t("connector.apps.text.auth"), + render: app => colors.dim(app.authType ?? "-"), + }, + { + header: translator.t("connector.apps.text.default"), + render: app => app.isDefault ? colors.green("✓") : colors.dim("-"), + }, + ]; + + return listScope === "all" ? [serviceColumn, ...columns] : columns; +} + +function colorConnectorAppStatus(status: string, colors: TerminalColors): string { + const colorName = status in connectorAppStatusColors + ? connectorAppStatusColors[status as keyof typeof connectorAppStatusColors] + : "gray"; + + return colors[colorName](status); +} + +// Pads every cell except the last to its column width (measured on visible +// characters) and joins the row with a two-space gutter. +function joinConnectorAppsRow( + cells: readonly string[], + widths: readonly number[], + colors: TerminalColors, +): string { + return cells + .map((cell, index) => index === cells.length - 1 + ? cell + : cell + " ".repeat(widths[index]! - visibleWidth(cell, colors))) + .join(" "); +} + +function visibleWidth(cell: string, colors: TerminalColors): number { + return colors.strip(cell).length; } diff --git a/src/application/commands/connector/index.cli.test.ts b/src/application/commands/connector/index.cli.test.ts index d14525e..7050c0d 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -1040,8 +1040,16 @@ describe("connectorCommand CLI", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Connection Name\tName\tStatus\tAuth\tDefault"); - expect(result.stdout).toContain("work\tWork Gmail\tactive\toauth2\tyes"); + // The by-service listing omits the Service column. + expect(result.stdout).not.toContain("Service"); + expect(result.stdout).toContain("Connection Name"); + expect(result.stdout).toContain("Default"); + expect(result.stdout).toContain("work"); + expect(result.stdout).toContain("Work Gmail"); + expect(result.stdout).toContain("active"); + expect(result.stdout).toContain("oauth2"); + // A default connection renders a check marker instead of a word. + expect(result.stdout).toContain("✓"); } finally { await sandbox.cleanup(); @@ -1117,7 +1125,12 @@ describe("connectorCommand CLI", () => { expect(JSON.parse(jsonResult.stdout)[0]).toMatchObject({ connectionName: null, }); - expect(textResult.stdout).toContain("-\tPersonal Gmail\tactive\t-\tno"); + expect(textResult.stdout).toContain("Personal Gmail"); + expect(textResult.stdout).toContain("active"); + // Missing connection name / auth render as a dash; a non-default app + // does not get the check marker. + expect(textResult.stdout).toContain("-"); + expect(textResult.stdout).not.toContain("✓"); } finally { await sandbox.cleanup(); @@ -1257,6 +1270,8 @@ describe("connectorCommand CLI", () => { expect(connectorHelp.stdout).toContain("apps"); expect(appsHelp.exitCode).toBe(0); expect(appsHelp.stdout).toContain("--json"); + expect(appsHelp.stdout).toContain("--organization"); + expect(appsHelp.stdout).toContain("--personal"); expect(appsHelp.stdout).toContain("List connected connector apps"); expect(appsHelp.stdout).not.toContain("disconnect"); expect(appsHelp.stdout).not.toContain("reconnect"); @@ -1267,6 +1282,417 @@ describe("connectorCommand CLI", () => { } }); + test("lists every connected connector app as json without a service argument", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const requests: Request[] = []; + const result = await sandbox.run( + ["connector", "apps", "--json"], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + data: [ + { + accountLabel: "user@example.com", + alias: "work", + authType: "oauth2", + displayName: "Work Gmail", + id: "app-1", + isDefault: true, + providerAccountId: "acct-1", + scopes: ["gmail.send"], + service: "gmail", + status: "active", + }, + { + accountLabel: "team", + alias: null, + authType: "oauth2", + displayName: "Linear", + id: "app-2", + isDefault: true, + providerAccountId: "acct-2", + scopes: [], + service: "linear", + status: "active", + }, + ], + })); + }, + }, + ); + const telemetryPayload = parseTelemetryRowPayload( + readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + )[0]!, + ); + + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("GET"); + expect(requests[0]?.url).toBe( + "https://connector.oomol.com/v1/apps?status=active", + ); + expect(requests[0]?.headers.get("x-oo-organization-name")).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual([ + { + accountLabel: "user@example.com", + authType: "oauth2", + connectionName: "work", + displayName: "Work Gmail", + isDefault: true, + scopes: ["gmail.send"], + service: "gmail", + status: "active", + }, + { + accountLabel: "team", + authType: "oauth2", + connectionName: null, + displayName: "Linear", + isDefault: true, + scopes: [], + service: "linear", + status: "active", + }, + ]); + expect(JSON.stringify(JSON.parse(result.stdout))).not.toContain("app-1"); + expect(telemetryPayload).toMatchObject({ + properties: { + command_full: "connector.apps", + identity_source: "personal", + list_scope: "all", + result_count_bucket: "1-5", + }, + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("lists every connected connector app as text with a service column", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const result = await sandbox.run( + ["connector", "apps"], + { + fetcher: async () => new Response(JSON.stringify({ + data: [ + { + accountLabel: "user@example.com", + alias: "work", + authType: "oauth2", + displayName: "Work Gmail", + isDefault: true, + scopes: ["gmail.send"], + service: "gmail", + status: "active", + }, + { + accountLabel: "team", + alias: null, + authType: "oauth2", + displayName: "Linear", + isDefault: true, + scopes: [], + service: "x", + status: "active", + }, + ], + })), + }, + ); + const lines = result.stdout.trimEnd().split("\n"); + + expect(result.exitCode).toBe(0); + // Header leads with the Service column across providers. + expect(lines[0]).toContain("Service"); + expect(lines[0]).toContain("Connection Name"); + expect(lines[0]).toContain("Default"); + expect(result.stdout).toContain("gmail"); + expect(result.stdout).toContain("Work Gmail"); + expect(result.stdout).toContain("Linear"); + expect(result.stdout).toContain("✓"); + // Columns are padded to a common width, so the "Name" column starts + // at the same offset regardless of service-name length. + expect(lines[1]!.indexOf("Work Gmail")).toBe(lines[2]!.indexOf("Linear")); + } + finally { + await sandbox.cleanup(); + } + }); + + test("lists every connected connector app under an organization identity", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const requests: Request[] = []; + const result = await sandbox.run( + ["connector", "apps", "--organization", "acme", "--json"], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ data: [] })); + }, + }, + ); + const telemetryPayload = parseTelemetryRowPayload( + readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + )[0]!, + ); + + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + "https://connector.oomol.com/v1/apps?status=active", + ); + expect(requests[0]?.headers.get("x-oo-organization-name")).toBe("acme"); + expect(telemetryPayload).toMatchObject({ + properties: { + command_full: "connector.apps", + identity_source: "flag", + list_scope: "all", + }, + }); + expect(telemetryPayload?.properties).not.toHaveProperty("organization"); + } + finally { + await sandbox.cleanup(); + } + }); + + test("lists every connected connector app under the configured default organization", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + await sandbox.run(["config", "set", "identity.organization", "acme"]); + + const requests: Request[] = []; + const result = await sandbox.run( + ["connector", "apps", "--json"], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ data: [] })); + }, + }, + ); + const appsTelemetryPayload = readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + ) + .map(row => parseTelemetryRowPayload(row)) + .find(payload => payload?.properties?.command_full === "connector.apps"); + + expect(result.exitCode).toBe(0); + expect(requests[0]?.headers.get("x-oo-organization-name")).toBe("acme"); + expect(appsTelemetryPayload).toMatchObject({ + properties: { + identity_source: "config", + list_scope: "all", + }, + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("applies the organization identity to the by-service apps listing", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const requests: Request[] = []; + const result = await sandbox.run( + ["connector", "apps", "gmail", "--organization", "acme", "--json"], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ data: [] })); + }, + }, + ); + const telemetryPayload = parseTelemetryRowPayload( + readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + )[0]!, + ); + + expect(result.exitCode).toBe(0); + expect(requests[0]?.url).toBe( + "https://connector.oomol.com/v1/apps/services/gmail", + ); + expect(requests[0]?.headers.get("x-oo-organization-name")).toBe("acme"); + expect(telemetryPayload).toMatchObject({ + properties: { + identity_source: "flag", + list_scope: "service", + }, + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("rejects conflicting identity flags when listing apps", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + let requestCount = 0; + const result = await sandbox.run( + ["connector", "apps", "--organization", "acme", "--personal"], + { + fetcher: async () => { + requestCount += 1; + + return new Response(JSON.stringify({ data: [] })); + }, + }, + ); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "Use either --organization or --personal, not both.", + ); + expect(requestCount).toBe(0); + } + finally { + await sandbox.cleanup(); + } + }); + + test("renders the no-connections message when no apps are connected", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const result = await sandbox.run( + ["connector", "apps"], + { + fetcher: async () => new Response(JSON.stringify({ data: [] })), + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "No connected connector apps were found.", + ); + } + finally { + await sandbox.cleanup(); + } + }); + + test("lists every connected app against a self-hosted connector without identity headers", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeConnectorFile(sandbox, { + url: "http://localhost:3000", + token: "oct_test", + }); + + const requests: Request[] = []; + const result = await sandbox.run( + ["connector", "apps", "--json"], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + data: [ + { + accountLabel: "default", + alias: "default", + authType: "oauth2", + displayName: "GitHub", + isDefault: true, + scopes: [], + service: "github", + status: "active", + }, + ], + })); + }, + }, + ); + const telemetryPayload = parseTelemetryRowPayload( + readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + )[0]!, + ); + + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("http://localhost:3000/v1/apps?status=active"); + expect(requests[0]?.headers.get("Authorization")).toBe("Bearer oct_test"); + expect(requests[0]?.headers.get("x-oo-organization-name")).toBeNull(); + expect(telemetryPayload).toMatchObject({ + properties: { + connector_kind: "self_hosted", + identity_source: "personal", + list_scope: "all", + }, + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("rejects --organization for a self-hosted connector when listing apps", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeConnectorFile(sandbox, { + url: "http://localhost:3000", + token: "oct_test", + }); + + let requestCount = 0; + const result = await sandbox.run( + ["connector", "apps", "--organization", "acme", "--json"], + { + fetcher: async () => { + requestCount += 1; + + return new Response(JSON.stringify({ data: [] })); + }, + }, + ); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "The --organization option is not supported by a self-hosted connector.", + ); + expect(requestCount).toBe(0); + } + finally { + await sandbox.cleanup(); + } + }); + test("supports connector run with cached schema and json output", async () => { const sandbox = await createCliSandbox(); diff --git a/src/application/commands/connector/shared.ts b/src/application/commands/connector/shared.ts index 100a7e5..7cd290c 100644 --- a/src/application/commands/connector/shared.ts +++ b/src/application/commands/connector/shared.ts @@ -145,7 +145,11 @@ const connectorAppViewSchema = z.object({ connectionName: alias, })); -const connectorAppsByServiceResponseSchema = z.object({ +// Both the list-all (`/v1/apps`) and by-service (`/v1/apps/services/{service}`) +// endpoints return the same `{ data: [appView] }` envelope, so a single schema +// covers both. The self-hosted open-source runtime exposes the same `/v1/apps` +// shape, so this parses every connector backend uniformly. +const connectorAppsResponseSchema = z.object({ data: z.array(connectorAppViewSchema), }); @@ -245,8 +249,44 @@ export async function searchConnectorActions( } } +// Lists every connected app under the effective identity. Backed by +// `GET /v1/apps?status=active`, which the OOMOL service and the open-source +// self-hosted runtime both implement with the same `{ data: [appView] }` +// envelope; the self-hosted runtime simply ignores the `status` filter. +export async function listConnectorApps( + options: { + identity?: ConnectorIdentity; + target: ConnectorRequestTarget; + }, + context: Pick, +): Promise { + const requestUrl = new URL(`${options.target.baseUrl}/v1/apps`); + + requestUrl.searchParams.set("status", "active"); + + return parseConnectorAppsResponse( + await requestText({ + context, + createRequestFailedError: createConnectorAppsRequestFailedError, + createUnexpectedError: createConnectorAppsUnexpectedError( + options.target, + context.translator, + ), + init: { + headers: { + ...connectorAuthorizationHeaders(options.target), + ...connectorIdentityHeaders(options.identity), + }, + }, + requestLabel: "Connector apps list", + requestUrl, + }), + ); +} + export async function listConnectorAppsByService( options: { + identity?: ConnectorIdentity; serviceName: string; target: ConnectorRequestTarget; }, @@ -256,40 +296,34 @@ export async function listConnectorAppsByService( `${options.target.baseUrl}/v1/apps/services/${encodeURIComponent(options.serviceName)}`, ); - const rawResponse = await requestText({ - context, - createRequestFailedError: status => new CliUserError( - "errors.connectorApps.requestFailed", - 1, - { - status, - }, - ), - createUnexpectedError: error => new CliUserError( - "errors.connectorApps.requestError", - 1, - { - message: createConnectorUnexpectedErrorMessage( - error, - options.target, - context.translator, - ), + return parseConnectorAppsResponse( + await requestText({ + context, + createRequestFailedError: createConnectorAppsRequestFailedError, + createUnexpectedError: createConnectorAppsUnexpectedError( + options.target, + context.translator, + ), + fields: { + start: { + serviceName: options.serviceName, + }, }, - ), - fields: { - start: { - serviceName: options.serviceName, + init: { + headers: { + ...connectorAuthorizationHeaders(options.target), + ...connectorIdentityHeaders(options.identity), + }, }, - }, - init: { - headers: connectorAuthorizationHeaders(options.target), - }, - requestLabel: "Connector apps list", - requestUrl, - }); + requestLabel: "Connector apps list", + requestUrl, + }), + ); +} +function parseConnectorAppsResponse(rawResponse: string): ConnectorAppView[] { try { - return connectorAppsByServiceResponseSchema.parse( + return connectorAppsResponseSchema.parse( JSON.parse(rawResponse) as unknown, ).data; } @@ -298,6 +332,19 @@ export async function listConnectorAppsByService( } } +function createConnectorAppsRequestFailedError(status: number): CliUserError { + return new CliUserError("errors.connectorApps.requestFailed", 1, { status }); +} + +function createConnectorAppsUnexpectedError( + target: ConnectorRequestTarget, + translator: Pick, +): (error: unknown) => CliUserError { + return error => new CliUserError("errors.connectorApps.requestError", 1, { + message: createConnectorUnexpectedErrorMessage(error, target, translator), + }); +} + export async function getConnectorActionMetadata( options: { actionName: string; diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index 9186fad..bb1a940 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -133,8 +133,13 @@ const commandTelemetryDecisions = { }, "connector.apps": { kind: "properties", - properties: ["connector_kind", "result_count_bucket"], - reason: "Records bounded connector app list size and the connector target kind (oomol/self_hosted) without app ids, connection names, account labels, or server URLs.", + properties: [ + "connector_kind", + "identity_source", + "list_scope", + "result_count_bucket", + ], + reason: "Records bounded connector app list size, the connector target kind (oomol/self_hosted), the identity source (personal/flag/config), and whether the listing was scoped to all apps or one service, without app ids, connection names, account labels, organization names, or server URLs.", }, "connector.login": { kind: "properties", diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index ec1a22d..b0d1dd1 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -64,7 +64,7 @@ export const enMessages = { "Proxy a provider API request through a connected connector app.", "commands.connector.proxy.summary": "Proxy a connector API request", "commands.connector.apps.description": - "List connected connector apps for one service.", + "List connected connector apps, optionally scoped to one service.", "commands.connector.apps.summary": "List connector apps", "commands.connector.login.description": "Validate and save a self-hosted connector server so connector commands use it instead of the OOMOL-hosted connector.", @@ -878,6 +878,10 @@ export const enMessages = { "Submit an async action and wait for its result action", "options.connectorRunConnectionName": "Run the action with the connector app connection name", + "options.connectorAppsOrganization": + "List connected apps under the given organization identity (alias: --org)", + "options.connectorAppsPersonal": + "List connected apps under your personal identity, ignoring any configured default organization", "options.connectorRunOrganization": "Run the action under the given organization identity (alias: --org)", "options.connectorRunPersonal": @@ -1154,6 +1158,7 @@ export const enMessages = { "arguments.skills.checkUpdate.packageName": "Package name(s) to check; checks every installed skill of each package", "arguments.skills.uninstall.name": "Skill or package name(s) to remove; a package name removes every installed skill that belongs to it", "arguments.serviceName": "Service name", + "arguments.connectorAppsServiceName": "Optional service name; omit to list every connected app", "arguments.connectorUrl": "Self-hosted connector server URL, for example http://localhost:3000", "arguments.actionId": "Action id(s) in the form ., for example cal.create_schedule", "arguments.shell": "Target shell", @@ -1170,11 +1175,12 @@ export const enMessages = { "connector.apps.text.auth": "Auth", "connector.apps.text.connectionName": "Connection Name", "connector.apps.text.default": "Default", - "connector.apps.text.default.no": "no", - "connector.apps.text.default.yes": "yes", "connector.apps.text.name": "Name", + "connector.apps.text.noConnections": + "No connected connector apps were found.", "connector.apps.text.noResults": "No connector apps were found for this service.", + "connector.apps.text.service": "Service", "connector.apps.text.status": "Status", "connector.run.text.dryRunPassed": "Validation passed.", "connector.login.manageTokens": "Manage runtime tokens at {accessUrl}", @@ -1313,7 +1319,7 @@ export const zhMessages = { "commands.connector.proxy.summary": "代理 connector API 请求", "commands.connector.apps.description": - "列出一个服务下已连接的 connector app。", + "列出已连接的 connector app,可选按单个服务过滤。", "commands.connector.apps.summary": "列出 connector app", "commands.connector.login.description": @@ -2091,6 +2097,10 @@ export const zhMessages = { "提交异步 action,并等待它的结果 action", "options.connectorRunConnectionName": "使用指定 connector app 连接名称运行该 action", + "options.connectorAppsOrganization": + "以指定组织身份列出已连接的 app(别名:--org)", + "options.connectorAppsPersonal": + "以个人身份列出已连接的 app,忽略已配置的默认组织", "options.connectorRunOrganization": "以指定组织身份运行该 action(别名:--org)", "options.connectorRunPersonal": @@ -2364,6 +2374,7 @@ export const zhMessages = { "arguments.skills.checkUpdate.packageName": "要检查的包名(可指定多个);会检查每个包已安装的全部 skill", "arguments.skills.uninstall.name": "要移除的 skill 或包名(可指定多个);传入包名会移除该包已安装的全部 skill", "arguments.serviceName": "服务名", + "arguments.connectorAppsServiceName": "可选的服务名;省略时列出全部已连接的 app", "arguments.connectorUrl": "自部署 Connector 服务地址,例如 http://localhost:3000", "arguments.actionId": ". 形式的 action id(可多个),例如 cal.create_schedule", "arguments.shell": "目标 shell", @@ -2379,11 +2390,12 @@ export const zhMessages = { "connector.apps.text.auth": "认证", "connector.apps.text.connectionName": "连接名称", "connector.apps.text.default": "默认", - "connector.apps.text.default.no": "否", - "connector.apps.text.default.yes": "是", "connector.apps.text.name": "名称", + "connector.apps.text.noConnections": + "未找到已连接的 connector app。", "connector.apps.text.noResults": "该服务下未找到 connector app。", + "connector.apps.text.service": "服务", "connector.apps.text.status": "状态", "connector.run.text.dryRunPassed": "校验通过。", "connector.login.manageTokens": "可在 {accessUrl} 管理 Runtime Token。", From 938438f5b429861f21ed031e9b8fed1974ad781e Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Mon, 6 Jul 2026 01:26:09 -0400 Subject: [PATCH 2/2] fix(connector): measure apps table width by display columns The `oo connector apps` table padded columns by `colors.strip(cell) .length`, which counts UTF-16 code units. A wide CJK display name or an emoji is one or two code units but occupies two terminal columns, so such a cell was under-padded and pushed the following columns out of alignment. Measure the visible width with `Bun.stringWidth()` instead: it treats ANSI color escapes as zero width and counts wide CJK/emoji glyphs as two columns, so both color codes and multi-cell characters keep the table aligned. The color palette is no longer needed for measurement, so the width helper drops its `colors` parameter. Signed-off-by: Kevin Cui --- .../commands/connector/apps.test.ts | 21 ++++++++++++++++ src/application/commands/connector/apps.ts | 24 ++++++++++--------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/application/commands/connector/apps.test.ts b/src/application/commands/connector/apps.test.ts index ba929c1..5de5183 100644 --- a/src/application/commands/connector/apps.test.ts +++ b/src/application/commands/connector/apps.test.ts @@ -68,6 +68,27 @@ describe("formatConnectorAppsAsText", () => { expect(output).toContain("-"); }); + test("pads wide CJK names by display width so later columns stay aligned", () => { + const output = formatConnectorAppsAsText( + [ + // "钉钉" is 2 UTF-16 units but 4 display columns; "AB" is 2 of + // each. Padding by display width keeps the status column aligned. + sampleApp({ displayName: "钉钉", service: "dingtalk", status: "active" }), + sampleApp({ displayName: "AB", service: "notion", status: "active" }), + ], + "all", + createTranslatorStub(), + createTerminalColors(false), + ); + const lines = output.split("\n"); + const displayWidthBeforeStatus = (line: string): number => + Bun.stringWidth(line.slice(0, line.indexOf("active"))); + + expect(displayWidthBeforeStatus(lines[1]!)).toBe( + displayWidthBeforeStatus(lines[2]!), + ); + }); + test("returns the no-connections message for an empty all-scope listing", () => { const output = formatConnectorAppsAsText( [], diff --git a/src/application/commands/connector/apps.ts b/src/application/commands/connector/apps.ts index f69547f..912c397 100644 --- a/src/application/commands/connector/apps.ts +++ b/src/application/commands/connector/apps.ts @@ -194,15 +194,16 @@ export function formatConnectorAppsAsText( const columns = createConnectorAppsColumns(listScope, translator, colors); const headerCells = columns.map(column => colors.dim(column.header)); const rows = apps.map(app => columns.map(column => column.render(app))); - // Column widths are computed from the visible (ANSI-stripped) length so the - // color escape sequences never skew the alignment. + // Column widths use the terminal display width, which ignores ANSI color + // escapes and counts wide CJK/emoji glyphs as two columns, so neither color + // codes nor multi-cell characters skew the alignment. const widths = columns.map((_, index) => Math.max( - visibleWidth(headerCells[index]!, colors), - ...rows.map(row => visibleWidth(row[index]!, colors)), + visibleWidth(headerCells[index]!), + ...rows.map(row => visibleWidth(row[index]!)), )); return [headerCells, ...rows] - .map(cells => joinConnectorAppsRow(cells, widths, colors)) + .map(cells => joinConnectorAppsRow(cells, widths)) .join("\n"); } @@ -253,20 +254,21 @@ function colorConnectorAppStatus(status: string, colors: TerminalColors): string return colors[colorName](status); } -// Pads every cell except the last to its column width (measured on visible -// characters) and joins the row with a two-space gutter. +// Pads every cell except the last to its column width (measured in display +// columns) and joins the row with a two-space gutter. function joinConnectorAppsRow( cells: readonly string[], widths: readonly number[], - colors: TerminalColors, ): string { return cells .map((cell, index) => index === cells.length - 1 ? cell - : cell + " ".repeat(widths[index]! - visibleWidth(cell, colors))) + : cell + " ".repeat(widths[index]! - visibleWidth(cell))) .join(" "); } -function visibleWidth(cell: string, colors: TerminalColors): number { - return colors.strip(cell).length; +// Terminal display width of the cell: ANSI color escapes count as zero and wide +// CJK/emoji glyphs count as two columns, unlike `String.length` (UTF-16 units). +function visibleWidth(cell: string): number { + return Bun.stringWidth(cell); }