Skip to content
Closed
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
15 changes: 11 additions & 4 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,17 @@ if (
})
) {
// The launcher owns shutdown until its child exits, so it bypasses the Effect CLI runtime.
// The service unit passes no arguments; flags and help go through the full CLI.
if (process.argv[2] === "__service-launcher" && process.argv.length === 3) {
const { runServiceLauncherCli } = await import("./cli/serviceLauncher.ts");
await runServiceLauncherCli();
// Service units pass only launcher flags; global flags and help go through the full CLI.
if (process.argv[2] === "__service-launcher") {
const { parseServiceLauncherArgs, runServiceLauncherCli } =
await import("./cli/serviceLauncher.ts");
const launcherOptions = parseServiceLauncherArgs(process.argv.slice(3));
if (launcherOptions !== undefined) {
await runServiceLauncherCli(launcherOptions);
} else {
const { runCliCommand } = await loadCliMain();
await runCliCommand();
}
} else {
const { runCliCommand } = await loadCliMain();
await runCliCommand();
Expand Down
22 changes: 18 additions & 4 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { Argument, Command } from "effect/cli";
import * as Option from "effect/Option";
import { Argument, Command, Flag } from "effect/cli";
import * as CliError from "effect/cli/CliError";

import * as NetService from "@t3tools/shared/Net";
Expand Down Expand Up @@ -97,11 +98,24 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
updateCommand,
installCommand,
uninstallCommand,
// bin.ts runs the bare service invocation; this keeps flags and help working like other commands.
Command.make("__service-launcher").pipe(
// bin.ts runs the service invocation directly; this keeps global flags and help working.
Command.make("__service-launcher", {
baseDir: Flag.String("base-dir").pipe(Flag.optional),
logFile: Flag.String("log-file").pipe(Flag.optional),
serviceUnit: Flag.String("service-unit").pipe(Flag.optional),
}).pipe(
Command.unlisted,
// The launcher owns shutdown until its child exits; `runMain` would interrupt that shutdown.
Command.withHandler(() => Effect.sync(() => void runServiceLauncherCli())),
Command.withHandler((flags) =>
Effect.sync(
() =>
void runServiceLauncherCli({
baseDir: Option.getOrUndefined(flags.baseDir),
logFile: Option.getOrUndefined(flags.logFile),
serviceUnit: Option.getOrUndefined(flags.serviceUnit),
}),
),
),
),
claudeHistoryCommand,
speechWorkerCommand,
Expand Down
61 changes: 37 additions & 24 deletions apps/server/src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Schema from "effect/Schema";
import { Command, GlobalFlag } from "effect/cli";
import * as CliError from "effect/cli/CliError";

import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts";
import * as ServerConfig from "../config.ts";
import { awaitLauncherLoss } from "../launcherWatch.ts";
import * as DatabaseMaintenance from "../persistence/DatabaseMaintenance.ts";
Expand All @@ -30,31 +31,43 @@ const runServerCommand = Effect.fn("server.runServerCommand")(function* (
readonly rejectRunningServer?: boolean;
},
) {
// Before config resolution, which claims a port and creates directories.
const { runServer } = yield* Effect.promise(() => import("../server.ts"));
const logLevel = yield* GlobalFlag.LogLevel;
const selectAutomaticPort = yield* AutomaticPort.make.pipe(Effect.provide(ProcessRunner.layer));
const config = yield* resolveServerConfig(flags, logLevel, { ...options, selectAutomaticPort });
const run = runServer.pipe(
Effect.provideService(ServerConfig.ServerConfig, config),
// Settings' Reclaim stops the server; repair once the database is closed, before the restart.
Effect.ensuring(
DatabaseMaintenance.runRequestedReclaim(config.dbPath).pipe(
Effect.provide(DatabaseMaintenance.layer),
),
),
);
if (config.mode !== "desktop" || config.desktopBootstrapToken === undefined) return yield* run;
yield* Effect.sync(() => {
process.stdout.on("error", ignoreClosedLauncherPipe);
process.stderr.on("error", ignoreClosedLauncherPipe);
});
return yield* Effect.raceFirst(
run,
awaitLauncherLoss().pipe(
// End like SIGTERM does (interrupt-only exit, so the runtime forces process exit).
Effect.andThen(Effect.interrupt),
),
Effect.gen(function* () {
// Before config resolution, which claims a port and creates directories.
const { runServer } = yield* Effect.promise(() => import("../server.ts"));
const logLevel = yield* GlobalFlag.LogLevel;
const selectAutomaticPort = yield* AutomaticPort.make.pipe(
Effect.provide(ProcessRunner.layer),
);
const config = yield* resolveServerConfig(flags, logLevel, {
...options,
selectAutomaticPort,
});
const run = runServer.pipe(
Effect.provideService(ServerConfig.ServerConfig, config),
// Settings' Reclaim stops the server; repair once the database is closed, before the restart.
Effect.ensuring(
DatabaseMaintenance.runRequestedReclaim(config.dbPath).pipe(
Effect.provide(DatabaseMaintenance.layer),
),
),
);
if (config.mode !== "desktop" || config.desktopBootstrapToken === undefined) {
return yield* run;
}
yield* Effect.sync(() => {
process.stdout.on("error", ignoreClosedLauncherPipe);
process.stderr.on("error", ignoreClosedLauncherPipe);
});
return yield* Effect.raceFirst(
run,
awaitLauncherLoss().pipe(
// End like SIGTERM does (interrupt-only exit, so the runtime forces process exit).
Effect.andThen(Effect.interrupt),
),
);
}),
ServiceLauncherClient.awaitServiceLauncherStop().pipe(Effect.andThen(Effect.interrupt)),
);
});

Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/cli/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function formatServiceStatus(
protection?: ServiceProtection.ServiceProtectionState,
): string {
if (!status.supported) {
return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd";
return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd, Windows with Task Scheduler";
}
if (!status.installed) {
return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
Expand Down Expand Up @@ -296,6 +296,7 @@ const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).
Effect.gen(function* () {
const service = yield* BootService.BootService;
const path = yield* Path.Path;
const platform = yield* HostProcess.Platform;
const { baseDir } = yield* ServerConfig.ServerConfig;
// An unreadable unit stays removable; only a unit known to serve another home is kept.
const installedBaseDir = yield* service.status.pipe(
Expand All @@ -304,7 +305,7 @@ const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).
);
if (
installedBaseDir !== undefined &&
path.resolve(installedBaseDir) !== path.resolve(baseDir)
!BootService.sameBootServiceBaseDir(path, platform, installedBaseDir, baseDir)
) {
yield* Console.log(
`The T3 Code service serves ${installedBaseDir}, so it was not removed. Pass \`${BootService.formatBootServiceBaseDirOption(installedBaseDir)}\` to remove it.`,
Expand Down
24 changes: 21 additions & 3 deletions apps/server/src/cli/serviceLauncher.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { main as runServiceLauncher } from "../serviceLauncher.ts";
import { type LauncherOptions, main as runServiceLauncher } from "../serviceLauncher.ts";

export async function runServiceLauncherCli(): Promise<void> {
/** Parses the Task Scheduler launcher flags without loading the full CLI graph. */
export function parseServiceLauncherArgs(args: ReadonlyArray<string>): LauncherOptions | undefined {
if (args.length % 2 !== 0) return undefined;
let baseDir: string | undefined;
let logFile: string | undefined;
let serviceUnit: string | undefined;
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (value === undefined) return undefined;
if (flag === "--base-dir") baseDir = value;
else if (flag === "--log-file") logFile = value;
else if (flag === "--service-unit") serviceUnit = value;
else return undefined;
}
return { baseDir, logFile, serviceUnit };
}

export async function runServiceLauncherCli(options?: LauncherOptions): Promise<void> {
try {
await runServiceLauncher();
await runServiceLauncher(options);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
process.stderr.write(`[service-launcher] ${message}\n`);
Expand Down
86 changes: 85 additions & 1 deletion apps/server/src/cli/uninstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,93 @@ import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as HostProcess from "@t3tools/shared/HostProcess";

import { findOwnedLauncher, findRecordedLaunchers, spawnWindowsRemoval } from "./uninstall.ts";
import * as BootService from "../cloud/bootService.ts";
import {
findOwnedLauncher,
findRecordedLaunchers,
runUninstall,
spawnWindowsRemoval,
} from "./uninstall.ts";

it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => {
it.effect(
"recovers an orphaned background service and moves unrecovered database backups into userdata",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs
.makeTempDirectoryScoped({ prefix: "t3-uninstall-run-" })
.pipe(Effect.flatMap(fs.realPath));
const runtimeDir = path.join(root, "runtime");
yield* fs.makeDirectory(path.join(runtimeDir, "versions", "1.0.0"), { recursive: true });
yield* fs.writeFileString(path.join(runtimeDir, "versions", "1.0.0", "t3"), "");
yield* fs.makeDirectory(path.join(runtimeDir, "db-backup", "pending-1"), {
recursive: true,
});
yield* fs.writeFileString(
path.join(runtimeDir, "db-backup", "pending-1", "database"),
"saved-pre-trial",
);
yield* fs.makeDirectory(path.join(runtimeDir, "db-backup", "partial.staging"), {
recursive: true,
});

let uninstallCalls = 0;
const bootService = BootService.BootService.of({
install: () => Effect.die("unexpected install"),
restart: Effect.die("unexpected restart"),
withStopped: (operation) => operation,
uninstall: Effect.sync(() => {
uninstallCalls += 1;
return true;
}),
status: Effect.succeed({
supported: true,
installed: false,
current: false,
unitPath: path.join(root, "unit", "t3code.service"),
logPath: path.join(root, "logs", "boot-service.log"),
}),
});

yield* runUninstall({ baseDir: root, assumeYes: true }).pipe(
Effect.provideService(BootService.BootService, bootService),
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HostProcess.Environment, {}),
Effect.provideService(HostProcess.IsExecutable, false),
);

assert.equal(uninstallCalls, 1);
assert.isFalse(yield* fs.exists(runtimeDir));
assert.equal(
yield* fs.readFileString(
path.join(root, "userdata", "db-backup", "pending-1", "database"),
),
"saved-pre-trial",
);
assert.isFalse(
yield* fs.exists(path.join(root, "userdata", "db-backup", "partial.staging")),
);

// If `runtime/db-backup` cannot be enumerated for a non-NotFound reason (e.g. ENOTDIR),
// uninstall aborts before deleting `runtimeDir`.
yield* fs.makeDirectory(path.join(runtimeDir, "versions", "1.0.0"), { recursive: true });
yield* fs.writeFileString(path.join(runtimeDir, "versions", "1.0.0", "t3"), "");
yield* fs.writeFileString(path.join(runtimeDir, "db-backup"), "not-a-directory");
const failedError = yield* runUninstall({ baseDir: root, assumeYes: true }).pipe(
Effect.provideService(BootService.BootService, bootService),
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HostProcess.Environment, {}),
Effect.provideService(HostProcess.IsExecutable, false),
Effect.flip,
);
assert.equal(failedError._tag, "CliUninstallError");
assert.include(failedError.message, "Could not inspect pending update database backups");
assert.isTrue(yield* fs.exists(path.join(runtimeDir, "versions", "1.0.0", "t3")));
}).pipe(Effect.scoped),
);

it.effect("claims only a launcher that points into this home's runtime tree", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
Loading
Loading