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
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@
"stream-json": "3.6.0",
"undici": "catalog:",
"yaml": "catalog:",
"yauzl": "^3.4.0"
"yauzl": "^3.4.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@effect/graphql-generator": "catalog:",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 75 additions & 1 deletion scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as NodeCrypto from "node:crypto";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { extractFile } from "@electron/asar";
import { createPackageWithOptions, extractFile } from "@electron/asar";
import { assert, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Deferred from "effect/Deferred";
Expand Down Expand Up @@ -61,7 +61,9 @@ import {
ancestorNodeModulesPaths,
copyDirectoryPreservingSymlinks,
LinuxBrowserSecretHostError,
MacPackagedAppMissingError,
stageBrowserSecret,
validateMacPackagedPayload,
validateWindowsPackagedPayload,
verifyPackagedBundleIsSelfContained,
WindowsPrimaryNativeProbeError,
Expand Down Expand Up @@ -227,6 +229,26 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu
} as const;
});

const makeMacPayloadFixture = Effect.fn("test.makeMacPayloadFixture")(function* (input: {
readonly serverEntrySource?: string;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mac-payload-test-" });
const sourceDir = path.join(tempDir, "app-source");
const serverEntryPath = path.join(sourceDir, "apps/server/dist/bin.mjs");
yield* fs.makeDirectory(path.dirname(serverEntryPath), { recursive: true });
yield* fs.writeFileString(serverEntryPath, input.serverEntrySource ?? "console.log('server');\n");

const stageDistDir = path.join(tempDir, "dist");
const resourcesDir = path.join(stageDistDir, "mac-arm64/T3 Code.app/Contents/Resources");
yield* fs.makeDirectory(resourcesDir, { recursive: true });
yield* Effect.promise(() =>
createPackageWithOptions(sourceDir, path.join(resourcesDir, "app.asar"), {}),
);
return { stageDistDir } as const;
});

it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
it.effect("rejects overlapping builds from another process until restoration finishes", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -1517,6 +1539,58 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
),
);

it.effect("rejects a macOS app whose packaged server bundle cannot resolve", () =>
Effect.scoped(
Effect.gen(function* () {
const fixture = yield* makeMacPayloadFixture({
serverEntrySource: 'import "t3code-deliberately-missing-package";\n',
});
const error = yield* validateMacPackagedPayload({
stageDistDir: fixture.stageDistDir,
}).pipe(Effect.flip);

assert.instanceOf(error, BundleNotSelfContainedError);
assert.include(error.output, "t3code-deliberately-missing-package");
}),
),
);

it.effect("accepts a macOS app whose packaged server bundle resolves", () =>
Effect.scoped(
Effect.gen(function* () {
const fixture = yield* makeMacPayloadFixture({});
yield* validateMacPackagedPayload({ stageDistDir: fixture.stageDistDir });
}),
),
);

it.effect("rejects a macOS stage without a packaged app", () =>
Effect.scoped(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const stageDistDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mac-payload-test-" });
const error = yield* validateMacPackagedPayload({ stageDistDir }).pipe(Effect.flip);

assert.instanceOf(error, MacPackagedAppMissingError);
}),
),
);

it.effect("rejects a macOS app without app.asar", () =>
Effect.scoped(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const stageDistDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mac-payload-test-" });
yield* fs.makeDirectory(`${stageDistDir}/mac-arm64/T3 Code.app/Contents/Resources`, {
recursive: true,
});
const error = yield* validateMacPackagedPayload({ stageDistDir }).pipe(Effect.flip);

assert.instanceOf(error, BundleNotSelfContainedError);
}),
),
);

it.effect("leaves the bundle self-check of a cross-built payload to the verify job", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
40 changes: 40 additions & 0 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3530,6 +3530,42 @@ export const validateWindowsPackagedPayload = Effect.fn(
return { packagedAppDir, fileCount, unpackedFiles } as const;
});

