Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The following tools must be installed on your machine to build and run the full
| Tool | Version | Purpose | Install |
|------|---------|---------|---------|
| **Node.js** | ≥ 18.0.0 | JavaScript runtime | [nodejs.org](https://nodejs.org) or `nvm install 22` |
| **pnpm** | 11.9.0+ | Package manager (monorepo workspaces) | `corepack enable && corepack prepare pnpm@latest --activate` |
| **pnpm** | 11.14.0+ | Package manager (monorepo workspaces) | `corepack enable && corepack prepare pnpm@latest --activate` |
| **Git** | Any recent | Source control | [git-scm.com](https://git-scm.com) |

### Platform-Specific Dependencies
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Shared CLI argument helpers.
*/

/**
* Find the first positional argument in a CLI arg list, skipping option flags
* (`--foo`) and — for value-taking flags — the value token that follows them.
*
* Without skipping flag values, a value placed before the positional (e.g.
* `thermoworks eta --target 203 ABC123`) would be mistaken for the positional
* itself, silently operating on the wrong target. Callers pass the set of
* value-taking flags so boolean flags (e.g. `--json`) fall through without
* consuming the following token.
*
* @param args CLI arguments (already scoped to the command).
* @param valueFlags Flags that consume the next token as their value.
* @returns The first positional argument, or `undefined` when none is present.
*/
export function firstPositional(
args: string[],
valueFlags: readonly string[] = [],
): string | undefined {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (arg.startsWith("--")) {
if (valueFlags.includes(arg)) i++;
continue;
}
return arg;
}
return undefined;
}
3 changes: 2 additions & 1 deletion packages/cli/src/commands/alarm-suggest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type MeatProfile, resolveMeatProfile } from "thermoworks-sdk";

import { firstPositional } from "../args.js";
import { type OutputOptions, outputJson } from "../output.js";

