437 lines
12 KiB
JavaScript
437 lines
12 KiB
JavaScript
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;
|