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
7 changes: 5 additions & 2 deletions docs/runbooks/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,16 +120,19 @@ that branch.
Start the GitHub App manifest flow:

```bash
npm run setup:app -- --write-env
npm run setup:app
```

For an organization-owned App:

```bash
npm run setup:app -- --org <organization> --write-env
npm run setup:app -- --org <organization>
```

Keep the command running while the user completes the browser flow.
The command saves credentials to `.env` without printing their values. On
POSIX systems, it makes new and existing `.env` files readable and writable
only by the owner. The `--write-env` flag is still accepted for compatibility.

### Human checkpoint: create the App

Expand Down
4 changes: 3 additions & 1 deletion scripts/lib/env-file.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFile, writeFile } from "node:fs/promises";
import { chmod, readFile, writeFile } from "node:fs/promises";

/**
* Sets each key in dotenv text: replaces an existing `KEY=` line (the first, commented-out lines untouched) or
Expand All @@ -19,6 +19,8 @@ function upsertEnv(text: string, values: Readonly<Record<string, string>>): stri
export async function writeEnvFile(path: string, values: Readonly<Record<string, string>>): Promise<void> {
let current = "";
try {
// writeFile's mode only protects new files. Restrict an existing file before adding secrets.
if (process.platform !== "win32") await chmod(path, 0o600);
current = await readFile(path, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
Expand Down
18 changes: 5 additions & 13 deletions scripts/setup-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import { githubApiUrl } from "../app/github/auth.js";
import { writeEnvFile } from "./lib/env-file.js";
import { githubWebUrl } from "./lib/github.js";
import { buildManifest, exchangeManifestCode, formPage, newState, registrationUrl, startCallbackServer } from "./lib/manifest.js";
import { bold, dim, errorMessage, green, yellow } from "./lib/output.js";
import { bold, dim, errorMessage, green } from "./lib/output.js";

const { values } = parseArgs({
options: {
org: { type: "string" },
name: { type: "string" },
port: { type: "string" },
"write-env": { type: "boolean", default: false },
// Accepted for compatibility; credentials are always saved instead of printed.
"write-env": { type: "boolean", default: true },
},
});

Expand Down Expand Up @@ -67,17 +68,8 @@ console.log(`\n${bold("1. Install the App")} on every repository in factory.conf
console.log(` ${app.html_url}/installations/new`);
for (const repo of repos) console.log(` - ${repo}`);

if (values["write-env"]) {
await writeEnvFile(".env", env);
console.log(`\n${green("✓")} Wrote GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_WEBHOOK_SECRET to .env`);
} else {
console.log(`\n${bold("Add these to .env")}; setup:render, setup:github, and doctor read them from there:`);
console.log(` GITHUB_APP_ID=${env.GITHUB_APP_ID}`);
console.log(` GITHUB_APP_PRIVATE_KEY=${env.GITHUB_APP_PRIVATE_KEY}`);
console.log(` GITHUB_WEBHOOK_SECRET=${env.GITHUB_WEBHOOK_SECRET}`);
console.log(dim(" The private key is base64-encoded so it fits on one line. Keep these values secret."));
console.log(yellow(" The private key isn't saved anywhere else. Rerun with --write-env to write .env instead."));
}
await writeEnvFile(".env", env);
console.log(`\n${green("✓")} Wrote GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_WEBHOOK_SECRET to .env`);

console.log(`\n${bold("2. Create the Render env groups")} after adding RENDER_API_KEY, RENDER_WORKSPACE_ID, and ANTHROPIC_API_KEY to .env:`);
console.log(" npm run setup:render");
Expand Down
82 changes: 82 additions & 0 deletions tests/setup-app-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writeEnvFile } from "../scripts/lib/env-file.js";

vi.mock("../app/config/index.js", () => ({
config: {
repos: { "acme/web": {} },
labels: { triage: "factory:triage", specApproved: "factory:spec-approved", prReview: "factory:pr-review", autopilot: "factory:autopilot" },
},
}));
vi.mock("../scripts/lib/env-file.js", () => ({ writeEnvFile: vi.fn() }));
vi.mock("../scripts/lib/manifest.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../scripts/lib/manifest.js")>()),
startCallbackServer: async () => ({
url: "http://127.0.0.1:5555",
result: Promise.resolve({
id: 123,
slug: "sf-test",
name: "sf-test",
pem: "fake-private-key-do-not-print",
html_url: "https://github.com/apps/sf-test",
}),
close: async () => {},
}),
}));

const originalArgv = process.argv;
const directories: string[] = [];

afterEach(async () => {
process.argv = originalArgv;
vi.restoreAllMocks();
vi.clearAllMocks();
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});

describe("setup:app credential output", () => {
it.each([{ args: [] }, { args: ["--write-env"] }])("saves credentials without printing them with arguments $args", async ({ args }) => {
process.argv = ["node", "scripts/setup-app.ts", ...args];
const log = vi.spyOn(console, "log").mockImplementation(() => {});
vi.resetModules();
await import("../scripts/setup-app.js");

const [path, credentials] = vi.mocked(writeEnvFile).mock.calls[0];
expect(path).toBe(".env");
expect(credentials).toEqual({
GITHUB_APP_ID: "123",
GITHUB_APP_PRIVATE_KEY: Buffer.from("fake-private-key-do-not-print").toString("base64"),
GITHUB_WEBHOOK_SECRET: expect.stringMatching(/^[a-f0-9]{64}$/),
});
const output = log.mock.calls.flat().join("\n");
expect(output).toContain("Wrote GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_WEBHOOK_SECRET to .env");
expect(output).toContain("https://github.com/apps/sf-test/installations/new");
expect(output).not.toContain("fake-private-key-do-not-print");
expect(output).not.toContain(credentials.GITHUB_APP_PRIVATE_KEY);
expect(output).not.toContain(credentials.GITHUB_WEBHOOK_SECRET);
});
});

describe("dotenv credential storage", () => {
it.each(["new", "existing"])("protects $0 dotenv files and preserves unrelated settings", async (kind) => {
const existing = kind === "existing";
const directory = await mkdtemp(join(tmpdir(), "software-factory-env-"));
directories.push(directory);
const path = join(directory, ".env");
if (existing) {
await writeFile(path, "# Keep this comment\nOTHER_SETTING=keep\nGITHUB_APP_PRIVATE_KEY=old\n");
if (process.platform !== "win32") await chmod(path, 0o644);
}
const { writeEnvFile: save } = await vi.importActual<typeof import("../scripts/lib/env-file.js")>("../scripts/lib/env-file.js");
await save(path, { GITHUB_APP_PRIVATE_KEY: "new-private-key", GITHUB_WEBHOOK_SECRET: "new-webhook-secret" });

const contents = await readFile(path, "utf8");
expect(contents).toContain("GITHUB_APP_PRIVATE_KEY=new-private-key\n");
expect(contents).toContain("GITHUB_WEBHOOK_SECRET=new-webhook-secret\n");
expect(contents).not.toContain("GITHUB_APP_PRIVATE_KEY=old");
if (existing) expect(contents).toContain("# Keep this comment\nOTHER_SETTING=keep\n");
if (process.platform !== "win32") expect((await stat(path)).mode & 0o777).toBe(0o600);
});
});
Loading