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
186 changes: 22 additions & 164 deletions src/cli/commands/auth_login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,15 @@
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { Command } from "@cliffy/command";
import { createContext, type GlobalOptions, isStdinTty } from "../context.ts";
import { AuthRepository } from "../../infrastructure/persistence/auth_repository.ts";
import {
getCollectives,
SwampClubClient,
} from "../../infrastructure/http/swamp_club_client.ts";
import { startCallbackServer } from "../../infrastructure/http/callback_server.ts";
import { openBrowser } from "../../infrastructure/process/browser.ts";
import { UserError } from "../../domain/errors.ts";
import { readSecretFromTty } from "../../infrastructure/io/stdin_reader.ts";
import { Spinner } from "../../presentation/spinner.ts";
import {
renderAuthLoginSuccess,
renderDeviceVerification,
} from "../../presentation/output/auth_login_output.ts";
import { generateDeviceCode } from "../../domain/auth/device_code.ts";
authLogin,
consumeStream,
createAuthLoginDeps,
createLibSwampContext,
} from "../../libswamp/mod.ts";
import type { AuthLoginInput } from "../../libswamp/mod.ts";
import { createAuthLoginRenderer } from "../../presentation/renderers/auth_login.ts";
import { createContext, type GlobalOptions, isStdinTty } from "../context.ts";

const DEFAULT_SERVER_URL = "https://swamp.club";

Expand All @@ -42,94 +35,6 @@ function resolveServerUrl(): string {
return Deno.env.get("SWAMP_CLUB_URL") ?? DEFAULT_SERVER_URL;
}

/** Read a line from stdin. */
async function readLine(prompt: string): Promise<string> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();

await Deno.stdout.write(encoder.encode(prompt));

const buf = new Uint8Array(4096);
const n = await Deno.stdin.read(buf);
if (n === null) {
return "";
}
return decoder.decode(buf.subarray(0, n)).trim();
}

/** Read a password from stdin without echoing. */
async function readPassword(prompt: string): Promise<string> {
try {
return await readSecretFromTty(prompt);
} catch (err) {
if (err instanceof Error && err.message === "Cancelled.") {
throw new UserError("Cancelled.");
}
throw err;
}
}

/** Get a session token via the browser-based login flow. */
async function browserFlow(
serverUrl: string,
spinner: Spinner | null,
): Promise<string> {
const state = crypto.randomUUID();
const deviceCode = generateDeviceCode();
const server = startCallbackServer(state, serverUrl);

const callbackUrl = `http://localhost:${server.port}/callback`;
const loginUrl = `${serverUrl}/login?cli_callback=${
encodeURIComponent(callbackUrl)
}&state=${encodeURIComponent(state)}&device_code=${
encodeURIComponent(deviceCode)
}`;

try {
await openBrowser(loginUrl);
} catch (err) {
// openBrowser throws UserError with the URL — show it and continue waiting
if (err instanceof UserError) {
spinner?.stop();
console.log(err.message);
spinner?.start("Waiting for authentication...");
} else {
const message = err instanceof Error ? err.message : String(err);
throw new UserError(`Failed to open browser: ${message}`);
}
}

spinner?.stop();
renderDeviceVerification(deviceCode);
console.log();
spinner?.start("Waiting for authentication...");

try {
const token = await server.token;
return token;
} finally {
await server.shutdown();
}
}

/** Get a session token via the stdin username/password flow. */
async function stdinFlow(
serverUrl: string,
usernameOpt: string | undefined,
passwordOpt: string | undefined,
): Promise<{ token: string; username: string }> {
const username: string = usernameOpt ?? await readLine("Username or email: ");
const password: string = passwordOpt ?? await readPassword("Password: ");

if (!username || !password) {
throw new UserError("Username and password are required.");
}

const client = new SwampClubClient(serverUrl);
const signIn = await client.signIn(username, password);
return { token: signIn.token, username: signIn.user.username };
}

// deno-lint-ignore no-explicit-any
type AnyOptions = any;

