Skip to content

Commit 4095c2d

Browse files
committed
refactor: split and merge util modules by topic
Split the grab-bag modules by what they hold and merge one-function modules into topic modules: - `common/fs` (atomic writes, file cleanup, `expandPath`), `common/strings` (`lowercase`, shell escaping) and `common/url` (URL helpers and `getQueryString`) - `api/models` for helpers over Coder API types, `error/abort` and `error/normalize`, `logging/ids`, `oauth/tokens` - helpers with one consumer move into it: `countSubstring` into `remote/sshConfig`, `findPort` into `remote/sshProcess`, PKCE helpers into `oauth/authorizer`, `rawDataToString` into `websocket/oneWayWebSocket`, `HandshakeError` into `websocket/codes`, and `sizeOf`/`safeStringify` into `logging/formatters`
1 parent d201d54 commit 4095c2d

72 files changed

Lines changed: 974 additions & 1009 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/announcements/manager.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as vscode from "vscode";
22

3-
import { errToStr } from "../api/models";
3+
import { errToStr } from "../error/normalize";
44
import { areNotificationsDisabled } from "../settings/notifications";
55
import { withProgress } from "../ui/progress";
66
import { createStatusBarItem } from "../ui/statusBar";

‎src/api/agentMetadata.ts‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
import { type WorkspaceAgent } from "coder/site/src/api/typesGenerated";
22
import * as vscode from "vscode";
3+
import { z } from "zod";
34

5+
import { errToStr } from "../error/normalize";
46
import { WebSocketCloseCode } from "../websocket/codes";
57

6-
import {
7-
type AgentMetadataEvent,
8-
AgentMetadataEventSchemaArray,
9-
errToStr,
10-
} from "./models";
11-
128
import type { UnidirectionalStream } from "../websocket/eventStreamConnection";
139

