Files

437 lines
12 KiB
JavaScript
Raw Permalink Normal View History

import fs from 'fs';
import path from 'path';
import os from 'os';
import { isPlainObject, readEnv, readFiniteNumber, readTrimmedString } from './parse.js';
const DEFAULT_LINEAR_CLIENT_ID = '91bbe26a69a2c8568d3683f1e01e776c';
const DEFAULT_LINEAR_SCOPES = 'read,write,comments:create';
const DEFAULT_LINEAR_BROKER_URL = 'https://api.openchamber.dev/v1/oauth/linear';
const ACCESS_TOKEN_REFRESH_SKEW_MS = 2 * 60_000;
const LEGACY_WORKSPACE_ID = 'legacy';
const SESSION_COMMENTS_SETTING_KEY = 'linearSessionComments';
function resolveDataDir() {
const fromEnv = readEnv('OPENCHAMBER_DATA_DIR');
if (fromEnv) {
return path.resolve(fromEnv);
}
return path.join(os.homedir(), '.config', 'openchamber');
}
function storageFile() {
return path.join(resolveDataDir(), 'linear-auth.json');
}
function settingsFile() {
return path.join(resolveDataDir(), 'settings.json');
}
function ensureStorageDir() {
const dir = resolveDataDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function readJsonFile(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const parsed = JSON.parse(trimmed);
if (!isPlainObject(parsed)) {
return null;
}
return parsed;
} catch (error) {
console.error('Failed to read Linear auth file:', error);
return null;
}
}
function writeJsonFile(filePath, payload) {
ensureStorageDir();
const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(tmpFile, 0o600);
} catch {
// best-effort
}
fs.renameSync(tmpFile, filePath);
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
}
function normalizeUser(user) {
if (!isPlainObject(user)) {
return null;
}
const id = readTrimmedString(user.id);
if (!id) {
return null;
}
return {
id,
name: readTrimmedString(user.name) || null,
displayName: readTrimmedString(user.displayName) || null,
email: readTrimmedString(user.email) || null,
avatarUrl: readTrimmedString(user.avatarUrl) || null,
};
}
function normalizeOrganization(organization) {
if (!isPlainObject(organization)) {
return null;
}
const id = readTrimmedString(organization.id);
const name = readTrimmedString(organization.name);
if (!id || !name) {
return null;
}
return {
id,
name,
urlKey: readTrimmedString(organization.urlKey) || null,
};
}
function resolveLinearWorkspaceId({ organization, user, workspaceId } = {}) {
const explicit = readTrimmedString(workspaceId);
if (explicit) return explicit;
const organizationId = organization ? readTrimmedString(organization.id) : '';
if (organizationId) return organizationId;
const userId = user ? readTrimmedString(user.id) : '';
if (userId) return `user:${userId}`;
return LEGACY_WORKSPACE_ID;
}
function normalizeAuthEntry(raw) {
if (!isPlainObject(raw)) {
return null;
}
const accessToken = readTrimmedString(raw.accessToken);
if (!accessToken) {
return null;
}
const user = normalizeUser(raw.user);
const organization = normalizeOrganization(raw.organization);
return {
accessToken,
refreshToken: readTrimmedString(raw.refreshToken) || null,
tokenType: readTrimmedString(raw.tokenType) || 'bearer',
expiresAt: readFiniteNumber(raw.expiresAt),
scope: readTrimmedString(raw.scope),
createdAt: readFiniteNumber(raw.createdAt),
authorizedAt: readFiniteNumber(raw.authorizedAt) || readFiniteNumber(raw.createdAt),
user,
organization,
current: Boolean(raw.current),
workspaceId: resolveLinearWorkspaceId({
organization,
user,
workspaceId: raw.workspaceId,
}),
};
}
function normalizeAuthList(raw) {
const source = Array.isArray(raw?.workspaces)
? raw.workspaces
: (raw?.accessToken ? [raw] : []);
const list = source.map((entry) => normalizeAuthEntry(entry)).filter(Boolean);
if (!list.length) {
return { list: [], changed: Boolean(raw && (raw.accessToken || Array.isArray(raw.workspaces))) };
}
let changed = Array.isArray(raw?.workspaces) === false && Boolean(raw?.accessToken);
const seen = new Set();
const deduped = [];
for (const entry of list) {
if (seen.has(entry.workspaceId)) {
changed = true;
continue;
}
seen.add(entry.workspaceId);
deduped.push(entry);
}
let currentFound = false;
deduped.forEach((entry) => {
if (entry.current && !currentFound) {
currentFound = true;
} else if (entry.current && currentFound) {
entry.current = false;
changed = true;
}
});
if (!currentFound && deduped[0]) {
deduped[0].current = true;
changed = true;
}
return { list: deduped, changed };
}
function readAuthList() {
const data = readJsonFile(storageFile());
if (!data) {
return [];
}
const { list, changed } = normalizeAuthList(data);
if (changed) {
writeAuthList(list);
}
return list;
}
function writeAuthList(list) {
if (!list.length) {
const filePath = storageFile();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return;
}
writeJsonFile(storageFile(), { workspaces: list });
}
function readSettings() {
return readJsonFile(settingsFile()) || {};
}
function writeSettings(settings) {
writeJsonFile(settingsFile(), settings);
}
function readSettingString(key) {
const stored = readSettings()[key];
return readTrimmedString(stored);
}
export function getLinearAuth() {
const list = readAuthList();
if (!list.length) {
return null;
}
return list.find((entry) => entry.current) || list[0];
}
export function getLinearAuthByWorkspaceId(workspaceId) {
const id = readTrimmedString(workspaceId);
if (!id) {
return getLinearAuth();
}
return readAuthList().find((entry) => entry.workspaceId === id) || null;
}
export function getLinearAuthWorkspaces() {
return readAuthList().map((entry) => ({
id: entry.workspaceId,
name: entry.organization?.name || null,
urlKey: entry.organization?.urlKey || null,
current: Boolean(entry.current),
user: entry.user || null,
authorizedAt: entry.authorizedAt || entry.createdAt || null,
}));
}
export function setLinearAuth(input, options = {}) {
const accessToken = readTrimmedString(input?.accessToken);
if (!accessToken) {
throw new Error('accessToken is required');
}
const activate = options.activate !== false;
const list = readAuthList();
const current = list.find((entry) => entry.current) || list[0] || null;
const nextUser = Object.prototype.hasOwnProperty.call(input, 'user')
? normalizeUser(input.user)
: current?.user || null;
const nextOrganization = Object.prototype.hasOwnProperty.call(input, 'organization')
? normalizeOrganization(input.organization)
: current?.organization || null;
const workspaceId = resolveLinearWorkspaceId({
organization: nextOrganization,
user: nextUser,
workspaceId: input?.workspaceId || (nextOrganization || nextUser ? '' : current?.workspaceId),
});
const existingIndex = list.findIndex((entry) => entry.workspaceId === workspaceId);
const previous = existingIndex >= 0 ? list[existingIndex] : (
nextOrganization || nextUser ? null : current
);
const targetIndex = existingIndex >= 0
? existingIndex
: (previous && !nextOrganization && !nextUser ? list.indexOf(previous) : -1);
const wasCurrent = previous?.current === true;
const next = {
accessToken,
refreshToken: Object.prototype.hasOwnProperty.call(input, 'refreshToken')
? (readTrimmedString(input.refreshToken) || null)
: previous?.refreshToken || null,
tokenType: readTrimmedString(input?.tokenType) || previous?.tokenType || 'bearer',
expiresAt: readFiniteNumber(input?.expiresAt) ?? previous?.expiresAt ?? null,
scope: readTrimmedString(input?.scope) || previous?.scope || '',
createdAt: previous?.createdAt || Date.now(),
authorizedAt: Object.prototype.hasOwnProperty.call(input, 'authorizedAt')
? (readFiniteNumber(input.authorizedAt) || Date.now())
: (activate ? Date.now() : (previous?.authorizedAt || previous?.createdAt || Date.now())),
user: nextUser,
organization: nextOrganization,
current: false,
workspaceId,
};
if (targetIndex >= 0) {
list[targetIndex] = next;
} else {
list.push(next);
}
const writtenIndex = targetIndex >= 0 ? targetIndex : list.length - 1;
if (activate || !list.some((entry) => entry.current)) {
list.forEach((entry, index) => {
entry.current = index === writtenIndex;
});
} else {
list[writtenIndex].current = wasCurrent;
}
writeAuthList(list);
return list[writtenIndex];
}
export function activateLinearAuth(workspaceId) {
const id = readTrimmedString(workspaceId);
if (!id) {
return false;
}
const list = readAuthList();
const index = list.findIndex((entry) => entry.workspaceId === id);
if (index === -1) {
return false;
}
list.forEach((entry, idx) => {
entry.current = idx === index;
});
writeAuthList(list);
return true;
}
export function clearLinearAuth(workspaceId) {
try {
const list = readAuthList();
if (!list.length) {
return true;
}
const id = readTrimmedString(workspaceId);
const remaining = id
? list.filter((entry) => entry.workspaceId !== id)
: list.filter((entry) => !entry.current);
if (!remaining.length) {
writeAuthList([]);
return true;
}
if (!remaining.some((entry) => entry.current)) {
remaining[0].current = true;
}
writeAuthList(remaining);
return true;
} catch (error) {
console.error('Failed to clear Linear auth file:', error);
return false;
}
}
export function isLinearAccessTokenStale(expiresAt, now = Date.now()) {
const expiry = readFiniteNumber(expiresAt);
if (expiry == null) {
return true;
}
return expiry - ACCESS_TOKEN_REFRESH_SKEW_MS <= now;
}
export function toLinearPublicStatus(auth, workspaces = getLinearAuthWorkspaces()) {
if (!auth?.accessToken) {
return { connected: false };
}
return {
connected: true,
user: auth.user || null,
organization: auth.organization || null,
scope: auth.scope || undefined,
workspaces,
};
}
export function getLinearClientId() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_ID');
if (fromEnv) return fromEnv;
const stored = readSettingString('linearClientId');
if (stored) return stored;
return DEFAULT_LINEAR_CLIENT_ID;
}
export function getLinearClientSecret() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_SECRET');
if (fromEnv) return fromEnv;
return readSettingString('linearClientSecret');
}
export function getLinearScopes() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_SCOPES');
if (fromEnv) return fromEnv;
const stored = readSettingString('linearScopes');
if (stored) return stored;
return DEFAULT_LINEAR_SCOPES;
}
export function getLinearBrokerUrl() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_BROKER_URL');
if (fromEnv) return fromEnv.replace(/\/+$/, '');
const stored = readSettingString('linearBrokerUrl');
if (stored) return stored.replace(/\/+$/, '');
return DEFAULT_LINEAR_BROKER_URL;
}
export function getLinearRedirectUri() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_REDIRECT_URI');
if (fromEnv) return fromEnv;
const stored = readSettingString('linearRedirectUri');
if (stored) return stored;
return `${getLinearBrokerUrl()}/callback`;
}
/**
* Status comments are opt-in: they are written into a Linear workspace other
* people read, so nothing is posted until the user turns them on.
*/
export function getLinearSessionCommentsEnabled() {
return readSettings()[SESSION_COMMENTS_SETTING_KEY] === true;
}
export function setLinearSessionCommentsEnabled(enabled) {
const next = enabled === true;
const settings = readSettings();
settings[SESSION_COMMENTS_SETTING_KEY] = next;
writeSettings(settings);
return next;
}
export function getLinearAuthFilePath() {
return storageFile();
}
export const DEFAULT_LINEAR_CLIENT_ID_VALUE = DEFAULT_LINEAR_CLIENT_ID;