Skip to content
Draft
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
21 changes: 20 additions & 1 deletion apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as NodeOS from "node:os";

import { assert, expect, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand All @@ -16,10 +17,28 @@ import {
import * as NetService from "@t3tools/shared/Net";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { deriveServerPaths } from "../config.ts";
import { resolveServerConfig } from "./config.ts";
import { DurationFromString, resolveServerConfig } from "./config.ts";

const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));

it.effect("decodes compact duration shorthands", () =>
Effect.gen(function* () {
const duration = yield* Schema.decodeUnknownEffect(DurationFromString)("5m");

assert.strictEqual(Duration.toMillis(duration), Duration.toMillis(Duration.minutes(5)));
}),
);

it.effect("rejects invalid duration strings", () =>
Effect.gen(function* () {
const error = yield* Effect.flip(
Schema.decodeUnknownEffect(DurationFromString)("not-a-duration"),
);

assert.include(String(error), "Invalid duration");
}),
);

const makeDesktopBootstrap = (
overrides: Partial<DesktopBackendBootstrapValue> = {},
): DesktopBackendBootstrapValue => ({
Expand Down
46 changes: 22 additions & 24 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,45 +395,42 @@ export const resolveCliAuthConfig = (

const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i;

const parseDurationInput = (value: string): Duration.Duration | null => {
const parseDurationInput = (value: string): Option.Option<Duration.Duration> => {
const trimmed = value.trim();
if (trimmed.length === 0) return null;
if (trimmed.length === 0) return Option.none();

const shorthand = DurationShorthandPattern.exec(trimmed);
const normalizedInput = shorthand?.groups
? (() => {
const amountText = shorthand.groups.value;
const unitText = shorthand.groups.unit;
if (typeof amountText !== "string" || typeof unitText !== "string") {
return null;
return Option.none<Duration.Input>();
}

const amount = Number.parseInt(amountText, 10);
if (!Number.isFinite(amount)) return null;
if (!Number.isFinite(amount)) return Option.none<Duration.Input>();

switch (unitText.toLowerCase()) {
case "ms":
return `${amount} millis`;
return Option.some(`${amount} millis` as Duration.Input);
case "s":
return `${amount} seconds`;
return Option.some(`${amount} seconds` as Duration.Input);
case "m":
return `${amount} minutes`;
return Option.some(`${amount} minutes` as Duration.Input);
case "h":
return `${amount} hours`;
return Option.some(`${amount} hours` as Duration.Input);
case "d":
return `${amount} days`;
return Option.some(`${amount} days` as Duration.Input);
case "w":
return `${amount} weeks`;
return Option.some(`${amount} weeks` as Duration.Input);
default:
return null;
return Option.none<Duration.Input>();
}
})()
: (trimmed as Duration.Input);
: Option.some(trimmed as Duration.Input);

if (normalizedInput === null) return null;

const decoded = Duration.fromInput(normalizedInput as Duration.Input);
return Option.isSome(decoded) ? decoded.value : null;
return Option.flatMap(normalizedInput, (input) => Duration.fromInput(input));
};

export const DurationFromString = Schema.String.pipe(
Expand All @@ -442,14 +439,15 @@ export const DurationFromString = Schema.String.pipe(
SchemaTransformation.transformOrFail({
decode: (value) => {
const duration = parseDurationInput(value);
if (duration !== null) {
return Effect.succeed(duration);
}
return Effect.fail(
new SchemaIssue.InvalidValue(Option.some(value), {
message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.",
}),
);
return Option.match(duration, {
onSome: Effect.succeed,
onNone: () =>
Effect.fail(
new SchemaIssue.InvalidValue(Option.some(value), {
message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.",
}),
),
});
},
encode: (duration) => Effect.succeed(Duration.format(duration)),
}),
Expand Down
24 changes: 17 additions & 7 deletions packages/client-runtime/src/state/vcsAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
type GitActionProgressEvent,
type GitRunStackedActionResult,
} from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import { assert, describe, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -64,7 +64,16 @@ function progress<T extends GitActionProgressEvent>(event: T): T {
}

describe("vcsActionState", () => {
it("preserves malformed target key diagnostics and the native cause without copying the key", () => {
it("round-trips target keys through a Schema JSON codec", () => {
const key = getVcsActionTargetKey({ environmentId, cwd });

if (key === null) {
assert.fail("expected target key to be encoded");
}
assert.deepStrictEqual(parseVcsActionTargetKey(key), { environmentId, cwd });
});

it("preserves malformed target key diagnostics without copying the key", () => {
const key = "not-json-with-credential=do-not-log";
let error: unknown;

Expand All @@ -74,16 +83,17 @@ describe("vcsActionState", () => {
error = cause;
}

expect(error).toBeInstanceOf(VcsActionTargetKeyParseError);
expect(error).toMatchObject({ keyLength: key.length, cause: expect.any(SyntaxError) });
expect(error).not.toHaveProperty("key");
expect((error as Error).message).not.toContain(key);
assert.instanceOf(error, VcsActionTargetKeyParseError);
const parseError = error as VcsActionTargetKeyParseError;
assert.strictEqual(parseError.keyLength, key.length);
assert.notProperty(parseError, "key");
assert.notInclude(parseError.message, key);
});

it("rejects invalid target key shapes", () => {
const key = JSON.stringify([environmentId]);

expect(() => parseVcsActionTargetKey(key)).toThrowError(VcsActionTargetKeyParseError);
assert.throws(() => parseVcsActionTargetKey(key), VcsActionTargetKeyParseError);
});

it("projects phase and hook progress without owning the async operation", () => {
Expand Down
10 changes: 5 additions & 5 deletions packages/client-runtime/src/state/vcsAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@ export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({

const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe());
let nextLocalActionId = 0;
const decodeVcsActionTargetKey = Schema.decodeUnknownSync(
Schema.Tuple([EnvironmentId, Schema.String]),
);
const VcsActionTargetKeyJson = Schema.fromJsonString(Schema.Tuple([EnvironmentId, Schema.String]));
const decodeVcsActionTargetKey = Schema.decodeUnknownSync(VcsActionTargetKeyJson);
const encodeVcsActionTargetKey = Schema.encodeSync(VcsActionTargetKeyJson);

export const vcsActionStateAtom = Atom.family((key: string) => {
return Atom.make(EMPTY_VCS_ACTION_STATE).pipe(
Expand All @@ -177,12 +177,12 @@ export function getVcsActionTargetKey(target: VcsActionTarget): string | null {
if (target.environmentId === null || target.cwd === null) {
return null;
}
return JSON.stringify([target.environmentId, target.cwd]);
return encodeVcsActionTargetKey([target.environmentId, target.cwd]);
}

export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget {
try {
const [environmentId, cwd] = decodeVcsActionTargetKey(JSON.parse(key));
const [environmentId, cwd] = decodeVcsActionTargetKey(key);
return { environmentId, cwd };
} catch (cause) {
throw new VcsActionTargetKeyParseError({ keyLength: key.length, cause });
Expand Down
Loading