diff --git a/.changeset/tracked-token-warning.md b/.changeset/tracked-token-warning.md new file mode 100644 index 00000000..983cd44e --- /dev/null +++ b/.changeset/tracked-token-warning.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +When `.taskless/.env.local.json` is tracked by git, the warning now gives the steps that help: `git rm --cached` to untrack it, check `.taskless/.gitignore`, and if the commit was pushed, replace the token with `auth logout` then `auth login`. Before, it only said to gitignore the file, which does nothing for a file git already tracks. diff --git a/packages/cli/src/auth/token.ts b/packages/cli/src/auth/token.ts index 150c6215..7223987a 100644 --- a/packages/cli/src/auth/token.ts +++ b/packages/cli/src/auth/token.ts @@ -68,7 +68,7 @@ export async function saveToken( const tasklessDirectory = join(cwd, ".taskless"); await mkdir(tasklessDirectory, { recursive: true }); - await addToGitignore(cwd, [".env.local.json"]); + await addToGitignore(cwd, [PER_REPO_AUTH_FILE]); await writeFile(join(tasklessDirectory, PER_REPO_AUTH_FILE), content, { mode: 0o600, }); @@ -126,7 +126,7 @@ function warnIfLegacyToken(): void { /** Warn if .env.local.json is tracked by git */ async function warnIfTracked(cwd: string): Promise { - const relativePath = ".taskless/.env.local.json"; + const relativePath = `.taskless/${PER_REPO_AUTH_FILE}`; try { const output = await new Promise((resolve, reject) => { execFile("git", ["ls-files", relativePath], { cwd }, (error, stdout) => { @@ -138,8 +138,16 @@ async function warnIfTracked(cwd: string): Promise { }); }); if (output.length > 0) { + // Already in the index, so a .gitignore entry alone changes nothing: + // git keeps tracking a file it already tracks. + const cli = getCliPrefix(); console.error( - "Warning: .taskless/.env.local.json is tracked by git. This file contains authentication tokens and should be gitignored." + [ + `Warning: ${relativePath} is tracked by git. It contains an authentication token.`, + ` 1. Untrack it and keep your local copy: git rm --cached ${relativePath}`, + ` 2. Make sure .taskless/.gitignore lists ${PER_REPO_AUTH_FILE}, then commit.`, + ` 3. If a commit containing it was pushed, treat the token as exposed and replace it: \`${cli} auth logout\`, then \`${cli} auth login\`. Logout only deletes the local copy, so it does not revoke the old token.`, + ].join("\n") ); } } catch { diff --git a/packages/cli/test/token.test.ts b/packages/cli/test/token.test.ts index 5956ea70..b08f4f05 100644 --- a/packages/cli/test/token.test.ts +++ b/packages/cli/test/token.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -14,6 +15,7 @@ beforeEach(async () => { afterEach(async () => { vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllEnvs(); await rm(temporaryDirectory, { recursive: true, force: true }); }); @@ -169,3 +171,36 @@ describe("removeToken", () => { expect(await removeToken()).toBe(false); }); }); + +describe("tracked token warning", () => { + it("tells the user to untrack the file, not just gitignore it", async () => { + await writeAuthFile({ access_token: "tracked-token" }); + execFileSync("git", ["init", "-q"], { cwd: temporaryDirectory }); + execFileSync("git", ["add", ".taskless/.env.local.json"], { + cwd: temporaryDirectory, + }); + const errors: string[] = []; + vi.spyOn(console, "error").mockImplementation((message: unknown) => { + errors.push(String(message)); + }); + + expect(await getToken(temporaryDirectory)).toBe("tracked-token"); + + const warning = errors.find((line) => line.includes("tracked by git")); + expect(warning).toContain("git rm --cached .taskless/.env.local.json"); + expect(warning).toContain("auth logout"); + expect(warning).toContain("auth login"); + expect(warning).toContain("does not revoke"); + }); + + it("stays quiet when the file is not tracked", async () => { + await writeAuthFile({ access_token: "untracked-token" }); + execFileSync("git", ["init", "-q"], { cwd: temporaryDirectory }); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(await getToken(temporaryDirectory)).toBe("untracked-token"); + expect( + spy.mock.calls.some((call) => String(call[0]).includes("tracked by git")) + ).toBe(false); + }); +});