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
5 changes: 3 additions & 2 deletions packages/gittensory-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ Two form factors for running `@jsonbored/gittensory-miner`: **laptop mode** (sin
npm install && npm --workspace @jsonbored/gittensory-miner run build
```

2. Inspect what is installed and where local state will live (no network calls):
2. Inspect what is installed and where local state will live. `status` and `doctor` stay offline; `init --verify-token` is optional and makes one authenticated GitHub call up front:

```sh
gittensory-miner status
gittensory-miner doctor
gittensory-miner init --verify-token # optional: validate GITHUB_TOKEN once before attempts
```

3. Expected layout after first use (default paths):
Expand Down Expand Up @@ -86,7 +87,7 @@ To run the miner continuously on a plain Linux host without Docker, supervise `g

```sh
npm install -g @jsonbored/gittensory-miner
gittensory-miner init
gittensory-miner init --verify-token # optional: validate GITHUB_TOKEN before discovery/attempt runs
sudo cp systemd/gittensory-miner.service.example /etc/systemd/system/gittensory-miner.service
sudo $EDITOR /etc/systemd/system/gittensory-miner.service # set User / WorkingDirectory / ExecStart / secrets
sudo systemctl daemon-reload
Expand Down
4 changes: 2 additions & 2 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ gittensory-miner doctor
gittensory-miner status
```

`init` creates `~/.config/gittensory-miner/` (or `GITTENSORY_MINER_CONFIG_DIR` / `XDG_CONFIG_HOME` overrides) and a local `laptop-state.sqlite3` bootstrap file. Re-running `init` is idempotent. `doctor` reports Node, the state directory, SQLite readiness, and whether Docker is installed (informational only).
`init` creates `~/.config/gittensory-miner/` (or `GITTENSORY_MINER_CONFIG_DIR` / `XDG_CONFIG_HOME` overrides) and a local `laptop-state.sqlite3` bootstrap file. Re-running `init` is idempotent. Pass `--verify-token` to make one authenticated GitHub API call up front and fail fast if `GITHUB_TOKEN` is invalid or missing repository access scopes. `doctor` reports Node, the state directory, SQLite readiness, and whether Docker is installed (informational only).

From a local checkout:

Expand All @@ -121,7 +121,7 @@ gittensory-miner --help
gittensory-miner help
gittensory-miner --version
gittensory-miner version
gittensory-miner init [--json]
gittensory-miner init [--json] [--verify-token]
gittensory-miner status [--json]
gittensory-miner doctor [--json]
gittensory-miner manage status [--json]
Expand Down
10 changes: 6 additions & 4 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import { resolveMinerVersion } from "../lib/version.js";

const cliArgs = process.argv.slice(2);

// `init`, `status`, and `doctor` are strictly local, offline commands — their contract is to make NO network calls.
// Dispatch them BEFORE the opportunistic npm-registry update check is even started, so they can never reach that
// network path (the update check runs for the remaining commands below).
// `status` and `doctor` are strictly local, offline commands — their contract is to make NO network calls.
// `init` stays local by default and only makes a network call when the operator explicitly passes
// `--verify-token`.
// Dispatch the local commands BEFORE the opportunistic npm-registry update check is even started, so they can
// never reach that network path (the update check runs for the remaining commands below).
if (cliArgs[0] === "init") {
process.exit(runInit(cliArgs.slice(1)));
process.exit(await runInit(cliArgs.slice(1)));
}

if (cliArgs[0] === "status") {
Expand Down
2 changes: 1 addition & 1 deletion packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function printHelp(input) {
" gittensory-miner --version",
" gittensory-miner help",
" gittensory-miner version",
" gittensory-miner init [--json] Bootstrap laptop-mode local SQLite state",
" gittensory-miner init [--json] [--verify-token] Bootstrap laptop-mode local SQLite state",
" gittensory-miner status [--json] Show installed versions + local state paths",
" gittensory-miner doctor [--json] Check this laptop is set up correctly",
" gittensory-miner manage status [--json] Show managed PR rows from local portfolio + ledger",
Expand Down
16 changes: 15 additions & 1 deletion packages/gittensory-miner/lib/laptop-init.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export type DoctorCheck = {
detail: string;
};

export type GithubTokenVerification = {
ok: boolean;
login: string | null;
scopes: string[];
detail: string;
};

export function resolveLaptopStateDbPath(env?: Record<string, string | undefined>): string;

export function initLaptopState(env?: Record<string, string | undefined>): LaptopInitResult;
Expand All @@ -34,4 +41,11 @@ export function checkCodexCliPresent(options?: {
resolveCodexAuthPath?: () => string;
}): DoctorCheck;

export function runInit(args?: string[], env?: Record<string, string | undefined>): number;
export function verifyGithubToken(options?: {
githubToken?: string;
fetchImpl?: typeof fetch;
apiBaseUrl?: string;
timeoutMs?: number;
}): Promise<GithubTokenVerification>;

export function runInit(args?: string[], env?: Record<string, string | undefined>): Promise<number>;
136 changes: 133 additions & 3 deletions packages/gittensory-miner/lib/laptop-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { delimiter, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { applySchemaMigrations } from "./schema-version.js";

const githubApiBaseUrl = "https://github.com/ghapi";
const githubApiVersion = "2022-11-28";
const classicRepoScopes = new Set(["repo", "public_repo"]);
const defaultDbFileName = "laptop-state.sqlite3";

/** Local state directory (mirrors `resolveMinerStateDir` in status.js — kept local to avoid import cycles). */
Expand Down Expand Up @@ -108,6 +111,113 @@ function resolveCodexAuthPath(env = process.env) {
return join(base, "auth.json");
}

function githubHeaders(githubToken) {
const headers = {
accept: "application/vnd.github+json",
"user-agent": "gittensory-miner",
"x-github-api-version": githubApiVersion,
};
const token = typeof githubToken === "string" ? githubToken.trim() : "";
if (token) headers.authorization = `Bearer ${token}`;
return headers;
}

function parseScopesHeader(scopesHeader) {
return typeof scopesHeader === "string" && scopesHeader.trim()
? scopesHeader.split(",").map((scope) => scope.trim()).filter(Boolean)
: [];
}

function formatScopes(scopes) {
return scopes.length > 0 ? scopes.join(", ") : "none reported";
}

function hasRepoAccessScope(scopes) {
return scopes.some((scope) => classicRepoScopes.has(scope));
}

function readGithubErrorMessage(payload, status) {
if (payload && typeof payload === "object" && typeof payload.message === "string" && payload.message.trim()) {
return payload.message.trim();
}
return `GitHub returned HTTP ${status}`;
}

/**
* Validate a GitHub token with one authenticated API call.
*
* The classic OAuth scope header is advisory when GitHub reports it: if GitHub returns `repo` or
* `public_repo`, we treat the token as sufficiently scoped for miner setup. If GitHub omits the classic
* scope header altogether, the token is still considered valid and the response is reported as "scopes not
* reported" — that keeps fine-grained tokens usable while still surfacing the scopes GitHub did return.
*/
export async function verifyGithubToken(options = {}) {
const githubToken = typeof options.githubToken === "string" ? options.githubToken.trim() : "";
const fetchImpl = options.fetchImpl ?? fetch;
const apiBaseUrl =
typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim()
? options.apiBaseUrl.trim().replace(/\/+$/, "") || githubApiBaseUrl
: githubApiBaseUrl;
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 5000;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);

let response;
try {
response = await fetchImpl(`${apiBaseUrl}/user`, {
method: "GET",
headers: githubHeaders(githubToken),
signal: controller.signal,
});
} catch (error) {
const detail = controller.signal.aborted
? `timed out after ${timeoutMs}ms`
: error instanceof Error
? error.message
: "request failed";
return {
ok: false,
login: null,
scopes: [],
detail: `GITHUB_TOKEN verification failed: ${detail}`,
};
} finally {
clearTimeout(timeout);
}

const payload = await response.json().catch(() => null);
const scopes = parseScopesHeader(response.headers.get("x-oauth-scopes"));
const login = payload && typeof payload === "object" && typeof payload.login === "string" ? payload.login.trim() : "";

if (!response.ok) {
return {
ok: false,
login: null,
scopes,
detail: `GITHUB_TOKEN verification failed: ${readGithubErrorMessage(payload, response.status)}`,
};
}

if (scopes.length > 0 && !hasRepoAccessScope(scopes)) {
return {
ok: false,
login: login || null,
scopes,
detail: `GITHUB_TOKEN is valid, but GitHub reported only ${formatScopes(scopes)}; reissue it with repo access for miner setup.`,
};
}

return {
ok: true,
login: login || null,
scopes,
detail:
scopes.length > 0
? `validated GitHub token for ${login || "unknown user"}; scopes: ${formatScopes(scopes)}`
: `validated GitHub token for ${login || "unknown user"}; GitHub did not report classic OAuth scopes`,
};
}

/** A coding-agent CLI is only needed once a driver provider is configured (#4289) — gated by
* `MINER_CODING_AGENT_PROVIDER` (#5165). When that provider is NOT the CLI being checked, absence is
* advisory (`ok: true`), mirroring checkDockerPresent's optional tone. When it IS configured and the CLI is
Expand Down Expand Up @@ -178,13 +288,33 @@ export function checkCodexCliPresent(options = {}) {
return { name: "codex-cli-present", ok: true, detail };
}

export function runInit(args = [], env = process.env) {
export async function runInit(args = [], env = process.env) {
const verifyToken = args.includes("--verify-token");
const jsonOutput = args.includes("--json");
let verification = null;
if (verifyToken) {
verification = await verifyGithubToken({ githubToken: env.GITHUB_TOKEN ?? "" });
if (!verification.ok) {
console.error(verification.detail);
return 1;
}
}

const result = initLaptopState(env);
if (args.includes("--json")) {
console.log(JSON.stringify(result, null, 2));
if (jsonOutput) {
console.log(
JSON.stringify(
verification ? { ...result, tokenVerification: verification } : result,
null,
2,
),
);
} else {
console.log(`initialized ${result.stateDir}`);
console.log(`sqlite: ${result.dbPath}${result.created ? "" : " (already existed)"}`);
if (verification) {
console.log(`token: ${verification.detail}`);
}
}
return 0;
}
Loading