Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a2ab65f
fix(http): normalize response timeout errors
francoischalifour Jul 21, 2026
d80171b
fix(config): surface configuration I/O failures
francoischalifour Jul 21, 2026
7f4269b
fix(api): preserve exact JSON integers
francoischalifour Jul 21, 2026
dfd2064
fix(query): redact secrets in JSON string cells
francoischalifour Jul 21, 2026
bb6ce24
fix(profile): roll back failed profile mutations
francoischalifour Jul 21, 2026
388bd30
fix(api): canonicalize exact integer tokens
francoischalifour Jul 21, 2026
f07f5b0
fix(http): time out streamed error bodies
francoischalifour Jul 21, 2026
8c08fe9
fix(http): distinguish peer aborts from timeouts
francoischalifour Jul 21, 2026
59f4b6c
fix(query): preserve JSON lexemes during redaction
francoischalifour Jul 21, 2026
c4835e9
fix(profile): restore config after partial deletion
francoischalifour Jul 21, 2026
bda8c85
fix(profile): restore configless profiles on rollback
francoischalifour Jul 21, 2026
1ab5b1d
fix(profile): report incomplete rollback recovery
francoischalifour Jul 21, 2026
d5eb9a2
test(redact): cover source-preserving JSON boundaries
francoischalifour Jul 21, 2026
bfb5ee1
refactor(profile): clarify mutation rollback flow
francoischalifour Jul 21, 2026
8904021
refactor(redact): expose source parser state
francoischalifour Jul 21, 2026
cd4c0e5
refactor(config): centralize atomic file updates
francoischalifour Jul 21, 2026
2bbc552
refactor(http): unify timeout error mapping
francoischalifour Jul 21, 2026
a86e7ce
refactor(api): clarify exact integer serialization
francoischalifour Jul 21, 2026
86bec67
feat(profile): use stable IDs for secret storage
francoischalifour Jul 21, 2026
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
46 changes: 39 additions & 7 deletions cli/src/commands/api/lib/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,19 @@ function parseFieldEntry(entry: string): [string, string] {
return [key, value];
}

export type ApiFieldValue = string | number | boolean | null;
type ExactJsonInteger = {
kind: "exact_json_integer";
json: string;
};

function canonicalizeJsonInteger(value: string): string {
const negative = value.startsWith("-");
const digits = negative ? value.slice(1) : value;
const canonicalDigits = digits.replace(/^0+/, "") || "0";
return negative && canonicalDigits !== "0" ? `-${canonicalDigits}` : canonicalDigits;
}

export type ApiFieldValue = string | number | boolean | null | ExactJsonInteger;

export type ParsedApiField = {
key: string;
Expand All @@ -80,7 +92,9 @@ function parseTypedFieldValue(value: string): ApiFieldValue {
return null;
}
if (/^-?\d+$/.test(value)) {
return Number(value);
const json = canonicalizeJsonInteger(value);
const parsed = Number(json);
return Number.isSafeInteger(parsed) ? parsed : { kind: "exact_json_integer", json };
}
if (value === "@-") {
return readFileSync(0, "utf8");
Expand Down Expand Up @@ -114,12 +128,30 @@ function parseTypedFields(
});
}