10+
export const AgentMetadataEventSchema = z.object({
11+
result: z.object({
12+
collected_at: z.string(),
13+
age: z.number(),
14+
value: z.string(),
15+
error: z.string(),
16+
}),
17+
description: z.object({
18+
display_name: z.string(),
19+
key: z.string(),
20+
script: z.string(),
21+
interval: z.number(),
22+
timeout: z.number(),
23+
}),
24+
});
25+
26+
export const AgentMetadataEventSchemaArray = z.array(AgentMetadataEventSchema);
27+
28+
export type AgentMetadataEvent = z.infer<typeof AgentMetadataEventSchema>;
29+
1430
export interface AgentMetadataClient {
1531
watchAgentMetadata(
1632
agentId: string,

‎src/api/coderApi.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ import {
1313
watchConfigurationChanges,
1414
} from "../configWatcher";
1515
import { sessionId } from "../core/sessionId";
16-
import { toError } from "../error/abort";
1716
import { ClientCertificateError } from "../error/clientCertificateError";
17+
import { toError } from "../error/normalize";
1818
import { ServerCertificateError } from "../error/serverCertificateError";
1919
import { getHeaders } from "../headers";
2020
import { EventStreamLogger } from "../logging/eventStreamLogger";
21+
import { sizeOf } from "../logging/formatters";
2122
import {
2223
createRequestMeta,
2324
logError,
2425
logRequest,
2526
logResponse,
2627
} from "../logging/httpLogger";
2728
import { HttpRequestsTelemetry } from "../logging/httpRequestsTelemetry";
28-
import { sizeOf } from "../logging/ids";
2929
import {
3030
type RequestConfigWithMeta,
3131
type HttpClientLogLevel,
@@ -37,7 +37,7 @@ import {
3737
NOOP_TELEMETRY_REPORTER,
3838
type TelemetryReporter,
3939
} from "../telemetry/reporter";
40-
import { WebSocketCloseCode } from "../websocket/codes";
40+
import { WebSocketCloseCode, handshakeStatus } from "../websocket/codes";
4141
import {
4242
OneWayWebSocket,
4343
type OneWayWebSocketInit,
@@ -49,7 +49,6 @@ import {
4949
type SocketFactory,
5050
} from "../websocket/reconnectingWebSocket";
5151
import { SseConnection } from "../websocket/sseConnection";
52-
import { handshakeStatus } from "../websocket/utils";
5352

5453
import { getRefreshCommand, refreshCertificates } from "./certificateRefresh";
5554
import { createHttpAgent } from "./httpAgent";

‎src/api/httpAgent.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
22
import { ProxyAgent } from "proxy-agent";
33
import { type WorkspaceConfiguration } from "vscode";
44

5-
import { expandPath } from "../common/strings";
5+
import { expandPath } from "../common/fs";
66

77
import { getProxyForUrl, joinNoProxy } from "./proxy";
88

‎src/api/models.ts‎

Lines changed: 14 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,10 @@
1-
import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors";
21
import {
32
type User,
43
type Workspace,
54
type WorkspaceAgent,
65
type WorkspaceResource,
76
type WorkspaceStatus,
87
} from "coder/site/src/api/typesGenerated";
9-
import { ErrorEvent } from "eventsource";
10-
import { z } from "zod";
11-
12-
/**
13-
* Convert various error types to readable strings
14-
*/
15-
export function errToStr(error: unknown, def = "No error message provided") {
16-
if (error instanceof Error && error.message) {
17-
return error.message;
18-
} else if (isApiError(error)) {
19-
return error.response.data.message;
20-
} else if (isApiErrorResponse(error)) {
21-
return error.message;
22-
} else if (error instanceof ErrorEvent) {
23-
const message = error.message || def;
24-
return error.code ? `${error.code}: ${message}` : message;
25-
} else if (typeof error === "string" && error.trim().length > 0) {
26-
return error;
27-
}
28-
return def;
29-
}
308

319
/** True when the user holds the deployment-wide owner role. */
3210
export function isOwner(user: User | undefined): boolean {
@@ -40,22 +18,6 @@ export function createWorkspaceIdentifier(workspace: Workspace): string {
4018
return `${workspace.owner_name}/${workspace.name}`;
4119
}
4220

43-
export function extractAllAgents(
44-
workspaces: readonly Workspace[],
45-
): WorkspaceAgent[] {
46-
return workspaces.reduce((acc, workspace) => {
47-
return acc.concat(extractAgents(workspace.latest_build.resources));
48-
}, [] as WorkspaceAgent[]);
49-
}
50-
51-
export function extractAgents(
52-
resources: readonly WorkspaceResource[],
53-
): WorkspaceAgent[] {
54-
return resources.reduce((acc, resource) => {
55-
return acc.concat(resource.agents ?? []);
56-
}, [] as WorkspaceAgent[]);
57-
}
58-
5921
const WORKSPACE_STATUS_LABEL: Readonly<Record<WorkspaceStatus, string>> = {
6022
canceled: "Canceled",
6123
canceling: "Canceling",
@@ -76,22 +38,18 @@ export function workspaceStatusLabel(status: WorkspaceStatus): string {
7638
);
7739
}
7840

79-
export const AgentMetadataEventSchema = z.object({
80-
result: z.object({
81-
collected_at: z.string(),
82-
age: z.number(),
83-
value: z.string(),
84-
error: z.string(),
85-
}),
86-
description: z.object({
87-
display_name: z.string(),
88-
key: z.string(),
89-
script: z.string(),
90-
interval: z.number(),
91-
timeout: z.number(),
92-
}),
93-
});
94-
95-
export const AgentMetadataEventSchemaArray = z.array(AgentMetadataEventSchema);
41+
export function extractAllAgents(
42+
workspaces: readonly Workspace[],
43+
): WorkspaceAgent[] {
44+
return workspaces.reduce((acc, workspace) => {
45+
return acc.concat(extractAgents(workspace.latest_build.resources));
46+
}, [] as WorkspaceAgent[]);
47+
}
9648

97-
export type AgentMetadataEvent = z.infer<typeof AgentMetadataEventSchema>;
49+
export function extractAgents(
50+
resources: readonly WorkspaceResource[],
51+
): WorkspaceAgent[] {
52+
return resources.reduce((acc, resource) => {
53+
return acc.concat(resource.agents ?? []);
54+
}, [] as WorkspaceAgent[]);
55+
}

‎src/api/workspace.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { spawn } from "node:child_process";
22
import * as vscode from "vscode";
33

4+
import { errToStr } from "../error/normalize";
45
import { getGlobalFlags, type CliAuth } from "../settings/cli";
56

6-
import { errToStr, createWorkspaceIdentifier } from "./models";
7+
import { createWorkspaceIdentifier } from "./models";
78

89
import type { Api } from "coder/site/src/api/api";
910
import type {

‎src/command/diagnosticFlow.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as vscode from "vscode";
22
import { ZodError } from "zod";
33

4-
import { toError } from "../error/abort";
4+
import { toError } from "../error/normalize";
55
import { withCancellableProgress, type ProgressContext } from "../ui/progress";
66
import { openJsonBeside } from "../webviews/openJson";
77

‎src/commands.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import * as semver from "semver";
55
import * as vscode from "vscode";
66

77
import {
8-
createWorkspaceIdentifier,
98
extractAgents,
9+
createWorkspaceIdentifier,
1010
workspaceStatusLabel,
1111
} from "./api/models";
1212
import { runDiagnosticCli } from "./command/diagnosticFlow";
1313
import { openInBrowser, toSafeHost } from "./common/url";
1414
import * as cliExec from "./core/cliExec";
15-
import { raceWithAbort, toError } from "./error/abort";
15+
import { raceWithAbort } from "./error/abort";
1616
import { CertificateError } from "./error/certificateError";
17+
import { toError } from "./error/normalize";
1718
import { type CliFeatureSet, cliFeatureSet } from "./featureSet";
1819
import {
1920
AuthTelemetry,

‎src/common/fs.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import * as fs from "node:fs/promises";
2+
import os from "node:os";
3+
import * as path from "node:path";
4+
5+
import type { Logger } from "../logging/logger";
26

37
const transientRenameCodes: ReadonlySet<string> = new Set([
48
"EPERM",
@@ -82,3 +86,111 @@ export async function writeAtomically<T>(
8286
throw err;
8387
}
8488
}
89+
90+
export interface FileCleanupCandidate {
91+
name: string;
92+
mtime: number;
93+
size: number;
94+
}
95+
96+
export interface FileCleanupOptions {
97+
/** Label for log messages, e.g. "telemetry file". */
98+
label: string;
99+
/** Cheap name-based predicate; non-matching entries are skipped before stat. */
100+
filter?: (name: string) => boolean;
101+
/** From the stat'd survivors of `filter`, returns the files to delete. */
102+
select: (
103+
files: FileCleanupCandidate[],
104+
now: number,
105+
) => Array<{ name: string }>;
106+
}
107+
108+
/**
109+
* Lists files in `dir`, applies `filter` to names, stats the survivors, and
110+
* unlinks whatever `select` returns. ENOENT is swallowed so concurrent
111+
* deletes are safe. Never throws; failures go to `logger.debug`.
112+
*/
113+
export async function cleanupFiles(
114+
dir: string,
115+
logger: Logger,
116+
options: FileCleanupOptions,
117+
): Promise<void> {
118+
const { label, filter, select } = options;
119+
const now = Date.now();
120+
let names: string[];
121+
try {
122+
names = await fs.readdir(dir);
123+
} catch (error) {
124+
// ENOENT just means the dir hasn't been created yet; anything else
125+
// (EACCES, EMFILE, ...) is a real failure worth surfacing.
126+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
127+
logger.debug(`Failed to read ${label} directory ${dir}`, error);
128+
}
129+
return;
130+
}
131+
const candidates = filter ? names.filter(filter) : names;
132+
133+
const withStats = await Promise.all(
134+
candidates.map(async (name) => {
135+
try {
136+
const stats = await fs.stat(path.join(dir, name));
137+
return {
138+
name,
139+
mtime: stats.mtime.getTime(),
140+
size: stats.size,
141+
};
142+
} catch (error) {
143+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
144+
logger.debug(`Failed to stat ${label} ${name}`, error);
145+
}
146+
return null;
147+
}
148+
}),
149+
);
150+
151+
const toDelete = select(
152+
withStats.filter((f) => f !== null),
153+
now,
154+
);
155+
156+
const deleted = await Promise.all(
157+
toDelete.map(async (file) => {
158+
// Basename only; never let `select` escape `dir`.
159+
const safeName = path.basename(file.name);
160+
try {
161+
await fs.unlink(path.join(dir, safeName));
162+
return safeName;
163+
} catch (error) {
164+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
165+
logger.debug(`Failed to delete ${label} ${safeName}`, error);
166+
}
167+
return null;
168+
}
169+
}),
170+
);
171+
172+
const successful = deleted.filter((name) => name !== null);
173+
if (successful.length > 0) {
174+
logger.debug(
175+
`Cleaned up ${successful.length} ${label}(s): ${successful.join(", ")}`,
176+
);
177+
}
178+
}
179+
180+
/**
181+
* Substitute `${env:VAR}` with `process.env.VAR` (unset → empty string),
182+
* `${userHome}` (anywhere) with `os.homedir()`, and a leading `~` with
183+
* `os.homedir()`. Env substitution runs first so env values can themselves
184+
* contain `~` or `${userHome}`.
185+
*/
186+
export function expandPath(input: string): string {
187+
const expanded = input.replace(
188+
/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,
189+
(_, name: string) => process.env[name] ?? "",
190+
);
191+
const userHome = os.homedir();
192+
const tildeExpanded = expanded.startsWith("~")
193+
? userHome + expanded.substring("~".length)
194+
: expanded;
195+
return tildeExpanded.replaceAll("${userHome}", userHome);
196+
}

0 commit comments

Comments
 (0)