/** Parse a named flag value from args (e.g., "--pit-band" "30" -> "30"). */
Expand Down Expand Up @@ -107,7 +108,7 @@ function formatSuggestion(s: AlarmSuggestion): string {
* This command only suggests; it never writes to a device.
*/
export async function alarmSuggest(args: string[], options: OutputOptions): Promise<void> {
const meat = args.find((a) => !a.startsWith("--"));
const meat = firstPositional(args, ["--pit-band", "--serial", "--meat-channel", "--pit-channel"]);
if (!meat) {
console.error(
"Usage: thermoworks alarm suggest <MEAT> [--pit-band <deg>] [--serial <SN>] [--meat-channel <1-9>] [--pit-channel <1-9>] [--json]",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/convert.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { toCelsius, toFahrenheit } from "thermoworks-sdk";

import { firstPositional } from "../args.js";
import { type OutputOptions, outputJson } from "../output.js";

/** A parsed and computed temperature conversion. */
Expand Down Expand Up @@ -53,7 +54,7 @@ export function parseConversion(raw: string | undefined, to?: string): Conversio
* 107c` prints the Fahrenheit value. A bare number needs `--to c|f`.
*/
export function convert(args: string[], options: OutputOptions): void {
const value = args.find((a) => !a.startsWith("--"));
const value = firstPositional(args, ["--to"]);
const toIdx = args.indexOf("--to");
const to = toIdx !== -1 && toIdx + 1 < args.length ? args[toIdx + 1] : undefined;

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/eta.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { predictDoneTime, ThermoworksCloud } from "thermoworks-sdk";

import { firstPositional } from "../args.js";
import { getCredentials } from "../credentials.js";
import { type OutputOptions, outputJson } from "../output.js";

Expand Down Expand Up @@ -45,7 +46,7 @@ export interface EtaArgs {
* Channel defaults to 1. Returns null when the serial is missing.
*/
export function parseEtaArgs(args: string[]): EtaArgs | null {
const serial = args.find((a) => !a.startsWith("--"));
const serial = firstPositional(args, ["--channel", "--target"]);
if (!serial) return null;

const channel = parseChannelFlag(getFlagValue(args, "--channel")) ?? 1;
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/stall.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { detectStall, type TemperatureReading, ThermoworksCloud } from "thermoworks-sdk";

import { firstPositional } from "../args.js";
import { getCredentials } from "../credentials.js";
import { type OutputOptions, outputJson } from "../output.js";

Expand Down Expand Up @@ -34,7 +35,7 @@ export interface StallArgs {
* Returns null when the serial is missing.
*/
export function parseStallArgs(args: string[]): StallArgs | null {
const serial = args.find((a) => !a.startsWith("--"));
const serial = firstPositional(args, ["--threshold", "--duration"]);
if (!serial) return null;

const thresholdDegrees = parsePositiveFlag(getFlagValue(args, "--threshold"), "--threshold");
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/temp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ThermoworksCloud, toCelsius, toFahrenheit } from "thermoworks-sdk";

import { firstPositional } from "../args.js";
import { getCredentials } from "../credentials.js";
import { type OutputOptions, outputJson } from "../output.js";

Expand Down Expand Up @@ -56,7 +57,7 @@ function convertReading(value: number, sourceUnits: string | null, unit: TempUni
* for `{ serial, channel, value, units }`.
*/
export async function temp(args: string[], options: OutputOptions): Promise<void> {
const serial = args.find((a) => !a.startsWith("--"));
const serial = firstPositional(args, ["--channel", "--unit"]);
if (!serial) {
console.error("Usage: thermoworks temp <SERIAL> [--channel <1-9>] [--unit auto|f|c] [--json]");
process.exit(1);
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/tests/args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { firstPositional } from "../src/args.js";

describe("firstPositional", () => {
it("returns the first positional when it comes first", () => {
expect(firstPositional(["ABC123", "--channel", "2"], ["--channel"])).toBe("ABC123");
});

it("skips a value-taking flag's value placed before the positional", () => {
// Regression: `--target 203 ABC123` must not treat 203 as the serial.
expect(firstPositional(["--target", "203", "ABC123"], ["--target"])).toBe("ABC123");
});

it("skips multiple leading value flags", () => {
expect(
firstPositional(["--channel", "2", "--unit", "f", "ABC123"], ["--channel", "--unit"]),
).toBe("ABC123");
});

it("does not skip the token after a boolean flag", () => {
// `--json` takes no value, so the following positional must be found.
expect(firstPositional(["--json", "ABC123"], ["--channel"])).toBe("ABC123");
});

it("finds the positional between flags", () => {
expect(firstPositional(["--json", "ABC123", "--channel", "2"], ["--channel"])).toBe("ABC123");
});

it("returns undefined when there is no positional", () => {
expect(firstPositional(["--channel", "2"], ["--channel"])).toBeUndefined();
});

it("returns undefined for an empty arg list", () => {
expect(firstPositional([], ["--channel"])).toBeUndefined();
});

it("treats unknown flags as boolean (does not consume the next token)", () => {
// `--verbose` is not a declared value flag, so `meat` is the positional.
expect(firstPositional(["--verbose", "brisket"], ["--target"])).toBe("brisket");
});

it("handles a trailing value flag with no value", () => {
expect(firstPositional(["ABC123", "--channel"], ["--channel"])).toBe("ABC123");
});

it("defaults to no value flags", () => {
expect(firstPositional(["ABC123", "--json"])).toBe("ABC123");
});
});
10 changes: 5 additions & 5 deletions packages/sdk/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ export function computeRetryDelay(
// Exponential backoff: baseDelay * 2^attempt, capped at maxDelay
const exponentialDelay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);

// Use Retry-After as floor if larger than computed delay
const baseDelay = Math.max(exponentialDelay, retryAfterMs);
// Full jitter on the exponential component: uniform random in [0, exponentialDelay).
const jitteredDelay = Math.random() * exponentialDelay;

// Full jitter: uniform random in [0, baseDelay], capped at maxDelay
const jitteredDelay = Math.random() * baseDelay;
return Math.min(jitteredDelay, maxDelayMs);
// Honor Retry-After as a hard floor: never retry sooner than the server asked,
// even after jitter. Still capped at maxDelay.
return Math.min(Math.max(jitteredDelay, retryAfterMs), maxDelayMs);
}

function isRetryableStatus(statusCode: number): boolean {
Expand Down
70 changes: 36 additions & 34 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,27 +909,29 @@ export class ThermoworksCloud {

if (groupIds.length === 0) return [];

// Fetch each group document from the account's deviceGroups subcollection
const groups: DeviceGroup[] = [];
for (const groupId of groupIds) {
const groupPath = `documents/accounts/${encodeURIComponent(accountId)}/deviceGroups/${encodeURIComponent(groupId)}`;
const groupResponse = await session.request("GET", groupPath);

if (groupResponse.status === 404) {
await groupResponse.text().catch(() => {});
// Group referenced but document missing - include with minimal info
groups.push({ id: groupId, name: "", devices: [] });
continue;
}
// Fetch each group document from the account's deviceGroups subcollection.
// The requests are independent, so run them in parallel to avoid N sequential
// round trips; Promise.all preserves groupIds order in the result.
const groups: DeviceGroup[] = await Promise.all(
groupIds.map(async (groupId): Promise<DeviceGroup> => {
const groupPath = `documents/accounts/${encodeURIComponent(accountId)}/deviceGroups/${encodeURIComponent(groupId)}`;
const groupResponse = await session.request("GET", groupPath);

if (groupResponse.status === 404) {
await groupResponse.text().catch(() => {});
// Group referenced but document missing - include with minimal info
return { id: groupId, name: "", devices: [] };
}

const groupDoc = (await groupResponse.json()) as { fields?: FirestoreFields };
const groupFields = groupDoc.fields ?? {};
groups.push({
id: groupId,
name: getString(groupFields, "name") ?? "",
devices: getStringArray(groupFields, "devices") ?? [],
});
}
const groupDoc = (await groupResponse.json()) as { fields?: FirestoreFields };
const groupFields = groupDoc.fields ?? {};
return {
id: groupId,
name: getString(groupFields, "name") ?? "",
devices: getStringArray(groupFields, "devices") ?? [],
};
}),
);

return groups;
}
Expand Down Expand Up @@ -1211,13 +1213,13 @@ function parseDevice(fields: FirestoreFields): Device {
serial: getString(fields, "serial") ?? "",
deviceId: getString(fields, "deviceId"),
label: sanitizeLabel(getString(fields, "label")),
type: getString(fields, "type"),
type: sanitizeLabel(getString(fields, "type")),
device: getString(fields, "device"),
status: getString(fields, "status"),
status: sanitizeLabel(getString(fields, "status")),
battery: getNumber(fields, "battery"),
batteryState: getString(fields, "battery_state") ?? getString(fields, "batteryState"),
wifiStrength: getNumber(fields, "wifi_stength") ?? getNumber(fields, "wifiStrength"),
firmware: getString(fields, "firmware"),
firmware: sanitizeLabel(getString(fields, "firmware")),
color: getString(fields, "color"),
thumbnail: getString(fields, "thumbnail"),
deviceDisplayUnits: getString(fields, "deviceDisplayUnits"),
Expand All @@ -1236,12 +1238,12 @@ function parseDevice(fields: FirestoreFields): Device {
lastWifiConnection: getTimestamp(fields, "lastWifiConnection"),
lastBluetoothConnection: getTimestamp(fields, "lastBluetoothConnection"),
sessionStart: getTimestamp(fields, "sessionStart"),
sessionLabel: getString(fields, "sessionLabel"),
sessionLabel: sanitizeLabel(getString(fields, "sessionLabel")),
lastArchive: getTimestamp(fields, "lastArchive"),
lastPurged: getTimestamp(fields, "lastPurged"),
assignedToAccountOn: getTimestamp(fields, "assignedToAccountOn"),
accountId: getString(fields, "accountId"),
notes: getString(fields, "notes"),
notes: sanitizeLabel(getString(fields, "notes")),
public: getBoolean(fields, "public"),
publicLink: getString(fields, "publicLink"),
searModeEnabled: getBoolean(fields, "searModeEnabled"),
Expand Down Expand Up @@ -1279,8 +1281,8 @@ function parseDeviceChannel(fields: FirestoreFields): DeviceChannel {
value: getNumber(fields, "value"),
units: getString(fields, "units"),
label: sanitizeLabel(getString(fields, "label")),
status: getString(fields, "status"),
type: getString(fields, "type"),
status: sanitizeLabel(getString(fields, "status")),
type: sanitizeLabel(getString(fields, "type")),
number: getString(fields, "number"),
enabled: getBoolean(fields, "enabled"),
color: getString(fields, "color"),
Expand Down Expand Up @@ -1423,10 +1425,10 @@ function parseArchive(fields: FirestoreFields, id: string): Archive {
start: getTimestamp(fields, "start"),
end: getTimestamp(fields, "end"),
count: getNumber(fields, "count"),
type: getString(fields, "type"),
label: getString(fields, "label"),
deviceLabel: getString(fields, "deviceLabel"),
notes: getString(fields, "notes"),
type: sanitizeLabel(getString(fields, "type")),
label: sanitizeLabel(getString(fields, "label")),
deviceLabel: sanitizeLabel(getString(fields, "deviceLabel")),
notes: sanitizeLabel(getString(fields, "notes")),
createdOn: getTimestamp(fields, "createdOn"),
public: getBoolean(fields, "public"),
publicLink: getString(fields, "publicLink"),
Expand Down Expand Up @@ -1457,13 +1459,13 @@ function parseArchiveChannel(fields: FirestoreFields): ArchiveChannel {

return {
number: getString(fields, "number"),
label: getString(fields, "label"),
label: sanitizeLabel(getString(fields, "label")),
units: getString(fields, "units"),
value: getNumber(fields, "value"),
status: getString(fields, "status"),
status: sanitizeLabel(getString(fields, "status")),
enabled: getBoolean(fields, "enabled"),
color: getString(fields, "color"),
type: getString(fields, "type"),
type: sanitizeLabel(getString(fields, "type")),
alarmHigh: parseAlarm(getMapFields(fields, "alarmHigh")),
alarmLow: parseAlarm(getMapFields(fields, "alarmLow")),
minimum: parseMinMaxReading(getMapFields(fields, "minimum")),
Expand Down
36 changes: 36 additions & 0 deletions packages/sdk/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,42 @@ describe("ThermoworksCloud", () => {
client.close();
});

it("sanitizes ANSI/control characters from untrusted device fields", async () => {
setupAuth();
mockRequest.mockResolvedValueOnce(
mockRes(200, { fields: { accountId: { stringValue: "acct-123" } } }) as any,
);
mockRequest.mockResolvedValueOnce(
mockRes(200, [
{
document: {
fields: {
serial: { stringValue: "ABC123" },
label: { stringValue: "\x1b[31mPit\x1b[0m" },
type: { stringValue: "no\x1bde" },
status: { stringValue: "on\x07line" },
firmware: { stringValue: "1.2.3\x1b[2J" },
sessionLabel: { stringValue: "Bris\x1b[Aket" },
notes: { stringValue: "line1\x00line2" },
accountId: { stringValue: "acct-123" },
},
},
},
]) as any,
);

const client = new ThermoworksCloud({ email: "test@example.com", password: "pass" });
const device = (await client.getDevices())[0];
// Untrusted cloud free-text must not carry terminal escape/control sequences.
expect(device?.label).toBe("Pit");
expect(device?.type).toBe("node");
expect(device?.status).toBe("online");
expect(device?.firmware).toBe("1.2.3");
expect(device?.sessionLabel).toBe("Brisket");
expect(device?.notes).toBe("line1line2");
client.close();
});

it("filters devices by serial", async () => {
setupAuth();
mockRequest.mockResolvedValueOnce(
Expand Down
15 changes: 15 additions & 0 deletions packages/sdk/tests/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ describe("computeRetryDelay", () => {
expect(delay).toBe(5000);
});

it("honors Retry-After as a hard floor even with small jitter", () => {
// Without the floor, full jitter (random→0) would drop the delay to 0,
// retrying sooner than the server's Retry-After asked.
vi.spyOn(Math, "random").mockReturnValue(0);
const delay = computeRetryDelay(0, 1000, 30_000, "5");
expect(delay).toBe(5000);
});

it("caps the Retry-After floor at maxDelayMs", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
// Retry-After 60s exceeds maxDelay 30s → capped at 30000.
const delay = computeRetryDelay(0, 1000, 30_000, "60");
expect(delay).toBe(30_000);
});

it("ignores invalid Retry-After header", () => {
vi.spyOn(Math, "random").mockReturnValue(0.5);
const delay = computeRetryDelay(0, 1000, 30_000, "not-a-number-or-date");
Expand Down
7 changes: 6 additions & 1 deletion packages/web/src/lib/cook-annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ export function saveCookAnnotations(
},
];
});
localStorage.setItem(annotationStorageKey(sessionId), JSON.stringify(stored));
try {
localStorage.setItem(annotationStorageKey(sessionId), JSON.stringify(stored));
} catch {
// Best-effort: ignore quota/availability errors (private mode, storage full),
// matching loadCookAnnotations which tolerates read failures.
}
}

interface SerializableReading {
Expand Down
Loading