function parsedFieldsToRecord(fields: ParsedApiField[]): Record<string, ApiFieldValue> {
const body: Record<string, ApiFieldValue> = {};
function isExactJsonInteger(value: ApiFieldValue): value is ExactJsonInteger {
return typeof value === "object" && value !== null && value.kind === "exact_json_integer";
}

export function formatApiFieldValue(value: ApiFieldValue): string {
if (isExactJsonInteger(value)) {
return value.json;
}
return value === null ? "null" : String(value);
}

function serializeApiFieldValue(value: ApiFieldValue): string {
return isExactJsonInteger(value) ? value.json : JSON.stringify(value);
}

function serializeApiFields(fields: ParsedApiField[]): string {
const valuesByKey = new Map<string, ApiFieldValue>();
for (const field of fields) {
body[field.key] = field.value;
valuesByKey.set(field.key, field.value);
}
return body;
const properties = [...valuesByKey].map(
([key, value]) => `${JSON.stringify(key)}:${serializeApiFieldValue(value)}`,
);
return `{${properties.join(",")}}`;
}

export type ResolveApiBodyOptions = {
Expand Down Expand Up @@ -189,7 +221,7 @@ export function resolveApiRequestPayload(

if (hasFields) {
return {
body: JSON.stringify(parsedFieldsToRecord(parsedFields)),
body: serializeApiFields(parsedFields),
queryFields: [],
};
}
Expand Down
18 changes: 18 additions & 0 deletions cli/src/commands/api/lib/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ describe("api-body", () => {
expect(JSON.parse(payload.body ?? "")).toEqual({ label: "CI Bot", name: "Analytics" });
});

test("preserves typed integers outside JavaScript's safe range", () => {
const payload = resolveApiRequestPayload({
method: "POST",
typedFields: ["id=0009007199254740993", "negative=-0009007199254740993", "zero=-000"],
});

expect(payload.body).toBe('{"id":9007199254740993,"negative":-9007199254740993,"zero":0}');
expect(() => JSON.parse(payload.body ?? "")).not.toThrow();

expect(
resolveApiHttp({
endpoint: "/records",
method: "GET",
typedFields: ["id=0009007199254740993"],
}).endpoint,
).toBe("/records?id=9007199254740993");
});

test("keeps an explicit JSON request body from --input", () => {
const filePath = join(testHome, "payload.json");
writeFileSync(filePath, '{"label":"raw"}', "utf8");
Expand Down
8 changes: 6 additions & 2 deletions cli/src/commands/api/lib/http.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { resolveApiRequestPayload, type ParsedApiField } from "@/commands/api/lib/body.ts";
import {
formatApiFieldValue,
resolveApiRequestPayload,
type ParsedApiField,
} from "@/commands/api/lib/body.ts";
import type { CommandOutputMode } from "@/lib/command-output.ts";
import { CliError } from "@/lib/errors.ts";
import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts";
Expand Down Expand Up @@ -106,7 +110,7 @@ function appendQueryFields(endpoint: string, fields: ParsedApiField[]): string {
const separator = endpoint.includes("?") ? "&" : "?";
const searchParams = new URLSearchParams();
for (const field of fields) {
searchParams.append(field.key, field.value === null ? "null" : String(field.value));
searchParams.append(field.key, formatApiFieldValue(field.value));
}
return `${endpoint}${separator}${searchParams.toString()}`;
}
Expand Down
5 changes: 3 additions & 2 deletions cli/src/commands/append/lib/data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { parseAppendJsonContent } from "@/commands/append/lib/data.ts";
import { CliError } from "@/lib/errors.ts";

describe("parseAppendJsonContent", () => {
test("normalizes inline JSON objects", () => {
expect(parseAppendJsonContent('{ "id": 1 }')).toBe('{"id":1}');
test("preserves inline JSON without rounding large integers", () => {
const input = '{ "id": 9007199254740993 }';
expect(parseAppendJsonContent(input)).toBe(input);
});

test("rejects non-JSON data", () => {
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/append/lib/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export function parseAppendJsonContent(dataArg: string): string {
}

try {
return JSON.stringify(JSON.parse(jsonContent));
JSON.parse(jsonContent);
return jsonContent;
} catch {
throw new CliError("Data must be valid JSON.");
}
Expand Down
167 changes: 89 additions & 78 deletions cli/src/lib/config-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,60 @@ import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { readEnv } from "@/lib/env.ts";

function trim(value: string): string {
return value.trim();
}
import { ConfigurationError } from "@/lib/errors.ts";

function tempSiblingPath(filePath: string): string {
return join(dirname(filePath), `.altertable-kv-${randomBytes(8).toString("hex")}`);
}

function isFileNotFound(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
);
}

function configFileError(action: string, filePath: string, cause: unknown): ConfigurationError {
return new ConfigurationError(`Unable to ${action} configuration file: ${filePath}`, { cause });
}

function configKey(line: string): string {
const separatorIndex = line.indexOf("=");
return line.slice(0, separatorIndex === -1 ? undefined : separatorIndex).trim();
}

function readConfigFileIfPresent(filePath: string): string | undefined {
try {
return readFileSync(filePath, "utf8");
} catch (error) {
if (isFileNotFound(error)) {
return undefined;
}
throw configFileError("read", filePath, error);
}
}

function writeConfigFileAtomically(filePath: string, content: string, action: string): void {
const temporaryPath = tempSiblingPath(filePath);
let failure: unknown;
try {
writeFileSync(temporaryPath, content, { mode: 0o600 });
renameSync(temporaryPath, filePath);
} catch (error) {
failure = error;
}
try {
rmSync(temporaryPath, { force: true });
} catch (error) {
failure ??= error;
}
if (failure !== undefined) {
throw configFileError(action, filePath, failure);
}
}

export function configDir(): string {
const override = readEnv("ALTERTABLE_CONFIG_HOME");
if (override) {
Expand All @@ -29,97 +74,63 @@ export function credentialsFile(): string {
}

export function kvGet(filePath: string, key: string): string {
try {
const content = readFileSync(filePath, "utf8");
for (const line of content.split("\n")) {
if (line === "" || line.startsWith("#")) {
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const lineKey = trim(line.slice(0, eqIndex));
if (lineKey === key) {
return trim(line.slice(eqIndex + 1));
}
}
} catch {
const content = readConfigFileIfPresent(filePath);
if (content === undefined) {
return "";
}
for (const line of content.split("\n")) {
if (line === "" || line.startsWith("#")) {
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const lineKey = line.slice(0, eqIndex).trim();
if (lineKey === key) {
return line.slice(eqIndex + 1).trim();
}
}
return "";
}

export function kvSet(filePath: string, key: string, value: string): void {
mkdirSync(dirname(filePath), { recursive: true });
const tmpPath = tempSiblingPath(filePath);
let found = false;
let lines: string[] = [];

try {
lines = readFileSync(filePath, "utf8").split("\n");
} catch {
lines = [];
mkdirSync(dirname(filePath), { recursive: true });
} catch (error) {
throw configFileError("write", filePath, error);
}

const output: string[] = [];
for (const line of lines) {
if (line === "" && output.length === 0 && lines.length === 1) {
continue;
}
const eqIndex = line.indexOf("=");
const lineKey = eqIndex === -1 ? trim(line) : trim(line.slice(0, eqIndex));
if (lineKey === key) {
output.push(`${key}=${value}`);
const lines = readConfigFileIfPresent(filePath)?.split("\n") ?? [];
let found = false;
const output = lines
.filter((line) => line !== "" || lines.length > 1)
.map((line) => {
if (configKey(line) !== key) {
return line;
}
found = true;
} else {
output.push(line);
}
}

return `${key}=${value}`;
});
if (!found) {
output.push(`${key}=${value}`);
}

try {
writeFileSync(
tmpPath,
output
.filter((line, index, array) => {
if (index === array.length - 1 && line === "") {
return false;
}
return true;
})
.join("\n") + (output.length > 0 ? "\n" : ""),
{ mode: 0o600 },
);
renameSync(tmpPath, filePath);
} finally {
rmSync(tmpPath, { force: true });
while (output.at(-1) === "") {
output.pop();
}
writeConfigFileAtomically(filePath, `${output.join("\n")}\n`, "write");
}

export function kvUnset(filePath: string, key: string): void {
try {
const lines = readFileSync(filePath, "utf8").split("\n");
const tmpPath = tempSiblingPath(filePath);
const output: string[] = [];
for (const line of lines) {
const eqIndex = line.indexOf("=");
const lineKey = eqIndex === -1 ? trim(line) : trim(line.slice(0, eqIndex));
if (lineKey === key) {
continue;
}
output.push(line);
}
try {
writeFileSync(tmpPath, output.join("\n"), { mode: 0o600 });
renameSync(tmpPath, filePath);
} finally {
rmSync(tmpPath, { force: true });
}
} catch {
// file does not exist
const content = readConfigFileIfPresent(filePath);
if (content === undefined) {
return;
}

const output = content
.split("\n")
.filter((line) => configKey(line) !== key)
.join("\n");
writeConfigFileAtomically(filePath, output, "update");
}
13 changes: 12 additions & 1 deletion cli/src/lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
Expand All @@ -12,9 +12,11 @@ import {
getQueryDefaultPager,
kvGet,
kvSet,
kvUnset,
resolveApiBase,
resolveManagementApiBase,
} from "@/lib/config.ts";
import { ConfigurationError } from "@/lib/errors.ts";
import { profileConfigFile } from "@/lib/profile-store.ts";

let testHome = "";
Expand Down Expand Up @@ -45,6 +47,15 @@ describe("config", () => {
expect(configGet("missing", profileName)).toBe("");
});

test("reports configuration I/O failures instead of treating them as missing files", () => {
const directoryPath = join(testHome, "not-a-file");
mkdirSync(directoryPath);

expect(() => kvGet(directoryPath, "user")).toThrow(ConfigurationError);
expect(() => kvSet(directoryPath, "user", "alice")).toThrow(ConfigurationError);
expect(() => kvUnset(directoryPath, "user")).toThrow(ConfigurationError);
});

test("resolves API bases from defaults, stored config, and environment overrides", () => {
expect(resolveApiBase(profileName)).toBe("https://api.altertable.ai");
expect(resolveManagementApiBase(profileName)).toBe("https://app.altertable.ai/rest/v1");
Expand Down
Loading