export class MacPackagedAppMissingError extends Schema.TaggedError<MacPackagedAppMissingError>()(
"MacPackagedAppMissingError",
{ stageDistDir: Schema.String },
) {
override get message(): string {
return `Expected electron-builder to leave a packaged .app with Contents/Resources/app.asar under ${this.stageDistDir}/mac*.`;
}
}

export const validateMacPackagedPayload = Effect.fn("desktopArtifact.validateMacPackagedPayload")(
function* (input: { readonly stageDistDir: string; readonly verbose?: boolean }) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const asarPaths: string[] = [];
for (const entry of yield* fs.readDirectory(input.stageDistDir)) {
if (!entry.startsWith("mac")) continue;
const outputDir = path.join(input.stageDistDir, entry);
const apps = yield* fs.readDirectory(outputDir).pipe(Effect.orElseSucceed(() => []));
for (const app of apps) {
if (app.endsWith(".app")) {
asarPaths.push(path.join(outputDir, app, "Contents/Resources/app.asar"));
}
}
}
if (asarPaths.length === 0) {
return yield* new MacPackagedAppMissingError({ stageDistDir: input.stageDistDir });
}
for (const asarPath of asarPaths) {
yield* verifyPackagedBundleIsSelfContained({ asarPath, verbose: input.verbose ?? false });
}
yield* Effect.log(
`[desktop-artifact] Validated macOS payload (${String(asarPaths.length)} app bundle(s)).`,
);
},
);

const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(function* (
options: ResolvedBuildOptions,
) {
Expand Down Expand Up @@ -4025,6 +4061,10 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f
verbose: options.verbose,
});
}
// x64 builds on arm64 runners; the arm64 check covers the shared JS bundle.
if (options.platform === "mac" && options.arch === "arm64" && hostPlatform === "darwin") {
yield* validateMacPackagedPayload({ stageDistDir, verbose: options.verbose });
}

const stageEntries = yield* fs.readDirectory(stageDistDir);
yield* fs.makeDirectory(options.outputDir, { recursive: true });
Expand Down
45 changes: 45 additions & 0 deletions scripts/lib/cli-external-packages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { findEsmImportsOfExternalPackages } from "./cli-executable-imports.ts";
import {
isRuntimeExternalCliDependency,
findInlinedExternalPackages,
selectCliRuntimeExternalDependencies,
shouldBundleCliDependency,
} from "./cli-external-packages.ts";

Expand All @@ -20,6 +21,9 @@ const PackageManifest = Schema.Struct({
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
peerDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
peerDependenciesMeta: Schema.optional(
Schema.Record(Schema.String, Schema.Struct({ optional: Schema.optional(Schema.Boolean) })),
),
});
type PackageManifest = typeof PackageManifest.Type;

Expand Down Expand Up @@ -120,6 +124,47 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => {
);
}),
);

// The stage installs only externals the server lists, so a required peer must be reachable from them.
it.effect("stages a provider for every required peer of a staged external", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const installed = yield* readInstalledPackages;
const server = decodeManifest(
yield* fileSystem.readFileString(
path.resolve(
path.dirname(NodeURL.fileURLToPath(import.meta.url)),
"../../apps/server/package.json",
),
),
);

const staged = new Set<string>();
const queue = Object.keys(selectCliRuntimeExternalDependencies(server.dependencies ?? {}));
for (const name of queue) {
if (staged.has(name)) continue;
staged.add(name);
const manifest = installed.get(name);
queue.push(
...Object.keys({ ...manifest?.dependencies, ...manifest?.optionalDependencies }),
);
}

const missing = [...staged].flatMap((name) => {
const manifest = installed.get(name);
return Object.keys(manifest?.peerDependencies ?? {})
.filter((peer) => !manifest?.peerDependenciesMeta?.[peer]?.optional && !staged.has(peer))
.map((peer) => `${name} -> ${peer}`);
});

assert.deepStrictEqual(
missing,
[],
`these peers of staged external packages are missing from the packaged app; add them to apps/server dependencies: ${missing.join(", ")}`,
);
}),
);
});

describe("findInlinedExternalPackages", () => {
Expand Down