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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion apps/desktop/src/app/DesktopCliCommand.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import { describe, expect, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -497,3 +497,33 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
}).pipe(Effect.scoped),
);
});

describe("runnableCliCommand", () => {
const state = {
supported: true,
installedPath: "/home/me/.local/bin/t3",
onPath: true,
launcherPath: "/home/me/My Home/.t3/bin/t3",
} as const;
it.each([
{ name: "our ada on PATH", state: { ...state, command: "ada" }, expected: "ada" },
{
name: "another ada in the alias's place",
state: { ...state, command: "t3", aliasBlockedBy: "/home/me/.local/bin/ada" },
expected: "t3",
},
{ name: "a desktop build that links only t3", state, expected: "t3" },
{
name: "a command off PATH",
state: { ...state, onPath: false, command: "ada" },
expected: "'/home/me/My Home/.t3/bin/t3'",
},
{
name: "no launcher",
state: { ...state, onPath: false, installedPath: null, launcherPath: null },
expected: undefined,
},
])("names $name", ({ state, expected }) => {
expect(DesktopCliCommand.runnableCliCommand(state, "linux")).toBe(expected);
});
});
16 changes: 15 additions & 1 deletion apps/desktop/src/app/DesktopCliCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import * as Stream from "effect/Stream";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import { pathSetup, readUserLoginShell } from "@t3tools/shared/shell";
import { pathSetup, readUserLoginShell, shellCommandPath } from "@t3tools/shared/shell";

import * as DesktopCliShim from "./DesktopCliShim.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
Expand Down Expand Up @@ -65,6 +65,20 @@ if ($env:T3_SET -eq '1') {
}
`;

/**
* A command to paste that runs this app's CLI: the name PATH runs as ours, else the bundled
* launcher. Undefined when neither is verified, since either name may be another program's.
*/
export const runnableCliCommand = (
state: DesktopCliCommandState,
platform: NodeJS.Platform,
): string | undefined =>
state.onPath
? (state.command ?? LEGACY_CLI_COMMAND)
: state.launcherPath !== null
? shellCommandPath(state.launcherPath, platform)
: undefined;

export class DesktopCliCommand extends Context.Service<
DesktopCliCommand,
{
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream";
import { HttpClient, HttpClientResponse } from "effect/http";
import { ChildProcessSpawner } from "effect/process";

import * as DesktopCliCommand from "../app/DesktopCliCommand.ts";
import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopState from "../app/DesktopState.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
Expand Down Expand Up @@ -149,6 +150,7 @@ function layerPool(
updateCancellations: Stream.empty,
}),
DesktopBrowserHost.layer,
Layer.mock(DesktopCliCommand.DesktopCliCommand)({}),
Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
resolvePrimary: Effect.die("unexpected primary config resolve"),
resolvePrimaryLabel: Ref.get(labelRef),
Expand Down
34 changes: 26 additions & 8 deletions apps/desktop/src/backend/DesktopBackendPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ChildProcessSpawner } from "effect/process";

import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
import * as DesktopBackendManager from "./DesktopBackendManager.ts";
import * as DesktopCliCommand from "../app/DesktopCliCommand.ts";
import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopState from "../app/DesktopState.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
Expand All @@ -26,7 +27,8 @@ import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import { APP_NAME, CLI_COMMAND } from "@t3tools/shared/branding";
import { APP_NAME, CLI_COMMAND, LEGACY_CLI_COMMAND } from "@t3tools/shared/branding";
import { powerShellOnly } from "@t3tools/shared/shell";

const { logWarning: logBackendPoolWarning } =
DesktopObservability.makeComponentLogger("desktop-backend-pool");
Expand Down Expand Up @@ -103,10 +105,24 @@ type UnregisterAction =
| { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> }
| { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance };

/** What to do when another server holds this home; `command` is one that runs this app's CLI. */
const stateDirOwnedDetail = (command: string | undefined, platform: NodeJS.Platform) => {
const run = (subcommand: string) =>
command === undefined ? `\`${subcommand}\`` : `\`${command} ${subcommand}\``;
const where =
command === undefined
? ` Run these commands with your ${APP_NAME} CLI (\`${CLI_COMMAND}\`, or legacy \`${LEGACY_CLI_COMMAND}\`).`
: powerShellOnly(command)
? " Run these commands in PowerShell."
: "";
return `Another ${APP_NAME} server, such as the background service, is using this data directory, or an older background service is set up for it.${where} Stop that server or try ${run("update")}. If the older launcher cannot update, run ${run("service uninstall")} and optionally reinstall with ${run("service install")} from a current CLI. If the CLI is missing or outdated, install it with \`${cliInstallCommand(platform === "win32" ? "win32" : "posix")}\`, and pass \`--base-dir\` with the service's ${APP_NAME} home to each command. Then reopen ${APP_NAME}. You can also set a separate T3CODE_HOME and pair with the running server.`;
};

