Skip to content
Open
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
3,915 changes: 2,718 additions & 1,197 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"db:migrate:remote": "wrangler d1 migrations apply gittensory --remote",
"drizzle:generate": "drizzle-kit generate",
"build:mcp": "npm --workspace @jsonbored/gittensory-mcp run build",
"build:raycast": "npm --workspace @jsonbored/gittensory-raycast run build",
"test:raycast": "npm --workspace @jsonbored/gittensory-raycast run test:ci",
"test:mcp-pack": "node scripts/check-mcp-package.mjs",
"actionlint": "node scripts/actionlint.mjs",
"ui:dev": "npm run ui:preview",
Expand Down Expand Up @@ -51,7 +53,7 @@
"test:smoke:production": "node scripts/smoke-production.mjs",
"test:smoke:browser:install": "playwright install chromium",
"test:smoke:browser": "node scripts/smoke-ui-browser.mjs",
"test:ci": "git diff --check && npm run actionlint && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run ui:openapi:check && npm run ui:version-audit && npm run ui:lint && npm run ui:typecheck && npm run ui:build && npm audit --audit-level=moderate",
"test:ci": "git diff --check && npm run actionlint && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run test:raycast && npm run ui:openapi:check && npm run ui:version-audit && npm run ui:lint && npm run ui:typecheck && npm run ui:build && npm audit --audit-level=moderate",
"test:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci && npm run changelog:check:mcp",
"test:watch": "vitest",
Expand Down
37 changes: 37 additions & 0 deletions packages/gittensory-raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Gittensory Raycast extension

Raycast command surface for [Gittensory](https://github.com/JSONbored/gittensory). The extension authenticates through **GitHub Device Flow** against the **Gittensory API** and stores only a **`gts_` session token** in Raycast `LocalStorage`.

## Package boundaries

| Layer | Responsibility |
| --- | --- |
| `lib/` | API client, device-flow auth, session storage helpers, public-output sanitizer (unit-tested, no Raycast UI imports) |
| `src/` | Raycast commands (`login`, `status`, `logout`) wired to `lib/` |
| Gittensory API | OAuth device flow, session validation, logout revocation |

This package does **not**:

- Store GitHub personal access tokens (PATs are rejected at validation time)
- Upload repository source contents or branch patches
- Embed wallet, hotkey, trust-score, payout, or farming language in user-visible output

Configure the API origin via the **API Origin** preference (default: `https://gittensory-api.aethereal.dev`).

## Local development

```bash
cd packages/gittensory-raycast
npm install
npm run build
npm run test
npm run lint
```

Load the extension in Raycast with **Import Extension** pointed at this directory after `npm run build`.

## Commands

- **Login** — starts device flow, opens the verification URL, polls until a `gts_` session is issued, persists locally
- **Status** — shows signed-in login, API origin, expiry, and scopes (sanitized)
- **Logout** — revokes the remote session when possible and clears local storage
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions packages/gittensory-raycast/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineConfig } from "eslint/config";
import raycastConfig from "@raycast/eslint-config";

export default defineConfig([...raycastConfig]);
32 changes: 32 additions & 0 deletions packages/gittensory-raycast/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { normalizeApiOrigin } from "./config";
import type { FetchLike } from "./types";

export type ApiRequestOptions = {
apiOrigin: string;
path: string;
method?: string;
body?: unknown;
token?: string | null;
fetchImpl?: FetchLike;
};