Expand All @@ -144,76 +49,29 @@ export const authLoginCommand = new Command()
.option("--password <password:string>", "Password (omit to prompt)")
.option("--no-browser", "Disable browser login, use username/password")
.action(async function (options: AnyOptions) {
const ctx = createContext(options as GlobalOptions, ["auth", "login"]);
ctx.logger.debug("Executing auth login command");
const cliCtx = createContext(options as GlobalOptions, ["auth", "login"]);
cliCtx.logger.debug("Executing auth login command");

const serverUrl: string = options.server ?? resolveServerUrl();
const client = new SwampClubClient(serverUrl);

// Decide which flow to use:
// - stdin flow if --username/--password provided, --no-browser, or non-TTY
// - browser flow otherwise
const useStdinFlow = options.username || options.password ||
options.browser === false || !isStdinTty();

const showSpinner = ctx.outputMode !== "json" && !useStdinFlow;
const spinner = showSpinner ? new Spinner() : null;

let sessionToken: string;
let knownUsername: string | undefined;

try {
if (useStdinFlow) {
ctx.logger.debug("Using stdin login flow");
const result = await stdinFlow(
serverUrl,
options.username,
options.password,
);
sessionToken = result.token;
knownUsername = result.username;
} else {
ctx.logger.debug("Using browser login flow");
spinner?.start("Opening browser...");
sessionToken = await browserFlow(serverUrl, spinner);
}

// Create an API key for CLI use
// BetterAuth limits API key names to 32 characters
spinner?.update("Securing session...");
const host = (Deno.hostname?.() ?? "unknown").slice(0, 14);
const keyName = `cli-${host}-${Date.now()}`;
ctx.logger.debug`Creating API key: ${keyName}`;
const apiKey = await client.createApiKey(sessionToken, keyName);

// Verify identity using the new API key
const whoami = await client.whoami(apiKey.key);
const username = whoami.username ?? knownUsername ?? "unknown";

// Store credentials (including cached collectives for auto-trust)
const collectives = getCollectives(whoami);
const repo = new AuthRepository();
await repo.save({
serverUrl,
apiKey: apiKey.key,
apiKeyId: apiKey.id,
username,
...(collectives ? { collectives } : {}),
});

spinner?.stop();
const showSpinner = cliCtx.outputMode !== "json" && !useStdinFlow;

renderAuthLoginSuccess({
username,
email: whoami.email,
name: whoami.name,
serverUrl,
apiKey: apiKey.key,
}, ctx.outputMode);
} catch (err) {
spinner?.stop();
throw err;
}
const ctx = createLibSwampContext({ logger: cliCtx.logger });
const deps = createAuthLoginDeps();
const input: AuthLoginInput = {
serverUrl,
useBrowserFlow: !useStdinFlow,
username: options.username,
password: options.password,
};
const renderer = createAuthLoginRenderer(cliCtx.outputMode, showSpinner);
await consumeStream(authLogin(ctx, deps, input), renderer.handlers());

ctx.logger.debug("Auth login command completed");
cliCtx.logger.debug("Auth login command completed");
});
98 changes: 25 additions & 73 deletions src/cli/commands/extension_fmt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { Command } from "@cliffy/command";
import {
consumeStream,
createExtensionFmtDeps,
createLibSwampContext,
extensionFmt,
} from "../../libswamp/mod.ts";
import { createExtensionFmtRenderer } from "../../presentation/renderers/extension_fmt.ts";
import { createContext, type GlobalOptions } from "../context.ts";
import { requireInitializedRepo } from "../repo_context.ts";
import { resolveExtensionFiles } from "../resolve_extension_files.ts";
import { UserError } from "../../domain/errors.ts";
import { checkExtensionQuality } from "../../domain/extensions/extension_quality_checker.ts";
import { EmbeddedDenoRuntime } from "../../infrastructure/runtime/embedded_deno_runtime.ts";
import {
renderExtensionFmt,
renderExtensionFmtCheck,
} from "../../presentation/output/extension_fmt_output.ts";

