diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9754f9421b5..7c0efea580cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,6 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" - schedule: - - cron: "0 */3 * * *" workflow_dispatch: inputs: channel: @@ -27,49 +25,8 @@ permissions: id-token: none jobs: - check_changes: - name: Check for changes since last nightly - if: github.event_name == 'schedule' - runs-on: blacksmith-8vcpu-ubuntu-2404 - outputs: - has_changes: ${{ steps.check.outputs.has_changes }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - id: check - name: Compare HEAD to last nightly tag - run: | - last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) - if [[ -z "$last_nightly_tag" ]]; then - echo "No previous nightly tag found. Proceeding with release." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") - head_sha=$(git rev-parse HEAD) - - if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - preflight: name: Preflight - needs: [check_changes] - if: | - !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: @@ -113,7 +70,7 @@ jobs: NIGHTLY_SHA: ${{ github.sha }} NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | - if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ diff --git a/.gitignore b/.gitignore index 9a520292d930..3f6968dfada7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ __screenshots__/ squashfs-root/ .vercel .gstack/ +/tmp/ dist-electron/ .electron-runtime/ .showcase/ diff --git a/.vite-hooks/pre-push b/.vite-hooks/pre-push new file mode 100755 index 000000000000..33bf059571e7 --- /dev/null +++ b/.vite-hooks/pre-push @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +vp run --workspace-root lastcode:ci:quick diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index de945054c893..73e831d4a398 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; import type * as Electron from "electron"; @@ -20,9 +19,9 @@ const defaultEnvironmentInput = { platform: "darwin", processArch: "arm64", appVersion: "1.2.3", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + appPath: "/Applications/LastCode.app/Contents/Resources/app.asar", isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + resourcesPath: "/Applications/LastCode.app/Contents/Resources", runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; @@ -39,7 +38,7 @@ interface ElectronAppCalls { const makeElectronAppLayer = (calls: ElectronAppCalls) => Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), - name: Effect.succeed("T3 Code"), + name: Effect.succeed("LastCode"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, @@ -107,8 +106,6 @@ const withIdentity = ( input: { readonly calls?: ElectronAppCalls; readonly environment?: TestEnvironmentInput; - readonly legacyPathExists?: boolean; - readonly legacyPathProbeError?: PlatformError.PlatformError; readonly packageJson?: string; readonly pngIconPath?: Option.Option; } = {}, @@ -124,12 +121,6 @@ const withIdentity = ( DesktopAppIdentity.layer.pipe( Layer.provideMerge( FileSystem.layerNoop({ - exists: (path) => - input.legacyPathProbeError - ? Effect.fail(input.legacyPathProbeError) - : Effect.succeed( - input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), - ), readFileString: () => Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), }), @@ -143,45 +134,17 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { - it.effect("keeps using the legacy userData path when it already exists", () => + it.effect("uses the isolated LastCode userData path", () => withIdentity( Effect.gen(function* () { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; const userDataPath = yield* identity.resolveUserDataPath; - assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + assert.equal(userDataPath, "/Users/alice/Library/Application Support/lastcode"); }), - { legacyPathExists: true }, ), ); - it.effect("preserves failures while inspecting the legacy userData path", () => { - const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "exists", - description: "permission denied", - pathOrDescriptor: legacyPath, - }); - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); - - assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError); - assert.equal(error.legacyPath, legacyPath); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to inspect legacy desktop user-data path at "${legacyPath}".`, - ); - }), - { legacyPathProbeError: cause }, - ); - }); - it.effect("configures app identity from the environment commit override", () => { const calls: ElectronAppCalls = { setAboutPanelOptions: [], @@ -194,8 +157,8 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.deepEqual(calls.setName, ["LastCode (Alpha)"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "LastCode (Alpha)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); assert.deepEqual(calls.setDockIcon, ["/icon.png"]); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 0be55d633e61..597466293175 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,22 +18,10 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); -export class DesktopUserDataPathResolutionError extends Schema.TaggedErrorClass()( - "DesktopUserDataPathResolutionError", - { - legacyPath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`; - } -} - export class DesktopAppIdentity extends Context.Service< DesktopAppIdentity, { - readonly resolveUserDataPath: Effect.Effect; + readonly resolveUserDataPath: Effect.Effect; readonly configure: Effect.Effect; } >()("@t3tools/desktop/app/DesktopAppIdentity") {} @@ -47,23 +35,7 @@ const normalizeCommitHash = (value: string): Option.Option => { export const resolveUserDataPath = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, - ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), - ), - ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + return environment.path.join(environment.appDataDirectory, environment.userDataDirName); }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); export const make = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909aef..00ffe63138fa 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -34,8 +34,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", - userDataDirName: isDevelopment ? "t3code-dev" : "t3code", - legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", + userDataDirName: isDevelopment ? "lastcode-dev" : "lastcode", path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -91,7 +90,7 @@ describe("DesktopClerk", () => { { storage: storageAdapter, passkeys: true, - renderer: { scheme: "t3code-dev", host: "app" }, + renderer: { scheme: "lastcode-dev", host: "app" }, }, ], ]); @@ -99,7 +98,10 @@ describe("DesktopClerk", () => { // The bridge acquires Electron's single-instance lock at creation, and // the lock both lives in and creates the userData directory — so the // real path must be set before the bridge exists. - assert.deepEqual(events, ["setPath:userData:/tmp/app-data/t3code-dev", "createClerkBridge"]); + assert.deepEqual(events, [ + "setPath:userData:/tmp/app-data/lastcode-dev", + "createClerkBridge", + ]); storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); @@ -210,8 +212,8 @@ describe("DesktopClerk", () => { }); it.each([ - { isDevelopment: true, scheme: "t3code-dev" }, - { isDevelopment: false, scheme: "t3code" }, + { isDevelopment: true, scheme: "lastcode-dev" }, + { isDevelopment: false, scheme: "lastcode" }, ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; storageMock.mockReturnValue(storageAdapter); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9611dc083d2f..a65e1872f6a3 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -87,12 +87,9 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; - // Electron scopes the single-instance lock to the userData directory and - // creates that directory when the lock is acquired. The SDK bridge takes - // the lock at creation, so userData must already point at the real - // directory here — under the default productName-derived path, acquiring - // the lock would create "T3 Code (Alpha)" and make the legacy-install - // detection in resolveUserDataPath match on fresh installs. + // Electron scopes the single-instance lock to the userData directory. The + // SDK bridge takes the lock at creation, so point it at LastCode's isolated + // profile before creating the bridge. const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index b7647b5cc10f..5baada964017 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -81,12 +81,12 @@ describe("DesktopEarlyElectronStartup", () => { }); assert.deepEqual(options, { - linuxWmClass: "t3code-dev", + linuxWmClass: "lastcode-dev", passwordStore: "gnome-libsecret", }); }); - it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { + it("keeps implicit development state under ~/.lastcode/dev when T3CODE_HOME is unset", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ env: { VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", @@ -94,7 +94,7 @@ describe("DesktopEarlyElectronStartup", () => { homeDirectory: "/home/user", joinPath, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.lastcode/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "kwallet" }); }, }); @@ -111,7 +111,7 @@ describe("DesktopEarlyElectronStartup", () => { homeDirectory: "/home/user", joinPath, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.lastcode/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); }, }); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 3e11d7961a9f..6285ea1b5c7c 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -1,4 +1,5 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -81,7 +82,9 @@ export function resolveEarlyLinuxElectronOptions( ): EarlyLinuxElectronOptions { const preference = resolveEarlyLinuxPasswordStorePreference(input); return { - linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", + linuxWmClass: isDevelopmentEnvironment(input.env) + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentExecutableName + : LASTCODE_DESKTOP_DISTRIBUTION.executableName, passwordStore: resolveLinuxPasswordStoreSwitch({ preference, env: input.env, diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 15d23f8e1522..8880a703a35f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -13,9 +13,9 @@ const defaultInput = { platform: "darwin", processArch: "arm64", appVersion: "0.0.22", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + appPath: "/Applications/LastCode.app/Contents/Resources/app.asar", isPackaged: false, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + resourcesPath: "/Applications/LastCode.app/Contents/Resources", runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; @@ -67,8 +67,10 @@ describe("DesktopEnvironment", () => { assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); - assert.equal(environment.linuxWmClass, "t3code-dev"); + assert.equal(environment.appUserModelId, "codes.lastobelus.lastcode.dev"); + assert.equal(environment.linuxWmClass, "lastcode-dev"); + assert.equal(environment.userDataDirName, "lastcode-dev"); + assert.equal(environment.displayName, "LastCode (Dev)"); assert.deepEqual( Option.map(environment.devServerUrl, (url) => url.href), Option.some("http://localhost:5173/"), @@ -106,8 +108,11 @@ describe("DesktopEnvironment", () => { ); const production = yield* makeEnvironment(); - assert.equal(development.stateDir, "/Users/alice/.t3/dev"); - assert.equal(production.stateDir, "/Users/alice/.t3/userdata"); + assert.equal(development.stateDir, "/Users/alice/.lastcode/dev"); + assert.equal(production.stateDir, "/Users/alice/.lastcode/userdata"); + assert.equal(production.userDataDirName, "lastcode"); + assert.equal(production.appUserModelId, "codes.lastobelus.lastcode"); + assert.equal(production.displayName, "LastCode (Alpha)"); }), ); @@ -116,12 +121,12 @@ describe("DesktopEnvironment", () => { const environment = yield* makeEnvironment( {}, { - T3CODE_DESKTOP_APP_USER_MODEL_ID: " com.t3tools.t3code.dev.local ", + T3CODE_DESKTOP_APP_USER_MODEL_ID: " codes.lastobelus.lastcode.dev.local ", VITE_DEV_SERVER_URL: "http://localhost:5173", }, ); - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev.local"); + assert.equal(environment.appUserModelId, "codes.lastobelus.lastcode.dev.local"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 1806289a08d1..5ab0bed8a477 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -4,6 +4,7 @@ import type { DesktopRuntimeArch, DesktopRuntimeInfo, } from "@t3tools/contracts"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -70,7 +71,6 @@ export class DesktopEnvironment extends Context.Service< readonly linuxApplicationsDir: string; readonly appImagePath: Option.Option; readonly userDataDirName: string; - readonly legacyUserDataDirName: string; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; readonly runtimeInfo: DesktopRuntimeInfo; readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; @@ -79,8 +79,6 @@ export class DesktopEnvironment extends Context.Service< } >()("@t3tools/desktop/app/DesktopEnvironment") {} -const APP_BASE_NAME = "T3 Code"; - function resolveDesktopAppStageLabel(input: { readonly isDevelopment: boolean; readonly appVersion: string; @@ -98,9 +96,9 @@ function resolveDesktopAppBranding(input: { }): DesktopAppBranding { const stageLabel = resolveDesktopAppStageLabel(input); return { - baseName: APP_BASE_NAME, + baseName: LASTCODE_DESKTOP_DISTRIBUTION.productName, stageLabel, - displayName: `${APP_BASE_NAME} (${stageLabel})`, + displayName: `${LASTCODE_DESKTOP_DISTRIBUTION.productName} (${stageLabel})`, }; } @@ -168,8 +166,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( joinPath: path.join, t3Home: config.t3Home, }); - const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const userDataDirName = isDevelopment + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentUserDataDirName + : LASTCODE_DESKTOP_DISTRIBUTION.userDataDirName; const linuxApplicationsDir = path.join( Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), "applications", @@ -213,14 +212,19 @@ const make = Effect.fn("desktop.environment.make")(function* ( branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => - isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + isDevelopment + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentAppId + : LASTCODE_DESKTOP_DISTRIBUTION.appId, ), - linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", - linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + linuxDesktopEntryName: isDevelopment + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentLinuxDesktopEntryName + : LASTCODE_DESKTOP_DISTRIBUTION.linuxDesktopEntryName, + linuxWmClass: isDevelopment + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentExecutableName + : LASTCODE_DESKTOP_DISTRIBUTION.executableName, linuxApplicationsDir, appImagePath: config.appImagePath, userDataDirName, - legacyUserDataDirName, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ platform: input.platform, @@ -258,7 +262,13 @@ const make = Effect.fn("desktop.environment.make")(function* ( path.join(resourcesPath, "resources", fileName), path.join(resourcesPath, fileName), ], - developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), + developmentDockIconPath: path.join( + rootDir, + "assets", + "lastcode", + "dev", + "app-icon-macos-1024.png", + ), }); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 30183808a152..1630ff2c74f4 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -22,10 +22,10 @@ const makeEnvironment = (overrides: Record = {}) => platform: "linux", isPackaged: true, isDevelopment: false, - displayName: "T3 Code (Alpha)", - linuxWmClass: "t3code", + displayName: "LastCode (Alpha)", + linuxWmClass: "lastcode", linuxApplicationsDir: "/home/alice/.local/share/applications", - appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), + appImagePath: Option.some("/home/alice/Applications/LastCode.AppImage"), path: { join: (...parts: ReadonlyArray) => parts.join("/") }, ...overrides, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -105,49 +105,49 @@ const emptyRecording = (): RecordedRegistration => ({ describe("DesktopLinuxUrlHandler", () => { it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code (Nightly)", - execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', - scheme: "t3code", + displayName: "LastCode (Nightly)", + execTarget: '/home/al ice/Apps/LastCode "100%" $HOME\\x.AppImage', + scheme: "lastcode", }); assert.include(entry, "[Desktop Entry]"); - assert.include(entry, "Name=T3 Code (Nightly)"); + assert.include(entry, "Name=LastCode (Nightly)"); // Exec composes both escaping layers: a literal backslash becomes four // backslashes in the file, a quote three characters, a dollar sign two // backslashes plus the sign. assert.include( entry, - 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', + 'Exec="/home/al ice/Apps/LastCode \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', ); assert.include(entry, "NoDisplay=true"); assert.notInclude(entry, "StartupWMClass="); - assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + assert.include(entry, "MimeType=x-scheme-handler/lastcode;"); }); it("carries structured context on registration errors", () => { const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ step: "write-desktop-entry", - scheme: "t3code", - desktopEntryPath: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + scheme: "lastcode", + desktopEntryPath: "/home/alice/.local/share/applications/lastcode-url-handler.desktop", cause: new Error("boom"), }); assert.equal( writeError.message, - "Failed to register the t3code:// URL handler (step: write-desktop-entry).", + "Failed to register the lastcode:// URL handler (step: write-desktop-entry).", ); assert.equal( writeError.desktopEntryPath, - "/home/alice/.local/share/applications/t3code-url-handler.desktop", + "/home/alice/.local/share/applications/lastcode-url-handler.desktop", ); const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ step: "set-default-handler", - scheme: "t3code", + scheme: "lastcode", exitCode: 4, }); assert.equal( exitError.message, - "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", + "Failed to register the lastcode:// URL handler (step: set-default-handler, xdg-mime exit code 4).", ); }); @@ -161,17 +161,17 @@ describe("DesktopLinuxUrlHandler", () => { assert.equal(recorded.files.length, 1); assert.equal( recorded.files[0]?.path, - "/home/alice/.local/share/applications/t3code-url-handler.desktop", + "/home/alice/.local/share/applications/lastcode-url-handler.desktop", ); assert.include( recorded.files[0]?.content, - 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + 'Exec="/home/alice/Applications/LastCode.AppImage" %U', ); - assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/lastcode;"); assert.deepEqual(recorded.commands, [ { command: "xdg-mime", - args: ["default", "t3code-url-handler.desktop", "x-scheme-handler/t3code"], + args: ["default", "lastcode-url-handler.desktop", "x-scheme-handler/lastcode"], }, ]); }); @@ -218,7 +218,7 @@ describe("DesktopLinuxUrlHandler", () => { module: "FileSystem", method: "writeFileString", description: "read-only filesystem", - pathOrDescriptor: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + pathOrDescriptor: "/home/alice/.local/share/applications/lastcode-url-handler.desktop", }), }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e531a54dfce6..64fe2985e709 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -20,7 +21,8 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // our own handler entry pointing at the current AppImage and claim the // scheme default via xdg-mime, exactly what the file manager's "set as // default" checkbox would record in mimeapps.list. -export const URL_HANDLER_DESKTOP_ENTRY_NAME = "t3code-url-handler.desktop"; +export const URL_HANDLER_DESKTOP_ENTRY_NAME = + LASTCODE_DESKTOP_DISTRIBUTION.linuxUrlHandlerDesktopEntryName; const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts index 006dd97092d4..282a4ea760cc 100644 --- a/apps/desktop/src/app/DesktopStatePaths.ts +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -1,5 +1,7 @@ import * as Option from "effect/Option"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; + export type JoinPath = (first: string, ...segments: string[]) => string; function normalizeConfiguredBaseDir(t3Home: Option.Option): Option.Option { @@ -16,7 +18,7 @@ export function resolveDesktopBaseDir(input: { readonly t3Home: Option.Option; }): string { return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => - input.joinPath(input.homeDirectory, ".t3"), + input.joinPath(input.homeDirectory, LASTCODE_DESKTOP_DISTRIBUTION.defaultHomeDirName), ); } diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 2db85dafc4da..31050b4f1531 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -35,7 +35,7 @@ describe("ElectronProtocol", () => { Effect.gen(function* () { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ - scheme: "t3code-dev", + scheme: "lastcode-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", @@ -44,11 +44,11 @@ describe("ElectronProtocol", () => { const response = yield* Effect.promise(() => handler!( - new Request("t3code-dev://app/api/health?verbose=1", { + new Request("lastcode-dev://app/api/health?verbose=1", { headers: { accept: "application/json", - origin: "t3code-dev://app", - referer: "t3code-dev://app/", + origin: "lastcode-dev://app", + referer: "lastcode-dev://app/", "sec-fetch-site": "same-origin", }, }), @@ -65,18 +65,18 @@ describe("ElectronProtocol", () => { ); assert.include( response.headers.get("content-security-policy") ?? "", - "img-src 'self' t3code-dev: blob: data: http: https:", + "img-src 'self' lastcode-dev: blob: data: http: https:", ); assert.include( response.headers.get("content-security-policy") ?? "", - "font-src 'self' t3code-dev: data:", + "font-src 'self' lastcode-dev: data:", ); }), ); assert.deepEqual( handleMock.mock.calls.map((call) => call[0]), - ["t3code-dev"], + ["lastcode-dev"], ); assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1"); const forwardedHeaders = new Headers(netFetchMock.mock.calls[0]?.[1]?.headers); @@ -84,7 +84,7 @@ describe("ElectronProtocol", () => { assert.isNull(forwardedHeaders.get("origin")); assert.isNull(forwardedHeaders.get("referer")); assert.isNull(forwardedHeaders.get("sec-fetch-site")); - assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); + assert.deepEqual(unhandleMock.mock.calls, [["lastcode-dev"]]); }).pipe(Effect.provide(ElectronProtocol.layer)), ); @@ -99,12 +99,12 @@ describe("ElectronProtocol", () => { Effect.gen(function* () { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ - scheme: "t3code", + scheme: "lastcode", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); - return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); + return yield* Effect.promise(() => handler!(new Request("lastcode://other/"))); }), ); @@ -127,12 +127,12 @@ describe("ElectronProtocol", () => { Effect.gen(function* () { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ - scheme: "t3code-dev", + scheme: "lastcode-dev", targetOrigin: new URL("http://127.0.0.1:5733/"), backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); - return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); + return yield* Effect.promise(() => handler!(new Request("lastcode-dev://app/"))); }), ); @@ -151,7 +151,7 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; const error = yield* Effect.scoped( protocol.registerDesktopProtocol({ - scheme: "t3code-dev", + scheme: "lastcode-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, @@ -159,9 +159,9 @@ describe("ElectronProtocol", () => { ).pipe(Effect.flip); assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError); - assert.equal(error.scheme, "t3code-dev"); + assert.equal(error.scheme, "lastcode-dev"); assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); + assert.equal(error.message, 'Failed to register Electron protocol scheme "lastcode-dev".'); }).pipe(Effect.provide(ElectronProtocol.layer)), ); @@ -176,7 +176,7 @@ describe("ElectronProtocol", () => { const exit = yield* Effect.exit( Effect.scoped( protocol.registerDesktopProtocol({ - scheme: "t3code", + scheme: "lastcode", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, @@ -188,16 +188,16 @@ describe("ElectronProtocol", () => { if (exit._tag === "Failure") { const error = Cause.squash(exit.cause); assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError); - assert.equal(error.scheme, "t3code"); + assert.equal(error.scheme, "lastcode"); assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); + assert.equal(error.message, 'Failed to unregister Electron protocol scheme "lastcode".'); } }).pipe(Effect.provide(ElectronProtocol.layer)), ); it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ - scheme: "t3code", + scheme: "lastcode", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", @@ -219,12 +219,12 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]); assert.deepEqual(directives["img-src"], [ "'self'", - "t3code:", + "lastcode:", "blob:", "data:", "http:", "https:", ]); - assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); + assert.deepEqual(directives["font-src"], ["'self'", "lastcode:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 11459c9ef7a8..4f68eabc06fd 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -8,9 +8,11 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; + export const DESKTOP_HOST = "app"; -export const DESKTOP_PRODUCTION_SCHEME = "t3code"; -export const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; +export const DESKTOP_PRODUCTION_SCHEME = LASTCODE_DESKTOP_DISTRIBUTION.productionScheme; +export const DESKTOP_DEVELOPMENT_SCHEME = LASTCODE_DESKTOP_DISTRIBUTION.developmentScheme; export function getDesktopScheme(isDevelopment: boolean): string { return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0e..e9430de47559 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -63,7 +63,7 @@ function makeFakeBrowserWindow() { const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), - getURL: vi.fn(() => "t3code-dev://app/"), + getURL: vi.fn(() => "lastcode-dev://app/"), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -387,19 +387,19 @@ describe("DesktopWindow", () => { it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "t3code://app/settings/connections", + applicationUrl: "lastcode://app/", + navigationUrl: "lastcode://app/settings/connections", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", + applicationUrl: "lastcode://app/", navigationUrl: "https://accounts.microsoft.com/oauth", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", + applicationUrl: "lastcode://app/", navigationUrl: "not a url", }), ); @@ -432,7 +432,7 @@ describe("DesktopWindow", () => { assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["lastcode-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), @@ -956,17 +956,17 @@ describe("DesktopWindow", () => { return yield* Effect.die("renderer load listeners were not registered"); } - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFailLoad({}, -9, "ERR_UNEXPECTED", "lastcode-dev://app/", true); assert.equal(fakeWindow.loadURL.mock.calls.length, 1); yield* TestClock.adjust(100); assert.deepEqual(fakeWindow.loadURL.mock.calls, [ - ["t3code-dev://app/"], - ["t3code-dev://app/"], + ["lastcode-dev://app/"], + ["lastcode-dev://app/"], ]); assert.equal(fakeWindow.reload.mock.calls.length, 0); - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFailLoad({}, -9, "ERR_UNEXPECTED", "lastcode-dev://app/", true); didFinishLoad(); yield* TestClock.adjust(250); assert.equal(fakeWindow.loadURL.mock.calls.length, 2); @@ -978,23 +978,23 @@ describe("DesktopWindow", () => { it("retries only transient failures for the development renderer", () => { assert.isTrue( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "lastcode-dev://app/", errorCode: -102, isMainFrame: true, - validatedUrl: "t3code-dev://app/", + validatedUrl: "lastcode-dev://app/", }), ); assert.isFalse( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "lastcode-dev://app/", errorCode: -3, isMainFrame: true, - validatedUrl: "t3code-dev://app/", + validatedUrl: "lastcode-dev://app/", }), ); assert.isFalse( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "lastcode-dev://app/", errorCode: -102, isMainFrame: true, validatedUrl: "https://example.com/", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 2de5b702a286..8a0997ea21dd 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -10,11 +10,11 @@ import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveWebAssetBrandForPackageVersion } from "../../../scripts/lib/brand-assets.ts"; import { - DEVELOPMENT_ICON_OVERRIDES, - resolveWebAssetBrandForPackageVersion, - resolveWebIconOverrides, -} from "../../../scripts/lib/brand-assets.ts"; + LASTCODE_DEVELOPMENT_ICON_OVERRIDES, + resolveLastCodeWebIconOverrides, +} from "../../../scripts/lib/lastcode-brand-assets.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; @@ -90,7 +90,7 @@ const preparePublishIcons = Effect.fn("preparePublishIcons")(function* ( const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const brand = resolveWebAssetBrandForPackageVersion(version); - const icons = resolveWebIconOverrides(brand, "dist/client").map((override) => ({ + const icons = resolveLastCodeWebIconOverrides(brand, "dist/client").map((override) => ({ sourcePath: path.join(repoRoot, override.sourceRelativePath), targetPath: path.join(serverDir, override.targetRelativePath), })); @@ -119,7 +119,7 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides") const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - for (const override of DEVELOPMENT_ICON_OVERRIDES) { + for (const override of LASTCODE_DEVELOPMENT_ICON_OVERRIDES) { const sourcePath = path.join(repoRoot, override.sourceRelativePath); const targetPath = path.join(serverDir, override.targetRelativePath); diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png index 3eed25ea6b78..f555ccb792d6 100644 Binary files a/apps/web/public/apple-touch-icon.png and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png index a3431b8c6dfe..8e8910d18d84 100644 Binary files a/apps/web/public/favicon-16x16.png and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png index 862f7629971f..779d306aea63 100644 Binary files a/apps/web/public/favicon-32x32.png and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 750da22602ee..9c1289d8df59 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/src/components/branding/LastCodeWordmark.test.tsx b/apps/web/src/components/branding/LastCodeWordmark.test.tsx new file mode 100644 index 000000000000..72d56061dba8 --- /dev/null +++ b/apps/web/src/components/branding/LastCodeWordmark.test.tsx @@ -0,0 +1,25 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { LastCodeWordmark } from "./LastCodeWordmark"; + +describe("LastCodeWordmark", () => { + it("themes Last and Code independently", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('aria-label="LastCode"'); + expect(markup).toContain('data-wordmark-part="last"'); + expect(markup).toContain("text-sidebar-foreground"); + expect(markup).toContain('data-wordmark-part="code"'); + expect(markup).toContain("text-sidebar-muted-foreground"); + expect(markup.match(/ { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('class="text-white"'); + expect(markup).toContain('class="text-white/70"'); + }); +}); diff --git a/apps/web/src/components/branding/LastCodeWordmark.tsx b/apps/web/src/components/branding/LastCodeWordmark.tsx new file mode 100644 index 000000000000..41fb14e3ad63 --- /dev/null +++ b/apps/web/src/components/branding/LastCodeWordmark.tsx @@ -0,0 +1,34 @@ +import { cn } from "../../lib/utils"; + +export function LastCodeWordmark({ onBackdrop = false }: { onBackdrop?: boolean }) { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index a8d3ef41416d..a58c30e68cf5 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -11,6 +11,7 @@ import { useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; import { Badge } from "../ui/badge"; +import { LastCodeWordmark } from "../branding/LastCodeWordmark"; import { SidebarFooter, SidebarHeader, @@ -79,35 +80,11 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { )} to="/" > - - - Code - + ); } -function T3Wordmark() { - return ( - - - - ); -} - export const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); const { isMobile, setOpenMobile } = useSidebar(); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index ddc7310430f4..57f1376c4a36 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -22,8 +22,6 @@ import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorag import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))"; const SIDEBAR_WIDTH_ICON = "3rem"; @@ -112,21 +110,13 @@ function SidebarProvider({ const [_open, _setOpen] = React.useState(defaultOpen); const open = openProp ?? _open; const setOpen = React.useCallback( - async (value: boolean | ((value: boolean) => boolean)) => { + (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === "function" ? value(open) : value; if (setOpenProp) { setOpenProp(openState); } else { _setOpen(openState); } - - // This sets the cookie to keep the sidebar state. - await cookieStore.set({ - expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, - name: SIDEBAR_COOKIE_NAME, - path: "/", - value: String(openState), - }); }, [setOpenProp, open], ); diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 63712ca7e295..b4c87b9d7c3d 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -123,9 +123,9 @@ describe("compressImageForStash", () => { }); it("reports too-large when even the smallest encoding overflows the budget", async () => { - const { close } = stubCanvasPipeline(() => 8_000_000); + const { close } = stubCanvasPipeline(() => 8_000); - const result = await compressImageForStash(makeFile(9_000_000)); + const result = await compressImageForStash(makeFile(9_000), 1_000); expect(result).toEqual({ ok: false, reason: "too-large" }); // The bitmap must still be released on the give-up path. @@ -206,9 +206,9 @@ describe("compressImageForStash", () => { }); it("compressImageToByteLimit reports too-large when no encoding fits", async () => { - const { close } = stubCanvasPipeline(() => 3_000_000); + const { close } = stubCanvasPipeline(() => 3_000); - const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000); + const result = await compressImageToByteLimit(makeFile(2_000), 1_000); expect(result).toEqual({ ok: false, reason: "too-large" }); expect(close).toHaveBeenCalled(); diff --git a/assets/lastcode/README.md b/assets/lastcode/README.md new file mode 100644 index 000000000000..11ffcfa19d87 --- /dev/null +++ b/assets/lastcode/README.md @@ -0,0 +1,18 @@ +# LastCode brand assets + +This directory owns LastCode artwork separately from the upstream T3 Code asset trees. +Keeping fork-specific sources and exports here limits merge conflicts while the fork tracks +upstream nightly releases. + +- `shared/wordmark.svg` is the editable source for the sidebar wordmark. +- `shared/app-mark-temporary.svg` is the current temporary app mark, including its baked-in + raster shadows. +- `dev/`, `nightly/`, and `prod/` are stable export targets for each release channel. + +The three channel directories intentionally contain the same temporary artwork today. Replace +their files in place when channel-specific Icon Composer projects are ready; build scripts and +application code should not need to change. Mobile remains on the upstream Icon Composer projects +until LastCode has native composer projects of its own. + +For the final app icons, keep the letter components on separate Icon Composer layers and recreate +their depth there. The checked-in temporary SVG remains useful as the visual reference and fallback. diff --git a/assets/lastcode/dev/app-icon-ios-1024.png b/assets/lastcode/dev/app-icon-ios-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/dev/app-icon-ios-1024.png differ diff --git a/assets/lastcode/dev/app-icon-macos-1024.png b/assets/lastcode/dev/app-icon-macos-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/dev/app-icon-macos-1024.png differ diff --git a/assets/lastcode/dev/app-icon-universal-1024.png b/assets/lastcode/dev/app-icon-universal-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/dev/app-icon-universal-1024.png differ diff --git a/assets/lastcode/dev/app-icon-windows.ico b/assets/lastcode/dev/app-icon-windows.ico new file mode 100644 index 000000000000..a57741d0e684 Binary files /dev/null and b/assets/lastcode/dev/app-icon-windows.ico differ diff --git a/assets/lastcode/dev/apple-touch-icon-180.png b/assets/lastcode/dev/apple-touch-icon-180.png new file mode 100644 index 000000000000..f555ccb792d6 Binary files /dev/null and b/assets/lastcode/dev/apple-touch-icon-180.png differ diff --git a/assets/lastcode/dev/favicon-16x16.png b/assets/lastcode/dev/favicon-16x16.png new file mode 100644 index 000000000000..8e8910d18d84 Binary files /dev/null and b/assets/lastcode/dev/favicon-16x16.png differ diff --git a/assets/lastcode/dev/favicon-32x32.png b/assets/lastcode/dev/favicon-32x32.png new file mode 100644 index 000000000000..779d306aea63 Binary files /dev/null and b/assets/lastcode/dev/favicon-32x32.png differ diff --git a/assets/lastcode/dev/favicon.ico b/assets/lastcode/dev/favicon.ico new file mode 100644 index 000000000000..9c1289d8df59 Binary files /dev/null and b/assets/lastcode/dev/favicon.ico differ diff --git a/assets/lastcode/nightly/app-icon-ios-1024.png b/assets/lastcode/nightly/app-icon-ios-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/nightly/app-icon-ios-1024.png differ diff --git a/assets/lastcode/nightly/app-icon-macos-1024.png b/assets/lastcode/nightly/app-icon-macos-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/nightly/app-icon-macos-1024.png differ diff --git a/assets/lastcode/nightly/app-icon-universal-1024.png b/assets/lastcode/nightly/app-icon-universal-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/nightly/app-icon-universal-1024.png differ diff --git a/assets/lastcode/nightly/app-icon-windows.ico b/assets/lastcode/nightly/app-icon-windows.ico new file mode 100644 index 000000000000..a57741d0e684 Binary files /dev/null and b/assets/lastcode/nightly/app-icon-windows.ico differ diff --git a/assets/lastcode/nightly/apple-touch-icon-180.png b/assets/lastcode/nightly/apple-touch-icon-180.png new file mode 100644 index 000000000000..f555ccb792d6 Binary files /dev/null and b/assets/lastcode/nightly/apple-touch-icon-180.png differ diff --git a/assets/lastcode/nightly/favicon-16x16.png b/assets/lastcode/nightly/favicon-16x16.png new file mode 100644 index 000000000000..8e8910d18d84 Binary files /dev/null and b/assets/lastcode/nightly/favicon-16x16.png differ diff --git a/assets/lastcode/nightly/favicon-32x32.png b/assets/lastcode/nightly/favicon-32x32.png new file mode 100644 index 000000000000..779d306aea63 Binary files /dev/null and b/assets/lastcode/nightly/favicon-32x32.png differ diff --git a/assets/lastcode/nightly/favicon.ico b/assets/lastcode/nightly/favicon.ico new file mode 100644 index 000000000000..9c1289d8df59 Binary files /dev/null and b/assets/lastcode/nightly/favicon.ico differ diff --git a/assets/lastcode/prod/app-icon-ios-1024.png b/assets/lastcode/prod/app-icon-ios-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/prod/app-icon-ios-1024.png differ diff --git a/assets/lastcode/prod/app-icon-macos-1024.png b/assets/lastcode/prod/app-icon-macos-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/prod/app-icon-macos-1024.png differ diff --git a/assets/lastcode/prod/app-icon-universal-1024.png b/assets/lastcode/prod/app-icon-universal-1024.png new file mode 100644 index 000000000000..2f7b3cf6352b Binary files /dev/null and b/assets/lastcode/prod/app-icon-universal-1024.png differ diff --git a/assets/lastcode/prod/app-icon-windows.ico b/assets/lastcode/prod/app-icon-windows.ico new file mode 100644 index 000000000000..a57741d0e684 Binary files /dev/null and b/assets/lastcode/prod/app-icon-windows.ico differ diff --git a/assets/lastcode/prod/apple-touch-icon-180.png b/assets/lastcode/prod/apple-touch-icon-180.png new file mode 100644 index 000000000000..f555ccb792d6 Binary files /dev/null and b/assets/lastcode/prod/apple-touch-icon-180.png differ diff --git a/assets/lastcode/prod/favicon-16x16.png b/assets/lastcode/prod/favicon-16x16.png new file mode 100644 index 000000000000..8e8910d18d84 Binary files /dev/null and b/assets/lastcode/prod/favicon-16x16.png differ diff --git a/assets/lastcode/prod/favicon-32x32.png b/assets/lastcode/prod/favicon-32x32.png new file mode 100644 index 000000000000..779d306aea63 Binary files /dev/null and b/assets/lastcode/prod/favicon-32x32.png differ diff --git a/assets/lastcode/prod/favicon.ico b/assets/lastcode/prod/favicon.ico new file mode 100644 index 000000000000..9c1289d8df59 Binary files /dev/null and b/assets/lastcode/prod/favicon.ico differ diff --git a/assets/lastcode/shared/app-mark-temporary.svg b/assets/lastcode/shared/app-mark-temporary.svg new file mode 100644 index 000000000000..7abe0d0eb2c8 --- /dev/null +++ b/assets/lastcode/shared/app-mark-temporary.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/lastcode/shared/wordmark.svg b/assets/lastcode/shared/wordmark.svg new file mode 100644 index 000000000000..7d545c0061b8 --- /dev/null +++ b/assets/lastcode/shared/wordmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/lastcode/README.md b/docs/lastcode/README.md new file mode 100644 index 000000000000..8d29f555c08c --- /dev/null +++ b/docs/lastcode/README.md @@ -0,0 +1,35 @@ +# LastCode Documentation + +LastCode is a personal downstream of T3 Code that rebases its complete fork-only +patch stack onto upstream nightly releases. Tracking an upstream nightly and +building an application are deliberately separate operations: every nightly can +be checkpointed, while only selected checkpoints need full local CI and a build. + +## Documents + +- [Nightly workflow](nightly-workflow.md): checkpoint tags, rebasing, promotion, + scheduling, recovery, and provenance. +- [Release workflow](release.md): local CI, PR merging, ad-hoc signing, builds, + and runtime isolation. +- [Fork conventions](fork-conventions.md): remotes, branch intent, upstream pull + requests, alternate forks, and evaluation tags. + +## Command Summary + +```bash +# Inspect what the checkpoint job would do. +pnpm lastcode:checkpoint --dry-run + +# Checkpoint every missing nightly and push immutable tags. +pnpm lastcode:checkpoint --push-tags --promote-if-no-open-prs + +# Enable the same operation at login and hourly. +pnpm lastcode:checkpoint:service install + +# Validate and build one explicit checkpoint. +pnpm lastcode:ci --checkpoint lastcode/checkpoint/ +pnpm lastcode:build:mac:arm64 --checkpoint lastcode/checkpoint/ +``` + +None of the checkpoint commands builds an application. No build is uploaded or +published unless a separate explicit release operation is added later. diff --git a/docs/fork-conventions.md b/docs/lastcode/fork-conventions.md similarity index 99% rename from docs/fork-conventions.md rename to docs/lastcode/fork-conventions.md index 55bfbe01f921..0a4b43bfc61e 100644 --- a/docs/fork-conventions.md +++ b/docs/lastcode/fork-conventions.md @@ -1,4 +1,4 @@ -# Fork Evaluation Conventions +# LastCode Fork Evaluation Conventions This document defines how `lastCode` tracks the upstream project, the canonical LastCode fork branch, notable forks, and candidate pull requests. diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md new file mode 100644 index 000000000000..673e23253156 --- /dev/null +++ b/docs/lastcode/nightly-workflow.md @@ -0,0 +1,174 @@ +# Nightly Checkpoint Workflow + +## Objectives + +The workflow has four independent requirements: + +1. Track upstream T3 Code nightly tags. +2. Rebase the complete LastCode patch stack rather than merge upstream history. +3. Preserve an immutable LastCode checkpoint for every upstream nightly, + including nightlies that are never packaged. +4. Build only when a checkpoint is intentionally selected and has passed full + local CI. + +Separating checkpointing from building keeps routine upstream tracking cheap and +makes every artifact traceable to exact source. + +## References + +| Reference | Purpose | Mutability | +| ----------------------------------- | -------------------------------------------------------- | ----------------------------- | +| `upstream/main` | Canonical T3 Code development branch | Moves upstream | +| `main` | Clean local mirror used for upstream contributions | Fast-forward only | +| `lastcode/main` | Latest promoted LastCode checkpoint and LastCode PR base | Rebased with force-with-lease | +| `lastcode/checkpoint/` | LastCode source rebased onto one upstream nightly | Immutable | +| `lastcode/build/.` | One local build attempt from a checkpoint | Immutable | +| `sync/nightly/` | Recovery branch retained after a failed sync | Temporary | + +Example tags: + +```text +lastcode/checkpoint/v0.0.34-nightly.20260812.1072 +lastcode/build/v0.0.34-nightly.20260812.1072.1 +``` + +The upstream nightly tag names remain owned by upstream. The namespaced +LastCode tags record the rebased downstream state and never move. + +## Checkpointing + +Preview the operation: + +```bash +pnpm lastcode:checkpoint --dry-run +``` + +Run it and publish checkpoint tags: + +```bash +pnpm lastcode:checkpoint --push-tags --promote-if-no-open-prs +``` + +The command: + +1. fetches upstream tags and the fork's existing checkpoint tags; +2. identifies every missing nightly newer than the current downstream base; +3. creates an initial checkpoint for the current base when bootstrapping; +4. processes missing nightlies oldest-first in a dedicated Git worktree; +5. rebases with Git `rerere` enabled so recurring resolutions can be reused; +6. installs dependencies and runs the checkpoint smoke gate; +7. creates and optionally pushes one annotated checkpoint tag per nightly; and +8. optionally promotes the newest checkpoint to `lastcode/main`. + +The smoke gate checks fork identity invariants, `git diff --check`, focused +LastCode tests, desktop protocol tests, and the scripts workspace typecheck. It +is intentionally smaller than the full build gate. + +### Promotion and open PRs + +`--promote-if-no-open-prs` keeps `lastcode/main` stable while any PR targeting it +is open. Checkpoint tags are still created and pushed, so PR activity cannot +cause a nightly to be missed. A later scheduled run promotes the newest +checkpoint after the PR queue is empty. + +Promotion uses an exact `--force-with-lease` value. It refuses to overwrite a +remote branch that changed after the job fetched it. + +Use `--promote` only when intentionally overriding the open-PR safeguard. + +### Failure recovery + +If rebase or smoke validation fails, the command stops at that nightly and +retains both: + +- `sync/nightly/`; and +- the printed `lastcode-nightly-sync` worktree path. + +It also posts a macOS notification. Resolve the rebase or failure in that +worktree, then decide whether to finish and tag it or abandon the sync attempt. +The next automated run refuses to replace an existing recovery worktree. + +No later nightly is checkpointed after a failure, because each failure should be +understood before the sequence continues. + +## Local Scheduling + +Install the per-user launch agent: + +```bash +pnpm lastcode:checkpoint:service install +``` + +The job runs at login and hourly while the Mac is awake. Missed intervals do not +matter: every run discovers all uncheckpointed tags and catches up oldest-first. +The job executes: + +```bash +pnpm lastcode:checkpoint --push-tags --promote-if-no-open-prs +``` + +Operational commands: + +```bash +pnpm lastcode:checkpoint:service status +pnpm lastcode:checkpoint:service run-now +pnpm lastcode:checkpoint:service uninstall +``` + +Logs are written to `~/.lastcode/automation/`. Uninstalling unloads the job and +moves its plist to a timestamped disabled backup instead of deleting it. +The installer creates a dedicated `lastcode-automation` Git worktree. Before +each run, that worktree fetches and force-checks out `origin/lastcode/main`; it +never uses or modifies a human development worktree. Uninstall leaves the +automation worktree available for inspection. + +The launch agent is opt-in. Repository installation and tests never register it. + +## Selecting a Build + +Check out the desired checkpoint, run full checkpoint CI, then build that same +tag: + +```bash +git switch --detach lastcode/checkpoint/v0.0.34-nightly.20260812.1072 +pnpm lastcode:ci --checkpoint lastcode/checkpoint/v0.0.34-nightly.20260812.1072 +pnpm lastcode:build:mac:arm64 \ + --checkpoint lastcode/checkpoint/v0.0.34-nightly.20260812.1072 +``` + +The build refuses a dirty worktree, a mismatched HEAD, a missing checkpoint CI +stamp, or an existing output directory. Fetching a newer upstream tag cannot +change the selected version. + +Output is grouped by upstream nightly and LastCode commit: + +```text +release-lastcode/ + v0.0.34-nightly.20260812.1072/ + / + LastCode-0.0.34-nightly.20260812.1072-arm64.dmg + LastCode-0.0.34-nightly.20260812.1072-arm64.zip + build-manifest.json + SHA256SUMS +``` + +`build-manifest.json` records the checkpoint tag, upstream tag and commit, +LastCode commit, build tag, build time, platform, architecture, artifact sizes, +and SHA-256 hashes. `SHA256SUMS` provides a conventional verification file. + +The build creates a local annotated `lastcode/build/...` tag. Pass `--push-tag` +only when that build record should be published to the fork. + +## GitHub Rules + +Configure the fork so that: + +- checkpoint and build tags cannot be modified or deleted; +- only the owner or automation identity can force-push `lastcode/main`; +- ordinary LastCode changes arrive through PRs targeting `lastcode/main`; and +- GitHub Actions remain disabled while local CI is authoritative. + +Branch protection must permit the intentional force-with-lease promotion model. +If GitHub cannot express that narrowly enough for a personal repository, rely on +repository ownership plus the checkpoint command's lease check rather than a +rule that makes promotion impossible. diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index 3fd74d5f364a..a4b4e830ba20 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -1,76 +1,101 @@ -# LastCode Private Release Workflow +# LastCode Local Release Workflow -LastCode is the private fork of `pingdotgg/t3code` used from the -`lastcode/main` branch. `main` remains an upstream mirror for clean pull request -work against `pingdotgg/t3code`. +LastCode uses local validation and ad-hoc macOS releases. GitHub Actions are +intentionally disabled, releases have no schedule, and artifacts remain local +unless an explicit publishing operation is performed. -## Nightly Sync +Nightly source tracking is documented separately in +[Nightly Checkpoint Workflow](nightly-workflow.md). -Update `lastcode/main` to the latest upstream nightly tag: +## Pull Request CI + +Every push runs the quick gate through `.vite-hooks/pre-push`: + +```bash +pnpm lastcode:ci:quick +``` + +Before merging a LastCode PR, run the full gate from a clean feature branch: ```bash -pnpm lastcode:sync-nightly +pnpm lastcode:ci ``` -Push the rebased branch when the result is ready to share: +The full PR gate fetches `origin/lastcode/main`, verifies that the tested head +contains that exact base commit, and runs formatting, linting, workspace +typechecks and tests, desktop build assertions, Rust tests, native static +analysis, and release smoke tests. Success writes a local stamp bound to both the +head commit and tested base. + +Merge the current ready PR with: ```bash -pnpm lastcode:sync-nightly --push +pnpm lastcode:merge ``` -The script: +The merge wrapper refuses dirty worktrees, unstamped commits, stale bases, draft +or conflicting PRs, and PRs that do not target `lastcode/main`. It squash-merges +with an exact-head guard. -- fetches tags from `upstream` -- resolves the newest `vX.Y.Z-nightly.YYYYMMDD.N` tag -- switches to `lastcode/main` -- rebases LastCode-only commits on that tag -- optionally pushes with `--force-with-lease` +## Checkpoint CI -Use this before starting new LastCode development and whenever upstream -publishes a nightly that should become the private fork base. +A release build uses a different full-CI context because rebasing intentionally +rewrites ancestry. Check out the immutable checkpoint and run: + +```bash +pnpm lastcode:ci --checkpoint lastcode/checkpoint/ +``` + +The resulting stamp binds the exact LastCode commit, checkpoint tag, upstream +tag, and upstream commit. A PR stamp cannot authorize a checkpoint build, and a +checkpoint stamp cannot authorize a PR merge. ## Apple Silicon Build -Build the local macOS Apple Silicon artifact: +Build the selected checkpoint: ```bash -pnpm lastcode:build:mac:arm64 +pnpm lastcode:build:mac:arm64 \ + --checkpoint lastcode/checkpoint/ ``` -The wrapper resolves the latest upstream nightly tag and runs the desktop -artifact builder with that version. Output goes to `release-lastcode/`. +The wrapper requires: -Packaging identity: +- a clean worktree; +- `HEAD` equal to the annotated checkpoint target; +- a valid full checkpoint-CI stamp; and +- a new, non-overwriting output directory. -- Product name: `†Code` -- ASCII artifact/app identifiers: `LastCode` -- Bundle id: `codes.lastobelus.lastcode` -- URL scheme: `lastcode` +The app bundle is sealed with Electron Builder's ad-hoc identity, so the bundle +and its resources pass macOS code-signature verification without an Apple +Developer certificate. It is not notarized for public distribution. -## Fork Workflow Bootstrap Branch +Local builds omit the hosted update feed. The built-in updater remains disabled +until LastCode intentionally publishes compatible releases. -`topic/fork-workflow-bootstrap` is not merged into `lastcode/main`. +## Runtime Identity -It added a useful idea, resolving the repository root from Git's common dir so -scripts work from linked worktrees, and the LastCode nightly scripts use that -pattern. The branch also encodes an older vendor/product workflow with Aadit -fork tracking. That does not match the current strategy, which intentionally -does not pull from Aadit. +LastCode can run alongside T3 Code and T3 Code Nightly because it owns separate +runtime resources: -## In-App Update Direction +| Resource | LastCode | T3 Code | +| ---------------- | --------------------------- | ----------------------- | +| Product | `LastCode` | `T3 Code` | +| Bundle ID | `codes.lastobelus.lastcode` | `com.t3tools.t3code` | +| Electron profile | `lastcode` / `lastcode-dev` | `t3code` / `t3code-dev` | +| State home | `~/.lastcode` | `~/.t3` | +| URL schemes | `lastcode`, `lastcode-dev` | `t3code`, `t3code-dev` | -The existing update button is backed by `electron-updater`: it checks a release -feed, downloads a published artifact, then restarts into the downloaded update. +The profile split also separates Chromium storage and the Electron +single-instance lock. Provider credentials remain in provider-owned locations, +such as `~/.codex`, so they do not need to be duplicated. -The requested LastCode updater is a different operation. It needs a local -orchestrator that: +Tailscale Serve is machine-global. Do not configure both applications to claim +the same Serve port simultaneously. -1. fetches the latest nightly tag from `pingdotgg/t3code` -2. rebases released LastCode work onto that tag -3. invokes Codex if the rebase or follow-up work needs agent assistance -4. builds a new Apple Silicon artifact locally -5. exposes the same update states the UI already understands: downloading, - downloaded, and install/restart +## In-App Update Direction -That should be implemented as a separate LastCode update backend instead of -overloading `electron-updater` internals. +The hosted `electron-updater` path downloads published releases. A future local +LastCode updater would instead need to invoke the checkpoint, local CI, and build +workflow and then install the resulting artifact. It should be a separate local +orchestrator rather than an overload of the hosted updater. diff --git a/package.json b/package.json index 0978e1d8e9f0..f1104a3f7dd0 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,12 @@ "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "connect:announce-ga": "node scripts/announce-connect-ga.ts", - "lastcode:sync-nightly": "node scripts/lastcode-sync-nightly.ts", - "lastcode:build:mac:arm64": "node scripts/lastcode-build-mac-arm64.ts", + "lastcode:checkpoint": "mise exec node@24.13.1 -- node scripts/lastcode-checkpoint.ts", + "lastcode:checkpoint:service": "mise exec node@24.13.1 -- node scripts/lastcode-nightly-service.ts", + "lastcode:build:mac:arm64": "mise exec node@24.13.1 -- node scripts/lastcode-build-mac-arm64.ts", + "lastcode:ci": "mise exec node@24.13.1 -- node scripts/lastcode-local-ci.ts --full", + "lastcode:ci:quick": "mise exec node@24.13.1 -- node scripts/lastcode-local-ci.ts --quick", + "lastcode:merge": "mise exec node@24.13.1 -- node scripts/lastcode-merge.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", "sync:repos": "node scripts/sync-reference-repos.ts" }, diff --git a/packages/shared/package.json b/packages/shared/package.json index f669bd0a452c..597e4380236f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./desktopDistribution": { + "types": "./src/desktopDistribution.ts", + "import": "./src/desktopDistribution.ts" + }, "./projectFavicon": { "types": "./src/projectFavicon.ts", "import": "./src/projectFavicon.ts" diff --git a/packages/shared/src/desktopDistribution.ts b/packages/shared/src/desktopDistribution.ts new file mode 100644 index 000000000000..347b4a7d9246 --- /dev/null +++ b/packages/shared/src/desktopDistribution.ts @@ -0,0 +1,16 @@ +export const LASTCODE_DESKTOP_DISTRIBUTION = { + productName: "LastCode", + asciiName: "LastCode", + appId: "codes.lastobelus.lastcode", + developmentAppId: "codes.lastobelus.lastcode.dev", + productionScheme: "lastcode", + developmentScheme: "lastcode-dev", + executableName: "lastcode", + developmentExecutableName: "lastcode-dev", + defaultHomeDirName: ".lastcode", + userDataDirName: "lastcode", + developmentUserDataDirName: "lastcode-dev", + linuxDesktopEntryName: "lastcode.desktop", + developmentLinuxDesktopEntryName: "lastcode-dev.desktop", + linuxUrlHandlerDesktopEntryName: "lastcode-url-handler.desktop", +} as const; diff --git a/scripts/apply-web-brand-assets.ts b/scripts/apply-web-brand-assets.ts index 30e6bc325b6c..64970f9a0012 100644 --- a/scripts/apply-web-brand-assets.ts +++ b/scripts/apply-web-brand-assets.ts @@ -9,10 +9,10 @@ import * as Path from "effect/Path"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { resolveWebAssetBrandForChannel, - resolveWebIconOverrides, WEB_ASSET_CHANNELS, type WebAssetBrand, } from "./lib/brand-assets.ts"; +import { resolveLastCodeWebIconOverrides } from "./lib/lastcode-brand-assets.ts"; const WEB_ASSET_BRANDS = [ "development", @@ -29,7 +29,7 @@ export const applyWebBrandAssets = Effect.fn("applyWebBrandAssets")(function* ( const repoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url)); yield* Effect.forEach( - resolveWebIconOverrides(brand, targetDirectory), + resolveLastCodeWebIconOverrides(brand, targetDirectory), (override) => fs.copyFile( path.join(repoRoot, override.sourceRelativePath), diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 4273f2c7da93..df84a816143e 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -44,7 +44,7 @@ import { STAGE_INSTALL_ARGS, WINDOWS_ASAR_UNPACK, } from "./build-desktop-artifact.ts"; -import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { LASTCODE_BRAND_ASSET_PATHS } from "./lib/lastcode-brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; function mockProcess(exitCode: number) { @@ -91,21 +91,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("uses LastCode desktop packaging product names", () => { - assert.equal(resolveDesktopProductName("0.0.17"), "†Code"); - assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "†Code"); + assert.equal(resolveDesktopProductName("0.0.17"), "LastCode"); + assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "LastCode"); }); it("switches desktop packaging icons to the nightly artwork for nightly versions", () => { assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17"), { - macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, - linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, - windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, + macIconPng: LASTCODE_BRAND_ASSET_PATHS.productionMacIconPng, + linuxIconPng: LASTCODE_BRAND_ASSET_PATHS.productionLinuxIconPng, + windowsIconIco: LASTCODE_BRAND_ASSET_PATHS.productionWindowsIconIco, }); assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17-nightly.20260413.42"), { - macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, - linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, - windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, + macIconPng: LASTCODE_BRAND_ASSET_PATHS.nightlyMacIconPng, + linuxIconPng: LASTCODE_BRAND_ASSET_PATHS.nightlyLinuxIconPng, + windowsIconIco: LASTCODE_BRAND_ASSET_PATHS.nightlyWindowsIconIco, }); }); @@ -349,11 +349,15 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.notProperty(mac, "asarUnpack"); assert.notProperty(linux, "asarUnpack"); assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK); + assert.equal(mac.appId, "codes.lastobelus.lastcode"); + assert.equal(mac.productName, "LastCode"); + assert.equal(mac.artifactName, "LastCode-${version}-${arch}.${ext}"); // Linux must register the renderer schemes so the generated .desktop - // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links. + // entry advertises LastCode's OAuth deep links. assert.deepStrictEqual((linux.linux as Record).protocols, [ - { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + { name: "LastCode", schemes: ["lastcode", "lastcode-dev"] }, ]); + assert.equal((linux.linux as Record).executableName, "lastcode"); for (const config of [mac, linux, win]) { assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); assert.deepStrictEqual(config.files, DESKTOP_FILE_EXCLUSIONS); @@ -409,7 +413,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); assert.deepStrictEqual(configuration, { - appId: "com.t3tools.t3code", + appId: "codes.lastobelus.lastcode", teamId: "ABC1234567", rpDomains: ["example.clerk.accounts.dev"], provisioningProfilePath: "/tmp/t3code.provisionprofile", @@ -429,7 +433,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "clerk.example.com", "example.clerk.accounts.dev", ]); - assert.include(entitlements, "ABC1234567.com.t3tools.t3code"); + assert.include(entitlements, "ABC1234567.codes.lastobelus.lastcode"); assert.include(entitlements, "webcredentials:clerk.example.com"); assert.include(entitlements, "webcredentials:example.clerk.accounts.dev"); assert.include(entitlements, "com.apple.security.cs.allow-jit"); @@ -524,15 +528,34 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); const mac = config.mac as Record; - assert.equal(config.appId, "com.t3tools.t3code"); + assert.equal(config.appId, "codes.lastobelus.lastcode"); assert.equal(mac.entitlements, "/tmp/entitlements.mac.plist"); assert.equal(mac.provisioningProfile, "/tmp/t3code.provisionprofile"); assert.deepStrictEqual(mac.protocols, [ - { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + { name: "LastCode", schemes: ["lastcode", "lastcode-dev"] }, ]); }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it.effect("seals local macOS builds with an ad-hoc identity", () => + Effect.gen(function* () { + const config = yield* createBuildConfig( + "mac", + "dmg", + "1.2.3", + false, + false, + undefined, + undefined, + ); + + const mac = config.mac as Record; + assert.equal(mac.identity, "-"); + assert.equal(mac.hardenedRuntime, false); + assert.notProperty(mac, "sign"); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); + it.effect("keeps executable resource editing enabled for unsigned Windows builds", () => Effect.gen(function* () { const config = yield* createBuildConfig( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index b00039d970b8..440b2a8e2177 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2,6 +2,7 @@ import * as NodeModule from "node:module"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; @@ -11,11 +12,8 @@ import desktopPackageJson from "../apps/desktop/package.json" with { type: "json import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import { applyWebBrandAssets } from "./apply-web-brand-assets.ts"; -import { - BRAND_ASSET_PATHS, - resolveWebAssetBrandForChannel, - type WebAssetBrand, -} from "./lib/brand-assets.ts"; +import { resolveWebAssetBrandForChannel, type WebAssetBrand } from "./lib/brand-assets.ts"; +import { LASTCODE_BRAND_ASSET_PATHS } from "./lib/lastcode-brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; @@ -36,12 +34,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; -const LASTCODE_PRODUCT_NAME = "†Code"; -const LASTCODE_ASCII_NAME = "LastCode"; -const LASTCODE_APP_ID = "codes.lastobelus.lastcode"; -const LASTCODE_PROTOCOL_SCHEME = "lastcode"; -const LASTCODE_EXECUTABLE_NAME = "lastcode"; - const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); @@ -822,7 +814,7 @@ export function resolveMacPasskeySigningConfiguration( } return { - appId: DESKTOP_APP_ID, + appId: LASTCODE_DESKTOP_DISTRIBUTION.appId, teamId, rpDomains: uniqueRpDomains, provisioningProfilePath, @@ -1489,16 +1481,16 @@ export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets { if (resolveDesktopUpdateChannel(version) === "nightly") { return { - macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, - linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, - windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, + macIconPng: LASTCODE_BRAND_ASSET_PATHS.nightlyMacIconPng, + linuxIconPng: LASTCODE_BRAND_ASSET_PATHS.nightlyLinuxIconPng, + windowsIconIco: LASTCODE_BRAND_ASSET_PATHS.nightlyWindowsIconIco, }; } return { - macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, - linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, - windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, + macIconPng: LASTCODE_BRAND_ASSET_PATHS.productionMacIconPng, + linuxIconPng: LASTCODE_BRAND_ASSET_PATHS.productionLinuxIconPng, + windowsIconIco: LASTCODE_BRAND_ASSET_PATHS.productionWindowsIconIco, }; } @@ -1520,7 +1512,7 @@ export function resolvePackageManagerUserAgent(packageManager: string): string { } export function resolveDesktopProductName(_version: string): string { - return LASTCODE_PRODUCT_NAME; + return LASTCODE_DESKTOP_DISTRIBUTION.productName; } export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( @@ -1536,11 +1528,11 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( readonly provisioningProfilePath: string; } | undefined, - ) { +) { const buildConfig: Record = { - appId: LASTCODE_APP_ID, + appId: LASTCODE_DESKTOP_DISTRIBUTION.appId, productName: resolveDesktopProductName(version), - artifactName: "LastCode-${version}-${arch}.${ext}", + artifactName: `${LASTCODE_DESKTOP_DISTRIBUTION.asciiName}-\${version}-\${arch}.\${ext}`, electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES], files: [...DESKTOP_FILE_EXCLUSIONS], directories: { @@ -1570,10 +1562,19 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", category: "public.app-category.developer-tools", + ...(!signed + ? { + identity: "-", + hardenedRuntime: false, + } + : {}), protocols: [ { - name: LASTCODE_ASCII_NAME, - schemes: [LASTCODE_PROTOCOL_SCHEME], + name: LASTCODE_DESKTOP_DISTRIBUTION.asciiName, + schemes: [ + LASTCODE_DESKTOP_DISTRIBUTION.productionScheme, + LASTCODE_DESKTOP_DISTRIBUTION.developmentScheme, + ], }, ], ...(macPasskeySigning @@ -1588,21 +1589,24 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "linux") { buildConfig.linux = { target: [target], - executableName: LASTCODE_EXECUTABLE_NAME, + executableName: LASTCODE_DESKTOP_DISTRIBUTION.executableName, icon: "icons", category: "Development", // electron-builder turns these into MimeType=x-scheme-handler/; // in the .desktop entry (Exec already gets %U), so browsers can hand - // t3code:// OAuth callbacks to the app. + // LastCode OAuth callbacks to the app. protocols: [ { - name: LASTCODE_ASCII_NAME, - schemes: [LASTCODE_PROTOCOL_SCHEME], + name: LASTCODE_DESKTOP_DISTRIBUTION.asciiName, + schemes: [ + LASTCODE_DESKTOP_DISTRIBUTION.productionScheme, + LASTCODE_DESKTOP_DISTRIBUTION.developmentScheme, + ], }, ], desktop: { entry: { - StartupWMClass: LASTCODE_EXECUTABLE_NAME, + StartupWMClass: LASTCODE_DESKTOP_DISTRIBUTION.executableName, }, }, }; diff --git a/scripts/lastcode-build-mac-arm64.test.ts b/scripts/lastcode-build-mac-arm64.test.ts new file mode 100644 index 000000000000..0db7a7b5c8f0 --- /dev/null +++ b/scripts/lastcode-build-mac-arm64.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vite-plus/test"; + +import { parseBuildOptions, resolveNextBuildNumber } from "./lastcode-build-mac-arm64.ts"; + +const checkpoint = "lastcode/checkpoint/v1.2.3-nightly.20260811.9"; + +it("requires an explicit immutable checkpoint", () => { + expect(() => parseBuildOptions([])).toThrow("A checkpoint is required"); + expect(parseBuildOptions(["--checkpoint", checkpoint, "--push-tag"])).toEqual({ + checkpointTag: checkpoint, + outputRoot: "release-lastcode", + pushTag: true, + verbose: false, + }); +}); + +it("allocates monotonically increasing build tags per checkpoint", () => { + expect( + resolveNextBuildNumber(checkpoint, [ + "lastcode/build/v1.2.3-nightly.20260811.9.1", + "lastcode/build/v1.2.3-nightly.20260811.9.3", + "lastcode/build/v1.2.3-nightly.20260810.8.99", + ]), + ).toBe(4); +}); diff --git a/scripts/lastcode-build-mac-arm64.ts b/scripts/lastcode-build-mac-arm64.ts index f8bf91366e46..4543d2c176ec 100644 --- a/scripts/lastcode-build-mac-arm64.ts +++ b/scripts/lastcode-build-mac-arm64.ts @@ -1,135 +1,233 @@ #!/usr/bin/env node -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Console from "effect/Console"; -import * as Effect from "effect/Effect"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off -- Local release orchestration intentionally uses host processes. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { assertCheckpointCiStamp, assertCleanWorktree } from "./lastcode-local-ci.ts"; import { - LastCodeNightlyError, - resolveLatestLocalNightlyTag, - resolveRepoRoot, - runGit, + buildTagFromCheckpointTag, + nightlyTagFromCheckpointTag, versionFromNightlyTag, } from "./lastcode-nightly.ts"; interface BuildOptions { - readonly fetch: boolean; - readonly outputDir: string; + readonly checkpointTag: string; + readonly outputRoot: string; + readonly pushTag: boolean; readonly verbose: boolean; } -function parseArgs(argv: ReadonlyArray): BuildOptions { - let fetch = true; - let outputDir = "release-lastcode"; +interface BuildArtifact { + readonly bytes: number; + readonly path: string; + readonly sha256: string; +} + +interface BuildManifest { + readonly schemaVersion: 1; + readonly arch: "arm64"; + readonly artifacts: ReadonlyArray; + readonly buildTag: string; + readonly builtAt: string; + readonly checkpointTag: string; + readonly lastCodeCommit: string; + readonly platform: "mac"; + readonly upstreamCommit: string; + readonly upstreamTag: string; +} + +export function parseBuildOptions(argv: ReadonlyArray): BuildOptions { + let checkpointTag: string | undefined; + let outputRoot = "release-lastcode"; + let pushTag = false; let verbose = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; - if (arg === "--") { - continue; - } else if (arg === "--no-fetch") { - fetch = false; - } else if (arg === "--output-dir") { + if (arg === "--") continue; + if (arg === "--checkpoint" || arg === "--output-root") { const value = argv[index + 1]; - if (!value) throw new Error("Missing value for --output-dir."); - outputDir = value; + if (!value) throw new Error(`Missing value for ${arg}.`); + if (arg === "--checkpoint") checkpointTag = value; + else outputRoot = value; index += 1; - } else if (arg === "--verbose") { - verbose = true; - } else { - throw new Error(`Unknown argument '${arg}'.`); - } + } else if (arg === "--push-tag") pushTag = true; + else if (arg === "--verbose") verbose = true; + else throw new Error(`Unknown argument '${arg}'.`); } - return { fetch, outputDir, verbose }; + if (!checkpointTag) { + throw new Error( + "A checkpoint is required. Pass --checkpoint lastcode/checkpoint/vX.Y.Z-nightly.YYYYMMDD.N.", + ); + } + if (!nightlyTagFromCheckpointTag(checkpointTag)) { + throw new Error(`Invalid LastCode checkpoint tag '${checkpointTag}'.`); + } + return { checkpointTag, outputRoot, pushTag, verbose }; } -const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ); +export function resolveNextBuildNumber( + checkpointTag: string, + existingBuildTags: ReadonlyArray, +): number { + const prefix = checkpointTag.replace("lastcode/checkpoint/", "lastcode/build/") + "."; + const used = existingBuildTags.flatMap((tag) => { + if (!tag.startsWith(prefix)) return []; + const value = Number(tag.slice(prefix.length)); + return Number.isSafeInteger(value) && value > 0 ? [value] : []; + }); + return Math.max(0, ...used) + 1; +} -const runBuildCommand = Effect.fn("lastcode.runBuildCommand")(function* ( - repoRoot: string, +function run( + cwd: string, + command: string, args: ReadonlyArray, -) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn( - ChildProcess.make("node", args, { - cwd: repoRoot, - env: { - ...process.env, - T3CODE_DESKTOP_UPDATE_REPOSITORY: - process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY ?? "lastobelus/lastCode", - }, - }), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - if (stdout.trim().length > 0) { - yield* Console.log(stdout.trim()); - } - if (stderr.trim().length > 0) { - yield* Console.error(stderr.trim()); + options: { readonly capture?: boolean; readonly env?: NodeJS.ProcessEnv } = {}, +): string { + const result = NodeChildProcess.spawnSync(command, args, { + cwd, + encoding: "utf8", + env: options.env ?? process.env, + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + [ + `${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`, + options.capture ? result.stderr.trim() : "", + ] + .filter(Boolean) + .join("\n"), + ); } - if (exitCode !== 0) { - return yield* new LastCodeNightlyError({ - message: `LastCode macOS build failed with exit code ${exitCode}.`, - }); + return options.capture ? result.stdout.trim() : ""; +} + +function git(repoRoot: string, args: ReadonlyArray): string { + return run(repoRoot, "git", args, { capture: true }); +} + +function hashFile(path: string): string { + return NodeCrypto.createHash("sha256").update(NodeFS.readFileSync(path)).digest("hex"); +} + +function collectArtifacts(outputDir: string): ReadonlyArray { + return NodeFS.readdirSync(outputDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name !== "build-manifest.json") + .map((entry) => { + const path = NodePath.join(outputDir, entry.name); + return { + bytes: NodeFS.statSync(path).size, + path: entry.name, + sha256: hashFile(path), + }; + }) + .toSorted((left, right) => left.path.localeCompare(right.path)); +} + +function main(argv: ReadonlyArray): void { + const options = parseBuildOptions(argv); + const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]); + assertCleanWorktree(repoRoot); + + const nightlyTag = nightlyTagFromCheckpointTag(options.checkpointTag)!; + const commit = git(repoRoot, ["rev-parse", "HEAD"]); + const checkpointCommit = git(repoRoot, ["rev-parse", `${options.checkpointTag}^{commit}`]); + if (commit !== checkpointCommit) { + throw new Error( + `HEAD ${commit} does not match requested checkpoint ${options.checkpointTag} at ${checkpointCommit}.`, + ); } -}); - -const parseCliOptions = Effect.try({ - try: () => parseArgs(process.argv.slice(2)), - catch: (cause) => - new LastCodeNightlyError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), -}); - -const main = Effect.gen(function* () { - const options = yield* parseCliOptions; - const repoRoot = yield* resolveRepoRoot(); - - if (options.fetch) { - yield* runGit(repoRoot, ["fetch", "upstream", "--prune", "--tags"]); + const upstreamCommit = git(repoRoot, ["rev-parse", `${nightlyTag}^{commit}`]); + const commonGitDir = git(repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); + assertCheckpointCiStamp(commonGitDir, checkpointCommit, options.checkpointTag, upstreamCommit); + + const shortCommit = git(repoRoot, ["rev-parse", "--short=10", commit]); + const outputDir = NodePath.resolve(repoRoot, options.outputRoot, nightlyTag, shortCommit); + if (NodeFS.existsSync(outputDir)) { + throw new Error( + `Build output already exists at ${outputDir}; artifacts are never overwritten.`, + ); } + NodeFS.mkdirSync(outputDir, { recursive: true }); - const latest = yield* resolveLatestLocalNightlyTag(repoRoot); - const version = versionFromNightlyTag(latest.tag); - yield* Console.log(`[lastcode] Building Apple Silicon LastCode artifact for ${latest.tag}.`); - - yield* runBuildCommand(repoRoot, [ - "scripts/build-desktop-artifact.ts", - "--platform", - "mac", - "--target", - "dmg", - "--arch", - "arm64", - "--build-version", - version, - "--output-dir", - options.outputDir, - ...(options.verbose ? ["--verbose"] : []), + const cargoPath = run(repoRoot, "rustup", ["which", "cargo", "--toolchain", "stable"], { + capture: true, + }); + const env = { + ...process.env, + PATH: `${NodePath.dirname(cargoPath)}${NodePath.delimiter}${process.env.PATH ?? ""}`, + }; + console.log(`[lastcode:build] Building ${options.checkpointTag} at ${commit}.`); + run( + repoRoot, + "node", + [ + "scripts/build-desktop-artifact.ts", + "--platform", + "mac", + "--target", + "dmg", + "--arch", + "arm64", + "--build-version", + versionFromNightlyTag(nightlyTag), + "--output-dir", + outputDir, + ...(options.verbose ? ["--verbose"] : []), + ], + { env }, + ); + + const existingBuildTags = git(repoRoot, ["tag", "--list", "lastcode/build/*"]) + .split(/\r?\n/) + .filter(Boolean); + const buildNumber = resolveNextBuildNumber(options.checkpointTag, existingBuildTags); + const buildTag = buildTagFromCheckpointTag(options.checkpointTag, buildNumber); + const manifest: BuildManifest = { + schemaVersion: 1, + arch: "arm64", + artifacts: collectArtifacts(outputDir), + buildTag, + builtAt: new Date().toISOString(), + checkpointTag: options.checkpointTag, + lastCodeCommit: commit, + platform: "mac", + upstreamCommit, + upstreamTag: nightlyTag, + }; + NodeFS.writeFileSync( + NodePath.join(outputDir, "build-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + NodeFS.writeFileSync( + NodePath.join(outputDir, "SHA256SUMS"), + `${manifest.artifacts.map(({ path, sha256 }) => `${sha256} ${path}`).join("\n")}\n`, + ); + git(repoRoot, [ + "tag", + "--annotate", + buildTag, + commit, + "--message", + `LastCode local build ${buildTag}\n\nCheckpoint: ${options.checkpointTag}\nManifest: ${NodePath.relative(repoRoot, NodePath.join(outputDir, "build-manifest.json"))}`, ]); -}); + if (options.pushTag) run(repoRoot, "git", ["push", "origin", buildTag]); + console.log(`[lastcode:build] Created ${buildTag}.`); + console.log(`[lastcode:build] Artifacts: ${outputDir}`); +} if (import.meta.main) { - main.pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`[lastcode:build] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } } diff --git a/scripts/lastcode-checkpoint.test.ts b/scripts/lastcode-checkpoint.test.ts new file mode 100644 index 000000000000..53d2b018cf3e --- /dev/null +++ b/scripts/lastcode-checkpoint.test.ts @@ -0,0 +1,81 @@ +import { assert, it } from "@effect/vitest"; + +import { resolveCheckpointPlan } from "./lastcode-checkpoint.ts"; +import { parseNightlyTag } from "./lastcode-nightly.ts"; + +function nightly(tag: string) { + const value = parseNightlyTag(tag); + assert.ok(value); + return value; +} + +it("bootstraps at the source nightly and checkpoints every later nightly", () => { + const plan = resolveCheckpointPlan({ + checkpointRefs: [], + nightlyTags: [ + "v0.0.2-nightly.20260103.3", + "v0.0.1-nightly.20260101.1", + "v0.0.1-nightly.20260102.2", + ], + sourceCommit: "source", + sourceNightlyTags: ["v0.0.1-nightly.20260101.1"], + sourceRef: "origin/lastcode/main", + }); + + assert.equal(plan.bootstrapCheckpoint, true); + assert.equal(plan.baseNightly.tag, "v0.0.1-nightly.20260101.1"); + assert.deepStrictEqual( + plan.missingNightlies.map(({ tag }) => tag), + ["v0.0.1-nightly.20260102.2", "v0.0.2-nightly.20260103.3"], + ); +}); + +it("continues from a newer unpromoted checkpoint when main has not changed", () => { + const old = nightly("v0.0.1-nightly.20260101.1"); + const newer = nightly("v0.0.1-nightly.20260102.2"); + const plan = resolveCheckpointPlan({ + checkpointRefs: [ + { checkpointTag: `lastcode/checkpoint/${old.tag}`, commit: "main", nightly: old }, + { checkpointTag: `lastcode/checkpoint/${newer.tag}`, commit: "checkpoint", nightly: newer }, + ], + nightlyTags: [old.tag, newer.tag, "v0.0.2-nightly.20260103.3"], + sourceCommit: "main", + sourceCheckpointTag: `lastcode/checkpoint/${old.tag}`, + sourceNightlyTags: [old.tag], + sourceRef: "origin/lastcode/main", + }); + + assert.equal(plan.candidateRef, `lastcode/checkpoint/${newer.tag}`); + assert.equal(plan.baseNightly.tag, newer.tag); + assert.deepStrictEqual( + plan.missingNightlies.map(({ tag }) => tag), + ["v0.0.2-nightly.20260103.3"], + ); +}); + +it("carries new main commits directly to the next missing nightly", () => { + const old = nightly("v0.0.1-nightly.20260101.1"); + const checkpointed = nightly("v0.0.1-nightly.20260102.2"); + const plan = resolveCheckpointPlan({ + checkpointRefs: [ + { checkpointTag: `lastcode/checkpoint/${old.tag}`, commit: "old-main", nightly: old }, + { + checkpointTag: `lastcode/checkpoint/${checkpointed.tag}`, + commit: "checkpoint", + nightly: checkpointed, + }, + ], + nightlyTags: [old.tag, checkpointed.tag, "v0.0.2-nightly.20260103.3"], + sourceCommit: "main-with-new-feature", + sourceCheckpointTag: `lastcode/checkpoint/${old.tag}`, + sourceNightlyTags: [old.tag], + sourceRef: "origin/lastcode/main", + }); + + assert.equal(plan.candidateRef, "origin/lastcode/main"); + assert.equal(plan.baseNightly.tag, old.tag); + assert.deepStrictEqual( + plan.missingNightlies.map(({ tag }) => tag), + ["v0.0.2-nightly.20260103.3"], + ); +}); diff --git a/scripts/lastcode-checkpoint.ts b/scripts/lastcode-checkpoint.ts new file mode 100644 index 000000000000..caea1ad5db75 --- /dev/null +++ b/scripts/lastcode-checkpoint.ts @@ -0,0 +1,501 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off -- Local Git orchestration intentionally uses host processes. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; + +import { + checkpointTagFromNightlyTag, + compareNightlyTags, + type NightlyTag, + nightlyTagFromCheckpointTag, + parseNightlyTag, + resolveLatestNightlyTag, + resolveUncheckpointedNightlies, +} from "./lastcode-nightly.ts"; + +const DEFAULT_SOURCE_REF = "refs/remotes/origin/lastcode/main"; +const DEFAULT_UPSTREAM_REMOTE = "upstream"; +const DEFAULT_PUSH_REMOTE = "origin"; +const CHECKPOINT_TAG_GLOB = "lastcode/checkpoint/v*-nightly.*"; + +export type PromotionMode = "never" | "always" | "if-no-open-prs"; + +interface CheckpointOptions { + readonly dryRun: boolean; + readonly fetch: boolean; + readonly promotion: PromotionMode; + readonly pushTags: boolean; + readonly smoke: boolean; + readonly sourceRef: string; + readonly upstreamRemote: string; + readonly pushRemote: string; +} + +interface CheckpointRef { + readonly checkpointTag: string; + readonly commit: string; + readonly nightly: NightlyTag; +} + +export interface CheckpointPlan { + readonly baseNightly: NightlyTag; + readonly bootstrapCheckpoint: boolean; + readonly candidateRef: string; + readonly missingNightlies: ReadonlyArray; +} + +function run( + cwd: string, + command: string, + args: ReadonlyArray, + options: { readonly capture?: boolean; readonly allowFailure?: boolean } = {}, +): string { + const result = NodeChildProcess.spawnSync(command, args, { + cwd, + encoding: "utf8", + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + if (options.allowFailure) return ""; + const details = options.capture ? result.stderr.trim() : ""; + throw new Error( + [`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`, details] + .filter(Boolean) + .join("\n"), + ); + } + return options.capture ? result.stdout.trim() : ""; +} + +function git( + repoRoot: string, + args: ReadonlyArray, + options: { readonly allowFailure?: boolean; readonly cwd?: string } = {}, +): string { + return run(options.cwd ?? repoRoot, "git", args, { + capture: true, + ...(options.allowFailure ? { allowFailure: true } : {}), + }); +} + +function splitLines(value: string): ReadonlyArray { + return value + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function isAncestor(repoRoot: string, ancestor: string, descendant: string): boolean { + const result = NodeChildProcess.spawnSync( + "git", + ["merge-base", "--is-ancestor", ancestor, descendant], + { cwd: repoRoot, stdio: "ignore" }, + ); + if (result.error) throw result.error; + return result.status === 0; +} + +function listCheckpointRefs(repoRoot: string): ReadonlyArray { + return splitLines(git(repoRoot, ["tag", "--list", CHECKPOINT_TAG_GLOB])) + .flatMap((checkpointTag) => { + const nightlyTag = nightlyTagFromCheckpointTag(checkpointTag); + const nightly = nightlyTag ? parseNightlyTag(nightlyTag) : undefined; + return nightly + ? [ + { + checkpointTag, + commit: git(repoRoot, ["rev-list", "-n", "1", checkpointTag]), + nightly, + }, + ] + : []; + }) + .toSorted((left, right) => compareNightlyTags(left.nightly, right.nightly)); +} + +function latestCheckpointAncestor( + repoRoot: string, + checkpoints: ReadonlyArray, + sourceRef: string, +): CheckpointRef | undefined { + return checkpoints.findLast((checkpoint) => isAncestor(repoRoot, checkpoint.commit, sourceRef)); +} + +export function resolveCheckpointPlan(input: { + readonly checkpointRefs: ReadonlyArray; + readonly nightlyTags: ReadonlyArray; + readonly sourceCommit: string; + readonly sourceCheckpointTag?: string; + readonly sourceNightlyTags: ReadonlyArray; + readonly sourceRef: string; +}): CheckpointPlan { + const latestCheckpoint = input.checkpointRefs.at(-1); + const sourceCheckpoint = input.checkpointRefs.find( + (checkpoint) => checkpoint.checkpointTag === input.sourceCheckpointTag, + ); + const sourceBase = sourceCheckpoint?.nightly ?? resolveLatestNightlyTag(input.sourceNightlyTags); + if (!sourceBase) { + throw new Error(`${input.sourceRef} is not based on a recognizable upstream nightly tag.`); + } + + const candidateRef = + sourceCheckpoint && + sourceCheckpoint.commit === input.sourceCommit && + latestCheckpoint && + sourceCheckpoint.nightly.tag !== latestCheckpoint.nightly.tag + ? latestCheckpoint.checkpointTag + : input.sourceRef; + const candidateBase = candidateRef === input.sourceRef ? sourceBase : latestCheckpoint?.nightly; + if (!candidateBase) throw new Error("Could not resolve the LastCode checkpoint base."); + + const checkpointTags = input.checkpointRefs.map(({ checkpointTag }) => checkpointTag); + const missingNightlies = resolveUncheckpointedNightlies(input.nightlyTags, checkpointTags).filter( + (nightly) => compareNightlyTags(nightly, candidateBase) > 0, + ); + + return { + baseNightly: candidateBase, + bootstrapCheckpoint: !checkpointTags.includes(checkpointTagFromNightlyTag(candidateBase.tag)), + candidateRef, + missingNightlies, + }; +} + +function parseArgs(argv: ReadonlyArray): CheckpointOptions { + let dryRun = false; + let fetch = true; + let promotion: PromotionMode = "never"; + let pushTags = false; + let smoke = true; + let sourceRef = DEFAULT_SOURCE_REF; + let upstreamRemote = DEFAULT_UPSTREAM_REMOTE; + let pushRemote = DEFAULT_PUSH_REMOTE; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") continue; + if (arg === "--dry-run") dryRun = true; + else if (arg === "--no-fetch") fetch = false; + else if (arg === "--no-smoke") smoke = false; + else if (arg === "--push-tags") pushTags = true; + else if (arg === "--promote") promotion = "always"; + else if (arg === "--promote-if-no-open-prs") promotion = "if-no-open-prs"; + else if (arg === "--source-ref" || arg === "--upstream-remote" || arg === "--push-remote") { + const value = argv[index + 1]; + if (!value) throw new Error(`Missing value for ${arg}.`); + if (arg === "--source-ref") sourceRef = value; + else if (arg === "--upstream-remote") upstreamRemote = value; + else pushRemote = value; + index += 1; + } else { + throw new Error(`Unknown argument '${arg}'.`); + } + } + + return { dryRun, fetch, promotion, pushTags, smoke, sourceRef, upstreamRemote, pushRemote }; +} + +function checkpointMessage( + repoRoot: string, + nightly: NightlyTag, + commit: string, + sourceRef: string, +): string { + return [ + `LastCode checkpoint for ${nightly.tag}`, + "", + `Upstream-Tag: ${nightly.tag}`, + `Upstream-Commit: ${git(repoRoot, ["rev-parse", `${nightly.tag}^{commit}`])}`, + `LastCode-Commit: ${commit}`, + `Source-Ref: ${sourceRef}`, + `Created-At: ${new Date().toISOString()}`, + ].join("\n"); +} + +function createCheckpointTag( + repoRoot: string, + nightly: NightlyTag, + commit: string, + sourceRef: string, +): string { + const checkpointTag = checkpointTagFromNightlyTag(nightly.tag); + git(repoRoot, [ + "tag", + "--annotate", + checkpointTag, + commit, + "--message", + checkpointMessage(repoRoot, nightly, commit, sourceRef), + ]); + return checkpointTag; +} + +function assertForkInvariants(worktree: string): void { + const requiredText = new Map([ + ["packages/shared/src/desktopDistribution.ts", "codes.lastobelus.lastcode"], + ["apps/web/src/components/branding/LastCodeWordmark.tsx", "LastCode"], + ["scripts/lastcode-build-mac-arm64.ts", "lastcode/checkpoint/"], + ]); + for (const [relativePath, expected] of requiredText) { + const path = NodePath.join(worktree, relativePath); + if (!NodeFS.existsSync(path) || !NodeFS.readFileSync(path, "utf8").includes(expected)) { + throw new Error( + `Checkpoint smoke invariant failed: ${relativePath} must contain '${expected}'.`, + ); + } + } + run(worktree, "git", ["diff", "--check"]); +} + +function runSmokeGate(worktree: string): void { + console.log("[lastcode:checkpoint] Installing checkpoint worktree dependencies..."); + run(worktree, "vp", ["install", "--frozen-lockfile"]); + assertForkInvariants(worktree); + run(worktree, "vp", [ + "test", + "run", + "scripts/lastcode-nightly.test.ts", + "scripts/lastcode-checkpoint.test.ts", + "scripts/lastcode-local-ci.test.ts", + "scripts/build-desktop-artifact.test.ts", + "apps/desktop/src/electron/ElectronProtocol.test.ts", + ]); + run(worktree, "vp", ["run", "--filter", "@t3tools/scripts", "typecheck"]); +} + +function notify(platform: NodeJS.Platform, title: string, message: string): void { + if (platform !== "darwin") return; + run( + process.cwd(), + "osascript", + [ + "-e", + "on run argv", + "-e", + "display notification (item 2 of argv) with title (item 1 of argv)", + "-e", + "end run", + title, + message, + ], + { allowFailure: true }, + ); +} + +function resolveAutomationWorktree(repoRoot: string): string { + const primaryWorktree = splitLines(git(repoRoot, ["worktree", "list", "--porcelain"])) + .find((line) => line.startsWith("worktree ")) + ?.slice("worktree ".length); + if (!primaryWorktree) throw new Error("Could not resolve the repository's primary worktree."); + return NodePath.join( + NodePath.dirname(primaryWorktree), + `${NodePath.basename(primaryWorktree)}-worktrees`, + "lastcode-nightly-sync", + ); +} + +function openPullRequestCount(repoRoot: string): number { + const value = run( + repoRoot, + "gh", + [ + "pr", + "list", + "--base", + "lastcode/main", + "--state", + "open", + "--json", + "number", + "--jq", + "length", + ], + { capture: true }, + ); + const count = Number(value); + if (!Number.isSafeInteger(count) || count < 0) throw new Error(`Invalid gh PR count '${value}'.`); + return count; +} + +function promoteCheckpoint( + repoRoot: string, + commit: string, + options: CheckpointOptions, + platform: NodeJS.Platform, +): void { + if (options.promotion === "never") return; + if (options.promotion === "if-no-open-prs") { + const count = openPullRequestCount(repoRoot); + if (count > 0) { + console.log( + `[lastcode:checkpoint] Kept lastcode/main stable because ${count} PR(s) are open.`, + ); + notify( + platform, + "LastCode checkpoint ready", + `${count} open PR(s) prevented lastcode/main promotion.`, + ); + return; + } + } + + git(repoRoot, ["fetch", options.pushRemote, "lastcode/main"]); + const expected = git(repoRoot, ["rev-parse", `refs/remotes/${options.pushRemote}/lastcode/main`]); + run(repoRoot, "git", [ + "push", + `--force-with-lease=refs/heads/lastcode/main:${expected}`, + options.pushRemote, + `${commit}:refs/heads/lastcode/main`, + ]); + console.log(`[lastcode:checkpoint] Promoted ${commit} to ${options.pushRemote}/lastcode/main.`); +} + +function main(argv: ReadonlyArray): void { + const options = parseArgs(argv); + const hostPlatform = Effect.runSync(HostProcessPlatform); + const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]); + git(repoRoot, ["config", "rerere.enabled", "true"]); + git(repoRoot, ["config", "rerere.autoupdate", "true"]); + + if (options.fetch) { + run(repoRoot, "git", ["fetch", options.upstreamRemote, "--prune", "--tags"]); + run( + repoRoot, + "git", + [ + "fetch", + options.pushRemote, + "+refs/tags/lastcode/checkpoint/*:refs/tags/lastcode/checkpoint/*", + ], + { allowFailure: true }, + ); + run(repoRoot, "git", [ + "fetch", + options.pushRemote, + `+refs/heads/lastcode/main:refs/remotes/${options.pushRemote}/lastcode/main`, + ]); + } + + const sourceCommit = git(repoRoot, ["rev-parse", `${options.sourceRef}^{commit}`]); + const checkpoints = listCheckpointRefs(repoRoot); + const sourceAncestor = latestCheckpointAncestor(repoRoot, checkpoints, options.sourceRef); + const sourceNightlyTags = splitLines( + git(repoRoot, ["tag", "--merged", options.sourceRef, "--list", "v*-nightly.*"]), + ); + const nightlyTags = splitLines(git(repoRoot, ["tag", "--list", "v*-nightly.*"])); + const plan = resolveCheckpointPlan({ + checkpointRefs: checkpoints, + nightlyTags, + sourceCommit, + ...(sourceAncestor ? { sourceCheckpointTag: sourceAncestor.checkpointTag } : {}), + sourceNightlyTags, + sourceRef: options.sourceRef, + }); + + console.log(`[lastcode:checkpoint] Source: ${plan.candidateRef}`); + console.log(`[lastcode:checkpoint] Upstream base: ${plan.baseNightly.tag}`); + if (plan.bootstrapCheckpoint) { + console.log( + `[lastcode:checkpoint] ${options.dryRun ? "Would create" : "Creating"} bootstrap checkpoint ${checkpointTagFromNightlyTag(plan.baseNightly.tag)}.`, + ); + } + for (const nightly of plan.missingNightlies) { + console.log( + `[lastcode:checkpoint] ${options.dryRun ? "Would checkpoint" : "Checkpointing"} ${nightly.tag}.`, + ); + } + if (options.dryRun) return; + + let candidateRef = plan.candidateRef; + let candidateCommit = git(repoRoot, ["rev-parse", `${candidateRef}^{commit}`]); + if (plan.bootstrapCheckpoint) { + const checkpointTag = createCheckpointTag( + repoRoot, + plan.baseNightly, + candidateCommit, + options.sourceRef, + ); + if (options.pushTags) run(repoRoot, "git", ["push", options.pushRemote, checkpointTag]); + } + + if (plan.missingNightlies.length === 0) { + promoteCheckpoint(repoRoot, candidateCommit, options, hostPlatform); + console.log("[lastcode:checkpoint] No uncheckpointed upstream nightlies remain."); + return; + } + + const worktree = resolveAutomationWorktree(repoRoot); + if (NodeFS.existsSync(worktree)) { + throw new Error( + `Nightly sync worktree already exists at ${worktree}. Resolve or remove it first.`, + ); + } + NodeFS.mkdirSync(NodePath.dirname(worktree), { recursive: true }); + const firstNightly = plan.missingNightlies[0]; + if (!firstNightly) throw new Error("Missing first nightly checkpoint."); + let branch = `sync/nightly/${firstNightly.tag}`; + if (git(repoRoot, ["show-ref", "--verify", `refs/heads/${branch}`], { allowFailure: true })) { + throw new Error(`Recovery branch ${branch} already exists.`); + } + + run(repoRoot, "git", ["worktree", "add", "--branch", branch, worktree, candidateRef]); + let completed = false; + try { + let baseTag = plan.baseNightly.tag; + for (const nightly of plan.missingNightlies) { + const recoveryBranch = `sync/nightly/${nightly.tag}`; + if (branch !== recoveryBranch) { + run(worktree, "git", ["branch", "--move", recoveryBranch]); + branch = recoveryBranch; + } + console.log(`[lastcode:checkpoint] Rebasing LastCode from ${baseTag} onto ${nightly.tag}...`); + run(worktree, "git", ["rebase", "--onto", nightly.tag, baseTag]); + candidateCommit = git(repoRoot, ["rev-parse", "HEAD"], { cwd: worktree }); + if (options.smoke) runSmokeGate(worktree); + const checkpointTag = createCheckpointTag( + repoRoot, + nightly, + candidateCommit, + options.sourceRef, + ); + if (options.pushTags) run(repoRoot, "git", ["push", options.pushRemote, checkpointTag]); + baseTag = nightly.tag; + candidateRef = checkpointTag; + console.log(`[lastcode:checkpoint] Created ${checkpointTag} at ${candidateCommit}.`); + } + completed = true; + } catch (error) { + notify( + hostPlatform, + "LastCode nightly sync needs attention", + `${branch} is retained at ${worktree}.`, + ); + console.error(`[lastcode:checkpoint] Recovery branch ${branch} is retained at ${worktree}.`); + throw error; + } finally { + if (completed) { + run(repoRoot, "git", ["worktree", "remove", worktree]); + git(repoRoot, ["update-ref", "-d", `refs/heads/${branch}`]); + } + } + + promoteCheckpoint(repoRoot, candidateCommit, options, hostPlatform); + notify(hostPlatform, "LastCode nightly checkpoint complete", `${candidateRef} is ready.`); +} + +if (import.meta.main) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error( + `[lastcode:checkpoint] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/lastcode-local-ci.test.ts b/scripts/lastcode-local-ci.test.ts new file mode 100644 index 000000000000..d068a164d522 --- /dev/null +++ b/scripts/lastcode-local-ci.test.ts @@ -0,0 +1,125 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { + assertCheckpointCiStamp, + assertFullCiStamp, + assertSupportedNodeVersion, + parseLocalCiOptions, + readFullCiStamp, + resolveLocalCiSteps, + verifyPreloadBundle, + writeFullCiStamp, +} from "./lastcode-local-ci.ts"; + +describe("lastcode-local-ci", () => { + it("requires the repository's supported Node release line", () => { + expect(() => assertSupportedNodeVersion("24.13.1")).not.toThrow(); + expect(() => assertSupportedNodeVersion("24.99.0")).not.toThrow(); + expect(() => assertSupportedNodeVersion("24.13.0")).toThrow("Node ^24.13.1"); + expect(() => assertSupportedNodeVersion("26.7.0")).toThrow("Node ^24.13.1"); + }); + + it("defaults to the full gate and supports the quick pre-push gate", () => { + expect(parseLocalCiOptions([])).toEqual({ mode: "full", dryRun: false }); + expect(parseLocalCiOptions(["--quick", "--", "--dry-run"])).toEqual({ + mode: "quick", + dryRun: true, + }); + expect( + parseLocalCiOptions(["--checkpoint", "lastcode/checkpoint/v1.2.3-nightly.20260811.1"]), + ).toEqual({ + mode: "full", + dryRun: false, + checkpointTag: "lastcode/checkpoint/v1.2.3-nightly.20260811.1", + }); + }); + + it("keeps release, native, Rust, and preload checks in the full gate", () => { + const quickLabels = resolveLocalCiSteps("quick").map(({ label }) => label); + const fullLabels = resolveLocalCiSteps("full").map(({ label }) => label); + + expect(quickLabels).toEqual([ + "Ensure Electron runtime", + "Format and lint", + "Workspace typecheck", + "Workspace tests", + ]); + expect(fullLabels).toEqual( + expect.arrayContaining([ + "Resource monitor formatting", + "Desktop build", + "Desktop preload bundle assertions", + "Resource monitor tests", + "Mobile native static analysis", + "Release smoke", + ]), + ); + }); + + it("checks the built preload bridge contract", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-preload-test-")); + const preloadPath = NodePath.join(root, "apps/desktop/dist-electron/preload.cjs"); + NodeFS.mkdirSync(NodePath.dirname(preloadPath), { recursive: true }); + NodeFS.writeFileSync( + preloadPath, + "desktopBridge getLocalEnvironmentBootstraps PICK_FOLDER_CHANNEL __clerk_internal_electron_passkeys", + ); + + expect(() => verifyPreloadBundle(root)).not.toThrow(); + NodeFS.writeFileSync(preloadPath, "desktopBridge"); + expect(() => verifyPreloadBundle(root)).toThrow("getLocalEnvironmentBootstraps"); + NodeFS.rmSync(root, { recursive: true, force: true }); + }); + + it("binds a PR full-CI stamp to both the head and tested base commits", () => { + const commonGitDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-stamp-test-")); + const stamp = { + commit: "head-sha", + completedAt: "2026-08-11T00:00:00.000Z", + context: { + kind: "pull-request" as const, + baseCommit: "base-sha", + baseRef: "lastcode/main" as const, + }, + } as const; + + writeFullCiStamp(commonGitDir, stamp); + expect(readFullCiStamp(commonGitDir, stamp.commit)).toEqual({ schemaVersion: 2, ...stamp }); + expect(assertFullCiStamp(commonGitDir, stamp.commit, stamp.context.baseCommit)).toEqual({ + schemaVersion: 2, + ...stamp, + }); + expect(() => assertFullCiStamp(commonGitDir, stamp.commit, "new-base-sha")).toThrow( + "Rebase and rerun", + ); + NodeFS.rmSync(commonGitDir, { recursive: true, force: true }); + }); + + it("binds a checkpoint full-CI stamp to its immutable tag and upstream commit", () => { + const commonGitDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-stamp-test-")); + const checkpointTag = "lastcode/checkpoint/v1.2.3-nightly.20260811.1"; + const stamp = { + commit: "checkpoint-sha", + completedAt: "2026-08-11T00:00:00.000Z", + context: { + kind: "checkpoint" as const, + checkpointTag, + upstreamCommit: "upstream-sha", + upstreamTag: "v1.2.3-nightly.20260811.1", + }, + }; + + writeFullCiStamp(commonGitDir, stamp); + expect( + assertCheckpointCiStamp(commonGitDir, stamp.commit, checkpointTag, "upstream-sha"), + ).toEqual({ schemaVersion: 2, ...stamp }); + expect(() => + assertCheckpointCiStamp(commonGitDir, stamp.commit, checkpointTag, "new-upstream-sha"), + ).toThrow("does not match checkpoint"); + NodeFS.rmSync(commonGitDir, { recursive: true, force: true }); + }); +}); diff --git a/scripts/lastcode-local-ci.ts b/scripts/lastcode-local-ci.ts new file mode 100644 index 000000000000..6c3e9e4cb6fa --- /dev/null +++ b/scripts/lastcode-local-ci.ts @@ -0,0 +1,471 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off -- Host-side CI orchestration runs subprocesses directly. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { cleanGitEnvironment, nightlyTagFromCheckpointTag } from "./lastcode-nightly.ts"; + +export const LASTCODE_BASE_BRANCH = "lastcode/main"; +export const LASTCODE_ORIGIN_REMOTE = "origin"; + +export type LocalCiMode = "quick" | "full"; + +interface CommandStep { + readonly kind: "command"; + readonly label: string; + readonly command: string; + readonly args: ReadonlyArray; + readonly failureHelp?: string; + readonly isolatedGitConfig?: boolean; + readonly rustToolchainPath?: boolean; + readonly transferBudgetOutput?: boolean; +} + +interface VerifyPreloadStep { + readonly kind: "verify-preload"; + readonly label: string; +} + +export type LocalCiStep = CommandStep | VerifyPreloadStep; + +export interface FullCiStamp { + readonly schemaVersion: 2; + readonly commit: string; + readonly completedAt: string; + readonly context: + | { + readonly kind: "pull-request"; + readonly baseCommit: string; + readonly baseRef: typeof LASTCODE_BASE_BRANCH; + } + | { + readonly kind: "checkpoint"; + readonly checkpointTag: string; + readonly upstreamCommit: string; + readonly upstreamTag: string; + }; +} + +export interface LocalCiOptions { + readonly mode: LocalCiMode; + readonly dryRun: boolean; + readonly checkpointTag?: string; +} + +export function assertSupportedNodeVersion(version = process.versions.node): void { + const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number); + const supported = major === 24 && (minor > 13 || (minor === 13 && patch >= 1)); + if (!supported) { + throw new Error( + `LastCode local CI requires Node ^24.13.1, received ${version}. Run it through the package script so mise selects the project runtime.`, + ); + } +} + +const QUICK_STEPS: ReadonlyArray = [ + { + kind: "command", + label: "Ensure Electron runtime", + command: "vp", + args: ["run", "--filter", "@t3tools/desktop", "ensure:electron"], + }, + { kind: "command", label: "Format and lint", command: "vp", args: ["check"] }, + { kind: "command", label: "Workspace typecheck", command: "vpr", args: ["typecheck"] }, + { + kind: "command", + label: "Workspace tests", + command: "vp", + args: ["run", "test"], + isolatedGitConfig: true, + transferBudgetOutput: true, + }, +]; + +const FULL_ONLY_STEPS: ReadonlyArray = [ + { + kind: "command", + label: "Resource monitor formatting", + command: "cargo", + args: ["fmt", "--manifest-path", "native/resource-monitor/Cargo.toml", "--", "--check"], + rustToolchainPath: true, + }, + { + kind: "command", + label: "Desktop build", + command: "vp", + args: ["run", "build:desktop"], + }, + { kind: "verify-preload", label: "Desktop preload bundle assertions" }, + { + kind: "command", + label: "Resource monitor tests", + command: "cargo", + args: ["test", "--locked", "--manifest-path", "native/resource-monitor/Cargo.toml"], + rustToolchainPath: true, + }, + { + kind: "command", + label: "Mobile native tool prerequisites", + command: "brew", + args: ["bundle", "check", "--file", "apps/mobile/Brewfile"], + failureHelp: "Install missing tools with: brew bundle install --file apps/mobile/Brewfile", + }, + { + kind: "command", + label: "Mobile native static analysis", + command: "vp", + args: ["run", "lint:mobile"], + }, + { + kind: "command", + label: "Release smoke", + command: "node", + args: ["scripts/release-smoke.ts"], + }, +]; + +const PRELOAD_PATH = "apps/desktop/dist-electron/preload.cjs"; +const PRELOAD_EXPECTED_EXPORTS = [ + "desktopBridge", + "getLocalEnvironmentBootstraps", + "PICK_FOLDER_CHANNEL", + "__clerk_internal_electron_passkeys", +] as const; + +export function parseLocalCiOptions(argv: ReadonlyArray): LocalCiOptions { + let mode: LocalCiMode = "full"; + let dryRun = false; + let checkpointTag: string | undefined; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } else if (arg === "--full") { + mode = "full"; + } else if (arg === "--quick") { + mode = "quick"; + } else if (arg === "--dry-run") { + dryRun = true; + } else if (arg === "--checkpoint") { + checkpointTag = argv[index + 1]; + if (!checkpointTag) throw new Error("Missing value for --checkpoint."); + index += 1; + } else { + throw new Error(`Unknown argument '${arg}'.`); + } + } + + return { mode, dryRun, ...(checkpointTag ? { checkpointTag } : {}) }; +} + +export function resolveLocalCiSteps(mode: LocalCiMode): ReadonlyArray { + return mode === "quick" ? QUICK_STEPS : [...QUICK_STEPS, ...FULL_ONLY_STEPS]; +} + +export function verifyPreloadBundle(repoRoot: string): void { + const preloadPath = NodePath.resolve(repoRoot, PRELOAD_PATH); + if (!NodeFS.existsSync(preloadPath)) { + throw new Error(`Expected desktop preload bundle at ${PRELOAD_PATH}.`); + } + + const contents = NodeFS.readFileSync(preloadPath, "utf8"); + for (const expectedExport of PRELOAD_EXPECTED_EXPORTS) { + if (!contents.includes(expectedExport)) { + throw new Error(`Desktop preload bundle is missing '${expectedExport}'.`); + } + } +} + +export function resolveFullCiStampPath(commonGitDir: string, commit: string): string { + return NodePath.resolve(commonGitDir, "lastcode-ci", `${commit}.json`); +} + +export function writeFullCiStamp( + commonGitDir: string, + stamp: Omit, +): string { + const stampPath = resolveFullCiStampPath(commonGitDir, stamp.commit); + NodeFS.mkdirSync(NodePath.dirname(stampPath), { recursive: true }); + NodeFS.writeFileSync( + stampPath, + `${JSON.stringify({ schemaVersion: 2, ...stamp } satisfies FullCiStamp, null, 2)}\n`, + ); + return stampPath; +} + +export function readFullCiStamp(commonGitDir: string, commit: string): FullCiStamp | undefined { + const stampPath = resolveFullCiStampPath(commonGitDir, commit); + if (!NodeFS.existsSync(stampPath)) return undefined; + + const value = JSON.parse(NodeFS.readFileSync(stampPath, "utf8")) as Partial; + if ( + value.schemaVersion !== 2 || + value.commit !== commit || + typeof value.context !== "object" || + value.context === null || + typeof value.completedAt !== "string" + ) { + throw new Error(`Invalid LastCode CI stamp at ${stampPath}.`); + } + return value as FullCiStamp; +} + +export function assertFullCiStamp( + commonGitDir: string, + commit: string, + baseCommit: string, +): FullCiStamp { + const stamp = readFullCiStamp(commonGitDir, commit); + if (!stamp) { + throw new Error(`Commit ${commit} has not passed full local CI. Run: pnpm lastcode:ci`); + } + if (stamp.context.kind !== "pull-request" || stamp.context.baseCommit !== baseCommit) { + throw new Error( + `Full local CI was not run against the current ${LASTCODE_BASE_BRANCH} commit ${baseCommit}. Rebase and rerun: pnpm lastcode:ci`, + ); + } + return stamp; +} + +export function assertCheckpointCiStamp( + commonGitDir: string, + commit: string, + checkpointTag: string, + upstreamCommit: string, +): FullCiStamp { + const stamp = readFullCiStamp(commonGitDir, commit); + if (!stamp) { + throw new Error( + `Checkpoint ${checkpointTag} at ${commit} has not passed full local CI. Run: pnpm lastcode:ci --checkpoint ${checkpointTag}`, + ); + } + if ( + stamp.context.kind !== "checkpoint" || + stamp.context.checkpointTag !== checkpointTag || + stamp.context.upstreamCommit !== upstreamCommit + ) { + throw new Error( + `Full local CI stamp for ${commit} does not match checkpoint ${checkpointTag}. Rerun: pnpm lastcode:ci --checkpoint ${checkpointTag}`, + ); + } + return stamp; +} + +function runProcess( + repoRoot: string, + command: string, + args: ReadonlyArray, + options: { + readonly capture?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly failureHelp?: string; + } = {}, +): string { + const inheritedEnv = cleanGitEnvironment(options.env ?? process.env); + const result = NodeChildProcess.spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + env: { + ...inheritedEnv, + PATH: `${NodePath.resolve(repoRoot, "node_modules/.bin")}${NodePath.delimiter}${inheritedEnv.PATH ?? ""}`, + }, + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + + if (result.error) throw result.error; + if (result.status !== 0) { + const stderr = options.capture ? result.stderr.trim() : ""; + const details = [ + `${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`, + stderr, + options.failureHelp ?? "", + ].filter(Boolean); + throw new Error(details.join("\n")); + } + + return options.capture ? result.stdout.trim() : ""; +} + +export function runGit(repoRoot: string, args: ReadonlyArray): string { + return runProcess(repoRoot, "git", args, { capture: true }); +} + +export function resolveRepoRoot(cwd = process.cwd()): string { + return runGit(cwd, ["rev-parse", "--show-toplevel"]); +} + +export function resolveCommonGitDir(repoRoot: string): string { + return runGit(repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); +} + +export function assertCleanWorktree(repoRoot: string): void { + const status = runGit(repoRoot, ["status", "--porcelain", "--untracked-files=all"]); + if (status) { + throw new Error(`Working tree must be clean for full local CI.\n${status}`); + } +} + +export function assertBaseIsAncestor(repoRoot: string, baseCommit: string, commit: string): void { + const result = NodeChildProcess.spawnSync( + "git", + ["merge-base", "--is-ancestor", baseCommit, commit], + { cwd: repoRoot, env: cleanGitEnvironment(process.env), stdio: "ignore" }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `Current branch is not based on the latest ${LASTCODE_BASE_BRANCH}. Rebase it before running full local CI.`, + ); + } +} + +function printPlan(mode: LocalCiMode): void { + console.log(`[lastcode:ci] ${mode} local CI plan:`); + for (const step of resolveLocalCiSteps(mode)) { + const command = step.kind === "command" ? `: ${step.command} ${step.args.join(" ")}` : ""; + console.log(`- ${step.label}${command}`); + } +} + +function runLocalCi(options: LocalCiOptions): void { + assertSupportedNodeVersion(); + const repoRoot = resolveRepoRoot(); + const steps = resolveLocalCiSteps(options.mode); + + if (options.dryRun) { + printPlan(options.mode); + return; + } + + let commitBefore: string | undefined; + let baseCommit: string | undefined; + let checkpointContext: Extract | undefined; + if (options.mode === "full") { + assertCleanWorktree(repoRoot); + commitBefore = runGit(repoRoot, ["rev-parse", "HEAD"]); + if (options.checkpointTag) { + const upstreamTag = nightlyTagFromCheckpointTag(options.checkpointTag); + if (!upstreamTag) + throw new Error(`Invalid LastCode checkpoint tag '${options.checkpointTag}'.`); + const checkpointCommit = runGit(repoRoot, ["rev-parse", `${options.checkpointTag}^{commit}`]); + if (checkpointCommit !== commitBefore) { + throw new Error( + `HEAD ${commitBefore} does not match checkpoint ${options.checkpointTag} at ${checkpointCommit}.`, + ); + } + const upstreamCommit = runGit(repoRoot, ["rev-parse", `${upstreamTag}^{commit}`]); + assertBaseIsAncestor(repoRoot, upstreamCommit, commitBefore); + checkpointContext = { + kind: "checkpoint", + checkpointTag: options.checkpointTag, + upstreamCommit, + upstreamTag, + }; + } else { + runProcess(repoRoot, "git", ["fetch", LASTCODE_ORIGIN_REMOTE, LASTCODE_BASE_BRANCH]); + baseCommit = runGit(repoRoot, [ + "rev-parse", + `refs/remotes/${LASTCODE_ORIGIN_REMOTE}/${LASTCODE_BASE_BRANCH}`, + ]); + assertBaseIsAncestor(repoRoot, baseCommit, commitBefore); + } + } + + const transferOutputDirectory = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "lastcode-local-ci-"), + ); + const isolatedGitConfigPath = NodePath.join(transferOutputDirectory, "gitconfig"); + const rustToolchainBin = + options.mode === "full" + ? NodePath.dirname( + runProcess(repoRoot, "rustup", ["which", "cargo", "--toolchain", "stable"], { + capture: true, + }), + ) + : undefined; + NodeFS.writeFileSync( + isolatedGitConfigPath, + [ + "[user]", + "\tname = LastCode Local CI", + "\temail = local-ci@lastcode.invalid", + "[init]", + "\tdefaultBranch = main", + "", + ].join("\n"), + ); + try { + for (const [index, step] of steps.entries()) { + console.log(`\n[lastcode:ci] ${index + 1}/${steps.length} ${step.label}`); + if (step.kind === "verify-preload") { + verifyPreloadBundle(repoRoot); + continue; + } + + const env = { + ...process.env, + ...(step.isolatedGitConfig + ? { + GIT_CONFIG_GLOBAL: isolatedGitConfigPath, + GIT_CONFIG_NOSYSTEM: "1", + } + : {}), + ...(step.rustToolchainPath && rustToolchainBin + ? { PATH: `${rustToolchainBin}${NodePath.delimiter}${process.env.PATH ?? ""}` } + : {}), + ...(step.transferBudgetOutput + ? { + T3CODE_TRANSFER_BUDGET_REPORT_PATH: NodePath.join( + transferOutputDirectory, + "t3code-transfer-budget.md", + ), + T3CODE_TRANSFER_BUDGET_RESULT_PATH: NodePath.join( + transferOutputDirectory, + "thread-transfer-result.json", + ), + } + : {}), + }; + runProcess(repoRoot, step.command, step.args, { + env, + ...(step.failureHelp ? { failureHelp: step.failureHelp } : {}), + }); + } + } finally { + NodeFS.rmSync(transferOutputDirectory, { recursive: true, force: true }); + } + + if (options.mode === "full" && commitBefore && (baseCommit || checkpointContext)) { + const commitAfter = runGit(repoRoot, ["rev-parse", "HEAD"]); + if (commitAfter !== commitBefore) { + throw new Error(`HEAD changed during local CI (${commitBefore} -> ${commitAfter}).`); + } + assertCleanWorktree(repoRoot); + const stampPath = writeFullCiStamp(resolveCommonGitDir(repoRoot), { + commit: commitBefore, + completedAt: new Date().toISOString(), + context: checkpointContext ?? { + kind: "pull-request", + baseCommit: baseCommit!, + baseRef: LASTCODE_BASE_BRANCH, + }, + }); + console.log(`\n[lastcode:ci] Full local CI passed for ${commitBefore}.`); + console.log(`[lastcode:ci] Stamp: ${stampPath}`); + } else { + console.log("\n[lastcode:ci] Quick local CI passed."); + } +} + +if (import.meta.main) { + try { + runLocalCi(parseLocalCiOptions(process.argv.slice(2))); + } catch (error) { + console.error(`[lastcode:ci] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/lastcode-merge.test.ts b/scripts/lastcode-merge.test.ts new file mode 100644 index 000000000000..4afbe984e56d --- /dev/null +++ b/scripts/lastcode-merge.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { validatePullRequestForMerge } from "./lastcode-merge.ts"; + +const mergeablePullRequest = { + number: 12, + url: "https://github.com/lastobelus/lastCode/pull/12", + state: "OPEN", + isDraft: false, + headRefOid: "head-sha", + baseRefName: "lastcode/main", + baseRefOid: "base-sha", + mergeable: "MERGEABLE", +} as const; + +describe("lastcode-merge", () => { + it("accepts an open LastCode PR at the stamped head", () => { + expect(() => + validatePullRequestForMerge(mergeablePullRequest, "head-sha", "base-sha"), + ).not.toThrow(); + }); + + it("rejects drafts, stale heads, conflicts, and other base branches", () => { + expect(() => + validatePullRequestForMerge( + { ...mergeablePullRequest, isDraft: true }, + "head-sha", + "base-sha", + ), + ).toThrow("still a draft"); + expect(() => + validatePullRequestForMerge(mergeablePullRequest, "other-sha", "base-sha"), + ).toThrow("not local HEAD"); + expect(() => + validatePullRequestForMerge(mergeablePullRequest, "head-sha", "other-base-sha"), + ).toThrow("not the locally tested base"); + expect(() => + validatePullRequestForMerge( + { ...mergeablePullRequest, mergeable: "CONFLICTING" }, + "head-sha", + "base-sha", + ), + ).toThrow("merge conflicts"); + expect(() => + validatePullRequestForMerge( + { ...mergeablePullRequest, baseRefName: "main" }, + "head-sha", + "base-sha", + ), + ).toThrow("not 'lastcode/main'"); + }); +}); diff --git a/scripts/lastcode-merge.ts b/scripts/lastcode-merge.ts new file mode 100644 index 000000000000..1ccacb4604c5 --- /dev/null +++ b/scripts/lastcode-merge.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Host-side merge orchestration runs subprocesses directly. +import * as NodeChildProcess from "node:child_process"; + +import { + assertBaseIsAncestor, + assertCleanWorktree, + assertFullCiStamp, + assertSupportedNodeVersion, + LASTCODE_BASE_BRANCH, + LASTCODE_ORIGIN_REMOTE, + resolveCommonGitDir, + resolveRepoRoot, + runGit, +} from "./lastcode-local-ci.ts"; + +const LASTCODE_GITHUB_REPOSITORY = process.env.LASTCODE_GITHUB_REPOSITORY ?? "lastobelus/lastCode"; + +export interface PullRequestForMerge { + readonly number: number; + readonly url: string; + readonly state: string; + readonly isDraft: boolean; + readonly headRefOid: string; + readonly baseRefName: string; + readonly baseRefOid: string; + readonly mergeable: string; +} + +export function validatePullRequestForMerge( + pullRequest: PullRequestForMerge, + expectedHead: string, + expectedBase: string, +): void { + if (pullRequest.state !== "OPEN") { + throw new Error(`Pull request #${pullRequest.number} is not open.`); + } + if (pullRequest.isDraft) { + throw new Error(`Pull request #${pullRequest.number} is still a draft.`); + } + if (pullRequest.baseRefName !== LASTCODE_BASE_BRANCH) { + throw new Error( + `Pull request #${pullRequest.number} targets '${pullRequest.baseRefName}', not '${LASTCODE_BASE_BRANCH}'.`, + ); + } + if (pullRequest.headRefOid !== expectedHead) { + throw new Error( + `Pull request #${pullRequest.number} points to ${pullRequest.headRefOid}, not local HEAD ${expectedHead}.`, + ); + } + if (pullRequest.baseRefOid !== expectedBase) { + throw new Error( + `Pull request #${pullRequest.number} is based on ${pullRequest.baseRefOid}, not the locally tested base ${expectedBase}.`, + ); + } + if (pullRequest.mergeable === "CONFLICTING") { + throw new Error(`Pull request #${pullRequest.number} has merge conflicts.`); + } +} + +function runCommand( + repoRoot: string, + command: string, + args: ReadonlyArray, + capture = false, +): string { + const result = NodeChildProcess.spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const stderr = capture ? result.stderr.trim() : ""; + throw new Error( + [`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`, stderr] + .filter(Boolean) + .join("\n"), + ); + } + return capture ? result.stdout.trim() : ""; +} + +function main(argv: ReadonlyArray): void { + assertSupportedNodeVersion(); + const dryRun = argv.length === 1 && argv[0] === "--dry-run"; + if (argv.length > (dryRun ? 1 : 0)) { + throw new Error("Usage: pnpm lastcode:merge [--dry-run]"); + } + + const repoRoot = resolveRepoRoot(); + assertCleanWorktree(repoRoot); + const branch = runGit(repoRoot, ["branch", "--show-current"]); + if (!branch || branch === LASTCODE_BASE_BRANCH) { + throw new Error( + `Run lastcode:merge from a feature branch, not '${branch || "detached HEAD"}'.`, + ); + } + + runCommand(repoRoot, "git", ["fetch", LASTCODE_ORIGIN_REMOTE, LASTCODE_BASE_BRANCH]); + const commit = runGit(repoRoot, ["rev-parse", "HEAD"]); + const baseCommit = runGit(repoRoot, [ + "rev-parse", + `refs/remotes/${LASTCODE_ORIGIN_REMOTE}/${LASTCODE_BASE_BRANCH}`, + ]); + assertBaseIsAncestor(repoRoot, baseCommit, commit); + assertFullCiStamp(resolveCommonGitDir(repoRoot), commit, baseCommit); + + const pullRequest = JSON.parse( + runCommand( + repoRoot, + "gh", + [ + "pr", + "view", + branch, + "--repo", + LASTCODE_GITHUB_REPOSITORY, + "--json", + "number,url,state,isDraft,headRefOid,baseRefName,baseRefOid,mergeable", + ], + true, + ), + ) as PullRequestForMerge; + validatePullRequestForMerge(pullRequest, commit, baseCommit); + + if (dryRun) { + console.log( + `[lastcode:merge] Would squash ${pullRequest.url} at ${commit} into ${LASTCODE_BASE_BRANCH}.`, + ); + return; + } + + runCommand(repoRoot, "gh", [ + "pr", + "merge", + String(pullRequest.number), + "--repo", + LASTCODE_GITHUB_REPOSITORY, + "--squash", + "--delete-branch", + "--match-head-commit", + commit, + ]); +} + +if (import.meta.main) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`[lastcode:merge] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/lastcode-nightly-service.test.ts b/scripts/lastcode-nightly-service.test.ts new file mode 100644 index 000000000000..da5967bda361 --- /dev/null +++ b/scripts/lastcode-nightly-service.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "vite-plus/test"; + +import { renderLaunchAgentPlist } from "./lastcode-nightly-service.ts"; + +it("renders an hourly checkpoint-only launch agent with escaped durable paths", () => { + const plist = renderLaunchAgentPlist({ + repoRoot: "/Users/example/LastCode & experiments", + logDirectory: "/Users/example/.lastcode/automation", + }); + + expect(plist).toContain("3600"); + expect(plist).toContain("--push-tags --promote-if-no-open-prs"); + expect(plist).toContain("git checkout --detach --force refs/remotes/origin/lastcode/main"); + expect(plist).not.toContain("lastcode-build"); + expect(plist).toContain("LastCode & experiments"); + expect(plist).toContain("nightly-checkpoint.stderr.log"); +}); diff --git a/scripts/lastcode-nightly-service.ts b/scripts/lastcode-nightly-service.ts new file mode 100644 index 000000000000..208f003095fd --- /dev/null +++ b/scripts/lastcode-nightly-service.ts @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off -- Host launchd setup uses the platform filesystem and process APIs. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; + +const LABEL = "codes.lastobelus.lastcode-nightly-checkpoint"; +const INTERVAL_SECONDS = 60 * 60; + +function xml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderLaunchAgentPlist(input: { + readonly logDirectory: string; + readonly repoRoot: string; +}): string { + const command = [ + "git fetch origin +refs/heads/lastcode/main:refs/remotes/origin/lastcode/main", + "&& git checkout --detach --force refs/remotes/origin/lastcode/main", + "&&", + "mise exec node@24.13.1 -- node scripts/lastcode-checkpoint.ts", + "--push-tags", + "--promote-if-no-open-prs", + ].join(" "); + return ` + + + + Label + ${LABEL} + ProgramArguments + + /bin/zsh + -lc + ${command} + + WorkingDirectory + ${xml(input.repoRoot)} + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + RunAtLoad + + StartInterval + ${INTERVAL_SECONDS} + ProcessType + Background + StandardOutPath + ${xml(NodePath.join(input.logDirectory, "nightly-checkpoint.stdout.log"))} + StandardErrorPath + ${xml(NodePath.join(input.logDirectory, "nightly-checkpoint.stderr.log"))} + + +`; +} + +function run( + command: string, + args: ReadonlyArray, + options: { readonly allowFailure?: boolean } = {}, +): void { + const result = NodeChildProcess.spawnSync(command, args, { stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`${command} ${args.join(" ")} failed with ${result.status ?? "unknown"}.`); + } +} + +function main(argv: ReadonlyArray): void { + if (Effect.runSync(HostProcessPlatform) !== "darwin") + throw new Error("LastCode nightly scheduling requires macOS launchd."); + const command = argv[0]; + if ( + !command || + argv.length !== 1 || + !["install", "run-now", "status", "uninstall"].includes(command) + ) { + throw new Error("Usage: pnpm lastcode:checkpoint:service "); + } + + const repoRoot = NodeChildProcess.execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: process.cwd(), + encoding: "utf8", + }).trim(); + const worktreeList = NodeChildProcess.execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoRoot, + encoding: "utf8", + }); + const primaryWorktree = worktreeList + .split(/\r?\n/) + .find((line) => line.startsWith("worktree ")) + ?.slice("worktree ".length); + if (!primaryWorktree) throw new Error("Could not resolve the repository's primary worktree."); + const automationWorktree = NodePath.join( + NodePath.dirname(primaryWorktree), + `${NodePath.basename(primaryWorktree)}-worktrees`, + "lastcode-automation", + ); + const home = NodeOS.homedir(); + const plistPath = NodePath.join(home, "Library", "LaunchAgents", `${LABEL}.plist`); + const logDirectory = NodePath.join(home, ".lastcode", "automation"); + const getuid = process.getuid; + if (!getuid) throw new Error("Could not resolve the current macOS user ID."); + const domain = `gui/${getuid()}`; + const service = `${domain}/${LABEL}`; + + if (command === "status") { + run("launchctl", ["print", service]); + return; + } + if (command === "run-now") { + run("launchctl", ["kickstart", "-k", service]); + return; + } + if (command === "uninstall") { + run("launchctl", ["bootout", service], { allowFailure: true }); + if (NodeFS.existsSync(plistPath)) { + const backupPath = `${plistPath}.disabled-${new Date().toISOString().replaceAll(":", "-")}`; + NodeFS.renameSync(plistPath, backupPath); + console.log(`[lastcode:service] Disabled plist retained at ${backupPath}.`); + } + return; + } + + run("git", [ + "-C", + repoRoot, + "fetch", + "origin", + "+refs/heads/lastcode/main:refs/remotes/origin/lastcode/main", + ]); + if (!NodeFS.existsSync(automationWorktree)) { + NodeFS.mkdirSync(NodePath.dirname(automationWorktree), { recursive: true }); + run("git", [ + "-C", + repoRoot, + "worktree", + "add", + "--detach", + automationWorktree, + "refs/remotes/origin/lastcode/main", + ]); + } + NodeFS.mkdirSync(NodePath.dirname(plistPath), { recursive: true }); + NodeFS.mkdirSync(logDirectory, { recursive: true }); + NodeFS.writeFileSync( + plistPath, + renderLaunchAgentPlist({ logDirectory, repoRoot: automationWorktree }), + ); + run("plutil", ["-lint", plistPath]); + run("launchctl", ["bootout", service], { allowFailure: true }); + run("launchctl", ["bootstrap", domain, plistPath]); + run("launchctl", ["kickstart", service]); + console.log(`[lastcode:service] Installed ${LABEL}; it runs at login and hourly.`); +} + +if (import.meta.main) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`[lastcode:service] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/lastcode-nightly.test.ts b/scripts/lastcode-nightly.test.ts index 7d2cab52b076..ffc03404412d 100644 --- a/scripts/lastcode-nightly.test.ts +++ b/scripts/lastcode-nightly.test.ts @@ -1,12 +1,38 @@ import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { + buildTagFromCheckpointTag, + checkpointTagFromNightlyTag, + cleanGitEnvironment, compareNightlyTags, + nightlyTagFromCheckpointTag, parseNightlyTag, resolveLatestNightlyTag, + resolveRepoRoot, + resolveUncheckpointedNightlies, versionFromNightlyTag, } from "./lastcode-nightly.ts"; +it("removes repository-local Git variables without removing authentication", () => { + assert.deepStrictEqual( + cleanGitEnvironment({ + GIT_DIR: "/tmp/repo/.git", + GIT_WORK_TREE: "/tmp/repo", + GIT_INDEX_FILE: "/tmp/repo/.git/index", + GIT_SSH_COMMAND: "ssh -i key", + PATH: "/usr/bin", + UNDEFINED_VALUE: undefined, + }), + { + GIT_SSH_COMMAND: "ssh -i key", + PATH: "/usr/bin", + }, + ); +}); + it("parses upstream nightly tags", () => { assert.deepStrictEqual(parseNightlyTag("v0.0.25-nightly.20260606.480"), { tag: "v0.0.25-nightly.20260606.480", @@ -44,3 +70,40 @@ it("derives package versions from nightly tags", () => { "0.0.25-nightly.20260606.480", ); }); + +it("maps immutable LastCode checkpoint and build tags", () => { + const nightly = "v0.0.25-nightly.20260606.480"; + const checkpoint = `lastcode/checkpoint/${nightly}`; + + assert.equal(checkpointTagFromNightlyTag(nightly), checkpoint); + assert.equal(nightlyTagFromCheckpointTag(checkpoint), nightly); + assert.equal( + buildTagFromCheckpointTag(checkpoint, 2), + "lastcode/build/v0.0.25-nightly.20260606.480.2", + ); + assert.equal(nightlyTagFromCheckpointTag("v0.0.25-nightly.20260606.480"), undefined); +}); + +it("lists every uncheckpointed nightly oldest first", () => { + assert.deepStrictEqual( + resolveUncheckpointedNightlies( + [ + "v0.0.26-nightly.20260607.482", + "not-a-nightly", + "v0.0.25-nightly.20260606.480", + "v0.0.26-nightly.20260607.481", + ], + ["lastcode/checkpoint/v0.0.26-nightly.20260607.481", "unrelated/tag"], + ).map(({ tag }) => tag), + ["v0.0.25-nightly.20260606.480", "v0.0.26-nightly.20260607.482"], + ); +}); + +it.effect("resolves the current linked worktree as the repository root", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoRoot = yield* resolveRepoRoot(); + const expectedRepoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url)); + assert.equal(repoRoot, path.resolve(expectedRepoRoot)); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/scripts/lastcode-nightly.ts b/scripts/lastcode-nightly.ts index 35de08c81c45..10baa089bc47 100644 --- a/scripts/lastcode-nightly.ts +++ b/scripts/lastcode-nightly.ts @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; import * as Data from "effect/Data"; -import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -15,11 +14,43 @@ export interface NightlyTag { readonly runNumber: number; } +export const LASTCODE_CHECKPOINT_TAG_PREFIX = "lastcode/checkpoint/"; +export const LASTCODE_BUILD_TAG_PREFIX = "lastcode/build/"; + export class LastCodeNightlyError extends Data.TaggedError("LastCodeNightlyError")<{ readonly message: string; readonly cause?: unknown; }> {} +const GIT_LOCAL_ENVIRONMENT_VARIABLES = new Set([ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", +]); + +export function cleanGitEnvironment( + environment: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(environment).filter( + (entry): entry is [string, string] => + entry[1] !== undefined && !GIT_LOCAL_ENVIRONMENT_VARIABLES.has(entry[0]), + ), + ); +} + export function parseNightlyTag(tag: string): NightlyTag | undefined { const match = /^v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag); if (!match) return undefined; @@ -56,6 +87,46 @@ export function versionFromNightlyTag(tag: string): string { return tag.startsWith("v") ? tag.slice(1) : tag; } +export function checkpointTagFromNightlyTag(tag: string): string { + if (!parseNightlyTag(tag)) { + throw new Error(`Invalid upstream nightly tag '${tag}'.`); + } + return `${LASTCODE_CHECKPOINT_TAG_PREFIX}${tag}`; +} + +export function nightlyTagFromCheckpointTag(tag: string): string | undefined { + if (!tag.startsWith(LASTCODE_CHECKPOINT_TAG_PREFIX)) return undefined; + const nightlyTag = tag.slice(LASTCODE_CHECKPOINT_TAG_PREFIX.length); + return parseNightlyTag(nightlyTag) ? nightlyTag : undefined; +} + +export function buildTagFromCheckpointTag(checkpointTag: string, buildNumber: number): string { + const nightlyTag = nightlyTagFromCheckpointTag(checkpointTag); + if (!nightlyTag) { + throw new Error(`Invalid LastCode checkpoint tag '${checkpointTag}'.`); + } + if (!Number.isSafeInteger(buildNumber) || buildNumber < 1) { + throw new Error(`Invalid LastCode build number '${buildNumber}'.`); + } + return `${LASTCODE_BUILD_TAG_PREFIX}${nightlyTag}.${buildNumber}`; +} + +export function resolveUncheckpointedNightlies( + nightlyTags: ReadonlyArray, + checkpointTags: ReadonlyArray, +): ReadonlyArray { + const checkpointed = new Set( + checkpointTags + .map((tag) => nightlyTagFromCheckpointTag(tag)) + .filter((tag): tag is string => tag !== undefined), + ); + + return nightlyTags + .map((tag) => parseNightlyTag(tag)) + .filter((tag): tag is NightlyTag => tag !== undefined && !checkpointed.has(tag.tag)) + .toSorted(compareNightlyTags); +} + const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), @@ -71,7 +142,12 @@ export const runGit = Effect.fn("lastcode.runGit")(function* ( options: { readonly allowFailure?: boolean } = {}, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd: repoRoot })); + const child = yield* spawner.spawn( + ChildProcess.make("git", args, { + cwd: repoRoot, + env: cleanGitEnvironment(process.env), + }), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStreamAsString(child.stdout), @@ -99,14 +175,7 @@ export const runGit = Effect.fn("lastcode.runGit")(function* ( export const resolveRepoRoot = Effect.fn("lastcode.resolveRepoRoot")(function* ( cwd = process.cwd(), ) { - const path = yield* Path.Path; - const topLevel = yield* runGit(cwd, ["rev-parse", "--show-toplevel"]); - const commonGitDir = yield* runGit(cwd, [ - "rev-parse", - "--path-format=absolute", - "--git-common-dir", - ]); - return path.dirname(path.resolve(topLevel, commonGitDir)); + return yield* runGit(cwd, ["rev-parse", "--show-toplevel"]); }); export const listLocalTags = Effect.fn("lastcode.listLocalTags")(function* (repoRoot: string) { diff --git a/scripts/lastcode-sync-nightly.ts b/scripts/lastcode-sync-nightly.ts deleted file mode 100644 index 92b933ceae2d..000000000000 --- a/scripts/lastcode-sync-nightly.ts +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env node - -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Console from "effect/Console"; -import * as Effect from "effect/Effect"; - -import { - LastCodeNightlyError, - resolveLatestLocalNightlyTag, - resolveRepoRoot, - runGit, -} from "./lastcode-nightly.ts"; - -interface SyncOptions { - readonly branch: string; - readonly remote: string; - readonly pushRemote: string; - readonly fetch: boolean; - readonly push: boolean; - readonly dryRun: boolean; -} - -function parseArgs(argv: ReadonlyArray): SyncOptions { - let branch = "lastcode/main"; - let remote = "upstream"; - let pushRemote = "origin"; - let fetch = true; - let push = false; - let dryRun = false; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--") { - continue; - } else if (arg === "--branch") { - const value = argv[index + 1]; - if (!value) throw new Error("Missing value for --branch."); - branch = value; - index += 1; - } else if (arg === "--remote") { - const value = argv[index + 1]; - if (!value) throw new Error("Missing value for --remote."); - remote = value; - index += 1; - } else if (arg === "--push-remote") { - const value = argv[index + 1]; - if (!value) throw new Error("Missing value for --push-remote."); - pushRemote = value; - index += 1; - } else if (arg === "--no-fetch") { - fetch = false; - } else if (arg === "--push") { - push = true; - } else if (arg === "--dry-run") { - dryRun = true; - } else { - throw new Error(`Unknown argument '${arg}'.`); - } - } - - return { branch, remote, pushRemote, fetch, push, dryRun }; -} - -const assertCleanWorktree = Effect.fn("lastcode.assertCleanWorktree")(function* (repoRoot: string) { - const status = yield* runGit(repoRoot, ["status", "--porcelain"]); - if (status.length > 0) { - return yield* new LastCodeNightlyError({ - message: `Working tree must be clean before syncing.\n${status}`, - }); - } -}); - -const parseCliOptions = Effect.try({ - try: () => parseArgs(process.argv.slice(2)), - catch: (cause) => - new LastCodeNightlyError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), -}); - -const main = Effect.gen(function* () { - const options = yield* parseCliOptions; - const repoRoot = yield* resolveRepoRoot(); - - if (!options.dryRun) { - yield* assertCleanWorktree(repoRoot); - } - - if (options.fetch) { - yield* Console.log(`[lastcode] Fetching ${options.remote} tags...`); - if (!options.dryRun) { - yield* runGit(repoRoot, ["fetch", options.remote, "--prune", "--tags"]); - } - } - - const latest = yield* resolveLatestLocalNightlyTag(repoRoot); - const currentBranch = yield* runGit(repoRoot, ["branch", "--show-current"]); - - yield* Console.log(`[lastcode] Latest upstream nightly: ${latest.tag}`); - yield* Console.log(`[lastcode] Sync branch: ${options.branch}`); - - if (options.dryRun) { - yield* Console.log( - `[lastcode] Would switch from ${currentBranch || "detached HEAD"} to ${options.branch}.`, - ); - yield* Console.log(`[lastcode] Would rebase ${options.branch} onto ${latest.tag}.`); - if (options.push) { - yield* Console.log(`[lastcode] Would push ${options.branch} to ${options.pushRemote}.`); - } - return; - } - - if (currentBranch !== options.branch) { - yield* runGit(repoRoot, ["switch", options.branch]); - } - - yield* runGit(repoRoot, ["rebase", latest.tag]); - - if (options.push) { - yield* runGit(repoRoot, ["push", "--force-with-lease", options.pushRemote, options.branch]); - } - - yield* Console.log(`[lastcode] ${options.branch} is based on ${latest.tag}.`); -}); - -if (import.meta.main) { - main.pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); -} diff --git a/scripts/lib/lastcode-brand-assets.test.ts b/scripts/lib/lastcode-brand-assets.test.ts new file mode 100644 index 000000000000..977aba09df78 --- /dev/null +++ b/scripts/lib/lastcode-brand-assets.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + LASTCODE_BRAND_ASSET_PATHS, + LASTCODE_DEVELOPMENT_ICON_OVERRIDES, + resolveLastCodeWebIconOverrides, +} from "./lastcode-brand-assets.ts"; + +describe("lastcode-brand-assets", () => { + it.each(["development", "nightly", "production"] as const)( + "keeps %s exports under the fork-owned asset tree", + (brand) => { + const overrides = resolveLastCodeWebIconOverrides(brand, "dist/client"); + + expect(overrides).toHaveLength(4); + expect( + overrides.every(({ sourceRelativePath }) => + sourceRelativePath.startsWith("assets/lastcode/"), + ), + ).toBe(true); + expect(overrides.map(({ targetRelativePath }) => targetRelativePath)).toEqual([ + "dist/client/favicon.ico", + "dist/client/favicon-16x16.png", + "dist/client/favicon-32x32.png", + "dist/client/apple-touch-icon.png", + ]); + }, + ); + + it("keeps native composer projects upstream until LastCode replacements exist", () => { + expect(LASTCODE_BRAND_ASSET_PATHS.developmentIconComposerProject).toBe( + "assets/dev/app-icon.icon", + ); + expect(LASTCODE_BRAND_ASSET_PATHS.nightlyIconComposerProject).toBe( + "assets/nightly/app-icon.icon", + ); + expect(LASTCODE_BRAND_ASSET_PATHS.productionIconComposerProject).toBe( + "assets/prod/app-icon.icon", + ); + }); + + it("uses LastCode development icons for local server packages", () => { + expect(LASTCODE_DEVELOPMENT_ICON_OVERRIDES[0]).toEqual({ + sourceRelativePath: LASTCODE_BRAND_ASSET_PATHS.developmentWebFaviconIco, + targetRelativePath: "dist/client/favicon.ico", + }); + }); +}); diff --git a/scripts/lib/lastcode-brand-assets.ts b/scripts/lib/lastcode-brand-assets.ts new file mode 100644 index 000000000000..ad7d091ff62b --- /dev/null +++ b/scripts/lib/lastcode-brand-assets.ts @@ -0,0 +1,89 @@ +import { BRAND_ASSET_PATHS, type IconOverride, type WebAssetBrand } from "./brand-assets.ts"; + +export const LASTCODE_BRAND_ASSET_PATHS = { + ...BRAND_ASSET_PATHS, + developmentIosIconPng: "assets/lastcode/dev/app-icon-ios-1024.png", + developmentUniversalIconPng: "assets/lastcode/dev/app-icon-universal-1024.png", + developmentDesktopIconPng: "assets/lastcode/dev/app-icon-macos-1024.png", + developmentWindowsIconIco: "assets/lastcode/dev/app-icon-windows.ico", + developmentWebFaviconIco: "assets/lastcode/dev/favicon.ico", + developmentWebFavicon16Png: "assets/lastcode/dev/favicon-16x16.png", + developmentWebFavicon32Png: "assets/lastcode/dev/favicon-32x32.png", + developmentWebAppleTouchIconPng: "assets/lastcode/dev/apple-touch-icon-180.png", + + nightlyIosIconPng: "assets/lastcode/nightly/app-icon-ios-1024.png", + nightlyMacIconPng: "assets/lastcode/nightly/app-icon-macos-1024.png", + nightlyLinuxIconPng: "assets/lastcode/nightly/app-icon-universal-1024.png", + nightlyWindowsIconIco: "assets/lastcode/nightly/app-icon-windows.ico", + nightlyWebFaviconIco: "assets/lastcode/nightly/favicon.ico", + nightlyWebFavicon16Png: "assets/lastcode/nightly/favicon-16x16.png", + nightlyWebFavicon32Png: "assets/lastcode/nightly/favicon-32x32.png", + nightlyWebAppleTouchIconPng: "assets/lastcode/nightly/apple-touch-icon-180.png", + + productionIosIconPng: "assets/lastcode/prod/app-icon-ios-1024.png", + productionMacIconPng: "assets/lastcode/prod/app-icon-macos-1024.png", + productionLinuxIconPng: "assets/lastcode/prod/app-icon-universal-1024.png", + productionWindowsIconIco: "assets/lastcode/prod/app-icon-windows.ico", + productionWebFaviconIco: "assets/lastcode/prod/favicon.ico", + productionWebFavicon16Png: "assets/lastcode/prod/favicon-16x16.png", + productionWebFavicon32Png: "assets/lastcode/prod/favicon-32x32.png", + productionWebAppleTouchIconPng: "assets/lastcode/prod/apple-touch-icon-180.png", +} as const; + +const WEB_ICON_TARGET_FILENAMES = { + faviconIco: "favicon.ico", + favicon16Png: "favicon-16x16.png", + favicon32Png: "favicon-32x32.png", + appleTouchIconPng: "apple-touch-icon.png", +} as const; + +const LASTCODE_WEB_ICON_SOURCE_PATHS_BY_BRAND = { + development: { + faviconIco: LASTCODE_BRAND_ASSET_PATHS.developmentWebFaviconIco, + favicon16Png: LASTCODE_BRAND_ASSET_PATHS.developmentWebFavicon16Png, + favicon32Png: LASTCODE_BRAND_ASSET_PATHS.developmentWebFavicon32Png, + appleTouchIconPng: LASTCODE_BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, + }, + nightly: { + faviconIco: LASTCODE_BRAND_ASSET_PATHS.nightlyWebFaviconIco, + favicon16Png: LASTCODE_BRAND_ASSET_PATHS.nightlyWebFavicon16Png, + favicon32Png: LASTCODE_BRAND_ASSET_PATHS.nightlyWebFavicon32Png, + appleTouchIconPng: LASTCODE_BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng, + }, + production: { + faviconIco: LASTCODE_BRAND_ASSET_PATHS.productionWebFaviconIco, + favicon16Png: LASTCODE_BRAND_ASSET_PATHS.productionWebFavicon16Png, + favicon32Png: LASTCODE_BRAND_ASSET_PATHS.productionWebFavicon32Png, + appleTouchIconPng: LASTCODE_BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, + }, +} as const satisfies Record>; + +export function resolveLastCodeWebIconOverrides( + brand: WebAssetBrand, + targetDirectory: string, +): ReadonlyArray { + const sourcePaths = LASTCODE_WEB_ICON_SOURCE_PATHS_BY_BRAND[brand]; + return [ + { + sourceRelativePath: sourcePaths.faviconIco, + targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.faviconIco}`, + }, + { + sourceRelativePath: sourcePaths.favicon16Png, + targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.favicon16Png}`, + }, + { + sourceRelativePath: sourcePaths.favicon32Png, + targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.favicon32Png}`, + }, + { + sourceRelativePath: sourcePaths.appleTouchIconPng, + targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.appleTouchIconPng}`, + }, + ]; +} + +export const LASTCODE_DEVELOPMENT_ICON_OVERRIDES = resolveLastCodeWebIconOverrides( + "development", + "dist/client", +);