export const layer = Layer.effect(
DesktopBackendPool,
Effect.gen(function* () {
const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
const cliCommand = yield* DesktopCliCommand.DesktopCliCommand;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const electronApp = yield* ElectronApp.ElectronApp;
Expand Down Expand Up @@ -176,13 +192,15 @@ export const layer = Layer.effect(
SubscriptionRef.set(primaryBackendStartup, { _tag: "Failing", ...failure }),
onShutdown: () => desktopWindow.handleBackendNotReady,
onPreflightFailed: handlePrimaryPreflightFailure,
onStateDirOwned: () =>
Effect.flatMap(HostProcess.Platform, (platform) =>
electronDialog.showErrorBox(
`This ${APP_NAME} home is unavailable`,
`Another ${APP_NAME} server, such as the background service, is using this data directory, or an older background service is set up for it. Stop that server or try \`${CLI_COMMAND} update\`. If the older launcher cannot update, run \`${CLI_COMMAND} service uninstall\` and optionally reinstall with \`${CLI_COMMAND} service install\` from a current CLI. If \`${CLI_COMMAND}\` is missing or outdated, install it with \`${cliInstallCommand(platform === "win32" ? "win32" : "posix")}\`, and pass \`--base-dir\` with the service's ${APP_NAME} home to each command. Then reopen ${APP_NAME}. You can also set a separate T3CODE_HOME and pair with the running server.`,
),
).pipe(Effect.andThen(electronApp.quit)),
onStateDirOwned: Effect.fn("DesktopBackendPool.onStateDirOwned")(function* () {
const platform = yield* HostProcess.Platform;
const command = DesktopCliCommand.runnableCliCommand(yield* cliCommand.state, platform);
yield* electronDialog.showErrorBox(
`This ${APP_NAME} home is unavailable`,
stateDirOwnedDetail(command, platform),
);
yield* electronApp.quit;
}),
onDatabaseRefused: () =>
electronDialog
.showErrorBox(
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe(
);

const layerDesktopBackend = DesktopBackendPool.layer.pipe(
Layer.provideMerge(DesktopCliCommand.layer),
Layer.provideMerge(DesktopAppIdentity.layer),
Layer.provideMerge(DesktopBackendConfiguration.layer),
Layer.provideMerge(DesktopWslEnvironment.layer),
Expand All @@ -215,7 +216,6 @@ const layerDesktopApplication = Layer.mergeAll(
DesktopWebLinks.layer,
DesktopApplicationMenu.layer,
DesktopLinuxUrlHandler.layer,
DesktopCliCommand.layer,
layerDesktopSsh,
).pipe(
Layer.provideMerge(layerDesktopSnapShot),
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/wsl/DesktopWslBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts";
import { APP_NAME, CLI_COMMAND } from "@t3tools/shared/branding";
import { APP_NAME, CLI_COMMAND, LEGACY_CLI_COMMAND } from "@t3tools/shared/branding";

export const WSL_INSTANCE_ID_PREFIX = "wsl:";
const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`;
Expand Down Expand Up @@ -130,7 +130,7 @@ export const layer = Layer.effect(
Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)),
onStateDirOwned: () =>
refuse(
`Another ${APP_NAME} server is using this distro's ${APP_NAME} home, or an older background service is set up for it. Stop that server or pair with it, or try \`${CLI_COMMAND} update\` in the distro. If the older launcher cannot update, run \`${CLI_COMMAND} service uninstall\` and optionally reinstall with \`${CLI_COMMAND} service install\` from a current CLI in the distro. If \`${CLI_COMMAND}\` is missing or outdated, install it in the distro with \`${cliInstallCommand("posix")}\`, and pass \`--base-dir\` with the service's ${APP_NAME} home to each command.`,
`Another ${APP_NAME} server is using this distro's ${APP_NAME} home, or an older background service is set up for it. Run these commands with the distro's ${APP_NAME} CLI (\`${CLI_COMMAND}\`, or legacy \`${LEGACY_CLI_COMMAND}\`). Stop that server or pair with it, or try \`update\` in the distro. If the older launcher cannot update, run \`service uninstall\` and optionally reinstall with \`service install\` from a current CLI in the distro. If the CLI is missing or outdated, install it in the distro with \`${cliInstallCommand("posix")}\`, and pass \`--base-dir\` with the service's ${APP_NAME} home to each command.`,
),
onDatabaseRefused: () =>
refuse(
Expand Down
82 changes: 78 additions & 4 deletions apps/server/src/cli/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { pinnedRuntimePaths, prunePinnedRuntimes } from "../service/pinnedRuntim
import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
import * as ProcessRunner from "../processRunner.ts";
import {
ownedCommandOnPath,
repointLauncher,
resolveLauncherPath,
resolveMirrorVersion,
Expand Down Expand Up @@ -474,17 +475,27 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => {
{ name: "only t3 is installed", ada: "missing", expected: "t3" },
{ name: "another program owns ada", ada: "foreign", expected: "t3" },
{ name: "ada and t3 are both installed", ada: "owned", expected: "ada" },
// Owning ada is not enough when PATH runs another program's ada first.
{ name: "another ada runs first on PATH", ada: "owned", shadowed: "earlier", expected: "t3" },
// An empty PATH entry is the working directory to a POSIX shell.
{ name: "an empty PATH entry runs another ada", ada: "owned", shadowed: "cwd", expected: "t3" },
// A shell skips a file it cannot execute.
{ name: "a non-executable ada comes first", ada: "owned", shadowed: "inert", expected: "ada" },
{ name: "the launchers are off PATH", ada: "owned", offPath: true, expected: "runtime" },
{ name: "no launcher is installed", ada: "missing", t3: "missing", expected: "runtime" },
] as const)("names the command and version a pending restart needs when $name", (row) =>
Effect.gen(function* () {
yield* withoutTerminals;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
// A shell would expand or split a runtime path like this one unless it is a literal.
const baseDir = yield* fs.makeTempDirectoryScoped({
prefix: 't3 update $workspace "q" `x`-',
});
const version = packageJson.version;
const runtime = pinnedRuntimePaths(path, baseDir, version, "linux");
yield* fs.makeDirectory(runtime.versionDir, { recursive: true });
yield* fs.writeFileString(runtime.entryPath, "");
yield* fs.writeFileString(runtime.entryPath, "", { mode: 0o755 });
yield* fs.writeFileString(runtime.sentinelPath, `${version}\n`);
const unitPath = path.join(baseDir, "t3code.service");
const logPath = path.join(baseDir, "boot-service.log");
Expand All @@ -508,8 +519,19 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => {
const launchers = { t3: "t3" in row ? row.t3 : "owned", ada: row.ada };
for (const [name, kind] of Object.entries(launchers)) {
if (kind === "owned") yield* fs.symlink(runtime.entryPath, path.join(bin, name));
if (kind === "foreign") yield* fs.writeFileString(path.join(bin, name), "#!/bin/sh\n");
if (kind === "foreign") {
yield* fs.writeFileString(path.join(bin, name), "#!/bin/sh\n", { mode: 0o755 });
}
}
const earlier = path.join(baseDir, "earlier");
yield* fs.makeDirectory(earlier);
const shadowed = "shadowed" in row ? row.shadowed : undefined;
if (shadowed !== undefined) {
const mode = shadowed === "inert" ? 0o644 : 0o755;
yield* fs.writeFileString(path.join(earlier, "ada"), "#!/bin/sh\n", { mode });
}
const PATH =
"offPath" in row ? earlier : shadowed === "cwd" ? `:${bin}` : [earlier, bin].join(":");
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
input.args?.includes("--version") === true
Expand All @@ -529,6 +551,8 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => {
assumeYes: false,
}).pipe(
Effect.provideService(HostProcess.InvokedAs, path.join(bin, "t3")),
Effect.provideService(HostProcess.Environment, { PATH }),
Effect.provideService(HostProcess.WorkingDirectory, earlier),
Effect.andThen(TestConsole.logLines),
Effect.map((lines) => lines.join("\n")),
Effect.provide(
Expand All @@ -545,7 +569,7 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => {
),
);

const command = row.expected === "runtime" ? runtime.entryPath : row.expected;
const command = row.expected === "runtime" ? `'${runtime.entryPath}'` : row.expected;
assert.include(
output,
`Background service still running the version before ${version}. Run \`${command} service restart\` when you are ready to switch it to ${version}.`,
Expand All @@ -561,3 +585,53 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => {
),
);
});

it.layer(NodeServices.layer)("t3 update on Windows PATH", (it) => {
it.effect.each([
// PATHEXT runs ada.bat before ada.cmd, so another program's ada.bat in an earlier folder wins.
{
name: "another ada.bat earlier on PATH",
pathExt: undefined,
foreign: "ada.bat",
expected: "t3",
},
{
name: "PATHEXT that lists .cmd first",
pathExt: ".CMD;.BAT",
foreign: "ada.bat",
expected: "ada",
},
{
name: "an extension PATHEXT leaves out",
pathExt: ".EXE;.CMD",
foreign: "ada.bat",
expected: "ada",
},
])("names the command PATH runs with $name", ({ pathExt, foreign, expected }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-win-" });
const target = path.join(baseDir, "runtime/versions/1.0.0/t3.exe");
const earlier = path.join(baseDir, "earlier");
const bin = path.join(baseDir, "bin");
yield* fs.makeDirectory(earlier);
yield* fs.makeDirectory(bin);
// In one folder, so PATHEXT order alone decides which ada runs there.
const folder = pathExt === undefined ? earlier : bin;
yield* fs.writeFileString(path.join(folder, foreign), "@echo off\r\nother.exe %*");
for (const name of ["ada", "t3"]) {
yield* fs.writeFileString(path.join(bin, `${name}.cmd`), `@echo off\r\n"${target}" %*`);
}

const command = yield* ownedCommandOnPath({ baseDir }).pipe(
Effect.provideService(HostProcess.Platform, "win32"),
Effect.provideService(HostProcess.Environment, {
Path: [earlier, bin].join(";"),
...(pathExt === undefined ? {} : { PATHEXT: pathExt }),
}),
);
assert.equal(command, expected);
}).pipe(Effect.scoped),
);
});
Loading
Loading