interface ExtensionFmtOptions extends GlobalOptions {
repoDir: string;
Expand All @@ -41,14 +42,14 @@ export const extensionFmtCommand = new Command()
.option("--repo-dir <dir:string>", "Repository directory", { default: "." })
.option("--check", "Check only, do not auto-fix")
.action(async function (options: ExtensionFmtOptions, manifestPath: string) {
const ctx = createContext(options, ["extension", "fmt"]);
ctx.logger.debug`Starting extension fmt`;
const cliCtx = createContext(options, ["extension", "fmt"]);
cliCtx.logger.debug`Starting extension fmt`;

// 1. Validate repo
const repoDir = options.repoDir ?? ".";
const { repoContext } = await requireInitializedRepo({
repoDir,
outputMode: ctx.outputMode,
outputMode: cliCtx.outputMode,
});

// 2. Resolve extension files (manifest, models, workflows, additional files)
Expand All @@ -57,89 +58,40 @@ export const extensionFmtCommand = new Command()
allVaultFiles,
allDriverFiles,
allDatastoreFiles,
allReportFiles,
additionalFilePaths,
} = await resolveExtensionFiles({
repoDir,
manifestPath,
repoContext,
logger: ctx.logger,
logger: cliCtx.logger,
});

// 3. Combine all files and filter to .ts
// (fmt only operates on TypeScript files)
const allFiles = [
...allModelFiles,
...allVaultFiles,
...allDriverFiles,
...allDatastoreFiles,
...allReportFiles,
...additionalFilePaths,
];
const tsFiles = allFiles.filter((f) => f.endsWith(".ts"));

if (tsFiles.length === 0) {
if (ctx.outputMode === "json") {
console.log(JSON.stringify({ status: "passed", fileCount: 0 }));
} else {
ctx.logger.info("No TypeScript files to check.");
}
return;
}

// 4. Get deno binary
const denoRuntime = new EmbeddedDenoRuntime();
const denoPath = await denoRuntime.ensureDeno();

// 5. Check-only mode
if (options.check) {
const result = await checkExtensionQuality(tsFiles, denoPath);
renderExtensionFmtCheck(result, ctx.outputMode);
if (!result.passed) {
throw new UserError(
"Quality checks failed. Run 'swamp extension fmt <manifest-path>' to fix.",
);
}
return;
}

// 6. Auto-fix mode: run deno fmt and deno lint --fix
const fmtCommand = new Deno.Command(denoPath, {
args: ["fmt", "--no-config", ...tsFiles],
stdout: "piped",
stderr: "piped",
});
const fmtOutput = await fmtCommand.output();
const fmtText = (
new TextDecoder().decode(fmtOutput.stderr) +
new TextDecoder().decode(fmtOutput.stdout)
).trim();

const lintCommand = new Deno.Command(denoPath, {
args: ["lint", "--fix", "--no-config", ...tsFiles],
stdout: "piped",
stderr: "piped",
});
const lintOutput = await lintCommand.output();
const lintText = (
new TextDecoder().decode(lintOutput.stderr) +
new TextDecoder().decode(lintOutput.stdout)
).trim();

// 7. Re-check to detect remaining unfixable issues
const remaining = await checkExtensionQuality(tsFiles, denoPath);
// 4. Create deps, input, renderer and run generator
const ctx = createLibSwampContext({ logger: cliCtx.logger });
const deps = await createExtensionFmtDeps();
const renderer = createExtensionFmtRenderer(cliCtx.outputMode);

renderExtensionFmt(
{
fileCount: tsFiles.length,
fmtOutput: fmtText,
lintOutput: lintText,
remainingIssues: remaining.issues,
},
ctx.outputMode,
await consumeStream(
extensionFmt(ctx, deps, { tsFiles, check: options.check ?? false }),
renderer.handlers(),
);

if (!remaining.passed) {
throw new UserError(
"Some issues could not be auto-fixed. See above for details.",
);
// 5. Throw if quality checks failed
if (!renderer.passed()) {
throw new UserError(renderer.failureMessage());
}

cliCtx.logger.debug`Extension fmt command completed`;
});
Loading
Loading