export async function gittensoryApiRequest<T>(options: ApiRequestOptions): Promise<T> {
const fetchImpl = options.fetchImpl ?? fetch;
const origin = normalizeApiOrigin(options.apiOrigin);
const headers: Record<string, string> = {
accept: "application/json",
"content-type": "application/json",
};
if (options.token) headers.authorization = `Bearer ${options.token}`;
const response = await fetchImpl(`${origin}${options.path}`, {
method: options.method ?? "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const payload = (await response.json().catch(() => ({}))) as T & { error?: string };
if (!response.ok) {
const message = typeof payload.error === "string" ? payload.error : `request_failed_${response.status}`;
throw new Error(message);
}
return payload;
}
149 changes: 149 additions & 0 deletions packages/gittensory-raycast/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { gittensoryApiRequest } from "./api";
import { isSessionExpired, validateGittensorySessionToken } from "./config";
import { clearStoredSession, loadStoredAuth, saveSession, type SessionStorageAdapter } from "./storage";
import type { DeviceFlowPollResult, DeviceFlowStart, FetchLike, GittensorySession, SessionStatus, SleepFn } from "./types";

const defaultSleep: SleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function startDeviceFlow(apiOrigin: string, fetchImpl?: FetchLike): Promise<DeviceFlowStart> {
const payload = await gittensoryApiRequest<{
deviceCode: string;
userCode: string;
verificationUri: string;
expiresIn: number;
interval?: number;
}>({
apiOrigin,
path: "/v1/auth/github/device/start",
method: "POST",
body: {},
fetchImpl,
});
return {
deviceCode: payload.deviceCode,
userCode: payload.userCode,
verificationUri: payload.verificationUri,
expiresIn: Number(payload.expiresIn ?? 900),
interval: Math.max(5, Number(payload.interval ?? 5)),
};
}

export async function pollDeviceFlow(apiOrigin: string, deviceCode: string, fetchImpl?: FetchLike): Promise<DeviceFlowPollResult> {
const payload = await gittensoryApiRequest<Record<string, unknown>>({
apiOrigin,
path: "/v1/auth/github/device/poll",
method: "POST",
body: { deviceCode },
fetchImpl,
});
if (typeof payload.token === "string" && payload.token) {
return {
token: validateGittensorySessionToken(payload.token),
login: typeof payload.login === "string" ? payload.login : "",
expiresAt: typeof payload.expiresAt === "string" ? payload.expiresAt : "",
scopes: Array.isArray(payload.scopes) ? payload.scopes.filter((scope): scope is string => typeof scope === "string") : [],
lastAuthenticatedAt: new Date().toISOString(),
};
}
const status = typeof payload.status === "string" ? payload.status : "error";
if (status === "authorization_pending" || status === "slow_down") {
return { status, message: typeof payload.message === "string" ? payload.message : undefined };
}
return { status: "error", message: typeof payload.message === "string" ? payload.message : status };
}

export async function loginWithDeviceFlow(
apiOrigin: string,
options: { fetchImpl?: FetchLike; sleep?: SleepFn; now?: () => number; onStart?: (start: DeviceFlowStart) => void | Promise<void> } = {},
): Promise<GittensorySession> {
const sleep = options.sleep ?? defaultSleep;
const now = options.now ?? (() => Date.now());
const start = await startDeviceFlow(apiOrigin, options.fetchImpl);
if (options.onStart) await options.onStart(start);
const deadline = now() + start.expiresIn * 1000;
let intervalMs = start.interval * 1000;
while (now() < deadline) {
await sleep(intervalMs);
const result = await pollDeviceFlow(apiOrigin, start.deviceCode, options.fetchImpl);
if ("token" in result && result.token) return result;
if (!("status" in result)) throw new Error("device_flow_invalid_response");
if (result.status === "slow_down") intervalMs += 5000;
if (result.status !== "authorization_pending" && result.status !== "slow_down") {
throw new Error(result.message ?? `device_flow_${result.status}`);
}
}
throw new Error("GitHub device flow expired before authorization completed.");
}

export async function fetchRemoteSessionStatus(
apiOrigin: string,
token: string,
fetchImpl?: FetchLike,
): Promise<{ login: string; expiresAt?: string; scopes?: string[] }> {
const payload = await gittensoryApiRequest<{ status?: string; login?: string; expiresAt?: string; scopes?: string[] }>({
apiOrigin,
path: "/v1/auth/session",
token,
fetchImpl,
});
if (payload.status !== "authenticated" || !payload.login) {
throw new Error("signed_out");
}
return {
login: payload.login,
expiresAt: payload.expiresAt,
scopes: payload.scopes,
};
}

export async function logoutRemoteSession(apiOrigin: string, token: string, fetchImpl?: FetchLike): Promise<void> {
await gittensoryApiRequest({ apiOrigin, path: "/v1/auth/logout", method: "POST", body: {}, token, fetchImpl });
}

export async function getSessionStatus(adapter: SessionStorageAdapter, fetchImpl?: FetchLike): Promise<SessionStatus> {
const stored = await loadStoredAuth(adapter);
if (!stored.session) return { signedIn: false };
if (isSessionExpired(stored.session.expiresAt)) {
await clearStoredSession(adapter);
return { signedIn: false, expired: true };
}
try {
const remote = await fetchRemoteSessionStatus(stored.apiOrigin, stored.session.token, fetchImpl);
return {
signedIn: true,
login: remote.login,
expiresAt: remote.expiresAt ?? stored.session.expiresAt,
scopes: remote.scopes ?? stored.session.scopes,
expired: false,
};
} catch {
await clearStoredSession(adapter);
return { signedIn: false, expired: true };
}
}

export async function loginAndPersist(
adapter: SessionStorageAdapter,
apiOrigin: string,
options: {
fetchImpl?: FetchLike;
sleep?: SleepFn;
now?: () => number;
onStart?: (start: DeviceFlowStart) => void | Promise<void>;
} = {},
): Promise<GittensorySession> {
const session = await loginWithDeviceFlow(apiOrigin, options);
return saveSession(adapter, session);
}

export async function logoutAndClear(adapter: SessionStorageAdapter, fetchImpl?: FetchLike): Promise<void> {
const stored = await loadStoredAuth(adapter);
if (stored.session?.token) {
try {
await logoutRemoteSession(stored.apiOrigin, stored.session.token, fetchImpl);
} catch {
// Local wipe still proceeds when remote revoke fails offline.
}
}
await clearStoredSession(adapter);
}
39 changes: 39 additions & 0 deletions packages/gittensory-raycast/lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export const DEFAULT_API_ORIGIN = "https://gittensory-api.aethereal.dev";

const GITHUB_TOKEN_PREFIX = /^(ghp|gho|ghu|ghs|ghr|github_pat)_/i;
const GITTENSORY_SESSION_TOKEN = /^gts_[a-f0-9]{64}$/i;

export function normalizeApiOrigin(value: string | undefined | null): string {
const raw = typeof value === "string" && value.trim() ? value.trim() : DEFAULT_API_ORIGIN;
try {
const url = new URL(raw);
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
return DEFAULT_API_ORIGIN;
}
return url.origin;
} catch {
return DEFAULT_API_ORIGIN;
}
}

export function looksLikeGitHubPersonalAccessToken(value: string): boolean {
return GITHUB_TOKEN_PREFIX.test(value.trim());
}

export function validateGittensorySessionToken(value: string): string {
const token = value.trim();
if (!token) throw new Error("Gittensory session token is required.");
if (looksLikeGitHubPersonalAccessToken(token)) {
throw new Error("GitHub personal access tokens are not stored. Use GitHub Device Flow login instead.");
}
if (!GITTENSORY_SESSION_TOKEN.test(token)) {
throw new Error("Session token must be a Gittensory session token (gts_…).");
}
return token;
}

export function isSessionExpired(expiresAt: string | undefined | null, nowMs = Date.now()): boolean {
if (!expiresAt) return false;
const parsed = Date.parse(expiresAt);
return Number.isFinite(parsed) && parsed <= nowMs;
}
17 changes: 17 additions & 0 deletions packages/gittensory-raycast/lib/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const FORBIDDEN_PUBLIC_OUTPUT =
/wallet|hotkey|raw trust|trust[-\s]?score|payout|reward[-\s]?estimate|farming|private[-\s]?reviewability|public[-\s]?score[-\s]?(?:estimate|prediction)|private[-\s]?scoreability|scoreability/i;

export function sanitizePublicText(value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
if (FORBIDDEN_PUBLIC_OUTPUT.test(trimmed)) {
return "Sensitive Gittensory context is only available in private surfaces.";
}
return trimmed;
}

export function assertNoForbiddenPublicText(value: string): void {
if (FORBIDDEN_PUBLIC_OUTPUT.test(value)) {
throw new Error("Public output must not include wallet, hotkey, trust score, payout, or farming language.");
}
}
Loading